简介:这份资源聚焦在 Visual Studio 2010 环境下使用 PDFLIB 及 TET 库读取 PDF 文件,面向具备一定 C++ 或 C# 基础、需要在桌面程序中实现文本与图像提取功能的开发者,尤其适合初次接触 PDFLIB API、希望从零跑通完整流程的中级学习者。压缩包共 49 个文件、约 11.54MB,以 8 个 cpp 源码与 2 个头文件为核心代码,配合 lib、dll 库文件、sln 与 vcxproj 工程文件,以及 Debug 目录下的 pdb、obj、exe 等编译产物,另附带多份 pdf 与 txt 文档用于说明与测试。资源中给出了从打开文件、初始化 TET、读取元数据到文本提取、图像提取与资源释放的完整示例,并对错误码捕获和授权许可问题作出提示,读者可据此直接编译工程、对照实现说明理解各函数调用关系,减少环境配置与接口摸索的成本。目前已有 2560 人学习下载。
1. 批量归档前的第一道坎:PDFLIB 读取 PDF 到底读的是什么
做 PDF 归档的人迟早会碰到一个场景:几百个 PDF 要先读出页数、标题、页面尺寸,再决定哪些走文本抽取、哪些送 OCR。很多人第一反应是找一个read_pdf()一把梭,结果在 PDFLIB 上翻车——它不是这么用的。使用 PDFLIB 库实现对 pdf 文件的读取,实际要拆成两条路径:PDI 负责读文档结构和页面对象,TET 负责读文字。前者能拿到页数、Box、元数据、旋转角,后者才能吐出可检索的文本。这套组合适合做发票归档、合同拆分、加水印套版的后端开发者,也适合要把 PDF 批量转文本的流程同学。先把边界认清楚,后面每一步才落得下去。
2. PDFLIB 的读取边界:PDI 管结构、TET 管文字,别拿一个函数通吃
2.1 为什么把 PDFLIB 当 pypdf 用会翻车
PDFLIB 的主业是生成和操作 PDF,核心动作围绕begin_document、begin_page_ext、show_text这一串写操作展开。读取不是写操作的逆运算,它走的是另一套叫 PDI 的机制:把一个外部 PDF 当作素材文档打开,再把里面的页面整页导入到新文档里。PDI 能读到文档结构、页面尺寸、旋转角、元数据,甚至能通过 pcos 查询页面字典里的原始键值,但它不会把页面上的文字“解析”成字符串给你。想要文字,得用同一家公司的 TET 产品,命令行和语言绑定都能调。
这个错位是新手最容易踩的坑。网上不少老教程贴的是pdf_open_pdi()、pdf_open_pdi_page()这类过程式函数名,照着抄到新版环境里直接报 undefined function——PHP 绑定后来改成了面向对象风格,方法挂在PDFlib类上,调用方式从pdf_begin_page($p, ...)变成$p->begin_page_ext(...)。血泪经验是:先看本地扩展的示例目录,别拿五年前的博客当准绳。
2.2 PDI 的三层对象模型:文档、页面、pcos 路径
PDI 读取时手里只有两类句柄:文档句柄和页面句柄。文档句柄由open_pdi_document返回,用来查全局信息和页面属性;页面句柄由open_pdi_page返回,只服务于“把这一页贴到当前输出页”这个动作。真正承担读取查询的是 pcos,一套类似 XPath 的路径表达式,挂在文档句柄上执行。
| 查询目标 | pcos 路径写法 | 返回类型 | 说明 |
|---|---|---|---|
| 总页数 | length:pages | 数值 | 最常用的入口 |
| 文档标题 | info:Title | 字符串 | 不存在时抛异常 |
| 第 i 页宽度 | pages[i]/width | 数值 | 下标从 0 开始 |
| 第 i 页高度 | pages[i]/height | 数值 | 单位是点 |
| 第 i 页旋转 | pages[i]/Rotate | 数值 | 常见 0/90/180/270 |
| 第 i 页 MediaBox | pages[i]/MediaBox | 数组 | 四个数,左下右上 |
注意两个坐标系不能混:pcos 里页面下标从 0 开始,open_pdi_page的页码从 1 开始。我在项目里一般包一层pageIndexToNo(),避免写到第三个月自己都记不清。
2.3 环境准备与最小验证脚本
先确认扩展在不在。PHP 的 PDFlib 绑定是商业库,装完要在php.ini里启用,扩展名可能是pdflib也可能是带版本后缀的写法,用php -m看实际输出。
php -v php -m | grep -i pdflib # 没有输出说明扩展没启用,先解决安装和 license 问题再往下走最小验证脚本只做一件事:打开 PDF、读页数、逐页打印宽高。跑通这一步,后面所有查询才有地基。
<?php $p = new PDFlib(); $doc = 0; try { $path = realpath('/data/in/sample.pdf'); if ($path === false) { throw new RuntimeException('file not found'); } // optlist 传空表示默认打开;加密文档在这里加 password=... $doc = $p->open_pdi_document($path, ''); if ($doc == 0) { throw new RuntimeException('open failed: ' . $p->get_errmsg()); } $pages = (int) $p->pcos_get_number($doc, 'length:pages'); echo "pages={$pages}\n"; for ($i = 0; $i < $pages; $i++) { // width/height 是页面几何尺寸,受 MediaBox 影响 $w = $p->pcos_get_number($doc, "pages[$i]/width"); $h = $p->pcos_get_number($doc, "pages[$i]/height"); printf("page %d: %.2f x %.2f\n", $i + 1, $w, $h); } } catch (Throwable $e) { fwrite(STDERR, $e->getMessage() . "\n"); } finally { if ($doc) { $p->close_pdi_document($doc); } $p->delete(); }open_pdi_document的第二个参数是选项串,空字符串走默认;遇到加密文档改成password=xxx即可。失败时它返回 0 而不是抛异常,所以必须主动判断返回值并打印get_errmsg(),否则你只会看到一个空页数。pcos_get_number返回浮点数,页数要转 int。finally里关闭句柄和释放 PDFlib 实例,批量跑的时候这一步省掉,内存会一路涨上去。
3. PDI 读取实操:从文档元数据到页面几何的完整取值链
3.1 打开文档与读取元数据
元数据读取的麻烦点不在 API,而在“字段可能不存在”。pcos_get_string碰到不存在的info:Title会抛PDFlibException,一个没有标题的 PDF 就能让整批任务中断。稳妥写法是把每个字段单独包一层 try/catch,拿不到就落空串,最后统一入库。
<?php $meta = [ 'title' => '', 'author' => '', 'subject' => '', 'creator' => '', 'producer' => '', ]; foreach ($meta as $key => $_) { $field = ucfirst($key); // Title / Author / Subject / Creator / Producer try { $meta[$key] = $p->pcos_get_string($doc, "info:{$field}"); } catch (PDFlibException $e) { // 字段不存在属于正常情况,不中断流程 $meta[$key] = ''; } } // 页数单独取,用于后续分页和校验 $pageCount = (int) $p->pcos_get_number($doc, 'length:pages');这段逻辑的关键是“失败即空值”,不要让元数据缺失影响页数读取。ucfirst只是为了少写几个字符串,实际项目里我会把字段名做成配置数组,方便以后加Keywords、CreationDate。另外info:前缀查的是文档信息字典,某些生成器会把标题写进 XMP 元数据而不写 info 字典,这时查出来是空串,不代表 PDF 没标题——需要的话再走 XMP 路径,属于另一条线。
3.2 页面几何:宽高、旋转与 Box
页面宽高是最常被误用的参数。pages[i]/width和pages[i]/height给的是页面几何尺寸,单位是点(1 点约等于 1/72 英寸),但页面旋转 90 度时,你在阅读器里看到的宽高和这两个值可能是反的。做套版、加水印、算缩放比例之前,必须把Rotate一起读出来。
<?php $geom = []; for ($i = 0; $i < $pageCount; $i++) { $w = $p->pcos_get_number($doc, "pages[$i]/width"); $h = $p->pcos_get_number($doc, "pages[$i]/height"); $rotate = 0; try { $rotate = (int) $p->pcos_get_number($doc, "pages[$i]/Rotate"); } catch (PDFlibException $e) { $rotate = 0; } // 阅读视角下的宽高:90/270 度时交换 $viewW = in_array($rotate, [90, 270], true) ? $h : $w; $viewH = in_array($rotate, [90, 270], true) ? $w : $h; $geom[] = compact('w', 'h', 'rotate', 'viewW', 'viewH'); }Rotate不存在时同样会抛异常,所以要兜底成 0。viewW、viewH是我自己算的“阅读视角尺寸”,套版时按这个算缩放,输出才不会横竖颠倒。
MediaBox 和 CropBox 的区别值得单独说一句。MediaBox 是物理页面边界,CropBox 是裁剪后的可见区域,两者不一致时,你在阅读器里看到的页面比width/height小。pages[i]/MediaBox返回的是数组,pcos 查数组的写法在不同绑定版本里略有差异,我一般先用pcos_get_string把整个值打出来看结构,再决定怎么取下标,不凭记忆写。
提示:页面尺寸对不上时,先把
pages[i]/MediaBox、pages[i]/CropBox、pages[i]/Rotate三个值原样打印出来,比对着阅读器截图看,基本能定位到是哪个 Box 在作怪。
3.3 读-存闭环:把页面导入新文档
只读不写验证不了读取结果是否正确。最直接的闭环是:打开源 PDF,新建一个输出 PDF,把源页面逐页fit_pdi_page贴进去。这一步跑通,说明文档句柄、页面句柄、页码映射都是对的。
<?php $p = new PDFlib(); $srcDoc = 0; $outDoc = 0; try { $srcDoc = $p->open_pdi_document('/data/in/sample.pdf', ''); if ($srcDoc == 0) { throw new RuntimeException($p->get_errmsg()); } $outDoc = $p->begin_document('/data/out/copy.pdf', ''); if ($outDoc == 0) { throw new RuntimeException($p->get_errmsg()); } $pages = (int) $p->pcos_get_number($srcDoc, 'length:pages'); for ($i = 0; $i < $pages; $i++) { $w = $p->pcos_get_number($srcDoc, "pages[$i]/width"); $h = $p->pcos_get_number($srcDoc, "pages[$i]/height"); $p->begin_page_ext($w, $h, ''); // 注意:open_pdi_page 页码从 1 开始,与 pcos 下标差 1 $page = $p->open_pdi_page($srcDoc, $i + 1, ''); if ($page == 0) { throw new RuntimeException($p->get_errmsg()); } // fitmethod=meet 等比缩放,adjustpage 按实际内容调整页面 $p->fit_pdi_page($page, 0, 0, 'fitmethod=meet adjustpage'); $p->close_pdi_page($page); $p->end_page_ext(''); } $p->end_document(''); } catch (Throwable $e) { fwrite(STDERR, $e->getMessage() . "\n"); } finally { if ($srcDoc) $p->close_pdi_document($srcDoc); $p->delete(); }fit_pdi_page的选项决定输出效果,常用的几个参数如下:
| 选项 | 取值 | 作用 |
|---|---|---|
fitmethod | meet/slice/clip/entire | 等比适配、裁剪填满、裁切、完整放入 |
adjustpage | 无值 | 按导入内容调整当前页尺寸 |
scale | 数值 | 固定缩放比例,和 fitmethod 二选一 |
position | left/center/right/top/bottom | 内容对齐方式 |
我一般用fitmethod=meet加adjustpage做无损复制;要做封面裁切才用slice。固定scale看着直观,但源页面尺寸一变就全乱,属于后悔药都来不及吃的写法。
4. 文本抽取落地:TET 的输出格式、参数与批量校验
4.1 TET 的两种用法与输出格式选择
PDI 拿不到文字,文字归 TET。TET 有两副面孔:命令行工具适合批处理和临时验证,语言绑定适合嵌进现有服务。命令行最省事,一条命令出结果;绑定适合你要在抽取过程中做逐页判断、动态改参数。
输出格式决定后续处理成本。纯文本最轻,适合全文检索入库;UTF-8 输出能避免中文乱码;XML 保留了字的位置、字体、字号信息,适合做版面还原或表格识别。我的经验是:先落 UTF-8 文本做字符数校验,确认抽取链路通了,再对重点文件切 XML 做精细解析。一上来就上 XML,解析成本会把验证周期拉长好几倍。
# 单文件抽取:-o 指定输出,-format 指定编码 tet -o /data/out/sample.txt -format utf8 /data/in/sample.pdf # 只抽指定页,减少大文件耗时 tet -o /data/out/sample_p1.txt -format utf8 -page 1 /data/in/sample.pdf具体开关名以本地tet -h的输出为准,不同版本会有增删,但“输出路径、格式、页范围”这三类参数的语义是稳定的。跑之前先用一个小文件确认命令能出内容,再上批量。
4.2 参数怎么设:阅读顺序、词间距、隐藏文本
TET 的参数按作用分三类,调参时按这个顺序排查,比乱试快得多。
第一类是阅读顺序(reading order)。PDF 里的文字是按绘制指令排列的,不是按人眼阅读顺序排列的。双栏排版、页眉页脚、侧边栏混在一起时,默认抽取结果会串行。常见做法是打开按栏排序的选项,让 TET 按版面区块组织输出;单栏文档可以关掉,减少不必要的重排。
第二类是词间距与连字符。英文 PDF 经常在行尾用连字符断词,直接抽出来就是informa- tion这种残词。TET 有去连字符的选项,但开着它处理中文文档没有副作用,处理代码清单反而可能把合法的连字符吃掉。我的做法是分类型跑:合同、论文开去连字符,技术手册关掉。
第三类是隐藏文本。有些 PDF 用渲染模式 3 藏了 OCR 文本层,或者藏了水印文字。默认抽取可能把它们过滤掉,也可能混进正文。判断方法是抽样对比:同一页的字符数和肉眼可见字数差一个数量级,基本就是隐藏层在捣乱。需要保留就显式打开对应选项,不需要就在后处理里按坐标或字体过滤。
注意:参数不是越多越好。每加一个选项,都要拿同一批样本重跑一遍字符数和抽样段落,确认没有把正文吃掉。玄学参数调到最后,往往是样本量不够造成的错觉。
4.3 批量抽取脚本与结果校验
批量脚本要解决三件事:遍历、失败隔离、结果可查。失败隔离指的是单个文件抽不出来不能中断整批,得把失败文件记下来单独处理。
#!/usr/bin/env bash set -u IN_DIR=/data/in OUT_DIR=/data/out FAIL_LOG=$OUT_DIR/fail.log mkdir -p "$OUT_DIR" : > "$FAIL_LOG" for f in "$IN_DIR"/*.pdf; do [ -e "$f" ] || continue base=$(basename "$f" .pdf) out="$OUT_DIR/$base.txt" if ! tet -o "$out" -format utf8 "$f" 2>>"$FAIL_LOG"; then echo "FAIL $f" >> "$FAIL_LOG" continue fi # 空结果单独标记,通常是扫描件或字体缺映射 if [ ! -s "$out" ]; then echo "EMPTY $f" >> "$FAIL_LOG" fi doneset -u防止变量拼错导致路径变成根目录;[ -e "$f" ]兜住通配符没匹配到文件的情况;-s判断输出是否为空,把“抽取成功但没文字”和“抽取失败”分开记录。这两类问题的处理方式完全不同,混在一个日志里排查会很痛苦。
校验环节我一般用 Python 做一次汇总,把页数、字符数、每页平均字符数打出来,低于阈值的标红转 OCR。
import os import re out_dir = "/data/out" rows = [] for name in os.listdir(out_dir): if not name.endswith(".txt"): continue path = os.path.join(out_dir, name) with open(path, encoding="utf-8", errors="ignore") as fh: text = fh.read() chars = len(re.sub(r"\s", "", text)) rows.append((name, chars)) rows.sort(key=lambda x: x[1]) for name, chars in rows[:20]: # 字符数异常低的优先人工看,多半是扫描件 print(f"{chars:>8} {name}")这段脚本不判断对错,只做排序,把最可疑的文件顶到前面。字符数为 0 或个位数的,基本可以判定没有文本层,走 OCR 比继续调 TET 参数划算。
5. 避坑排查:五个让 PDFLIB 读取结果对不上的现场
5.1 打开返回 0,页数却是空的
现象是脚本不报错,length:pages查出 0,后面循环一次都不进。原因通常是路径问题:相对路径受工作目录影响、文件名带空格没转义、文件权限不足。PDFlib 打开失败返回 0 而不是抛异常,不主动检查就会静默失败。解决办法是打开前先realpath校验,打开后立刻判断返回值并打印get_errmsg(),把错误暴露在第一现场。
5.2 取不到 Title 就整批中断
现象是跑到某个没有标题的 PDF 时进程退出,日志里是PDFlibException。原因是pcos_get_string对不存在的键会抛异常,不会返回空串。解决办法是每个元数据字段单独 try/catch,取不到落空值;更稳的做法是先把字段清单配置化,循环里统一兜底,别在业务代码里散落一堆 try。
5.3 导出的永远是第二页
现象是循环里明明传了$i,输出却总是同一页或者整体错位。原因是 pcos 的页面下标从 0 开始,open_pdi_page的页码从 1 开始,两套编号混用。解决办法是把页码转换收敛到一个函数里,入参统一用 0 基下标,只在调用open_pdi_page的那一行加 1,并在注释里写清楚。
5.4 导入后内容被裁掉或四周留白
现象是fit_pdi_page之后页面边缘被切,或者内容缩得很小。原因有三个:用了固定scale没跟随页面尺寸;fitmethod=meet没加adjustpage,输出页还是原尺寸;源页面 CropBox 和 MediaBox 不一致,按 MediaBox 排版导致可见区域偏移。解决办法是先用entire验证内容完整,再切meet + adjustpage做正式输出,同时把两个 Box 的值打进日志比对。
5.5 文本抽取结果为空或乱码
现象是 TET 输出了文件但内容几乎为空,或者中文变成问号。原因分三类:扫描件没有文本层;字体缺少 ToUnicode 映射,抽出来是乱码;隐藏文本层被参数过滤。排查顺序是先看每页字符数,全为零直接转 OCR;有字符但乱码,换输出编码并检查字体;字符数正常但内容错位,调阅读顺序参数。别在乱码上死磕参数,字体映射缺失是 PDF 生成端的问题,读取端补不回来。
6. 进阶用法:加密文档、大文件分页与读取结果的自动校验
加密 PDF 在open_pdi_document的选项串里加password就能打开,但要注意权限位:有些文档允许打开、允许打印,但不允许复制文本,这时候 PDI 能读页面,TET 抽文字可能受限。我的做法是先只读元数据和页数,确认权限后再决定是否走文本抽取,避免批量任务跑到一半大面积失败。
<?php // 加密文档:密码放在 optlist 里,其余参数保持默认 $doc = $p->open_pdi_document($path, 'password=yourpass'); if ($doc == 0) { // 密码错误和文件损坏在这里是同一个返回码,靠 errmsg 区分 throw new RuntimeException($p->get_errmsg()); }大文件的核心不是“能不能读”,而是“读完会不会把内存耗光”。按页循环时,open_pdi_page拿到的页面句柄用完必须close_pdi_page,不要等脚本结束统一释放;输出文档用追加模式分段落盘,别把几百页全攒在内存里。我处理过一批单文件上千页的归档件,改成逐页开关句柄后,内存曲线从一路上扬变成平的。
校验环节最容易被省掉,也最容易在返工时后悔。我的习惯是每次读取任务结束跑三项核对:页数是否等于源文件页数、每页字符数是否落在合理区间、随机抽三页和阅读器截图比对首行文字。三项里任何一项对不上,就回到第 3 章把几何参数和页码映射重新打印一遍。这套动作看着笨,但能挡住九成的“结果看起来对、实际错位”的问题。需要文中这套 PDI 读取脚本、TET 批量命令和校验脚本的,按文末方式自取,拿去改改路径就能跑。希望帮到你。
本文还有配套的精品资源,点击获取