// The screenshot reader, tested by building PNGs whose pixels are already known. // // This matters more than it looks. If a filter is decoded wrong the pixels are still // plausible colours — slightly off greys and greens, not obvious garbage — and the // contrast audit would report confident, wrong numbers about a page nobody would think // to re-check. So every filter type gets encoded here and read back. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const zlib = require("node:zlib"); const { decode, pixelAt } = require("./png"); const CRC_TABLE = (() => { const table = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c; } return table; })(); function crc32(buf) { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; } function chunk(type, data) { const head = Buffer.alloc(8); head.writeUInt32BE(data.length, 0); head.write(type, 4, "ascii"); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, "ascii"), data])), 0); return Buffer.concat([head, data, crc]); } 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; } // pixels: [y][x] = [r,g,b(,a)]. filter is applied to every row, which is legal PNG and // is exactly how each decoder branch gets exercised. function encode(pixels, filter = 0, channels = 4) { const height = pixels.length, width = pixels[0].length; const stride = width * channels; const rows = []; let prior = Buffer.alloc(stride); for (let y = 0; y < height; y++) { const raw = Buffer.alloc(stride); for (let x = 0; x < width; x++) { for (let c = 0; c < channels; c++) { raw[x * channels + c] = pixels[y][x][c] !== undefined ? pixels[y][x][c] : 255; } } const filtered = Buffer.alloc(stride); for (let i = 0; i < stride; i++) { const left = i >= channels ? raw[i - channels] : 0; const up = prior[i]; const upLeft = i >= channels ? prior[i - channels] : 0; switch (filter) { case 0: filtered[i] = raw[i]; break; case 1: filtered[i] = (raw[i] - left) & 0xff; break; case 2: filtered[i] = (raw[i] - up) & 0xff; break; case 3: filtered[i] = (raw[i] - ((left + up) >> 1)) & 0xff; break; case 4: filtered[i] = (raw[i] - paeth(left, up, upLeft)) & 0xff; break; default: throw new Error("bad filter"); } } rows.push(Buffer.concat([Buffer.from([filter]), filtered])); prior = raw; } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = channels === 4 ? 6 : 2; return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0)), ]); } // Deliberately awkward: neighbouring values that differ, so a filter decoded with the // wrong neighbour produces a different answer rather than the same one by luck. const PIXELS = [ [[20, 30, 40], [200, 10, 90], [18, 18, 18], [255, 255, 255]], [[0, 0, 0], [28, 108, 60], [108, 68, 39], [1, 2, 3]], [[240, 217, 168], [47, 122, 77], [200, 200, 200], [17, 17, 17]], ]; describe("every filter type reads back exactly", () => { [0, 1, 2, 3, 4].forEach(filter => { test(`filter ${filter}`, () => { const image = decode(encode(PIXELS, filter)); assert.equal(image.width, 4); assert.equal(image.height, 3); for (let y = 0; y < PIXELS.length; y++) { for (let x = 0; x < PIXELS[y].length; x++) { assert.deepEqual(pixelAt(image, x, y), PIXELS[y][x], `pixel ${x},${y} under filter ${filter}`); } } }); }); test("and a three-channel image reads back too", () => { const image = decode(encode(PIXELS, 4, 3)); assert.deepEqual(pixelAt(image, 1, 2), [47, 122, 77]); }); test("including where Paeth has to choose between close neighbours", () => { // Written first to catch paeth(left, up, ...) decoded as paeth(up, left, ...), // on the assumption that the two differ on a tie. They don't, anywhere: the // predictor is symmetric in its first two arguments, checked over all 16,777,216 // byte triples. The reason is that a tie between pa and pb requires // left + up == 2 * upLeft, and that is exactly the condition making pc zero — so // every tie is resolved by returning upLeft, whichever order the other two // arrived in. Swapping them is a no-op and no test can catch it, which is worth // knowing rather than papering over with a fixture that appears to. // // What this does catch is the mistake that is actually available: reading the // wrong neighbour. The values sit close together so a predictor that reaches one // pixel off produces a different answer rather than the same one by luck. const CLOSE = [ [[100, 10, 20], [110, 40, 25]], [[90, 70, 30], [55, 95, 35]], ]; const image = decode(encode(CLOSE, 4)); assert.deepEqual(pixelAt(image, 1, 1), [55, 95, 35]); assert.deepEqual(pixelAt(image, 0, 1), [90, 70, 30]); assert.deepEqual(pixelAt(image, 1, 0), [110, 40, 25]); }); }); describe("sampling", () => { const image = decode(encode(PIXELS, 0)); test("rounds to the nearest pixel", () => { assert.deepEqual(pixelAt(image, 0.4, 0.4), [20, 30, 40]); assert.deepEqual(pixelAt(image, 0.6, 0.4), [200, 10, 90]); }); test("clamps rather than throwing or reading black", () => { // A rect can sit a hair outside the shot once it's been rounded, and a silent // black pixel there would read as a contrast failure that isn't one. assert.deepEqual(pixelAt(image, -5, -5), [20, 30, 40]); assert.deepEqual(pixelAt(image, 999, 999), [17, 17, 17]); }); }); describe("what it refuses", () => { test("something that isn't a PNG at all", () => { assert.throws(() => decode(Buffer.from("not a png at all, honestly")), /not a PNG/); }); test("an interlaced one, rather than returning scrambled pixels", () => { const png = encode(PIXELS, 0); // Byte 12 of the IHDR data: 8 signature + 8 chunk header + 12 = interlace flag. const broken = Buffer.from(png); broken[8 + 8 + 12] = 1; assert.throws(() => decode(broken), /interlaced/); }); test("a colour type it cannot read", () => { const broken = Buffer.from(encode(PIXELS, 0)); broken[8 + 8 + 9] = 3; // palette assert.throws(() => decode(broken), /colour type/); }); });