GIF: basic parsing of image sections and skipping extensions

This commit is contained in:
Evan Hahn 2023-09-16 14:06:58 -05:00
parent 858c6c83a4
commit c755f0bb6f
3 changed files with 382 additions and 1 deletions

View File

@ -1,7 +1,7 @@
// @ts-check
/** @enum {string} */
export const GifNodeType = [
const types = [
"root",
"header",
@ -17,7 +17,44 @@ export const GifNodeType = [
"globalColorTable",
"globalColorTableColor",
"imageSection",
"imageDescriptor",
"imageSeparator",
"imageDescriptorLeftPosition",
"imageDescriptorTopPosition",
"imageDescriptorWidth",
"imageDescriptorHeight",
"imageDescriptorPackedFields",
"localColorTable",
"localColorTableColor",
"imageData",
"imageDataLzwMinimumCodeSize",
"imageDataSubBlock",
"imageDataSubBlockSize",
"imageDataSubBlockData",
"imageDataTerminator",
"extensionBlockIntroducer",
"extensionBlockSize",
"unknownExtensionBlock",
"unknownExtensionBlockLabel",
"unknownExtensionBlockData",
].reduce((result, id) => {
result[id] = id;
return result;
}, Object.create(null));
// TODO: Revert this and just export the thing
export const GifNodeType = new Proxy(types, {
get(target, prop) {
const result = target[prop];
if (!result) {
throw new Error(`cannot get ${String(prop)}`);
}
return result;
},
});

View File

@ -181,6 +181,123 @@ const NODE_UI_FNS = {
),
),
}),
// Image descriptor
[GifNodeType.imageSection]: () => ({
title: "Image",
description: p("A single image in the GIF."),
}),
[GifNodeType.imageDescriptor]: () => ({
title: "Image Descriptor",
description: p("TODO"),
}),
[GifNodeType.imageSeparator]: () => ({
title: "Image Separator",
description: p("TODO"),
}),
[GifNodeType.imageDescriptorLeftPosition]: () => ({
title: "Image Left Position",
description: p("The X coordinate of the top left corner of this frame."),
}),
[GifNodeType.imageDescriptorTopPosition]: () => ({
title: "Image Top Position",
description: p("The Y coordinate of the top left corner of this frame."),
}),
[GifNodeType.imageDescriptorWidth]: () => ({
title: "Image Width",
description: p("The width of this frame."),
}),
[GifNodeType.imageDescriptorHeight]: () => ({
title: "Image Height",
description: p("The height of this frame."),
}),
[GifNodeType.imageDescriptorPackedFields]: () => ({
title: "Image Descriptor packed fields",
description: p("TODO"),
}),
// Local color table
[GifNodeType.localColorTable]: ({ children }) => ({
description: p(
`The Local Color Table is a list of colors for this frame. Each color is encoded as three bytes: one for red, one for green, and one for blue. The number of colors in the table is ${
pluralize(children?.length || 0, "color")
}.`,
),
}),
[GifNodeType.localColorTableColor]: ({ bytes }) => ({
// TODO: DRY this out with the global color above? Or maybe just use a single node type?
title: "Local Color Table color",
description: p(
"A color. It looks like this: ",
crel(
"span",
{
"style":
`display:inline-block;width:1em;height:1em;vertical-align:middle;color:transparent;background:rgb(${
bytes.join(",")
})`,
},
`#${formatByte(bytes[0])}${formatByte(bytes[1])}${
formatByte(bytes[2])
}`,
),
),
}),
// Image data
[GifNodeType.imageData]: () => ({
title: "Image Data",
description: p("TODO"),
}),
[GifNodeType.imageDataLzwMinimumCodeSize]: () => ({
title: "LZW Minimum Code Size",
description: p("TODO"),
}),
[GifNodeType.imageDataSubBlock]: () => ({
title: "Image Data Sub-Block",
description: p("TODO"),
}),
[GifNodeType.imageDataSubBlockSize]: () => ({
title: "Image Data Sub-Block Size",
description: p("TODO"),
}),
[GifNodeType.imageDataSubBlockData]: () => ({
title: "Image Data Sub-Block Data",
description: p("TODO"),
}),
[GifNodeType.imageDataTerminator]: () => ({
title: "Image Data Terminator",
description: p("TODO"),
}),
// Generic extension stuff
[GifNodeType.extensionBlockIntroducer]: () => ({
title: "Extension Block Introducer",
description: p("TODO"),
}),
[GifNodeType.extensionBlockSize]: () => ({
title: "Extension Block Size",
description: p("TODO"),
}),
// Unknown extension block
[GifNodeType.unknownExtensionBlock]: () => ({
title: "Unknown Extension Block",
description: p("TODO"),
}),
[GifNodeType.unknownExtensionBlockLabel]: () => ({
title: "Unknown Extension Block Label",
description: p("TODO"),
}),
[GifNodeType.unknownExtensionBlockData]: () => ({
title: "Unknown Extension Block Data",
description: p("TODO"),
}),
};
/**

View File

@ -3,6 +3,10 @@
import { GifNodeType } from "./constants.js";
/** @typedef {import("../../types/gif.d.ts").GifNode} GifNode */
const BLOCK_TYPE_IMAGE = 0x2c;
const BLOCK_TYPE_EXTENSION = 0x21;
const BLOCK_TYPE_TRAILER = 0x3b;
/**
* @typedef {object} LogicalScreenDescriptorPackedField
* @prop {boolean} globalColorTableFlag
@ -22,6 +26,206 @@ export const parseLogicalScreenDescriptorPackedField = (byte) => ({
sizeOfGlobalColorTable: 2 ** ((byte & 0b00000111) + 1),
});
/**
* @typedef {object} ImageDescriptorPackedField
* @prop {boolean} localColorTableFlag
* @prop {boolean} interlaceFlag
* @prop {boolean} sortFlag
* @prop {number} reservedBits
* @prop {number} sizeOfLocalColorTable
*/
/**
* @param {number} byte
* @returns {ImageDescriptorPackedField}
*/
export const parseImageDescriptorPackedField = (byte) => ({
localColorTableFlag: (byte & 0b10000000) !== 0,
interlaceFlag: (byte & 0b1000000) !== 0,
sortFlag: (byte & 0b100000) !== 0,
reservedBits: (byte & 0b11000) >> 2,
sizeOfLocalColorTable: 2 ** ((byte & 0b111) + 1),
});
/**
* @param {Uint8Array} bytes
* @param {number} startingOffset
* @returns {{ node: GifNode, newOffset: number }}
*/
const readImageBlock = (bytes, startingOffset) => {
let offset = startingOffset;
/** @type {GifNode[]} */ const children = [{
type: GifNodeType.imageSeparator,
bytes: bytes.subarray(offset, offset + 1),
}];
offset++;
// Image descriptor
// TODO: remove these commented-out lines?
// const imageLeftPosition = dataView.getUint16(offset, true);
// const imageTopPosition = dataView.getUint16(offset + 2, true);
// const imageWidth = dataView.getUint16(offset + 4, true);
// const imageHeight = dataView.getUint16(offset + 6, true);
const packedField = parseImageDescriptorPackedField(bytes[offset + 8]);
children.push({
type: GifNodeType.imageDescriptor,
bytes: bytes.subarray(offset, offset + 9),
children: [
{
type: GifNodeType.imageDescriptorLeftPosition,
bytes: bytes.subarray(offset, offset + 2),
},
{
type: GifNodeType.imageDescriptorTopPosition,
bytes: bytes.subarray(offset + 2, offset + 4),
},
{
type: GifNodeType.imageDescriptorWidth,
bytes: bytes.subarray(offset + 4, offset + 6),
},
{
type: GifNodeType.imageDescriptorHeight,
bytes: bytes.subarray(offset + 6, offset + 8),
},
{
type: GifNodeType.imageDescriptorPackedFields,
bytes: bytes.subarray(offset + 8, offset + 9),
},
],
});
offset += 9;
// Local color table
if (packedField.localColorTableFlag) {
/** @type {GifNode[]} */ const colorNodes = [];
for (let i = 0; i < packedField.sizeOfLocalColorTable; i++) {
colorNodes.push({
type: GifNodeType.localColorTableColor,
bytes: bytes.subarray(offset + (i * 3), offset + (i * 3) + 3),
});
}
const sizeOfLocalColorTableInBytes = packedField.sizeOfLocalColorTable *
3;
children.push({
type: GifNodeType.localColorTable,
bytes: bytes.subarray(
offset,
offset + sizeOfLocalColorTableInBytes,
),
children: colorNodes,
});
offset += sizeOfLocalColorTableInBytes;
}
const imageDataStartOffset = offset;
// TODO: remove this?
// const lzwMinimumCodeSize = bytes[offset++];
offset++;
/** @type {GifNode[]} */ const imageDataSubBlocks = [];
while (true) {
const subBlockSize = bytes[offset];
const subBlockSizeBytes = bytes.subarray(offset, offset + 1);
if (subBlockSize === 0) {
imageDataSubBlocks.push({
type: GifNodeType.imageDataTerminator,
bytes: subBlockSizeBytes,
});
offset++;
break;
}
imageDataSubBlocks.push({
type: GifNodeType.imageDataSubBlock,
bytes: bytes.subarray(offset, offset + 1 + subBlockSize),
children: [
{
type: GifNodeType.imageDataSubBlockSize,
bytes: subBlockSizeBytes,
},
{
type: GifNodeType.imageDataSubBlockData,
bytes: bytes.subarray(offset + 1, offset + 1 + subBlockSize),
},
],
});
offset += 1 + subBlockSize;
}
children.push({
type: GifNodeType.imageData,
bytes: bytes.subarray(imageDataStartOffset, offset),
children: [
{
type: GifNodeType.imageDataLzwMinimumCodeSize,
bytes: bytes.subarray(imageDataStartOffset, imageDataStartOffset + 1),
},
...imageDataSubBlocks,
],
});
return {
newOffset: offset,
node: {
type: GifNodeType.imageSection,
bytes: bytes.subarray(startingOffset, offset),
children,
},
};
};
/**
* @param {Uint8Array} bytes
* @param {number} startingOffset
* @returns {{ node: GifNode, newOffset: number }}
*/
const readExtensionBlock = (bytes, startingOffset) => {
let offset = startingOffset;
/** @type {GifNode[]} */ const children = [{
type: GifNodeType.extensionBlockIntroducer,
bytes: bytes.subarray(offset, offset + 1),
}];
offset++;
const extensionLabel = bytes[offset++];
const extensionSize = bytes[offset++];
const extensionSizeNode = {
type: GifNodeType.extensionBlockSize,
bytes: new Uint8Array([extensionSize]),
};
switch (extensionLabel) {
// TODO: handle various documented extensions
default:
children.push(
{
type: GifNodeType.unknownExtensionBlockLabel,
bytes: new Uint8Array([extensionLabel]),
},
extensionSizeNode,
{
type: GifNodeType.unknownExtensionBlockData,
bytes: bytes.subarray(offset, offset + extensionSize),
},
);
offset += extensionSize;
return {
newOffset: offset,
node: {
type: GifNodeType.unknownExtensionBlock,
bytes: bytes.subarray(startingOffset, offset),
children,
},
};
}
};
/**
* @param {Uint8Array} bytes
* @returns {null | GifNode} The root node of the GIF tree, or null if the GIF is invalid.
@ -98,5 +302,28 @@ export default (bytes) => {
offset += sizeOfGlobalColorTableInBytes;
}
readingBlocks:
while (true) {
const blockType = bytes[offset];
let readerFn;
switch (blockType) {
case BLOCK_TYPE_IMAGE:
readerFn = readImageBlock;
break;
case BLOCK_TYPE_EXTENSION:
readerFn = readExtensionBlock;
break;
case BLOCK_TYPE_TRAILER:
break readingBlocks;
default:
throw new Error(`Unknown GIF block type: ${blockType}`);
}
const { node, newOffset } = readerFn(bytes, offset);
children.push(node);
offset = newOffset;
}
return { type: GifNodeType.root, bytes, children };
};