dsh-office-kit 0.3.2 → 0.4.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.
@@ -17,7 +17,7 @@ import os
17
17
  import shutil
18
18
  import subprocess
19
19
  import sys
20
- import tempfile
20
+ import uuid
21
21
  from pathlib import Path
22
22
  from typing import Any, Iterable
23
23
 
@@ -84,197 +84,197 @@ def ensure_parent(path: str) -> Path:
84
84
 
85
85
 
86
86
  # ---------------------------------------------------------------------------
87
- # Shared rendering helpers
87
+ # LibreOffice kit (the renderer DSH ships)
88
88
  # ---------------------------------------------------------------------------
89
89
 
90
-
91
- def find_soffice() -> str | None:
92
- candidates = [
93
- os.environ.get("SOFFICE"),
94
- r"C:\Program Files\LibreOffice\program\soffice.exe",
95
- r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
96
- "/Applications/LibreOffice.app/Contents/MacOS/soffice",
97
- "/usr/bin/soffice",
98
- "/usr/local/bin/soffice",
99
- ]
100
- for candidate in candidates:
101
- if candidate and Path(candidate).is_file():
102
- return candidate
103
- found = shutil.which("soffice")
104
- return found
105
-
106
-
107
- #: Automation entry points per suite and format: (executable, COM ProgID).
108
- RENDER_ENGINES: dict[str, dict[str, tuple[str, str]]] = {
109
- "microsoft": {
110
- "docx": ("WINWORD.EXE", "Word.Application"),
111
- "pptx": ("POWERPNT.EXE", "PowerPoint.Application"),
112
- "xlsx": ("EXCEL.EXE", "Excel.Application"),
113
- },
114
- "kingsoft": {
115
- "docx": ("wps.exe", "KWps.Application"),
116
- "pptx": ("wpp.exe", "KWpp.Application"),
117
- "xlsx": ("et.exe", "Ket.Application"),
118
- },
119
- }
120
-
121
-
122
- def _kingsoft_dirs() -> list[Path]:
123
- """WPS Office hides its binaries under a per-version directory."""
124
- bases = [
125
- os.environ.get("WPS_OFFICE_HOME"),
126
- os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Kingsoft", "WPS Office"),
127
- os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Kingsoft", "WPS Office"),
128
- os.path.join(os.environ.get("LOCALAPPDATA", ""), "Kingsoft", "WPS Office"),
129
- ]
130
- found: list[Path] = []
131
- for base in bases:
132
- if not base:
133
- continue
134
- root = Path(base)
135
- if (root / "office6").is_dir():
136
- found.append(root / "office6")
137
- continue
138
- if not root.is_dir():
139
- continue
140
- versions = [child for child in root.iterdir() if (child / "office6").is_dir()]
141
- versions.sort(key=lambda child: [int(part) if part.isdigit() else part for part in child.name.replace("-", ".").split(".")], reverse=True)
142
- for child in versions:
143
- found.append(child / "office6")
144
- return found
145
-
146
-
147
- def office_engine(kind: str, family: str | None = None) -> tuple[str, str] | None:
148
- """Return (suite, executable) for an installed Office-compatible application."""
149
- if sys.platform != "win32":
90
+ #: Where the kit's CLI lives inside any node_modules tree that hosts this plugin.
91
+ KIT_RELATIVE = Path("node_modules") / "@deepseek-ai" / "libreoffice-kit" / "lib" / "cli.js"
92
+
93
+ #: Actionable explanation used whenever the kit cannot be located.
94
+ KIT_MISSING = (
95
+ "the LibreOffice renderer is not available: the DSH LibreOffice kit was not found. "
96
+ "It ships with DeepSeek Harness as @deepseek-ai/libreoffice-kit; install the plugin "
97
+ "inside the harness tree, or point DSH_LIBREOFFICE_KIT_CLI at <kit>/lib/cli.js"
98
+ )
99
+
100
+
101
+ def find_kit() -> tuple[str, str] | None:
102
+ """Return (node, kit CLI) for the bundled renderer, or None when it is absent.
103
+
104
+ Resolution walks up from this file, which covers both layouts that matter:
105
+ a plugin-local dependency (`<plugin>/node_modules/...`) and the harness
106
+ install (`<dsh>/node_modules/dsh-office-kit/../../@deepseek-ai/...`).
107
+ """
108
+ candidates: list[Path] = []
109
+ override = os.environ.get("DSH_LIBREOFFICE_KIT_CLI")
110
+ if override:
111
+ candidates.append(Path(override).expanduser())
112
+ for root in (PACKAGE_ROOT, *PACKAGE_ROOT.parents):
113
+ candidates.append(root / KIT_RELATIVE)
114
+ cli = next((candidate for candidate in candidates if candidate.is_file()), None)
115
+ if cli is None:
150
116
  return None
151
- families = [family] if family else ["microsoft", "kingsoft"]
152
- for name in families:
153
- executable = RENDER_ENGINES[name][kind][0]
154
- if name == "microsoft":
155
- roots = [
156
- r"C:\Program Files\Microsoft Office\root\Office16",
157
- r"C:\Program Files (x86)\Microsoft Office\root\Office16",
158
- r"C:\Program Files\Microsoft Office\Office16",
159
- r"C:\Program Files (x86)\Microsoft Office\Office16",
160
- ]
161
- for root in roots:
162
- candidate = Path(root) / executable
163
- if candidate.is_file():
164
- return name, str(candidate)
165
- continue
166
- for directory in _kingsoft_dirs():
167
- candidate = directory / executable
168
- if candidate.is_file():
169
- return name, str(candidate)
170
- return None
171
-
117
+ node = os.environ.get("DSH_NODE") or shutil.which("node")
118
+ if node is None:
119
+ return None
120
+ return node, str(cli)
172
121
 
173
- def office_binary(kind: str) -> str | None:
174
- engine = office_engine(kind)
175
- return engine[1] if engine else None
176
122
 
123
+ def _kit_call(arguments: list[str], timeout: int = 300) -> dict[str, Any]:
124
+ """Run one kit command and return its JSON report, failing with the kit's own words."""
125
+ kit = find_kit()
126
+ if kit is None:
127
+ fail(KIT_MISSING)
128
+ node, cli = kit
129
+ try:
130
+ completed = subprocess.run(
131
+ [node, cli, *arguments],
132
+ capture_output=True,
133
+ text=True,
134
+ # The kit always speaks UTF-8; the locale code page would mangle CJK paths.
135
+ encoding="utf-8",
136
+ errors="replace",
137
+ timeout=timeout,
138
+ )
139
+ except subprocess.TimeoutExpired:
140
+ fail(f"the LibreOffice kit timed out after {timeout}s ({arguments[0]})")
141
+ except OSError as exc:
142
+ fail(f"cannot start the LibreOffice kit: {exc}", detail=KIT_MISSING)
143
+ if completed.returncode != 0:
144
+ detail = (completed.stderr or completed.stdout).strip()[:4000]
145
+ fail(f"the LibreOffice kit could not run '{arguments[0]}'", detail=detail or None)
146
+ try:
147
+ payload = json.loads(completed.stdout)
148
+ except json.JSONDecodeError:
149
+ fail(
150
+ f"the LibreOffice kit printed no JSON report for '{arguments[0]}'",
151
+ detail=(completed.stdout or completed.stderr).strip()[:800] or None,
152
+ )
153
+ if not isinstance(payload, dict):
154
+ fail(f"the LibreOffice kit printed an unexpected report for '{arguments[0]}'")
155
+ return payload
156
+
157
+
158
+ def kit_report() -> dict[str, Any]:
159
+ """Describe the kit without ever failing: status output must survive a missing runtime."""
160
+ kit = find_kit()
161
+ if kit is None:
162
+ return {"available": False, "remedy": KIT_MISSING}
163
+ node, cli = kit
164
+ report: dict[str, Any] = {"available": True, "node": node, "cli": cli, "backend": None, "version": None}
165
+ try:
166
+ completed = subprocess.run(
167
+ [node, cli, "capabilities", "--json"],
168
+ capture_output=True,
169
+ text=True,
170
+ encoding="utf-8",
171
+ errors="replace",
172
+ timeout=120,
173
+ )
174
+ except (OSError, subprocess.SubprocessError) as exc:
175
+ report["error"] = f"{type(exc).__name__}: {exc}"
176
+ return report
177
+ if completed.returncode != 0:
178
+ report["error"] = ((completed.stderr or completed.stdout).strip() or f"capabilities exited {completed.returncode}")[:800]
179
+ return report
180
+ try:
181
+ runtime = (json.loads(completed.stdout) or {}).get("runtime") or {}
182
+ except (json.JSONDecodeError, AttributeError):
183
+ report["error"] = "capabilities printed no JSON"
184
+ return report
185
+ report["backend"] = runtime.get("backend")
186
+ report["version"] = runtime.get("version")
187
+ return report
188
+
189
+
190
+ def glyph_warnings(missing: list[str]) -> list[str]:
191
+ """Turn the kit's missing-font list into one warning a caller can act on."""
192
+ if not missing:
193
+ return []
194
+ return [
195
+ f"{len(missing)} font(s) used by this file are not installed: {', '.join(missing)}. "
196
+ "The file rendered, but that text uses a fallback font and may reflow or show boxes; "
197
+ "a zero exit code only means the renderer ran."
198
+ ]
177
199
 
178
- def _engine_path(kind: str, family: str) -> str | None:
179
- engine = office_engine(kind, family)
180
- return engine[1] if engine else None
181
200
 
201
+ def report_warnings(warnings: list[str]) -> None:
202
+ for warning in warnings:
203
+ print(f"warning: {warning}", file=sys.stderr)
182
204
 
183
- def _powershell() -> str | None:
184
- return shutil.which("powershell") or shutil.which("pwsh")
185
205
 
206
+ def export_pdf(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
207
+ """Convert to PDF through the kit. `dpi` is accepted for signature compatibility."""
208
+ del dpi
209
+ out_dir.mkdir(parents=True, exist_ok=True)
210
+ pdf = out_dir / (input_path.stem + ".pdf")
211
+ report = _kit_call(["convert", "--input", str(input_path), "--output", str(pdf)])
212
+ produced = Path(report.get("outputPath") or pdf)
213
+ if not produced.is_file():
214
+ fail(f"the LibreOffice kit reported a PDF export for {input_path.name} but wrote none")
215
+ return {
216
+ "pdf": str(produced),
217
+ "missing_fonts": [str(name) for name in report.get("missingFonts") or []],
218
+ }
186
219
 
187
- def _office_script(kind: str, family: str) -> str:
188
- progid = RENDER_ENGINES[family][kind][1]
189
- if kind == "docx":
190
- if family == "kingsoft":
191
- return rf"""
192
- $ErrorActionPreference = 'Stop'
193
- $app = New-Object -ComObject {progid}
194
- $app.Visible = $false
195
- $app.DisplayAlerts = 0
196
- $doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
197
- try {{ $doc.SaveAs([ref]$env:DSH_PDF, [ref]17) }} finally {{ $doc.Close(0); $app.Quit() }}
198
- """
199
- return rf"""
200
- $ErrorActionPreference = 'Stop'
201
- $app = New-Object -ComObject {progid}
202
- $app.Visible = $false
203
- $app.DisplayAlerts = 0
204
- $doc = $app.Documents.Open($env:DSH_INPUT, $false, $true)
205
- try {{ $doc.ExportAsFixedFormat($env:DSH_PDF, 17) }} finally {{ $doc.Close(0); $app.Quit() }}
206
- """
207
- if kind == "pptx":
208
- return rf"""
209
- $ErrorActionPreference = 'Stop'
210
- $app = New-Object -ComObject {progid}
211
- $app.DisplayAlerts = 1
212
- $pres = $app.Presentations.Open($env:DSH_INPUT, $true, $false, $false)
213
- try {{ $pres.SaveAs($env:DSH_PDF, 32) }} finally {{ $pres.Close(); $app.Quit() }}
214
- """
215
- return rf"""
216
- $ErrorActionPreference = 'Stop'
217
- $app = New-Object -ComObject {progid}
218
- $app.Visible = $false
219
- $app.DisplayAlerts = $false
220
- $wb = $app.Workbooks.Open($env:DSH_INPUT)
221
- try {{ $wb.ExportAsFixedFormat(0, $env:DSH_PDF) }} finally {{ $wb.Close($false); $app.Quit() }}
222
- """
223
220
 
221
+ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
222
+ """Rasterise an Office file page by page through the kit.
224
223
 
225
- def export_pdf(input_path: Path, kind: str, out_dir: Path) -> tuple[str, str]:
226
- """Export an Office file to PDF. Returns (renderer, pdf path)."""
224
+ The kit creates its output directory itself and refuses an existing one, so
225
+ render into a staging directory and move the artifacts into the caller's
226
+ directory, which may be reused across renders.
227
+ """
227
228
  out_dir.mkdir(parents=True, exist_ok=True)
228
- pdf = out_dir / (input_path.stem + ".pdf")
229
- if pdf.exists():
230
- pdf.unlink()
231
-
232
- power_shell = _powershell()
233
- failures: list[str] = []
234
- if power_shell:
235
- for family, renderer in (("microsoft", "microsoft-office"), ("kingsoft", "kingsoft-wps")):
236
- engine = office_engine(kind, family)
237
- if engine is None:
238
- continue
239
- script = Path(tempfile.gettempdir()) / f"dsh-office-{kind}-{os.getpid()}.ps1"
240
- script.write_text(_office_script(kind, family), encoding="utf-8")
241
- env = dict(os.environ)
242
- env["DSH_INPUT"] = str(input_path.resolve())
243
- env["DSH_PDF"] = str(pdf.resolve())
244
- try:
245
- completed = subprocess.run(
246
- [power_shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
247
- capture_output=True,
248
- text=True,
249
- timeout=240,
250
- env=env,
251
- )
252
- except subprocess.TimeoutExpired:
253
- failures.append(f"{family}: timed out")
254
- continue
255
- finally:
256
- script.unlink(missing_ok=True)
257
- if completed.returncode == 0 and pdf.is_file():
258
- return renderer, str(pdf)
259
- failures.append(f"{family}: " + ((completed.stderr or completed.stdout).strip() or "no PDF produced"))
260
-
261
- soffice = find_soffice()
262
- if soffice:
263
- completed = subprocess.run(
264
- [soffice, "--headless", "--norestore", "--convert-to", "pdf", "--outdir", str(out_dir), str(input_path)],
265
- capture_output=True,
266
- text=True,
267
- timeout=240,
268
- )
269
- if completed.returncode != 0 or not pdf.is_file():
270
- detail = (completed.stderr or completed.stdout).strip()
271
- fail(f"LibreOffice export failed for {input_path.name}", detail=detail or None)
272
- return "libreoffice", str(pdf)
273
-
274
- fail(
275
- "no document renderer found: install LibreOffice, Microsoft Office, or WPS Office",
276
- detail="; ".join(failures) or None,
277
- )
229
+ staging = out_dir / f".kit-render-{os.getpid()}-{uuid.uuid4().hex[:8]}"
230
+ report = _kit_call(["render", "--input", str(input_path), "--output-dir", str(staging), "--dpi", str(dpi)])
231
+ images = []
232
+ for item in report.get("images") or []:
233
+ if not item.get("path"):
234
+ continue
235
+ source = Path(item["path"])
236
+ target = out_dir / source.name
237
+ os.replace(source, target)
238
+ images.append({
239
+ "page": item.get("page"),
240
+ "sheet": item.get("sheet"),
241
+ "range": item.get("range"),
242
+ "width": item.get("width"),
243
+ "height": item.get("height"),
244
+ "bytes": item.get("byteLength"),
245
+ "path": str(target),
246
+ })
247
+ manifest = staging / "manifest.json"
248
+ if manifest.is_file():
249
+ os.replace(manifest, out_dir / manifest.name)
250
+ shutil.rmtree(staging, ignore_errors=True)
251
+ if not images:
252
+ fail(f"the LibreOffice kit rendered no pages for {input_path.name}")
253
+ missing = [str(name) for name in report.get("missingFonts") or []]
254
+ warnings = glyph_warnings(missing)
255
+ report_warnings(warnings)
256
+ result: dict[str, Any] = {
257
+ "ok": True,
258
+ "kind": kind,
259
+ "input": str(input_path),
260
+ "renderer": "libreoffice-kit",
261
+ "engine": report.get("backend"),
262
+ "page_count": len(images),
263
+ "pages": [image["path"] for image in images],
264
+ "images": images,
265
+ "missing_fonts": missing,
266
+ "glyph_fallback": bool(missing),
267
+ "warnings": warnings,
268
+ }
269
+ if emit_pdf:
270
+ converted = export_pdf(input_path, out_dir, dpi)
271
+ result["pdf"] = converted["pdf"]
272
+ if not missing and converted["missing_fonts"]:
273
+ result["missing_fonts"] = converted["missing_fonts"]
274
+ result["glyph_fallback"] = True
275
+ result["warnings"] = glyph_warnings(converted["missing_fonts"])
276
+ report_warnings(result["warnings"])
277
+ return result
278
278
 
279
279
 
280
280
  def rasterize_pdf(pdf: str, out_dir: Path, dpi: int = 110) -> list[str]:
@@ -300,20 +300,6 @@ def rasterize_pdf(pdf: str, out_dir: Path, dpi: int = 110) -> list[str]:
300
300
  return pages
301
301
 
302
302
 
303
- def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int) -> dict[str, Any]:
304
- renderer, pdf = export_pdf(input_path, kind, out_dir)
305
- pages = rasterize_pdf(pdf, out_dir, dpi)
306
- return {
307
- "ok": True,
308
- "kind": kind,
309
- "input": str(input_path),
310
- "renderer": renderer,
311
- "pdf": pdf,
312
- "page_count": len(pages),
313
- "pages": pages,
314
- }
315
-
316
-
317
303
  # ---------------------------------------------------------------------------
318
304
  # DOCX
319
305
  # ---------------------------------------------------------------------------
@@ -584,35 +570,130 @@ def docx_read(input_path: Path) -> dict[str, Any]:
584
570
  # ---------------------------------------------------------------------------
585
571
 
586
572
 
587
- def _pdf_font() -> str:
573
+ #: TrueType CJK candidates. ReportLab's TTFont only handles glyf outlines, so a
574
+ #: CFF/PostScript TTC such as NotoSansCJK raises on registration; it is listed
575
+ #: after the TrueType fonts and its failure is not treated as success.
576
+ _PDF_CJK_CANDIDATES = [
577
+ r"C:\Windows\Fonts\msyh.ttc",
578
+ r"C:\Windows\Fonts\msyh.ttf",
579
+ r"C:\Windows\Fonts\simhei.ttf",
580
+ r"C:\Windows\Fonts\simsun.ttc",
581
+ "/System/Library/Fonts/PingFang.ttc",
582
+ "/System/Library/Fonts/STHeiti Light.ttc",
583
+ "/usr/share/fonts/truetype/winfonts/msyh.ttc",
584
+ "/usr/share/fonts/truetype/winfonts/msyhl.ttc",
585
+ "/usr/share/fonts/truetype/winfonts/simhei.ttf",
586
+ "/usr/share/fonts/truetype/winfonts/NotoSansSC-VF.ttf",
587
+ "/usr/share/fonts/truetype/winfonts/Deng.ttf",
588
+ "/usr/share/fonts/fonts-gb/GB_ST_GB18030.ttf",
589
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
590
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
591
+ "/usr/share/fonts/truetype/arphic/uming.ttc",
592
+ "/usr/share/fonts/truetype/arphic/ukai.ttc",
593
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
594
+ ]
595
+
596
+ #: Probes used to decide whether a registered face can actually draw CJK text.
597
+ _CJK_PROBE_CODEPOINTS = (0x4E2D, 0x6587, 0x9A8C, 0x8BC1) # 中文验证
598
+
599
+ #: Fonts that register successfully but carry no CJK glyphs. Using one for CJK
600
+ #: text produces blank boxes and a text layer of NUL characters, so it is only
601
+ #: ever a last resort for purely Latin content.
602
+ _LATIN_FALLBACK_FONTS = [
603
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
604
+ ]
605
+
606
+
607
+ def _font_draws_cjk(face) -> bool:
608
+ """Whether a registered reportlab face maps any probe codepoint to a glyph."""
609
+ glyphs = getattr(face, "charToGlyph", None)
610
+ if not isinstance(glyphs, dict):
611
+ return False
612
+ return all(glyphs.get(codepoint) for codepoint in _CJK_PROBE_CODEPOINTS)
613
+
614
+
615
+ def _register_font(font_name: str, path: str) -> bool:
616
+ """Register one font file under ``font_name``; TTC files use their first face."""
588
617
  from reportlab.pdfbase import pdfmetrics
589
618
  from reportlab.pdfbase.ttfonts import TTFont
590
619
 
591
- candidates = [
592
- r"C:\Windows\Fonts\msyh.ttc",
593
- r"C:\Windows\Fonts\msyh.ttf",
594
- r"C:\Windows\Fonts\simsun.ttc",
595
- "/System/Library/Fonts/PingFang.ttc",
596
- "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
597
- "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
598
- "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
599
- "/usr/share/fonts/truetype/arphic/uming.ttc",
600
- "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
601
- ]
602
- for candidate in candidates:
603
- if Path(candidate).is_file():
604
- try:
605
- pdfmetrics.registerFont(TTFont("OfficeCJK", candidate))
606
- return "OfficeCJK"
607
- except Exception:
608
- continue
620
+ attempts = [{"subfontIndex": 0}, {}] if path.lower().endswith(".ttc") else [{}]
621
+ for extra in attempts:
622
+ try:
623
+ pdfmetrics.registerFont(TTFont(font_name, path, **extra))
624
+ return True
625
+ except Exception:
626
+ continue
627
+ return False
628
+
629
+
630
+ class PdfFontError(RuntimeError):
631
+ """Raised when the content needs glyphs no available font can draw."""
632
+
633
+
634
+ def _pdf_font(required_text: str = "") -> str:
635
+ """Pick a font that can actually draw ``required_text``.
636
+
637
+ Registration success alone is not enough: DejaVuSans registers cleanly and
638
+ still renders every CJK character as an empty box, so each candidate is
639
+ probed for real glyph coverage before it is accepted. When the content needs
640
+ CJK and no font supplies it, this fails instead of emitting NUL text.
641
+ """
642
+ from reportlab.pdfbase import pdfmetrics
643
+
644
+ needs_cjk = any(ord(character) > 0x2E7F for character in required_text)
645
+ for candidate in _PDF_CJK_CANDIDATES:
646
+ if not Path(candidate).is_file():
647
+ continue
648
+ if not _register_font("OfficeCJK", candidate):
649
+ continue
650
+ if not needs_cjk or _font_draws_cjk(pdfmetrics.getFont("OfficeCJK").face):
651
+ return "OfficeCJK"
609
652
  try:
610
653
  from reportlab.pdfbase.cidfonts import UnicodeCIDFont
611
654
 
612
655
  pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
613
656
  return "STSong-Light"
614
657
  except Exception:
615
- return "Helvetica"
658
+ pass
659
+ if needs_cjk:
660
+ raise PdfFontError(
661
+ "no font with CJK glyphs is available: the content contains CJK text but every "
662
+ "candidate font is missing or has no CJK coverage (ReportLab cannot load CFF/PostScript "
663
+ "outlines such as NotoSansCJK-Regular.ttc). Install a TrueType CJK font, for example "
664
+ "fonts-wqy-zenhei or fonts-noto-cjk, and retry."
665
+ )
666
+ for candidate in _LATIN_FALLBACK_FONTS:
667
+ if Path(candidate).is_file() and _register_font("OfficeLatin", candidate):
668
+ return "OfficeLatin"
669
+ return "Helvetica"
670
+
671
+
672
+ def _pdf_blocks_text(blocks: Iterable[dict[str, Any]]) -> str:
673
+ """Collect the literal text of PDF blocks so font choice can match the content."""
674
+ parts: list[str] = []
675
+
676
+ def visit(value: Any) -> None:
677
+ if isinstance(value, dict):
678
+ for key in ("text", "title", "subtitle"):
679
+ if isinstance(value.get(key), str):
680
+ parts.append(value[key])
681
+ for key in ("headers", "items"):
682
+ for item in value.get(key, []) or []:
683
+ if isinstance(item, str):
684
+ parts.append(item)
685
+ for row in value.get("rows", []) or []:
686
+ for cell in row if isinstance(row, (list, tuple)) else [row]:
687
+ if isinstance(cell, str):
688
+ parts.append(cell)
689
+ for key in ("blocks", "pages"):
690
+ visit(value.get(key, []))
691
+ elif isinstance(value, (list, tuple)):
692
+ for item in value:
693
+ visit(item)
694
+
695
+ visit(list(blocks))
696
+ return "".join(parts)
616
697
 
617
698
 
618
699
  def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[str, int]):
@@ -680,19 +761,8 @@ def pdf_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
680
761
  from reportlab.lib.units import inch
681
762
  from reportlab.platypus import SimpleDocTemplate
682
763
 
683
- font = _pdf_font()
684
764
  size = A4 if str(spec.get("page_size", "Letter")).lower() == "a4" else letter
685
765
  ensure_parent(str(out))
686
- document = SimpleDocTemplate(
687
- str(out),
688
- pagesize=size,
689
- leftMargin=0.85 * inch,
690
- rightMargin=0.85 * inch,
691
- topMargin=0.8 * inch,
692
- bottomMargin=0.8 * inch,
693
- title=str(spec.get("title", "")),
694
- author=str(spec.get("author", "")),
695
- )
696
766
  counters = {key: 0 for key in ("headings", "paragraphs", "list_items", "tables", "images")}
697
767
  following: list[dict[str, Any]] = []
698
768
  for page in spec.get("pages", []):
@@ -709,6 +779,19 @@ def pdf_create(spec: dict[str, Any], out: Path) -> dict[str, Any]:
709
779
  if spec.get("subtitle"):
710
780
  blocks.append({"type": "paragraph", "text": spec["subtitle"]})
711
781
  blocks.extend(following)
782
+ # Choose the font against the text that will actually be drawn, so CJK content
783
+ # can never silently fall back to a Latin-only face.
784
+ font = _pdf_font(_pdf_blocks_text(blocks))
785
+ document = SimpleDocTemplate(
786
+ str(out),
787
+ pagesize=size,
788
+ leftMargin=0.85 * inch,
789
+ rightMargin=0.85 * inch,
790
+ topMargin=0.8 * inch,
791
+ bottomMargin=0.8 * inch,
792
+ title=str(spec.get("title", "")),
793
+ author=str(spec.get("author", "")),
794
+ )
712
795
  document.build(_pdf_flowables(blocks, font, counters))
713
796
  return {"ok": True, "kind": "pdf", "command": "create", "output": str(out), "font": font, "counts": counters}
714
797
 
@@ -1130,38 +1213,14 @@ def xlsx_read(input_path: Path, data_only: bool = False) -> dict[str, Any]:
1130
1213
 
1131
1214
 
1132
1215
  def xlsx_recalc(input_path: Path, out: Path) -> dict[str, Any]:
1133
- """Force a spreadsheet application to recalculate and store cached formula values."""
1134
- engine = office_engine("xlsx") if sys.platform == "win32" else None
1135
- if engine is None or _powershell() is None:
1136
- fail("formula recalculation requires Microsoft Excel or WPS Spreadsheets on Windows; openpyxl cannot evaluate formulas")
1137
- family, executable = engine
1138
- progid = RENDER_ENGINES[family]["xlsx"][1]
1216
+ """Recalculate formulas through the LibreOffice kit and store the cached values."""
1139
1217
  ensure_parent(str(out))
1140
- if out.resolve() != input_path.resolve():
1141
- shutil.copyfile(input_path, out)
1142
- script = Path(tempfile.gettempdir()) / f"dsh-office-recalc-{os.getpid()}.ps1"
1143
- script.write_text(rf"""
1144
- $ErrorActionPreference = 'Stop'
1145
- $app = New-Object -ComObject {progid}
1146
- $app.Visible = $false
1147
- $app.DisplayAlerts = $false
1148
- $wb = $app.Workbooks.Open($env:DSH_OUT)
1149
- try {{ $app.CalculateFull(); $wb.Save() }} finally {{ $wb.Close($true); $app.Quit() }}
1150
- """, encoding="utf-8")
1151
- env = dict(os.environ)
1152
- env["DSH_OUT"] = str(out.resolve())
1153
- try:
1154
- completed = subprocess.run(
1155
- [_powershell(), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)],
1156
- capture_output=True,
1157
- text=True,
1158
- timeout=180,
1159
- env=env,
1160
- )
1161
- finally:
1162
- script.unlink(missing_ok=True)
1163
- if completed.returncode != 0 or not out.is_file():
1164
- fail("formula recalculation failed", detail=(completed.stderr or completed.stdout).strip() or None)
1218
+ staging = out.with_name(f"{out.name}.recalc-{os.getpid()}.xlsx")
1219
+ report = _kit_call(["recalculate", "--input", str(input_path), "--output", str(staging)])
1220
+ produced = Path(report.get("outputPath") or staging)
1221
+ if not produced.is_file():
1222
+ fail("the LibreOffice kit reported a recalculation but wrote no workbook")
1223
+ os.replace(produced, out)
1165
1224
  cached = 0
1166
1225
  try:
1167
1226
  import openpyxl
@@ -1179,8 +1238,10 @@ try {{ $app.CalculateFull(); $wb.Save() }} finally {{ $wb.Close($true); $app.Qui
1179
1238
  "command": "recalc",
1180
1239
  "input": str(input_path),
1181
1240
  "output": str(out),
1182
- "renderer": f"{family}-office" if family == "microsoft" else "kingsoft-wps",
1183
- "executable": executable,
1241
+ "renderer": "libreoffice-kit",
1242
+ "engine": report.get("backend"),
1243
+ "executable": find_kit()[1] if find_kit() else None,
1244
+ "missing_fonts": [str(name) for name in report.get("missingFonts") or []],
1184
1245
  "cached_values": cached,
1185
1246
  }
1186
1247
 
@@ -1212,6 +1273,9 @@ def status() -> dict[str, Any]:
1212
1273
  packages.append({"package": name, "installed": True, "version": str(getattr(module, "__version__", ""))})
1213
1274
  except Exception as exc:
1214
1275
  packages.append({"package": name, "installed": False, "error": f"{type(exc).__name__}: {exc}"})
1276
+ installed = {item["package"]: item["installed"] for item in packages}
1277
+ kit = kit_report()
1278
+ ready = bool(kit.get("available") and kit.get("backend"))
1215
1279
  return {
1216
1280
  "ok": True,
1217
1281
  "command": "status",
@@ -1220,23 +1284,14 @@ def status() -> dict[str, Any]:
1220
1284
  "platform": platform.platform(),
1221
1285
  "packages": packages,
1222
1286
  "renderers": {
1223
- "libreoffice": find_soffice(),
1224
- "microsoft_office": {
1225
- "word": _engine_path("docx", "microsoft"),
1226
- "powerpoint": _engine_path("pptx", "microsoft"),
1227
- "excel": _engine_path("xlsx", "microsoft"),
1228
- },
1229
- "kingsoft_wps": {
1230
- "writer": _engine_path("docx", "kingsoft"),
1231
- "presentation": _engine_path("pptx", "kingsoft"),
1232
- "spreadsheet": _engine_path("xlsx", "kingsoft"),
1233
- },
1287
+ "kit": kit,
1234
1288
  "can_render": {
1235
- "docx": bool(office_engine("docx") or find_soffice()),
1236
- "pptx": bool(office_engine("pptx") or find_soffice()),
1237
- "xlsx": bool(office_engine("xlsx") or find_soffice()),
1238
- "pdf": True,
1289
+ "docx": ready,
1290
+ "pptx": ready,
1291
+ "xlsx": ready,
1292
+ "pdf": bool(installed.get("pymupdf")),
1239
1293
  },
1294
+ "can_recalculate": {"xlsx": ready},
1240
1295
  },
1241
1296
  }
1242
1297
 
@@ -1266,6 +1321,8 @@ def main() -> None:
1266
1321
  render.add_argument("--out-dir", required=True)
1267
1322
  render.add_argument("--dpi", type=int, default=110)
1268
1323
  render.add_argument("--json-out")
1324
+ if kind in {"docx", "pptx", "xlsx"}:
1325
+ render.add_argument("--pdf", action="store_true", help="also export a PDF next to the page images")
1269
1326
 
1270
1327
  if kind == "xlsx":
1271
1328
  recalc = commands.add_parser("recalc")
@@ -1307,7 +1364,7 @@ def main() -> None:
1307
1364
  if args.format == "pdf":
1308
1365
  result = pdf_render(input_path, out_dir, args.dpi)
1309
1366
  else:
1310
- result = render_office(input_path, args.format, out_dir, args.dpi)
1367
+ result = render_office(input_path, args.format, out_dir, args.dpi, emit_pdf=bool(getattr(args, "pdf", False)))
1311
1368
  elif args.command == "recalc":
1312
1369
  result = xlsx_recalc(require_file(args.input), Path(args.out))
1313
1370
  else: