前言
前段时间要批量处理一批多语言 PDF(英文行业报告、德语设备手册、中文发票混在一起),目标是先做文本提取和结构校验,再决定哪些走翻译、哪些需要 OCR。原本以为 pdfplumber + pypdf 两个库就能搞定,结果一路踩坑:提取出来全是 (cid:3)、元数据中文乱码、双栏文档左右串行、几十页的文件直接把内存顶上去。
这篇文章把四个坑的定位过程和修复代码完整记下来,代码都在 Python 3.13 + pdfplumber 0.11.10 + pypdf 6.18.0 上实测跑过,样本用的是真实的英文市场报告 PDF 和一份 36 页的产品手册。
环境准备
bash
pip install pdfplumber==0.11.10 pypdf==6.18.0
- Python 3.13
- pdfplumber 0.11.10(负责版式/字符级提取)
- pypdf 6.18.0(负责页面级读写、拆分、压缩)
- 系统:Windows / macOS 均可
坑一:提取出来的文本全是 (cid:3) 或控制字符
第一个坑来得很快。用 pdfplumber 打开那份英文报告,extract_text() 返回的文本长这样:
text
06
China(cid:3)Artificial(cid:3)Intelligence(cid:3)Framework(cid:3)
Market(cid:3)Research(cid:3)Report
单词之间本该是空格,全变成了 (cid:3)。换成 pypdf 的 extract_text() 试试,结果也不对,只是表现形式不同:
text
'China\x03Artificial\x03Intelligence\x03Framework\x03\n'
原因 :这份 PDF 用的是子集化的字体(LGRGCC+Noto Sans Regular),而空格这个字形没有建立起正确的 ToUnicode 映射。于是提取时,PDF 里的字符码 3 无法映射回 Unicode 码点,pdfplumber 只能输出占位符 (cid:3),pypdf 则直接吐出了原始控制字符 \x03。
验证方法 :看一眼字符级数据,字体名后面的 + 前缀就是子集字体的标志:
python
import pdfplumber
with pdfplumber.open("report.pdf") as pdf:
chars = pdf.pages[0].chars
print(len(chars))
for c in chars[:8]:
print(round(c["x0"]), round(c["top"]), repr(c["text"]), c.get("fontname"))
输出:
text
1392
227 47 'C' LGRGCC+Noto Sans Regular
232 47 'h' LGRGCC+Noto Sans Regular
...
247 47 '(cid:3)' LGRGCC+Noto Sans Regular
修复方案 :不要指望单个库能搞定,两个输出都要归一化。(cid:N) 和控制字符(\x00-\x08)统一替换为空格即可------因为在这个样本里,码点 3 对应的就是空格:
python
import re
from pypdf import PdfReader
import pdfplumber
CID_PAT = re.compile(r"\(cid:\d+\)")
CTRL_PAT = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
def clean_text(text: str) -> str:
"""归一化提取文本:处理 cid 占位符与未映射的控制字符
Args:
text: pdfplumber / pypdf 的原始输出
Returns:
清洗后的文本
"""
if not text:
return ""
text = CID_PAT.sub(" ", text) # (cid:3) -> 空格
text = CTRL_PAT.sub(" ", text) # \x03 -> 空格
text = re.sub(r"[ \t]{2,}", " ", text)
return text
with pdfplumber.open("report.pdf") as pdf:
raw = pdf.pages[0].extract_text() or ""
print(clean_text(raw)[:100])
清洗后输出正常:
text
06
China Artificial Intelligence Framework
Market Research Report
As shown in the above image, this survey covers seven industries. Quotas were applied across
注意:
(cid:N)里的 N 不一定是空格。如果替换后语义明显不通,说明该字体的映射缺失更严重,这时候应该转成 OCR 路线,而不是硬猜码点含义。
坑二:元数据里的中文乱码
第二个坑在 metadata。读一份中文文件时 meta["/Title"] 拿到的是 bytes,直接 print 出来是乱码:
python
from pypdf import PdfReader
r = PdfReader("report.pdf")
print(repr((r.metadata or {}).get("/Title")))
# b'\xfe\xff\x00N\x00o\x00t\x00i\x00c\x00e'
原因 :PDF 规范里文本字符串有两种编码------PDFDocEncoding 和 UTF-16BE。带 \xfe\xff 前导 BOM 的就是 UTF-16BE。pypdf 在部分字段上不会自动帮你解码,需要自己处理。
修复方案:按编码顺序试探,并去掉 BOM:
python
def decode_pdf_text(raw) -> str:
"""解码 PDF 文本字符串(兼容 PDFDocEncoding / UTF-16BE)
Args:
raw: metadata 中的原始值,可能是 str / bytes / NullObject
Returns:
解码后的字符串
"""
if raw is None or not isinstance(raw, bytes):
return "" if raw is None else str(raw)
for enc in ("utf-16-be", "utf-16", "utf-8", "latin-1"):
try:
return raw.decode(enc).lstrip("\ufeff")
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="replace")
顺带提醒:pdfplumber 的 pdf.metadata 和 pypdf 的 reader.metadata 返回的键名不完全一致(前者无 / 前缀),写通用工具时要做兼容。
坑三:双栏排版提取后左右串行
多栏文档是文本提取的老大难。默认的 extract_text() 按 y 坐标从上到下、同一行按 x 排序输出,于是双栏论文会变成"左栏第一行 + 右栏第一行"这种交错结果。
修复方案:先裁切再提取,用页面宽度把左右两栏切开,各自独立提取:
python
import pdfplumber
def extract_two_column(page) -> str:
"""按左右分栏提取文本,避免多栏串行
Args:
page: pdfplumber 的 Page 对象
Returns:
先左栏后右栏的完整文本
"""
w, h = page.width, page.height
mid = w / 2
left = page.crop((0, 0, mid, h)).extract_text() or ""
right = page.crop((mid, 0, w, h)).extract_text() or ""
return left + "\n" + right
实测在这份报告上,整页提取得到 2408 个字符,按 crop 左右切开后分别是 1569 和 866,合计与整页一致,但阅读顺序变成了"先读完左栏再读右栏",符合人的阅读习惯。
需要注意的是,mid = w / 2 只适用于标准双栏。三栏或带侧边栏的版式,得先用 page.vertical_edges 拿到实际分栏线的位置,再动态计算切割点:
python
# 用检测到的竖直分隔线作为切分依据,比硬编码 1/2 更稳
edges = [round(e["x0"]) for e in page.vertical_edges if 0.2 * w < e["x0"] < 0.8 * w]
坑四:批量处理时内存被顶满
最后一个坑是量上来之后才暴露的。用列表推导一次性把所有页都转成文本,文件一多内存就压不住了:
python
# 反面写法:所有页面文本同时驻留内存
with pdfplumber.open(path) as pdf:
all_text = [p.extract_text() or "" for p in pdf.pages] # 页数一多就危险
用 tracemalloc 量一下就很直观。改成逐页处理、处理完即释放后,实测峰值只有 4.32 MB:
python
import tracemalloc
import pdfplumber
def stream_text(path: str, limit: int | None = None):
"""逐页生成文本,避免整本文本同时驻留内存
Args:
path: PDF 路径
limit: 最多处理多少页(None 表示全部)
Yields:
每一页的文本
"""
with pdfplumber.open(path) as pdf:
for i, page in enumerate(pdf.pages):
if limit is not None and i >= limit:
break
yield page.extract_text() or ""
# 单页对象超出作用域即可被回收,不要在外部持有 page 引用
total = 0
for text in stream_text("report.pdf", limit=10):
total += len(text)
print("chars:", total)
另外两个实用做法:
- 只读需要的页 :pypdf 的
PdfReader是惰性的,reader.pages[i]只在访问时才解析,不要提前把所有页add_page到一个PdfWriter里。 - 单页另存做切片 :只需要某几页时,用
PdfWriter单独写出一页,比整本载入再筛选省得多:
python
from pypdf import PdfReader, PdfWriter
reader = PdfReader("manual.pdf") # 36 页
writer = PdfWriter()
writer.add_page(reader.pages[0]) # 只取第 1 页
with open("page1.pdf", "wb") as f:
writer.write(f) # 实测写出 157,903 字节
踩坑速查表
| 现象 | 根因 | 修复要点 |
|---|---|---|
文本中大量 (cid:N) |
子集字体缺 ToUnicode 映射 | 正则归一化为空格;语义不通则转 OCR |
文本中混入 \x03 等控制字符 |
同上,pypdf 输出的是原始码 | 统一过滤 \x00-\x1f 区段 |
| metadata 中文乱码 | UTF-16BE + BOM 未解码 |
按 utf-16-be → utf-8 顺序试探解码并去 BOM |
| 双栏文档左右串行 | 默认按行 y/x 排序输出 | 按页面宽度 crop 分栏后再提取 |
| 批量处理内存暴涨 | 所有页面文本同时驻留 | 逐页生成器 + 惰性读取 + 单页切片 |
总结
这四个坑的共同点是:问题都不在代码逻辑,而在输入文件本身。PDF 不是一种"结构化数据格式",它更像一份打印指令,字体映射、编码方式、版式分栏都可能因生成工具而异。所以批量处理 PDF 时,"先探测、再解析"比"直接一把梭"要省事得多。
处理流程上我现在的做法是:先用字符级数据探测字体映射是否完整 → 再按版式选择分栏策略 → 最后逐页流式处理。文本还能正常提取的文件,走脚本自动化;字体映射缺失严重的,直接交给带 OCR 的文档翻译服务处理,别在本地硬啃。
如果只是想拿到一份排版完整的译文(而不是自己写解析代码),用现成的文档级翻译服务会更划算------比如 PDFTranslator(pdftranslator.org)这类工具,在服务端就完成了版式解析和翻译,输出保留原始布局,也能省掉这一整套字体与分栏的适配工作。但对需要自己掌控解析流程的场景,上面这几个坑还是绕不开。
标签:PDF处理、Python、pdfplumber、pypdf、文档解析