// A minimal PNG reader, built on node:zlib. // // This repository has no dependencies and is not about to grow one for a // one-off job, so decoding is done here: signature, chunks, inflate, unfilter. // Enough of the format to read the NES track maps that tools/readExcitebikeMaps.js // transcribes — greyscale, truecolour, palette and alpha variants at bit // depths 1 through 16, non-interlaced. import { inflateSync } from 'node:zlib'; const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }; function paeth(a, b, c) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); if (pa <= pb && pa <= pc) return a; if (pb <= pc) return b; return c; } /** Undo the per-scanline filters PNG applies before compression. */ function unfilter(raw, width, height, bpp, bytesPerRow) { const out = Buffer.alloc(height * bytesPerRow); let pos = 0; for (let y = 0; y < height; y += 1) { const filter = raw[pos]; pos += 1; const row = y * bytesPerRow; const prev = row - bytesPerRow; for (let i = 0; i < bytesPerRow; i += 1) { const x = raw[pos + i]; const a = i >= bpp ? out[row + i - bpp] : 0; const b = y > 0 ? out[prev + i] : 0; const c = i >= bpp && y > 0 ? out[prev + i - bpp] : 0; let value; switch (filter) { case 0: value = x; break; case 1: value = x + a; break; case 2: value = x + b; break; case 3: value = x + ((a + b) >> 1); break; case 4: value = x + paeth(a, b, c); break; default: throw new Error(`unknown PNG filter ${filter} on row ${y}`); } out[row + i] = value & 0xff; } pos += bytesPerRow; } return out; } /** Pull `bitDepth`-wide samples out of a packed scanline. */ function sampleAt(row, index, bitDepth) { if (bitDepth === 8) return row[index]; if (bitDepth === 16) return row[index * 2]; const perByte = 8 / bitDepth; const byte = row[Math.floor(index / perByte)]; const shift = 8 - bitDepth * ((index % perByte) + 1); return (byte >> shift) & ((1 << bitDepth) - 1); } /** * Decode a PNG buffer to `{ width, height, data }`, where `data` is flat RGBA. * Interlaced files are rejected rather than silently mis-read. */ export function decodePng(buffer) { if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error('not a PNG'); let width = 0; let height = 0; let bitDepth = 8; let colorType = 6; let palette = null; let transparency = null; const idat = []; let offset = 8; while (offset < buffer.length) { const length = buffer.readUInt32BE(offset); const type = buffer.toString('ascii', offset + 4, offset + 8); const body = buffer.subarray(offset + 8, offset + 8 + length); offset += 12 + length; if (type === 'IHDR') { width = body.readUInt32BE(0); height = body.readUInt32BE(4); bitDepth = body[8]; colorType = body[9]; if (body[12] !== 0) throw new Error('interlaced PNGs are not supported'); } else if (type === 'PLTE') { palette = body; } else if (type === 'tRNS') { transparency = body; } else if (type === 'IDAT') { idat.push(body); } else if (type === 'IEND') { break; } } const channels = CHANNELS[colorType]; if (!channels) throw new Error(`unsupported PNG colour type ${colorType}`); const bitsPerPixel = channels * bitDepth; const bytesPerRow = Math.ceil((width * bitsPerPixel) / 8); const bpp = Math.max(1, Math.ceil(bitsPerPixel / 8)); const raw = inflateSync(Buffer.concat(idat)); const rows = unfilter(raw, width, height, bpp, bytesPerRow); const data = new Uint8ClampedArray(width * height * 4); const max = (1 << bitDepth) - 1; for (let y = 0; y < height; y += 1) { const row = rows.subarray(y * bytesPerRow, (y + 1) * bytesPerRow); for (let x = 0; x < width; x += 1) { const o = (y * width + x) * 4; let r; let g; let b; let a = 255; if (colorType === 3) { const idx = sampleAt(row, x, bitDepth); r = palette[idx * 3]; g = palette[idx * 3 + 1]; b = palette[idx * 3 + 2]; if (transparency && idx < transparency.length) a = transparency[idx]; } else if (colorType === 0 || colorType === 4) { const v = sampleAt(row, x * channels, bitDepth); r = bitDepth === 8 || bitDepth === 16 ? v : Math.round((v / max) * 255); g = r; b = r; if (colorType === 4) a = sampleAt(row, x * channels + 1, bitDepth); } else { r = sampleAt(row, x * channels, bitDepth); g = sampleAt(row, x * channels + 1, bitDepth); b = sampleAt(row, x * channels + 2, bitDepth); if (colorType === 6) a = sampleAt(row, x * channels + 3, bitDepth); } data[o] = r; data[o + 1] = g; data[o + 2] = b; data[o + 3] = a; } } return { width, height, data }; } /** 0xRRGGBB at (x, y), ignoring alpha. */ export function pixelAt(img, x, y) { const o = (y * img.width + x) * 4; return (img.data[o] << 16) | (img.data[o + 1] << 8) | img.data[o + 2]; }