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.
- package/README.md +46 -21
- package/dist/SBOM.spdx.json +222 -0
- package/dist/THIRD_PARTY_NOTICES.txt +244 -0
- package/dist/assets/chromium-seccomp.json +181 -0
- package/dist/assets/computer/BROWSER_SKILLS_THIRD_PARTY_NOTICES.txt +23 -0
- package/dist/assets/computer/Dockerfile +191 -49
- package/dist/assets/computer/PLAYWRIGHT_THIRD_PARTY_NOTICES.txt +217 -0
- package/dist/assets/computer/SKILLS_THIRD_PARTY_NOTICES.txt +29 -0
- package/dist/assets/computer/THIRD_PARTY_NOTICES.txt +28 -0
- package/dist/assets/computer/WEB_THIRD_PARTY_NOTICES.txt +42 -0
- package/dist/assets/computer/browser-skills-requirements.txt +12 -0
- package/dist/assets/computer/chromium-wrapper.sh +12 -0
- package/dist/assets/computer/control.mjs +112 -52
- package/dist/assets/computer/entrypoint.sh +272 -35
- package/dist/assets/computer/libreoffice-registrymodifications.xcu +8 -0
- package/dist/assets/computer/manifests/browser.json +72 -0
- package/dist/assets/computer/manifests/computer.json +76 -0
- package/dist/assets/computer/manifests/file-system.json +42 -0
- package/dist/assets/computer/manifests/workstation.json +78 -0
- package/dist/assets/computer/node_modules/playwright-core/LICENSE +202 -0
- package/dist/assets/computer/node_modules/playwright-core/NOTICE +5 -0
- package/dist/assets/computer/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/dist/assets/computer/node_modules/playwright-core/browsers.json +75 -0
- package/dist/assets/computer/node_modules/playwright-core/index.js +17 -0
- package/dist/assets/computer/node_modules/playwright-core/index.mjs +28 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/coreBundle.js +74830 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js +90764 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +2179 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/dist/assets/computer/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/dist/assets/computer/node_modules/playwright-core/package.json +34 -0
- package/dist/assets/computer/skills/core/docx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/docx/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/docx/references/revisions-and-comments.md +88 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_comments.py +289 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_common.py +94 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_create.py +177 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_edit.py +250 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_read.py +149 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_revisions.py +147 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_template.py +70 -0
- package/dist/assets/computer/skills/core/docx/scripts/docx_validate.py +156 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/LICENSE +21 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/SKILL.md +24 -0
- package/dist/assets/computer/skills/core/ocr-and-documents/scripts/ocr_document.py +132 -0
- package/dist/assets/computer/skills/core/pdf/LICENSE +21 -0
- package/dist/assets/computer/skills/core/pdf/SKILL.md +25 -0
- package/dist/assets/computer/skills/core/pdf/references/forms.md +100 -0
- package/dist/assets/computer/skills/core/pdf/scripts/_raster.py +76 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_create.py +130 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_fill_form.py +97 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_form_layout.py +168 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_make_form.py +145 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_merge.py +50 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_meta.py +115 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_page_image.py +99 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_read.py +153 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_secure.py +71 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_split.py +84 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_stamp.py +143 -0
- package/dist/assets/computer/skills/core/pdf/scripts/pdf_watermark.py +51 -0
- package/dist/assets/computer/skills/core/plan/LICENSE +21 -0
- package/dist/assets/computer/skills/core/plan/SKILL.md +21 -0
- package/dist/assets/computer/skills/core/powerpoint/LICENSE +21 -0
- package/dist/assets/computer/skills/core/powerpoint/SKILL.md +18 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_create.py +214 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_edit.py +436 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_from_template.py +88 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_read.py +131 -0
- package/dist/assets/computer/skills/core/powerpoint/scripts/pptx_render.py +93 -0
- package/dist/assets/computer/skills/core/xlsx/LICENSE +21 -0
- package/dist/assets/computer/skills/core/xlsx/SKILL.md +22 -0
- package/dist/assets/computer/skills/core/xlsx/references/restructuring.md +71 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/csv_to_xlsx.py +104 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_create.py +259 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_edit.py +263 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_read.py +160 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_recalc.py +110 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_restructure.py +337 -0
- package/dist/assets/computer/skills/core/xlsx/scripts/xlsx_to_csv.py +69 -0
- package/dist/assets/computer/skills/core-catalog.json +246 -0
- package/dist/assets/computer/skills-requirements.txt +18 -0
- package/dist/assets/computer/web-provider.py +592 -0
- package/dist/assets/computer/web-requirements.txt +37 -0
- package/dist/assets/gateway/Dockerfile +3 -1
- package/dist/assets/gateway/gateway.mjs +69 -40
- package/dist/assets/image-catalog.json +183 -0
- package/dist/qubicl.mjs +297 -167
- 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())
|