Add the possibility to merge/update acroforms when merging/extracting (bug 2015853)

This commit is contained in:
calixteman 2026-03-07 17:47:24 +01:00
parent a4fcd830cc
commit baf8647b1f
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
6 changed files with 713 additions and 36 deletions

View File

@ -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,

View File

@ -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<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() {
// 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<void>}
@ -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();

View File

@ -878,3 +878,4 @@
!two_pages.pdf
!sci-notation.pdf
!nested_outline.pdf
!form_two_pages.pdf

Binary file not shown.

View File

@ -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();
});
});
});
});

View File

@ -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);