fix(review): EPS batch conversion in zip compile (v2)

- v1 fell through because it required pre-converted PDF in zip
- v2: convert all .eps files post-extract via gs -dSAFER
- 15s timeout per file; fail per-file, not blocking
- Size guards: 20MB per file warning, 100MB total abort
- Conversion log written to output.log
- Verified on real MDPI official zip → 7-page PDF

v1 lesson: test sample had pre-converted PDF but real zip does not
This commit is contained in:
potaser
2026-10-14 09:47:49 +08:00
parent 2797145104
commit de0b9f92be
3 changed files with 357 additions and 86 deletions
@@ -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
+168 -51
View File
@@ -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)
+147 -35
View File
@@ -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