|
| 1 | +/** |
| 2 | + * Minimal PNG encoder/decoder for visual regression baselines. |
| 3 | + * |
| 4 | + * Only the subset Pts' visual tests need: 8-bit RGBA, non-interlaced, single |
| 5 | + * IDAT stream. Kept dependency-free so baselines can be written and compared |
| 6 | + * in the plain node test project. |
| 7 | + */ |
| 8 | + |
| 9 | +import { deflateSync, inflateSync } from "node:zlib"; |
| 10 | + |
| 11 | +const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); |
| 12 | +const BYTES_PER_PIXEL = 4; |
| 13 | + |
| 14 | +export type RGBAImage = { |
| 15 | + width: number; |
| 16 | + height: number; |
| 17 | + pixels: Uint8ClampedArray; // width * height * 4, row-major, top-down |
| 18 | +}; |
| 19 | + |
| 20 | +const CRC_TABLE = (() => { |
| 21 | + const table = new Int32Array(256); |
| 22 | + for (let n = 0; n < 256; n++) { |
| 23 | + let c = n; |
| 24 | + for (let k = 0; k < 8; k++) { |
| 25 | + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; |
| 26 | + } |
| 27 | + table[n] = c; |
| 28 | + } |
| 29 | + return table; |
| 30 | +})(); |
| 31 | + |
| 32 | +function crc32(buf: Buffer): number { |
| 33 | + let c = -1; |
| 34 | + for (let i = 0; i < buf.length; i++) { |
| 35 | + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); |
| 36 | + } |
| 37 | + return (c ^ -1) >>> 0; |
| 38 | +} |
| 39 | + |
| 40 | +function chunk(type: string, data: Buffer): Buffer { |
| 41 | + const head = Buffer.alloc(8); |
| 42 | + head.writeUInt32BE(data.length, 0); |
| 43 | + head.write(type, 4, "ascii"); |
| 44 | + const crc = Buffer.alloc(4); |
| 45 | + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); |
| 46 | + return Buffer.concat([head, data, crc]); |
| 47 | +} |
| 48 | + |
| 49 | +/** |
| 50 | + * Encode an RGBA image as a PNG buffer. Rows use the Sub filter, which keeps |
| 51 | + * smooth horizontal gradients (ie, most colour plots) small. |
| 52 | + */ |
| 53 | +export function encodePNG(image: RGBAImage): Buffer { |
| 54 | + const { width, height, pixels } = image; |
| 55 | + const stride = width * BYTES_PER_PIXEL; |
| 56 | + const raw = Buffer.alloc(height * (stride + 1)); |
| 57 | + |
| 58 | + for (let y = 0; y < height; y++) { |
| 59 | + const src = y * stride; |
| 60 | + const dest = y * (stride + 1); |
| 61 | + raw[dest] = 1; // Sub filter |
| 62 | + for (let i = 0; i < stride; i++) { |
| 63 | + const left = i >= BYTES_PER_PIXEL ? pixels[src + i - BYTES_PER_PIXEL] : 0; |
| 64 | + raw[dest + 1 + i] = (pixels[src + i] - left) & 0xff; |
| 65 | + } |
| 66 | + } |
| 67 | + |
| 68 | + const ihdr = Buffer.alloc(13); |
| 69 | + ihdr.writeUInt32BE(width, 0); |
| 70 | + ihdr.writeUInt32BE(height, 4); |
| 71 | + ihdr[8] = 8; // bit depth |
| 72 | + ihdr[9] = 6; // colour type: RGBA |
| 73 | + ihdr[10] = 0; // compression |
| 74 | + ihdr[11] = 0; // filter |
| 75 | + ihdr[12] = 0; // interlace |
| 76 | + |
| 77 | + return Buffer.concat([ |
| 78 | + SIGNATURE, |
| 79 | + chunk("IHDR", ihdr), |
| 80 | + chunk("IDAT", deflateSync(raw, { level: 9 })), |
| 81 | + chunk("IEND", Buffer.alloc(0)), |
| 82 | + ]); |
| 83 | +} |
| 84 | + |
| 85 | +function paeth(a: number, b: number, c: number): number { |
| 86 | + const p = a + b - c; |
| 87 | + const pa = Math.abs(p - a); |
| 88 | + const pb = Math.abs(p - b); |
| 89 | + const pc = Math.abs(p - c); |
| 90 | + if (pa <= pb && pa <= pc) return a; |
| 91 | + return pb <= pc ? b : c; |
| 92 | +} |
| 93 | + |
| 94 | +/** |
| 95 | + * Decode a PNG buffer produced by [`encodePNG`](#link). |
| 96 | + */ |
| 97 | +export function decodePNG(buf: Buffer): RGBAImage { |
| 98 | + if (!buf.subarray(0, 8).equals(SIGNATURE)) { |
| 99 | + throw new Error("Not a PNG file"); |
| 100 | + } |
| 101 | + |
| 102 | + let width = 0; |
| 103 | + let height = 0; |
| 104 | + const idat: Buffer[] = []; |
| 105 | + |
| 106 | + let offset = 8; |
| 107 | + while (offset < buf.length) { |
| 108 | + const length = buf.readUInt32BE(offset); |
| 109 | + const type = buf.toString("ascii", offset + 4, offset + 8); |
| 110 | + const data = buf.subarray(offset + 8, offset + 8 + length); |
| 111 | + offset += 12 + length; |
| 112 | + |
| 113 | + if (type === "IHDR") { |
| 114 | + width = data.readUInt32BE(0); |
| 115 | + height = data.readUInt32BE(4); |
| 116 | + if (data[8] !== 8 || data[9] !== 6 || data[12] !== 0) { |
| 117 | + throw new Error("Only 8-bit non-interlaced RGBA PNGs are supported"); |
| 118 | + } |
| 119 | + } else if (type === "IDAT") { |
| 120 | + idat.push(data); |
| 121 | + } else if (type === "IEND") { |
| 122 | + break; |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + const raw = inflateSync(Buffer.concat(idat)); |
| 127 | + const stride = width * BYTES_PER_PIXEL; |
| 128 | + const pixels = new Uint8ClampedArray(height * stride); |
| 129 | + |
| 130 | + for (let y = 0; y < height; y++) { |
| 131 | + const filter = raw[y * (stride + 1)]; |
| 132 | + const src = y * (stride + 1) + 1; |
| 133 | + const dest = y * stride; |
| 134 | + for (let i = 0; i < stride; i++) { |
| 135 | + const a = i >= BYTES_PER_PIXEL ? pixels[dest + i - BYTES_PER_PIXEL] : 0; |
| 136 | + const b = y > 0 ? pixels[dest + i - stride] : 0; |
| 137 | + const c = |
| 138 | + y > 0 && i >= BYTES_PER_PIXEL |
| 139 | + ? pixels[dest + i - stride - BYTES_PER_PIXEL] |
| 140 | + : 0; |
| 141 | + const x = raw[src + i]; |
| 142 | + let value = x; |
| 143 | + if (filter === 1) value = x + a; |
| 144 | + else if (filter === 2) value = x + b; |
| 145 | + else if (filter === 3) value = x + ((a + b) >> 1); |
| 146 | + else if (filter === 4) value = x + paeth(a, b, c); |
| 147 | + else if (filter !== 0) throw new Error(`Unknown PNG filter ${filter}`); |
| 148 | + pixels[dest + i] = value & 0xff; |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + return { width, height, pixels }; |
| 153 | +} |
0 commit comments