|
| 1 | +import zlib from 'node:zlib'; |
| 2 | +import { predictByte } from './png-predictor.ts'; |
| 3 | +import { PNG } from './png.ts'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Test-only PNG writer. `pngjs` picks one filter for a whole image and cannot express the |
| 7 | + * per-row mixtures, palettes, transparency, and header variations the crop paths have to |
| 8 | + * classify, so fixtures assemble the chunks directly. Every fixture is read back by `pngjs` |
| 9 | + * in the test that uses it, which is what proves this writer is honest. |
| 10 | + */ |
| 11 | + |
| 12 | +const SIGNATURE = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); |
| 13 | +const FILTER_CYCLE: readonly number[] = [0, 1, 2, 3, 4]; |
| 14 | +const IHDR_COLOR_TYPE_BYTE = 25; |
| 15 | +const IHDR_BIT_DEPTH_BYTE = 24; |
| 16 | + |
| 17 | +export type PngFixture = Readonly<{ |
| 18 | + pixels: Uint8Array; |
| 19 | + width: number; |
| 20 | + height: number; |
| 21 | + channels: number; |
| 22 | + colorType: number; |
| 23 | + bitDepth?: number; |
| 24 | + interlace?: number; |
| 25 | + compressionMethod?: number; |
| 26 | + filterMethod?: number; |
| 27 | + filterFor?: (row: number) => number; |
| 28 | + palette?: Uint8Array; |
| 29 | + transparency?: Uint8Array; |
| 30 | + ancillary?: Readonly<{ type: string; data: Uint8Array }>; |
| 31 | +}>; |
| 32 | + |
| 33 | +export function encodeFixturePng(fixture: PngFixture): Buffer { |
| 34 | + const { height, width } = fixture; |
| 35 | + const header = Buffer.alloc(13); |
| 36 | + header.writeUInt32BE(width, 0); |
| 37 | + header.writeUInt32BE(height, 4); |
| 38 | + header[8] = fixture.bitDepth ?? 8; |
| 39 | + header[9] = fixture.colorType; |
| 40 | + header[10] = fixture.compressionMethod ?? 0; |
| 41 | + header[11] = fixture.filterMethod ?? 0; |
| 42 | + header[12] = fixture.interlace ?? 0; |
| 43 | + const chunks: Uint8Array[] = [SIGNATURE, pngChunk('IHDR', header)]; |
| 44 | + if (fixture.ancillary) chunks.push(pngChunk(fixture.ancillary.type, fixture.ancillary.data)); |
| 45 | + if (fixture.palette) chunks.push(pngChunk('PLTE', fixture.palette)); |
| 46 | + if (fixture.transparency) chunks.push(pngChunk('tRNS', fixture.transparency)); |
| 47 | + chunks.push(pngChunk('IDAT', zlib.deflateSync(filterScanlines(fixture), { level: 6 }))); |
| 48 | + chunks.push(pngChunk('IEND', new Uint8Array(0))); |
| 49 | + return Buffer.concat(chunks); |
| 50 | +} |
| 51 | + |
| 52 | +/** A pixel grid where neighbouring pixels differ, so a wrong row or column cannot hide. */ |
| 53 | +export function rampPixels( |
| 54 | + width: number, |
| 55 | + height: number, |
| 56 | + channels: number, |
| 57 | + alpha: (x: number, y: number) => number = () => 255, |
| 58 | +): Uint8Array { |
| 59 | + const pixels = new Uint8Array(width * height * channels); |
| 60 | + for (let y = 0; y < height; y += 1) { |
| 61 | + for (let x = 0; x < width; x += 1) { |
| 62 | + const offset = (y * width + x) * channels; |
| 63 | + pixels[offset] = (x * 7 + y * 3) & 0xff; |
| 64 | + if (channels >= 3) { |
| 65 | + pixels[offset + 1] = (x * 11 + y * 5) & 0xff; |
| 66 | + pixels[offset + 2] = (x * 13 + y * 17) & 0xff; |
| 67 | + } |
| 68 | + if (channels === 2) pixels[offset + 1] = alpha(x, y) & 0xff; |
| 69 | + if (channels === 4) pixels[offset + 3] = alpha(x, y) & 0xff; |
| 70 | + } |
| 71 | + } |
| 72 | + return pixels; |
| 73 | +} |
| 74 | + |
| 75 | +/** Reads any encoded PNG back as RGBA through `pngjs`, with the layout bytes it declares. */ |
| 76 | +export function readPngForTest(buffer: Buffer): { |
| 77 | + width: number; |
| 78 | + height: number; |
| 79 | + colorType: number; |
| 80 | + bitDepth: number; |
| 81 | + rgba: Uint8Array; |
| 82 | +} { |
| 83 | + const png = PNG.sync.read(buffer); |
| 84 | + return { |
| 85 | + width: png.width, |
| 86 | + height: png.height, |
| 87 | + colorType: buffer.readUInt8(IHDR_COLOR_TYPE_BYTE), |
| 88 | + bitDepth: buffer.readUInt8(IHDR_BIT_DEPTH_BYTE), |
| 89 | + rgba: png.data, |
| 90 | + }; |
| 91 | +} |
| 92 | + |
| 93 | +function filterScanlines(fixture: PngFixture): Uint8Array { |
| 94 | + const { channels, height, width } = fixture; |
| 95 | + const stride = width * channels; |
| 96 | + const scanlines = new Uint8Array(height * (stride + 1)); |
| 97 | + for (let row = 0; row < height; row += 1) { |
| 98 | + const data = row * (stride + 1) + 1; |
| 99 | + const filter = fixture.filterFor?.(row) ?? FILTER_CYCLE[row % FILTER_CYCLE.length]!; |
| 100 | + scanlines[row * (stride + 1)] = filter; |
| 101 | + const from = row * stride; |
| 102 | + for (let offset = 0; offset < stride; offset += 1) { |
| 103 | + scanlines[data + offset] = |
| 104 | + (fixture.pixels[from + offset]! - |
| 105 | + predictByte( |
| 106 | + filter, |
| 107 | + offset >= channels ? fixture.pixels[from + offset - channels]! : 0, |
| 108 | + row > 0 ? fixture.pixels[from - stride + offset]! : 0, |
| 109 | + row > 0 && offset >= channels ? fixture.pixels[from - stride + offset - channels]! : 0, |
| 110 | + )) & |
| 111 | + 0xff; |
| 112 | + } |
| 113 | + } |
| 114 | + return scanlines; |
| 115 | +} |
| 116 | + |
| 117 | +function pngChunk(type: string, data: Uint8Array): Buffer { |
| 118 | + const chunk = Buffer.alloc(12 + data.length); |
| 119 | + chunk.writeUInt32BE(data.length, 0); |
| 120 | + chunk.write(type, 4, 'ascii'); |
| 121 | + chunk.set(data, 8); |
| 122 | + chunk.writeUInt32BE(zlib.crc32(chunk.subarray(4, 8 + data.length)) >>> 0, 8 + data.length); |
| 123 | + return chunk; |
| 124 | +} |
| 125 | + |
| 126 | +/** Flips a byte of the named chunk's checksum, so a reader that verifies checksums must notice. */ |
| 127 | +export function corruptChunkChecksum(buffer: Buffer, type: string): Buffer { |
| 128 | + const corrupted = Buffer.from(buffer); |
| 129 | + let offset = 8; |
| 130 | + while (offset + 12 <= corrupted.length) { |
| 131 | + const length = corrupted.readUInt32BE(offset); |
| 132 | + if (corrupted.toString('ascii', offset + 4, offset + 8) === type) { |
| 133 | + const checksum = offset + 8 + length; |
| 134 | + corrupted[checksum] = (corrupted[checksum] ?? 0) ^ 0xff; |
| 135 | + return corrupted; |
| 136 | + } |
| 137 | + offset += 12 + length; |
| 138 | + } |
| 139 | + throw new Error(`fixture PNG carries no ${type} chunk to corrupt`); |
| 140 | +} |
0 commit comments