From 13d8a166bf47cb92e8a9be093306abf6c79eb451 Mon Sep 17 00:00:00 2001 From: Jeff Schiller Date: Fri, 22 Dec 2023 14:14:05 -0800 Subject: [PATCH] Add a GIF Parser into image/parsers --- .c8rc | 1 + .gitignore | 1 + README.md | 27 +- image/parsers/README.md | 6 + image/parsers/gif.js | 486 ++++++++++++++++++++++++++++++ tests/image-parsers-gif.spec.js | 56 ++++ tests/image-testfiles/comment.gif | Bin 0 -> 170 bytes tests/image-testfiles/xmp.gif | Bin 0 -> 3312 bytes 8 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 image/parsers/README.md create mode 100644 image/parsers/gif.js create mode 100644 tests/image-parsers-gif.spec.js create mode 100644 tests/image-testfiles/comment.gif create mode 100644 tests/image-testfiles/xmp.gif diff --git a/.c8rc b/.c8rc index 27c1220..c1becbc 100644 --- a/.c8rc +++ b/.c8rc @@ -4,6 +4,7 @@ "include": [ "archive/*.js", "codecs/*.js", + "image/parsers/*.js", "file/*.js", "io/*.js" ], diff --git a/.gitignore b/.gitignore index a128d44..0dcdc58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ coverage +image/.DS_Store node_modules tests/archive.spec.notready.js diff --git a/README.md b/README.md index 75ab9b6..08ea990 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Includes: * bitjs/codecs: Get the codec info of media containers in a ISO RFC6381 MIME type string * bitjs/file: Detect the type of file from its binary signature. - * bitjs/image: Conversion of WebP images to PNG or JPEG. + * bitjs/image: Parsing GIF. Conversion of WebP to PNG or JPEG. * bitjs/io: Low-level classes for interpreting binary data (BitStream ByteStream). For example, reading or peeking at N bits at a time. @@ -107,9 +107,30 @@ const mimeType = findMimeType(someArrayBuffer); ### bitjs.image -This package includes code for dealing with binary images. It includes a module for converting WebP -images into alternative raster graphics formats (PNG/JPG). +This package includes code for dealing with binary images. It includes general event-based parsers +for images (GIF only, at the moment). It also includes a module for converting WebP images into +alternative raster graphics formats (PNG/JPG). This latter module is deprecated, now that WebP +images are well-supported in all browsers. +#### GIF Parser +```javascript +import { GifParser } from './bitjs/image/parsers/gif.js' + +const parser = new GifParser(someArrayBuffer); +parser.addEventListener('application_extension', evt => { + const appId = evt.applicationExtension.applicationIdentifier + const appAuthCode = new TextDecoder().decode( + evt.applicationExtension.applicationAuthenticationCode); + if (appId === 'XMP Data' && appAuthCode === 'XMP') { + /** @type {Uint8Array} */ + const appData = evt.applicationExtension.applicationData; + // Do something with appData (parse the XMP). + } +}); +parser.start(); +``` + +#### WebP Converter ```javascript import { convertWebPtoPNG, convertWebPtoJPG } from './bitjs/image/webp-shim/webp-shim.js'; // convertWebPtoPNG() takes in an ArrayBuffer containing the bytes of a WebP diff --git a/image/parsers/README.md b/image/parsers/README.md new file mode 100644 index 0000000..5d86ba9 --- /dev/null +++ b/image/parsers/README.md @@ -0,0 +1,6 @@ +General-purpose, event-based parsers for digital images. + +Currently only supports GIF. + +Some nice implementations for HEIF, JPEG, PNG, TIFF here: +https://github.com/MikeKovarik/exifr/tree/master/src/file-parsers \ No newline at end of file diff --git a/image/parsers/gif.js b/image/parsers/gif.js new file mode 100644 index 0000000..b69418c --- /dev/null +++ b/image/parsers/gif.js @@ -0,0 +1,486 @@ +/* + * gif.js + * + * An event-based parser for GIF images. + * + * Licensed under the MIT License + * + * Copyright(c) 2023 Google Inc. + */ + +import { BitStream } from '../../io/bitstream.js'; +import { ByteStream } from '../../io/bytestream.js'; + +// https://www.w3.org/Graphics/GIF/spec-gif89a.txt + +export const GifParseEventType = { + APPLICATION_EXTENSION: 'application_extension', + COMMENT_EXTENSION: 'comment_extension', + GRAPHIC_CONTROL_EXTENSION: 'graphic_control_extension', + HEADER: 'header', + LOGICAL_SCREEN: 'logical_screen', + PLAIN_TEXT_EXTENSION: 'plain_text_extension', + TABLE_BASED_IMAGE: 'table_based_image', + TRAILER: 'trailer', +}; + +/** + * @typedef GifHeader + * @property {string} version + */ + +export class GifHeaderParseEvent extends Event { + /** @param {GifHeader} header */ + constructor(header) { + super(GifParseEventType.HEADER); + /** @type {GifHeader} */ + this.header = header; + } +} + +/** + * @typedef GifColor + * @property {number} red + * @property {number} green + * @property {number} blue + */ + +/** + * @typedef GifLogicalScreen + * @property {number} logicalScreenWidth + * @property {number} logicalScreenHeight + * @property {boolean} globalColorTableFlag + * @property {number} colorResolution + * @property {boolean} sortFlag + * @property {number} globalColorTableSize + * @property {number} backgroundColorIndex + * @property {number} pixelAspectRatio + * @property {GifColor[]=} globalColorTable Only if globalColorTableFlag is true. + */ + +export class GifLogicalScreenParseEvent extends Event { + /** @param {GifLogicalScreen} */ + constructor(logicalScreen) { + super(GifParseEventType.LOGICAL_SCREEN); + /** @type {GifLogicalScreen} */ + this.logicalScreen = logicalScreen; + } +} + +/** + * @typedef GifTableBasedImage + * @property {number} imageLeftPosition + * @property {number} imageTopPosition + * @property {number} imageWidth + * @property {number} imageHeight + * @property {boolean} localColorTableFlag + * @property {boolean} interlaceFlag + * @property {boolean} sortFlag + * @property {number} localColorTableSize + * @property {GifColor[]=} localColorTable Only if localColorTableFlag is true. + * @property {number} lzwMinimumCodeSize + * @property {Uint8Array} imageData + */ + +export class GifTableBasedImageEvent extends Event { + /** @param {GifTableBasedImage} img */ + constructor(img) { + super(GifParseEventType.TABLE_BASED_IMAGE); + /** @type {GifTableBasedImage} */ + this.tableBasedImage = img; + } +} + +/** + * @typedef GifGraphicControlExtension + * @property {number} disposalMethod + * @property {boolean} userInputFlag + * @property {boolean} transparentColorFlag + * @property {number} delayTime + * @property {number} transparentColorIndex + */ + +export class GifGraphicControlExtensionEvent extends Event { + /** @param {GifGraphicControlExtension} ext */ + constructor(ext) { + super(GifParseEventType.GRAPHIC_CONTROL_EXTENSION); + /** @type {GifGraphicControlExtension} */ + this.graphicControlExtension = ext; + } +} + +/** + * @typedef GifCommentExtension + * @property {string} comment + */ + +export class GifCommentExtensionEvent extends Event { + /** @param {string} comment */ + constructor(comment) { + super(GifParseEventType.COMMENT_EXTENSION); + /** @type {string} */ + this.comment = comment; + } +} + +/** + * @typedef GifPlainTextExtension + * @property {number} textGridLeftPosition + * @property {number} textGridTopPosition + * @property {number} textGridWidth + * @property {number} textGridHeight + * @property {number} characterCellWidth + * @property {number} characterCellHeight + * @property {number} textForegroundColorIndex + * @property {number} textBackgroundColorIndex + * @property {string} plainText + */ + +export class GifPlainTextExtensionEvent extends Event { + /** @param {GifPlainTextExtension} ext */ + constructor(ext) { + super(GifParseEventType.PLAIN_TEXT_EXTENSION); + /** @type {GifPlainTextExtension} */ + this.plainTextExtension = ext; + } +} + +/** + * @typedef GifApplicationExtension + * @property {string} applicationIdentifier + * @property {Uint8Array} applicationAuthenticationCode + * @property {Uint8Array} applicationData + */ + +export class GifApplicationExtensionEvent extends Event { + /** @param {GifApplicationExtension} ext */ + constructor(ext) { + super(GifParseEventType.APPLICATION_EXTENSION); + /** @type {GifApplicationExtension} */ + this.applicationExtension = ext; + } +} + +export class GifTrailerEvent extends Event { + constructor() { + super(GifParseEventType.TRAILER); + } +} + +/** + * The Grammar. + * + * ::= Header * Trailer + * ::= Logical Screen Descriptor [Global Color Table] + * ::= | + * + * ::= [Graphic Control Extension] + * ::= | + * Plain Text Extension + * ::= Image Descriptor [Local Color Table] Image Data + * ::= Application Extension | + * Comment Extension + */ + +export class GifParser extends EventTarget { + /** + * @type {ByteStream} + * @private + */ + bstream; + + /** + * @type {string} + * @private + */ + version; + + /** + * @param {ArrayBuffer} ab + */ + constructor(ab) { + super(); + this.bstream = new ByteStream(ab); + } + + /** + * Overridden so that the type hints for eventType are specific. + * @param {'application_extension'|'comment_extension'|'graphical_control_extension'|'header'|'logical_screen'|'plain_text_extension'|'table_based_image'|'trailer'} eventType + * @param {EventListenerOrEventListenerObject} listener + * @override + */ + addEventListener(eventType, listener) { + super.addEventListener(eventType, listener); + } + + /** + * @returns {Promise} A Promise that resolves when the parsing is complete. + */ + async start() { + // Header. + const gif = this.bstream.readString(3); // "GIF" + if (gif !== "GIF") throw `Not GIF: ${gif}`; + + const version = this.version = this.bstream.readString(3); // "87a" or "89a" + if (!["87a", "89a"].includes(version)) throw `Bad version: ${version}`; + + this.dispatchEvent(new GifHeaderParseEvent( + /** @type {GifHeader} */ ({ version }) + )); + + // Logical Screen Descriptor. + const logicalScreenWidth = this.bstream.readNumber(2); + const logicalScreenHeight = this.bstream.readNumber(2); + + const bitstream = new BitStream(this.bstream.readBytes(1).buffer, true); + const globalColorTableFlag = !!bitstream.readBits(1); + const colorResolution = bitstream.readBits(3) + 1; + const sortFlag = !!bitstream.readBits(1); // sortFlag + const globalColorTableSize = 2 ** (bitstream.readBits(3) + 1); + const backgroundColorIndex = this.bstream.readNumber(1); + const pixelAspectRatio = this.bstream.readNumber(1); + + // Global Color Table + let globalColorTable = undefined; + if (globalColorTableFlag) { + globalColorTable = []; + // Series of R,G,B. + for (let c = 0; c < globalColorTableSize; ++c) { + globalColorTable.push(/** @type {GifColor} */ ({ + red: this.bstream.readNumber(1), + green: this.bstream.readNumber(1), + blue: this.bstream.readNumber(1), + })); + } + } + this.dispatchEvent(new GifLogicalScreenParseEvent( + /** @type {GifLogicalScreen} */ ({ + logicalScreenWidth, + logicalScreenHeight, + globalColorTableFlag, + colorResolution, + sortFlag, + globalColorTableSize, + backgroundColorIndex, + pixelAspectRatio, + globalColorTable, + }) + )); + + while (this.readGraphicBlock()) { + // Read a graphic block + } + } + + /** + * @private + * @returns {boolean} True if this was not the last block. + */ + readGraphicBlock() { + let nextByte = this.bstream.readNumber(1); + + // Image Descriptor. + if (nextByte === 0x2C) { + const imageLeftPosition = this.bstream.readNumber(2); + const imageTopPosition = this.bstream.readNumber(2); + const imageWidth = this.bstream.readNumber(2); + const imageHeight = this.bstream.readNumber(2); + + const bitstream = new BitStream(this.bstream.readBytes(1).buffer, true); + const localColorTableFlag = !!bitstream.readBits(1); + const interlaceFlag = !!bitstream.readBits(1); + const sortFlag = !!bitstream.readBits(1); + bitstream.readBits(2); // reserved + const localColorTableSize = 2 ** (bitstream.readBits(3) + 1); + + let localColorTable = undefined; + if (localColorTableFlag) { + // this.bstream.readBytes(3 * localColorTableSize); + localColorTable = []; + // Series of R,G,B. + for (let c = 0; c < localColorTableSize; ++c) { + localColorTable.push(/** @type {GifColor} */ ({ + red: this.bstream.readNumber(1), + green: this.bstream.readNumber(1), + blue: this.bstream.readNumber(1), + })); + } + } + + // Table-Based Image. + const lzwMinimumCodeSize = this.bstream.readNumber(1); + const bytesArr = []; + let bytes; + let totalNumBytes = 0; + while ((bytes = this.readSubBlock())) { + totalNumBytes += bytes.byteLength; + bytesArr.push(bytes); + } + + const imageData = new Uint8Array(totalNumBytes); + let ptr = 0; + for (const arr of bytesArr) { + imageData.set(arr, ptr); + ptr += arr.byteLength; + } + + this.dispatchEvent(new GifTableBasedImageEvent( + /** @type {GifTableBasedImage} */ ({ + imageLeftPosition, + imageTopPosition, + imageWidth, + imageHeight, + localColorTableFlag, + interlaceFlag, + sortFlag, + localColorTableSize, + localColorTable, + lzwMinimumCodeSize, + imageData, + }) + )); + + return true; + } + // Extensions. + else if (nextByte === 0x21) { + if (this.version !== '89a') { + throw `Found Extension Introducer (0x21) but was not GIF 89a: ${this.version}`; + } + + const label = this.bstream.readNumber(1); + + // Graphic Control Extension. + if (label === 0xF9) { + const blockSize = this.bstream.readNumber(1); + if (blockSize !== 4) throw `GCE: Block size of ${blockSize}`; + + // Packed Fields. + const bitstream = new BitStream(this.bstream.readBytes(1).buffer, true); + bitstream.readBits(3); // Reserved + const disposalMethod = bitstream.readBits(3); + const userInputFlag = !!bitstream.readBits(1); + const transparentColorFlag = !!bitstream.readBits(1); + + const delayTime = this.bstream.readNumber(2); + const transparentColorIndex = this.bstream.readNumber(1); + const blockTerminator = this.bstream.readNumber(1); + if (blockTerminator !== 0) throw `GCE: Block terminator of ${blockTerminator}`; + + this.dispatchEvent(new GifGraphicControlExtensionEvent( + /** @type {GifGraphicControlExtension} */ ({ + disposalMethod, + userInputFlag, + transparentColorFlag, + delayTime, + transparentColorIndex, + }) + )); + return true; + } + + // Comment Extension. + else if (label === 0xFE) { + let bytes; + let comment = ''; + while ((bytes = this.readSubBlock())) { + comment += new TextDecoder().decode(bytes); + } + this.dispatchEvent(new GifCommentExtensionEvent(comment)); + return true; + } + + // Plain Text Extension. + else if (label === 0x01) { + const blockSize = this.bstream.readNumber(1); + if (blockSize !== 12) throw `PTE: Block size of ${blockSize}`; + + const textGridLeftPosition = this.bstream.readNumber(2); + const textGridTopPosition = this.bstream.readNumber(2); + const textGridWidth = this.bstream.readNumber(2); + const textGridHeight = this.bstream.readNumber(2); + const characterCellWidth = this.bstream.readNumber(1); + const characterCellHeight = this.bstream.readNumber(1); + const textForegroundColorIndex = this.bstream.readNumber(1); + const textBackgroundColorIndex = this.bstream.readNumber(1); + let bytes; + let plainText = '' + while ((bytes = this.readSubBlock())) { + plainText += new TextDecoder().decode(bytes); + } + + this.dispatchEvent(new GifPlainTextExtensionEvent( + /** @type {GifPlainTextExtension} */ ({ + textGridLeftPosition, + textGridTopPosition, + textGridWidth, + textGridHeight, + characterCellWidth, + characterCellHeight, + textForegroundColorIndex, + textBackgroundColorIndex, + plainText, + }) + )); + + return true; + } + + // Application Extension. + else if (label === 0xFF) { + const blockSize = this.bstream.readNumber(1); + if (blockSize !== 11) throw `AE: Block size of ${blockSize}`; + + // TODO: Extract EXIF / XMP / whatever. + const applicationIdentifier = this.bstream.readString(8); + const applicationAuthenticationCode = this.bstream.readBytes(3); + const bytesArr = []; + let bytes; + let totalNumBytes = 0; + while ((bytes = this.readSubBlock())) { + totalNumBytes += bytes.byteLength; + bytesArr.push(bytes); + } + + const applicationData = new Uint8Array(totalNumBytes); + let ptr = 0; + for (const arr of bytesArr) { + applicationData.set(arr, ptr); + ptr += arr.byteLength; + } + + this.dispatchEvent(new GifApplicationExtensionEvent( + /** {@type GifApplicationExtension} */ ({ + applicationIdentifier, + applicationAuthenticationCode, + applicationData, + }) + )); + + return true; + } + + else { + throw `Unrecognized extension label=0x${label.toString(16)}`; + } + } + else if (nextByte === 0x3B) { + this.dispatchEvent(new GifTrailerEvent()); + // Read the trailer. + return false; + } + else { + throw `Unknown marker: 0x${nextByte.toString(16)}`; + } + } + + /** + * @private + * @returns {Uint8Array} Data from the sub-block, or null if this was the last, zero-length block. + */ + readSubBlock() { + let subBlockSize = this.bstream.readNumber(1); + if (subBlockSize === 0) return null; + return this.bstream.readBytes(subBlockSize); + } +} diff --git a/tests/image-parsers-gif.spec.js b/tests/image-parsers-gif.spec.js new file mode 100644 index 0000000..d21ae0b --- /dev/null +++ b/tests/image-parsers-gif.spec.js @@ -0,0 +1,56 @@ +import * as fs from 'node:fs'; +import 'mocha'; +import { expect } from 'chai'; +import { GifParser } from '../image/parsers/gif.js'; + +const COMMENT_GIF = `tests/image-testfiles/comment.gif`; +const XMP_GIF = 'tests/image-testfiles/xmp.gif'; + +describe('bitjs.image.parsers.GifParser', () => { + it('parses GIF with Comment Extension', async () => { + const nodeBuf = fs.readFileSync(COMMENT_GIF); + const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length); + + const parser = new GifParser(ab); + let trailerFound = false; + let comment; + parser.addEventListener('logical_screen', evt => { + const {logicalScreenWidth, logicalScreenHeight} = evt.logicalScreen; + expect(logicalScreenWidth).equals(32); + expect(logicalScreenHeight).equals(52); + }); + parser.addEventListener('table_based_image', evt => { + const {imageWidth, imageHeight} = evt.tableBasedImage; + expect(imageWidth).equals(32); + expect(imageHeight).equals(52); + }); + parser.addEventListener('comment_extension', evt => comment = evt.comment); + parser.addEventListener('trailer', evt => trailerFound = true); + + await parser.start(); + + expect(trailerFound).equals(true); + expect(comment).equals('JEFF!'); + }); + + it('parses GIF with Application Extension', async () => { + const nodeBuf = fs.readFileSync(XMP_GIF); + const ab = nodeBuf.buffer.slice(nodeBuf.byteOffset, nodeBuf.byteOffset + nodeBuf.length); + + const parser = new GifParser(ab); + let appId; + let appAuthCode; + let hasAppData = false; + parser.addEventListener('application_extension', evt => { + appId = evt.applicationExtension.applicationIdentifier + appAuthCode = new TextDecoder().decode( + evt.applicationExtension.applicationAuthenticationCode); + hasAppData = evt.applicationExtension.applicationData.byteLength > 0; + }); + + await parser.start(); + + expect(appId).equals("XMP Data"); + expect(appAuthCode).equals('XMP'); + }); +}); diff --git a/tests/image-testfiles/comment.gif b/tests/image-testfiles/comment.gif new file mode 100644 index 0000000000000000000000000000000000000000..c7ea4d026823cfb0646f7be52f166b46fd831b09 GIT binary patch literal 170 zcmZ?wbhEHbRA4Y+_`t~U|Nnmm1_s5SEI<-S|6}!Xb#qf>&;jv4N*I_bdiqzM=H_2K zT_}w|(VMbtZpKo-wAV`ubj(W6-dVc$vETmFjD~9-zH(<_ov1P2X}XA7 z`f^^=l)X}0^;Eb2WNp{HXLwfTQ2fsw;Txdfl30=mq;2dg z3KEmEQ%e+*Qqwc@Y}McI-mk8ZnPRIRZt82`Ti~3Uk?B!Ylp0*+7m{3+ootz+Zg0nB zQ(;w+TacStlBiITo0C^;Rbi{1n3A8AY6WD2g!R=Gz)DK8ZPh(<6Vp?ztXwNH(?as| za}*4X^vn&_frb>Nq*(>IxIv8o@@&;JN=gc>^!3Zj%k|2Q_413-^$jg8E%gnI^o@*k zfhu&1EAvVcD|GXUm4PO3DS*vzNi9w;$}A|!%+FH*nVFcBUs__TuFjepxOP4HOv~a=vd2{E?o;7pE^l4M4OrA7xLVsUxPj^>mM|)dqOLJ3WLw#LsO?6de zMR{3iNpVqOL4ICtPIgviMtWLmN^(+SLVR3oOmtLaM0i+eNN`YKfWM!wkGGenhr64r zi?frXgT0-tjkT4fg}IrjiLsHPfxe!uj<%MjhPs-nin5ZTg1nrpjI@-bgt(Zfh_H~L z06!lu4>uPl2Rj=p3o{cV1A`8zPXX#^Ffdj0^shY4&A)ic+cVXx56Zl{P#S-tH)Yw} zjHP~Qua_3+n3bNrvvluczx}5f4c9z;<<7!7QDeT-bP=`m<-DdTd!@GOsc!$t+OB!e z@T|#MWszp)G7Fj^555&CQz$99eD9^W&>JHH3+3}9fe F1_0HziL?L! literal 0 HcmV?d00001