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.
- package/CAPABILITY.md +23 -20
- package/README.md +1 -1
- package/assets/skills/office-documents/SKILL.md +1 -1
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +1 -1
- package/assets/skills/office-documents/render_docx.py +10 -5
- package/assets/skills/office-documents/scripts/render_and_diff.py +1 -1
- package/assets/skills/office-documents/tasks/verify_render.md +4 -4
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +14 -15
- package/assets/skills/office-pdf/SKILL.md +1 -1
- package/assets/skills/office-presentations/SKILL.md +8 -7
- package/assets/skills/office-presentations/container_tools/render_slides.py +5 -5
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +5 -6
- package/assets/skills/office-spreadsheets/SKILL.md +3 -3
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +5 -5
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +3 -3
- package/assets/skills/shared/annotation-recipes.md +6 -7
- package/assets/skills/shared/check_runtime.py +19 -14
- package/assets/skills/shared/common-workflow.md +3 -3
- package/dist/index.js +107 -68
- package/package.json +2 -1
- package/postinstall.mjs +26 -10
- package/python/office_cli.py +331 -273
package/python/office_cli.py
CHANGED
|
@@ -17,7 +17,7 @@ import os
|
|
|
17
17
|
import shutil
|
|
18
18
|
import subprocess
|
|
19
19
|
import sys
|
|
20
|
-
import
|
|
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
|
-
#
|
|
87
|
+
# LibreOffice kit (the renderer DSH ships)
|
|
88
88
|
# ---------------------------------------------------------------------------
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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":
|
|
1183
|
-
"
|
|
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
|
-
"
|
|
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":
|
|
1236
|
-
"pptx":
|
|
1237
|
-
"xlsx":
|
|
1238
|
-
"pdf":
|
|
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:
|