
Latex Translate Zh
- 1 installs
- Updated June 16, 2026
- wishrem/latex-translate-zh
Translate an English LaTeX paper into Chinese block by block and compile a properly typeset Chinese PDF.
About
Translates English LaTeX papers (including arxiv sources) into Chinese at the block level, then tunes CJK typography and compiles a verified Chinese PDF. A developer or researcher uses it to produce a Chinese version of an English LaTeX paper.
- Baseline compile plus CJK compatibility scan for fragile macros and spacing
- CJK font matching, baselineskip computation, and multi-pass PDF acceptance checks
Latex Translate Zh by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,361 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wishrem/latex-translate-zh --skill latex-translate-zhAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | June 16, 2026 |
| Repository | wishrem/latex-translate-zh ↗ |
What it does
Translate an English LaTeX paper into Chinese block by block and compile a properly typeset Chinese PDF.
Files
LaTeX论文翻译与中文编译
将英文LaTeX论文按翻译块维度翻译为中文,并编译输出中文PDF。
总体流程
全局规则:任何时候遇到缺失工具、缺失LaTeX包、缺失字体等,不得自行执行安装命令。必须先列出缺失项和安装命令,询问用户确认后再执行。无 sudo 权限时尤其不能静默跳过,必须明确告知用户手动安装。
0. 工具链与字体检测 — 检查编译工具和中文字体是否就绪,缺失则询问用户 1. 获取源文件 — 下载/拷贝 LaTeX 源码到 /tmp/latex-translate-<id>/ 2. 结构分析 — 判定单文件/多文件,决定并行或串行翻译策略 2.5. 基线编译与兼容扫描 — 编译原始英文版,扫描中文兼容风险(fragile macro、负间距等) 3. 块级翻译 — 提取段落级翻译块 → 翻译 → 回填,多文件时并行加速 4. 中文排版调优 — 字体匹配、行距计算、字号调节、粗斜体检查 4.1 字体选型 — 用 match_cjk_font.py 根据拉丁字体风格匹配中文字体 4.2 行距计算 — 用 compute_baselineskip.py 计算最佳 baselineskip 4.3 字号调节 — CJK 字体默认比英文放大 1pt(Scale=1.1) 4.4 粗斜体检查 — 用 check_cjk_variants.py 检查 Bold/Italic,生成 fallback 4.5 注入配置 — 将 4.1-4.4 的产出写入 main.tex 5. 编译与兼容修复 — 编译并自动修复中文兼容问题 5.5. PDF验收 — 多遍编译后检查交叉引用、排版和日志;日志有 ! 级错误时不得交付 6. 输出 — 将验收通过的 PDF 复制到当前目录
---
Step 0: 工具链与字体检测(最先执行)
在开始翻译前,先检测编译工具链:
uv run python $SKILL_DIR/scripts/compile.py --check-tools脚本输出:
- 操作系统和包管理器类型(apt/pacman/dnf/brew/winget)
- 编译引擎(xelatex/xetex/lualatex/luatex/pdflatex)和构建工具(latexmk/bibtex/biber)的可用状态
- 推荐中文字体(Noto Serif CJK SC, Noto Sans CJK SC, WenQuanYi 等)的安装状态
- 缺失工具/字体列表 + JSON结构化数据(供程序化处理)
- 不会输出硬编码的包名——包名随时间变化,必须实时搜索确认
如果检测到工具缺失,禁止自行执行安装命令,执行以下步骤:
1. 解析脚本输出的 [TOOLS_JSON] 行中的 search_query 字段 2. 使用 WebFetch 搜索正确的包名(如 install latexmk biber on Linux pacman) 3. 将缺失工具和安装命令呈现给用户,等待用户明确确认:
检测到以下工具缺失:
- latexmk → 安装: sudo pacman -S texlive-binextra
- biber → 安装: sudo pacman -S biber
是否需要我执行安装?[y/n]4. 用户回复 y/yes 后才执行安装命令。用户回复 n/no 或未确认时不能执行 5. 如无 sudo 权限导致安装失败,向用户说明并提供手动安装命令
工具说明:
| 工具 | 必需 | 用途 |
|---|---|---|
xelatex | 必需 | 中文编译(基于 xetex 引擎的 LaTeX 格式) |
xetex | 必需 | XeTeX 基础引擎,xelatex 的底层依赖 |
bibtex | 必需 | 参考文献处理 |
latexmk | 必需 | 自动多遍编译,处理交叉引用/参考文献 |
lualatex | 可选 | LuaLaTeX 备选引擎 |
luatex | 可选 | LuaTeX 基础引擎 |
biber | 可选 | 现代 biblatex 参考文献处理 |
中文字体: 至少需要一种中文字体(如 Noto Serif CJK SC、Noto Sans CJK SC、WenQuanYi 等),编译才能正常输出中文。脚本会自动检测推荐字体的安装状态。
---
Step 1: 获取源文件
工作目录预检(最先执行)
在获取源文件前,先检查工作目录是否已存在:
ls /tmp/latex-translate-<id> 2>/dev/null && echo "EXISTS" || echo "FREE"如果目录已存在(输出 EXISTS),不得自动删除或覆盖。必须提示用户:
工作目录 /tmp/latex-translate-<id> 已存在(可能是之前的翻译任务残留)。
请手动删除后再继续:
rm -rf /tmp/latex-translate-<id>
是否已删除?[y/n]用户确认后才能继续。
Arxiv论文
arxiv URL: https://arxiv.org/abs/XXXX.XXXXX
源码下载: https://arxiv.org/e-print/XXXX.XXXXXmkdir -p /tmp/latex-translate-<arxiv-id>
wget -O /tmp/latex-translate-<arxiv-id>/source.tar.gz "https://arxiv.org/e-print/XXXX.XXXXX"
tar -xzf /tmp/latex-translate-<arxiv-id>/source.tar.gz -C /tmp/latex-translate-<arxiv-id>/
# 如果有 .tar.gz 嵌套(arxiv有时双重打包)继续解压解压后查找主文件 main.tex 或 paper.tex。
通用下载网址
mkdir -p /tmp/latex-translate-<name>
wget -O /tmp/latex-translate-<name>/source.<ext> "<URL>"
# 如需解压: tar -xzf /tmp/latex-translate-<name>/source.<ext> -C /tmp/latex-translate-<name>/本地目录
cp -r /path/to/latex/project /tmp/latex-translate-<name>/注意:所有工作在 /tmp/latex-translate-<id>/ 下进行,不修改原始文件。原始项目作为只读基线保存。
创建中文工作目录
获取源文件后,创建完整的中文工作副本:
# 原始项目只读保存
# 复制整个项目到中文工作目录
cp -r /tmp/latex-translate-<id>/source /tmp/latex-translate-<id>/work_zh/中文工作目录必须保持:
- 所有文件名不变
- 目录结构不变
\input{}、\include{}路径不变\includegraphics{}路径不变\bibliography{}路径不变\label{}、\ref{}、\cite{}不变figure/table环境结构不变
只替换正文文本节点,不改变项目文件依赖图。原始项目保存在 /tmp/latex-translate-<id>/source/ 作为只读基线,用于 diff、回滚和结构校验。
禁止临场手写替代脚本:提取、合并、回填、编译和验收必须优先使用本 skill 的脚本接口。不要为了扁平化 JSON、批量替换 \n、修复反斜杠、复制 PDF 等步骤临场写 Python/sed/perl 脚本;这类脚本容易破坏 LaTeX 结构。若现有脚本接口不足,先修 skill 脚本,再用脚本执行。
---
Step 2: 项目结构分析
获取源文件后,先分析项目是否已分文件(使用 \input{} / \include{} 拆分了章节):
cd /tmp/latex-translate-<id>/source
# 检测所有 \input 和 \include 引用(忽略注释行)
grep -n '^[^%]*\\input{\|^[^%]*\\include{' main.tex已分文件(推荐用并行翻译)
如果主文件通过 \input{} / \include{} 引入独立 .tex 文件(如 sections/intro.tex, chapters/method.tex),且每个文件对应一个章节:
1. 列出所有内容文件,排除 preamble-only 文件(宏包配置、自定义命令等) 2. 统计内容文件数量,确认可以并行 3. 直接进入 Step 3.2 并行翻译
未分文件(需要先拆分)
如果只有一个大的 main.tex(所有内容在一个文件内):
1. 分析文档结构,按 \section{...} / \subsection{...} 边界识别可拆分的章节 2. 向用户呈现拆分方案:
原文件 main.tex 共 N 个章节,建议拆分为:
sections/00_preamble.tex — 导言区(documentclass、宏包、自定义命令)
sections/01_abstract.tex — Abstract
sections/02_intro.tex — 1. Introduction
sections/03_related.tex — 2. Related Work
sections/04_method.tex — 3. Method
sections/05_experiments.tex — 4. Experiments
sections/06_conclusion.tex — 5. Conclusion
main.tex — 骨架文件(只含 \input{} 和少量结构命令)
是否按此方案拆分?[y/n]3. 用户确认后,执行拆分:将各章节内容提取到独立文件,主文件用 \input{} 引用 4. 先编译一次拆分后的文件,确认排版正确,呈现给用户确认:
拆分后编译成功,PDF正常生成。排版是否OK?没问题的话继续翻译。[y/n]5. 用户确认排版后,再进入翻译步骤
依赖文件处理
如果项目中有 .sty, .cls, .bst, .bib, 图片等依赖文件不存在于源码目录中,编译时会报错。遇到此类问题:
1. 解析编译错误,列出缺失的依赖(如 file.sty not found) 2. 向用户呈现:
LaTeX 依赖缺失:
style/nips.sty — 会议模板文件
figures/architecture.pdf — 图片资源
mybib.bib — 参考文献数据库
如何处理?
[A] 自动安装(Web搜索下载)
[B] 手动安装(告诉我文件位置或提供URL)
[C] 跳过(我自行处理)3. 根据用户选择执行
---
Step 2.5: 基线编译与中文兼容性扫描
在翻译前,必须先编译原始英文项目,建立基线。该步骤用于区分:
- 原始模板本身的问题
- 翻译过程中引入的问题
- 中文字体/ctex/XeTeX 与英文模板之间的兼容问题
2.5.1 原始项目基线编译
在 source/ 目录中对原始英文项目编译一次:
cd /tmp/latex-translate-<id>/source
latexmk -pdf -interaction=nonstopmode main.tex如果原项目使用 XeLaTeX 或 LuaLaTeX,则:
latexmk -xelatex -interaction=nonstopmode main.tex如果基线编译失败(缺包/缺字体等),不得自行执行安装命令。必须解析错误,列出缺失项和安装命令,向用户呈现:
基线编译失败,缺少以下依赖:
LaTeX 包:
algpseudocode.sty → sudo pacman -S texlive-publishers
times.sty → sudo pacman -S texlive-fontsrecommended
请手动安装后告知,或回复 [skip] 跳过基线编译直接进入翻译。记录以下信息:
- 是否能生成 PDF
- 是否存在 undefined references/citations
- 是否存在 overfull/underfull box
- 是否存在 mdframed/tcolorbox/tabbing/caption 相关 warning/error
- 编译遍数
2.5.2 中文兼容性风险扫描
运行兼容性扫描脚本:
uv run python $SKILL_DIR/scripts/latex_compat_scan.py /tmp/latex-translate-<id>/source/也可以直接 grep 辅助检查高风险结构:
grep -RInE '\\begin\{(mdframed|framed|tcolorbox|tabbing|wrapfigure|minipage|figure\*|table\*)\}|\\caption\{|\\section\{|\\subsection\{|\\noindent\\textbf|\\newcommand|\\renewcommand|\\DeclareRobustCommand|\\xspace|\\vspace\{-|\\hspace\{-|\\vskip -' .重点记录以下风险:
1. `mdframed` / `framed` / `tcolorbox`:中文行高可能导致盒子高度计算不准,后续内容重叠 2. `tabbing`:中文翻译后缩进层级可能暴露缺少 \= tab 位的问题 3. `\caption{}`、`\section{}`、`\subsection{}`:属于 moving arguments,自定义宏可能 fragile 4. 含 `\xspace` 的宏:在 caption、section、PDF bookmark 中高风险 5. `\noindent\textbf{...}`:run-in heading,中文变长后可能视觉拥挤 6. 负间距:\vspace{-...}、\hspace{-...}、\vskip -...,英文模板中常见,中文后更容易触发重叠
2.5.3 风险清单输出
生成 compatibility_report.md,格式:
# 中文兼容性风险报告
## Box/frame 环境
- main.tex:128 mdframed 后紧跟 \section — 可能需要 skipbelow 或额外 \vspace
## Tabbing 环境
- algorithm.tex:45 tabbing 有 N 层 \> 但只定义 M 个 tab 位
## Fragile macros in moving arguments
- \sys 使用了 \xspace
- \sys 出现在 \caption 中 (experiment.tex:72)
## Run-in headings
- overload.tex: Request level
- overload.tex: System level
## 负间距
- experiment.tex: figure* 含 \vspace{-0.05in}翻译时必须针对报告中的每个风险点做防御处理。如果基线编译本身失败,排除项目自身问题后再翻译。
---
Step 3: 翻译执行
3.1 翻译块判定规则
翻译块定义:文献中可以独立翻译、独立校对、且语义相对完整的最小内容单元。
优先级顺序: 1. 加粗字体摘要 / 小标题式概括语 2. 最小章节序号 3. 无编号但具有标题功能的小节标题 4. 连续段落的语义完整性
判定流程(按顺序执行):
第一步:排除非正文内容
以下内容不作为翻译块:
- 文章标题
\title{...} - 作者、机构、邮箱、通讯作者信息
- 页眉、页脚、页码
- 引用文献列表 (
\begin{thebibliography}...) - 尾页作者介绍、致谢中的作者履历
- 代码段 (
\begin{lstlisting},\begin{verbatim}等) - 算法伪代码本体 (
\begin{algorithm}内代码) - 表格内部原始数据 (但表题/表注需翻译)
- 公式本体 (
\begin{equation}...内的数学公式) - 图表中的坐标轴、图例、数值标签
\cite{},\ref{},\label{}命令
例外(这些应该翻译):
- 图题/表题 (
\caption{...}中含解释性文字的) - 公式前后的解释文字
- 算法说明段落(算法伪代码本体不翻译)
- 表格标题和说明文字
- 脚注和尾注(含解释性内容的)
- 摘要 (
\begin{abstract}...\end{abstract})
第二步:识别加粗摘要
如果文档中有 \textbf{...} 加粗句子充当"摘要式标题",它和后续解释段落合并为一个翻译块。
判断标准:加粗文字是否承担了"小标题/主题句/概括句"的结构功能。
- 如果是段内强调词,不单独作翻译块
- 如果多个连续加粗句分别引出不同段落,每个加粗句及其覆盖段落分别作为翻译块
第三步:识别最小章节层级
- 如果有编号章节(如 2 → 2.1 → 2.1.1),以最小编号层级为翻译块单位
- 如果上级标题下没有正文,不单独作为翻译块
- 如果上级标题下有导言:
- 导言能独立表达完整含义 → 单独翻译块
- 导言只是引出后文 → 并入其后第一个最小章节翻译块
- 导言概括整个大节 → 作为该大节的"总述翻译块"
第四步:检查语义完整性
以下情况合并为一个翻译块:
- 同一小节下多个段落共同解释一个概念
- 一个段落提出问题,下一段给出方法或结论
- 多段共同解释同一个公式、模型、实验设置或结果
- 加粗摘要后连续若干段都在展开同一主题
不要仅因为换行或分段就拆成多个翻译块。
第五步:检查是否破坏上下文
以下结构应尽量保持在同一个翻译块中:
- "提出概念 → 解释概念"
- "提出问题 → 给出解决方案"
- "实验设置 → 对应说明"
- "结果描述 → 结果分析"
- "公式 → 公式解释"
特殊情况
- Abstract:作为独立翻译块。即使内部分多个段落,通常合并为一个翻译块
- Keywords:翻译但单独标记为"关键词块"
- Acknowledgements:询问用户是否翻译
- Appendix:询问用户;如含正文解释按章节规则划分,纯数据/证明/代码可排除
3.2 翻译写入策略
禁止直接修改用户原始源码,禁止生成 `_zh.tex` 后缀文件。
翻译在复制出的工作目录 /tmp/latex-translate-<id>/work_zh/ 中进行:
1. 保持所有文件名、目录结构、\input{}、图片路径、bib 路径不变 2. 对 .tex 文件做原位文本替换——只替换正文文本,不动 LaTeX 结构 3. 不修改 \input{sections/intro} 为 \input{sections/intro_zh} 4. 原始目录作为只读基线,用于 diff、回滚和结构校验
提取文本块
uv run python $SKILL_DIR/scripts/extract_blocks.py -d /tmp/latex-translate-<id>/work_zh/ -o /tmp/latex-translate-<id>/blocks.json输出 blocks.json,每个翻译块包含 block_id、file、source_text、context。
提取脚本按段落保留内联 LaTeX 命令、数学公式和引用命令,避免把一句话切成 As shown in、, our ... 这类碎片。它会跳过表格主体、公式环境、算法主体、图片/表格位置参数(如 [ht]),只抽取标题、caption、脚注、正文段落等可翻译内容。
翻译文本块
agent 只翻译 source_text,返回 block_id → translation 映射。不修改 JSON 中的任何其他字段,不重写 .tex 文件。
翻译输出格式可以是以下任一形式,回填脚本均可读取:
{"blocks": [{"block_id": "blk_0001", "translation": "译文"}]}{"blk_0001": "译文"}{"intro.tex": {"blk_0001": "译文"}}如果 context 是 section、caption、textbf、emph 等命令参数,source_text 只包含命令内部文字。译文也只写内部文字,不要额外包一层 \section{}、\caption{}、\emph{}。
宏后紧跟中文时必须留边界:写 \sys{}在、\sys 在、\cm{}的,不要写 \sys在、\cm的,否则 TeX 会把它解析成新的未定义命令。
回填译文
uv run python $SKILL_DIR/scripts/backfill_blocks.py -d /tmp/latex-translate-<id>/work_zh/ -t /tmp/latex-translate-<id>/translations.json --blocks /tmp/latex-translate-<id>/blocks.json回填脚本根据 block_id 和 source_text 精确定位并替换为译文。多个文件时自动并行写入(ThreadPoolExecutor),大幅加速回填速度。串行模式可用 --no-parallel 禁用。
回填脚本会拒绝会破坏结构的译文,包括 LaTeX 命令数量变化、数学 $ 数量变化、表格换行 \\ 数量变化、疑似布局参数等。遇到 [SKIP] 时不要用手写替换绕过;回到对应翻译块修正译文。
3.3 并行翻译(已分文件项目)
触发条件:项目已用 \input{} / \include{} 分文件,且内容文件数 ≥ 2。
执行步骤:
1. 复制项目到 work_zh/ — 保持目录结构和文件依赖不变 2. 扫描全文建术语表 — 主agent先读取所有内容文件,提取专有名词,确定统一译法,输出术语表 3. 提取文本块 — 运行 extract_blocks.py 生成 blocks.json 4. 分组并启子agent — 按文件将 blocks 分组,对每组启动子agent翻译:
子agent翻译任务:
翻译文件: /tmp/latex-translate-<id>/blocks.json 中 file="sections/intro.tex" 的所有块
输出: /tmp/latex-translate-<id>/translations_intro.json
术语表: [从主agent传入]
对每个块,只翻译 source_text 字段为中文,其他字段原样保留。输出 JSON,不要重写 .tex。
翻译规则:
- 保留所有 \cite, \ref, \label 等命令不变(source_text 中可能含有)
- 保留所有 LaTeX 宏命令本身不变;宏后接中文要写成 \sys{}在 或 \sys 在
- 如果 context 是 section/caption/textbf/emph,只输出内部译文,不要额外包 \section{} 或 \caption{}
- 缩写保留原文,首次出现加全称
- 技术名词保留英文
- 人名不翻译
- 专业术语:中文译名(英文原文)首次,后续只用中文数量控制:超过5个文件时,每批5个。全部完成后再回填。
5. 合并翻译结果 — 主agent汇总所有子agent的翻译输出 6. 回填 — 运行 backfill_blocks.py 将译文写入 work_zh/ 7. 注入中文支持 — 在 work_zh/ 的主文件中添加 xeCJK 支持 8. 编译 — 编译 work_zh/ 中的中文版
3.4 顺序翻译(未分文件项目)
触发条件:项目是单文件,或拆分后仍有较大内容块。
1. 复制项目到 work_zh/ 2. 建立术语表 3. 提取文本块 — 运行 extract_blocks.py 4. 主agent按块翻译 — 生成翻译 JSON 5. 回填 — 运行 backfill_blocks.py 6. 编译
3.5 翻译格式
翻译时:
- 保留所有
\cite{...},\ref{...},\label{...},\begin{...},\end{...}不变 - 保留数学公式
$...$,$$...$$,\begin{equation}...\end{equation}不变 - 保留交叉引用标记如
[1],(Smith et al., 2023),Eq. (3),Figure 2,Table 1 - 保留
\textbf{},\emph{},\textit{}等格式化命令,只翻译其内部文字 - 图表标题
\caption{...}中的文字需翻译 - 章节标题
\section{...},\subsection{...}中的文字需翻译
3.6 术语翻译规则
缩写保留原文
CNN, LLM, GPU, MAH, CAS 等缩写保留。首次出现时如有全称:
Large Language Model (LLM)→大语言模型(Large Language Model, LLM)- 原文只给缩写时不强行扩展
技术名词保留英文
Nvidia, PyTorch, TensorFlow, Transformer, CUDA, GitHub 等品牌/框架/库名保留英文:
Transformer→TransformerTransformer architecture→Transformer 架构
人名不翻译
Hinton, LeCun, Vaswani, Turing 等人名保留原文,不音译。极稳定译名(如图灵)可用。
专业术语:中文译名(英文原文)
首次出现:
Multi-head Attention→多头注意力(Multi-head Attention)Self-Attention→自注意力(Self-Attention)
后续只用中文译名。
术语一致性
同一术语全文译法统一。必须先扫描全文,建立术语表后再翻译。
| 原文术语 | 推荐译法 | 首次出现形式 |
|---|---|---|
| Multi-head Attention | 多头注意力 | 多头注意力(Multi-head Attention) |
| Embedding | 嵌入表示 | 嵌入表示(Embedding) |
| Fine-tuning | 微调 | 微调(Fine-tuning) |
| Token | 词元 | 词元(Token) |
| Layer Normalization | 层归一化 | 层归一化(Layer Normalization) |
3.7 LaTeX命令保留清单
翻译时绝不修改的命令: \cite, \ref, \label, \begin/\end, 数学环境, \bibliography, \bibliographystyle, 模板宏命令
保留命令,只翻译内部文字: \textbf, \emph, \textit, \section, \subsection, \caption
3.8 Run-in heading 翻译规则
英文论文常用以下形式作为行内小标题:
\noindent\textbf{Request level:} text...
\noindent\textbf{System level:} text...这不是结构错误。翻译时应保留结构,只翻译标题文字。
翻译原则:
1. 尽量短译,不做解释性扩写:
Request level:→请求级别:System level:→系统级别:
2. 不得把短标题翻译成很长的中文短语 3. 不得擅自改成 \paragraph{} 或 \subsubsection{} 4. 如果视觉验收发现行内标题过长或拥挤,再局部改成块状标题
可选局部块状化修复:
如果标题不超过 12 个汉字,保持行内形式:
\par\smallskip
\noindent\textbf{请求级别:}\quad
正文...如果标题超过 12 个汉字,可改为块状:
\par\smallskip
\noindent\textbf{请求级别:}\par
正文...---
Step 4: 中文排版调优
在翻译完成后、中文编译前,对 CJK 字体进行排版调优。这一步骤独立于编译修复, 专门解决中文渲染的视觉质量。
4.1 字体选型
用 match_cjk_font.py 根据原始拉丁字体风格自动匹配中文字体:
uv run python $SKILL_DIR/scripts/match_cjk_font.py /tmp/latex-translate-<id>/work_zh/main.tex --code-only脚本会:
- 解析 .tex/.cls 识别拉丁 rmfamily/sffamily/ttfamily 风格(衬线/无衬线/等宽)
- 通过 fc-list 查询系统可用中文字体
- 按风格匹配:衬线→Noto Serif CJK SC / 无衬线→Noto Sans CJK SC / 等宽→Noto Sans Mono CJK SC
- 输出
\setCJKmainfont/sansfont/monofont配置
也可快速输出 JSON 格式供 pipeline 使用:
uv run python $SKILL_DIR/scripts/match_cjk_font.py /tmp/latex-translate-<id>/work_zh/main.tex --json4.2 行距计算
用 compute_baselineskip.py 基于 CJK 字体度量计算最佳行距:
uv run python $SKILL_DIR/scripts/compute_baselineskip.py /tmp/latex-translate-<id>/work_zh/main.tex --cjk-scale 1.1 --code-only--cjk-scale 参数接受 CJK 字体缩放因子(见 4.3),脚本会基于缩放后的序号计算行距。 输出 \fontsize{...}{...}\selectfont 或含 \setCJKmainfont{...}[Scale=...] 的完整配置。
公式依据:baselineskip = effective_size × cjk_factor,其中 cjk_factor 由拉丁文默认行距因子叠 加 CJK 字体密度补偿量得出,夹在 [latin_skip × 1.04, effective_size × 1.6] 区间。
4.3 字号调节
中文字体在相同 pt 值下视觉偏小,默认将 CJK 字体放大 1pt:
| 基础字号 | Scale 值 | 实际 CJK 字号 |
|---|---|---|
| 10pt | 1.1 | 11pt |
| 11pt | 1.09 | ≈12pt |
| 12pt | 1.08 | ≈13pt |
通过 \setCJKmainfont{...}[Scale=<factor>] 实现。必须将此 Scale 值传给 4.2 的 --cjk-scale 参数以确保行距同步缩放。
4.4 粗斜体检查
用 check_cjk_variants.py 检查 CJK 字体的 Bold/Italic 变体支持:
uv run python $SKILL_DIR/scripts/check_cjk_variants.py "Noto Serif CJK SC" --code-only脚本会:
- 通过 fc-list 检测字体是否提供 Bold、Italic、BoldItalic 子面
- 缺失项自动生成
\xeCJKsetup{AutoFakeBold=..., AutoFakeSlant=...} - 对已提供的变体生成
BoldFont/ItalicFont映射
4.5 注入配置
将 4.1-4.4 产出的 LaTeX 代码注入到 main.tex:
| 配置项 | 注入位置 | 来源 |
|---|---|---|
\usepackage{xeCJK} + \xeCJKsetup{...} | 导言区(\begin{document} 前) | 固定 |
\setCJKmainfont/sansfont/monofont | 导言区 | 4.1 match_cjk_font.py |
\xeCJKsetup{AutoFakeBold/AutoFakeSlant} | 导言区 | 4.4 check_cjk_variants.py |
\fontsize{...}{...}\selectfont | \begin{document} 之后第一行 | 4.2 compute_baselineskip.py |
[Scale=...] 加入 \setCJKmainfont 选项中 | 导言区 | 4.3 字号调节 |
示例完整配置(10pt 文档):
% 导言区(\begin{document} 之前)
\usepackage{xeCJK}
\xeCJKsetup{CJKmath=true}
\setCJKmainfont{Noto Serif CJK SC}[Scale=1.1, BoldFont={Noto Serif CJK SC}, AutoFakeSlant={0.167}]
\setCJKsansfont{Noto Sans CJK SC}
\setCJKmonofont{Noto Sans Mono CJK SC}
\xeCJKsetup{AutoFakeSlant={0.167}}
\begin{document}
\fontsize{10}{14.5}\selectfont
% ... 其余正文 ...已注入包不要重复添加:如果 4.1-4.4 产出的某些配置已在文件中存在,不要重复写入。
4.6 字体缺失处理
如果任一脚本报告字体未安装,不得自行安裝。列出缺失字体和安装命令,询问用户确认。
---
Step 5: 编译与兼容修复
5.1 中文支持注入策略
优先采用最小侵入策略:保留原始 `\documentclass`,只在导言区添加中文支持。
ctex/ctexart/ctexrep/ctexbook 不仅提供中文支持,还会改变标题、字号、行距、中文标点、章节格式等排版参数。对论文模板,尤其是 arXiv 论文、会议模板、双栏模板、ACM/IEEE 模板、含 mdframed 的模板,优先使用 ctex 极易引发兼容性问题。
默认方案:保留原 documentclass + xeCJK
在导言区、\begin{document} 之前添加最小 xeCJK 配置:
\usepackage{xeCJK}
\xeCJKsetup{CJKmath=true, AutoFakeBold=true, AutoFakeSlant=0.167}
\setCJKmainfont{Noto Serif CJK SC}[Scale=1.05]
\setCJKsansfont{Noto Sans CJK SC}[Scale=1.05]
\setCJKmonofont{Noto Sans Mono CJK SC}[Scale=1.05]字体名和 Scale 应优先来自 match_cjk_font.py、compute_baselineskip.py、check_cjk_variants.py 的输出。不要同时加载 ctex 和 xeCJK。
仅在以下条件全部满足时,才替换为 ctexart/ctexrep/ctexbook
1. 原始文档类是裸 article / report / book(无模板宏包覆盖版式) 2. 没有会议/期刊模板宏包控制版式 3. 没有大量自定义标题格式、双栏布局、mdframed/tcolorbox、复杂浮动体 4. 基线编译和中文试编译均确认替换 documentclass 不造成版式异常 5. 用户明确接受标题/字号/行距可能变化
禁止事项
- 不得在未知模板中直接把会议/期刊 class 或定制 article 模板替换成
ctexart - 不得对 ACM/IEEE/USENIX/NeurIPS 等会议模板直接使用
ctex或ctexart - 不得对含
mdframed+fontspec的模板直接使用 ctexart
5.2 编译命令
uv run python $SKILL_DIR/scripts/compile.py -d /tmp/latex-translate-<id>/work_zh --recipe xelatex-bibtex编译脚本自动:
- 检测中文内容,优先使用XeLaTeX
- 使用 latexmk 自动多遍编译(处理bibtex/biber交叉引用)
- 使用
-no-shell-escape安全模式 - 报告编译错误时,给出具体行号和错误类型
- 编译后扫描
.log,如果存在!级 LaTeX 错误、未稳定引用、图片加载错误、表格对齐错误等,即使 PDF 文件存在也返回失败
不要用 | tail、grep -v、latexmk -f 或手动 xelatex && bibtex && xelatex 来绕过脚本验收。latexmk -f 可以生成带错误的 PDF,这种 PDF 不能交付。
5.3 Box/Frame 环境中文兼容修复
如果文档使用 mdframed、framed、tcolorbox,翻译后必须检查其后是否紧跟 \section、\subsection、正文段落或浮动体。
mdframed 安全规则
如果 mdframed 内含中文,且其后 5 行内出现 \section / \subsection / \paragraph / 正文段落,则优先添加安全间距:
\end{mdframed}
\par\addvspace{1em}如果多个 mdframed 都出现类似问题,可在导言区统一添加(谨慎使用):
\usepackage{etoolbox}
\AfterEndEnvironment{mdframed}{\par\addvspace{1em}}全局补丁仅当大量 mdframed 同时出现问题时才使用,优先做局部补丁。
tcolorbox 安全规则
如果使用 tcolorbox,优先启用 breakable 并设置间距:
\usepackage[most]{tcolorbox}需要跨页或长中文说明框时:
\begin{tcolorbox}[breakable, before skip=1em, after skip=1em]
...
\end{tcolorbox}修复原则
1. 不使用负间距修复重叠 2. 优先增加 skipbelow / after skip / \addvspace 3. 局部修复优先于全局修复 4. 修复后必须重新编译并检查相邻页面
5.4 Tabbing 环境修复规则
翻译前后必须扫描所有 tabbing 环境。
检查规则
在每个 tabbing 环境中:
1. 统计正文中最大连续 \> 层级数 2. 检查是否存在 \=...\kill 行定义 tab 位 3. 如果最大 \> 层级数大于已定义 \= 数量,则必须补充 tab 位
自动修复示例
如果环境中存在三重或四重 \>,添加足够的 tab 位:
\begin{tabbing}
\hspace{1.5em}\=\hspace{1.5em}\=\hspace{1.5em}\=\hspace{1.5em}\=\kill
...
\end{tabbing}禁止事项
不得通过删除 \> 或改变伪代码结构来规避错误。
5.5 Moving Arguments 中的 Fragile Macro 修复
LaTeX 中以下命令的参数属于 moving arguments 或类 moving arguments:
\caption{...}\section{...}、\subsection{...}、\subsubsection{...}\paragraph{...}\title{...}
在这些参数中使用自定义宏前,必须检查宏定义是否 fragile。
高风险宏定义
包含以下内容的自定义宏视为高风险:
\xspace\footnote\cite\ref\url- 复杂格式命令
- 未用
\DeclareRobustCommand声明的项目名宏
修复优先级(按推荐度排序)
1. 最佳:在 \caption{} 中直接写普通文本,不要用宏:
\caption{Mooncake 的性能结果}2. 次选:将宏定义改为 robust:
\DeclareRobustCommand{\sys}{Mooncake\xspace}3. 最后手段:在 moving argument 中使用 \protect:
\caption{\protect\sys 的性能结果}自动扫描命令
grep -RInE '\\newcommand\{\\[A-Za-z@]+\}.*\\xspace|\\caption\{.*\\[A-Za-z@]+' *.tex sections/*.tex5.6 依赖处理
编译过程中如遇到缺失依赖(LaTeX包、图片、字体、样式文件等),禁止自行执行安装命令。必须先呈现给用户,等待确认。
1. 解析编译输出,提取缺失依赖信息(如 ! LaTeX Error: File 'xxx.sty' not found) 2. 分类依赖类型:
- LaTeX包(.sty/.cls) — 可通过 tlmgr 安装
- 图片资源(.pdf/.png/.jpg) — 可能需从原始项目拷贝或重新生成
- 字体文件 — 需安装系统字体或指定已有字体
- 参考文献(.bib) — 需确认路径或下载
3. 向用户呈现并询问处理方式:
编译缺少以下依赖:
LaTeX包:
algorithm.sty — 算法环境
subfigure.sty — 子图支持
图片资源:
figures/arch.pdf — 架构图
如何处理?
[A] 自动安装LaTeX包(tlmgr install),图片跳过并添加占位
[B] 全部手动处理(请告诉我文件位置)
[C] 我自行解决,编译可以先跳过4. 根据用户选择执行后重编译
5.7 编译失败处理:中文兼容错误库
除依赖缺失外,必须识别以下中文兼容错误模式:
| 错误现象 | 常见根因 | 修复动作 |
|---|---|---|
| 框与后文重叠 | mdframed/framed 高度计算与中文行高不匹配 | 在 \end{mdframed} 后添加 \par\addvspace{1em} 或配置 skipbelow |
| Undefined tab position | tabbing 缺少足够 \= tab 位 | 添加 \=\=\=\kill 行 |
| Undefined control sequence in caption | fragile macro 出现在 moving argument 中 | caption 中写普通文本,或 \DeclareRobustCommand,或 \protect |
| PDF bookmark warning | 中文/宏命令进入 section/bookmark | 使用 \texorpdfstring{TeX文本}{PDF文本} |
| Overfull boxes 大幅增加 | 中文译文过长、run-in heading 过长 | 短译标题、局部断行、调整段落 |
| 浮动体与正文重叠 | 原模板负间距在中文后不再安全 | 减少或删除局部负 \vspace |
\sys not defined 或类似 | 翻译文件丢失原始宏定义 | 确认导言区宏定义未被移除,\input 路径正确 |
---
Step 5.5: PDF验收
5.5.1 交叉引用验收
第一遍 XeLaTeX 中出现 ??、undefined references、undefined citations 不立即视为翻译错误。这是 LaTeX 正常行为。
必须先通过编译脚本执行完整多遍编译:
uv run python $SKILL_DIR/scripts/compile.py -d /tmp/latex-translate-<id>/work_zh --clean-all
uv run python $SKILL_DIR/scripts/compile.py -d /tmp/latex-translate-<id>/work_zh --recipe xelatex-bibtex或根据项目使用 biber:
uv run python $SKILL_DIR/scripts/compile.py -d /tmp/latex-translate-<id>/work_zh --recipe xelatex-biber只有在最终编译后仍出现以下内容,才视为失败:
grep -RInE 'undefined references|Citation .* undefined|Reference .* undefined|There were undefined references|Label.*multiply defined' *.log
grep -RInE '§\?\?|图 *\?\?|表 *\?\?|\[\?, *\?, *\?\]|Figure *\?\?|Table *\?\?' *.tex *.aux *.log如果最终 PDF 中引用正常,不得把第一遍 ?? 归因于翻译破坏结构。
任何最终 .log 中残留以下内容都视为失败,不能复制 PDF:
! Undefined control sequence
! Misplaced alignment tab character &
! Missing $ inserted
! LaTeX Error
! Package graphics Error
Unable to load picture or PDF file5.5.2 结构一致性不是最终验收
翻译后必须检查: 1. LaTeX 结构是否一致(\label、\cite、\ref、\begin/\end 数量与位置) 2. 编译日志是否干净(没有新增的 error,warnings 与基线对比) 3. PDF 视觉排版是否正常
即使 \label、\cite、\ref、\begin/\end 完全一致,仍可能因为中文字体、行高、ctex、XeTeX、fragile macro、run-in heading、负间距导致 PDF 错乱。
结构 diff 通过后,仍必须执行中文兼容性检查和视觉验收。
5.5.3 视觉检查要点
打开生成的 PDF,逐页检查:
1. 文字是否与图表、框、页眉页脚重叠 2. mdframed/tcolorbox 等框环境后是否出现大段空白或内容重叠 3. \section/\subsection 标题是否过长导致换行异常 4. run-in heading 是否过于拥挤 5. 浮动体(图、表)位置是否正常 6. 页边距是否与原始 PDF 一致或有合理变化
如发现问题,回到 Step 5.3-5.7 对应的修复规则处理,然后重编译、重验收。
---
Step 6: 输出
编译验收通过后,PDF位于 /tmp/latex-translate-<id>/work_zh/main.pdf,复制到当前工作目录:
cp /tmp/latex-translate-<id>/work_zh/main.pdf ./<paper-name>_zh.pdf---
__pycache__/
*.pyc
*.pyo
*.aux
*.log
*.out
*.toc
*.bbl
*.blg
*.fdb_latexmk
*.fls
*.synctex.gz
*.nav
*.snm
*.vrb
*.pdf
*.bak
*.backup
*~
.DS_Store
3.10
AGENTS.md
约束
- 必须使用 `uv` 运行项目中的 Python 脚本:
uv run python $SKILL_DIR/scripts/<script>.py - 不要安装或修改本地的 skill:本仓库是 skill 源码,不要执行
npx skills add等安装操作 - 不要自动修改 version 或提交 commit:版本号和 git 操作需要用户明确指示
- 遇到缺失工具/包/字体不得自动安装:必须先列出安装命令,等待用户确认
- 所有翻译工作在 `/tmp/latex-translate-<id>/` 中进行:绝不修改原始源文件
- 本项目没有测试/lint/CI 命令:不要尝试运行 pytest、ruff、mypy 等
常用命令
| 命令 | 用途 |
|---|---|
uv run python scripts/compile.py --check-tools | 检测工具链 |
uv run python scripts/extract_blocks.py -d <dir> -o blocks.json | 提取翻译块 |
uv run python scripts/backfill_blocks.py -d <dir> -t translations.json | 回填译文 |
uv run python scripts/compile.py -d <zh_dir> | 编译中文 PDF |
uv run python scripts/latex_compat_scan.py <dir> | 中文兼容性扫描 |
uv run python scripts/match_cjk_font.py <main.tex> | 字体风格匹配 |
uv run python scripts/compute_baselineskip.py <main.tex> | 行距计算 |
uv run python scripts/check_cjk_variants.py <font> | 粗斜体检查 |
结构
SKILL.md # Agent 工作流指令(主规范)
scripts/
compile.py # XeLaTeX 编译
extract_blocks.py # 解析 .tex 提取翻译块
backfill_blocks.py # 回填译文(ThreadPoolExecutor 并行写入)
latex_compat_scan.py # 中文兼容性预检
grade_assertions.py # 评估断言
match_cjk_font.py # 字体风格匹配
compute_baselineskip.py # 行距计算
check_cjk_variants.py # 粗斜体检查注意事项
pyproject.toml没有[build-system],不能作为包安装,脚本直接通过路径调用compile.py默认禁用 shell escape,--shell-escape需要--trusted-sourcebackfill_blocks.py跳过歧义匹配(原文出现多次),按长度排序避免部分匹配- 远程仓库:
git@github.com:Wishrem/latex-translate-zh.git,分支main
Commit 规范
type: 中文简述
英文补充说明(可选单行)。
Bump version to X.Y.Z(如涉及版本号变更)。type:feat/fix/docs/refactor/chore- 标题一行、中文简述;详情最多 2-3 行英文,不用 bullet list
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
[project]
name = "latex-translate-zh"
version = "0.2.1"
description = "LaTeX论文翻译成中文并编译PDF — AI agent skill for translating LaTeX papers to Chinese"
license = { text = "MIT" }
requires-python = ">=3.10"
latex-translate-zh
将 LaTeX 论文翻译成中文并编译 PDF —— AI Agent 技能。

当前测试状态:OpenCode + Deepseek V4 Flash ✅,其他平台兼容性待验证。欢迎反馈。
为什么比网页翻译/PDF 翻译更好?
| 方式 | 公式 | 排版 | 交叉引用 | 图表标题 |
|---|---|---|---|---|
| arxiv 网页 + 翻译插件 | 错位/丢失 | 无 | 断裂 | 混在正文中 |
| PDF 提取翻译 | 乱码/图片 | 丢失结构 | 号对不上 | 图层分离 |
| latex-translate-zh | 对齐 | 保持 | 有效 | 原位替换 |
- arxiv 网页翻译:公式渲染成 MathJax 后被翻译破坏,
\cite变乱码,图表标题混入正文无法区分 - PDF 翻译:提取文字丢失 LaTeX 结构,数学符号变
?,引用编号错乱,编译后布局变形 - latex-translate-zh:直接在
.tex源码层翻译后编译 —— 数学公式原样渲染、引用编号不变、图表标题位置精准、编排格式与原文一致,拿到的是可打印的高保真中文 PDF
按论文自然结构翻译
一篇论文的正文不是一整块连续文字——它由摘要、章节、小节、段落、图题、表题等结构单元组成。大多数翻译工具要么把整篇论文当一坨文本丢进去(上下文断裂),要么逐句翻译(丢失段落语义)。
latex-translate-zh 的翻译块(Translation Block)设计参考了这个论文写作过程:
\section{...}→ 一节标题,一个翻译块- 连续几个展开同一主题的段落 → 一个翻译块(不会把"问题→分析→结论"拆成三句零散的话)
\caption{...}→ 图题/表题一个翻译块,独立于正文(因为caption 通常都是对图片进行描述)- 公式前后的解释文字 → 和公式保持在同一块,翻译时能看见数学上下文
通过理解文档结构后按语义完整单元切分——这恰好也是译者在翻译论文时心里划的分段界限。
术语处理
学术论文中术语密度高,同一概念在不同段落反复出现。如果每次遇到都重新翻译,结果必然是"embedding"在第一章叫"嵌入"、第三章又叫"词向量"。
本技能在翻译前先扫描全文建立术语表,确保全文译法统一:
| 处理策略 | 示例 |
|---|---|
| 缩写保留 | CNN、LLM、GPU、MAH 不翻译 |
| 技术名词保留英文 | Transformer(不是"变压器")、PyTorch、CUDA |
| 人名不翻译 | Hinton、Vaswani、LeCun 不音译 |
| 首次出现括号注原文 | 多头注意力(Multi-head Attention),后续只用"多头注意力" |
| 全文一致 | 同一个 \emph{overfitting} 全文译法相同 |
这些规则不在 prompt 里写死让模型"尽量做到"——而是先执行扫描,生成术语表,翻译时作为硬约束传给每个翻译 subagent。
功能
输入 arxiv 链接、下载地址或本地 LaTeX 项目目录,此技能自动:
1. 提取可翻译文本块 — 解析 .tex 文件,保留 LaTeX 命令、数学公式、引用文献 2. 翻译为中文 — 术语一致,多文件项目用 subagent 并行翻译 3. 回填译文 — 并行写入,不改变任何文件结构和路径 4. 编译中文 PDF — XeLaTeX + latexmk,自动处理 CJK 字体 5. 输出 PDF — 复制到当前工作目录
不修改原始文件,翻译在沙箱工作副本中完成。
快速开始
npx skills add Wishrem/latex-translate-zh在 agent 中说:
翻译这篇 arxiv 论文: https://arxiv.org/abs/1706.03762
或:
把 ~/papers/my-paper/ 里的 LaTeX 论文翻译成中文
环境要求
- Python 3.10+ +
uv - XeLaTeX + latexmk + bibtex (TeX Live 或 MiKTeX)
- 至少一种中文字体 (Noto Serif CJK SC / Noto Sans CJK SC / WenQuanYi 等)
工具链检测会自动识别操作系统,通过 Web 搜索给出~~可能~~正确的安装命令。
项目结构
latex-translate-zh/
├── SKILL.md # 技能指令(agent 执行的规则)
└── scripts/ # Python 辅助脚本
├── compile.py # LaTeX 编译(latexmk + XeLaTeX)
├── extract_blocks.py # 提取可翻译文本块
├── backfill_blocks.py # 回填译文(并行写入)
├── latex_compat_scan.py # 中文兼容性扫描
└── grade_assertions.py # 评估断言检查翻译流程
提取 → 翻译 → 回填
1. 提取: extract_blocks.py 解析 .tex 文件,输出 blocks.json — 包含精确源码位置的可翻译文本片段列表。自动跳过数学公式、引用、图表、参考文献。
2. 翻译: Agent 翻译每个 block 的 source_text 字段。多文件项目按文件并行翻译。
3. 回填: backfill_blocks.py 使用精确字符串匹配将译文写回工作副本,多文件用 ThreadPoolExecutor 并行写入。
License
MIT — 详见 LICENSE。
"""
将翻译结果回填到 LaTeX 文件中。
读取翻译 JSON(block_id → translation),在指定项目中
查找每个 block_id 对应的源文本并替换为译文。
Usage:
uv run python backfill_blocks.py /path/to/project translations.json
translations.json 格式:
{
"blocks": [
{"block_id": "blk_0001", "translation": "中文译文"},
...
]
}
或 JSONL 格式(每行一个翻译块)。
"""
import argparse
import json
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Optional
def _find_matching_brace(s: str, pos: int) -> int:
"""Find matching closing brace/bracket. pos points to the opening char."""
open_char = s[pos]
close_char = '}' if open_char == '{' else ']'
depth = 1
i = pos + 1
while i < len(s) and depth > 0:
if s[i] == open_char:
depth += 1
elif s[i] == close_char:
depth -= 1
if depth == 0:
return i
i += 1
return len(s)
def _looks_like_block_id(value: str) -> bool:
return bool(re.fullmatch(r"(blk_\d+|[A-Za-z0-9_.:-]+)", value))
def _normalize_translation(value: object, block_id: str = "") -> str:
"""Normalize model-produced translation strings.
Some agents emit literal "\\n" inside JSON values instead of JSON newlines.
Convert only standalone escaped newlines, not LaTeX commands such as
\newcommand.
"""
if value is None:
return ""
text = str(value)
text = re.sub(r"(?<!\\)\\n(?![A-Za-z@])", "\n", text)
text = re.sub(r"(?<!\\)\\t(?![A-Za-z@])", "\t", text)
return text
def _add_translation(translations: dict[str, str], block_id: object, value: object) -> None:
if not isinstance(block_id, str) or not _looks_like_block_id(block_id):
return
text = _normalize_translation(value, block_id)
if text:
translations[block_id] = text
def _collect_translations(data: object, translations: dict[str, str]) -> None:
"""Accept common translation JSON shapes.
Supported:
{"blocks": [{"block_id": "blk_0001", "translation": "..."}]}
[{"block_id": "blk_0001", "translation": "..."}]
{"blk_0001": "..."}
{"intro.tex": {"blk_0001": "..."}}
{"intro.tex": [{"block_id": "blk_0001", "translation": "..."}]}
"""
if isinstance(data, list):
for item in data:
_collect_translations(item, translations)
return
if not isinstance(data, dict):
return
if "block_id" in data:
value = data.get("translation", data.get("translated_text", data.get("target_text")))
_add_translation(translations, data.get("block_id"), value)
return
if "blocks" in data:
_collect_translations(data["blocks"], translations)
return
for key, value in data.items():
if isinstance(value, str):
_add_translation(translations, key, value)
elif isinstance(value, (dict, list)):
_collect_translations(value, translations)
def load_translations(trans_file: Path) -> dict[str, str]:
"""Load translations from JSON or JSONL file."""
text = trans_file.read_text(encoding="utf-8", errors="ignore").strip()
translations: dict[str, str] = {}
# Try JSON array/object
try:
data = json.loads(text)
_collect_translations(data, translations)
return translations
except json.JSONDecodeError:
pass
# Try JSONL
for line in text.split('\n'):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
_collect_translations(item, translations)
except (json.JSONDecodeError, KeyError):
continue
return translations
def _command_counts(text: str) -> dict[str, int]:
commands = [
r"\\begin", r"\\end", r"\\label", r"\\ref", r"\\eqref",
r"\\cite", r"\\citep", r"\\citet", r"\\cref", r"\\Cref",
r"\\includegraphics",
]
return {cmd: len(re.findall(re.escape(cmd) + r"\b", text)) for cmd in commands}
def _all_command_counts(text: str) -> dict[str, int]:
counts: dict[str, int] = {}
for name in re.findall(r"\\[A-Za-z@]+\*?", text):
counts[name] = counts.get(name, 0) + 1
return counts
def _has_suspicious_escape(text: str) -> bool:
"""Detect common escaped-control leftovers from generated JSON."""
return bool(re.search(r"(?<!\\)\\[nt](?![A-Za-z@])", text))
def validate_translation(source_text: str, translation: str) -> tuple[bool, str]:
"""Reject translations that are likely to break LaTeX structure."""
if not translation.strip():
return False, "译文为空"
if _has_suspicious_escape(translation):
return False, "译文包含疑似未解码的 \\n 或 \\t"
src_counts = _command_counts(source_text)
dst_counts = _command_counts(translation)
changed = [
f"{cmd}: {src_counts[cmd]}->{dst_counts[cmd]}"
for cmd in src_counts
if src_counts[cmd] != dst_counts[cmd]
]
if changed:
return False, "LaTeX 命令数量变化: " + ", ".join(changed)
src_all = _all_command_counts(source_text)
dst_all = _all_command_counts(translation)
if src_all != dst_all:
names = sorted(set(src_all) | set(dst_all))
changed_all = [
f"{name}: {src_all.get(name, 0)}->{dst_all.get(name, 0)}"
for name in names
if src_all.get(name, 0) != dst_all.get(name, 0)
]
return False, "LaTeX 宏集合变化: " + ", ".join(changed_all[:12])
if source_text.count("$") != translation.count("$"):
return False, "数学模式 $ 数量变化"
if source_text.count("\\\\") != translation.count("\\\\"):
return False, r"换行命令 \\ 数量变化"
if re.fullmatch(r"\[?[!htbpH,\s]+\]?", source_text.strip()):
return False, "疑似布局参数,不应回填"
return True, ""
def load_source_blocks(blocks_file: Optional[Path], translations: dict[str, str]) -> dict[str, dict]:
"""Load source blocks to get source_text and file info."""
if not blocks_file or not blocks_file.exists():
return {}
text = blocks_file.read_text(encoding="utf-8", errors="ignore").strip()
blocks: dict[str, dict] = {}
try:
data = json.loads(text)
items = data.get("blocks", data if isinstance(data, list) else [])
for item in items:
if item.get("block_id") in translations:
blocks[item["block_id"]] = item
except json.JSONDecodeError:
for line in text.split('\n'):
line = line.strip()
if not line:
continue
try:
item = json.loads(line)
if item.get("block_id") in translations:
blocks[item["block_id"]] = item
except json.JSONDecodeError:
continue
return blocks
def backfill_file(
filepath: Path,
file_translations: dict[str, str],
source_info: dict[str, dict],
) -> int:
"""Replace source text with translations in a file. Returns number of replacements."""
content = filepath.read_text(encoding="utf-8", errors="ignore")
replaced = 0
# Sort blocks by source_text length (longest first) to avoid partial matches
for block_id, translation in sorted(
file_translations.items(),
key=lambda x: len(source_info.get(x[0], {}).get("source_text", "")),
reverse=True,
):
info = source_info.get(block_id, {})
source_text = info.get("source_text", "")
if not source_text:
continue
ok, reason = validate_translation(source_text, translation)
if not ok:
print(f" [SKIP] {block_id}: {reason}", file=sys.stderr)
continue
# Only replace if source_text appears exactly once
count = content.count(source_text)
if count == 1:
content = content.replace(source_text, translation, 1)
replaced += 1
elif count > 1:
# Source text appears multiple times - potentially ambiguous
# For safety, skip ambiguous replacements
print(f" [SKIP] {block_id}: 源文本出现 {count} 次,跳过以避免歧义替换", file=sys.stderr)
else:
print(f" [SKIP] {block_id}: 源文本未找到", file=sys.stderr)
if replaced > 0:
filepath.write_text(content, encoding="utf-8")
print(f" [OK] {filepath.name}: 替换 {replaced} 处", file=sys.stderr)
return replaced
def backfill_project(
project_dir: Path,
translations: dict[str, str],
source_blocks: dict[str, dict],
parallel: bool = True,
) -> int:
"""Apply all translations to project files. Files are processed in parallel."""
# Group translations by file
by_file: dict[str, dict[str, str]] = {}
for block_id, translation in translations.items():
info = source_blocks.get(block_id, {})
filename = info.get("file", "")
if filename:
by_file.setdefault(filename, {})[block_id] = translation
if not by_file:
print("[WARN] 没有可供回填的翻译块", file=sys.stderr)
return 0
total = 0
file_count = len(by_file)
if parallel and file_count > 1:
print(f" 并行写入 {file_count} 个文件...", file=sys.stderr)
with ThreadPoolExecutor(max_workers=min(file_count, 8)) as executor:
futures = {
executor.submit(
_backfill_one_file, project_dir, rel_path, file_trans, source_blocks
): rel_path
for rel_path, file_trans in by_file.items()
}
for future in as_completed(futures):
rel_path = futures[future]
try:
n = future.result()
total += n
except Exception as e:
print(f" [ERR] {rel_path}: {e}", file=sys.stderr)
else:
for rel_path, file_trans in by_file.items():
filepath = project_dir / rel_path
if filepath.is_file():
total += backfill_file(filepath, file_trans, source_blocks)
else:
print(f" [MISS] 文件不存在: {rel_path}", file=sys.stderr)
return total
def _backfill_one_file(
project_dir: Path,
rel_path: str,
file_trans: dict[str, str],
source_info: dict[str, dict],
) -> int:
"""Backfill a single file (for parallel execution)."""
filepath = project_dir / rel_path
if not filepath.is_file():
print(f" [MISS] 文件不存在: {rel_path}", file=sys.stderr)
return 0
return backfill_file(filepath, file_trans, source_info)
def main():
parser = argparse.ArgumentParser(
description="将翻译结果回填到 LaTeX 文件中",
)
parser.add_argument(
"project_dir",
nargs="?",
help="LaTeX 项目目录(复制出的工作目录)",
)
parser.add_argument(
"translations",
nargs="?",
help="翻译 JSON 文件(block_id → translation)",
)
parser.add_argument("-d", "--dir", dest="project_dir_opt", help="LaTeX 项目目录")
parser.add_argument("-t", "--translations-file", dest="translations_opt", help="翻译 JSON 文件")
parser.add_argument(
"--blocks",
help="提取阶段输出的 blocks JSON 文件(提供源文本定位信息)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="仅检查不实际写入",
)
parser.add_argument(
"--no-parallel",
action="store_true",
help="禁用并行写入(串行模式)",
)
args = parser.parse_args()
project_dir = args.project_dir or args.project_dir_opt
translations_arg = args.translations or args.translations_opt
if not project_dir:
parser.error("需要提供 project_dir 或 -d/--dir")
if not translations_arg:
parser.error("需要提供 translations 或 -t/--translations-file")
root = Path(project_dir).resolve()
if not root.is_dir():
print(f"[ERROR] 目录不存在: {args.project_dir}", file=sys.stderr)
sys.exit(1)
trans_file = Path(translations_arg)
if not trans_file.is_file():
print(f"[ERROR] 翻译文件不存在: {translations_arg}", file=sys.stderr)
sys.exit(1)
translations = load_translations(trans_file)
if not translations:
print("[ERROR] 未加载到任何翻译", file=sys.stderr)
sys.exit(1)
blocks_file = Path(args.blocks) if args.blocks else None
source_blocks = load_source_blocks(blocks_file, translations)
print(f"加载 {len(translations)} 个翻译块,其中 {len(source_blocks)} 个有源文件信息", file=sys.stderr)
if args.dry_run:
print("\n[Dry run] 以下翻译将回填:", file=sys.stderr)
for block_id, trans in translations.items():
info = source_blocks.get(block_id, {})
src = info.get("source_text", "?")[:60]
print(f" {block_id}: {src} → {trans[:60]}", file=sys.stderr)
sys.exit(0)
replaced = backfill_project(root, translations, source_blocks,
parallel=not args.no_parallel)
print(f"\n回填完成: {replaced} 处替换", file=sys.stderr)
if __name__ == "__main__":
main()
"""
检查 CJK 字体的粗体/斜体变体支持,输出 xeCJK fallback 配置。
通过 fc-list 查询指定字体的 Bold、Italic、BoldItalic 子面,
对缺失的变体生成 AutoFakeBold/AutoFakeSlant 配置。
Usage:
uv run python check_cjk_variants.py "Noto Serif CJK SC"
uv run python check_cjk_variants.py "Noto Serif CJK SC" --json
uv run python check_cjk_variants.py "Noto Serif CJK SC" --code-only
"""
import argparse
import json
import re
import subprocess
import sys
# ──────────────────────────── Font Variant Detection ────────────────────────────
def get_font_styles(font_name: str) -> dict[str, str]:
"""
通过 fc-list 查询字体的所有变体 (style → file path)。
返回:
'Regular': /path/to/font.ttc
'Bold': /path/to/font.ttc (or absent if not found)
'Italic': /path/to/font.ttc (or absent)
'Bold Italic': /path/to/font.ttc (or absent)
"""
styles = {}
try:
result = subprocess.run(
['fc-list', font_name, 'style', 'file'],
capture_output=True, text=True, timeout=15,
)
if result.returncode != 0:
return styles
for line in result.stdout.splitlines():
# Format: /path/to/font.ttc: style=Regular
m = re.match(r'^(.+?):\s*style=(.+?)(?:,\w+)*\s*$', line)
if m:
file_path, style = m.group(1).strip(), m.group(2).strip()
styles[style] = file_path
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return styles
def check_variants(font_name: str) -> dict:
"""
检查字体变体支持。
返回:
font_name: 查询的字体名
has_bold: True/False
has_italic: True/False
has_bold_italic: True/False
styles: 所有检测到的 style 映射
variants: 具体变体配置建议
auto_fake_bold: 推荐的 AutoFakeBold 值(None 表示不需要)
auto_fake_slant: 推荐的 AutoFakeSlant 值
"""
styles = get_font_styles(font_name)
style_names = set(s.lower() for s in styles.keys())
has_bold = any(kw in style_names for kw in ('bold', 'black', 'heavy', 'semibold', 'demibold'))
has_italic = any(kw in style_names for kw in ('italic', 'oblique', 'slanted'))
has_bold_italic = any(
('bold' in s and ('italic' in s or 'oblique' in s)) for s in style_names
)
# 推荐配置
variants = {}
if has_bold:
# 找到实际 Bold style 名
for s in styles:
if any(kw in s.lower() for kw in ('bold', 'black', 'heavy', 'semibold', 'demibold')):
variants['BoldFont'] = font_name
variants['BoldFeatures'] = f'{{Style={s}}}'
break
if has_italic:
for s in styles:
if any(kw in s.lower() for kw in ('italic', 'oblique', 'slanted')):
variants['ItalicFont'] = font_name
variants['ItalicFeatures'] = f'{{Style={s}}}'
break
if has_bold_italic:
for s in styles:
if 'bold' in s.lower() and ('italic' in s.lower() or 'oblique' in s.lower()):
variants['BoldItalicFont'] = font_name
variants['BoldItalicFeatures'] = f'{{Style={s}}}'
break
# AutoFake 建议值
auto_fake_bold = 2.5 if not has_bold else None
auto_fake_slant = 0.167 if not has_italic else None
return {
'font_name': font_name,
'has_bold': has_bold,
'has_italic': has_italic,
'has_bold_italic': has_bold_italic,
'styles': {k: v for k, v in styles.items()},
'variants': variants,
'auto_fake_bold': auto_fake_bold,
'auto_fake_slant': auto_fake_slant,
}
# ──────────────────────────── Output Formatting ────────────────────────────
def _format_font_options(variants: dict, auto_fake_bold: float | None,
auto_fake_slant: float | None, indent: str = '') -> str:
"""生成 xeCJK 字体选项(用于 setCJKmainfont 的 [] 内)。"""
parts = []
for key in ('BoldFont', 'ItalicFont', 'BoldItalicFont'):
if key in variants:
feat_key = key.replace('Font', 'Features')
style_val = variants.get(feat_key, f'{{Style={variants[key]}}}')
parts.append(f"{indent} {key}={{{variants[key]}}},")
if auto_fake_bold is not None:
parts.append(f"{indent} AutoFakeBold={{{auto_fake_bold}}},")
if auto_fake_slant is not None:
parts.append(f"{indent} AutoFakeSlant={{{auto_fake_slant}}},")
return "\n".join(parts)
def format_result(result: dict, json_mode: bool = False, code_only: bool = False) -> str:
"""格式化输出版本检查结果。"""
if json_mode:
return json.dumps({
'font_name': result['font_name'],
'has_bold': result['has_bold'],
'has_italic': result['has_italic'],
'has_bold_italic': result['has_bold_italic'],
'auto_fake_bold': result['auto_fake_bold'],
'auto_fake_slant': result['auto_fake_slant'],
'detected_styles': list(result['styles'].keys()),
}, ensure_ascii=False)
lines = []
fname = result['font_name']
lines.append(f"字体: {fname}")
lines.append(f"检测到 {len(result['styles'])} 种变体: {', '.join(result['styles'].keys())}")
bold_mark = "✓" if result['has_bold'] else "✗"
italic_mark = "✓" if result['has_italic'] else "✗"
bi_mark = "✓" if result['has_bold_italic'] else "✗"
lines.append(f"Bold: {bold_mark}")
lines.append(f"Italic: {italic_mark}")
lines.append(f"BoldItalic: {bi_mark}")
# 推荐配置
sections = []
if result['auto_fake_bold'] is not None or result['auto_fake_slant'] is not None:
xe = []
if result['auto_fake_bold'] is not None:
xe.append(f"AutoFakeBold={{{result['auto_fake_bold']}}}")
if result['auto_fake_slant'] is not None:
xe.append(f"AutoFakeSlant={{{result['auto_fake_slant']}}}")
sections.append("\\xeCJKsetup{" + ", ".join(xe) + "}")
# 字体级配置
if result['variants'] or result['auto_fake_bold'] is not None or result['auto_fake_slant'] is not None:
opts = _format_font_options(result['variants'],
result['auto_fake_bold'],
result['auto_fake_slant'])
sections.append(f"\\setCJKmainfont{{{fname}}}[\n{opts}\n]")
if code_only:
return "\n".join(sections)
if sections:
lines.append(f"\n推荐配置:")
lines.append("\n".join(sections))
else:
lines.append(f"\n无需额外配置(所有变体均已提供)。")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="检查 CJK 字体的粗体/斜体变体支持",
)
parser.add_argument(
'font_name',
help='CJK 字体名称(如 "Noto Serif CJK SC")',
)
parser.add_argument(
'--json', action='store_true',
help='以 JSON 格式输出',
)
parser.add_argument(
'--code-only', action='store_true',
help='仅输出 LaTeX 代码行',
)
args = parser.parse_args()
result = check_variants(args.font_name)
print(format_result(result, json_mode=args.json, code_only=args.code_only))
if __name__ == '__main__':
main()
"""
LaTeX Compilation Script - 中文学位论文编译器 (xelatex/lualatex)
默认行为:
使用 latexmk + XeLaTeX 自动处理所有依赖(bibtex/biber、交叉引用、
索引、术语表),并自动决定最优编译次数。这是中文论文的推荐方案。
Usage:
uv run python compile.py main.tex # 默认: latexmk + xelatex
uv run python compile.py main.tex --compiler xelatex # 显式指定编译器
uv run python compile.py main.tex --recipe xelatex-bibtex # 传统 BibTeX
uv run python compile.py main.tex --recipe xelatex-biber # 现代 biblatex
uv run python compile.py main.tex --watch # 监视模式
uv run python compile.py main.tex --clean # 清理辅助文件
Recipes (中文论文推荐 XeLaTeX/LuaLaTeX):
latexmk - LaTeXmk + XeLaTeX 自动处理 (默认 - 推荐)
xelatex - XeLaTeX 单次编译
lualatex - LuaLaTeX 单次编译
xelatex-bibtex - xelatex -> bibtex -> xelatex*2 (传统)
xelatex-biber - xelatex -> biber -> xelatex*2 (现代 biblatex)
lualatex-bibtex - lualatex -> bibtex -> lualatex*2
lualatex-biber - lualatex -> biber -> lualatex*2
"""
import argparse
import json
import platform
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
class LaTeXCompiler:
"""Unified LaTeX compilation with multiple recipes."""
COMPILERS = {"pdflatex", "xelatex", "lualatex"}
# Default recipe: latexmk with XeLaTeX for Chinese documents (best practice)
# latexmk auto-detects bibtex/biber needs and runs the correct number of passes
DEFAULT_RECIPE = "latexmk"
# Recipes matching VS Code LaTeX Workshop configuration
# Chinese thesis: XeLaTeX/LuaLaTeX recommended for proper CJK support
# Recommended workflow:
# - latexmk (default): Auto-detect and handle all dependencies with XeLaTeX
# - xelatex-bibtex: Traditional BibTeX workflow (legacy .bst styles)
# - xelatex-biber: Modern biblatex + biber workflow (recommended for new theses)
RECIPES = {
# Single compilation (quick builds)
"xelatex": ["xelatex"],
"lualatex": ["lualatex"],
"latexmk": ["latexmk-xelatex"], # Default: XeLaTeX + auto-handles bibtex/biber
# Full workflows (explicit control over compilation steps)
"xelatex-bibtex": ["xelatex", "bibtex", "xelatex", "xelatex"],
"xelatex-biber": ["xelatex", "biber", "xelatex", "xelatex"],
"lualatex-bibtex": ["lualatex", "bibtex", "lualatex", "lualatex"],
"lualatex-biber": ["lualatex", "biber", "lualatex", "lualatex"],
}
# Patterns indicating Chinese content
CHINESE_PATTERNS = [
r"\\usepackage.*{ctex}",
r"\\usepackage.*{xeCJK}",
r"\\documentclass.*{ctexart}",
r"\\documentclass.*{ctexbook}",
r"\\documentclass.*{ctexrep}",
r"\\documentclass.*{thuthesis}",
r"\\documentclass.*{pkuthss}",
r"\\documentclass.*{ustcthesis}",
r"\\documentclass.*{fduthesis}",
r"[\u4e00-\u9fff]", # Chinese characters
]
def __init__(
self,
tex_file: str,
compiler: Optional[str] = None,
recipe: Optional[str] = None,
shell_escape: bool = False,
):
self.tex_file = Path(tex_file).resolve()
self.work_dir = self.tex_file.parent
self.compiler = compiler or self._detect_compiler()
self.recipe = recipe
self.shell_escape = shell_escape
def _detect_compiler(self) -> str:
"""Auto-detect appropriate compiler based on document content."""
try:
content = self.tex_file.read_text(encoding="utf-8", errors="ignore")
except Exception:
return "pdflatex" # Default fallback
# Check for Chinese content
for pattern in self.CHINESE_PATTERNS:
if re.search(pattern, content):
print("[INFO] Detected Chinese content, using xelatex")
return "xelatex"
# Check for explicit engine specification
if re.search(r"%\s*!TEX\s+program\s*=\s*xelatex", content, re.IGNORECASE):
return "xelatex"
if re.search(r"%\s*!TEX\s+program\s*=\s*lualatex", content, re.IGNORECASE):
return "lualatex"
if re.search(r"%\s*!TEX\s+program\s*=\s*pdflatex", content, re.IGNORECASE):
return "pdflatex"
# Check for fontspec (requires xelatex or lualatex)
if re.search(r"\\usepackage.*{fontspec}", content):
print("[INFO] Detected fontspec package, using xelatex")
return "xelatex"
return "pdflatex"
def _check_tools_for_compiler(self) -> tuple[bool, str]:
"""Check tools required for latexmk-based compilation."""
if not shutil.which("latexmk"):
return False, "latexmk not found — 请先安装"
compiler_cmd = self.compiler
if not shutil.which(compiler_cmd):
return False, f"{compiler_cmd} not found — 请先安装"
return True, "All tools available"
def _check_tools_for_recipe(self) -> tuple[bool, str]:
"""Check tools required by a recipe."""
steps = self.RECIPES.get(self.recipe, [])
required = []
for step in steps:
if step == "latexmk-xelatex":
required.extend(["latexmk", "xelatex"])
elif step == "latexmk-lualatex":
required.extend(["latexmk", "lualatex"])
elif step in ("xelatex", "lualatex", "bibtex", "biber"):
required.append(step)
for tool in dict.fromkeys(required):
if not shutil.which(tool):
return False, f"{tool} not found. Install TeX Live or MiKTeX."
return True, "All tools available"
def _engine_shell_mode_arg(self) -> str:
"""Return the explicit shell-escape mode passed to TeX engines."""
return "-shell-escape" if self.shell_escape else "-no-shell-escape"
def _latexmk_engine_args(self) -> list[str]:
"""Build latexmk engine args with explicit shell-escape mode."""
if self.compiler not in self.COMPILERS:
return ["-pdf"]
engine = self.compiler
engine = f"{engine} {self._engine_shell_mode_arg()}"
if self.compiler == "pdflatex":
return ["-pdf", f"-pdflatex={engine} %O %S"]
if self.compiler == "xelatex":
return ["-xelatex", "-pdfxe", f"-xelatex={engine} %O %S"]
return ["-lualatex", "-pdflua", f"-lualatex={engine} %O %S"]
def _maybe_warn_shell_escape(self) -> None:
if self.shell_escape:
print("[WARNING] Shell escape enabled. Only use with trusted sources.")
def _log_file(self, outdir: Optional[str] = None) -> Path:
if outdir:
return (self.work_dir / outdir / self.tex_file.with_suffix(".log").name).resolve()
return self.tex_file.with_suffix(".log")
def _validate_log(self, outdir: Optional[str] = None) -> tuple[bool, list[str]]:
"""Reject PDFs produced from logs that still contain real TeX errors."""
log_file = self._log_file(outdir)
if not log_file.exists():
return False, [f"日志文件不存在: {log_file}"]
text = log_file.read_text(encoding="utf-8", errors="ignore")
problems: list[str] = []
error_lines = []
for line in text.splitlines():
if line.startswith("! "):
# Font warnings are logged as warnings, not bang-errors. Any
# remaining bang line means TeX recovered after a real error.
error_lines.append(line.strip())
if len(error_lines) >= 12:
break
if error_lines:
problems.append("LaTeX 错误仍存在: " + " | ".join(error_lines))
failure_patterns = [
r"Emergency stop",
r"Fatal error occurred",
r"Undefined control sequence",
r"Misplaced alignment tab character",
r"Missing \$ inserted",
r"Extra \}, or forgotten",
r"File `[^']+' not found",
r"Unable to load picture or PDF file",
r"Package graphics Error",
]
for pattern in failure_patterns:
if re.search(pattern, text):
problems.append(f"日志匹配失败模式: {pattern}")
unresolved_patterns = [
r"undefined references",
r"Citation .* undefined",
r"Reference .* undefined",
r"There were undefined references",
r"Label\(s\) may have changed",
]
for pattern in unresolved_patterns:
if re.search(pattern, text, re.IGNORECASE):
problems.append(f"交叉引用/引用未稳定: {pattern}")
return not problems, problems
def _report_validation(self, outdir: Optional[str] = None) -> int:
pdf_file = self.tex_file.with_suffix(".pdf")
if outdir:
pdf_file = (self.work_dir / outdir / pdf_file.name).resolve()
if not pdf_file.exists():
print(f"\n[ERROR] PDF not found: {pdf_file}")
return 1
ok, problems = self._validate_log(outdir)
if not ok:
print(f"\n[ERROR] PDF was produced but validation failed: {pdf_file}")
for problem in problems[:10]:
print(f" - {problem}")
return 1
print(f"\n[SUCCESS] PDF generated and validated: {pdf_file}")
return 0
def compile(
self, watch: bool = False, biber: bool = False, outdir: Optional[str] = None
) -> int:
"""
Compile the LaTeX document.
Args:
watch: Enable continuous compilation mode
biber: Use biber instead of bibtex
outdir: Output directory for generated files
Returns:
Exit code (0 for success)
"""
# Check tools
if self.recipe:
ok, msg = self._check_tools_for_recipe()
if not ok:
print(f"[ERROR] {msg}")
return 1
else:
ok, msg = self._check_tools_for_compiler()
if not ok:
print(f"[ERROR] {msg}")
return 1
# If recipe is set, use recipe-based compilation
if self.recipe:
return self._compile_with_recipe(outdir)
print(f"[INFO] Compiling {self.tex_file.name} with {self.compiler}")
print(f"[INFO] Working directory: {self.work_dir}")
self._maybe_warn_shell_escape()
# Build latexmk command
cmd = ["latexmk"]
# Add compiler-specific options
cmd.extend(self._latexmk_engine_args())
# Add common options
cmd.extend(
[
"-interaction=nonstopmode",
"-file-line-error",
"-synctex=1",
]
)
# Biber support
if biber:
cmd.append("-use-biber")
# Watch mode
if watch:
cmd.append("-pvc")
print("[INFO] Watch mode enabled. Press Ctrl+C to stop.")
# Add input file
cmd.append(str(self.tex_file))
# Run compilation
try:
result = subprocess.run(
cmd,
cwd=self.work_dir,
capture_output=False,
)
if result.returncode == 0:
return self._report_validation(outdir)
else:
print(f"\n[ERROR] Compilation failed with exit code {result.returncode}")
return result.returncode
except KeyboardInterrupt:
print("\n[INFO] Compilation stopped by user")
return 0
except Exception as e:
print(f"[ERROR] {e}")
return 1
def _compile_with_recipe(self, outdir: Optional[str] = None) -> int:
"""Compile using a predefined recipe (VS Code LaTeX Workshop style)."""
if self.recipe not in self.RECIPES:
print(f"[ERROR] Unknown recipe: {self.recipe}")
print(f"[INFO] Available recipes: {', '.join(self.RECIPES.keys())}")
return 1
steps = self.RECIPES[self.recipe]
print(f"[INFO] Using recipe: {self.recipe}")
print(f"[INFO] Steps: {' -> '.join(steps)}")
print(f"[INFO] Working directory: {self.work_dir}")
self._maybe_warn_shell_escape()
tex_base = self.tex_file.stem
for i, step in enumerate(steps, 1):
print(f"\n[STEP {i}/{len(steps)}] Running {step}...")
if step == "latexmk-xelatex":
cmd = [
"latexmk",
"-xelatex",
f"-xelatex=xelatex {self._engine_shell_mode_arg()} %O %S",
"-interaction=nonstopmode",
"-synctex=1",
"-file-line-error",
]
if outdir:
cmd.append(f"-outdir={outdir}")
cmd.append(str(self.tex_file))
elif step == "latexmk-lualatex":
cmd = [
"latexmk",
"-lualatex",
f"-lualatex=lualatex {self._engine_shell_mode_arg()} %O %S",
"-interaction=nonstopmode",
"-synctex=1",
"-file-line-error",
]
if outdir:
cmd.append(f"-outdir={outdir}")
cmd.append(str(self.tex_file))
elif step in ("xelatex", "lualatex"):
cmd = [
step,
"-interaction=nonstopmode",
"-synctex=1",
self._engine_shell_mode_arg(),
str(self.tex_file),
]
elif step == "bibtex":
cmd = ["bibtex", tex_base]
elif step == "biber":
cmd = ["biber", tex_base]
else:
print(f"[ERROR] Unknown step: {step}")
return 1
try:
result = subprocess.run(
cmd,
cwd=self.work_dir,
capture_output=False,
)
if result.returncode != 0:
# bibtex/biber may return non-zero for warnings, continue anyway
if step not in ("bibtex", "biber"):
print(f"[ERROR] Step {step} failed with exit code {result.returncode}")
return result.returncode
else:
print(f"[WARNING] {step} returned {result.returncode}, continuing...")
except FileNotFoundError:
print(f"[ERROR] {step} not found. Please install it.")
return 1
except Exception as e:
print(f"[ERROR] {e}")
return 1
return self._report_validation(outdir)
@staticmethod
def _detect_os_info() -> dict:
"""Detect OS and package manager for install hints."""
system = platform.system()
if system == "Linux":
# Check for specific distro
try:
import distro as distro_pkg
distro_id = distro_pkg.id()
except ImportError:
distro_id = ""
# Fallback: check known package managers
if shutil.which("apt"):
return {"os": "linux-deb", "pkg": "apt", "cmd": "sudo apt-get install -y", "prefix": ""}
elif shutil.which("pacman"):
return {"os": "linux-arch", "pkg": "pacman", "cmd": "sudo pacman -S --noconfirm", "prefix": ""}
elif shutil.which("dnf"):
return {"os": "linux-rpm", "pkg": "dnf", "cmd": "sudo dnf install -y", "prefix": "texlive-"}
elif shutil.which("yum"):
return {"os": "linux-rpm", "pkg": "yum", "cmd": "sudo yum install -y", "prefix": "texlive-"}
elif shutil.which("zypper"):
return {"os": "linux-suse", "pkg": "zypper", "cmd": "sudo zypper install -y", "prefix": "texlive-"}
else:
return {"os": "linux", "pkg": "unknown", "cmd": "", "prefix": ""}
elif system == "Darwin":
return {"os": "macos", "pkg": "brew", "cmd": "brew install", "prefix": ""}
elif system == "Windows":
return {"os": "windows", "pkg": "winget", "cmd": "winget install", "prefix": ""}
else:
return {"os": "unknown", "pkg": "unknown", "cmd": "", "prefix": ""}
@classmethod
def _check_chinese_fonts(cls) -> dict:
"""Check availability of common Chinese fonts.
Returns:
dict: {family_name: {"available": bool, "sample": str}}
"""
RECOMMENDED = [
"Noto Serif CJK SC", "Noto Sans CJK SC",
"Source Han Serif SC", "Source Han Sans SC",
"WenQuanYi Micro Hei", "WenQuanYi Zen Hei",
"SimSun", "SimHei", "KaiTi", "FangSong",
"AR PL UMing CN", "AR PL UKai CN",
]
found = {f: {"available": False, "sample": ""} for f in RECOMMENDED}
# Try fc-list first (standard on Linux/macOS with fontconfig)
try:
result = subprocess.run(
["fc-list", ":lang=zh", "family"], capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
for line in result.stdout.splitlines():
family = line.strip().rstrip(',')
for name in RECOMMENDED:
if name in family:
found[name]["available"] = True
found[name]["sample"] = family[:80]
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# Fallback: check known font file paths
for name in RECOMMENDED:
if not found[name]["available"]:
pass # glob check skipped — fc-list is definitive enough
return found
@classmethod
def check_tools(cls) -> dict:
"""Check availability of all compilation tools and Chinese fonts.
Does NOT hardcode package names — caller should web-search for the
correct install command on the detected distro.
Returns:
dict with keys: os_info, tools, fonts, missing
"""
os_info = cls._detect_os_info()
tools = {
"xelatex": "xelatex", "xetex": "xetex",
"lualatex": "lualatex", "luatex": "luatex",
"pdflatex": "pdflatex", "latexmk": "latexmk",
"bibtex": "bibtex", "biber": "biber",
}
results = {}
for name, cmd in tools.items():
path = shutil.which(cmd)
results[name] = {"available": path is not None, "path": path or ""}
# Chinese font detection
fonts = cls._check_chinese_fonts()
# Print report
pkg_name = os_info["pkg"].upper()
print("\n" + "=" * 55)
print(f" LaTeX 编译工具链检测 [{os_info['os']}/{pkg_name}]")
print("=" * 55)
print(" --- 编译引擎 ---")
missing = []
engine_names = ["xelatex", "xetex", "lualatex", "luatex", "pdflatex"]
for name in engine_names:
info = results[name]
if info["available"]:
print(f" [OK] {name:<12} {info['path']}")
else:
print(f" [MISS] {name:<12} (请 web 搜索安装方式)")
missing.append(name)
print(" --- 构建工具 ---")
for name in ["latexmk", "bibtex", "biber"]:
info = results[name]
if info["available"]:
print(f" [OK] {name:<12} {info['path']}")
else:
print(f" [MISS] {name:<12} (请 web 搜索安装方式)")
missing.append(name)
print(" --- 中文字体(推荐) ---")
font_missing = []
for fname, finfo in fonts.items():
if finfo["available"]:
print(f" [OK] {fname:<24}")
else:
font_missing.append(fname)
if font_missing:
print(f" 缺失推荐字体: {', '.join(font_missing)}")
print(f" (至少需要一种中文字体用于编译)")
print("-" * 55)
print(f" 编译工具: {sum(1 for n in tools if results[n]['available'])}/{len(tools)}")
print(f" 中文字体: {sum(1 for f in fonts.values() if f['available'])}/{len(fonts)}")
print("=" * 55)
# Output structured JSON
report = {
"os": os_info["os"],
"package_manager": os_info["pkg"],
"tools": results,
"fonts": {k: v["available"] for k, v in fonts.items()},
"missing_tools": missing,
"missing_fonts": font_missing,
"search_query": f"install {' '.join(missing)} on {platform.system()} {os_info['pkg']}",
}
print("\n[TOOLS_JSON] " + json.dumps(report, ensure_ascii=False) + "\n")
return report
def clean(self, full: bool = False) -> int:
"""
Clean auxiliary files.
Args:
full: Also remove output PDF
Returns:
Exit code (0 for success)
"""
print(f"[INFO] Cleaning auxiliary files in {self.work_dir}")
cmd = ["latexmk", "-c"]
if full:
cmd = ["latexmk", "-C"]
cmd.append(str(self.tex_file))
try:
result = subprocess.run(cmd, cwd=self.work_dir, capture_output=True)
if result.returncode == 0:
print("[SUCCESS] Auxiliary files cleaned")
return result.returncode
except Exception as e:
print(f"[ERROR] {e}")
return 1
def main():
parser = argparse.ArgumentParser(
description="LaTeX 中文学位论文编译脚本 - 支持 xelatex/lualatex",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
默认行为:
使用 latexmk + XeLaTeX 自动处理所有依赖(bibtex/biber、交叉引用等),
并自动决定最优编译次数。这是中文论文的推荐方案。
Recipes (中文论文推荐 XeLaTeX):
latexmk LaTeXmk + XeLaTeX 自动处理 (默认 - 推荐)
xelatex XeLaTeX 单次编译
lualatex LuaLaTeX 单次编译
xelatex-bibtex xelatex -> bibtex -> xelatex*2 (传统 BibTeX)
xelatex-biber xelatex -> biber -> xelatex*2 (现代 biblatex)
lualatex-bibtex lualatex -> bibtex -> lualatex*2
lualatex-biber lualatex -> biber -> lualatex*2
Examples:
uv run python compile.py main.tex # 默认: latexmk + xelatex
uv run python compile.py main.tex --recipe xelatex-bibtex # 传统 BibTeX
uv run python compile.py main.tex --recipe xelatex-biber # 现代 biblatex
uv run python compile.py main.tex --watch # 监视模式
""",
)
parser.add_argument("tex_file", nargs="?", help="主 .tex 文件路径 (--check-tools 时可选)")
parser.add_argument("-d", "--dir", dest="work_dir_arg", help="包含主 .tex 的目录(兼容 AGENTS.md 常用命令)")
parser.add_argument("--main", default="main.tex", help="-d/--dir 模式下的主 .tex 文件名,默认 main.tex")
parser.add_argument(
"--compiler",
"-c",
choices=["pdflatex", "xelatex", "lualatex"],
help="编译器 (未指定时自动检测,中文默认 xelatex)",
)
parser.add_argument(
"--recipe",
"-r",
choices=[
"xelatex",
"lualatex",
"latexmk",
"xelatex-bibtex",
"xelatex-biber",
"lualatex-bibtex",
"lualatex-biber",
],
help="使用预定义编译配置 (中文推荐 XeLaTeX)",
)
parser.add_argument("--watch", "-w", action="store_true", help="启用监视模式 (持续编译)")
parser.add_argument("--biber", "-b", action="store_true", help="使用 biber 处理参考文献")
parser.add_argument(
"--shell-escape",
action="store_true",
help="启用 shell-escape (需要 --trusted-source)",
)
parser.add_argument(
"--trusted-source",
action="store_true",
help="确认 LaTeX 源文件可信后再启用 shell-escape",
)
parser.add_argument("--clean", action="store_true", help="清理辅助文件")
parser.add_argument("--clean-all", action="store_true", help="清理所有生成文件 (含 PDF)")
parser.add_argument("--outdir", "-o", help="输出目录 (仅 latexmk 配置支持)")
parser.add_argument(
"--check-tools",
action="store_true",
help="检测编译工具链可用性(不编译)",
)
args = parser.parse_args()
# Tool check mode: no tex file needed
if args.check_tools:
LaTeXCompiler.check_tools()
sys.exit(0)
# Validate input file
tex_arg = args.tex_file
if args.work_dir_arg and not tex_arg:
tex_arg = str(Path(args.work_dir_arg) / args.main)
if not tex_arg:
parser.error("需要提供 tex_file,或使用 -d/--dir 指定目录")
tex_path = Path(tex_arg)
if not tex_path.exists():
print(f"[ERROR] 文件不存在: {tex_arg}")
sys.exit(1)
if tex_path.suffix != ".tex":
print(f"[WARNING] 文件扩展名不是 .tex: {args.tex_file}")
if args.shell_escape and not args.trusted_source:
print(
"[ERROR] --shell-escape 可执行 LaTeX 源文件中的命令。"
"确认源文件可信后再同时传入 --trusted-source。"
)
sys.exit(1)
# Create compiler instance
compiler = LaTeXCompiler(
tex_arg,
args.compiler,
args.recipe,
shell_escape=args.shell_escape,
)
# Execute requested action
if args.clean or args.clean_all:
sys.exit(compiler.clean(full=args.clean_all))
else:
sys.exit(
compiler.compile(
watch=args.watch,
biber=args.biber,
outdir=args.outdir,
)
)
if __name__ == "__main__":
main()
"""
根据中英文字体度量计算最佳行距 (baselineskip)
acmart 模板默认为 \fontsize{10}{12}(ratio = 1.2),这对拉丁文合适,
但中文字符笔划密度大,需要更大行距。此脚本通过解析实际字体度量
(hhea/OS2 表)量化视觉差异,输出推荐的 \fontsize{size}{skip} 配置。
Usage:
uv run python compute_baselineskip.py main.tex
uv run python compute_baselineskip.py main.tex --json
uv run python compute_baselineskip.py main.tex --cjk "Noto Serif CJK SC"
uv run python compute_baselineskip.py main.tex --cjk-scale 1.1
uv run python compute_baselineskip.py main.tex --cjk-scale 1.1 --code-only
"""
import argparse
import json
import re
import struct
import subprocess
import sys
from pathlib import Path
# ──────────────────────────── Font Metadata ────────────────────────────
# Standard CJK line spacing factor (Latin default = 1.2, CJK needs 1.3-1.5)
DEFAULT_CJK_FACTOR = 1.35
# For font sizes not explicitly listed in acmart.cls, interpolate
FONT_SIZE_BASELINE = {
# acmart.cls \ACM@fontsize{size}{baselineskip} definitions
7: (7, 8), # \@acmtiny
8: (8, 10), # \@acmsmall
9: (9, 11), # interpolated (~1.22 ratio)
10: (10, 12), # \@acmnormal
11: (11, 13), # interpolated (~1.18 ratio)
12: (12, 14), # interpolated (~1.17 ratio)
}
def read_font_metrics(font_path: str, ttc_index: int = 0) -> dict:
"""
从字体二进制文件中读取 hhea 和 OS/2 表的度量数据。
支持 .otf/.ttf 和 .ttc 格式。返回 dict:
upem: 单位 em 大小(通常 1000)
ascender: hhea 表 ascender (font units)
descender: hhea 表 descender(通常为负值)
linegap: hhea 表 lineGap
typo_ascender: OS/2 sTypoAscender
typo_descender: OS/2 sTypoDescender(通常为负值)
typo_linegap: OS/2 sTypoLineGap
"""
with open(font_path, 'rb') as f:
header = f.read(4)
base_offset = 0
if header == b'ttcf':
_, num_fonts = struct.unpack('>II', f.read(8))
if ttc_index >= num_fonts:
raise ValueError(f"TTC index {ttc_index} out of range (0-{num_fonts - 1})")
f.read(ttc_index * 4)
base_offset = struct.unpack('>I', f.read(4))[0]
f.seek(base_offset)
_ = f.read(4) # sfVersion
# Table directory starts at base_offset + 4
f.seek(base_offset + 4)
num_tables, _, _, _ = struct.unpack('>HHHH', f.read(8))
tables = {}
for _ in range(num_tables):
tag, _, off, length = struct.unpack('>4sIII', f.read(16))
tables[tag] = (off, length)
def get_table(tag: bytes):
if tag not in tables:
return None
off, length = tables[tag]
f.seek(off)
return f.read(length)
head = get_table(b'head')
hhea = get_table(b'hhea')
os2 = get_table(b'OS/2')
upem = struct.unpack('>H', head[18:20])[0] if head and len(head) >= 20 else 1000
ascender = struct.unpack('>h', hhea[4:6])[0] if hhea and len(hhea) >= 6 else 0
descender = struct.unpack('>h', hhea[6:8])[0] if hhea and len(hhea) >= 8 else 0
linegap = struct.unpack('>h', hhea[8:10])[0] if hhea and len(hhea) >= 10 else 0
ta = struct.unpack('>h', os2[68:70])[0] if os2 and len(os2) >= 70 else ascender
td = struct.unpack('>h', os2[70:72])[0] if os2 and len(os2) >= 72 else descender
tl = struct.unpack('>h', os2[72:74])[0] if os2 and len(os2) >= 74 else linegap
return {
'upem': upem,
'ascender': ascender,
'descender': descender,
'linegap': linegap,
'typo_ascender': ta,
'typo_descender': td,
'typo_linegap': tl,
}
def find_font_file(font_name: str) -> str | None:
"""通过 fc-match 查找字体文件路径。"""
try:
result = subprocess.run(
['fc-match', '-v', font_name],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return None
for line in result.stdout.splitlines():
m = re.match(r'\s*file:\s*"(.+?)"\s*', line)
if m:
return m.group(1)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
return None
# ──────────────────────────── LaTeX Parsing ────────────────────────────
def parse_font_size_from_tex(main_tex: str) -> int:
r"""从 \documentclass[...,<size>pt,...]{...} 提取基础字号。"""
with open(main_tex, 'r') as f:
content = f.read()
m = re.search(
r'\\documentclass\s*\[([^\]]*?)\]\s*\{',
content, re.DOTALL,
)
if m:
options = m.group(1)
size_m = re.search(r'(\d+)\s*pt', options)
if size_m:
return int(size_m.group(1))
# acmart default is 10pt
doc_m = re.search(r'\\documentclass\s*\{(\w+)\}', content)
if doc_m and doc_m.group(1) == 'acmart':
return 10
return 10
def parse_cjk_font_from_tex(main_tex: str) -> str | None:
r"""从 \setCJKmainfont{<name>} 提取 CJK 主字体名。"""
with open(main_tex, 'r') as f:
content = f.read()
m = re.search(r'\\setCJKmainfont\s*\{([^}]+)\}', content)
if m:
return m.group(1).strip()
return None
def parse_cls_baselineskip(main_tex: str) -> float | None:
"""从关联的 .cls 文件中查找 \fontsize 定义获取原始行距。"""
with open(main_tex, 'r') as f:
content = f.read()
m = re.search(r'\\documentclass\s*(?:\[[^\]]*\])?\s*\{(\w+)\}', content)
if not m:
return None
cls_name = m.group(1)
tex_dir = Path(main_tex).parent
cls_path = tex_dir / f"{cls_name}.cls"
if not cls_path.exists():
cls_path = Path(f"{cls_name}.cls")
if not cls_path.exists():
try:
result = subprocess.run(
['kpsewhich', f'{cls_name}.cls'],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
cls_path = Path(result.stdout.strip())
except Exception:
pass
if not cls_path.exists():
return None
with open(cls_path, 'r', errors='ignore') as f:
cls_content = f.read()
patterns = [
r'\\@acmnormal\s*\{(.*?)\}',
r'\\fontsize\s*\{(\d+)\}\s*\{(\d+)\}',
r'\\newcommand\s*\{\\@acmnormal\}\s*\{[^}]*\\fontsize\s*\{(\d+)\}\s*\{(\d+)\}',
]
for pat in patterns:
m = re.search(pat, cls_content, re.DOTALL)
if m:
groups = m.groups()
if len(groups) == 1:
inner_m = re.search(r'\\fontsize\s*\{(\d+)\}\s*\{(\d+)\}', groups[0])
if inner_m:
return float(inner_m.group(2))
elif len(groups) == 2:
return float(groups[1])
return None
# ──────────────────────────── Core Calculation ────────────────────────────
def compute_baselineskip(
font_size: int,
latin_font: str | None = None,
cjk_font: str | None = None,
cjk_scale: float = 1.0,
) -> dict:
"""
计算最佳行距。
参数:
font_size: 基础拉丁文字号(pt)
cjk_scale: 中文字体缩放因子(`\\setCJKmainfont{...}[Scale=<cjk_scale>]`)
计算行距时会基于缩放后的实际字号。
公式:
cjk_eff_ratio = min(cjk_typo_body / cjk_upem, 1.0)
cjk_factor = latin_factor * (1 + (1.0 - cjk_eff_ratio) * 0.5 + 0.12)
# 当 CJK visual body 少于 em-square 时,说明字体内部空隙少、密度大
# 需要增加行距;+0.12 是基准中文行距增量
baselineskip = effective_size * cjk_factor
回退策略:
若无法读取字体度量 → 使用 DEFAULT_CJK_FACTOR = 1.35
"""
# 计算 CJK 实际显示字号
effective_size = font_size * cjk_scale
# 1. 获取拉丁文默认行距因子
latin_size, latin_skip = FONT_SIZE_BASELINE.get(
font_size, (font_size, int(font_size * 1.2))
)
latin_factor = latin_skip / latin_size
# 2. 获取 CJK 字体度量
cjk_metrics = None
cjk_path = None
if cjk_font:
cjk_path = find_font_file(cjk_font)
if not cjk_path:
cjk_path = find_font_file('Noto Serif CJK SC')
if cjk_path:
try:
cjk_metrics = read_font_metrics(cjk_path)
except Exception:
pass
# 3. 计算 CJK 视觉密度因子
if cjk_metrics and cjk_metrics['upem'] > 0:
cjk_body = cjk_metrics['typo_ascender'] + abs(cjk_metrics['typo_descender'])
cjk_body_ratio = cjk_body / cjk_metrics['upem']
# CJK typo body 通常等于 em (ratio=1.0),但实际字符视觉密度更大
# density = 1 - ratio: ratio 越小说明字体内空白越多,越不需要增加行距
density = max(0.0, 1.0 - cjk_body_ratio)
cjk_factor = latin_factor + density * 0.5 + 0.12
else:
cjk_factor = DEFAULT_CJK_FACTOR
# 4. 计算并夹取行距(基于 CJK 实际显示字号)
baselineskip = effective_size * cjk_factor
baselineskip = max(baselineskip, latin_skip * 1.04) # 不低于拉丁行距的 104%
baselineskip = min(baselineskip, effective_size * 1.6) # 不超过 CJK 字号的 1.6 倍
baselineskip = round(baselineskip * 2) / 2 # 舍入到 0.5pt
# 5. 拉丁字体信息(诊断用)
latin_metrics = None
latin_path = None
if latin_font:
latin_path = find_font_file(latin_font)
if not latin_path:
for name in ['Linux Libertine O', 'LinLibertine', 'Libertine',
'Latin Modern Roman', 'Noto Serif']:
latin_path = find_font_file(name)
if latin_path:
break
if latin_path:
try:
latin_metrics = read_font_metrics(latin_path)
except Exception:
pass
return {
'font_size': font_size,
'cjk_scale': cjk_scale,
'effective_size': effective_size,
'original_baselineskip': latin_skip,
'original_factor': round(latin_factor, 4),
'cjk_factor': round(cjk_factor, 4),
'recommended_baselineskip': baselineskip,
'cjk_font': cjk_font,
'cjk_path': cjk_path,
'cjk_metrics': cjk_metrics,
'latin_font_name': 'Linux Libertine O' if not latin_font else latin_font,
'latin_path': latin_path,
'latin_metrics': latin_metrics,
}
def format_result(result: dict, json_mode: bool = False, code_only: bool = False) -> str:
"""格式化输出结果。"""
if json_mode:
output = {
'font_size': result['font_size'],
'cjk_scale': result['cjk_scale'],
'effective_size': result['effective_size'],
'baselineskip': result['recommended_baselineskip'],
'factor': result['cjk_factor'],
'original_baselineskip': result['original_baselineskip'],
'cjk_font': result['cjk_font'],
}
return json.dumps(output, ensure_ascii=False)
lines = []
lines.append(f"基础字号: {result['font_size']}pt")
if result['cjk_scale'] != 1.0:
lines.append(f"CJK 缩放: {result['cjk_scale']} "
f"(实际字号 {result['effective_size']}pt)")
lines.append(f"原始拉丁行距: {result['original_baselineskip']}pt "
f"(因子 {result['original_factor']})")
if result['cjk_metrics']:
m = result['cjk_metrics']
body = m['typo_ascender'] + abs(m['typo_descender'])
cjk_label = result['cjk_font'] or '(自动检测)'
lines.append(f"CJK 字体度量: {cjk_label}")
lines.append(f" upem={m['upem']} typo_body={body} "
f"({m['typo_ascender']}/{-abs(m['typo_descender'])})")
else:
cjk_label = result['cjk_font'] or '(未指定,使用默认因子)'
lines.append(f"CJK 字体: {cjk_label}")
if result['latin_metrics']:
m = result['latin_metrics']
body = m['typo_ascender'] + abs(m['typo_descender'])
lines.append(f"拉丁字体度量: {result['latin_font_name']}")
lines.append(f" upem={m['upem']} typo_body={body} "
f"({m['typo_ascender']}/{-abs(m['typo_descender'])})")
lines.append(f"CJK 行距因子: {result['cjk_factor']}")
lines.append(f"\n推荐行距: {result['recommended_baselineskip']}pt")
if code_only:
cjk_font = result.get('cjk_font')
if cjk_font and result['cjk_scale'] != 1.0:
return (f"\\setCJKmainfont{{{cjk_font}}}[Scale={{{result['cjk_scale']}}}]\n"
f"\\fontsize{{{result['font_size']}}}"
f"{{{result['recommended_baselineskip']}}}\\selectfont")
return (f"\\fontsize{{{result['font_size']}}}"
f"{{{result['recommended_baselineskip']}}}\\selectfont")
if cjk_font := result.get('cjk_font'):
if result['cjk_scale'] != 1.0:
lines.append(f"\nLaTeX 代码:\n "
f"\\setCJKmainfont{{{cjk_font}}}[Scale={{{result['cjk_scale']}}}]\n "
f"\\fontsize{{{result['font_size']}}}"
f"{{{result['recommended_baselineskip']}}}\\selectfont")
else:
lines.append(f"\nLaTeX 代码:\n "
f"\\fontsize{{{result['font_size']}}}"
f"{{{result['recommended_baselineskip']}}}\\selectfont")
else:
lines.append(f"\nLaTeX 代码:\n "
f"\\fontsize{{{result['font_size']}}}"
f"{{{result['recommended_baselineskip']}}}\\selectfont")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="根据中英文字体度量计算最佳行距 (baselineskip)",
)
parser.add_argument(
'main_tex', nargs='?',
help='主 .tex 文件路径',
)
parser.add_argument(
'--cjk', '--cjk-font', dest='cjk_font',
help='CJK 主字体名称(如 "Noto Serif CJK SC")',
)
parser.add_argument(
'--latin', '--latin-font', dest='latin_font',
help='拉丁字体名称(如 "Linux Libertine O")',
)
parser.add_argument(
'--json', action='store_true',
help='以 JSON 格式输出',
)
parser.add_argument(
'--font-size', '-s', type=int, default=None,
help='显式指定基础字号 (pt),不指定则从 .tex 文件解析',
)
parser.add_argument(
'--cjk-scale', type=float, default=1.0,
help='CJK 字体缩放因子(\\setCJKmainfont{...}[Scale=<val>],默认 1.0)',
)
parser.add_argument(
'--code-only', action='store_true',
help='仅输出 LaTeX 代码行,供 pipeline 使用',
)
args = parser.parse_args()
font_size = args.font_size
if args.main_tex:
tex_path = Path(args.main_tex)
if not tex_path.exists():
print(f"[ERROR] 文件不存在: {args.main_tex}", file=sys.stderr)
sys.exit(1)
if font_size is None:
font_size = parse_font_size_from_tex(str(tex_path))
if args.cjk_font is None:
cjk_font = parse_cjk_font_from_tex(str(tex_path))
else:
cjk_font = args.cjk_font
else:
if font_size is None:
font_size = 10
cjk_font = args.cjk_font
result = compute_baselineskip(
font_size=font_size,
latin_font=args.latin_font,
cjk_font=cjk_font,
cjk_scale=args.cjk_scale,
)
print(format_result(result, json_mode=args.json, code_only=args.code_only))
if __name__ == '__main__':
main()
"""Grade assertions for latex-translate-zh eval."""
import json
import re
import sys
from pathlib import Path
def check_assertions(run_dir: str) -> list[dict]:
tex_file = Path(run_dir) / "main_zh.tex"
pdf_file = Path(run_dir) / "main_zh.pdf"
log_file = Path(run_dir) / "build.log"
results = []
# Assertion 1: main_zh.tex exists
tex_exists = tex_file.exists()
results.append({
"text": "main_zh.tex file created with translated content",
"passed": tex_exists,
"evidence": f"File {tex_file} exists={tex_exists}" + (
f", size={tex_file.stat().st_size}B" if tex_exists else ""
)
})
# Assertion 2: main_zh.pdf compiled
pdf_exists = pdf_file.exists()
results.append({
"text": "main_zh.pdf compiled successfully from the translated tex",
"passed": pdf_exists,
"evidence": f"File {pdf_file} exists={pdf_exists}" + (
f", size={pdf_file.stat().st_size}B" if pdf_exists else ""
)
})
if not tex_exists:
return results
content = tex_file.read_text(encoding="utf-8", errors="ignore")
# Assertion 3: LaTeX commands preserved
latex_commands = [
r'\\section\{', r'\\subsection\{', r'\\begin\{equation\}',
r'\\cite\{', r'\\ref\{', r'\\label\{',
r'\\begin\{table\}', r'\\begin\{figure\}',
r'\\begin\{itemize\}', r'\\begin\{thebibliography\}'
]
found_cmds = [c for c in latex_commands if re.search(c, content)]
all_preserved = len(found_cmds) >= 7 # At least 7 of 10 key commands
results.append({
"text": "Translation preserves all LaTeX commands (section, equation, cite, ref, label)",
"passed": all_preserved,
"evidence": f"Found {len(found_cmds)}/10 key LaTeX commands: {found_cmds}"
})
# Assertion 4: Math environments preserved
has_math = bool(re.search(r'\$.*\$|\$\$.*\$\$|\\begin\{equation\}', content, re.DOTALL))
has_math_symbols = bool(re.search(r'\\mathcal|\\theta|\\eta|\\mathbb', content))
results.append({
"text": "Translation preserves math environments and formulas unchanged",
"passed": has_math and has_math_symbols,
"evidence": f"Has math env={has_math}, has math symbols={has_math_symbols}"
})
# Assertion 5: Figure/table captions translated (contain Chinese)
caption_cn = bool(re.search(r'\\caption\{.*[\u4e00-\u9fff]', content, re.DOTALL))
results.append({
"text": "Figure/table captions translated to Chinese",
"passed": caption_cn,
"evidence": f"Chinese found in captions={caption_cn}"
})
# Assertion 6: Section titles translated (contain Chinese)
section_cn = bool(re.search(r'\\section\{.*[\u4e00-\u9fff]', content, re.DOTALL))
results.append({
"text": "Section titles translated to Chinese",
"passed": section_cn,
"evidence": f"Chinese found in section titles={section_cn}"
})
# Assertion 7: References not translated (bibliography contains English)
# Find thebibliography block and check it has English content
bib_match = re.search(
r'\\begin\{thebibliography\}.*?\\end\{thebibliography\}',
content, re.DOTALL
)
if bib_match:
bib_content = bib_match.group(0)
# References should have English author names, titles etc.
has_english = bool(re.search(r'[A-Za-z]{10,}', bib_content))
has_less_chinese = len(re.findall(r'[\u4e00-\u9fff]', bib_content)) < 20
ref_preserved = has_english and has_less_chinese
else:
ref_preserved = False
results.append({
"text": "References section not translated (bibliography preserved as-is)",
"passed": ref_preserved,
"evidence": f"Bibliography found={bool(bib_match)}, has English content={has_english if bib_match else 'N/A'}"
})
return results
def main():
if len(sys.argv) < 2:
print("Usage: grade.py <run_outputs_dir>")
sys.exit(1)
run_dir = sys.argv[1]
results = check_assertions(run_dir)
out = Path(run_dir).parent / "grading.json"
out.write_text(json.dumps({"expectations": results}, indent=2, ensure_ascii=False))
print(f"Grading written to {out}")
passed = sum(1 for r in results if r["passed"])
print(f"Passed: {passed}/{len(results)}")
for r in results:
status = "PASS" if r["passed"] else "FAIL"
print(f" [{status}] {r['text']}")
print(f" {r['evidence']}")
if __name__ == "__main__":
main()
"""
LaTeX 中文兼容性风险扫描脚本
扫描 LaTeX 项目中的高风险结构,输出兼容性风险报告。
检测项:
- mdframed/framed/tcolorbox 后紧跟 section/subsection
- tabbing 中 \\> 层级是否超过 \\= 定义
- \\xspace 宏是否出现在 caption/section/subsection 中
- \\noindent\\textbf 形成 run-in heading
- 负间距 \\vspace{-...} / \\hspace{-...} / \\vskip -...
- ctexart 替换复杂模板风险
Usage:
uv run python latex_compat_scan.py /path/to/latex/project
uv run python latex_compat_scan.py /path/to/latex/project --json
"""
import argparse
import json
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class Finding:
category: str
file: str
line: int
content: str
detail: str = ""
@dataclass
class ScanReport:
findings: list[Finding] = field(default_factory=list)
def add(self, finding: Finding):
self.findings.append(finding)
def by_category(self) -> dict[str, list[Finding]]:
cats: dict[str, list[Finding]] = {}
for f in self.findings:
cats.setdefault(f.category, []).append(f)
return cats
def empty(self) -> bool:
return len(self.findings) == 0
def _read_tex_files(root: Path) -> dict[str, str]:
"""Read all .tex files into a dict of {relpath: content}."""
files = {}
for tex in sorted(root.rglob("*.tex")):
try:
files[str(tex.relative_to(root))] = tex.read_text(encoding="utf-8", errors="ignore")
except Exception:
pass
return files
# ── Check: mdframed/framed/tcolorbox 后紧跟 section/subsection ──
def check_box_followed_by_section(files: dict[str, str], report: ScanReport):
section_cmd = r"\\section\b|\\subsection\b|\\subsubsection\b|\\paragraph\b"
for path, content in files.items():
lines = content.split("\n")
for i, line in enumerate(lines, 1):
if re.search(r"\\end\{(mdframed|framed|tcolorbox|framedbox)\}", line):
# Check next 5 lines for section
for j in range(i, min(i + 6, len(lines) + 1)):
if re.search(section_cmd, lines[j - 1]):
report.add(Finding(
category="box_next_to_section",
file=path, line=j,
content=lines[j - 1].strip(),
detail=f"\\end{{{re.search(r'end\{([^}]+)\}', line).group(1)}}} 后 {j - i} 行出现 section",
))
break
# ── Check: tabbing 中 \\> 层级超过 \\= 定义 ──
def _count_tabs_in_text(lines: list[str], start: int, end: int) -> int:
"""Count max consecutive \\> in body (between \\begin and \\end)."""
max_depth = 0
for i in range(start, end):
line = lines[i]
# Remove comments
line = re.sub(r"(?<!\\)%.*$", "", line)
# Count consecutive \>
matches = re.findall(r"(\\>)+", line)
for m in matches:
depth = m.count("\\>")
if depth > max_depth:
max_depth = depth
return max_depth
def _count_tab_stops_in_kill(lines: list[str], start: int, end: int) -> int:
"""Count \\= in \\kill line or set lines."""
for i in range(start, end):
line = lines[i]
if "\\kill" in line:
# Count \= in the kill line
return line.count("\\=")
return 0
def check_tabbing(files: dict[str, str], report: ScanReport):
for path, content in files.items():
lines = content.split("\n")
for i, line in enumerate(lines):
m = re.match(r"^(\s*)", line)
indent_len = len(m.group(1)) if m else 0
if re.search(r"\\begin\{tabbing\}", line):
start = i
# Find matching \end{tabbing} on same indent level
end = None
for j in range(i + 1, len(lines)):
m2 = re.match(r"^(\s*)", lines[j])
j_indent = len(m2.group(1)) if m2 else 0
if j_indent == indent_len and r"\end{tabbing}" in lines[j]:
end = j
break
if end is None:
continue
max_tabs = _count_tabs_in_text(lines, start + 1, end)
tab_stops = _count_tab_stops_in_kill(lines, start + 1, end)
if max_tabs > tab_stops:
report.add(Finding(
category="tabbing_insufficient_stops",
file=path, line=start + 1,
content=lines[start].strip(),
detail=f"最大 \\> 层级: {max_tabs}, \\= 定义数: {tab_stops}",
))
# ── Check: \\xspace macro in caption/section ──
def check_fragile_in_moving_args(files: dict[str, str], report: ScanReport):
moving_env = [
r"\\caption\b",
r"\\section\b", r"\\subsection\b", r"\\subsubsection\b",
r"\\paragraph\b", r"\\title\b",
]
moving_pattern = "|".join(moving_env)
# Step 1: Find macros that use \xspace
xspace_macros = set()
for path, content in files.items():
for m in re.finditer(
r"\\newcommand\{\\([A-Za-z@]+)\}.*\\xspace|"
r"\\DeclareRobustCommand\{\\([A-Za-z@]+)\}.*\\xspace|"
r"\\def\\([A-Za-z@]+).*\\xspace",
content,
):
name = m.group(1) or m.group(2) or m.group(3) or ""
if name:
xspace_macros.add(name)
if not xspace_macros:
return
# Step 2: Check if these macros appear in moving args
for path, content in files.items():
for m in re.finditer(
rf"({moving_pattern})\s*\{{",
content,
):
cmd = m.group(1)
# Find the matching closing brace for this specific caption/section
pos = m.end()
brace_count = 1
end_pos = pos
for j in range(pos, len(content)):
if content[j] == "{":
brace_count += 1
elif content[j] == "}":
brace_count -= 1
if brace_count == 0:
end_pos = j
break
arg_content = content[pos:end_pos]
for macro in sorted(xspace_macros, key=len, reverse=True):
if re.search(rf"\\{macro}\b", arg_content):
line_no = content[:m.start()].count("\n") + 1
report.add(Finding(
category="fragile_in_moving_arg",
file=path, line=line_no,
content=f"{cmd}... {{... \\{macro} ...}}",
detail=f"\\{macro} (含 \\xspace) 出现在 {cmd} 中",
))
# ── Check: run-in heading (\\noindent\\textbf) ──
def check_runin_headings(files: dict[str, str], report: ScanReport):
for path, content in files.items():
lines = content.split("\n")
for i, line in enumerate(lines, 1):
m = re.search(r"\\noindent\s*\\textbf\{([^}]+)\}", line)
if m:
title = m.group(1)
report.add(Finding(
category="runin_heading",
file=path, line=i,
content=line.strip(),
detail=f'run-in heading: "{title}"',
))
# ── Check: negative spacing ──
def check_negative_spacing(files: dict[str, str], report: ScanReport):
for path, content in files.items():
lines = content.split("\n")
for i, line in enumerate(lines, 1):
if re.search(r"\\vspace\{-\d|\\hspace\{-\d|\\vskip\s*-\d", line):
report.add(Finding(
category="negative_spacing",
file=path, line=i,
content=line.strip(),
detail="负间距在中文环境下可能触发重叠",
))
# ── Check: ctexart replacing complex template ──
def check_ctexart_risk(files: dict[str, str], report: ScanReport):
for path, content in files.items():
# Check for documentclass that might be replaced
m = re.search(r"\\documentclass(?:\[[^\]]*\])?\{(.+?)\}", content)
if not m:
continue
cls_name = m.group(1).strip()
if cls_name in ("article", "report", "book"):
# Check if there are template-specific packages
risk_signals = []
if re.search(r"\\usepackage.*\{mdframed\}", content):
risk_signals.append("mdframed")
if re.search(r"\\usepackage.*\{tcolorbox\}", content):
risk_signals.append("tcolorbox")
if re.search(r"\\usepackage.*\{fontspec\}", content):
risk_signals.append("fontspec")
if re.search(r"\\(twocolumn|onecolumn)\b", content):
risk_signals.append("多栏布局")
if re.search(r"\\usepackage.*\{geometry\}", content):
risk_signals.append("自定义版面")
if risk_signals:
report.add(Finding(
category="ctexart_risk",
file=path, line=1,
content=f"\\documentclass{{{cls_name}}} + {{{', '.join(risk_signals)}}}",
detail=f"包含 {'、'.join(risk_signals)},替换为 ctex{cls_name[:3]} 有高风险",
))
# ── Main ──
def scan_project(root: Path) -> ScanReport:
report = ScanReport()
files = _read_tex_files(root)
check_box_followed_by_section(files, report)
check_tabbing(files, report)
check_fragile_in_moving_args(files, report)
check_runin_headings(files, report)
check_negative_spacing(files, report)
check_ctexart_risk(files, report)
return report
def print_report(report: ScanReport, json_output: bool = False):
if json_output:
data = {
"total_findings": len(report.findings),
"by_category": {
cat: [
{"file": f.file, "line": f.line, "detail": f.detail}
for f in findings
]
for cat, findings in report.by_category().items()
},
}
print(json.dumps(data, ensure_ascii=False, indent=2))
return
if report.empty():
print("✓ 未发现中文兼容性风险")
return
print(f"\n# 中文兼容性风险报告 ({len(report.findings)} 项)\n")
cat_labels = {
"box_next_to_section": "Box/Frame 环境后紧跟 section",
"tabbing_insufficient_stops": "Tabbing tab 位不足",
"fragile_in_moving_arg": "Fragile macro 出现在 moving arguments",
"runin_heading": "Run-in heading",
"negative_spacing": "负间距",
"ctexart_risk": "ctexart 替换风险",
}
for cat, findings in sorted(report.by_category().items()):
label = cat_labels.get(cat, cat)
print(f"## {label} ({len(findings)} 处)\n")
for f in findings:
print(f" - {f.file}:{f.line} {f.detail}")
print()
def main():
parser = argparse.ArgumentParser(
description="LaTeX 中文兼容性风险扫描",
)
parser.add_argument(
"project_dir",
help="LaTeX 项目目录路径",
)
parser.add_argument(
"--json",
action="store_true",
help="输出 JSON 格式",
)
args = parser.parse_args()
root = Path(args.project_dir)
if not root.is_dir():
print(f"[ERROR] 目录不存在: {args.project_dir}", file=sys.stderr)
sys.exit(1)
report = scan_project(root)
print_report(report, json_output=args.json)
if not args.json:
print(f"\n共发现 {len(report.findings)} 个潜在风险,翻译时请针对性防御。")
sys.exit(0 if len(report.findings) == 0 else 1)
if __name__ == "__main__":
main()