mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-06-25 09:35:48 +02:00
Add the possibility to merge/update acroforms when merging/extracting (bug 2015853)
This commit is contained in:
parent
a4fcd830cc
commit
baf8647b1f
@ -208,6 +208,38 @@ function getParentToUpdate(dict, ref, xref) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deepCompare(a, b) {
|
||||||
|
if (a === b) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (a instanceof Dict && b instanceof Dict) {
|
||||||
|
if (a.size !== b.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const [key, value1] of a.getRawEntries()) {
|
||||||
|
const value2 = b.getRaw(key);
|
||||||
|
if (value2 === undefined || !deepCompare(value1, value2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0, ii = a.length; i < ii; i++) {
|
||||||
|
if (!deepCompare(a[i], b[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
const ROMAN_NUMBER_MAP = [
|
const ROMAN_NUMBER_MAP = [
|
||||||
"", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM",
|
"", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM",
|
||||||
@ -745,6 +777,7 @@ export {
|
|||||||
arrayBuffersToBytes,
|
arrayBuffersToBytes,
|
||||||
codePointIter,
|
codePointIter,
|
||||||
collectActions,
|
collectActions,
|
||||||
|
deepCompare,
|
||||||
encodeToXmlString,
|
encodeToXmlString,
|
||||||
escapePDFName,
|
escapePDFName,
|
||||||
escapeString,
|
escapeString,
|
||||||
|
|||||||
@ -17,13 +17,17 @@
|
|||||||
/** @typedef {import("../document.js").Page} Page */
|
/** @typedef {import("../document.js").Page} Page */
|
||||||
/** @typedef {import("../xref.js").XRef} XRef */
|
/** @typedef {import("../xref.js").XRef} XRef */
|
||||||
|
|
||||||
|
import {
|
||||||
|
deepCompare,
|
||||||
|
getInheritableProperty,
|
||||||
|
stringToAsciiOrUTF16BE,
|
||||||
|
} from "../core_utils.js";
|
||||||
import { Dict, isName, Name, Ref, RefSet, RefSetCache } from "../primitives.js";
|
import { Dict, isName, Name, Ref, RefSet, RefSetCache } from "../primitives.js";
|
||||||
import { getModificationDate, stringToPDFString } from "../../shared/util.js";
|
import { getModificationDate, stringToPDFString } from "../../shared/util.js";
|
||||||
import { incrementalUpdate, writeValue } from "../writer.js";
|
import { incrementalUpdate, writeValue } from "../writer.js";
|
||||||
import { NameTree, NumberTree } from "../name_number_tree.js";
|
import { NameTree, NumberTree } from "../name_number_tree.js";
|
||||||
import { BaseStream } from "../base_stream.js";
|
import { BaseStream } from "../base_stream.js";
|
||||||
import { StringStream } from "../stream.js";
|
import { StringStream } from "../stream.js";
|
||||||
import { stringToAsciiOrUTF16BE } from "../core_utils.js";
|
|
||||||
|
|
||||||
const MAX_LEAVES_PER_PAGES_NODE = 16;
|
const MAX_LEAVES_PER_PAGES_NODE = 16;
|
||||||
const MAX_IN_NAME_TREE_NODE = 64;
|
const MAX_IN_NAME_TREE_NODE = 64;
|
||||||
@ -60,6 +64,12 @@ class DocumentData {
|
|||||||
this.namespaces = null;
|
this.namespaces = null;
|
||||||
this.structTreeAF = null;
|
this.structTreeAF = null;
|
||||||
this.structTreePronunciationLexicon = [];
|
this.structTreePronunciationLexicon = [];
|
||||||
|
this.acroForm = null;
|
||||||
|
this.acroFormDefaultAppearance = "";
|
||||||
|
this.acroFormDefaultResources = null;
|
||||||
|
this.acroFormQ = 0;
|
||||||
|
this.hasSignatureAnnotations = false;
|
||||||
|
this.fieldToParent = new RefSetCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,6 +134,20 @@ class PDFEditor {
|
|||||||
|
|
||||||
structTreePronunciationLexicon = [];
|
structTreePronunciationLexicon = [];
|
||||||
|
|
||||||
|
fields = [];
|
||||||
|
|
||||||
|
acroFormDefaultAppearance = "";
|
||||||
|
|
||||||
|
acroFormDefaultResources = null;
|
||||||
|
|
||||||
|
acroFormNeedAppearances = false;
|
||||||
|
|
||||||
|
acroFormSigFlags = 0;
|
||||||
|
|
||||||
|
acroFormCalculationOrder = null;
|
||||||
|
|
||||||
|
acroFormQ = 0;
|
||||||
|
|
||||||
constructor({ useObjectStreams = true, title = "", author = "" } = {}) {
|
constructor({ useObjectStreams = true, title = "", author = "" } = {}) {
|
||||||
[this.rootRef, this.rootDict] = this.newDict;
|
[this.rootRef, this.rootDict] = this.newDict;
|
||||||
[this.infoRef, this.infoDict] = this.newDict;
|
[this.infoRef, this.infoDict] = this.newDict;
|
||||||
@ -625,6 +649,7 @@ class PDFEditor {
|
|||||||
|
|
||||||
this.#fixPostponedRefCopies(allDocumentData);
|
this.#fixPostponedRefCopies(allDocumentData);
|
||||||
await this.#mergeStructTrees(allDocumentData);
|
await this.#mergeStructTrees(allDocumentData);
|
||||||
|
await this.#mergeAcroForms(allDocumentData);
|
||||||
|
|
||||||
return this.writePDF();
|
return this.writePDF();
|
||||||
}
|
}
|
||||||
@ -648,6 +673,9 @@ class PDFEditor {
|
|||||||
pdfManager
|
pdfManager
|
||||||
.ensureCatalog("structTreeRoot")
|
.ensureCatalog("structTreeRoot")
|
||||||
.then(structTreeRoot => (documentData.structTreeRoot = structTreeRoot)),
|
.then(structTreeRoot => (documentData.structTreeRoot = structTreeRoot)),
|
||||||
|
pdfManager
|
||||||
|
.ensureCatalog("acroForm")
|
||||||
|
.then(acroForm => (documentData.acroForm = acroForm)),
|
||||||
]);
|
]);
|
||||||
const structTreeRoot = documentData.structTreeRoot;
|
const structTreeRoot = documentData.structTreeRoot;
|
||||||
if (structTreeRoot) {
|
if (structTreeRoot) {
|
||||||
@ -683,7 +711,12 @@ class PDFEditor {
|
|||||||
async #postCollectPageData(pageData) {
|
async #postCollectPageData(pageData) {
|
||||||
const {
|
const {
|
||||||
page: { xref, annotations },
|
page: { xref, annotations },
|
||||||
documentData: { pagesMap, destinations, usedNamedDestinations },
|
documentData: {
|
||||||
|
pagesMap,
|
||||||
|
destinations,
|
||||||
|
usedNamedDestinations,
|
||||||
|
fieldToParent,
|
||||||
|
},
|
||||||
} = pageData;
|
} = pageData;
|
||||||
|
|
||||||
if (!annotations) {
|
if (!annotations) {
|
||||||
@ -693,6 +726,7 @@ class PDFEditor {
|
|||||||
const promises = [];
|
const promises = [];
|
||||||
let newAnnotations = [];
|
let newAnnotations = [];
|
||||||
let newIndex = 0;
|
let newIndex = 0;
|
||||||
|
let { hasSignatureAnnotations } = pageData.documentData;
|
||||||
|
|
||||||
// Filter out annotations that are linking to deleted pages.
|
// Filter out annotations that are linking to deleted pages.
|
||||||
for (const annotationRef of annotations) {
|
for (const annotationRef of annotations) {
|
||||||
@ -700,6 +734,20 @@ class PDFEditor {
|
|||||||
promises.push(
|
promises.push(
|
||||||
xref.fetchIfRefAsync(annotationRef).then(async annotationDict => {
|
xref.fetchIfRefAsync(annotationRef).then(async annotationDict => {
|
||||||
if (!isName(annotationDict.get("Subtype"), "Link")) {
|
if (!isName(annotationDict.get("Subtype"), "Link")) {
|
||||||
|
if (isName(annotationDict.get("Subtype"), "Widget")) {
|
||||||
|
hasSignatureAnnotations ||= isName(
|
||||||
|
annotationDict.get("FT"),
|
||||||
|
"Sig"
|
||||||
|
);
|
||||||
|
const parentRef = annotationDict.get("Parent") || null;
|
||||||
|
// We remove the parent to avoid visiting it when cloning the
|
||||||
|
// annotation.
|
||||||
|
// It'll be fixed later in #mergeAcroForms when merging the
|
||||||
|
// AcroForms.
|
||||||
|
annotationDict.delete("Parent");
|
||||||
|
fieldToParent.put(annotationRef, parentRef);
|
||||||
|
}
|
||||||
|
|
||||||
newAnnotations[newAnnotationIndex] = annotationRef;
|
newAnnotations[newAnnotationIndex] = annotationRef;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -735,6 +783,7 @@ class PDFEditor {
|
|||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
newAnnotations = newAnnotations.filter(annot => !!annot);
|
newAnnotations = newAnnotations.filter(annot => !!annot);
|
||||||
pageData.annotations = newAnnotations.length > 0 ? newAnnotations : null;
|
pageData.annotations = newAnnotations.length > 0 ? newAnnotations : null;
|
||||||
|
pageData.documentData.hasSignatureAnnotations ||= hasSignatureAnnotations;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -813,46 +862,52 @@ class PDFEditor {
|
|||||||
}
|
}
|
||||||
const pageRef = this.newPages[i];
|
const pageRef = this.newPages[i];
|
||||||
const pageDict = this.xref[pageRef.num];
|
const pageDict = this.xref[pageRef.num];
|
||||||
|
const visited = new RefSet();
|
||||||
|
visited.put(pageRef);
|
||||||
|
|
||||||
// Visit the new page in order to collect used StructParent entries.
|
// Visit the new page in order to collect used StructParent entries.
|
||||||
this.#visitObject(pageDict, dict => {
|
this.#visitObject(
|
||||||
const structParent =
|
pageDict,
|
||||||
dict.get("StructParent") ?? dict.get("StructParents");
|
dict => {
|
||||||
if (typeof structParent !== "number") {
|
const structParent =
|
||||||
return;
|
dict.get("StructParent") ?? dict.get("StructParents");
|
||||||
}
|
if (typeof structParent !== "number") {
|
||||||
usedStructParents.add(structParent);
|
return;
|
||||||
let parent = parentTree.get(structParent);
|
}
|
||||||
const parentRef = parent instanceof Ref ? parent : null;
|
usedStructParents.add(structParent);
|
||||||
if (parentRef) {
|
let parent = parentTree.get(structParent);
|
||||||
const array = xref.fetch(parentRef);
|
const parentRef = parent instanceof Ref ? parent : null;
|
||||||
if (Array.isArray(array)) {
|
if (parentRef) {
|
||||||
parent = array;
|
const array = xref.fetch(parentRef);
|
||||||
|
if (Array.isArray(array)) {
|
||||||
|
parent = array;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(parent) && parent.every(ref => ref === null)) {
|
||||||
|
parent = null;
|
||||||
|
}
|
||||||
|
if (!parent) {
|
||||||
|
if (dict.has("StructParent")) {
|
||||||
|
dict.delete("StructParent");
|
||||||
|
} else {
|
||||||
|
dict.delete("StructParents");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let newStructParent = oldStructParentMapping.get(structParent);
|
||||||
|
if (newStructParent === undefined) {
|
||||||
|
newStructParent = newStructParentId++;
|
||||||
|
oldStructParentMapping.set(structParent, newStructParent);
|
||||||
|
newParentTree.set(newStructParent, [oldRefMapping, parent]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (Array.isArray(parent) && parent.every(ref => ref === null)) {
|
|
||||||
parent = null;
|
|
||||||
}
|
|
||||||
if (!parent) {
|
|
||||||
if (dict.has("StructParent")) {
|
if (dict.has("StructParent")) {
|
||||||
dict.delete("StructParent");
|
dict.set("StructParent", newStructParent);
|
||||||
} else {
|
} else {
|
||||||
dict.delete("StructParents");
|
dict.set("StructParents", newStructParent);
|
||||||
}
|
}
|
||||||
return;
|
},
|
||||||
}
|
visited
|
||||||
let newStructParent = oldStructParentMapping.get(structParent);
|
);
|
||||||
if (newStructParent === undefined) {
|
|
||||||
newStructParent = newStructParentId++;
|
|
||||||
oldStructParentMapping.set(structParent, newStructParent);
|
|
||||||
newParentTree.set(newStructParent, [oldRefMapping, parent]);
|
|
||||||
}
|
|
||||||
if (dict.has("StructParent")) {
|
|
||||||
dict.set("StructParent", newStructParent);
|
|
||||||
} else {
|
|
||||||
dict.set("StructParents", newStructParent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -1159,6 +1214,361 @@ class PDFEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #mergeAcroForms(allDocumentData) {
|
||||||
|
this.#setAcroFormDefaultBasicValues(allDocumentData);
|
||||||
|
this.#setAcroFormDefaultAppearance(allDocumentData);
|
||||||
|
this.#setAcroFormQ(allDocumentData);
|
||||||
|
await this.#setAcroFormDefaultResources(allDocumentData);
|
||||||
|
const newFields = this.fields;
|
||||||
|
for (const documentData of allDocumentData) {
|
||||||
|
let fields = documentData.acroForm?.get("Fields") || null;
|
||||||
|
if (!fields && documentData.fieldToParent.size > 0) {
|
||||||
|
fields = this.#fixFields(
|
||||||
|
documentData.fieldToParent,
|
||||||
|
documentData.document.xref
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(fields) && fields.length > 0) {
|
||||||
|
this.currentDocument = documentData;
|
||||||
|
await this.#cloneFields(newFields, fields);
|
||||||
|
this.currentDocument = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#setAcroFormQ(allDocumentData) {
|
||||||
|
let firstQ = 0;
|
||||||
|
let firstDocData = null;
|
||||||
|
for (const documentData of allDocumentData) {
|
||||||
|
const q = documentData.acroForm?.get("Q");
|
||||||
|
if (typeof q !== "number" || q === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstDocData?.acroFormQ > 0) {
|
||||||
|
documentData.acroFormQ = q;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstQ === 0) {
|
||||||
|
firstQ = q;
|
||||||
|
firstDocData = documentData;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (q === firstQ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
firstDocData.acroFormQ ||= firstQ;
|
||||||
|
documentData.acroFormQ = q;
|
||||||
|
firstQ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstQ > 0) {
|
||||||
|
this.acroFormQ = firstQ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#setAcroFormDefaultBasicValues(allDocumentData) {
|
||||||
|
let sigFlags = 0;
|
||||||
|
let needAppearances = false;
|
||||||
|
const calculationOrder = [];
|
||||||
|
for (const documentData of allDocumentData) {
|
||||||
|
if (!documentData.acroForm) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sf = documentData.acroForm.get("SigFlags");
|
||||||
|
if (typeof sf === "number" && documentData.hasSignatureAnnotations) {
|
||||||
|
sigFlags |= sf;
|
||||||
|
}
|
||||||
|
if (documentData.acroForm.get("NeedAppearances") === true) {
|
||||||
|
needAppearances = true;
|
||||||
|
}
|
||||||
|
const co = documentData.acroForm.get("CO") || null;
|
||||||
|
if (!Array.isArray(co)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { oldRefMapping } = documentData;
|
||||||
|
for (const coRef of co) {
|
||||||
|
const newCoRef = oldRefMapping.get(coRef);
|
||||||
|
if (newCoRef) {
|
||||||
|
calculationOrder.push(newCoRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.acroFormSigFlags = sigFlags;
|
||||||
|
this.acroFormNeedAppearances = needAppearances;
|
||||||
|
this.acroFormCalculationOrder =
|
||||||
|
calculationOrder.length > 0 ? calculationOrder : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#setAcroFormDefaultAppearance(allDocumentData) {
|
||||||
|
// If all the DAs are the same we just use it in the AcroForm. Otherwise, we
|
||||||
|
// set the DA for each documentData and use for any annotations that don't
|
||||||
|
// have their own DA.
|
||||||
|
let firstDA = null;
|
||||||
|
let firstDocData = null;
|
||||||
|
for (const documentData of allDocumentData) {
|
||||||
|
const da = documentData.acroForm?.get("DA") || null;
|
||||||
|
if (!da || typeof da !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstDocData?.acroFormDefaultAppearance) {
|
||||||
|
documentData.acroFormDefaultAppearance = da;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstDA) {
|
||||||
|
firstDA = da;
|
||||||
|
firstDocData = documentData;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (da === firstDA) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
firstDocData.acroFormDefaultAppearance ||= firstDA;
|
||||||
|
documentData.acroFormDefaultAppearance = da;
|
||||||
|
firstDA = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstDA) {
|
||||||
|
this.acroFormDefaultAppearance = firstDA;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #setAcroFormDefaultResources(allDocumentData) {
|
||||||
|
let firstDR = null;
|
||||||
|
let firstDRRef = null;
|
||||||
|
let firstDocData = null;
|
||||||
|
for (const documentData of allDocumentData) {
|
||||||
|
const dr = documentData.acroForm?.get("DR") || null;
|
||||||
|
if (!dr || !(dr instanceof Dict)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (firstDocData?.acroFormDefaultResources) {
|
||||||
|
documentData.acroFormDefaultResources = dr;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!firstDR) {
|
||||||
|
firstDR = dr;
|
||||||
|
firstDRRef = documentData.acroForm.getRaw("DR");
|
||||||
|
firstDocData = documentData;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (deepCompare(firstDR, dr)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
firstDocData.acroFormDefaultResources ||= firstDR;
|
||||||
|
documentData.acroFormDefaultResources = dr;
|
||||||
|
firstDR = null;
|
||||||
|
firstDRRef = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstDR) {
|
||||||
|
this.currentDocument = firstDocData;
|
||||||
|
this.acroFormDefaultResources = await this.#collectDependencies(
|
||||||
|
firstDRRef,
|
||||||
|
true,
|
||||||
|
firstDocData.document.xref
|
||||||
|
);
|
||||||
|
this.currentDocument = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the document has some fields but no Fields entry in the AcroForm, we
|
||||||
|
* need to fix that by creating a Fields entry with the oldest parent field
|
||||||
|
* for each field.
|
||||||
|
* @param {Map<Ref, Ref>} fieldToParent
|
||||||
|
* @param {XRef} xref
|
||||||
|
* @returns {Array<Ref>}
|
||||||
|
*/
|
||||||
|
#fixFields(fieldToParent, xref) {
|
||||||
|
const newFields = [];
|
||||||
|
const processed = new RefSet();
|
||||||
|
for (const [fieldRef, parentRef] of fieldToParent) {
|
||||||
|
if (!parentRef) {
|
||||||
|
newFields.push(fieldRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let parent = parentRef;
|
||||||
|
let lastNonNullParent = parentRef;
|
||||||
|
while (true) {
|
||||||
|
parent = xref.fetchIfRef(parent)?.get("Parent") || null;
|
||||||
|
if (!parent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastNonNullParent = parent;
|
||||||
|
}
|
||||||
|
if (!processed.has(lastNonNullParent)) {
|
||||||
|
newFields.push(lastNonNullParent);
|
||||||
|
processed.put(lastNonNullParent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #cloneFields(newFields, fields) {
|
||||||
|
const processed = new RefSet();
|
||||||
|
const stack = [
|
||||||
|
{
|
||||||
|
kids: fields,
|
||||||
|
newKids: newFields,
|
||||||
|
pos: 0,
|
||||||
|
oldParentRef: null,
|
||||||
|
parentRef: null,
|
||||||
|
parent: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const {
|
||||||
|
document: { xref },
|
||||||
|
oldRefMapping,
|
||||||
|
fieldToParent,
|
||||||
|
acroFormDefaultAppearance,
|
||||||
|
acroFormDefaultResources,
|
||||||
|
acroFormQ,
|
||||||
|
} = this.currentDocument;
|
||||||
|
const daToFix = [];
|
||||||
|
const drToFix = [];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const data = stack.at(-1);
|
||||||
|
const { kids, newKids, parent, pos } = data;
|
||||||
|
if (pos === kids.length) {
|
||||||
|
stack.pop();
|
||||||
|
if (newKids.length === 0 || !parent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDict = (this.xref[data.parentRef.num] =
|
||||||
|
this.cloneDict(parent));
|
||||||
|
parentDict.delete("Parent");
|
||||||
|
parentDict.delete("Kids");
|
||||||
|
await this.#collectDependencies(parentDict, false, xref);
|
||||||
|
parentDict.set("Kids", newKids);
|
||||||
|
|
||||||
|
if (stack.length > 0) {
|
||||||
|
const lastData = stack.at(-1);
|
||||||
|
if (!lastData.parentRef && lastData.oldParentRef) {
|
||||||
|
const parentRef = (lastData.parentRef = this.newRef);
|
||||||
|
parentDict.set("Parent", parentRef);
|
||||||
|
oldRefMapping.put(lastData.oldParentRef, parentRef);
|
||||||
|
}
|
||||||
|
lastData.newKids.push(data.parentRef);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const oldKidRef = kids[data.pos++];
|
||||||
|
if (!(oldKidRef instanceof Ref) || processed.has(oldKidRef)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
processed.put(oldKidRef);
|
||||||
|
const kid = xref.fetchIfRef(oldKidRef);
|
||||||
|
if (kid.has("Kids")) {
|
||||||
|
const kidsArray = kid.get("Kids");
|
||||||
|
if (!Array.isArray(kidsArray)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack.push({
|
||||||
|
kids: kidsArray,
|
||||||
|
newKids: [],
|
||||||
|
pos: 0,
|
||||||
|
oldParentRef: oldKidRef,
|
||||||
|
parentRef: null,
|
||||||
|
parent: kid,
|
||||||
|
});
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fieldToParent.has(oldKidRef)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newRef = oldRefMapping.get(oldKidRef);
|
||||||
|
if (!newRef) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
newKids.push(newRef);
|
||||||
|
if (!data.parentRef && data.oldParentRef) {
|
||||||
|
data.parentRef = this.newRef;
|
||||||
|
oldRefMapping.put(data.oldParentRef, data.parentRef);
|
||||||
|
}
|
||||||
|
const newKid = this.xref[newRef.num];
|
||||||
|
if (data.parentRef) {
|
||||||
|
newKid.set("Parent", data.parentRef);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
acroFormDefaultAppearance &&
|
||||||
|
isName(newKid.get("FT"), "Tx") &&
|
||||||
|
!newKid.has("DA")
|
||||||
|
) {
|
||||||
|
// Fix the DA later since we need to have all the fields tree.
|
||||||
|
daToFix.push(newKid);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
acroFormDefaultResources &&
|
||||||
|
!newKid.has("Kids") &&
|
||||||
|
newKid.get("AP") instanceof Dict
|
||||||
|
) {
|
||||||
|
// Fix the DR later since we need to have all the fields tree.
|
||||||
|
drToFix.push(newKid);
|
||||||
|
}
|
||||||
|
if (acroFormQ && !newKid.has("Q")) {
|
||||||
|
newKid.set("Q", acroFormQ);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of daToFix) {
|
||||||
|
const da = getInheritableProperty({ dict: field, key: "DA" });
|
||||||
|
if (!da) {
|
||||||
|
// No DA in a parent field, we can set the default one.
|
||||||
|
field.set("DA", acroFormDefaultAppearance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resourcesValuesCache = new Map();
|
||||||
|
for (const field of drToFix) {
|
||||||
|
const ap = field.get("AP");
|
||||||
|
for (const value of ap.getValues()) {
|
||||||
|
if (!(value instanceof BaseStream)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let resources = value.dict.getRaw("Resources");
|
||||||
|
if (!resources) {
|
||||||
|
const newResourcesRef =
|
||||||
|
await resourcesValuesCache.getOrInsertComputed(
|
||||||
|
acroFormDefaultResources,
|
||||||
|
() => this.#cloneObject(acroFormDefaultResources, xref)
|
||||||
|
);
|
||||||
|
value.dict.set("Resources", newResourcesRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = xref.fetchIfRef(resources);
|
||||||
|
for (const [
|
||||||
|
resKey,
|
||||||
|
resValue,
|
||||||
|
] of acroFormDefaultResources.getRawEntries()) {
|
||||||
|
if (!resources.has(resKey)) {
|
||||||
|
let newResValue = resValue;
|
||||||
|
if (resValue instanceof Ref) {
|
||||||
|
newResValue = await this.#collectDependencies(
|
||||||
|
resValue,
|
||||||
|
true,
|
||||||
|
xref
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
resValue instanceof Dict ||
|
||||||
|
resValue instanceof BaseStream ||
|
||||||
|
Array.isArray(resValue)
|
||||||
|
) {
|
||||||
|
newResValue = await resourcesValuesCache.getOrInsertComputed(
|
||||||
|
resValue,
|
||||||
|
() => this.#cloneObject(resValue, xref)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resources.set(resKey, newResValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #collectPageLabels() {
|
async #collectPageLabels() {
|
||||||
// We can only preserve page labels when editing a single PDF file.
|
// We can only preserve page labels when editing a single PDF file.
|
||||||
// This is consistent with behavior in Adobe Acrobat.
|
// This is consistent with behavior in Adobe Acrobat.
|
||||||
@ -1484,6 +1894,33 @@ class PDFEditor {
|
|||||||
rootDict.set("StructTreeRoot", structTreeRef);
|
rootDict.set("StructTreeRoot", structTreeRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#makeAcroForm() {
|
||||||
|
if (this.fields.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { rootDict } = this;
|
||||||
|
const acroFormRef = this.newRef;
|
||||||
|
const acroForm = (this.xref[acroFormRef.num] = new Dict());
|
||||||
|
rootDict.set("AcroForm", acroFormRef);
|
||||||
|
acroForm.set("Fields", this.fields);
|
||||||
|
if (this.acroFormNeedAppearances) {
|
||||||
|
acroForm.set("NeedAppearances", true);
|
||||||
|
}
|
||||||
|
if (this.acroFormSigFlags > 0) {
|
||||||
|
acroForm.set("SigFlags", this.acroFormSigFlags);
|
||||||
|
}
|
||||||
|
acroForm.setIfArray("CO", this.acroFormCalculationOrder);
|
||||||
|
acroForm.setIfDict("DR", this.acroFormDefaultResources);
|
||||||
|
if (this.acroFormDefaultAppearance) {
|
||||||
|
acroForm.set("DA", this.acroFormDefaultAppearance);
|
||||||
|
}
|
||||||
|
if (this.acroFormQ > 0) {
|
||||||
|
acroForm.set("Q", this.acroFormQ);
|
||||||
|
}
|
||||||
|
// We don't merge XFA stuff because it'd require to parse, extract and merge
|
||||||
|
// all the data, which is a lot of work for a deprecated feature (i.e. XFA).
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the root dictionary.
|
* Create the root dictionary.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
@ -1492,6 +1929,7 @@ class PDFEditor {
|
|||||||
const { rootDict } = this;
|
const { rootDict } = this;
|
||||||
rootDict.setIfName("Type", "Catalog");
|
rootDict.setIfName("Type", "Catalog");
|
||||||
rootDict.setIfName("Version", this.version);
|
rootDict.setIfName("Version", this.version);
|
||||||
|
this.#makeAcroForm();
|
||||||
this.#makePageTree();
|
this.#makePageTree();
|
||||||
this.#makePageLabelsTree();
|
this.#makePageLabelsTree();
|
||||||
this.#makeDestinationsTree();
|
this.#makeDestinationsTree();
|
||||||
|
|||||||
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -878,3 +878,4 @@
|
|||||||
!two_pages.pdf
|
!two_pages.pdf
|
||||||
!sci-notation.pdf
|
!sci-notation.pdf
|
||||||
!nested_outline.pdf
|
!nested_outline.pdf
|
||||||
|
!form_two_pages.pdf
|
||||||
|
|||||||
BIN
test/pdfs/form_two_pages.pdf
Normal file
BIN
test/pdfs/form_two_pages.pdf
Normal file
Binary file not shown.
@ -6223,5 +6223,112 @@ small scripts as well as for`);
|
|||||||
await loadingTask.destroy();
|
await loadingTask.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("AcroForm", function () {
|
||||||
|
it("extract page 2 and check AcroForm Fields T entries", async function () {
|
||||||
|
let loadingTask = getDocument(
|
||||||
|
buildGetDocumentParams("form_two_pages.pdf")
|
||||||
|
);
|
||||||
|
let pdfDoc = await loadingTask.promise;
|
||||||
|
|
||||||
|
// Collect the fieldNames (derived from T entries) of annotations on
|
||||||
|
// page 2 of the original document.
|
||||||
|
const origPage2 = await pdfDoc.getPage(2);
|
||||||
|
const origAnnotations = await origPage2.getAnnotations();
|
||||||
|
const origFieldNames = origAnnotations
|
||||||
|
.filter(a => a.fieldName)
|
||||||
|
.map(a => a.fieldName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
// Extract only page 2 (0-based index = 1).
|
||||||
|
const data = await pdfDoc.extractPages([
|
||||||
|
{ document: null, includePages: [1] },
|
||||||
|
]);
|
||||||
|
await loadingTask.destroy();
|
||||||
|
|
||||||
|
loadingTask = getDocument(data);
|
||||||
|
pdfDoc = await loadingTask.promise;
|
||||||
|
|
||||||
|
expect(pdfDoc.numPages).toEqual(1);
|
||||||
|
|
||||||
|
// The AcroForm Fields in the new PDF should correspond exactly to the
|
||||||
|
// annotations that were on page 2 of the original document, with the
|
||||||
|
// same T entries (encoded in fieldName).
|
||||||
|
const page = await pdfDoc.getPage(1);
|
||||||
|
const annotations = await page.getAnnotations();
|
||||||
|
const fieldNames = annotations
|
||||||
|
.filter(a => a.fieldName)
|
||||||
|
.map(a => a.fieldName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
expect(fieldNames).toEqual(origFieldNames);
|
||||||
|
|
||||||
|
// Also verify the AcroForm Fields via getFieldObjects, which directly
|
||||||
|
// reflects the T entries of the fields in the AcroForm dictionary.
|
||||||
|
const fieldObjects = await pdfDoc.getFieldObjects();
|
||||||
|
expect(fieldObjects).not.toBeNull();
|
||||||
|
expect(Object.keys(fieldObjects).sort()).toEqual(origFieldNames);
|
||||||
|
|
||||||
|
await loadingTask.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merge pages 2 and 1 and check AcroForm Fields T entries", async function () {
|
||||||
|
let loadingTask = getDocument(
|
||||||
|
buildGetDocumentParams("form_two_pages.pdf")
|
||||||
|
);
|
||||||
|
let pdfDoc = await loadingTask.promise;
|
||||||
|
|
||||||
|
// Collect fieldNames from each page of the original document.
|
||||||
|
const origPage1 = await pdfDoc.getPage(1);
|
||||||
|
const origPage1FieldNames = (await origPage1.getAnnotations())
|
||||||
|
.filter(a => a.fieldName)
|
||||||
|
.map(a => a.fieldName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const origPage2 = await pdfDoc.getPage(2);
|
||||||
|
const origPage2FieldNames = (await origPage2.getAnnotations())
|
||||||
|
.filter(a => a.fieldName)
|
||||||
|
.map(a => a.fieldName)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
// Extract page 2 first, then page 1.
|
||||||
|
const data = await pdfDoc.extractPages([
|
||||||
|
{ document: null, includePages: [1] },
|
||||||
|
{ document: null, includePages: [0] },
|
||||||
|
]);
|
||||||
|
await loadingTask.destroy();
|
||||||
|
|
||||||
|
loadingTask = getDocument(data);
|
||||||
|
pdfDoc = await loadingTask.promise;
|
||||||
|
|
||||||
|
expect(pdfDoc.numPages).toEqual(2);
|
||||||
|
|
||||||
|
// Page 1 of the new PDF should have the fields from original page 2.
|
||||||
|
const page1 = await pdfDoc.getPage(1);
|
||||||
|
const page1FieldNames = (await page1.getAnnotations())
|
||||||
|
.filter(a => a.fieldName)
|
||||||
|
.map(a => a.fieldName)
|
||||||
|
.sort();
|
||||||
|
expect(page1FieldNames).toEqual(origPage2FieldNames);
|
||||||
|
|
||||||
|
// Page 2 of the new PDF should have the fields from original page 1.
|
||||||
|
const page2 = await pdfDoc.getPage(2);
|
||||||
|
const page2FieldNames = (await page2.getAnnotations())
|
||||||
|
.filter(a => a.fieldName)
|
||||||
|
.map(a => a.fieldName)
|
||||||
|
.sort();
|
||||||
|
expect(page2FieldNames).toEqual(origPage1FieldNames);
|
||||||
|
|
||||||
|
// The AcroForm Fields should contain all fields from both pages.
|
||||||
|
const fieldObjects = await pdfDoc.getFieldObjects();
|
||||||
|
expect(fieldObjects).not.toBeNull();
|
||||||
|
const allOrigFieldNames = [
|
||||||
|
...new Set([...origPage1FieldNames, ...origPage2FieldNames]),
|
||||||
|
].sort();
|
||||||
|
expect(Object.keys(fieldObjects).sort()).toEqual(allOrigFieldNames);
|
||||||
|
|
||||||
|
await loadingTask.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
arrayBuffersToBytes,
|
arrayBuffersToBytes,
|
||||||
|
deepCompare,
|
||||||
encodeToXmlString,
|
encodeToXmlString,
|
||||||
escapePDFName,
|
escapePDFName,
|
||||||
escapeString,
|
escapeString,
|
||||||
@ -474,6 +475,103 @@ describe("core_utils", function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("deepCompare", function () {
|
||||||
|
it("should return true for the same reference", function () {
|
||||||
|
const dict = new Dict();
|
||||||
|
expect(deepCompare(dict, dict)).toBeTrue();
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(deepCompare(arr, arr)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for identical primitive values", function () {
|
||||||
|
expect(deepCompare(1, 1)).toBeTrue();
|
||||||
|
expect(deepCompare("hello", "hello")).toBeTrue();
|
||||||
|
expect(deepCompare(null, null)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for different primitive values", function () {
|
||||||
|
expect(deepCompare(1, 2)).toBeFalse();
|
||||||
|
expect(deepCompare("hello", "world")).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for two equal empty Dicts", function () {
|
||||||
|
expect(deepCompare(new Dict(), new Dict())).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for Dicts with different sizes", function () {
|
||||||
|
const a = new Dict();
|
||||||
|
a.set("key", 1);
|
||||||
|
expect(deepCompare(a, new Dict())).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for Dicts with same Ref values", function () {
|
||||||
|
const ref = Ref.get(10, 0);
|
||||||
|
const a = new Dict();
|
||||||
|
a.set("Foo", ref);
|
||||||
|
const b = new Dict();
|
||||||
|
b.set("Foo", ref);
|
||||||
|
expect(deepCompare(a, b)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for Dicts with different Ref values", function () {
|
||||||
|
const a = new Dict();
|
||||||
|
a.set("Foo", Ref.get(10, 0));
|
||||||
|
const b = new Dict();
|
||||||
|
b.set("Foo", Ref.get(20, 0));
|
||||||
|
expect(deepCompare(a, b)).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for Dicts with different numeric values", function () {
|
||||||
|
const a = new Dict();
|
||||||
|
a.set("Foo", 1);
|
||||||
|
const b = new Dict();
|
||||||
|
b.set("Foo", 2);
|
||||||
|
expect(deepCompare(a, b)).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for equal nested Dicts", function () {
|
||||||
|
const inner1 = new Dict();
|
||||||
|
inner1.set("Bar", Ref.get(5, 0));
|
||||||
|
const outer1 = new Dict();
|
||||||
|
outer1.set("Foo", inner1);
|
||||||
|
|
||||||
|
const inner2 = new Dict();
|
||||||
|
inner2.set("Bar", Ref.get(5, 0));
|
||||||
|
const outer2 = new Dict();
|
||||||
|
outer2.set("Foo", inner2);
|
||||||
|
|
||||||
|
expect(deepCompare(outer1, outer2)).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for Dicts with the same key but different nested Dicts", function () {
|
||||||
|
const inner1 = new Dict();
|
||||||
|
inner1.set("Bar", Ref.get(5, 0));
|
||||||
|
const outer1 = new Dict();
|
||||||
|
outer1.set("Foo", inner1);
|
||||||
|
|
||||||
|
const inner2 = new Dict();
|
||||||
|
inner2.set("Bar", Ref.get(99, 0));
|
||||||
|
const outer2 = new Dict();
|
||||||
|
outer2.set("Foo", inner2);
|
||||||
|
|
||||||
|
expect(deepCompare(outer1, outer2)).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for equal arrays", function () {
|
||||||
|
const ref = Ref.get(1, 0);
|
||||||
|
expect(deepCompare([ref, ref], [ref, ref])).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for arrays with different lengths", function () {
|
||||||
|
const ref = Ref.get(1, 0);
|
||||||
|
expect(deepCompare([ref, ref], [ref])).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for arrays with different values", function () {
|
||||||
|
expect(deepCompare([Ref.get(1, 0)], [Ref.get(2, 0)])).toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getSizeInBytes", function () {
|
describe("getSizeInBytes", function () {
|
||||||
it("should get the size in bytes to use to represent a positive integer", function () {
|
it("should get the size in bytes to use to represent a positive integer", function () {
|
||||||
expect(getSizeInBytes(0)).toEqual(0);
|
expect(getSizeInBytes(0)).toEqual(0);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user