Thanks to KJ for pointing me in the right direction!
A coworker wrote up a different way to fill the fields using pdfrw, example below:
from pdfrw import PdfReader, PdfWriter, PdfDict, PdfObject, PdfName, PageMerge
from pdfrw.objects.pdfstring import PdfString
def fill_pdf_fields(input_path, output_path):
pdf = PdfReader(input_path)
# Ensure viewer regenerates appearances
if not pdf.Root.AcroForm:
pdf.Root.AcroForm = PdfDict(NeedAppearances=PdfObject('true'))
else:
pdf.Root.AcroForm.update(PdfDict(NeedAppearances=PdfObject('true')))
for page in pdf.pages:
annotations = page.Annots
if annotations:
for annot in annotations:
if annot.Subtype == PdfName('Widget') and annot.T:
field_name = str(annot.T)[1:-1]
if field_name == "MemberName": annot.V = PdfObject(f'(Test)')
if field_name == "Address": annot.V = PdfObject(f'(123 Sesame St)')
if field_name == "CityStateZip": annot.V = PdfObject(f'(Birmingham, AK 12345-6789)')
if field_name == "Level": annot.V = PdfObject(f'(1)')
if field_name == "OfficialsNumber": annot.V = PdfObject(f'(9999999)')
if field_name == "Season2": annot.V = PdfObject(f'(2025-26)')
if field_name == "Season1": annot.V = PdfObject(f'(2025-2026)')
PdfWriter().write(output_path, pdf)
print(f"Filled PDF saved to: {output_path}")
def flatten_pdf_fields(input_path, output_path):
template_pdf = PdfReader(input_path)
for page in template_pdf.pages:
annotations = page.Annots
if annotations:
for annot in annotations:
if annot.Subtype == PdfName('Widget') and annot.T and annot.V:
# Remove interactive field appearance
annot.update({
PdfName('F'): PdfObject('4'), # Make field read-only
PdfName('AP'): None # Remove appearance stream
})
# Flatten page by merging its own content (no overlay)
PageMerge(page).render()
PdfWriter(output_path, trailer=template_pdf).write()
print(f"Flattened PDF saved to: {output_path}")
if __name__ == "__main__":
fill_pdf_fields(template_pdf, filled_pdf)
flatten_pdf_fields(filled_pdf, flattened_pdf)
I researched interactions with NeedAppearances, and found this Stack post:
NeedAppearances=pdfrw.PdfObject('true') forces manual pdf save in Acrobat Reader
The answer provides a code snippet that from what I can tell, acts as a reader generating those appearance streams so the filled in fields actually show their contents.
Code snippet for reference:
from pikepdf import Pdf
with Pdf.open('source_pdf.pdf') as pdf:
pdf.generate_appearance_streams()
pdf.save('output.pdf')