b7e64c79b4
- 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>
215 lines
7.8 KiB
JavaScript
215 lines
7.8 KiB
JavaScript
'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 };
|