"""pptx_render.py: 把 .pptx 高保真转成 PDF,供前端在线预览复用现成的 PDF iframe。 设计见 DESIGN §8.3。要点: - 跑在 backend host(`web/app.py` 进程),**不进执行沙盒**;面向 user_root 下任意 pptx。 - 调 `soffice --headless --convert-to pdf`;**每次独立 `-env:UserInstallation`** 临时 profile, 绕开 LibreOffice 单 profile 锁(否则并发转换互斥),用完即删。 - 转换结果缓存到源 pptx 同目录的隐藏 `.preview/..pdf`;hash 由 mtime+size 派生, 源文件一改 hash 变 → 自然失效。`.preview/` 是 dotdir,`_enumerate_files` 已跳过。 - soffice 缺失 → 抛 `SofficeNotFoundError`(端点回 501);超时/失败 → 抛 `PptxConvertError`。 本模块是**同步**的(subprocess 阻塞);端点侧用 `run_in_executor` 不堵事件循环、用 per-path `asyncio.Lock` 防同一文件并发重复转换。 """ from __future__ import annotations import hashlib import shutil import subprocess import tempfile from pathlib import Path # Windows 常见安装路径 + Linux 部署路径;都没有再查 PATH。 _SOFFICE_CANDIDATES = [ r"C:\Program Files\LibreOffice\program\soffice.exe", r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", "/usr/bin/soffice", "/usr/bin/libreoffice", "/opt/libreoffice/program/soffice", ] _PREVIEW_DIRNAME = ".preview" _DEFAULT_TIMEOUT = 60 # soffice 冷启 + 转换;大 deck 靠它兜底,超时 kill class SofficeNotFoundError(RuntimeError): """本机/镜像未装 LibreOffice。端点据此回 501 + 提示下载。""" class PptxConvertError(RuntimeError): """soffice 转换失败 / 超时 / 没产出 PDF。端点据此回 500 + 前端回退下载。""" def find_soffice() -> str: """定位 soffice 可执行文件;复用 render_bg.py 的「候选路径 + PATH 兜底」思路。""" for c in _SOFFICE_CANDIDATES: if Path(c).exists(): return c for name in ("soffice", "soffice.exe", "libreoffice"): p = shutil.which(name) if p: return p raise SofficeNotFoundError( "服务器未装 LibreOffice(soffice),无法在线预览 pptx。请安装 " "libreoffice-impress 或下载原文件查看。" ) def _cache_pdf_path(pptx_path: Path) -> Path: """缓存落点:`/.preview/..pdf`。 hash 由源文件 (mtime_ns, size) 派生 —— 源一改 hash 变,旧缓存自然失效(换名), 无需显式比对新鲜度。 """ st = pptx_path.stat() sig = f"{st.st_mtime_ns}-{st.st_size}".encode("utf-8") digest = hashlib.sha1(sig).hexdigest()[:12] return pptx_path.parent / _PREVIEW_DIRNAME / f"{pptx_path.stem}.{digest}.pdf" def _prune_stale(cache_dir: Path, stem: str, keep: Path) -> None: """删同一 stem 的旧 hash 缓存(源文件已变),只留 keep。兜底磁盘,best-effort。""" try: for old in cache_dir.glob(f"{stem}.*.pdf"): if old != keep: old.unlink(missing_ok=True) except OSError: pass def _run_soffice(soffice: str, pptx_path: Path, outdir: Path, timeout: int) -> Path: """跑一次 soffice 转换,产出 PDF 落到 outdir,返回该 PDF 路径。 `-env:UserInstallation` 指向**独立临时 profile**,避免与宿主 LibreOffice 或其他并发 转换抢单 profile 锁。`--convert-to` 模式默认不执行文档宏(安全边界,见 DESIGN §8.3)。 """ with tempfile.TemporaryDirectory(prefix="lo-profile-") as profile: profile_uri = Path(profile).resolve().as_uri() cmd = [ soffice, "--headless", "--norestore", "--nolockcheck", "--nodefault", f"-env:UserInstallation={profile_uri}", "--convert-to", "pdf", "--outdir", str(outdir), str(pptx_path), ] try: proc = subprocess.run( cmd, capture_output=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as e: raise PptxConvertError( f"pptx 转换超时(>{timeout}s):{pptx_path.name}" ) from e out_pdf = outdir / f"{pptx_path.stem}.pdf" if proc.returncode != 0 or not out_pdf.exists(): tail = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace")[-500:] raise PptxConvertError( f"soffice 转换失败(rc={proc.returncode}):{pptx_path.name}\n{tail}" ) return out_pdf def pptx_to_pdf(pptx_path: Path | str, *, timeout: int = _DEFAULT_TIMEOUT) -> Path: """把 pptx 转成 PDF 并返回 PDF 路径(同步、带缓存)。 命中且新鲜的缓存直接返回;否则调 soffice 转换,产物原子落到 `.preview/`。 soffice 缺失 → `SofficeNotFoundError`;转换失败/超时 → `PptxConvertError`。 """ pptx_path = Path(pptx_path) if not pptx_path.is_file(): raise PptxConvertError(f"pptx 不存在:{pptx_path}") cache_pdf = _cache_pdf_path(pptx_path) if cache_pdf.exists(): return cache_pdf soffice = find_soffice() # 先解析 soffice,缺失早抛(不白建临时目录) cache_dir = cache_pdf.parent cache_dir.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="pptx-pdf-") as tmp: out_pdf = _run_soffice(soffice, pptx_path, Path(tmp), timeout) # 原子落位:同分区 replace;跨分区(临时目录在别处)退回 copy。 try: out_pdf.replace(cache_pdf) except OSError: shutil.copyfile(out_pdf, cache_pdf) _prune_stale(cache_dir, pptx_path.stem, keep=cache_pdf) return cache_pdf