@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,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build a deck from a .pptx template (brand deck) and fill placeholders.
|
|
3
|
+
|
|
4
|
+
Two modes:
|
|
5
|
+
1) Token fill (default): open TEMPLATE, replace every {{token}} across
|
|
6
|
+
slides, tables, and notes using --values JSON ({"token": "value"}),
|
|
7
|
+
save to OUTPUT. Formatting of the token's run is preserved.
|
|
8
|
+
2) --add-slides SPEC.json: additionally append slides built from the
|
|
9
|
+
template's own layouts (referenced by layout name or index), so new
|
|
10
|
+
slides inherit the brand master. Spec:
|
|
11
|
+
{"slides": [{"layout": "Title and Content", "title": "New",
|
|
12
|
+
"bullets": ["a", {"text": "b", "level": 1}],
|
|
13
|
+
"notes": "presenter text"}]}
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from pptx import Presentation
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fill_tokens(prs, values):
|
|
23
|
+
from pptx_edit import replace_text # same scripts/ directory
|
|
24
|
+
total = 0
|
|
25
|
+
for token, value in values.items():
|
|
26
|
+
total += replace_text(prs, f"{{{{{token}}}}}", str(value))
|
|
27
|
+
return total
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_layout(prs, ref):
|
|
31
|
+
if isinstance(ref, int):
|
|
32
|
+
return prs.slide_layouts[ref]
|
|
33
|
+
for layout in prs.slide_layouts:
|
|
34
|
+
if layout.name == ref:
|
|
35
|
+
return layout
|
|
36
|
+
raise SystemExit(f"layout {ref!r} not found; available: "
|
|
37
|
+
f"{[la.name for la in prs.slide_layouts]}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def add_slides(prs, spec):
|
|
41
|
+
from pptx_create import add_bullets
|
|
42
|
+
for slide_spec in spec.get("slides", []):
|
|
43
|
+
layout = find_layout(prs, slide_spec.get("layout", 1))
|
|
44
|
+
slide = prs.slides.add_slide(layout)
|
|
45
|
+
if slide_spec.get("title") is not None and slide.shapes.title:
|
|
46
|
+
slide.shapes.title.text = slide_spec["title"]
|
|
47
|
+
if slide_spec.get("bullets"):
|
|
48
|
+
body = next((ph for ph in slide.placeholders
|
|
49
|
+
if ph.placeholder_format.idx != 0), None)
|
|
50
|
+
if body is not None:
|
|
51
|
+
add_bullets(body.text_frame, slide_spec["bullets"])
|
|
52
|
+
if slide_spec.get("notes"):
|
|
53
|
+
slide.notes_slide.notes_text_frame.text = slide_spec["notes"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv=None):
|
|
57
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
58
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
description="Fill {{tokens}} in a .pptx template and optionally "
|
|
61
|
+
"append slides using the template's own layouts.",
|
|
62
|
+
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
63
|
+
parser.add_argument("template", help="path to the template .pptx")
|
|
64
|
+
parser.add_argument("output", help="output .pptx path")
|
|
65
|
+
parser.add_argument("--values", metavar="JSON",
|
|
66
|
+
help="JSON file mapping token -> replacement value")
|
|
67
|
+
parser.add_argument("--add-slides", metavar="SPEC_JSON",
|
|
68
|
+
help="JSON spec of slides to append")
|
|
69
|
+
args = parser.parse_args(argv)
|
|
70
|
+
|
|
71
|
+
prs = Presentation(args.template)
|
|
72
|
+
filled = 0
|
|
73
|
+
if args.values:
|
|
74
|
+
with open(args.values, encoding="utf-8") as fh:
|
|
75
|
+
filled = fill_tokens(prs, json.load(fh))
|
|
76
|
+
if args.add_slides:
|
|
77
|
+
with open(args.add_slides, encoding="utf-8") as fh:
|
|
78
|
+
add_slides(prs, json.load(fh))
|
|
79
|
+
prs.save(args.output)
|
|
80
|
+
print(json.dumps({"ok": True, "output": args.output,
|
|
81
|
+
"tokens_filled": filled}))
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
import os
|
|
87
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
88
|
+
sys.exit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read a .pptx file: JSON outline, notes, or export embedded images.
|
|
3
|
+
|
|
4
|
+
Modes:
|
|
5
|
+
--outline JSON with per-slide layout, texts, tables, notes,
|
|
6
|
+
chart data, and image inventory (default mode).
|
|
7
|
+
--notes JSON list of speaker notes per slide.
|
|
8
|
+
--images DIR Export every embedded picture to DIR as files.
|
|
9
|
+
"""
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from pptx import Presentation
|
|
16
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
17
|
+
from pptx.util import Emu
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def iter_shapes(shapes):
|
|
21
|
+
"""Yield shapes, descending into groups."""
|
|
22
|
+
for shape in shapes:
|
|
23
|
+
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
24
|
+
yield from iter_shapes(shape.shapes)
|
|
25
|
+
else:
|
|
26
|
+
yield shape
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def chart_info(chart):
|
|
30
|
+
info = {"type": str(chart.chart_type),
|
|
31
|
+
"categories": [str(c) for c in chart.plots[0].categories],
|
|
32
|
+
"series": []}
|
|
33
|
+
for plot in chart.plots:
|
|
34
|
+
for series in plot.series:
|
|
35
|
+
try:
|
|
36
|
+
name = series.name
|
|
37
|
+
except (AttributeError, KeyError):
|
|
38
|
+
name = None
|
|
39
|
+
info["series"].append({"name": name,
|
|
40
|
+
"values": list(series.values)})
|
|
41
|
+
return info
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def slide_record(index, slide):
|
|
45
|
+
rec = {"index": index, "layout": slide.slide_layout.name,
|
|
46
|
+
"texts": [], "tables": [], "images": [], "charts": [],
|
|
47
|
+
"notes": None}
|
|
48
|
+
for shape in iter_shapes(slide.shapes):
|
|
49
|
+
if shape.has_text_frame and shape.text_frame.text.strip():
|
|
50
|
+
rec["texts"].append(shape.text_frame.text)
|
|
51
|
+
if shape.has_table:
|
|
52
|
+
rec["tables"].append(
|
|
53
|
+
[[cell.text for cell in row.cells]
|
|
54
|
+
for row in shape.table.rows])
|
|
55
|
+
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
56
|
+
try:
|
|
57
|
+
img = shape.image
|
|
58
|
+
rec["images"].append({"filename": img.filename,
|
|
59
|
+
"ext": img.ext,
|
|
60
|
+
"size_bytes": len(img.blob)})
|
|
61
|
+
except (KeyError, ValueError):
|
|
62
|
+
rec["images"].append({"filename": None, "ext": None,
|
|
63
|
+
"size_bytes": None,
|
|
64
|
+
"note": "linked or unreadable"})
|
|
65
|
+
if shape.has_chart:
|
|
66
|
+
rec["charts"].append(chart_info(shape.chart))
|
|
67
|
+
if slide.has_notes_slide:
|
|
68
|
+
rec["notes"] = slide.notes_slide.notes_text_frame.text
|
|
69
|
+
return rec
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def export_images(prs, out_dir):
|
|
73
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
74
|
+
written = []
|
|
75
|
+
for i, slide in enumerate(prs.slides):
|
|
76
|
+
for j, shape in enumerate(iter_shapes(slide.shapes)):
|
|
77
|
+
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
img = shape.image
|
|
81
|
+
except (KeyError, ValueError):
|
|
82
|
+
continue
|
|
83
|
+
path = os.path.join(out_dir, f"slide{i}_img{j}.{img.ext}")
|
|
84
|
+
with open(path, "wb") as fh:
|
|
85
|
+
fh.write(img.blob)
|
|
86
|
+
written.append(path)
|
|
87
|
+
return written
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(argv=None):
|
|
91
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
92
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
93
|
+
parser = argparse.ArgumentParser(
|
|
94
|
+
description="Read a .pptx: outline/notes as JSON, export images.")
|
|
95
|
+
parser.add_argument("pptx", help="path to the .pptx file")
|
|
96
|
+
parser.add_argument("--outline", action="store_true",
|
|
97
|
+
help="print full JSON outline (default)")
|
|
98
|
+
parser.add_argument("--notes", action="store_true",
|
|
99
|
+
help="print speaker notes only")
|
|
100
|
+
parser.add_argument("--images", metavar="DIR",
|
|
101
|
+
help="export embedded images into DIR")
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
|
|
104
|
+
prs = Presentation(args.pptx)
|
|
105
|
+
|
|
106
|
+
if args.images:
|
|
107
|
+
written = export_images(prs, args.images)
|
|
108
|
+
print(json.dumps({"ok": True, "exported": written}, indent=2))
|
|
109
|
+
return 0
|
|
110
|
+
if args.notes:
|
|
111
|
+
notes = [slide.notes_slide.notes_text_frame.text
|
|
112
|
+
if slide.has_notes_slide else None
|
|
113
|
+
for slide in prs.slides]
|
|
114
|
+
print(json.dumps({"ok": True, "notes": notes},
|
|
115
|
+
indent=2, ensure_ascii=True))
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
outline = {
|
|
119
|
+
"ok": True,
|
|
120
|
+
"slide_size_inches": [round(Emu(prs.slide_width).inches, 3),
|
|
121
|
+
round(Emu(prs.slide_height).inches, 3)],
|
|
122
|
+
"slide_count": len(prs.slides._sldIdLst),
|
|
123
|
+
"layouts_available": [lay.name for lay in prs.slide_layouts],
|
|
124
|
+
"slides": [slide_record(i, s) for i, s in enumerate(prs.slides)],
|
|
125
|
+
}
|
|
126
|
+
print(json.dumps(outline, indent=2, ensure_ascii=True))
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render every slide of a .pptx to per-slide PNG images.
|
|
3
|
+
|
|
4
|
+
Pipeline: LibreOffice (soffice --headless --convert-to pdf) turns the deck
|
|
5
|
+
into a PDF, then poppler (pdftoppm, or pdftocairo as an alternate) splits
|
|
6
|
+
the PDF into one PNG per slide.
|
|
7
|
+
|
|
8
|
+
Output is JSON. When both tools are present:
|
|
9
|
+
{"rendered": true, "files": ["render/slide-1.png", ...]}
|
|
10
|
+
When either tool is missing the script still exits 0 and reports:
|
|
11
|
+
{"rendered": false, "missing": ["soffice"], "guidance": "..."}
|
|
12
|
+
so callers can degrade gracefully (fall back to pptx_read.py --outline).
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import glob
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
|
+
from render import find_soffice # also finds the standard Windows/macOS install, not only PATH
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_tools():
|
|
28
|
+
"""Return (soffice, splitter, missing); soffice discovery is shared with render.py."""
|
|
29
|
+
soffice = find_soffice()
|
|
30
|
+
splitter = shutil.which("pdftoppm") or shutil.which("pdftocairo")
|
|
31
|
+
missing = []
|
|
32
|
+
if not soffice:
|
|
33
|
+
missing.append("soffice")
|
|
34
|
+
if not splitter:
|
|
35
|
+
missing.append("pdftoppm (or pdftocairo)")
|
|
36
|
+
return soffice, splitter, missing
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render(pptx_path, out_dir, prefix, dpi):
|
|
40
|
+
soffice, splitter, missing = find_tools()
|
|
41
|
+
if missing:
|
|
42
|
+
return {
|
|
43
|
+
"rendered": False, "missing": missing,
|
|
44
|
+
"guidance": "Install LibreOffice (soffice) and poppler-utils "
|
|
45
|
+
"(pdftoppm/pdftocairo) to render slides. Without "
|
|
46
|
+
"them, verify decks with pptx_read.py --outline "
|
|
47
|
+
"instead.",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
51
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
52
|
+
proc = subprocess.run(
|
|
53
|
+
[soffice, "--headless", "--convert-to", "pdf",
|
|
54
|
+
"--outdir", tmp, pptx_path],
|
|
55
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
56
|
+
errors="replace", timeout=300, check=False)
|
|
57
|
+
pdfs = glob.glob(os.path.join(tmp, "*.pdf"))
|
|
58
|
+
if proc.returncode != 0 or not pdfs:
|
|
59
|
+
raise SystemExit(f"soffice PDF conversion failed: {proc.stderr}")
|
|
60
|
+
pdf = pdfs[0]
|
|
61
|
+
out_prefix = os.path.join(out_dir, prefix)
|
|
62
|
+
proc = subprocess.run(
|
|
63
|
+
[splitter, "-png", "-r", str(dpi), pdf, out_prefix],
|
|
64
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
65
|
+
errors="replace", timeout=300, check=False)
|
|
66
|
+
if proc.returncode != 0:
|
|
67
|
+
raise SystemExit(f"{os.path.basename(splitter)} failed: "
|
|
68
|
+
f"{proc.stderr}")
|
|
69
|
+
|
|
70
|
+
files = sorted(glob.glob(out_prefix + "*.png"))
|
|
71
|
+
return {"rendered": True, "files": files}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main(argv=None):
|
|
75
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
76
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
77
|
+
parser = argparse.ArgumentParser(
|
|
78
|
+
description="Render each slide of a .pptx to a PNG via "
|
|
79
|
+
"soffice + pdftoppm/pdftocairo.",
|
|
80
|
+
epilog=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
81
|
+
parser.add_argument("pptx", help="path to the .pptx file")
|
|
82
|
+
parser.add_argument("--outdir", default="render",
|
|
83
|
+
help="directory for PNGs (default: ./render)")
|
|
84
|
+
parser.add_argument("--prefix", default="slide",
|
|
85
|
+
help="PNG filename prefix (default: slide)")
|
|
86
|
+
parser.add_argument("--dpi", type=int, default=100,
|
|
87
|
+
help="render resolution (default: 100)")
|
|
88
|
+
args = parser.parse_args(argv)
|
|
89
|
+
|
|
90
|
+
result = render(args.pptx, args.outdir, args.prefix, args.dpi)
|
|
91
|
+
print(json.dumps(result, indent=2))
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
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())
|