diff --git a/rfp-analyzer/scripts/build_fillable_form.py b/rfp-analyzer/scripts/build_fillable_form.py new file mode 100644 index 0000000..c9f9018 --- /dev/null +++ b/rfp-analyzer/scripts/build_fillable_form.py @@ -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 + +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 + +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()