前言
做 PDF 翻译的时候,正文段落其实好办——取文本、翻译、写回,只要不改版式就行。真正难的是表格。
表格是"格式保留"里最脆弱的部分:它不是一段流动的文字,而是一个二维网格。翻错了位置,数字和项目名就会对不上号,比排版乱掉更严重。
我这次把两件事都测了一遍:
- 提取:pdfplumber 和 PyMuPDF 谁更靠得住?在什么情况下会崩?
- 回填:把译文写回原来的单元格,到底会不会溢出?
测试环境:Windows 11,Python 3.13.12,PyMuPDF 1.28.2,pdfplumber 0.11.10。
测试样本怎么造的
自己造样本的好处是有 ground truth,可以逐格算准确率。
我生成了三种变体,每个都是 12 页,每页 10 行 × 4 列(Item Code / Description / Qty / Unit Price),一共 120 行、480 个数据单元格:
| 变体 | 特征 | 考察点 |
|---|---|---|
table_ruled_12p.pdf | 规范框线网格 | 理想情况能不能 100% 还原 |
table_borderless_12p.pdf | 无框线,只靠文字对齐;描述列折成两行 | 列边界靠猜时会发生什么 |
table_merged_12p.pdf | 有框线,但表头 colspan=3 合并 | 合并单元格对整表的影响 |
评分用两个指标,比"单元格数对不对"更能说明问题:
- 单元格召回率:归一化后逐格比对,看多少个单元格的内容被完整取到
- 整行还原率:一行 4 个值是否落在同一条输出行里——这才是回填可用的前提
实测结果
有框线:两个库都满分
table_ruled_12p.pdf
pdfplumber tables=12 cell_recall=100.00% row_recall=100.00% time=0.3330s
PyMuPDF tables=12 cell_recall=100.00% row_recall=100.00% time=0.4038s
这个结果符合预期。有框线的表,网格线本身就是答案,两库都直接用线的交点建网格,不需要猜。速度上 pdfplumber 略快 20% 左右,12 页差 0.07 秒,业务上可以忽略。
无框线 + 多行单元格:两个库一起崩
table_borderless_12p.pdf
pdfplumber tables=12 cell_recall=75.00% row_recall=0.00% time=0.4640s
PyMuPDF tables=12 cell_recall=75.00% row_recall=0.00% time=0.6044s
单元格召回 75%、整行还原 0%——两个库给出了完全一样的数字,这不是巧合,是因为它们用了同一类方法:靠"文字之间的水平间隙"推断列边界。
问题出在描述列。这一列的文字最长、还会换行,于是:
- 单行内看,描述列内部词与词之间也有间隙,被误判成列边界,一列文字被拦腰切成三四段
- 换行后,第二行的起始位置和第一行不同,两行被当成了两行不同的数据
结果就是每个逻辑单元格碎成三四块,且没有任何一条输出行能凑齐 4 个值。75% 恰好等于 4 列里 3 列完好(Item Code / Qty / Unit Price 都是短值,没被切断),描述列整列报废。
合并表头:两库都塌成一片
table_merged_12p.pdf
pdfplumber tables=12 cell_recall=25.00% row_recall=0.00% time=0.5323s
PyMuPDF tables=12 cell_recall=25.00% row_recall=0.00% time=0.8094s
第 1 页第 1 行提取出来是这样的(两库输出一致):
[['Order Detail (colspan=3)', 'Amount'],
['Precision ball bearing, sealed,\nITM-0000 stainless steel 1', '3.75']]
表头识别对了(合并单元格被正确当成一格),但数据行整行塌成了一格:物料编码、描述、数量全被塞进同一个单元格。原因还是同一个——合并表头让竖线的分布上下不一致,间隙推断失效。
小结
| 场景 | pdfplumber | PyMuPDF |
|---|---|---|
| 有框线 | 100% / 100% / 0.333s | 100% / 100% / 0.404s |
| 无框线 + 多行 | 75% / 0% / 0.464s | 75% / 0% / 0.604s |
| 合并表头 | 25% / 0% / 0.532s | 25% / 0% / 0.809s |
结论很清楚:两个库的能力边界是同一条线——表格有没有完整框线。有框线时它们是成熟工具,无框线时它们只是"猜",而且猜错的方式是静默的:不报错,只是给你一份看起来是表格、实际上对不上号的数据。
用投影法把无框线表救回来
既然靠"单行间隙"不行,那就换一个信号:整张表累积下来的空白带。
思路是这样的:把表格区域内所有文字的 x 区间投影到 x 轴上取并集,那么"从来没有被任何文字覆盖过"的竖条,就是列与列之间真正的沟槽(gutter)。它的中点就是列边界。
关键代码:
import pymupdf
def cluster_lines(words, tol=2.0):
"""按 y 中心把词聚成文本行,容差 2pt"""
words = sorted(words, key=lambda w: ((w[1] + w[3]) / 2, w[0]))
lines, cur = [], []
for w in words:
yc = (w[1] + w[3]) / 2
if not cur:
cur = [w]
continue
yc0 = sum((c[1] + c[3]) / 2 for c in cur) / len(cur)
if abs(yc - yc0) <= tol:
cur.append(w)
else:
lines.append(sorted(cur, key=lambda x: x[0]))
cur = [w]
if cur:
lines.append(sorted(cur, key=lambda x: x[0]))
return lines
def derive_bounds(data_lines, ncols, min_gap=8.0):
"""投影法:整表累积的空白带 -> 列边界"""
occ = sorted((w[0], w[2]) for ln in data_lines for w in ln)
merged = [list(occ[0])]
for a, b in occ[1:]:
if a <= merged[-1][1] + 0.5: # 区间有重叠就合并
merged[-1][1] = max(merged[-1][1], b)
else:
merged.append([a, b])
# 相邻区间的缝隙 = 候选沟槽,太窄的丢掉
gaps = [(merged[i][1], merged[i + 1][0]) for i in range(len(merged) - 1)]
gaps = [g for g in gaps if g[1] - g[0] >= min_gap]
if len(gaps) < ncols - 1:
return None
gaps.sort(key=lambda g: -(g[1] - g[0])) # 取最宽的 ncols-1 条
keep = sorted(gaps[:ncols - 1])
return [-1e9] + [(a + b) / 2 for a, b in keep] + [1e9]
列数从表头行拿(表头词按水平间隙归组),行分组则必须先算行锚点,再按 y 带归属:
# 1) 行锚点:第 1 列含行首键(ITM-xxxx)的行,其 y 中心就是这一行的锚
anchors = []
for ln in data_lines:
buckets = assign(ln, bounds, ncols)
if " ".join(buckets[0]).strip().lower().startswith("itm-"):
anchors.append(sum((w[1] + w[3]) / 2 for w in ln) / len(ln))
# 2) 每一行文字归到最近的锚点,距离超过半行距就丢弃
pitch = min(anchors[i + 1] - anchors[i] for i in range(len(anchors) - 1))
cells = [[[] for _ in range(ncols)] for _ in anchors]
for ln in data_lines:
yc = sum((w[1] + w[3]) / 2 for w in ln) / len(ln)
ri = min(range(len(anchors)), key=lambda i: abs(yc - anchors[i]))
if abs(yc - anchors[ri]) > pitch * 0.6:
continue
for bi, words in enumerate(assign(ln, bounds, ncols, with_words=True)):
for w in words:
cells[ri][bi].append(((w[1] + w[3]) / 2, w[0], w[4]))
这一步是我这次踩得最深的坑。最初的写法是"遇到行首键就开新行,其它行追加为续行",看起来很自然,结果是整体错位一行。
原因是多行单元格跨了行锚点:描述列第 1 行的文字,y 坐标在数字行的上方;按顺序扫描时它会先被追加到上一行去。用真实 PDF 验证时表现为"每行的描述文字都是上一行的",而单元格召回率居然是 100%——只有整行还原率会暴露它。
换成"先算锚点、再按 y 带归属"之后:
table_ruled_12p.pdf rows=120 cell_recall=100.00% row_recall=100.00% time=0.0162s
table_borderless_12p.pdf rows=120 cell_recall=100.00% row_recall=100.00% time=0.0133s
table_merged_12p.pdf rows=0 cell_recall=0.00% row_recall=0.00% time=0.0097s
无框线场景从 75% / 0% 提到 100% / 100%,而且因为不做表格检测,耗时从 0.46s 降到 0.013s(快了约 35 倍)。
但要诚实说清代价:合并表头这一档它直接交白卷(0 行)。因为这套方法依赖"Item Code"这样的表头锚点,而合并表头恰好把这个锚点破坏了。所以它不是替代品,是补位——有框线、结构规整的表用库,无框线、多行、表头规范的用投影法。
译文回填:真正的约束不是"放不下"
提取只是上半场。把译文写回原单元格,会发生什么?
我用 PyMuPDF 的 insert_textbox 做权威判定:它返回非负值表示放得下(返回剩余空间),返回负值表示溢出。为了拿到真实单元格矩形,我先从页面矢量图元里筛出"贯穿整表宽度的横线 + 贯穿整表高度的竖线"(这份 PDF 是规范网格,还原出来正好是 11 行 × 4 列,与 find_tables() 报告一致)。
先看一个基础事实,同字号下中文比英文窄:
import pymupdf
helv, cjk = pymupdf.Font("helv"), pymupdf.Font("china-s")
# 14 组人工核对的 EN→ZH 对照
# 平均:中文宽度 / 英文宽度 = 0.752
# 也就是 9pt 中文的横向占用只有 9pt 英文的 75%
中文横向只占英文的 75%。所以"中文更长会溢出"这个担心,方向反了——14 组对照在 9pt 原字号下回填,溢出 0 个。
那真正的约束在哪?在纵向:
9.0pt: 英文 9 行(行高 13.33pt) / 中文 9 行(行高 13.33pt)
12.0pt: 英文 6 行(行高 20.00pt) / 中文 7 行(行高 17.14pt)
9pt 文字需要约 13.3pt 行高(字号的 1.48 倍),中英文一致。于是一个 25.5pt 高的单元格只能排 1 行,34pt 的能排 2 行。用真实单元格测出来的中文容量是这样的:
| 列宽 | 行高 25.5pt | 行高 34.1pt |
|---|---|---|
| 79.4pt | 8 字 | 16 字 |
| 249.4pt | 27 字 | 38 字 |
| 56.7pt | 6 字 | 12 字 |
| 96.4pt | 10 字 | 20 字 |
这张表是回填的预算表。往 56.7pt 宽、25.5pt 高的窄列里塞一句 13 字的中文,9pt 下就溢出 4.9pt,得缩到 7.5pt 才放得下。反过来,中文更短带来的 25% 余量也可以换成更大的字号(实测这些单元格的中文上限在 14.5–20.5pt,平均 16.86pt),但整表统一字号时上限是 14.5pt,被最窄的那格卡住。
还有一点要提前防住:无框线表如果直接拿库生成的网格去回填,你会把译文写进碎片里。实测 PyMuPDF(strategy="text") 在无框线表上给出的是 41 行 × 6 列,而真实逻辑网格是 11 行 × 4 列——每个逻辑单元格被拆成约 3.7 段,回填时可用高度只剩四分之一。
回填的实操建议:
- 先用真实网格(或投影法)拿到逻辑单元格矩形,不要直接用检测结果
- 写回时按目标矩形的高度算"能排几行",再决定字号:
可用行数 = floor(可用高度 / 字号 / 1.48) - 字号不要为了填满而放大——同一页还有没被翻译的数字列,字号跳变会很突兀
- 擦原文用
add_redact_annot+apply_redactions(graphics=0),只擦文字层,别把表格线一起擦掉
import pymupdf
pg.add_redact_annot(pymupdf.Rect(x0, y0, x1, y1))
pg.apply_redactions(graphics=0) # 关键:graphics=0 保留矢量表格线
rect = pymupdf.Rect(x0 + 1.2, y0 + 0.6, x1 - 1.2, y1 - 0.6)
fs = 9.0
while fs > 4 and pg.insert_textbox(rect, zh, fontsize=fs,
fontname="china-s") < 0:
fs -= 0.5 # 溢出就缩字号,直到放得下
pg.insert_textbox(rect, zh, fontsize=fs, fontname="china-s")
结论
- pdfplumber 与 PyMuPDF 在有框线表上等价,都是 100% / 100%。选型看生态习惯就行。
- 无框线 + 多行单元格是两个库的共同盲区(75% / 0%),而且失败是静默的——它照样给你返回列表,只是内容对不上号。
- 投影法能补上这个洞(100% / 100%,还快 35 倍),但代价是依赖表头锚点,合并表头场景直接失效。别指望单一方案通吃。
- 回填的约束在纵向不在横向。中文比英文窄 25%,横向不会溢出;一个 9pt 汉字需要 13.3pt 行高,窄列里译文一长就得缩字号。
- 如果表格结构复杂到两库一法都搞不定,那这类文档更理性的做法是别自己拼网格——交给能完整保留版式的在线翻译链路,让它按原版面直接产出译文,而不是先拆成网格再拼回去。
实测脚本和测试样本已归档,可以直接复跑:
gen_table_pdfs.py:生成三种变体的测试 PDF + ground truthbench_table_extract.py:两库对比评分custom_table_extract.py:投影法实现backfill_overflow.py:译文回填的容量与溢出实测
标签:PDF翻译、Python、表格提取、PyMuPDF、效率工具
702



被折叠的 条评论
为什么被折叠?



