多栏PDF阅读顺序重建:用Python按坐标聚类还原双栏论文的翻译顺序

前言

做 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%

几个值得注意的点:

  1. 内容流乱了之后,方式 A 完全失效 (0%)。实际输出是 P24, P21, P05, P09, P03...,完全随机。
  2. 方式 B 和 D 只有 4.2% ,且跨栏错跳率高达 50%------它们输出的正是 P01, P13, P02, P14 这种左右交替的典型串行。这里要强调:sort=True 不能解决多栏问题,它内部做的也是几何排序。
  3. 方式 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)

三个实操结论:

  1. 先抽查,再决定。 打开文档看两三页,取一段文字看顺序是否连贯。连贯就别动它------不要条件反射地写排序代码,那会引入本不存在的 45% 错序率。

  2. 只在确认内容流乱了、且文档是多栏时才重排。 单栏文档做分栏重排没有收益,只需普通 (y, x) 排序。

  3. 检测 gutter 必须用 span 级坐标。 用 block 级会被跨栏合并的块破坏,这是本次实验中最容易踩的坑。

对翻译流程的意义: 译文读起来连不连贯,取决于送进翻译引擎的原文顺序。顺序错了,翻译质量再高也没用------读者拿到的是"拼不回去的碎片"。把这一步做对,比在术语和润色上多花时间回报更高。

总结

  • 双栏 PDF 取文串行有两个独立成因:内容流乱、排序规则错。本文给出了区分方法和各自的解法。
  • 实测显示:真实文档的内容流常常本来就是对的,强行按 y 排序平均引入 45.7% 的跨栏错跳;内容流被打乱时自然流则完全失效(准确率 0%)。
  • 分栏感知重排在两种场景下都表现稳定(0.6% / 0% 错跳,83.3% 准确率),是内容流不可信时的可靠选择。
  • 核心技巧:用 span 级坐标做投影检测 gutter,避开块级合并的干扰。

如果你的场景是"整篇文档翻译、且要求版面不变",这类版面分析工作交给专门的工具会更省事------把文件整篇上传、选好目标语言后直接下载,取文顺序和版面还原由工具内部处理,不需要自己维护一套 PDF 解析逻辑。自己写代码的价值在于可控性:当你要处理的是批量文档、或者需要把取文结果接入自己的术语库和质检流程时,上面这套几何重排逻辑可以直接复用。

标签:PDF解析、Python、pymupdf、文档处理、排版分析

相关推荐
赵民勇1 小时前
Python collections.ChainMap 详解
python
三十岁老牛再出发1 小时前
9月16日总结
python·深度学习·机器学习
GlobalInfo1 小时前
机器人力控采集芯片全球市场深度分析:人形机器人分布式力感知下的24位Delta-Sigma与多通道同步博弈
大数据·人工智能·ai·机器人
俊哥V1 小时前
每日 AI 研究简报 · 2026-09-17
人工智能·ai
笨蛋©1 小时前
2026制造实战:气泡图 (Balloon Drawing) 自动生成与质量检验计划优化指南
ai·数字化·质量管理·制造业·fai
西索斯coding1 小时前
GPT-5.6-Luna 接入 Cline 教程:base_url 配置、max_tokens 上限与 ZDR 请求头写法
大数据·人工智能·gpt·ai
程序员无隅1 小时前
Harness Engineering 阶段 4:用任务边界、功能清单和运行反馈约束 Agent
ai
计算机源码社1 小时前
基于大数据的全球温室气体排放燃料结构与碳强度评估研究-基于Spark的全球温室气体排放多维度检测与评估分析
大数据·hadoop·python·数据挖掘·数据分析·spark·毕业设计
eybk1 小时前
用Kivy制作手机相片分类局域网传送工具,还能传输数据库文件
开发语言·python