// Just enough PNG to read pixels back out of a screenshot. // // The contrast audit needs to know what colour is actually behind a piece of text, and // the only honest answer is the one the browser painted. Walking the DOM and // compositing `background-color` up the ancestors gets it wrong wherever a gradient, // a shadow or a border is doing the painting: tried that first, and it reported the // gold dealer button as black-on-black at Lc 0.4 — a fault that does not exist, on an // element anyone can see is legible. Sampling the screenshot cannot make that mistake. // // zlib is in Node, so this needs no dependency. Screenshots are 8-bit and // non-interlaced; anything else throws rather than returning quietly wrong pixels. const zlib = require("node:zlib"); const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); function paeth(a, b, c) { const p = a + b - c; const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); if (pa <= pb && pa <= pc) return a; return pb <= pc ? b : c; } function decode(buffer) { if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG"); let width = 0, height = 0, depth = 0, colorType = 0, interlace = 0; const idat = []; let at = 8; while (at < buffer.length) { const length = buffer.readUInt32BE(at); const type = buffer.toString("ascii", at + 4, at + 8); const data = buffer.subarray(at + 8, at + 8 + length); at += 12 + length; if (type === "IHDR") { width = data.readUInt32BE(0); height = data.readUInt32BE(4); depth = data[8]; colorType = data[9]; interlace = data[12]; } else if (type === "IDAT") { idat.push(data); } else if (type === "IEND") { break; } } if (depth !== 8) throw new Error(`unsupported bit depth ${depth}`); if (interlace !== 0) throw new Error("interlaced PNG not supported"); if (colorType !== 2 && colorType !== 6) throw new Error(`unsupported colour type ${colorType}`); const channels = colorType === 6 ? 4 : 3; const raw = zlib.inflateSync(Buffer.concat(idat)); const stride = width * channels; const out = Buffer.alloc(width * height * 4); let prior = Buffer.alloc(stride); for (let y = 0; y < height; y++) { const filter = raw[y * (stride + 1)]; const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))); for (let i = 0; i < stride; i++) { const left = i >= channels ? line[i - channels] : 0; const up = prior[i]; const upLeft = i >= channels ? prior[i - channels] : 0; switch (filter) { case 0: break; case 1: line[i] = (line[i] + left) & 0xff; break; case 2: line[i] = (line[i] + up) & 0xff; break; case 3: line[i] = (line[i] + ((left + up) >> 1)) & 0xff; break; case 4: line[i] = (line[i] + paeth(left, up, upLeft)) & 0xff; break; default: throw new Error(`unknown filter ${filter} on row ${y}`); } } for (let x = 0; x < width; x++) { const from = x * channels; const to = (y * width + x) * 4; out[to] = line[from]; out[to + 1] = line[from + 1]; out[to + 2] = line[from + 2]; out[to + 3] = channels === 4 ? line[from + 3] : 255; } prior = line; } return { width, height, data: out }; } // Nearest pixel, clamped. A rect can sit a hair outside the shot after rounding, and // clamping is the right answer there rather than an exception or a black pixel. function pixelAt(image, x, y) { const px = Math.min(image.width - 1, Math.max(0, Math.round(x))); const py = Math.min(image.height - 1, Math.max(0, Math.round(y))); const at = (py * image.width + px) * 4; return [image.data[at], image.data[at + 1], image.data[at + 2]]; } module.exports = { decode, pixelAt };