fertig-classic-games/tools/lib/png.js

152 lines
5.0 KiB
JavaScript

// 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];
}