PDFLIB批量PDF读取:TET文本抽取与坐标还原实战 简介面向 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同时带了一个 PDIPDF 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 字体、有没有加密和损坏文件、要不要坐标级别的版面还原、能不能接受偶发乱码。前三个只要有任意一个答「是」商业库的排查时间成本基本就能把授权费赚回来。这不是给商业库站台是三千份文件里出了五十份乱码、你一份份去对字体表的那种绝望实在不想再来一次。维度PDFLIBPDI 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() # errorpolicyreturn出错时返回错误码而不是直接抛异常批量跑时方便逐条记录 self.pdf.PDF_set_parameter(errorpolicy, return) # SearchPath让库在指定目录里找字体、ICC 等资源避免相对路径踩坑 self.pdf.PDF_set_parameter(SearchPath, ./data) def open(self, path, password): # optlist 传 passwordxxx 时按打开密码处理空串表示按普通文档打开 opt fpassword{password} if password else doc self.pdf.PDF_open_pdi_document(path, opt, 0) # 第三个参数 len0 表示文件名以 0 结尾 if doc 0: # 打开失败要立刻取错误码和错误信息否则后面全是连环失败 raise RuntimeError(fopen 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实例还能复用逻辑上就三步建实例、设参数、开文档。errorpolicyreturn这个参数值得单独强调默认策略在出错时会直接抛异常批量跑的时候一个坏文件就能把整个线程打断改成 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 参数传 0title / author / subject文档元数据可能为空串creator / producer生成工具信息用于判断来源做去重辅助encrypted是否加密返回 0 或 1width / height页面宽高页面级page 从 1 开始def read_meta(self, doc): def val(key, page0): 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, p0: 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), # 页面级 keypage 必须从 1 开始 page_height: round(num(height, 1), 2), } return meta这段代码是整条流程里最稳的一段几乎不出错但它决定后面所有逻辑的分支。encrypted1的文件要先去密码表里找密码找不到就进「待人工」队列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 fpassword{password} if password else doc self.tet.TET_open_document(path, opt, 0) if doc 0: raise RuntimeError(self.tet.TET_get_errmsg()) try: # granularityword一词一行content 指定要带出的字段 raw self.tet.TET_get_text( doc, page, granularityword 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_tol3.0): 按 y1 聚类成行行内按 x0 排序PDF 原点在左下y 大的在上面 rows [] for w in sorted(words, keylambda 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(keylambda 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_hashtext文件摘要去重和断点续跑的主键statustextok / scanned / broken / need_passwordpagesint页数异常的可以用来比对charsint抽取字符数接近 0 的进复核errmsgtext失败原因排查时第一眼看这个第二处是并发模型。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_asciiFalse) \n) return ok except Exception as e: return ffail:{type(e).__name__} finally: # 句柄在 open 里拿的这里要保证关掉拿不到 doc 的情况由 open 自己清理 pass with ThreadPoolExecutor(max_workers4) 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 份跑一遍把页数、字符数、空文本率打出来再决定放不放量。这套习惯帮我拦下过好几次「逻辑改了但样本没覆盖」的翻车。希望帮到你。本文还有配套的精品资源点击获取