From ed8a1c3b63aaa2bc58fb9ca0b41cabe56d436cc6 Mon Sep 17 00:00:00 2001 From: Evan Hahn Date: Wed, 2 Aug 2023 13:39:57 -0500 Subject: [PATCH] PNG: UI partially works; usable, if not missing features --- public/common/crel.js | 18 +- public/common/formatBytes.js | 33 ++ public/common/getOwn.js | 24 ++ public/common/global.css | 27 +- public/common/pluralize.js | 13 + public/common/vendor/reset.css | 1 + public/index/index.js | 3 +- public/png/getNodeUi.js | 670 ++++++++++++++++++++++++++++++++ public/png/nodePath.js | 30 ++ public/png/parsePngUint.js | 8 + public/png/png.css | 10 + public/png/png.js | 11 +- test/common/formatBytes.test.ts | 17 + test/common/getOwn.test.ts | 20 + test/common/pluralize.test.ts | 12 + test/png/parsePngUint.test.ts | 8 + 16 files changed, 893 insertions(+), 12 deletions(-) create mode 100644 public/common/formatBytes.js create mode 100644 public/common/getOwn.js create mode 100644 public/common/pluralize.js create mode 100644 public/common/vendor/reset.css create mode 100644 public/png/getNodeUi.js create mode 100644 public/png/nodePath.js create mode 100644 public/png/parsePngUint.js create mode 100644 test/common/formatBytes.test.ts create mode 100644 test/common/getOwn.test.ts create mode 100644 test/common/pluralize.test.ts create mode 100644 test/png/parsePngUint.test.ts diff --git a/public/common/crel.js b/public/common/crel.js index a100541..23b9a2b 100644 --- a/public/common/crel.js +++ b/public/common/crel.js @@ -6,10 +6,10 @@ * * @param {string} tagName * @param {object} [attributes={}] - * @param {...(string | Node)} children + * @param {(string | Node)[]} children * @returns {HTMLElement} */ -export default function crel(tagName, attributes = {}, ...children) { +export default (tagName, attributes = {}, ...children) => { const el = document.createElement(tagName); for (const [key, value] of Object.entries(attributes)) { @@ -19,4 +19,16 @@ export default function crel(tagName, attributes = {}, ...children) { el.append(...children); return el; -} +}; + +/** + * Create a fragment. Similar to `crel`. + * + * @param {(string | Node)[]} children + * @returns {DocumentFragment} + */ +export const fragment = (...children) => { + const result = document.createDocumentFragment(); + result.append(...children); + return result; +}; diff --git a/public/common/formatBytes.js b/public/common/formatBytes.js new file mode 100644 index 0000000..dbe841c --- /dev/null +++ b/public/common/formatBytes.js @@ -0,0 +1,33 @@ +// @ts-check + +/** + * @param {Uint8Array} bytes + * @returns {string} + */ +export default (bytes, maxBytes = 1024) => { + let result = ""; + let hasWrittenFirstByte = false; + + // We truncate for performance. + // + // There are other ways to achieve this but this is the simplest. + const shouldTruncate = bytes.byteLength > maxBytes; + if (shouldTruncate) { + bytes = bytes.subarray(0, maxBytes); + } + + for (const byte of bytes) { + if (hasWrittenFirstByte) { + result += " "; + } else { + hasWrittenFirstByte = true; + } + result += byte.toString(16).padStart(2, "0"); + } + + if (shouldTruncate) { + result += " … "; + } + + return result; +}; diff --git a/public/common/getOwn.js b/public/common/getOwn.js new file mode 100644 index 0000000..93d79ee --- /dev/null +++ b/public/common/getOwn.js @@ -0,0 +1,24 @@ +// @ts-check + +/** + * Get a property if it's an "own" property, otherwise return `undefined`. + * + * @example + * const obj = { foo: "bar" }; + * + * obj.foo; + * // => "bar" + * obj.hasOwnProperty + * // => [Function] + * + * getOwn(obj, "foo"); + * // => "bar" + * getOwn(obj, "hasOwnProperty"); + * // => undefined + * + * @template {object} T + * @param {T} obj + * @param {keyof T} key + * @returns {undefined | T[keyof T]} + */ +export default (obj, key) => Object.hasOwn(obj, key) ? obj[key] : undefined; diff --git a/public/common/global.css b/public/common/global.css index 42facb7..43d6172 100644 --- a/public/common/global.css +++ b/public/common/global.css @@ -1,3 +1,5 @@ +@import url("vendor/reset.css"); + :root { --foreground-color: #111; --background-color: #fff; @@ -23,7 +25,7 @@ box-sizing: border-box; } -html { +body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-size: 18pt; line-height: 1.5em; @@ -31,10 +33,33 @@ html { background: var(--background-color); } +h1, +p { + margin-bottom: 1rem; +} + +h1 { + font-weight: bold; + font-size: 200%; +} + a { color: inherit; } +ul, +ol { + padding-left: 1rem; +} + +ul { + list-style-type: disc; +} + +ol { + list-style-type: decimal; +} + input, textarea { font: inherit; diff --git a/public/common/pluralize.js b/public/common/pluralize.js new file mode 100644 index 0000000..8e74896 --- /dev/null +++ b/public/common/pluralize.js @@ -0,0 +1,13 @@ +// @ts-check + +/** + * Pluralize an English string. + * @param {number} count + * @param {string} noun + * @returns {string} + */ +export default (count, noun) => { + let result = count + " " + noun; + if (count !== 1) result += "s"; + return result; +}; diff --git a/public/common/vendor/reset.css b/public/common/vendor/reset.css new file mode 100644 index 0000000..bdcdb83 --- /dev/null +++ b/public/common/vendor/reset.css @@ -0,0 +1 @@ +html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{border:0;font-size:100%;font:inherit;vertical-align:baseline;margin:0;padding:0}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:none}table{border-collapse:collapse;border-spacing:0} \ No newline at end of file diff --git a/public/index/index.js b/public/index/index.js index b3ff99d..09b0841 100644 --- a/public/index/index.js +++ b/public/index/index.js @@ -15,6 +15,7 @@ const inputEl = /** @type {HTMLInputElement} */ ( accept, }) ); +const inputContainerEl = crel("p", {}, inputEl); const supportedFileTypeNameString = SUPPORTED_FILE_TYPES .map((t, index, array) => ( @@ -75,7 +76,7 @@ const main = () => { }); }); - appEl.append(labelParagraphEl, inputEl, disclaimerParagraphEl); + appEl.append(labelParagraphEl, inputContainerEl, disclaimerParagraphEl); }; main(); diff --git a/public/png/getNodeUi.js b/public/png/getNodeUi.js new file mode 100644 index 0000000..0c6eb53 --- /dev/null +++ b/public/png/getNodeUi.js @@ -0,0 +1,670 @@ +// @ts-check + +import { PngNodeType } from "./constants.js"; +import crel, { fragment } from "../common/crel.js"; +import formatBytes from "../common/formatBytes.js"; +import pluralize from "../common/pluralize.js"; +import getOwn from "../common/getOwn.js"; +import parsePngUint from "./parsePngUint.js"; +/** @typedef {import("../../types/png.d.ts").PngNode} PngNode */ + +/** + * @param {(string | Node)[]} children + */ +const p = (...children) => crel("p", {}, ...children); + +/** + * @param {string} text + * @param {string} href + */ +const a = (text, href) => crel("a", { href, target: "_blank" }, text); + +/** + * @param {number} byte + * @returns {string} + */ +const formatByte = (byte) => byte.toString(16).padStart(2, "0"); + +/** + * @param {Uint8Array} _bytes + * @returns {null | Uint8Array} + */ +const maybeInflate = (_bytes) => { + // TODO: Inflate the bytes + return null; +}; + +/** + * @typedef {object} NodeUi + * @prop {string} title + * @prop {HTMLElement | DocumentFragment} description + */ + +/** @type {Record NodeUi>} */ +const NODE_UI_FNS = { + // Generic parts of a PNG + + [PngNodeType.root]: () => ({ + title: "PNG file", + description: fragment(), + }), + [PngNodeType.signature]: () => ({ + title: "PNG signature", + description: fragment( + p( + "Every PNG starts with the same 8 bytes: the ", + a("PNG signature", "https://www.w3.org/TR/png/#5PNG-file-signature"), + ".", + ), + p("PNG decoders use these bytes to ensure that they're reading a PNG image. Typically, they reject the file if it doesn't start with the signature. Data can get corrupted in various ways (ever had a file with the wrong extension?) and this helps address that."), + p('Fun fact: if you decode these bytes as ASCII, you\'ll see the letters "PNG" in there!'), + ), + }), + [PngNodeType.chunkLength]: ({ bytes }) => ({ + title: "Chunk length", + description: fragment( + p( + "This chunk has ", + pluralize(parsePngUint(bytes), "byte"), + " of data, as you see in the chunk data section below. This gets encoded as a 32-bit unsigned integer. Read more in the ", + a( + '"Chunk layout" section of the spec', + "https://www.w3.org/TR/png/#5Chunk-layout", + ), + ".", + ), + ), + }), + [PngNodeType.chunkType]: ({ bytes }) => ({ + title: "Chunk type", + description: p( + "The chunk type, ", + new TextDecoder().decode(bytes), + ", encoded as ASCII.", + ), + }), + [PngNodeType.chunkData]: () => ({ + title: "Chunk data", + description: p("The chunk's data."), + }), + [PngNodeType.chunkCrc]: () => ({ + title: "Chunk checksum", + description: p( + "A four-byte checksum for this chunk. Checksums use the CRC32 algorithm on the type and data bytes in the chunk.", + ), + }), + [PngNodeType.unknownChunk]: (node) => { + const children = node.children || []; + const chunkTypeNode = children.find((child) => + child.type === PngNodeType.chunkType + ); + const chunkTypeBytes = chunkTypeNode?.bytes; + const chunkType = chunkTypeBytes + ? new TextDecoder().decode(chunkTypeBytes) + : null; + return { + title: chunkType ? `${chunkType} chunk` : "Unknown chunk", + description: p( + "This looks like a valid chunk type, but this tool doesn't know about it.", + ), + }; + }, + + // IHDR + + [PngNodeType.ihdr]: () => ({ + title: "IHDR: Image header", + description: p( + "Every PNG is made up of multiple chunks. The first chunk is always the image header, or ", + a("IHDR", "https://www.w3.org/TR/png/#11IHDR"), + ". It contains metadata about the image, such as dimensions and color information.", + ), + }), + [PngNodeType.ihdrChunkData]: () => ({ + title: "Image header data", + description: fragment(), + }), + [PngNodeType.ihdrWidth]: ({ bytes }) => ({ + title: "Image width", + description: p( + "This image has a width of ", + pluralize(parsePngUint(bytes), "pixel"), + ". That gets encoded as a 32-bit unsigned integer.", + ), + }), + [PngNodeType.ihdrHeight]: ({ bytes }) => ({ + title: "Image height", + description: p( + "This image has a height of ", + pluralize(parsePngUint(bytes), "pixel"), + ". That gets encoded as a 32-bit unsigned integer.", + ), + }), + [PngNodeType.ihdrBitDepth]: ({ bytes }) => ({ + title: "Image bit depth", + description: p( + `The number of bits per sample in the image. This image uses ${ + pluralize(bytes[0], "bit") + } per sample.`, + ), + }), + [PngNodeType.ihdrColourType]: ({ bytes }) => { + const colorType = bytes[0]; + const colorTypeString = ({ + 0: "greyscale. That means each pixel is a greyscale.", + 2: '"truecolor". That means each pixel has an RGB value.', + 3: '"indexed-color". That means each pixel is looked up in the PLTE palette chunk.', + 4: "greyscale with alpha. That means each pixel has a greyscale value followed by an alpha value.", + 6: '"truecolor" with alpha. That means that each pixel has an RGBA value.', + })[colorType]; + let description; + if (colorTypeString) { + description = + `The color type of the image. This image is of color type ${colorType}, also known as ${colorTypeString}`; + } else { + description = + `The color type of the image...but this one, ${colorType}, is invalid.`; + } + return { title: "Image color type", description: p(description) }; + }, + [PngNodeType.ihdrCompressionMethod]: () => ({ + title: "Image compression method", + description: p( + "The compression method for the pixel data. All PNGs set this to 0, but this is here just in case the world wants to support a new compression method in the future.", + ), + }), + [PngNodeType.ihdrFilterMethod]: () => ({ + title: "Image filter method", + description: p( + "The filter method for the pixel data. All PNGs set this to 0, but this is here just in case the world wants to support a new compression method in the future.", + ), + }), + [PngNodeType.ihdrInterlaceMethod]: ({ bytes }) => ({ + title: "Image interlace method", + description: fragment( + p( + a("Interlacing", "https://www.w3.org/TR/png/#8Interlace"), + " can allow a rough image to be shown while it's loading. The PNG spec defines two interlace methods: none (where pixels are extracted sequentially from left to right) and Adam7, where pixels are transmitted in a more complex order.", + "This image uses the ", + bytes[0] === 0 ? "former" : "latter", + ".", + ), + ), + }), + + // PLTE + + [PngNodeType.plte]: () => ({ + title: "PLTE: Palette", + description: fragment( + p( + "This is a ", + a("palette chunk", "https://www.w3.org/TR/png/#11PLTE"), + ". Is is typically used for indexed-color images where a palette is defined, and then each pixel is looked up in this palette. It can improve compression when you the image only has a small number of colors.", + ), + p("In some cases (not detected by this tool), it can also be used for other purposes. See the spec for details."), + ), + }), + [PngNodeType.plteChunkData]: () => ({ + title: "Palette chunk data", + description: p( + "Each pixel is represented by a red, green, and blue value of 1 byte each.", + ), + }), + [PngNodeType.plteColor]: ({ bytes }) => ({ + title: "Palette color", + description: p( + "A palette 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]) + }`, + ), + ), + }), + + // IDAT + + [PngNodeType.idat]: () => ({ + title: "IDAT: Image data", + description: p( + "The ", + a("image data chunk", "https://www.w3.org/TR/png/#11IDAT"), + " contains the compressed image data. The compressed image data can be spread across multiple IDAT chunks or all in one.", + ), + }), + [PngNodeType.idatChunkData]: ({ bytes }) => { + const inflatedBytes = maybeInflate(bytes); + return { + title: "Image data", + description: fragment( + p("The image data. This is compressed in the zlib format."), + ...(inflatedBytes + ? [ + p("The uncompressed bytes look like this:"), + crel("pre", {}, formatBytes(inflatedBytes)), + ] + : []), + ), + }; + }, + + // IEND + + [PngNodeType.iend]: () => ({ + title: "IEND: Image end", + description: p( + "The ", + a("IEND chunk", "https://www.w3.org/TR/png/#11IEND"), + " marks the end of a PNG and should always be the final chunk.", + ), + }), + [PngNodeType.iendChunkLength]: () => ({ + title: "Chunk length", + description: p("IEND chunks always have a length of 0."), + }), + + // tRNS + + [PngNodeType.trns]: () => ({ + title: "tRNS: Transparency", + description: p( + "The ", + a("tRNS chunk", "https://www.w3.org/TR/png/#11tRNS"), + " encodes transparency information.", + ), + }), + + // cHRM + + [PngNodeType.chrm]: () => ({ + title: "cHRM: Primary chromaticities and white point", + description: p( + "The ", + a("cHRM chunk", "https://www.w3.org/TR/png/#11cHR"), + " encodes chromacities of colors and the white point.", + ), + }), + + // gAMA + + [PngNodeType.gama]: () => ({ + title: "gAMA: Image gamma", + description: p( + "The ", + a("gAMA chunk", "https://www.w3.org/TR/png/#11gAMA"), + " specifies the ", + a("gamma value", "https://www.w3.org/TR/png/#dfn-gamma-value"), + " of the image.", + ), + }), + + // iCCP + + [PngNodeType.iccp]: () => ({ + title: "iCCP: Color profile", + description: p( + "The ", + a("iCCP chunk", "https://www.w3.org/TR/png/#11iCCP"), + " encodes an embedded color profile from the International Color Consortium.", + ), + }), + + // sBIT + + [PngNodeType.sbit]: () => ({ + title: "sBIT: Significant bits", + description: p( + "The ", + a("sBIT chunk", "https://www.w3.org/TR/png/#11sBIT"), + " .", + ), + }), + + // sRGB + + [PngNodeType.srgb]: () => ({ + title: "sRGB: Standard RGB color space", + description: p( + "The ", + a("sRGB chunk", "https://www.w3.org/TR/png/#srgb-standard-colour-space"), + " specifies the rendering intent for the color space.", + ), + }), + + // cICP + + [PngNodeType.cicp]: () => ({ + title: "cICP", + description: p( + "The ", + a("cICP chunk", "https://www.w3.org/TR/png/#cICP-chunk"), + ' specifies "coding-independent code points for video signal type identification".', + ), + }), + + // Text chunks + + [PngNodeType.text]: () => ({ + title: "tEXt: Text data", + description: p( + "You can encode text with the ", + a("tEXt chunk", "https://www.w3.org/TR/png/#11tEXt"), + ". Each text chunk contains a keyword, a null separator, and 0 or more bytes of Latin-1 text.", + ), + }), + [PngNodeType.textData]: () => ({ + title: "Text data", + description: fragment(), + }), + [PngNodeType.textKeyword]: ({ bytes }) => ({ + title: "Keyword", + description: p( + JSON.stringify( + new TextDecoder("latin1").decode(bytes), + ), + ", encoded with Latin-1.", + ), + }), + [PngNodeType.textNullSeparator]: () => ({ + title: "Null separator", + description: p("A null separator (a single zero)."), + }), + [PngNodeType.textString]: ({ bytes }) => ({ + title: "Text", + description: p( + JSON.stringify( + new TextDecoder("latin1").decode(bytes), + ), + ", encoded with Latin-1.", + ), + }), + + [PngNodeType.ztxt]: () => ({ + title: "zTXt: Compressed text data", + description: p( + "You can encode text with the ", + a("zTXt chunk", "https://www.w3.org/TR/png/#11zTXt"), + ". Each of these chunks contains a keyword, a null separator, a compression method, and zlib-compressed Latin-1 text.", + ), + }), + [PngNodeType.ztxtData]: () => ({ + title: "Compressed text data", + description: fragment(), + }), + [PngNodeType.ztxtCompressionMethod]: () => ({ + title: "Text compression method", + description: p( + "The compression method for the text. All PNGs set this to 0, but this is here in case we want to support a new compression method in the future.", + ), + }), + [PngNodeType.ztxtString]: ({ bytes }) => { + const inflatedBytes = maybeInflate(bytes); + let description; + if (inflatedBytes) { + const deflatedBytes = new TextEncoder().encode("TODO"); + description = p( + JSON.stringify( + new TextDecoder("latin1").decode(deflatedBytes), + ), + ", encoded with Latin-1 and then compressed.", + ); + } else { + description = p( + "Text, encoded with Latin-1 and then compressed. I couldn't decompress it, though...", + ); + } + return { title: "Text (compressed)", description }; + }, + + [PngNodeType.itxt]: () => ({ + title: "iTXt: International text data", + description: p( + "You can encode text with the ", + a("iTXt chunk", "https://www.w3.org/TR/png/#11iTXt"), + ". Each of these chunks contains ", + ), + }), + + // bKGD + + [PngNodeType.bkgd]: () => ({ + title: "bKGD: Default background", + description: p( + "The ", + a("bKGD chunk", "https://www.w3.org/TR/png/#11bKGD"), + " specifies the default background color.", + ), + }), + + // hIST + + [PngNodeType.hist]: () => ({ + title: "hIST: Palette histogram", + description: p( + "The ", + a("hIST chunk", "https://www.w3.org/TR/png/#11hIST"), + " gives the approximate usage frequency of each color in the palette, and only makes sense if there's a PLTE chunk.", + ), + }), + + // pHYs + + [PngNodeType.phys]: () => ({ + title: "pHYs: Physical size", + description: p( + "The ", + a("pHYs chunk", "https://www.w3.org/TR/png/#11pHYs"), + " describes the intended physical size of the image.", + ), + }), + + // sPLT + + [PngNodeType.splt]: () => ({ + title: "sPLT: Suggested palette", + description: p( + "The ", + a("sPLT chunk", "https://www.w3.org/TR/png/#11sPLT"), + ", not to be confused with the PLTE palette chunk, suggests a reduced palette to be used when the display can't display all the colors in the image.", + ), + }), + + // eXIf + + [PngNodeType.exif]: () => ({ + title: "eXIf: Exif data", + description: p( + "The ", + a("eXIf chunk", "https://www.w3.org/TR/png/#eXIf"), + " encodes exif data, which can include lots of metadata.", + ), + }), + + // tIME + + [PngNodeType.time]: () => ({ + title: "tIME: Last modified time", + description: p( + "The ", + a("tIME chunk", "https://www.w3.org/TR/png/#11tIME"), + " encodes the last modified time.", + ), + }), + + // acTL + + [PngNodeType.actl]: () => ({ + title: "acTL: Animation control", + description: p( + "The ", + a("acTL chunk", "https://www.w3.org/TR/png/#acTL-chunk"), + " provides some metadata about animated PNGs.", + ), + }), + + // fcTL + + [PngNodeType.fctl]: () => ({ + title: "fcTL: Animation frame control", + description: p( + "The ", + a("fcTL chunk", "https://www.w3.org/TR/png/#fcTL-chunk"), + " defines metadata (such as the duration) of an APNG's frame.", + ), + }), + + // fdAT + + [PngNodeType.fdat]: () => ({ + title: "fdAT: Animation frame data", + description: p( + "The ", + a("fdAT chunk", "https://www.w3.org/TR/png/#fdAT-chunk"), + " describes the frame of an animated PNG. It is like the IDAT chunk but for APNGs.", + ), + }), + + // oFFs + + [PngNodeType.offs]: () => ({ + title: "oFFs: Image offset", + description: p( + "The ", + a( + "oFFs chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.oFFs", + ), + " is a nonstandard chunk used in printing.", + ), + }), + + // pCAL + + [PngNodeType.pcal]: () => ({ + title: "pCAL: Pixel value calibration", + description: p( + "The ", + a( + "pCAL chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.pCAL", + ), + " is a nonstandard chunk, useful in unusual cases when the pixels store physical data other than color values, like temperatures.", + ), + }), + + // sCAL + + [PngNodeType.scal]: () => ({ + title: "sCAL: Physical scale", + description: p( + "The ", + a( + "sCAL chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.sCAL", + ), + " is a nonstandard chunk that describes the intended physical size of the image.", + ), + }), + + // gIFg + + [PngNodeType.gifg]: () => ({ + title: "gIFg: GIF graphic control extension", + description: p( + "The ", + a( + "gIFg chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.gIFg", + ), + " is a nonstandard chunk that extends PNGs to be animated.", + ), + }), + + // gIFx + + [PngNodeType.gifx]: () => ({ + title: "gIFx: GIF application extension", + description: p( + "The ", + a( + "gIFx chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.gIFx", + ), + " is a nonstandard chunk that extends PNGs to be animated.", + ), + }), + + // gIFt + + [PngNodeType.gift]: () => ({ + title: "gIFt: GIF plain text", + description: p( + "The ", + a( + "gIFt chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#DC.gIFt", + ), + " is a nonstandard, deprecated chunk for GIF text.", + ), + }), + + // sTER + + [PngNodeType.ster]: () => ({ + title: "sTER: Stereo images", + description: p( + "The ", + a( + "sTER chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.sTER", + ), + " is a nonstandard chunk for stereogram images.", + ), + }), + + // dSIG + + [PngNodeType.dsig]: () => ({ + title: "dSIG: Digital signature", + description: p( + "The ", + a( + "dSIG chunk", + "https://ftp-osl.osuosl.org/pub/libpng/documents/pngext-1.5.0.html#C.dSIG", + ), + " is a nonstandard chunk for digital signatures.", + ), + }), + + // iDOT + + [PngNodeType.idot]: () => ({ + title: "iDOT: Apple's unusual chunk", + description: p( + "The iDOT chunk is a nonstandard chunk. It is most commonly generated by Apple platforms. Its undocumented workings have been ", + a( + "reverse engineered", + "https://www.hackerfactor.com/blog/index.php?/archives/895-Connecting-the-iDOTs.html", + ), + ".", + ), + }), +}; + +/** + * @param {PngNode} 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); +}; diff --git a/public/png/nodePath.js b/public/png/nodePath.js new file mode 100644 index 0000000..54fdbd6 --- /dev/null +++ b/public/png/nodePath.js @@ -0,0 +1,30 @@ +// @ts-check + +/** + * A node path is an array of indices that can traverse a Node tree. + * + * For example, given the following tree: + * + * ```javascript + * { + * name: "top", + * children: [ + * { name: "first" }, + * { name: "second", children: [{ name: "grandchild" }] }, + * ], + * } + * ``` + * + * `[]` is "top", `[0]` is "first", `[1]` is "second", and `[1, 0]` is "grandchild". + * + * @typedef {number[]} NodePath + */ + +/** + * @param {unknown} value + * @returns {null | NodePath} The parsed node path, or null if invalid. + */ +export const parse = (value) => { + if (typeof value !== "string") return null; + return value.split(".").map(Number); +}; diff --git a/public/png/parsePngUint.js b/public/png/parsePngUint.js new file mode 100644 index 0000000..16c535a --- /dev/null +++ b/public/png/parsePngUint.js @@ -0,0 +1,8 @@ +// @ts-check + +/** + * @param {Uint8Array} bytes + * @returns {number} + */ +export default (bytes) => + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0); diff --git a/public/png/png.css b/public/png/png.css index 7d01679..9af152f 100644 --- a/public/png/png.css +++ b/public/png/png.css @@ -1,8 +1,18 @@ +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; diff --git a/public/png/png.js b/public/png/png.js index 170efff..0397e8e 100644 --- a/public/png/png.js +++ b/public/png/png.js @@ -1,8 +1,10 @@ // @ts-check import crel from "../common/crel.js"; +import formatBytes from "../common/formatBytes.js"; import parsePng from "./parsePng.js"; import parseHash from "./parseHash.js"; +import getNodeUi from "./getNodeUi.js"; /** @typedef {import("./nodePath.js").NodePath} NodePath */ /** @typedef {import("../../types/png.d.ts").PngNode} PngNode */ @@ -26,10 +28,8 @@ class Explorer { const traverse = (node, path) => { const nodeBytesEl = crel("span", { "data-path": path }); - // TODO: Show a user-friendly title. const isRoot = path.length === 0; - const title = node.type; - const description = "TODO: Description"; + const { title, description } = getNodeUi(node); const nodeTreeEl = crel( "details", { "data-path": path, ...(isRoot ? { open: "open" } : {}) }, @@ -59,10 +59,7 @@ class Explorer { }); nodeTreeEl.append(treeChildrenEl); } else { - // TODO: Update this formatting - nodeBytesEl.innerHTML = [...node.bytes].map((b) => - b.toString(16).padStart(2, "0") - ).join(" "); + nodeBytesEl.innerHTML = formatBytes(node.bytes, 256); } return [nodeBytesEl, nodeTreeEl]; diff --git a/test/common/formatBytes.test.ts b/test/common/formatBytes.test.ts new file mode 100644 index 0000000..db1382a --- /dev/null +++ b/test/common/formatBytes.test.ts @@ -0,0 +1,17 @@ +import { assertEquals } from "assert"; +import { b } from "../helpers.ts"; +import formatBytes from "../../public/common/formatBytes.js"; + +Deno.test("returns the empty string for no bytes", () => { + assertEquals(formatBytes(b()), ""); +}); + +Deno.test("formats one byte", () => { + assertEquals(formatBytes(b(0)), "00"); + assertEquals(formatBytes(b(10)), "0a"); + assertEquals(formatBytes(b(255)), "ff"); +}); + +Deno.test("formats multiple bytes", () => { + assertEquals(formatBytes(b(0, 10, 255)), "00 0a ff"); +}); diff --git a/test/common/getOwn.test.ts b/test/common/getOwn.test.ts new file mode 100644 index 0000000..cc00e12 --- /dev/null +++ b/test/common/getOwn.test.ts @@ -0,0 +1,20 @@ +import { assertEquals } from "assert"; +import getOwn from "../../public/common/getOwn.js"; + +class Person { + constructor(public age: number) {} + getAge() { + return this.age; + } +} +const person = new Person(10); + +Deno.test('fetches "own" properties', () => { + assertEquals(getOwn(person, "age"), 10); +}); + +Deno.test("ignores inherited properties", () => { + assertEquals(getOwn(person, "getAge"), undefined); + // deno-lint-ignore no-explicit-any + assertEquals(getOwn(person, "hasOwnProperty" as any), undefined); +}); diff --git a/test/common/pluralize.test.ts b/test/common/pluralize.test.ts new file mode 100644 index 0000000..b608fc1 --- /dev/null +++ b/test/common/pluralize.test.ts @@ -0,0 +1,12 @@ +import { assertEquals } from "assert"; +import pluralize from "../../public/common/pluralize.js"; + +Deno.test("doesn't pluralize when count is 1", () => { + assertEquals(pluralize(1, "apple"), "1 apple"); +}); + +Deno.test("pluralizes when count is not 1", () => { + assertEquals(pluralize(-1, "apple"), "-1 apples"); + assertEquals(pluralize(0, "apple"), "0 apples"); + assertEquals(pluralize(2, "apple"), "2 apples"); +}); diff --git a/test/png/parsePngUint.test.ts b/test/png/parsePngUint.test.ts new file mode 100644 index 0000000..5479d9e --- /dev/null +++ b/test/png/parsePngUint.test.ts @@ -0,0 +1,8 @@ +import { assertEquals } from "assert"; +import { b } from "../helpers.ts"; +import parsePngUint from "../../public/png/parsePngUint.js"; + +Deno.test("parses PNG uints", () => { + assertEquals(parsePngUint(b(0, 0, 0, 0)), 0); + assertEquals(parsePngUint(b(0xfe, 0xdc, 0xba, 0x98)), 0xfedcba98); +});