Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions app/services/form_filler/filler.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,20 @@ 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 = {}
for widget in page_widgets(page):
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)
Expand Down
31 changes: 29 additions & 2 deletions app/services/form_filler/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"]
89 changes: 87 additions & 2 deletions tests/test_form_filler.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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"}
Loading