scripts/pdf_form_layout.py
scripts/pdf_form_layout.pyBrowse 21 files
2,003 tokens
7,131 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env python32"""Validate a form-spec layout BEFORE building the PDF, with optional3visual overlay rendering for review with a vision model.4 5Input is the same JSON spec pdf_make_form.py consumes: each field has6"page", "label_box" and "entry_box" as [x0, y0, x1, y1] in PDF points7(origin bottom-left). Checks per field:8 - boxes lie within the page bounds9 - boxes are well-formed (x0 < x1, y0 < y1)10 - entry boxes meet minimum sizes (default 8x8 pt; 12 pt height for text)11 - no two entry boxes on the same page overlap12 - the label sits near its entry box (default within 150 pt gap)13 14Prints a JSON report {"ok": bool, "fields": [...], "errors": N};15exit 0 when clean, 1 when any check fails.16 17--render-overlay OUT.png rasterizes --overlay-page (default 1) of an18existing PDF (--pdf; a blank page of spec size if omitted) and draws19label boxes (blue) and entry boxes (red) with field names, for20review with `vision_analyze`. If no rasterizer (pypdfium2/pdftoppm) is21available the overlay is skipped with {"rendered": false, "missing": [...]}22and validation exit status is unchanged.23"""24from __future__ import annotations25 26import argparse27import json28import sys29 30MIN_W = 8.031MIN_H = 8.032MIN_TEXT_H = 12.033MAX_LABEL_GAP = 150.034 35 36def _boxes_overlap(a, b) -> bool:37 return not (a[2] <= b[0] or b[2] <= a[0] or a[3] <= b[1] or b[3] <= a[1])38 39 40def _box_gap(a, b) -> float:41 dx = max(b[0] - a[2], a[0] - b[2], 0.0)42 dy = max(b[1] - a[3], a[1] - b[3], 0.0)43 return (dx ** 2 + dy ** 2) ** 0.544 45 46def _page_size(spec: dict) -> tuple[float, float]:47 sizes = {"a4": (595.27, 841.89), "letter": (612.0, 792.0)}48 ps = spec.get("page_size", "A4")49 if isinstance(ps, (list, tuple)) and len(ps) == 2:50 return float(ps[0]), float(ps[1])51 return sizes.get(str(ps).lower(), sizes["a4"])52 53 54def _check_box(box, width, height, min_w, min_h, kind) -> list[str]:55 problems = []56 if box is None:57 return [f"{kind}_box missing"]58 x0, y0, x1, y1 = (float(v) for v in box)59 if x0 >= x1 or y0 >= y1:60 problems.append(f"{kind}_box malformed (need x0<x1 and y0<y1): {box}")61 return problems62 if x0 < 0 or y0 < 0 or x1 > width or y1 > height:63 problems.append(f"{kind}_box outside page bounds {width}x{height}: {box}")64 if x1 - x0 < min_w or y1 - y0 < min_h:65 problems.append(f"{kind}_box below minimum size {min_w}x{min_h}: {box}")66 return problems67 68 69def validate(spec: dict) -> dict:70 width, height = _page_size(spec)71 fields = spec.get("fields", [])72 report = []73 entry_boxes: dict[int, list[tuple[str, list[float]]]] = {}74 for f in fields:75 name = f.get("name", "?")76 page = int(f.get("page", 1))77 problems = []78 min_h = MIN_TEXT_H if f.get("type", "text") in ("text", "dropdown") else MIN_H79 entry = f.get("entry_box")80 problems += _check_box(entry, width, height, MIN_W, min_h, "entry")81 label = f.get("label_box")82 if f.get("label"):83 problems += _check_box(label, width, height, 4, 4, "label")84 if entry and label and len(problems) == 0:85 gap = _box_gap([float(v) for v in label], [float(v) for v in entry])86 if gap > MAX_LABEL_GAP:87 problems.append(f"label is {gap:.0f}pt from its entry box (max {MAX_LABEL_GAP:.0f})")88 if _boxes_overlap([float(v) for v in label], [float(v) for v in entry]):89 problems.append("label_box overlaps its own entry_box")90 if entry and not any("malformed" in p or "missing" in p for p in problems):91 ebox = [float(v) for v in entry]92 for other_name, other_box in entry_boxes.get(page, []):93 if _boxes_overlap(ebox, other_box):94 problems.append(f"entry_box overlaps field {other_name!r}")95 entry_boxes.setdefault(page, []).append((name, ebox))96 report.append({"name": name, "page": page, "ok": not problems, "problems": problems})97 errors = sum(1 for r in report if not r["ok"])98 return {"ok": errors == 0, "page_size": [width, height],99 "field_count": len(report), "errors": errors, "fields": report}100 101 102def render_overlay(spec: dict, pdf_path: str | None, page: int, out_png: str,103 dpi: int = 100) -> dict:104 sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent))105 import _raster106 if not _raster.available_backends() and pdf_path:107 return {"rendered": False, "missing": _raster.missing_hints()}108 from PIL import Image, ImageDraw109 width, height = _page_size(spec)110 if pdf_path:111 img = _raster.rasterize_page(pdf_path, page, dpi=dpi)112 if img is None:113 return {"rendered": False, "missing": _raster.missing_hints()}114 scale = img.width / width115 else:116 scale = dpi / 72.0117 img = Image.new("RGB", (int(width * scale), int(height * scale)), "white")118 draw = ImageDraw.Draw(img)119 120 def to_px(box):121 x0, y0, x1, y1 = (float(v) for v in box)122 return [x0 * scale, img.height - y1 * scale, x1 * scale, img.height - y0 * scale]123 124 for f in spec.get("fields", []):125 if int(f.get("page", 1)) != page:126 continue127 if f.get("entry_box"):128 px = to_px(f["entry_box"])129 draw.rectangle(px, outline=(220, 30, 30), width=2)130 draw.text((px[0] + 2, px[1] + 2), str(f.get("name", "?")), fill=(220, 30, 30))131 if f.get("label_box"):132 draw.rectangle(to_px(f["label_box"]), outline=(30, 60, 220), width=2)133 img.save(out_png)134 return {"rendered": True, "overlay": out_png, "page": page,135 "legend": {"entry_box": "red", "label_box": "blue"}}136 137 138def main() -> int:139 for stream in (sys.stdout, sys.stderr):140 try:141 stream.reconfigure(encoding="utf-8")142 except Exception:143 pass144 parser = argparse.ArgumentParser(145 description="Validate form-spec layout (boxes, overlaps, label pairing); "146 "optionally render an annotated overlay image.")147 parser.add_argument("spec", help="Form spec JSON (same format as pdf_make_form.py)")148 parser.add_argument("--pdf", help="Existing PDF to rasterize under the overlay "149 "(blank page if omitted)")150 parser.add_argument("--render-overlay", metavar="OUT_PNG",151 help="Write an annotated PNG for visual review")152 parser.add_argument("--overlay-page", type=int, default=1, help="1-based page (default 1)")153 parser.add_argument("--dpi", type=int, default=100, help="Overlay render DPI (default 100)")154 args = parser.parse_args()155 156 with open(args.spec, encoding="utf-8") as fh:157 spec = json.load(fh)158 result = validate(spec)159 if args.render_overlay:160 result["overlay"] = render_overlay(spec, args.pdf, args.overlay_page,161 args.render_overlay, args.dpi)162 json.dump(result, sys.stdout, ensure_ascii=False, indent=2)163 print()164 return 0 if result["ok"] else 1165 166 167if __name__ == "__main__":168 sys.exit(main())169