diff --git a/src/core/core_utils.js b/src/core/core_utils.js index d0d801fb5..287547616 100644 --- a/src/core/core_utils.js +++ b/src/core/core_utils.js @@ -208,6 +208,38 @@ function getParentToUpdate(dict, ref, xref) { 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 const ROMAN_NUMBER_MAP = [ "", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM", @@ -745,6 +777,7 @@ export { arrayBuffersToBytes, codePointIter, collectActions, + deepCompare, encodeToXmlString, escapePDFName, escapeString, diff --git a/src/core/editor/pdf_editor.js b/src/core/editor/pdf_editor.js index aca8649b8..470913817 100644 --- a/src/core/editor/pdf_editor.js +++ b/src/core/editor/pdf_editor.js @@ -17,13 +17,17 @@ /** @typedef {import("../document.js").Page} Page */ /** @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 { getModificationDate, stringToPDFString } from "../../shared/util.js"; import { incrementalUpdate, writeValue } from "../writer.js"; import { NameTree, NumberTree } from "../name_number_tree.js"; import { BaseStream } from "../base_stream.js"; import { StringStream } from "../stream.js"; -import { stringToAsciiOrUTF16BE } from "../core_utils.js"; const MAX_LEAVES_PER_PAGES_NODE = 16; const MAX_IN_NAME_TREE_NODE = 64; @@ -60,6 +64,12 @@ class DocumentData { this.namespaces = null; this.structTreeAF = null; 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 = []; + fields = []; + + acroFormDefaultAppearance = ""; + + acroFormDefaultResources = null; + + acroFormNeedAppearances = false; + + acroFormSigFlags = 0; + + acroFormCalculationOrder = null; + + acroFormQ = 0; + constructor({ useObjectStreams = true, title = "", author = "" } = {}) { [this.rootRef, this.rootDict] = this.newDict; [this.infoRef, this.infoDict] = this.newDict; @@ -625,6 +649,7 @@ class PDFEditor { this.#fixPostponedRefCopies(allDocumentData); await this.#mergeStructTrees(allDocumentData); + await this.#mergeAcroForms(allDocumentData); return this.writePDF(); } @@ -648,6 +673,9 @@ class PDFEditor { pdfManager .ensureCatalog("structTreeRoot") .then(structTreeRoot => (documentData.structTreeRoot = structTreeRoot)), + pdfManager + .ensureCatalog("acroForm") + .then(acroForm => (documentData.acroForm = acroForm)), ]); const structTreeRoot = documentData.structTreeRoot; if (structTreeRoot) { @@ -683,7 +711,12 @@ class PDFEditor { async #postCollectPageData(pageData) { const { page: { xref, annotations }, - documentData: { pagesMap, destinations, usedNamedDestinations }, + documentData: { + pagesMap, + destinations, + usedNamedDestinations, + fieldToParent, + }, } = pageData; if (!annotations) { @@ -693,6 +726,7 @@ class PDFEditor { const promises = []; let newAnnotations = []; let newIndex = 0; + let { hasSignatureAnnotations } = pageData.documentData; // Filter out annotations that are linking to deleted pages. for (const annotationRef of annotations) { @@ -700,6 +734,20 @@ class PDFEditor { promises.push( xref.fetchIfRefAsync(annotationRef).then(async annotationDict => { 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; return; } @@ -735,6 +783,7 @@ class PDFEditor { await Promise.all(promises); newAnnotations = newAnnotations.filter(annot => !!annot); pageData.annotations = newAnnotations.length > 0 ? newAnnotations : null; + pageData.documentData.hasSignatureAnnotations ||= hasSignatureAnnotations; } /** @@ -813,46 +862,52 @@ class PDFEditor { } const pageRef = this.newPages[i]; const pageDict = this.xref[pageRef.num]; + const visited = new RefSet(); + visited.put(pageRef); // Visit the new page in order to collect used StructParent entries. - this.#visitObject(pageDict, dict => { - const structParent = - dict.get("StructParent") ?? dict.get("StructParents"); - if (typeof structParent !== "number") { - return; - } - usedStructParents.add(structParent); - let parent = parentTree.get(structParent); - const parentRef = parent instanceof Ref ? parent : null; - if (parentRef) { - const array = xref.fetch(parentRef); - if (Array.isArray(array)) { - parent = array; + this.#visitObject( + pageDict, + dict => { + const structParent = + dict.get("StructParent") ?? dict.get("StructParents"); + if (typeof structParent !== "number") { + return; + } + usedStructParents.add(structParent); + let parent = parentTree.get(structParent); + const parentRef = parent instanceof Ref ? parent : null; + if (parentRef) { + 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")) { - dict.delete("StructParent"); + dict.set("StructParent", newStructParent); } else { - dict.delete("StructParents"); + dict.set("StructParents", newStructParent); } - return; - } - 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); - } - }); + }, + visited + ); } 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} fieldToParent + * @param {XRef} xref + * @returns {Array} + */ + #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() { // We can only preserve page labels when editing a single PDF file. // This is consistent with behavior in Adobe Acrobat. @@ -1484,6 +1894,33 @@ class PDFEditor { 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. * @returns {Promise} @@ -1492,6 +1929,7 @@ class PDFEditor { const { rootDict } = this; rootDict.setIfName("Type", "Catalog"); rootDict.setIfName("Version", this.version); + this.#makeAcroForm(); this.#makePageTree(); this.#makePageLabelsTree(); this.#makeDestinationsTree(); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 9eea6f975..01472be49 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -878,3 +878,4 @@ !two_pages.pdf !sci-notation.pdf !nested_outline.pdf +!form_two_pages.pdf diff --git a/test/pdfs/form_two_pages.pdf b/test/pdfs/form_two_pages.pdf new file mode 100644 index 000000000..079319f6d Binary files /dev/null and b/test/pdfs/form_two_pages.pdf differ diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 9533d8171..a16e45878 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -6223,5 +6223,112 @@ small scripts as well as for`); 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(); + }); + }); }); }); diff --git a/test/unit/core_utils_spec.js b/test/unit/core_utils_spec.js index 402796de5..2005d8ced 100644 --- a/test/unit/core_utils_spec.js +++ b/test/unit/core_utils_spec.js @@ -15,6 +15,7 @@ import { arrayBuffersToBytes, + deepCompare, encodeToXmlString, escapePDFName, 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 () { it("should get the size in bytes to use to represent a positive integer", function () { expect(getSizeInBytes(0)).toEqual(0);