qubicl-cli 0.1.0-dev.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +46 -21
  2. package/dist/SBOM.spdx.json +222 -0
  3. package/dist/THIRD_PARTY_NOTICES.txt +244 -0
  4. package/dist/assets/chromium-seccomp.json +181 -0
  5. package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
  6. package/dist/assets/computer/Dockerfile +191 -49
  7. package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
  8. package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
  9. package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
  10. package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
  11. package/dist/assets/computer/browser-skills-requirements.txt +12 -0
  12. package/dist/assets/computer/chromium-wrapper.sh +12 -0
  13. package/dist/assets/computer/control.mjs +112 -52
  14. package/dist/assets/computer/entrypoint.sh +272 -35
  15. package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
  16. package/dist/assets/computer/manifests/browser.json +72 -0
  17. package/dist/assets/computer/manifests/computer.json +76 -0
  18. package/dist/assets/computer/manifests/file-system.json +42 -0
  19. package/dist/assets/computer/manifests/workstation.json +78 -0
  20. package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
  21. package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
  22. package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
  23. package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
  24. package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
  25. package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
  26. package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
  27. package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
  28. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
  29. package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
  30. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
  31. package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
  32. package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
  33. package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
  34. package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
  35. package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
  36. package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
  37. package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
  38. package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
  39. package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
  40. package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
  41. package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
  42. package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
  43. package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
  44. package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
  45. package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
  46. package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
  47. package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
  48. package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
  49. package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
  50. package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
  51. package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
  52. package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
  53. package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
  54. package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
  55. package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
  56. package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
  57. package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
  58. package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
  59. package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
  60. package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
  61. package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
  62. package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
  63. package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
  64. package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
  65. package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
  66. package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
  67. package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
  68. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
  69. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
  70. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
  71. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
  72. package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
  73. package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
  74. package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
  75. package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
  76. package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
  77. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
  78. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
  79. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
  80. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
  81. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
  82. package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
  83. package/dist/assets/computer/skills/core-catalog.json +246 -0
  84. package/dist/assets/computer/skills-requirements.txt +18 -0
  85. package/dist/assets/computer/web-provider.py +592 -0
  86. package/dist/assets/computer/web-requirements.txt +37 -0
  87. package/dist/assets/gateway/Dockerfile +3 -1
  88. package/dist/assets/gateway/gateway.mjs +69 -40
  89. package/dist/assets/image-catalog.json +183 -0
  90. package/dist/qubicl.mjs +297 -167
  91. package/package.json +7 -7
@@ -0,0 +1,100 @@
1
+ # Building Fillable Forms: spec format and workflow
2
+
3
+ The same JSON spec drives both `pdf_form_layout.py` (design lint) and
4
+ `pdf_make_form.py` (AcroForm build). Coordinates are PDF points, origin
5
+ at the bottom-left of the page (1 pt = 1/72 inch; A4 is 595.27 x 841.89,
6
+ letter is 612 x 792).
7
+
8
+ ## Spec shape
9
+
10
+ ```json
11
+ {
12
+ "title": "Example Intake Form",
13
+ "author": "example-author",
14
+ "page_size": "A4",
15
+ "page_count": 1,
16
+ "fields": [
17
+ {"name": "surname", "type": "text", "page": 1,
18
+ "label": "Surname", "label_box": [72, 700, 150, 714],
19
+ "entry_box": [160, 696, 400, 716],
20
+ "value": "", "tooltip": "Family name"},
21
+
22
+ {"name": "agree", "type": "checkbox", "page": 1,
23
+ "label": "I agree", "label_box": [72, 660, 150, 674],
24
+ "entry_box": [160, 658, 176, 674], "checked": false},
25
+
26
+ {"name": "color", "type": "radio", "page": 1,
27
+ "label": "Color", "label_box": [72, 620, 150, 634],
28
+ "entry_box": [160, 616, 400, 636],
29
+ "options": ["red", "blue"], "value": "blue"},
30
+
31
+ {"name": "size", "type": "dropdown", "page": 1,
32
+ "label": "Size", "label_box": [72, 580, 150, 594],
33
+ "entry_box": [160, 576, 300, 596],
34
+ "options": ["small", "large"], "value": "small"}
35
+ ]
36
+ }
37
+ ```
38
+
39
+ - `page_size`: `"A4"`, `"letter"`, or `[width, height]` in points.
40
+ - `page_count`: optional; extended automatically to the highest field page.
41
+ - Boxes are `[x0, y0, x1, y1]` with `x0 < x1`, `y0 < y1`.
42
+ - `label` is drawn as static text near `label_box`; omit it (and
43
+ `label_box`) for unlabeled fields.
44
+ - `radio`: the buttons are laid out left-to-right inside `entry_box`,
45
+ one slot per option, each with a small static caption. `value`
46
+ pre-selects an option by its export name.
47
+ - `dropdown` maps to an AcroForm choice (combo) field.
48
+
49
+ ## Field types → what pdf_read.py --fields reports
50
+
51
+ | Spec type | /FT | value format after fill |
52
+ |---|---|---|
53
+ | text | /Tx (`text`) | the string |
54
+ | checkbox | /Btn (`button`) | `/Yes` or `/Off` |
55
+ | radio | /Btn (`button`) | `/<export>`, e.g. `/red` |
56
+ | dropdown | /Ch (`choice`) | the option string |
57
+
58
+ When filling with `pdf_fill_form.py`, checkboxes accept `true`/`false`;
59
+ radio values need the leading slash (`"/red"`); dropdown values are the
60
+ plain option string.
61
+
62
+ ## Layout lint rules (pdf_form_layout.py)
63
+
64
+ Per field, on its declared page:
65
+
66
+ - boxes must be well-formed and inside the page bounds;
67
+ - entry boxes must be at least 8x8 pt (12 pt tall for text/dropdown);
68
+ - no two entry boxes on the same page may overlap (the second and later
69
+ fields of an overlapping cluster are flagged);
70
+ - a label must sit within 150 pt of its entry box and must not overlap it.
71
+
72
+ Exit code 0 = clean, 1 = at least one problem; the JSON report lists
73
+ per-field `problems`. Lint the spec BEFORE building — fixing numbers in
74
+ JSON is cheaper than debugging a rendered PDF.
75
+
76
+ ## Visual review loop
77
+
78
+ ```bash
79
+ python3 scripts/pdf_form_layout.py spec.json --render-overlay overlay.png [--pdf built.pdf]
80
+ ```
81
+
82
+ Red rectangles = entry boxes (with field names), blue = label boxes.
83
+ Without `--pdf` the overlay is drawn on a blank page (PIL-only, always
84
+ works); with `--pdf` the real page is rasterized underneath
85
+ (needs pypdfium2 or pdftoppm — otherwise the report says
86
+ `"rendered": false` with capability guidance). Inspect the PNG with the
87
+ available Qubicl image or desktop tooling
88
+ and ask specifically about collisions, alignment, and stray labels.
89
+
90
+ ## Radio-group quirks (reportlab + pypdf)
91
+
92
+ - reportlab requires at least two `radio()` calls per group; a
93
+ single-option radio group produces a broken field.
94
+ - Pre-selecting is done at build time via `"value"`; changing selection
95
+ later via `pdf_fill_form.py` needs the slashed export name (`"/red"`).
96
+ - Some viewers render reportlab radio appearances inconsistently after a
97
+ pypdf fill; verify with `--fields` (data truth) plus a rendered page
98
+ image (visual truth) rather than either alone.
99
+ - Flattening radio groups is the least reliable flatten case — check the
100
+ output image before shipping.
@@ -0,0 +1,76 @@
1
+ """Shared page rasterizer with a fallback chain: pypdfium2 -> pdftoppm.
2
+
3
+ Returns PIL Images so callers can annotate/save. Not a CLI.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+
13
+ def available_backends() -> list[str]:
14
+ """Names of usable rasterizer backends, in preference order."""
15
+ backends = []
16
+ try:
17
+ import pypdfium2 # noqa: F401
18
+ backends.append("pypdfium2")
19
+ except ImportError:
20
+ pass
21
+ if shutil.which("pdftoppm"):
22
+ backends.append("pdftoppm")
23
+ return backends
24
+
25
+
26
+ def missing_hints() -> list[str]:
27
+ """Qubicl image requirements when no backend is available."""
28
+ return [
29
+ "Qubicl image dependency pypdfium2",
30
+ "Qubicl image dependency poppler-utils (provides pdftoppm)",
31
+ ]
32
+
33
+
34
+ def rasterize_page(pdf_path: str, page: int, dpi: int = 150, password: str | None = None):
35
+ """Render one 1-based page to a PIL Image, or None if no backend works.
36
+
37
+ Raises ValueError for an out-of-range page when a backend is present.
38
+ """
39
+ for backend in available_backends():
40
+ if backend == "pypdfium2":
41
+ return _via_pdfium(pdf_path, page, dpi, password)
42
+ if backend == "pdftoppm":
43
+ img = _via_pdftoppm(pdf_path, page, dpi, password)
44
+ if img is not None:
45
+ return img
46
+ return None
47
+
48
+
49
+ def _via_pdfium(pdf_path: str, page: int, dpi: int, password: str | None):
50
+ import pypdfium2 as pdfium
51
+ doc = pdfium.PdfDocument(pdf_path, password=password)
52
+ try:
53
+ if not 1 <= page <= len(doc):
54
+ raise ValueError(f"page {page} out of range 1-{len(doc)}")
55
+ bitmap = doc[page - 1].render(scale=dpi / 72.0)
56
+ return bitmap.to_pil().convert("RGB")
57
+ finally:
58
+ doc.close()
59
+
60
+
61
+ def _via_pdftoppm(pdf_path: str, page: int, dpi: int, password: str | None):
62
+ from PIL import Image
63
+ with tempfile.TemporaryDirectory() as tmp:
64
+ prefix = str(Path(tmp) / "page")
65
+ cmd = ["pdftoppm", "-png", "-r", str(dpi), "-f", str(page), "-l", str(page)]
66
+ if password:
67
+ cmd += ["-upw", password]
68
+ cmd += [pdf_path, prefix]
69
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8")
70
+ if proc.returncode != 0:
71
+ raise ValueError(f"pdftoppm failed: {proc.stderr.strip()}")
72
+ produced = sorted(Path(tmp).glob("page*.png"))
73
+ if not produced:
74
+ raise ValueError(f"page {page} out of range (pdftoppm produced no image)")
75
+ with Image.open(produced[0]) as img:
76
+ return img.convert("RGB")
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Create a PDF from a JSON spec using reportlab platypus.
3
+
4
+ Spec format (UTF-8 JSON):
5
+ {
6
+ "title": "Example Report",
7
+ "author": "example-author",
8
+ "page_size": "A4", // or "letter" (default: A4)
9
+ "page_numbers": true, // default true
10
+ "elements": [
11
+ {"type": "heading", "text": "Section 1", "level": 1},
12
+ {"type": "paragraph", "text": "Body text..."},
13
+ {"type": "table", "rows": [["H1", "H2"], ["a", "b"]], "header": true},
14
+ {"type": "image", "path": "chart.png", "width": 400},
15
+ {"type": "pagebreak"}
16
+ ]
17
+ }
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+
25
+
26
+ def _reconfigure_stdio() -> None:
27
+ for stream in (sys.stdout, sys.stderr):
28
+ try:
29
+ stream.reconfigure(encoding="utf-8")
30
+ except Exception:
31
+ pass
32
+
33
+
34
+ def build_pdf(spec: dict, out_path: str) -> int:
35
+ try:
36
+ from reportlab.lib import colors
37
+ from reportlab.lib.pagesizes import A4, letter
38
+ from reportlab.lib.styles import getSampleStyleSheet
39
+ from reportlab.lib.units import inch
40
+ from reportlab.platypus import (
41
+ Image,
42
+ PageBreak,
43
+ Paragraph,
44
+ SimpleDocTemplate,
45
+ Spacer,
46
+ Table,
47
+ TableStyle,
48
+ )
49
+ except ImportError:
50
+ print("Required Qubicl PDF dependency is unavailable: reportlab", file=sys.stderr)
51
+ return 2
52
+
53
+ page_size = letter if str(spec.get("page_size", "A4")).lower() == "letter" else A4
54
+ styles = getSampleStyleSheet()
55
+ story = []
56
+ for el in spec.get("elements", []):
57
+ etype = el.get("type")
58
+ if etype == "heading":
59
+ level = min(max(int(el.get("level", 1)), 1), 3)
60
+ story.append(Paragraph(el.get("text", ""), styles[f"Heading{level}"]))
61
+ elif etype == "paragraph":
62
+ story.append(Paragraph(el.get("text", ""), styles["BodyText"]))
63
+ story.append(Spacer(1, 6))
64
+ elif etype == "table":
65
+ rows = el.get("rows", [])
66
+ if not rows:
67
+ continue
68
+ table = Table(rows, repeatRows=1 if el.get("header", True) else 0)
69
+ style = [
70
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
71
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
72
+ ]
73
+ if el.get("header", True):
74
+ style += [
75
+ ("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
76
+ ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
77
+ ]
78
+ table.setStyle(TableStyle(style))
79
+ story.append(table)
80
+ story.append(Spacer(1, 10))
81
+ elif etype == "image":
82
+ kwargs = {}
83
+ if el.get("width"):
84
+ kwargs["width"] = float(el["width"])
85
+ if el.get("height"):
86
+ kwargs["height"] = float(el["height"])
87
+ img = Image(el["path"], **kwargs)
88
+ if "width" in kwargs and "height" not in kwargs:
89
+ # keep aspect ratio
90
+ ratio = img.imageHeight / img.imageWidth
91
+ img.drawWidth = kwargs["width"]
92
+ img.drawHeight = kwargs["width"] * ratio
93
+ story.append(img)
94
+ story.append(Spacer(1, 10))
95
+ elif etype == "pagebreak":
96
+ story.append(PageBreak())
97
+ else:
98
+ print(f"Warning: unknown element type {etype!r}, skipped", file=sys.stderr)
99
+
100
+ def draw_page_number(canvas, doc):
101
+ if spec.get("page_numbers", True):
102
+ canvas.saveState()
103
+ canvas.setFont("Helvetica", 9)
104
+ canvas.drawCentredString(page_size[0] / 2.0, 0.5 * inch, f"Page {doc.page}")
105
+ canvas.restoreState()
106
+
107
+ doc = SimpleDocTemplate(
108
+ out_path,
109
+ pagesize=page_size,
110
+ title=spec.get("title", ""),
111
+ author=spec.get("author", ""),
112
+ )
113
+ doc.build(story, onFirstPage=draw_page_number, onLaterPages=draw_page_number)
114
+ print(json.dumps({"output": out_path, "elements": len(spec.get("elements", []))}))
115
+ return 0
116
+
117
+
118
+ def main() -> int:
119
+ _reconfigure_stdio()
120
+ parser = argparse.ArgumentParser(description="Create a PDF from a JSON spec (reportlab).")
121
+ parser.add_argument("spec", help="Path to UTF-8 JSON spec file")
122
+ parser.add_argument("-o", "--output", required=True, help="Output PDF path")
123
+ args = parser.parse_args()
124
+ with open(args.spec, encoding="utf-8") as fh:
125
+ spec = json.load(fh)
126
+ return build_pdf(spec, args.output)
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env python3
2
+ """Fill AcroForm fields from a UTF-8 JSON file; optionally flatten.
3
+
4
+ The JSON is a flat object: {"FieldName": "value", "Agree": true, ...}
5
+ - text fields: strings
6
+ - checkboxes: true/false (or an explicit on-state name like "/Yes")
7
+ - radio / dropdown: the export value as a string (see pdf_read.py --fields "options")
8
+
9
+ Sets NeedAppearances so conforming viewers regenerate field appearances.
10
+ Flattening uses pypdf appearance merging; verify visually for exotic widgets.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+
18
+
19
+ def main() -> int:
20
+ for stream in (sys.stdout, sys.stderr):
21
+ try:
22
+ stream.reconfigure(encoding="utf-8")
23
+ except Exception:
24
+ pass
25
+ parser = argparse.ArgumentParser(description="Fill PDF AcroForm fields from JSON (pypdf).")
26
+ parser.add_argument("pdf", help="Input form PDF")
27
+ parser.add_argument("--fields-json", required=True, help="UTF-8 JSON file of field values")
28
+ parser.add_argument("-o", "--output", required=True, help="Output PDF path")
29
+ parser.add_argument("--flatten", action="store_true",
30
+ help="Make fields read-only and burn appearances into the page")
31
+ parser.add_argument("--password", help="Password if the input is encrypted")
32
+ args = parser.parse_args()
33
+
34
+ try:
35
+ from pypdf import PdfReader, PdfWriter
36
+ from pypdf.generic import BooleanObject, NameObject
37
+ except ImportError:
38
+ print("Required Qubicl PDF dependency is unavailable: pypdf", file=sys.stderr)
39
+ return 2
40
+
41
+ with open(args.fields_json, encoding="utf-8") as fh:
42
+ values = json.load(fh)
43
+
44
+ reader = PdfReader(args.pdf)
45
+ if reader.is_encrypted:
46
+ if args.password is None or not reader.decrypt(args.password):
47
+ print("Error: input is encrypted; pass --password", file=sys.stderr)
48
+ return 3
49
+ available = set((reader.get_fields() or {}).keys())
50
+ missing = [name for name in values if name not in available]
51
+ if missing:
52
+ print(f"Warning: fields not found in form, skipped: {missing}", file=sys.stderr)
53
+
54
+ writer = PdfWriter()
55
+ writer.append(reader)
56
+
57
+ # Normalize checkbox booleans to the field's actual on-state name
58
+ # (e.g. "/Yes"): pypdf does not reliably map bare True to the on-state.
59
+ field_info = reader.get_fields() or {}
60
+ fill = {}
61
+ for name, value in values.items():
62
+ if name not in available:
63
+ continue
64
+ if isinstance(value, bool):
65
+ states = [str(s) for s in (field_info[name].get("/_States_") or [])]
66
+ on_state = next((s for s in states if s != "/Off"), "/Yes")
67
+ value = on_state if value else "/Off"
68
+ fill[name] = value
69
+ for page in writer.pages:
70
+ writer.update_page_form_field_values(page, fill, auto_regenerate=False)
71
+
72
+ # Set NeedAppearances so viewers render values even without appearance streams.
73
+ root = writer._root_object
74
+ if "/AcroForm" in root:
75
+ root["/AcroForm"][NameObject("/NeedAppearances")] = BooleanObject(True)
76
+
77
+ flattened = False
78
+ if args.flatten:
79
+ try:
80
+ # pypdf >= 5: flatten via update with flags making fields read-only,
81
+ # then remove interactivity by merging appearances.
82
+ for page in writer.pages:
83
+ writer.update_page_form_field_values(page, fill, flags=1) # 1 = ReadOnly
84
+ flattened = True
85
+ except Exception as exc:
86
+ print(f"Warning: flatten step failed ({exc}); output keeps interactive fields",
87
+ file=sys.stderr)
88
+
89
+ with open(args.output, "wb") as fh:
90
+ writer.write(fh)
91
+ print(json.dumps({"output": args.output, "filled": sorted(fill), "skipped": missing,
92
+ "flattened": flattened}, ensure_ascii=False))
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a form-spec layout BEFORE building the PDF, with optional
3
+ visual overlay rendering for review with a vision model.
4
+
5
+ Input is the same JSON spec pdf_make_form.py consumes: each field has
6
+ "page", "label_box" and "entry_box" as [x0, y0, x1, y1] in PDF points
7
+ (origin bottom-left). Checks per field:
8
+ - boxes lie within the page bounds
9
+ - 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 overlap
12
+ - the label sits near its entry box (default within 150 pt gap)
13
+
14
+ Prints a JSON report {"ok": bool, "fields": [...], "errors": N};
15
+ exit 0 when clean, 1 when any check fails.
16
+
17
+ --render-overlay OUT.png rasterizes --overlay-page (default 1) of an
18
+ existing PDF (--pdf; a blank page of spec size if omitted) and draws
19
+ label boxes (blue) and entry boxes (red) with field names, for
20
+ review with the available Qubicl image or desktop tooling. If no rasterizer (pypdfium2/pdftoppm) is
21
+ available the overlay is skipped with {"rendered": false, "missing": [...]}
22
+ and validation exit status is unchanged.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import sys
29
+
30
+ MIN_W = 8.0
31
+ MIN_H = 8.0
32
+ MIN_TEXT_H = 12.0
33
+ MAX_LABEL_GAP = 150.0
34
+
35
+
36
+ def _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
+
40
+ def _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.5
44
+
45
+
46
+ def _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
+
54
+ def _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 problems
62
+ 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 problems
67
+
68
+
69
+ def 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_H
79
+ 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
+
102
+ def 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 _raster
106
+ if not _raster.available_backends() and pdf_path:
107
+ return {"rendered": False, "missing": _raster.missing_hints()}
108
+ from PIL import Image, ImageDraw
109
+ 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 / width
115
+ else:
116
+ scale = dpi / 72.0
117
+ 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
+ continue
127
+ 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
+
138
+ def main() -> int:
139
+ for stream in (sys.stdout, sys.stderr):
140
+ try:
141
+ stream.reconfigure(encoding="utf-8")
142
+ except Exception:
143
+ pass
144
+ 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 1
165
+
166
+
167
+ if __name__ == "__main__":
168
+ sys.exit(main())