Skip to content

Commit 0ee557c

Browse files
authored
Merge pull request #20905 from calixteman/reorganize_outlines
Add support for saving outlines after reorganize/merge (bug 2009574)
2 parents 76bd6dc + e67892d commit 0ee557c

5 files changed

Lines changed: 582 additions & 1 deletion

File tree

src/core/catalog.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ class Catalog {
317317
return shadow(this, "documentOutline", obj);
318318
}
319319

320-
#readDocumentOutline() {
320+
#readDocumentOutline(options = {}) {
321321
let obj = this.#catDict.get("Outlines");
322322
if (!(obj instanceof Dict)) {
323323
return null;
@@ -382,6 +382,10 @@ class Catalog {
382382
items: [],
383383
};
384384

385+
if (options.keepRawDict) {
386+
outlineItem.rawDict = outlineDict;
387+
}
388+
385389
i.parent.items.push(outlineItem);
386390
obj = outlineDict.getRaw("First");
387391
if (obj instanceof Ref && !processed.has(obj)) {
@@ -397,6 +401,19 @@ class Catalog {
397401
return root.items.length > 0 ? root.items : null;
398402
}
399403

404+
get documentOutlineForEditor() {
405+
let obj = null;
406+
try {
407+
obj = this.#readDocumentOutline({ keepRawDict: true });
408+
} catch (ex) {
409+
if (ex instanceof MissingDataException) {
410+
throw ex;
411+
}
412+
warn("Unable to read document outline.");
413+
}
414+
return shadow(this, "documentOutlineForEditor", obj);
415+
}
416+
400417
get permissions() {
401418
let permissions = null;
402419
try {

src/core/editor/pdf_editor.js

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
/**

test/pdfs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,4 +884,5 @@
884884
!form_two_pages.pdf
885885
!outlines_se.pdf
886886
!radial_gradients.pdf
887+
!outlines_for_editor.pdf
887888
!mesh_shading_empty.pdf

test/pdfs/outlines_for_editor.pdf

2.91 KB
Binary file not shown.

0 commit comments

Comments
 (0)