import { inflateSync } from 'node:zlib' export interface DecodedPng { width: number height: number /** RGBA8888, row-major, length = width * height * 4 */ rgba: Buffer } function paeth(a: number, b: number, c: number): number { 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 } function unfilter(filter: number, cur: Buffer, prev: Buffer, bpp: number): void { const n = cur.length switch (filter) { case 0: break case 1: for (let i = 0; i < n; i++) cur[i] = (cur[i] + (i >= bpp ? cur[i - bpp] : 0)) & 0xff break case 2: for (let i = 0; i < n; i++) cur[i] = (cur[i] + prev[i]) & 0xff break case 3: for (let i = 0; i < n; i++) { const a = i >= bpp ? cur[i - bpp] : 0 cur[i] = (cur[i] + ((a + prev[i]) >> 1)) & 0xff } break case 4: for (let i = 0; i < n; i++) { const a = i >= bpp ? cur[i - bpp] : 0 const c = i >= bpp ? prev[i - bpp] : 0 cur[i] = (cur[i] + paeth(a, prev[i], c)) & 0xff } break default: throw new Error(`unknown PNG filter ${filter}`) } } /** * Minimal PNG decoder for 8-bit non-interlaced images (color type 6/RGBA, * 2/RGB, 0/grayscale). Enough to read Nitro spritesheet PNGs (RGBA8888). * Returns RGBA pixels. Throws on unsupported formats. */ export function decodePng(buf: Buffer): DecodedPng { if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) { throw new Error('not a PNG') } let off = 8 let width = 0 let height = 0 let bitDepth = 0 let colorType = 0 let interlace = 0 const idat: Buffer[] = [] while (off + 8 <= buf.length) { const len = buf.readUInt32BE(off) const type = buf.toString('ascii', off + 4, off + 8) const data = buf.subarray(off + 8, off + 8 + len) off += 12 + len // length(4) + type(4) + data + crc(4) if (type === 'IHDR') { width = data.readUInt32BE(0) height = data.readUInt32BE(4) bitDepth = data[8] colorType = data[9] interlace = data[12] } else if (type === 'IDAT') { idat.push(data) } else if (type === 'IEND') { break } } if (bitDepth !== 8) throw new Error(`unsupported PNG bit depth ${bitDepth}`) if (interlace !== 0) throw new Error('interlaced PNG not supported') const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : colorType === 0 ? 1 : -1 if (channels === -1) throw new Error(`unsupported PNG color type ${colorType}`) const raw = inflateSync(Buffer.concat(idat)) const stride = width * channels const planar = Buffer.alloc(height * stride) let prev = Buffer.alloc(stride) let rp = 0 for (let y = 0; y < height; y++) { const filter = raw[rp++] const cur = planar.subarray(y * stride, y * stride + stride) raw.copy(cur, 0, rp, rp + stride) rp += stride unfilter(filter, cur, prev, channels) prev = cur } if (channels === 4) return { width, height, rgba: planar } const rgba = Buffer.alloc(width * height * 4) for (let i = 0, j = 0; i < width * height; i++) { if (channels === 3) { rgba[j++] = planar[i * 3] rgba[j++] = planar[i * 3 + 1] rgba[j++] = planar[i * 3 + 2] } else { const g = planar[i] rgba[j++] = g rgba[j++] = g rgba[j++] = g } rgba[j++] = 255 } return { width, height, rgba } } /** Crop a w×h RGBA region at (x,y) out of a sheetW-wide RGBA buffer. */ export function cropRgba( rgba: Buffer, sheetW: number, x: number, y: number, w: number, h: number, ): Buffer { const out = Buffer.alloc(w * h * 4) for (let row = 0; row < h; row++) { const src = ((y + row) * sheetW + x) * 4 rgba.copy(out, row * w * 4, src, src + w * 4) } return out }