mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-04-09 14:54:04 +02:00
Merge pull request #20905 from calixteman/reorganize_outlines
Add support for saving outlines after reorganize/merge (bug 2009574)
This commit is contained in:
commit
0ee557cd60
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -884,4 +884,5 @@
|
||||
!form_two_pages.pdf
|
||||
!outlines_se.pdf
|
||||
!radial_gradients.pdf
|
||||
!outlines_for_editor.pdf
|
||||
!mesh_shading_empty.pdf
|
||||
|
||||
110
test/pdfs/outlines_for_editor.pdf
Normal file
110
test/pdfs/outlines_for_editor.pdf
Normal 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
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user