mathtext2doc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mathtext2doc/__init__.py +12 -0
- mathtext2doc/__main__.py +5 -0
- mathtext2doc/cli.py +249 -0
- mathtext2doc/compiler.py +495 -0
- mathtext2doc/parser.py +809 -0
- mathtext2doc/plotter.py +775 -0
- mathtext2doc/texgen.py +228 -0
- mathtext2doc-0.1.0.dist-info/METADATA +328 -0
- mathtext2doc-0.1.0.dist-info/RECORD +13 -0
- mathtext2doc-0.1.0.dist-info/WHEEL +5 -0
- mathtext2doc-0.1.0.dist-info/entry_points.txt +2 -0
- mathtext2doc-0.1.0.dist-info/licenses/LICENSE +21 -0
- mathtext2doc-0.1.0.dist-info/top_level.txt +1 -0
mathtext2doc/compiler.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""LaTeX 编译器 + PNG 转换。
|
|
2
|
+
|
|
3
|
+
策略:
|
|
4
|
+
1. 优先直接输出 PNG:尝试 `xelatex -output-format=png`(若发行版支持)。
|
|
5
|
+
- TeX Live 的 xelatex 不直接支持 PNG 输出,所以这一步通常会跳过。
|
|
6
|
+
- 但保留作为优先尝试,符合需求规范第 6 条。
|
|
7
|
+
2. 回退方案:xelatex 编译 PDF → pdftoppm / pdftocairo / convert 转 PNG。
|
|
8
|
+
- 这是支持中文 + ctex 的最稳定路径。
|
|
9
|
+
3. 多页 PDF 输出多张 PNG:pdftoppm 默认就会按页输出 prefix-1.png prefix-2.png ...
|
|
10
|
+
4. 失败时保留 .log / .tex / .pdf 等中间产物,输出清晰错误信息。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import List, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompileError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# 工具:查找可执行文件
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _which(name: str) -> Optional[str]:
|
|
32
|
+
return shutil.which(name)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# 编译器选择
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def pick_latex_compiler(prefer: Optional[str] = None) -> str:
|
|
40
|
+
"""选择一个可用的 LaTeX 编译器。
|
|
41
|
+
|
|
42
|
+
优先级:
|
|
43
|
+
1. 用户指定的 prefer
|
|
44
|
+
2. xelatex(中文支持最好)
|
|
45
|
+
3. lualatex
|
|
46
|
+
4. pdflatex(不支持中文,仅作为兜底)
|
|
47
|
+
"""
|
|
48
|
+
candidates = []
|
|
49
|
+
if prefer:
|
|
50
|
+
candidates.append(prefer)
|
|
51
|
+
candidates.extend(["xelatex", "lualatex", "pdflatex"])
|
|
52
|
+
for c in candidates:
|
|
53
|
+
if _which(c):
|
|
54
|
+
return c
|
|
55
|
+
raise CompileError(
|
|
56
|
+
"未找到任何 LaTeX 编译器。请安装 TeX Live / MiKTeX / MacTeX,"
|
|
57
|
+
"并确保 xelatex 在 PATH 中。\n"
|
|
58
|
+
"下载:https://www.tug.org/texlive/"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# 主编译流程
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CompileResult:
|
|
68
|
+
png_paths: List[str]
|
|
69
|
+
pdf_path: Optional[str]
|
|
70
|
+
log_path: Optional[str]
|
|
71
|
+
compiler: str
|
|
72
|
+
fallback_used: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compile_tex_to_png(
|
|
76
|
+
tex_path: str,
|
|
77
|
+
out_dir: str,
|
|
78
|
+
*,
|
|
79
|
+
compiler: Optional[str] = None,
|
|
80
|
+
dpi: int = 150,
|
|
81
|
+
overwrite: bool = False,
|
|
82
|
+
keep_intermediates: bool = True,
|
|
83
|
+
) -> CompileResult:
|
|
84
|
+
"""编译 .tex 到 PNG(多页输出多张)。
|
|
85
|
+
|
|
86
|
+
参数:
|
|
87
|
+
tex_path: .tex 文件路径
|
|
88
|
+
out_dir: 输出目录(.tex 所在目录)
|
|
89
|
+
compiler: 指定 LaTeX 编译器;None 自动选择
|
|
90
|
+
dpi: PNG DPI,默认 150
|
|
91
|
+
overwrite: 是否覆盖已有 PNG
|
|
92
|
+
keep_intermediates: 是否保留 .log/.aux/.pdf 等中间文件
|
|
93
|
+
|
|
94
|
+
返回 CompileResult。
|
|
95
|
+
|
|
96
|
+
失败抛 CompileError,错误信息中包含日志路径。
|
|
97
|
+
"""
|
|
98
|
+
if not os.path.isfile(tex_path):
|
|
99
|
+
raise CompileError(f".tex 文件不存在:{tex_path}")
|
|
100
|
+
|
|
101
|
+
base = os.path.splitext(os.path.basename(tex_path))[0]
|
|
102
|
+
chosen = pick_latex_compiler(compiler)
|
|
103
|
+
|
|
104
|
+
# 第一步:尝试直接 PNG 输出(仅当编译器是 xelatex/lualatex/pdflatex 且支持时)
|
|
105
|
+
# 实际上 TeX Live 的 xelatex 不支持 -output-format=png,所以这一步几乎总失败。
|
|
106
|
+
# 但我们保留作为优先尝试。
|
|
107
|
+
png_paths = _try_direct_png(tex_path, out_dir, chosen, dpi, overwrite)
|
|
108
|
+
if png_paths:
|
|
109
|
+
return CompileResult(
|
|
110
|
+
png_paths=png_paths,
|
|
111
|
+
pdf_path=None,
|
|
112
|
+
log_path=os.path.join(out_dir, base + ".log"),
|
|
113
|
+
compiler=chosen,
|
|
114
|
+
fallback_used=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# 第二步:回退——编译 PDF
|
|
118
|
+
pdf_path = _compile_pdf(tex_path, out_dir, chosen)
|
|
119
|
+
log_path = os.path.join(out_dir, base + ".log")
|
|
120
|
+
|
|
121
|
+
if not os.path.isfile(pdf_path):
|
|
122
|
+
# 读取 log 最后 30 行帮助诊断
|
|
123
|
+
log_tail = _read_log_tail(log_path)
|
|
124
|
+
raise CompileError(
|
|
125
|
+
f"LaTeX 编译失败,未生成 PDF。\n"
|
|
126
|
+
f"编译器:{chosen}\n"
|
|
127
|
+
f".tex 文件:{tex_path}\n"
|
|
128
|
+
f".log 文件:{log_path}\n"
|
|
129
|
+
f"日志末尾:\n{log_tail}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# 第三步:PDF → PNG(多页)
|
|
133
|
+
png_paths = _pdf_to_png(pdf_path, out_dir, base, dpi, overwrite)
|
|
134
|
+
|
|
135
|
+
if not png_paths:
|
|
136
|
+
raise CompileError(
|
|
137
|
+
f"PDF 转 PNG 失败。请确认已安装 pdftoppm / pdftocairo / ImageMagick 之一。\n"
|
|
138
|
+
f"PDF 文件:{pdf_path}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# 第四步:清理中间文件(可选)
|
|
142
|
+
if not keep_intermediates:
|
|
143
|
+
_cleanup_intermediates(out_dir, base, keep_pdf=False)
|
|
144
|
+
else:
|
|
145
|
+
# 保留 .pdf / .log / .tex,仅清理 .aux / .out 等
|
|
146
|
+
_cleanup_intermediates(out_dir, base, keep_pdf=True)
|
|
147
|
+
|
|
148
|
+
return CompileResult(
|
|
149
|
+
png_paths=png_paths,
|
|
150
|
+
pdf_path=pdf_path if keep_intermediates else None,
|
|
151
|
+
log_path=log_path if keep_intermediates else None,
|
|
152
|
+
compiler=chosen,
|
|
153
|
+
fallback_used=True,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# 直接 PNG 输出尝试
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _try_direct_png(
|
|
162
|
+
tex_path: str,
|
|
163
|
+
out_dir: str,
|
|
164
|
+
compiler: str,
|
|
165
|
+
dpi: int,
|
|
166
|
+
overwrite: bool,
|
|
167
|
+
) -> List[str]:
|
|
168
|
+
"""尝试让 LaTeX 直接输出 PNG。TeX Live 通常不支持,所以基本会返回 []。"""
|
|
169
|
+
base = os.path.splitext(os.path.basename(tex_path))[0]
|
|
170
|
+
# xelatex 不支持 -output-format=png;lualatex 同样;pdflatex 也不行。
|
|
171
|
+
# 真正能直接输出 PNG 的是 dvipng(处理 dvi),但 dvipng 不支持 xetex 输出。
|
|
172
|
+
# 我们这里只做一个保险检查:如果用户装了 dvi 工具链,就尝试 latex+dvipng 路径。
|
|
173
|
+
# 但这又无法处理中文 ctex,所以只在源文件不含中文时才尝试。
|
|
174
|
+
try:
|
|
175
|
+
with open(tex_path, "r", encoding="utf-8") as f:
|
|
176
|
+
content = f.read()
|
|
177
|
+
except Exception:
|
|
178
|
+
return []
|
|
179
|
+
has_chinese = bool(re.search(r"[\u4e00-\u9fff]", content))
|
|
180
|
+
if has_chinese:
|
|
181
|
+
# 直接 PNG 路径无法处理中文,跳过
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
latex = _which("latex")
|
|
185
|
+
dvipng = _which("dvipng")
|
|
186
|
+
if not latex or not dvipng:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
# 临时把 \documentclass{ctexart} 改成 \documentclass{article}(去掉中文依赖)
|
|
190
|
+
# 这里只做最简单的尝试;如果失败就回退。
|
|
191
|
+
tmp_tex = os.path.join(out_dir, base + "_direct_png.tex")
|
|
192
|
+
try:
|
|
193
|
+
with open(tex_path, "r", encoding="utf-8") as f:
|
|
194
|
+
src = f.read()
|
|
195
|
+
# 简单替换 ctexart → article,并去掉 ctex 相关包
|
|
196
|
+
src = src.replace(r"\documentclass[11pt,a4paper]{ctexart}",
|
|
197
|
+
r"\documentclass[11pt,a4paper]{article}")
|
|
198
|
+
with open(tmp_tex, "w", encoding="utf-8") as f:
|
|
199
|
+
f.write(src)
|
|
200
|
+
except Exception:
|
|
201
|
+
return []
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
# latex → dvi
|
|
205
|
+
r1 = subprocess.run(
|
|
206
|
+
[latex, "-interaction=nonstopmode", "-output-directory=" + out_dir,
|
|
207
|
+
"-no-shell-escape", tmp_tex],
|
|
208
|
+
cwd=out_dir, capture_output=True, text=True, timeout=120,
|
|
209
|
+
)
|
|
210
|
+
dvi_path = os.path.join(out_dir, base + "_direct_png.dvi")
|
|
211
|
+
if not os.path.isfile(dvi_path):
|
|
212
|
+
return []
|
|
213
|
+
# dvipng → png(多页会输出 _direct_png1.png _direct_png2.png ...)
|
|
214
|
+
# dvipn 默认输出 prefix%d.png
|
|
215
|
+
out_prefix = os.path.join(out_dir, base + "_direct_png_")
|
|
216
|
+
r2 = subprocess.run(
|
|
217
|
+
[dvipng, "-q", "-D", str(dpi), "-o", out_prefix + "%d.png",
|
|
218
|
+
"-T", "tight", "--png", dvi_path],
|
|
219
|
+
cwd=out_dir, capture_output=True, text=True, timeout=120,
|
|
220
|
+
)
|
|
221
|
+
# 收集生成的 PNG
|
|
222
|
+
pngs = []
|
|
223
|
+
for fn in sorted(os.listdir(out_dir)):
|
|
224
|
+
if fn.startswith(base + "_direct_png_") and fn.endswith(".png"):
|
|
225
|
+
pngs.append(os.path.join(out_dir, fn))
|
|
226
|
+
if pngs:
|
|
227
|
+
# 重命名为 base-N.png
|
|
228
|
+
final = []
|
|
229
|
+
for i, p in enumerate(pngs, start=1):
|
|
230
|
+
dst = os.path.join(out_dir, f"{base}-{i}.png")
|
|
231
|
+
if os.path.exists(dst) and not overwrite:
|
|
232
|
+
raise CompileError(f"输出文件已存在:{dst}(使用 --overwrite 覆盖)")
|
|
233
|
+
shutil.move(p, dst)
|
|
234
|
+
final.append(dst)
|
|
235
|
+
return final
|
|
236
|
+
except Exception:
|
|
237
|
+
return []
|
|
238
|
+
finally:
|
|
239
|
+
# 清理临时文件
|
|
240
|
+
for ext in [".aux", ".log", ".dvi"]:
|
|
241
|
+
p = os.path.join(out_dir, base + "_direct_png" + ext)
|
|
242
|
+
if os.path.isfile(p):
|
|
243
|
+
try:
|
|
244
|
+
os.remove(p)
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
if os.path.isfile(tmp_tex):
|
|
248
|
+
try:
|
|
249
|
+
os.remove(tmp_tex)
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
# 编译 PDF
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
def _compile_pdf(tex_path: str, out_dir: str, compiler: str) -> str:
|
|
261
|
+
"""调用 xelatex/lualatex/pdflatex 编译 PDF。"""
|
|
262
|
+
base = os.path.splitext(os.path.basename(tex_path))[0]
|
|
263
|
+
pdf_path = os.path.join(out_dir, base + ".pdf")
|
|
264
|
+
|
|
265
|
+
cmd = [
|
|
266
|
+
compiler,
|
|
267
|
+
"-interaction=nonstopmode",
|
|
268
|
+
"-halt-on-error",
|
|
269
|
+
"-output-directory=" + out_dir,
|
|
270
|
+
tex_path,
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
proc = subprocess.run(
|
|
275
|
+
cmd, cwd=out_dir, capture_output=True, text=True, timeout=180,
|
|
276
|
+
)
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
raise CompileError(
|
|
279
|
+
f"LaTeX 编译超时(180s)。编译器:{compiler}\n"
|
|
280
|
+
f".tex 文件:{tex_path}"
|
|
281
|
+
)
|
|
282
|
+
except FileNotFoundError:
|
|
283
|
+
raise CompileError(
|
|
284
|
+
f"找不到编译器 {compiler},请确认已安装。"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return pdf_path
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# PDF → PNG
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def _pdf_to_png(
|
|
295
|
+
pdf_path: str,
|
|
296
|
+
out_dir: str,
|
|
297
|
+
base: str,
|
|
298
|
+
dpi: int,
|
|
299
|
+
overwrite: bool,
|
|
300
|
+
) -> List[str]:
|
|
301
|
+
"""把 PDF 转 PNG,多页输出多张。返回 PNG 路径列表。
|
|
302
|
+
|
|
303
|
+
优先级(前一个不可用就回退到下一个):
|
|
304
|
+
1. PyMuPDF (fitz) — 纯 Python wheel,无系统依赖,质量最好
|
|
305
|
+
2. pdftoppm — poppler-utils 命令行
|
|
306
|
+
3. pdftocairo — poppler-utils 命令行(渲染质量稍好)
|
|
307
|
+
4. convert — ImageMagick 命令行(兜底)
|
|
308
|
+
"""
|
|
309
|
+
# 1. PyMuPDF(纯 Python,优先)
|
|
310
|
+
try:
|
|
311
|
+
import fitz # PyMuPDF
|
|
312
|
+
return _pdf_to_png_pymupdf(pdf_path, out_dir, base, dpi, overwrite)
|
|
313
|
+
except ImportError:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
# 2. pdftoppm
|
|
317
|
+
if _which("pdftoppm"):
|
|
318
|
+
return _pdf_to_png_pdftoppm(pdf_path, out_dir, base, dpi, overwrite)
|
|
319
|
+
# 3. pdftocairo
|
|
320
|
+
if _which("pdftocairo"):
|
|
321
|
+
return _pdf_to_png_pdftocairo(pdf_path, out_dir, base, dpi, overwrite)
|
|
322
|
+
# 4. ImageMagick convert
|
|
323
|
+
if _which("convert"):
|
|
324
|
+
return _pdf_to_png_convert(pdf_path, out_dir, base, dpi, overwrite)
|
|
325
|
+
|
|
326
|
+
raise CompileError(
|
|
327
|
+
"未找到 PDF→PNG 转换工具。请任选一种安装:\n"
|
|
328
|
+
" pip install PyMuPDF # 推荐,纯 Python,无系统依赖\n"
|
|
329
|
+
" apt-get install poppler-utils # 或:pdftoppm/pdftocairo\n"
|
|
330
|
+
" brew install poppler # macOS\n"
|
|
331
|
+
" apt-get install imagemagick # 或:ImageMagick convert"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _pdf_to_png_pymupdf(
|
|
336
|
+
pdf_path: str, out_dir: str, base: str, dpi: int, overwrite: bool,
|
|
337
|
+
) -> List[str]:
|
|
338
|
+
"""用 PyMuPDF (fitz) 把 PDF 转 PNG。
|
|
339
|
+
|
|
340
|
+
优势:纯 Python wheel(pip install PyMuPDF 即可),无系统依赖,
|
|
341
|
+
渲染质量比 poppler 更好,速度更快。
|
|
342
|
+
"""
|
|
343
|
+
import fitz # type: ignore
|
|
344
|
+
|
|
345
|
+
# 清理旧文件
|
|
346
|
+
if overwrite:
|
|
347
|
+
for fn in os.listdir(out_dir):
|
|
348
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
349
|
+
try:
|
|
350
|
+
os.remove(os.path.join(out_dir, fn))
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
else:
|
|
354
|
+
existing = [fn for fn in os.listdir(out_dir)
|
|
355
|
+
if fn.startswith(base + "-") and fn.endswith(".png")]
|
|
356
|
+
if existing:
|
|
357
|
+
raise CompileError(
|
|
358
|
+
f"输出文件已存在:{os.path.join(out_dir, existing[0])} "
|
|
359
|
+
f"(使用 --overwrite 覆盖)"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# PyMuPDF 的 zoom 系数:DPI / 72(PDF 默认 72 DPI)
|
|
363
|
+
zoom = dpi / 72.0
|
|
364
|
+
matrix = fitz.Matrix(zoom, zoom)
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
doc = fitz.open(pdf_path)
|
|
368
|
+
except Exception as ex:
|
|
369
|
+
raise CompileError(f"PyMuPDF 打开 PDF 失败:{ex}")
|
|
370
|
+
|
|
371
|
+
pngs: List[str] = []
|
|
372
|
+
try:
|
|
373
|
+
for page_idx in range(len(doc)):
|
|
374
|
+
page = doc.load_page(page_idx)
|
|
375
|
+
# alpha=False 输出 RGB(白底),避免透明背景在 LaTeX 嵌入时变黑
|
|
376
|
+
pix = page.get_pixmap(matrix=matrix, alpha=False)
|
|
377
|
+
out_path = os.path.join(out_dir, f"{base}-{page_idx + 1}.png")
|
|
378
|
+
pix.save(out_path)
|
|
379
|
+
pngs.append(out_path)
|
|
380
|
+
finally:
|
|
381
|
+
doc.close()
|
|
382
|
+
|
|
383
|
+
if not pngs:
|
|
384
|
+
raise CompileError("PyMuPDF 未渲染出任何 PNG(PDF 可能是空的)")
|
|
385
|
+
return pngs
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _pdf_to_png_pdftoppm(
|
|
389
|
+
pdf_path: str, out_dir: str, base: str, dpi: int, overwrite: bool,
|
|
390
|
+
) -> List[str]:
|
|
391
|
+
# pdftoppm -r DPI -png prefix 输出 prefix-1.png prefix-2.png ...
|
|
392
|
+
prefix = os.path.join(out_dir, base)
|
|
393
|
+
# 先清理可能存在的旧文件
|
|
394
|
+
if overwrite:
|
|
395
|
+
for fn in os.listdir(out_dir):
|
|
396
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
397
|
+
try:
|
|
398
|
+
os.remove(os.path.join(out_dir, fn))
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
else:
|
|
402
|
+
# 检查是否已存在
|
|
403
|
+
existing = [fn for fn in os.listdir(out_dir)
|
|
404
|
+
if fn.startswith(base + "-") and fn.endswith(".png")]
|
|
405
|
+
if existing:
|
|
406
|
+
raise CompileError(
|
|
407
|
+
f"输出文件已存在:{os.path.join(out_dir, existing[0])} "
|
|
408
|
+
f"(使用 --overwrite 覆盖)"
|
|
409
|
+
)
|
|
410
|
+
cmd = ["pdftoppm", "-r", str(dpi), "-png", pdf_path, prefix]
|
|
411
|
+
try:
|
|
412
|
+
subprocess.run(cmd, cwd=out_dir, capture_output=True, text=True, timeout=120)
|
|
413
|
+
except Exception as ex:
|
|
414
|
+
raise CompileError(f"pdftoppm 执行失败:{ex}")
|
|
415
|
+
pngs = []
|
|
416
|
+
for fn in sorted(os.listdir(out_dir)):
|
|
417
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
418
|
+
pngs.append(os.path.join(out_dir, fn))
|
|
419
|
+
return pngs
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _pdf_to_png_pdftocairo(
|
|
423
|
+
pdf_path: str, out_dir: str, base: str, dpi: int, overwrite: bool,
|
|
424
|
+
) -> List[str]:
|
|
425
|
+
prefix = os.path.join(out_dir, base)
|
|
426
|
+
if overwrite:
|
|
427
|
+
for fn in os.listdir(out_dir):
|
|
428
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
429
|
+
try:
|
|
430
|
+
os.remove(os.path.join(out_dir, fn))
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
cmd = ["pdftocairo", "-r", str(dpi), "-png", pdf_path, prefix]
|
|
434
|
+
try:
|
|
435
|
+
subprocess.run(cmd, cwd=out_dir, capture_output=True, text=True, timeout=120)
|
|
436
|
+
except Exception as ex:
|
|
437
|
+
raise CompileError(f"pdftocairo 执行失败:{ex}")
|
|
438
|
+
pngs = []
|
|
439
|
+
for fn in sorted(os.listdir(out_dir)):
|
|
440
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
441
|
+
pngs.append(os.path.join(out_dir, fn))
|
|
442
|
+
return pngs
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _pdf_to_png_convert(
|
|
446
|
+
pdf_path: str, out_dir: str, base: str, dpi: int, overwrite: bool,
|
|
447
|
+
) -> List[str]:
|
|
448
|
+
# ImageMagick:convert -density DPI file.pdf out-%d.png
|
|
449
|
+
pattern = os.path.join(out_dir, base + "-%d.png")
|
|
450
|
+
if overwrite:
|
|
451
|
+
for fn in os.listdir(out_dir):
|
|
452
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
453
|
+
try:
|
|
454
|
+
os.remove(os.path.join(out_dir, fn))
|
|
455
|
+
except Exception:
|
|
456
|
+
pass
|
|
457
|
+
cmd = ["convert", "-density", str(dpi), pdf_path, pattern]
|
|
458
|
+
try:
|
|
459
|
+
subprocess.run(cmd, cwd=out_dir, capture_output=True, text=True, timeout=180)
|
|
460
|
+
except Exception as ex:
|
|
461
|
+
raise CompileError(f"ImageMagick convert 执行失败:{ex}")
|
|
462
|
+
pngs = []
|
|
463
|
+
for fn in sorted(os.listdir(out_dir)):
|
|
464
|
+
if fn.startswith(base + "-") and fn.endswith(".png"):
|
|
465
|
+
pngs.append(os.path.join(out_dir, fn))
|
|
466
|
+
return pngs
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
# 辅助
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
def _read_log_tail(log_path: str, n: int = 40) -> str:
|
|
474
|
+
if not os.path.isfile(log_path):
|
|
475
|
+
return "(无日志文件)"
|
|
476
|
+
try:
|
|
477
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
478
|
+
lines = f.readlines()
|
|
479
|
+
return "".join(lines[-n:])
|
|
480
|
+
except Exception as ex:
|
|
481
|
+
return f"(读取日志失败:{ex})"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _cleanup_intermediates(out_dir: str, base: str, keep_pdf: bool) -> None:
|
|
485
|
+
"""清理 LaTeX 中间文件。"""
|
|
486
|
+
exts_to_remove = [".aux", ".out", ".toc", ".fls", ".fdb_latexmk", ".synctex.gz"]
|
|
487
|
+
if not keep_pdf:
|
|
488
|
+
exts_to_remove.append(".pdf")
|
|
489
|
+
for ext in exts_to_remove:
|
|
490
|
+
p = os.path.join(out_dir, base + ext)
|
|
491
|
+
if os.path.isfile(p):
|
|
492
|
+
try:
|
|
493
|
+
os.remove(p)
|
|
494
|
+
except Exception:
|
|
495
|
+
pass
|