最近有位同行抱着一台笔记本找我,说手头两百多页的产品说明文档,全选复制再粘贴到编辑器里,出来的全是方块、问号和断成碎片的短句,格式更是面目全非。这个场景几乎每隔一段时间就会重演一次,因为绝大多数人对 PDF 的直觉判断是"一种排好版的文档",可它骨子里其实更接近"一叠已经画好的图纸"。所以一旦你的目标是从 PDF 里提取内容,无论是纯文字、表格还是扫描件里的字,第一件要建立起来的认知就是:你不是在"读文件",你是在"还原一次绘制过程"。
这篇文章想做的事情很具体:把 PDF 这种格式的内部构造讲清楚,讲明白为什么复制粘贴会翻车,然后把提取内容这条路从判断文件类型、选工具、写代码到校验结果,完整走一遍。读者对象也很明确,不管你是要从几百份报表里抠数据的运营,是做文档解析功能的后端,还是单纯想把手边那堆资料整理成可编辑文本的人,看完都能自己动手跑通,并且知道在什么地方容易摔跤。
1. PDF 为什么"看起来是文档,本质是画布"
1.1 一份 PDF 拆开之后到底是什么
把 PDF 文件用纯文本方式打开,你会看到一堆以%PDF-1.x开头的乱码,里面有obj、endobj、stream、endstream、xref这些关键字反复出现。这就是它的真面目:一个由"对象"按顺序堆起来的容器。容器里的对象类型不多,无非是数字、字符串、名称、数组、字典和流,而页面本身只是字典里的一个条目,告诉你这页多大、用了哪些资源、内容流在哪。
真正决定页面长什么样的是内容流(content stream)。那个流里存的不是文字,而是一串绘制指令,比如BT表示开始文本块,Tf选字体和字号,Td或Tm改变当前绘制位置,Tj和TJ把一段字符串"画"到画布上,ET结束文本块。画线、画矩形、填色也都有各自的指令。换句话说,一页 PDF 等价于一份"先移到坐标 (72, 720),用某个字体画出这几个字"的脚本。
这个认知一旦建立,很多困惑就自动消解了。PDF 里根本不存在"段落"这个概念,也不存在"表格"、"列表"这些结构,它们全都是人类看到一堆按特定位置摆放的字形后自己脑补出来的。提取工具做的事情本质上是反向工程:把绘制指令拆开,拿到每一小段文字和它的坐标,再想办法用坐标把人类的阅读逻辑拼回去。拼得好,结果干净;拼不好,就是你在编辑器里看到的那堆碎片。
1.2 文本流里的坐标游戏:段落顺序为什么会乱
很多人第一次被坑,是因为发现提取出来的文字"顺序不对":明明看着是从上到下的三段话,结果提取出来是三段话的第一行凑在一起,第二行凑在一起。原因不难理解,内容流的写入顺序和人的阅读顺序没有必然关系。生成 PDF 的软件为了优化,经常按"字体分组"或者"对象复用"来组织绘制指令,先把所有用同一字体的内容画完,再画另一种字体。
这就意味着,一个粗暴的提取器如果只是"按流里的顺序把字符串拼起来",得到的文本在逻辑上是错乱的。靠谱的库会在背后做一层版面分析:把所有文字块连同坐标一起拿出来,按纵坐标聚类成行,行内再按横坐标排序,然后从上到下输出。这也是为什么同一个 PDF 用不同工具提取,结果能差出十万八千里——差别往往不在"能不能提出来",而在"排序策略够不够聪明"。
我个人的经验是,判断一个工具值不值得用,最省事的办法是拿一份双栏排版的文档去试它。单栏文档几乎所有工具都能糊弄过去,双栏文档立刻分出高下:好的工具会把左栏读完再读右栏,差的工具会把左右两栏的行交叉着给你,读起来像精神分裂。
1.3 字体编码与 ToUnicode:乱码的根子在这儿
乱码问题值得单独说清楚,因为它是新手最容易卡住、也最容易误判成"工具不行"的地方。PDF 里的文字在内容流里存的是一串字节,这些字节怎么映射到具体字形,由字体字典决定。简单字体(单字节编码)依赖一张 Encoding 表和一个 Differences 数组来修正个别字形名;而中日韩文档大量使用的复合字体(Type0 / CID 字体)用的是多字节字符码,一个汉字往往占两个字节。
问题在于,这套映射只解决了"字节到字形"的问题,没有解决"字节到 Unicode"的问题。显示的时候只需要知道画哪个字形就够,所以很多生成器并不写入 ToUnicode CMap。一旦缺了这张表,提取器看到00 2A这样的码,只能猜它对应哪个汉字。猜不准的结果就是方块、问号,或者一串毫无意义的拉丁字母——坊间常说的"复制出来是乱码",根子基本都在这儿。
所以处理中文 PDF 时,一个非常实用的动作是先去查字体字典里有没有ToUnicode。有,事情好办;没有,就得走字形名反查、CID 对照表匹配,甚至干脆放弃文本层改用 OCR 识别。这一步判断做在前面,能省掉后面大量的瞎折腾。
2. 动手之前先给文件"验伤":三条路线的分岔口
2.1 文本型、扫描型、混合型,三类文件的区别
拿到一堆 PDF,先别急着写代码,先把它们分类。按照内容组织方式,实际工作中遇到的文件大致能落进三类。
第一类是文本型,页面里存的是真正的绘制指令,文字可以选中、可以搜索。这类最幸福,提取出来基本就是原文。
第二类是扫描型,整页只有一张图片,内容流里就是"把这张图贴到 (0,0),尺寸铺满"。你在阅读器里看不到任何可以选中的文字,光标扫过去一片死寂。这类文件必须走 OCR,没有任何捷径。
第三类是混合型,也是最容易出事故的一类。它既有图片层又有文字层,但两者对不上:常见于扫描后做过一次 OCR 再导出的文件,文字层是识别结果,可能错字连篇,也可能只覆盖了部分区域;还有一种情况是"图片上叠了一层隐形文字"用来支持搜索,提取的时候能拿到字,但错位、重复、缺行都很常见。
分类做不对,后面的努力基本白费。我见过不少人拿着混合型文件死磕文本提取,抱怨"提出来一半是空的",其实另一半内容只存在于图片里,工具再强也变不出字来。
2.2 五分钟定性:不用写代码的判断方法
不给文件分类就直接上代码,是新手最常见的浪费时间的做法。其实只要几分钟,不写一行程序也能判断得八九不离十。
最直接的办法是在阅读器里试着框选一段文字。能连续选中、复制出来是正常文字的,大概率是文本型;点半天选不中,或者选中的框和字对不上,那就要警惕扫描件或隐形文字层。第二个办法是看文件体积和页数的比值,一页纯文字通常几十 KB,如果一页动辄几百 KB 甚至上兆,里面大概率塞了整页图片。第三个办法是搜索,随便挑一个页面上明明存在的词去搜,搜不到基本可以判定没有可用文本层。
命令行工具也能帮忙。pdfinfo一看页数、加密状态、页面尺寸;pdffonts列出文档用到的所有字体,如果字体列表是空的,那基本可以确定是纯图片;pdfimages -list会告诉你每一页里埋了几张图、什么色彩空间、多大分辨率。这三条命令跑完,一份文件的性质就清清楚楚了,比盲写脚本试错高效太多。
2.3 加密、权限与增量更新带来的隐形陷阱
除了内容类型,还有几个"看不见"的属性会直接影响提取,必须提前确认。
加密是最常见的干扰项。PDF 有两类密码,一类是打开文档要输的用户密码,另一类是用来控制复制、打印、编辑权限的所有者密码。后者很讨厌:文件能直接打开,你能看能读,但脚本一跑就抛权限异常。技术上主流库都提供了忽略权限位读取的能力,但这类操作只应该用在自己拥有合法处置权的文件上,这个边界必须自己守住。
增量更新是另一个坑。PDF 支持"追加式保存",每次修改不会重写整个文件,而是在尾部追加新对象,再更新交叉引用表。结果是文件里同时躺着新旧两个版本的同一页内容。解析器一般会读最新的那一份,但一些实现粗糙的工具会读到旧版本,于是你提取出来的内容对不上你眼睛看到的。遇到"提取结果像几个月前的版本"这种诡异现象,先怀疑增量更新。
还有一种情况是文本藏在表单字段和注释里。页面正文提取干净得很,但表单里填的答案一个也不见,因为那些内容存在 AcroForm 字典里,不在页面内容流中。同样的道理,批注、便签、高亮里的文字也需要单独走一遍注释提取的接口。
3. 工具搭台:从一行命令到能跑量的流水线
3.1 命令行工具:快,但边界清楚
如果只是一次性任务,比如把十几份文件转成文本,我通常懒得写代码。一套命令行工具就够了:pdftotext加-layout参数能保住基本的版面,连着用很久都稳;pdfinfo看元信息;pdftoppm把页面渲染成图片,配合后续的识别环节。这一套安装起来也简单,系统包管理器里都有现成的。
命令行的好处是"零心智负担",一条命令一个动作,处理几份文件足够。但边界也很清楚:它不太能处理复杂的表格结构,遇到双栏、多列、图文混排的版面,-layout也救不回来;批量任务里想加个"提不出来就转 OCR"的分支,也很别扭。所以我的习惯是,先用命令行跑一批做粗筛,看看哪几份文件质量差,再针对性地写脚本。
3.2 四个 Python 库的性格对比
需要工程化的时候,Python 生态是绕不开的。常用的几个库性格差异很大,选错会浪费大量时间,所以值得摊开对比一下。
| 库 | 定位 | 优势 | 短板 |
|---|---|---|---|
| pdfplumber | 文本 + 表格 + 细粒度坐标 | 字符级坐标和表格线信息齐全,调试友好 | 速度偏慢,大文件吃内存 |
| PyMuPDF | 通用高性能渲染与解析 | 速度快,文本、渲染、图片一把抓 | 表格结构需要自己拼,API 风格自成一套 |
| pypdf | 轻量操作库 | 合并、拆分、加解密、元信息处理顺手 | 文本提取偏粗糙,版面分析能力弱 |
| pdfminer.six | 底层解析 | 布局参数可调,能拿到极细的结构信息 | 上手门槛高,速度最慢 |
我的实际搭配是:PyMuPDF 做批量初筛和渲染,因为它快,几百页也能在几秒内过一遍,还能顺手判断每页的文本量来决定要不要走识别;pdfplumber 做精提取,尤其是表格和对齐要求高的场景,它的字符坐标和线条对象能省掉大量自己解析的功夫;pypdf 处理文件级操作,合并、拆页、看加密状态这些交给它最省事;pdfminer.six 则是在前几个都解决不了某个刁钻版面时的最后一招。
一个最基本的文本提取、带坐标的版本大概长这样:
import fitz # PyMuPDF doc = fitz.open("sample.pdf") for page_no, page in enumerate(doc): blocks = page.get_text("blocks") # (x0, y0, x1, y1, text, block_no, block_type) for b in blocks: x0, y0, x1, y1, text, bno, btype = b if btype != 0: # 0 表示文本块,1 表示图片 continue print(f"p{page_no+1} [{x0:.1f},{y0:.1f}] {text.strip()[:40]}")注意get_text的几个模式差别很大:"text"直接给文本,"blocks"给块级坐标,"words"给词级坐标,"dict"给最完整的结构(含字体、字号、颜色)。做版面还原时用"dict"或"words",做快速预览用"text"就够。
3.3 表格提取:为什么它总是最难啃的一块
表格是提取任务里翻车率最高的部分,原因还是回到那个根本点:PDF 里没有表格,只有线条和文字。所谓"表格",可能是几条用re指令画的矩形边框,可能是用背景填充色块拼出来的假线,也可能是压根没有线、纯靠文字对齐排出来的版式。三种情况对应三套完全不同的解法。
有线框的表格相对好办。用 pdfplumber 的extract_tables(),它在背后靠"找线"和"找文字交集"来切分单元格,设定好线宽阈值通常就能出结果:
import pdfplumber with pdfplumber.open("sample.pdf") as pdf: for page in pdf.pages: for table in page.extract_tables(): for row in table: print([cell.replace("\n", "") if cell else "" for cell in row])无框表格就得自己动手了。思路是先拿到所有字符的坐标,按纵坐标把字符聚成行(同一行的 y 差值通常小于某个阈值,注意要在容差范围内,因为字号不同基线会有偏差),再在行内按横坐标排序,然后根据列与列之间的横向空白间隙切分出单元格。这个方法的鲁棒性完全取决于数据的规整程度,遇到单元格内换行、跨行合并、数字右对齐的情况,就得写额外的规则。
还有一个常被忽略的坑:合并单元格。有线框的表格里,一个跨三行的单元格在数据结构上就是一个大矩形,提取出来时,工具可能把它填在第一行、其余两行给空值,也可能三行都重复填。下游消费数据的一方如果不清楚这个约定,很容易把统计搞错。我的做法是提取完立刻打印前五行肉眼过一遍,确认单元格数量和空值分布符合预期,再往下走。
3.4 OCR 路线的成本账
确认是扫描件,就只能上 OCR 了。这条路的技术栈很固定:先把页面渲染成位图,再交给识别引擎,最后把识别结果按块拼回文本。渲染分辨率是关键参数,300 DPI 是一个比较稳的默认值,低了字形糊成一团,高了处理速度直线下降,而且对小字号未必有帮助。
import fitz import pytesseract from PIL import Image import io doc = fitz.open("scanned.pdf") page = doc[0] pix = page.get_pixmap(dpi=300) img = Image.open(io.BytesIO(pix.tobytes("png"))) text = pytesseract.image_to_string(img, lang="chi_sim+eng") print(text)OCR 的账要算清楚:一是时间成本,一页 300 DPI 的识别通常在零点几秒到几秒之间,几百页就是几十分钟,必须做并发或者分批;二是准确率成本,印刷体干净文档能到很不错的水平,但表格里的数字、上下标、特殊符号、印章覆盖的区域,错误率会明显上升;三是版面成本,纯识别出来的文本是一维的,原来的多栏结构、表格行列全都丢了,如果需要保留结构,得用带版面分析的识别方式,复杂度立刻上一个台阶。
我的建议是,只要文件量超过几十页,就先抽三到五页做识别测试,把结果和原图逐字对比一遍,估个准确率出来。如果错误率已经到了"每段都要人工改"的程度,那还不如直接找人录入,别硬扛。
4. 一份复杂 PDF 的完整提取过程
4.1 抽样与版面摸底
真实项目里我从不直接对整批文件跑脚本,第一步永远是抽样摸底。抽法很简单:取第一份文件的第 1 页、中间页和最后一页,各渲染成图片看一眼。这三页基本能覆盖封面、正文、附录三种典型版面,能提前暴露大量问题——有的文档封面是图片、正文是文本,有的前几页正常后面突然变成扫描件。
摸底的目的是把每一份文件的提取策略定下来,写成一张对照清单:
| 观察项 | 判定结果 | 对应策略 |
|---|---|---|
| 能选中文字、字体列表非空 | 文本型 | 直接走文本提取 |
| 整页只有图、字体列表为空 | 扫描型 | 渲染 + 识别 |
| 能选中但文字和图像错位 | 混合型 | 双路提取后对齐去重 |
| 有边框线条 | 规则表格 | 线条检测切分 |
| 无边框、靠对齐 | 版式表格 | 坐标聚类切分 |
| 打开正常但脚本报权限错 | 权限受限 | 确认授权后处理 |
这张表看着简陋,但它决定了后面每一步走哪条路。我踩过的最大一次坑就是跳过这一步,拿一套针对文本型文件调好的参数直接跑了两百多份混合文件,结果三分之一的输出是空的,白跑一晚上。
4.2 基于坐标的文本重排
对文本型文件,直接拿默认输出往往还差一口气,尤其是在页眉页脚、页码、脚注混在正文里的情况下。这时候需要基于坐标做一轮清洗,逻辑大致是这样:
import fitz doc = fitz.open("sample.pdf") for page in doc: h = page.rect.height words = page.get_text("words") # (x0,y0,x1,y1,word,block,line,word_no) body = [w for w in words if 0.06*h < w[1] < 0.94*h] # 先按行聚类,再按横向排序 rows = {} for w in body: key = round(w[1] / 3) rows.setdefault(key, []).append(w) for key in sorted(rows): line = sorted(rows[key], key=lambda w: w[0]) print(" ".join(w[4] for w in line))几个参数需要解释一下。上下各留 6% 的边距,是因为绝大多数文档的页眉页脚都在这个区间里,先把它们切掉,后面处理正文就干净得多。行聚类用的round(y/3)里的 3 是容差像素,这个值要跟字号挂钩:正文 10 号字的时候 3 差不多,如果文档里有大标题和小字混排,这个固定值会把标题和正文合成一行,得改成按字号动态计算容差。
还有一个细节是空格。PDF 里字与字之间未必有真正的空格字符,很多时候只是位置拉开了距离。所以直接拼word会得到"这是一个例子"这样的连体字。判断依据是相邻两个字符的间距是否明显大于字宽的某个比例,超过就补一个空格。这个阈值没有通用值,得按具体文档调,中文文档和英文文档的差别尤其大。
4.3 表格的两套打法:线框还原与文本对齐
表格部分我在上一节讲了思路,这里补上实操细节,因为真正跑起来的时候,麻烦都在细节里。
线框表格的关键是调table_settings。默认配置对细线、浅色线识别不敏感,把line_tol(线的合并容差)和snap_tol(文字吸附到线的容差)适当放大,能救回不少漏切的表格。另外要留意edge_min_length,太短会被当成噪声过滤掉,某些表格里单元格之间的分隔线恰好很短,一过滤整张表就塌了。
import pdfplumber settings = { "vertical_strategy": "lines", "horizontal_strategy": "lines", "snap_tol": 3, "line_tol": 2, "edge_min_length": 10, } with pdfplumber.open("sample.pdf") as pdf: tables = pdf.pages[0].extract_tables(settings)无框表格就得纯手工。我的做法是先用page.chars把所有字符按行聚起来,算出每一行的字符分布,再看列的位置是不是稳定。列位置稳定的(也就是每个单元格的起始 x 坐标集合高度重合),就可以按 x 切列;列位置不稳的,说明是流式排版,别硬做表格,老老实实按文本提取。这个判断很重要,我在没有框线的表格上强行切列,结果把一句话切成三段的经历不止一次。
顺带提一句,extract_tables()有个容易忽略的行为:它返回的单元格里可能带换行符,做后续处理前最好统一清掉,否则拼 CSV 的时候会把一行拆成好几行。
4.4 校验:怎么确认自己提对了
提取完不校验,等于没做完。我一般会做三层校验,成本不高但能挡住绝大多数事故。
第一层是数量校验:页数对不对,每页的字符数分布是不是离谱。正常文档每页几千字符,如果某一页突然只有两位数,十有八九是那一页出了问题。第二层是抽样比对:随机抽三到五页,把提取结果和原文件并排打开,逐行看三五段。这一步最笨也最有效,错字、漏行、乱序都能一眼看出来。第三层是结构校验,对表格来说就是行列数对不对、关键字段有没有空值异常。
再补一个实用的技巧:把提取结果里出现频率最高的 20 个"词"统计出来看一眼。如果最高的几个是"第页""共页""版权"这类页眉页脚的东西,说明清洗没做干净;如果出现一堆单字符的碎片,说明中文的字符间距处理有问题,被切碎了。
5. 那些让人怀疑人生的报错与坑
5.1 中文变方块:一条完整的排查链路
中文乱码是我遇过最多次的问题,值得把排查链路完整写一遍,因为它的成因有好几种,症状却长得差不多。
第一步,先确认是不是真的缺 ToUnicode。用解析库把第一页的字体字典打印出来,看里面有没有ToUnicode条目。有,说明映射表在,问题可能出在库的读取方式或者字体本身用了非常规编码。没有,那基本就锁定成因了。
第二步,看字体类型。如果是 Type0 / CID 字体又没有 ToUnicode,绝大多数库都无能为力,因为它们没有依据去反推编码。这时候可以尝试从字体的 CMap 里找线索,某些字体带了预定义的编码表(比如 GBK 相关的),能据此还原;但更常见的情况是只能靠字形名,而中文输出的字形名往往是g123这种没有语义的编号,等于没有。
第三步,评估替代方案。要么用前面提到的 OCR 兜底,要么找原始来源。我这几年的实际处理方式很务实:遇到缺 ToUnicode 的中文文件,先花十分钟试 OCR,如果准确率够用就直接走识别,不再在编码问题上耗时间。因为哪怕你最终逆推出了映射关系,这套逻辑也只对那一份文件有效,换个文件就得重来,投入产出完全不划算。
5.2 页眉页脚、断行与连字符
比乱码更隐蔽的是"看起来没错,但其实有问题"的那一类。页眉页脚是典型的,它们在每一页重复出现,进入提取结果之后会变成噪音。如果下游要做全文检索或者词频统计,这些重复内容会严重污染结果。前面提到的按坐标切边距是最简单的办法,但如果页脚的位置不固定(有的文档页码在里侧外侧交替),就得靠"内容模式匹配"来兜底:统计跨页重复出现的短行,出现次数超过页数一半的,就当页眉页脚干掉。
断行问题也很磨人。PDF 里的一个自然段可能被切成七八个文本块,块之间没有换行符的语义,只有坐标上的相邻关系。硬拼起来,会出现"这句话到一半 突然换行了"这种情况。处理办法是做一次后处理:如果上一行结尾没有句末标点,且下一行开头是小写字母或中文,就把两行的换行去掉合并。中文相对省事,因为不需要处理连字符;英文文档里还要额外处理行尾的-,判断它是真连字符还是断词造成的,判据是后半段拼回去之后是不是一个真实存在的词,这个只能靠词表。
5.3 大文件与批量任务的处理姿势
最后说说工程上的坑。几百页的文件一次性加载进内存,pdfplumber 这类库很容易把内存吃满,尤其是带大量图片的文件。解决办法是按页打开、按页关闭,不要一次性把所有 page 对象都拿在手里,处理完一页就把结果吐出去。
批量任务最大的风险是"跑一半挂了"。我现在的习惯是每处理完一份文件就往结果目录里落一个独立的输出文件,同时记一行日志,这样即使中途崩了,也能从断点续上,不用从头再来。另外一定要给每份文件加超时,某些结构畸形的文件会让解析器卡死很长时间,没有超时保护整个队列就被一份文件拖住了。
还有一点容易被忽略:并发数不要开太高。解析是 IO 和 CPU 混合型任务,但很多库内部有全局状态,进程内多线程跑容易出诡异的结果。稳妥的做法是用多进程,进程数控制在核数左右,别贪多。
6. 一点实际操作中的体会
这套流程我用来处理过各种规模的任务,从几十份到上千份都跑过,最大的感受是:八十的成功率来自前面那几分钟的文件分类,剩下的二十才是代码要解决的。很多人一上来就找"最强的 PDF 提取库",其实工具之间的差距远没有想象中那么大,真正拉开差距的是你有没有先搞清楚手里这批文件是文本型还是扫描型、有没有缺 ToUnicode、表格是有线还是无线。
另外一条经验是关于"够用"的判断。提取这件事没有百分之百干净的结果,真实文档总有各种例外,追求完美会陷入无休止的规则堆叠。我的做法是先定一个下游能接受的准确率线,抽检达到这条线就停手,剩下的错误交给人工复核环节。一个能跑到 95% 自动加 5% 人工的流水线,比一个卡在 99% 迟迟交不出去的方案有价值得多。
如果真的要说一个最省事的小技巧,那就是:在写任何提取代码之前,先用pdffonts和pdfimages -list各跑一次。这两条命令几秒钟就能告诉你这份文件有没有可用文本、有没有埋图片,比你在代码里加一堆 try 和日志去猜,效率高太多了。