
简介面向需要在 VS2010 环境中读取 PDF 的 C/C 与 C# 开发者这份工程示例围绕 PDFLIB 及其 TET 文本图像提取模块展开演示 PDF 打开、元数据读取、逐页文本与图像提取、布局信息获取及资源释放等流程并给出库路径配置、链接依赖和错误处理思路。压缩包共 49 个文件约 11.54MB包含 cpp 与 h 源码、lib/dll 库文件、sln/vcxproj 工程配置以及 pdf、txt、log 等说明与运行记录便于直接对照编译和排查环境问题。已有 2560 人学习下载。读者可据此获得一套可运行的 VS2010 集成范例理解 P/Invoke 调用方式、TET 初始化与清理顺序并参考实现说明和示例文档完成二次开发与验证。1. 一份两百页的合同摆在面前PDFLIB 的 PDF 读取解决的是什么问题某公司的法务要归档一批合同两百多页的扫描件里夹着十几页原生排版页要求把每页正文、图片、页眉页脚坐标都落进结构化表。团队先用轻量解析库跑了一遍纯文字页没问题一碰到子集嵌入字体、旋转页和图文混排就开始漏字、坐标错位。换到 PDFLIB 这条线之后PDF读取 从“猜”变成了“查”pCos 层按路径直接访问 PDF 对象TET 负责把字形还原成 Unicode。它不是拿来即用的万能钥匙商业授权、API 偏底层、得先看懂 PDF 对象模型才用得顺手但凡是需要精确到字符坐标、需要拿到原始内容流和图片字节的场景它给的控制力是封装库比不了的。做文档中台、票据识别、合同归档、图纸解析的开发者值得把它拆一遍。2. PDF 读取的地基对象表、xref 与 pCos 路径2.1 打开之前先认清PDF 里到底存了什么PDF 不是“文字文件”它是一张对象图加一张索引表。文件头之后是一串间接对象每个对象有编号和世代号写成12 0 obj ... endobj正文末尾是交叉引用表 xref记录“第 12 号对象从第几字节开始”trailer 里再用/Root指向 CatalogCatalog 指向/Pages页树页树的叶子节点才是真正的 Page 对象。Page 里的/Contents是内容流/Resources是这一页用到的字体、图片、表单等资源字典。内容流本身是一串操作符加操作数比如BT /F1 12 Tf 72 720 Td (Hello) Tj ET。这里的(Hello)只是字体编码里的字节不是 Unicode中文文档里更常见的是两位字节码。想还原成可读文字必须拿页面的字体资源读它的/ToUnicodeCMap 或者编码表做映射。这也是很多轻量库“看起来能读、中文就翻车”的根源它们只做了字节拼接没做编码映射。PDFLIB 把这件事拆成了三层分工很清楚层负责什么PDFLIB 里的对应接口对象层页数、页树、字典、数组、流的原始字节pCospcos_get_number/pcos_get_string/pcos_get_stream内容层解释内容流里的绘图与文本操作符自己写解析器或直接交给 TET文本层字形到 Unicode、字符坐标、词边界TETText Extraction Toolkit看懂这张表后面的代码就不会用错地方。我自己经常看到有人拿 pCos 取了一堆流字节然后抱怨“取出来是乱码”——那是对象层本来就该是字节解释工作得往上走一层。2.2 pCos 路径一行字符串定位任意对象pCos 最舒服的地方是把对象访问变成了路径字符串不用自己写 token 解析器。路径语法不复杂字典用/Key数组用[i]pages[i]是 PDFLIB 给的页对象快捷方式length:前缀返回元素个数。下面这几个是我日常用得最多的路径建议先抄进小本子。路径写法返回什么length:pages文档总页数pages[3]第 4 页的 Page 对象pages[3]/MediaBox[0]该页媒体框左下角 x 坐标pages[3]/Rotate该页旋转角度取不到说明没写默认 0pages[3]/Resources/Font/F1/BaseFont字体名pages[3]/Resources/XObject/Im1该页的一张图片对象length:pages[3]/Contents内容流是数组时返回元素个数路径的返回值类型由pcos_get_number、pcos_get_string、pcos_get_stream这三个函数决定取错类型会直接报错或者返回 0。名字类型的对象取出来不带前面的斜杠比如/Type /Page取pages[0]/Type得到的是字符串Page判断时别写成/Page这个细节我在第一次接的时候就栽过。2.3 环境准备与最小可运行脚本PDFLIB 是商业库正式使用需要授权关键字试用版有页数和水印限制。安装上没什么玄学把对应语言的绑定包和解压出来的动态库放到一起Windows 下是pdflib.dll和pdflib.libLinux 下是libpdf.soPython 绑定直接在发行包目录里执行python setup.py install或者把pdflib包目录丢进site-packages。TET 是另一套库绑定方式一样两个要分别放。先跑一个最小验证脚本确认授权、动态库、文件路径三件事都对# check_pdf.py —— 只做一件事打开文档并打印页数 from pdflib import PDFlib, PDFlibError p PDFlib() # 正式版必须设置授权试用版可以注释掉这一行 p.set_option(licenseyour-license-key) # 默认出错抛异常改成 return 后靠返回值判断批处理时更好控 p.set_option(errorpolicyreturn) try: doc p.open_pdi_document(sample.pdf, ) if doc 0: raise SystemExit(打开失败: %s % p.get_errmsg()) pages int(p.pcos_get_number(doc, length:pages)) print(pages , pages) # 顺手确认一下第一页的类型正常应输出 Page print(type , p.pcos_get_string(doc, pages[0]/Type)) finally: p.close_pdi_document(doc) p.delete()open_pdi_document的第二个参数是 optlist空字符串表示默认需要密码时写passwordxxx多个选项用空格分隔。返回 0 就代表失败具体原因用get_errmsg()拿。set_option(errorpolicyreturn)是我强烈建议加上的一行批处理几千个文件时一个坏文件抛异常会把整批任务带崩改成返回值判断后可以单文件跳过、记日志继续跑。最后delete()释放 PDFlib 实例别省这一步。3. 核心读取链路从打开文档到取出可读文本3.1 打开与关闭doc 句柄的两种打开方式PDFLIB 里有两个容易混的入口。open_pdi_document是“把 PDF 当数据源”返回的句柄用于 pCos 对象访问这是读取场景要用的open_pdi_file是“把 PDF 页当图形素材导入”主要服务于输出文档比如把某份 PDF 的第 2 页贴到你正在生成的报告里。做纯读取只用前者就够了两个混用会出现“明明打开了却取不到对象路径”的怪现象。句柄的生命周期要自己管没有垃圾回收替你兜底。一个 PDF 对应一个 doc 句柄读完必须close_pdi_document否则大文件循环处理时内存会一路涨到 OOM。我一般用上下文管理器把这一步焊死from contextlib import contextmanager contextmanager def open_pdf(p, filename, passwordNone): # optlist 里可以带多个选项用空格分隔 opt password%s % password if password else doc p.open_pdi_document(filename, opt) if doc 0: raise RuntimeError(open %s failed: %s % (filename, p.get_errmsg())) try: yield doc finally: p.close_pdi_document(doc)yield之前拿到的doc是整数句柄后面所有 pCos 调用都要带上它。这样即使中间取流失败抛异常finally也能保证句柄被关掉。参数说明password只对加密文档有效明文文档传了也没影响但别把空字符串拼成password某些版本会解析成空密码反而报错所以上面用了三目判断。3.2 页树遍历Pages 不是平的pages[i]这个快捷路径已经替你跑完页树绝大多数场景直接用就好。但有两类场景需要手工走一是要拿到页树节点本身的属性做判断二是文档页树被工具生成得特别深需要确认到底几层。手工递归的代码不长但要注意/Type是名字类型取出来是Page或Pages别带斜杠。def walk_pages(p, doc, node/Root/Pages, depth0): 深度优先遍历页树产出 Page 对象的路径 typ p.pcos_get_string(doc, node /Type) if typ Page: yield node return if typ ! Pages: return n int(p.pcos_get_number(doc, length: node /Kids)) for i in range(n): child %s/Kids[%d] % (node, i) yield from walk_pages(p, doc, child, depth 1)node初值用/Root/Pages这是绝对路径写法length:前缀对数组返回元素个数。递归深度一般不会超过 6 层真遇到几百层的畸形文档加个 depth 上限保护比较稳妥。产出的是路径字符串后面要取页面属性时直接把这个路径当参数传进去比拿对象引用在 Python 绑定里操作更省事。3.3 内容流Contents 可能是流也可能是数组取内容流之前要做一次类型判断因为一页的内容可能是单个流对象也可能是多个流的数组——排版工具切图、增量保存都会产生数组形式。判断方式不复杂先试着按数组取length:取不到就当单流处理。def read_contents(p, doc, page_path): 返回该页所有内容流解码后的字节按顺序拼接 base page_path /Contents chunks [] try: n int(p.pcos_get_number(doc, length: base)) paths [%s/Contents[%d] % (page_path, i) for i in range(n)] except Exception: paths [base] for path in paths: # 空 optlistPDFLIB 取流时会应用 FlateDecode 等常见过滤器 data p.pcos_get_stream(doc, , path) if data: chunks.append(data) return b\n.join(chunks)pcos_get_stream的第二个参数是 optlist这里给空串。第三个参数就是对象路径。返回的是字节串长度是几 KB 到几十 KB 量级别一页一页塞进列表攒着几百页的文档很快就会吃掉几百 MB。取到内容流之后正常做法是用正则或状态机扫/Im1 Do、/Fm2 Do这类操作符反查页面用了哪些图片和表单这个技巧在 4.2 节会用到。3.4 文本层TET 逐页提取与字符级信息TET 是 PDFLIB 家族里专门做文本抽取的组件它替你处理字体编码、ToUnicode 映射、连字拆分、阅读顺序推断这些麻烦事。基本用法是文档级打开、页级 begin/end 包住取文本放在中间。from pdflib import TET, TETError tet TET() tet.set_option(licenseyour-license-key) tet.set_option(errorpolicyreturn) doc tet.open_document(sample.pdf, ) if doc 0: raise SystemExit(TET open failed: %s % tet.get_errmsg()) try: pages int(tet.pcos_get_number(doc, length:pages)) for pageno in range(1, pages 1): tet.begin_page(doc, pageno, ) text tet.get_text(doc, pageno, ) # 整页纯文本UTF-8 tet.end_page(doc) print(--- page, pageno, chars, len(text)) finally: tet.close_document(doc) tet.delete()页码从 1 开始这点跟很多 Python 习惯不一样写循环时容易 off-by-one。get_text的 optlist 可以带granularityword、outputformatutf8之类的开关具体可用值按你手上绑定的头文件来不同版本的签名可能是不带 pageno 的重载编译报参数不匹配就换一下逻辑不变。begin_page/end_page必须成对漏掉end_page会让下一页的行序错乱这种问题很难从结果上看出来只能靠代码规约防。4. 三类高频读取任务文本坐标、图片抽取、页面属性4.1 文本与坐标用字符信息拼出词框只取纯文本满足不了票据和合同场景业务方要的是“金额这几个字在第几页、坐标多少”。TET 提供字符级信息接口每个字符带 Unicode、字号、字体 ID 和包围盒坐标自己按坐标和 Unicode 拼词就行。下面是按行聚类的简化实现够处理大部分横排文档def page_words(tet, doc, pageno, y_tol3.0): 把字符按基线聚成行再按 x 间距切成词返回 [(text, x0, y0, x1, y1)] tet.begin_page(doc, pageno, ) chars [] for idx in range(tet.get_char_count(doc, pageno)): info tet.get_char_info(doc, idx) # info 是元组字段顺序按头文件来unicode、fontid、fontsize、x0、y0、x1、y1 uni, _fid, _size, x0, y0, x1, y1 info[0], info[1], info[2], info[3], info[4], info[5], info[6] chars.append((chr(uni), x0, y0, x1, y1)) tet.end_page(doc) lines {} for ch in chars: key round(ch[2] / y_tol) # 用 y0 做行聚类容差按字号调 lines.setdefault(key, []).append(ch) words [] for key in sorted(lines, reverseTrue): # PDF 的 y 轴向上倒序才是从上到下 line sorted(lines[key], keylambda c: c[1]) buf, box , None for ch in line: box (ch[1], ch[2], ch[3], ch[4]) if box is None else (box[0], box[1], ch[3], ch[4]) buf ch[0] words.append((buf, *box)) return wordsy_tol是行聚类容差正文 10~12pt 时给 3 就够标题字号大要相应放大否则同一行会被拆成两行。get_char_info返回的字段顺序各语言绑定略有差异稳妥做法是先打一条日志把整个元组打出来核对别直接照抄索引。坐标单位是 PDF 用户单位跟页面 MediaBox 同一套坐标系要转像素就按目标 DPI 除以 72 换算别忘了处理/Rotate。4.2 图片抽取先看 Filter 再决定落盘格式页面里的图片存在/Resources/XObject字典下键名由生成工具决定常见的是Im0、Im1。想遍历一页所有图片靠猜键名不靠谱实际做法是从内容流里扫Do操作符反查引用名再逐个去取对象import re def image_names(p, doc, page_path): stream read_contents(p, doc, page_path) # 复用 3.3 的函数 # 内容流里的图片引用形如 /Im1 Do表单是 /Fm2 Do return set(m.decode(latin-1) for m in re.findall(rb/([A-Za-z0-9#_.-])\sDo\b, stream)) def get_filter(p, doc, path): Filter 可能是名字也可能是数组统一成列表 try: return [p.pcos_get_string(doc, path /Filter)] except Exception: n int(p.pcos_get_number(doc, length: path /Filter)) return [p.pcos_get_string(doc, %s/Filter[%d] % (path, i)) for i in range(n)] def dump_images(p, doc, page_path, outdir): for name in image_names(p, doc, page_path): path %s/Resources/XObject/%s % (page_path, name) if p.pcos_get_string(doc, path /Subtype) ! Image: continue filters get_filter(p, doc, path) data p.pcos_get_stream(doc, , path) if not data: continue if DCTDecode in filters: ext jpg # 本身就是 JPEG 字节直接落盘 elif JPXDecode in filters: ext jp2 else: ext raw # FlateDecode 的要自己补文件头见下 with open(%s/%s.%s % (outdir, name, ext), wb) as f: f.write(data)Filter是解码过滤器链DCTDecode意味着流里就是 JPEG 原始字节落盘改个后缀就能看FlateDecode解开后是裸像素得结合/Width、Height、BitsPerComponent、ColorSpace自己补 PPM 头或者丢给 Pillow 的Image.frombytes重建。注意ColorSpace可能是数组形式Indexed、ICCBased、Separation遇到这些直接 frombytes 会得到花屏稳妥做法是用 Pillow 按带调色板模式拼或者退一步只做尺寸统计、不做像素还原。4.3 页面几何与元数据MediaBox 的继承坑页面尺寸、旋转角、裁剪框这些属性会从页树父节点继承下来Page 对象自己不一定写。用pages[i]快捷路径时 PDFLIB 基本帮你解析好了手工从/Root/Pages/Kids往下走时就得自己往上找/Parent。读取几何信息最稳的写法是走快捷路径缺省值给兜底def page_geometry(p, doc, index): base pages[%d] % index def num(key, default0.0): try: return float(p.pcos_get_number(doc, %s/%s % (base, key))) except Exception: return default box [num(MediaBox[0]), num(MediaBox[1]), num(MediaBox[2]), num(MediaBox[3])] crop [num(CropBox[0], box[0]), num(CropBox[1], box[1]), num(CropBox[2], box[2]), num(CropBox[3], box[3])] return { width: box[2] - box[0], height: box[3] - box[1], crop: crop, rotate: int(num(Rotate)) % 360, user_unit: num(UserUnit, 1.0), # 大幅面图纸会用它放大坐标 }MediaBox是物理页面CropBox是显示区域印刷品常见 CropBox 比 MediaBox 小一圈做坐标映射要用 CropBox否则边缘会出现负坐标。Rotate取 90 或 270 时宽高要互换。UserUnit是 PDF 1.6 之后给大幅面图纸用的缩放因子缺省 1.0取不到就走兜底不要直接拿去乘而不判 0否则整页坐标全变 0。4.4 批量读取句柄生命周期与内存批量场景的核心不是速度是别把内存跑爆。三条经验PDFlib 实例不要跨线程共享官方文档明确写了非线程安全要多核就用进程池doc 句柄用上下文管理器管住取流之后立刻处理写盘别攒在内存里等最后统一输出。from concurrent.futures import ProcessPoolExecutor def process_one(args): path, outdir args p PDFlib() # 每个进程内独立实例 p.set_option(licenseyour-license-key) p.set_option(errorpolicyreturn) try: with open_pdf(p, path) as doc: n int(p.pcos_get_number(doc, length:pages)) for i in range(n): # 这里做逐页读取读完就落盘不驻留 ... return path, n, ok except Exception as e: return path, 0, str(e) finally: p.delete() if __name__ __main__: files [(a.pdf, out/a), (b.pdf, out/b)] with ProcessPoolExecutor(max_workers4) as pool: for r in pool.map(process_one, files): print(r)max_workers不是越大越好PDFLIB 单实例解析大图很吃内存4~8 之间按机器内存调。返回三元组(文件, 页数, 状态)方便做失败重跑比抛异常中断整批强得多。p.delete()放在finally里进程池里漏掉这一步的话跑几百个文件之后内存曲线会很难看。5. 避坑与排查PDF 读取里最容易翻车的五件事5.1 提取出来是空字符串或者问号现象TET 返回的文本全是空或者一堆?和方块。原因有两类一类是页面本来就没有文本层——扫描件是整页图片任何文本抽取库都拿不到字另一类是字体没有/ToUnicode表只有字形索引映射关系缺失。解决办法先看pages[i]/Resources/Font有几个字体、有没有 ToUnicode再判断内容流里有没有Tj、TJ操作符确实是扫描件的走 OCR 而不是死磕 PDFLIB。字体缺映射的TET 会退化用字形名猜测能捞回一部分剩下的只能靠人工抽检。5.2 加密文档打开直接返回 0现象open_pdi_document返回 0get_errmsg提示需要密码或权限不足。原因文档设置了打开密码或者权限密码尤其是银行回单、电子发票这类系统导出的文件经常带一个空用户密码加权限限制。解决optlist 里传passwordxxx空用户密码的文档通常不传也能开但权限标志会限制复制文本。别想着绕过——PDFLIB 会遵守文档的权限声明这是它的合规设计硬来只会拿到空结果。5.3 页数对不上或者读到一半报对象不存在现象某份文件在阅读器里显示 300 页程序读出来 298 页或者取某个对象时直接报错。原因文件被多次增量保存过xref 里残留旧版本记录或者传输过程中被截断xref 偏移量已经不对。解决先用 PDFLIB 把文件完整打开再重写一遍输出成一个新 PDF让 xref 重建这是最省事的“洗文件”办法实在不行的检查文件尾部有没有%%EOF没有就是截断源头重新拿文件比在代码里打补丁划算。5.4 图片抽出来花屏、斜条纹或者颜色反了现象落盘的图片能打开但是满屏斜条纹、颜色偏蓝或者上下颠倒。原因斜条纹几乎都是 FlateDecode 之后没做 predictor 反转PDF 内部用了 PNG 预测编码做压缩优化颜色异常多半是 ColorSpace 是 ICCBased 或 Indexed直接按 RGB 解释就错了。解决判断DecodeParms里的Predictor值大于等于 10 就按 PNG 规则逐行还原ColorSpace 是数组的先解析出基础色彩空间Indexed 的要读调色板表。这块没有捷径只能按规范一条条对。5.5 处理几百个文件后内存飙升现象跑批任务前几十个文件速度正常跑到两三百个时越来越慢最后被系统杀掉。原因doc 句柄没关、PDFlib 实例没 delete、或者把每页的流字节攒在列表里等最后统一处理。解决所有打开操作套try/finally把close_pdi_document和delete()写死在finally里流数据取到就落盘进程池里每个 worker 独立建实例。我一般会在批处理里加一条日志每处理 50 个文件打印一次 RSS涨得明显就说明有泄漏比事后查快得多。6. 二次开发把读取层封装成可替换的提取后端真实项目里最忌讳把 PDFLIB 的调用散落在业务代码各处一旦要换库或者升级授权改起来就是灾难。我通常先定义一个窄接口把“读 PDF”这件事收敛成四个方法业务层只依赖接口from abc import ABC, abstractmethod class PdfSource(ABC): abstractmethod def page_count(self) - int: ... abstractmethod def page_text(self, pageno: int) - str: ... abstractmethod def page_words(self, pageno: int) - list: 返回 [(text, x0, y0, x1, y1)]用于坐标类业务 abstractmethod def page_meta(self, pageno: int) - dict: width / height / rotate / crop class TetSource(PdfSource): 走 TET文本质量优先适合合同、票据 def __init__(self, path, license_key): self.tet TET() self.tet.set_option(license%s % license_key) self.path path self.doc None def __enter__(self): self.doc self.tet.open_document(self.path, ) return self def __exit__(self, *exc): self.tet.close_document(self.doc) self.tet.delete() def page_count(self): return int(self.tet.pcos_get_number(self.doc, length:pages)) def page_text(self, pageno): self.tet.begin_page(self.doc, pageno, ) try: return self.tet.get_text(self.doc, pageno, ) finally: self.tet.end_page(self.doc)PdfSource只暴露业务真正需要的东西坐标格式统一成左上角原点的像素值在实现内部换算业务层不用关心 PDF 的 y 轴向上。再加一个PcosSource走纯 pCos 路线只做页数、几何、图片抽取不做文本还原——速度快、开销小适合“先摸一遍文档结构再决定用不用 TET”的预处理阶段。两个后端的选择可以做成工厂后端适用场景单页耗时量级主要代价TET合同、票据、需要字符坐标毫秒到几十毫秒授权成本高扫描件仍需 OCR纯 pCos页数统计、图片抽取、结构巡检亚毫秒到几毫秒不做文本还原中文要自己处理编码pCos 自写内容流解析只需要少量固定格式文本介于两者之间维护成本高字体一变就崩接入之后一定要加一层质量校验不然换后端或升级授权出的问题会静默扩散。我习惯的阈值是页数必须完全一致每页字符数低于 20 的页面单独列出来人工抽查随机抽 5 页跟阅读器里复制的文本做一次编辑距离比对相似度低于 0.9 就报警。这套校验跑一遍只要几秒但能挡住绝大多数“看起来跑通了其实全是空”的事故。改造时还有一个技巧值得说把page_words的坐标统一归一化到 0~1 的相对坐标再往业务层传这样页面旋转、UserUnit 缩放、不同 DPI 的渲染都不需要业务方跟着改。归一化在实现层做一次比在每个消费方做十次划算。从那以后我每次接 PDF 读取的活都强制先拿三份最脏的样本跑一遍——一份扫描件、一份带权限密码的、一份页树被工具改得乱七八糟的——跑通了再谈排期。希望帮到你。本文还有配套的精品资源点击获取