Merge pull request #20905 from calixteman/reorganize_outlines

Add support for saving outlines after reorganize/merge (bug 2009574)
This commit is contained in:
calixteman 2026-03-17 22:33:49 +01:00 committed by GitHub
commit 0ee557cd60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 692 additions and 1 deletions

View File

@ -317,7 +317,7 @@ class Catalog {
return shadow(this, "documentOutline", obj);
}
#readDocumentOutline() {
#readDocumentOutline(options = {}) {
let obj = this.#catDict.get("Outlines");
if (!(obj instanceof Dict)) {
return null;
@ -382,6 +382,10 @@ class Catalog {
items: [],
};
if (options.keepRawDict) {
outlineItem.rawDict = outlineDict;
}
i.parent.items.push(outlineItem);
obj = outlineDict.getRaw("First");
if (obj instanceof Ref && !processed.has(obj)) {
@ -397,6 +401,19 @@ class Catalog {
return root.items.length > 0 ? root.items : null;
}
get documentOutlineForEditor() {
let obj = null;
try {
obj = this.#readDocumentOutline({ keepRawDict: true });
} catch (ex) {
if (ex instanceof MissingDataException) {
throw ex;
}
warn("Unable to read document outline.");
}
return shadow(this, "documentOutlineForEditor", obj);
}
get permissions() {
let permissions = null;
try {

View File

@ -70,6 +70,7 @@ class DocumentData {
this.acroFormQ = 0;
this.hasSignatureAnnotations = false;
this.fieldToParent = new RefSetCache();
this.outline = null;
}
}
@ -148,6 +149,8 @@ class PDFEditor {
acroFormQ = 0;
outlineItems = null;
constructor({ useObjectStreams = true, title = "", author = "" } = {}) {
[this.rootRef, this.rootDict] = this.newDict;
[this.infoRef, this.infoDict] = this.newDict;
@ -633,6 +636,7 @@ class PDFEditor {
promises.length = 0;
this.#collectValidDestinations(allDocumentData);
this.#collectOutlineDestinations(allDocumentData);
this.#collectPageLabels();
for (const page of this.oldPages) {
@ -650,6 +654,7 @@ class PDFEditor {
this.#fixPostponedRefCopies(allDocumentData);
await this.#mergeStructTrees(allDocumentData);
await this.#mergeAcroForms(allDocumentData);
this.#buildOutline(allDocumentData);
return this.writePDF();
}
@ -676,6 +681,9 @@ class PDFEditor {
pdfManager
.ensureCatalog("acroForm")
.then(acroForm => (documentData.acroForm = acroForm)),
pdfManager
.ensureCatalog("documentOutlineForEditor")
.then(outline => (documentData.outline = outline)),
]);
const structTreeRoot = documentData.structTreeRoot;
if (structTreeRoot) {
@ -1214,6 +1222,224 @@ class PDFEditor {
}
}
/**
* Collect named destinations referenced in the outlines so they are kept
* when filtering duplicate named destinations.
* @param {Array<DocumentData>} allDocumentData
*/
#collectOutlineDestinations(allDocumentData) {
const collect = (items, destinations, usedNamedDestinations) => {
for (const item of items) {
if (typeof item.dest === "string" && destinations?.has(item.dest)) {
usedNamedDestinations.add(item.dest);
}
if (item.items.length > 0) {
collect(item.items, destinations, usedNamedDestinations);
}
}
};
for (const documentData of allDocumentData) {
const { outline, destinations, usedNamedDestinations } = documentData;
if (outline?.length) {
collect(outline, destinations, usedNamedDestinations);
}
}
}
/**
* Check whether an outline item has a valid destination in the output doc.
* @param {Object} item
* @param {DocumentData} documentData
* @returns {boolean}
*/
#isValidOutlineDest(item, documentData) {
const { dest, action, url, unsafeUrl, attachment, setOCGState } = item;
// External links (including relative URLs that can't be made absolute),
// named actions, attachments and OCG state changes are always kept.
if (action || url || unsafeUrl || attachment || setOCGState) {
return true;
}
if (!dest) {
return false;
}
if (typeof dest === "string") {
const name = documentData.dedupNamedDestinations.get(dest) || dest;
return this.namedDestinations.has(name);
}
if (Array.isArray(dest) && dest[0] instanceof Ref) {
return !!documentData.oldRefMapping.get(dest[0]);
}
return false;
}
/**
* Recursively filter outline items, removing those with no valid destination
* and no remaining children.
* @param {Array} items
* @param {DocumentData} documentData
* @returns {Array}
*/
#filterOutlineItems(items, documentData) {
const result = [];
for (const item of items) {
const filteredChildren = this.#filterOutlineItems(
item.items,
documentData
);
const hasValidOwnDest = this.#isValidOutlineDest(item, documentData);
if (hasValidOwnDest || filteredChildren.length > 0) {
result.push({
...item,
// When the item's own destination is invalid (but it has surviving
// children), clear the destination so the output item is a plain
// container rather than a broken link.
dest: hasValidOwnDest ? item.dest : null,
items: filteredChildren,
_documentData: documentData,
});
}
}
return result;
}
/**
* Filter outline trees and collect the result into this.outlineItems.
* Must be called after page copies are made (oldRefMapping is populated).
* @param {Array<DocumentData>} allDocumentData
*/
#buildOutline(allDocumentData) {
const outlineItems = [];
for (const documentData of allDocumentData) {
const { outline } = documentData;
if (!outline?.length) {
continue;
}
outlineItems.push(...this.#filterOutlineItems(outline, documentData));
}
this.outlineItems = outlineItems.length > 0 ? outlineItems : null;
}
/**
* Write the destination or action of an outline item into the given dict.
* @param {Dict} itemDict
* @param {Object} item
* @returns {Promise<void>}
*/
async #setOutlineItemDest(itemDict, item) {
const { dest, rawDict } = item;
const documentData = item._documentData;
if (dest) {
if (typeof dest === "string") {
const name = documentData.dedupNamedDestinations.get(dest) || dest;
itemDict.set("Dest", stringToAsciiOrUTF16BE(name));
} else if (Array.isArray(dest)) {
const newDest = dest.slice();
if (newDest[0] instanceof Ref) {
newDest[0] = documentData.oldRefMapping.get(newDest[0]) || newDest[0];
}
itemDict.set("Dest", newDest);
}
return;
}
// For all other action types (URI, GoToR, Named, SetOCGState, ...) clone
// the raw action dict from the original document.
const actionDict = rawDict?.get("A");
if (actionDict instanceof Dict) {
this.currentDocument = documentData;
const actionRef = await this.#cloneObject(
actionDict,
documentData.document.xref
);
this.currentDocument = null;
itemDict.set("A", actionRef);
}
}
/**
* Build and write the document outline (bookmarks) into the output PDF.
* @returns {Promise<void>}
*/
async #makeOutline() {
const { outlineItems } = this;
if (!outlineItems?.length) {
return;
}
const [outlineRootRef, outlineRootDict] = this.newDict;
outlineRootDict.setIfName("Type", "Outlines");
// First pass: allocate a new Ref for every item in the tree.
const assignRefs = items => {
for (const item of items) {
[item._ref] = this.newDict;
if (item.items.length > 0) {
assignRefs(item.items);
}
}
};
assignRefs(outlineItems);
// Second pass: fill each Dict and return the total visible item count.
const fillItems = async (items, parentRef) => {
let totalCount = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
const dict = this.xref[item._ref.num];
dict.set("Title", stringToAsciiOrUTF16BE(item.title));
dict.set("Parent", parentRef);
if (i > 0) {
dict.set("Prev", items[i - 1]._ref);
}
if (i < items.length - 1) {
dict.set("Next", items[i + 1]._ref);
}
if (item.items.length > 0) {
dict.set("First", item.items[0]._ref);
dict.set("Last", item.items.at(-1)._ref);
const childCount = await fillItems(item.items, item._ref);
if (item.count !== undefined) {
// Preserve the original expanded/collapsed state while updating
// the number of visible descendants after filtering.
dict.set("Count", item.count < 0 ? -childCount : childCount);
}
// A closed item (count < 0) hides its descendants, so it only
// contributes 1 to the parent's visible-item tally.
totalCount +=
item.count !== undefined && item.count < 0 ? 1 : childCount + 1;
} else {
totalCount += 1;
}
await this.#setOutlineItemDest(dict, item);
const flags = (item.bold ? 2 : 0) | (item.italic ? 1 : 0);
if (flags !== 0) {
dict.set("F", flags);
}
if (
item.color &&
(item.color[0] !== 0 || item.color[1] !== 0 || item.color[2] !== 0)
) {
dict.set("C", [
item.color[0] / 255,
item.color[1] / 255,
item.color[2] / 255,
]);
}
}
return totalCount;
};
const totalCount = await fillItems(outlineItems, outlineRootRef);
outlineRootDict.set("First", outlineItems[0]._ref);
outlineRootDict.set("Last", outlineItems.at(-1)._ref);
outlineRootDict.set("Count", totalCount);
this.rootDict.set("Outlines", outlineRootRef);
}
async #mergeAcroForms(allDocumentData) {
this.#setAcroFormDefaultBasicValues(allDocumentData);
this.#setAcroFormDefaultAppearance(allDocumentData);
@ -1937,6 +2163,7 @@ class PDFEditor {
this.#makePageLabelsTree();
this.#makeDestinationsTree();
this.#makeStructTree();
await this.#makeOutline();
}
/**

View File

@ -884,4 +884,5 @@
!form_two_pages.pdf
!outlines_se.pdf
!radial_gradients.pdf
!outlines_for_editor.pdf
!mesh_shading_empty.pdf

View File

@ -0,0 +1,110 @@
%PDF-1.7
%<25><><EFBFBD><EFBFBD>
10 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
11 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
12 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
13 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
14 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
1 0 obj
<< /Type /Catalog /Pages 2 0 R /Names 3 0 R /Outlines 4 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /Count 5 >>
endobj
3 0 obj
<< /Dests 5 0 R >>
endobj
5 0 obj
<< /Names [(page1dest) [10 0 R /XYZ 0 0 0] (page2dest) [11 0 R /XYZ 0 0 0] (page3dest) [12 0 R /XYZ 0 0 0] (page4dest) [13 0 R /XYZ 0 0 0] (page5dest) [14 0 R /XYZ 0 0 0]] >>
endobj
4 0 obj
<< /Type /Outlines /First 20 0 R /Last 26 0 R /Count 7 >>
endobj
20 0 obj
<< /Title (Page 1 - explicit dest) /Parent 4 0 R /Next 21 0 R /Dest [10 0 R /XYZ 0 0 0] >>
endobj
21 0 obj
<< /Title (Page 2 - named dest) /Parent 4 0 R /Prev 20 0 R /Next 22 0 R /Dest (page2dest) >>
endobj
22 0 obj
<< /Title (External URL) /Parent 4 0 R /Prev 21 0 R /Next 23 0 R /A << /Type /Action /S /URI /URI (https://mozilla.org) >> >>
endobj
23 0 obj
<< /Title (Next Page action) /Parent 4 0 R /Prev 22 0 R /Next 24 0 R /A << /Type /Action /S /Named /N /NextPage >> >>
endobj
24 0 obj
<< /Title (Remote PDF link) /Parent 4 0 R /Prev 23 0 R /Next 25 0 R /A << /Type /Action /S /GoToR /F (other.pdf) /D [0 /Fit] >> >>
endobj
25 0 obj
<< /Title (Chapter) /Parent 4 0 R /Prev 24 0 R /Next 26 0 R /Dest (page1dest) /First 30 0 R /Last 32 0 R /Count -3 >>
endobj
26 0 obj
<< /Title (No dest parent) /Parent 4 0 R /Prev 25 0 R /First 33 0 R /Last 33 0 R /Count -1 >>
endobj
30 0 obj
<< /Title (Section 1) /Parent 25 0 R /Next 31 0 R /Dest [11 0 R /FitH 100] >>
endobj
31 0 obj
<< /Title (Section 2) /Parent 25 0 R /Prev 30 0 R /Next 32 0 R /Dest (page3dest) /F 3 /C [1 0 0] >>
endobj
32 0 obj
<< /Title (Subsection) /Parent 25 0 R /Prev 31 0 R /Dest (page5dest) /First 34 0 R /Last 34 0 R /Count -1 >>
endobj
33 0 obj
<< /Title (Child with dest) /Parent 26 0 R /Dest (page5dest) >>
endobj
34 0 obj
<< /Title (Deep item) /Parent 32 0 R /Dest (page4dest) >>
endobj
xref
0 35
0000000000 65535 f
0000000375 00000 n
0000000453 00000 n
0000000539 00000 n
0000000763 00000 n
0000000573 00000 n
0000000000 65535 f
0000000000 65535 f
0000000000 65535 f
0000000000 65535 f
0000000015 00000 n
0000000087 00000 n
0000000159 00000 n
0000000231 00000 n
0000000303 00000 n
0000000000 65535 f
0000000000 65535 f
0000000000 65535 f
0000000000 65535 f
0000000000 65535 f
0000000836 00000 n
0000000943 00000 n
0000001052 00000 n
0000001194 00000 n
0000001328 00000 n
0000001475 00000 n
0000001609 00000 n
0000000000 65535 f
0000000000 65535 f
0000000000 65535 f
0000001719 00000 n
0000001813 00000 n
0000001929 00000 n
0000002054 00000 n
0000002134 00000 n
trailer
<< /Size 35 /Root 1 0 R >>
startxref
2208
%%EOF

View File

@ -6330,5 +6330,341 @@ small scripts as well as for`);
await loadingTask.destroy();
});
});
describe("Outlines", function () {
// outlines_for_editor.pdf has 5 pages and the following outline tree:
//
// [0] "Page 1 - explicit dest" dest=[page1 /XYZ 0 0 0]
// [1] "Page 2 - named dest" dest=(page2dest)
// [2] "External URL" /A /URI https://mozilla.org
// [3] "Next Page action" /A /Named /NextPage
// [4] "Remote PDF link" /A /GoToR other.pdf
// [5] "Chapter" dest=(page1dest)
// [5.0] "Section 1" dest=[page2 /FitH 100]
// [5.1] "Section 2" dest=(page3dest) bold+italic, red
// [5.2] "Subsection" dest=(page5dest)
// [5.2.0] "Deep item" dest=(page4dest)
// [6] "No dest parent" (no dest / action)
// [6.0] "Child with dest" dest=(page5dest)
it("should preserve the full outline when all pages are kept", async function () {
const loadingTask = getDocument(
buildGetDocumentParams("outlines_for_editor.pdf")
);
const pdfDoc = await loadingTask.promise;
const originalOutline = await pdfDoc.getOutline();
const data = await pdfDoc.extractPages([{ document: null }]);
await loadingTask.destroy();
const newLoadingTask = getDocument(data);
const newPdfDoc = await newLoadingTask.promise;
const outline = await newPdfDoc.getOutline();
expect(Array.isArray(outline)).toEqual(true);
expect(outline.length).toEqual(7);
// Item [0]: explicit array dest
expect(outline[0].title).toEqual("Page 1 - explicit dest");
expect(Array.isArray(outline[0].dest)).toEqual(true);
expect(outline[0].dest[1].name).toEqual("XYZ");
// Item [1]: named string dest
expect(outline[1].title).toEqual("Page 2 - named dest");
expect(typeof outline[1].dest).toEqual("string");
// Item [2]: URI action
expect(outline[2].title).toEqual("External URL");
expect(outline[2].dest).toEqual(null);
expect(outline[2].url).toEqual("https://mozilla.org/");
// Item [3]: built-in named action
expect(outline[3].title).toEqual("Next Page action");
expect(outline[3].dest).toEqual(null);
expect(outline[3].action).toEqual("NextPage");
// Item [4]: GoToR (remote PDF) relative path, so url is null but
// unsafeUrl holds the raw file path (with dest hash appended).
expect(outline[4].title).toEqual("Remote PDF link");
expect(outline[4].dest).toEqual(null);
expect(outline[4].unsafeUrl).toContain("other.pdf");
// Item [5]: "Chapter" parent with named dest and 3 children
const chapter = outline[5];
expect(chapter.title).toEqual("Chapter");
expect(typeof chapter.dest).toEqual("string");
expect(chapter.items.length).toEqual(3);
expect(chapter.count).toEqual(originalOutline[5].count);
// Section 1: explicit FitH dest
expect(chapter.items[0].title).toEqual("Section 1");
expect(Array.isArray(chapter.items[0].dest)).toEqual(true);
expect(chapter.items[0].dest[1].name).toEqual("FitH");
// Section 2: named dest + bold + italic + red color
const section2 = chapter.items[1];
expect(section2.title).toEqual("Section 2");
expect(typeof section2.dest).toEqual("string");
expect(section2.bold).toEqual(true);
expect(section2.italic).toEqual(true);
expect(section2.color).toEqual(new Uint8ClampedArray([255, 0, 0]));
// Subsection: parent with own dest + one child
const subsection = chapter.items[2];
expect(subsection.title).toEqual("Subsection");
expect(subsection.items.length).toEqual(1);
expect(subsection.items[0].title).toEqual("Deep item");
// Item [6]: "No dest parent" no dest, but has a child
const noDestParent = outline[6];
expect(noDestParent.title).toEqual("No dest parent");
expect(noDestParent.dest).toEqual(null);
expect(noDestParent.items.length).toEqual(1);
expect(noDestParent.count).toEqual(originalOutline[6].count);
expect(noDestParent.items[0].title).toEqual("Child with dest");
await newLoadingTask.destroy();
});
it("should filter outline items pointing to deleted pages", async function () {
// Keep only pages 0 and 1 (page 1 and page 2).
const loadingTask = getDocument(
buildGetDocumentParams("outlines_for_editor.pdf")
);
const pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null, includePages: [0, 1] },
]);
await loadingTask.destroy();
const newLoadingTask = getDocument(data);
const newPdfDoc = await newLoadingTask.promise;
const outline = await newPdfDoc.getOutline();
expect(Array.isArray(outline)).toEqual(true);
// 6 items: all except "No dest parent" (its child dest was on page 5).
expect(outline.length).toEqual(6);
const titles = outline.map(i => i.title);
expect(titles).not.toContain("No dest parent");
// "Chapter" is kept (own dest=page1dest points to kept page 1);
// it should have only "Section 1" "Section 2" (page3) and
// "Subsection" (page5 / page4) are gone.
const chapter = outline.find(i => i.title === "Chapter");
expect(chapter).not.toBeUndefined();
expect(chapter.items.length).toEqual(1);
expect(chapter.items[0].title).toEqual("Section 1");
// External links are always preserved.
expect(titles).toContain("External URL");
expect(titles).toContain("Next Page action");
expect(titles).toContain("Remote PDF link");
await newLoadingTask.destroy();
});
it("should keep parent items that have no dest but still have valid children", async function () {
// Keep only pages 2-4 (page 3, 4, 5).
const loadingTask = getDocument(
buildGetDocumentParams("outlines_for_editor.pdf")
);
const pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([
{ document: null, includePages: [2, 3, 4] },
]);
await loadingTask.destroy();
const newLoadingTask = getDocument(data);
const newPdfDoc = await newLoadingTask.promise;
const outline = await newPdfDoc.getOutline();
expect(Array.isArray(outline)).toEqual(true);
// 5 items: explicit dest (page1) and named dest (page2dest) are gone;
// the 3 external-link items + "Chapter" + "No dest parent" remain.
expect(outline.length).toEqual(5);
const titles = outline.map(i => i.title);
expect(titles).not.toContain("Page 1 - explicit dest");
expect(titles).not.toContain("Page 2 - named dest");
// "Chapter" has no valid own dest (page1dest deleted) but has
// surviving children, so it must be kept.
const chapter = outline.find(i => i.title === "Chapter");
expect(chapter).not.toBeUndefined();
expect(chapter.dest).toEqual(null);
expect(chapter.items.length).toEqual(2);
const childTitles = chapter.items.map(i => i.title);
expect(childTitles).toContain("Section 2");
expect(childTitles).toContain("Subsection");
expect(childTitles).not.toContain("Section 1");
const subsection = chapter.items.find(i => i.title === "Subsection");
expect(subsection.items.length).toEqual(1);
expect(subsection.items[0].title).toEqual("Deep item");
// "No dest parent" has a surviving child (page5dest on kept page 5).
const noDestParent = outline.find(i => i.title === "No dest parent");
expect(noDestParent).not.toBeUndefined();
expect(noDestParent.items.length).toEqual(1);
await newLoadingTask.destroy();
});
it("should merge outlines from two copies, cross-linking surviving dests", async function () {
// Merge: page 1 (index 0) from copy A, page 3 (index 2) from copy B.
// Named dests in the output: "page1dest" → merged page 1 (copy A p1),
// "page3dest" → merged page 2 (copy B p3).
//
// Copy A contributes (page 1 kept):
// "Page 1 - explicit dest" explicit dest to kept page
// "External URL" / "Next Page action" / "Remote PDF link" external
// "Chapter" (dest=page1dest) with only child "Section 2"
// Section 2 (dest=page3dest) survives because page3dest is valid
// (points to copy B's page 3 in the merged doc).
//
// Copy B contributes (page 3 kept):
// "External URL" / "Next Page action" / "Remote PDF link" external
// "Chapter" (dest=page1dest) with only child "Section 2"
// Copy B's "Chapter" has dest=page1dest which happens to be valid
// in the merged doc (copy A's page 1), so it cross-links there.
const loadingTask = getDocument(
buildGetDocumentParams("outlines_for_editor.pdf")
);
const pdfDoc = await loadingTask.promise;
const pdfDataB = await DefaultFileReaderFactory.fetch({
path: TEST_PDFS_PATH + "outlines_for_editor.pdf",
});
const data = await pdfDoc.extractPages([
{ document: null, includePages: [0] },
{ document: pdfDataB, includePages: [2] },
]);
await loadingTask.destroy();
const newLoadingTask = getDocument(data);
const newPdfDoc = await newLoadingTask.promise;
expect(newPdfDoc.numPages).toEqual(2);
const outline = await newPdfDoc.getOutline();
expect(Array.isArray(outline)).toEqual(true);
// 5 items from copy A + 4 items from copy B = 9 total.
expect(outline.length).toEqual(9);
// ---- Copy A items ----
expect(outline[0].title).toEqual("Page 1 - explicit dest");
expect(Array.isArray(outline[0].dest)).toEqual(true);
expect(outline[1].title).toEqual("External URL");
expect(outline[2].title).toEqual("Next Page action");
expect(outline[3].title).toEqual("Remote PDF link");
// "Chapter" from copy A: own dest (page1dest) is valid; the only
// surviving child is "Section 2" whose dest (page3dest) cross-links
// to copy B's page (merged page 2).
const chapterA = outline[4];
expect(chapterA.title).toEqual("Chapter");
expect(typeof chapterA.dest).toEqual("string"); // page1dest
expect(chapterA.items.length).toEqual(1);
expect(chapterA.items[0].title).toEqual("Section 2");
expect(typeof chapterA.items[0].dest).toEqual("string"); // page3dest
// ---- Copy B items ----
expect(outline[5].title).toEqual("External URL");
expect(outline[6].title).toEqual("Next Page action");
expect(outline[7].title).toEqual("Remote PDF link");
// "Chapter" from copy B: its original dest (page1dest) resolves to
// copy A's page 1 after merging, so it is kept (cross-document link).
const chapterB = outline[8];
expect(chapterB.title).toEqual("Chapter");
expect(typeof chapterB.dest).toEqual("string"); // page1dest → copy A p1
expect(chapterB.items.length).toEqual(1);
expect(chapterB.items[0].title).toEqual("Section 2");
expect(typeof chapterB.items[0].dest).toEqual("string"); // page3dest
// "Page 1 - explicit dest" from copy B should be absent (copy B's
// page 1 was not kept).
const titles = outline.map(i => i.title);
expect(titles.indexOf("Page 1 - explicit dest")).toEqual(0);
expect(titles.lastIndexOf("Page 1 - explicit dest")).toEqual(0);
// Neither copy contributes "Page 2 - named dest" or "No dest parent".
expect(titles).not.toContain("Page 2 - named dest");
expect(titles).not.toContain("No dest parent");
await newLoadingTask.destroy();
});
it("should produce no outline when the source PDF has none", async function () {
// tracemonkey.pdf has no outline at all.
const loadingTask = getDocument(tracemonkeyGetDocumentParams);
const pdfDoc = await loadingTask.promise;
const data = await pdfDoc.extractPages([{ document: null }]);
await loadingTask.destroy();
const newLoadingTask = getDocument(data);
const newPdfDoc = await newLoadingTask.promise;
const outline = await newPdfDoc.getOutline();
expect(outline).toEqual(null);
await newLoadingTask.destroy();
});
it("should rename conflicting named dests when both copies keep the page", async function () {
// Merge page 1 (index 0) from copy A with page 1 (index 0) from copy B
// (same PDF). Both copies have "page1dest" pointing to their page 1,
// and both pages are kept. The deduplication logic must rename the
// second occurrence so both named dests survive in the output.
const loadingTask = getDocument(
buildGetDocumentParams("outlines_for_editor.pdf")
);
const pdfDoc = await loadingTask.promise;
const pdfDataB = await DefaultFileReaderFactory.fetch({
path: TEST_PDFS_PATH + "outlines_for_editor.pdf",
});
const data = await pdfDoc.extractPages([
{ document: null, includePages: [0] },
{ document: pdfDataB, includePages: [0] },
]);
await loadingTask.destroy();
const newLoadingTask = getDocument(data);
const newPdfDoc = await newLoadingTask.promise;
expect(newPdfDoc.numPages).toEqual(2);
const outline = await newPdfDoc.getOutline();
expect(Array.isArray(outline)).toEqual(true);
// Copy A: "Page 1 - explicit dest", "External URL", "Next Page
// action", "Remote PDF link", "Chapter" (dest=page1dest)
// Copy B: same 5 items but "Chapter" dest is renamed.
expect(outline.length).toEqual(10);
// The "Chapter" items from the two copies must have different dest
// strings: one with the original "page1dest" and one with the renamed
// version (contains a suffix to avoid collisions).
const chapterItems = outline.filter(i => i.title === "Chapter");
expect(chapterItems.length).toEqual(2);
const chapterDests = chapterItems.map(i => i.dest);
expect(chapterDests[0]).not.toEqual(chapterDests[1]);
// One of them is the original name.
expect(chapterDests.includes("page1dest")).toEqual(true);
// The other is a renamed version that still exists in the doc.
const renamedDest = chapterDests.find(d => d !== "page1dest");
expect(typeof renamedDest).toEqual("string");
// Verify the "Page 1 - explicit dest" items: copy A uses an array dest
// pointing to its page, copy B uses its renamed page ref.
const page1Items = outline.filter(
i => i.title === "Page 1 - explicit dest"
);
expect(page1Items.length).toEqual(2);
expect(Array.isArray(page1Items[0].dest)).toEqual(true);
expect(Array.isArray(page1Items[1].dest)).toEqual(true);
await newLoadingTask.destroy();
});
});
});
});