Skip to content

Commit 66e7dc3

Browse files
committed
feat(forms): add removeField method to delete form fields
Adds the ability to remove individual form fields from a PDF document. The method removes the field from the AcroForm and all associated widget annotations from their respective pages.
1 parent f270308 commit 66e7dc3

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-0
lines changed

src/api/pdf-form.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,3 +678,153 @@ describe("drawField", () => {
678678
expect(checkbox2!.isChecked()).toBe(true);
679679
});
680680
});
681+
682+
describe("removeField", () => {
683+
it("removes a field by instance", async () => {
684+
const pdf = PDF.create();
685+
pdf.addPage({ size: "letter" });
686+
const form = pdf.getOrCreateForm();
687+
const page = pdf.getPage(0);
688+
689+
const field = form.createTextField("name");
690+
page!.drawField(field, { x: 100, y: 700, width: 200, height: 24 });
691+
692+
expect(form.hasField("name")).toBe(true);
693+
expect(form.fieldCount).toBe(1);
694+
695+
const removed = form.removeField(field);
696+
697+
expect(removed).toBe(true);
698+
expect(form.hasField("name")).toBe(false);
699+
expect(form.fieldCount).toBe(0);
700+
expect(form.getField("name")).toBeUndefined();
701+
});
702+
703+
it("removes a field by name", async () => {
704+
const pdf = PDF.create();
705+
pdf.addPage({ size: "letter" });
706+
const form = pdf.getOrCreateForm();
707+
const page = pdf.getPage(0);
708+
709+
form.createTextField("email");
710+
page!.drawField(form.getTextField("email")!, { x: 100, y: 700, width: 200, height: 24 });
711+
712+
const removed = form.removeField("email");
713+
714+
expect(removed).toBe(true);
715+
expect(form.hasField("email")).toBe(false);
716+
});
717+
718+
it("returns false for non-existent field", async () => {
719+
const pdf = PDF.create();
720+
const form = pdf.getOrCreateForm();
721+
722+
const removed = form.removeField("nonexistent");
723+
724+
expect(removed).toBe(false);
725+
});
726+
727+
it("removes widget annotations from page", async () => {
728+
const pdf = PDF.create();
729+
pdf.addPage({ size: "letter" });
730+
const form = pdf.getOrCreateForm();
731+
const page = pdf.getPage(0);
732+
733+
const field = form.createTextField("name");
734+
page!.drawField(field, { x: 100, y: 700, width: 200, height: 24 });
735+
736+
// Verify widget was added
737+
expect(field.getWidgets()).toHaveLength(1);
738+
739+
form.removeField(field);
740+
741+
// After removal, the page should not have form field annotations
742+
// We can verify by saving and reloading
743+
const bytes = await pdf.save();
744+
const pdf2 = await PDF.load(bytes);
745+
const form2 = pdf2.getForm();
746+
747+
// Form should exist but be empty
748+
if (form2) {
749+
expect(form2.fieldCount).toBe(0);
750+
}
751+
});
752+
753+
it("removes field with multiple widgets across pages", async () => {
754+
const pdf = PDF.create();
755+
pdf.addPage({ size: "letter" });
756+
pdf.addPage({ size: "letter" });
757+
const form = pdf.getOrCreateForm();
758+
const page1 = pdf.getPage(0);
759+
const page2 = pdf.getPage(1);
760+
761+
const field = form.createTextField("sharedField");
762+
page1!.drawField(field, { x: 100, y: 700, width: 200, height: 24 });
763+
page2!.drawField(field, { x: 50, y: 500, width: 300, height: 30 });
764+
765+
expect(field.getWidgets()).toHaveLength(2);
766+
767+
form.removeField(field);
768+
769+
expect(form.hasField("sharedField")).toBe(false);
770+
expect(form.fieldCount).toBe(0);
771+
});
772+
773+
it("allows creating a new field with the same name after removal", async () => {
774+
const pdf = PDF.create();
775+
pdf.addPage({ size: "letter" });
776+
const form = pdf.getOrCreateForm();
777+
const page = pdf.getPage(0);
778+
779+
const field1 = form.createTextField("name", { defaultValue: "First" });
780+
page!.drawField(field1, { x: 100, y: 700, width: 200, height: 24 });
781+
782+
form.removeField(field1);
783+
784+
// Should be able to create a new field with the same name
785+
const field2 = form.createTextField("name", { defaultValue: "Second" });
786+
page!.drawField(field2, { x: 100, y: 700, width: 200, height: 24 });
787+
788+
expect(form.hasField("name")).toBe(true);
789+
expect(form.getTextField("name")?.getValue()).toBe("Second");
790+
});
791+
792+
it("removes existing field from loaded PDF", async () => {
793+
const bytes = await loadFixture("forms", "sample_form.pdf");
794+
const pdf = await PDF.load(bytes);
795+
const form = pdf.getForm();
796+
797+
const initialCount = form!.fieldCount;
798+
const fieldToRemove = form!.getFields()[0];
799+
const fieldName = fieldToRemove.name;
800+
801+
const removed = form!.removeField(fieldToRemove);
802+
803+
expect(removed).toBe(true);
804+
expect(form!.hasField(fieldName)).toBe(false);
805+
expect(form!.fieldCount).toBe(initialCount - 1);
806+
});
807+
808+
it("persists removal through save/load cycle", async () => {
809+
const pdf = PDF.create();
810+
pdf.addPage({ size: "letter" });
811+
const form = pdf.getOrCreateForm();
812+
const page = pdf.getPage(0);
813+
814+
form.createTextField("keep");
815+
form.createTextField("remove");
816+
page!.drawField(form.getTextField("keep")!, { x: 100, y: 700, width: 200, height: 24 });
817+
page!.drawField(form.getTextField("remove")!, { x: 100, y: 650, width: 200, height: 24 });
818+
819+
form.removeField("remove");
820+
821+
const bytes = await pdf.save();
822+
const pdf2 = await PDF.load(bytes);
823+
const form2 = pdf2.getForm();
824+
825+
expect(form2).not.toBeNull();
826+
expect(form2!.fieldCount).toBe(1);
827+
expect(form2!.hasField("keep")).toBe(true);
828+
expect(form2!.hasField("remove")).toBe(false);
829+
});
830+
});

src/api/pdf-form.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { PdfArray } from "#src/objects/pdf-array";
6060
import { PdfDict } from "#src/objects/pdf-dict";
6161
import { PdfName } from "#src/objects/pdf-name";
6262
import { PdfNumber } from "#src/objects/pdf-number";
63+
import { PdfRef } from "#src/objects/pdf-ref";
6364
import { PdfString } from "#src/objects/pdf-string";
6465

6566
import type { PDFContext } from "./pdf-context";
@@ -961,6 +962,130 @@ export class PDFForm {
961962
return field;
962963
}
963964

965+
// ─────────────────────────────────────────────────────────────────────────────
966+
// Field Removal
967+
// ─────────────────────────────────────────────────────────────────────────────
968+
969+
/**
970+
* Remove a form field from the document.
971+
*
972+
* This removes the field from the AcroForm and all its widget annotations
973+
* from their respective pages.
974+
*
975+
* @param fieldOrName - The field instance or field name to remove
976+
* @returns true if the field was found and removed, false otherwise
977+
*
978+
* @example
979+
* ```typescript
980+
* // Remove by field instance
981+
* const nameField = form.getTextField("name");
982+
* form.removeField(nameField);
983+
*
984+
* // Remove by name
985+
* form.removeField("email");
986+
* ```
987+
*/
988+
removeField(fieldOrName: FormField | string): boolean {
989+
// Resolve field from name if needed
990+
const field =
991+
typeof fieldOrName === "string" ? this.fieldsByName.get(fieldOrName) : fieldOrName;
992+
993+
if (!field) {
994+
return false;
995+
}
996+
997+
const fieldRef = field.getRef();
998+
999+
if (!fieldRef) {
1000+
return false;
1001+
}
1002+
1003+
// Remove widgets from their pages
1004+
this.removeWidgetsFromPages(field);
1005+
1006+
// Remove field from AcroForm's /Fields array
1007+
const removed = this._acroForm.removeField(fieldRef);
1008+
1009+
if (removed) {
1010+
// Update internal caches
1011+
this.allFields = this.allFields.filter(f => f !== field);
1012+
this.fieldsByName.delete(field.name);
1013+
}
1014+
1015+
return removed;
1016+
}
1017+
1018+
/**
1019+
* Remove all widgets of a field from their respective pages.
1020+
*/
1021+
private removeWidgetsFromPages(field: FormField): void {
1022+
const widgets = field.getWidgets();
1023+
const pageRefs = this._ctx.pages.getPages();
1024+
1025+
// Build a set of widget refs for fast lookup
1026+
const widgetRefKeys = new Set<string>();
1027+
1028+
for (const widget of widgets) {
1029+
if (widget.ref) {
1030+
widgetRefKeys.add(`${widget.ref.objectNumber} ${widget.ref.generation}`);
1031+
}
1032+
}
1033+
1034+
if (widgetRefKeys.size === 0) {
1035+
return;
1036+
}
1037+
1038+
// Scan pages and remove widget annotations
1039+
for (const pageRef of pageRefs) {
1040+
const pageDict = this._ctx.registry.resolve(pageRef);
1041+
1042+
if (!(pageDict instanceof PdfDict)) {
1043+
continue;
1044+
}
1045+
1046+
const annotsEntry = pageDict.get("Annots");
1047+
1048+
if (!annotsEntry) {
1049+
continue;
1050+
}
1051+
1052+
let annots: PdfArray | null = null;
1053+
1054+
if (annotsEntry instanceof PdfArray) {
1055+
annots = annotsEntry;
1056+
} else if (annotsEntry instanceof PdfRef) {
1057+
const resolved = this._ctx.registry.resolve(annotsEntry);
1058+
1059+
if (resolved instanceof PdfArray) {
1060+
annots = resolved;
1061+
}
1062+
}
1063+
1064+
if (!annots) {
1065+
continue;
1066+
}
1067+
1068+
// Remove matching widget refs from this page's annotations
1069+
// Iterate backwards to safely remove items
1070+
for (let i = annots.length - 1; i >= 0; i--) {
1071+
const item = annots.at(i);
1072+
1073+
if (item instanceof PdfRef) {
1074+
const key = `${item.objectNumber} ${item.generation}`;
1075+
1076+
if (widgetRefKeys.has(key)) {
1077+
annots.remove(i);
1078+
}
1079+
}
1080+
}
1081+
1082+
// If annots is now empty, remove the /Annots entry
1083+
if (annots.length === 0) {
1084+
pageDict.delete("Annots");
1085+
}
1086+
}
1087+
}
1088+
9641089
// ─────────────────────────────────────────────────────────────────────────────
9651090
// Field Creation Helpers
9661091
// ─────────────────────────────────────────────────────────────────────────────

src/document/forms/acro-form.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,37 @@ export class AcroForm implements AcroFormLike {
765765
this.clearCache();
766766
}
767767

768+
/**
769+
* Remove a field reference from the Fields array.
770+
*
771+
* This only removes the field from the AcroForm's /Fields array.
772+
* Widget removal from pages must be handled separately.
773+
*
774+
* @param fieldRef Reference to the field dictionary to remove
775+
* @returns true if the field was found and removed, false otherwise
776+
*/
777+
removeField(fieldRef: PdfRef): boolean {
778+
const fieldsArray = this.dict.getArray("Fields");
779+
780+
if (!fieldsArray) {
781+
return false;
782+
}
783+
784+
// Find and remove the field reference
785+
for (let i = 0; i < fieldsArray.length; i++) {
786+
const item = fieldsArray.at(i);
787+
788+
if (item instanceof PdfRef && item === fieldRef) {
789+
fieldsArray.remove(i);
790+
this.clearCache();
791+
792+
return true;
793+
}
794+
}
795+
796+
return false;
797+
}
798+
768799
// ─────────────────────────────────────────────────────────────────────────────
769800
// Form Flattening
770801
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)