Skip to content

removeField() leaves stale widget refs in page /Annots arrays #1784

Description

@cozminv

What were you trying to do?

Remove all AcroForm fields from an existing PDF and recreate them with new geometry.

This is a form-editor workflow:
load template → read fields → removeField on each field → createTextField / addToPage → save

Expected result:
same number of live page annotations as before
no broken indirect-object references
ability to recreate fields with the same names

How did you attempt to do it?

  1. Load a PDF that already contains AcroForm fields (or create fields with pdf-lib).
  2. Call form.getFields() and form.removeField(field) for every field.
  3. Recreate fields with form.createTextField(name) and field.addToPage(page, options).
  4. Save with doc.save() and reload or parse the output.

Minimal code path (Node / browser):

  import { PDFDocument } from 'pdf-lib';

  const pdfDoc = await PDFDocument.create();
  const page = pdfDoc.addPage();
  const form = pdfDoc.getForm();

  const field = form.createTextField('example.text.field');
  field.addToPage(page, { x: 50, y: 50, width: 100, height: 20 });

  const widgetRef = pdfDoc.context.getObjectRef(
    field.acroField.getWidgets()[0].dict
  );

  form.removeField(field);

  // Recreate same field name
  form.createTextField('example.text.field').addToPage(
    page,
    { x: 60, y: 60, width: 100, height: 20 }
  );

On a large production template we used the same pattern for 274 fields across 8 pages.

What actually happened?

After removeField() and save():

  1. Page /Annots arrays still contain widget annotation refs that no longer resolve.

    On our 274-field / 8-page template:

    • /Annots count: 274 → 524
    • 250 dangling refs (pypdf: "Object N 0 not defined")
    • 274 live /Widget annotations after recreation
  2. File size grew without logical content changes
    (~458 KB → ~580 KB on first re-export) due to dead objects and stale /Annots refs.

  3. Some recreate attempts failed with:
    A field already exists with the specified name: ""
    (24 fields in our template) while others left ghost /Annots entries.

Root cause in src/api/form/PDFForm.ts, removeField():

  const widgetRef = this.findWidgetAppearanceRef(field, widget);
  page.node.removeAnnot(widgetRef);

findWidgetAppearanceRef() returns an /AP/N appearance stream ref.
Page /Annots lists the widget annotation dict ref.
PDFPageLeaf.removeAnnot() matches by exact PDFRef, so the widget entry is never removed.
context.delete() then removes the widget object, leaving a dangling /Annots pointer.

Related:
#1001 — same diagnosis (widgetRef not found in page annotations)
PR #1002 — fixed child widget refs in document context, not /Annots pruning

What did you expect to happen?

After form.removeField(field):

  • The widget annotation ref is removed from the page /Annots array.
  • No dangling indirect-object references remain after save().
  • Recreating a field with the same name succeeds.
  • Repeated save without logical changes does not accumulate extra /Annots entries.

For the minimal repro, after removeField:

  const annotsAfter = page.node.Annots()?.asArray() ?? [];
  // widgetRef should NOT still be in annotsAfter

On a large template, /Annots count should equal the number of live widgets,
not roughly double with hundreds of broken refs.

How can we reproduce the issue?

SSCCE — Node, no external PDF required

File: repro-remove-field-annots.mjs

  import { PDFDocument } from 'pdf-lib';

  const pdfDoc = await PDFDocument.create();
  const page = pdfDoc.addPage();
  const form = pdfDoc.getForm();

  const field = form.createTextField('example.text.field');
  field.addToPage(page, { x: 50, y: 50, width: 100, height: 20 });

  const widgetRef = pdfDoc.context.getObjectRef(
    field.acroField.getWidgets()[0].dict
  );
  const annotsBefore = page.node.Annots()?.asArray() ?? [];

  console.log('before removeField:');
  console.log('  widgetRef in /Annots:', annotsBefore.includes(widgetRef));

  form.removeField(field);

  const annotsAfterRemove = page.node.Annots()?.asArray() ?? [];
  console.log('after removeField (before save):');
  console.log('  widgetRef still in /Annots:', annotsAfterRemove.includes(widgetRef));

  const bytes = await pdfDoc.save();
  const reloaded = await PDFDocument.load(bytes);
  const reloadedPage = reloaded.getPages()[0];
  const reloadedAnnots = reloadedPage.node.Annots()?.asArray() ?? [];

  let broken = 0;
  for (const ref of reloadedAnnots) {
    try {
      reloaded.context.lookup(ref);
    } catch {
      broken += 1;
    }
  }

  console.log('after save + reload:');
  console.log('  /Annots entries:', reloadedAnnots.length);
  console.log('  broken refs:', broken);

Steps:

  1. npm install pdf-lib@1.17.1
  2. Save script as repro-remove-field-annots.mjs
  3. node repro-remove-field-annots.mjs

Observed on 1.17.1:
widgetRef still in /Annots: true (after removeField)
broken refs > 0 after reload (or pypdf "Object N 0 not defined")

Proposed fix in removeField():

  const widgetRef = this.doc.context.getObjectRef(widget.dict);
  if (widgetRef === undefined) {
    throw new Error('Could not find PDFRef for widget annotation');
  }
  page.node.removeAnnot(widgetRef);

Instead of:

  const widgetRef = this.findWidgetAppearanceRef(field, widget);
  page.node.removeAnnot(widgetRef);

Unit test name (in a fork):
removes widget annotation refs from page Annots arrays
File: tests/api/form/PDFForm.spec.ts

Version

1.17.1

What environment are you running pdf-lib in?

Node, Browser

Checklist

  • My report includes a Short, Self Contained, Correct (Compilable) Example.
  • I have attached all PDFs, images, and other files needed to run my SSCCE.

Additional Notes

Note: No external PDF is required. The SSCCE creates the document in memory.

Same root cause as #1001: removeAnnot() is called with an appearance stream ref,
not the widget annotation ref in /Annots.

PR #1002 fixed child widget cleanup in the document context but did not fix
which ref is passed to page.node.removeAnnot().

Community fork cantoo-scribe/pdf-lib already uses getObjectRef(widget.dict) in
removeField():
https://github.com/cantoo-scribe/pdf-lib/blob/master/src/api/form/PDFForm.ts

We have a minimal fix + unit test on branch fix/removefield-page-annots:

  • src/api/form/PDFForm.ts
  • tests/api/form/PDFForm.spec.ts

Happy to open a PR if useful despite maintenance status in #1720.

Also related: #987, #1168.

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions