diff --git a/core/pdf.js b/core/pdf.js index 21febb7..04ceefb 100644 --- a/core/pdf.js +++ b/core/pdf.js @@ -60,6 +60,7 @@ class PdfBuilder { 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() { @@ -173,6 +174,16 @@ class PdfBuilder { 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 @@ -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 = ''; if (this.bookmarks.length) { const outlineRootId = objects.length + 1; diff --git a/exporters/pdf.js b/exporters/pdf.js index d749e4a..ee80b7c 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -252,13 +252,25 @@ function exportPdf(ast, outDir, template = {}) { 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) { writeLines('Contents', { size: 16, font: 'F2' }); y += 4; + const tocSize = 10.5; + const lineH = tocSize * 1.35; for (const step of ast.steps) { - writeLines(`${step.number}. ${step.title || 'Untitled step'}`, { - size: 10.5, indent: 14 * step.depth, - }); + const 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(); y = M; @@ -283,6 +295,8 @@ function exportPdf(ast, outDir, template = {}) { // together — never split across a page boundary. ensure(headHeight); 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; 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] }); diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 9b6fa73..007a54b 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -55,6 +55,24 @@ function bookmarkPages(buf) { 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. */ function assertWellFormedXml(xml, label) { 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); }); +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) => { const { ast, root } = fixtureAst(t, 'pdfgs'); const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));