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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions