fix(pdf-server): radio + dropdown in fill_form/save (#577)
* fix(pdf-server): fail fast on never-polled viewer + surface batch errors
interact's get_screenshot/get_text waited the full 45s for a viewer that
never mounted (host went idle before iframe reached startPolling), then
timed out with a generic message. Now tracks viewsPolled and fails in ~8s
with a recovery hint. Batch errors were also ordered success-first —
hosts that flatten isError to content[0] showed "Queued: Filled N fields"
and dropped the actual failure entirely. Viewer side: poll even when PDF
load throws, and catch connect() rejection instead of silently blanking.
* 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.
This commit is contained in:
parent
f987bc43b2
commit
38700569e7
3 changed files with 434 additions and 72 deletions
|
|
@ -2708,6 +2708,60 @@ async function buildFieldNameMap(
|
|||
log.info(`Built field name map: ${fieldNameToIds.size} fields`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set one form field's value in pdf.js's annotationStorage, in the format
|
||||
* AnnotationLayer expects to READ when it re-renders.
|
||||
*
|
||||
* Radio buttons need per-widget booleans: pdf.js's RadioButtonWidgetAnnotation
|
||||
* render() has inverted string coercion (`value !== buttonValue` → true for
|
||||
* every NON-matching widget), so a string value on all widgets checks the
|
||||
* first rendered one and clears the rest regardless of what you asked for.
|
||||
* Match pdf.js's own change handler instead: `{value: true}` on the widget
|
||||
* whose buttonValue matches, `{value: false}` on the siblings.
|
||||
*
|
||||
* Also patches the live DOM element for the current page so the user sees the
|
||||
* change without waiting for a full re-render.
|
||||
*/
|
||||
function setFieldInStorage(name: string, value: string | boolean): void {
|
||||
if (!pdfDocument) return;
|
||||
const ids = fieldNameToIds.get(name);
|
||||
if (!ids) return;
|
||||
const storage = pdfDocument.annotationStorage;
|
||||
|
||||
// Radio group: at least one widget ID has a buttonValue recorded.
|
||||
const isRadio = ids.some((id) => radioButtonValues.has(id));
|
||||
if (isRadio) {
|
||||
const want = String(value);
|
||||
for (const id of ids) {
|
||||
const checked = radioButtonValues.get(id) === want;
|
||||
storage.setValue(id, { value: checked });
|
||||
const el = formLayerEl.querySelector(
|
||||
`input[data-element-id="${id}"]`,
|
||||
) as HTMLInputElement | null;
|
||||
if (el) el.checked = checked;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Text / checkbox / select: same value on every widget (a field can have
|
||||
// multiple widget annotations sharing one /V).
|
||||
const storageValue = typeof value === "boolean" ? value : String(value);
|
||||
for (const id of ids) {
|
||||
storage.setValue(id, { value: storageValue });
|
||||
const el = formLayerEl.querySelector(`[data-element-id="${id}"]`) as
|
||||
| HTMLInputElement
|
||||
| HTMLSelectElement
|
||||
| HTMLTextAreaElement
|
||||
| null;
|
||||
if (!el) continue;
|
||||
if (el instanceof HTMLInputElement && el.type === "checkbox") {
|
||||
el.checked = !!value;
|
||||
} else {
|
||||
el.value = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync formFieldValues into pdfDocument.annotationStorage so AnnotationLayer renders pre-filled values.
|
||||
* Skips values that match the PDF's baseline — those are already in storage
|
||||
* in pdf.js's native format (which may differ from our string/bool repr,
|
||||
|
|
@ -2715,17 +2769,9 @@ async function buildFieldNameMap(
|
|||
* form can break the Reset button's ability to restore defaults. */
|
||||
function syncFormValuesToStorage(): void {
|
||||
if (!pdfDocument || fieldNameToIds.size === 0) return;
|
||||
const storage = pdfDocument.annotationStorage;
|
||||
for (const [name, value] of formFieldValues) {
|
||||
if (pdfBaselineFormValues.get(name) === value) continue;
|
||||
const ids = fieldNameToIds.get(name);
|
||||
if (ids) {
|
||||
for (const id of ids) {
|
||||
storage.setValue(id, {
|
||||
value: typeof value === "boolean" ? value : String(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
setFieldInStorage(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4093,6 +4139,15 @@ app.ontoolresult = async (result: CallToolResult) => {
|
|||
} catch (err) {
|
||||
log.error("Error loading PDF:", err);
|
||||
showError(err instanceof Error ? err.message : String(err));
|
||||
// Poll anyway. The server's interact tool has no way to know we choked —
|
||||
// without a poll it waits 45s on every get_screenshot against this
|
||||
// viewUUID. handleGetPages already null-guards pdfDocument, so a failed
|
||||
// load just means empty page data → server returns "No screenshot
|
||||
// returned" (fast, actionable) instead of "Timeout waiting for page data
|
||||
// from viewer" (slow, opaque).
|
||||
if (viewUUID && interactEnabled) {
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -4213,44 +4268,10 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
|
|||
case "fill_form":
|
||||
for (const field of cmd.fields) {
|
||||
formFieldValues.set(field.name, field.value);
|
||||
// Set in PDF.js annotation storage and update DOM elements directly
|
||||
if (pdfDocument) {
|
||||
const ids = fieldNameToIds.get(field.name);
|
||||
if (ids) {
|
||||
for (const id of ids) {
|
||||
pdfDocument.annotationStorage.setValue(id, {
|
||||
value:
|
||||
typeof field.value === "boolean"
|
||||
? field.value
|
||||
: String(field.value),
|
||||
});
|
||||
// Update the live DOM element if it exists on the current page
|
||||
const el = formLayerEl.querySelector(
|
||||
`[data-element-id="${id}"]`,
|
||||
) as
|
||||
| HTMLInputElement
|
||||
| HTMLSelectElement
|
||||
| HTMLTextAreaElement
|
||||
| null;
|
||||
if (el) {
|
||||
if (
|
||||
el instanceof HTMLInputElement &&
|
||||
el.type === "checkbox"
|
||||
) {
|
||||
el.checked = !!field.value;
|
||||
} else if (el instanceof HTMLSelectElement) {
|
||||
el.value = String(field.value);
|
||||
} else {
|
||||
el.value = String(field.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.info(
|
||||
`fill_form: no annotation IDs for field "${field.name}"`,
|
||||
);
|
||||
}
|
||||
if (!fieldNameToIds.has(field.name)) {
|
||||
log.info(`fill_form: no annotation IDs for field "${field.name}"`);
|
||||
}
|
||||
setFieldInStorage(field.name, field.value);
|
||||
}
|
||||
// Re-render to show updated form values (handles fields on other pages)
|
||||
renderPage();
|
||||
|
|
@ -4440,16 +4461,27 @@ app.onteardown = async () => {
|
|||
app.onhostcontextchanged = handleHostContextChanged;
|
||||
|
||||
// Connect to host
|
||||
app.connect().then(() => {
|
||||
log.info("Connected to host");
|
||||
const ctx = app.getHostContext();
|
||||
if (ctx) {
|
||||
handleHostContextChanged(ctx);
|
||||
}
|
||||
// Restore annotations early using toolInfo.id (available before tool result)
|
||||
restoreAnnotations();
|
||||
updateAnnotationsBadge();
|
||||
});
|
||||
app
|
||||
.connect()
|
||||
.then(() => {
|
||||
log.info("Connected to host");
|
||||
const ctx = app.getHostContext();
|
||||
if (ctx) {
|
||||
handleHostContextChanged(ctx);
|
||||
}
|
||||
// Restore annotations early using toolInfo.id (available before tool result)
|
||||
restoreAnnotations();
|
||||
updateAnnotationsBadge();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// ui/initialize failed or transport rejected. Without a catch this is an
|
||||
// unhandled rejection — iframe shows blank, server times out on every
|
||||
// interact call with no clue why.
|
||||
log.error("Failed to connect to host:", err);
|
||||
showError(
|
||||
`Failed to connect to host: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Debug helper: dump all annotation state. Run in DevTools console as
|
||||
// `__pdfDebug()` to diagnose ghost annotations (visible on canvas but not
|
||||
|
|
|
|||
|
|
@ -1031,6 +1031,81 @@ describe("buildAnnotatedPdfBytes", () => {
|
|||
const bytes2 = await doc.save();
|
||||
expect(bytes2.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe("form field persistence", () => {
|
||||
// One fixture with every field type we support. pdf-lib's addOptionToPage
|
||||
// writes radio buttonValues as numeric index strings ("0","1","2"), which
|
||||
// is the stress case — the viewer stores those, but .select() wants labels.
|
||||
let formPdfBytes: Uint8Array;
|
||||
beforeAll(async () => {
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([612, 792]);
|
||||
const form = doc.getForm();
|
||||
form.createTextField("name").addToPage(page, { x: 10, y: 700 });
|
||||
form.createCheckBox("agree").addToPage(page, { x: 10, y: 660 });
|
||||
const dd = form.createDropdown("country");
|
||||
dd.addOptions(["USA", "UK", "Canada"]);
|
||||
dd.addToPage(page, { x: 10, y: 620 });
|
||||
const rg = form.createRadioGroup("size");
|
||||
rg.addOptionToPage("small", page, { x: 10, y: 580 });
|
||||
rg.addOptionToPage("medium", page, { x: 50, y: 580 });
|
||||
rg.addOptionToPage("large", page, { x: 90, y: 580 });
|
||||
formPdfBytes = await doc.save();
|
||||
});
|
||||
|
||||
it("writes text, checkbox, dropdown, and radio (by label) in one pass", async () => {
|
||||
const out = await buildAnnotatedPdfBytes(
|
||||
formPdfBytes,
|
||||
[],
|
||||
new Map<string, string | boolean>([
|
||||
["name", "Alice"],
|
||||
["agree", true],
|
||||
["country", "Canada"],
|
||||
["size", "medium"],
|
||||
]),
|
||||
);
|
||||
const form = (await PDFDocument.load(out)).getForm();
|
||||
expect(form.getTextField("name").getText()).toBe("Alice");
|
||||
expect(form.getCheckBox("agree").isChecked()).toBe(true);
|
||||
expect(form.getDropdown("country").getSelected()).toEqual(["Canada"]);
|
||||
expect(form.getRadioGroup("size").getSelected()).toBe("medium");
|
||||
});
|
||||
|
||||
it("maps numeric radio buttonValue to option label by index", async () => {
|
||||
// The viewer stores what pdf.js reports as buttonValue ("2"), not the
|
||||
// label. Save must translate or the radio is silently dropped.
|
||||
const out = await buildAnnotatedPdfBytes(
|
||||
formPdfBytes,
|
||||
[],
|
||||
new Map<string, string | boolean>([["size", "2"]]),
|
||||
);
|
||||
const form = (await PDFDocument.load(out)).getForm();
|
||||
expect(form.getRadioGroup("size").getSelected()).toBe("large");
|
||||
});
|
||||
|
||||
it("leaves radio unset when value is neither label nor valid index", async () => {
|
||||
const out = await buildAnnotatedPdfBytes(
|
||||
formPdfBytes,
|
||||
[],
|
||||
new Map<string, string | boolean>([["size", "bogus"]]),
|
||||
);
|
||||
const form = (await PDFDocument.load(out)).getForm();
|
||||
expect(form.getRadioGroup("size").getSelected()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips unknown field names without throwing", async () => {
|
||||
const out = await buildAnnotatedPdfBytes(
|
||||
formPdfBytes,
|
||||
[],
|
||||
new Map<string, string | boolean>([
|
||||
["nonexistent", "x"],
|
||||
["name", "kept"],
|
||||
]),
|
||||
);
|
||||
const form = (await PDFDocument.load(out)).getForm();
|
||||
expect(form.getTextField("name").getText()).toBe("kept");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ import {
|
|||
PDFString,
|
||||
PDFHexString,
|
||||
StandardFonts,
|
||||
PDFTextField,
|
||||
PDFCheckBox,
|
||||
PDFDropdown,
|
||||
PDFRadioGroup,
|
||||
} from "pdf-lib";
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -810,26 +814,45 @@ export async function buildAnnotatedPdfBytes(
|
|||
// Add proper PDF annotation objects
|
||||
await addAnnotationDicts(pdfDoc, annotations);
|
||||
|
||||
// Apply form fills
|
||||
// Apply form fills. Dispatch on actual field type — getTextField(name) throws
|
||||
// for dropdowns/radios, so the old try/catch silently dropped those on save.
|
||||
if (formFields.size > 0) {
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
for (const [name, value] of formFields) {
|
||||
try {
|
||||
if (typeof value === "boolean") {
|
||||
const checkbox = form.getCheckBox(name);
|
||||
if (value) checkbox.check();
|
||||
else checkbox.uncheck();
|
||||
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 textField = form.getTextField(name);
|
||||
textField.setText(value);
|
||||
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
|
||||
}
|
||||
} catch {
|
||||
// Field not found or wrong type — skip
|
||||
} 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 {
|
||||
// Form not available — skip
|
||||
// pdfDoc.getForm() throws if the PDF has no AcroForm
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue