@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.
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 +128 -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 +305 -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 +248 -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,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())