@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.
- 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 +132 -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 +309 -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 +256 -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,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT License. Part of the Hermes docx skill.
|
|
3
|
+
"""Inspect and resolve tracked changes (w:ins / w:del) in a .docx.
|
|
4
|
+
|
|
5
|
+
Subcommands:
|
|
6
|
+
list JSON list of revisions: id, author, date, type, text
|
|
7
|
+
accept-all accept every insertion and deletion
|
|
8
|
+
reject-all reject every insertion and deletion
|
|
9
|
+
accept accept one revision by --id
|
|
10
|
+
reject reject one revision by --id
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
docx_revisions.py list report.docx
|
|
14
|
+
docx_revisions.py accept-all report.docx -o accepted.docx
|
|
15
|
+
docx_revisions.py reject report.docx --id 3 -o out.docx
|
|
16
|
+
|
|
17
|
+
Semantics (direct XML manipulation, python-docx oxml layer):
|
|
18
|
+
accept w:ins -> unwrap (keep inserted runs) reject w:ins -> remove
|
|
19
|
+
accept w:del -> remove reject w:del -> restore
|
|
20
|
+
(restore = w:delText tags renamed to w:t, wrapper unwrapped)
|
|
21
|
+
|
|
22
|
+
Covers run-level insertions/deletions anywhere in body, tables (nested
|
|
23
|
+
included), headers and footers. Row/paragraph-mark revisions and format
|
|
24
|
+
changes (w:rPrChange etc.) are reported by docx_read.py --revisions but
|
|
25
|
+
not resolved here.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
from docx import Document
|
|
34
|
+
|
|
35
|
+
from docx_common import iter_part_roots
|
|
36
|
+
|
|
37
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def q(tag: str) -> str:
|
|
41
|
+
return f"{{{W}}}{tag}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
INS, DEL = q("ins"), q("del")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _iter_revision_elements(doc):
|
|
48
|
+
"""Yield every w:ins / w:del element across body, headers, footers."""
|
|
49
|
+
for root in iter_part_roots(doc):
|
|
50
|
+
yield from root.iter(INS, DEL)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rev_text(el) -> str:
|
|
54
|
+
tag = q("delText") if el.tag == DEL else q("t")
|
|
55
|
+
return "".join(t.text or "" for t in el.iter(tag))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rev_record(el) -> dict:
|
|
59
|
+
return {
|
|
60
|
+
"id": el.get(q("id")),
|
|
61
|
+
"author": el.get(q("author")),
|
|
62
|
+
"date": el.get(q("date")),
|
|
63
|
+
"type": "insertion" if el.tag == INS else "deletion",
|
|
64
|
+
"text": _rev_text(el),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _unwrap(el) -> None:
|
|
69
|
+
"""Replace `el` with its children, keeping document order."""
|
|
70
|
+
parent = el.getparent()
|
|
71
|
+
idx = list(parent).index(el)
|
|
72
|
+
for child in list(el):
|
|
73
|
+
parent.insert(idx, child)
|
|
74
|
+
idx += 1
|
|
75
|
+
parent.remove(el)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _apply(el, accept: bool) -> None:
|
|
79
|
+
if el.getparent() is None: # already detached via an outer wrapper
|
|
80
|
+
return
|
|
81
|
+
if el.tag == INS:
|
|
82
|
+
if accept:
|
|
83
|
+
_unwrap(el)
|
|
84
|
+
else:
|
|
85
|
+
el.getparent().remove(el)
|
|
86
|
+
else: # w:del
|
|
87
|
+
if accept:
|
|
88
|
+
el.getparent().remove(el)
|
|
89
|
+
else:
|
|
90
|
+
for dt in list(el.iter(q("delText"))):
|
|
91
|
+
dt.tag = q("t")
|
|
92
|
+
_unwrap(el)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve(doc, accept: bool, rev_id: str | None = None) -> int:
|
|
96
|
+
targets = [el for el in _iter_revision_elements(doc)
|
|
97
|
+
if rev_id is None or el.get(q("id")) == rev_id]
|
|
98
|
+
for el in targets:
|
|
99
|
+
_apply(el, accept)
|
|
100
|
+
return len(targets)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main() -> int:
|
|
104
|
+
ap = argparse.ArgumentParser(
|
|
105
|
+
description="List, accept, or reject tracked changes in a .docx.")
|
|
106
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
107
|
+
|
|
108
|
+
def common(p, out=True):
|
|
109
|
+
p.add_argument("path", help="input .docx")
|
|
110
|
+
if out:
|
|
111
|
+
p.add_argument("-o", "--output",
|
|
112
|
+
help="output path (default: overwrite input)")
|
|
113
|
+
|
|
114
|
+
common(sub.add_parser("list", help="list revisions as JSON"), out=False)
|
|
115
|
+
common(sub.add_parser("accept-all", help="accept every revision"))
|
|
116
|
+
common(sub.add_parser("reject-all", help="reject every revision"))
|
|
117
|
+
for name in ("accept", "reject"):
|
|
118
|
+
p = sub.add_parser(name, help=f"{name} one revision by id")
|
|
119
|
+
common(p)
|
|
120
|
+
p.add_argument("--id", required=True, help="revision id (w:id)")
|
|
121
|
+
|
|
122
|
+
args = ap.parse_args()
|
|
123
|
+
doc = Document(args.path)
|
|
124
|
+
|
|
125
|
+
if args.cmd == "list":
|
|
126
|
+
revs = [_rev_record(el) for el in _iter_revision_elements(doc)]
|
|
127
|
+
print(json.dumps({"ok": True, "revisions": revs}, ensure_ascii=False))
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
accept = args.cmd in ("accept-all", "accept")
|
|
131
|
+
rev_id = getattr(args, "id", None)
|
|
132
|
+
n = resolve(doc, accept, rev_id)
|
|
133
|
+
if rev_id is not None and n == 0:
|
|
134
|
+
print(json.dumps({"ok": False,
|
|
135
|
+
"error": f"no revision with id {rev_id}"}))
|
|
136
|
+
return 1
|
|
137
|
+
out = args.output or args.path
|
|
138
|
+
doc.save(out)
|
|
139
|
+
print(json.dumps({"ok": True, "output": out, "resolved": n,
|
|
140
|
+
"action": "accept" if accept else "reject"},
|
|
141
|
+
ensure_ascii=False))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
sys.exit(main())
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT License. Part of the Hermes docx skill.
|
|
3
|
+
"""Fill {{placeholder}} tokens in a .docx from a JSON mapping.
|
|
4
|
+
|
|
5
|
+
Tokens are replaced everywhere: body paragraphs, tables (including nested
|
|
6
|
+
tables), headers and footers. Run formatting is preserved; tokens split
|
|
7
|
+
across runs are handled.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
docx_template.py template.docx values.json output.docx
|
|
11
|
+
docx_template.py template.docx values.json output.docx --strict
|
|
12
|
+
|
|
13
|
+
values.json: {"name": "Ada", "date": "2026-01-01"} fills {{name}}, {{date}}.
|
|
14
|
+
With --strict, exits 1 if any {{token}} remains unfilled after processing.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
from docx import Document
|
|
24
|
+
|
|
25
|
+
from docx_common import iter_all_paragraphs, replace_in_paragraph
|
|
26
|
+
|
|
27
|
+
TOKEN_RE = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
ap = argparse.ArgumentParser(
|
|
32
|
+
description="Fill {{token}} placeholders in a .docx from JSON.")
|
|
33
|
+
ap.add_argument("template", help="input .docx with {{tokens}}")
|
|
34
|
+
ap.add_argument("values", help="JSON file of token -> value")
|
|
35
|
+
ap.add_argument("output", help="output .docx path")
|
|
36
|
+
ap.add_argument("--strict", action="store_true",
|
|
37
|
+
help="fail if any token remains unfilled")
|
|
38
|
+
args = ap.parse_args()
|
|
39
|
+
|
|
40
|
+
with open(args.values, encoding="utf-8") as f:
|
|
41
|
+
values = json.load(f)
|
|
42
|
+
|
|
43
|
+
doc = Document(args.template)
|
|
44
|
+
filled = {}
|
|
45
|
+
for para in iter_all_paragraphs(doc):
|
|
46
|
+
# Normalize whitespace variants like {{ name }} first.
|
|
47
|
+
for m in set(TOKEN_RE.findall(para.text)):
|
|
48
|
+
if m in values:
|
|
49
|
+
# Replace any spacing variant with canonical token, then fill.
|
|
50
|
+
for variant in set(
|
|
51
|
+
t.group(0) for t in TOKEN_RE.finditer(para.text)
|
|
52
|
+
if t.group(1) == m):
|
|
53
|
+
n = replace_in_paragraph(para, variant, str(values[m]))
|
|
54
|
+
filled[m] = filled.get(m, 0) + n
|
|
55
|
+
|
|
56
|
+
remaining = sorted({m for para in iter_all_paragraphs(doc)
|
|
57
|
+
for m in TOKEN_RE.findall(para.text)})
|
|
58
|
+
doc.save(args.output)
|
|
59
|
+
result = {"ok": True, "output": args.output, "filled": filled,
|
|
60
|
+
"unfilled_tokens": remaining}
|
|
61
|
+
if args.strict and remaining:
|
|
62
|
+
result["ok"] = False
|
|
63
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
64
|
+
return 1
|
|
65
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
sys.exit(main())
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# MIT License. Part of the Hermes docx skill.
|
|
3
|
+
"""Health-check a .docx package and report issues as JSON.
|
|
4
|
+
|
|
5
|
+
Usage: docx_validate.py file.docx
|
|
6
|
+
|
|
7
|
+
Checks (health-check tier, NOT full XSD schema validation):
|
|
8
|
+
- the file is a readable zip and python-docx can open it
|
|
9
|
+
- required package parts exist ([Content_Types].xml, document.xml)
|
|
10
|
+
- every relationship in every .rels file resolves to a part in the
|
|
11
|
+
package (dangling image/hyperlink/etc. rels are reported; external
|
|
12
|
+
targets such as hyperlinks are skipped)
|
|
13
|
+
- r:embed / r:id references in document.xml resolve to relationships
|
|
14
|
+
- embedded images are non-empty and start with known magic bytes
|
|
15
|
+
(PNG/JPEG/GIF/BMP/TIFF/EMF/WMF/SVG); no PIL required
|
|
16
|
+
- paragraph and run style ids referenced by the document exist in
|
|
17
|
+
styles.xml
|
|
18
|
+
|
|
19
|
+
Output: {"ok": bool, "issues": [{"severity": "error"|"warning", ...}]}
|
|
20
|
+
Exit code 1 when any error-severity issue is found (warnings exit 0).
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import posixpath
|
|
27
|
+
import sys
|
|
28
|
+
import zipfile
|
|
29
|
+
|
|
30
|
+
from lxml import etree
|
|
31
|
+
|
|
32
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
33
|
+
R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
|
34
|
+
PR = "http://schemas.openxmlformats.org/package/2006/relationships"
|
|
35
|
+
|
|
36
|
+
IMAGE_MAGIC = (
|
|
37
|
+
b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a",
|
|
38
|
+
b"BM", b"II*\x00", b"MM\x00*",
|
|
39
|
+
b"\x01\x00\x00\x00", # EMF
|
|
40
|
+
b"\xd7\xcd\xc6\x9a", b"\x01\x00\x09\x00", # WMF variants
|
|
41
|
+
b"<?xml", b"<svg",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _issue(issues, severity, code, detail):
|
|
46
|
+
issues.append({"severity": severity, "code": code, "detail": detail})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _rel_target(base_part: str, target: str) -> str:
|
|
50
|
+
base_dir = posixpath.dirname(base_part)
|
|
51
|
+
return posixpath.normpath(posixpath.join(base_dir, target)).lstrip("/")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate(path: str) -> dict:
|
|
55
|
+
issues: list[dict] = []
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
zf = zipfile.ZipFile(path)
|
|
59
|
+
except (OSError, zipfile.BadZipFile) as exc:
|
|
60
|
+
_issue(issues, "error", "not-a-zip", str(exc))
|
|
61
|
+
return {"ok": False, "issues": issues}
|
|
62
|
+
|
|
63
|
+
names = set(zf.namelist())
|
|
64
|
+
bad = zf.testzip()
|
|
65
|
+
if bad is not None:
|
|
66
|
+
_issue(issues, "error", "corrupt-member", f"CRC check failed: {bad}")
|
|
67
|
+
|
|
68
|
+
for required in ("[Content_Types].xml", "word/document.xml"):
|
|
69
|
+
if required not in names:
|
|
70
|
+
_issue(issues, "error", "missing-part",
|
|
71
|
+
f"required part absent: {required}")
|
|
72
|
+
if issues and any(i["severity"] == "error" for i in issues):
|
|
73
|
+
return {"ok": False, "issues": issues}
|
|
74
|
+
|
|
75
|
+
# --- relationships resolve ------------------------------------------
|
|
76
|
+
rel_ids_by_source: dict[str, dict] = {}
|
|
77
|
+
for rels_name in [n for n in names if n.endswith(".rels")]:
|
|
78
|
+
try:
|
|
79
|
+
root = etree.fromstring(zf.read(rels_name))
|
|
80
|
+
except etree.XMLSyntaxError as exc:
|
|
81
|
+
_issue(issues, "error", "bad-rels-xml", f"{rels_name}: {exc}")
|
|
82
|
+
continue
|
|
83
|
+
source_part = posixpath.normpath(
|
|
84
|
+
posixpath.join(posixpath.dirname(rels_name), ".."))
|
|
85
|
+
source_part = "" if source_part == "." else source_part
|
|
86
|
+
ids = {}
|
|
87
|
+
for rel in root.iter(f"{{{PR}}}Relationship"):
|
|
88
|
+
rid, target = rel.get("Id"), rel.get("Target", "")
|
|
89
|
+
mode = rel.get("TargetMode", "Internal")
|
|
90
|
+
ids[rid] = target
|
|
91
|
+
if mode == "External":
|
|
92
|
+
continue
|
|
93
|
+
resolved = _rel_target(source_part + "/x" if source_part
|
|
94
|
+
else "x", target)
|
|
95
|
+
if resolved not in names:
|
|
96
|
+
_issue(issues, "error", "dangling-rel",
|
|
97
|
+
f"{rels_name}: {rid} -> {target} (missing part)")
|
|
98
|
+
rel_ids_by_source[source_part or "_package"] = ids
|
|
99
|
+
|
|
100
|
+
# --- r:id / r:embed references in document.xml -----------------------
|
|
101
|
+
doc_root = etree.fromstring(zf.read("word/document.xml"))
|
|
102
|
+
doc_rels = rel_ids_by_source.get("word", {})
|
|
103
|
+
for el in doc_root.iter():
|
|
104
|
+
for attr in (f"{{{R}}}id", f"{{{R}}}embed", f"{{{R}}}link"):
|
|
105
|
+
rid = el.get(attr)
|
|
106
|
+
if rid and rid not in doc_rels:
|
|
107
|
+
_issue(issues, "error", "unresolved-reference",
|
|
108
|
+
f"document.xml references {rid} with no relationship")
|
|
109
|
+
|
|
110
|
+
# --- embedded images decode ------------------------------------------
|
|
111
|
+
for name in [n for n in names if n.startswith("word/media/")]:
|
|
112
|
+
data = zf.read(name)
|
|
113
|
+
if not data:
|
|
114
|
+
_issue(issues, "error", "empty-image", name)
|
|
115
|
+
elif not any(data.startswith(m) for m in IMAGE_MAGIC):
|
|
116
|
+
_issue(issues, "warning", "unknown-image-format",
|
|
117
|
+
f"{name}: unrecognized magic bytes")
|
|
118
|
+
|
|
119
|
+
# --- styles referenced exist ------------------------------------------
|
|
120
|
+
defined = set()
|
|
121
|
+
if "word/styles.xml" in names:
|
|
122
|
+
styles_root = etree.fromstring(zf.read("word/styles.xml"))
|
|
123
|
+
defined = {s.get(f"{{{W}}}styleId")
|
|
124
|
+
for s in styles_root.iter(f"{{{W}}}style")}
|
|
125
|
+
for tag, attr in ((f"{{{W}}}pStyle", f"{{{W}}}val"),
|
|
126
|
+
(f"{{{W}}}rStyle", f"{{{W}}}val"),
|
|
127
|
+
(f"{{{W}}}tblStyle", f"{{{W}}}val")):
|
|
128
|
+
for el in doc_root.iter(tag):
|
|
129
|
+
sid = el.get(attr)
|
|
130
|
+
if sid and sid not in defined:
|
|
131
|
+
_issue(issues, "error", "missing-style",
|
|
132
|
+
f"style id referenced but not defined: {sid}")
|
|
133
|
+
|
|
134
|
+
# --- python-docx can open it ------------------------------------------
|
|
135
|
+
try:
|
|
136
|
+
from docx import Document
|
|
137
|
+
Document(path)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
_issue(issues, "error", "python-docx-open-failed", str(exc))
|
|
140
|
+
|
|
141
|
+
ok = not any(i["severity"] == "error" for i in issues)
|
|
142
|
+
return {"ok": ok, "issues": issues}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> int:
|
|
146
|
+
ap = argparse.ArgumentParser(
|
|
147
|
+
description="Health-check a .docx (not XSD schema validation).")
|
|
148
|
+
ap.add_argument("path", help="the .docx file to check")
|
|
149
|
+
args = ap.parse_args()
|
|
150
|
+
report = validate(args.path)
|
|
151
|
+
print(json.dumps(report, ensure_ascii=False))
|
|
152
|
+
return 0 if report["ok"] else 1
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
if __name__ == "__main__":
|
|
156
|
+
sys.exit(main())
|
|
@@ -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())
|