@cteno/sdk-linux-x64 0.0.0-stage → 2.10.13
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/LICENSE.daemon +31 -0
- package/README.md +4 -2
- package/agents/flow-analyzer/AGENT.md +71 -0
- package/bin/cteno-host-cli +4 -0
- package/bin/cteno-wiki +0 -0
- package/bin/rg +0 -0
- package/bin/secret-exec +0 -0
- package/manifest.json +12 -0
- package/package.json +33 -4
- package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
- package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
- package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
- package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
- package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
- package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
- package/plugins/cteno.inbox/.cteno-plugin/plugin.json +128 -0
- package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
- package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
- package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
- package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
- package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
- package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
- package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
- package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
- package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
- package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
- package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
- package/plugins/cteno.memory/ui/index.html +822 -0
- package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
- package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
- package/plugins/jev/.cteno-plugin/plugin.json +305 -0
- package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
- package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
- package/skills/agent-create/SKILL.md +86 -0
- package/skills/cteno-host-cli/SKILL.md +248 -0
- package/skills/docx/LICENSE +23 -0
- package/skills/docx/NOTICE.md +8 -0
- package/skills/docx/SKILL.md +69 -0
- package/skills/docx/references/cjk_typography.md +357 -0
- package/skills/docx/references/design_principles.md +819 -0
- package/skills/docx/references/revisions-and-comments.md +88 -0
- package/skills/docx/references/track_changes_guide.md +200 -0
- package/skills/docx/references/typography_guide.md +294 -0
- package/skills/docx/scripts/cjk_fonts.py +244 -0
- package/skills/docx/scripts/docx_comments.py +289 -0
- package/skills/docx/scripts/docx_common.py +92 -0
- package/skills/docx/scripts/docx_create.py +259 -0
- package/skills/docx/scripts/docx_edit.py +250 -0
- package/skills/docx/scripts/docx_read.py +149 -0
- package/skills/docx/scripts/docx_revisions.py +146 -0
- package/skills/docx/scripts/docx_template.py +70 -0
- package/skills/docx/scripts/docx_validate.py +156 -0
- package/skills/docx/scripts/render.py +250 -0
- package/skills/docx/tests/test_docx_skill.py +525 -0
- package/skills/home-devices/SKILL.md +121 -0
- package/skills/mcp/SKILL.md +72 -0
- package/skills/mcp/scripts/mcp_setup.py +439 -0
- package/skills/pdf/LICENSE +23 -0
- package/skills/pdf/NOTICE.md +8 -0
- package/skills/pdf/SKILL.md +63 -0
- package/skills/pdf/references/design-tokens.md +381 -0
- package/skills/pdf/references/forms.md +99 -0
- package/skills/pdf/references/nano-pdf-editing.md +40 -0
- package/skills/pdf/references/ocr-extraction.md +165 -0
- package/skills/pdf/scripts/_raster.py +76 -0
- package/skills/pdf/scripts/extract_marker.py +88 -0
- package/skills/pdf/scripts/extract_pymupdf.py +101 -0
- package/skills/pdf/scripts/pdf_create.py +191 -0
- package/skills/pdf/scripts/pdf_fill_form.py +97 -0
- package/skills/pdf/scripts/pdf_form_layout.py +168 -0
- package/skills/pdf/scripts/pdf_make_form.py +145 -0
- package/skills/pdf/scripts/pdf_merge.py +50 -0
- package/skills/pdf/scripts/pdf_meta.py +115 -0
- package/skills/pdf/scripts/pdf_page_image.py +99 -0
- package/skills/pdf/scripts/pdf_read.py +153 -0
- package/skills/pdf/scripts/pdf_secure.py +71 -0
- package/skills/pdf/scripts/pdf_split.py +84 -0
- package/skills/pdf/scripts/pdf_stamp.py +143 -0
- package/skills/pdf/scripts/pdf_watermark.py +51 -0
- package/skills/pdf/scripts/render.py +250 -0
- package/skills/pdf/tests/test_pdf_skill.py +415 -0
- package/skills/pptx/LICENSE +23 -0
- package/skills/pptx/NOTICE.md +8 -0
- package/skills/pptx/SKILL.md +62 -0
- package/skills/pptx/references/design-system.md +392 -0
- package/skills/pptx/references/pptxgenjs.md +420 -0
- package/skills/pptx/references/slide-types.md +413 -0
- package/skills/pptx/scripts/cjk_fonts.py +244 -0
- package/skills/pptx/scripts/pptx_create.py +240 -0
- package/skills/pptx/scripts/pptx_edit.py +436 -0
- package/skills/pptx/scripts/pptx_from_template.py +88 -0
- package/skills/pptx/scripts/pptx_read.py +131 -0
- package/skills/pptx/scripts/pptx_render.py +96 -0
- package/skills/pptx/scripts/render.py +250 -0
- package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
- package/skills/skill-create/SKILL.md +114 -0
- package/skills/video-generation/SKILL.md +27 -0
- package/skills/xlsx/LICENSE +23 -0
- package/skills/xlsx/NOTICE.md +8 -0
- package/skills/xlsx/SKILL.md +63 -0
- package/skills/xlsx/references/format.md +71 -0
- package/skills/xlsx/references/restructuring.md +71 -0
- package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
- package/skills/xlsx/scripts/formula_check.py +428 -0
- package/skills/xlsx/scripts/render.py +250 -0
- package/skills/xlsx/scripts/xlsx_create.py +265 -0
- package/skills/xlsx/scripts/xlsx_edit.py +269 -0
- package/skills/xlsx/scripts/xlsx_read.py +166 -0
- package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
- package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
- package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
- package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
- package/tools/create_goal/TOOL.md +36 -0
- package/tools/image_generation/TOOL.md +81 -0
- package/tools/skill/TOOL.md +117 -0
- package/tools/update_profile_notes/TOOL.md +39 -0
|
@@ -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 `vision_analyze`. 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())
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a fillable AcroForm PDF from a JSON spec (reportlab canvas.acroForm).
|
|
3
|
+
|
|
4
|
+
Spec format (UTF-8 JSON; coordinates in PDF points, origin bottom-left):
|
|
5
|
+
{
|
|
6
|
+
"title": "Example Intake Form",
|
|
7
|
+
"page_size": "A4", // or "letter" or [width, height]
|
|
8
|
+
"page_count": 1,
|
|
9
|
+
"fields": [
|
|
10
|
+
{"name": "surname", "type": "text", "page": 1,
|
|
11
|
+
"label": "Surname", "label_box": [72, 700, 150, 714],
|
|
12
|
+
"entry_box": [160, 696, 400, 716], "value": "", "tooltip": "Family name"},
|
|
13
|
+
{"name": "agree", "type": "checkbox", "page": 1,
|
|
14
|
+
"label": "I agree", "label_box": [72, 660, 150, 674],
|
|
15
|
+
"entry_box": [160, 658, 176, 674], "checked": false},
|
|
16
|
+
{"name": "color", "type": "radio", "page": 1,
|
|
17
|
+
"label": "Color", "label_box": [72, 620, 150, 634],
|
|
18
|
+
"entry_box": [160, 616, 400, 636], "options": ["red", "blue"],
|
|
19
|
+
"value": "red"},
|
|
20
|
+
{"name": "size", "type": "dropdown", "page": 1,
|
|
21
|
+
"label": "Size", "label_box": [72, 580, 150, 594],
|
|
22
|
+
"entry_box": [160, 576, 300, 596], "options": ["small", "large"],
|
|
23
|
+
"value": "small"}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
The same spec (label_box/entry_box/page) is what pdf_form_layout.py validates,
|
|
27
|
+
so lint the layout first, then build.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _reconfigure_stdio() -> None:
|
|
37
|
+
for stream in (sys.stdout, sys.stderr):
|
|
38
|
+
try:
|
|
39
|
+
stream.reconfigure(encoding="utf-8")
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _page_size(spec: dict):
|
|
45
|
+
from reportlab.lib.pagesizes import A4, letter
|
|
46
|
+
ps = spec.get("page_size", "A4")
|
|
47
|
+
if isinstance(ps, (list, tuple)) and len(ps) == 2:
|
|
48
|
+
return float(ps[0]), float(ps[1])
|
|
49
|
+
return letter if str(ps).lower() == "letter" else A4
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_form(spec: dict, out_path: str) -> int:
|
|
53
|
+
try:
|
|
54
|
+
from reportlab.lib import colors
|
|
55
|
+
from reportlab.pdfgen import canvas
|
|
56
|
+
except ImportError:
|
|
57
|
+
print("Missing dependency: install with 'python3 -m pip install reportlab'", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
width, height = _page_size(spec)
|
|
61
|
+
page_count = int(spec.get("page_count", 1))
|
|
62
|
+
fields = spec.get("fields", [])
|
|
63
|
+
by_page: dict[int, list[dict]] = {}
|
|
64
|
+
for f in fields:
|
|
65
|
+
by_page.setdefault(int(f.get("page", 1)), []).append(f)
|
|
66
|
+
page_count = max([page_count, *by_page.keys()]) if by_page else page_count
|
|
67
|
+
|
|
68
|
+
c = canvas.Canvas(out_path, pagesize=(width, height))
|
|
69
|
+
if spec.get("title"):
|
|
70
|
+
c.setTitle(str(spec["title"]))
|
|
71
|
+
if spec.get("author"):
|
|
72
|
+
c.setAuthor(str(spec["author"]))
|
|
73
|
+
form = c.acroForm
|
|
74
|
+
created = []
|
|
75
|
+
|
|
76
|
+
for pageno in range(1, page_count + 1):
|
|
77
|
+
for f in by_page.get(pageno, []):
|
|
78
|
+
name = f["name"]
|
|
79
|
+
ftype = f.get("type", "text")
|
|
80
|
+
ex0, ey0, ex1, ey1 = (float(v) for v in f["entry_box"])
|
|
81
|
+
ew, eh = ex1 - ex0, ey1 - ey0
|
|
82
|
+
if f.get("label"):
|
|
83
|
+
lx, ly = (float(f["label_box"][0]), float(f["label_box"][1])) \
|
|
84
|
+
if f.get("label_box") else (ex0 - 90, ey0 + 4)
|
|
85
|
+
c.setFont("Helvetica", float(f.get("label_size", 10)))
|
|
86
|
+
c.setFillColor(colors.black)
|
|
87
|
+
c.drawString(lx, ly + 2, str(f["label"]))
|
|
88
|
+
tooltip = f.get("tooltip", "")
|
|
89
|
+
if ftype == "text":
|
|
90
|
+
form.textfield(name=name, x=ex0, y=ey0, width=ew, height=eh,
|
|
91
|
+
value=str(f.get("value", "")), tooltip=tooltip,
|
|
92
|
+
borderWidth=0.5, forceBorder=True)
|
|
93
|
+
elif ftype == "checkbox":
|
|
94
|
+
size = min(ew, eh)
|
|
95
|
+
form.checkbox(name=name, x=ex0, y=ey0, size=size,
|
|
96
|
+
checked=bool(f.get("checked", False)),
|
|
97
|
+
buttonStyle="check", tooltip=tooltip,
|
|
98
|
+
borderWidth=0.5, forceBorder=True)
|
|
99
|
+
elif ftype == "radio":
|
|
100
|
+
options = f.get("options", [])
|
|
101
|
+
if not options:
|
|
102
|
+
print(f"Warning: radio {name!r} has no options, skipped", file=sys.stderr)
|
|
103
|
+
continue
|
|
104
|
+
size = min(eh, ew / max(len(options), 1) * 0.5, 16)
|
|
105
|
+
slot = ew / len(options)
|
|
106
|
+
sel = f.get("value")
|
|
107
|
+
c.setFont("Helvetica", 8)
|
|
108
|
+
for i, opt in enumerate(options):
|
|
109
|
+
ox = ex0 + i * slot
|
|
110
|
+
form.radio(name=name, value=str(opt), x=ox, y=ey0, size=size,
|
|
111
|
+
selected=(str(opt) == str(sel)), buttonStyle="circle",
|
|
112
|
+
borderWidth=0.5, forceBorder=True)
|
|
113
|
+
c.drawString(ox + size + 2, ey0 + size / 3, str(opt))
|
|
114
|
+
elif ftype == "dropdown":
|
|
115
|
+
options = [str(o) for o in f.get("options", [])]
|
|
116
|
+
value = str(f.get("value", options[0] if options else ""))
|
|
117
|
+
form.choice(name=name, x=ex0, y=ey0, width=ew, height=eh,
|
|
118
|
+
options=options, value=value, tooltip=tooltip,
|
|
119
|
+
borderWidth=0.5, forceBorder=True)
|
|
120
|
+
else:
|
|
121
|
+
print(f"Warning: unknown field type {ftype!r} for {name!r}, skipped",
|
|
122
|
+
file=sys.stderr)
|
|
123
|
+
continue
|
|
124
|
+
created.append({"name": name, "type": ftype, "page": pageno})
|
|
125
|
+
c.showPage()
|
|
126
|
+
c.save()
|
|
127
|
+
print(json.dumps({"output": out_path, "pages": page_count, "fields": created},
|
|
128
|
+
ensure_ascii=False))
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main() -> int:
|
|
133
|
+
_reconfigure_stdio()
|
|
134
|
+
parser = argparse.ArgumentParser(
|
|
135
|
+
description="Create a fillable AcroForm PDF from a JSON spec (reportlab).")
|
|
136
|
+
parser.add_argument("spec", help="Path to UTF-8 JSON form spec")
|
|
137
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
138
|
+
args = parser.parse_args()
|
|
139
|
+
with open(args.spec, encoding="utf-8") as fh:
|
|
140
|
+
spec = json.load(fh)
|
|
141
|
+
return build_form(spec, args.output)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
sys.exit(main())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Merge multiple PDFs into one, optionally adding a bookmark per source file."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> int:
|
|
12
|
+
for stream in (sys.stdout, sys.stderr):
|
|
13
|
+
try:
|
|
14
|
+
stream.reconfigure(encoding="utf-8")
|
|
15
|
+
except Exception:
|
|
16
|
+
pass
|
|
17
|
+
parser = argparse.ArgumentParser(description="Merge PDFs (pypdf).")
|
|
18
|
+
parser.add_argument("inputs", nargs="+", help="Input PDF paths, in order")
|
|
19
|
+
parser.add_argument("-o", "--output", required=True, help="Output PDF path")
|
|
20
|
+
parser.add_argument("--bookmarks", action="store_true",
|
|
21
|
+
help="Add a top-level bookmark per input file (its basename)")
|
|
22
|
+
args = parser.parse_args()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from pypdf import PdfReader, PdfWriter
|
|
26
|
+
except ImportError:
|
|
27
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
28
|
+
return 2
|
|
29
|
+
|
|
30
|
+
writer = PdfWriter()
|
|
31
|
+
total = 0
|
|
32
|
+
for path in args.inputs:
|
|
33
|
+
reader = PdfReader(path)
|
|
34
|
+
if reader.is_encrypted:
|
|
35
|
+
print(f"Error: {path} is encrypted; decrypt it first with pdf_secure.py --decrypt", file=sys.stderr)
|
|
36
|
+
return 3
|
|
37
|
+
start = total
|
|
38
|
+
for page in reader.pages:
|
|
39
|
+
writer.add_page(page)
|
|
40
|
+
total += 1
|
|
41
|
+
if args.bookmarks:
|
|
42
|
+
writer.add_outline_item(os.path.splitext(os.path.basename(path))[0], start)
|
|
43
|
+
with open(args.output, "wb") as fh:
|
|
44
|
+
writer.write(fh)
|
|
45
|
+
print(json.dumps({"output": args.output, "inputs": len(args.inputs), "page_count": total}))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
sys.exit(main())
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Document metadata and file attachments for PDFs (pypdf).
|
|
3
|
+
|
|
4
|
+
Modes (one required):
|
|
5
|
+
--set-meta set metadata keys given via --title/--author/...
|
|
6
|
+
--clear-meta drop all document info metadata
|
|
7
|
+
--attach FILE embed a file attachment
|
|
8
|
+
--list-attachments list embedded attachment names
|
|
9
|
+
--extract-attachments DIR write all attachments into DIR
|
|
10
|
+
|
|
11
|
+
Metadata note: values are stored in the classic DocInfo dictionary
|
|
12
|
+
(Title/Author/Subject/Keywords). XMP metadata, if present, is not
|
|
13
|
+
rewritten and may disagree in sophisticated viewers.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> int:
|
|
25
|
+
for stream in (sys.stdout, sys.stderr):
|
|
26
|
+
try:
|
|
27
|
+
stream.reconfigure(encoding="utf-8")
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
parser = argparse.ArgumentParser(description="Set/clear PDF metadata; manage attachments.")
|
|
31
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
32
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
33
|
+
mode.add_argument("--set-meta", action="store_true", help="Set metadata fields")
|
|
34
|
+
mode.add_argument("--clear-meta", action="store_true", help="Remove all DocInfo metadata")
|
|
35
|
+
mode.add_argument("--attach", metavar="FILE", help="Embed FILE as an attachment")
|
|
36
|
+
mode.add_argument("--list-attachments", action="store_true", help="List attachment names")
|
|
37
|
+
mode.add_argument("--extract-attachments", metavar="DIR", help="Extract attachments into DIR")
|
|
38
|
+
parser.add_argument("-o", "--output", help="Output PDF (required for write modes)")
|
|
39
|
+
parser.add_argument("--title")
|
|
40
|
+
parser.add_argument("--author")
|
|
41
|
+
parser.add_argument("--subject")
|
|
42
|
+
parser.add_argument("--keywords")
|
|
43
|
+
parser.add_argument("--password", help="Password if the input is encrypted")
|
|
44
|
+
args = parser.parse_args()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from pypdf import PdfReader, PdfWriter
|
|
48
|
+
except ImportError:
|
|
49
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
|
|
52
|
+
reader = PdfReader(args.pdf)
|
|
53
|
+
if reader.is_encrypted:
|
|
54
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
55
|
+
print("Error: input is encrypted; pass --password", file=sys.stderr)
|
|
56
|
+
return 3
|
|
57
|
+
|
|
58
|
+
if args.list_attachments:
|
|
59
|
+
names = list(reader.attachments.keys())
|
|
60
|
+
json.dump({"attachment_count": len(names), "attachments": names}, sys.stdout,
|
|
61
|
+
ensure_ascii=False, indent=2)
|
|
62
|
+
print()
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
if args.extract_attachments:
|
|
66
|
+
out_dir = Path(args.extract_attachments)
|
|
67
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
written = []
|
|
69
|
+
for name, contents in reader.attachments.items():
|
|
70
|
+
data = contents[0] if isinstance(contents, list) else contents
|
|
71
|
+
safe = os.path.basename(name) or "attachment.bin"
|
|
72
|
+
target = out_dir / safe
|
|
73
|
+
with open(target, "wb") as fh:
|
|
74
|
+
fh.write(bytes(data))
|
|
75
|
+
written.append(str(target))
|
|
76
|
+
json.dump({"extracted": written}, sys.stdout, ensure_ascii=False, indent=2)
|
|
77
|
+
print()
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
if not args.output:
|
|
81
|
+
print("Error: -o/--output is required for write modes", file=sys.stderr)
|
|
82
|
+
return 4
|
|
83
|
+
|
|
84
|
+
writer = PdfWriter()
|
|
85
|
+
writer.append(reader)
|
|
86
|
+
|
|
87
|
+
if args.set_meta:
|
|
88
|
+
meta = {}
|
|
89
|
+
for key, value in ((f"/{k.capitalize()}", getattr(args, k))
|
|
90
|
+
for k in ("title", "author", "subject", "keywords")):
|
|
91
|
+
if value is not None:
|
|
92
|
+
meta[key] = value
|
|
93
|
+
if not meta:
|
|
94
|
+
print("Error: --set-meta needs at least one of --title/--author/--subject/--keywords",
|
|
95
|
+
file=sys.stderr)
|
|
96
|
+
return 4
|
|
97
|
+
writer.add_metadata(meta)
|
|
98
|
+
result = {"output": args.output, "set": {k.lstrip("/"): v for k, v in meta.items()}}
|
|
99
|
+
elif args.clear_meta:
|
|
100
|
+
writer.metadata = None
|
|
101
|
+
result = {"output": args.output, "cleared": True}
|
|
102
|
+
else: # --attach
|
|
103
|
+
attach_path = Path(args.attach)
|
|
104
|
+
with open(attach_path, "rb") as fh:
|
|
105
|
+
writer.add_attachment(attach_path.name, fh.read())
|
|
106
|
+
result = {"output": args.output, "attached": attach_path.name}
|
|
107
|
+
|
|
108
|
+
with open(args.output, "wb") as fh:
|
|
109
|
+
writer.write(fh)
|
|
110
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
sys.exit(main())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export PDF pages as PNG images at a chosen DPI.
|
|
3
|
+
|
|
4
|
+
Rasterizer fallback chain: pypdfium2 (pip) -> pdftoppm (poppler-utils).
|
|
5
|
+
When neither is available, exits 0 with {"rendered": false, "missing": [...]}
|
|
6
|
+
so callers can branch instead of crashing.
|
|
7
|
+
|
|
8
|
+
Typical uses: visual verification with a vision model, and exporting
|
|
9
|
+
image-only (scanned) pages for hand-off to the references/ocr-extraction.md in this skill.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_pages(spec: str, page_count: int) -> list[int]:
|
|
20
|
+
"""'1-3,5,9-' (1-based, inclusive) -> sorted page list."""
|
|
21
|
+
pages: set[int] = set()
|
|
22
|
+
for part in spec.split(","):
|
|
23
|
+
part = part.strip()
|
|
24
|
+
if not part:
|
|
25
|
+
continue
|
|
26
|
+
if "-" in part:
|
|
27
|
+
start_s, _, end_s = part.partition("-")
|
|
28
|
+
start = int(start_s) if start_s else 1
|
|
29
|
+
end = int(end_s) if end_s else page_count
|
|
30
|
+
pages.update(range(start, end + 1))
|
|
31
|
+
else:
|
|
32
|
+
pages.add(int(part))
|
|
33
|
+
bad = [p for p in pages if not 1 <= p <= page_count]
|
|
34
|
+
if bad:
|
|
35
|
+
raise ValueError(f"pages out of range 1-{page_count}: {sorted(bad)}")
|
|
36
|
+
return sorted(pages)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main() -> int:
|
|
40
|
+
for stream in (sys.stdout, sys.stderr):
|
|
41
|
+
try:
|
|
42
|
+
stream.reconfigure(encoding="utf-8")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
parser = argparse.ArgumentParser(description="Export PDF pages as PNG images.")
|
|
46
|
+
parser.add_argument("pdf", help="Input PDF path")
|
|
47
|
+
parser.add_argument("--pages", default="1-", help="1-based ranges, e.g. '1-3,5' (default: all)")
|
|
48
|
+
parser.add_argument("--dpi", type=int, default=150, help="Render DPI (default 150)")
|
|
49
|
+
parser.add_argument("--out-dir", required=True, help="Directory for PNG files")
|
|
50
|
+
parser.add_argument("--prefix", default="page", help="Output filename prefix (default 'page')")
|
|
51
|
+
parser.add_argument("--password", help="Password for encrypted PDFs")
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
55
|
+
import _raster
|
|
56
|
+
|
|
57
|
+
if not _raster.available_backends():
|
|
58
|
+
json.dump({"rendered": False, "missing": _raster.missing_hints()}, sys.stdout)
|
|
59
|
+
print()
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
from pypdf import PdfReader
|
|
64
|
+
except ImportError:
|
|
65
|
+
print("Missing dependency: install with 'python3 -m pip install pypdf'", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
reader = PdfReader(args.pdf)
|
|
68
|
+
if reader.is_encrypted:
|
|
69
|
+
if args.password is None or not reader.decrypt(args.password):
|
|
70
|
+
print("File is encrypted; pass --password.", file=sys.stderr)
|
|
71
|
+
return 3
|
|
72
|
+
page_count = len(reader.pages)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
pages = parse_pages(args.pages, page_count)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
78
|
+
return 4
|
|
79
|
+
|
|
80
|
+
out_dir = Path(args.out_dir)
|
|
81
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
files = []
|
|
83
|
+
for pageno in pages:
|
|
84
|
+
img = _raster.rasterize_page(args.pdf, pageno, dpi=args.dpi, password=args.password)
|
|
85
|
+
if img is None:
|
|
86
|
+
json.dump({"rendered": False, "missing": _raster.missing_hints()}, sys.stdout)
|
|
87
|
+
print()
|
|
88
|
+
return 0
|
|
89
|
+
out_path = out_dir / f"{args.prefix}{pageno:03d}.png"
|
|
90
|
+
img.save(out_path)
|
|
91
|
+
files.append(str(out_path))
|
|
92
|
+
json.dump({"rendered": True, "dpi": args.dpi, "page_count": page_count,
|
|
93
|
+
"files": files}, sys.stdout, ensure_ascii=False, indent=2)
|
|
94
|
+
print()
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
sys.exit(main())
|