Python批量处理PDF踩坑实录:中文编码、字体内嵌、多栏排版与内存占用

前言

前段时间要批量处理一批多语言 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 规范里文本字符串有两种编码------PDFDocEncodingUTF-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")

顺带提醒:pdfplumberpdf.metadatapypdfreader.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、文档解析

相关推荐
服务器硬件项目经理1 小时前
第14篇_服务器主板上MOSFET的三大应用
运维·服务器·人工智能·单片机·硬件工程·硬件
3Q工具箱 - BraggTroy1 小时前
Python + PyInstaller 实战:从零打造 10 个 Windows 桌面小工具(附全部踩坑记录与核心代码)
开发语言·windows·python
一航jason1 小时前
大模型“上车“评估参数列表
人工智能·ai·aigc·ai编程·ai-native
泡海椒2 小时前
脚本包导入实战:JQuick-Java自定义包引入与别名配置教程
java·开发语言·python
zcmodeltech2 小时前
能源电力沙盘模型控制系统设计——基于STM32与Modbus RTU的“发—输—变—配—用”全链条动态展示方案
数据库·stm32·单片机·嵌入式硬件·能源
fbbqt2 小时前
Python模块与包
开发语言·python
高斯的手稿08012 小时前
丽萨单片机固件升级,裁剪hex文件
单片机·嵌入式硬件
数智化码农2 小时前
半导体制造人力资源数字化选型:适配双用工体系,兼顾合规与人效
大数据·运维·python·制造
statistican_ABin2 小时前
中国婚姻与生育观念变迁分析报告
python