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