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.
@@ -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()