Files
EpicNext-Cms/src/lib/services/imager/png-decode.ts
T

129 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}