dsh-office-kit 0.0.0-stage → 0.2.0
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/CAPABILITY.md +75 -0
- package/LICENSE.md +21 -0
- package/README.md +105 -2
- package/assets/icons/LICENSE +21 -0
- package/assets/icons/README.md +9 -0
- package/assets/icons/comment-fill.svg +1 -0
- package/assets/icons/display.svg +1 -0
- package/assets/icons/file-letter-p.svg +1 -0
- package/assets/icons/file-text.svg +1 -0
- package/assets/icons/layout-cells.svg +1 -0
- package/assets/skills/office-documents/SKILL.md +101 -0
- package/assets/skills/office-pdf/SKILL.md +86 -0
- package/assets/skills/office-presentations/SKILL.md +110 -0
- package/assets/skills/office-spreadsheets/SKILL.md +110 -0
- package/cordis.patch.yml +3 -0
- package/dist/client.js +267 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +408 -0
- package/package.json +143 -3
- package/postinstall.mjs +363 -0
- package/python/office_cli.py +1355 -0
- package/python/requirements.txt +10 -0
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""DSH Office Suite helper CLI.
|
|
3
|
+
|
|
4
|
+
Creates, reads, edits, recalculates, renders, and verifies DOCX, PDF, PPTX,
|
|
5
|
+
and XLSX files. Every command prints one JSON object on stdout. Failures print
|
|
6
|
+
one JSON object on stderr and exit non-zero.
|
|
7
|
+
|
|
8
|
+
The CLI is deliberately a thin, deterministic layer over well-known Python
|
|
9
|
+
libraries. It never calls an external model and never fetches remote services.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Iterable
|
|
23
|
+
|
|
24
|
+
VERSION = "0.1.0"
|
|
25
|
+
|
|
26
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
27
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
28
|
+
try:
|
|
29
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
30
|
+
except (AttributeError, ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def emit(value: Any, out: str | None = None) -> None:
|
|
35
|
+
text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
36
|
+
if out:
|
|
37
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
Path(out).write_text(text + "\n", encoding="utf-8")
|
|
39
|
+
print(text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def fail(message: str, *, code: int = 1, detail: Any = None) -> "NoReturn":
|
|
43
|
+
payload: dict[str, Any] = {"ok": False, "error": message}
|
|
44
|
+
if detail is not None:
|
|
45
|
+
payload["detail"] = detail
|
|
46
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str), file=sys.stderr)
|
|
47
|
+
raise SystemExit(code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_json(path: str) -> dict[str, Any]:
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
fail(f"specification not found: {path}")
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
fail(f"invalid JSON specification: {exc}")
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
fail("specification must be a JSON object")
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def require_file(path: str, what: str = "file") -> Path:
|
|
63
|
+
resolved = Path(path).expanduser()
|
|
64
|
+
if not resolved.is_file():
|
|
65
|
+
fail(f"{what} not found: {path}")
|
|
66
|
+
return resolved
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_parent(path: str) -> Path:
|
|
70
|
+
resolved = Path(path).expanduser()
|
|
71
|
+
resolved.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
return resolved
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Shared rendering helpers
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def find_soffice() -> str | None:
|
|
81
|
+
candidates = [
|
|
82
|
+
os.environ.get("SOFFICE"),
|
|
83
|
+
r"C:\Program Files\LibreOffice\program\soffice.exe",
|
|
84
|
+
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
|
85
|
+
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
|
|
86
|
+
"/usr/bin/soffice",
|
|
87
|
+
"/usr/local/bin/soffice",
|
|
88
|
+
]
|
|
89
|
+
for candidate in candidates:
|
|
90
|
+
if candidate and Path(candidate).is_file():
|
|
91
|
+
return candidate
|
|
92
|
+
found = shutil.which("soffice")
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def office_binary(kind: str) -> str | None:
|
|
97
|
+
if sys.platform != "win32":
|
|
98
|
+
return None
|
|
99
|
+
names = {"docx": "WINWORD.EXE", "pptx": "POWERPNT.EXE", "xlsx": "EXCEL.EXE"}
|
|
100
|
+
name = names[kind]
|
|
101
|
+
roots = [
|
|
102
|
+
r"C:\Program Files\Microsoft Office\root\Office16",
|
|
103
|
+
r"C:\Program Files (x86)\Microsoft Office\root\Office16",
|
|
104
|
+
r"C:\Program Files\Microsoft Office\Office16",
|
|
105
|
+
r"C:\Program Files (x86)\Microsoft Office\Office16",
|
|
106
|
+
]
|
|
107
|
+
for root in roots:
|
|
108
|
+
candidate = Path(root) / name
|
|
109
|
+
if candidate.is_file():
|
|
110
|
+
return str(candidate)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _powershell() -> str | None:
|
|
115
|
+
return shutil.which("powershell") or shutil.which("pwsh")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _office_script(kind: str) -> str:
|
|
119
|
+
if kind == "docx":
|
|
120
|
+
return r"""
|
|
121
|
+
$ErrorActionPreference = 'Stop'
|
|
122
|
+
$app = New-Object -ComObject Word.Application
|
|
123
|
+
$app.Visible = $false
|
|
124
|
+
$app.DisplayAlerts = 0
|
|
125
|
+
$doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
|
|
126
|
+
try { $doc.ExportAsFixedFormat($env:DSH_PDF, 17) } finally { $doc.Close(0); $app.Quit() }
|
|
127
|
+
"""
|
|
128
|
+
if kind == "pptx":
|
|
129
|
+
return r"""
|
|
130
|
+
$ErrorActionPreference = 'Stop'
|
|
131
|
+
$app = New-Object -ComObject PowerPoint.Application
|
|
132
|
+
$app.DisplayAlerts = 1
|
|
133
|
+
$pres = $app.Presentations.Open($env:DSH_INPUT, $true, $false, $false)
|
|
134
|
+
try { $pres.SaveAs($env:DSH_PDF, 32) } finally { $pres.Close(); $app.Quit() }
|
|
135
|
+
"""
|
|
136
|
+
return r"""
|
|
137
|
+
$ErrorActionPreference = 'Stop'
|
|
138
|
+
$app = New-Object -ComObject Excel.Application
|
|
139
|
+
$app.Visible = $false
|
|
140
|
+
$app.DisplayAlerts = $false
|
|
141
|
+
$wb = $app.Workbooks.Open($env:DSH_INPUT)
|
|
142
|
+
try { $wb.ExportAsFixedFormat(0, $env:DSH_PDF) } finally { $wb.Close($false); $app.Quit() }
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def export_pdf(input_path: Path, kind: str, out_dir: Path) -> tuple[str, str]:
|
|
147
|
+
"""Export an Office file to PDF. Returns (renderer, pdf path)."""
|
|
148
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
pdf = out_dir / (input_path.stem + ".pdf")
|
|
150
|
+
if pdf.exists():
|
|
151
|
+
pdf.unlink()
|
|
152
|
+
|
|
153
|
+
power_shell = _powershell()
|
|
154
|
+
if power_shell and office_binary(kind):
|
|
155
|
+
script = Path(tempfile.gettempdir()) / f"dsh-office-{kind}-{os.getpid()}.ps1"
|
|
156
|
+
script.write_text(_office_script(kind), encoding="utf-8")
|
|
157
|
+
env = dict(os.environ)
|
|
158
|
+
env["DSH_INPUT"] = str(input_path.resolve())
|
|
159
|
+
env["DSH_PDF"] = str(pdf.resolve())
|
|
160
|
+
try:
|
|
161
|
+
completed = subprocess.run(
|
|
162
|
+
[power_shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
|
163
|
+
capture_output=True,
|
|
164
|
+
text=True,
|
|
165
|
+
timeout=240,
|
|
166
|
+
env=env,
|
|
167
|
+
)
|
|
168
|
+
except subprocess.TimeoutExpired:
|
|
169
|
+
fail(f"Microsoft Office export timed out for {input_path.name}")
|
|
170
|
+
finally:
|
|
171
|
+
script.unlink(missing_ok=True)
|
|
172
|
+
if completed.returncode != 0 or not pdf.is_file():
|
|
173
|
+
detail = (completed.stderr or completed.stdout).strip()
|
|
174
|
+
fail(f"Microsoft Office export failed for {input_path.name}", detail=detail or None)
|
|
175
|
+
return "microsoft-office", str(pdf)
|
|
176
|
+
|
|
177
|
+
soffice = find_soffice()
|
|
178
|
+
if soffice:
|
|
179
|
+
completed = subprocess.run(
|
|
180
|
+
[soffice, "--headless", "--norestore", "--convert-to", "pdf", "--outdir", str(out_dir), str(input_path)],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
text=True,
|
|
183
|
+
timeout=240,
|
|
184
|
+
)
|
|
185
|
+
if completed.returncode != 0 or not pdf.is_file():
|
|
186
|
+
detail = (completed.stderr or completed.stdout).strip()
|
|
187
|
+
fail(f"LibreOffice export failed for {input_path.name}", detail=detail or None)
|
|
188
|
+
return "libreoffice", str(pdf)
|
|
189
|
+
|
|
190
|
+
fail("no document renderer found: install LibreOffice or Microsoft Office (Word/PowerPoint/Excel)")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def rasterize_pdf(pdf: str, out_dir: Path, dpi: int = 110) -> list[str]:
|
|
194
|
+
try:
|
|
195
|
+
import pymupdf
|
|
196
|
+
except ImportError:
|
|
197
|
+
try:
|
|
198
|
+
import fitz as pymupdf # type: ignore[no-redef]
|
|
199
|
+
except ImportError:
|
|
200
|
+
fail("PyMuPDF is not installed; run the plugin postinstall again")
|
|
201
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
pages: list[str] = []
|
|
203
|
+
document = pymupdf.open(pdf)
|
|
204
|
+
try:
|
|
205
|
+
for index, page in enumerate(document, start=1):
|
|
206
|
+
zoom = dpi / 72.0
|
|
207
|
+
pixmap = page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom), alpha=False)
|
|
208
|
+
target = out_dir / f"page-{index}.png"
|
|
209
|
+
pixmap.save(str(target))
|
|
210
|
+
pages.append(str(target))
|
|
211
|
+
finally:
|
|
212
|
+
document.close()
|
|
213
|
+
return pages
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int) -> dict[str, Any]:
|
|
217
|
+
renderer, pdf = export_pdf(input_path, kind, out_dir)
|
|
218
|
+
pages = rasterize_pdf(pdf, out_dir, dpi)
|
|
219
|
+
return {
|
|
220
|
+
"ok": True,
|
|
221
|
+
"kind": kind,
|
|
222
|
+
"input": str(input_path),
|
|
223
|
+
"renderer": renderer,
|
|
224
|
+
"pdf": pdf,
|
|
225
|
+
"page_count": len(pages),
|
|
226
|
+
"pages": pages,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
# DOCX
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _docx_align(name: str | None):
|
|
236
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"left": WD_ALIGN_PARAGRAPH.LEFT,
|
|
240
|
+
"center": WD_ALIGN_PARAGRAPH.CENTER,
|
|
241
|
+
"right": WD_ALIGN_PARAGRAPH.RIGHT,
|
|
242
|
+
"justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
|
|
243
|
+
}.get((name or "").lower(), WD_ALIGN_PARAGRAPH.LEFT)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _docx_add_equation(paragraph, latex: str) -> bool:
|
|
247
|
+
try:
|
|
248
|
+
from latex2mathml.converter import convert as latex_to_mathml
|
|
249
|
+
from lxml import etree
|
|
250
|
+
from mathml2omml import convert as mathml_to_omml
|
|
251
|
+
|
|
252
|
+
omml = mathml_to_omml(latex_to_mathml(latex))
|
|
253
|
+
if "xmlns:m=" not in omml:
|
|
254
|
+
omml = omml.replace(
|
|
255
|
+
"<m:oMath",
|
|
256
|
+
'<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"',
|
|
257
|
+
1,
|
|
258
|
+
)
|
|
259
|
+
element = etree.fromstring(omml.encode("utf-8"))
|
|
260
|
+
paragraph._p.append(element)
|
|
261
|
+
return True
|
|
262
|
+
except Exception:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _docx_add_block(document, block: dict[str, Any], counters: dict[str, int]) -> None:
|
|
267
|
+
from docx.shared import Inches, Pt
|
|
268
|
+
|
|
269
|
+
block_type = str(block.get("type", "paragraph")).lower()
|
|
270
|
+
if block_type == "heading":
|
|
271
|
+
level = int(block.get("level", 1))
|
|
272
|
+
document.add_heading(str(block.get("text", "")), level=max(0, min(level, 9)))
|
|
273
|
+
counters["headings"] += 1
|
|
274
|
+
elif block_type == "paragraph":
|
|
275
|
+
paragraph = document.add_paragraph()
|
|
276
|
+
run = paragraph.add_run(str(block.get("text", "")))
|
|
277
|
+
run.bold = bool(block.get("bold", False))
|
|
278
|
+
run.italic = bool(block.get("italic", False))
|
|
279
|
+
if "size" in block:
|
|
280
|
+
run.font.size = Pt(float(block["size"]))
|
|
281
|
+
if block.get("color"):
|
|
282
|
+
from docx.shared import RGBColor
|
|
283
|
+
|
|
284
|
+
raw = str(block["color"]).lstrip("#")
|
|
285
|
+
if len(raw) == 6:
|
|
286
|
+
run.font.color.rgb = RGBColor.from_string(raw.upper())
|
|
287
|
+
paragraph.alignment = _docx_align(block.get("align"))
|
|
288
|
+
if block.get("style"):
|
|
289
|
+
paragraph.style = str(block["style"])
|
|
290
|
+
counters["paragraphs"] += 1
|
|
291
|
+
elif block_type in {"bullets", "numbered"}:
|
|
292
|
+
style = "List Bullet" if block_type == "bullets" else "List Number"
|
|
293
|
+
for item in block.get("items", []):
|
|
294
|
+
paragraph = document.add_paragraph(str(item), style=style)
|
|
295
|
+
if block.get("size"):
|
|
296
|
+
for run in paragraph.runs:
|
|
297
|
+
run.font.size = Pt(float(block["size"]))
|
|
298
|
+
counters["list_items"] += 1
|
|
299
|
+
elif block_type == "table":
|
|
300
|
+
headers = [str(value) for value in block.get("headers", [])]
|
|
301
|
+
rows = block.get("rows", [])
|
|
302
|
+
columns = max([len(headers), *[len(row) for row in rows]]) if (headers or rows) else 0
|
|
303
|
+
if columns == 0:
|
|
304
|
+
return
|
|
305
|
+
table = document.add_table(rows=0, cols=columns)
|
|
306
|
+
style = str(block.get("style", "Table Grid"))
|
|
307
|
+
try:
|
|
308
|
+
table.style = style
|
|
309
|
+
except Exception:
|
|
310
|
+
table.style = "Table Grid"
|
|
311
|
+
if headers:
|
|
312
|
+
cells = table.add_row().cells
|
|
313
|
+
for index, value in enumerate(headers):
|
|
314
|
+
cells[index].text = value
|
|
315
|
+
for paragraph in cells[index].paragraphs:
|
|
316
|
+
for run in paragraph.runs:
|
|
317
|
+
run.bold = True
|
|
318
|
+
for row in rows:
|
|
319
|
+
cells = table.add_row().cells
|
|
320
|
+
for index, value in enumerate(row):
|
|
321
|
+
if index < len(cells):
|
|
322
|
+
cells[index].text = "" if value is None else str(value)
|
|
323
|
+
counters["tables"] += 1
|
|
324
|
+
elif block_type == "image":
|
|
325
|
+
path = require_file(str(block.get("path", "")), "image")
|
|
326
|
+
width = block.get("width")
|
|
327
|
+
if width is None:
|
|
328
|
+
document.add_picture(str(path))
|
|
329
|
+
else:
|
|
330
|
+
document.add_picture(str(path), width=Inches(float(width)))
|
|
331
|
+
counters["images"] += 1
|
|
332
|
+
elif block_type == "equation":
|
|
333
|
+
paragraph = document.add_paragraph()
|
|
334
|
+
if not _docx_add_equation(paragraph, str(block.get("latex", ""))):
|
|
335
|
+
paragraph.add_run(str(block.get("latex", ""))).italic = True
|
|
336
|
+
counters["equation_fallbacks"] += 1
|
|
337
|
+
else:
|
|
338
|
+
counters["equations"] += 1
|
|
339
|
+
elif block_type == "quote":
|
|
340
|
+
document.add_paragraph(str(block.get("text", "")), style="Intense Quote")
|
|
341
|
+
counters["paragraphs"] += 1
|
|
342
|
+
elif block_type == "page_break":
|
|
343
|
+
from docx.enum.text import WD_BREAK
|
|
344
|
+
|
|
345
|
+
document.add_paragraph().add_run().add_break(WD_BREAK.PAGE)
|
|
346
|
+
elif block_type == "spacer":
|
|
347
|
+
document.add_paragraph("")
|
|
348
|
+
else:
|
|
349
|
+
document.add_paragraph(str(block.get("text", "")))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _docx_add_blocks(document, blocks: Iterable[dict[str, Any]], counters: dict[str, int]) -> None:
|
|
353
|
+
for block in blocks:
|
|
354
|
+
if isinstance(block, dict):
|
|
355
|
+
_docx_add_block(document, block, counters)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def docx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
|
|
359
|
+
from docx import Document
|
|
360
|
+
from docx.shared import Pt
|
|
361
|
+
|
|
362
|
+
document = Document()
|
|
363
|
+
style = document.styles["Normal"]
|
|
364
|
+
style.font.name = "Calibri"
|
|
365
|
+
style.font.size = Pt(11)
|
|
366
|
+
section = document.sections[0]
|
|
367
|
+
page_size = str(spec.get("page_size", "Letter")).lower()
|
|
368
|
+
if page_size == "a4":
|
|
369
|
+
section.page_width = int(8.27 * 914400)
|
|
370
|
+
section.page_height = int(11.69 * 914400)
|
|
371
|
+
if str(spec.get("orientation", "portrait")).lower() == "landscape":
|
|
372
|
+
width, height = section.page_width, section.page_height
|
|
373
|
+
section.page_width, section.page_height = height, width
|
|
374
|
+
for side in ("top_margin", "bottom_margin", "left_margin", "right_margin"):
|
|
375
|
+
if side in spec:
|
|
376
|
+
setattr(section, side, int(float(spec[side]) * 914400))
|
|
377
|
+
|
|
378
|
+
if spec.get("title"):
|
|
379
|
+
document.add_heading(str(spec["title"]), 0)
|
|
380
|
+
if spec.get("subtitle"):
|
|
381
|
+
paragraph = document.add_paragraph(str(spec["subtitle"]))
|
|
382
|
+
paragraph.alignment = _docx_align("center")
|
|
383
|
+
if spec.get("author") or spec.get("title"):
|
|
384
|
+
document.core_properties.title = str(spec.get("title", ""))
|
|
385
|
+
document.core_properties.author = str(spec.get("author", ""))
|
|
386
|
+
if spec.get("header"):
|
|
387
|
+
document.sections[0].header.paragraphs[0].text = str(spec["header"])
|
|
388
|
+
if spec.get("footer"):
|
|
389
|
+
document.sections[0].footer.paragraphs[0].text = str(spec["footer"])
|
|
390
|
+
|
|
391
|
+
counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images", "equations", "equation_fallbacks")}
|
|
392
|
+
_docx_add_blocks(document, spec.get("blocks", spec.get("sections", [])), counters)
|
|
393
|
+
ensure_parent(str(out))
|
|
394
|
+
document.save(str(out))
|
|
395
|
+
return {"ok": True, "kind": "docx", "command": "create", "output": str(out), "counts": counters}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _docx_iter_paragraphs(document):
|
|
399
|
+
for paragraph in document.paragraphs:
|
|
400
|
+
yield paragraph
|
|
401
|
+
for table in document.tables:
|
|
402
|
+
for row in table.rows:
|
|
403
|
+
for cell in row.cells:
|
|
404
|
+
for paragraph in cell.paragraphs:
|
|
405
|
+
yield paragraph
|
|
406
|
+
for section in document.sections:
|
|
407
|
+
for part in (section.header, section.footer):
|
|
408
|
+
for paragraph in part.paragraphs:
|
|
409
|
+
yield paragraph
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
|
|
413
|
+
count = 0
|
|
414
|
+
for run in paragraph.runs:
|
|
415
|
+
if find in run.text:
|
|
416
|
+
run.text = run.text.replace(find, replacement)
|
|
417
|
+
count += 1
|
|
418
|
+
if count == 0 and find in paragraph.text:
|
|
419
|
+
text = paragraph.text.replace(find, replacement)
|
|
420
|
+
for run in list(paragraph.runs):
|
|
421
|
+
run._element.getparent().remove(run._element)
|
|
422
|
+
paragraph.add_run(text)
|
|
423
|
+
count = 1
|
|
424
|
+
return count
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def docx_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
|
|
428
|
+
from docx import Document
|
|
429
|
+
|
|
430
|
+
document = Document(str(input_path))
|
|
431
|
+
replace_count = 0
|
|
432
|
+
for item in spec.get("replace", []):
|
|
433
|
+
find = str(item.get("find", ""))
|
|
434
|
+
replacement = str(item.get("replace", ""))
|
|
435
|
+
if not find:
|
|
436
|
+
continue
|
|
437
|
+
for paragraph in _docx_iter_paragraphs(document):
|
|
438
|
+
replace_count += _replace_in_paragraph(paragraph, find, replacement)
|
|
439
|
+
counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images", "equations", "equation_fallbacks")}
|
|
440
|
+
_docx_add_blocks(document, spec.get("blocks", []), counters)
|
|
441
|
+
core = spec.get("core")
|
|
442
|
+
if isinstance(core, dict):
|
|
443
|
+
for key in ("title", "author", "subject", "keywords", "comments"):
|
|
444
|
+
if key in core:
|
|
445
|
+
setattr(document.core_properties, key, str(core[key]))
|
|
446
|
+
ensure_parent(str(out))
|
|
447
|
+
document.save(str(out))
|
|
448
|
+
return {
|
|
449
|
+
"ok": True,
|
|
450
|
+
"kind": "docx",
|
|
451
|
+
"command": "edit",
|
|
452
|
+
"input": str(input_path),
|
|
453
|
+
"output": str(out),
|
|
454
|
+
"replacements": replace_count,
|
|
455
|
+
"counts": counters,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _docx_has_equation(paragraph) -> bool:
|
|
460
|
+
tag = "{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath"
|
|
461
|
+
return bool(paragraph._p.findall(f".//{tag}"))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def docx_read(input_path: Path) -> dict[str, Any]:
|
|
465
|
+
from docx import Document
|
|
466
|
+
|
|
467
|
+
document = Document(str(input_path))
|
|
468
|
+
paragraphs = []
|
|
469
|
+
headings = []
|
|
470
|
+
equations = 0
|
|
471
|
+
for paragraph in document.paragraphs:
|
|
472
|
+
text = paragraph.text.strip()
|
|
473
|
+
if not text and not _docx_has_equation(paragraph):
|
|
474
|
+
continue
|
|
475
|
+
item = {"text": text, "style": paragraph.style.name if paragraph.style is not None else None}
|
|
476
|
+
if _docx_has_equation(paragraph):
|
|
477
|
+
equations += 1
|
|
478
|
+
item["equation"] = True
|
|
479
|
+
if paragraph.style is not None and paragraph.style.name.lower().startswith("heading"):
|
|
480
|
+
headings.append(text)
|
|
481
|
+
if text:
|
|
482
|
+
paragraphs.append(item)
|
|
483
|
+
tables = []
|
|
484
|
+
for table in document.tables:
|
|
485
|
+
tables.append({"rows": [[cell.text for cell in row.cells] for row in table.rows]})
|
|
486
|
+
section = document.sections[0]
|
|
487
|
+
return {
|
|
488
|
+
"ok": True,
|
|
489
|
+
"kind": "docx",
|
|
490
|
+
"command": "read",
|
|
491
|
+
"input": str(input_path),
|
|
492
|
+
"title": document.core_properties.title or None,
|
|
493
|
+
"author": document.core_properties.author or None,
|
|
494
|
+
"paragraphs": paragraphs,
|
|
495
|
+
"headings": headings,
|
|
496
|
+
"tables": tables,
|
|
497
|
+
"images": len(document.inline_shapes),
|
|
498
|
+
"equations": equations,
|
|
499
|
+
"counts": {
|
|
500
|
+
"paragraphs": len(paragraphs),
|
|
501
|
+
"headings": len(headings),
|
|
502
|
+
"tables": len(tables),
|
|
503
|
+
"images": len(document.inline_shapes),
|
|
504
|
+
},
|
|
505
|
+
"page": {
|
|
506
|
+
"width_inches": round(section.page_width.inches, 2),
|
|
507
|
+
"height_inches": round(section.page_height.inches, 2),
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# PDF
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _pdf_font() -> str:
|
|
518
|
+
from reportlab.pdfbase import pdfmetrics
|
|
519
|
+
from reportlab.pdfbase.ttfonts import TTFont
|
|
520
|
+
|
|
521
|
+
candidates = [
|
|
522
|
+
r"C:\Windows\Fonts\msyh.ttc",
|
|
523
|
+
r"C:\Windows\Fonts\msyh.ttf",
|
|
524
|
+
r"C:\Windows\Fonts\simsun.ttc",
|
|
525
|
+
"/System/Library/Fonts/PingFang.ttc",
|
|
526
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
527
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
528
|
+
]
|
|
529
|
+
for candidate in candidates:
|
|
530
|
+
if Path(candidate).is_file():
|
|
531
|
+
try:
|
|
532
|
+
pdfmetrics.registerFont(TTFont("OfficeCJK", candidate))
|
|
533
|
+
return "OfficeCJK"
|
|
534
|
+
except Exception:
|
|
535
|
+
continue
|
|
536
|
+
return "Helvetica"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[str, int]):
|
|
540
|
+
from reportlab.lib import colors
|
|
541
|
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
|
542
|
+
from reportlab.lib.units import inch
|
|
543
|
+
from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
|
|
544
|
+
from xml.sax.saxutils import escape
|
|
545
|
+
|
|
546
|
+
styles = getSampleStyleSheet()
|
|
547
|
+
body = ParagraphStyle("OfficeBody", parent=styles["BodyText"], fontName=font, fontSize=10.5, leading=15)
|
|
548
|
+
heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24)
|
|
549
|
+
heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20)
|
|
550
|
+
title = ParagraphStyle("OfficeTitle", parent=styles["Title"], fontName=font, fontSize=22, leading=28)
|
|
551
|
+
flowables = []
|
|
552
|
+
for block in blocks:
|
|
553
|
+
if not isinstance(block, dict):
|
|
554
|
+
continue
|
|
555
|
+
block_type = str(block.get("type", "paragraph")).lower()
|
|
556
|
+
if block_type == "title":
|
|
557
|
+
flowables.append(Paragraph(escape(str(block.get("text", ""))), title))
|
|
558
|
+
flowables.append(Spacer(1, 0.2 * inch))
|
|
559
|
+
elif block_type == "heading":
|
|
560
|
+
style = heading1 if int(block.get("level", 1)) <= 1 else heading2
|
|
561
|
+
flowables.append(Paragraph(escape(str(block.get("text", ""))), style))
|
|
562
|
+
counters["headings"] += 1
|
|
563
|
+
elif block_type in {"paragraph", "quote"}:
|
|
564
|
+
flowables.append(Paragraph(escape(str(block.get("text", ""))), body))
|
|
565
|
+
counters["paragraphs"] += 1
|
|
566
|
+
elif block_type in {"bullets", "numbered"}:
|
|
567
|
+
for item in block.get("items", []):
|
|
568
|
+
prefix = "• " if block_type == "bullets" else "1. "
|
|
569
|
+
flowables.append(Paragraph(prefix + escape(str(item)), body))
|
|
570
|
+
counters["list_items"] += 1
|
|
571
|
+
elif block_type == "table":
|
|
572
|
+
headers = [str(value) for value in block.get("headers", [])]
|
|
573
|
+
rows = [[str(value) for value in row] for row in block.get("rows", [])]
|
|
574
|
+
data = ([headers] if headers else []) + rows
|
|
575
|
+
if data:
|
|
576
|
+
table = Table(data, repeatRows=1 if headers else 0)
|
|
577
|
+
table.setStyle(TableStyle([
|
|
578
|
+
("FONTNAME", (0, 0), (-1, -1), font),
|
|
579
|
+
("FONTSIZE", (0, 0), (-1, -1), 9),
|
|
580
|
+
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
|
|
581
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8EEF7")),
|
|
582
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
|
583
|
+
]))
|
|
584
|
+
flowables.append(table)
|
|
585
|
+
flowables.append(Spacer(1, 0.15 * inch))
|
|
586
|
+
counters["tables"] += 1
|
|
587
|
+
elif block_type == "image":
|
|
588
|
+
path = require_file(str(block.get("path", "")), "image")
|
|
589
|
+
width = float(block.get("width", 4.5)) * inch
|
|
590
|
+
flowables.append(Image(str(path), width=width, height=width * 0.66))
|
|
591
|
+
counters["images"] += 1
|
|
592
|
+
elif block_type == "page_break":
|
|
593
|
+
flowables.append(PageBreak())
|
|
594
|
+
elif block_type == "spacer":
|
|
595
|
+
flowables.append(Spacer(1, float(block.get("height", 0.2)) * inch))
|
|
596
|
+
return flowables
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def pdf_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
|
|
600
|
+
from reportlab.lib.pagesizes import A4, letter
|
|
601
|
+
from reportlab.lib.units import inch
|
|
602
|
+
from reportlab.platypus import SimpleDocTemplate
|
|
603
|
+
|
|
604
|
+
font = _pdf_font()
|
|
605
|
+
size = A4 if str(spec.get("page_size", "Letter")).lower() == "a4" else letter
|
|
606
|
+
ensure_parent(str(out))
|
|
607
|
+
document = SimpleDocTemplate(
|
|
608
|
+
str(out),
|
|
609
|
+
pagesize=size,
|
|
610
|
+
leftMargin=0.85 * inch,
|
|
611
|
+
rightMargin=0.85 * inch,
|
|
612
|
+
topMargin=0.8 * inch,
|
|
613
|
+
bottomMargin=0.8 * inch,
|
|
614
|
+
title=str(spec.get("title", "")),
|
|
615
|
+
author=str(spec.get("author", "")),
|
|
616
|
+
)
|
|
617
|
+
counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images")}
|
|
618
|
+
blocks: list[dict[str, Any]] = []
|
|
619
|
+
if spec.get("title"):
|
|
620
|
+
blocks.append({"type": "title", "text": spec["title"]})
|
|
621
|
+
if spec.get("subtitle"):
|
|
622
|
+
blocks.append({"type": "paragraph", "text": spec["subtitle"]})
|
|
623
|
+
for page in spec.get("pages", []):
|
|
624
|
+
if isinstance(page, dict):
|
|
625
|
+
blocks.extend(page.get("blocks", []))
|
|
626
|
+
if page.get("break", True):
|
|
627
|
+
blocks.append({"type": "page_break"})
|
|
628
|
+
blocks.extend(spec.get("blocks", []))
|
|
629
|
+
document.build(_pdf_flowables(blocks, font, counters))
|
|
630
|
+
return {"ok": True, "kind": "pdf", "command": "create", "output": str(out), "font": font, "counts": counters}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def pdf_read(input_path: Path) -> dict[str, Any]:
|
|
634
|
+
from pypdf import PdfReader
|
|
635
|
+
|
|
636
|
+
reader = PdfReader(str(input_path))
|
|
637
|
+
pages = []
|
|
638
|
+
total_words = 0
|
|
639
|
+
for index, page in enumerate(reader.pages, start=1):
|
|
640
|
+
text = page.extract_text() or ""
|
|
641
|
+
total_words += len(text.split())
|
|
642
|
+
pages.append({"page": index, "text": text, "characters": len(text)})
|
|
643
|
+
metadata = {}
|
|
644
|
+
try:
|
|
645
|
+
metadata = {key: str(value) for key, value in (reader.metadata or {}).items() if value is not None}
|
|
646
|
+
except Exception:
|
|
647
|
+
metadata = {}
|
|
648
|
+
fields = []
|
|
649
|
+
try:
|
|
650
|
+
root = reader.trailer["/Root"]
|
|
651
|
+
if "/AcroForm" in root:
|
|
652
|
+
fields = sorted((root["/AcroForm"].get("/Fields") or []))
|
|
653
|
+
fields = [str(getattr(field, "idnum", None) or field) for field in fields]
|
|
654
|
+
except Exception:
|
|
655
|
+
fields = []
|
|
656
|
+
return {
|
|
657
|
+
"ok": True,
|
|
658
|
+
"kind": "pdf",
|
|
659
|
+
"command": "read",
|
|
660
|
+
"input": str(input_path),
|
|
661
|
+
"page_count": len(reader.pages),
|
|
662
|
+
"word_count": total_words,
|
|
663
|
+
"metadata": metadata,
|
|
664
|
+
"pages": pages,
|
|
665
|
+
"form_fields": fields,
|
|
666
|
+
"ocr": {"available": False, "reason": "OCR is not bundled; use a dedicated OCR tool."},
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def pdf_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
|
|
671
|
+
from pypdf import PdfReader, PdfWriter
|
|
672
|
+
|
|
673
|
+
writer = PdfWriter()
|
|
674
|
+
reader = PdfReader(str(input_path))
|
|
675
|
+
for page in reader.pages:
|
|
676
|
+
writer.add_page(page)
|
|
677
|
+
rotate = int(spec.get("rotate", 0))
|
|
678
|
+
targets = spec.get("pages")
|
|
679
|
+
if rotate:
|
|
680
|
+
for index, page in enumerate(writer.pages):
|
|
681
|
+
if targets is None or index in targets:
|
|
682
|
+
page.rotate(rotate)
|
|
683
|
+
for extra in spec.get("merge", []):
|
|
684
|
+
extra_reader = PdfReader(str(require_file(str(extra), "PDF to merge")))
|
|
685
|
+
for page in extra_reader.pages:
|
|
686
|
+
writer.add_page(page)
|
|
687
|
+
if spec.get("remove_pages"):
|
|
688
|
+
remove = set(int(value) for value in spec["remove_pages"])
|
|
689
|
+
writer = PdfWriter()
|
|
690
|
+
for index, page in enumerate(reader.pages):
|
|
691
|
+
if index not in remove:
|
|
692
|
+
writer.add_page(page)
|
|
693
|
+
for extra in spec.get("merge", []):
|
|
694
|
+
extra_reader = PdfReader(str(require_file(str(extra), "PDF to merge")))
|
|
695
|
+
for page in extra_reader.pages:
|
|
696
|
+
writer.add_page(page)
|
|
697
|
+
if isinstance(spec.get("metadata"), dict):
|
|
698
|
+
metadata = {}
|
|
699
|
+
for key, value in spec["metadata"].items():
|
|
700
|
+
name = str(key)
|
|
701
|
+
metadata[name if name.startswith("/") else f"/{name}"] = str(value)
|
|
702
|
+
writer.add_metadata(metadata)
|
|
703
|
+
ensure_parent(str(out))
|
|
704
|
+
with open(out, "wb") as handle:
|
|
705
|
+
writer.write(handle)
|
|
706
|
+
return {"ok": True, "kind": "pdf", "command": "edit", "input": str(input_path), "output": str(out), "page_count": len(writer.pages)}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def pdf_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
|
|
710
|
+
pages = rasterize_pdf(str(input_path), out_dir, dpi)
|
|
711
|
+
return {"ok": True, "kind": "pdf", "command": "render", "input": str(input_path), "page_count": len(pages), "pages": pages}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# ---------------------------------------------------------------------------
|
|
715
|
+
# PPTX
|
|
716
|
+
# ---------------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _pptx_layout(prs, name: str):
|
|
720
|
+
mapping = {
|
|
721
|
+
"title": 0,
|
|
722
|
+
"bullets": 1,
|
|
723
|
+
"section": 2,
|
|
724
|
+
"title_only": 5,
|
|
725
|
+
"blank": 6,
|
|
726
|
+
}
|
|
727
|
+
index = mapping.get(name, 5)
|
|
728
|
+
return prs.slide_layouts[min(index, len(prs.slide_layouts) - 1)]
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _pptx_add_chart(slide, spec: dict[str, Any]):
|
|
732
|
+
from pptx.chart.data import CategoryChartData
|
|
733
|
+
from pptx.enum.chart import XL_CHART_TYPE
|
|
734
|
+
from pptx.util import Inches
|
|
735
|
+
|
|
736
|
+
chart_type = {
|
|
737
|
+
"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
738
|
+
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
739
|
+
"line": XL_CHART_TYPE.LINE_MARKERS,
|
|
740
|
+
"pie": XL_CHART_TYPE.PIE,
|
|
741
|
+
}.get(str(spec.get("type", "bar")).lower(), XL_CHART_TYPE.COLUMN_CLUSTERED)
|
|
742
|
+
data = CategoryChartData()
|
|
743
|
+
data.categories = [str(value) for value in spec.get("categories", [])]
|
|
744
|
+
for series in spec.get("series", []):
|
|
745
|
+
data.add_series(str(series.get("name", "Series")), [float(value) for value in series.get("values", [])])
|
|
746
|
+
frame = slide.shapes.add_chart(
|
|
747
|
+
chart_type,
|
|
748
|
+
Inches(float(spec.get("left", 0.8))),
|
|
749
|
+
Inches(float(spec.get("top", 1.6))),
|
|
750
|
+
Inches(float(spec.get("width", 8.4))),
|
|
751
|
+
Inches(float(spec.get("height", 4.6))),
|
|
752
|
+
data,
|
|
753
|
+
)
|
|
754
|
+
if spec.get("title"):
|
|
755
|
+
frame.chart.has_title = True
|
|
756
|
+
frame.chart.chart_title.text_frame.text = str(spec["title"])
|
|
757
|
+
return frame.chart
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _pptx_add_slide(prs, spec: dict[str, Any], counters: dict[str, int]):
|
|
761
|
+
from pptx.util import Inches, Pt
|
|
762
|
+
|
|
763
|
+
kind = str(spec.get("layout", "bullets")).lower()
|
|
764
|
+
slide = prs.slides.add_slide(_pptx_layout(prs, kind))
|
|
765
|
+
title_shape = slide.shapes.title
|
|
766
|
+
if title_shape is not None and spec.get("title") is not None:
|
|
767
|
+
title_shape.text = str(spec["title"])
|
|
768
|
+
elif spec.get("title") is not None:
|
|
769
|
+
title_box = slide.shapes.add_textbox(Inches(0.6), Inches(0.35), Inches(12.1), Inches(0.9))
|
|
770
|
+
title_frame = title_box.text_frame
|
|
771
|
+
title_frame.word_wrap = True
|
|
772
|
+
title_frame.text = str(spec["title"])
|
|
773
|
+
title_frame.paragraphs[0].font.size = Pt(32)
|
|
774
|
+
title_frame.paragraphs[0].font.bold = True
|
|
775
|
+
if kind == "title":
|
|
776
|
+
if len(slide.placeholders) > 1 and spec.get("subtitle") is not None:
|
|
777
|
+
slide.placeholders[1].text = str(spec["subtitle"])
|
|
778
|
+
elif kind == "bullets":
|
|
779
|
+
body = None
|
|
780
|
+
for placeholder in slide.placeholders:
|
|
781
|
+
if placeholder.placeholder_format.idx == 1:
|
|
782
|
+
body = placeholder.text_frame
|
|
783
|
+
break
|
|
784
|
+
if body is not None:
|
|
785
|
+
body.clear()
|
|
786
|
+
items = spec.get("bullets", [])
|
|
787
|
+
for index, item in enumerate(items):
|
|
788
|
+
text = str(item.get("text", "")) if isinstance(item, dict) else str(item)
|
|
789
|
+
paragraph = body.paragraphs[0] if index == 0 else body.add_paragraph()
|
|
790
|
+
paragraph.text = text
|
|
791
|
+
paragraph.level = int(item.get("level", 0)) if isinstance(item, dict) else 0
|
|
792
|
+
counters["bullets"] += len(spec.get("bullets", []))
|
|
793
|
+
else:
|
|
794
|
+
top = 1.5
|
|
795
|
+
if spec.get("bullets"):
|
|
796
|
+
box = slide.shapes.add_textbox(Inches(0.8), Inches(top), Inches(11.6), Inches(0.9))
|
|
797
|
+
frame = box.text_frame
|
|
798
|
+
frame.word_wrap = True
|
|
799
|
+
for index, item in enumerate(spec["bullets"]):
|
|
800
|
+
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
|
|
801
|
+
paragraph.text = "• " + str(item)
|
|
802
|
+
paragraph.font.size = Pt(18)
|
|
803
|
+
top += 1.0
|
|
804
|
+
if isinstance(spec.get("table"), dict):
|
|
805
|
+
table_spec = spec["table"]
|
|
806
|
+
rows = 1 + len(table_spec.get("rows", []))
|
|
807
|
+
columns = len(table_spec.get("headers", [])) or (len(table_spec["rows"][0]) if table_spec.get("rows") else 0)
|
|
808
|
+
if rows > 0 and columns > 0:
|
|
809
|
+
shape = slide.shapes.add_table(rows, columns, Inches(0.8), Inches(top), Inches(11.6), Inches(0.5 * rows))
|
|
810
|
+
table = shape.table
|
|
811
|
+
for index, value in enumerate(table_spec.get("headers", [])):
|
|
812
|
+
table.cell(0, index).text = str(value)
|
|
813
|
+
for row_index, row in enumerate(table_spec.get("rows", []), start=1):
|
|
814
|
+
for column_index, value in enumerate(row):
|
|
815
|
+
table.cell(row_index, column_index).text = "" if value is None else str(value)
|
|
816
|
+
counters["tables"] += 1
|
|
817
|
+
top += 0.5 * rows + 0.3
|
|
818
|
+
if isinstance(spec.get("chart"), dict):
|
|
819
|
+
chart_spec = dict(spec["chart"])
|
|
820
|
+
chart_spec.setdefault("top", round(top, 2))
|
|
821
|
+
chart_spec.setdefault("height", round(min(4.2, max(1.5, 7.1 - top)), 2))
|
|
822
|
+
_pptx_add_chart(slide, chart_spec)
|
|
823
|
+
counters["charts"] += 1
|
|
824
|
+
top += float(chart_spec["height"]) + 0.2
|
|
825
|
+
if isinstance(spec.get("image"), dict):
|
|
826
|
+
image = spec["image"]
|
|
827
|
+
path = require_file(str(image.get("path", "")), "image")
|
|
828
|
+
slide.shapes.add_picture(
|
|
829
|
+
str(path),
|
|
830
|
+
Inches(float(image.get("left", 0.8))),
|
|
831
|
+
Inches(float(image.get("top", top))),
|
|
832
|
+
width=Inches(float(image.get("width", 3.0))),
|
|
833
|
+
)
|
|
834
|
+
counters["images"] += 1
|
|
835
|
+
counters["slides"] += 1
|
|
836
|
+
return slide
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def pptx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
|
|
840
|
+
from pptx import Presentation
|
|
841
|
+
|
|
842
|
+
prs = Presentation()
|
|
843
|
+
prs.slide_width = int(13.333 * 914400)
|
|
844
|
+
prs.slide_height = int(7.5 * 914400)
|
|
845
|
+
prs.core_properties.title = str(spec.get("title", ""))
|
|
846
|
+
prs.core_properties.author = str(spec.get("author", "DSH Office Suite"))
|
|
847
|
+
counters = {key: 0 for key in ("slides", "bullets", "tables", "charts", "images")}
|
|
848
|
+
for slide_spec in spec.get("slides", []):
|
|
849
|
+
if isinstance(slide_spec, dict):
|
|
850
|
+
_pptx_add_slide(prs, slide_spec, counters)
|
|
851
|
+
ensure_parent(str(out))
|
|
852
|
+
prs.save(str(out))
|
|
853
|
+
return {"ok": True, "kind": "pptx", "command": "create", "output": str(out), "counts": counters}
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _pptx_iter_text(slide, out: list[str]) -> None:
|
|
857
|
+
for shape in slide.shapes:
|
|
858
|
+
if shape.has_text_frame:
|
|
859
|
+
for paragraph in shape.text_frame.paragraphs:
|
|
860
|
+
text = "".join(run.text for run in paragraph.runs).strip()
|
|
861
|
+
if text:
|
|
862
|
+
out.append(text)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def pptx_read(input_path: Path) -> dict[str, Any]:
|
|
866
|
+
from pptx import Presentation
|
|
867
|
+
|
|
868
|
+
prs = Presentation(str(input_path))
|
|
869
|
+
slides = []
|
|
870
|
+
for index, slide in enumerate(prs.slides, start=1):
|
|
871
|
+
texts: list[str] = []
|
|
872
|
+
_pptx_iter_text(slide, texts)
|
|
873
|
+
tables = []
|
|
874
|
+
charts = []
|
|
875
|
+
images = 0
|
|
876
|
+
for shape in slide.shapes:
|
|
877
|
+
if shape.has_table:
|
|
878
|
+
table = shape.table
|
|
879
|
+
tables.append({"rows": [[cell.text for cell in row.cells] for row in table.rows]})
|
|
880
|
+
if shape.has_chart:
|
|
881
|
+
chart = shape.chart
|
|
882
|
+
categories = list(chart.plots[0].categories) if chart.plots else []
|
|
883
|
+
series = []
|
|
884
|
+
try:
|
|
885
|
+
for item in chart.series:
|
|
886
|
+
series.append({"name": item.name, "values": list(item.values)})
|
|
887
|
+
except Exception:
|
|
888
|
+
series = []
|
|
889
|
+
charts.append({
|
|
890
|
+
"type": str(chart.chart_type),
|
|
891
|
+
"title": chart.chart_title.text_frame.text if chart.has_title else None,
|
|
892
|
+
"categories": [str(value) for value in categories],
|
|
893
|
+
"series": series,
|
|
894
|
+
})
|
|
895
|
+
if shape.shape_type is not None and "PICTURE" in str(shape.shape_type):
|
|
896
|
+
images += 1
|
|
897
|
+
title = slide.shapes.title.text if slide.shapes.title is not None else None
|
|
898
|
+
slides.append({"slide": index, "title": title, "texts": texts, "tables": tables, "charts": charts, "images": images})
|
|
899
|
+
return {
|
|
900
|
+
"ok": True,
|
|
901
|
+
"kind": "pptx",
|
|
902
|
+
"command": "read",
|
|
903
|
+
"input": str(input_path),
|
|
904
|
+
"slide_count": len(prs.slides),
|
|
905
|
+
"title": prs.core_properties.title or None,
|
|
906
|
+
"slides": slides,
|
|
907
|
+
"counts": {
|
|
908
|
+
"slides": len(prs.slides),
|
|
909
|
+
"tables": sum(len(item["tables"]) for item in slides),
|
|
910
|
+
"charts": sum(len(item["charts"]) for item in slides),
|
|
911
|
+
},
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def pptx_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
|
|
916
|
+
from pptx import Presentation
|
|
917
|
+
|
|
918
|
+
prs = Presentation(str(input_path))
|
|
919
|
+
replacements = 0
|
|
920
|
+
for item in spec.get("replace", []):
|
|
921
|
+
find = str(item.get("find", ""))
|
|
922
|
+
replacement = str(item.get("replace", ""))
|
|
923
|
+
if not find:
|
|
924
|
+
continue
|
|
925
|
+
for slide in prs.slides:
|
|
926
|
+
for shape in slide.shapes:
|
|
927
|
+
if not shape.has_text_frame:
|
|
928
|
+
continue
|
|
929
|
+
for paragraph in shape.text_frame.paragraphs:
|
|
930
|
+
for run in paragraph.runs:
|
|
931
|
+
if find in run.text:
|
|
932
|
+
run.text = run.text.replace(find, replacement)
|
|
933
|
+
replacements += 1
|
|
934
|
+
counters = {key: 0 for key in ("slides", "bullets", "tables", "charts", "images")}
|
|
935
|
+
for slide_spec in spec.get("slides", []):
|
|
936
|
+
if isinstance(slide_spec, dict):
|
|
937
|
+
_pptx_add_slide(prs, slide_spec, counters)
|
|
938
|
+
if isinstance(spec.get("core"), dict):
|
|
939
|
+
for key in ("title", "author", "subject", "keywords", "comments"):
|
|
940
|
+
if key in spec["core"]:
|
|
941
|
+
setattr(prs.core_properties, key, str(spec["core"][key]))
|
|
942
|
+
ensure_parent(str(out))
|
|
943
|
+
prs.save(str(out))
|
|
944
|
+
return {"ok": True, "kind": "pptx", "command": "edit", "input": str(input_path), "output": str(out), "replacements": replacements, "added": counters}
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
# ---------------------------------------------------------------------------
|
|
948
|
+
# XLSX
|
|
949
|
+
# ---------------------------------------------------------------------------
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _xlsx_value(value: Any):
|
|
953
|
+
if isinstance(value, str) and value.startswith("="):
|
|
954
|
+
return value
|
|
955
|
+
return value
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _xlsx_cells(worksheet, address: str):
|
|
959
|
+
"""Yield every cell in a single address or an A1:B2 range."""
|
|
960
|
+
from openpyxl.utils import range_boundaries
|
|
961
|
+
|
|
962
|
+
if ":" not in address:
|
|
963
|
+
yield worksheet[address]
|
|
964
|
+
return
|
|
965
|
+
min_col, min_row, max_col, max_row = range_boundaries(address)
|
|
966
|
+
for row in worksheet.iter_rows(min_col=min_col, min_row=min_row, max_col=max_col, max_row=max_row):
|
|
967
|
+
for cell in row:
|
|
968
|
+
yield cell
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def _xlsx_apply_style(cell, style: dict[str, Any]) -> None:
|
|
972
|
+
from openpyxl.styles import Alignment, Font, PatternFill
|
|
973
|
+
|
|
974
|
+
font = {}
|
|
975
|
+
if style.get("bold"):
|
|
976
|
+
font["bold"] = True
|
|
977
|
+
if style.get("italic"):
|
|
978
|
+
font["italic"] = True
|
|
979
|
+
if style.get("size"):
|
|
980
|
+
font["size"] = float(style["size"])
|
|
981
|
+
if style.get("color"):
|
|
982
|
+
font["color"] = str(style["color"]).lstrip("#").upper()
|
|
983
|
+
if font:
|
|
984
|
+
cell.font = Font(**font)
|
|
985
|
+
if style.get("fill"):
|
|
986
|
+
cell.fill = PatternFill("solid", fgColor=str(style["fill"]).lstrip("#").upper())
|
|
987
|
+
if style.get("number_format"):
|
|
988
|
+
cell.number_format = str(style["number_format"])
|
|
989
|
+
if style.get("align"):
|
|
990
|
+
cell.alignment = Alignment(horizontal=str(style["align"]), vertical="center")
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _xlsx_add_chart(worksheet, spec: dict[str, Any]) -> None:
|
|
994
|
+
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
|
|
995
|
+
|
|
996
|
+
chart_type = str(spec.get("type", "bar")).lower()
|
|
997
|
+
chart = {"bar": BarChart, "column": BarChart, "line": LineChart, "pie": PieChart}.get(chart_type, BarChart)()
|
|
998
|
+
chart.title = str(spec.get("title", ""))
|
|
999
|
+
chart.height = float(spec.get("height", 8))
|
|
1000
|
+
chart.width = float(spec.get("width", 14))
|
|
1001
|
+
data = spec.get("data")
|
|
1002
|
+
if isinstance(data, dict):
|
|
1003
|
+
min_col = int(data.get("min_col", 2))
|
|
1004
|
+
min_row = int(data.get("min_row", 1))
|
|
1005
|
+
max_col = int(data.get("max_col", min_col))
|
|
1006
|
+
max_row = int(data.get("max_row", worksheet.max_row))
|
|
1007
|
+
values = Reference(worksheet, min_col=min_col, min_row=min_row, max_col=max_col, max_row=max_row)
|
|
1008
|
+
chart.add_data(values, titles_from_data=bool(data.get("titles_from_data", True)))
|
|
1009
|
+
categories = data.get("categories")
|
|
1010
|
+
if isinstance(categories, dict):
|
|
1011
|
+
chart.set_categories(Reference(
|
|
1012
|
+
worksheet,
|
|
1013
|
+
min_col=int(categories.get("min_col", 1)),
|
|
1014
|
+
min_row=int(categories.get("min_row", 2)),
|
|
1015
|
+
max_row=int(categories.get("max_row", max_row)),
|
|
1016
|
+
))
|
|
1017
|
+
worksheet.add_chart(chart, str(spec.get("anchor", "E2")))
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def xlsx_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
|
|
1021
|
+
from openpyxl import Workbook
|
|
1022
|
+
from openpyxl.utils import get_column_letter
|
|
1023
|
+
from openpyxl.drawing.image import Image as XLImage
|
|
1024
|
+
|
|
1025
|
+
workbook = Workbook()
|
|
1026
|
+
workbook.remove(workbook.active)
|
|
1027
|
+
counters = {"sheets": 0, "charts": 0, "images": 0}
|
|
1028
|
+
for sheet_spec in spec.get("sheets", []):
|
|
1029
|
+
if not isinstance(sheet_spec, dict):
|
|
1030
|
+
continue
|
|
1031
|
+
title = str(sheet_spec.get("name", f"Sheet{counters['sheets'] + 1}"))[:31]
|
|
1032
|
+
worksheet = workbook.create_sheet(title)
|
|
1033
|
+
counters["sheets"] += 1
|
|
1034
|
+
for row_index, row in enumerate(sheet_spec.get("rows", []), start=1):
|
|
1035
|
+
for column_index, value in enumerate(row, start=1):
|
|
1036
|
+
worksheet.cell(row=row_index, column=column_index, value=_xlsx_value(value))
|
|
1037
|
+
for address, formula in (sheet_spec.get("formulas") or {}).items():
|
|
1038
|
+
worksheet[str(address)] = str(formula)
|
|
1039
|
+
for address, style in (sheet_spec.get("styles") or {}).items():
|
|
1040
|
+
if isinstance(style, dict):
|
|
1041
|
+
for cell in _xlsx_cells(worksheet, str(address)):
|
|
1042
|
+
_xlsx_apply_style(cell, style)
|
|
1043
|
+
for column, width in (sheet_spec.get("widths") or {}).items():
|
|
1044
|
+
worksheet.column_dimensions[str(column)].width = float(width)
|
|
1045
|
+
for row, height in (sheet_spec.get("row_heights") or {}).items():
|
|
1046
|
+
worksheet.row_dimensions[int(row)].height = float(height)
|
|
1047
|
+
if sheet_spec.get("freeze"):
|
|
1048
|
+
worksheet.freeze_panes = str(sheet_spec["freeze"])
|
|
1049
|
+
for merged in sheet_spec.get("merged", []):
|
|
1050
|
+
worksheet.merge_cells(str(merged))
|
|
1051
|
+
for chart_spec in sheet_spec.get("charts", []):
|
|
1052
|
+
if isinstance(chart_spec, dict):
|
|
1053
|
+
_xlsx_add_chart(worksheet, chart_spec)
|
|
1054
|
+
counters["charts"] += 1
|
|
1055
|
+
for image_spec in sheet_spec.get("images", []):
|
|
1056
|
+
if isinstance(image_spec, dict):
|
|
1057
|
+
path = require_file(str(image_spec.get("path", "")), "image")
|
|
1058
|
+
image = XLImage(str(path))
|
|
1059
|
+
if image_spec.get("width"):
|
|
1060
|
+
image.width = int(image_spec["width"])
|
|
1061
|
+
if image_spec.get("height"):
|
|
1062
|
+
image.height = int(image_spec["height"])
|
|
1063
|
+
worksheet.add_image(image, str(image_spec.get("anchor", "A1")))
|
|
1064
|
+
counters["images"] += 1
|
|
1065
|
+
worksheet.sheet_view.showGridLines = bool(sheet_spec.get("gridlines", True))
|
|
1066
|
+
if not workbook.sheetnames:
|
|
1067
|
+
workbook.create_sheet("Sheet1")
|
|
1068
|
+
ensure_parent(str(out))
|
|
1069
|
+
workbook.save(str(out))
|
|
1070
|
+
return {"ok": True, "kind": "xlsx", "command": "create", "output": str(out), "counts": counters}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def xlsx_read(input_path: Path, data_only: bool = False) -> dict[str, Any]:
|
|
1074
|
+
from openpyxl import load_workbook
|
|
1075
|
+
|
|
1076
|
+
workbook = load_workbook(str(input_path), data_only=data_only)
|
|
1077
|
+
sheets = []
|
|
1078
|
+
for worksheet in workbook.worksheets:
|
|
1079
|
+
rows = []
|
|
1080
|
+
for row in worksheet.iter_rows():
|
|
1081
|
+
values = [cell.value for cell in row]
|
|
1082
|
+
while values and values[-1] is None:
|
|
1083
|
+
values.pop()
|
|
1084
|
+
if values:
|
|
1085
|
+
rows.append(values)
|
|
1086
|
+
formulas = []
|
|
1087
|
+
for row in worksheet.iter_rows():
|
|
1088
|
+
for cell in row:
|
|
1089
|
+
if isinstance(cell.value, str) and cell.value.startswith("="):
|
|
1090
|
+
formulas.append({"cell": cell.coordinate, "formula": cell.value, "cached": None})
|
|
1091
|
+
sheets.append({
|
|
1092
|
+
"name": worksheet.title,
|
|
1093
|
+
"dimensions": worksheet.dimensions,
|
|
1094
|
+
"rows": rows,
|
|
1095
|
+
"formulas": formulas,
|
|
1096
|
+
"merged": [str(item) for item in worksheet.merged_cells.ranges],
|
|
1097
|
+
"charts": len(worksheet._charts),
|
|
1098
|
+
"images": len(worksheet._images),
|
|
1099
|
+
})
|
|
1100
|
+
workbook.close()
|
|
1101
|
+
return {
|
|
1102
|
+
"ok": True,
|
|
1103
|
+
"kind": "xlsx",
|
|
1104
|
+
"command": "read",
|
|
1105
|
+
"input": str(input_path),
|
|
1106
|
+
"data_only": data_only,
|
|
1107
|
+
"sheets": sheets,
|
|
1108
|
+
"counts": {
|
|
1109
|
+
"sheets": len(sheets),
|
|
1110
|
+
"formulas": sum(len(sheet["formulas"]) for sheet in sheets),
|
|
1111
|
+
"charts": sum(sheet["charts"] for sheet in sheets),
|
|
1112
|
+
},
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def xlsx_edit(spec: dict[str, Any], input_path: Path, out: Path) -> dict[str, Any]:
|
|
1117
|
+
from openpyxl import load_workbook
|
|
1118
|
+
from openpyxl.drawing.image import Image as XLImage
|
|
1119
|
+
|
|
1120
|
+
workbook = load_workbook(str(input_path))
|
|
1121
|
+
changed = 0
|
|
1122
|
+
for operation in spec.get("operations", []):
|
|
1123
|
+
if not isinstance(operation, dict):
|
|
1124
|
+
continue
|
|
1125
|
+
action = str(operation.get("action", "set"))
|
|
1126
|
+
if action == "add_sheet":
|
|
1127
|
+
title = str(operation.get("name", "Sheet"))
|
|
1128
|
+
worksheet = workbook.create_sheet(title)
|
|
1129
|
+
elif action == "delete_sheet":
|
|
1130
|
+
name = str(operation.get("name", ""))
|
|
1131
|
+
if name not in workbook.sheetnames:
|
|
1132
|
+
fail(f"sheet not found: {name}")
|
|
1133
|
+
workbook.remove(workbook[name])
|
|
1134
|
+
continue
|
|
1135
|
+
elif action == "rename_sheet":
|
|
1136
|
+
old = str(operation.get("from", ""))
|
|
1137
|
+
new = str(operation.get("to", ""))
|
|
1138
|
+
if old not in workbook.sheetnames:
|
|
1139
|
+
fail(f"sheet not found: {old}")
|
|
1140
|
+
workbook[old].title = new
|
|
1141
|
+
continue
|
|
1142
|
+
else:
|
|
1143
|
+
name = str(operation.get("sheet", workbook.sheetnames[0]))
|
|
1144
|
+
if name not in workbook.sheetnames:
|
|
1145
|
+
fail(f"sheet not found: {name}")
|
|
1146
|
+
worksheet = workbook[name]
|
|
1147
|
+
if action == "set_cells":
|
|
1148
|
+
for address, value in (operation.get("cells") or {}).items():
|
|
1149
|
+
worksheet[str(address)] = _xlsx_value(value)
|
|
1150
|
+
changed += 1
|
|
1151
|
+
elif action == "append_rows":
|
|
1152
|
+
for row in operation.get("rows", []):
|
|
1153
|
+
worksheet.append([_xlsx_value(value) for value in row])
|
|
1154
|
+
changed += 1
|
|
1155
|
+
elif action == "style":
|
|
1156
|
+
for address, style in (operation.get("cells") or {}).items():
|
|
1157
|
+
if isinstance(style, dict):
|
|
1158
|
+
for cell in _xlsx_cells(worksheet, str(address)):
|
|
1159
|
+
_xlsx_apply_style(cell, style)
|
|
1160
|
+
changed += 1
|
|
1161
|
+
elif action == "chart":
|
|
1162
|
+
_xlsx_add_chart(worksheet, operation)
|
|
1163
|
+
changed += 1
|
|
1164
|
+
elif action == "image":
|
|
1165
|
+
path = require_file(str(operation.get("path", "")), "image")
|
|
1166
|
+
worksheet.add_image(XLImage(str(path)), str(operation.get("anchor", "A1")))
|
|
1167
|
+
changed += 1
|
|
1168
|
+
elif action == "rows":
|
|
1169
|
+
for row in operation.get("rows", []):
|
|
1170
|
+
worksheet.append([_xlsx_value(value) for value in row])
|
|
1171
|
+
changed += 1
|
|
1172
|
+
ensure_parent(str(out))
|
|
1173
|
+
workbook.save(str(out))
|
|
1174
|
+
workbook.close()
|
|
1175
|
+
return {"ok": True, "kind": "xlsx", "command": "edit", "input": str(input_path), "output": str(out), "changes": changed}
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def xlsx_recalc(input_path: Path, out: Path) -> dict[str, Any]:
|
|
1179
|
+
"""Force Microsoft Excel to recalculate and save cached formula values."""
|
|
1180
|
+
if sys.platform != "win32" or office_binary("xlsx") is None:
|
|
1181
|
+
fail("formula recalculation requires Microsoft Excel on Windows; openpyxl cannot evaluate formulas")
|
|
1182
|
+
script = Path(tempfile.gettempdir()) / f"dsh-office-recalc-{os.getpid()}.ps1"
|
|
1183
|
+
script.write_text(r"""
|
|
1184
|
+
$ErrorActionPreference = 'Stop'
|
|
1185
|
+
$app = New-Object -ComObject Excel.Application
|
|
1186
|
+
$app.Visible = $false
|
|
1187
|
+
$app.DisplayAlerts = $false
|
|
1188
|
+
$wb = $app.Workbooks.Open($env:DSH_INPUT)
|
|
1189
|
+
try {
|
|
1190
|
+
$app.CalculateFull()
|
|
1191
|
+
$wb.SaveAs($env:DSH_OUT, 51)
|
|
1192
|
+
} finally { $wb.Close($false); $app.Quit() }
|
|
1193
|
+
""", encoding="utf-8")
|
|
1194
|
+
env = dict(os.environ)
|
|
1195
|
+
env["DSH_INPUT"] = str(input_path.resolve())
|
|
1196
|
+
env["DSH_OUT"] = str(out.resolve())
|
|
1197
|
+
try:
|
|
1198
|
+
completed = subprocess.run(
|
|
1199
|
+
[_powershell(), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
|
1200
|
+
capture_output=True,
|
|
1201
|
+
text=True,
|
|
1202
|
+
timeout=180,
|
|
1203
|
+
env=env,
|
|
1204
|
+
)
|
|
1205
|
+
finally:
|
|
1206
|
+
script.unlink(missing_ok=True)
|
|
1207
|
+
if completed.returncode != 0 or not out.is_file():
|
|
1208
|
+
fail("Excel recalculation failed", detail=(completed.stderr or completed.stdout).strip() or None)
|
|
1209
|
+
return {"ok": True, "kind": "xlsx", "command": "recalc", "input": str(input_path), "output": str(out)}
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
# ---------------------------------------------------------------------------
|
|
1213
|
+
# Status and dispatch
|
|
1214
|
+
# ---------------------------------------------------------------------------
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def status() -> dict[str, Any]:
|
|
1218
|
+
import importlib
|
|
1219
|
+
import platform
|
|
1220
|
+
|
|
1221
|
+
packages = []
|
|
1222
|
+
for name, module_name in [
|
|
1223
|
+
("python-docx", "docx"),
|
|
1224
|
+
("openpyxl", "openpyxl"),
|
|
1225
|
+
("python-pptx", "pptx"),
|
|
1226
|
+
("pypdf", "pypdf"),
|
|
1227
|
+
("reportlab", "reportlab"),
|
|
1228
|
+
("pdfplumber", "pdfplumber"),
|
|
1229
|
+
("pymupdf", "pymupdf"),
|
|
1230
|
+
("pillow", "PIL"),
|
|
1231
|
+
("latex2mathml", "latex2mathml"),
|
|
1232
|
+
("mathml2omml", "mathml2omml"),
|
|
1233
|
+
]:
|
|
1234
|
+
try:
|
|
1235
|
+
module = importlib.import_module(module_name)
|
|
1236
|
+
packages.append({"package": name, "installed": True, "version": str(getattr(module, "__version__", ""))})
|
|
1237
|
+
except Exception as exc:
|
|
1238
|
+
packages.append({"package": name, "installed": False, "error": f"{type(exc).__name__}: {exc}"})
|
|
1239
|
+
return {
|
|
1240
|
+
"ok": True,
|
|
1241
|
+
"command": "status",
|
|
1242
|
+
"plugin_version": VERSION,
|
|
1243
|
+
"python": {"executable": sys.executable, "version": platform.python_version()},
|
|
1244
|
+
"platform": platform.platform(),
|
|
1245
|
+
"packages": packages,
|
|
1246
|
+
"renderers": {
|
|
1247
|
+
"libreoffice": find_soffice(),
|
|
1248
|
+
"microsoft_office": {
|
|
1249
|
+
"word": office_binary("docx"),
|
|
1250
|
+
"powerpoint": office_binary("pptx"),
|
|
1251
|
+
"excel": office_binary("xlsx"),
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def main() -> None:
|
|
1258
|
+
parser = argparse.ArgumentParser(description="DSH Office Suite helper CLI")
|
|
1259
|
+
subparsers = parser.add_subparsers(dest="format", required=True)
|
|
1260
|
+
|
|
1261
|
+
for kind in ("docx", "pdf", "pptx", "xlsx"):
|
|
1262
|
+
kind_parser = subparsers.add_parser(kind)
|
|
1263
|
+
commands = kind_parser.add_subparsers(dest="command", required=True)
|
|
1264
|
+
|
|
1265
|
+
create = commands.add_parser("create")
|
|
1266
|
+
create.add_argument("--spec", required=True)
|
|
1267
|
+
create.add_argument("--out", required=True)
|
|
1268
|
+
if kind in {"docx", "pptx", "xlsx"}:
|
|
1269
|
+
create.add_argument("--json-out")
|
|
1270
|
+
|
|
1271
|
+
read = commands.add_parser("read")
|
|
1272
|
+
read.add_argument("--input", required=True)
|
|
1273
|
+
read.add_argument("--json-out")
|
|
1274
|
+
if kind == "xlsx":
|
|
1275
|
+
read.add_argument("--data-only", action="store_true")
|
|
1276
|
+
|
|
1277
|
+
render = commands.add_parser("render")
|
|
1278
|
+
render.add_argument("--input", required=True)
|
|
1279
|
+
render.add_argument("--out-dir", required=True)
|
|
1280
|
+
render.add_argument("--dpi", type=int, default=110)
|
|
1281
|
+
render.add_argument("--json-out")
|
|
1282
|
+
|
|
1283
|
+
if kind in {"docx", "pdf", "pptx", "xlsx"}:
|
|
1284
|
+
edit = commands.add_parser("edit")
|
|
1285
|
+
edit.add_argument("--input", required=True)
|
|
1286
|
+
edit.add_argument("--spec", required=True)
|
|
1287
|
+
edit.add_argument("--out", required=True)
|
|
1288
|
+
edit.add_argument("--json-out")
|
|
1289
|
+
|
|
1290
|
+
if kind == "xlsx":
|
|
1291
|
+
recalc = commands.add_parser("recalc")
|
|
1292
|
+
recalc.add_argument("--input", required=True)
|
|
1293
|
+
recalc.add_argument("--out", required=True)
|
|
1294
|
+
recalc.add_argument("--json-out")
|
|
1295
|
+
|
|
1296
|
+
status_parser = subparsers.add_parser("status")
|
|
1297
|
+
|
|
1298
|
+
args = parser.parse_args()
|
|
1299
|
+
try:
|
|
1300
|
+
if args.format == "status":
|
|
1301
|
+
emit(status())
|
|
1302
|
+
return
|
|
1303
|
+
if args.command == "create":
|
|
1304
|
+
spec = load_json(args.spec)
|
|
1305
|
+
out = Path(args.out)
|
|
1306
|
+
if args.format == "docx":
|
|
1307
|
+
result = docx_create(spec, out)
|
|
1308
|
+
elif args.format == "pdf":
|
|
1309
|
+
result = pdf_create(spec, out)
|
|
1310
|
+
elif args.format == "pptx":
|
|
1311
|
+
result = pptx_create(spec, out)
|
|
1312
|
+
else:
|
|
1313
|
+
result = xlsx_create(spec, out)
|
|
1314
|
+
elif args.command == "read":
|
|
1315
|
+
input_path = require_file(args.input)
|
|
1316
|
+
if args.format == "docx":
|
|
1317
|
+
result = docx_read(input_path)
|
|
1318
|
+
elif args.format == "pdf":
|
|
1319
|
+
result = pdf_read(input_path)
|
|
1320
|
+
elif args.format == "pptx":
|
|
1321
|
+
result = pptx_read(input_path)
|
|
1322
|
+
else:
|
|
1323
|
+
result = xlsx_read(input_path, data_only=bool(getattr(args, "data_only", False)))
|
|
1324
|
+
elif args.command == "edit":
|
|
1325
|
+
input_path = require_file(args.input)
|
|
1326
|
+
spec = load_json(args.spec)
|
|
1327
|
+
out = Path(args.out)
|
|
1328
|
+
if args.format == "docx":
|
|
1329
|
+
result = docx_edit(spec, input_path, out)
|
|
1330
|
+
elif args.format == "pdf":
|
|
1331
|
+
result = pdf_edit(spec, input_path, out)
|
|
1332
|
+
elif args.format == "pptx":
|
|
1333
|
+
result = pptx_edit(spec, input_path, out)
|
|
1334
|
+
else:
|
|
1335
|
+
result = xlsx_edit(spec, input_path, out)
|
|
1336
|
+
elif args.command == "render":
|
|
1337
|
+
input_path = require_file(args.input)
|
|
1338
|
+
out_dir = Path(args.out_dir)
|
|
1339
|
+
if args.format == "pdf":
|
|
1340
|
+
result = pdf_render(input_path, out_dir, args.dpi)
|
|
1341
|
+
else:
|
|
1342
|
+
result = render_office(input_path, args.format, out_dir, args.dpi)
|
|
1343
|
+
elif args.command == "recalc":
|
|
1344
|
+
result = xlsx_recalc(require_file(args.input), Path(args.out))
|
|
1345
|
+
else:
|
|
1346
|
+
fail(f"unsupported command: {args.command}")
|
|
1347
|
+
emit(result, getattr(args, "json_out", None))
|
|
1348
|
+
except SystemExit:
|
|
1349
|
+
raise
|
|
1350
|
+
except Exception as exc:
|
|
1351
|
+
fail(f"{type(exc).__name__}: {exc}")
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
if __name__ == "__main__":
|
|
1355
|
+
main()
|