dsh-office-kit 0.3.3 → 0.4.1

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