Files
EpicNext-Cms/src/lib/services/imager/png-decode.ts
T
Remco e85e4d74ea revert fb8e77bb68
revert style: clean up code with prettier and eslint
2026-07-12 21:02:03 +02:00

136 lines
3.8 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
}