| name | pdfjs-impl-forms-and-save |
| description | Use when implementing interactive PDF form filling, reading form field values, or saving modified PDFs with user input. Prevents data loss from not persisting AnnotationStorage changes and incorrect form field type handling. Covers AnnotationStorage API, form field types (text, checkbox, radio, dropdown, signature), getFieldObjects(), saveDocument(), and download patterns. Keywords: PDF forms, AnnotationStorage, getFieldObjects, saveDocument, form filling, AcroForm, fill PDF form, save filled PDF, interactive form, read form values, download modified PDF.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires pdfjs-dist 5.x. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
pdfjs-impl-forms-and-save
Quick Reference
Form Architecture
| Component | Purpose | Key API |
|---|
| AnnotationLayer | Renders interactive form fields on the page | AnnotationLayer.render() |
| AnnotationStorage | Stores user-modified form values in memory | pdfDoc.annotationStorage |
| getFieldObjects() | Reads all form field metadata from the PDF | pdfDoc.getFieldObjects() |
| saveDocument() | Exports PDF bytes WITH form data embedded | pdfDoc.saveDocument() |
| getData() | Exports ORIGINAL PDF bytes WITHOUT modifications | pdfDoc.getData() |
| AnnotationEditorLayer | Enables ink, text, stamp, signature editing | AnnotationEditorLayer.render() |
Critical Warnings
NEVER use getData() to export a filled form -- getData() returns the original unmodified PDF bytes. ALWAYS use saveDocument() to include AnnotationStorage changes.
ALWAYS render the AnnotationLayer with annotationStorage linked to the document -- without this, form field changes are never captured.
NEVER assume all PDFs use AcroForm -- check for XFA forms first. PDF.js has limited XFA support and silently drops some field types.
ALWAYS call saveDocument() AFTER the user has finished editing -- AnnotationStorage updates are synchronous, but the save is async.
NEVER modify annotationStorage directly via internal maps -- ALWAYS use annotationStorage.setValue(key, value) to ensure modification tracking works.
ALWAYS set annotationMode to AnnotationMode.ENABLE_FORMS (value 2) when rendering the AnnotationLayer for interactive forms. The default mode (1) renders annotations but disables form interaction.
Essential Patterns
Rendering Interactive Form Fields
import { getDocument, GlobalWorkerOptions, AnnotationLayer } from "pdfjs-dist";
import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist";
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
async function renderFormPage(
doc: PDFDocumentProxy,
pageNum: number,
container: HTMLDivElement
): Promise<void> {
const page = await doc.getPage(pageNum);
const scale = 1.5;
const viewport = page.getViewport({ scale });
const dpr = window.devicePixelRatio || 1;
const canvas = document.createElement("canvas");
canvas.width = .(viewport. * dpr);
canvas. = .(viewport. * dpr);
canvas.. = ;
canvas.. = ;
ctx = canvas.()!;
ctx.(dpr, dpr);
container.(canvas);
page.({ : ctx, viewport }).;
annotationDiv = .();
annotationDiv. = ;
annotationDiv.. = ;
annotationDiv.. = ;
annotationDiv.. = ;
annotationDiv.. = ;
annotationDiv.. = ;
container.(annotationDiv);
annotations = page.({ : });
annotationLayerParams = {
: viewport.({ : }),
: annotationDiv,
annotations,
page,
: doc.,
: ,
};
.(annotationLayerParams);
}
Reading All Form Fields
async function readFormFields(
doc: PDFDocumentProxy
): Promise<Map<string, { type: string; value: unknown }>> {
const fieldObjects = await doc.getFieldObjects();
const result = new Map<string, { type: string; value: unknown }>();
if (!fieldObjects) {
return result;
}
for (const [fieldName, fields] of Object.entries(fieldObjects)) {
const field = fields[0];
result.set(fieldName, {
type: field.type,
value: field.value,
});
}
return result;
}
Pre-filling Form Fields Programmatically
async function prefillForm(
doc: PDFDocumentProxy,
values: Record<string, string | boolean>
): Promise<void> {
const fieldObjects = await doc.getFieldObjects();
if (!fieldObjects) return;
for (const [fieldName, fields] of Object.entries(fieldObjects)) {
if (!(fieldName in values)) continue;
for (const field of fields) {
const storageKey = field.id;
const newValue = values[fieldName];
switch (field.type) {
case "text":
doc.annotationStorage.setValue(storageKey, { value: String(newValue) });
break;
case "checkbox":
doc.annotationStorage.setValue(storageKey, {
value: (newValue),
});
;
:
doc..(storageKey, { : (newValue) });
;
:
:
doc..(storageKey, { : (newValue) });
;
}
}
}
}
Saving and Downloading
saveDocument() -- Export with Form Data
async function saveFilledPDF(doc: PDFDocumentProxy): Promise<Uint8Array> {
const data = await doc.saveDocument();
return data;
}
Download Pattern
async function downloadFilledPDF(
doc: PDFDocumentProxy,
filename: string = "filled-form.pdf"
): Promise<void> {
const data = await doc.saveDocument();
const blob = new Blob([data], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
getData() vs saveDocument()
Need to export the PDF?
├── User has filled form fields or added annotations?
│ ├── YES → ALWAYS use saveDocument()
│ │ Returns modified PDF with AnnotationStorage changes embedded
│ └── NO → Use getData()
│ Returns the original unmodified PDF bytes
│
└── Need the raw original bytes regardless of edits?
└── Use getData() -- ignores all AnnotationStorage modifications
Decision Tree: Form Type Handling
PDF loaded -- does it have forms?
├── Call getFieldObjects()
│ ├── Returns null → No AcroForm fields. Check for XFA (see below).
│ └── Returns object → AcroForm fields present
│ │
│ ├── field.type === "text"
│ │ → Renders as <input type="text"> or <textarea>
│ │ → Storage value: { value: "string" }
│ │
│ ├── field.type === "checkbox"
│ │ → Renders as <input type="checkbox">
│ │ → Storage value: { value: true/false }
│ │
│ ├── field.type === "radiobutton"
│ │ → Renders as <input type="radio"> (grouped by field name)
│ │ → Storage value: { value: "exportValue" }
│ │
│ ├── field.type === "combobox"
│ │ → Renders as <select> (dropdown)
│ │ → Storage value: { value: "selectedOption" }
│ │
│ ├── field.type === "listbox"
│ │ → Renders as <select multiple>
│ │ → Storage value: { value: "selectedOption" }
│ │
│ └── field.type === "signature"
│ → Renders as placeholder element
│ → PDF.js does NOT support digital signing
│ → Use AnnotationEditorLayer for ink/image signatures
│
├── Check for XFA forms
│ ├── doc.allXfaHtml is non-null → XFA form detected
│ │ → PDF.js renders XFA with LIMITED support
│ │ → NEVER rely on saveDocument() for XFA -- data may be lost
│ │ → Recommend server-side processing for XFA forms
│ └── doc.allXfaHtml is null → Not an XFA form
│
└── Need annotation editing (ink, text, stamps)?
→ Use AnnotationEditorLayer (see below)
Annotation Editor Layer
import { AnnotationEditorLayer } from "pdfjs-dist";
const AnnotationEditorType = {
DISABLE: -1,
NONE: 0,
FREETEXT: 3,
HIGHLIGHT: 9,
STAMP: 13,
INK: 15,
SIGNATURE: 101,
};
function enableInkEditor(
page: PDFPageProxy,
doc: PDFDocumentProxy,
container: HTMLDivElement,
scale: number
): void {
const viewport = page.getViewport({ scale });
const editorDiv = document.createElement("div");
editorDiv.className = "annotationEditorLayer";
editorDiv.style.position = "absolute";
editorDiv.style.top = "0";
editorDiv.style.left = "0";
container.appendChild(editorDiv);
}
Detecting Form Presence
async function detectFormType(
doc: PDFDocumentProxy
): Promise<"acroform" | "xfa" | "none"> {
const xfaHtml = await doc.allXfaHtml;
if (xfaHtml) return "xfa";
const fields = await doc.getFieldObjects();
if (fields && Object.keys(fields).length > 0) return "acroform";
return "none";
}
Required CSS
@import "pdfjs-dist/web/pdf_viewer.css";
.annotationLayer {
position: absolute;
top: 0;
left: 0;
z-index: 2;
}
.annotationLayer input[type="text"],
.annotationLayer textarea {
border: 1px solid transparent;
background: rgba(0, 84, 255, 0.13);
}
.annotationLayer input[type="text"]:focus,
.annotationLayer textarea:focus {
border-color: #0054ff;
outline: none;
}
.annotationLayer input[type="checkbox"],
.annotationLayer input[type="radio"] {
cursor: pointer;
}
.annotationLayer select {
background: rgba(0, 84, 255, );
: solid transparent;
}
Reference Links
Official Sources