232 lines
9.1 KiB
Python
232 lines
9.1 KiB
Python
"""Office 文档转 PDF(host-side LibreOffice)。
|
||
|
||
Docker 沙盒内不安装 LibreOffice;本工具运行在 backend host,把用户工作区内已有的
|
||
DOCX/PPTX/XLSX/ODF 文件交给 host `soffice --headless` 转成 PDF。新写的 Markdown
|
||
报告不走这里,统一由沙盒平台渲染器 `rendering/render.py --format pdf` 直出。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
from uuid import uuid4
|
||
|
||
from core.artifacts import ArtifactRef, ToolExecutionResult, resolve_artifact_path
|
||
from tools.base import FileOutOfBounds, Tool
|
||
from web.pptx_render import SofficeNotFoundError, find_soffice
|
||
|
||
|
||
_FAMILY_SUFFIXES = {
|
||
"Writer": frozenset({".doc", ".docx", ".odt"}),
|
||
"Calc": frozenset({".xls", ".xlsx", ".ods"}),
|
||
"Impress": frozenset({".ppt", ".pptx", ".odp"}),
|
||
}
|
||
_KNOWN_SUFFIXES = frozenset().union(*_FAMILY_SUFFIXES.values())
|
||
_DEBIAN_PACKAGES = {
|
||
"Writer": ("libreoffice-writer", "libreoffice-writer-nogui"),
|
||
"Calc": ("libreoffice-calc", "libreoffice-calc-nogui"),
|
||
"Impress": ("libreoffice-impress", "libreoffice-impress-nogui"),
|
||
}
|
||
_DEFAULT_TIMEOUT = 120
|
||
|
||
|
||
def _dpkg_package_installed(package: str) -> bool:
|
||
try:
|
||
proc = subprocess.run(
|
||
["dpkg-query", "-W", "-f=${Status}", package],
|
||
capture_output=True,
|
||
timeout=5,
|
||
check=False,
|
||
text=True,
|
||
)
|
||
except (OSError, subprocess.TimeoutExpired):
|
||
return False
|
||
return proc.returncode == 0 and "install ok installed" in proc.stdout
|
||
|
||
|
||
def _debian_office_families(soffice: str) -> Optional[frozenset[str]]:
|
||
"""标准 Debian/Ubuntu 包安装时返回已安装组件;其他安装形态返回 None。"""
|
||
if os.name != "posix" or shutil.which("dpkg-query") is None:
|
||
return None
|
||
try:
|
||
resolved = Path(soffice).resolve()
|
||
except OSError:
|
||
return None
|
||
if not resolved.is_relative_to("/usr"):
|
||
return None
|
||
return frozenset(
|
||
family
|
||
for family, packages in _DEBIAN_PACKAGES.items()
|
||
if any(_dpkg_package_installed(package) for package in packages)
|
||
)
|
||
|
||
|
||
@lru_cache(maxsize=1)
|
||
def office_supported_suffixes() -> frozenset[str]:
|
||
"""返回 host 当前 LibreOffice 组件实际支持的输入后缀。"""
|
||
try:
|
||
soffice = find_soffice()
|
||
except SofficeNotFoundError:
|
||
return frozenset()
|
||
families = _debian_office_families(soffice)
|
||
if families is None:
|
||
# Windows/macOS/自带 tar 包通常是完整套件;无法可靠拆包探测时按完整安装处理。
|
||
return _KNOWN_SUFFIXES
|
||
return frozenset().union(*(_FAMILY_SUFFIXES[family] for family in families))
|
||
|
||
|
||
def soffice_available() -> bool:
|
||
"""Host 至少有一个可用 LibreOffice 组件时才向 agent 注册工具。"""
|
||
return bool(office_supported_suffixes())
|
||
|
||
|
||
class OfficeToPdfTool(Tool):
|
||
name = "office_to_pdf"
|
||
description = "Convert an existing Office document to PDF using backend host LibreOffice."
|
||
parameters = {
|
||
"type": "object",
|
||
"properties": {
|
||
"source": {
|
||
"type": "string",
|
||
"description": (
|
||
"Existing Office file path: task-relative, user-root-relative as shown in chat, "
|
||
"or an absolute path under /workspace."
|
||
),
|
||
},
|
||
"output": {
|
||
"type": "string",
|
||
"description": (
|
||
"Optional output .pdf path in the user's workspace. "
|
||
"Defaults to the source path with a .pdf suffix."
|
||
),
|
||
},
|
||
},
|
||
"required": ["source"],
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
base_dir: Optional[Path] = None,
|
||
user_root: Optional[Path] = None,
|
||
) -> None:
|
||
super().__init__(base_dir=base_dir, user_root=user_root)
|
||
self.supported_suffixes = office_supported_suffixes()
|
||
supported = ", ".join(suffix.lstrip(".").upper() for suffix in sorted(self.supported_suffixes))
|
||
self.description = (
|
||
"Convert an existing Office document in the current user's workspace to PDF using "
|
||
f"LibreOffice on the backend host. Formats available on this host: {supported or 'none'}. "
|
||
"Use this only when an Office file already exists; for a new Markdown report, call "
|
||
"the platform rendering/render.py command with --format pdf directly."
|
||
)
|
||
|
||
def _resolve_office_path(self, raw: str, *, existing: bool) -> Path:
|
||
"""兼容 task 相对路径与界面展示的 `<working_dir>/...` 用户根相对路径。"""
|
||
p = Path(raw)
|
||
if p.is_absolute() or raw == "/workspace" or raw.startswith("/workspace/"):
|
||
return self._resolve_user_file(raw)
|
||
|
||
base_candidate = self._resolve_user_file(raw)
|
||
if existing and base_candidate.exists():
|
||
return base_candidate
|
||
if self.user_root is None:
|
||
return base_candidate
|
||
|
||
root = self.user_root.resolve()
|
||
root_candidate = (root / p).resolve()
|
||
try:
|
||
root_candidate.relative_to(root)
|
||
task_rel = self.base_dir.resolve().relative_to(root)
|
||
except ValueError:
|
||
return base_candidate
|
||
|
||
has_task_prefix = p.parts[:len(task_rel.parts)] == task_rel.parts
|
||
if (existing and root_candidate.exists()) or has_task_prefix:
|
||
return root_candidate
|
||
return base_candidate
|
||
|
||
def execute(self, source: str, output: Optional[str] = None) -> str | ToolExecutionResult:
|
||
try:
|
||
src = self._resolve_office_path(source, existing=True)
|
||
except FileOutOfBounds:
|
||
return f"[Error] source path out of user workspace: {source}"
|
||
if not src.is_file():
|
||
return f"[Error] source file not found: {source}"
|
||
suffix = src.suffix.lower()
|
||
if suffix not in _KNOWN_SUFFIXES:
|
||
known = ", ".join(sorted(_KNOWN_SUFFIXES))
|
||
return f"[Error] unsupported Office format {src.suffix!r}; known formats: {known}"
|
||
if suffix not in self.supported_suffixes:
|
||
family = next(name for name, suffixes in _FAMILY_SUFFIXES.items() if suffix in suffixes)
|
||
return (
|
||
f"[Error] backend host LibreOffice lacks the {family} component required for "
|
||
f"{suffix}; ask the administrator to install that component"
|
||
)
|
||
|
||
raw_out = output.strip() if isinstance(output, str) and output.strip() else ""
|
||
try:
|
||
out = self._resolve_office_path(raw_out, existing=False) if raw_out else src.with_suffix(".pdf")
|
||
except FileOutOfBounds:
|
||
return f"[Error] output path out of user workspace: {output}"
|
||
if out.suffix.lower() != ".pdf":
|
||
return f"[Error] output must end with .pdf: {output}"
|
||
|
||
try:
|
||
soffice = find_soffice()
|
||
except SofficeNotFoundError as e:
|
||
return f"[Error] LibreOffice unavailable on backend host: {e}"
|
||
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
with tempfile.TemporaryDirectory(prefix="office-pdf-") as tmp:
|
||
tmp_dir = Path(tmp)
|
||
profile_uri = (tmp_dir / "profile").resolve().as_uri()
|
||
cmd = [
|
||
soffice,
|
||
"--headless",
|
||
"--norestore",
|
||
"--nolockcheck",
|
||
"--nodefault",
|
||
f"-env:UserInstallation={profile_uri}",
|
||
"--convert-to",
|
||
"pdf",
|
||
"--outdir",
|
||
str(tmp_dir),
|
||
str(src),
|
||
]
|
||
proc = subprocess.run(
|
||
cmd,
|
||
capture_output=True,
|
||
timeout=_DEFAULT_TIMEOUT,
|
||
check=False,
|
||
)
|
||
converted = tmp_dir / f"{src.stem}.pdf"
|
||
if proc.returncode != 0 or not converted.is_file() or converted.stat().st_size == 0:
|
||
detail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-500:]
|
||
return f"[Error] LibreOffice conversion failed (rc={proc.returncode}): {detail}"
|
||
|
||
staged = out.with_name(f".{out.name}.{uuid4().hex}.tmp")
|
||
try:
|
||
shutil.copyfile(converted, staged)
|
||
staged.replace(out)
|
||
finally:
|
||
staged.unlink(missing_ok=True)
|
||
except subprocess.TimeoutExpired:
|
||
return f"[Error] LibreOffice conversion timed out after {_DEFAULT_TIMEOUT}s: {source}"
|
||
except OSError as e:
|
||
return f"[Error] failed to publish PDF: {type(e).__name__}: {e}"
|
||
|
||
content = f"[OK] PDF created: {self._display(out)} ({out.stat().st_size} bytes)"
|
||
if self.user_root is None:
|
||
return content
|
||
try:
|
||
_, rel = resolve_artifact_path(
|
||
str(out), working_dir=self.base_dir, user_root=self.user_root,
|
||
)
|
||
except ValueError:
|
||
return content
|
||
return ToolExecutionResult(content=content, artifacts=(ArtifactRef(path=rel),))
|