Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19
+32
@@ -60,6 +60,7 @@ class PdfBuilder {
|
|||||||
this.images = []; // { name, width, height, data (deflated RGB), smask? }
|
this.images = []; // { name, width, height, data (deflated RGB), smask? }
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
this.bookmarks = []; // { title, pageIndex, y }
|
this.bookmarks = []; // { title, pageIndex, y }
|
||||||
|
this.links = []; // { pageIndex, x, yTop, w, h, target }
|
||||||
}
|
}
|
||||||
|
|
||||||
addPage() {
|
addPage() {
|
||||||
@@ -173,6 +174,16 @@ class PdfBuilder {
|
|||||||
this.bookmarks.push({ title: toLatin1(title), pageIndex });
|
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() {
|
build() {
|
||||||
if (!this.pages.length) this.addPage();
|
if (!this.pages.length) this.addPage();
|
||||||
const objects = []; // 1-based; objects[i] = body string|Buffer after header
|
const objects = []; // 1-based; objects[i] = body string|Buffer after header
|
||||||
@@ -212,6 +223,27 @@ class PdfBuilder {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = '';
|
let outlinesRef = '';
|
||||||
if (this.bookmarks.length) {
|
if (this.bookmarks.length) {
|
||||||
const outlineRootId = objects.length + 1;
|
const outlineRootId = objects.length + 1;
|
||||||
|
|||||||
+17
-3
@@ -252,13 +252,25 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
y = M;
|
y = M;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filled in below as each step claims its page, so the Contents entries
|
||||||
|
// above (already laid out before pagination starts) can link to it.
|
||||||
|
const tocTargets = new Map(); // stepId -> { pageIndex }
|
||||||
|
|
||||||
if (tpl.includeToc && ast.steps.length > 1) {
|
if (tpl.includeToc && ast.steps.length > 1) {
|
||||||
writeLines('Contents', { size: 16, font: 'F2' });
|
writeLines('Contents', { size: 16, font: 'F2' });
|
||||||
y += 4;
|
y += 4;
|
||||||
|
const tocSize = 10.5;
|
||||||
|
const lineH = tocSize * 1.35;
|
||||||
for (const step of ast.steps) {
|
for (const step of ast.steps) {
|
||||||
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, {
|
const indent = 14 * step.depth;
|
||||||
size: 10.5, indent: 14 * step.depth,
|
const target = {};
|
||||||
});
|
tocTargets.set(step.stepId, target);
|
||||||
|
for (const line of pdf.wrapText(`${step.number}. ${step.title || 'Untitled step'}`, tocSize, usableW - indent, 'F1')) {
|
||||||
|
ensure(lineH);
|
||||||
|
pdf.text(line, M + indent, y, { size: tocSize });
|
||||||
|
pdf.linkRect(M + indent, y, usableW - indent, lineH, target);
|
||||||
|
y += lineH;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pdf.addPage();
|
pdf.addPage();
|
||||||
y = M;
|
y = M;
|
||||||
@@ -283,6 +295,8 @@ function exportPdf(ast, outDir, template = {}) {
|
|||||||
// together — never split across a page boundary.
|
// together — never split across a page boundary.
|
||||||
ensure(headHeight);
|
ensure(headHeight);
|
||||||
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
|
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
|
||||||
|
const tocTarget = tocTargets.get(step.stepId);
|
||||||
|
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
|
||||||
const headSize = step.depth > 0 ? 12 : 14;
|
const headSize = step.depth > 0 ? 12 : 14;
|
||||||
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
|
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
|
||||||
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
|
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
|
||||||
|
|||||||
@@ -55,6 +55,24 @@ function bookmarkPages(buf) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve each Link annotation's `/Dest` on the page at `pageIndex` to a 0-based page index. */
|
||||||
|
function tocLinkTargets(buf, pageIndex) {
|
||||||
|
const text = buf.toString('latin1');
|
||||||
|
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
|
||||||
|
.map((m) => Number(m[1]));
|
||||||
|
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
|
||||||
|
const objBody = (id) => new RegExp(`(?:^|\\n)${id} 0 obj\\n([\\s\\S]*?)\\nendobj`).exec(text)[1];
|
||||||
|
|
||||||
|
const annots = /\/Annots \[([^\]]+)\]/.exec(objBody(kids[pageIndex]));
|
||||||
|
if (!annots) return [];
|
||||||
|
return [...annots[1].matchAll(/(\d+) 0 R/g)].map((m) => {
|
||||||
|
const body = objBody(Number(m[1]));
|
||||||
|
assert.match(body, /\/Subtype \/Link/);
|
||||||
|
const dest = /\/Dest \[(\d+) 0 R/.exec(body);
|
||||||
|
return pageIndexOf.get(Number(dest[1]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Tiny XML well-formedness check: balanced tags, single root. */
|
/** Tiny XML well-formedness check: balanced tags, single root. */
|
||||||
function assertWellFormedXml(xml, label) {
|
function assertWellFormedXml(xml, label) {
|
||||||
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim();
|
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim();
|
||||||
@@ -103,6 +121,16 @@ test('PDF export: valid document, bookmarks per step, images embedded', (t) => {
|
|||||||
assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2);
|
assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PDF Contents: each entry is a clickable link to its step\'s page', (t) => {
|
||||||
|
const { ast, root } = fixtureAst(t, 'pdftoc');
|
||||||
|
const buf = fs.readFileSync(exportPdf(ast, path.join(root, 'out')).file);
|
||||||
|
|
||||||
|
const bookmarks = bookmarkPages(buf); // one per step, in document order
|
||||||
|
const tocTargets = tocLinkTargets(buf, 1); // page 0 = cover, page 1 = Contents
|
||||||
|
|
||||||
|
assert.deepEqual(tocTargets, bookmarks.map((b) => b.pageIndex));
|
||||||
|
});
|
||||||
|
|
||||||
test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => {
|
test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => {
|
||||||
const { ast, root } = fixtureAst(t, 'pdfgs');
|
const { ast, root } = fixtureAst(t, 'pdfgs');
|
||||||
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));
|
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));
|
||||||
|
|||||||
Reference in New Issue
Block a user