Files
Iisyourdad b7e64c79b4 Add imaging/document primitives: PNG codec, rasterizer, GIF, PDF
- Pure-JS PNG decode (grey/RGB/palette/alpha) + RGBA encode, CRC-checked;
  decode verified byte-identical with ImageMagick
- Software rasterizer: shapes, arrows, blur, highlight, magnify, tooltip,
  number badges, cursor, bitmap text (vendored public-domain font8x8),
  crop/resize, focused-view rendering
- GIF89a encoder with LZW (cross-validated pixel-for-pixel against
  ImageMagick decode) + NETSCAPE looping
- Minimal PDF 1.4 writer: pages, fonts, rects, images, outlines, valid
  xref; rendering validated under Ghostscript
- 12 imaging workflow tests (35 total)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:48:57 -05:00

161 lines
5.7 KiB
JavaScript

'use strict';
const zlib = require('node:zlib');
const { crc32 } = require('./zip');
/**
* Pure-JS PNG codec. Decodes 8-bit greyscale/RGB/palette/grey+alpha/RGBA
* (non-interlaced) into RGBA; encodes RGBA. Enough for screenshots and
* export rasterization without native dependencies.
*/
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const MAX_DIM = 32768;
function decodePng(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 57 || !buffer.subarray(0, 8).equals(SIGNATURE)) {
throw new Error('png: bad signature');
}
let pos = 8;
let width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0;
let palette = null, trns = null;
const idat = [];
while (pos + 8 <= buffer.length) {
const len = buffer.readUInt32BE(pos);
const type = buffer.toString('latin1', pos + 4, pos + 8);
const dataStart = pos + 8;
if (len > buffer.length - dataStart) throw new Error('png: truncated chunk');
const data = buffer.subarray(dataStart, dataStart + len);
const expectCrc = buffer.readUInt32BE(dataStart + len);
if (crc32(buffer.subarray(pos + 4, dataStart + len)) !== expectCrc) {
throw new Error(`png: CRC mismatch in ${type}`);
}
if (type === 'IHDR') {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
bitDepth = data[8];
colorType = data[9];
interlace = data[12];
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
throw new Error('png: unreasonable dimensions');
}
if (bitDepth !== 8) throw new Error(`png: unsupported bit depth ${bitDepth}`);
if (![0, 2, 3, 4, 6].includes(colorType)) throw new Error(`png: bad color type ${colorType}`);
if (interlace !== 0) throw new Error('png: interlaced images not supported');
} else if (type === 'PLTE') {
palette = data;
} else if (type === 'tRNS') {
trns = data;
} else if (type === 'IDAT') {
idat.push(data);
} else if (type === 'IEND') {
break;
}
pos = dataStart + len + 4;
}
if (!width) throw new Error('png: missing IHDR');
if (idat.length === 0) throw new Error('png: missing IDAT');
const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType];
const bpp = channels; // bytes per pixel at 8-bit depth
const stride = width * bpp;
const raw = zlib.inflateSync(Buffer.concat(idat));
if (raw.length < (stride + 1) * height) throw new Error('png: scanline data too short');
// Unfilter
const lines = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const src = (stride + 1) * y + 1;
const dst = stride * y;
for (let x = 0; x < stride; x++) {
const rawByte = raw[src + x];
const left = x >= bpp ? lines[dst + x - bpp] : 0;
const up = y > 0 ? lines[dst + x - stride] : 0;
const upLeft = y > 0 && x >= bpp ? lines[dst + x - stride - bpp] : 0;
let val;
switch (filter) {
case 0: val = rawByte; break;
case 1: val = rawByte + left; break;
case 2: val = rawByte + up; break;
case 3: val = rawByte + ((left + up) >> 1); break;
case 4: {
const p = left + up - upLeft;
const pa = Math.abs(p - left), pb = Math.abs(p - up), pc = Math.abs(p - upLeft);
val = rawByte + (pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft);
break;
}
default: throw new Error(`png: bad filter ${filter}`);
}
lines[dst + x] = val & 0xff;
}
}
// Expand to RGBA
const out = Buffer.alloc(width * height * 4);
for (let i = 0, p = 0; i < width * height; i++, p += 4) {
const s = i * bpp;
switch (colorType) {
case 0:
out[p] = out[p + 1] = out[p + 2] = lines[s];
out[p + 3] = 255;
break;
case 2:
out[p] = lines[s]; out[p + 1] = lines[s + 1]; out[p + 2] = lines[s + 2];
out[p + 3] = 255;
break;
case 3: {
const idx = lines[s];
if (!palette || idx * 3 + 2 >= palette.length) throw new Error('png: palette index out of range');
out[p] = palette[idx * 3]; out[p + 1] = palette[idx * 3 + 1]; out[p + 2] = palette[idx * 3 + 2];
out[p + 3] = trns && idx < trns.length ? trns[idx] : 255;
break;
}
case 4:
out[p] = out[p + 1] = out[p + 2] = lines[s];
out[p + 3] = lines[s + 1];
break;
case 6:
out[p] = lines[s]; out[p + 1] = lines[s + 1]; out[p + 2] = lines[s + 2];
out[p + 3] = lines[s + 3];
break;
}
}
return { width, height, data: out };
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const typeBuf = Buffer.from(type, 'latin1');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])));
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
/** Encode an RGBA image { width, height, data } to PNG bytes. */
function encodePng(img) {
const { width, height, data } = img;
if (!width || !height || data.length !== width * height * 4) {
throw new Error('png: encode expects RGBA data of width*height*4');
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // RGBA
// compression/filter/interlace = 0
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0; // filter: none
data.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
}
const idat = zlib.deflateSync(raw, { level: 6 });
return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))]);
}
module.exports = { decodePng, encodePng };