From 294570349c00b33904c30a93f9934d9110525eec Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 03:51:45 -0400 Subject: [PATCH 1/7] fix(pdf-server): one bad form field no longer aborts saving the rest #577 dropped the per-field try/catch in buildAnnotatedPdfBytes when it swapped to type-dispatch, so the first field whose pdf-lib write throws (max-length text, missing /Yes appearance, radio buttonValue mapping to neither label nor index, ...) bubbled to the outer catch and silently dropped every subsequent field. Compounded by getAnnotatedPdfBytes passing every baseline field, so even an untouched problematic field in the PDF poisoned the whole save. - Re-wrap each field write in its own try/catch (warn + continue). - getAnnotatedPdfBytes now only sends fields whose value differs from baseline (still includes explicit ''/false sentinels for cleared fields so pdf-lib overwrites the original /V). - Regression test: a maxLength=2 text field fed a long string, followed by a normal field; assert the second one still lands. --- examples/pdf-server/src/mcp-app.ts | 24 ++++-- .../pdf-server/src/pdf-annotations.test.ts | 28 +++++++ examples/pdf-server/src/pdf-annotations.ts | 77 +++++++++++-------- 3 files changed, 90 insertions(+), 39 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index fe30b14f..1af24e56 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -2971,15 +2971,23 @@ async function getAnnotatedPdfBytes(): Promise { } } - // buildAnnotatedPdfBytes gates on formFields.size > 0 and only writes - // entries present in the map. After clearAllItems() the map is empty → - // zero setText/uncheck calls → pdf-lib leaves original /V intact → - // the "stripped PDF" we promised keeps all its form data. To actually - // clear, send an explicit sentinel for every baseline field the user - // dropped: "" for text, false for checkbox (matching baseline type). - const formFieldsOut = new Map(formFieldValues); + // Only write fields that actually changed vs. what's already in the PDF. + // Unchanged fields are no-ops at best, and at worst trip pdf-lib edge + // cases (max-length text, missing /Yes appearance, …) on fields the user + // never touched — which, before the per-field catch in + // buildAnnotatedPdfBytes, aborted every subsequent field. + // + // Fields the user cleared (present in baseline, absent from formFieldValues + // after clearAllItems()) still need an explicit "" / false so pdf-lib + // overwrites the original /V instead of leaving it intact. + const formFieldsOut = new Map(); + for (const [name, value] of formFieldValues) { + if (pdfBaselineFormValues.get(name) !== value) { + formFieldsOut.set(name, value); + } + } for (const [name, baselineValue] of pdfBaselineFormValues) { - if (!formFieldsOut.has(name)) { + if (!formFieldValues.has(name)) { formFieldsOut.set(name, typeof baselineValue === "boolean" ? false : ""); } } diff --git a/examples/pdf-server/src/pdf-annotations.test.ts b/examples/pdf-server/src/pdf-annotations.test.ts index fe34d835..0ab9bd6b 100644 --- a/examples/pdf-server/src/pdf-annotations.test.ts +++ b/examples/pdf-server/src/pdf-annotations.test.ts @@ -1105,6 +1105,34 @@ describe("buildAnnotatedPdfBytes", () => { const form = (await PDFDocument.load(out)).getForm(); expect(form.getTextField("name").getText()).toBe("kept"); }); + + it("a field that throws on write does not abort subsequent fields", async () => { + // Regression for #577: the per-field try/catch was dropped, so the + // first throwing field bubbled to the outer catch and silently dropped + // every field after it. setText() throws when value exceeds maxLength. + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const form = doc.getForm(); + const limited = form.createTextField("limited"); + limited.setMaxLength(2); + limited.addToPage(page, { x: 10, y: 700 }); + form.createTextField("after").addToPage(page, { x: 10, y: 660 }); + const fixture = await doc.save(); + + const out = await buildAnnotatedPdfBytes( + fixture, + [], + new Map([ + ["limited", "way too long"], // throws + ["after", "kept"], + ]), + ); + + const saved = (await PDFDocument.load(out)).getForm(); + expect(saved.getTextField("after").getText()).toBe("kept"); + // The throwing field is left at whatever pdf-lib could do with it — + // we only assert it didn't poison "after". + }); }); }); diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index 25d9ebb6..bf350be6 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -22,6 +22,7 @@ import { PDFCheckBox, PDFDropdown, PDFRadioGroup, + type PDFForm, } from "pdf-lib"; // ============================================================================= @@ -815,44 +816,58 @@ export async function buildAnnotatedPdfBytes( await addAnnotationDicts(pdfDoc, annotations); // Apply form fills. Dispatch on actual field type — getTextField(name) throws - // for dropdowns/radios, so the old try/catch silently dropped those on save. + // for dropdowns/radios, so we look up the generic field and instanceof it. + // Each field is wrapped in its own try/catch: pdf-lib can throw on + // length-constrained text, radios whose buttonValue maps to neither label + // nor index, checkboxes missing a /Yes appearance, etc. One bad field must + // not abort the rest of the loop (regressed in #577 when the inner catch + // was dropped along with the type-specific getters). if (formFields.size > 0) { + let form: PDFForm | undefined; try { - const form = pdfDoc.getForm(); + form = pdfDoc.getForm(); + } catch { + // No AcroForm in this PDF + } + if (form) { for (const [name, value] of formFields) { - const field = form.getFieldMaybe(name); - if (!field) continue; - - if (field instanceof PDFCheckBox) { - if (value) field.check(); - else field.uncheck(); - } else if (field instanceof PDFRadioGroup) { - // The viewer stores pdf.js's buttonValue, which for PDFs with an - // /Opt array is a numeric index ("0","1","2") rather than the - // option label pdf-lib's select() expects. Try the label first, - // then fall back to indexing into getOptions(). - const opts = field.getOptions(); - const s = String(value); - if (opts.includes(s)) { - field.select(s); - } else { - const idx = Number(s); - if (Number.isInteger(idx) && idx >= 0 && idx < opts.length) { - field.select(opts[idx]); + try { + const field = form.getFieldMaybe(name); + if (!field) continue; + + if (field instanceof PDFCheckBox) { + if (value) field.check(); + else field.uncheck(); + } else if (field instanceof PDFRadioGroup) { + // The viewer stores pdf.js's buttonValue, which for PDFs with an + // /Opt array is a numeric index ("0","1","2") rather than the + // option label pdf-lib's select() expects. Try the label first, + // then fall back to indexing into getOptions(). + const opts = field.getOptions(); + const s = String(value); + if (opts.includes(s)) { + field.select(s); + } else { + const idx = Number(s); + if (Number.isInteger(idx) && idx >= 0 && idx < opts.length) { + field.select(opts[idx]); + } + // else: value is neither label nor index — leave unset } - // else: value is neither label nor index — leave unset + } else if (field instanceof PDFDropdown) { + // select() auto-enables edit mode for values outside getOptions(), + // so this works for both enumerated and free-text combos. + field.select(String(value)); + } else if (field instanceof PDFTextField) { + field.setText(String(value)); } - } else if (field instanceof PDFDropdown) { - // select() auto-enables edit mode for values outside getOptions(), - // so this works for both enumerated and free-text combos. - field.select(String(value)); - } else if (field instanceof PDFTextField) { - field.setText(String(value)); + // PDFButton, PDFOptionList, PDFSignature: no fill_form support yet + } catch (err) { + // Skip this field; carry on with the rest. Surfacing per-field + // failures is the caller's job (see fill_form result), not save's. + console.warn(`buildAnnotatedPdfBytes: skipped field "${name}":`, err); } - // PDFButton, PDFOptionList, PDFSignature: no fill_form support yet } - } catch { - // pdfDoc.getForm() throws if the PDF has no AcroForm } } From 17bc2c5805133a7c78a604a11db405702b44ec08 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:10:47 -0400 Subject: [PATCH 2/7] fix(pdf-server): radio groups misclassified as PDFCheckBox save the chosen widget Some PDFs (e.g. third-party forms like the IRS f1040 family, and the demo Form.pdf) omit the /Ff Radio flag bit on button fields, so pdf-lib classifies a multi-widget radio as PDFCheckBox. The viewer stores pdf.js's buttonValue (the widget's on-state name, e.g. '0'/'1') as a string. The PDFCheckBox branch did 'if (value) field.check()', which always sets the FIRST widget's on-state - so any choice saved as the first option. When the value is a string on a PDFCheckBox, treat it as a radio on-value: write /V and per-widget /AS directly via the low-level acroField (mirroring PDFAcroRadioButton.setValue minus its first-widget-only onValues guard). Booleans keep check()/uncheck(). Test: build a radio fixture, clear the Radio flag so reload sees PDFCheckBox, save with '1', assert /V = /1 and second widget /AS = /1. --- .../pdf-server/src/pdf-annotations.test.ts | 42 ++++++++++++++++ examples/pdf-server/src/pdf-annotations.ts | 50 ++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/examples/pdf-server/src/pdf-annotations.test.ts b/examples/pdf-server/src/pdf-annotations.test.ts index 0ab9bd6b..88eb1a07 100644 --- a/examples/pdf-server/src/pdf-annotations.test.ts +++ b/examples/pdf-server/src/pdf-annotations.test.ts @@ -1133,6 +1133,48 @@ describe("buildAnnotatedPdfBytes", () => { // The throwing field is left at whatever pdf-lib could do with it — // we only assert it didn't poison "after". }); + + it("radio misclassified as PDFCheckBox: string value selects the matching widget", async () => { + // Some PDFs (e.g. IRS/third-party forms) omit the /Ff Radio bit, so + // pdf-lib hands us a PDFCheckBox. The viewer stored pdf.js's + // buttonValue ("0"/"1"), not a boolean — check() would always pick + // the first widget. setButtonGroupValue writes /V + per-widget /AS + // directly so the chosen widget sticks. + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const form = doc.getForm(); + const rg = form.createRadioGroup("Gender"); + rg.addOptionToPage("Male", page, { x: 10, y: 700 }); + rg.addOptionToPage("Female", page, { x: 60, y: 700 }); + // pdf-lib's addOptionToPage writes widget on-values "0","1". Clear the + // Radio flag (bit 16) so the reloaded form classifies it as checkbox. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rg.acroField as any).clearFlag(1 << 15); + const fixture = await doc.save(); + + // Sanity: reload sees it as PDFCheckBox now. + const reForm = (await PDFDocument.load(fixture)).getForm(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reField = reForm.getFieldMaybe("Gender") as any; + expect(reField?.constructor?.name).toBe("PDFCheckBox"); + + const out = await buildAnnotatedPdfBytes( + fixture, + [], + new Map([["Gender", "1"]]), + ); + + const saved = await PDFDocument.load(out); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const acro = (saved.getForm().getFieldMaybe("Gender") as any).acroField; + const v = acro.dict.get(PDFName.of("V")); + expect(v).toBeInstanceOf(PDFName); + expect((v as PDFName).decodeText()).toBe("1"); + // Second widget /AS is the on-state, first is /Off. + const widgets = acro.getWidgets(); + expect(widgets[0].getAppearanceState()?.decodeText()).toBe("Off"); + expect(widgets[1].getAppearanceState()?.decodeText()).toBe("1"); + }); }); }); diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index bf350be6..b4ae1393 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -801,6 +801,40 @@ export async function addAnnotationDicts( } } +/** + * Select a radio-style button group by widget on-value, bypassing pdf-lib's + * type-level guards. Used when pdf-lib classifies a radio as `PDFCheckBox` + * (PDF lacks the /Ff Radio bit) — `check()` would always pick the first + * widget. Mirrors `PDFAcroRadioButton.setValue` minus its `onValues` throw. + */ +function setButtonGroupValue( + field: PDFCheckBox | PDFRadioGroup, + onValue: string, +): void { + const acro = field.acroField; + const off = PDFName.of("Off"); + const widgets = acro.getWidgets(); + // Match by PDFName identity (pdf-lib interns names) — the viewer stored + // pdf.js's buttonValue, which IS the widget's /AP /N on-state name. + let target = onValue && onValue !== "Off" ? PDFName.of(onValue) : off; + if ( + target !== off && + !widgets.some((w: { getOnValue(): PDFName | undefined }) => { + return w.getOnValue() === target; + }) + ) { + // No widget has this on-state — leave as-is rather than corrupt /V. + return; + } + acro.dict.set(PDFName.of("V"), target); + for (const w of widgets) { + const on = (w as { getOnValue(): PDFName | undefined }).getOnValue(); + (w as { setAppearanceState(s: PDFName): void }).setAppearanceState( + on === target ? target : off, + ); + } +} + /** * Build annotated PDF bytes from the original document. * Applies user annotations and form fills, returns Uint8Array of the new PDF. @@ -836,8 +870,20 @@ export async function buildAnnotatedPdfBytes( if (!field) continue; if (field instanceof PDFCheckBox) { - if (value) field.check(); - else field.uncheck(); + if (typeof value === "string") { + // A string value on a "checkbox" means pdf-lib misclassified a + // radio group (PDF lacks the /Ff Radio flag bit). The viewer + // stored pdf.js's buttonValue, which is the widget's appearance + // on-state name (e.g. "0"/"1"). check()/uncheck() would set the + // FIRST widget's on-state regardless, so write /V and per-widget + // /AS directly — same as PDFAcroRadioButton.setValue but without + // its onValues guard (which checks the first widget only). + setButtonGroupValue(field, value); + } else if (value) { + field.check(); + } else { + field.uncheck(); + } } else if (field instanceof PDFRadioGroup) { // The viewer stores pdf.js's buttonValue, which for PDFs with an // /Opt array is a numeric index ("0","1","2") rather than the From cf739e133322f63b31936cf03a97792f90273bf6 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:28:54 -0400 Subject: [PATCH 3/7] fix(pdf-server): clearing a radio from the panel unchecks all widgets clearFieldInStorage wrote the same string clearValue to every widget's storage, hitting pdf.js's inverted string-coercion bug (the one setFieldInStorage already works around): the wrong widget rendered checked. For radio, write {value:false} to every widget instead. --- examples/pdf-server/src/annotation-panel.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/pdf-server/src/annotation-panel.ts b/examples/pdf-server/src/annotation-panel.ts index 6b410952..081d5c85 100644 --- a/examples/pdf-server/src/annotation-panel.ts +++ b/examples/pdf-server/src/annotation-panel.ts @@ -831,8 +831,15 @@ function clearFieldInStorage(name: string): void { meta?.[0]?.defaultValue ?? ""; const type = meta?.find((f) => f.type)?.type; - const clearValue = - type === "checkbox" || type === "radiobutton" ? (dv ?? "Off") : (dv ?? ""); + // Radio: per-widget BOOLEANS, never a string. pdf.js's + // RadioButtonWidgetAnnotation render() has inverted string coercion (see + // setFieldInStorage), so writing the same string to every widget checks + // the wrong one. {value:false} on all = nothing selected. + if (type === "radiobutton") { + for (const id of ids) storage.setValue(id, { value: false }); + return; + } + const clearValue = type === "checkbox" ? (dv ?? "Off") : (dv ?? ""); for (const id of ids) storage.setValue(id, { value: clearValue }); } From 026814fd80ce9f3e370ab1aa4a6fe166ba645f43 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:30:26 -0400 Subject: [PATCH 4/7] fix(pdf-server): only treat multi-widget PDFCheckBox as misclassified radio A single-widget checkbox getting a string value (e.g. 'Yes'/'Off' if something upstream stores the export string instead of boolean) was being routed through setButtonGroupValue, which no-ops when no widget's on-state matches the value - leaving the box unchanged. Gate the radio-path on widgets.length > 1; single-widget falls through to check()/uncheck() with liberal truthy/'Off' semantics. --- examples/pdf-server/src/pdf-annotations.ts | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index b4ae1393..0198fe36 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -870,19 +870,22 @@ export async function buildAnnotatedPdfBytes( if (!field) continue; if (field instanceof PDFCheckBox) { - if (typeof value === "string") { - // A string value on a "checkbox" means pdf-lib misclassified a - // radio group (PDF lacks the /Ff Radio flag bit). The viewer - // stored pdf.js's buttonValue, which is the widget's appearance - // on-state name (e.g. "0"/"1"). check()/uncheck() would set the - // FIRST widget's on-state regardless, so write /V and per-widget - // /AS directly — same as PDFAcroRadioButton.setValue but without - // its onValues guard (which checks the first widget only). + const widgets = field.acroField.getWidgets(); + if (typeof value === "string" && widgets.length > 1) { + // Multi-widget "checkbox" with a string value = pdf-lib + // misclassified a radio group (PDF lacks the /Ff Radio flag). + // The viewer stored pdf.js's buttonValue (the widget's on-state + // name, e.g. "0"/"1"); check()/uncheck() would hit the FIRST + // widget regardless. Write /V and per-widget /AS directly. setButtonGroupValue(field, value); - } else if (value) { - field.check(); } else { - field.uncheck(); + // Single-widget (real) checkbox. Viewer normally stores boolean, + // but be liberal: any truthy non-"Off" string counts as checked. + const on = + value === true || + (typeof value === "string" && value !== "" && value !== "Off"); + if (on) field.check(); + else field.uncheck(); } } else if (field instanceof PDFRadioGroup) { // The viewer stores pdf.js's buttonValue, which for PDFs with an From 2398691a8917aa6269841fb515d4cfe2ea4c67f3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:33:48 -0400 Subject: [PATCH 5/7] fix(pdf-server): generate missing field appearances on save PDFs whose checkbox/radio widgets lack an /AP/N/ stream: check() sets /V and /AS but Preview/Acrobat can't render a state with no appearance, so the field looks unchanged. Call form.updateFieldAppearances() after the write loop so pdf-lib synthesizes defaults for dirty fields. --- examples/pdf-server/src/pdf-annotations.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index 0198fe36..4715027e 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -917,6 +917,16 @@ export async function buildAnnotatedPdfBytes( console.warn(`buildAnnotatedPdfBytes: skipped field "${name}":`, err); } } + // Some PDFs ship widgets with no on-state appearance stream (no + // /AP/N/). pdf-lib's check()/select()/setText() set /V and /AS + // but don't synthesize the missing appearance, so other readers + // (Preview, Acrobat) show the field as if unchanged. This generates a + // default appearance for any field marked dirty above. + try { + form.updateFieldAppearances(); + } catch (err) { + console.warn("buildAnnotatedPdfBytes: updateFieldAppearances:", err); + } } } From d7a768e147955fb0166c85174d118547cfbe42cd Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:36:17 -0400 Subject: [PATCH 6/7] feat(pdf-server): save PDFOptionList (multiselect listbox) values Language field in Form.pdf is a PDFOptionList; the save loop skipped it. Parse the viewer's comma-joined string, select() the subset that exists in getOptions() (pdf-lib throws on unknowns and there's no edit-mode fallback like Dropdown has). --- examples/pdf-server/src/pdf-annotations.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/pdf-server/src/pdf-annotations.ts b/examples/pdf-server/src/pdf-annotations.ts index 4715027e..8f98334a 100644 --- a/examples/pdf-server/src/pdf-annotations.ts +++ b/examples/pdf-server/src/pdf-annotations.ts @@ -21,6 +21,7 @@ import { PDFTextField, PDFCheckBox, PDFDropdown, + PDFOptionList, PDFRadioGroup, type PDFForm, } from "pdf-lib"; @@ -907,10 +908,23 @@ export async function buildAnnotatedPdfBytes( // select() auto-enables edit mode for values outside getOptions(), // so this works for both enumerated and free-text combos. field.select(String(value)); + } else if (field instanceof PDFOptionList) { + // Viewer stores multiselect listboxes as a comma-joined string + // (pdf.js's annotationStorage value for /Ch is string|string[]; + // our Map flattens arrays). Only select + // values that are actually in the option list — pdf-lib throws + // on unknowns and there's no edit-mode fallback like Dropdown. + const opts = field.getOptions(); + const wanted = String(value) + .split(",") + .map((s) => s.trim()) + .filter((s) => opts.includes(s)); + if (wanted.length > 0) field.select(wanted); + else field.clear(); } else if (field instanceof PDFTextField) { field.setText(String(value)); } - // PDFButton, PDFOptionList, PDFSignature: no fill_form support yet + // PDFButton, PDFSignature: no fill_form support yet } catch (err) { // Skip this field; carry on with the rest. Surfacing per-field // failures is the caller's job (see fill_form result), not save's. From 4739a585c5753093811749aa1553ff3fb7f125b1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 04:36:46 -0400 Subject: [PATCH 7/7] fix(pdf-server): capture all selected options on is only the first option; join them all + // so save can select() the full set on a PDFOptionList. + value = Array.from(target.selectedOptions, (o) => o.value).join(","); } else { value = target.value; }