Skip to content

Commit 064762b

Browse files
AntikodeAntikode
authored andcommitted
fix: pages-per-sheet.js (N-up combine) silently dropped source PDF's bookmarks
Combining pages onto sheets via PDFDocument.create()+embedPages() never carried the source's own /Outlines over. Since multiple source pages map to one output sheet, remap each bookmark to the sheet its source page landed on (Math.floor((page-1)/count)+1) via the same readExistingBookmarks/addOutline pattern established in bookmarks.js. Skipped entirely when the source has zero bookmarks. Verified against the real vendored pdf-lib: bookmarks correctly remap for both single-per-sheet and multiple-per-sheet cases, and no /Outlines entry is created when the source has none.
1 parent 10efd6a commit 064762b

1 file changed

Lines changed: 95 additions & 0 deletions

File tree

pages-per-sheet.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,89 @@ function gridFor(count, orientation) {
152152
return { sheet, cols, rows };
153153
}
154154

155+
/**
156+
* Reads the source PDF's own top-level /Outlines linked list, if any — combining
157+
* pages onto N-up sheets via PDFDocument.create()+embedPages() never carries
158+
* /Outlines over, so without this the source's bookmarks are silently dropped.
159+
* Only direct [pageRef, ...] /Dest arrays are resolved, matching the same
160+
* resolution logic used in bookmarks.js/split-by-bookmarks.js.
161+
*/
162+
async function readExistingBookmarks(doc) {
163+
const { PDFName } = await getPdfLib();
164+
const context = doc.context;
165+
const outlinesRef = doc.catalog.get(PDFName.of("Outlines"));
166+
if (!outlinesRef) return [];
167+
const outlines = context.lookup(outlinesRef);
168+
if (!outlines) return [];
169+
170+
const pageRefs = doc.getPages().map((p) => p.ref);
171+
function resolvePage(dest) {
172+
if (!dest || typeof dest.get !== "function") return -1;
173+
try {
174+
const pageRef = dest.get(0);
175+
if (!pageRef) return -1;
176+
return pageRefs.findIndex(
177+
(r) => r.tag === pageRef.tag && r.objectNumber === pageRef.objectNumber
178+
);
179+
} catch (e) {
180+
return -1;
181+
}
182+
}
183+
184+
const items = [];
185+
let cur = outlines.get(PDFName.of("First"));
186+
let guard = 0;
187+
while (cur && guard++ < 10000) {
188+
const item = context.lookup(cur);
189+
if (!item) break;
190+
const titleObj = item.get(PDFName.of("Title"));
191+
const title = titleObj && titleObj.decodeText ? titleObj.decodeText() : "";
192+
const dest = item.get(PDFName.of("Dest"));
193+
const pageIndex = resolvePage(dest);
194+
if (title && pageIndex >= 0) items.push({ title, page: pageIndex + 1 });
195+
cur = item.get(PDFName.of("Next"));
196+
}
197+
return items;
198+
}
199+
200+
/**
201+
* Builds a flat PDF outline (bookmark) tree from a list of {title, page}
202+
* entries via pdf-lib's low-level context API — same construction used in
203+
* bookmarks.js. Entries are expected to already point at valid pages in `doc`.
204+
*/
205+
async function addOutline(doc, entries) {
206+
const { PDFName, PDFString, PDFNumber } = await getPdfLib();
207+
const context = doc.context;
208+
const sorted = [...entries].sort((a, b) => a.page - b.page);
209+
210+
const outlineRef = context.nextRef();
211+
const itemRefs = sorted.map(() => context.nextRef());
212+
213+
sorted.forEach((entry, i) => {
214+
const page = doc.getPage(entry.page - 1);
215+
const dict = {
216+
Title: PDFString.of(entry.title),
217+
Parent: outlineRef,
218+
Dest: context.obj([page.ref, PDFName.of("Fit")]),
219+
};
220+
if (i > 0) dict.Prev = itemRefs[i - 1];
221+
if (i < itemRefs.length - 1) dict.Next = itemRefs[i + 1];
222+
context.assign(itemRefs[i], context.obj(dict));
223+
});
224+
225+
context.assign(
226+
outlineRef,
227+
context.obj({
228+
Type: PDFName.of("Outlines"),
229+
First: itemRefs[0],
230+
Last: itemRefs[itemRefs.length - 1],
231+
Count: PDFNumber.of(itemRefs.length),
232+
})
233+
);
234+
235+
doc.catalog.set(PDFName.of("Outlines"), outlineRef);
236+
}
237+
155238
async function nupPdf() {
156239
const { PDFDocument, degrees } = await getPdfLib();
157240
const count = currentCount();
@@ -163,6 +246,10 @@ async function nupPdf() {
163246
// embedPages() discards each page's own /Rotate — capture it before embedding,
164247
// or a rotated source page (e.g. a phone-scanned PDF) draws sideways on the sheet.
165248
const srcRotations = srcPages.map((p) => (((p.getRotation().angle % 360) + 360) % 360));
249+
// Combining pages onto sheets via embedPages()/addPage() never carries the
250+
// source's own bookmarks over — remap each one to the sheet its source page
251+
// landed on (many-to-one, since several source pages share one output sheet).
252+
const srcBookmarks = await readExistingBookmarks(srcDoc);
166253
const outDoc = await PDFDocument.create();
167254
const embedded = await outDoc.embedPages(srcPages);
168255

@@ -200,6 +287,14 @@ async function nupPdf() {
200287
sheetsMade++;
201288
}
202289

290+
if (srcBookmarks.length) {
291+
const remapped = srcBookmarks.map((b) => ({
292+
title: b.title,
293+
page: Math.floor((b.page - 1) / count) + 1,
294+
}));
295+
await addOutline(outDoc, remapped);
296+
}
297+
203298
const bytes = await outDoc.save();
204299
return {
205300
blob: new Blob([bytes], { type: "application/pdf" }),

0 commit comments

Comments
 (0)