@cteno/sdk-linux-x64 0.0.0-stage → 2.10.14

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 (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +132 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +309 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +256 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. package/tools/update_profile_notes/TOOL.md +39 -0
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """Render an office file (or PDF) to page/slide PNGs so you can LOOK at the result.
3
+
4
+ Usage:
5
+ python3 render.py FILE [--outdir DIR] [--dpi 110] [--pages 1-3] [--pdf-only]
6
+
7
+ FILE may be .docx/.doc/.pptx/.ppt/.xlsx/.xls/.odt/.odp/.ods/.rtf/.csv or .pdf.
8
+ Office files are converted to PDF with LibreOffice (soffice) in an isolated,
9
+ throw-away user profile (so a running LibreOffice or a stale lock never hangs
10
+ the conversion), then split into PNGs with pdftoppm, or pypdfium2 if poppler
11
+ is missing.
12
+
13
+ Prints JSON:
14
+ {"rendered": true, "pdf": "DIR/name.pdf", "pages": N, "files": ["DIR/name-01.png", ...]}
15
+ {"rendered": false, "missing": [...], "install": "..."} (exit 0, nothing rendered)
16
+ Exit 1 only when a tool was present but the conversion itself failed.
17
+
18
+ This file is shared verbatim by the docx, pptx, xlsx and pdf skills.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import glob
24
+ import json
25
+ import os
26
+ import platform
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+
32
+ OFFICE_EXT = {".docx", ".doc", ".dotx", ".pptx", ".ppt", ".potx", ".xlsx", ".xlsm", ".xls",
33
+ ".odt", ".odp", ".ods", ".rtf", ".csv"}
34
+
35
+
36
+ def find_soffice() -> str | None:
37
+ override = os.environ.get("SOFFICE")
38
+ if override is not None: # explicit path, or "" to mean "no LibreOffice"
39
+ return override if override and os.path.exists(override) else None
40
+ for name in ("soffice", "libreoffice"):
41
+ p = shutil.which(name)
42
+ if p:
43
+ return p
44
+ for p in ("/Applications/LibreOffice.app/Contents/MacOS/soffice",
45
+ r"C:\Program Files\LibreOffice\program\soffice.exe",
46
+ r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
47
+ "/usr/lib/libreoffice/program/soffice", "/opt/libreoffice/program/soffice"):
48
+ if os.path.exists(p):
49
+ return p
50
+ return None
51
+
52
+
53
+ def install_hint(missing: list[str]) -> str:
54
+ sysname = platform.system()
55
+ tips = []
56
+ if "soffice" in missing:
57
+ tips.append({"Darwin": "brew install --cask libreoffice (or download from libreoffice.org)",
58
+ "Linux": "sudo apt-get install -y libreoffice-core libreoffice-writer libreoffice-calc libreoffice-impress",
59
+ "Windows": "winget install TheDocumentFoundation.LibreOffice"}.get(sysname, "install LibreOffice"))
60
+ if "pdftoppm" in missing:
61
+ tips.append({"Darwin": "brew install poppler", "Linux": "sudo apt-get install -y poppler-utils",
62
+ "Windows": "pip install pypdfium2"}.get(sysname, "pip install pypdfium2"))
63
+ return "; ".join(tips)
64
+
65
+
66
+ # Windows/Office CJK font names -> fonts that actually exist on this machine, so the
67
+ # preview shows real glyphs with similar metrics instead of empty boxes. Only the
68
+ # throw-away LibreOffice profile used for rendering is changed; the document is not.
69
+ CJK_SUBSTITUTES = {
70
+ "Darwin": {
71
+ "SimSun": "Songti SC", "宋体": "Songti SC", "NSimSun": "Songti SC", "SimSun-ExtB": "Songti SC",
72
+ "SimHei": "Heiti SC", "黑体": "Heiti SC", "Microsoft YaHei": "Hiragino Sans GB",
73
+ "微软雅黑": "Hiragino Sans GB", "Microsoft JhengHei": "Heiti TC", "PMingLiU": "Songti TC",
74
+ "FangSong": "STFangsong", "FangSong_GB2312": "STFangsong", "仿宋": "STFangsong", "仿宋_GB2312": "STFangsong",
75
+ "KaiTi": "Kaiti SC", "KaiTi_GB2312": "Kaiti SC", "楷体": "Kaiti SC", "楷体_GB2312": "Kaiti SC",
76
+ "DengXian": "Hiragino Sans GB", "等线": "Hiragino Sans GB",
77
+ "FZXiaoBiaoSong-B05S": "Songti SC", "方正小标宋简体": "Songti SC",
78
+ },
79
+ "Linux": {k: v for k, v in (
80
+ ("SimSun", "Noto Serif CJK SC"), ("宋体", "Noto Serif CJK SC"), ("NSimSun", "Noto Serif CJK SC"),
81
+ ("SimHei", "Noto Sans CJK SC"), ("黑体", "Noto Sans CJK SC"), ("Microsoft YaHei", "Noto Sans CJK SC"),
82
+ ("微软雅黑", "Noto Sans CJK SC"), ("FangSong", "Noto Serif CJK SC"), ("FangSong_GB2312", "Noto Serif CJK SC"),
83
+ ("KaiTi", "Noto Serif CJK SC"), ("KaiTi_GB2312", "Noto Serif CJK SC"), ("DengXian", "Noto Sans CJK SC"),
84
+ ("FZXiaoBiaoSong-B05S", "Noto Serif CJK SC"))},
85
+ }
86
+
87
+
88
+ SYSTEM_CJK_FONT_FILES = [
89
+ # LibreOffice on macOS does not see these system fonts; link them into the profile so CJK renders.
90
+ "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/STHeiti Medium.ttc",
91
+ "/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/Supplemental/Songti.ttc",
92
+ "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Supplemental/Kaiti.ttc",
93
+ "/System/Library/Fonts/Supplemental/STFangsong.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
94
+ ]
95
+
96
+
97
+ def write_profile(prof: str) -> None:
98
+ fonts_dir = os.path.join(prof, "user", "fonts")
99
+ os.makedirs(fonts_dir, exist_ok=True)
100
+ for f in SYSTEM_CJK_FONT_FILES:
101
+ if os.path.exists(f):
102
+ try:
103
+ os.symlink(f, os.path.join(fonts_dir, os.path.basename(f)))
104
+ except OSError:
105
+ shutil.copy(f, fonts_dir)
106
+ pairs = CJK_SUBSTITUTES.get(platform.system())
107
+ if not pairs:
108
+ return # Windows has the real fonts
109
+ from xml.sax.saxutils import quoteattr
110
+ nodes = "".join(
111
+ f'<item oor:path="/org.openoffice.VCL/FontSubstitutions"><node oor:name={quoteattr("p" + str(i))} oor:op="replace">'
112
+ f'<prop oor:name="ReplaceFont"><value>{src}</value></prop>'
113
+ f'<prop oor:name="SubstituteFont"><value>{dst}</value></prop>'
114
+ f'<prop oor:name="Always"><value>true</value></prop>'
115
+ f'<prop oor:name="OnScreenOnly"><value>false</value></prop></node></item>'
116
+ for i, (src, dst) in enumerate(pairs.items()))
117
+ nodes += ('<item oor:path="/org.openoffice.VCL"><prop oor:name="FontSubstitutionsEnabled" oor:op="fuse">'
118
+ '<value>true</value></prop></item>'
119
+ '<item oor:path="/org.openoffice.Office.Common/Font/Substitution"><prop oor:name="Replacement" oor:op="fuse">'
120
+ '<value>true</value></prop></item>'
121
+ '<item oor:path="/org.openoffice.Office.Common/Font/Substitution"><prop oor:name="FontPairs" oor:op="fuse">'
122
+ '<value>' + "".join(
123
+ f'<it><prop oor:name="Always"><value>true</value></prop>'
124
+ f'<prop oor:name="OnScreenOnly"><value>false</value></prop>'
125
+ f'<prop oor:name="ReplaceFont"><value>{s}</value></prop>'
126
+ f'<prop oor:name="SubstituteFont"><value>{d}</value></prop></it>'
127
+ for s, d in pairs.items()) + '</value></prop></item>')
128
+ os.makedirs(os.path.join(prof, "user"), exist_ok=True)
129
+ with open(os.path.join(prof, "user", "registrymodifications.xcu"), "w", encoding="utf-8") as f:
130
+ f.write('<?xml version="1.0" encoding="UTF-8"?>\n<oor:items xmlns:oor="http://openoffice.org/2001/registry" '
131
+ 'xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
132
+ + nodes + '</oor:items>\n')
133
+
134
+
135
+ def to_pdf(src: str, outdir: str, timeout: int) -> str:
136
+ soffice = find_soffice()
137
+ if not soffice:
138
+ raise LookupError("soffice")
139
+ with tempfile.TemporaryDirectory(prefix="lo-profile-") as prof:
140
+ write_profile(prof)
141
+ profile_url = "file:///" + prof.replace("\\", "/").lstrip("/")
142
+ cmd = [soffice, f"-env:UserInstallation={profile_url}", "--headless", "--norestore",
143
+ "--nolockcheck", "--convert-to", "pdf", "--outdir", outdir, src]
144
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8",
145
+ errors="replace", timeout=timeout, check=False)
146
+ pdf = os.path.join(outdir, os.path.splitext(os.path.basename(src))[0] + ".pdf")
147
+ if proc.returncode != 0 or not os.path.exists(pdf):
148
+ raise RuntimeError(f"soffice conversion failed (rc={proc.returncode}): {proc.stderr.strip() or proc.stdout.strip()}")
149
+ return pdf
150
+
151
+
152
+ def parse_pages(spec: str | None, n: int) -> list[int]:
153
+ if not spec:
154
+ return list(range(1, n + 1))
155
+ out: list[int] = []
156
+ for part in spec.split(","):
157
+ a, _, b = part.partition("-")
158
+ lo, hi = int(a), int(b or a)
159
+ out += [p for p in range(lo, hi + 1) if 1 <= p <= n]
160
+ return out
161
+
162
+
163
+ def pdf_pages(pdf: str) -> int:
164
+ try:
165
+ from pypdf import PdfReader
166
+ return len(PdfReader(pdf).pages)
167
+ except Exception:
168
+ info = shutil.which("pdfinfo")
169
+ if info:
170
+ r = subprocess.run([info, pdf], capture_output=True, text=True, check=False)
171
+ for line in r.stdout.splitlines():
172
+ if line.startswith("Pages:"):
173
+ return int(line.split()[1])
174
+ return 0
175
+
176
+
177
+ def rasterize(pdf: str, outdir: str, stem: str, dpi: int, pages: list[int]) -> list[str]:
178
+ width = max(2, len(str(max(pages) if pages else 1)))
179
+ files = []
180
+ tool = shutil.which("pdftoppm")
181
+ if tool:
182
+ for p in pages:
183
+ prefix = os.path.join(outdir, f"{stem}-{p:0{width}d}")
184
+ subprocess.run([tool, "-png", "-r", str(dpi), "-f", str(p), "-l", str(p), "-singlefile", pdf, prefix],
185
+ check=True, capture_output=True)
186
+ files.append(prefix + ".png")
187
+ return files
188
+ import pypdfium2 as pdfium # raises ImportError -> reported as missing
189
+ doc = pdfium.PdfDocument(pdf)
190
+ for p in pages:
191
+ img = doc[p - 1].render(scale=dpi / 72).to_pil()
192
+ path = os.path.join(outdir, f"{stem}-{p:0{width}d}.png")
193
+ img.save(path)
194
+ files.append(path)
195
+ return files
196
+
197
+
198
+ def main(argv=None) -> int:
199
+ if hasattr(sys.stdout, "reconfigure"):
200
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
201
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0],
202
+ epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
203
+ ap.add_argument("file")
204
+ ap.add_argument("--outdir", default=None, help="default: <file dir>/render")
205
+ ap.add_argument("--dpi", type=int, default=110)
206
+ ap.add_argument("--pages", default=None, help="e.g. 1-3,7 (default: all)")
207
+ ap.add_argument("--pdf-only", action="store_true", help="convert to PDF, skip PNGs")
208
+ ap.add_argument("--timeout", type=int, default=240)
209
+ a = ap.parse_args(argv)
210
+
211
+ src = os.path.abspath(a.file)
212
+ outdir = os.path.abspath(a.outdir or os.path.join(os.path.dirname(src), "render"))
213
+ os.makedirs(outdir, exist_ok=True)
214
+ stem = os.path.splitext(os.path.basename(src))[0]
215
+ ext = os.path.splitext(src)[1].lower()
216
+ missing: list[str] = []
217
+ try:
218
+ if ext == ".pdf":
219
+ pdf = src
220
+ elif ext in OFFICE_EXT:
221
+ pdf = to_pdf(src, outdir, a.timeout)
222
+ else:
223
+ print(json.dumps({"rendered": False, "error": f"unsupported file type {ext}"}))
224
+ return 1
225
+ except LookupError:
226
+ missing.append("soffice")
227
+ except (RuntimeError, subprocess.TimeoutExpired) as e:
228
+ print(json.dumps({"rendered": False, "error": str(e)}, ensure_ascii=False))
229
+ return 1
230
+ if missing:
231
+ print(json.dumps({"rendered": False, "missing": missing, "install": install_hint(missing)}, ensure_ascii=False))
232
+ return 0
233
+ n = pdf_pages(pdf)
234
+ if a.pdf_only:
235
+ print(json.dumps({"rendered": True, "pdf": pdf, "pages": n, "files": []}, ensure_ascii=False))
236
+ return 0
237
+ try:
238
+ for old in glob.glob(os.path.join(outdir, f"{stem}-*.png")):
239
+ os.remove(old)
240
+ files = rasterize(pdf, outdir, stem, a.dpi, parse_pages(a.pages, n))
241
+ except ImportError:
242
+ print(json.dumps({"rendered": False, "pdf": pdf, "missing": ["pdftoppm"],
243
+ "install": install_hint(["pdftoppm"])}, ensure_ascii=False))
244
+ return 0
245
+ print(json.dumps({"rendered": True, "pdf": pdf, "pages": n, "files": files}, ensure_ascii=False))
246
+ return 0
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())
@@ -0,0 +1,415 @@
1
+ """End-to-end tests for the pdf skill helper scripts. No network required."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
13
+
14
+
15
+ def run(script: str, *args: str, expect: int = 0) -> subprocess.CompletedProcess:
16
+ env = dict(os.environ, LC_ALL="C", LANG="C", PYTHONIOENCODING="utf-8")
17
+ proc = subprocess.run(
18
+ [sys.executable, str(SCRIPTS / script), *args],
19
+ capture_output=True, text=True, encoding="utf-8", env=env,
20
+ check=False,
21
+ )
22
+ assert proc.returncode == expect, f"{script} {args}: rc={proc.returncode}\n{proc.stderr}"
23
+ return proc
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def workdir(tmp_path_factory) -> Path:
28
+ return tmp_path_factory.mktemp("pdfwork")
29
+
30
+
31
+ @pytest.fixture(scope="module")
32
+ def sample_image(workdir: Path) -> Path:
33
+ from PIL import Image
34
+ img_path = workdir / "sample.png"
35
+ img = Image.new("RGB", (120, 80), (30, 120, 200))
36
+ img.save(img_path)
37
+ return img_path
38
+
39
+
40
+ @pytest.fixture(scope="module")
41
+ def report_pdf(workdir: Path, sample_image: Path) -> Path:
42
+ spec = {
43
+ "title": "Quarterly Example Report",
44
+ "author": "example-author",
45
+ "elements": [
46
+ {"type": "heading", "text": "Quarterly Example Report", "level": 1},
47
+ {"type": "paragraph", "text": "This is the introduction paragraph with a marker UNIQUEMARK42."},
48
+ {"type": "table", "rows": [["Region", "Units"], ["North", "1250"], ["South", "980"]], "header": True},
49
+ {"type": "image", "path": str(sample_image), "width": 200},
50
+ {"type": "pagebreak"},
51
+ {"type": "heading", "text": "Appendix", "level": 2},
52
+ {"type": "paragraph", "text": "Second page content."},
53
+ ],
54
+ }
55
+ spec_path = workdir / "spec.json"
56
+ spec_path.write_text(json.dumps(spec), encoding="utf-8")
57
+ out = workdir / "report.pdf"
58
+ run("pdf_create.py", str(spec_path), "-o", str(out))
59
+ assert out.exists() and out.stat().st_size > 500
60
+ return out
61
+
62
+
63
+ def test_create_and_meta(report_pdf: Path):
64
+ meta = json.loads(run("pdf_read.py", str(report_pdf), "--meta").stdout)
65
+ assert meta["page_count"] == 2
66
+ assert meta["encrypted"] is False
67
+ assert meta["likely_scanned_pages"] == []
68
+ assert "Quarterly Example Report" in meta["metadata"].get("Title", "")
69
+
70
+
71
+ def test_extract_text(report_pdf: Path):
72
+ data = json.loads(run("pdf_read.py", str(report_pdf), "--text").stdout)
73
+ assert data["page_count"] == 2
74
+ assert "UNIQUEMARK42" in data["pages"][0]
75
+ assert "Appendix" in data["pages"][1]
76
+ assert "Page 1" in data["pages"][0] # page number footer
77
+
78
+
79
+ def test_extract_tables(report_pdf: Path, workdir: Path):
80
+ csv_dir = workdir / "csvs"
81
+ data = json.loads(run("pdf_read.py", str(report_pdf), "--tables",
82
+ "--csv-dir", str(csv_dir)).stdout)
83
+ assert data["table_count"] >= 1
84
+ rows = data["tables"][0]["rows"]
85
+ assert rows[0] == ["Region", "Units"]
86
+ assert ["North", "1250"] in rows
87
+ csv_files = list(csv_dir.glob("*.csv"))
88
+ assert csv_files and "Region" in csv_files[0].read_text(encoding="utf-8")
89
+
90
+
91
+ @pytest.fixture(scope="module")
92
+ def form_pdf(workdir: Path) -> Path:
93
+ from reportlab.lib.pagesizes import A4
94
+ from reportlab.pdfgen import canvas
95
+ out = workdir / "form.pdf"
96
+ c = canvas.Canvas(str(out), pagesize=A4)
97
+ form = c.acroForm
98
+ c.drawString(72, 760, "Example Form")
99
+ form.textfield(name="surname", x=72, y=700, width=300, height=20, value="")
100
+ form.checkbox(name="agree", x=72, y=660, buttonStyle="check")
101
+ form.radio(name="color", value="red", x=72, y=620, selected=False)
102
+ form.radio(name="color", value="blue", x=110, y=620, selected=True)
103
+ form.choice(name="size", x=72, y=580, width=120, height=20,
104
+ options=["small", "large"], value="small")
105
+ c.save()
106
+ return out
107
+
108
+
109
+ def test_form_fill_unicode_roundtrip(form_pdf: Path, workdir: Path):
110
+ surname = "Фамилия — ‘test’"
111
+ values = {"surname": surname, "agree": True, "color": "/red", "size": "large"}
112
+ fields_json = workdir / "values.json"
113
+ fields_json.write_text(json.dumps(values, ensure_ascii=False), encoding="utf-8")
114
+ filled = workdir / "filled.pdf"
115
+ run("pdf_fill_form.py", str(form_pdf), "--fields-json", str(fields_json),
116
+ "-o", str(filled))
117
+ data = json.loads(run("pdf_read.py", str(filled), "--fields").stdout)
118
+ fields = data["fields"]
119
+ assert fields["surname"]["value"] == surname
120
+ assert fields["agree"]["value"] in ("/Yes", "/On", "True", "/1")
121
+ assert fields["color"]["value"] == "/red"
122
+ assert fields["size"]["value"] == "large"
123
+
124
+
125
+ def test_merge_split_rotate(report_pdf: Path, workdir: Path):
126
+ merged = workdir / "merged.pdf"
127
+ out = json.loads(run("pdf_merge.py", str(report_pdf), str(report_pdf),
128
+ "-o", str(merged), "--bookmarks").stdout)
129
+ assert out["page_count"] == 4
130
+
131
+ part = workdir / "part.pdf"
132
+ out = json.loads(run("pdf_split.py", str(merged), "--pages", "2-3",
133
+ "--rotate", "90", "-o", str(part)).stdout)
134
+ assert out["page_count"] == 2
135
+ meta = json.loads(run("pdf_read.py", str(part), "--meta").stdout)
136
+ assert meta["page_count"] == 2
137
+ assert all(p["rotation"] % 360 == 90 for p in meta["pages"])
138
+
139
+
140
+ def test_watermark(report_pdf: Path, workdir: Path):
141
+ # Build the stamp at mid-page so its text does not overlap existing
142
+ # headings (overlapping glyphs confuse text extraction).
143
+ from reportlab.lib.pagesizes import A4
144
+ from reportlab.pdfgen import canvas
145
+ stamp = workdir / "stamp.pdf"
146
+ c = canvas.Canvas(str(stamp), pagesize=A4)
147
+ c.setFont("Helvetica", 40)
148
+ c.drawString(200, 400, "DRAFT")
149
+ c.save()
150
+ stamped = workdir / "stamped.pdf"
151
+ run("pdf_watermark.py", str(report_pdf), "--stamp", str(stamp), "-o", str(stamped))
152
+ data = json.loads(run("pdf_read.py", str(stamped), "--text").stdout)
153
+ assert all("DRAFT" in page for page in data["pages"])
154
+
155
+
156
+ def test_encrypt_decrypt_roundtrip(report_pdf: Path, workdir: Path):
157
+ enc = workdir / "enc.pdf"
158
+ run("pdf_secure.py", str(report_pdf), "--encrypt", "-o", str(enc),
159
+ "--user-password", "your-password")
160
+ meta = json.loads(run("pdf_read.py", str(enc), "--meta").stdout)
161
+ assert meta["encrypted"] is True
162
+
163
+ dec = workdir / "dec.pdf"
164
+ run("pdf_secure.py", str(enc), "--decrypt", "-o", str(dec),
165
+ "--password", "your-password")
166
+ data = json.loads(run("pdf_read.py", str(dec), "--text").stdout)
167
+ assert "UNIQUEMARK42" in data["pages"][0]
168
+
169
+
170
+ def test_compress(report_pdf: Path, workdir: Path):
171
+ out = workdir / "compressed.pdf"
172
+ run("pdf_split.py", str(report_pdf), "--pages", "1-2", "--compress", "-o", str(out))
173
+ data = json.loads(run("pdf_read.py", str(out), "--text").stdout)
174
+ assert "UNIQUEMARK42" in data["pages"][0]
175
+
176
+
177
+ # ---------------------------------------------------------------- form creation
178
+
179
+ FORM_SPEC = {
180
+ "title": "Example Intake Form",
181
+ "page_size": "A4",
182
+ "fields": [
183
+ {"name": "surname", "type": "text", "page": 1, "label": "Surname",
184
+ "label_box": [72, 700, 150, 714], "entry_box": [160, 696, 400, 716]},
185
+ {"name": "agree", "type": "checkbox", "page": 1, "label": "I agree",
186
+ "label_box": [72, 660, 150, 674], "entry_box": [160, 658, 176, 674]},
187
+ {"name": "color", "type": "radio", "page": 1, "label": "Color",
188
+ "label_box": [72, 620, 150, 634], "entry_box": [160, 616, 400, 636],
189
+ "options": ["red", "blue"], "value": "blue"},
190
+ {"name": "size", "type": "dropdown", "page": 1, "label": "Size",
191
+ "label_box": [72, 580, 150, 594], "entry_box": [160, 576, 300, 596],
192
+ "options": ["small", "large"], "value": "small"},
193
+ ],
194
+ }
195
+
196
+
197
+ @pytest.fixture(scope="module")
198
+ def built_form(workdir: Path) -> Path:
199
+ spec_path = workdir / "formspec.json"
200
+ spec_path.write_text(json.dumps(FORM_SPEC), encoding="utf-8")
201
+ out = workdir / "built_form.pdf"
202
+ result = json.loads(run("pdf_make_form.py", str(spec_path), "-o", str(out)).stdout)
203
+ assert len(result["fields"]) == 4
204
+ return out
205
+
206
+
207
+ def test_make_form_lists_all_fields(built_form: Path):
208
+ data = json.loads(run("pdf_read.py", str(built_form), "--fields").stdout)
209
+ fields = data["fields"]
210
+ assert set(fields) == {"surname", "agree", "color", "size"}
211
+ assert fields["surname"]["type"] == "text"
212
+ assert fields["agree"]["options"] == ["/Off", "/Yes"]
213
+ assert fields["color"]["value"] == "/blue" # pre-selected radio
214
+ assert set(fields["size"]["options"]) == {"small", "large"}
215
+ # label text is drawn on the page, not just stored in the widget
216
+ text = json.loads(run("pdf_read.py", str(built_form), "--text").stdout)
217
+ assert "Surname" in text["pages"][0] and "Size" in text["pages"][0]
218
+
219
+
220
+ def test_make_form_fill_roundtrip(built_form: Path, workdir: Path):
221
+ values = {"surname": "Smith", "agree": True, "color": "/red", "size": "large"}
222
+ vals = workdir / "builtvals.json"
223
+ vals.write_text(json.dumps(values), encoding="utf-8")
224
+ filled = workdir / "built_filled.pdf"
225
+ run("pdf_fill_form.py", str(built_form), "--fields-json", str(vals), "-o", str(filled))
226
+ fields = json.loads(run("pdf_read.py", str(filled), "--fields").stdout)["fields"]
227
+ assert fields["surname"]["value"] == "Smith"
228
+ assert fields["agree"]["value"] == "/Yes"
229
+ assert fields["color"]["value"] == "/red"
230
+ assert fields["size"]["value"] == "large"
231
+
232
+
233
+ # ------------------------------------------------------------- layout validation
234
+
235
+ def test_form_layout_valid_spec(workdir: Path):
236
+ spec_path = workdir / "layout_ok.json"
237
+ spec_path.write_text(json.dumps(FORM_SPEC), encoding="utf-8")
238
+ report = json.loads(run("pdf_form_layout.py", str(spec_path)).stdout)
239
+ assert report["ok"] is True
240
+ assert report["errors"] == 0
241
+ assert all(f["ok"] for f in report["fields"])
242
+
243
+
244
+ def test_form_layout_detects_problems(workdir: Path):
245
+ bad = {
246
+ "page_size": "A4",
247
+ "fields": [
248
+ # out of bounds (x1 beyond A4 width)
249
+ {"name": "wide", "type": "text", "page": 1, "label": "Wide",
250
+ "label_box": [10, 700, 60, 714], "entry_box": [70, 696, 900, 716]},
251
+ # two overlapping entry boxes
252
+ {"name": "one", "type": "text", "page": 1, "label": "One",
253
+ "label_box": [10, 600, 60, 614], "entry_box": [70, 596, 300, 616]},
254
+ {"name": "two", "type": "text", "page": 1, "label": "Two",
255
+ "label_box": [10, 560, 60, 574], "entry_box": [200, 600, 400, 620]},
256
+ # label far away from its entry
257
+ {"name": "lost", "type": "text", "page": 1, "label": "Lost",
258
+ "label_box": [10, 100, 60, 114], "entry_box": [400, 500, 500, 520]},
259
+ # too small
260
+ {"name": "tiny", "type": "text", "page": 1,
261
+ "entry_box": [10, 50, 14, 54]},
262
+ ],
263
+ }
264
+ spec_path = workdir / "layout_bad.json"
265
+ spec_path.write_text(json.dumps(bad), encoding="utf-8")
266
+ proc = run("pdf_form_layout.py", str(spec_path), expect=1)
267
+ report = json.loads(proc.stdout)
268
+ assert report["ok"] is False
269
+ by_name = {f["name"]: f for f in report["fields"]}
270
+ assert any("bounds" in p for p in by_name["wide"]["problems"])
271
+ assert any("overlaps field" in p for p in by_name["two"]["problems"])
272
+ assert any("from its entry" in p for p in by_name["lost"]["problems"])
273
+ assert any("minimum size" in p for p in by_name["tiny"]["problems"])
274
+ assert by_name["one"]["ok"] # first of the overlapping pair reports clean
275
+
276
+
277
+ # ------------------------------------------------- rasterization (overlay, pages)
278
+
279
+ def _raster_available() -> bool:
280
+ import shutil
281
+ try:
282
+ import pypdfium2
283
+ return True
284
+ except ImportError:
285
+ return shutil.which("pdftoppm") is not None
286
+
287
+
288
+ def test_form_layout_overlay(built_form: Path, workdir: Path):
289
+ spec_path = workdir / "formspec.json"
290
+ out_png = workdir / "overlay.png"
291
+ report = json.loads(run("pdf_form_layout.py", str(spec_path), "--pdf", str(built_form),
292
+ "--render-overlay", str(out_png)).stdout)
293
+ overlay = report["overlay"]
294
+ if _raster_available():
295
+ assert overlay["rendered"] is True
296
+ assert out_png.exists() and out_png.stat().st_size > 1000
297
+ from PIL import Image
298
+ with Image.open(out_png) as img:
299
+ assert img.width > 100 and img.height > 100
300
+ else:
301
+ assert overlay["rendered"] is False
302
+ assert overlay["missing"] # install hints present
303
+
304
+
305
+ def test_form_layout_overlay_blank_page(workdir: Path):
306
+ # No --pdf: overlay is drawn on a blank page, PIL-only, always renders.
307
+ spec_path = workdir / "formspec.json"
308
+ out_png = workdir / "overlay_blank.png"
309
+ report = json.loads(run("pdf_form_layout.py", str(spec_path),
310
+ "--render-overlay", str(out_png)).stdout)
311
+ assert report["overlay"]["rendered"] is True
312
+ assert out_png.exists()
313
+
314
+
315
+ def test_page_image_export(report_pdf: Path, workdir: Path):
316
+ out_dir = workdir / "pageimgs"
317
+ result = json.loads(run("pdf_page_image.py", str(report_pdf), "--pages", "1-2",
318
+ "--dpi", "72", "--out-dir", str(out_dir)).stdout)
319
+ if _raster_available():
320
+ assert result["rendered"] is True
321
+ assert len(result["files"]) == 2
322
+ from PIL import Image
323
+ with Image.open(result["files"][0]) as img:
324
+ # A4 at 72 dpi is ~595x842 px
325
+ assert 500 < img.width < 700
326
+ else:
327
+ assert result["rendered"] is False
328
+ assert result["missing"]
329
+
330
+
331
+ def test_page_image_bad_range(report_pdf: Path, workdir: Path):
332
+ if not _raster_available():
333
+ pytest.skip("no rasterizer available")
334
+ run("pdf_page_image.py", str(report_pdf), "--pages", "9",
335
+ "--out-dir", str(workdir / "nope"), expect=4)
336
+
337
+
338
+ # --------------------------------------------------------------------- stamping
339
+
340
+ def test_stamp_text(report_pdf: Path, workdir: Path):
341
+ out = workdir / "stamp_text.pdf"
342
+ run("pdf_stamp.py", str(report_pdf), "-o", str(out),
343
+ "--text", "STAMPMARK77", "--x", "150", "--y", "500",
344
+ "--font-size", "30", "--color", "#cc0000", "--pages", "1")
345
+ data = json.loads(run("pdf_read.py", str(out), "--text").stdout)
346
+ assert "STAMPMARK77" in data["pages"][0]
347
+ assert "STAMPMARK77" not in data["pages"][1] # only page 1 stamped
348
+
349
+
350
+ def test_stamp_text_rotated_opacity(report_pdf: Path, workdir: Path):
351
+ out = workdir / "stamp_rot.pdf"
352
+ run("pdf_stamp.py", str(report_pdf), "-o", str(out),
353
+ "--text", "DRAFT", "--x", "150", "--y", "400", "--font-size", "60",
354
+ "--rotation", "45", "--opacity", "0.3")
355
+ # Rotated glyphs confuse pdfplumber's line grouping; verify via pypdf.
356
+ from pypdf import PdfReader
357
+ text = PdfReader(str(out)).pages[0].extract_text()
358
+ assert "DRAFT" in text
359
+
360
+
361
+ def test_stamp_image(report_pdf: Path, sample_image: Path, workdir: Path):
362
+ out = workdir / "stamp_img.pdf"
363
+ run("pdf_stamp.py", str(report_pdf), "-o", str(out),
364
+ "--image", str(sample_image), "--x", "400", "--y", "60",
365
+ "--width", "100", "--pages", "2")
366
+ from pypdf import PdfReader
367
+ before = PdfReader(str(report_pdf))
368
+ after = PdfReader(str(out))
369
+
370
+ def image_xobjects(page):
371
+ res = page.get("/Resources", {})
372
+ xo = res.get("/XObject")
373
+ if xo is None:
374
+ return 0
375
+ return sum(1 for k in xo if xo[k].get("/Subtype") == "/Image")
376
+
377
+ assert image_xobjects(after.pages[1]) > image_xobjects(before.pages[1])
378
+ assert image_xobjects(after.pages[0]) == image_xobjects(before.pages[0])
379
+
380
+
381
+ # --------------------------------------------------------- metadata + attachments
382
+
383
+ def test_meta_set_and_clear(report_pdf: Path, workdir: Path):
384
+ out = workdir / "meta_set.pdf"
385
+ run("pdf_meta.py", str(report_pdf), "--set-meta", "-o", str(out),
386
+ "--title", "Retitled Example", "--author", "example-author",
387
+ "--subject", "Testing", "--keywords", "alpha, beta")
388
+ meta = json.loads(run("pdf_read.py", str(out), "--meta").stdout)["metadata"]
389
+ assert meta["Title"] == "Retitled Example"
390
+ assert meta["Author"] == "example-author"
391
+ assert meta["Subject"] == "Testing"
392
+ assert meta["Keywords"] == "alpha, beta"
393
+
394
+ cleared = workdir / "meta_clear.pdf"
395
+ run("pdf_meta.py", str(out), "--clear-meta", "-o", str(cleared))
396
+ meta = json.loads(run("pdf_read.py", str(cleared), "--meta").stdout)["metadata"]
397
+ assert "Title" not in meta or meta.get("Title") in ("", None)
398
+
399
+
400
+ def test_attachments_roundtrip(report_pdf: Path, workdir: Path):
401
+ payload = workdir / "payload.txt"
402
+ payload.write_text("attachment payload UNIQUEATTACH99\n", encoding="utf-8")
403
+ with_att = workdir / "with_att.pdf"
404
+ run("pdf_meta.py", str(report_pdf), "--attach", str(payload), "-o", str(with_att))
405
+
406
+ listing = json.loads(run("pdf_meta.py", str(with_att), "--list-attachments").stdout)
407
+ assert listing["attachment_count"] == 1
408
+ assert listing["attachments"] == ["payload.txt"]
409
+
410
+ ext_dir = workdir / "extracted"
411
+ result = json.loads(run("pdf_meta.py", str(with_att),
412
+ "--extract-attachments", str(ext_dir)).stdout)
413
+ assert len(result["extracted"]) == 1
414
+ extracted = Path(result["extracted"][0])
415
+ assert "UNIQUEATTACH99" in extracted.read_text(encoding="utf-8")