@@ -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+
155238async 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