toc-pages.ts
197 lines · make-pdf/src/toc-pages.ts at 3f79d81
1/**
2 * TOC page numbers, offline: print, read where each heading landed from the
3 * printed PDF itself, write those numbers into the TOC, and print again until
4 * the numbers the PDF shows are the numbers its own layout produced.
5 *
6 * Why this strategy (#2903): Chromium's print engine has no CSS
7 * `target-counter()`, and a pagination polyfill (Paged.js) would mean running
8 * a third-party script inside the user's browser profile on untrusted
9 * markdown and re-implementing the layout Chromium already does. Instead the
10 * oracle is the PDF: the TOC links make Chromium (Skia) write one named
11 * destination per target heading — `/Dests << /toc-0 [<page ref> /XYZ …] >>`
12 * in the catalog — and the page tree gives each page ref its 1-based number,
13 * the same physical number the `counter(page)` footer prints.
14 *
15 * No script is injected and nothing is fetched, so the offline/sanitization
16 * boundary is unchanged. Every failure is explicit (TocPaginationError →
17 * exit 3): a PDF this parser cannot read, a TOC target with no destination,
18 * or numbers that have not settled after MAX_PRINTS. A TOC with empty
19 * page-number cells is never written.
20 */
22import * as fs from "node:fs";
23import * as path from "node:path";
25/** Prints needed is normally 2 (measure, then confirm); the third absorbs one reflow. */
26export const MAX_PRINTS = 3;
28export class TocPaginationError extends Error {
29 constructor(message: string) {
30 super(message);
31 this.name = "TocPaginationError";
32 }
33}
35const TOC_SECTION = /<section class="toc">[\s\S]*?<\/section>/;
36const TOC_PAGE_SPAN = /(<span class="toc-page" data-toc-target=")([^"]*)(">)[^<]*(<\/span>)/g;
38function decodeAttr(s: string): string {
39 return s
40 .replace(/</g, "<")
41 .replace(/>/g, ">")
42 .replace(/"/g, '"')
43 .replace(/'/g, "'")
44 .replace(/&/g, "&");
45}
47/**
48 * The key a TOC target and its PDF destination share. Chromium names a
49 * destination after the link's URL fragment, percent-encoded (`#café` →
50 * `/caf#25C3#25A9`), so both sides compare percent-decoded.
51 */
52function destKey(id: string): string {
53 try {
54 return decodeURIComponent(id);
55 } catch {
56 return id;
57 }
58}
60/** TOC target keys, in TOC order. Empty when the document has no TOC. */
61export function tocTargets(html: string): string[] {
62 const toc = html.match(TOC_SECTION)?.[0] ?? "";
63 return [...toc.matchAll(TOC_PAGE_SPAN)].map((m) => destKey(decodeAttr(m[2])));
64}
66/** Write each target's page from `pages` (keyed by tocTargets keys; 0 when unknown) into its TOC cell. */
67export function fillTocPages(html: string, pages: ReadonlyMap<string, number>): string {
68 return html.replace(TOC_SECTION, (toc) =>
69 toc.replace(TOC_PAGE_SPAN, (_m, open: string, target: string, close: string, end: string) =>
70 `${open}${target}${close}${pages.get(destKey(decodeAttr(target))) ?? 0}${end}`));
71}
73// ─── PDF reading (classic xref, as Chromium/Skia writes it) ───────────────
75/** PDF name token (after the slash) → string: `#xx` escapes are UTF-8 bytes. */
76function decodePdfName(raw: string): string {
77 const bytes: number[] = [];
78 for (let i = 0; i < raw.length; i++) {
79 if (raw[i] === "#" && /^[0-9a-fA-F]{2}$/.test(raw.slice(i + 1, i + 3))) {
80 bytes.push(parseInt(raw.slice(i + 1, i + 3), 16));
81 i += 2;
82 } else {
83 bytes.push(raw.charCodeAt(i) & 0xff);
84 }
85 }
86 return Buffer.from(bytes).toString("utf8");
87}
89/**
90 * Named destination (as a tocTargets key) → 1-based page number, read from a PDF's catalog /Dests
91 * dictionary and page tree. Throws TocPaginationError when the file is not a
92 * PDF this reader understands (cross-reference streams, a /Dests name tree).
93 */
94export function pdfDestinationPages(pdf: Uint8Array): Map<string, number> {
95 const fail = (why: string): never => {
96 throw new TocPaginationError(`could not read TOC page numbers from the printed PDF: ${why}`);
97 };
98 const s = Buffer.from(pdf).toString("latin1");
99 const startxref = s.match(/startxref\s+(\d+)\s+%%EOF\s*$/);
100 if (!startxref) return fail("no startxref trailer");
101 const xrefAt = Number(startxref[1]);
102 if (!s.startsWith("xref", xrefAt)) return fail("cross-reference streams are not supported");
104 const offsets = new Map<number, number>();
105 const trailerAt = s.indexOf("trailer", xrefAt);
106 if (trailerAt < 0) return fail("no trailer dictionary");
107 const table = s.slice(xrefAt + 4, trailerAt).trim().split(/\r?\n|\r/);
108 for (let i = 0; i < table.length;) {
109 const [first, count] = table[i].trim().split(/\s+/).map(Number);
110 for (let k = 0; k < count; k++) {
111 const [off, , kind] = (table[i + 1 + k] ?? "").trim().split(/\s+/);
112 if (kind === "n") offsets.set(first + k, Number(off));
113 }
114 i += 1 + count;
115 }
117 const object = (num: number): string => {
118 const at = offsets.get(num);
119 if (at === undefined) return fail(`object ${num} is not in the cross-reference table`);
120 const end = s.indexOf("endobj", at);
121 return s.slice(at, end < 0 ? undefined : end);
122 };
123 const ref = (body: string, key: string): number | null => {
124 const m = body.match(new RegExp(`/${key}\\s+(\\d+)\\s+\\d+\\s+R`));
125 return m ? Number(m[1]) : null;
126 };
128 const root = ref(s.slice(trailerAt), "Root") ?? fail("trailer has no /Root");
129 const catalog = object(root);
130 const pagesRoot = ref(catalog, "Pages") ?? fail("catalog has no /Pages");
132 const pageNumber = new Map<number, number>();
133 const walk = (num: number, depth: number): void => {
134 if (depth > 32) fail("page tree is too deep");
135 const node = object(num);
136 const kids = node.match(/\/Kids\s*\[([^\]]*)\]/);
137 if (/\/Type\s*\/Pages\b/.test(node) && kids) {
138 for (const k of kids[1].matchAll(/(\d+)\s+\d+\s+R/g)) walk(Number(k[1]), depth + 1);
139 } else {
140 pageNumber.set(num, pageNumber.size + 1);
141 }
142 };
143 walk(pagesRoot, 0);
145 const pages = new Map<string, number>();
146 const destsRef = ref(catalog, "Dests");
147 if (destsRef === null) {
148 if (/\/Names\s*<<[^>]*\/Dests/.test(catalog)) fail("named destinations in a /Names tree are not supported");
149 return pages;
150 }
151 for (const m of object(destsRef).matchAll(/\/([^\s()<>[\]{}/%]+)\s*\[\s*(\d+)\s+\d+\s+R/g)) {
152 const page = pageNumber.get(Number(m[2]));
153 if (page !== undefined) pages.set(destKey(decodePdfName(m[1])), page);
154 }
155 return pages;
156}
158// ─── Print loop ───────────────────────────────────────────────────────────
160/**
161 * Print `html` to `output` with every TOC page cell holding the page its
162 * heading prints on. `print(html, out)` prints one PDF (one browser render).
163 * Returns print's result from the accepted pass.
164 */
165export async function printWithTocPages<R>(
166 html: string,
167 output: string,
168 print: (html: string, out: string) => Promise<R>,
169): Promise<R> {
170 const targets = tocTargets(html);
171 if (targets.length === 0) return print(html, output);
173 const tmp = path.join(path.dirname(output), `.${path.basename(output)}.toc-pass.pdf`);
174 let pages: ReadonlyMap<string, number> = new Map();
175 try {
176 for (let pass = 1; pass <= MAX_PRINTS; pass++) {
177 const result = await print(fillTocPages(html, pages), tmp);
178 const printed = pdfDestinationPages(fs.readFileSync(tmp));
179 const missing = targets.filter((t) => !printed.has(t));
180 if (missing.length > 0) {
181 throw new TocPaginationError(
182 `the printed PDF has no destination for TOC target(s) ${missing.map((t) => `#${t}`).join(", ")}; ` +
183 `their links and page numbers would be wrong`);
184 }
185 if (targets.every((t) => pages.get(t) === printed.get(t))) {
186 fs.renameSync(tmp, output);
187 return result;
188 }
189 pages = printed;
190 }
191 throw new TocPaginationError(
192 `TOC page numbers did not settle after ${MAX_PRINTS} prints (adding them reflowed the document); ` +
193 `drop --toc or shorten the longest TOC entries`);
194 } finally {
195 fs.rmSync(tmp, { force: true });
196 }
197}