From 984e3eb10a995d72ccfec8479b75d5cf359d2ffe Mon Sep 17 00:00:00 2001 From: Anand Biju Date: Sat, 24 Oct 2026 18:32:10 +0530 Subject: [PATCH] fix: :bug: fill radio button groups with the chosen option The buttons of a radio group share one field name. write_pdf treated each button like a checkbox, so an answer of "Yes" gave every button its own on state and the last one won, and any other answer left the group empty. On OSHA Form 301, "Was employee treated in an emergency room?" answered "Yes" came out as "No". The schema also gave the model a free-form string, so it never saw the options. A radio group is now offered its buttons' states as an enum, plus "Off", the way checkboxes get "Yes" and "Off". write_pdf gives the whole group the chosen state, and pypdf turns on the button that has it. Groups that geometry reads as table columns are unchanged. --- app/services/form_filler/filler.py | 8 ++- app/services/form_filler/template.py | 31 +++++++++- tests/test_form_filler.py | 89 +++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/app/services/form_filler/filler.py b/app/services/form_filler/filler.py index 57cde66..fd36faf 100644 --- a/app/services/form_filler/filler.py +++ b/app/services/form_filler/filler.py @@ -84,7 +84,7 @@ def call_model(prompt, schema, model=None, provider=None): def write_pdf(pdf_path, values, out_path): new_doc = PdfWriter(clone_from=pdf_path) - # pypdf sets checkboxes by appearance-state name ("/Yes"), not "Yes" + # pypdf sets checkboxes and radio buttons by appearance-state name ("/Yes"), not "Yes" for page in new_doc.pages: drop_null_parents(page) page_values = {} @@ -92,8 +92,12 @@ def write_pdf(pdf_path, values, out_path): if widget.field_type == SIGNATURE: continue value = values.get(widget.field_name, "") - if widget.field_type in (CHECKBOX, RADIOBUTTON): + if widget.field_type == CHECKBOX: value = on_state(widget) if value == "Yes" else "/Off" + elif widget.field_type == RADIOBUTTON: + # The buttons of a group share its name, so all of them get the + # chosen option's state and pypdf turns on the one that has it + value = f"/{value}" if value and value != "Off" else "/Off" page_values[widget.field_name] = "" if value is None else str(value) if page_values: new_doc.update_page_form_field_values(page, page_values) diff --git a/app/services/form_filler/template.py b/app/services/form_filler/template.py index 507048a..74d08b0 100644 --- a/app/services/form_filler/template.py +++ b/app/services/form_filler/template.py @@ -36,12 +36,14 @@ def create_template(pdf_path): """(schema, tables, groups) for a fillable PDF. schema is what the model fills: geometry tables as arrays, every other - widget as a scalar, each described by its /TU tooltip. tables and groups - map array rows back to widget names (expand_output). + widget as a scalar, each described by its /TU tooltip. A radio group's + scalar is one of its options. tables and groups map array rows back to + widget names (expand_output). """ layout = geometry.extract(os.path.abspath(pdf_path)) all_tables, all_groups, schema = build_schema(layout) add_tooltip_descriptions(schema, all_groups, read_tooltips(pdf_path)) + add_radio_options(schema, read_radio_options(pdf_path)) return schema, all_tables, all_groups @@ -196,3 +198,28 @@ def describe(key): column_prop["description"] = describe(column) else: prop["description"] = describe(key) + + +# -------------------------------------------------------------- radio groups + +def read_radio_options(pdf_path): + """{group name: options}: the state that turns each of its buttons on + ("Male", "Female"), in page order.""" + options = {} + for page in PdfReader(pdf_path).pages: + for widget in geometry.page_widgets(page): + if widget.field_type == geometry.RADIOBUTTON: + group = options.setdefault(widget.field_name, []) + option = geometry.on_state(widget)[1:] + if option not in group: + group.append(option) + return options + + +def add_radio_options(schema, options): + """A radio group is answered with one of its options, or "Off" for none, + like a checkbox. Groups read as table columns are left as they are.""" + for key, prop in schema["properties"].items(): + if key in options: + prop.pop("type", None) + prop["enum"] = options[key] + ["Off"] diff --git a/tests/test_form_filler.py b/tests/test_form_filler.py index 5544fc8..78720da 100644 --- a/tests/test_form_filler.py +++ b/tests/test_form_filler.py @@ -1,7 +1,8 @@ """Tests for app.services.form_filler without a real LLM. Uses the real ICS 205A from the benchmark data: the template is built from the -PDF itself, the model call is patched, and the filled PDF is read back. +PDF itself, the model call is patched, and the filled PDF is read back. None of +the benchmark PDFs has a radio group, so those tests write a small form instead. """ import json @@ -10,7 +11,7 @@ from pypdf import PdfReader -from app.services.form_filler import filler, template +from app.services.form_filler import filler, geometry, template from app.services.llm import ModelInfo ICS_205A = str(Path(__file__).resolve().parents[1] / "benchmark/data/pdfs/ics_205a.pdf") @@ -98,3 +99,87 @@ def test_call_model_without_a_pick_uses_the_default(test_llm): generate.assert_called_once_with("prompt", {"type": "object"}, "qwen2.5:1.5b") + +def _radio_form(path): + """A one-page form with a text field and two radio groups. Each button has + its own 'on' appearance state (/Male, /Female) next to /Off, as Acrobat + writes them.""" + def button(parent, state, x, y): + return ( + f"<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R" + f" /Rect [{x} {y} {x + 12} {y + 12}] /AS /Off" + f" /AP << /N << /{state} 11 0 R /Off 11 0 R >> >> >>" + ) + + objects = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 8 0 R]" + " /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1" + " /BaseFont /Helvetica >> >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200]" + " /Annots [4 0 R 6 0 R 7 0 R 9 0 R 10 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [20 160 200 175] >>", + "<< /FT /Btn /Ff 49152 /T (Gender) /Kids [6 0 R 7 0 R] >>", + button(5, "Male", 20, 120), + button(5, "Female", 80, 120), + "<< /FT /Btn /Ff 49152 /T (ER) /Kids [9 0 R 10 0 R] >>", + button(8, "Yes", 20, 80), + button(8, "No", 80, 80), + "<< /Length 0 >>\nstream\n\nendstream", + ] + pdf = bytearray(b"%PDF-1.7\n") + offsets = [] + for number, body in enumerate(objects, start=1): + offsets.append(len(pdf)) + pdf += f"{number} 0 obj\n{body}\nendobj\n".encode() + xref, size = len(pdf), len(objects) + 1 + pdf += f"xref\n0 {size}\n0000000000 65535 f \n".encode() + pdf += "".join(f"{offset:010d} 00000 n \n" for offset in offsets).encode() + pdf += f"trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + path.write_bytes(bytes(pdf)) + return str(path) + + +def _radio_states(pdf_path, group): + """{state that turns each button of the group on: the button's current /AS}""" + return { + geometry.on_state(widget): widget.annotation["/AS"] + for page in PdfReader(pdf_path).pages + for widget in geometry.page_widgets(page) + if widget.field_name == group + } + + +def test_radio_groups_offer_their_options(tmp_path): + schema, _, _ = template.create_template(_radio_form(tmp_path / "radio.pdf")) + props = schema["properties"] + + assert props["Gender"]["enum"] == ["Male", "Female", "Off"] + assert props["ER"]["enum"] == ["Yes", "No", "Off"] + assert "type" not in props["Gender"] + assert props["Name"]["type"] == "string" + + +def test_fill_turns_on_the_chosen_radio_button(tmp_path): + pdf_path = _radio_form(tmp_path / "radio.pdf") + out_path = tmp_path / "filled.pdf" + answer = {"Name": "Ana Diaz", "Gender": "Female", "ER": "Yes"} + + with patch.object(filler, "call_model", return_value=json.dumps(answer)) as call: + filler.fill(pdf_path, "Narrative text.", str(out_path)) + + assert call.call_args.args[1]["properties"]["Gender"]["enum"] == ["Male", "Female", "Off"] + assert _radio_states(out_path, "Gender") == {"/Male": "/Off", "/Female": "/Female"} + # Every button used to get the last button's state, so "Yes" turned on "No" + assert _radio_states(out_path, "ER") == {"/Yes": "/Yes", "/No": "/Off"} + assert PdfReader(str(out_path)).get_fields()["Name"]["/V"] == "Ana Diaz" + + +def test_radio_group_answered_off_stays_unselected(tmp_path): + pdf_path = _radio_form(tmp_path / "radio.pdf") + out_path = tmp_path / "filled.pdf" + + filler.write_pdf(pdf_path, {"Gender": "Off", "ER": ""}, str(out_path)) + + assert _radio_states(out_path, "Gender") == {"/Male": "/Off", "/Female": "/Off"} + assert _radio_states(out_path, "ER") == {"/Yes": "/Off", "/No": "/Off"}