diff --git a/fixtures/issues/form-filling/5e_character_sheet.pdf b/fixtures/issues/form-filling/5e_character_sheet.pdf new file mode 100644 index 0000000..0c5646e Binary files /dev/null and b/fixtures/issues/form-filling/5e_character_sheet.pdf differ diff --git a/src/document/forms/acro-form.ts b/src/document/forms/acro-form.ts index 3ddfb0b..6665d5a 100644 --- a/src/document/forms/acro-form.ts +++ b/src/document/forms/acro-form.ts @@ -138,7 +138,7 @@ export class AcroForm implements AcroFormLike { return this.fieldsCache; } - const fieldsArray = this.dict.getArray("Fields"); + const fieldsArray = this.dict.getArray("Fields", this.registry.resolve.bind(this.registry)); if (!fieldsArray) { return []; @@ -592,7 +592,7 @@ export class AcroForm implements AcroFormLike { fields.push(field); } else { // Non-terminal: recurse into children - const childKids = dict.getArray("Kids"); + const childKids = dict.getArray("Kids", this.registry.resolve.bind(this.registry)); if (childKids) { fields.push(...this.collectFields(childKids, visited, fullName)); @@ -611,7 +611,7 @@ export class AcroForm implements AcroFormLike { * - Its /Kids contain widgets (no /T) rather than child fields (have /T) */ private isTerminalField(dict: PdfDict): boolean { - const kids = dict.getArray("Kids"); + const kids = dict.getArray("Kids", this.registry.resolve.bind(this.registry)); if (!kids || kids.length === 0) { return true; @@ -722,7 +722,7 @@ export class AcroForm implements AcroFormLike { * @param fieldRef Reference to the field dictionary */ addField(fieldRef: PdfRef): void { - let fieldsArray = this.dict.getArray("Fields"); + let fieldsArray = this.dict.getArray("Fields", this.registry.resolve.bind(this.registry)); if (!fieldsArray) { fieldsArray = new PdfArray([]); @@ -752,7 +752,7 @@ export class AcroForm implements AcroFormLike { * @returns true if the field was found and removed, false otherwise */ removeField(fieldRef: PdfRef): boolean { - const fieldsArray = this.dict.getArray("Fields"); + const fieldsArray = this.dict.getArray("Fields", this.registry.resolve.bind(this.registry)); if (!fieldsArray) { return false; diff --git a/src/document/forms/fields/base.ts b/src/document/forms/fields/base.ts index 0207310..45c89d6 100644 --- a/src/document/forms/fields/base.ts +++ b/src/document/forms/fields/base.ts @@ -394,7 +394,7 @@ export abstract class TerminalField extends FormField { return this._widgets; } - const kids = this.dict.getArray("Kids"); + const kids = this.dict.getArray("Kids", this.registry.resolve.bind(this.registry)); if (!kids) { this._widgets = []; @@ -438,7 +438,7 @@ export abstract class TerminalField extends FormField { } // Otherwise, /Kids contains widgets - const kids = this.dict.getArray("Kids"); + const kids = this.dict.getArray("Kids", this.registry.resolve.bind(this.registry)); if (!kids) { this._widgets = []; @@ -493,7 +493,7 @@ export abstract class TerminalField extends FormField { const widgetRef = this.registry.register(widgetDict); // Ensure /Kids array exists - let kids = this.dict.getArray("Kids"); + let kids = this.dict.getArray("Kids", this.registry.resolve.bind(this.registry)); if (!kids) { kids = new PdfArray([]); diff --git a/src/document/forms/fields/choice-fields.ts b/src/document/forms/fields/choice-fields.ts index 82c9380..1f2c8da 100644 --- a/src/document/forms/fields/choice-fields.ts +++ b/src/document/forms/fields/choice-fields.ts @@ -85,7 +85,7 @@ export class DropdownField extends TerminalField { * Get available options. */ getOptions(): ChoiceOption[] { - return parseChoiceOptions(this.dict.getArray("Opt")); + return parseChoiceOptions(this.dict.getArray("Opt", this.registry.resolve.bind(this.registry))); } /** @@ -199,7 +199,7 @@ export class ListBoxField extends TerminalField { * Get available options. */ getOptions(): ChoiceOption[] { - return parseChoiceOptions(this.dict.getArray("Opt")); + return parseChoiceOptions(this.dict.getArray("Opt", this.registry.resolve.bind(this.registry))); } /** @@ -208,7 +208,7 @@ export class ListBoxField extends TerminalField { */ getValue(): string[] { // /I (selection indices) takes precedence for multi-select - const indices = this.dict.getArray("I"); + const indices = this.dict.getArray("I", this.registry.resolve.bind(this.registry)); if (indices && indices.length > 0) { const options = this.getOptions(); @@ -297,7 +297,9 @@ export class ListBoxField extends TerminalField { this.dict.set("V", PdfArray.of(...values.map(v => PdfString.fromString(v)))); } - // Set /I (indices) for multi-select + // Set /I (indices) to stay in sync with /V. + // For multi-select, /I stores the selected indices. + // For single-select, clear /I so it doesn't shadow /V. if (this.isMultiSelect) { const indices = values .map(v => options.findIndex(o => o.value === v)) @@ -309,6 +311,8 @@ export class ListBoxField extends TerminalField { } else { this.dict.delete("I"); } + } else { + this.dict.delete("I"); } this.needsAppearanceUpdate = true; diff --git a/src/document/forms/form-flattener.ts b/src/document/forms/form-flattener.ts index 4b8af1b..a2dd97c 100644 --- a/src/document/forms/form-flattener.ts +++ b/src/document/forms/form-flattener.ts @@ -391,7 +391,20 @@ export class FormFlattener { * This isolates the original page's graphics state from our additions. */ private wrapAndAppendContent(page: PdfDict, newContent: Uint8Array): void { - const existing = page.get("Contents"); + let existing = page.get("Contents"); + + // Resolve indirect reference — /Contents may be a PdfRef pointing to a PdfArray + // of stream refs. Without resolving, we'd wrap the ref as-is, producing a + // nested array reference that PDF viewers cannot interpret. + // Only unwrap if the ref points to an array; if it points to a single stream, + // keep the PdfRef so it can be placed directly in the new contents array. + if (existing instanceof PdfRef) { + const resolved = this.registry.resolve(existing); + + if (resolved instanceof PdfArray) { + existing = resolved; + } + } // Create prefix stream with "q\n" const prefixBytes = new Uint8Array([0x71, 0x0a]); // "q\n" diff --git a/src/document/forms/widget-annotation.ts b/src/document/forms/widget-annotation.ts index f3f61d5..8515e43 100644 --- a/src/document/forms/widget-annotation.ts +++ b/src/document/forms/widget-annotation.ts @@ -129,7 +129,8 @@ export class WidgetAnnotation { * @param state Optional state name for stateful widgets */ setNormalAppearance(stream: PdfStream, state?: string): void { - let ap = this.dict.getDict("AP"); + const resolve = this.registry.resolve.bind(this.registry); + let ap = this.dict.getDict("AP", resolve); if (!ap) { ap = new PdfDict(); @@ -138,7 +139,7 @@ export class WidgetAnnotation { if (state) { // Stateful: AP.N is a dict of state -> stream - const nEntry = ap.get("N"); + const nEntry = ap.get("N", resolve); let nDict: PdfDict; if (nEntry instanceof PdfDict && !(nEntry instanceof PdfStream)) { @@ -183,7 +184,7 @@ export class WidgetAnnotation { * For checkboxes/radios, this is the value when checked. */ getOnValue(): string | null { - const ap = this.dict.getDict("AP"); + const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry)); if (!ap) { return null; @@ -215,7 +216,7 @@ export class WidgetAnnotation { * @returns True if all states have appearance streams */ hasAppearancesForStates(states: string[]): boolean { - const ap = this.dict.getDict("AP"); + const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry)); if (!ap) { return false; @@ -247,13 +248,14 @@ export class WidgetAnnotation { * Check if this widget has any normal appearance stream. */ hasNormalAppearance(): boolean { - const ap = this.dict.getDict("AP"); + const resolve = this.registry.resolve.bind(this.registry); + const ap = this.dict.getDict("AP", resolve); if (!ap) { return false; } - const n = ap.get("N"); + const n = ap.get("N", resolve); return n !== null && n !== undefined; } @@ -263,7 +265,7 @@ export class WidgetAnnotation { * For stateful widgets (checkbox/radio), pass the state name. */ getNormalAppearance(state?: string): PdfStream | null { - const ap = this.dict.getDict("AP"); + const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry)); if (!ap) { return null; @@ -298,7 +300,7 @@ export class WidgetAnnotation { * Get rollover appearance stream (shown on mouse hover). */ getRolloverAppearance(state?: string): PdfStream | null { - const ap = this.dict.getDict("AP"); + const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry)); if (!ap) { return null; @@ -333,7 +335,7 @@ export class WidgetAnnotation { * Get down appearance stream (shown when clicked). */ getDownAppearance(state?: string): PdfStream | null { - const ap = this.dict.getDict("AP"); + const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry)); if (!ap) { return null; @@ -368,7 +370,7 @@ export class WidgetAnnotation { * Get border style. */ getBorderStyle(): BorderStyle | null { - const bs = this.dict.getDict("BS"); + const bs = this.dict.getDict("BS", this.registry.resolve.bind(this.registry)); if (!bs) { return null; diff --git a/src/document/name-tree.ts b/src/document/name-tree.ts index 48dc6d3..1692587 100644 --- a/src/document/name-tree.ts +++ b/src/document/name-tree.ts @@ -86,7 +86,7 @@ export class NameTree { return null; } - const kids = node.getArray("Kids"); + const kids = node.getArray("Kids", this.resolver); if (!kids || kids.length === 0) { return null; @@ -146,7 +146,7 @@ export class NameTree { } // We're at a leaf node - search the /Names array - const names = node.getArray("Names"); + const names = node.getArray("Names", this.resolver); if (!names) { return null; @@ -220,7 +220,7 @@ export class NameTree { if (node.has("Kids")) { // Intermediate node - queue children - const kids = node.getArray("Kids"); + const kids = node.getArray("Kids", this.resolver); if (kids) { for (let i = 0; i < kids.length; i++) { @@ -247,7 +247,7 @@ export class NameTree { } } else if (node.has("Names")) { // Leaf node - yield entries - const names = node.getArray("Names"); + const names = node.getArray("Names", this.resolver); if (names) { for (let i = 0; i < names.length; i += 2) { diff --git a/src/tests/issues/issue-55-indirect-fields-contents.test.ts b/src/tests/issues/issue-55-indirect-fields-contents.test.ts new file mode 100644 index 0000000..a9e565d --- /dev/null +++ b/src/tests/issues/issue-55-indirect-fields-contents.test.ts @@ -0,0 +1,197 @@ +/** + * Regression test for issue #55: + * "Bug: Missing resolver when reading indirect /Fields and /Contents references" + * + * Bug 1: AcroForm.getFields() calls getArray("Fields") without a resolver. + * When /Fields is stored as a PdfRef (indirect object), getArray returns + * undefined because the value type is "ref", not "array". The form appears empty. + * + * Bug 2: FormFlattener.wrapAndAppendContent() doesn't resolve /Contents before + * checking its type. When /Contents is a PdfRef pointing to a PdfArray of + * stream refs, the code wraps the ref as-is, producing a nested array reference + * that PDF viewers cannot interpret. All original page content disappears. + * + * @see https://github.com/LibPDF-js/core/issues/55 + */ + +import { PDF } from "#src/api/pdf"; +import { PdfArray } from "#src/objects/pdf-array"; +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfRef } from "#src/objects/pdf-ref"; +import { loadFixture, saveTestOutput } from "#src/test-utils"; +import { describe, expect, it } from "vitest"; + +describe("Issue #55: Indirect /Fields and /Contents references", () => { + describe("Bug 1: Indirect /Fields reference", () => { + it("loads form fields from PDF with indirect /Fields array", async () => { + // The 5E character sheet stores /Fields as an indirect reference + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm(); + expect(form).not.toBeNull(); + + const acroForm = form!.acroForm(); + const fields = acroForm.getFields(); + + // Before the fix, this returned [] because getArray("Fields") returned + // undefined when /Fields was a PdfRef + expect(fields.length).toBeGreaterThan(0); + }); + + it("can read field names from the 5E character sheet", async () => { + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm()!; + const fields = form.getFields(); + + // The sheet should have many named fields + expect(fields.length).toBeGreaterThan(10); + + // At least some fields should have names + const named = fields.filter(f => f.name.length > 0); + expect(named.length).toBeGreaterThan(0); + }); + + it("can fill fields in the 5E character sheet", async () => { + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm()!; + const textFields = form.getTextFields(); + + // Should have text fields to fill + expect(textFields.length).toBeGreaterThan(0); + + // Fill the first writable text field + const writable = textFields.find(f => !f.isReadOnly()); + expect(writable).toBeDefined(); + + writable!.setValue("Test Value"); + expect(writable!.getValue()).toBe("Test Value"); + }); + + it("removeField works with indirect /Fields array", async () => { + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm()!; + const fields = form.getFields(); + const countBefore = fields.length; + expect(countBefore).toBeGreaterThan(0); + + // Remove first field by name + const removed = form.removeField(fields[0].name); + expect(removed).toBe(true); + + // Field count should decrease + const countAfter = form.getFields().length; + expect(countAfter).toBe(countBefore - 1); + }); + + it("round-trips: fill, save, reload, verify fields persist", async () => { + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm()!; + const textFields = form.getTextFields(); + const writable = textFields.find(f => !f.isReadOnly()); + expect(writable).toBeDefined(); + + const fieldName = writable!.name; + writable!.setValue("Round Trip"); + + // Save and reload + const saved = await pdf.save(); + await saveTestOutput("issues/issue-55-filled.pdf", saved); + + const pdf2 = await PDF.load(saved); + const form2 = pdf2.getForm()!; + const field2 = form2.getField(fieldName); + + expect(field2).not.toBeNull(); + expect(field2!.getValue()).toBe("Round Trip"); + }); + }); + + describe("Bug 2: Indirect /Contents reference during flatten", () => { + it("preserves page content when flattening PDF with indirect /Contents", async () => { + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm()!; + const acroForm = form.acroForm(); + + // Fill a field so flatten has something to bake in + const textFields = form.getTextFields(); + const writable = textFields.find(f => !f.isReadOnly()); + if (writable) { + writable.setValue("Flattened"); + } + + // Flatten the form + form.flatten(); + + // Save and reload + const saved = await pdf.save(); + await saveTestOutput("issues/issue-55-flattened.pdf", saved); + + const pdf2 = await PDF.load(saved); + + // Verify pages still exist and have content + const pageCount = pdf2.getPageCount(); + expect(pageCount).toBeGreaterThan(0); + + // Check that /Contents on pages with former fields is an array + // (not a nested array ref). Each page's Contents should be a flat + // array of stream refs, not contain refs-to-arrays. + const resolve = (ref: PdfRef) => pdf2.getObject(ref); + + for (let i = 0; i < pageCount; i++) { + const page = pdf2.getPage(i)!; + const pageDict = pdf2.getObject(page.ref); + expect(pageDict).toBeDefined(); + + if (pageDict instanceof PdfDict) { + const contents = pageDict.get("Contents", resolve); + + if (contents instanceof PdfArray) { + // Every item in the contents array should be a PdfRef to a stream, + // NOT a PdfRef to another PdfArray (which was the bug) + for (let j = 0; j < contents.length; j++) { + const item = contents.at(j); + + if (item instanceof PdfRef) { + const resolved = resolve(item); + // The resolved value should NOT be a PdfArray — that would mean + // we have a nested array (the bug condition) + expect(resolved).not.toBeInstanceOf(PdfArray); + } + } + } + } + } + }); + + it("flatten + save produces valid PDF", async () => { + const bytes = await loadFixture("issues", "form-filling/5e_character_sheet.pdf"); + const pdf = await PDF.load(bytes); + + const form = pdf.getForm()!; + form.flatten(); + + const saved = await pdf.save(); + + // Should be a valid PDF that can be reloaded + const pdf2 = await PDF.load(saved); + expect(pdf2.getPageCount()).toBeGreaterThan(0); + + // Form should have no fields after flattening + const form2 = pdf2.getForm()?.acroForm(); + if (form2) { + expect(form2.getFields().length).toBe(0); + } + }); + }); +}); diff --git a/src/tests/issues/issue-55-indirect-refs-comprehensive.test.ts b/src/tests/issues/issue-55-indirect-refs-comprehensive.test.ts new file mode 100644 index 0000000..e612ba1 --- /dev/null +++ b/src/tests/issues/issue-55-indirect-refs-comprehensive.test.ts @@ -0,0 +1,460 @@ +/** + * Comprehensive tests for indirect reference resolution across the codebase. + * + * These tests synthetically create indirect references by registering inline + * values as indirect objects and replacing dict entries with PdfRefs. This + * exercises code paths that would fail without proper resolver usage. + * + * Covers: /Kids, /AP, /Opt, /I, /BS, and NameTree /Kids + /Names. + * + * @see https://github.com/LibPDF-js/core/issues/55 + */ + +import { PDF } from "#src/api/pdf"; +import type { DropdownField, ListBoxField, TextField } from "#src/document/forms/fields"; +import { NameTree } from "#src/document/name-tree"; +import { PdfArray } from "#src/objects/pdf-array"; +import { PdfDict } from "#src/objects/pdf-dict"; +import { PdfName } from "#src/objects/pdf-name"; +import { PdfNumber } from "#src/objects/pdf-number"; +import { PdfRef } from "#src/objects/pdf-ref"; +import { PdfStream } from "#src/objects/pdf-stream"; +import { PdfString } from "#src/objects/pdf-string"; +import { loadFixture } from "#src/test-utils"; +import { describe, expect, it } from "vitest"; + +/** + * Helper: replace a direct dict entry with an indirect ref to the same value. + * Returns the PdfRef for the newly registered indirect object. + */ +function makeIndirect(dict: PdfDict, key: string, pdf: PDF): PdfRef | null { + const value = dict.get(key); + + if (!value || value instanceof PdfRef) { + return value instanceof PdfRef ? value : null; + } + + const ref = pdf.register(value); + dict.set(key, ref); + + return ref; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Indirect /Kids on field hierarchies +// ───────────────────────────────────────────────────────────────────────────── + +describe("Issue #55: Indirect /Kids in field tree", () => { + it("collectFields resolves indirect /Kids on non-terminal fields", async () => { + const bytes = await loadFixture("forms", "fancy_fields.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const acroForm = form.acroForm(); + + // Get field count with everything inline (baseline) + const baselineCount = acroForm.getFields().length; + expect(baselineCount).toBeGreaterThan(0); + + // Now make /Kids indirect on any non-terminal fields we can find. + // First, traverse the top-level /Fields to find dicts with /Kids. + const resolve = (ref: PdfRef) => pdf.getObject(ref); + const acroDict = acroForm.getDict(); + const fieldsArray = acroDict.getArray("Fields", resolve); + + if (fieldsArray) { + for (let i = 0; i < fieldsArray.length; i++) { + const item = fieldsArray.at(i, resolve); + + if (item instanceof PdfDict && item.has("Kids")) { + makeIndirect(item, "Kids", pdf); + } + } + } + + // Clear cache and re-read fields — should still find all of them + acroForm.clearCache(); + const afterCount = acroForm.getFields().length; + expect(afterCount).toBe(baselineCount); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Indirect /AP on widget annotations +// ───────────────────────────────────────────────────────────────────────────── + +describe("Issue #55: Indirect /AP on widget annotations", () => { + it("getNormalAppearance resolves indirect /AP dict", async () => { + const bytes = await loadFixture("forms", "sample_form.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const acroForm = form.acroForm(); + const fields = acroForm.getFields(); + + // Find a text field with a widget that has an appearance + const textField = fields.find(f => { + if (f.type !== "text") { + return false; + } + const widgets = f.getWidgets(); + + return widgets.some(w => w.getNormalAppearance() !== null); + }) as TextField | undefined; + + expect(textField).toBeDefined(); + + const widget = textField!.getWidgets()[0]; + + // Verify baseline: appearance exists + const baselineAppearance = widget.getNormalAppearance(); + expect(baselineAppearance).toBeInstanceOf(PdfStream); + + // Make /AP indirect + makeIndirect(widget.dict, "AP", pdf); + + // Should still find the appearance + const appearance = widget.getNormalAppearance(); + expect(appearance).toBeInstanceOf(PdfStream); + }); + + it("hasNormalAppearance resolves indirect /AP dict", async () => { + const bytes = await loadFixture("forms", "sample_form.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + const withAppearance = fields.find(f => { + return f.getWidgets().some(w => w.hasNormalAppearance()); + }); + + expect(withAppearance).toBeDefined(); + + const widget = withAppearance!.getWidgets().find(w => w.hasNormalAppearance())!; + + // Make /AP indirect + makeIndirect(widget.dict, "AP", pdf); + + // Should still detect the appearance + expect(widget.hasNormalAppearance()).toBe(true); + }); + + it("setNormalAppearance resolves indirect /AP dict", async () => { + const bytes = await loadFixture("forms", "sample_form.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + const textField = fields.find(f => f.type === "text" && !f.isReadOnly()) as + | TextField + | undefined; + expect(textField).toBeDefined(); + + const widget = textField!.getWidgets()[0]; + + // Make /AP indirect + makeIndirect(widget.dict, "AP", pdf); + + // Setting appearance should still work (finds existing AP dict via resolver) + const stream = new PdfStream(new PdfDict(), new Uint8Array([0x71, 0x0a])); + widget.setNormalAppearance(stream); + + // Verify it was set + expect(widget.hasNormalAppearance()).toBe(true); + }); + + it("getOnValue resolves indirect /AP dict on checkbox", async () => { + const bytes = await loadFixture("forms", "sample_form.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + const checkbox = fields.find(f => f.type === "checkbox"); + + if (!checkbox) { + return; + } // skip if no checkbox in this fixture + + const widget = checkbox.getWidgets()[0]; + const baselineOnValue = widget.getOnValue(); + + // Make /AP indirect + makeIndirect(widget.dict, "AP", pdf); + + // Should still find the on-value + expect(widget.getOnValue()).toBe(baselineOnValue); + }); + + it("getBorderStyle resolves indirect /BS dict", async () => { + const bytes = await loadFixture("forms", "fancy_fields.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + // Find a widget with /BS + const withBS = fields.flatMap(f => f.getWidgets()).find(w => w.getBorderStyle() !== null); + + if (!withBS) { + return; + } // skip if no /BS in fixture + + const baselineBS = withBS.getBorderStyle()!; + + // Make /BS indirect + makeIndirect(withBS.dict, "BS", pdf); + + const bs = withBS.getBorderStyle(); + expect(bs).not.toBeNull(); + expect(bs!.width).toBe(baselineBS.width); + expect(bs!.style).toBe(baselineBS.style); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Indirect /Opt and /I on choice fields +// ───────────────────────────────────────────────────────────────────────────── + +describe("Issue #55: Indirect /Opt and /I on choice fields", () => { + it("getOptions resolves indirect /Opt on dropdown", async () => { + const bytes = await loadFixture("forms", "fancy_fields.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + const dropdown = fields.find(f => f.type === "dropdown") as DropdownField | undefined; + + if (!dropdown) { + return; + } + + const baselineOptions = dropdown.getOptions(); + expect(baselineOptions.length).toBeGreaterThan(0); + + // Make /Opt indirect + makeIndirect(dropdown.getDict(), "Opt", pdf); + + const options = dropdown.getOptions(); + expect(options.length).toBe(baselineOptions.length); + expect(options[0]?.value).toBe(baselineOptions[0]?.value); + }); + + it("getOptions resolves indirect /Opt on listbox", async () => { + const bytes = await loadFixture("forms", "fancy_fields.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + const listbox = fields.find(f => f.type === "listbox") as ListBoxField | undefined; + + if (!listbox) { + return; + } + + const baselineOptions = listbox.getOptions(); + expect(baselineOptions.length).toBeGreaterThan(0); + + // Make /Opt indirect + makeIndirect(listbox.getDict(), "Opt", pdf); + + const options = listbox.getOptions(); + expect(options.length).toBe(baselineOptions.length); + expect(options[0]?.value).toBe(baselineOptions[0]?.value); + }); + + it("getValue resolves indirect /I on listbox", async () => { + const bytes = await loadFixture("forms", "fancy_fields.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const fields = form.acroForm().getFields(); + + const listbox = fields.find(f => f.type === "listbox") as ListBoxField | undefined; + + if (!listbox) { + return; + } + + // Synthetically set /I as an indirect ref + const options = listbox.getOptions(); + + if (options.length === 0) { + return; + } + + const indicesArray = PdfArray.of(PdfNumber.of(0)); + const indicesRef = pdf.register(indicesArray); + listbox.getDict().set("I", indicesRef); + + // Also set /V to match + listbox.getDict().set("V", PdfString.fromString(options[0].value)); + + const value = listbox.getValue(); + expect(value).toContain(options[0].value); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Indirect /Kids on widget annotations (field base.ts) +// ───────────────────────────────────────────────────────────────────────────── + +describe("Issue #55: Indirect /Kids on field widgets", () => { + it("resolveWidgets handles indirect /Kids array", async () => { + const bytes = await loadFixture("forms", "sample_form.pdf"); + const pdf = await PDF.load(bytes); + const form = pdf.getForm()!; + const acroForm = form.acroForm(); + + // Get baseline + const fields = acroForm.getFields(); + const withWidgets = fields.find(f => f.getWidgets().length > 0); + expect(withWidgets).toBeDefined(); + + const baselineWidgetCount = withWidgets!.getWidgets().length; + expect(baselineWidgetCount).toBeGreaterThan(0); + + // The 5E sheet has a button with separate /Kids widgets. + // For this test, use a field that has /Kids and make it indirect. + const resolve = (ref: PdfRef) => pdf.getObject(ref); + const fieldsArray = acroForm.getDict().getArray("Fields", resolve); + + if (!fieldsArray) { + return; + } + + let madeIndirect = false; + + for (let i = 0; i < fieldsArray.length; i++) { + const item = fieldsArray.at(i, resolve); + + if (item instanceof PdfDict && item.has("Kids") && !item.has("T")) { + // This is a widget container — make /Kids indirect + makeIndirect(item, "Kids", pdf); + madeIndirect = true; + } + } + + if (!madeIndirect) { + // No separate-widget fields to test; pass vacuously + return; + } + + // Re-read: clear cache and verify widgets are still found + acroForm.clearCache(); + const newFields = acroForm.getFields(); + expect(newFields.length).toBeGreaterThan(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Indirect /Kids and /Names in NameTree +// ───────────────────────────────────────────────────────────────────────────── + +describe("Issue #55: Indirect refs in NameTree", () => { + it("get() resolves indirect /Names array on leaf node", () => { + // Build a name tree with indirect /Names + const registry = createMiniRegistry(); + const resolve = (ref: PdfRef) => registry.get(ref) ?? null; + + const namesArray = new PdfArray([ + PdfString.fromString("alpha"), + PdfString.fromString("value-alpha"), + PdfString.fromString("beta"), + PdfString.fromString("value-beta"), + ]); + + const namesRef = registerObject(registry, namesArray); + + // Root dict has /Names as an indirect ref + const root = new PdfDict(); + root.set("Names", namesRef); + + const tree = new NameTree(root, resolve); + + expect(tree.get("alpha")).toBeInstanceOf(PdfString); + expect((tree.get("alpha") as PdfString).asString()).toBe("value-alpha"); + expect(tree.get("beta")).toBeInstanceOf(PdfString); + expect(tree.get("nonexistent")).toBeNull(); + }); + + it("get() resolves indirect /Kids array on intermediate node", () => { + const registry = createMiniRegistry(); + const resolve = (ref: PdfRef) => registry.get(ref) ?? null; + + // Leaf node + const leafDict = new PdfDict(); + leafDict.set( + "Names", + new PdfArray([PdfString.fromString("key1"), PdfString.fromString("val1")]), + ); + leafDict.set("Limits", PdfArray.of(PdfString.fromString("key1"), PdfString.fromString("key1"))); + const leafRef = registerObject(registry, leafDict); + + // Intermediate node: /Kids as indirect ref + const kidsArray = PdfArray.of(leafRef); + const kidsRef = registerObject(registry, kidsArray); + + const root = new PdfDict(); + root.set("Kids", kidsRef); + + const tree = new NameTree(root, resolve); + + expect(tree.get("key1")).toBeInstanceOf(PdfString); + expect((tree.get("key1") as PdfString).asString()).toBe("val1"); + }); + + it("entries() resolves indirect /Kids and /Names arrays", () => { + const registry = createMiniRegistry(); + const resolve = (ref: PdfRef) => registry.get(ref) ?? null; + + // Leaf 1 + const leaf1 = new PdfDict(); + const names1 = new PdfArray([ + PdfString.fromString("a"), + PdfString.fromString("v-a"), + PdfString.fromString("b"), + PdfString.fromString("v-b"), + ]); + const names1Ref = registerObject(registry, names1); + leaf1.set("Names", names1Ref); // indirect /Names + leaf1.set("Limits", PdfArray.of(PdfString.fromString("a"), PdfString.fromString("b"))); + const leaf1Ref = registerObject(registry, leaf1); + + // Leaf 2 + const leaf2 = new PdfDict(); + leaf2.set("Names", new PdfArray([PdfString.fromString("c"), PdfString.fromString("v-c")])); + leaf2.set("Limits", PdfArray.of(PdfString.fromString("c"), PdfString.fromString("c"))); + const leaf2Ref = registerObject(registry, leaf2); + + // Root: /Kids as indirect ref + const kidsArray = PdfArray.of(leaf1Ref, leaf2Ref); + const kidsRef = registerObject(registry, kidsArray); + + const root = new PdfDict(); + root.set("Kids", kidsRef); + + const tree = new NameTree(root, resolve); + + const entries = [...tree.entries()]; + expect(entries.length).toBe(3); + expect(entries.map(([k]) => k)).toEqual(["a", "b", "c"]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers for NameTree tests (mini registry without full ObjectRegistry) +// ───────────────────────────────────────────────────────────────────────────── + +let nextObjNum = 1000; + +function createMiniRegistry(): Map { + nextObjNum = 1000; + + return new Map(); +} + +function registerObject( + registry: Map, + obj: import("#src/objects/pdf-object").PdfObject, +): PdfRef { + const ref = PdfRef.of(nextObjNum++, 0); + registry.set(ref, obj); + + return ref; +}