PNG: UI partially works; usable, if not missing features
This commit is contained in:
parent
fbdf529257
commit
ed8a1c3b63
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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;
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -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}
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
|
};
|
|
@ -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);
|
||||||
|
};
|
|
@ -0,0 +1,8 @@
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export default (bytes) =>
|
||||||
|
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0);
|
|
@ -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;
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
|
@ -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");
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
Loading…
Reference in New Issue