@@ -70,6 +70,7 @@ class DocumentData {
7070 this . acroFormQ = 0 ;
7171 this . hasSignatureAnnotations = false ;
7272 this . fieldToParent = new RefSetCache ( ) ;
73+ this . outline = null ;
7374 }
7475}
7576
@@ -148,6 +149,8 @@ class PDFEditor {
148149
149150 acroFormQ = 0 ;
150151
152+ outlineItems = null ;
153+
151154 constructor ( { useObjectStreams = true , title = "" , author = "" } = { } ) {
152155 [ this . rootRef , this . rootDict ] = this . newDict ;
153156 [ this . infoRef , this . infoDict ] = this . newDict ;
@@ -633,6 +636,7 @@ class PDFEditor {
633636 promises . length = 0 ;
634637
635638 this . #collectValidDestinations( allDocumentData ) ;
639+ this . #collectOutlineDestinations( allDocumentData ) ;
636640 this . #collectPageLabels( ) ;
637641
638642 for ( const page of this . oldPages ) {
@@ -650,6 +654,7 @@ class PDFEditor {
650654 this . #fixPostponedRefCopies( allDocumentData ) ;
651655 await this . #mergeStructTrees( allDocumentData ) ;
652656 await this . #mergeAcroForms( allDocumentData ) ;
657+ this . #buildOutline( allDocumentData ) ;
653658
654659 return this . writePDF ( ) ;
655660 }
@@ -676,6 +681,9 @@ class PDFEditor {
676681 pdfManager
677682 . ensureCatalog ( "acroForm" )
678683 . then ( acroForm => ( documentData . acroForm = acroForm ) ) ,
684+ pdfManager
685+ . ensureCatalog ( "documentOutlineForEditor" )
686+ . then ( outline => ( documentData . outline = outline ) ) ,
679687 ] ) ;
680688 const structTreeRoot = documentData . structTreeRoot ;
681689 if ( structTreeRoot ) {
@@ -1214,6 +1222,224 @@ class PDFEditor {
12141222 }
12151223 }
12161224
1225+ /**
1226+ * Collect named destinations referenced in the outlines so they are kept
1227+ * when filtering duplicate named destinations.
1228+ * @param {Array<DocumentData> } allDocumentData
1229+ */
1230+ #collectOutlineDestinations( allDocumentData ) {
1231+ const collect = ( items , destinations , usedNamedDestinations ) => {
1232+ for ( const item of items ) {
1233+ if ( typeof item . dest === "string" && destinations ?. has ( item . dest ) ) {
1234+ usedNamedDestinations . add ( item . dest ) ;
1235+ }
1236+ if ( item . items . length > 0 ) {
1237+ collect ( item . items , destinations , usedNamedDestinations ) ;
1238+ }
1239+ }
1240+ } ;
1241+ for ( const documentData of allDocumentData ) {
1242+ const { outline, destinations, usedNamedDestinations } = documentData ;
1243+ if ( outline ?. length ) {
1244+ collect ( outline , destinations , usedNamedDestinations ) ;
1245+ }
1246+ }
1247+ }
1248+
1249+ /**
1250+ * Check whether an outline item has a valid destination in the output doc.
1251+ * @param {Object } item
1252+ * @param {DocumentData } documentData
1253+ * @returns {boolean }
1254+ */
1255+ #isValidOutlineDest( item , documentData ) {
1256+ const { dest, action, url, unsafeUrl, attachment, setOCGState } = item ;
1257+ // External links (including relative URLs that can't be made absolute),
1258+ // named actions, attachments and OCG state changes are always kept.
1259+ if ( action || url || unsafeUrl || attachment || setOCGState ) {
1260+ return true ;
1261+ }
1262+ if ( ! dest ) {
1263+ return false ;
1264+ }
1265+ if ( typeof dest === "string" ) {
1266+ const name = documentData . dedupNamedDestinations . get ( dest ) || dest ;
1267+ return this . namedDestinations . has ( name ) ;
1268+ }
1269+ if ( Array . isArray ( dest ) && dest [ 0 ] instanceof Ref ) {
1270+ return ! ! documentData . oldRefMapping . get ( dest [ 0 ] ) ;
1271+ }
1272+ return false ;
1273+ }
1274+
1275+ /**
1276+ * Recursively filter outline items, removing those with no valid destination
1277+ * and no remaining children.
1278+ * @param {Array } items
1279+ * @param {DocumentData } documentData
1280+ * @returns {Array }
1281+ */
1282+ #filterOutlineItems( items , documentData ) {
1283+ const result = [ ] ;
1284+ for ( const item of items ) {
1285+ const filteredChildren = this . #filterOutlineItems(
1286+ item . items ,
1287+ documentData
1288+ ) ;
1289+ const hasValidOwnDest = this . #isValidOutlineDest( item , documentData ) ;
1290+ if ( hasValidOwnDest || filteredChildren . length > 0 ) {
1291+ result . push ( {
1292+ ...item ,
1293+ // When the item's own destination is invalid (but it has surviving
1294+ // children), clear the destination so the output item is a plain
1295+ // container rather than a broken link.
1296+ dest : hasValidOwnDest ? item . dest : null ,
1297+ items : filteredChildren ,
1298+ _documentData : documentData ,
1299+ } ) ;
1300+ }
1301+ }
1302+ return result ;
1303+ }
1304+
1305+ /**
1306+ * Filter outline trees and collect the result into this.outlineItems.
1307+ * Must be called after page copies are made (oldRefMapping is populated).
1308+ * @param {Array<DocumentData> } allDocumentData
1309+ */
1310+ #buildOutline( allDocumentData ) {
1311+ const outlineItems = [ ] ;
1312+ for ( const documentData of allDocumentData ) {
1313+ const { outline } = documentData ;
1314+ if ( ! outline ?. length ) {
1315+ continue ;
1316+ }
1317+ outlineItems . push ( ...this . #filterOutlineItems( outline , documentData ) ) ;
1318+ }
1319+ this . outlineItems = outlineItems . length > 0 ? outlineItems : null ;
1320+ }
1321+
1322+ /**
1323+ * Write the destination or action of an outline item into the given dict.
1324+ * @param {Dict } itemDict
1325+ * @param {Object } item
1326+ * @returns {Promise<void> }
1327+ */
1328+ async #setOutlineItemDest( itemDict , item ) {
1329+ const { dest, rawDict } = item ;
1330+ const documentData = item . _documentData ;
1331+ if ( dest ) {
1332+ if ( typeof dest === "string" ) {
1333+ const name = documentData . dedupNamedDestinations . get ( dest ) || dest ;
1334+ itemDict . set ( "Dest" , stringToAsciiOrUTF16BE ( name ) ) ;
1335+ } else if ( Array . isArray ( dest ) ) {
1336+ const newDest = dest . slice ( ) ;
1337+ if ( newDest [ 0 ] instanceof Ref ) {
1338+ newDest [ 0 ] = documentData . oldRefMapping . get ( newDest [ 0 ] ) || newDest [ 0 ] ;
1339+ }
1340+ itemDict . set ( "Dest" , newDest ) ;
1341+ }
1342+ return ;
1343+ }
1344+ // For all other action types (URI, GoToR, Named, SetOCGState, ...) clone
1345+ // the raw action dict from the original document.
1346+ const actionDict = rawDict ?. get ( "A" ) ;
1347+ if ( actionDict instanceof Dict ) {
1348+ this . currentDocument = documentData ;
1349+ const actionRef = await this . #cloneObject(
1350+ actionDict ,
1351+ documentData . document . xref
1352+ ) ;
1353+ this . currentDocument = null ;
1354+ itemDict . set ( "A" , actionRef ) ;
1355+ }
1356+ }
1357+
1358+ /**
1359+ * Build and write the document outline (bookmarks) into the output PDF.
1360+ * @returns {Promise<void> }
1361+ */
1362+ async #makeOutline( ) {
1363+ const { outlineItems } = this ;
1364+ if ( ! outlineItems ?. length ) {
1365+ return ;
1366+ }
1367+
1368+ const [ outlineRootRef , outlineRootDict ] = this . newDict ;
1369+ outlineRootDict . setIfName ( "Type" , "Outlines" ) ;
1370+
1371+ // First pass: allocate a new Ref for every item in the tree.
1372+ const assignRefs = items => {
1373+ for ( const item of items ) {
1374+ [ item . _ref ] = this . newDict ;
1375+ if ( item . items . length > 0 ) {
1376+ assignRefs ( item . items ) ;
1377+ }
1378+ }
1379+ } ;
1380+ assignRefs ( outlineItems ) ;
1381+
1382+ // Second pass: fill each Dict and return the total visible item count.
1383+ const fillItems = async ( items , parentRef ) => {
1384+ let totalCount = 0 ;
1385+ for ( let i = 0 ; i < items . length ; i ++ ) {
1386+ const item = items [ i ] ;
1387+ const dict = this . xref [ item . _ref . num ] ;
1388+
1389+ dict . set ( "Title" , stringToAsciiOrUTF16BE ( item . title ) ) ;
1390+ dict . set ( "Parent" , parentRef ) ;
1391+ if ( i > 0 ) {
1392+ dict . set ( "Prev" , items [ i - 1 ] . _ref ) ;
1393+ }
1394+ if ( i < items . length - 1 ) {
1395+ dict . set ( "Next" , items [ i + 1 ] . _ref ) ;
1396+ }
1397+
1398+ if ( item . items . length > 0 ) {
1399+ dict . set ( "First" , item . items [ 0 ] . _ref ) ;
1400+ dict . set ( "Last" , item . items . at ( - 1 ) . _ref ) ;
1401+ const childCount = await fillItems ( item . items , item . _ref ) ;
1402+ if ( item . count !== undefined ) {
1403+ // Preserve the original expanded/collapsed state while updating
1404+ // the number of visible descendants after filtering.
1405+ dict . set ( "Count" , item . count < 0 ? - childCount : childCount ) ;
1406+ }
1407+ // A closed item (count < 0) hides its descendants, so it only
1408+ // contributes 1 to the parent's visible-item tally.
1409+ totalCount +=
1410+ item . count !== undefined && item . count < 0 ? 1 : childCount + 1 ;
1411+ } else {
1412+ totalCount += 1 ;
1413+ }
1414+
1415+ await this . #setOutlineItemDest( dict , item ) ;
1416+
1417+ const flags = ( item . bold ? 2 : 0 ) | ( item . italic ? 1 : 0 ) ;
1418+ if ( flags !== 0 ) {
1419+ dict . set ( "F" , flags ) ;
1420+ }
1421+ if (
1422+ item . color &&
1423+ ( item . color [ 0 ] !== 0 || item . color [ 1 ] !== 0 || item . color [ 2 ] !== 0 )
1424+ ) {
1425+ dict . set ( "C" , [
1426+ item . color [ 0 ] / 255 ,
1427+ item . color [ 1 ] / 255 ,
1428+ item . color [ 2 ] / 255 ,
1429+ ] ) ;
1430+ }
1431+ }
1432+ return totalCount ;
1433+ } ;
1434+
1435+ const totalCount = await fillItems ( outlineItems , outlineRootRef ) ;
1436+ outlineRootDict . set ( "First" , outlineItems [ 0 ] . _ref ) ;
1437+ outlineRootDict . set ( "Last" , outlineItems . at ( - 1 ) . _ref ) ;
1438+ outlineRootDict . set ( "Count" , totalCount ) ;
1439+
1440+ this . rootDict . set ( "Outlines" , outlineRootRef ) ;
1441+ }
1442+
12171443 async #mergeAcroForms( allDocumentData ) {
12181444 this . #setAcroFormDefaultBasicValues( allDocumentData ) ;
12191445 this . #setAcroFormDefaultAppearance( allDocumentData ) ;
@@ -1937,6 +2163,7 @@ class PDFEditor {
19372163 this . #makePageLabelsTree( ) ;
19382164 this . #makeDestinationsTree( ) ;
19392165 this . #makeStructTree( ) ;
2166+ await this . #makeOutline( ) ;
19402167 }
19412168
19422169 /**
0 commit comments