forked from admin/PlantAI-rag-new
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user