系列文章:(一)PDF 三层实战 | (二)PPT/手写笔记 OCR | 源码:GitHub 仓库
前四层我们只关心一件事:文字是什么。
从这一层开始,要关心第二件事:文字在哪里。
每个文字块在页面上都有位置坐标 (x0, y0, x1, y1)------这就是 bbox(bounding box,坐标框) 。有了它,才能解决两个经典难题:
- 多栏排版 :报纸/杂志风格,
extract_text()会把左栏右栏读串 - 带水印 PDF:半透明水印干扰 OCR,要在像素/对象阶段就拦掉
场景 1:多栏排版 PDF
痛点:文字读串了
看这个双栏页面:
┌────────────────────────────────┐
│ 左栏 右栏 │
│ 苹果是一家科技公司, 橙子富含维生素C,│
│ 总部位于库比蒂诺, 是很多人喜爱的水果,│
│ 由乔布斯创立。 常用于榨汁。 │
└────────────────────────────────┘
用 page.get_text() 提取,拿到的是:
苹果是一家科技公司,橙子富含维生素C,总部位于库比蒂诺,是很多人喜爱的水果,由乔布斯创立。常用于榨汁。
两栏内容完全搅在一起。为什么?因为 PDF 内部存储文字接近"绘制顺序",不是阅读顺序------它根本不知道"栏"的存在。
人类怎么一眼看出这是两句?因为眼睛在看每行文字的起点位置(x 坐标) :左栏文字都从一个 x 位置附近开始,右栏是另一个。
程序无法判断语义,只能靠坐标------这就是引入 bbox 的动机。
bbox 从哪来?
PyMuPDF 的 page.get_text("blocks") 返回 7 元组:
arduino
(x0, y0, x1, y1, "文字内容", 块编号, 块类型)
x0, y0= 左上角;x1, y1= 右下角- 块类型:
0= 文字块,1= 图片块(图片块第 5 位不是文字!)
算法:递归 XY-cut
怎么自动还原阅读顺序?工业界经典算法 XY-cut,三步逻辑:
markdown
1. X 切:把块往 y 轴投影,找"横贯整页的空白带" → 上下分段
2. Y 切:在每段内往 x 轴投影,找"纵贯的空白带" → 左右分栏
3. 交替递归:切不动(横竖都无空白带)→ 原子区域,按 (y0, x0) 排序
直观理解:
- 双栏页面:Y 切能在中线附近找到纵向空白带 → 切成左栏、右栏两组
- 跨栏大标题:X 切先把它单独切成"上段",排在两栏之前
- 一段连续文字:横竖都切不开 → 原子区域,内部按先上后下排序
"找空白带"的本质是区间重叠分组:把块按某轴排序后扫描,下一个块的起点落在当前组区间结束之前 → 同组;否则中间有空白带 → 封组开新组。
代码实现
新建 parsers/pdf_ocr_bbox.py:
python
"""
第五层 · 场景 1:多栏排版 PDF --- XY-cut 生产级阅读顺序还原
"""
import pymupdf
def group_by_axis(blocks: list, sort_idx: int, end_idx: int, margin: float = 5.0) -> list:
"""
将块列表按某一轴分组:投影区间重叠的块归为一组
:param sort_idx: 排序用的坐标下标(y 轴=1 即 y0;x 轴=0 即 x0)
:param end_idx: 区间结束下标(y 轴=3 即 y1;x 轴=2 即 x1)
:param margin: 区间腐蚀量(实测修正,见翻车 1):块 bbox 自带内边距,比实际
文字大几个点,相邻块会"虚重叠";先把区间两头各缩 margin 个点
再判重叠,虚重叠消失、真实空隙才露出来
:return: [[组1的块...], [组2的块...], ...] 组与组之间存在空白带
"""
# 先排序,再逐块扫描,才能知道"下一个块"是谁
blocks = sorted(blocks, key=lambda b: b[sort_idx])
groups = []
current = [blocks[0]]
max_end = blocks[0][end_idx] # 当前组在该轴上到达的最远位置
for b in blocks[1:]:
# b 的起点落在当前组区间结束之前(先腐蚀 margin)→ 真实重叠 → 同组
# 否则中间有空白带 → 封组,开新组
if b[sort_idx] < max_end - margin:
current.append(b)
max_end = max(max_end, b[end_idx]) # 组的区间可能被新块撑大
else:
groups.append(current)
current = [b]
max_end = b[end_idx]
groups.append(current) # 最后一组别忘收尾
return groups
def xy_cut_order(blocks: list, mode: str = "x", margin: float = 5.0) -> list:
"""
递归 XY-cut:输入带 bbox 的块列表,输出按阅读顺序排列的块列表
:param mode: "x"=找横向空白带(上下分段);"y"=找纵向空白带(左右分栏)
:param margin: 区间腐蚀量,透传给 group_by_axis
"""
# 递归出口:没块或只剩一个块 → 不用再切
if len(blocks) <= 1:
return blocks
if mode == "x":
# X 切:按 y 轴分组(y0 下标 1,y1 下标 3)
groups = group_by_axis(blocks, sort_idx=1, end_idx=3, margin=margin)
if len(groups) == 1:
# 横着切不开 → 换方向,尝试 Y 切(找栏间距)
return xy_cut_order(blocks, mode="y", margin=margin)
result = []
for g in groups: # 上段 → 下段
result.extend(xy_cut_order(g, mode="y", margin=margin)) # 段内继续左右分栏
return result
else:
# Y 切:按 x 轴分组(x0 下标 0,x1 下标 2)
groups = group_by_axis(blocks, sort_idx=0, end_idx=2, margin=margin)
if len(groups) == 1:
# 横竖都切不开 → 原子区域,按 (y0, x0) 排序:先上后下、先左后右
return sorted(blocks, key=lambda b: (b[1], b[0]))
result = []
for g in groups: # 左栏 → 右栏
result.extend(xy_cut_order(g, mode="x", margin=margin)) # 栏内继续上下分段
return result
def load_pdf_multicolumn(pdf_path: str, page_marker: str = "页") -> str:
"""多栏排版 PDF 专用解析器(XY-cut 阅读顺序还原)"""
pdf_data = pymupdf.open(pdf_path)
page_results = []
for i, page in enumerate(pdf_data):
raw_blocks = page.get_text("blocks")
valid_blocks = []
for b in raw_blocks:
# type 0=文字块(直接保留);type 1=图片块(第 5 位不是文字)
# 图片块统一替换成占位符 [图片],让它参与排序"占住位置",
# 这样图片前后的文字顺序才不会被搞乱
if b[6] == 0:
valid_blocks.append(b)
elif b[6] == 1:
valid_blocks.append((b[0], b[1], b[2], b[3], "[图片]", b[5], 1))
ordered = xy_cut_order(valid_blocks, mode="x") # 从 X 切开始
# 按排好的顺序取每块的第 5 位(文字),过滤空块,join 起来
texts = [str(b[4]).strip() for b in ordered if str(b[4]).strip()]
page_content = f"--- 第 {i + 1} {page_marker} ---\n" + "\n".join(texts)
page_results.append(page_content)
return "\n\n".join(page_results)
if __name__ == "__main__":
result = load_pdf_multicolumn("../test_files/我国省级政府在线政务服务渠道发展水平与地域分异研究_上官莉娜.pdf")
print(result[:5000])
运行结果

三个设计细节
| 细节 | 处理 | 原因 |
|---|---|---|
| 图片块 | 替换成 [图片] 占位符参与排序 |
占住位置,图片前后文字顺序才不乱 |
| 跨栏大标题 | X 切先切成独立"上段" | 标题自然排在两栏之前 |
| 递归出口 | len(blocks) <= 1 直接返回 |
原子区域无需再切 |
生产级补充:中部通栏的图/表格,MVP 用"跨栏块置顶"兜底;更严谨的做法是完整递归 XY-cut,能把它放回正确位置。
代码里margin=5.0不是拍脑袋加的------块 bbox 有内边距会导致相邻块"虚重叠",把左右栏焊死;腐蚀量还得卡在"行隙 < margin < 栏隙"之间。这是两次实测踩坑后定下的,详见下方翻车 1 / 翻车 2。
场景 2:带水印 PDF
先分类:水印有两种存在形式
| 形式 | 来源 | 本质 | 解法 |
|---|---|---|---|
| A:矢量水印 | Word 插入水印后导出 PDF | 页面图层上的对象,有 bbox/字号/颜色 | 按特征精确过滤 ✅ |
| B:烧死的水印 | 盖章/水印纸扫描成图片 PDF | 和正文熔在同一张图的像素里 | 图像预处理抹掉 ⚠️ |
两种情况解法完全不同,所以第一个问题是:怎么判断手里是哪种?
答案还是 bbox 那套:get_text("blocks") 能提取到的都是文字对象;提取不到但看得见的一定在图片里。有文字层 → 路线 A;没文字层(扫描件)→ 路线 B。
路线 A:矢量水印的特征过滤
人类一眼认出水印靠四个特征:颜色浅、字号大、斜 45°、一页重复好几次。程序要把它们变成数字。
get_text("blocks") 拿不到颜色和字号,但 get_text("dict") 可以------它返回更细的 span ,每个 span 带 color(sRGB 整数)和 size(字号)。浅灰色的 sRGB 整数偏大(0x000000 是黑,0xFFFFFF 是白),拆出 RGB 求平均亮度即可量化。
路线 B:烧死水印的像素过滤
扫描件里,正文黑字灰度接近 0,浅色水印灰度接近 200+。二值化(大于阈值→白,小于等于→黑)能让水印"隐形",再喂 OCR。
代价:灰色页眉、浅红公章等浅色正常内容会被一起抹掉。所以阈值默认 190(只抹"很浅"的),并做成可配置参数。
为什么"先去水印再 OCR"而不是"OCR 完再清洗文本"?
想象 OCR 已经把斜着的水印识别出来了,混在正文行里。这时想用规则删掉它------但 OCR 出的水印文字可能识别错("机密"变"札宓"),规则还能可靠匹配吗?
错误在流水线中出现得越早、被拦截得越早,处理代价越小。 水印在像素/对象阶段去掉,OCR 连见都没见到它;等 OCR 输出再删文本,面对的是已经识别错的垃圾------删不干净,还会误删正文。(呼应第一篇主旨:Garbage in, garbage out。)
代码实现
新建 parsers/pdf_ocr_sy.py:
python
"""
第五层 · 场景 2:带水印 PDF --- 双路线去水印
"""
import pymupdf
from rapidocr_onnxruntime import RapidOCR
import numpy as np
import cv2
from collections import Counter
def is_watermark_span(span: dict,
repeat_count: int,
color_threshold: int = 160,
size_threshold: float = 40.0,
min_repeat: int = 3) -> bool:
"""
判断单个 span 是否为水印
:param repeat_count: 该 span 文本在本页出现的次数(外层统计传入)
"""
text = span.get("text", "").strip()
if not text:
return True # 空 span 直接丢弃
# ── 特征①:颜色浅(强特征,单独成立)──
# color 是 0xRRGGBB,位运算拆通道;亮度=三通道平均,越大越浅
color = span.get("color", 0)
r = (color >> 16) & 255
g = (color >> 8) & 255
b = color & 255
brightness = (r + g + b) / 3
if brightness > color_threshold: # 黑字 color=0 → 亮度 0,绝不误杀
return True
# ── 特征②+③:弱特征必须组合(实测翻车后的修正版)──
# 中英文混排时 "RAG" 因字体切换总独立成 span、一页出现 5 次,
# 按"重复即水印"会误杀正文;黑色大标题字号大但也是正文。
# 所以:超大字号 AND 短文本重复,同时命中才判水印
is_huge = span.get("size", 0) > size_threshold
is_repeat = len(text) <= 10 and repeat_count >= min_repeat
if is_huge and is_repeat:
return True
# 进阶扩展:line["dir"] != (1, 0) 说明文字被旋转,
# 45° 水印的 dir ≈ (0.707, 0.707),可再加一条旋转判断
return False
def remove_watermark_by_threshold(img: np.ndarray, threshold: int = 190) -> np.ndarray:
"""灰度二值化:浅色像素(水印)→ 白,深色像素(正文)→ 黑"""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# THRESH_BINARY:大于阈值 → 255(白),小于等于 → 0(黑)
# 极性搞反会把正文刷成背景------二值化最经典的坑
_, binary = cv2.threshold(gray, threshold, 255, cv2.THRESH_BINARY)
return cv2.cvtColor(binary, cv2.COLOR_GRAY2BGR) # 转回三通道,RapidOCR 才能吃
def extract_text_filter_watermark(page) -> str:
"""有文字层的页面:get_text("dict") → 过滤水印 span → 拼接正文"""
data = page.get_text("dict")
# 第一遍:收集所有 span,统计每个文本在本页出现的次数
# (特征③依赖全局统计,必须先收集再判断,不能边遍历边过滤)
all_spans = []
for block in data["blocks"]:
if block.get("type") != 0: # 只看文字块
continue
for line in block.get("lines", []):
for span in line.get("spans", []):
all_spans.append(span)
counter = Counter(s["text"].strip() for s in all_spans if s["text"].strip())
# 第二遍:按原始顺序过滤水印 span,拼接正文
lines_text = []
for block in data["blocks"]:
if block.get("type") != 0:
continue
for line in block.get("lines", []):
parts = [s["text"] for s in line.get("spans", [])
if not is_watermark_span(s, counter.get(s["text"].strip(), 0))]
if parts:
lines_text.append("".join(parts))
return "\n".join(lines_text)
def load_pdf_watermark(pdf_path: str,
text_threshold: int = 500,
gray_threshold: int = 190,
render_zoom: float = 2.0) -> str:
"""
带水印 PDF 专用解析器(双路线)
:param gray_threshold: 路线 B 二值化阈值(只抹浅色水印)
:param render_zoom: 路线 B 渲染放大倍数(2.0 = 144 DPI)
"""
pdf_data = pymupdf.open(pdf_path)
ocr = RapidOCR()
page_results = []
for i, page in enumerate(pdf_data):
text = page.get_text()
if len(text) > text_threshold:
# 路线 A:有文字层 → span 特征过滤,精确无损
page_content = extract_text_filter_watermark(page)
else:
# 路线 B:扫描件 → 渲染 → 二值化抹水印 → OCR
pix = page.get_pixmap(matrix=pymupdf.Matrix(render_zoom, render_zoom))
img = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, pix.n)
img = remove_watermark_by_threshold(img, gray_threshold)
result, _ = ocr(img)
ocr_lines = []
if result:
for line in result:
item = line[1]
ocr_lines.append(item[0] if isinstance(item, tuple) else str(item))
page_content = "\n".join(ocr_lines)
page_results.append(f"--- 第 {i + 1} 页 ---\n{page_content}")
return "\n\n".join(page_results)
if __name__ == "__main__":
# 路线 A:Word → 设计 → 水印 → 自定义水印"机密" → 导出 PDF
# result = load_pdf_watermark("../test_files/RAG-水印.pdf")
# 路线 B:把带水印的纸打印出来再扫描,或输出成图片版 PDF
result = load_pdf_watermark("../test_files/烧死的水印.pdf", gray_threshold=190)
print("路线B:", result[:1000])
运行结果
线路A

线路B

实测翻车记录(本文最值钱的部分)
翻车 1:XY-cut 把左右栏"焊死"了------区间腐蚀救场
单栏论文跑通后,换一篇真正的双栏论文《我国省级政府在线政务服务渠道发展水平与地域分异研究》验证,第 3 页翻车------左右栏文字交错输出,一行"左栏半句"接一行"右栏半句"。
做控制实验(打印第 3 页全部 87 个块的坐标),真凶有两个,都指向同一件事:pymupdf 的块 bbox 自带内边距,比实际文字大几个点,导致相邻块"虚重叠" 。
| # | 发现 | 后果 |
|---|---|---|
| 1 | 页头 URL 块 y=47,67、正文首行 y0=64,"重叠"仅 3pt(假的) | X 切找不到横贯整页的空白带 → 切不出上下段 |
| 2 | 左栏止于 x1=287、右栏始于 x0=308,真实栏间隙 21pt;URL 块 x=273,322 一头扎进间隙 | Y 切时 URL 块和左右栏都重叠 → 左右栏并进同一组 → 整页退化成按 (y0,x0) 排序 → 交错 |
修复:区间腐蚀(margin erosion)。 分组前把每块区间两头各缩 margin=5pt,重叠判断从 b[sort_idx] < max_end 改成 b[sort_idx] < max_end - margin。3pt 的虚重叠被腐蚀掉,21pt 的真实栏间隙毫发无损。
| 验证项 | 结果 |
|---|---|
| 政务服务论文第 3 页(此前交错) | ✅ 左栏从"自2016年..."连续读到"...参考借鉴。",右栏整体在后,顺序完全正确 |
| 单栏论文(回归测试) | ✅ 与修复前一致,无损 |
顺带纠正一个误判: 之前以为跑通的那篇论文是双栏,实测它正文块横贯整页(x1≈531,页宽 595),右上角只是页眉小字------其实是单栏。单栏页 X 切、Y 切都切不开,退化成按 (y0,x0) 排序,XY-cut 天然兼容单栏、不会切坏。所以它的"回归"验证的是"单栏不被切坏",真正考验分栏能力的是双栏的政务服务论文。
翻车 2:三栏却变成"横着读"------margin 的隐含前提
两栏搞定,那三栏呢?报纸、学术海报、部分年报都是三栏。先说结论:group_by_axis 是"按区间重叠分组"、不是二分,Y 切切出几组就递归处理几组,双栏只是"组数=2"的特例,理论上三栏、四栏都不用改代码。
实测却翻车了。 用 pymupdf 造一个三栏测试 PDF(通栏标题 + 三栏各 8 行,每行带"【N栏-NN】"编号),第一次行距设了 22pt,输出变成"横向逐行读":【1栏-01】【2栏-01】【3栏-01】【1栏-02】......
打印块坐标做控制实验:造的 PDF 块 bbox 紧凑、没有虚重叠,行隙 10pt 大于腐蚀量 5pt → X 切把每一行都切成"横贯整页的横带",每个横带内再左右排序 → 变成表格式横读。
修正: 按真实文档比例重造(行距 14pt → 行隙约 4pt;栏隙 35pt)→ PASS ✅。输出顺序:标题 → 1 栏 18 行 → 2 栏 18 行 → 3 栏 1~8 行。
这次翻车暴露了 margin 腐蚀的隐含前提,正好和翻车 1 的"5 是实测平衡点"闭环:
css
行隙 < margin < 栏隙
- 真实文档天然满足:行块 bbox 带内边距 → 行间虚重叠约 8pt(腐蚀后切不开行);栏隙 15~30pt(腐蚀后切得开栏)。
- 程序生成的 PDF 可能违反:bbox 紧凑、行距偏大 → 行隙超过 margin,行被当成"段"切开。
为什么 margin 不调大到 10,把页脚页码也切出去? 实测同栏相邻行的虚重叠有 7~8pt)"和"跨区虚重叠(3pt)"之间------5 是实测平衡点。(遗留小瑕疵:页脚页码与左栏末行虚重叠 8pt,超过腐蚀量,会贴在左栏末尾,对 RAG 切片无害,进阶可用"页脚检测"处理。)
结论:XY-cut 能处理任意栏数,但用之前要先确认文档满足 行隙 < margin < 栏隙------这是排版特征,不是代码能自动兜底的。
翻车 3:去了水印,识别反而变差了?
路线 B 第一版跑完:水印确实没了,但正文一堆形近字错误,甚至整行丢失:
| 原文 | OCR 识别结果 | 错误类型 |
|---|---|---|
| 第一步------文档解析 | 第-步一-文精解析 | "一"→"-";"档"→"精" |
| 打个比方 | 打个比力 | "方"→"力" |
| 混在一起 | 坚混作一起 | "在"→"坚"、"一"→"作" |
| 蔬菜 | 藏菜 | "蔬"→"藏" |
| RAG 的流程大家都熟悉:文档 → 切片 → ... | (整行丢失) | 疑似与行内"→"有关 |
三个嫌疑:二值化副作用、渲染分辨率不足、特殊符号难识别。
对照实验:把 DPI 从 72 提到 144(render_zoom=2.0),其他不动:
| 对比项 | 72 DPI(改前) | zoom=2.0(改后) |
|---|---|---|
| 含"→"的那一行 | 整行丢失 | ✅ 完整识别(含箭头) |
| 第一步------文档解析 | 第-步一-文精解析 | ✅ 基本正确 |
| 打个比方 | 打个比力 | ✅ 正确 |
| 混在一起的蔬菜 | 坚混作一起的藏菜 | ✅ 正确 |
| 表格页(全景图表) | 基本没识别出来 | ✅ 行列内容都出来了 |
| 仍存在的错误 | ------ | "分拣"→"分抹"、"检索"→"检素" 等少量形近字 |
结论:渲染分辨率才是主因,二值化的副作用比预想小得多。 6 处错误修好 5 处,连丢失的整行和整个表格都找回来了。剩余形近字错误是 OCR 引擎本身的水平,属于正常损耗。
排查顺序的经验:先提 DPI 做对照,再怀疑预处理------别一上来就怪二值化。
翻车 4:正文里的 "RAG" 被当水印误杀了
路线 A 第一版跑完:"机密"出现 0 次,差点宣布过滤成功。做控制实验(直接 get_text 看原始数据)后真相大白:
发现 1:"机密"根本不在文字层! Word 的艺术字水印导出 PDF 后是矢量图形 而不是文字对象,get_text 从来就看不见它。"机密=0"不是被过滤了,是本来就没有。
推论:对有文字层的 PDF,Word 原生水印不会污染文本输出;路线 A 防的是"文字型水印"(少数工具生成);图形水印只有走 OCR(路线 B)时才会被渲染进图里,靠二值化抹掉。
发现 2:标题"RAG文档解析"变成了"文档解析",正文 5 处 RAG 全消失。 颜色分析排除误杀(所有文字都是纯黑 0x0)。真凶是特征③"重复出现":中英文混排时字体切换导致 "RAG" 总是独立成 span、一页出现 3+ 次 → 被当成重复水印误杀。同理被误杀的还有"→""="和项目符号。
修正:弱特征不能一票否决,必须组合。
markdown
旧规则:命中 ①浅色 / ②超大字号 / ③重复出现 任一个 → 判水印
新规则:①浅色是强特征,单独成立;
②和③是弱特征,必须同时命中(超大字号 AND 短文本重复)才判水印
修正后实测:RAG 恢复(7 次),"→""="全部找回,"机密"仍为 0。
过滤规则要同时防两种错误: 假阴性 (该删的没删)------先确认目标在原始数据里真的存在,再谈"删除成功"; 假阳性(不该删的删了)------拿原文逐词对照,尤其盯紧高频短词和小符号。
小结
| 场景 | 核心思路 | 关键函数 |
|---|---|---|
| 多栏排版 | 递归 XY-cut + 区间腐蚀还原阅读顺序 | xy_cut_order() / group_by_axis() |
| 矢量水印 | span 特征过滤(浅色=强特征) | is_watermark_span() |
| 烧死水印 | 二值化抹浅色像素 + 高 DPI 渲染 + OCR | remove_watermark_by_threshold() |
本篇核心经验:
- bbox 是布局理解的地基:程序不懂语义,只懂坐标
- 块 bbox 有内边距,相邻块会"虚重叠" :分组前要区间腐蚀,腐蚀量卡在"行隙 < margin < 栏隙"之间
- 算法的隐含前提要主动验证:XY-cut 换到三栏、或程序生成的 PDF,前提就可能被打破
- 错误拦截越早代价越小:水印要在进 OCR 之前拦掉
- 强特征单独成立,弱特征必须组合:否则误杀正文
- 先对照实验再下结论:DPI 才是识别率主因,不是二值化
- 宣称"成功"前先做控制实验:确认目标真的存在过
下一篇预告: 第六层------特殊表格/文书:发票票据的二维码解码与关键字段定位、合同签名公章处理。源码持续更新:GitHub 仓库