dsh-office-kit 0.3.3 → 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.
- 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 +13 -16
- 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 +79 -59
- package/package.json +2 -1
- package/postinstall.mjs +26 -10
- package/python/office_cli.py +330 -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,197 @@ 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
|
-
"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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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)
|
|
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":
|
|
1183
|
-
"
|
|
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
|
-
"
|
|
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":
|
|
1236
|
-
"pptx":
|
|
1237
|
-
"xlsx":
|
|
1238
|
-
"pdf":
|
|
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:
|