☰
PDF文本抽取别急着OCR:pCOS摸结构、TET抽文字
2026/10/29 14:42:49 网站建设 项目流程

简介:围绕PDFLIB库在VS2010下读取PDF文件的工程实践包,面向需要完成文本与图像提取的C++/C#开发者。压缩包共49个文件、约11.54MB,以cpp源码和h头文件为核心,配以libtet.lib、libtet.dll等库文件支撑编译链接,另有4份pdf文档、若干txt说明以及sln、vcxproj工程配置,构成可直接打开调试的解决方案目录。整套示例覆盖打开文件、初始化TET、读取元数据、逐页抽取文本与图像、获取布局信息到释放资源等环节,并演示错误码捕获思路,配套image_extractor、fontfilter、tetml等多个功能模块源码,便于对照API文档逐步拆解。已有2560人学习,适合想快速打通PDF解析链路的初、中级开发者。

1. 别急着上 OCR:一份 PDF 到手先摸清底细

做 PDF 读取的人早晚会撞上同一件事:对方丢来一个文件夹,两百多份 PDF,三天内要把合同条款提成结构化数据。真上手抽文本才发现,其中四成是纯图片扫描件,抽出来全是空字符串,剩下的还混着内嵌字体缺字形映射的,复制出来是一排方框。这一步的判断不该靠猜,得先把 PDF 当对象图去看。

PDFlib 常被记住的标签是「生成 PDF」,读取侧反而被低估了。它把读取拆成两层:pCOS 直接钻对象树,页数、页面尺寸、字体字典、内容流长度一次全拿到,速度快、不依赖渲染;TET 负责把字形还原成带坐标的文字。两块能力是分开授权的,这点后面会细说。适合谁?要批量做文档入库前校验的、要把 PDF 转成结构化文本的、要判断该不该上 OCR 的岗位。三行代码能看清的底细,比人工点开二十个文件靠谱得多。

2. 读取路径的选择:pCOS 摸结构,TET 拿文字

动手前先把「读取」这个词拆开。同一个需求,有人要的是「这份 PDF 有几页、多大、能不能打开」,有人要的是「把第三段的文字原样提出来」,还有人要的是「把表格的行列还原出来」。这三件事在 PDFlib 里走的是完全不同的两条路径,选错了不是慢一点的问题,是根本做不出来。

2.1 PDF 是对象图,读取深度分三层

一个 PDF 文件从字节层面看是四段:头部标记、一堆间接对象(12 0 obj ... endobj)、交叉引用表、尾部 trailer。trailer 里的/Root指向 catalog 字典,catalog 的/Pages是页面树的根,往下/Kids递归到每一页的/Page对象,页面对象上挂/MediaBox(尺寸)、/Contents(内容流)、/Resources(字体、图像等资源)。这一层全是字典和数组,读起来像遍历 JSON。

内容流那一层就麻烦了。解开 FlateDecode 之后拿到的是操作符序列,形如BT /F1 12 Tf 100 700 Td (Hello) Tj ET。注意括号里那个东西:对单字节字体它是字符编码,要经过字体的/Encoding映射到字形名,再查字体程序拿轮廓;对中文这类 CID 字体,里面存的是两字节 CID,必须再查/ToUnicode这个 CMap,才知道它对应哪个 Unicode 码位。字体没嵌 ToUnicode 的 PDF,字形画得出来,文字却抽不出来——这是最常见的「看起来正常、程序读到空」的成因。

所以读取深度分三层:读结构(几页、多大、加密没加密、字体嵌没嵌)走 pCOS;读字节(内容流原始数据、图像流)也走 pCOS,但要自己处理 Filter;读文字(CID 还原成 Unicode、按坐标排阅读顺序)走 TET。很多团队跳过前两层直接冲第三层,失败几次后得出「PDF 抽文本不靠谱」的结论,转头去上 OCR,成本翻十倍,其实第一层跑一遍就能提前分流。

2.2 pCOS 与 TET 的能力边界

两张授权、两套 API,功能不重叠。下面这张表是我按实际项目里踩过的需求整理的,做选型时直接对着看:

能力项pCOS(PDFlib 核心)TET(文本抽取工具)
页数与页面尺寸直接读对象,微秒级不负责
Info / XMP 元数据可读不涉及
字体字典与嵌入状态可读,含子集标记内部使用,不对外暴露
图像对象与像素尺寸可读不涉及
内容流原始字节可读,需自行解 Filter不涉及
页面文字(Unicode)不负责核心能力
字符 / 词 / 行包围盒不负责提供 bbox
阅读顺序还原不负责内置列/段落判断
加密文档支持带密码打开支持带密码打开
授权方式随核心库单独产品线

选型结论很干脆:只想知道「这份文件是什么状况」,pCOS 一个接口就够,不用买 TET;要把文字拿出来做检索或入库,两套都得有,先用 pCOS 做一轮筛选,把扫描件、空文本页、加密文件挑出去,再交给 TET 逐页抽。反过来先上 TET 属于盲跑,遇到加密文件直接抛异常,异常信息还未必指向真正原因。

提示:PDFlib 不是开源库,动手前先确认手上有可用授权。读取接口本身开销很小,但授权缺失时库会进入受限模式,报出来的错误码和语法错误长得很像,排查时容易跑偏。

2.3 环境准备与最小可运行骨架

拿到发行包后先确认三样东西齐不齐:头文件目录(pdflib.h、tet.h)、库文件目录(静态库或动态库)、授权文件。编译时-I指头文件、-L指库目录,链接顺序上库要放在源文件后面,核心库是-lpdf,如果文档里有中日韩字体还要追加-lpdcjk。运行前把授权文件路径放进环境变量,各绑定变量名略有差异,主流写法是PDFLIBLICENSEFILE。

先跑一个最小骨架,验证「能不能打开、能不能拿到页数」这两件事。这一步过了再谈业务:

/* probe.c — 最小可运行的 PDF 读取骨架 */ #include <stdio.h> #include "pdflib.h" int main(void) { PDF *p; int doc = -1; /* PDI 文档句柄,-1 表示无效 */ p = PDF_new(); if (p == NULL) return 1; PDF_TRY(p) { /* 第三个参数是 optlist:空串表示全部用默认值 */ doc = PDF_open_pdi_document(p, "sample.pdf", ""); if (doc == -1) { fprintf(stderr, "open failed: %s\n", PDF_get_errmsg(p)); } else { /* pages 节点的长度就是页数 */ printf("page count: %.0f\n", PDF_pcos_get_number(p, "pages/length")); } } PDF_CATCH(p) { fprintf(stderr, "errno=%d msg=%s\n", PDF_get_errnum(p), PDF_get_errmsg(p)); } PDF_END_TRY; if (doc != -1) PDF_close_pdi_document(p, doc); PDF_delete(p); return 0; }

这段代码有四个关键点。PDF_new()创建一个 PDFlib 上下文,所有后续调用都挂在它上面,它本身不绑定任何文件。PDF_open_pdi_document()以只读方式打开输入文档,返回一个整数句柄,不是立即把整份 PDF 读进内存,页面内容是懒加载的,所以哪怕几百兆的文件这一步也很快。PDF_TRY/PDF_CATCH是必须包的,pCOS 遇到任何路径问题都走异常通道,不包的话进程直接退出,错误信息也拿不到。最后PDF_close_pdi_document()和PDF_delete()配对释放,漏掉前者会句柄泄漏,漏掉后者在长驻服务里会慢慢吃内存。

编译和运行:

gcc probe.c -o probe -I/opt/pdflib/include \ -L/opt/pdflib/lib -lpdf -lpdcjk -lm export PDFLIBLICENSEFILE=/opt/pdflib/license/pdflib.upr ./probe

输出的page count就是第一层结论。如果这里是 0 或者直接报错,先别往下写业务逻辑,按第 5 章的排查顺序走一遍,八成是路径或授权问题。如果页数正常但后面抽不出文字,那就是第二层、第三层的事,第 3 章和第 4 章分别处理。

3. pCOS 路径表达式:把页数、尺寸、加密标记一次读出来

pCOS 这套接口最反直觉的地方是:它不给你一堆getPageCount()、getPageSize()之类的封装函数,只给你一条路径表达式加四个取值函数。刚开始会觉得原始,用熟之后会发现这种设计反而省事——任何节点,不管在对象树哪个角落,写对路径就能取到,不用等库作者给你加 API。

3.1 路径语法:斜杠走字典,方括号走数组

路径字符串的语法规则只有三条,但每条都有坑。斜杠/表示取字典里的键,比如trailer/Info/Title表示先到 trailer 取/Info字典,再取里面的/Title。方括号[n]表示取数组的第 n 个元素,下标从 0 开始,比如pages[0]/MediaBox[2]取第一页媒体框的第三个值——MediaBox 是[llx lly urx ury]四元数组,所以[2]是右上角 x 坐标,也就是页面宽度。最后可以接一个length取当前节点的长度,数组和字典都能用。

取值函数按类型分四个:取数字用PDF_pcos_get_number,取字符串和名字用PDF_pcos_get_string,取布尔用PDF_pcos_get_boolean,想知道这个节点到底是什么类型用PDF_pcos_get_identifier。流对象单独一个PDF_pcos_get_stream,返回值是解压后的字节。类型取错不会自动转换,会直接抛异常,所以拿不准的时候先调get_identifier探一下类型,比猜快得多。

路径写法含义常见误用
pages/length页数写成pages.length或pagecount
pages[0]/MediaBox[2]第一页宽度(pt)拿[0]当宽度,那是左下角 x
pages[i]/Rotate页面旋转角该节点常常不存在
trailer/Info/Title文档标题Info 存在但 Title 不一定有
pages[0]/Contents内容流引用当字符串取,拿到的是乱码
trailer/Encrypt加密字典无加密时该节点不存在

3.2 逐页遍历:尺寸、旋转、内容流长度

真实场景里最常批量抓的就是三样:每页的宽高、旋转角、内容流字节数。最后一个指标很多人忽略,但它特别有用——内容流只有几百字节的页面,几乎可以肯定是空白页或纯图片页,一眼就能从候选集里剔掉。

/* walk_pages.c — 逐页读出尺寸、旋转、内容流长度 */ #include <stdio.h> #include "pdflib.h" /* 安全取值:节点不存在时返回默认值,不中断整个循环 */ static double safe_num(PDF *p, const char *path, double dflt) { double v = dflt; PDF_TRY(p) { v = PDF_pcos_get_number(p, path); } PDF_CATCH(p) { PDF_clear_err(p); /* 关键:清掉错误状态再返回 */ v = dflt; } PDF_END_TRY; return v; } int main(void) { PDF *p = PDF_new(); int doc = -1, i, n; char path[256]; PDF_TRY(p) { doc = PDF_open_pdi_document(p, "sample.pdf", ""); if (doc == -1) { fprintf(stderr, "open failed: %s\n", PDF_get_errmsg(p)); } else { n = (int) safe_num(p, "pages/length", 0); for (i = 0; i < n; i++) { double w, h, rot, clen; snprintf(path, sizeof path, "pages[%d]/MediaBox[2]", i); w = safe_num(p, path, -1); snprintf(path, sizeof path, "pages[%d]/MediaBox[3]", i); h = safe_num(p, path, -1); snprintf(path, sizeof path, "pages[%d]/Rotate", i); rot = safe_num(p, path, 0); /* 不旋转时节点常缺失 */ snprintf(path, sizeof path, "pages[%d]/Contents/length", i); clen = safe_num(p, path, -1); printf("p%-3d %.1fx%.1f rot=%.0f stream=%.0f\n", i + 1, w, h, rot, clen); } } } PDF_CATCH(p) { fprintf(stderr, "errno=%d msg=%s\n", PDF_get_errnum(p), PDF_get_errmsg(p)); } PDF_END_TRY; if (doc != -1) PDF_close_pdi_document(p, doc); PDF_delete(p); return 0; }

safe_num这个包装是整个遍历能不能跑完的关键。pCOS 的取值函数在路径解析失败时走的是异常通道,不是返回错误码——如果你的循环里直接调PDF_pcos_get_number(p, "pages[3]/Rotate"),而第三页恰好没写/Rotate(很常见,默认值 0 就没必要写),异常会把整个外层PDF_TRY捕获掉,循环从第三页直接跳到PDF_CATCH,后面所有页都不处理了,日志上还看不出是第几页出的问题。

PDF_clear_err(p)的作用是把错误状态复位。不加这一句,异常标志会一直挂着,下一次取值即使成功,PDF_get_errnum拿到的仍是上一次的错误码,排查时会怀疑人生。

至于/Contents/length,返回的是内容流解压后的长度(不含 Filter 处理的话拿到的是引用),拿来判断空白页足够了。如果页面上/Contents是一个数组(分多个流写),length会返回数组元素个数而不是字节数,这时候要改成pages[i]/Contents[0]/length逐个取。这个差异在实际文件里出现频率不低,做统计时留个心眼。

3.3 加密与损坏文件:能不能读、怎么带密码读

加密 PDF 在PDF_open_pdi_document这一步就会拦下来。最省事的判断方式是不预判,直接开,拿-1时看PDF_get_errmsg的返回内容——如果是权限相关,八成就是需要密码。带密码打开靠第三个参数 optlist:

/* 需要密码的文档:把 password 塞进 optlist */ doc = PDF_open_pdi_document(p, "protected.pdf", "password=123456");

optlist 是 PDFlib 里到处都在用的键值串格式,多个选项用空格分隔,值里含空格或特殊字符要加花括号。密码试错没有次数限制,但每次失败都会在错误状态里留痕,批量跑的时候记得在失败分支里PDF_clear_err,否则下一个文件会用着上一个文件的残留状态。

真正麻烦的是「损坏但不彻底损坏」的文件:交叉引用表被截断、对象偏移量对不上,这类文件用阅读器打开可能没问题(阅读器会自己重建 xref),pCOS 却会因为找不到对象而莫名失败。遇到这种可以先把它过一遍修复工具再读,或者在打开选项里加上容错相关的标志——具体选项名各版本略有差异,查一下头文件里的注释比记我这句话靠谱。

还有一种情况值得单独说:PDF 版本号高于库支持的版本。这类文件不会报版本错误,而是某个节点取出来是空值或者长度不对。判断办法是先看头部字节里的%PDF-1.x,如果明显偏新,别急着认定代码写错了,先把「库版本能否覆盖」这条列进排查清单。

4. 文本抽取落地:用 TET 把行、词、坐标写成 TSV

结构层面看清楚了,接下来是真正要文字的活。TET 的接口比 pCOS 收敛得多:打开文档、拿文本、拿字符包围盒,三个动作基本覆盖八成需求。难的不是调接口,是理解它给的坐标和粒度,以及什么时候该放弃它去上 OCR。

4.1 坐标体系与 page 编号

TET 的 page 编号从 1 开始,不是 0,这一点和 pCOS 的数组下标完全相反,混着用必错。TET_get_text的第二个参数传 0 表示「整份文档所有页」,传具体页码则只出那一页。字符包围盒的坐标单位是 pt(1/72 英寸),原点在页面左下角,和 PDF 的坐标系一致,但和屏幕、和大多数图像库的左上原点不一致——要叠图做可视化的话,得先用页高减一次 y 坐标。

阅读顺序这一块 TET 做得比裸解析内容流好很多。裸解析拿到的是内容流里的绘制顺序,而绘制顺序和阅读顺序没有必然关系:一份双栏排版的文档,内容流里可能是左栏一段、右栏一段交替写,直接按流顺序输出,文字是乱的。TET 内部按位置聚类再做行/列判断,对常规排版基本是对的,对多栏布局也大致可用,遇到分栏特别碎的(比如报纸式三栏加图注)还是会串,得自己按 bbox 的 x 坐标二次切分。

字符粒度、词粒度、行粒度通过 optlist 的granularity选项切换,默认是按行输出。行粒度做全文检索够用,做表格还原就必须降到字符级,靠 bbox 自己聚列。

4.2 抽文本与字符包围盒落表

下面这段把一份 PDF 的全文和每个字符的矩形框一起输出成 TSV,管道下游可以直接喂给数据库或 pandas:

/* tet_dump.c — 抽全文,同时按字符落 TSV */ #include <stdio.h> #include "tet.h" int main(void) { TET *tet = TET_new(); int doc = -1, page, i, n, pages; const char *text; const TET_bbox *box; TET_TRY(tet) { doc = TET_open_document(tet, "sample.pdf", 0, ""); if (doc == -1) { fprintf(stderr, "open failed: %s\n", TET_get_errmsg(tet)); } else { /* page=0 表示全文;len=0 表示不限制返回长度 */ text = TET_get_text(tet, 0, 0, ""); printf("#TEXT\n%s\n", text); pages = TET_get_page_count(tet, doc); printf("#CHARS\tpage\tidx\tllx\tlly\turx\tury\n"); for (page = 1; page <= pages; page++) { n = TET_get_char_count(tet, page); for (i = 0; i < n; i++) { box = TET_get_char_bbox(tet, page, i); if (box == NULL) continue; /* 空白字符可能没有框 */ printf("%d\t%d\t%.2f\t%.2f\t%.2f\t%.2f\n", page, i, box->llx, box->lly, box->urx, box->ury); } } } } TET_CATCH(tet) { fprintf(stderr, "errno=%d msg=%s\n", TET_get_errnum(tet), TET_get_errmsg(tet)); } TET_END_TRY; if (doc != -1) TET_close_document(tet, doc); TET_delete(tet); return 0; }

三个参数要说明白。TET_open_document的第三个参数传 0,表示文件名按 C 字符串长度自动计算;如果文件名里含非 ASCII 字符而你的运行环境编码不确定,就显式传字节长度。TET_get_text的第三个参数是缓冲区长度限制,0 表示不限制,库内部自己管理内存——但这也意味着返回的指针生命周期跟着 TET 句柄走,别在TET_close_document之后还拿着用。TET_get_char_bbox返回的是结构体指针,字段llx/lly/urx/ury就是左下角和右上角,字符是空白或者被裁掉时可能返回 NULL。

TSV 这个格式看着土,但在文本抽取场景里比 JSON 好用:一行一条记录,awk、cut、sort直接处理,几百万行也不吃内存。我一般会先落 TSV 再入库,中间出问题随时能拿原始文件复现。

4.3 什么时候必须换 OCR:三个判据

不是所有 PDF 都值得用 TET 硬抽。三个判据,满足任何一个就说明该走 OCR 通道了。

第一,字符数为零。整页TET_get_char_count返回 0,或者只有几个空白字符,说明这页压根没有文本层,是扫描图或者纯矢量绘图。这种情况下 TET 不会报错,它老老实实告诉你「这页没字」,很多人不看这个返回值,直接拿空字符串往下走,最后在数据质量环节才发现。

第二,字符数正常但内容是替换符。抽出来一堆\ufffd或者方块,说明字形画得出来但 ToUnicode 映射缺失。判据是统计替换符占比,超过 5% 就该考虑 OCR 或者换抽取引擎。这种情况在早期排版软件导出的 PDF、部分扫描件套壳的伪文本 PDF 里很常见。

第三,文本层与视觉内容对不上。有些 PDF 的文本层是「隐藏的」,用来做检索索引,内容和实际显示的字不一样;还有的是 OCR 套壳时留下的低质量文本层,错字连篇。判断办法很土但有效:随机抽几页渲染成图片,人工对一眼,和抽出来的文字比一比。这个动作花十分钟,能省掉后面几天的返工。

提示:TET 抽取失败和「文件里真没字」在日志上长得几乎一样,区别只在TET_get_char_count的返回值。批量任务里务必把这个数字记进结果表,它是后续分流唯一的依据。

5. 避坑与排查:五个让读取任务白跑的坑

接口本身不难,难的是出错的时候信息量太少。PDFlib 的错误码偏底层,很多时候只告诉你「路径解析失败」,不告诉你是哪个节点。下面五条是我在不同项目里反复遇到的,按现象、原因、解决三步写,遇到问题按顺序对号入座。

5.1 打开阶段:链接与授权

现象一:编译通过,链接时报undefined reference to 'PDF_new'。

原因基本只有两个:库没链上,或者链接顺序不对。gcc a.c -lpdf这种写法在某些工具链上会把库放在目标文件之前处理,符号还没被引用到就

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询