前言
做 PDF 批量翻译流水线时,有个问题几乎必然遇到:页眉页脚。一份 300 页的文档,页眉"XXX Annual Report"重复 300 次,每页还带一个页码。这些文字喂给翻译引擎,轻则浪费 token 和额度,重则污染译文------页码被翻成奇怪的字、版权行被断句错乱,回填后版面还会溢出。所以翻译前识别并过滤页眉页脚,是流水线的标配步骤。
本文在 3 份真实 PDF 上实测两条技术路线:固定 y 阈值过滤 vs 跨页重复内容识别,并把识别算法从 v1 迭代到 v3 的全过程(包括两次漏检)完整记录下来。
在展开之前,先说清楚这个问题的代价结构。页眉页脚对翻译流水线的影响是三个层面的:成本层面 ,每页重复一次的固定文字会成倍放大翻译调用量,实测中文报告里这部分占全文 14.76%,300 页的文档就是几十页的无效翻译;质量层面 ,页码、日期、版权符号没有上下文,机翻结果往往诡异,回填后和原文混在一起很难清理;排版层面,译文长度天然膨胀,页眉页脚回填后极容易溢出边距,把好端端的版式挤乱。所以这个步骤值得认真做,而不是随手用个正则糊弄。
实测样本与环境
- Python 3.13.12,PyMuPDF 1.28.2(
pip install pymupdf) - 样本 1:中文行业调研报告,30 页,正文 17486 字符
- 样本 2:英文打印机快速安装手册,36 页,正文 38427 字符
- 样本 3:合成年度报告,300 页,正文 248811 字符
思路:用 page.get_text("dict") 拿到每页文本块的坐标和内容,再做判定。
方法A:固定 y 阈值------简单但会误伤
最直觉的方案:页面顶部 8% 和底部 8% 带内的文本块,一律当页眉页脚删掉。实测结果:
| 样本 | 标记块数 | 误伤正文块 | 误伤文字占标记文字 |
|---|---|---|---|
| 中文报告(30页) | 61 | 3 | 1.7% |
| 英文手册(36页) | 73 | 40 | 95.6% |
| 合成报告(300页) | 300 | 0 | 0.0% |
英文手册翻车翻得很彻底:它的版式顶部是"步骤编号 + 安装步骤"的表格区,大量正文块就落在顶部带里。固定 y 阈值对"页眉页脚固定在边带"的文档有效,但无法区分"边带里的页眉"和"边带里的正文"------版式一变就崩。
方法B:跨页重复识别------三个版本的迭代
v1 严格版:页码页脚漏检
核心思想:把每页文本块做归一化(去空白、数字掩码为 #),统计每个归一化文本覆盖的页数,覆盖 ≥60% 页数且每次出现都在边带的,判为页眉页脚。
中文报告上表现完美:检出 2 条------页眉"中国人工智能框架市场调研报告 01"(29 次)和版权页脚"© 2022 Omdia..."(29 次),两者重复文字占全文 14.76%。300 页合成报告也检出页眉"Section 1: Annual Report Extract"(300 次,占 4.06%)。
但英文手册检出 0 条。它的页脚明明是每页一个页码"1"~"36"。
坑:正文数字与页码撞车
诊断发现:页码"1"、"2"..."36" 归一化后全是 #,而正文里的孤立数字块(步骤编号"1"、"4"、"23")归一化后也是 # 。统计 # 的全部出现:53 次,其中 33 次在底部边带(y0/页高=0.958,位置纹丝不动),20 次散在正文里。v1 要求"每次出现都在边带",20 次正文出现直接把页码页脚否决了。
v2 多数表决:仍不够
改成"≥90% 的出现在边带即可":33/53 = 0.623 < 0.9,还是漏。多数表决的阈值难选------放宽到 0.6,页码勉强过线,但别的文档里正文数字占比更高时又会误判。
v3 位置聚类:最终方案
换一个更强的信号:页眉页脚不仅文本重复,位置也逐页固定。把文本块按(归一化文本,y0 坐标按 2pt 取整)聚类,只要有一个簇满足"出现次数 ≥ 60% 页数"且"该簇位置在边带",就判为页眉页脚。
v3 在三份样本上全部检出:中文报告 2 条、英文手册页码页脚(33 次 @ y0/h=0.958)、合成报告页眉(300 次)。正文的孤立数字因为 y 坐标散乱,聚不成大簇,天然被排除。
几个阈值参数的选取值得单独说明。聚类容差取 2pt :实测中同一页眉逐页的 y 坐标抖动通常在 0.5pt 以内(浮点渲染误差),2pt 足以吸收抖动又不会把上下相邻的两个不同元素合并;如果文档经过旋转或裁切处理,可以适当放大。覆盖阈值取 60% 页数 :封面、目录、章节扉页通常不带页眉页脚,要求 100% 覆盖会漏检,60% 留足了余量;但对 3~5 页的短文档,这个阈值的统计意义会变弱,样本量太小时建议降阈值或人工确认。120 字符上限是防误伤护栏:正文里偶尔有跨页重复的引用语或免责声明块,这类内容通常较长且不固定在边带,长度和位置双重约束基本能把它们挡在判定之外。
消融实验:数字掩码有多重要
把数字掩码关掉重跑:中文报告只能检出 1 条(页码"01"变化的部分聚不上),英文手册 0 条,合成报告 0 条。不做数字掩码,一切含页码的页眉页脚都识别不出来。这一步是整个方案成立的前提。
警示:过滤时要按"文本+位置"匹配
v3 还有个衍生坑:判定出页脚 # 之后,如果按文本全局过滤 (把所有归一化为 # 的块都删掉),正文里的步骤编号"1"、"4"、"23"会被一起删掉------这就是把方法 A 的误伤换了个形式又引进来。正确做法是删除时匹配(文本,位置簇)这个二元组:只删落在 y0≈页脚位置的那 33 个块,正文数字一个不动。
核心代码
python
import pymupdf, re
from collections import defaultdict
def norm(t): # 归一化: 去空白 + 数字掩码
return re.sub(r"\d+", "#", re.sub(r"\s+", "", t))
def detect_header_footer(pdf_path, top=0.08, bottom=0.08,
rep=0.6, tol=2.0):
doc = pymupdf.open(pdf_path)
n, clusters = len(doc), defaultdict(list)
for pno, page in enumerate(doc):
h = page.rect.height
for b in page.get_text("dict")["blocks"]:
if b["type"] != 0: continue
txt = "".join(s["text"] for l in b["lines"]
for s in l["spans"]).strip()
if not txt: continue
key = (norm(txt), round(b["bbox"][1] / tol)) # 文本+位置聚类
clusters[key].append((b["bbox"][1], h, txt))
hf = {}
for (nt, _), occ in clusters.items():
y0, h, raw = occ[0]
if (len(occ) >= rep * n and len(raw) < 120 and
(y0 <= h * top or y0 >= h * (1 - bottom))):
hf[nt] = (len(occ), raw)
doc.close()
return hf
# 删除时按(文本+位置)匹配, 不要按文本全局匹配
实测中该函数处理 300 页文档耗时在亚秒级,对流水线性能无压力。
总结
| 方案 | 中文报告 | 英文手册 | 300页报告 | 结论 |
|---|---|---|---|---|
| A 固定y阈值 | 可用(误伤1.7%) | 误伤95.6% | 可用 | 版式敏感 |
| B v1 严格边带 | 2条/14.76% | 0条(漏) | 1条 | 页码撞车 |
| B v2 多数表决0.9 | 2条 | 0条(漏) | 1条 | 阈值难选 |
| B v3 位置聚类 | 2条 | 1条 | 1条 | 推荐 |
三条经验:① 页眉页脚的本质是"内容重复 + 位置固定",两个信号都要用;② 数字掩码是页码识别的前提;③ 过滤时按(文本+位置)匹配,防止正文数字被误删。这套方案可直接嵌入 PDF 翻译预处理流水线,也适用于其他需要清洗整页重复文字的场景。
标签:PDF翻译、PyMuPDF、Python、文本提取、效率工具