1
0
Fork 0
mirror of https://github.com/codedread/bitjs synced 2025-10-03 09:39:16 +02:00

Add a GIF Parser into image/parsers

This commit is contained in:
Jeff Schiller 2023-12-22 14:14:05 -08:00
parent 9558624b6a
commit 13d8a166bf
8 changed files with 574 additions and 3 deletions

1
.c8rc
View file

@ -4,6 +4,7 @@
"include": [ "include": [
"archive/*.js", "archive/*.js",
"codecs/*.js", "codecs/*.js",
"image/parsers/*.js",
"file/*.js", "file/*.js",
"io/*.js" "io/*.js"
], ],

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
coverage coverage
image/.DS_Store
node_modules node_modules
tests/archive.spec.notready.js tests/archive.spec.notready.js

View file

@ -14,7 +14,7 @@ Includes:
* bitjs/codecs: Get the codec info of media containers in a ISO RFC6381 * bitjs/codecs: Get the codec info of media containers in a ISO RFC6381
MIME type string MIME type string
* bitjs/file: Detect the type of file from its binary signature. * 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 * bitjs/io: Low-level classes for interpreting binary data (BitStream
ByteStream). For example, reading or peeking at N bits at a time. ByteStream). For example, reading or peeking at N bits at a time.
@ -107,9 +107,30 @@ const mimeType = findMimeType(someArrayBuffer);
### bitjs.image ### bitjs.image
This package includes code for dealing with binary images. It includes a module for converting WebP This package includes code for dealing with binary images. It includes general event-based parsers
images into alternative raster graphics formats (PNG/JPG). 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 ```javascript
import { convertWebPtoPNG, convertWebPtoJPG } from './bitjs/image/webp-shim/webp-shim.js'; import { convertWebPtoPNG, convertWebPtoJPG } from './bitjs/image/webp-shim/webp-shim.js';
// convertWebPtoPNG() takes in an ArrayBuffer containing the bytes of a WebP // convertWebPtoPNG() takes in an ArrayBuffer containing the bytes of a WebP

6
image/parsers/README.md Normal file
View file

@ -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

486
image/parsers/gif.js Normal file
View file

@ -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.
*
* <GIF Data Stream> ::= Header <Logical Screen> <Data>* Trailer
* <Logical Screen> ::= Logical Screen Descriptor [Global Color Table]
* <Data> ::= <Graphic Block> |
* <Special-Purpose Block>
* <Graphic Block> ::= [Graphic Control Extension] <Graphic-Rendering Block>
* <Graphic-Rendering Block> ::= <Table-Based Image> |
* Plain Text Extension
* <Table-Based Image> ::= Image Descriptor [Local Color Table] Image Data
* <Special-Purpose Block> ::= 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<void>} 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);
}
}

View file

@ -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');
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB