GIF: basic parsing of header

Had to move a lot of stuff out of PNG-land and into common utilities.
This commit is contained in:
Evan Hahn 2023-08-11 22:40:30 -05:00
parent 69c66e7d99
commit 7f64453e5f
15 changed files with 258 additions and 125 deletions

View File

@ -1,8 +1,8 @@
// @ts-check // @ts-check
import crel, { fragment } from "../common/crel.js"; import crel, { fragment } from "./crel.js";
import formatBytes from "../common/formatBytes.js"; import formatBytes from "./formatBytes.js";
import allChildrenOf from "../common/allChildrenOf.js"; import allChildrenOf from "./allChildrenOf.js";
import * as nodePath from "./nodePath.js"; import * as nodePath from "./nodePath.js";
/** @typedef {import("./nodePath.js").NodePath} NodePath */ /** @typedef {import("./nodePath.js").NodePath} NodePath */
/** @typedef {import("../../types/png.d.ts").PngNode} PngNode */ /** @typedef {import("../../types/png.d.ts").PngNode} PngNode */

View File

@ -31,6 +31,7 @@ body {
line-height: 1.5em; line-height: 1.5em;
color: var(--foreground-color); color: var(--foreground-color);
background: var(--background-color); background: var(--background-color);
margin: 1em;
} }
h1, h1,
@ -64,3 +65,97 @@ input,
textarea { textarea {
font: inherit; font: inherit;
} }
#explorer {
display: flex;
overflow: hidden;
}
#explorer .bytes,
#explorer .tree {
flex: 1;
overflow: auto;
}
#explorer .bytes {
max-width: 15em;
font-family: Inconsolata, Consolas, Monaco, monospace;
font-size: 80%;
line-height: 1.5em;
white-space: pre-wrap;
}
#explorer .bytes span {
cursor: pointer;
}
#explorer .bytes span.activated {
background: var(--activated-color);
color: var(--background-color);
}
#explorer .tree {
padding-left: 0.5rem;
scrollbar-gutter: stable;
}
#explorer .tree details.activated {
outline-width: 3px;
outline-color: var(--tree-activated-border-color);
}
#explorer .tree details {
outline: 1px solid var(--tree-border-color);
border-radius: 10px;
padding: 0 0.5rem 0 0.5rem;
transition: outline ease-out 0.1s;
margin: 3px 3px 0.5rem 3px;
}
#explorer .tree details[open] {
padding-bottom: 6px;
}
#explorer .tree details p:last-child {
margin-bottom: 0;
}
#explorer .tree details .children {
margin-left: 0.5rem;
}
#explorer .tree details summary {
cursor: pointer;
user-select: none;
display: flex;
}
#explorer .tree details summary:before {
content: "▶";
margin-right: 0.2em;
transition: transform ease-out 0.1s;
}
#explorer .tree details[open]>summary:before {
transform: rotate(90deg);
}
#explorer .tree details summary:focus {
outline: none;
}
#explorer .tree details summary .title {
flex: 1;
font-weight: bold;
}
#explorer .tree details summary .bytecount {
margin-left: 0.5em;
font-size: 75%;
opacity: 0.5;
}
#explorer .tree details:not(.activated) {
background: var(--background-color);
color: var(--foreground-color);
}

13
public/gif/constants.js Normal file
View File

@ -0,0 +1,13 @@
// @ts-check
/** @enum {string} */
export const GifNodeType = [
"root",
"header",
"headerSignature",
"headerVersion",
].reduce((result, id) => {
result[id] = id;
return result;
}, Object.create(null));

70
public/gif/getNodeUi.js Normal file
View File

@ -0,0 +1,70 @@
// @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";
/** @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);
/**
* @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}.`,
),
};
},
};
/**
* @param {GifNode} node
* @returns {NodeUi}
*/
export default (node) => {
const uiFn = getOwn(NODE_UI_FNS, node.type);
if (!uiFn) throw new Error("Found a node with no matching UI function");
return uiFn(node);
};

View File

@ -1,6 +1,13 @@
// @ts-check // @ts-check
import * as base64 from "../common/vendor/base64-js.js"; import * as base64 from "../common/vendor/base64-js.js";
import parseGif from "./parseGif.js";
import getNodeUi from "./getNodeUi.js";
import explorer from "../common/explorer.js";
const errorEl = document.getElementById("error");
const explorerEl = document.getElementById("explorer");
if (!errorEl || !explorerEl) throw new Error("HTML is not set up correctly");
const main = () => { const main = () => {
// TODO: We may want a better UI here. // TODO: We may want a better UI here.
@ -12,7 +19,16 @@ const main = () => {
} }
const bytes = base64.toByteArray(fileDataBase64); const bytes = base64.toByteArray(fileDataBase64);
console.log(bytes); const rootNode = parseGif(bytes);
if (!rootNode) {
// TODO: Is there better UI than this?
errorEl.removeAttribute("hidden");
return;
}
explorerEl.innerHTML = "";
explorerEl.append(explorer(rootNode, getNodeUi));
explorerEl.removeAttribute("hidden");
}; };
main(); main();

View File

@ -7,7 +7,6 @@
<meta http-equiv="refresh" content="0; url=.." /> <meta http-equiv="refresh" content="0; url=.." />
</noscript> </noscript>
<link rel="stylesheet" href="../common/global.css"> <link rel="stylesheet" href="../common/global.css">
<link rel="stylesheet" href="gif.css">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>

30
public/gif/parseGif.js Normal file
View File

@ -0,0 +1,30 @@
// @ts-check
import { GifNodeType } from "./constants.js";
/** @typedef {import("../../types/gif.d.ts").GifNode} GifNode */
/**
* @param {Uint8Array} bytes
* @returns {null | GifNode} The root node of the PNG tree, or null if the PNG is invalid.
*/
export default (bytes) => {
/** @type {GifNode[]} */
const children = [
{
type: GifNodeType.header,
bytes: bytes.subarray(0, 6),
children: [
{
type: GifNodeType.headerSignature,
bytes: bytes.subarray(0, 3),
},
{
type: GifNodeType.headerVersion,
bytes: bytes.subarray(3, 6),
},
],
},
];
return { type: GifNodeType.root, bytes, children };
};

View File

@ -7,7 +7,6 @@
<meta http-equiv="refresh" content="0; url=.." /> <meta http-equiv="refresh" content="0; url=.." />
</noscript> </noscript>
<link rel="stylesheet" href="../common/global.css"> <link rel="stylesheet" href="../common/global.css">
<link rel="stylesheet" href="png.css">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>

View File

@ -1,97 +0,0 @@
body {
margin: 1rem;
}
#explorer {
display: flex;
overflow: hidden;
}
#explorer .bytes,
#explorer .tree {
flex: 1;
overflow: auto;
}
#explorer .bytes {
max-width: 15em;
font-family: Inconsolata, Consolas, Monaco, monospace;
font-size: 80%;
line-height: 1.5em;
white-space: pre-wrap;
}
#explorer .bytes span {
cursor: pointer;
}
#explorer .bytes span.activated {
background: var(--activated-color);
color: var(--background-color);
}
#explorer .tree {
padding-left: 0.5rem;
scrollbar-gutter: stable;
}
#explorer .tree details.activated {
outline-width: 3px;
outline-color: var(--tree-activated-border-color);
}
#explorer .tree details {
outline: 1px solid var(--tree-border-color);
border-radius: 10px;
padding: 0 0.5rem 0 0.5rem;
transition: outline ease-out 0.1s;
margin: 3px 3px 0.5rem 3px;
}
#explorer .tree details[open] {
padding-bottom: 6px;
}
#explorer .tree details p:last-child {
margin-bottom: 0;
}
#explorer .tree details .children {
margin-left: 0.5rem;
}
#explorer .tree details summary {
cursor: pointer;
user-select: none;
display: flex;
}
#explorer .tree details summary:before {
content: "▶";
margin-right: 0.2em;
transition: transform ease-out 0.1s;
}
#explorer .tree details[open]>summary:before {
transform: rotate(90deg);
}
#explorer .tree details summary:focus {
outline: none;
}
#explorer .tree details summary .title {
flex: 1;
font-weight: bold;
}
#explorer .tree details summary .bytecount {
margin-left: 0.5em;
font-size: 75%;
opacity: 0.5;
}
#explorer .tree details:not(.activated) {
background: var(--background-color);
color: var(--foreground-color);
}

View File

@ -3,7 +3,7 @@
import * as base64 from "../common/vendor/base64-js.js"; import * as base64 from "../common/vendor/base64-js.js";
import parsePng from "./parsePng.js"; import parsePng from "./parsePng.js";
import getNodeUi from "./getNodeUi.js"; import getNodeUi from "./getNodeUi.js";
import explorer from "./explorer.js"; import explorer from "../common/explorer.js";
const errorEl = document.getElementById("error"); const errorEl = document.getElementById("error");
const explorerEl = document.getElementById("explorer"); const explorerEl = document.getElementById("explorer");

View File

@ -1,5 +1,9 @@
import { assert, assertEquals } from "assert"; import { assert, assertEquals } from "assert";
import { isEqualTo, isSupersetOf, parse } from "../../public/png/nodePath.js"; import {
isEqualTo,
isSupersetOf,
parse,
} from "../../public/common/nodePath.js";
Deno.test("parse", () => { Deno.test("parse", () => {
assertEquals(parse(""), []); assertEquals(parse(""), []);

19
types/common.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
export type Node<T> = {
/**
* The type of this node. Typically an enum specific to the format.
*/
type: T;
/**
* The bytes that make up this node.
*
* It is highly encouraged to use `Uint8Array.prototype.subarray`,
* not `.slice`, to avoid copying the data.
*/
bytes: Uint8Array;
/**
* Child nodes.
*/
children?: ReadonlyArray<Node<T>>;
};

4
types/gif.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
import { GifNodeType } from "../public/gif/constants.js";
import { Node } from "./common.d.ts";
export type GifNode = Node<GifNodeType>;

21
types/png.d.ts vendored
View File

@ -1,23 +1,4 @@
import { PngNodeType } from "../public/png/constants.js"; import { PngNodeType } from "../public/png/constants.js";
import { Node } from "./common.d.ts";
type Node<T> = {
/**
* The type of this node. Typically an enum specific to the format.
*/
type: T;
/**
* The bytes that make up this node.
*
* It is highly encouraged to use `Uint8Array.prototype.subarray`,
* not `.slice`, to avoid copying the data.
*/
bytes: Uint8Array;
/**
* Child nodes.
*/
children?: ReadonlyArray<Node<T>>;
};
export type PngNode = Node<PngNodeType>; export type PngNode = Node<PngNodeType>;