150 lines
3.7 KiB
TypeScript
150 lines
3.7 KiB
TypeScript
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;
|
||
}
|