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>
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 8x8 monochrome bitmap font (ASCII 0-127), public domain.
|
||||
* Generated from vendor/font8x8/font8x8_basic.h
|
||||
* (Daniel Hepper / Marcel Sondaar / IBM public-domain VGA fonts).
|
||||
* Each glyph is 8 bytes; bit n of byte y is pixel (x=n, y).
|
||||
*/
|
||||
|
||||
const FONT8X8 = [
|
||||
[0,0,0,0,0,0,0,0], // 0x0
|
||||
[0,0,0,0,0,0,0,0], // 0x1
|
||||
[0,0,0,0,0,0,0,0], // 0x2
|
||||
[0,0,0,0,0,0,0,0], // 0x3
|
||||
[0,0,0,0,0,0,0,0], // 0x4
|
||||
[0,0,0,0,0,0,0,0], // 0x5
|
||||
[0,0,0,0,0,0,0,0], // 0x6
|
||||
[0,0,0,0,0,0,0,0], // 0x7
|
||||
[0,0,0,0,0,0,0,0], // 0x8
|
||||
[0,0,0,0,0,0,0,0], // 0x9
|
||||
[0,0,0,0,0,0,0,0], // 0xa
|
||||
[0,0,0,0,0,0,0,0], // 0xb
|
||||
[0,0,0,0,0,0,0,0], // 0xc
|
||||
[0,0,0,0,0,0,0,0], // 0xd
|
||||
[0,0,0,0,0,0,0,0], // 0xe
|
||||
[0,0,0,0,0,0,0,0], // 0xf
|
||||
[0,0,0,0,0,0,0,0], // 0x10
|
||||
[0,0,0,0,0,0,0,0], // 0x11
|
||||
[0,0,0,0,0,0,0,0], // 0x12
|
||||
[0,0,0,0,0,0,0,0], // 0x13
|
||||
[0,0,0,0,0,0,0,0], // 0x14
|
||||
[0,0,0,0,0,0,0,0], // 0x15
|
||||
[0,0,0,0,0,0,0,0], // 0x16
|
||||
[0,0,0,0,0,0,0,0], // 0x17
|
||||
[0,0,0,0,0,0,0,0], // 0x18
|
||||
[0,0,0,0,0,0,0,0], // 0x19
|
||||
[0,0,0,0,0,0,0,0], // 0x1a
|
||||
[0,0,0,0,0,0,0,0], // 0x1b
|
||||
[0,0,0,0,0,0,0,0], // 0x1c
|
||||
[0,0,0,0,0,0,0,0], // 0x1d
|
||||
[0,0,0,0,0,0,0,0], // 0x1e
|
||||
[0,0,0,0,0,0,0,0], // 0x1f
|
||||
[0,0,0,0,0,0,0,0], // " "
|
||||
[24,60,60,24,24,0,24,0], // "!"
|
||||
[54,54,0,0,0,0,0,0], // "\""
|
||||
[54,54,127,54,127,54,54,0], // "#"
|
||||
[12,62,3,30,48,31,12,0], // "$"
|
||||
[0,99,51,24,12,102,99,0], // "%"
|
||||
[28,54,28,110,59,51,110,0], // "&"
|
||||
[6,6,3,0,0,0,0,0], // "'"
|
||||
[24,12,6,6,6,12,24,0], // "("
|
||||
[6,12,24,24,24,12,6,0], // ")"
|
||||
[0,102,60,255,60,102,0,0], // "*"
|
||||
[0,12,12,63,12,12,0,0], // "+"
|
||||
[0,0,0,0,0,12,12,6], // ","
|
||||
[0,0,0,63,0,0,0,0], // "-"
|
||||
[0,0,0,0,0,12,12,0], // "."
|
||||
[96,48,24,12,6,3,1,0], // "/"
|
||||
[62,99,115,123,111,103,62,0], // "0"
|
||||
[12,14,12,12,12,12,63,0], // "1"
|
||||
[30,51,48,28,6,51,63,0], // "2"
|
||||
[30,51,48,28,48,51,30,0], // "3"
|
||||
[56,60,54,51,127,48,120,0], // "4"
|
||||
[63,3,31,48,48,51,30,0], // "5"
|
||||
[28,6,3,31,51,51,30,0], // "6"
|
||||
[63,51,48,24,12,12,12,0], // "7"
|
||||
[30,51,51,30,51,51,30,0], // "8"
|
||||
[30,51,51,62,48,24,14,0], // "9"
|
||||
[0,12,12,0,0,12,12,0], // ":"
|
||||
[0,12,12,0,0,12,12,6], // ";"
|
||||
[24,12,6,3,6,12,24,0], // "<"
|
||||
[0,0,63,0,0,63,0,0], // "="
|
||||
[6,12,24,48,24,12,6,0], // ">"
|
||||
[30,51,48,24,12,0,12,0], // "?"
|
||||
[62,99,123,123,123,3,30,0], // "@"
|
||||
[12,30,51,51,63,51,51,0], // "A"
|
||||
[63,102,102,62,102,102,63,0], // "B"
|
||||
[60,102,3,3,3,102,60,0], // "C"
|
||||
[31,54,102,102,102,54,31,0], // "D"
|
||||
[127,70,22,30,22,70,127,0], // "E"
|
||||
[127,70,22,30,22,6,15,0], // "F"
|
||||
[60,102,3,3,115,102,124,0], // "G"
|
||||
[51,51,51,63,51,51,51,0], // "H"
|
||||
[30,12,12,12,12,12,30,0], // "I"
|
||||
[120,48,48,48,51,51,30,0], // "J"
|
||||
[103,102,54,30,54,102,103,0], // "K"
|
||||
[15,6,6,6,70,102,127,0], // "L"
|
||||
[99,119,127,127,107,99,99,0], // "M"
|
||||
[99,103,111,123,115,99,99,0], // "N"
|
||||
[28,54,99,99,99,54,28,0], // "O"
|
||||
[63,102,102,62,6,6,15,0], // "P"
|
||||
[30,51,51,51,59,30,56,0], // "Q"
|
||||
[63,102,102,62,54,102,103,0], // "R"
|
||||
[30,51,7,14,56,51,30,0], // "S"
|
||||
[63,45,12,12,12,12,30,0], // "T"
|
||||
[51,51,51,51,51,51,63,0], // "U"
|
||||
[51,51,51,51,51,30,12,0], // "V"
|
||||
[99,99,99,107,127,119,99,0], // "W"
|
||||
[99,99,54,28,28,54,99,0], // "X"
|
||||
[51,51,51,30,12,12,30,0], // "Y"
|
||||
[127,99,49,24,76,102,127,0], // "Z"
|
||||
[30,6,6,6,6,6,30,0], // "["
|
||||
[3,6,12,24,48,96,64,0], // "\\"
|
||||
[30,24,24,24,24,24,30,0], // "]"
|
||||
[8,28,54,99,0,0,0,0], // "^"
|
||||
[0,0,0,0,0,0,0,255], // "_"
|
||||
[12,12,24,0,0,0,0,0], // "`"
|
||||
[0,0,30,48,62,51,110,0], // "a"
|
||||
[7,6,6,62,102,102,59,0], // "b"
|
||||
[0,0,30,51,3,51,30,0], // "c"
|
||||
[56,48,48,62,51,51,110,0], // "d"
|
||||
[0,0,30,51,63,3,30,0], // "e"
|
||||
[28,54,6,15,6,6,15,0], // "f"
|
||||
[0,0,110,51,51,62,48,31], // "g"
|
||||
[7,6,54,110,102,102,103,0], // "h"
|
||||
[12,0,14,12,12,12,30,0], // "i"
|
||||
[48,0,48,48,48,51,51,30], // "j"
|
||||
[7,6,102,54,30,54,103,0], // "k"
|
||||
[14,12,12,12,12,12,30,0], // "l"
|
||||
[0,0,51,127,127,107,99,0], // "m"
|
||||
[0,0,31,51,51,51,51,0], // "n"
|
||||
[0,0,30,51,51,51,30,0], // "o"
|
||||
[0,0,59,102,102,62,6,15], // "p"
|
||||
[0,0,110,51,51,62,48,120], // "q"
|
||||
[0,0,59,110,102,6,15,0], // "r"
|
||||
[0,0,62,3,30,48,31,0], // "s"
|
||||
[8,12,62,12,12,44,24,0], // "t"
|
||||
[0,0,51,51,51,51,110,0], // "u"
|
||||
[0,0,51,51,51,30,12,0], // "v"
|
||||
[0,0,99,107,127,127,54,0], // "w"
|
||||
[0,0,99,54,28,54,99,0], // "x"
|
||||
[0,0,51,51,51,62,48,31], // "y"
|
||||
[0,0,63,25,12,38,63,0], // "z"
|
||||
[56,12,12,7,12,12,56,0], // "{"
|
||||
[24,24,24,0,24,24,24,0], // "|"
|
||||
[7,12,12,56,12,12,7,0], // "}"
|
||||
[110,59,0,0,0,0,0,0], // "~"
|
||||
[0,0,0,0,0,0,0,0] // 0x7f
|
||||
];
|
||||
|
||||
module.exports = { FONT8X8 };
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* GIF89a encoder (pure JS). Uses a fixed 6x7x6 RGB palette (252 colors),
|
||||
* full-frame LZW-compressed frames, and a NETSCAPE looping extension.
|
||||
* Good enough for screenshot slideshows; deterministic output.
|
||||
*/
|
||||
|
||||
const R_LEVELS = 6, G_LEVELS = 7, B_LEVELS = 6;
|
||||
|
||||
function buildPalette() {
|
||||
const palette = Buffer.alloc(256 * 3);
|
||||
let i = 0;
|
||||
for (let r = 0; r < R_LEVELS; r++) {
|
||||
for (let g = 0; g < G_LEVELS; g++) {
|
||||
for (let b = 0; b < B_LEVELS; b++) {
|
||||
palette[i * 3] = Math.round((r * 255) / (R_LEVELS - 1));
|
||||
palette[i * 3 + 1] = Math.round((g * 255) / (G_LEVELS - 1));
|
||||
palette[i * 3 + 2] = Math.round((b * 255) / (B_LEVELS - 1));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return palette; // remaining entries stay black
|
||||
}
|
||||
|
||||
const PALETTE = buildPalette();
|
||||
|
||||
function quantizeIndex(r, g, b) {
|
||||
const ri = Math.round((r / 255) * (R_LEVELS - 1));
|
||||
const gi = Math.round((g / 255) * (G_LEVELS - 1));
|
||||
const bi = Math.round((b / 255) * (B_LEVELS - 1));
|
||||
return ri * G_LEVELS * B_LEVELS + gi * B_LEVELS + bi;
|
||||
}
|
||||
|
||||
/** Map an RGBA image to palette indices. */
|
||||
function toIndices(img) {
|
||||
const n = img.width * img.height;
|
||||
const out = Buffer.alloc(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = i * 4;
|
||||
out[i] = quantizeIndex(img.data[p], img.data[p + 1], img.data[p + 2]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** GIF LZW compression of palette indices, minCodeSize 8. */
|
||||
function lzwEncode(indices) {
|
||||
const MIN_CODE = 8;
|
||||
const CLEAR = 1 << MIN_CODE; // 256
|
||||
const EOI = CLEAR + 1; // 257
|
||||
const MAX_CODE = 4096;
|
||||
|
||||
const bytes = [];
|
||||
let bitBuf = 0, bitCnt = 0;
|
||||
let codeSize = MIN_CODE + 1;
|
||||
const emit = (code) => {
|
||||
bitBuf |= code << bitCnt;
|
||||
bitCnt += codeSize;
|
||||
while (bitCnt >= 8) {
|
||||
bytes.push(bitBuf & 0xff);
|
||||
bitBuf >>>= 8;
|
||||
bitCnt -= 8;
|
||||
}
|
||||
};
|
||||
|
||||
let dict = new Map();
|
||||
let next = EOI + 1;
|
||||
const reset = () => { dict = new Map(); next = EOI + 1; codeSize = MIN_CODE + 1; };
|
||||
|
||||
emit(CLEAR);
|
||||
let prefix = -1;
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
const c = indices[i];
|
||||
if (prefix < 0) { prefix = c; continue; }
|
||||
const key = prefix * 256 + c;
|
||||
const found = dict.get(key);
|
||||
if (found !== undefined) {
|
||||
prefix = found;
|
||||
} else {
|
||||
emit(prefix);
|
||||
dict.set(key, next);
|
||||
next++;
|
||||
// The decoder builds its table one entry behind the encoder, so the
|
||||
// width change happens at (1<<codeSize)+1, not (1<<codeSize).
|
||||
if (next === (1 << codeSize) + 1 && codeSize < 12) codeSize++;
|
||||
if (next >= MAX_CODE) {
|
||||
emit(CLEAR);
|
||||
reset();
|
||||
}
|
||||
prefix = c;
|
||||
}
|
||||
}
|
||||
if (prefix >= 0) emit(prefix);
|
||||
emit(EOI);
|
||||
if (bitCnt > 0) bytes.push(bitBuf & 0xff);
|
||||
|
||||
// Pack into <=255-byte sub-blocks
|
||||
const out = [Buffer.from([MIN_CODE])];
|
||||
for (let i = 0; i < bytes.length; i += 255) {
|
||||
const blockData = bytes.slice(i, i + 255);
|
||||
out.push(Buffer.from([blockData.length]), Buffer.from(blockData));
|
||||
}
|
||||
out.push(Buffer.from([0]));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode frames (RGBA images, all same size) into an animated GIF.
|
||||
* delayCs is per-frame delay in centiseconds; loop 0 = forever.
|
||||
*/
|
||||
function encodeGif(frames, { delayCs = 150, loop = 0 } = {}) {
|
||||
if (!frames.length) throw new Error('gif: no frames');
|
||||
const { width, height } = frames[0];
|
||||
const parts = [];
|
||||
|
||||
parts.push(Buffer.from('GIF89a', 'latin1'));
|
||||
const lsd = Buffer.alloc(7);
|
||||
lsd.writeUInt16LE(width, 0);
|
||||
lsd.writeUInt16LE(height, 2);
|
||||
lsd[4] = 0xf7; // GCT present, 8-bit color, 256 entries
|
||||
lsd[5] = 0; // background color
|
||||
lsd[6] = 0; // aspect
|
||||
parts.push(lsd, PALETTE);
|
||||
|
||||
// NETSCAPE2.0 looping extension
|
||||
parts.push(Buffer.from([0x21, 0xff, 0x0b]));
|
||||
parts.push(Buffer.from('NETSCAPE2.0', 'latin1'));
|
||||
parts.push(Buffer.from([0x03, 0x01, loop & 0xff, (loop >> 8) & 0xff, 0x00]));
|
||||
|
||||
for (const frame of frames) {
|
||||
if (frame.width !== width || frame.height !== height) {
|
||||
throw new Error('gif: all frames must share dimensions');
|
||||
}
|
||||
const gce = Buffer.alloc(8);
|
||||
gce[0] = 0x21; gce[1] = 0xf9; gce[2] = 4;
|
||||
gce[3] = 0x04; // disposal: do not dispose
|
||||
gce.writeUInt16LE(Math.max(2, Math.round(delayCs)), 4);
|
||||
gce[6] = 0; gce[7] = 0;
|
||||
parts.push(gce);
|
||||
|
||||
const desc = Buffer.alloc(10);
|
||||
desc[0] = 0x2c;
|
||||
desc.writeUInt16LE(0, 1); desc.writeUInt16LE(0, 3);
|
||||
desc.writeUInt16LE(width, 5); desc.writeUInt16LE(height, 7);
|
||||
desc[9] = 0; // no local color table
|
||||
parts.push(desc, lzwEncode(toIndices(frame)));
|
||||
}
|
||||
|
||||
parts.push(Buffer.from([0x3b])); // trailer
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
module.exports = { encodeGif, PALETTE, quantizeIndex, toIndices, lzwEncode };
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
'use strict';
|
||||
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
/**
|
||||
* Minimal PDF 1.4 writer: pages, Helvetica/Helvetica-Bold/Courier text,
|
||||
* rects/lines, deflated content streams, RGB images as XObjects, and a
|
||||
* simple outline (bookmarks). Coordinates passed in are top-left based in
|
||||
* points; converted to PDF's bottom-left space internally.
|
||||
*/
|
||||
|
||||
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' };
|
||||
// Approximate average glyph width factors (per 1pt font size) for wrapping.
|
||||
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 };
|
||||
|
||||
function esc(text) {
|
||||
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
function toLatin1(text) {
|
||||
let out = '';
|
||||
for (const ch of String(text)) {
|
||||
const code = ch.codePointAt(0);
|
||||
out += code <= 0xff ? ch : '?';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function col(c) {
|
||||
return `${(c[0] / 255).toFixed(3)} ${(c[1] / 255).toFixed(3)} ${(c[2] / 255).toFixed(3)}`;
|
||||
}
|
||||
|
||||
class PdfBuilder {
|
||||
constructor({ pageWidth = 595.28, pageHeight = 841.89 } = {}) {
|
||||
this.pageWidth = pageWidth;
|
||||
this.pageHeight = pageHeight;
|
||||
this.pages = [];
|
||||
this.images = []; // { name, width, height, data (deflated RGB), smask? }
|
||||
this.imageCache = new Map();
|
||||
this.bookmarks = []; // { title, pageIndex, y }
|
||||
}
|
||||
|
||||
addPage() {
|
||||
this.pages.push({ ops: [] });
|
||||
return this.pages.length - 1;
|
||||
}
|
||||
|
||||
get currentPage() {
|
||||
if (!this.pages.length) this.addPage();
|
||||
return this.pages[this.pages.length - 1];
|
||||
}
|
||||
|
||||
textWidth(text, size, font = 'F1') {
|
||||
return String(text).length * size * (FONT_WIDTH_FACTOR[font] || 0.51);
|
||||
}
|
||||
|
||||
/** Greedy word wrap to maxWidth points. */
|
||||
wrapText(text, size, maxWidth, font = 'F1') {
|
||||
const lines = [];
|
||||
for (const para of String(text).split('\n')) {
|
||||
const words = para.split(/\s+/).filter(Boolean);
|
||||
if (!words.length) { lines.push(''); continue; }
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const candidate = line ? `${line} ${word}` : word;
|
||||
if (this.textWidth(candidate, size, font) <= maxWidth || !line) line = candidate;
|
||||
else { lines.push(line); line = word; }
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
|
||||
const y = this.pageHeight - yTop - size;
|
||||
this.currentPage.ops.push(
|
||||
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET`
|
||||
);
|
||||
}
|
||||
|
||||
rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) {
|
||||
const y = this.pageHeight - yTop - h;
|
||||
const ops = [];
|
||||
if (fill) ops.push(`${col(fill)} rg`);
|
||||
if (stroke) ops.push(`${col(stroke)} RG ${lineWidth} w`);
|
||||
ops.push(`${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)} re`);
|
||||
ops.push(fill && stroke ? 'B' : fill ? 'f' : 'S');
|
||||
this.currentPage.ops.push(ops.join(' '));
|
||||
}
|
||||
|
||||
line(x0, y0t, x1, y1t, { color = [0, 0, 0], width = 1 } = {}) {
|
||||
const y0 = this.pageHeight - y0t, y1 = this.pageHeight - y1t;
|
||||
this.currentPage.ops.push(
|
||||
`${col(color)} RG ${width} w ${x0.toFixed(2)} ${y0.toFixed(2)} m ${x1.toFixed(2)} ${y1.toFixed(2)} l S`
|
||||
);
|
||||
}
|
||||
|
||||
/** Draw an RGBA raster image; alpha is dropped (composited upstream). */
|
||||
image(img, x, yTop, w, h) {
|
||||
let name = this.imageCache.get(img);
|
||||
if (!name) {
|
||||
name = `Im${this.images.length + 1}`;
|
||||
const rgb = Buffer.alloc(img.width * img.height * 3);
|
||||
for (let i = 0, n = img.width * img.height; i < n; i++) {
|
||||
rgb[i * 3] = img.data[i * 4];
|
||||
rgb[i * 3 + 1] = img.data[i * 4 + 1];
|
||||
rgb[i * 3 + 2] = img.data[i * 4 + 2];
|
||||
}
|
||||
this.images.push({ name, width: img.width, height: img.height, data: zlib.deflateSync(rgb) });
|
||||
this.imageCache.set(img, name);
|
||||
}
|
||||
const y = this.pageHeight - yTop - h;
|
||||
this.currentPage.ops.push(
|
||||
`q ${w.toFixed(2)} 0 0 ${h.toFixed(2)} ${x.toFixed(2)} ${y.toFixed(2)} cm /${name} Do Q`
|
||||
);
|
||||
return name;
|
||||
}
|
||||
|
||||
bookmark(title, pageIndex = this.pages.length - 1) {
|
||||
this.bookmarks.push({ title: toLatin1(title), pageIndex });
|
||||
}
|
||||
|
||||
build() {
|
||||
if (!this.pages.length) this.addPage();
|
||||
const objects = []; // 1-based; objects[i] = body string|Buffer after header
|
||||
const addObj = (body) => { objects.push(body); return objects.length; };
|
||||
|
||||
// Reserve ids: 1 catalog, 2 pages tree (filled later)
|
||||
addObj(null); // 1: catalog placeholder
|
||||
addObj(null); // 2: pages placeholder
|
||||
|
||||
const fontIds = {};
|
||||
for (const [res, base] of Object.entries(FONTS)) {
|
||||
fontIds[res] = addObj(`<< /Type /Font /Subtype /Type1 /BaseFont /${base} /Encoding /WinAnsiEncoding >>`);
|
||||
}
|
||||
const imageIds = {};
|
||||
for (const img of this.images) {
|
||||
imageIds[img.name] = addObj({
|
||||
dict: `<< /Type /XObject /Subtype /Image /Width ${img.width} /Height ${img.height} ` +
|
||||
`/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length ${img.data.length} >>`,
|
||||
stream: img.data,
|
||||
});
|
||||
}
|
||||
|
||||
const fontRes = Object.entries(fontIds).map(([r, id]) => `/${r} ${id} 0 R`).join(' ');
|
||||
const imgRes = this.images.map((img) => `/${img.name} ${imageIds[img.name]} 0 R`).join(' ');
|
||||
const resources = `<< /Font << ${fontRes} >> ${this.images.length ? `/XObject << ${imgRes} >>` : ''} >>`;
|
||||
|
||||
const pageIds = [];
|
||||
for (const page of this.pages) {
|
||||
const content = zlib.deflateSync(Buffer.from(page.ops.join('\n'), 'latin1'));
|
||||
const contentId = addObj({
|
||||
dict: `<< /Filter /FlateDecode /Length ${content.length} >>`,
|
||||
stream: content,
|
||||
});
|
||||
pageIds.push(addObj(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${this.pageWidth} ${this.pageHeight}] ` +
|
||||
`/Resources ${resources} /Contents ${contentId} 0 R >>`
|
||||
));
|
||||
}
|
||||
|
||||
let outlinesRef = '';
|
||||
if (this.bookmarks.length) {
|
||||
const outlineRootId = objects.length + 1;
|
||||
const itemIds = this.bookmarks.map((_, i) => outlineRootId + 1 + i);
|
||||
addObj(`<< /Type /Outlines /First ${itemIds[0]} 0 R /Last ${itemIds[itemIds.length - 1]} 0 R /Count ${itemIds.length} >>`);
|
||||
this.bookmarks.forEach((bm, i) => {
|
||||
const parts = [
|
||||
`/Title (${esc(bm.title)})`,
|
||||
`/Parent ${outlineRootId} 0 R`,
|
||||
`/Dest [${pageIds[bm.pageIndex]} 0 R /Fit]`,
|
||||
];
|
||||
if (i > 0) parts.push(`/Prev ${itemIds[i - 1]} 0 R`);
|
||||
if (i < itemIds.length - 1) parts.push(`/Next ${itemIds[i + 1]} 0 R`);
|
||||
addObj(`<< ${parts.join(' ')} >>`);
|
||||
});
|
||||
outlinesRef = `/Outlines ${outlineRootId} 0 R /PageMode /UseOutlines`;
|
||||
}
|
||||
|
||||
objects[0] = `<< /Type /Catalog /Pages 2 0 R ${outlinesRef} >>`;
|
||||
objects[1] = `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
// Serialize with xref
|
||||
const chunks = [Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'latin1')];
|
||||
let offset = chunks[0].length;
|
||||
const offsets = [0];
|
||||
objects.forEach((body, idx) => {
|
||||
offsets.push(offset);
|
||||
let objBuf;
|
||||
if (body && typeof body === 'object' && body.stream) {
|
||||
objBuf = Buffer.concat([
|
||||
Buffer.from(`${idx + 1} 0 obj\n${body.dict}\nstream\n`, 'latin1'),
|
||||
body.stream,
|
||||
Buffer.from('\nendstream\nendobj\n', 'latin1'),
|
||||
]);
|
||||
} else {
|
||||
objBuf = Buffer.from(`${idx + 1} 0 obj\n${body}\nendobj\n`, 'latin1');
|
||||
}
|
||||
chunks.push(objBuf);
|
||||
offset += objBuf.length;
|
||||
});
|
||||
|
||||
const xrefStart = offset;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) {
|
||||
xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
}
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF\n`;
|
||||
chunks.push(Buffer.from(xref, 'latin1'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PdfBuilder, FONTS };
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
'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 };
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
'use strict';
|
||||
|
||||
const { FONT8X8 } = require('./font8x8');
|
||||
|
||||
/**
|
||||
* Software rasterizer for annotation rendering in exports. Operates on
|
||||
* RGBA images ({ width, height, data: Buffer }). The same normalized
|
||||
* annotation scene graph drawn by the editor canvas is burned into pixels
|
||||
* here, so exports match the editor.
|
||||
*
|
||||
* Stroke widths are normalized to a 1000px-wide reference image and scaled,
|
||||
* font sizes are fractions of image height — both resolution-independent.
|
||||
*/
|
||||
|
||||
function createImage(width, height, color = [255, 255, 255, 255]) {
|
||||
const data = Buffer.alloc(width * height * 4);
|
||||
for (let p = 0; p < data.length; p += 4) {
|
||||
data[p] = color[0]; data[p + 1] = color[1]; data[p + 2] = color[2]; data[p + 3] = color[3];
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
function cloneImage(img) {
|
||||
return { width: img.width, height: img.height, data: Buffer.from(img.data) };
|
||||
}
|
||||
|
||||
function parseColor(str, fallback = [0, 0, 0, 255]) {
|
||||
if (Array.isArray(str)) return str;
|
||||
if (typeof str !== 'string' || str === 'transparent' || str === 'none' || str === '') return str === undefined ? fallback : [0, 0, 0, 0];
|
||||
const m = /^#?([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(str.trim());
|
||||
if (!m) return fallback;
|
||||
const v = parseInt(m[1], 16);
|
||||
return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff, m[2] ? parseInt(m[2], 16) : 255];
|
||||
}
|
||||
|
||||
function blendPixel(img, x, y, color) {
|
||||
if (x < 0 || y < 0 || x >= img.width || y >= img.height) return;
|
||||
const a = color[3] / 255;
|
||||
if (a <= 0) return;
|
||||
const p = (y * img.width + x) * 4;
|
||||
const d = img.data;
|
||||
if (a >= 1) {
|
||||
d[p] = color[0]; d[p + 1] = color[1]; d[p + 2] = color[2]; d[p + 3] = 255;
|
||||
return;
|
||||
}
|
||||
d[p] = Math.round(color[0] * a + d[p] * (1 - a));
|
||||
d[p + 1] = Math.round(color[1] * a + d[p + 1] * (1 - a));
|
||||
d[p + 2] = Math.round(color[2] * a + d[p + 2] * (1 - a));
|
||||
d[p + 3] = Math.max(d[p + 3], Math.round(255 * a));
|
||||
}
|
||||
|
||||
function fillRect(img, x, y, w, h, color) {
|
||||
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
|
||||
const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h));
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
for (let xx = x0; xx < x1; xx++) blendPixel(img, xx, yy, color);
|
||||
}
|
||||
}
|
||||
|
||||
function strokeRect(img, x, y, w, h, color, t) {
|
||||
fillRect(img, x - t / 2, y - t / 2, w + t, t, color); // top
|
||||
fillRect(img, x - t / 2, y + h - t / 2, w + t, t, color); // bottom
|
||||
fillRect(img, x - t / 2, y + t / 2, t, h - t, color); // left
|
||||
fillRect(img, x + w - t / 2, y + t / 2, t, h - t, color); // right
|
||||
}
|
||||
|
||||
function ovalCoverage(cx, cy, rx, ry, px, py) {
|
||||
const dx = (px - cx) / rx, dy = (py - cy) / ry;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
function fillOval(img, x, y, w, h, color) {
|
||||
const cx = x + w / 2, cy = y + h / 2, rx = Math.max(1, w / 2), ry = Math.max(1, h / 2);
|
||||
const y0 = Math.max(0, Math.floor(y)), y1 = Math.min(img.height, Math.ceil(y + h));
|
||||
const x0 = Math.max(0, Math.floor(x)), x1 = Math.min(img.width, Math.ceil(x + w));
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
for (let xx = x0; xx < x1; xx++) {
|
||||
if (ovalCoverage(cx, cy, rx, ry, xx + 0.5, yy + 0.5) <= 1) blendPixel(img, xx, yy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function strokeOval(img, x, y, w, h, color, t) {
|
||||
const cx = x + w / 2, cy = y + h / 2;
|
||||
const rxO = Math.max(1, w / 2 + t / 2), ryO = Math.max(1, h / 2 + t / 2);
|
||||
const rxI = Math.max(0.5, w / 2 - t / 2), ryI = Math.max(0.5, h / 2 - t / 2);
|
||||
const y0 = Math.max(0, Math.floor(cy - ryO)), y1 = Math.min(img.height, Math.ceil(cy + ryO));
|
||||
const x0 = Math.max(0, Math.floor(cx - rxO)), x1 = Math.min(img.width, Math.ceil(cx + rxO));
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
for (let xx = x0; xx < x1; xx++) {
|
||||
const px = xx + 0.5, py = yy + 0.5;
|
||||
if (ovalCoverage(cx, cy, rxO, ryO, px, py) <= 1 && ovalCoverage(cx, cy, rxI, ryI, px, py) > 1) {
|
||||
blendPixel(img, xx, yy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawLine(img, x0, y0, x1, y1, color, t) {
|
||||
const half = Math.max(0.5, t / 2);
|
||||
const minX = Math.max(0, Math.floor(Math.min(x0, x1) - half - 1));
|
||||
const maxX = Math.min(img.width, Math.ceil(Math.max(x0, x1) + half + 1));
|
||||
const minY = Math.max(0, Math.floor(Math.min(y0, y1) - half - 1));
|
||||
const maxY = Math.min(img.height, Math.ceil(Math.max(y0, y1) + half + 1));
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const lenSq = dx * dx + dy * dy || 1;
|
||||
for (let yy = minY; yy < maxY; yy++) {
|
||||
for (let xx = minX; xx < maxX; xx++) {
|
||||
const px = xx + 0.5, py = yy + 0.5;
|
||||
let u = ((px - x0) * dx + (py - y0) * dy) / lenSq;
|
||||
u = Math.max(0, Math.min(1, u));
|
||||
const ex = x0 + u * dx - px, ey = y0 + u * dy - py;
|
||||
if (ex * ex + ey * ey <= half * half) blendPixel(img, xx, yy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scanline polygon fill (even-odd). points: [[x,y],...] */
|
||||
function fillPolygon(img, points, color) {
|
||||
const ys = points.map((p) => p[1]);
|
||||
const y0 = Math.max(0, Math.floor(Math.min(...ys)));
|
||||
const y1 = Math.min(img.height, Math.ceil(Math.max(...ys)));
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
const scanY = yy + 0.5;
|
||||
const xs = [];
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const [ax, ay] = points[i];
|
||||
const [bx, by] = points[(i + 1) % points.length];
|
||||
if ((ay <= scanY && by > scanY) || (by <= scanY && ay > scanY)) {
|
||||
xs.push(ax + ((scanY - ay) / (by - ay)) * (bx - ax));
|
||||
}
|
||||
}
|
||||
xs.sort((a, b) => a - b);
|
||||
for (let k = 0; k + 1 < xs.length; k += 2) {
|
||||
const xa = Math.max(0, Math.round(xs[k]));
|
||||
const xb = Math.min(img.width, Math.round(xs[k + 1]));
|
||||
for (let xx = xa; xx < xb; xx++) blendPixel(img, xx, yy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawArrow(img, x0, y0, x1, y1, color, t) {
|
||||
const dx = x1 - x0, dy = y1 - y0;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const headLen = Math.min(len * 0.4, Math.max(10, t * 4));
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const bx = x1 - ux * headLen, by = y1 - uy * headLen;
|
||||
drawLine(img, x0, y0, bx, by, color, t);
|
||||
const wing = headLen * 0.5;
|
||||
fillPolygon(img, [
|
||||
[x1, y1],
|
||||
[bx - uy * wing, by + ux * wing],
|
||||
[bx + uy * wing, by - ux * wing],
|
||||
], color);
|
||||
}
|
||||
|
||||
function boxBlur(img, x, y, w, h, radius) {
|
||||
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
|
||||
const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h));
|
||||
if (x1 <= x0 || y1 <= y0) return;
|
||||
const r = Math.max(1, Math.round(radius));
|
||||
// Two passes of box blur approximates gaussian well enough for redaction.
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
const src = Buffer.from(img.data);
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
for (let xx = x0; xx < x1; xx++) {
|
||||
let rs = 0, gs = 0, bs = 0, n = 0;
|
||||
for (let oy = -r; oy <= r; oy += Math.max(1, Math.floor(r / 3))) {
|
||||
for (let ox = -r; ox <= r; ox += Math.max(1, Math.floor(r / 3))) {
|
||||
const sx = Math.min(x1 - 1, Math.max(x0, xx + ox));
|
||||
const sy = Math.min(y1 - 1, Math.max(y0, yy + oy));
|
||||
const p = (sy * img.width + sx) * 4;
|
||||
rs += src[p]; gs += src[p + 1]; bs += src[p + 2]; n++;
|
||||
}
|
||||
}
|
||||
const p = (yy * img.width + xx) * 4;
|
||||
img.data[p] = Math.round(rs / n);
|
||||
img.data[p + 1] = Math.round(gs / n);
|
||||
img.data[p + 2] = Math.round(bs / n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Nearest-neighbour scaled copy of a region (used by magnify). */
|
||||
function magnifyRegion(img, x, y, w, h, zoom, borderColor, t) {
|
||||
const src = cloneImage(img);
|
||||
const cx = x + w / 2, cy = y + h / 2;
|
||||
const sw = w / zoom, sh = h / zoom;
|
||||
const sx0 = cx - sw / 2, sy0 = cy - sh / 2;
|
||||
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
|
||||
const x1 = Math.min(img.width, Math.round(x + w)), y1 = Math.min(img.height, Math.round(y + h));
|
||||
for (let yy = y0; yy < y1; yy++) {
|
||||
for (let xx = x0; xx < x1; xx++) {
|
||||
// Only inside the oval lens
|
||||
if (ovalCoverage(cx, cy, w / 2, h / 2, xx + 0.5, yy + 0.5) > 1) continue;
|
||||
const sx = Math.min(img.width - 1, Math.max(0, Math.round(sx0 + ((xx - x) / w) * sw)));
|
||||
const sy = Math.min(img.height - 1, Math.max(0, Math.round(sy0 + ((yy - y) / h) * sh)));
|
||||
const sp = (sy * src.width + sx) * 4;
|
||||
const dp = (yy * img.width + xx) * 4;
|
||||
img.data[dp] = src.data[sp]; img.data[dp + 1] = src.data[sp + 1];
|
||||
img.data[dp + 2] = src.data[sp + 2]; img.data[dp + 3] = 255;
|
||||
}
|
||||
}
|
||||
strokeOval(img, x, y, w, h, borderColor, t);
|
||||
}
|
||||
|
||||
// ---- text -----------------------------------------------------------------
|
||||
|
||||
function glyphFor(ch) {
|
||||
const code = ch.codePointAt(0);
|
||||
return FONT8X8[code >= 0 && code < 128 ? code : 63]; // '?' fallback
|
||||
}
|
||||
|
||||
/** Width/height of text at a pixel size (8x8 glyphs, integer scaled). */
|
||||
function measureText(text, sizePx) {
|
||||
const scale = Math.max(1, Math.round(sizePx / 8));
|
||||
const lines = String(text).split('\n');
|
||||
const w = Math.max(...lines.map((l) => l.length)) * 8 * scale;
|
||||
return { width: w, height: lines.length * 10 * scale, scale, lineHeight: 10 * scale };
|
||||
}
|
||||
|
||||
function drawText(img, x, y, text, sizePx, color) {
|
||||
const { scale, lineHeight } = measureText(text, sizePx);
|
||||
const lines = String(text).split('\n');
|
||||
let ty = Math.round(y);
|
||||
for (const line of lines) {
|
||||
let tx = Math.round(x);
|
||||
for (const ch of line) {
|
||||
const glyph = glyphFor(ch);
|
||||
for (let gy = 0; gy < 8; gy++) {
|
||||
const row = glyph[gy];
|
||||
for (let gx = 0; gx < 8; gx++) {
|
||||
if (!(row & (1 << gx))) continue;
|
||||
fillRect(img, tx + gx * scale, ty + gy * scale, scale, scale, color);
|
||||
}
|
||||
}
|
||||
tx += 8 * scale;
|
||||
}
|
||||
ty += lineHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function drawTextCentered(img, cx, cy, text, sizePx, color) {
|
||||
const m = measureText(text, sizePx);
|
||||
drawText(img, cx - m.width / 2, cy - m.height / 2 + m.scale, text, sizePx, color);
|
||||
}
|
||||
|
||||
function drawCursorIcon(img, x, y, sizePx, color) {
|
||||
const s = sizePx;
|
||||
fillPolygon(img, [
|
||||
[x, y], [x, y + s], [x + s * 0.28, y + s * 0.75],
|
||||
[x + s * 0.45, y + s * 1.05], [x + s * 0.58, y + s * 0.98],
|
||||
[x + s * 0.42, y + s * 0.68], [x + s * 0.72, y + s * 0.68],
|
||||
], [255, 255, 255, 255]);
|
||||
// dark outline by drawing slightly smaller inner arrow
|
||||
fillPolygon(img, [
|
||||
[x + 2, y + 4], [x + 2, y + s - 3], [x + s * 0.26, y + s * 0.7],
|
||||
[x + s * 0.42, y + s * 0.98], [x + s * 0.52, y + s * 0.93],
|
||||
[x + s * 0.37, y + s * 0.63], [x + s * 0.62, y + s * 0.63],
|
||||
], color);
|
||||
}
|
||||
|
||||
// ---- composition ----------------------------------------------------------
|
||||
|
||||
function crop(img, x, y, w, h) {
|
||||
const x0 = Math.max(0, Math.round(x)), y0 = Math.max(0, Math.round(y));
|
||||
const cw = Math.min(img.width - x0, Math.round(w)), ch = Math.min(img.height - y0, Math.round(h));
|
||||
if (cw <= 0 || ch <= 0) throw new Error('crop: empty region');
|
||||
const out = createImage(cw, ch);
|
||||
for (let yy = 0; yy < ch; yy++) {
|
||||
img.data.copy(out.data, yy * cw * 4, ((y0 + yy) * img.width + x0) * 4, ((y0 + yy) * img.width + x0 + cw) * 4);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bilinear resize. */
|
||||
function resize(img, w, h) {
|
||||
const out = createImage(w, h);
|
||||
for (let yy = 0; yy < h; yy++) {
|
||||
const sy = ((yy + 0.5) * img.height) / h - 0.5;
|
||||
const y0 = Math.max(0, Math.floor(sy)), y1 = Math.min(img.height - 1, y0 + 1);
|
||||
const fy = sy - y0;
|
||||
for (let xx = 0; xx < w; xx++) {
|
||||
const sx = ((xx + 0.5) * img.width) / w - 0.5;
|
||||
const x0 = Math.max(0, Math.floor(sx)), x1 = Math.min(img.width - 1, x0 + 1);
|
||||
const fx = sx - x0;
|
||||
const dp = (yy * w + xx) * 4;
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const p00 = img.data[(y0 * img.width + x0) * 4 + c];
|
||||
const p01 = img.data[(y0 * img.width + x1) * 4 + c];
|
||||
const p10 = img.data[(y1 * img.width + x0) * 4 + c];
|
||||
const p11 = img.data[(y1 * img.width + x1) * 4 + c];
|
||||
out.data[dp + c] = Math.round(
|
||||
p00 * (1 - fx) * (1 - fy) + p01 * fx * (1 - fy) + p10 * (1 - fx) * fy + p11 * fx * fy
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawImage(dst, src, dx, dy) {
|
||||
for (let yy = 0; yy < src.height; yy++) {
|
||||
for (let xx = 0; xx < src.width; xx++) {
|
||||
const sp = (yy * src.width + xx) * 4;
|
||||
blendPixel(dst, dx + xx, dy + yy, [src.data[sp], src.data[sp + 1], src.data[sp + 2], src.data[sp + 3]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- annotation rendering ---------------------------------------------------
|
||||
|
||||
const DRAW_ORDER = { blur: 0, highlight: 1, magnify: 2, rect: 3, oval: 3, line: 3, arrow: 3, cursor: 4, number: 5, text: 6, tooltip: 7 };
|
||||
|
||||
/**
|
||||
* Burn annotations into a copy of the base image. Annotation coords are
|
||||
* fractions of the image; returns a new image.
|
||||
*/
|
||||
function renderAnnotations(baseImg, annotations = []) {
|
||||
const img = cloneImage(baseImg);
|
||||
const W = img.width, H = img.height;
|
||||
const px = (frac, total) => frac * total;
|
||||
const strokePx = (sw) => Math.max(1, Math.round((sw || 3) * W / 1000));
|
||||
const fontPx = (style) => Math.max(8, Math.round((style.fontSize || 0.022) * H));
|
||||
|
||||
const ordered = [...annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
|
||||
|
||||
for (const ann of ordered) {
|
||||
const x = px(ann.x, W), y = px(ann.y, H), w = px(ann.w, W), h = px(ann.h, H);
|
||||
const style = ann.style || {};
|
||||
const stroke = parseColor(style.stroke, [229, 72, 77, 255]);
|
||||
const fill = parseColor(style.fill, [0, 0, 0, 0]);
|
||||
const textColor = parseColor(style.textColor, [255, 255, 255, 255]);
|
||||
const t = strokePx(style.strokeWidth);
|
||||
|
||||
switch (ann.type) {
|
||||
case 'rect':
|
||||
if (fill[3] > 0) fillRect(img, x, y, w, h, fill);
|
||||
strokeRect(img, x, y, w, h, stroke, t);
|
||||
break;
|
||||
case 'oval':
|
||||
if (fill[3] > 0) fillOval(img, x, y, w, h, fill);
|
||||
strokeOval(img, x, y, w, h, stroke, t);
|
||||
break;
|
||||
case 'line':
|
||||
drawLine(img, x, y, x + w, y + h, stroke, t);
|
||||
break;
|
||||
case 'arrow':
|
||||
drawArrow(img, x, y, x + w, y + h, stroke, t);
|
||||
break;
|
||||
case 'blur':
|
||||
boxBlur(img, x, y, w, h, ann.radius || 8);
|
||||
break;
|
||||
case 'highlight':
|
||||
fillRect(img, x, y, w, h, [255, 235, 59, 105]);
|
||||
break;
|
||||
case 'magnify':
|
||||
magnifyRegion(img, x, y, w, h, ann.zoom || 2, stroke, t);
|
||||
break;
|
||||
case 'text': {
|
||||
drawText(img, x, y, ann.text || '', fontPx(style), stroke[3] > 0 ? stroke : [0, 0, 0, 255]);
|
||||
break;
|
||||
}
|
||||
case 'tooltip': {
|
||||
const bg = parseColor(style.fill === 'transparent' || !style.fill ? '#1F2937' : style.fill);
|
||||
fillRect(img, x, y, w, h, bg);
|
||||
strokeRect(img, x, y, w, h, parseColor(style.stroke, [17, 24, 39, 255]), Math.max(1, Math.round(t / 2)));
|
||||
const tail = style.tail || 'bottom';
|
||||
const ts = Math.max(6, Math.min(w, h) * 0.25);
|
||||
if (tail === 'bottom') fillPolygon(img, [[x + w / 2 - ts, y + h], [x + w / 2 + ts, y + h], [x + w / 2, y + h + ts * 1.4]], bg);
|
||||
if (tail === 'top') fillPolygon(img, [[x + w / 2 - ts, y], [x + w / 2 + ts, y], [x + w / 2, y - ts * 1.4]], bg);
|
||||
if (tail === 'left') fillPolygon(img, [[x, y + h / 2 - ts], [x, y + h / 2 + ts], [x - ts * 1.4, y + h / 2]], bg);
|
||||
if (tail === 'right') fillPolygon(img, [[x + w, y + h / 2 - ts], [x + w, y + h / 2 + ts], [x + w + ts * 1.4, y + h / 2]], bg);
|
||||
drawTextCentered(img, x + w / 2, y + h / 2, ann.text || '', fontPx(style), textColor);
|
||||
break;
|
||||
}
|
||||
case 'number': {
|
||||
const r = Math.max(8, Math.min(w, h) / 2);
|
||||
const cx = x + w / 2, cy = y + h / 2;
|
||||
fillOval(img, cx - r, cy - r, r * 2, r * 2, stroke);
|
||||
drawTextCentered(img, cx, cy, String(ann.value ?? '?'), Math.max(8, r), textColor);
|
||||
break;
|
||||
}
|
||||
case 'cursor':
|
||||
drawCursorIcon(img, x, y, Math.max(12, Math.min(w, h)), [17, 24, 39, 255]);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
/** Apply focused view (zoom/pan crop, then scale back to original size). */
|
||||
function applyFocusedView(img, fv) {
|
||||
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
|
||||
const vw = img.width / fv.zoom, vh = img.height / fv.zoom;
|
||||
const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2);
|
||||
const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2);
|
||||
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh);
|
||||
return resize(region, img.width, img.height);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createImage, cloneImage, parseColor, blendPixel,
|
||||
fillRect, strokeRect, fillOval, strokeOval, drawLine, drawArrow,
|
||||
fillPolygon, boxBlur, magnifyRegion,
|
||||
measureText, drawText, drawTextCentered, drawCursorIcon,
|
||||
crop, resize, drawImage,
|
||||
renderAnnotations, applyFocusedView,
|
||||
};
|
||||
Reference in New Issue
Block a user