Skip to content

Commit a7b42d4

Browse files
authored
fix(forms): pass resolver for indirect refs in dicts and name trees (#56)
PdfDict.getArray(), getDict(), and get() return undefined when the value is an indirect PdfRef and no resolver is provided. This caused silent failures for PDFs that store /Fields, /Kids, /AP, /BS, /Opt, /I, /Contents, and name-tree /Kids and /Names as indirect references. Fixes 24 call sites across acro-form, field base, widget-annotation, choice-fields, form-flattener, and name-tree. Also fixes a latent listbox bug where stale /I entries weren't cleared on single-select setValue(). Closes #55
1 parent f489b93 commit a7b42d4

9 files changed

Lines changed: 703 additions & 27 deletions

File tree

347 KB
Binary file not shown.

src/document/forms/acro-form.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ export class AcroForm implements AcroFormLike {
138138
return this.fieldsCache;
139139
}
140140

141-
const fieldsArray = this.dict.getArray("Fields");
141+
const fieldsArray = this.dict.getArray("Fields", this.registry.resolve.bind(this.registry));
142142

143143
if (!fieldsArray) {
144144
return [];
@@ -592,7 +592,7 @@ export class AcroForm implements AcroFormLike {
592592
fields.push(field);
593593
} else {
594594
// Non-terminal: recurse into children
595-
const childKids = dict.getArray("Kids");
595+
const childKids = dict.getArray("Kids", this.registry.resolve.bind(this.registry));
596596

597597
if (childKids) {
598598
fields.push(...this.collectFields(childKids, visited, fullName));
@@ -611,7 +611,7 @@ export class AcroForm implements AcroFormLike {
611611
* - Its /Kids contain widgets (no /T) rather than child fields (have /T)
612612
*/
613613
private isTerminalField(dict: PdfDict): boolean {
614-
const kids = dict.getArray("Kids");
614+
const kids = dict.getArray("Kids", this.registry.resolve.bind(this.registry));
615615

616616
if (!kids || kids.length === 0) {
617617
return true;
@@ -722,7 +722,7 @@ export class AcroForm implements AcroFormLike {
722722
* @param fieldRef Reference to the field dictionary
723723
*/
724724
addField(fieldRef: PdfRef): void {
725-
let fieldsArray = this.dict.getArray("Fields");
725+
let fieldsArray = this.dict.getArray("Fields", this.registry.resolve.bind(this.registry));
726726

727727
if (!fieldsArray) {
728728
fieldsArray = new PdfArray([]);
@@ -752,7 +752,7 @@ export class AcroForm implements AcroFormLike {
752752
* @returns true if the field was found and removed, false otherwise
753753
*/
754754
removeField(fieldRef: PdfRef): boolean {
755-
const fieldsArray = this.dict.getArray("Fields");
755+
const fieldsArray = this.dict.getArray("Fields", this.registry.resolve.bind(this.registry));
756756

757757
if (!fieldsArray) {
758758
return false;

src/document/forms/fields/base.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ export abstract class TerminalField extends FormField {
394394
return this._widgets;
395395
}
396396

397-
const kids = this.dict.getArray("Kids");
397+
const kids = this.dict.getArray("Kids", this.registry.resolve.bind(this.registry));
398398

399399
if (!kids) {
400400
this._widgets = [];
@@ -438,7 +438,7 @@ export abstract class TerminalField extends FormField {
438438
}
439439

440440
// Otherwise, /Kids contains widgets
441-
const kids = this.dict.getArray("Kids");
441+
const kids = this.dict.getArray("Kids", this.registry.resolve.bind(this.registry));
442442

443443
if (!kids) {
444444
this._widgets = [];
@@ -493,7 +493,7 @@ export abstract class TerminalField extends FormField {
493493
const widgetRef = this.registry.register(widgetDict);
494494

495495
// Ensure /Kids array exists
496-
let kids = this.dict.getArray("Kids");
496+
let kids = this.dict.getArray("Kids", this.registry.resolve.bind(this.registry));
497497

498498
if (!kids) {
499499
kids = new PdfArray([]);

src/document/forms/fields/choice-fields.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export class DropdownField extends TerminalField {
8585
* Get available options.
8686
*/
8787
getOptions(): ChoiceOption[] {
88-
return parseChoiceOptions(this.dict.getArray("Opt"));
88+
return parseChoiceOptions(this.dict.getArray("Opt", this.registry.resolve.bind(this.registry)));
8989
}
9090

9191
/**
@@ -199,7 +199,7 @@ export class ListBoxField extends TerminalField {
199199
* Get available options.
200200
*/
201201
getOptions(): ChoiceOption[] {
202-
return parseChoiceOptions(this.dict.getArray("Opt"));
202+
return parseChoiceOptions(this.dict.getArray("Opt", this.registry.resolve.bind(this.registry)));
203203
}
204204

205205
/**
@@ -208,7 +208,7 @@ export class ListBoxField extends TerminalField {
208208
*/
209209
getValue(): string[] {
210210
// /I (selection indices) takes precedence for multi-select
211-
const indices = this.dict.getArray("I");
211+
const indices = this.dict.getArray("I", this.registry.resolve.bind(this.registry));
212212

213213
if (indices && indices.length > 0) {
214214
const options = this.getOptions();
@@ -297,7 +297,9 @@ export class ListBoxField extends TerminalField {
297297
this.dict.set("V", PdfArray.of(...values.map(v => PdfString.fromString(v))));
298298
}
299299

300-
// Set /I (indices) for multi-select
300+
// Set /I (indices) to stay in sync with /V.
301+
// For multi-select, /I stores the selected indices.
302+
// For single-select, clear /I so it doesn't shadow /V.
301303
if (this.isMultiSelect) {
302304
const indices = values
303305
.map(v => options.findIndex(o => o.value === v))
@@ -309,6 +311,8 @@ export class ListBoxField extends TerminalField {
309311
} else {
310312
this.dict.delete("I");
311313
}
314+
} else {
315+
this.dict.delete("I");
312316
}
313317

314318
this.needsAppearanceUpdate = true;

src/document/forms/form-flattener.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,20 @@ export class FormFlattener {
391391
* This isolates the original page's graphics state from our additions.
392392
*/
393393
private wrapAndAppendContent(page: PdfDict, newContent: Uint8Array): void {
394-
const existing = page.get("Contents");
394+
let existing = page.get("Contents");
395+
396+
// Resolve indirect reference — /Contents may be a PdfRef pointing to a PdfArray
397+
// of stream refs. Without resolving, we'd wrap the ref as-is, producing a
398+
// nested array reference that PDF viewers cannot interpret.
399+
// Only unwrap if the ref points to an array; if it points to a single stream,
400+
// keep the PdfRef so it can be placed directly in the new contents array.
401+
if (existing instanceof PdfRef) {
402+
const resolved = this.registry.resolve(existing);
403+
404+
if (resolved instanceof PdfArray) {
405+
existing = resolved;
406+
}
407+
}
395408

396409
// Create prefix stream with "q\n"
397410
const prefixBytes = new Uint8Array([0x71, 0x0a]); // "q\n"

src/document/forms/widget-annotation.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ export class WidgetAnnotation {
129129
* @param state Optional state name for stateful widgets
130130
*/
131131
setNormalAppearance(stream: PdfStream, state?: string): void {
132-
let ap = this.dict.getDict("AP");
132+
const resolve = this.registry.resolve.bind(this.registry);
133+
let ap = this.dict.getDict("AP", resolve);
133134

134135
if (!ap) {
135136
ap = new PdfDict();
@@ -138,7 +139,7 @@ export class WidgetAnnotation {
138139

139140
if (state) {
140141
// Stateful: AP.N is a dict of state -> stream
141-
const nEntry = ap.get("N");
142+
const nEntry = ap.get("N", resolve);
142143
let nDict: PdfDict;
143144

144145
if (nEntry instanceof PdfDict && !(nEntry instanceof PdfStream)) {
@@ -183,7 +184,7 @@ export class WidgetAnnotation {
183184
* For checkboxes/radios, this is the value when checked.
184185
*/
185186
getOnValue(): string | null {
186-
const ap = this.dict.getDict("AP");
187+
const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry));
187188

188189
if (!ap) {
189190
return null;
@@ -215,7 +216,7 @@ export class WidgetAnnotation {
215216
* @returns True if all states have appearance streams
216217
*/
217218
hasAppearancesForStates(states: string[]): boolean {
218-
const ap = this.dict.getDict("AP");
219+
const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry));
219220

220221
if (!ap) {
221222
return false;
@@ -247,13 +248,14 @@ export class WidgetAnnotation {
247248
* Check if this widget has any normal appearance stream.
248249
*/
249250
hasNormalAppearance(): boolean {
250-
const ap = this.dict.getDict("AP");
251+
const resolve = this.registry.resolve.bind(this.registry);
252+
const ap = this.dict.getDict("AP", resolve);
251253

252254
if (!ap) {
253255
return false;
254256
}
255257

256-
const n = ap.get("N");
258+
const n = ap.get("N", resolve);
257259

258260
return n !== null && n !== undefined;
259261
}
@@ -263,7 +265,7 @@ export class WidgetAnnotation {
263265
* For stateful widgets (checkbox/radio), pass the state name.
264266
*/
265267
getNormalAppearance(state?: string): PdfStream | null {
266-
const ap = this.dict.getDict("AP");
268+
const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry));
267269

268270
if (!ap) {
269271
return null;
@@ -298,7 +300,7 @@ export class WidgetAnnotation {
298300
* Get rollover appearance stream (shown on mouse hover).
299301
*/
300302
getRolloverAppearance(state?: string): PdfStream | null {
301-
const ap = this.dict.getDict("AP");
303+
const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry));
302304

303305
if (!ap) {
304306
return null;
@@ -333,7 +335,7 @@ export class WidgetAnnotation {
333335
* Get down appearance stream (shown when clicked).
334336
*/
335337
getDownAppearance(state?: string): PdfStream | null {
336-
const ap = this.dict.getDict("AP");
338+
const ap = this.dict.getDict("AP", this.registry.resolve.bind(this.registry));
337339

338340
if (!ap) {
339341
return null;
@@ -368,7 +370,7 @@ export class WidgetAnnotation {
368370
* Get border style.
369371
*/
370372
getBorderStyle(): BorderStyle | null {
371-
const bs = this.dict.getDict("BS");
373+
const bs = this.dict.getDict("BS", this.registry.resolve.bind(this.registry));
372374

373375
if (!bs) {
374376
return null;

src/document/name-tree.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class NameTree {
8686
return null;
8787
}
8888

89-
const kids = node.getArray("Kids");
89+
const kids = node.getArray("Kids", this.resolver);
9090

9191
if (!kids || kids.length === 0) {
9292
return null;
@@ -146,7 +146,7 @@ export class NameTree {
146146
}
147147

148148
// We're at a leaf node - search the /Names array
149-
const names = node.getArray("Names");
149+
const names = node.getArray("Names", this.resolver);
150150

151151
if (!names) {
152152
return null;
@@ -220,7 +220,7 @@ export class NameTree {
220220

221221
if (node.has("Kids")) {
222222
// Intermediate node - queue children
223-
const kids = node.getArray("Kids");
223+
const kids = node.getArray("Kids", this.resolver);
224224

225225
if (kids) {
226226
for (let i = 0; i < kids.length; i++) {
@@ -247,7 +247,7 @@ export class NameTree {
247247
}
248248
} else if (node.has("Names")) {
249249
// Leaf node - yield entries
250-
const names = node.getArray("Names");
250+
const names = node.getArray("Names", this.resolver);
251251

252252
if (names) {
253253
for (let i = 0; i < names.length; i += 2) {

0 commit comments

Comments
 (0)