Backend
Filling a checkbox with pypdf, and the on-state string it never validates
PDFops DEV Community
1 views
pypdf's update_page_form_field_values() will set a checkbox's value without checking whether the value you passed means anything. Pass the wrong on-state string, one that doesn't exist anywhere in that field's appearance dictionary, and the call returns normally, the field looks filled if you read it back with get_fields(), and the box still renders unchecked in every viewer. No exception, no log line, not even at DEBUG level. Four common inputs trigger it: "Yes" without the leading slash, "true", "1", and the Python boolean True. Only the exact string the field's own appearance dictionary defines actually checks the box.
What actually decides whether a box looks checked
An AcroForm checkbox carries two things that have to agree: the field's value (/V) and the widget's appearance state (/AS). A viewer renders whichever appearance stream /AS points to, out of the ones listed in the widget's /AP /N dictionary. The PDF spec never mandates a name for the checked state. /Yes is the common convention because Acrobat's own form wizard writes it, but a PDF built in a different tool can use /On, /1, or a name its author chose. There is no universal on value for a checkbox, only whatever key sits in that specific field's own appearance dictionary next to /Off.
Reproducing the silent no-op
Take a one-field test PDF whose checkbox agree_to_terms really uses /Yes as its on-state, confirmed by reading _States_ off the field: ['/Off', '/Yes']. Running update_page_form_field_values() with auto_regenerate=False for the values "Yes", "true", "1", and True leaves /V and /AS at /Off every time, on pypdf 6.18.0. Only {"agree_to_terms": "/Yes"}, matching the field's real on-state string exactly, sets both to /Yes. The function returns None, so there is nothing in the call to check for success, and nothing gets logged either way.
from pypdf import PdfReader, PdfWriter
reader = PdfReader("consent-form.pdf")
writer = PdfWriter()
writer.append(reader)
# Every one of these looks reasonable. None of them checks the box.
for value in ["Yes", "true", "1", True]:
writer.update_page_form_field_values(
writer.pages[0], {"agree_to_terms": value}, auto_regenerate=False,
)
print(value, "->", writer.get_fields()["agree_to_terms"].get("/V"))
# /Off, every single time. No exception, no warning.
# Only the field's own on-state string checks it.
writer.update_page_form_field_values(
writer.pages[0], {"agree_to_terms": "/Yes"}, auto_regenerate=False,
)
print(writer.get_fields()["agree_to_terms"].get("/V")) # /Yes
Reading the real on-state before you fill
The fix is to stop guessing at the on-state name and read it off the field instead. reader.get_fields() exposes it as _States_, a list where one entry is always /Off and the other is the string that actually checks the box; the widget annotation's own /AP /N keys carry the same string for anyone working at that lower level. Either way, the value passed to update_page_form_field_values has to come from the PDF itself, never from a hardcoded /Yes or a stringified boolean, because the on-state name belongs to that specific template, not to the file format in general.
reader = PdfReader("consent-form.pdf")
fields = reader.get_fields()
on_state = next(
s for s in fields["agree_to_terms"]["/_States_"] if s != "/Off"
)
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0], {"agree_to_terms": on_state}, auto_regenerate=False,
)
The default that trades one bug for a viewer-dependent one
update_page_form_field_values defaults auto_regenerate to True, and that default sets NeedAppearances on the whole document's AcroForm dictionary rather than baking the checkbox's new appearance into the file at write time. NeedAppearances tells a conforming viewer to regenerate every field's appearance itself when the document opens, so rendering moves from decided once, at fill time, to decided per viewer, on every open. Acrobat honors the flag. A number of other renderers, including some used in headless pipelines, do not regenerate anything and just show whatever appearance stream was already baked in, which for a field filled this way can mean no visible change at all.
What that breaks beyond checkboxes
That reaches past checkboxes for anyone testing generated PDFs by hashing them. A document with NeedAppearances set can render two different ways depending on which engine opens it, and that undercuts the premise behind hashing a PDF and diffing it in CI: the bytes match, but a screenshot or a downstream re-render is not guaranteed to. auto_regenerate=False avoids planting that flag, at the cost of handling any appearance-stream work pypdf does not bake in on its own.
Try it
None of this is specific to a checkbox filled by hand. It is why /api/fill-form takes a plain "true" or "false" string for any checkbox field, resolves the real on-state name internally, and bakes a fresh appearance stream into the response PDF instead of setting NeedAppearances and leaving the outcome to whichever viewer opens the file next. A free key from /docs/signup covers 250 calls a month, and /docs/inspect reads back a template's real field names and types first, checkboxes included, so there is no need to go spelunging through _States_ by hand at all.
Read original: https://dev.to/pdfops/filling-a-checkbox-with-pypdf-and-the-on-state-string-it-never-validates-5hii
← Previous
How to access BIOS remotely
Next →
n8n Assistant Launches as a Preview AI Workflow Builder for Cloud and Docker Users
Related
plutil -extract replaced my plist with the key I was reading, and exited 0
Backend
1
DEV Community
Stripe vs Wise: The Solopreneur's Transaction Labyrinth
Backend
2
Dev.to (EN Zone)
SENTINEL-RL for SOCs: Architectural Gains and Cost Realities from Decoupling Semantic and Topological Reasoning
Backend
2
DEV Community
Day 15: Visualizing Embeddings with t-SNE and PCA
Backend
2
DEV Community
Comments0
No comments yet — be the first