简介:面向 VS2010 开发者的 PDFLIB/TET 读取 PDF 实践工程,聚焦在 C++ 或 C# 项目中集成 PDF 处理库,完成 PDF 打开、文本与图像提取、元数据读取及资源释放,适合希望快速跑通示例并理解接口调用流程的初中级读者。压缩包共 49 个文件,约 11.54MB,含 8 个 cpp 源码、2 个头文件及动态库、静态库、解决方案与项目文件,并配 PDF 样例、说明文本和调试输出;包内还保留日志、中间文件与可执行程序,便于观察编译链接和运行结果。已有 2562 人学习下载。适合入门与工程实践参考。读者可获得项目配置思路、打开关闭句柄、初始化 TET、逐页抽取文本、提取图像、获取版面信息与错误码处理的代码框架,示例覆盖 C++ 调用和 C# 互操作思路,对库路径、链接选项、接口调用排错及授权许可注意事项具有参考价值。
1. 一份 3000 页合同库摆在面前:PDF 读取为什么要单独拆一个模块
前阵子接手一个文档归档的活:一个目录里躺着三千多份 PDF,合同、报价单、扫描件混在一起,业务方要的是每份文件抽出标题、页数、签署方和正文前两百字,后面还要按关键词建索引。我一开始拿常见的开源解析器糊了一遍,结果在加密文件、CID 中文字体和双栏排版上连续翻车,最后换成 PDFLIB 这套商业库,PDF 读取的稳定性才算压住。这篇就按我拆这个模块的顺序写:PDFLIB 能干什么、怎么装、最小读取代码长什么样、文本和坐标怎么抽、哪些坑必须提前躲开,以及后面怎么把它接进自己的处理流水线。适合手上有一批 PDF 要批量解析、又不想在解析器选型上反复试错的人。
2. 选型与分层:PDFLIB 的四个部件和它在链路里的位置
2.1 PDFLIB 的四个部件,读 PDF 只用到其中两个
PDFLIB 这个名字在搜索里经常被当成一个库,实际它是一组库,按能力拆成了几个产品。核心库负责生成 PDF,同时带了一个 PDI(PDF Import)模块,可以把已有的 PDF 当资源打开、取页面、取尺寸;pCOS 是对象级访问,像一把手术刀,能按对象路径直接读到文档内部的结构、字体、图像和内容流;TET 是文本抽取工具包,专门把内容流里的字形按字体编码表映射回 Unicode,再按坐标还原成词、行、段;PLOP 处理线性化、修复损坏文件、加解密和体积优化;Rasterizer 负责把页面光栅化成位图。
做「读取」这件事,主力就是 PDI 加 TET。PDI 用来把门打开——拿页数、拿页面尺寸、判断加密、把某一页拆出来;TET 用来把内容读出来——文本、字体名、字号、颜色、每个词的 bbox。pCOS 一般当兜底手段,遇到 TET 抽不出字的文件,用它看看内容流里到底有没有文字,能快速判断是「真没有」还是「解析失败」。常见做法是这三件套一起装,别只装核心库,否则后面处理坏文件会很难受。
| 部件 | 主要能力 | 在本项目里的用途 |
|---|---|---|
| 核心库 + PDI | 打开已有 PDF、取页、取尺寸、拆合页面 | 取页数、判断加密、按页拆分 |
| pCOS | 按对象路径访问文档内部结构 | 排查抽不出文本的原因 |
| TET | 文本抽取、坐标、字体、字号 | 正文与标题抽取的主力 |
| PLOP | 修复、线性化、加解密 | 处理损坏或被加密的文件 |
值得多说一句 TET 为什么单独存在。PDF 里的文字不是「存着的一段字符串」,而是「某个字体里的某个字形编号 + 一串位置坐标」,中文字体还分简单字体和 CID 字体,编码方式各不相同。要把这套东西还原成能看的文本,本质是在做编码表查表和阅读顺序重建,自己写至少要填进去几个月。血泪经验是:不要在解析器上做自研,除非你的业务就是做解析器。
2.2 项目分层:接入层、解析层、抽取层、输出层
我在这个模块里分了四层,每层职责很窄,方便单独换实现。接入层只干三件事:扫目录、按「文件大小 + 前 64KB 摘要」去重、把任务写进队列。解析层拿 PDFLIB 打开文档,判断能否正常打开、是否加密、总页数多少、元数据有哪些,这一层不算文本,只产出文档描述。抽取层用 TET 拿文本和坐标,用 PDI 做页面级操作。输出层把结果写成 JSONL,同时写一份进 SQLite 做状态记录。
这样分的好处是失败可定位。一个文件处理失败,先看它在哪一层挂的:解析层挂说明文件本身有问题,抽取层挂说明字体或内容流有异常,输出层挂通常是磁盘或编码问题。如果全揉在一个函数里,报错信息只有一行异常栈,排查起来就是黑匣子。
分层还有一个隐性收益:抽取层是最容易换实现的。哪天业务说成本要压,把 TET 换成开源方案,只动这一层,接入和输出完全不用改。我在别的项目里吃过亏——解析逻辑和业务逻辑混写在一个文件里,后来换库改了三天。
2.3 和开源方案怎么选,别只看授权费
选型这事我一般会列四个判断维度:文件里有没有大量中文 CID 字体、有没有加密和损坏文件、要不要坐标级别的版面还原、能不能接受偶发乱码。前三个只要有任意一个答「是」,商业库的排查时间成本基本就能把授权费赚回来。这不是给商业库站台,是三千份文件里出了五十份乱码、你一份份去对字体表的那种绝望,实在不想再来一次。
| 维度 | PDFLIB(PDI + TET) | 常见开源解析器 |
|---|---|---|
| 中文 CID 字体 | 编码映射覆盖较全,少见乱码 | 依赖字体是否带 ToUnicode,缺了就得兜底 |
| 加密文档 | 支持传入打开密码 | 部分库只支持空密码或有限算法 |
| 坐标粒度 | 词 / 字形级,含字体字号颜色 | 多数支持词级,字段丰富度不一 |
| 损坏文件 | PLOP 可先修复再读 | 一般直接失败 |
| 授权 | 商业授权,按部署方式计价 | 开源许可,注意 AGPL 类条款 |
如果你的场景只是「把一批干净 PDF 抽成纯文本丢进检索」,文件量不大、能人工抽查,开源方案完全够用,别为了用而用。真正需要 PDFLIB 的是那种「一次跑完几万份、结果直接进业务流程、没人有时间人工复核」的场景。
3. 环境搭建与最小可运行:打开文档、数页数、取元数据
3.1 目录结构与依赖安装
我习惯把读取模块单独做成一个可执行的小包,目录按职责分,配置文件不塞进代码里。下面这套结构在几个项目里都沿用过,改起来不别扭:
pdf-reader/ ├── config/ │ └── reader.yaml # 并发数、超时、密码表路径 ├── data/ │ ├── input/ # 待处理的 PDF │ └── output/ # JSONL 结果 ├── logs/ ├── src/ │ ├── reader.py # PDFLIB 封装,只做打开/元数据/文本 │ ├── pipeline.py # 扫描、去重、入队 │ └── writer.py # 结果落盘 + 状态记录 ├── tests/ │ └── samples/ # 放几份已知结果的样本文件 └── requirements.txt安装上,PDFLIB 的 Python 绑定一般随发行包提供,按你拿到的包形式走 pip 或解压后安装,注意解释器位数和版本要和包一致。我没法给你一个通用的版本号,因为包是跟着授权走的,拿到什么装什么。装完先跑一句python -c "from pdflib import PDFlib; print('ok')",能打印 ok 再往下走,别一上来就写业务代码。
配置文件里我一般只放三类东西:并发数(PDFlib 实例不是线程安全的,后面会讲)、单文件超时、以及密码表路径。密码表单独放一个文件而不是写进配置,是因为它属于敏感数据,权限要单独管。
3.2 初始化与打开文档:十分钟跑通第一行读取代码
封装的第一层是初始化。PDFLIB 的 Python 绑定基本沿用了 C 接口的命名,函数名都是PDF_开头,只是去掉了那个握手用的p参数,所以你看 C 的文档能直接对上 Python 的调用。下面这段是最小可用版本:
from pdflib import PDFlib # PDFLIB 的 Python 绑定,接口名沿用 C 风格 class Reader: def __init__(self): self.pdf = PDFlib() # 对应 C 的 PDF_new() # errorpolicy=return:出错时返回错误码而不是直接抛异常,批量跑时方便逐条记录 self.pdf.PDF_set_parameter("errorpolicy", "return") # SearchPath:让库在指定目录里找字体、ICC 等资源,避免相对路径踩坑 self.pdf.PDF_set_parameter("SearchPath", "./data") def open(self, path, password=""): # optlist 传 password=xxx 时按打开密码处理;空串表示按普通文档打开 opt = f"password={password}" if password else "" doc = self.pdf.PDF_open_pdi_document(path, opt, 0) # 第三个参数 len=0 表示文件名以 0 结尾 if doc == 0: # 打开失败要立刻取错误码和错误信息,否则后面全是连环失败 raise RuntimeError(f"open failed: {self.pdf.PDF_get_errnum()} " f"{self.pdf.PDF_get_errmsg()}") return doc def close(self, doc): self.pdf.PDF_close_pdi_document(doc) # 只 close 不 delete,实例还能复用逻辑上就三步:建实例、设参数、开文档。errorpolicy=return这个参数值得单独强调,默认策略在出错时会直接抛异常,批量跑的时候一个坏文件就能把整个线程打断;改成 return 之后,坏文件只返回一个 0 句柄,你在调用处判断并记录即可,其余文件照常处理。
PDF_open_pdi_document的第三个参数是文件名的字节长度,传 0 表示按 C 字符串处理,Python 里基本都传 0。optlist 是个字符串,多个选项用空格分隔,密码选项就写在这一串里,别拼错了引号。返回的 doc 是文档句柄,后面所有页面级操作都要带着它。
3.3 元数据与页数读取:PDF_get_pdi_value 的 key 怎么查
拿到句柄之后第一件事是读文档级信息。PDFLIB 用PDF_get_pdi_value统一读这些值,走的是 key-value 形式,key 是字符串,返回的是浮点数,页数字段要转成整数。实际用到的 key 没几个,我把常用的列在下面:
| key | 含义 | 备注 |
|---|---|---|
| pages | 总页数 | 文档级,page 参数传 0 |
| title / author / subject | 文档元数据 | 可能为空串 |
| creator / producer | 生成工具信息 | 用于判断来源,做去重辅助 |
| encrypted | 是否加密 | 返回 0 或 1 |
| width / height | 页面宽高 | 页面级,page 从 1 开始 |
def read_meta(self, doc): def val(key, page=0): return self.pdf.PDF_get_pdi_value(key, doc, page, 0) pages = int(val("pages")) meta = { "pages": pages, "title": (self.pdf.PDF_get_pdi_unicode? if False else ""), # 见下方说明 }上面这段我写到一半停住了,因为标题、作者这类字符串字段在 PDFLIB 里走的是文本取值的接口,不是PDF_get_pdi_value的数值通道。数值通道只负责页数、宽高、是否加密这类数字。字符串元数据我用的是文档信息里对应的取值接口,拿到的可能是 UTF-8 也可能是本地编码,落盘前统一转一次。
def read_meta(self, doc): num = lambda k, p=0: self.pdf.PDF_get_pdi_value(k, doc, p, 0) pages = int(num("pages")) meta = { "pages": pages, "encrypted": int(num("encrypted")), "page_width": round(num("width", 1), 2), # 页面级 key,page 必须从 1 开始 "page_height": round(num("height", 1), 2), } return meta这段代码是整条流程里最稳的一段,几乎不出错,但它决定后面所有逻辑的分支。encrypted=1的文件要先去密码表里找密码,找不到就进「待人工」队列;pages为 0 说明文件结构坏了,直接进修复队列。把这几个判断放在最前面,能挡掉后面一大半的异常。
注意页面宽高这类 key 传 0 不会报错,但返回值是 0 或者不可信的数字。这是最容易埋雷的地方,代码跑起来不报错,结果表格里宽高一列全是 0,等业务方问起来才发现。我的做法是在封装层把 page 参数做成必填,文档级 key 显式传DOC_LEVEL = 0这个常量,代码里一眼能看出这是文档级还是页面级调用。
4. 抽取层实做:从 TET 文本流到可用的段落坐标
4.1 TET 的三档粒度,page、word、glyph 各管什么
TET 抽文本的核心接口是TET_get_text,两个关键选项是granularity和content。granularity决定切分单位:page是整页一段文本,最快,适合只需要正文的场景;word是一个词一行输出,带坐标,是做版面还原的默认选择;glyph是单个字形,用来处理字距异常或需要精确位置的特殊排版。content决定返回哪些字段,文本、bbox、字体名、字号、颜色都在里面,字段之间用制表符分隔。
from pdflib import TET class TextExtractor: def __init__(self): self.tet = TET() self.tet.TET_set_parameter("errorpolicy", "return") def extract_words(self, path, page, password=""): opt = f"password={password}" if password else "" doc = self.tet.TET_open_document(path, opt, 0) if doc == 0: raise RuntimeError(self.tet.TET_get_errmsg()) try: # granularity=word:一词一行;content 指定要带出的字段 raw = self.tet.TET_get_text( doc, page, "granularity=word content={text,bbox,fontname,fontsize}" ) return raw finally: # 无论成功失败都要关文档,否则句柄会一直堆在进程里 self.tet.TET_close_document(doc)TET_open_document和 PDI 那个打开接口不是一回事,前者是 TET 自己的文档句柄,后者是 PDI 的句柄,不要交叉使用。TET_get_text的第二个参数是页码,这里是从 1 开始计的,传 0 拿不到东西。
返回的raw是一个多行字符串,每行一个词,字段用 tab 隔开。字段顺序不是按你写 content 的顺序来的,是按库内部的固定顺序输出。第一次接的时候我建议先拿一页把原始输出打到终端上对一遍,确认第几列是文本、第几列开始是坐标,再写解析。别上来就写解析逻辑,我在这上面浪费过半天,原因就是假设了字段顺序。
4.2 用 bbox 把词拼回行和段
拿到词和坐标之后,要解决的是阅读顺序。PDF 内容流的写入顺序和人的阅读顺序经常不一致,尤其是双栏排版、表格、页眉页脚混排的文档,直接按流顺序拼出来是一锅粥。我的做法是按 y 坐标聚类成行,行内按 x 排序,再按行间距判段。
def parse_word_line(line): """把 TET 的一行输出拆成 {text, x0, y0, x1, y1},解析失败返回 None""" parts = line.split("\t") text = parts[0] nums = [] for p in parts[1:]: try: nums.append(float(p)) except ValueError: pass # 字体名之类的非数值字段直接跳过 if len(nums) < 4 or not text.strip(): return None x0, y0, x1, y1 = nums[-4:] # 约定取最后四个数值当 bbox return {"text": text, "x0": x0, "y0": y0, "x1": x1, "y1": y1} def build_rows(words, y_tol=3.0): """按 y1 聚类成行,行内按 x0 排序;PDF 原点在左下,y 大的在上面""" rows = [] for w in sorted(words, key=lambda w: (-w["y1"], w["x0"])): for row in rows: if abs(row["y1"] - w["y1"]) <= y_tol: row["words"].append(w) break else: rows.append({"y1": w["y1"], "words": [w]}) for row in rows: row["words"].sort(key=lambda w: w["x0"]) return rowsy_tol是同一行的容差,不要写死成 3。经验值是按字号取 0.2 到 0.4 倍,字号 10 就用 2 到 4。容差太小,同一行的字会被拆成两行;太大,相邻两行会被粘成一行。文档里字号混杂的时候,我一般按每行的中位字号动态算这个容差。
build_rows返回的行还需要再合并成段。判段规则很简单:相邻两行的 y 差值如果超过行高的 1.6 倍,就断开。行高用该行 bbox 的高度,遇到行高为 0 的异常数据就退回用字号。这套规则在双栏文档上会失效,因为左右栏的 y 会交替出现,解决办法是先按 x 把页面切成左右两半,各自做行聚类,再拼起来。
表格是另一个麻烦。表格里的词坐标是规整网格,按行聚类出来的结果会把一行里不同单元格的文字粘在一起。我的处理是先识别重复出现的 x 边界(同一列的词 x0 高度一致),聚成列,再按单元格输出。这个逻辑不适合塞在通用抽取里,我一般单独开一个后处理器,只对识别为表格的页启用。
4.3 加密、损坏、扫描件:三种读不出字的情况怎么分流
抽取层最常见的失败不是代码写错,是文件本身不给你字。我把它分成三类处理:
def classify(reader, extractor, path, password=""): try: doc = reader.open(path, password) except RuntimeError as e: msg = str(e).lower() if "password" in msg or "encrypt" in msg: return "need_password" return "broken" # 打不开且不是密码问题,多半是结构损坏 try: meta = reader.read_meta(doc) if meta["pages"] == 0: return "broken" raw = extractor.extract_words(path, 1, password) chars = len(raw.replace("\t", "").strip()) if chars < 30: # 首页几乎没字,大概率是扫描件,没有文本层,需要走 OCR return "scanned" return "ok" finally: reader.close(doc)分流的意义在于让后续处理知道该做什么。need_password去密码表里找,找不到就挂起;broken丢进修复队列,用 PLOP 先过一遍再重试;scanned走 OCR 通道,这一步和 PDFLIB 无关,但必须区分出来,否则你会一直以为是自己解析代码有问题。
那个 30 字符的阈值不是拍脑袋定的。合同、报告的首页一般有标题、抬头、编号,正常文件首页的字符数远超这个数;低于这个数基本可以判定没有文本层。上线前我一般会拿 50 份已知类型的文件跑一遍,看看这个阈值卡在什么位置合适,不同业务域差别挺大。
5. 避坑与排查:五条踩出来的血泪经验
5.1 初始化与环境类
坑一:跑起来不报错,但输出里多了不该有的东西。现象是生成的文档带着评估标记,或者干脆在初始化阶段返回一串错误码。原因基本都是授权没生效:key 文件不在库默认查找的位置,或者 key 绑定了运行环境、换了机器就失效。解决方式是把 key 的路径固定下来,通过环境变量注入,进程启动时做一次自检——打开一份已知文件、读一次页数,读不出来就直接退出,别让它在半失效状态下跑完几万份再发现。
坑二:导入时报找不到共享库,或者加载后立刻崩溃。现象是 import 失败,或者 import 成功但第一次调用就段错误。原因通常是解释器位数和库不匹配,或者系统里装了两套不同来源的版本互相覆盖。解决方式是先确认解释器架构,再用依赖查看工具看库的实际依赖链有没有缺失项,最后把虚拟环境清干净重装。这件事上别偷懒用系统级安装,隔离环境能省掉大量玄学问题。
5.2 解析与数据类
坑三:跑到第两千份的时候内存涨到几个 G,然后进程被杀。现象是内存曲线一路向上,句柄数也持续增长。原因是只调用了关闭文档的接口,没有释放实例本身,或者异常路径上跳过了关闭调用。我后来的固定写法是把句柄包进上下文管理器,__exit__里先关文档再释放实例,异常也不会漏。批量任务还要加一条:每处理 N 份就重建一次实例,别让一个实例跑到底。
坑四:页数读对了,页面宽高全是 0。现象是结果表里宽高一列全为 0,但页数正常。原因就是前面说的页码基准问题——文档级 key 传 0,页面级 key 必须从 1 开始,传 0 不报错但返回值不可信。解决办法是在封装层把 page 参数设成必填,文档级调用显式写常量,再补一条单元测试:拿一份已知尺寸的样本,断言宽高在合理区间内。
坑五:中文变成空格或者一堆问号,双栏文档的句子前后颠倒。现象有两种,一种是一个字都抽不出来,另一种是字都在但顺序乱。前者多半是字体缺少 Unicode 映射表,这种文件 TET 也映射不回来,只能走 OCR 兜底,别在解析上死磕;后者是内容流顺序问题,用 4.2 那套 bbox 重建就能解决。区分方法很简单:抽一页出来看有没有字,有字就说明映射没问题,纯粹是顺序。
6. 二次开发:把读取能力接进流水线要动的四处改动
如果只是跑一次性的脚本,前面几章的内容够了。但真正落地时你面对的是「每天新增几百份、要能断点续跑、要能查某份文件为什么失败」,这时候得在封装外面再包一层。我一般动四个地方。
第一处是状态表。用 SQLite 建一张doc_status表,主键用文件内容的摘要值(不是路径,路径会变),字段包含状态、页数、字符数、错误码、处理时间。有这张表,重跑时直接跳过已成功的,失败的重试次数也能控制住。
| 字段 | 类型 | 用途 |
|---|---|---|
| doc_hash | text | 文件摘要,去重和断点续跑的主键 |
| status | text | ok / scanned / broken / need_password |
| pages | int | 页数,异常的可以用来比对 |
| chars | int | 抽取字符数,接近 0 的进复核 |
| errmsg | text | 失败原因,排查时第一眼看这个 |
第二处是并发模型。PDFlib 的实例不是线程安全的,每个线程必须持有自己的实例,不能共享。我一般用线程池加线程本地实例,线程数不要超过 CPU 核数,因为这类解析任务主要是 CPU 密集的。
import json, threading from concurrent.futures import ThreadPoolExecutor _local = threading.local() def get_reader(): # 每个线程一个实例,避免跨线程共享句柄导致的崩溃 if not hasattr(_local, "reader"): _local.reader = Reader() return _local.reader def handle_one(path, out_fp): reader, extractor = get_reader(), get_extractor() try: doc = reader.open(path) meta = reader.read_meta(doc) raw = extractor.extract_words(path, 1) record = {"path": path, **meta, "chars": len(raw)} out_fp.write(json.dumps(record, ensure_ascii=False) + "\n") return "ok" except Exception as e: return f"fail:{type(e).__name__}" finally: # 句柄在 open 里拿的,这里要保证关掉;拿不到 doc 的情况由 open 自己清理 pass with ThreadPoolExecutor(max_workers=4) as pool: for res in pool.map(handle_one, paths): pass这段骨架的关键点是threading.local()和max_workers。前者保证实例隔离,后者控制压力。finally里那段我没有直接写关闭,因为handle_one里doc的作用域只到 try 内部,实际项目里我会把 open/close 收进一个上下文管理器,代码短一截也不容易漏。
第三处是失败隔离。每个文件单独 try,失败只影响自己。同时给单文件加超时,PDFLIB 在极端损坏的文件上会卡很久,没有超时的话整个线程池会被一个文件拖死。
第四处是抽样回归。改完抽取逻辑别直接全区重跑,先抽 20 份覆盖不同类型的文件,比对页数、字符数、空文本率三个指标,和上一版结果对照。指标对不上就回滚,别硬着头皮跑完再发现。
从那以后我每接一批新文件,都先抽 20 份跑一遍,把页数、字符数、空文本率打出来,再决定放不放量。这套习惯帮我拦下过好几次「逻辑改了但样本没覆盖」的翻车。希望帮到你。
本文还有配套的精品资源,点击获取