314 lines
9.7 KiB
JavaScript
314 lines
9.7 KiB
JavaScript
// @ts-check
|
|
|
|
import { GifNodeType } from "./constants.js";
|
|
import crel, { fragment } from "../common/crel.js";
|
|
import getOwn from "../common/getOwn.js";
|
|
import { areBytesEqual } from "../common/bytes.js";
|
|
import pluralize from "../common/pluralize.js";
|
|
import { parseLogicalScreenDescriptorPackedField } from "./parseGif.js";
|
|
/** @typedef {import("../../types/gif.d.ts").GifNode} GifNode */
|
|
|
|
const textEncoder = new TextEncoder();
|
|
|
|
const VERSION_87 = textEncoder.encode("87a");
|
|
const VERSION_89 = textEncoder.encode("89a");
|
|
|
|
/**
|
|
* @param {(string | Node)[]} children
|
|
*/
|
|
const p = (...children) => crel("p", {}, ...children);
|
|
|
|
/**
|
|
* @param {(string | Node)[]} children
|
|
*/
|
|
const li = (...children) => crel("li", {}, ...children);
|
|
|
|
/**
|
|
* @param {number} byte
|
|
* @returns {string}
|
|
*/
|
|
const formatByte = (byte) => byte.toString(16).padStart(2, "0");
|
|
|
|
/**
|
|
* @param {Uint8Array} bytes
|
|
* @returns {number}
|
|
*/
|
|
const readGifUint = (bytes) => {
|
|
if (bytes.length !== 2) throw new Error("Expected 2 bytes");
|
|
return (bytes[1] << 8) | bytes[0];
|
|
};
|
|
|
|
/**
|
|
* @typedef {object} NodeUi
|
|
* @prop {string} title
|
|
* @prop {HTMLElement | DocumentFragment} description
|
|
*/
|
|
|
|
/** @type {Record<string, (node: GifNode) => NodeUi>} */
|
|
const NODE_UI_FNS = {
|
|
[GifNodeType.root]: () => ({
|
|
title: "GIF file",
|
|
description: fragment(),
|
|
}),
|
|
|
|
// Header
|
|
|
|
[GifNodeType.header]: () => ({
|
|
title: "GIF header",
|
|
description: fragment(
|
|
p('GIFs start with a 6-byte header. This is typically the string "GIF87a" or "GIF89a", encoded as ASCII.'),
|
|
),
|
|
}),
|
|
[GifNodeType.headerSignature]: () => ({
|
|
title: "GIF signature",
|
|
description: p('GIFs start with the string "GIF", encoded as ASCII.'),
|
|
}),
|
|
[GifNodeType.headerVersion]: ({ bytes }) => {
|
|
/** @type {string} */ let end;
|
|
if (areBytesEqual(bytes, VERSION_87)) {
|
|
end = "87a";
|
|
} else if (areBytesEqual(bytes, VERSION_89)) {
|
|
end = "89a";
|
|
} else {
|
|
end = "something unexpected! This might not be a valid GIF file";
|
|
}
|
|
return {
|
|
title: "GIF version",
|
|
description: p(
|
|
`The version of the GIF format. This is typically "87a" or "89a". In this case, the version is ${end}.`,
|
|
),
|
|
};
|
|
},
|
|
|
|
// Logical screen descriptor
|
|
|
|
[GifNodeType.logicalScreenDescriptor]: () => ({
|
|
title: "Logical Screen Descriptor block",
|
|
description: p(
|
|
"The Logical Screen Descriptor contains information about the overall GIF, such as its dimensions.",
|
|
),
|
|
}),
|
|
[GifNodeType.logicalScreenWidth]: ({ bytes }) => ({
|
|
title: "Logical Screen Width",
|
|
description: p(
|
|
`The width of the image stored as a 16-bit little endian unsigned integer. In this case, the width is ${
|
|
pluralize(readGifUint(bytes), "byte")
|
|
}.`,
|
|
),
|
|
}),
|
|
[GifNodeType.logicalScreenHeight]: ({ bytes }) => ({
|
|
title: "Logical Screen Height",
|
|
description: p(
|
|
`The height of the image stored as a 16-bit little endian unsigned integer. In this case, the height is ${
|
|
pluralize(readGifUint(bytes), "byte")
|
|
}.`,
|
|
),
|
|
}),
|
|
[GifNodeType.logicalScreenDescriptorPackedFields]: ({ bytes }) => {
|
|
const byte = bytes[0];
|
|
const packedField = parseLogicalScreenDescriptorPackedField(byte);
|
|
|
|
return {
|
|
title: "Logical Screen Descriptor packed fields",
|
|
description: fragment(
|
|
p("This byte contains several flags that control how the GIF is displayed. In binary, it looks like this:"),
|
|
p(bytes[0].toString(2).padStart(8, "0")),
|
|
crel(
|
|
"ul",
|
|
{},
|
|
li(
|
|
packedField.globalColorTableFlag
|
|
? "The first bit is 1, which means that a global color table follows the logical screen descriptor. See below for more information about this table."
|
|
: "The first bit is 0, which means that this GIF has no global color table. This means that each frame has its own local color table.",
|
|
),
|
|
li(
|
|
`The next three bits (${
|
|
packedField.colorResolution.toString(2).padStart(3, "0")
|
|
}) encode the color resolution. The bigger this number, the more colors this GIF can represent. This value is decoded as binary and then incremented by one, so the color resolution is ${packedField.colorResolution}.`,
|
|
),
|
|
li(
|
|
`The next bit is ${
|
|
packedField.sortFlag ? 1 : 0
|
|
}, which means that the colors in the global color table are${
|
|
packedField.sortFlag ? "" : "n't"
|
|
} sorted. This used to be important when memory was more scarce, but it's not really important anymore.`,
|
|
),
|
|
li(
|
|
`The last three bits (${
|
|
(byte & 0b111).toString(2).padStart(3, "0")
|
|
}) encode the number of colors of the global color table. To decode this field, you add 1 and then raise that to the power of 2, so the size of the global color table is ${
|
|
pluralize(packedField.sizeOfGlobalColorTable, "color")
|
|
}. `,
|
|
),
|
|
),
|
|
),
|
|
};
|
|
},
|
|
[GifNodeType.logicalScreenBackgroundColorIndex]: () => ({
|
|
title: "Background Color Index",
|
|
description: p("TODO"),
|
|
}),
|
|
[GifNodeType.logicalScreenPixelAspectRatio]: () => ({
|
|
title: "Pixel Aspect Ratio",
|
|
description: p("TODO"),
|
|
}),
|
|
|
|
// Global color table
|
|
|
|
[GifNodeType.globalColorTable]: ({ children }) => ({
|
|
title: "Global Color Table",
|
|
description: p(
|
|
`The Global Color Table is a list of colors. 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.globalColorTableColor]: ({ bytes }) => ({
|
|
title: "Global 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 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"),
|
|
}),
|
|
};
|
|
|
|
/**
|
|
* @param {GifNode} node
|
|
* @returns {NodeUi}
|
|
*/
|
|
export default (node) => {
|
|
const uiFn = getOwn(NODE_UI_FNS, node.type);
|
|
if (!uiFn) {
|
|
throw new Error(`Found a node (${node.type}) with no matching UI function`);
|
|
}
|
|
return uiFn(node);
|
|
};
|