152 lines
5.0 KiB
JavaScript
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];
|
|
}
|