Add build_fillable_form.py — generate/overlay fillable AcroForm PDFs for v1.1
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user