diff --git a/app/services/form_filler/filler.py b/app/services/form_filler/filler.py index 87d18c0..0aa3afc 100644 --- a/app/services/form_filler/filler.py +++ b/app/services/form_filler/filler.py @@ -109,7 +109,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 = {} @@ -117,8 +117,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"}