前言
做 PDF 翻译预处理时,有个问题比"取不出文字"更隐蔽:文字全都取出来了,但顺序是错的。
双栏排版的学术论文、期刊、产品手册是重灾区。直接取文会得到后面这种结果------第 1 段、第 13 段、第 2 段、第 14 段......左右两栏交替出现。丢给翻译引擎之后,译文读起来是断的:一句话说到一半跳到另一栏,读者要来回拼。
这篇文章用一个可复现的实验回答三个问题:多栏串行的成因是什么、常见解法(按 y 坐标排序)为什么反而更糟、以及正确的分栏重排怎么做。所有代码在本机实测跑通,数据均为实测值。
一、问题定位:串行的成因分两种,别混为一谈
双栏文档取文顺序出错,其实有两个完全不同的原因,处理方式也不一样。
原因一:内容流顺序本身就是乱的。
PDF 内部是"绘图指令流",文字按写入顺序排布,这个顺序不一定等于阅读顺序。用 InDesign 等排版工具导出、或者由多个文件拼接合成的 PDF,内容流常常是乱的------因为写入顺序是生成器的实现细节,跟人眼阅读顺序没关系。
原因二:你按视觉位置排序,但排序规则没考虑分栏。
这是更常见的一种。很多人取到文本块后写一句 sorted(blocks, key=lambda b: (b.y, b.x))------先按纵坐标、再按横坐标。在单栏文档上这没问题,在双栏文档上就会变成"同一水平高度,先左后右",于是左右两栏交替输出。
这两种原因的应对方式完全不同,而网上的通用建议往往只覆盖第二种,还给了个错的解法。下面用实验说清楚。
二、环境准备
bash
pip install pymupdf
- Python 3.10+(本机实测 3.13.12)
- pymupdf 1.28.2
- 无需其他依赖,纯几何计算
三、四种取文方式
我们对比四种方式,覆盖从"什么都不做"到"分栏感知"的完整区间:
| 代号 | 方式 | 说明 |
|---|---|---|
| A | 自然文本流 | page.get_text("blocks") 原样返回,不排序 |
| B | 全局 y-x 排序 | 按 (y, x) 排序,最常见的"自己写个排序" |
| C | 分栏感知重排 | 先检测栏间空隙,再栏内排序(本文方案) |
| D | 内置 sort=True | PyMuPDF 内置的几何重排 |
python
import pymupdf
def read_A_natural(page):
"""方式A:自然文本流顺序"""
return [b for b in page.get_text("blocks") if b[6] == 0 and b[4].strip()]
def read_B_yx(page):
"""方式B:全局 (y, x) 排序"""
bl = [b for b in page.get_text("blocks") if b[6] == 0 and b[4].strip()]
# 按 y 每 8pt 分桶,避免同一行的微小基线差异导致错序
return sorted(bl, key=lambda b: (round(b[1] / 8), b[0]))
def read_D_sorted(page):
"""方式D:内置 sort=True"""
return [b for b in page.get_text("blocks", sort=True)
if b[6] == 0 and b[4].strip()]
注意
b[6] == 0是过滤图片块(只保留文本块),b[4].strip()过滤空白块。
四、关键一步:检测栏间空隙(gutter)
分栏重排的第一步是找出栏与栏之间的空白带。直觉做法是把所有文本块的 x 区间投影成覆盖率直方图,找空白段。
这里有个坑:不能直接用 get_text("blocks") 的块并集做投影。
原因是 PyMuPDF 会对相邻文本做块级合并------当两栏的文字在同一水平线上靠得比较近时,它可能把左右两栏的内容合并成一个横跨页面的块。这个块一出现,gutter 就被"吃掉"了,后续分栏判断直接失效。
我在实验里就踩到这个坑:打乱内容流之后,24 个段落里出现了 1 个横跨两栏的块(bbox 宽度从 x=52 一直延伸到 x=508),导致 gutter 检测返回空。
正确做法是用 span(文本片段)级坐标做投影,因为 span 不会被跨栏合并:
python
import numpy as np
def detect_gutters(page, min_ratio=0.035, skip_top=0.05):
"""用 span 级 x 投影找栏间空隙,返回栏切分点 x 坐标列表
Args:
page: pymupdf.Page
min_ratio: 空隙宽度占页宽的最小比例,过滤字间距噪声
skip_top: 跳过页眉区域的比例
"""
W, H = page.rect.width, page.rect.height
d = page.get_text("dict")
xs = []
for blk in d["blocks"]:
if blk["type"] != 0: # 只处理文本块
continue
for line in blk["lines"]:
for sp in line["spans"]:
if not sp["text"].strip():
continue
x0, y0, x1, y1 = sp["bbox"]
# 跳过页眉页脚:通栏元素会污染投影
if y0 < H * skip_top or y1 > H * 0.96:
continue
xs.append((x0, x1))
if not xs:
return []
nb = 200 # 200 个 bin,约 3pt 精度
hist = np.zeros(nb)
for x0, x1 in xs:
i0 = max(0, int(x0 / W * nb))
i1 = min(nb - 1, int(x1 / W * nb))
hist[i0:i1 + 1] += 1
occ = hist > 0
cuts, start = [], None
for i in range(nb):
if not occ[i]:
if start is None:
start = i
else:
if start is not None:
# 只保留页内、宽度达阈值的空隙
if start > 0 and (i - 1) < nb - 1 and (i - start) / nb >= min_ratio:
cuts.append((start + i) / 2 / nb * W)
start = None
return sorted(cuts)
实测在一份标准 A4 双栏(左右各 50pt 边距、中缝 80pt)的合成文档上,检出切分点 282.6,理论值 297.5,误差约 15pt------因为段落最后一行常常没填满,使空白带略微左移。这个精度对分栏判断完全够用。
五、分栏感知重排
拿到切分点后,把文本块按中心点归到对应栏,栏内按 y 排序;通栏元素(标题、页眉)单独按 y 插回序列:
python
def read_C_columns(page, wide_tol=1.45):
"""方式C:先分栏、栏内按 y 排序,通栏元素按 y 插回"""
bl = [b for b in page.get_text("blocks") if b[6] == 0 and b[4].strip()]
if not bl:
return []
W = page.rect.width
cuts = detect_gutters(page)
if not cuts: # 单栏文档:退化为普通 (y, x) 排序
return sorted(bl, key=lambda b: (b[1], b[0]))
bounds = [0.0] + cuts + [W]
widths = [bounds[i + 1] - bounds[i] for i in range(len(bounds) - 1)]
single = min(widths)
# 宽度超过单栏宽度 * wide_tol 的块视为通栏(标题/页眉)
full, body = [], []
for b in bl:
(full if (b[2] - b[0]) > single * wide_tol else body).append(b)
def col_of(b):
"""判断块中心落在第几栏"""
c = (b[0] + b[2]) / 2
for i in range(len(widths)):
if bounds[i] <= c <= bounds[i + 1]:
return i
return len(widths) - 1
full.sort(key=lambda b: b[1])
body.sort(key=lambda b: (col_of(b), b[1]))
# 通栏元素按 y 插回正文序列
out, bi = [], 0
for h in full:
while bi < len(body) and body[bi][1] < h[1]:
out.append(body[bi]); bi += 1
out.append(h)
out.extend(body[bi:])
return out
关键在于 col_of 用块中心点而不是左边界判断归属------跨栏标题的左边界一定在左栏,用左边界会全部误判成第一栏。
六、实验一:内容流顺序被打乱的双栏文档
为了有确定的"标准答案",我构造了一份双栏 PDF:24 个段落,每段以 P01...P24 开头,版面按"先第一栏从上到下、再第二栏从上到下"排布,但故意打乱内容流的写入顺序,模拟排版工具导出的真实形态。
python
def build_scrambled(path, n=24, cols=2, seed=11):
"""构造内容流顺序被打乱、但阅读顺序已知的双栏 PDF"""
import numpy as np
rng = np.random.default_rng(seed)
doc = pymupdf.open()
page = doc.new_page(width=595, height=842)
M, GUT, TOP, BOT = 50, 80, 70, 780
colw = (595 - 2 * M - GUT * (cols - 1)) / cols
per = n // cols
slot = (BOT - TOP) / per
layout = [] # 记录真实阅读顺序
k = 0
for c in range(cols):
x0 = M + c * (colw + GUT)
for r in range(per):
k += 1
layout.append((f"P{k:02d}", x0, TOP + r * slot + 4))
order = layout[:]
rng.shuffle(order) # 关键:打乱写入顺序
for tag, x0, y0 in order:
page.insert_textbox(
pymupdf.Rect(x0, y0, x0 + colw, y0 + slot - 8),
f"{tag} " + "content " * int(rng.integers(3, 6)),
fontsize=9, fontname="helv")
doc.save(path); doc.close()
return [t[0] for t in layout]
准确率定义为"位置正确的段落数 / 总段落数"。另外定义一个跨栏错跳率:相邻两个块分属不同栏、但纵向位移小于 18pt 的比例------这正是"串行"的直接信号。
实测结果:
| 方法 | 顺序准确率 | 跨栏错跳率 |
|---|---|---|
| A 自然文本流 | 0.0% | 0.0% |
| B 全局 y-x 排序 | 4.2% | 50.0% |
| C 分栏感知重排 | 83.3% | 0.0% |
| D 内置 sort=True | 4.2% | 50.0% |
几个值得注意的点:
- 内容流乱了之后,方式 A 完全失效 (0%)。实际输出是
P24, P21, P05, P09, P03...,完全随机。 - 方式 B 和 D 只有 4.2% ,且跨栏错跳率高达 50%------它们输出的正是
P01, P13, P02, P14这种左右交替的典型串行。这里要强调:sort=True不能解决多栏问题,它内部做的也是几何排序。 - 方式 C 达到 83.3% 且零错跳 ,剩下 4 个段落错位,全部由前面提到的"块级合并"引起------那 1 个横跨两栏的块被归入某一栏,挤掉了 4 个段落的正确位置。这属于块级合并的固有干扰,可以通过改用行级(
"dict"模式)切分进一步消除。
作为对照,我又构造了一份内容流顺序正确的双栏文档:此时方式 A 和 C 都是 100%,而方式 B 只有 8.3%、跨栏错跳率 52.2%。
结论:如果你的 PDF 内容流本身是正确顺序的,那么"自己按 y 排序"是把好数据搞坏。 先判断内容流状态,再决定要不要重排。
七、实验二:真实 PDF 上的验证
合成文档说明原理,真实文件才说明问题。我在本机的几份真实多栏 PDF 上跑了同样的对比(用跨栏错跳率衡量,越低越好):
| 文档 / 页 | 文本块 | 栏数 | A 自然流 | B y-x 排序 | C 分栏感知 | D sort=True |
|---|---|---|---|---|---|---|
| HL2700 快速指南 p0 | 40 | 2 | 0.0% | 12.8% | 2.6% | 2.6% |
| HL2700 快速指南 p3 | 31 | 2 | 0.0% | 40.0% | 0.0% | 33.3% |
| HL2700 快速指南 p4 | 29 | 2 | 0.0% | 57.1% | 0.0% | 42.9% |
| 双语报告 p9 | 49 | 2 | 0.0% | 72.9% | 0.0% | 66.7% |
| 平均 | --- | --- | 0.0% | 45.7% | 0.6% | 36.4% |
结果和直觉相反,但和第六节的结论一致:这几份真实文档的内容流顺序本来就是正确的,所以最"朴素"的方式 A 反而是最优解(0% 错跳);而强行按坐标排序的方式 B 引入了 45.7% 的错跳,最差的一页达到 72.9%------将近四分之三的相邻块都在跨栏乱跳。
唯一例外是 p0(B 只有 12.8%),原因是那页顶部有一个通栏大标题,y 排序时它天然排在前面,起到了锚定作用。
八、结论与路线判据
把两轮实验合起来,可以给出一条明确的判断链:
python
def read_pdf_ordered(page, content_stream_is_trusted=None):
"""按内容流状态选择取文策略
Args:
content_stream_is_trusted: None=自动判断(推荐先人工抽查几页),
True=直接用自然流, False=强制分栏重排
"""
cuts = detect_gutters(page)
# 单栏文档:自然流通常就是对的,不要多此一举
if not cuts:
return read_A_natural(page)
if content_stream_is_trusted is True:
return read_A_natural(page) # 内容流可信 → 保持原样
# 多栏 + 内容流不可信 → 分栏重排
return read_C_columns(page)
三个实操结论:
-
先抽查,再决定。 打开文档看两三页,取一段文字看顺序是否连贯。连贯就别动它------不要条件反射地写排序代码,那会引入本不存在的 45% 错序率。
-
只在确认内容流乱了、且文档是多栏时才重排。 单栏文档做分栏重排没有收益,只需普通 (y, x) 排序。
-
检测 gutter 必须用 span 级坐标。 用 block 级会被跨栏合并的块破坏,这是本次实验中最容易踩的坑。
对翻译流程的意义: 译文读起来连不连贯,取决于送进翻译引擎的原文顺序。顺序错了,翻译质量再高也没用------读者拿到的是"拼不回去的碎片"。把这一步做对,比在术语和润色上多花时间回报更高。
总结
- 双栏 PDF 取文串行有两个独立成因:内容流乱、排序规则错。本文给出了区分方法和各自的解法。
- 实测显示:真实文档的内容流常常本来就是对的,强行按 y 排序平均引入 45.7% 的跨栏错跳;内容流被打乱时自然流则完全失效(准确率 0%)。
- 分栏感知重排在两种场景下都表现稳定(0.6% / 0% 错跳,83.3% 准确率),是内容流不可信时的可靠选择。
- 核心技巧:用 span 级坐标做投影检测 gutter,避开块级合并的干扰。
如果你的场景是"整篇文档翻译、且要求版面不变",这类版面分析工作交给专门的工具会更省事------把文件整篇上传、选好目标语言后直接下载,取文顺序和版面还原由工具内部处理,不需要自己维护一套 PDF 解析逻辑。自己写代码的价值在于可控性:当你要处理的是批量文档、或者需要把取文结果接入自己的术语库和质检流程时,上面这套几何重排逻辑可以直接复用。
标签:PDF解析、Python、pymupdf、文档处理、排版分析