PDF 绝对定位中英对照翻译
把一份(通常是双栏学术)PDF 论文译成中文,产出一份每段/每图/每表位置与原 PDF 一致的中文(或中英混排)PDF。
核心思路:提取原 PDF 每个文本块的 bbox,用 LaTeX textpos 的绝对定位把这些块钉在同样的坐标上;中文比英文长,靠「列感知 reflow」把同列后续块下移,靠 XeLaTeX 实测块高保证不重叠。
何时用 / 不用
用:双栏论文 PDF 翻译并要求版式对齐;"对照着看";"绝对坐标一致";需要保留原表格数据与图表。
不用:只要纯译文(直接 Markdown/Word 更省事);单栏短文;PDF 是扫描件无文字层(需先 OCR)。
前置检查
- 确认 PDF 有文字层:
fitz打开后page.get_text()非空。扫描件先 OCR。 - 确认字体:Windows 上装
Noto Serif SC/Noto Sans SC(注意不是Noto Serif CJK SC)。 - 确认 xelatex 可用:脚本会自动从
PATH探测xelatex;若不在PATH上,可在项目级abs_cfg.py写xelatex = r"C:\...\xelatex.exe",或在命令行用--xelatex显式指定。Linux/macOS 上常见路径/usr/local/bin/xelatex、/Library/TeX/texbin/xelatex也会兜底探测。 - 用普通 Python 运行脚本(Python 3.10+,已装
PyMuPDF即fitz)。脚本内部用subprocess.run(..., cwd=...)调 xelatex,无需手动拼 PATH。
打包脚本(推荐工作流)
scripts/ 下是已在本机端到端跑通(17 页论文)的 4 个脚本。概念见下方「流程」,实操直接调这 4 个:
PY="python" # 任意 Python 3.10+ 环境(需装 PyMuPDF)
SK="<skill仓库>/scripts" # 本仓库的 scripts/ 目录
PROJ="<项目目录>" # 所有产物都写这里;先建好空目录
# 1) 提取:PDF -> blocks.json + figs/figN.png + figs/IMAGES.md(图注对照表)
"$PY" "$SK/1_extract.py" --pdf "<源论文.pdf>" --out "$PROJ" --dpi 300
# 2) 写译文:在项目目录建 trans.py,导出 body=[...](必要)与 META_CN=[...](可选,
# 首页中文 front-matter)。abs_cfg.py 可覆盖 DEFAULTS 任意键(论文相关调参都放这)。
# ⚠️ 引擎不自动翻译——译文由你提供,翻译质量决定成品质量。
# 3) 生成绝对定位 tex(读 blocks.json + trans.py + figs/)
"$PY" "$SK/2_build.py" --project "$PROJ" --trans trans.py --out main_abs.tex
# 4) 编译(隔离 _build_abs/,跑两遍)
"$PY" "$SK/3_compile.py" --project "$PROJ"
# 5) 核验(重叠扫描 + 编号/缩进检查 + 逐页渲染 preview_abs/)
"$PY" "$SK/4_verify.py" --project "$PROJ" --pdf main_abs.pdf --render
trans.py 的 body 元素类型与「流程 / 第 2 步」一致(标题/段落/列表/表格/图/公式/算法)。
abs_cfg.py 是可选覆盖文件,例如某篇不是 A4 双栏、或字号偏大,就在这里改 page_w/page_h/col_x/body_fs 等。
流程
第 1 步:提取块坐标 → blocks.json
用 PyMuPDF 逐页导出 block 级 bbox + 文本:
import fitz, json
doc = fitz.open(src_pdf)
out = []
for page in doc:
d = {"page": page.number + 1, "width": page.rect.width,
"height": page.rect.height, "blocks": []}
for b in page.get_text("dict")["blocks"]:
if b["type"] == 0: # text
txt = "\n".join("".join(s["text"] for s in ln["spans"])
for ln in b["lines"])
d["blocks"].append({"type": "text", "bbox": list(b["bbox"]),
"text": txt})
else: # image
d["blocks"].append({"type": "image", "bbox": list(b["bbox"])})
out.append(d)
json.dump(out, open("blocks.json", "w", encoding="utf-8"), ensure_ascii=False)
同时把插图按 300dpi 导出到 figs/(page.get_pixmap(clip=image_bbox, dpi=300))。
第 2 步:译文按「语义对象」组织(build.py)
不要按页硬翻。建一个 body 列表,元素类型:
- 标题(\section*{} / \subsection*{})
- 正文段落(纯文本)
- 列表项(每条一个元素,不要把 \begin{itemize} 包裹元素混进来)
- 表格(\begin{table}...\label{tabN}...\caption{中文标题}\begin{tabular}{...}...)
- 图(\begin{figure}...\label{figN}...\caption{中文图注}...)
- 公式(\begin{equation}...)
- 算法(用非浮动的 \begin{center}\fbox{\parbox{...}{...}}\end{center})
保留英文的部分(参考文献、CRediT、基金、作者简介)单独标记,最后按原文逐块贴回。
第 3 步:编号锚定配对(关键,别用顺序配对)
LaTeX 浮动体会乱序,所以表格按编号锚定、图按编号锚定、公式按行带锚定:
S_tables[int(m.group(1))] = block # 原 PDF "Table N" 块
body_tables[N] = tex # 译文 \label{tabN}
# 渲染时: for nn, sb in S_tables.items(): 用 sb['cb'] 放中文标题、sb['db'] 放表格
- 表格标题判定:
re.match(r'^Table \d+\n', text);表体是它的下一个 block。 - 图注判定:
re.match(r'^Fig\. \d+\. ', text);对应图片用loc_fig_image()(同页、在注上方、x 中心最近)。 - 公式:不要贪婪扩展!用一个 anchor 子串 + 列 x 上限 + 行带(±15pt) 限定:
EQ_SPEC = [(1, 7, '𝑊(𝑘) =', 300, 15, 0), (2, 8, '(2)', 300, 15, 340)]
# 命中 anchor 所在块 → 取 (yc±band) 内、x<xlim 的块,合并 bbox
⚠️ 不给 ymin/band 约束会把算法框吞进公式 bbox(踩过)。
其余正文/标题/列表:按章节标题把两块切成分段,段内顺序 1:1 配对(min(len(S_seg), len(body_seg)))。
第 3.5 步:中文排版细节(章节序号 / 段首缩进)
绝对定位方案没有 LaTeX 自动编号,也没有正常的段落机制,这两个"顺手就有"的东西必须显式补上。
① 章节序号必须从原 PDF 里捞回来。
译文数据(body)里的标题通常写成 \subsection{基于 ccTLD 的分类}——不带编号,编号原本靠 LaTeX 自动生成。而原 PDF 的标题块文本是带号的("3.3.1. ccTLD-based classification")。所以:
def _head_num(t):
m = re.match(r'^\s*(\d+(?:\.\d+)*\.?)(?=\s)', t)
return (m.group(1) + ' ') if m else ''
# 译文标题 = 从 S 标题块提取的编号 + 译文标题正文
'content': _head_num(sb['text']) + strip_heading(body_text[bh_idx[k]])
back-matter 标题(CRediT/References/…)在原 PDF 里本就无编号 → 自动为空,不用特判。
⚠️ 加上编号后,短标题会超出原 bbox 宽度而折行。修法:bold 标题一律占整列宽度(x0=colx; w=colr-colx)。原 PDF 标题本就顶格,起点不变,只影响右端与换行。
② 段首两字缩进用 \hspace*{2em},不要用 \parindent。
textblock 内是单段落,\parindent 行为依赖内部实现;\hspace*(带星号)在行首不会消失,稳定。
INDENT_EM = 2.0 # 1 CJK 字 = 1em → 2em 即"两个汉字"
ind = r"\hspace*{%.2fem}" % INDENT_EM if f.get('indent') else ""
判定:是正文段落才缩进 —— text 且非 bold,且内容不以 \textbullet / \begin{ / \[ / $$ 开头(标题、列表项、显示公式、bio 固定框都不缩进)。
⚠️ 测量与排版必须同构:第 5 步的探针 \vbox 里也要加同一个 \hspace*{2em},否则实测高度偏离实际,缩进一加又压字。
# 探针
r"\setbox\MBX=\vbox{\hsize=%.2fpt\raggedright\fontsize{%.2f}{%.2f}\selectfont %s%s\par}" % (f['_w'], fs, fs*LH, ind, f['content'])
# 输出
r"\noindent\raggedright " + fsp + ind + inner
缩进使首行可用宽度减 2em、行数可能增加;因为高度是实测的,reflow 会自动下移,不会新增重叠(实测:页数与重叠数不变)。
摘要/关键词这类带「摘要:」标签的整块元素若走的是估算式 fit_fontsize 路径,加缩进会触发缩字号,建议先不加并询问用户。
第 4 步:列感知 reflow(reflow())——不重叠的核心
对每一页、每一列(x<300 左栏 / ≥300 右栏)分别处理:
- 保留原始 top y 作锚点(这是"位置一致"的来源)。
- 块高已知后,自上而下放置:
y = max(原 top, 上一块 bottom + gap)。即"下移"而非"缩小"。 - 仅当整列超出页底(如 772pt)才整列等比缩小。
- 障碍物 obstacles:照片、作者简介等固定在原位的元素,流式文本不得压入——在障碍物上方的文本按可用高度收缩。
def reflow(stream, bottom_limit=772.0, gap=1.5, obstacles=None):
for pg in pages:
for col in (0, 1):
c = sorted([f for f in bypage[pg] if _colof(f['bbox'][0]) == col],
key=lambda f: f['bbox'][1])
acc = None
for f in c:
y = f['bbox'][1]
if acc is not None and y < acc + gap:
y = acc + gap
f['out_y'] = y
acc = y + f['meas_h']
必须给块设最小宽度(粗体 90pt / 正文 60pt)并覆盖整栏宽度,否则窄块里 \raggedright 会让中文逐字换行(竖排)。
第 5 步:用 XeLaTeX 实测每个块的真实高度(本方案的关键)
em 估算(汉 1.0em / 拉丁 0.5em)对拉丁文本高估约 1.5×、对中文行高低估,会产生压字。
不要相信估算——让 LaTeX 自己量:
# 探针 tex:每块放进 \vbox,量 ht+dp 后 \typeout
lines = [PREAMBLE, r"\newsavebox{\MBX}", r"\newlength{\TDL}"]
for i, f in enumerate(flow):
fs = f['fs']
lines.append(r"\setbox\MBX=\vbox{\hsize=%.2fpt\raggedright"
r"\fontsize{%.2f}{%.2f}\selectfont %s\par}"
% (f['_w'], fs, fs * 1.42, f['content']))
lines.append(r"\setlength{\TDL}{\ht\MBX}\addtolength{\TDL}{\dp\MBX}")
lines.append(r"\typeout{MBH %d \the\TDL}" % i)
# 解析: re.finditer(r'MBH (\d+) ([\d.]+)pt', log)
两个必须踩对的坑:
- 必须用 \vbox 再取 \ht+\dp。\parbox 的 \ht 只是首行高度,不是整块高。
- \the\ht 与 \the\dp 之间没有空格(输出 6.84pt1.84pt),所以只用一个 \typeout 量总和。
行距系数用 LH = 1.42(1.24 会让相邻中文行重叠)。
第 6 步:输出带分页的 tex 并编译
绝对定位内容不产生竖直材料,普通 \newpage 不触发分页 → 必须 \null\newpage。
按页排序 jobs,页号变化时插入 \null\newpage。
\documentclass[a4paper,10pt]{article}
\usepackage[absolute]{textpos}
\textblockorigin{0pt}{0pt}
\usepackage{graphicx,xeCJK,amsmath,amssymb,booktabs,array,xurl}
\setCJKmainfont{Noto Serif SC}
\setCJKsansfont{Noto Sans SC}
\pagestyle{empty}
\setlength{\parindent}{0pt}
\begin{document}
\begin{textblock*}{205.6pt}[0,0](329.6pt, 51.1pt)
\noindent\raggedright\fontsize{8.50}{12.07}\selectfont 中文内容\end{textblock*}
⚠️ \selectfont 后必须留一个空格,否则会与后面的字母粘成 \selectfontComputers → Undefined control sequence。
编译两次(隔离目录 _build_abs/),subprocess.run([XELATEX, "-interaction=nonstopmode", "-output-directory=", tex], cwd=...)。
第 7 步:验证(必做,不要凭感觉)
- 精确重叠扫描(span 级两两相交,而非按行分桶):
解读时注意区分真碰撞与 PyMuPDF 的上下行升/降部交叠(oy ≤ 4pt且frac < 0.6通常是噪声)。
数学上下标的独立 span(如W_scaled的scaled)会长期误报,属假阳性。 - 逐页渲染目视核对(
get_pixmap(dpi=110)),重点看中文最密的页与参考文献/作者简介页。 - 对可疑处高倍裁剪(
dpi=300, clip=Rect(...))确认是否真叠字。
本管线实测:修复后 17 页仅剩 3 处数学下标假阳性,其余 0 重叠。
常见故障速查
| 症状 | 原因 | 修法 |
|---|---|---|
\selectfontComputers 未定义 |
\selectfont 后无空格 |
加空格 |
| 内容不分页 | 绝对定位无竖直材料 | 用 \null\newpage |
| 中文标题竖着每字一行 | 块宽过窄 + \raggedright |
设最小宽度并撑满栏宽 |
| 中文段落压住下一段 | 块高低估 | 改用 XeLaTeX 实测高度 |
| 整段被缩到 4–5pt | em 模型把拉丁高估 | 实测高度;纯拉丁块按 原高 × fs比 × 1.32 |
| 公式 bbox 吞掉算法框 | 公式锚定贪婪扩展 | 加 xlim + band + ymin |
| 某标题整体错位/重复 | back-matter 名单 typo(如 credict)或把数学碎片当标题 |
核对 BM_NAMES;标题判定要求编号后跟 ≥3 拉丁字符 |
| 表格标题以英文重复出现 | 原语言表块混进流式文本 | 加 _looks_like_raw_table() 守卫剔除 |
| 参考文献压住作者简介 | bio 未设为障碍物 | bio 文本块加入 obstacles(且只用右栏 + 窄竖版图判定) |
| 章节序号(3.1.1)全丢 | 译文标题不含编号,绝对定位无自动编号 | _head_num() 从原 PDF 标题块提取编号后前置 |
| 加编号后标题折行 | 短标题超出原 bbox 宽 | bold 标题占满整列宽度 |
| 缩进一加就压字 | 探针测量时没加同样的缩进 | 探针 \vbox 与 emit 用同一段 ind 前缀 |
| 页面框比原 PDF 小 0.37%、块整体偏移 ≈0.7mm | PyMuPDF bbox 是 PDF 的 bp;LaTeX 长度是 pt(1pt = 72.27/72 bp) | 设 coord_scale = 72.27/72;并开 match_page_size=True 用 \pdfpagewidth/\pdfpageheight 对齐页面框 |
图注含 \log_{10} 等花括号数学被 (.*?) 截断 |
非贪婪正则吃不到配平花括号 | _grab_braced() 配平提取 \caption{...} 整段 |
| 7 张图里 fig1/fig2 错位、后面整体偏 2 位 | 图按"文档出现顺序"编号,把页 1 装饰图标当 fig1/fig2 | 改按"图注编号"锚定:fig<N>.png 取自 Fig. N. 的 N |
| 作者简介旁竖排小照片从不出现 | 只导了 ≥24pt 大图,小照片被 min_img 滤掉且未贴回 |
bio_photo 配置 + BIO_BLOCKS 障碍物:小照片导出为 extraN.png 并 \includegraphics 贴回 |
| 插图被拉伸/压扁 1~2pt | 导出时加了 ±2pt 边距,破坏原 rect 宽高比 | 严格按图片 rect 裁剪(clip = rect & page.rect),不加边距 |
Bash command not found |
本机 Git Bash PATH 损坏 | 全程用 managed Python 绝对路径 + subprocess.run(cwd=...) |
页码/脚注块混进正文流,S_text 比 body 多出 n 个「1…17」块,译文无法 1:1 配对 |
DEFAULTS.page_h 默认 A4 (841.89),而原 PDF 是 US Letter (792);is_pagenum/is_footnote 用错的 page_h 判定,y≈715 的页码落在阈值之外被判成正文 |
abs_cfg.py 里显式设 page_w/page_h 为原 PDF 实测尺寸(blocks.json 的 p["width"]/p["height"]),不要依赖 DEFAULTS |
| 整节正文突然变回英文,且该节 S 段落数 ≠ 译文段落数 | _is_row_block() 启发式(\n + 首行<75字符 + 宽<0.72页宽)在双栏论文里把正文段落误判成表格行,被表注的「行块吸收」吞掉 |
给 _is_row_block 加每行高度判据:表格行 h/line ≈ 1–3pt,正文 ≈ 10.5pt → 要求 h/line <= row_block_max_per_line(默认 5.0);并给吸收循环加 table_row_gap 垂直邻近上限 |
| 段落数与译文对不上(S 多出的块被静默丢弃) | build_flow 段内 min(len(bn), len(sn)) 配对,多余的 S 块无声消失 |
加 ABS_SECT_DIAG=1 环境变量打印每节 S=/body= 对照,逐节补齐译文;PDF 的物理块切分(跨栏/跨页断行)必须照抄,不能按语义合段 |
译文里的 % & # $ 让编译报 Missing $ / textblock ended by \end{document} |
译文未做 LaTeX 转义;textpos 的 \begin{textblock*} 只要内容里有裸 % 就会把 \end{textblock*} 注释掉 |
对译文做保护宏的转义(识别 \cmd{...} 结构,只转义纯文本段);三引号 r"""...""" 里的图注/表注同样要转义(易漏) |
字符串里的 \textasciicircum 渲染成 extasciicircum(丢了 \t) |
Python 普通字符串把 \t 解析成制表符 |
含反斜杠命令的字符串一律用 raw 字符串(r"...");注意 raw 字符串末尾的 \n 要改为 "..." "\n" 拼接 |
| 公式裁剪图盖住中文译文 | 公式区域 y 上界探到了正文段落(原 PDF 里公式紧贴在段落下沿) | 公式区域只框住公式本体(用 page.get_text("dict") 逐行 bbox 定位,含编号 (N)),不要从段落顶部开始框 |
| 末页出现一大段密码明文/表格内容 | 该表没有独立 caption,sb["cb"] 退化成整个表体 bbox,被当图注排出 |
表在 figs/tableN.png 已存在原图时,S_tables 分支只排译文 caption,不发 tabular 文本;无 caption 则整表跳过 |
| 表格体/页脚/旋转侧栏仍被排出 | 未被 fig_regions / eq_blocks 覆盖 |
逐块核对 blocks.json,把表体 (page, idx)、页脚声明、arXiv 旋转侧栏加入跳过清单(写进 render_all.py 的 MANUAL_SKIP 才不会被覆盖) |
交付
成品命名为 <主题>_中英对照_绝对定位.pdf;同时保留 main_abs.tex 与逐页核验截图目录 preview_abs/。
用户说"没生效"时的排查顺序(踩过)
同一路径的 PDF 若在同一会话里交付过两次,预览面板/PDF 阅读器可能按路径缓存旧快照,用户看到的仍是上一版。所以:
- 先取证,别急着改代码:直接从成品 PDF 里读回特征(
page.get_text("dict"))自证。
- 编号:bold = any(s["flags"] & 16 ...)+re.match(r'^\d+(?:\.\d+)*\.\s', text)。
- 缩进:相邻两行0 < y1-y0 < 16且x0 - x1 ≈ 2em→ 该段有首行缩进。
- ⚠️ 抓标题别加"标题字数 ≥3"的过滤:1. 引言/4. 方法/6. 讨论/7. 结论这类两字标题会被漏掉,从而误判成"编号丢失"(踩过)。 - 换一个新文件名重新交付(如加
_带编号缩进版),彻底绕开缓存。 - 附上当场渲染的 PNG(
get_pixmap(dpi=130))——图片必然是新的,是最直观的铁证。