Add build_fillable_form.py — generate/overlay fillable AcroForm PDFs for v1.1

This commit is contained in:
2026-07-20 16:21:16 -05:00
parent 0ce7db067e
commit eab49c5673
+295
View File
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
build_fillable_form.py — Turn an extracted RFP form into a standalone, fillable
AcroForm PDF that the rfp-form-filler skill can consume directly.
Two modes:
1) GENERATE (spec -> new fillable PDF)
For forms that live as a table inside the RFP body, or as a flat/scanned page
with no real form fields. You reconstruct the form as a clean PDF with proper
named AcroForm fields (text boxes, checkboxes) laid out in reading order.
python build_fillable_form.py generate <spec.json> <output.pdf>
2) OVERLAY (existing PDF page(s) + field spec -> fillable PDF)
For a flat PDF form you want to KEEP visually intact (agency letterhead, exact
wording) but make fillable. You supply the source PDF plus a list of field
rectangles in PDF coordinates (y=0 at bottom), and the script stamps invisible
AcroForm widgets on top of the existing pages.
python build_fillable_form.py overlay <source.pdf> <fields.json> <output.pdf>
The output of either mode is a real AcroForm PDF. Verify it afterward with the
pdf skill's check_fillable_fields.py — it should report fillable fields, which is
exactly what rfp-form-filler's read_pdf_fields / fill_with_profile path expects.
--------------------------------------------------------------------------------
SPEC FORMAT (generate mode)
--------------------------------------------------------------------------------
{
"title": "Buy America Certification",
"source_ref": "Attachment C, pp. 40-41", // where it came from in the RFP
"instructions": "Offeror shall complete and sign...", // optional preamble
"page_size": "letter", // "letter" (default) or "a4"
"fields": [
{ "name": "offeror_legal_name", "label": "Offeror Legal Name", "type": "text" },
{ "name": "uei", "label": "UEI", "type": "text", "width": 200 },
{ "name": "certifies_compliant", "label": "Offeror certifies it complies with 49 U.S.C. 5323(j)", "type": "checkbox" },
{ "name": "certifies_noncompliant", "label": "Offeror cannot comply (attach explanation)", "type": "checkbox" },
{ "name": "authorized_signature", "label": "Authorized Signature", "type": "signature" },
{ "name": "signatory_name", "label": "Printed Name & Title", "type": "text" },
{ "name": "date_signed", "label": "Date", "type": "text", "width": 140 },
{ "note": "All information must match the offeror's SAM.gov registration." }
]
}
Field object keys:
name (required for a real field) — the AcroForm field name. Use snake_case,
stable, human-readable. This is what rfp-form-filler maps profile values to.
label (required) — visible caption printed next to the field.
type — "text" (default), "checkbox", or "signature".
"signature" renders a wide box + label; it is left for a human to sign
(the field is created but form-filler will not auto-populate it).
width — optional field-box width in points (default: text 260, sig 260, cb 12).
note — a spec item with only "note" prints an italic instruction line, no field.
--------------------------------------------------------------------------------
FIELDS FORMAT (overlay mode)
--------------------------------------------------------------------------------
[
{ "name": "offeror_legal_name", "page": 1, "type": "text",
"rect": [90, 640, 360, 662] }, // [left, bottom, right, top], PDF coords
{ "name": "certifies_compliant", "page": 1, "type": "checkbox",
"rect": [72, 500, 84, 512] }
]
Get the rectangles from the pdf skill: convert_pdf_to_images.py to view the page,
extract_form_structure.py for text coordinates, then translate to PDF coords
(y=0 at the bottom of the page).
"""
import json
import sys
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib.colors import black, HexColor
from reportlab.lib.units import inch
from reportlab.pdfgen import canvas
from pypdf import PdfReader, PdfWriter
from pypdf.generic import (
DictionaryObject, NameObject, TextStringObject, NumberObject,
ArrayObject, BooleanObject, IndirectObject,
)
# ----- shared constants -----
MARGIN = 0.9 * inch
LINE = 22 # vertical step between simple lines
LABEL_SIZE = 10
TITLE_SIZE = 15
NOTE_SIZE = 9
ACCENT = HexColor("#8a6d1f") # MPM-ish dark gold for the title rule
# ============================================================
# GENERATE MODE
# ============================================================
def generate(spec_path, out_path):
with open(spec_path) as f:
spec = json.load(f)
page = A4 if str(spec.get("page_size", "letter")).lower() == "a4" else letter
pw, ph = page
c = canvas.Canvas(out_path, pagesize=page)
y = ph - MARGIN
# Title
c.setFillColor(ACCENT)
c.setFont("Helvetica-Bold", TITLE_SIZE)
c.drawString(MARGIN, y, spec.get("title", "Required Form"))
y -= 6
c.setStrokeColor(ACCENT)
c.setLineWidth(1.2)
c.line(MARGIN, y, pw - MARGIN, y)
c.setFillColor(black)
y -= 18
# Provenance + instructions
c.setFont("Helvetica-Oblique", NOTE_SIZE)
if spec.get("source_ref"):
c.drawString(MARGIN, y, f"Extracted from: {spec['source_ref']}")
y -= 14
if spec.get("instructions"):
y = _wrap(c, spec["instructions"], MARGIN, y, pw - 2 * MARGIN,
"Helvetica-Oblique", NOTE_SIZE, leading=12)
y -= 6
y -= 6
fields_created = []
for item in spec.get("fields", []):
if "note" in item and "name" not in item:
c.setFont("Helvetica-Oblique", NOTE_SIZE)
y = _wrap(c, item["note"], MARGIN, y, pw - 2 * MARGIN,
"Helvetica-Oblique", NOTE_SIZE, leading=12)
y -= 4
continue
if y < MARGIN + 3 * LINE: # new page if we're running out of room
c.showPage()
y = ph - MARGIN
ftype = item.get("type", "text")
label = item.get("label", item.get("name", ""))
name = item["name"]
c.setFont("Helvetica", LABEL_SIZE)
if ftype == "checkbox":
size = 12
c.acroForm.checkbox(
name=name, x=MARGIN, y=y - size + 2, size=size,
borderWidth=1, borderColor=black, fillColor=None,
buttonStyle="check", forceBorder=True,
)
c.drawString(MARGIN + size + 8, y - size + 4, label)
fields_created.append(name)
y -= (size + 12)
elif ftype == "signature":
width = item.get("width", 260)
height = 26
c.drawString(MARGIN, y, label + ":")
y -= (height + 2)
c.acroForm.textfield(
name=name, x=MARGIN, y=y, width=width, height=height,
borderWidth=1, borderColor=black, fillColor=None,
fontSize=11, forceBorder=True,
)
fields_created.append(name)
y -= (LINE + 6)
else: # text
width = item.get("width", 260)
height = 18
c.drawString(MARGIN, y, label + ":")
lbl_w = c.stringWidth(label + ":", "Helvetica", LABEL_SIZE)
fx = MARGIN + lbl_w + 8
if fx + width > pw - MARGIN: # label too long -> field on next line
y -= (height + 4)
fx = MARGIN
c.acroForm.textfield(
name=name, x=fx, y=y - 3, width=width, height=height,
borderWidth=1, borderColor=black, fillColor=None,
fontSize=11, forceBorder=True,
)
fields_created.append(name)
y -= (LINE + 8)
c.showPage()
c.save()
print(f"Generated fillable form: {out_path}")
print(f"Fields created ({len(fields_created)}): {', '.join(fields_created)}")
def _wrap(c, text, x, y, max_w, font, size, leading=12):
c.setFont(font, size)
words = text.split()
line = ""
for w in words:
trial = (line + " " + w).strip()
if c.stringWidth(trial, font, size) <= max_w:
line = trial
else:
c.drawString(x, y, line)
y -= leading
line = w
if line:
c.drawString(x, y, line)
y -= leading
return y
# ============================================================
# OVERLAY MODE (add AcroForm widgets on top of existing pages)
# ============================================================
def overlay(src_path, fields_path, out_path):
with open(fields_path) as f:
fields = json.load(f)
reader = PdfReader(src_path)
writer = PdfWriter()
for p in reader.pages:
writer.add_page(p)
# ensure an AcroForm dict exists
root = writer._root_object
if "/AcroForm" not in root:
acro = DictionaryObject()
acro[NameObject("/Fields")] = ArrayObject()
acro[NameObject("/NeedAppearances")] = BooleanObject(True)
root[NameObject("/AcroForm")] = writer._add_object(acro)
acro = root["/AcroForm"]
acro[NameObject("/NeedAppearances")] = BooleanObject(True)
created = []
for fld in fields:
page_idx = fld.get("page", 1) - 1
if page_idx < 0 or page_idx >= len(writer.pages):
print(f" ! skipping {fld.get('name')} — page {fld.get('page')} out of range")
continue
page = writer.pages[page_idx]
rect = fld["rect"]
ftype = fld.get("type", "text")
name = fld["name"]
widget = DictionaryObject()
widget[NameObject("/Type")] = NameObject("/Annot")
widget[NameObject("/Subtype")] = NameObject("/Widget")
widget[NameObject("/Rect")] = ArrayObject([NumberObject(v) for v in rect])
widget[NameObject("/T")] = TextStringObject(name)
widget[NameObject("/F")] = NumberObject(4) # Print flag
if ftype == "checkbox":
widget[NameObject("/FT")] = NameObject("/Btn")
widget[NameObject("/V")] = NameObject("/Off")
widget[NameObject("/AS")] = NameObject("/Off")
mk = DictionaryObject()
mk[NameObject("/BC")] = ArrayObject([NumberObject(0)])
widget[NameObject("/MK")] = mk
else:
widget[NameObject("/FT")] = NameObject("/Tx")
widget[NameObject("/V")] = TextStringObject("")
widget[NameObject("/DA")] = TextStringObject("/Helv 11 Tf 0 g")
ref = writer._add_object(widget)
widget[NameObject("/P")] = page.indirect_reference
if "/Annots" in page:
page[NameObject("/Annots")].append(ref)
else:
page[NameObject("/Annots")] = ArrayObject([ref])
acro["/Fields"].append(ref)
created.append(name)
with open(out_path, "wb") as fh:
writer.write(fh)
print(f"Overlaid fillable fields onto: {out_path}")
print(f"Fields created ({len(created)}): {', '.join(created)}")
# ============================================================
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
mode = sys.argv[1]
if mode == "generate" and len(sys.argv) == 4:
generate(sys.argv[2], sys.argv[3])
elif mode == "overlay" and len(sys.argv) == 5:
overlay(sys.argv[2], sys.argv[3], sys.argv[4])
else:
print(__doc__)
sys.exit(1)
if __name__ == "__main__":
main()