diff --git a/docs/pr-reports/2026-10-13-p3b-compile-ui.md b/docs/pr-reports/2026-10-13-p3b-compile-ui.md index c634c48..04e661b 100644 --- a/docs/pr-reports/2026-10-13-p3b-compile-ui.md +++ b/docs/pr-reports/2026-10-13-p3b-compile-ui.md @@ -58,6 +58,48 @@ P3b:论文重排版面板启用"编译 PDF" Tab,实现"上传 .tex → 服 - QUEUE_FULL → 429 + 信封(后端路径实测于 P3a;前端文案 vitest 6/6 覆盖) - scripts/prewarm_tectonic.sh → exit=0 OK,宏包缓存就绪 +## EPS 兼容性修复 v2(2026-10-14 追加) + +背景:真实 MDPI 官方 zip(v6.5a)上传编译在 XDV→PDF 阶段失败: +`xdvipdfmx: image inclusion failed for "Definitions/logo-mdpi.eps"`。 +v1 回退逻辑 gate 在"zip 自带 `*-eps-converted-to.pdf` 预转换版"——真实官方 zip +**不带**该文件(v1 本地 e2e 通过是因自造样本带了;测试样本与真实样本不一致,教训已记录)。 + +v2 方案:**不依赖扫描引用与 zip 自带预转换版**,解压后扫描项目内所有 `.eps` +批量现场转同名 `.pdf`(`latex_compile.process_eps_images`): + +1. 工具优先级 **gs → epstopdf → ps2pdf**(`shutil.which` 逐个 fallback);gs 命令 + 约束1 强制 `-dSAFER -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress` + (-dSAFER 防 EPS 内嵌任意 PostScript 执行);无新 Python 依赖(subprocess) +2. 约束2:单文件转换 **15s 超时** → warning 行 `[eps-convert] xxx.eps 转换超时` + 并跳过该文件继续 +3. 约束3:单文件转换后 >20MB → warning;累计转换产物 >100MB → + `InspectError("ZIP_TOO_LARGE")` 中止(信封原样透出该 code) +4. 约束4:`[eps-convert] {src} → {dst} ({in} → {out}, {elapsed}s, {tool})` + 写入 `output.log`(正常/超时两条路径都落盘,引擎 log 之后、chatter 之前) +5. 转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫, + 不碰编码、不改正文);无扩展名引用 `{.../X}` 由 graphicx 命中新 `X.pdf` + (解析顺序 .pdf→…→.eps,规避 .eps) +6. 全工具失败 → log 行 `[eps-convert] 全部工具失败,图片 xxx.eps 未转换` → + 编译自然失败 → 前端提示「项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试」 + +真实官方 zip 实测(`scripts/debug_compile.py` 重打包 fba37ae3c853 现场 + 8100 HTTP 链路): + +| 指标 | 结果 | +|---|---| +| 转换 EPS 数 | 2(logo-mdpi.eps、logo-updates.eps,均 gs) | +| 每个耗时 | 563678→13057B 0.27s;287998→16588B 0.27s(合计 0.54s) | +| 显式引用改写 | 1 个文件(Definitions/mdpi.cls) | +| 编译 | 2.83s(debug 脚本)/ 2.99s(HTTP) | +| 产出 PDF | 70688 / 70687 bytes,7 页,`%PDF-` ✓ | + +测试:pytest **175 passed**(原 169 全保留,移除 3 个 v1 语义已变的 EPS 用例, +新增 9 个 v2 用例:preset 免转换 / 真实 gs 转换 / 工具 fallback / 全失败 / +超时 warning / 20MB warning / 100MB 中止 / 端到端成功 / 友好报错)。 + +教训(关键):**外部下载的模板类场景,e2e 必须用真实官方文件**——v1 的自造样本 +带了预转换版,真实官方 zip 不带,导致修复上线后失效。 + ## 部署验证(部署后执行) ```bash diff --git a/review_service/app/services/latex_compile.py b/review_service/app/services/latex_compile.py index a4e5b6d..ab101a0 100644 --- a/review_service/app/services/latex_compile.py +++ b/review_service/app/services/latex_compile.py @@ -27,6 +27,7 @@ import os import re import shutil import signal +import subprocess import time import uuid from dataclasses import dataclass @@ -256,7 +257,9 @@ def sweep_expired() -> int: class InspectError(Exception): - """inspect 业务失败。code ∈ {VALIDATION, ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID}。""" + """inspect/消费阶段业务失败。 + code ∈ {VALIDATION, ZIP_UNSAFE, ZIP_BOMB, ZIP_INVALID, ZIP_TOO_LARGE}。 + """ def __init__(self, code: str, message: str) -> None: super().__init__(message) @@ -324,54 +327,156 @@ _IMG_INCLUSION_RE = re.compile(r"""image inclusion failed for ["']([^"']+)["']"" _EPS_CONVERTED_SUFFIX = "-eps-converted-to.pdf" _REF_TEXT_EXTS = {".tex", ".cls", ".sty", ".cfg", ".def"} +# EPS 现场转换 v2(约束 1-4):xdvipdfmx 无法内嵌 EPS → 解压后批量转同名 .pdf +_EPS_CONVERT_TIMEOUT_S = 15 # 约束2:单文件转换超时(秒) +_EPS_CONVERT_MAX_FILE = 20 * 1024 * 1024 # 约束3:单文件转换后上限(warning) +_EPS_CONVERT_MAX_TOTAL = 100 * 1024 * 1024 # 约束3:转换产物总上限(中止) -def fallback_eps_to_converted_pdf(project: Path) -> list[str]: - """EPS 兼容回退:XeTeX/xdvipdfmx 无法内嵌 EPS(MDPI 模板 logo 即此例)。 - 对项目内每个 X.eps,若同目录存在预转换版 X-eps-converted-to.pdf - (epstopdf/MDPI 官方约定命名): - 1. 显式引用:把 .tex/.cls/.sty/.cfg/.def 中的 `X.eps` 替换为转换版文件名 - (纯字节 ASCII 替换,不触碰文件编码); - 2. 缺省扩展名引用:graphicx 的 `{.../X}` 解析顺序为 .pdf→…→.eps, - 若无 X.pdf 则把转换版复制为 X.pdf,规避 .eps 命中 - (MDPI 正文模板存在 `\includegraphics{Definitions/logo-mdpi}` 写法)。 - → 被改写的文件相对路径列表(无可用转换版/无引用 → 空)。 +def _eps_tool_commands(eps: Path, pdf: Path) -> list[tuple[str, list[str]]]: + """转换工具优先级:gs(约束1 必须 -dSAFER)→ epstopdf → ps2pdf;只列 PATH 里可用的。""" + tools: list[tuple[str, list[str]]] = [] + gs = shutil.which("gs") + if gs: + tools.append(("gs", [ + gs, "-dSAFER", "-dNOPAUSE", "-dBATCH", "-sDEVICE=pdfwrite", + "-dPDFSETTINGS=/prepress", + f"-sOutputFile={pdf}", str(eps), + ])) + epstopdf = shutil.which("epstopdf") + if epstopdf: + # epstopdf 默认输出到同目录同名 .pdf(即 pdf 路径) + tools.append(("epstopdf", [epstopdf, str(eps)])) + ps2pdf = shutil.which("ps2pdf") + if ps2pdf: + tools.append(("ps2pdf", [ps2pdf, str(eps), str(pdf)])) + return tools + + +def _convert_eps_file(eps: Path, pdf: Path) -> tuple[str, str]: + """逐工具尝试把 eps 转成同名 pdf → ("ok", tool) | ("timeout", tool) | ("fail", "")。 + + 约束2:单工具 15s 超时 → 放弃该文件(继续下一个 eps,不再试后续工具)。 + 成功判定:退出后 pdf 存在且非空(防半成品)。 """ - eps_files = [ + tools = _eps_tool_commands(eps, pdf) + if not tools: + return "fail", "" + for tool, cmd in tools: + pdf.unlink(missing_ok=True) # 清上一个工具的半成品 + try: + subprocess.run( + cmd, timeout=_EPS_CONVERT_TIMEOUT_S, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, + ) + except subprocess.TimeoutExpired: + pdf.unlink(missing_ok=True) + return "timeout", tool + except OSError: + continue + if pdf.is_file() and pdf.stat().st_size > 0: + return "ok", tool + pdf.unlink(missing_ok=True) + return "fail", "" + + +def process_eps_images(project: Path) -> list[str]: + """EPS 兼容 v2:解压后**批量转换项目内所有 .eps**(不依赖 zip 自带预转换版)。 + + 对每个 X.eps(原 .eps 保留): + 1. 已有同名 X.pdf → 跳过转换(引用照常切换为 .pdf); + 2. zip 自带 X-eps-converted-to.pdf 预转换版 → 复制为 X.pdf(免转换); + 3. 否则现场转换:gs(-dSAFER) → epstopdf → ps2pdf 逐个 fallback(约束1), + 单文件 15s 超时 → warning 后继续下一个(约束2)。 + 转换成功 → 显式 `X.eps` 引用字节替换为 `X.pdf`(.tex/.cls/.sty/.cfg/.def 全扫, + 纯字节 ASCII/UTF-8 替换不碰编码);无扩展名引用 `{.../X}` 由 graphicx 直接 + 命中新生成的 X.pdf(解析顺序 .pdf→…→.eps)。 + 约束3:单文件转换后 >20MB → warning;转换产物累计 >100MB → 抛 + InspectError("ZIP_TOO_LARGE") 中止;约束4:返回值为写入 output.log 的 + "[eps-convert]" 日志行(由 _run_project 落盘)。 + → 日志行列表(无 .eps 项目 → 空)。 + """ + eps_files = sorted( p for p in project.rglob("*") if p.is_file() and p.suffix.lower() == ".eps" - ] + ) if not eps_files: return [] - pairs: list[tuple[bytes, bytes]] = [] + lines: list[str] = [] + handled: list[tuple[bytes, bytes]] = [] + total_out = 0 + + def _rel(p: Path) -> str: + return p.relative_to(project).as_posix() + for eps in eps_files: + pdf = eps.with_suffix(".pdf") + src_rel, dst_rel = _rel(eps), _rel(pdf) + if pdf.is_file(): + handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8"))) + continue converted = eps.with_name(eps.stem + _EPS_CONVERTED_SUFFIX) - if not converted.is_file(): - continue - pairs.append((eps.name.encode("ascii"), converted.name.encode("ascii"))) - plain_pdf = eps.with_suffix(".pdf") - if not plain_pdf.exists(): - shutil.copyfile(converted, plain_pdf) - logger.info("eps fallback: 已复制 %s → %s(缺省扩展名解析规避 .eps)", - converted.name, plain_pdf.name) - if not pairs: - return [] - changed: list[str] = [] - for f in project.rglob("*"): - if not (f.is_file() and f.suffix.lower() in _REF_TEXT_EXTS): - continue - data = f.read_bytes() - new = data - for old_b, new_b in pairs: - new = new.replace(old_b, new_b) - if new != data: - f.write_bytes(new) - changed.append(f.relative_to(project).as_posix()) + if converted.is_file(): + t0 = time.monotonic() + shutil.copyfile(converted, pdf) + elapsed = time.monotonic() - t0 + tool = "preset" + else: + t0 = time.monotonic() + status, tool = _convert_eps_file(eps, pdf) + elapsed = time.monotonic() - t0 + if status == "timeout": + lines.append(f"[eps-convert] {src_rel} 转换超时") # 约束2 + continue + if status != "ok": + lines.append( + f"[eps-convert] 全部工具失败,图片 {src_rel} 未转换") + continue + # 约束4:转换日志(成功路径) + lines.append( + f"[eps-convert] {src_rel} → {dst_rel} " + f"({eps.stat().st_size} → {pdf.stat().st_size}, " + f"{elapsed:.2f}s, {tool})") + size_out = pdf.stat().st_size + if size_out > _EPS_CONVERT_MAX_FILE: # 约束3 + lines.append( + f"[eps-convert] 警告:{dst_rel} 转换后 {size_out} 字节" + f"超过 {_EPS_CONVERT_MAX_FILE // (1024 * 1024)}MB 上限") + total_out += size_out + if total_out > _EPS_CONVERT_MAX_TOTAL: # 约束3 + raise InspectError( + "ZIP_TOO_LARGE", + f"EPS 转换后总大小超过 {_EPS_CONVERT_MAX_TOTAL // (1024 * 1024)}MB 上限" + f"(已达 {total_out // (1024 * 1024)}MB),请压缩图片后重试") + handled.append((eps.name.encode("utf-8"), pdf.name.encode("utf-8"))) + + # 显式 .eps 引用 → .pdf(仅对转换成功者;失败的保持 .eps → 编译兜底友好报错) + changed = 0 + if handled: + for f in project.rglob("*"): + if not (f.is_file() and f.suffix.lower() in _REF_TEXT_EXTS): + continue + data = f.read_bytes() + new = data + for old_b, new_b in handled: + new = new.replace(old_b, new_b) + if new != data: + f.write_bytes(new) + changed += 1 if changed: - logger.info( - "eps fallback: %d 个文件的 .eps 引用已切换为 *%s", - len(changed), _EPS_CONVERTED_SUFFIX) - return changed + lines.append(f"[eps-convert] {changed} 个文件的显式 .eps 引用已改写为 .pdf") + if lines: + logger.info("eps v2: %s", " | ".join(lines)) + return lines + + +def _append_eps_lines(log_path: Path, lines: list[str] | None) -> None: + """约束4:把 [eps-convert] 转换日志写入 output.log(引擎 log 之后、chatter 之前)。""" + if not lines: + return + with log_path.open("a", encoding="utf-8") as fh: + fh.write("\n".join(lines) + "\n") def classify_compile_error(log_tail: str) -> str: @@ -393,11 +498,8 @@ def classify_compile_error(log_tail: str) -> str: if m: name = m.group(1) if Path(name).suffix.lower() == ".eps": - stem = Path(name).stem - return ( - f"图片 {name} 无法内嵌(EPS 在当前引擎下不受支持):" - f"请提供 {stem}{_EPS_CONVERTED_SUFFIX} 预转换 PDF,或改用 PDF/PNG 图片" - ) + # EPS v2 已现场转换;仍失败 = 服务器转换没成功(工具缺失/超时/格式怪异) + return "项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试" return f"图片 {name} 无法内嵌,请检查图片格式与路径(建议使用 PDF/PNG)" if re.search(r"Emergency stop|Undefined control sequence", log_tail): return "LaTeX 源码有语法错误,请展开日志定位出错行" @@ -496,7 +598,8 @@ async def compile_project_job(job_id: str) -> dict[str, Any]: """两段式之②:原子消费 inspect → tectonic 编译 → 产物留 pdf/log/meta。 队列/工具检查**先于消费**(inspect 保留 → 失败可直接重试); - EPS 回退:存在 *-eps-converted-to.pdf 时自动把 .eps 引用切换为转换版; + EPS v2:解压后批量转换所有 .eps → 同名 .pdf(gs -dSAFER → epstopdf → ps2pdf, + 单文件 15s 超时;产物累计 >100MB → ZIP_TOO_LARGE 中止),转换日志进 output.log; 成功 → 立即删 project/;失败(COMPILE_FAILED/TIMEOUT)→ **保留 project/ 现场 24h** 供诊断(sweep_compile_jobs_expired 按 failed_project_ttl_s 清理; KEEP_FAILED_PROJECTS=1 永久保留); @@ -519,11 +622,12 @@ async def compile_project_job(job_id: str) -> dict[str, Any]: try: project, meta = await asyncio.to_thread(consume_inspect, job_id, cid) try: - # EPS 回退:xdvipdfmx 无法内嵌 EPS(MDPI 模板 logo 即此例); - # 存在 *-eps-converted-to.pdf 预转换版时自动切换引用(失败现场保留改写后文件) - await asyncio.to_thread(fallback_eps_to_converted_pdf, project) + # EPS v2:解压后批量转换所有 .eps → 同名 .pdf 并切换显式引用 + # (不依赖 zip 自带预转换版;失败现场保留转换后的改写结果) + eps_lines = await asyncio.to_thread(process_eps_images, project) async with _lock: - result = await _run_project(cid, project, str(meta.get("main", ""))) + result = await _run_project( + cid, project, str(meta.get("main", "")), eps_lines) # 成功 → 立即清理 project 树(失败路径在 finally 保留现场) shutil.rmtree(project, ignore_errors=True) project = None @@ -543,8 +647,17 @@ async def compile_project_job(job_id: str) -> dict[str, Any]: pass -async def _run_project(cid: str, project: Path, main_rel: str) -> dict[str, Any]: - """单槽内执行项目编译:cwd=main 所在目录(相对路径解析),outdir=compile_jobs/{cid}。""" +async def _run_project( + cid: str, + project: Path, + main_rel: str, + eps_log_lines: list[str] | None = None, +) -> dict[str, Any]: + """单槽内执行项目编译:cwd=main 所在目录(相对路径解析),outdir=compile_jobs/{cid}。 + + eps_log_lines:EPS v2 的 [eps-convert] 转换日志(约束4),正常/超时两条路径 + 都会写进 output.log(引擎 log 之后、chatter/[timeout] 之前)。 + """ settings = get_settings() outdir = compile_job_dir(cid) outdir.mkdir(parents=True, exist_ok=True) @@ -583,6 +696,8 @@ async def _run_project(cid: str, project: Path, main_rel: str) -> dict[str, Any] pass # 超时后 communicate 已取消,chatter 不可取;.log(--keep-logs)仍完整 _ensure_project_log(outdir, stem, cwd) + # 约束4:转换日志先落(转换发生在编译之前),再追加超时标记 + _append_eps_lines(log_path, eps_log_lines) # 追加而非覆盖:保留完整引擎输出(诊断 XDV→PDF 阶段必需) with log_path.open("a", encoding="utf-8") as fh: fh.write(f"\n[timeout] 编译超时({timeout:g}s),进程已终止\n") @@ -607,6 +722,8 @@ async def _run_project(cid: str, project: Path, main_rel: str) -> dict[str, Any] if not log_path.is_file(): log_path.write_text( "[no log] tectonic 未产出日志(进程异常退出)", encoding="utf-8") + # 约束4:[eps-convert] 转换日志(引擎 log 之后、chatter 之前) + _append_eps_lines(log_path, eps_log_lines) # --print 的进程输出(含 XDV→PDF 阶段)追加到 output.log 全文之后; # 信封 detail 仍只取尾 200 行,磁盘保留完整 log 供"查看完整 log"下载 _append_chatter(log_path, stdout_bytes) diff --git a/review_service/tests/test_compile_zip.py b/review_service/tests/test_compile_zip.py index 5faa7ed..248c2f3 100644 --- a/review_service/tests/test_compile_zip.py +++ b/review_service/tests/test_compile_zip.py @@ -9,6 +9,7 @@ from __future__ import annotations import io import json import os +import shutil import time import zipfile from pathlib import Path @@ -345,56 +346,144 @@ def test_run_project_timeout(client: TestClient, monkeypatch): assert "[timeout]" in log.text -# ================================================================ EPS 回退(xdvipdfmx 不支持内嵌 EPS) +# ================================================================ EPS 兼容 v2(批量现场转换) -def test_fallback_eps_converted_pdf(tmp_path): - """存在 *-eps-converted-to.pdf → 引用自动切换(.cls/.tex 都扫,字节替换不坏编码)。""" +_MIN_EPS = ( + b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n" + b"0 0 1 setrgbcolor 0 0 10 10 rectfill\nshowpage\n%%EOF\n" +) + + +def _eps_project(tmp_path, tex_body: str = "") -> Path: proj = tmp_path / "p" + proj.mkdir() + (proj / "fig.eps").write_bytes(_MIN_EPS) + (proj / "main.tex").write_text( + "\\documentclass{article}\n\\usepackage{graphicx}\n" + "\\begin{document}\n" + tex_body + "\n\\end{document}\n", + encoding="utf-8") + return proj + + +def test_eps_v2_preset_converted(tmp_path, monkeypatch): + """zip 自带 *-eps-converted-to.pdf → 免转换复制为 X.pdf + 显式引用改写。""" + # other.eps 无转换版 → mock 工具为空使其失败,隔离 preset 逻辑(不真跑 gs) + monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: []) + proj = _eps_project(tmp_path) defs = proj / "Definitions" - defs.mkdir(parents=True) - (defs / "logo-mdpi.eps").write_bytes(b"%!PS-Adobe-3.0 EPSF-3.0\n") + defs.mkdir() + (defs / "logo-mdpi.eps").write_bytes(_MIN_EPS) (defs / "logo-mdpi-eps-converted-to.pdf").write_bytes(_tiny_pdf()) - (defs / "other.eps").write_bytes(b"%!PS\n") # 无转换版 → 不动 + (defs / "other.eps").write_bytes(b"%!PS\n") # 无转换版 → 走现场转换(被 mock 挡下) (defs / "mdpi.cls").write_text( "\\includegraphics{Definitions/logo-mdpi.eps}\n" "\\includegraphics{Definitions/other.eps}\n", encoding="utf-8") - (proj / "main.tex").write_text( - "\\documentclass{Definitions/mdpi}\n\\begin{document}\nx\n\\end{document}\n", - encoding="utf-8") - changed = lc.fallback_eps_to_converted_pdf(proj) - assert changed == ["Definitions/mdpi.cls"] + lines = lc.process_eps_images(proj) + cls = (defs / "mdpi.cls").read_text(encoding="utf-8") - assert "logo-mdpi-eps-converted-to.pdf" in cls - assert "Definitions/other.eps" in cls # 无转换版的引用保持原样 - assert (defs / "logo-mdpi.eps").is_file() # 源文件不删(诊断现场保留) - # 缺省扩展名解析规避:有转换版 → 复制出 X.pdf(graphicx 先试 .pdf) - assert (defs / "logo-mdpi.pdf").is_file() - assert not (defs / "other.pdf").exists() # 无转换版 → 不复制 + assert "Definitions/logo-mdpi.pdf" in cls # 显式引用已改写为 .pdf + assert "Definitions/other.eps" in cls # 转换失败的保持原样 + assert (defs / "logo-mdpi.eps").is_file() # 原文件保留 + assert (defs / "logo-mdpi.pdf").is_file() # 免转换复制 + assert not (defs / "other.pdf").exists() + assert any(", preset)" in l for l in lines), lines + assert any("全部工具失败,图片 Definitions/other.eps 未转换" in l + for l in lines), lines -def test_fallback_eps_noop_without_converted(tmp_path): - proj = tmp_path / "p" - proj.mkdir() - (proj / "fig.eps").write_bytes(b"%!PS\n") - (proj / "main.tex").write_text("x", encoding="utf-8") - assert lc.fallback_eps_to_converted_pdf(proj) == [] - assert (proj / "main.tex").read_text(encoding="utf-8") == "x" +def test_eps_v2_real_gs_convert(tmp_path): + """无预转换版 → 现场 gs 转换(真实工具链;测试点:显式 + 缺省扩展名引用都就位)。""" + if not shutil.which("gs"): + pytest.skip("gs 未安装") + proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}\n\\includegraphics{fig}") + lines = lc.process_eps_images(proj) + assert any("[eps-convert] fig.eps → fig.pdf" in l and ", gs)" in l for l in lines) + pdf = proj / "fig.pdf" + assert pdf.is_file() and pdf.read_bytes()[:5] == b"%PDF-" + tex = (proj / "main.tex").read_text(encoding="utf-8") + assert "{fig.pdf}" in tex # 显式 .eps 引用已改写 + assert "{fig}" in tex # 缺省扩展名引用由 X.pdf 命中 + assert (proj / "fig.eps").is_file() # 原文件保留 + + +def test_eps_v2_tool_fallback(tmp_path, monkeypatch): + """工具优先级 gs → epstopdf → ps2pdf:gs 失败 → epstopdf 成功。""" + def fake_tools(eps: Path, pdf: Path): + return [ + ("gs", ["sh", "-c", "exit 1"]), + ("epstopdf", ["sh", "-c", f"echo %PDF-fake > {pdf}"]), + ] + monkeypatch.setattr(lc, "_eps_tool_commands", fake_tools) + proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}") + lines = lc.process_eps_images(proj) + assert any(l.startswith("[eps-convert] fig.eps → fig.pdf") and ", epstopdf)" in l + for l in lines), lines + assert (proj / "fig.pdf").is_file() + assert "{fig.pdf}" in (proj / "main.tex").read_text(encoding="utf-8") + + +def test_eps_v2_all_tools_fail(tmp_path, monkeypatch): + """全工具失败 → log 行「全部工具失败…未转换」,引用保持 .eps。""" + monkeypatch.setattr( + lc, "_eps_tool_commands", + lambda eps, pdf: [("gs", ["sh", "-c", "exit 1"])]) + proj = _eps_project(tmp_path, "\\includegraphics{fig.eps}") + lines = lc.process_eps_images(proj) + assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in lines + assert not (proj / "fig.pdf").exists() + assert "{fig.eps}" in (proj / "main.tex").read_text(encoding="utf-8") + + +def test_eps_v2_timeout_warns(tmp_path, monkeypatch): + """约束2:单文件转换 15s 超时 → warning 行,放弃该文件继续。""" + monkeypatch.setattr(lc, "_EPS_CONVERT_TIMEOUT_S", 1) + monkeypatch.setattr( + lc, "_eps_tool_commands", + lambda eps, pdf: [("gs", ["sh", "-c", "sleep 5"])]) + proj = _eps_project(tmp_path) + lines = lc.process_eps_images(proj) + assert "[eps-convert] fig.eps 转换超时" in lines + assert not (proj / "fig.pdf").exists() + + +def test_eps_v2_file_size_warning(tmp_path, monkeypatch): + """约束3a:单文件转换后超上限 → warning 行(不中止)。""" + monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_FILE", 10) + monkeypatch.setattr( + lc, "_eps_tool_commands", + lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])]) + proj = _eps_project(tmp_path) + lines = lc.process_eps_images(proj) + assert any("警告" in l and "超过" in l for l in lines), lines + + +def test_eps_v2_total_size_aborts(tmp_path, monkeypatch): + """约束3b:转换产物累计超上限 → InspectError(ZIP_TOO_LARGE) 中止。""" + monkeypatch.setattr(lc, "_EPS_CONVERT_MAX_TOTAL", 100) + monkeypatch.setattr( + lc, "_eps_tool_commands", + lambda eps, pdf: [("gs", ["sh", "-c", f"head -c 1024 /dev/zero > {pdf}"])]) + proj = _eps_project(tmp_path) + with pytest.raises(lc.InspectError) as ei: + lc.process_eps_images(proj) + assert ei.value.code == "ZIP_TOO_LARGE" + assert "MB" in ei.value.message @needs_tectonic def test_run_eps_with_converted_pdf_succeeds(client: TestClient): - """EPS + 预转换 PDF → 显式 .eps 引用与缺省扩展名引用都编译成功(MDPI logo 场景)。""" + """EPS + 预转换 PDF(preset 路径)→ 显式与缺省扩展名引用都编译成功。""" tex = ( "\\documentclass{article}\n\\usepackage{graphicx}\n" "\\begin{document}\n" "\\includegraphics[width=1cm]{fig.eps}\n" # 显式 .eps → 文本替换 - "\\includegraphics[width=1cm]{fig}\n" # 缺省扩展名 → 复制 X.pdf 规避 .eps + "\\includegraphics[width=1cm]{fig}\n" # 缺省扩展名 → X.pdf 命中 "\\end{document}\n" ) payload = _zip_bytes({ "main.tex": tex, - "fig.eps": b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n", + "fig.eps": _MIN_EPS, "fig-eps-converted-to.pdf": _tiny_pdf(), }) job_id = _upload(client, payload).json()["meta"]["job_id"] @@ -405,21 +494,44 @@ def test_run_eps_with_converted_pdf_succeeds(client: TestClient): @needs_tectonic -def test_run_eps_without_converted_friendly_message(client: TestClient): - """只有 EPS 无转换版 → xdvipdfmx 内嵌失败 → 友好提示(指向预转换 PDF)。""" +def test_run_eps_real_gs_conversion_succeeds(client: TestClient): + """核心 v2 场景:真实 EPS 无预转换版 → 服务器现场转换 → 端到端编译成功。""" + if not shutil.which("gs"): + pytest.skip("gs 未安装") + tex = ( + "\\documentclass{article}\n\\usepackage{graphicx}\n" + "\\begin{document}\n" + "\\includegraphics[width=1cm]{fig.eps}\n" + "\\includegraphics[width=1cm]{fig}\n" + "\\end{document}\n" + ) + payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS}) + job_id = _upload(client, payload).json()["meta"]["job_id"] + body = client.post("/api/review/compile/run", json={"job_id": job_id}).json() + assert body["ok"] is True, body + assert client.get(body["pdf_url"]).content[:5] == b"%PDF-" + # 约束4:转换日志落 output.log + log = client.get(body["log_url"]).text + assert "[eps-convert] fig.eps → fig.pdf" in log and ", gs)" in log + + +@needs_tectonic +def test_run_eps_all_tools_fail_friendly_message( + client: TestClient, monkeypatch): + """测试点2+3:全工具失败 → log 记「全部工具失败」→ 编译失败 → 前端友好提示。""" + monkeypatch.setattr(lc, "_eps_tool_commands", lambda eps, pdf: []) tex = ( "\\documentclass{article}\n\\usepackage{graphicx}\n" "\\begin{document}\n\\includegraphics[width=1cm]{fig.eps}\n\\end{document}\n" ) - payload = _zip_bytes({ - "main.tex": tex, - "fig.eps": b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 10 10\n", - }) + payload = _zip_bytes({"main.tex": tex, "fig.eps": _MIN_EPS}) job_id = _upload(client, payload).json()["meta"]["job_id"] body = client.post("/api/review/compile/run", json={"job_id": job_id}).json() assert body["ok"] is False and body["error"]["code"] == "COMPILE_FAILED" - assert "无法内嵌" in body["error"]["message"] - assert "fig-eps-converted-to.pdf" in body["error"]["message"] + assert body["error"]["message"] == \ + "项目包含 EPS 图片,服务器转换失败,请转成 PDF/PNG 后重试" + log = client.get(body["log_url"]).text + assert "[eps-convert] 全部工具失败,图片 fig.eps 未转换" in log # ================================================================ zip 只读端点 404