PNG: UI partially works; usable, if not missing features

This commit is contained in:
Evan Hahn 2023-08-02 13:39:57 -05:00
parent fbdf529257
commit ed8a1c3b63
16 changed files with 893 additions and 12 deletions

View File

@ -6,10 +6,10 @@
* *
* @param {string} tagName * @param {string} tagName
* @param {object} [attributes={}] * @param {object} [attributes={}]
* @param {...(string | Node)} children * @param {(string | Node)[]} children
* @returns {HTMLElement} * @returns {HTMLElement}
*/ */
export default function crel(tagName, attributes = {}, ...children) { export default (tagName, attributes = {}, ...children) => {
const el = document.createElement(tagName); const el = document.createElement(tagName);
for (const [key, value] of Object.entries(attributes)) { for (const [key, value] of Object.entries(attributes)) {
@ -19,4 +19,16 @@ export default function crel(tagName, attributes = {}, ...children) {
el.append(...children); el.append(...children);
return el; 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;
};

View File

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

24
public/common/getOwn.js Normal file
View File

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

View File

@ -1,3 +1,5 @@
@import url("vendor/reset.css");
:root { :root {
--foreground-color: #111; --foreground-color: #111;
--background-color: #fff; --background-color: #fff;
@ -23,7 +25,7 @@
box-sizing: border-box; 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-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 18pt; font-size: 18pt;
line-height: 1.5em; line-height: 1.5em;
@ -31,10 +33,33 @@ html {
background: var(--background-color); background: var(--background-color);
} }
h1,
p {
margin-bottom: 1rem;
}
h1 {
font-weight: bold;
font-size: 200%;
}
a { a {
color: inherit; color: inherit;
} }
ul,
ol {
padding-left: 1rem;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
input, input,
textarea { textarea {
font: inherit; font: inherit;

View File

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

1
public/common/vendor/reset.css vendored Normal file
View File

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

View File

@ -15,6 +15,7 @@ const inputEl = /** @type {HTMLInputElement} */ (
accept, accept,
}) })
); );
const inputContainerEl = crel("p", {}, inputEl);
const supportedFileTypeNameString = SUPPORTED_FILE_TYPES const supportedFileTypeNameString = SUPPORTED_FILE_TYPES
.map((t, index, array) => ( .map((t, index, array) => (
@ -75,7 +76,7 @@ const main = () => {
}); });
}); });
appEl.append(labelParagraphEl, inputEl, disclaimerParagraphEl); appEl.append(labelParagraphEl, inputContainerEl, disclaimerParagraphEl);
}; };
main(); main();

670
public/png/getNodeUi.js Normal file
View File

@ -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<string, (node: PngNode) => 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);
};

30
public/png/nodePath.js Normal file
View File

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

View File

@ -0,0 +1,8 @@
// @ts-check
/**
* @param {Uint8Array} bytes
* @returns {number}
*/
export default (bytes) =>
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);

View File

@ -1,8 +1,18 @@
body {
margin: 1rem;
}
#explorer { #explorer {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
} }
#explorer .bytes,
#explorer .tree {
flex: 1;
overflow: auto;
}
#explorer .bytes { #explorer .bytes {
max-width: 15em; max-width: 15em;
font-family: Inconsolata, Consolas, Monaco, monospace; font-family: Inconsolata, Consolas, Monaco, monospace;

View File

@ -1,8 +1,10 @@
// @ts-check // @ts-check
import crel from "../common/crel.js"; import crel from "../common/crel.js";
import formatBytes from "../common/formatBytes.js";
import parsePng from "./parsePng.js"; import parsePng from "./parsePng.js";
import parseHash from "./parseHash.js"; import parseHash from "./parseHash.js";
import getNodeUi from "./getNodeUi.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 */
@ -26,10 +28,8 @@ class Explorer {
const traverse = (node, path) => { const traverse = (node, path) => {
const nodeBytesEl = crel("span", { "data-path": path }); const nodeBytesEl = crel("span", { "data-path": path });
// TODO: Show a user-friendly title.
const isRoot = path.length === 0; const isRoot = path.length === 0;
const title = node.type; const { title, description } = getNodeUi(node);
const description = "TODO: Description";
const nodeTreeEl = crel( const nodeTreeEl = crel(
"details", "details",
{ "data-path": path, ...(isRoot ? { open: "open" } : {}) }, { "data-path": path, ...(isRoot ? { open: "open" } : {}) },
@ -59,10 +59,7 @@ class Explorer {
}); });
nodeTreeEl.append(treeChildrenEl); nodeTreeEl.append(treeChildrenEl);
} else { } else {
// TODO: Update this formatting nodeBytesEl.innerHTML = formatBytes(node.bytes, 256);
nodeBytesEl.innerHTML = [...node.bytes].map((b) =>
b.toString(16).padStart(2, "0")
).join(" ");
} }
return [nodeBytesEl, nodeTreeEl]; return [nodeBytesEl, nodeTreeEl];

View File

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

View File

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

View File

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

View File

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