Fix PDF Form Fields Not Visible After Filling
The script reports success, the file size grows, and re-reading the PDF confirms every value is present — but the form opens with all boxes blank:
>>> PdfReader("out/filled.pdf").get_fields()["applicant_name"]["/V"]
'Dana Whitfield'
# ...yet the same file in Chrome, Firefox or macOS Preview shows an empty box.
The data is in the file. What is missing is the picture of the data. A PDF form field stores its value in the field dictionary (/V) and stores what the reader draws in a separate appearance stream (/AP) attached to the widget annotation. Writing /V without regenerating /AP leaves the old, empty appearance in place, and every viewer that renders appearance streams verbatim — Chrome's PDFium, Firefox's pdf.js, macOS Preview, most mobile readers — faithfully draws the blank box it was handed.
Root Cause
Acrobat is the outlier that hides the bug. It honours the document-level /NeedAppearances flag, which means "the appearance streams are stale, rebuild them on open", so a file filled without appearances looks correct there and broken everywhere else. When that flag is absent and no fresh /AP was written, there is nothing to draw and nothing instructing the viewer to draw it.
A second, less common cause produces the same symptom: the field's default appearance string (/DA) names a font that is not present in the form's resource dictionary (/AcroForm /DR). The viewer then has a stream to draw but no font to draw it with, and renders nothing. Both causes are separated by the diagnostic below.
Minimal Diagnostic
Run this against the output file. It reports, per field, whether a value exists, whether an appearance stream exists, and whether the document asks viewers to rebuild appearances.
# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader
FILLED_PDF = Path("out/filled.pdf")
def appearance_report(pdf_path: Path) -> None:
"""Show whether each filled field also carries a drawable appearance stream."""
if not pdf_path.exists():
raise FileNotFoundError(f"No such file: {pdf_path}")
try:
reader = PdfReader(str(pdf_path))
except Exception as exc:
raise RuntimeError(f"Could not parse {pdf_path}: {exc}") from exc
acro = reader.trailer["/Root"].get("/AcroForm", {})
print(f"/NeedAppearances = {acro.get('/NeedAppearances', False)}")
for page_no, page in enumerate(reader.pages, start=1):
for annot in page.get("/Annots") or []:
obj = annot.get_object()
if obj.get("/Subtype") != "/Widget":
continue
parent = obj.get("/Parent")
name = obj.get("/T") or (parent.get_object().get("/T") if parent else "?")
value = obj.get("/V") or (parent.get_object().get("/V") if parent else None)
has_ap = "/AP" in obj
print(f"p{page_no} {str(name):<28} value={str(value)!r:<20} /AP={'yes' if has_ap else 'NO'}")
if __name__ == "__main__":
appearance_report(FILLED_PDF)
The broken file looks like this:
/NeedAppearances = False
p1 applicant_name value='Dana Whitfield' /AP=NO
p1 applicant_email value='[email protected]' /AP=NO
A value with /AP=NO and /NeedAppearances = False is the confirmed root cause: nothing in the file tells any viewer what to paint. The four combinations of those two markers map cleanly onto what a reader will show:
The Fix
Do both things — generate the appearance streams and set the flag. They cover different viewers, and the pair costs one extra line.
# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject
SRC = Path("data/application_form.pdf")
DEST = Path("out/filled.pdf")
def fill_visibly(src: Path, dest: Path, values: dict[str, str]) -> Path:
"""Fill an AcroForm so the values render in every viewer, not just Acrobat."""
reader = PdfReader(str(src))
# clone_from copies the /AcroForm dictionary; append() can drop it entirely
writer = PdfWriter(clone_from=reader)
# 1. Tell viewers the appearances are stale and must be rebuilt on open.
writer._root_object[NameObject("/AcroForm")][NameObject("/NeedAppearances")] = BooleanObject(True)
# 2. Write the values AND let pypdf build an appearance stream for each one,
# which is what auto_regenerate=True does. Every page, not just page 1.
for page in writer.pages:
writer.update_page_form_field_values(page, values, auto_regenerate=True)
dest.parent.mkdir(parents=True, exist_ok=True)
with dest.open("wb") as fh:
writer.write(fh)
return dest
if __name__ == "__main__":
fill_visibly(SRC, DEST, {"applicant_name": "Dana Whitfield", "applicant_email": "[email protected]"})
print(f"Wrote {DEST}")
Re-run the diagnostic on the new output and the two markers flip:
/NeedAppearances = True
p1 applicant_name value='Dana Whitfield' /AP=yes
Variant Fix 1: Values Render as Empty Boxes or Wrong Glyphs
If the text appears as hollow rectangles, or in a font nothing on the page uses, the appearance stream exists but the /DA string names a font missing from the form's resources. Inspect what the field asks for:
# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader
def font_report(pdf_path: Path) -> None:
"""Compare each field's requested font against the form's resource dictionary."""
reader = PdfReader(str(pdf_path))
acro = reader.trailer["/Root"].get("/AcroForm", {})
available = list((acro.get("/DR", {}).get("/Font", {}) or {}).keys())
print("fonts available in /DR:", available)
print("document default /DA:", acro.get("/DA"))
for name, field in (reader.get_fields() or {}).items():
da = field.get("/DA")
if da:
print(f"{name:<28} /DA={da}")
if __name__ == "__main__":
font_report(Path("data/application_form.pdf"))
Output on a form built by a tool that did not embed its font:
fonts available in /DR: ['/Helv', '/ZaDb']
document default /DA: /Helv 0 Tf 0 g
applicant_name /DA=/Arial 10 Tf 0 g
/Arial is not in /DR, so nothing renders. Point the field at a font that is present:
# pip install "pypdf>=4.2,<6"
from pypdf.generic import NameObject, TextStringObject
def repoint_font(writer, field_name: str, da: str = "/Helv 10 Tf 0 g") -> None:
"""Rewrite a field's default appearance to a font present in /DR."""
for page in writer.pages:
for annot in page.get("/Annots") or []:
obj = annot.get_object()
target = obj.get("/T") or (obj.get("/Parent").get_object().get("/T") if obj.get("/Parent") else None)
if str(target) == field_name:
# /Helv and /ZaDb are the two fonts every AcroForm viewer provides
obj[NameObject("/DA")] = TextStringObject(da)
Setting the size to 0 (/Helv 0 Tf 0 g) turns on auto-sizing, which is the safe choice for fields whose content length varies — a 40-character company name in a fixed 12 pt field is clipped rather than shrunk.
Variant Fix 2: Only Checkboxes Stay Blank
If text fields render but buttons do not, the cause is different: the widget's appearance state (/AS) was not moved to match the new value. pypdf's auto_regenerate handles text but not the button state on some files, so set both explicitly.
# pip install "pypdf>=4.2,<6"
from pypdf.generic import NameObject
def tick(writer, field_name: str, on_state: str = "/Yes") -> None:
"""Set both the field value and the widget's drawn state for a checkbox."""
for page in writer.pages:
for annot in page.get("/Annots") or []:
obj = annot.get_object()
target = obj.get("/T") or (obj.get("/Parent").get_object().get("/T") if obj.get("/Parent") else None)
if str(target) != field_name:
continue
obj[NameObject("/V")] = NameObject(on_state) # the data
obj[NameObject("/AS")] = NameObject(on_state) # what gets drawn
Confirm on_state against the widget's own dictionary rather than assuming /Yes — the full lookup is in filling PDF forms with Python, whose diagnostic step prints the legal states for every button field.
Variant Fix 3: Fields Vanish Entirely After the Write
If the output has no fields at all — get_fields() returns None — the form dictionary was lost during the copy, not during the fill. This happens when the writer is built by appending pages instead of cloning the document:
# pip install "pypdf>=4.2,<6"
from pypdf import PdfReader, PdfWriter
reader = PdfReader("data/form.pdf")
# WRONG: copies pages, drops /AcroForm on many files
broken = PdfWriter()
broken.append(reader)
# RIGHT: copies the whole object graph, form dictionary included
fixed = PdfWriter(clone_from=reader)
print("fields preserved:", bool(fixed._root_object.get("/AcroForm")))
The same trap catches page-level operations: splitting or merging a form with the techniques in merging and splitting PDF documents keeps the pages but not necessarily the form, so fill before you assemble.
Verification
Assert on all three markers — value, appearance stream and flag — so a regression fails in CI rather than in a recipient's browser.
# pip install "pypdf>=4.2,<6"
from pathlib import Path
from pypdf import PdfReader
def assert_renderable(pdf_path: Path, expected: dict[str, str]) -> None:
"""Fail unless each value is present AND drawable."""
reader = PdfReader(str(pdf_path))
acro = reader.trailer["/Root"].get("/AcroForm", {})
need_appearances = bool(acro.get("/NeedAppearances", False))
has_ap = {}
for page in reader.pages:
for annot in page.get("/Annots") or []:
obj = annot.get_object()
parent = obj.get("/Parent")
name = obj.get("/T") or (parent.get_object().get("/T") if parent else None)
if name:
has_ap[str(name)] = "/AP" in obj
fields = reader.get_fields() or {}
for name, want in expected.items():
got = fields.get(name, {}).get("/V")
assert got is not None, f"{name}: no value written"
assert str(got).lstrip("/") == str(want).lstrip("/"), f"{name}: {got!r} != {want!r}"
assert need_appearances or has_ap.get(name), (
f"{name}: value present but nothing to draw — set /NeedAppearances or regenerate /AP"
)
print(f"{len(expected)} field(s) verified renderable in {pdf_path.name}")
if __name__ == "__main__":
assert_renderable(Path("out/filled.pdf"), {"applicant_name": "Dana Whitfield"})
For an end-to-end proof that does not trust any PDF library, rasterise page 1 and check that ink appears inside the widget rectangle:
# pip install pdf2image "pypdf>=4.2,<6" (needs poppler-utils on the system)
from pathlib import Path
from pdf2image import convert_from_path
from pypdf import PdfReader
def ink_in_widget(pdf_path: Path, field_name: str, dpi: int = 150) -> bool:
"""Rasterise page 1 and report whether the field's box contains any dark pixels."""
reader = PdfReader(str(pdf_path))
page = reader.pages[0]
rect = None
for annot in page.get("/Annots") or []:
obj = annot.get_object()
parent = obj.get("/Parent")
name = obj.get("/T") or (parent.get_object().get("/T") if parent else None)
if str(name) == field_name:
rect = [float(v) for v in obj["/Rect"]]
break
if rect is None:
raise KeyError(f"No widget for {field_name}")
scale = dpi / 72.0
image = convert_from_path(str(pdf_path), dpi=dpi, first_page=1, last_page=1)[0]
height = image.size[1]
x0, y0, x1, y1 = rect
# PDF origin is bottom-left; image origin is top-left, so flip the y axis.
crop = image.crop((int(x0 * scale), int(height - y1 * scale),
int(x1 * scale), int(height - y0 * scale))).convert("L")
return min(crop.getdata()) < 200 # any pixel darker than light grey
A True result is the strongest available evidence: the value is not merely stored, it is painted where a human will look for it. Wire the three checks into the order below and a broken fill can never reach a recipient:
FAQ
Why does the file look correct in Acrobat but blank everywhere else?
Acrobat honours /NeedAppearances and rebuilds stale appearance streams when it opens a file. Chrome, Firefox and Preview render the stream exactly as stored. Testing only in Acrobat therefore hides the defect — always open the output in a stream-rendering viewer before shipping.
Is setting /NeedAppearances alone enough?
Often, but not universally: some mobile and embedded viewers ignore the flag. Generating the appearance stream with auto_regenerate=True covers those, and the flag covers viewers whose own layout differs from pypdf's. Ship both.
Does regenerating appearances change the visual design of the form?
It redraws only the field contents, using the font and size from /DA inside the widget's existing rectangle. Borders, background shading and page artwork come from the widget's /MK dictionary and the page content stream, neither of which is touched.
The values show but are clipped at the right edge — what now?
The field is fixed-size and the string is longer than the box. Switch the /DA size to 0 for auto-sizing, or shorten the value. Auto-sizing is generally preferable on names and addresses; see the font section above for the exact edit.
Can I flatten instead of fixing appearances? Flattening merges the appearance stream into the page — but if no appearance exists, flattening produces a page with nothing on it. Fix the appearances first, then flatten, as described in flatten PDF form fields with Python.
Related
- Filling PDF Forms with Python — the full AcroForm workflow this fix belongs to
- Flatten PDF Form Fields with Python — bake the values in once they render correctly
- Fix ReportLab Unicode Font Errors — the same missing-font class of failure when generating PDFs
- Merging and Splitting PDF Documents — why form dictionaries get lost during assembly
- Generating PDF Reports Dynamically — build a document instead of filling one
Part of Filling PDF Forms with Python.