阅读顺序与 XY-cut — 以自然顺序提取多栏 PDF
多栏 PDF —— 学术论文、教科书、杂志文章、政策简报 —— 会让绝大多数提取工具绊倒。朴素的从上到下扫描会先拿第 1 栏的一个词、再拿第 2 栏的一个词、再回到第 1 栏,最后产出像 accompaally (第 1 栏的 "accompa" 拼上第 2 栏的 "ally") 这样的乱码。
PDF Oxide 使用 XY-cut 算法检测分栏,并自动生成自然阅读顺序。自 v0.3.34 起还增加了对稀疏版面 (版权页、扉页) 的误判防护,并能正确处理正文中内嵌表格的混合版面。
快速示例
提取默认就是分栏感知的,无需额外标志:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("academic-paper.pdf")
text = doc.extract_text(0)
# 每栏内部从上到下读取,不会交错。
Rust
use pdf_oxide::PdfDocument;
let mut doc = PdfDocument::open("academic-paper.pdf")?;
let text = doc.extract_text(0)?;
JavaScript / TypeScript (Node)
const { PdfDocument } = require("pdf-oxide");
const doc = new PdfDocument("academic-paper.pdf");
const text = doc.extractText(0);
doc.close();
JavaScript (WASM)
import { WasmPdfDocument } from "pdf-oxide-wasm";
const doc = new WasmPdfDocument(bytes);
console.log(doc.extractText(0));
doc.free();
Go
doc, _ := pdfoxide.Open("academic-paper.pdf")
defer doc.Close()
text, _ := doc.ExtractText(0)
fmt.Println(text)
C#
using PdfOxide;
using var doc = PdfDocument.Open("academic-paper.pdf");
Console.WriteLine(doc.ExtractText(0));
XY-cut 做了什么
XY-cut 算法沿着空白槽交替做垂直与水平切分,将页面递归分成矩形区域:
- 把所有字符投影到 X 轴。若出现又高又宽的纵向间隙 (分栏槽),就按该 X 坐标把页面切成两块区域。
- 在每块区域内投影到 Y 轴,按水平方向的槽 (段落间隙、小节边界) 进行切分。
- 递归直到叶子区域不再有明显的槽 —— 这些就是最小块。
- 按从上到下、从左到右的顺序序列化这些块。
这与人阅读的方式一致:第 1 栏从上到下,然后第 2 栏从上到下,最后读整页宽的页脚。
何时触发 XY-cut
当 extract_text 检测到多栏版面时,XY-cut 会自动运行。以下情况会 跳过:
- 单栏页面 (未找到纵向槽,使用默认的按行排序)
- 每个疑似分栏少于约 10 个文本 span 的稀疏页面 —— 通常是扉页或版权页,两个 X 中心峰只是伪影而非真实分栏 (v0.3.34 已修复)
常规情况下无需配置。若想强制某种模式,见下文「退出」部分。
v0.3.34 修复了什么
未打标签的多栏 PDF 输出交错的问题
在未打标签的多栏 PDF (学术教科书、遗传学参考书) 上,extract_text 之前会先在 extract_spans() 中应用 XY-cut,再在 extract_text_with_options 中用按行排序重新排列结果,导致分栏结构被破坏。结果就是 accompaally 之类的乱码片段。
修复:真正多栏的页面上不再做按行重排。已在 Hartwell Genetics、Murphy ML 和 Kandel Neural Science 等教科书上验证输出干净。
正文内嵌表格的页面
混合版面 (正文中内嵌表格) 可能会迷惑分栏检测器,因为用 tab 撑开的表格行填满了分栏槽。修复:
- 宽 span (超过区域宽度的 55%) 会被排除在投影密度之外 —— tab 撑开的行不再遮蔽分栏槽。
- 单字符 span (如
G、T这类表格单元格值) 会被排除在投影之外,避免跨槽散落。 - 覆盖度按字符数估计计算,而非使用原始 bbox 宽度,因此 tab 撑开的行不再伪装成密集正文。
稀疏版面下的误判
版权页、扉页、版权说明可能每「栏」只有 7–10 个 span,却出现两个 X 中心峰。它们不再被当作多栏处理,避免了 XY-cut 把同一行内两半位于不同 X 位置的句子切开的问题。
按栏的结构化访问
在 extract_text 下一层,可以取得带相同分栏顺序的单词或字符级数据:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("paper.pdf")
for w in doc.extract_words(0):
print(f"{w.text} ({w.x0:.0f},{w.y0:.0f})")
Rust
let mut doc = PdfDocument::open("paper.pdf")?;
for w in doc.extract_words(0)? {
println!("{} ({:.0},{:.0})", w.text, w.x0, w.y0);
}
Go
doc, _ := pdfoxide.Open("paper.pdf")
defer doc.Close()
words, _ := doc.ExtractWords(0)
for _, w := range words {
fmt.Printf("%s (%.0f,%.0f)\n", w.Text, w.X0, w.Y0)
}
C#
using var doc = PdfDocument.Open("paper.pdf");
// Node/C# 返回 (text, x, y, w, h) 形式的行:
var lines = doc.ExtractTextLines(0);
foreach (var (text, x, y, w, h) in lines)
Console.WriteLine($"{text} ({x:F0},{y:F0})");
每个单词 / 行都携带边界框,所以你可以按栏分组,必要时用自定义策略自行重排 (例如阿拉伯语版面先读右栏)。
手动检测多栏页面
若要在提取前根据页面是否多栏进行分支:
Python
from pdf_oxide import PdfDocument
doc = PdfDocument("mixed.pdf")
for i in range(doc.page_count()):
words = doc.extract_words(i)
# 启发式:不同的 X 中心聚类
x_centers = {round((w.x0 + w.x1) / 2 / 50) * 50 for w in words}
if len(x_centers) >= 2:
print(f"Page {i}: likely multi-column ({len(x_centers)} X-centers)")
生产用途下还是优先使用 extract_text,把判断交给「XY-cut + 稀疏版面保护」的组合。
退出或自定义顺序
如果需要按位置顺序的原始 span (例如自研版面引擎),使用 extract_chars 或 extract_words —— 它们返回带边界框的记录,可在其上套用你自己的排序:
Python
chars = doc.extract_chars(0)
# 从上到下,再从左到右 —— 忽略分栏
chars_sorted = sorted(chars, key=lambda c: (-c.y, c.x))
Rust
let mut chars = doc.extract_chars(0)?;
chars.sort_by(|a, b| b.y.partial_cmp(&a.y).unwrap()
.then(a.x.partial_cmp(&b.x).unwrap()));
相关页面
- 文本提取 — 完整提取 API
- 提取配置文件 — 按文档类型调优空格检测
- 从 PDF 提取表格 — 结构化表格输出
- Changelog — v0.3.34 多栏与混合版面修复