Files
Tyler b51a4c46ce Make PDF Contents entries clickable links to each step's page
Adds PdfBuilder.linkRect() for /Subtype /Link annotations with a
/Dest pointing at another page's destination. TOC entries record a
target placeholder that the per-step loop fills in once it knows
which page the step landed on, so clicking a Contents line jumps the
reader straight to that step.
2026-06-15 13:22:51 -05:00

300 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' };
// Approximate average glyph width factors (per 1pt font size) for wrapping.
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 };
function esc(text) {
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
// "Smart typography" characters commonly produced by rich-text editors and
// pasted content (e.g. from Word/Google Docs) that have a printable glyph in
// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte
// instead of falling back to "?".
const WINANSI_EXTRA = {
0x20ac: 0x80, // €
0x2026: 0x85, // … ellipsis
0x2030: 0x89, // ‰
0x2039: 0x8b, //
0x203a: 0x9b, //
0x2018: 0x91, // ' left single quote
0x2019: 0x92, // ' right single quote
0x201c: 0x93, // " left double quote
0x201d: 0x94, // " right double quote
0x2022: 0x95, // • bullet
0x2013: 0x96, // en dash
0x2014: 0x97, // — em dash
0x2122: 0x99, // ™
};
function toLatin1(text) {
let out = '';
for (const ch of String(text)) {
const code = ch.codePointAt(0);
if (code <= 0xff) out += ch;
else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]);
else out += '?';
}
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 }
this.links = []; // { pageIndex, x, yTop, w, h, target }
}
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;
// Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces.
const clean = String(str).replace(/\t/g, ' ');
this.currentPage.ops.push(
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET`
);
}
/**
* Render a line made of mixed-font/color segments (e.g. bold/italic runs)
* as one continuous text object: consecutive Tj operators without an
* intervening Tm advance the cursor by each font's real glyph widths, so
* spacing matches the viewer's metrics exactly (unlike our approximate
* textWidth(), which is only used for word-wrap decisions).
*/
textRun(parts, x, yTop, size) {
const y = this.pageHeight - yTop - size;
const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`];
let curFont = null;
let curColor = null;
for (const part of parts) {
if (!part.text) continue;
if (part.font !== curFont) {
ops.push(`/${part.font} ${size} Tf`);
curFont = part.font;
}
if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) {
ops.push(`${col(part.color)} rg`);
curColor = part.color;
}
const clean = String(part.text).replace(/\t/g, ' ');
ops.push(`(${esc(toLatin1(clean))}) Tj`);
}
ops.push('ET');
this.currentPage.ops.push(ops.join(' '));
}
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 });
}
/**
* Add a clickable region that jumps to another page (e.g. a Contents
* entry linking to a step). `target` is either a page index, or an
* object whose `pageIndex` is filled in later — once the destination
* page is known — and read when `build()` runs.
*/
linkRect(x, yTop, w, h, target, pageIndex = this.pages.length - 1) {
this.links.push({ pageIndex, x, yTop, w, h, target });
}
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 >>`
));
}
// Link annotations (e.g. clickable Contents entries that jump to a
// step's page). Targets resolved late, after every step has claimed a
// page, so `target` may be an object whose `pageIndex` was only just set.
const pageAnnots = new Map(); // page index -> [annotation object id, ...]
for (const link of this.links) {
const targetIndex = typeof link.target === 'number' ? link.target : link.target?.pageIndex;
if (targetIndex == null || !pageIds[targetIndex]) continue;
const y1 = this.pageHeight - link.yTop - link.h;
const y2 = this.pageHeight - link.yTop;
const annotId = addObj(
`<< /Type /Annot /Subtype /Link /Rect [${link.x.toFixed(2)} ${y1.toFixed(2)} ${(link.x + link.w).toFixed(2)} ${y2.toFixed(2)}] ` +
`/Border [0 0 0] /Dest [${pageIds[targetIndex]} 0 R /Fit] >>`
);
if (!pageAnnots.has(link.pageIndex)) pageAnnots.set(link.pageIndex, []);
pageAnnots.get(link.pageIndex).push(annotId);
}
for (const [pageIndex, annotIds] of pageAnnots) {
const id = pageIds[pageIndex];
objects[id - 1] = objects[id - 1].replace(/ >>$/, ` /Annots [${annotIds.map((a) => `${a} 0 R`).join(' ')}] >>`);
}
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 };