Skip to content

Commit 17bc2c5

Browse files
committed
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.
1 parent 2945703 commit 17bc2c5

2 files changed

Lines changed: 90 additions & 2 deletions

File tree

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,48 @@ describe("buildAnnotatedPdfBytes", () => {
11331133
// The throwing field is left at whatever pdf-lib could do with it —
11341134
// we only assert it didn't poison "after".
11351135
});
1136+
1137+
it("radio misclassified as PDFCheckBox: string value selects the matching widget", async () => {
1138+
// Some PDFs (e.g. IRS/third-party forms) omit the /Ff Radio bit, so
1139+
// pdf-lib hands us a PDFCheckBox. The viewer stored pdf.js's
1140+
// buttonValue ("0"/"1"), not a boolean — check() would always pick
1141+
// the first widget. setButtonGroupValue writes /V + per-widget /AS
1142+
// directly so the chosen widget sticks.
1143+
const doc = await PDFDocument.create();
1144+
const page = doc.addPage([612, 792]);
1145+
const form = doc.getForm();
1146+
const rg = form.createRadioGroup("Gender");
1147+
rg.addOptionToPage("Male", page, { x: 10, y: 700 });
1148+
rg.addOptionToPage("Female", page, { x: 60, y: 700 });
1149+
// pdf-lib's addOptionToPage writes widget on-values "0","1". Clear the
1150+
// Radio flag (bit 16) so the reloaded form classifies it as checkbox.
1151+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1152+
(rg.acroField as any).clearFlag(1 << 15);
1153+
const fixture = await doc.save();
1154+
1155+
// Sanity: reload sees it as PDFCheckBox now.
1156+
const reForm = (await PDFDocument.load(fixture)).getForm();
1157+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1158+
const reField = reForm.getFieldMaybe("Gender") as any;
1159+
expect(reField?.constructor?.name).toBe("PDFCheckBox");
1160+
1161+
const out = await buildAnnotatedPdfBytes(
1162+
fixture,
1163+
[],
1164+
new Map<string, string | boolean>([["Gender", "1"]]),
1165+
);
1166+
1167+
const saved = await PDFDocument.load(out);
1168+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1169+
const acro = (saved.getForm().getFieldMaybe("Gender") as any).acroField;
1170+
const v = acro.dict.get(PDFName.of("V"));
1171+
expect(v).toBeInstanceOf(PDFName);
1172+
expect((v as PDFName).decodeText()).toBe("1");
1173+
// Second widget /AS is the on-state, first is /Off.
1174+
const widgets = acro.getWidgets();
1175+
expect(widgets[0].getAppearanceState()?.decodeText()).toBe("Off");
1176+
expect(widgets[1].getAppearanceState()?.decodeText()).toBe("1");
1177+
});
11361178
});
11371179
});
11381180

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,40 @@ export async function addAnnotationDicts(
801801
}
802802
}
803803

804+
/**
805+
* Select a radio-style button group by widget on-value, bypassing pdf-lib's
806+
* type-level guards. Used when pdf-lib classifies a radio as `PDFCheckBox`
807+
* (PDF lacks the /Ff Radio bit) — `check()` would always pick the first
808+
* widget. Mirrors `PDFAcroRadioButton.setValue` minus its `onValues` throw.
809+
*/
810+
function setButtonGroupValue(
811+
field: PDFCheckBox | PDFRadioGroup,
812+
onValue: string,
813+
): void {
814+
const acro = field.acroField;
815+
const off = PDFName.of("Off");
816+
const widgets = acro.getWidgets();
817+
// Match by PDFName identity (pdf-lib interns names) — the viewer stored
818+
// pdf.js's buttonValue, which IS the widget's /AP /N on-state name.
819+
let target = onValue && onValue !== "Off" ? PDFName.of(onValue) : off;
820+
if (
821+
target !== off &&
822+
!widgets.some((w: { getOnValue(): PDFName | undefined }) => {
823+
return w.getOnValue() === target;
824+
})
825+
) {
826+
// No widget has this on-state — leave as-is rather than corrupt /V.
827+
return;
828+
}
829+
acro.dict.set(PDFName.of("V"), target);
830+
for (const w of widgets) {
831+
const on = (w as { getOnValue(): PDFName | undefined }).getOnValue();
832+
(w as { setAppearanceState(s: PDFName): void }).setAppearanceState(
833+
on === target ? target : off,
834+
);
835+
}
836+
}
837+
804838
/**
805839
* Build annotated PDF bytes from the original document.
806840
* Applies user annotations and form fills, returns Uint8Array of the new PDF.
@@ -836,8 +870,20 @@ export async function buildAnnotatedPdfBytes(
836870
if (!field) continue;
837871

838872
if (field instanceof PDFCheckBox) {
839-
if (value) field.check();
840-
else field.uncheck();
873+
if (typeof value === "string") {
874+
// A string value on a "checkbox" means pdf-lib misclassified a
875+
// radio group (PDF lacks the /Ff Radio flag bit). The viewer
876+
// stored pdf.js's buttonValue, which is the widget's appearance
877+
// on-state name (e.g. "0"/"1"). check()/uncheck() would set the
878+
// FIRST widget's on-state regardless, so write /V and per-widget
879+
// /AS directly — same as PDFAcroRadioButton.setValue but without
880+
// its onValues guard (which checks the first widget only).
881+
setButtonGroupValue(field, value);
882+
} else if (value) {
883+
field.check();
884+
} else {
885+
field.uncheck();
886+
}
841887
} else if (field instanceof PDFRadioGroup) {
842888
// The viewer stores pdf.js's buttonValue, which for PDFs with an
843889
// /Opt array is a numeric index ("0","1","2") rather than the

0 commit comments

Comments
 (0)