Skip to content

Commit 7acc125

Browse files
committed
fix(pdf-server): radio button + dropdown handling in fill_form and save
Three related bugs around non-text form fields: Viewer fill_form (mcp-app.ts): writing a string to annotationStorage on every radio widget trips pdf.js's inverted string coercion (`value !== buttonValue` → true) — the first-rendered widget checks itself and clears the rest, so whatever you ask for, you get the first option. Fix: extract a setFieldInStorage() helper that writes {value: true} only on the widget whose buttonValue matches, {value: false} on siblings, matching pdf.js's own change handler. Also covers the syncFormValuesToStorage() path (localStorage restore). Save (pdf-annotations.ts): buildAnnotatedPdfBytes only knew about getTextField/getCheckBox — dropdowns and radios threw in the try block and fell through the catch, so they were silently dropped on save. Fix: instanceof dispatch on getFieldMaybe(). For radios, accept either the option label or a numeric buttonValue (indexed into getOptions()), since the viewer stores the latter for PDFs with /Opt. display_pdf output (server.ts): radio groups showed as N identical `name [Btn]` lines with no way to tell them apart; dropdowns didn't list their options. Fix: surface buttonValue as exportValue and the options array from the annotation data already in hand.
1 parent b0ac2bc commit 7acc125

File tree

4 files changed

+202
-58
lines changed

4 files changed

+202
-58
lines changed

examples/pdf-server/server.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,10 @@ interface FormFieldInfo {
857857
y: number;
858858
width: number;
859859
height: number;
860+
/** Radio button export value (buttonValue) — distinguishes widgets that share a field name. */
861+
exportValue?: string;
862+
/** Dropdown/listbox option values, as seen in the widget's `options` array. */
863+
options?: string[];
860864
}
861865

862866
/**
@@ -909,6 +913,16 @@ async function extractFormFieldInfo(
909913
// Convert to model coords (top-left origin): modelY = pageHeight - pdfY - height
910914
const modelY = pageHeight - y2;
911915

916+
// Choice widgets (combo/listbox) carry `options` as
917+
// [{exportValue, displayValue}]. Expose export values — that's
918+
// what fill_form needs.
919+
let options: string[] | undefined;
920+
if (Array.isArray(ann.options) && ann.options.length > 0) {
921+
options = ann.options
922+
.map((o: { exportValue?: string }) => o?.exportValue)
923+
.filter((v: unknown): v is string => typeof v === "string");
924+
}
925+
912926
fields.push({
913927
name: fieldName,
914928
type: fieldType,
@@ -918,6 +932,12 @@ async function extractFormFieldInfo(
918932
width: Math.round(width),
919933
height: Math.round(height),
920934
...(ann.alternativeText ? { label: ann.alternativeText } : undefined),
935+
// Radio: buttonValue is the per-widget export value — the only
936+
// thing distinguishing three `size [Btn]` lines from each other.
937+
...(ann.radioButton && ann.buttonValue != null
938+
? { exportValue: String(ann.buttonValue) }
939+
: undefined),
940+
...(options?.length ? { options } : undefined),
921941
});
922942
}
923943
}
@@ -1327,6 +1347,14 @@ Set \`elicit_form_inputs\` to true to prompt the user to fill form fields before
13271347
y: z.number(),
13281348
width: z.number(),
13291349
height: z.number(),
1350+
exportValue: z
1351+
.string()
1352+
.optional()
1353+
.describe("Radio button value — pass this to fill_form"),
1354+
options: z
1355+
.array(z.string())
1356+
.optional()
1357+
.describe("Dropdown/listbox option values"),
13301358
}),
13311359
)
13321360
.optional()
@@ -1498,8 +1526,14 @@ URL: ${normalized}`,
14981526
for (const f of fields) {
14991527
const label = f.label ? ` "${f.label}"` : "";
15001528
const nameStr = f.name || "(unnamed)";
1529+
// Radio: =<exportValue> tells the model what value to pass.
1530+
// Dropdown: options:[...] lists valid choices.
1531+
const exportSuffix = f.exportValue ? `=${f.exportValue}` : "";
1532+
const optsSuffix = f.options
1533+
? ` options:[${f.options.join(", ")}]`
1534+
: "";
15011535
lines.push(
1502-
` ${nameStr}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}`,
1536+
` ${nameStr}${exportSuffix}${label} [${f.type}] at (${f.x},${f.y}) ${f.width}×${f.height}${optsSuffix}`,
15031537
);
15041538
}
15051539
}

examples/pdf-server/src/mcp-app.ts

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2708,24 +2708,70 @@ async function buildFieldNameMap(
27082708
log.info(`Built field name map: ${fieldNameToIds.size} fields`);
27092709
}
27102710

2711+
/**
2712+
* Set one form field's value in pdf.js's annotationStorage, in the format
2713+
* AnnotationLayer expects to READ when it re-renders.
2714+
*
2715+
* Radio buttons need per-widget booleans: pdf.js's RadioButtonWidgetAnnotation
2716+
* render() has inverted string coercion (`value !== buttonValue` → true for
2717+
* every NON-matching widget), so a string value on all widgets checks the
2718+
* first rendered one and clears the rest regardless of what you asked for.
2719+
* Match pdf.js's own change handler instead: `{value: true}` on the widget
2720+
* whose buttonValue matches, `{value: false}` on the siblings.
2721+
*
2722+
* Also patches the live DOM element for the current page so the user sees the
2723+
* change without waiting for a full re-render.
2724+
*/
2725+
function setFieldInStorage(name: string, value: string | boolean): void {
2726+
if (!pdfDocument) return;
2727+
const ids = fieldNameToIds.get(name);
2728+
if (!ids) return;
2729+
const storage = pdfDocument.annotationStorage;
2730+
2731+
// Radio group: at least one widget ID has a buttonValue recorded.
2732+
const isRadio = ids.some((id) => radioButtonValues.has(id));
2733+
if (isRadio) {
2734+
const want = String(value);
2735+
for (const id of ids) {
2736+
const checked = radioButtonValues.get(id) === want;
2737+
storage.setValue(id, { value: checked });
2738+
const el = formLayerEl.querySelector(
2739+
`input[data-element-id="${id}"]`,
2740+
) as HTMLInputElement | null;
2741+
if (el) el.checked = checked;
2742+
}
2743+
return;
2744+
}
2745+
2746+
// Text / checkbox / select: same value on every widget (a field can have
2747+
// multiple widget annotations sharing one /V).
2748+
const storageValue = typeof value === "boolean" ? value : String(value);
2749+
for (const id of ids) {
2750+
storage.setValue(id, { value: storageValue });
2751+
const el = formLayerEl.querySelector(`[data-element-id="${id}"]`) as
2752+
| HTMLInputElement
2753+
| HTMLSelectElement
2754+
| HTMLTextAreaElement
2755+
| null;
2756+
if (!el) continue;
2757+
if (el instanceof HTMLInputElement && el.type === "checkbox") {
2758+
el.checked = !!value;
2759+
} else {
2760+
el.value = String(value);
2761+
}
2762+
}
2763+
}
2764+
27112765
/** Sync formFieldValues into pdfDocument.annotationStorage so AnnotationLayer renders pre-filled values.
27122766
* Skips values that match the PDF's baseline — those are already in storage
27132767
* in pdf.js's native format (which may differ from our string/bool repr,
27142768
* e.g. checkbox stores "Yes" not `true`). Overwriting with our normalised
27152769
* form can break the Reset button's ability to restore defaults. */
27162770
function syncFormValuesToStorage(): void {
27172771
if (!pdfDocument || fieldNameToIds.size === 0) return;
2718-
const storage = pdfDocument.annotationStorage;
27192772
for (const [name, value] of formFieldValues) {
27202773
if (pdfBaselineFormValues.get(name) === value) continue;
2721-
const ids = fieldNameToIds.get(name);
2722-
if (ids) {
2723-
for (const id of ids) {
2724-
storage.setValue(id, {
2725-
value: typeof value === "boolean" ? value : String(value),
2726-
});
2727-
}
2728-
}
2774+
setFieldInStorage(name, value);
27292775
}
27302776
}
27312777

@@ -4222,44 +4268,10 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
42224268
case "fill_form":
42234269
for (const field of cmd.fields) {
42244270
formFieldValues.set(field.name, field.value);
4225-
// Set in PDF.js annotation storage and update DOM elements directly
4226-
if (pdfDocument) {
4227-
const ids = fieldNameToIds.get(field.name);
4228-
if (ids) {
4229-
for (const id of ids) {
4230-
pdfDocument.annotationStorage.setValue(id, {
4231-
value:
4232-
typeof field.value === "boolean"
4233-
? field.value
4234-
: String(field.value),
4235-
});
4236-
// Update the live DOM element if it exists on the current page
4237-
const el = formLayerEl.querySelector(
4238-
`[data-element-id="${id}"]`,
4239-
) as
4240-
| HTMLInputElement
4241-
| HTMLSelectElement
4242-
| HTMLTextAreaElement
4243-
| null;
4244-
if (el) {
4245-
if (
4246-
el instanceof HTMLInputElement &&
4247-
el.type === "checkbox"
4248-
) {
4249-
el.checked = !!field.value;
4250-
} else if (el instanceof HTMLSelectElement) {
4251-
el.value = String(field.value);
4252-
} else {
4253-
el.value = String(field.value);
4254-
}
4255-
}
4256-
}
4257-
} else {
4258-
log.info(
4259-
`fill_form: no annotation IDs for field "${field.name}"`,
4260-
);
4261-
}
4271+
if (!fieldNameToIds.has(field.name)) {
4272+
log.info(`fill_form: no annotation IDs for field "${field.name}"`);
42624273
}
4274+
setFieldInStorage(field.name, field.value);
42634275
}
42644276
// Re-render to show updated form values (handles fields on other pages)
42654277
renderPage();

examples/pdf-server/src/pdf-annotations.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,81 @@ describe("buildAnnotatedPdfBytes", () => {
10311031
const bytes2 = await doc.save();
10321032
expect(bytes2.length).toBeGreaterThan(0);
10331033
});
1034+
1035+
describe("form field persistence", () => {
1036+
// One fixture with every field type we support. pdf-lib's addOptionToPage
1037+
// writes radio buttonValues as numeric index strings ("0","1","2"), which
1038+
// is the stress case — the viewer stores those, but .select() wants labels.
1039+
let formPdfBytes: Uint8Array;
1040+
beforeAll(async () => {
1041+
const doc = await PDFDocument.create();
1042+
const page = doc.addPage([612, 792]);
1043+
const form = doc.getForm();
1044+
form.createTextField("name").addToPage(page, { x: 10, y: 700 });
1045+
form.createCheckBox("agree").addToPage(page, { x: 10, y: 660 });
1046+
const dd = form.createDropdown("country");
1047+
dd.addOptions(["USA", "UK", "Canada"]);
1048+
dd.addToPage(page, { x: 10, y: 620 });
1049+
const rg = form.createRadioGroup("size");
1050+
rg.addOptionToPage("small", page, { x: 10, y: 580 });
1051+
rg.addOptionToPage("medium", page, { x: 50, y: 580 });
1052+
rg.addOptionToPage("large", page, { x: 90, y: 580 });
1053+
formPdfBytes = await doc.save();
1054+
});
1055+
1056+
it("writes text, checkbox, dropdown, and radio (by label) in one pass", async () => {
1057+
const out = await buildAnnotatedPdfBytes(
1058+
formPdfBytes,
1059+
[],
1060+
new Map<string, string | boolean>([
1061+
["name", "Alice"],
1062+
["agree", true],
1063+
["country", "Canada"],
1064+
["size", "medium"],
1065+
]),
1066+
);
1067+
const form = (await PDFDocument.load(out)).getForm();
1068+
expect(form.getTextField("name").getText()).toBe("Alice");
1069+
expect(form.getCheckBox("agree").isChecked()).toBe(true);
1070+
expect(form.getDropdown("country").getSelected()).toEqual(["Canada"]);
1071+
expect(form.getRadioGroup("size").getSelected()).toBe("medium");
1072+
});
1073+
1074+
it("maps numeric radio buttonValue to option label by index", async () => {
1075+
// The viewer stores what pdf.js reports as buttonValue ("2"), not the
1076+
// label. Save must translate or the radio is silently dropped.
1077+
const out = await buildAnnotatedPdfBytes(
1078+
formPdfBytes,
1079+
[],
1080+
new Map<string, string | boolean>([["size", "2"]]),
1081+
);
1082+
const form = (await PDFDocument.load(out)).getForm();
1083+
expect(form.getRadioGroup("size").getSelected()).toBe("large");
1084+
});
1085+
1086+
it("leaves radio unset when value is neither label nor valid index", async () => {
1087+
const out = await buildAnnotatedPdfBytes(
1088+
formPdfBytes,
1089+
[],
1090+
new Map<string, string | boolean>([["size", "bogus"]]),
1091+
);
1092+
const form = (await PDFDocument.load(out)).getForm();
1093+
expect(form.getRadioGroup("size").getSelected()).toBeUndefined();
1094+
});
1095+
1096+
it("skips unknown field names without throwing", async () => {
1097+
const out = await buildAnnotatedPdfBytes(
1098+
formPdfBytes,
1099+
[],
1100+
new Map<string, string | boolean>([
1101+
["nonexistent", "x"],
1102+
["name", "kept"],
1103+
]),
1104+
);
1105+
const form = (await PDFDocument.load(out)).getForm();
1106+
expect(form.getTextField("name").getText()).toBe("kept");
1107+
});
1108+
});
10341109
});
10351110

10361111
// =============================================================================

examples/pdf-server/src/pdf-annotations.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import {
1818
PDFString,
1919
PDFHexString,
2020
StandardFonts,
21+
PDFTextField,
22+
PDFCheckBox,
23+
PDFDropdown,
24+
PDFRadioGroup,
2125
} from "pdf-lib";
2226

2327
// =============================================================================
@@ -810,26 +814,45 @@ export async function buildAnnotatedPdfBytes(
810814
// Add proper PDF annotation objects
811815
await addAnnotationDicts(pdfDoc, annotations);
812816

813-
// Apply form fills
817+
// Apply form fills. Dispatch on actual field type — getTextField(name) throws
818+
// for dropdowns/radios, so the old try/catch silently dropped those on save.
814819
if (formFields.size > 0) {
815820
try {
816821
const form = pdfDoc.getForm();
817822
for (const [name, value] of formFields) {
818-
try {
819-
if (typeof value === "boolean") {
820-
const checkbox = form.getCheckBox(name);
821-
if (value) checkbox.check();
822-
else checkbox.uncheck();
823+
const field = form.getFieldMaybe(name);
824+
if (!field) continue;
825+
826+
if (field instanceof PDFCheckBox) {
827+
if (value) field.check();
828+
else field.uncheck();
829+
} else if (field instanceof PDFRadioGroup) {
830+
// The viewer stores pdf.js's buttonValue, which for PDFs with an
831+
// /Opt array is a numeric index ("0","1","2") rather than the
832+
// option label pdf-lib's select() expects. Try the label first,
833+
// then fall back to indexing into getOptions().
834+
const opts = field.getOptions();
835+
const s = String(value);
836+
if (opts.includes(s)) {
837+
field.select(s);
823838
} else {
824-
const textField = form.getTextField(name);
825-
textField.setText(value);
839+
const idx = Number(s);
840+
if (Number.isInteger(idx) && idx >= 0 && idx < opts.length) {
841+
field.select(opts[idx]);
842+
}
843+
// else: value is neither label nor index — leave unset
826844
}
827-
} catch {
828-
// Field not found or wrong type — skip
845+
} else if (field instanceof PDFDropdown) {
846+
// select() auto-enables edit mode for values outside getOptions(),
847+
// so this works for both enumerated and free-text combos.
848+
field.select(String(value));
849+
} else if (field instanceof PDFTextField) {
850+
field.setText(String(value));
829851
}
852+
// PDFButton, PDFOptionList, PDFSignature: no fill_form support yet
830853
}
831854
} catch {
832-
// Form not available — skip
855+
// pdfDoc.getForm() throws if the PDF has no AcroForm
833856
}
834857
}
835858

0 commit comments

Comments
 (0)