简介:这是一套面向Visual Studio 2010开发者的PDFLIB TET库读取PDF文件示例工程,特别适合需要在C++或C#项目中集成PDF文本与图像提取功能的初学者参照学习。包内共49个文件,容量约11.54MB,以cpp源码、h头文件、sln/vcxproj工程配置为主,同时包含编译生成的exe、dll、lib、pdb,附带测试用PDF文档、readme说明和实现说明文档,目录结构完整,解压后即可直接打开调试。已有2556人学习下载。工程代码完整展示了TET初始化、p_open_file打开PDF、逐页文本提取、图像资源获取以及错误处理等核心流程,并针对库路径配置和链接依赖给出了清晰示范;配套实现说明.pdf与受保护PDF测试样例,可帮助读者避开常见配置坑点,快速掌握PDFLIB库在VS2010下的搭建方法和API调用思路。
1. 用PDFlib读PDF,第一步要绕过“生成器”这个标签
在PHP、Java、C++的服务端圈子里,PDFlib几乎和“PDF生成”画等号,电子发票、报表导出、票据打印,凡是提到它都是往PDF里写字、画线、放图片。可一旦需求变成“读取PDF”,很多工程师会默认绕开它,转头去找pdftotext或者开源解析库。实际上PDFlib不是不能读,而是读取入口藏得很深,官方把能力拆在PDI和PCOS两套接口里,配合open_pdi_document和pcos_get_*这一组函数,页数、页面尺寸、XMP元数据、书签、字体信息都能读出来。这篇文章就把PDFlib读PDF的完整姿势、最小可跑代码、参数边界和实战里最容易翻车的几个场景一次讲透。
2. PDFlib读取PDF的底层逻辑:PDI与PCOS各管哪一段
2.1 PDI把已有PDF当作可复用的页面素材读进来
PDFlib对“读取”这件事的理解和普通解析库完全不一样。pdftotext这类工具把PDF当作一个文本流,目标是抽出字符串;而PDFlib把PDF当作一个带有页面、字体、图像、注释、元数据的结构化容器,读取的目的是“复用它”而不是“拆解它”。这套读取能力叫PDI(PDF Import Library),你通过open_pdi_document打开一个已有PDF,再通过open_pdi_page打开其中某一页,之后这页可以原样放到一个新建PDF上,也可以查询它的属性。整个过程不会修改源文件,它是一条干净的单向通道。
一个容易误会的点是:PDI的操作必须挂在一个PDFlib输出文档上下文里。也就是说,哪怕你只是读PDF的页数,也要先执行begin_document开一个空文档,PDI文档只是这个输出上下文里的一个“素材引用”。很多人第一次写的时候把open_pdi_document扔在最前面,结果返回0,还以为是文件路径错了。问多几次就会发现,是没先调用begin_document。这套模型决定了所有PDI代码都有一个固定的骨架:开文档 -> 打开PDI文档 -> 打开页面/查属性 -> 关闭PDI文档 -> 关文档。
PDI能读的对象包括:页面数量、页面宽高、页面的缩放旋转信息、嵌入字体列表,以及PDF的书签层级。这些数据以数值和字符串的形式暴露给调用方,但你要清楚它不提供“把整页文本一次性倒出来”的接口。文本提取在PDFlib主库里只有很有限的支持,真正的批量文本抽取,官方路线是配合TET(Text Extraction Toolkit)来做。这一点后面单独讲,因为它直接决定你的方案边界。
2.2 PCOS:直读PDF内部对象树的只读窗口
PDI之外,PDFlib还带了一整套 pcos_get_* 函数,全称是PDFlib COS API。COS是PDF的对象模型,PDF文件底子上就是一棵对象树,根节点下有 pages、trailer、info、metadata 这些分支。pcos_get_number、pcos_get_string、pcos_get_object 这几个函数允许你用类似XPath的路径直接去树里取值,不需要先打开某一页,也不需要理解PDF解析器内部实现。这套接口是只读的,拿到的都是容器里的原始数据。
PCOS和PDI的分工我用一句话概括:PDI读页面,PCOS读数据。想要每页尺寸、页数、生产者、创建时间、标题、关键字,用PCOS路径直接取;想要把某一页完整“搬”进输出文档并保持矢量图形和字体不丢,走PDI的open_pdi_page加fit_pdi_page。大多数“用PDFlib读取PDF”的需求,落到代码层面就是这两种调用交替使用。也正因为PCOS能直接读取对象树深处的内容,它在排查“这个PDF为什么页数对不上”“为什么元数据读出来是空”的时候非常有用,能把黑匣子问题变成路径写没写对的问题。
2.3 读取流程的顺序:先开输出文档,再开PDI文档
综合上面两套接口,一个标准的读取流程是这样:先创建PDFlib对象并begin_document,然后用open_pdi_document把目标PDF挂进来,接着按需用pcos_get_number或open_pdi_page读取数据,最后先close_pdi_document再end_document。顺序不能反,PDI文档依赖输出文档上下文而存在。
<?php $p = new PDFlib(); $doc = $p->begin_document("", ""); // 空输出文档,作为读取上下文 $pd = $p->open_pdi_document("input.pdf", ""); if ($pd == 0) { die("读取失败: " . $p->get_errmsg()); } $pages = $p->pcos_get_number($pd, "length:pages"); echo "页数: " . intval($pages) . PHP_EOL; $p->close_pdi_document($pd); $p->end_document($doc); ?>begin_document的第一个参数传空字符串,表示不落盘、只存在于内存中,这是纯读取场景的标准做法。open_pdi_document的第二个参数是选项字符串,没有特殊需求就传空字符串;如果目标PDF带密码,这里要传password=xxx,后续章节会展开。输出这段代码后,你应该能在命令行看到PDF的基本信息,这就算把PDI的读取链路跑通了。
3. 用PDI打开PDF并读取页数、尺寸、元数据:最小可跑通的PHP代码
3.1 先跑通打开与关闭:begin_document 到 close_pdi_document 的完整骨架
真实的业务代码里,读取PDF的诉求大多数不是“显示出来”,而是拿到结构化数据去入库、对比、做校验。比如一个电子发票归档系统,需要逐批读取PDF的页数、页面尺寸和标题,生成索引清单。这种场景用前面那段骨架就能跑通,但有几个细节需要补上:第一是输出文档句柄$doc和PDI文档句柄$pd是两个不同的整数ID,不要混用;第二是错误处理不能只看open_pdi_document的返回值,pcos_get_number在某些PDF上也可能失败,需要用get_errmsg把所有失败路径的报错打出来;第三是页面的尺寸单位是PDF点,1点等于1/72英寸,需要换算成毫米时除以72再乘以25.4。
一个我经常看到的翻车现场是:初学者照着教程写完了代码,输出页数正确,但读取第二本PDF时开始报错,再看错误信息,指向close_pdi_document传入了一个已经被释放的句柄。原因是同一个PDFlib对象被多次复用时,旧的PDI文档没有及时关闭,导致句柄空间被耗尽。规范做法是在每读完一本书之后立即close_pdi_document,并且把所有读取逻辑封装成独立函数,避免状态漂移。
3.2 用PCOS读取页数与页面尺寸:pcos_get_number 的参数怎么填
PCOS的路径语法是这个方案里最绕的地方,也是新手把它当玄学的主要原因。读取总页数用length:pages,这是一个固定前缀加对象名的写法;读取第N页宽度用pages[N]/width,注意页号从1开始;读取页面旋转角度用pages[N]/rotate;读取PDF标题用metadata/xmpmeta/rdf:RDF/rdf:Description/dc:title这一长串,路径是大小写敏感的,写错一个字母返回的就是空字符串或0。
<?php $p = new PDFlib(); $p->begin_document("", ""); $pd = $p->open_pdi_document("sample.pdf", ""); if ($pd == 0) { die("打开失败: " . $p->get_errmsg()); } $total = intval($p->pcos_get_number($pd, "length:pages")); echo "总页数: {$total}" . PHP_EOL; for ($i = 1; $i <= $total; $i++) { $w = $p->pcos_get_number($pd, "pages[{$i}]/width"); $h = $p->pcos_get_number($pd, "pages[{$i}]/height"); $wmm = round($w * 25.4 / 72, 2); $hmm = round($h * 25.4 / 72, 2); echo "第{$i}页: {$wmm} x {$hmm} mm" . PHP_EOL; } $title = $p->pcos_get_string($pd, "metadata/xmpmeta/rdf:RDF/rdf:Description/dc:title"); if ($title != "") { echo "标题: {$title}" . PHP_EOL; } $p->close_pdi_document($pd); $p->end_document(""); ?>这段代码里有两个必须解释的参数细节。pages[N]的N从1开始,这是PDF对象树里的自然下标,和数组的0起始不是一回事。width和height返回的是PDF点,直接用来做业务判断时会和用户预期的毫米数值差很远,所以代码里做了单位和换算。dc:title前面的rdf:RDF是XMP元数据的标准容器,不是所有PDF都嵌了XMP,读不到时pcos_get_string返回空字符串而不是报错,所以这里用if ($title != "")做保护。
3.3 不带XMP的旧PDF,元数据要从pcos_get_string的info分支取
专门说一下元数据读取。PDF有两种承载元数据的方式:新式PDF在metadata里放XMP格式的XML段,旧式PDF在文档信息字典里放简单的键值对。用PCOS读旧式PDF时路径要改成info/Title、info/Author、info/Creator这种写法。很多生产环境里的PDF是各种老系统生成的,XMP段缺失很正常,只读metadata/xmpmeta/...就会拿到一堆空字符串。
$title = $p->pcos_get_string($pd, "info/Title"); if ($title == "") { $title = $p->pcos_get_string($pd, "metadata/xmpmeta/rdf:RDF/rdf:Description/dc:title"); }上面这个降级策略在真实项目中非常实用:优先读info/Title,拿不到再退到XMP,两层都空就标记为“未命名文档”。但要注意info/Title返回的字符串可能是UTF-16编码的,直接echo会看到乱码,需要按编码转换成UTF-8。这个细节经常被忽略,我自己的习惯是无论从哪个分支读元数据,都统一走一个编码清洗函数。
4. 读取页面内容与文本流:PDFlib能做到哪一层
4.1 用 fit_pdi_page 把整页内容“读”进输出文档
PDI真正的强项是页面级复用。当你拿着一堆PDF要合并、要加页眉页脚、要转存成另一个版本的PDF时,open_pdi_page加fit_pdi_page是比任何解析库都稳的方案。它能保留原页面的矢量图形、嵌入字体、图片资源,渲染效果和源PDF几乎一致。
<?php $p = new PDFlib(); $p->begin_document("merged.pdf", ""); $pd = $p->open_pdi_document("source_1.pdf", ""); $page = $p->open_pdi_page($pd, 1, ""); $width = $p->pcos_get_number($pd, "pages[1]/width"); $height = $p->pcos_get_number($pd, "pages[1]/height"); $p->fit_pdi_page($page, 0, 0, "scale 1"); $p->close_pdi_page($page); $p->close_pdi_document($pd); $p->end_document(""); ?>fit_pdi_page的第二个和第三个参数是目标位置坐标,这里传0和0表示放在左下角;第四个参数是个可扩展的选项串,scale 1表示不缩放。实际使用时最常加的选项是rotate和position,比如PDF是竖版但你要横排输出,可以传rotate 90。这个函数本质上是把“源页面”当作一个图形对象绘制到输出页面上,所以你必须在调用前用pcos_get_number拿到页面尺寸,自行决定输出页该开多大。
这里有一个很多人踩过的坑:open_pdi_page成功后,如果忘记调用close_pdi_page,输出PDF的页数会正常,但内存占用会一路涨上去,处理几百个文件的批量任务迟早崩。每页处理完立刻close_pdi_page,这是PDI写代码的铁律。
4.2 文本流提取的真正边界:PDI不擅长抽纯文本
标题里写着“读取PDF”,很多人的第一诉求是“把PDF里的文字都取出来”。坦率说,PDFlib主库的PDI模块在这块能力很弱。它能通过p_get_pdi_parameter拿到少量页面属性,但不存在一个“给我这一页全文”的简单函数。原因在于PDF本身不存文本段落,它只存字符的绘制位置和字体引用,要还原出阅读顺序,需要做坐标排序、字体映射、字符间距合并,这是一套独立的文本抽取引擎该干的事。
PDFlib官方给出的文本提取方案是TET,它和PDFlib同属一个产品家族,专门做文本流抽取和字符级定位。如果你评估下来确实要抽纯文本,我的建议是不要在PDFlib主库上死磕,直接评估TET或者开源的pdftotext,一周能省下来。反过来,如果你的需求是“读取PDF的页数、尺寸、元数据、书签,并能在新PDF里原样复用页面”,那PDFlib主库是足够胜任的,不需要引入额外工具。
这句话直接点透了选型:你的“读取”到底是要读内容资源,还是要读文本语义。前者是PDFlib的地盘,后者请转向TET。绝大多数电子归档、打印流转、页面合并项目要的是前者。
4.3 读取书签与层级结构:pcos_get_object 处理嵌套对象
书签在PDF对象树里是一棵嵌套树,根节点在Bookmarks下,每个书签对象有Title和子书签列表。PCOS读取嵌套对象时,pcos_get_number和pcos_get_string都只能取叶子值,要遍历层级必须借助pcos_get_object拿对象ID,再继续拼接路径往下查。
$bookmarkCount = intval($p->pcos_get_number($pd, "length:Bookmarks")); for ($i = 0; $i < $bookmarkCount; $i++) { $title = $p->pcos_get_string($pd, "Bookmarks[{$i}]/Title"); $childCount = intval($p->pcos_get_number($pd, "Bookmarks[{$i}]/length:Children")); echo "书签: {$title}, 子书签数: {$childCount}" . PHP_EOL; }注意书签的下标是从0开始的,和前面pages[N]从1开始不一样。这个差异我也解释不清为什么,但实际调用的确如此。读取多级书签时,用递归拼接路径的方式最保险,不要尝试一次性写死一个多级路径,因为不同PDF生成器的书签嵌套深度差别很大。
5. PDFlib读取PDF的五个常见坑:从“打不开”到“读了白读”
5.1 密码保护PDF直接返回0,还不给具体错误
现象:open_pdi_document对某些PDF返回0,get_errmsg()只显示“OpenPdiDocument failed”或者干脆是空,排查半天不知道密码错了还是文件坏了。
原因:PDF的加密分两种,一种是打开密码(user password),一种是权限密码(owner password)。PDI打开时默认不带密码参数,遇到加密PDF直接失败,而且错误信息不区分“文件损坏”和“密码错误”。
解决:先确认PDF是否加密,再传密码重试。
$doc = $p->open_pdi_document("locked.pdf", "password=123456"); if ($doc == 0) { $err = $p->get_errmsg(); if (strpos($err, "password") !== false) { die("密码错误或该PDF不允许被读取"); } }传密码后仍然失败时,注意owner password和user password的区别:只要其中有任何一个正确,PDI通常都能打开,但某些PDF设置了“不允许打印/不允许提取”这类权限位,即使密码对了,后续open_pdi_page也会在放置页面时报权限错误。这个问题没有绕过方案,只能在业务层面提示“此PDF受权限控制”。
5.2 扫描件没有文本层,读到的文本流是空串
现象:PDF页数和尺寸读起来完全正常,但想提取文本时,所有页面输出都是空白。检查后发现页面里明明能看见文字。
原因:这类PDF本质上是图片,一页就是一张扫描图,文字不是字符而是像素。任何解析库都抽不出文本,因为这根本不存在文本对象。
解决:确认PDF是否存在文本层,用pcos_get_number检查页面内容流里的字符数是一个可行的判断方法。更简单的方案:直接用PDF阅读器打开后框选文字,能选中就说明有文本层,选不中就是扫描件。对于扫描件,唯一路线是接入OCR服务识别,PDFlib主库不负责这部分。TET虽然能提取文本,但同样提不了照片里的字。
5.3 页号到底是0起还是1起,两个API混着用必翻车
现象:读页数用length:pages得到10,循环里用pages[0]/width读尺寸时返回0,但换成pages[1]/width就正常;同理Bookmarks[0]正常而Bookmarks[1]可能会漏。
原因:PCOS路径里,页对象数组的下标从1开始,这是PDF对象树的天然编号;而书签数组、字体数组这些附属列表却是从0开始。两个数组混用在同一段代码里时,新手很容易把下标统一写成从0开始,结果页数对不上。
解决:在代码里把“页号”和“数组下标”拆成两个变量,凡是拼接pages[{$i}]路径,$i从1循环到$total;凡是拼接Bookmarks[{$i}],$i从0循环。这个习惯能规避大部分踩坑。如果还是拿不准,就先用pcos_get_number("length:pages")验证总页数,再逐页读宽度,哪个页返回0就说明下标写错了。
5.4 中文字体被轮廓化之后,文本流提取直接失效
现象:用某些国产PDF编辑器转换过的PDF,打开后文字显示正常,用PDI读取页面也能成功,但提取文本时得到的是一堆空格或空白段落。
原因:部分编辑器为了跨平台显示一致,把中文字体转成了轮廓路径,也就是文字变成了矢量图形,字形信息还在,但字符编码被丢弃了。PDF解析器拿到的是“一堆曲线”,无法还原成字符。
解决:先确认PDF字体是否可提取。用pcos_get_string($pd, "pages[1]/Fonts[0]/Name")看字体列表,如果看到的是Subset或Type3字体,大概率文本层已经不完整。这种文件在业务层面只能接受“不可提取”的现实,或者保留原始生成版本的PDF。这也提醒我们,批量生成PDF时不要勾选“导出为轮廓”,否则后续数据查询和归档都会遇到大麻烦。
5.5 end_document 不调用,输出文件被锁死
现象:脚本第一次运行成功生成了输出PDF,第二次运行同一个脚本时提示文件被占用或读取失败,但代码里明明已经close_pdi_document了。
原因:close_pdi_document只释放了PDI文档句柄,输出文档本身还没关闭。PDFlib对象在进程结束时虽然会自动清理资源,但输出文件的写锁不会立刻释放,Windows下尤其明显。批量任务里这个问题很容易被忽略。
解决:强制在流程末尾调用end_document(""),并且放到finally块里保证执行。下面的写法在命令行脚本里足够健壮:
try { $doc = $p->begin_document("output.pdf", ""); // ... PDI读取逻辑 $p->end_document($doc); } catch (Exception $e) { file_put_contents("error.log", $p->get_errmsg()); }如果你把begin_document的第一个参数传空字符串,也就是纯内存模式,锁文件的概率会小很多,但end_document依然要调用,否则对象树的资源不会完整回收。监控内存时你会发现不end_document的进程内存在批量任务里只涨不降。
6. 进阶:把PDFlib的读取能力接进批量处理管线
上面几章解决的是“怎么读”,最后一节给出一个能直接用到生产环境里的批量读取方案:遍历指定目录下的所有PDF,逐本读取页数和尺寸,输出一份CSV索引表。这种需求在归档系统、打印计费、文档盘点里出现频率极高。
<?php $dir = "./pdfs"; $handle = opendir($dir); $rows = []; while (($file = readdir($handle)) !== false) { if (pathinfo($file, PATHINFO_EXTENSION) !== "pdf") { continue; } $path = $dir . "/" . $file; $p = new PDFlib(); $p->begin_document("", ""); $pd = $p->open_pdi_document($path, ""); if ($pd == 0) { $rows[] = [$file, "error", $p->get_errmsg()]; $p->end_document(""); continue; } $pages = intval($p->pcos_get_number($pd, "length:pages")); $w = round($p->pcos_get_number($pd, "pages[1]/width") * 25.4 / 72, 2); $h = round($p->pcos_get_number($pd, "pages[1]/height") * 25.4 / 72, 2); $rows[] = [$file, $pages, "{$w}x{$h}mm"]; $p->close_pdi_document($pd); $p->end_document(""); } fputcsv(fopen("index.csv", "w"), ["文件名", "页数", "首页尺寸"]); foreach ($rows as $row) { fputcsv(fopen("index.csv", "a"), $row); } ?>这段代码有两个可以继续打磨的方向。第一个是异常隔离:单本PDF损坏不应该中断整个目录的遍历,所以每一本PDF都独立创建PDFlib对象并独立做错误处理,失败只记录行不终止进程。第二个是维度的扩展:读完页数之后,还可以继续读每页的旋转角度、PDF的生产者信息,判断这批文件是不是同一个系统导出的,这对数据治理很有价值。
我自己在维护一个文档归档服务时,最初也是只抽页数和尺寸,后来被业务追问“哪些PDF用了非系统字体”才发现PCOS能查pages[1]/Fonts数组。把这个信息纳入索引表之后,排查供应商文件问题的时间从一天缩短到十分钟。这个习惯我一直留着:凡是新接入一批PDF,先跑一遍读取脚本生成全量属性索引,再决定后续处理策略,总比等用户发现问题再回头查要省事得多。希望这篇笔记帮你把PDFlib读取这条路走通,少踩几个我已经替你踩过的坑。
本文还有配套的精品资源,点击获取