qubicl-cli 0.1.0-dev.0 → 0.1.1

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 +129 -19
  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 +8 -8
@@ -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("Required Qubicl PDF dependency is unavailable: 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("Required Qubicl PDF dependency is unavailable: 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("Required Qubicl PDF dependency is unavailable: 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: pinned pypdfium2 -> 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 ocr-and-documents 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("Required Qubicl PDF dependency is unavailable: 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())
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """Read a PDF: per-page text, tables, metadata, or form fields. JSON to stdout."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import os
9
+ import sys
10
+
11
+
12
+ def _reconfigure_stdio() -> None:
13
+ for stream in (sys.stdout, sys.stderr):
14
+ try:
15
+ stream.reconfigure(encoding="utf-8")
16
+ except Exception:
17
+ pass
18
+
19
+
20
+ def _need(module: str, package: str):
21
+ try:
22
+ return __import__(module)
23
+ except ImportError:
24
+ print(f"Required Qubicl PDF dependency is unavailable: {package}", file=sys.stderr)
25
+ raise SystemExit(2)
26
+
27
+
28
+ def read_text(path: str, password: str | None) -> dict:
29
+ pdfplumber = _need("pdfplumber", "pdfplumber")
30
+ pages = []
31
+ with pdfplumber.open(path, password=password) as pdf:
32
+ for page in pdf.pages:
33
+ pages.append(page.extract_text() or "")
34
+ return {"page_count": len(pages), "pages": pages}
35
+
36
+
37
+ def read_tables(path: str, password: str | None, csv_dir: str | None) -> dict:
38
+ pdfplumber = _need("pdfplumber", "pdfplumber")
39
+ result = []
40
+ written = []
41
+ with pdfplumber.open(path, password=password) as pdf:
42
+ for pageno, page in enumerate(pdf.pages, start=1):
43
+ for tidx, table in enumerate(page.extract_tables()):
44
+ result.append({"page": pageno, "index": tidx, "rows": table})
45
+ if csv_dir:
46
+ os.makedirs(csv_dir, exist_ok=True)
47
+ csv_path = os.path.join(csv_dir, f"page{pageno}_table{tidx}.csv")
48
+ with open(csv_path, "w", encoding="utf-8", newline="") as fh:
49
+ csv.writer(fh).writerows([[c if c is not None else "" for c in row] for row in table])
50
+ written.append(csv_path)
51
+ out = {"table_count": len(result), "tables": result}
52
+ if csv_dir:
53
+ out["csv_files"] = written
54
+ return out
55
+
56
+
57
+ def read_meta(path: str, password: str | None) -> dict:
58
+ pypdf = _need("pypdf", "pypdf")
59
+ reader = pypdf.PdfReader(path)
60
+ encrypted = reader.is_encrypted
61
+ if encrypted:
62
+ if password is None or not reader.decrypt(password):
63
+ return {"encrypted": True, "note": "Provide --password to read metadata of an encrypted file."}
64
+ meta = {}
65
+ if reader.metadata:
66
+ for key, value in reader.metadata.items():
67
+ meta[str(key).lstrip("/")] = str(value)
68
+ pages = []
69
+ for idx, page in enumerate(reader.pages, start=1):
70
+ box = page.mediabox
71
+ pages.append({
72
+ "page": idx,
73
+ "width": float(box.width),
74
+ "height": float(box.height),
75
+ "rotation": int(page.get("/Rotate", 0)),
76
+ })
77
+ # scanned-page heuristic: no extractable text but page has images
78
+ likely_scanned = []
79
+ try:
80
+ pdfplumber = _need("pdfplumber", "pdfplumber")
81
+ with pdfplumber.open(path, password=password) as pdf:
82
+ for pageno, page in enumerate(pdf.pages, start=1):
83
+ text = (page.extract_text() or "").strip()
84
+ if not text and page.images:
85
+ likely_scanned.append(pageno)
86
+ except SystemExit:
87
+ raise
88
+ except Exception as exc: # pragma: no cover - heuristic only
89
+ print(f"Warning: scanned-page check failed: {exc}", file=sys.stderr)
90
+ out = {
91
+ "encrypted": encrypted,
92
+ "page_count": len(reader.pages),
93
+ "metadata": meta,
94
+ "pages": pages,
95
+ "likely_scanned_pages": likely_scanned,
96
+ }
97
+ if likely_scanned:
98
+ out["note"] = ("Image-only pages detected: no text layer to extract. "
99
+ "Use the ocr-and-documents skill for OCR.")
100
+ return out
101
+
102
+
103
+ FIELD_TYPES = {"/Tx": "text", "/Btn": "button", "/Ch": "choice", "/Sig": "signature"}
104
+
105
+
106
+ def read_fields(path: str, password: str | None) -> dict:
107
+ pypdf = _need("pypdf", "pypdf")
108
+ reader = pypdf.PdfReader(path)
109
+ if reader.is_encrypted:
110
+ if password is None or not reader.decrypt(password):
111
+ print("File is encrypted; pass --password.", file=sys.stderr)
112
+ raise SystemExit(3)
113
+ fields = reader.get_fields() or {}
114
+ out = {}
115
+ for name, field in fields.items():
116
+ ftype = FIELD_TYPES.get(str(field.get("/FT")), str(field.get("/FT")))
117
+ value = field.get("/V")
118
+ states = field.get("/_States_")
119
+ entry = {"type": ftype, "value": None if value is None else str(value)}
120
+ if states:
121
+ entry["options"] = [str(s) for s in states]
122
+ out[name] = entry
123
+ return {"field_count": len(out), "fields": out}
124
+
125
+
126
+ def main() -> int:
127
+ _reconfigure_stdio()
128
+ parser = argparse.ArgumentParser(description="Extract text, tables, metadata, or form fields from a PDF.")
129
+ parser.add_argument("pdf", help="Input PDF path")
130
+ mode = parser.add_mutually_exclusive_group(required=True)
131
+ mode.add_argument("--text", action="store_true", help="Per-page text as JSON")
132
+ mode.add_argument("--tables", action="store_true", help="Tables as JSON (optionally CSV via --csv-dir)")
133
+ mode.add_argument("--meta", action="store_true", help="Metadata, page sizes, encrypted/scanned flags")
134
+ mode.add_argument("--fields", action="store_true", help="AcroForm fields with types and values")
135
+ parser.add_argument("--csv-dir", help="Also write each table as a CSV file into this directory")
136
+ parser.add_argument("--password", help="Password for encrypted PDFs")
137
+ args = parser.parse_args()
138
+
139
+ if args.text:
140
+ result = read_text(args.pdf, args.password)
141
+ elif args.tables:
142
+ result = read_tables(args.pdf, args.password, args.csv_dir)
143
+ elif args.meta:
144
+ result = read_meta(args.pdf, args.password)
145
+ else:
146
+ result = read_fields(args.pdf, args.password)
147
+ json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
148
+ print()
149
+ return 0
150
+
151
+
152
+ if __name__ == "__main__":
153
+ sys.exit(main())
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """Encrypt or decrypt a PDF with passwords (AES-256 via pypdf).
3
+
4
+ Note: permission flags set at encryption time are advisory — viewers may honor
5
+ them, but any PDF library can strip them. Only the user password gates content.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+
13
+
14
+ def main() -> int:
15
+ for stream in (sys.stdout, sys.stderr):
16
+ try:
17
+ stream.reconfigure(encoding="utf-8")
18
+ except Exception:
19
+ pass
20
+ parser = argparse.ArgumentParser(description="Encrypt/decrypt PDFs (pypdf, AES-256).")
21
+ parser.add_argument("pdf", help="Input PDF path")
22
+ parser.add_argument("-o", "--output", required=True, help="Output PDF path")
23
+ mode = parser.add_mutually_exclusive_group(required=True)
24
+ mode.add_argument("--encrypt", action="store_true", help="Encrypt the PDF")
25
+ mode.add_argument("--decrypt", action="store_true", help="Remove encryption (password required)")
26
+ parser.add_argument("--user-password", help="User (open) password for --encrypt")
27
+ parser.add_argument("--owner-password", help="Owner password for --encrypt (defaults to user password)")
28
+ parser.add_argument("--password", help="Known password for --decrypt")
29
+ args = parser.parse_args()
30
+
31
+ try:
32
+ from pypdf import PdfReader, PdfWriter
33
+ except ImportError:
34
+ print("Required Qubicl PDF dependency is unavailable: pypdf", file=sys.stderr)
35
+ return 2
36
+
37
+ reader = PdfReader(args.pdf)
38
+ if args.encrypt:
39
+ if not args.user_password:
40
+ print("Error: --encrypt requires --user-password", file=sys.stderr)
41
+ return 2
42
+ if reader.is_encrypted:
43
+ print("Error: input already encrypted; decrypt first", file=sys.stderr)
44
+ return 3
45
+ writer = PdfWriter()
46
+ writer.append(reader)
47
+ writer.encrypt(
48
+ user_password=args.user_password,
49
+ owner_password=args.owner_password or args.user_password,
50
+ algorithm="AES-256",
51
+ )
52
+ action = "encrypted"
53
+ else:
54
+ if not reader.is_encrypted:
55
+ print("Error: input is not encrypted", file=sys.stderr)
56
+ return 3
57
+ if args.password is None or not reader.decrypt(args.password):
58
+ print("Error: wrong or missing --password", file=sys.stderr)
59
+ return 4
60
+ writer = PdfWriter()
61
+ writer.append(reader)
62
+ action = "decrypted"
63
+
64
+ with open(args.output, "wb") as fh:
65
+ writer.write(fh)
66
+ print(json.dumps({"output": args.output, "action": action, "page_count": len(reader.pages)}))
67
+ return 0
68
+
69
+
70
+ if __name__ == "__main__":
71
+ sys.exit(main())