@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,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")
|