PDF Forms skill for Codex

Fill any AcroForm so every viewer shows it. Codex inspects the form first, printing every field name, type and checkbox state, then maps data to exact field names instead of guessing. It regenerates appearances so values show outside Acrobat, flattens when the recipient must not edit, batch-fills from CSV without leaking values between rows, and can build a new fillable form in ReportLab.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- pdf-forms

Installs to ~/.agents/skills/pdf-forms/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $pdf-forms, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Fills, reads, flattens and builds fillable PDF forms (AcroForm) with pypdf, qpdf and ReportLab - maps data to field names, sets checkbox and radio export values, regenerates appearances so values show in every viewer, and detects XFA forms that need a different route. Use when the user wants to populate a government or vendor PDF form from data, batch-fill forms from a CSV, read submitted form values, flatten a filled form, or create a new fillable PDF.

Use it when

  • A PDF has form fields that must be populated from data
  • Filled values are invisible in some viewers
  • You need to read back values from submitted PDF forms
  • A new fillable PDF form has to be created in code

Use something else when

How to check the result

Re-open the output with PdfReader.get_fields() and assert each field's value, then render page images to confirm checkboxes and text are visible.

The mistake it prevents

Setting a checkbox to "Yes" or True when the form's on-state is /1 or /On leaves the box unticked with no error.

~/.agents/skills/pdf-forms/SKILL.md

--- name: pdf-forms description: Fills, reads, flattens and builds fillable PDF forms (AcroForm) with pypdf, qpdf and ReportLab - maps data to field names, sets checkbox and radio… license: MIT ---

Done well means: every intended field holds the right value, the value is visible in Acrobat, Preview, Chrome and mobile viewers, checkboxes and radios use the form’s own export values, and the output is flattened when the recipient must not edit it.

Inspect the form before mapping anything

Needs pypdf (plus reportlab to build forms, qpdf to flatten on the CLI). Use the package manager named in AGENTS.md; installs need network, so ask for approval if the sandbox blocks them.

from pypdf import PdfReader

r = PdfReader("form.pdf")
root = r.trailer["/Root"]
acro = root.get("/AcroForm")
print("AcroForm:", acro is not None, "XFA:", bool(acro and "/XFA" in acro))

for name, f in (r.get_fields() or {}).items():
    print(
        f"{name!r:45} type={f.get('/FT')} value={f.get('/V')!r}",
        f"states={f.get('/_States_', '')}",
    )
What you seeMeaningRoute
Fields listed, no /XFAStandard AcroFormFill with pypdf (below)
/XFA present and fields listedHybrid XFA + AcroFormFill the AcroForm, then delete /XFA so viewers use it
/XFA present, get_fields() emptyDynamic XFA (LiveCycle)Cannot be filled reliably with open tools; ask for a static version or print-to-PDF and overlay text
No fields at allFlat PDF that looks like a formOverlay text at coordinates (last section) or build a fillable version

Field types: /Tx text, /Btn checkbox or radio, /Ch choice (list or combo), /Sig signature. Names are fully qualified (applicant.address.city); use the exact string get_fields() returns.

Produce a field map before writing code. Render the page and label fields with their names when names are cryptic (Text12): r.get_pages_showing_field(field_obj) tells you which page each field sits on, and the widget’s /Rect gives its position.

Fill with pypdf

from pypdf import PdfReader, PdfWriter
from pypdf.generic import NameObject

reader = PdfReader("w9.pdf")
writer = PdfWriter(clone_from=reader)

values = {
    "topmostSubform[0].Page1[0].f1_1[0]": "Ada Lovelace",
    "topmostSubform[0].Page1[0].f1_2[0]": "Analytical Engines LLC",
    "topmostSubform[0].Page1[0].c1_1[0]": "/1",   # checkbox: use its own "on" state
}

# page=None applies the values to every page that carries the fields
writer.update_page_form_field_values(None, values, auto_regenerate=False)
writer.set_need_appearances_writer(True)   # viewers rebuild appearances if needed

# Hybrid XFA forms: drop the XFA packet so viewers show the AcroForm values
acro = writer._root_object["/AcroForm"]
if "/XFA" in acro:
    del acro[NameObject("/XFA")]

with open("w9-filled.pdf", "wb") as fh:
    writer.write(fh)

Rules for values:

  • Text: plain strings. Respect /MaxLen (comb fields) and the field’s date format; the form will not reformat for you.
  • Checkbox: the “on” value is form-specific (/Yes, /On, /1, /Married). Read it from /_States_ and use exactly that name; anything else leaves the box unchecked. Unchecked is /Off.
  • Radio group: set the parent field to the chosen kid’s state name, slash included ("/annual", not "annual"); pypdf then switches each kid’s /AS to match.
  • Choice: the value must be one of /Opt entries (or its export value when /Opt holds pairs).

Flatten

Flatten when the recipient should not edit values, or when a viewer still shows blanks.

writer.update_page_form_field_values(None, values, auto_regenerate=False, flatten=True)

Or on the CLI, which also builds missing appearance streams first:

qpdf --generate-appearances --flatten-annotations=all filled.pdf flat.pdf

After flattening, get_fields() on the output should be empty or have no widgets, and pdftotext flat.pdf - should contain the values.

Batch fill from CSV

import csv
from pathlib import Path
from pypdf import PdfReader, PdfWriter

FIELD_MAP = {"name": "applicant_name", "dob": "applicant_dob", "agree": "consent_box"}
ON_STATE = "/Yes"   # read from /_States_ during inspection

template = PdfReader("application.pdf")
out = Path("out"); out.mkdir(exist_ok=True)

with open("applicants.csv", newline="", encoding="utf-8-sig") as fh:
    for i, row in enumerate(csv.DictReader(fh), start=1):
        w = PdfWriter(clone_from=template)
        vals = {FIELD_MAP[k]: v for k, v in row.items() if k in FIELD_MAP}
        vals["consent_box"] = ON_STATE if row.get("agree") == "yes" else "/Off"
        w.update_page_form_field_values(None, vals, auto_regenerate=False)
        w.set_need_appearances_writer(True)
        w.write(out / f"{i:04d}-{row['name'].replace(' ', '_')}.pdf")

Clone from the reader per row; reusing one writer accumulates every row’s values. Sanitise names used in filenames. To combine the results into one PDF, flatten each first: identical field names across merged files share a single value.

Build a fillable form with ReportLab

from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas

c = canvas.Canvas("signup.pdf", pagesize=A4)
form = c.acroForm
c.setFont("Helvetica", 11)

c.drawString(50, 760, "Full name")
form.textfield(name="full_name", x=150, y=750, width=300, height=20,
               tooltip="Full name", borderStyle="underlined", fieldFlags="required")
c.drawString(50, 720, "Plan")
form.choice(name="plan", options=["Free", "Team", "Enterprise"], value="Free",
            x=150, y=712, width=150, height=20)
c.drawString(50, 680, "Subscribe to updates")
form.checkbox(name="updates", x=210, y=676, size=14, fieldFlags="")   # default is required
c.drawString(50, 640, "Billing")
for i, choice in enumerate(["monthly", "annual"]):
    form.radio(name="billing", value=choice, selected=(i == 0), x=150 + i * 90, y=636, size=14)
    c.drawString(170 + i * 90, 640, choice.title())
c.save()

Give every field a stable, meaningful name and a tooltip (screen readers announce it). ReportLab’s checkbox defaults to fieldFlags="required"; pass "" for optional boxes.

Flat PDFs: overlay text at coordinates

When there are no fields, draw values on a transparent overlay and merge it:

from io import BytesIO
from pypdf import PdfReader, PdfWriter
from reportlab.pdfgen import canvas

base = PdfReader("flat-form.pdf")
page = base.pages[0]
w_pt, h_pt = float(page.mediabox.width), float(page.mediabox.height)

buf = BytesIO()
c = canvas.Canvas(buf, pagesize=(w_pt, h_pt))
c.setFont("Helvetica", 10)
c.drawString(132, h_pt - 188, "Ada Lovelace")    # PDF origin is bottom-left
c.save()
buf.seek(0)

w = PdfWriter(clone_from=base)
w.pages[0].merge_page(PdfReader(buf).pages[0])
w.write("flat-filled.pdf")

Find coordinates with pdfplumber (page.extract_words() gives the label positions; note pdfplumber’s top is measured from the top edge, so convert with h_pt - top).

Gotchas

  • Values present but invisible: missing appearance streams. Use set_need_appearances_writer(True) and, for recipients on viewers that ignore it, flatten with qpdf --generate-appearances.
  • auto_regenerate=True (the default) only sets the NeedAppearances flag; it does not draw anything itself, so it does not replace flattening for strict viewers.
  • Fonts: text fields render with the form’s /DA font. Non-Latin values in a field whose font is Helvetica will show as blanks or boxes; flatten using a viewer or regenerate with a font that has the glyphs.
  • Never fill signature (/Sig) fields with text, and never place an image of someone’s signature without their explicit consent.
  • Forms with personal data (tax, medical, ID) stay local: do not upload them to online converters or include their contents in logs.

Verification

qpdf --check filled.pdf
pdftotext -layout filled.pdf - | grep -n "Ada Lovelace"
pdftoppm -f 1 -l 1 -r 90 -png filled.pdf check
  • Re-read with PdfReader("filled.pdf").get_fields() and assert each mapped field’s /V.
  • Open the rendered PNG: checkboxes ticked, text inside the boxes, nothing truncated.
  • For batches, check the count of output files equals CSV rows and spot-check first, last and one row with the longest values.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "PDF Forms"
  short_description: "Fill any AcroForm so every viewer shows it"
  default_prompt: "$pdf-forms Fill this W-9 PDF with the vendor details from vendors.csv, one file per row"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem