资讯详情

PDF文本抽取别急着OCR:pCOS摸结构、TET抽文字

📅 2026/10/29 14:42:56 | 华诺云谱 👁 阅读
PDF文本抽取别急着OCR:pCOS摸结构、TET抽文字
简介围绕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功能不重叠。下面这张表是我按实际项目里踩过的需求整理的做选型时直接对着看能力项pCOSPDFlib 核心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或pagecountpages[0]/MediaBox[2]第一页宽度pt拿[0]当宽度那是左下角 xpages[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, password123456);optlist 是 PDFlib 里到处都在用的键值串格式多个选项用空格分隔值里含空格或特殊字符要加花括号。密码试错没有次数限制但每次失败都会在错误状态里留痕批量跑的时候记得在失败分支里PDF_clear_err否则下一个文件会用着上一个文件的残留状态。真正麻烦的是「损坏但不彻底损坏」的文件交叉引用表被截断、对象偏移量对不上这类文件用阅读器打开可能没问题阅读器会自己重建 xrefpCOS 却会因为找不到对象而莫名失败。遇到这种可以先把它过一遍修复工具再读或者在打开选项里加上容错相关的标志——具体选项名各版本略有差异查一下头文件里的注释比记我这句话靠谱。还有一种情况值得单独说PDF 版本号高于库支持的版本。这类文件不会报版本错误而是某个节点取出来是空值或者长度不对。判断办法是先看头部字节里的%PDF-1.x如果明显偏新别急着认定代码写错了先把「库版本能否覆盖」这条列进排查清单。4. 文本抽取落地用 TET 把行、词、坐标写成 TSV结构层面看清楚了接下来是真正要文字的活。TET 的接口比 pCOS 收敛得多打开文档、拿文本、拿字符包围盒三个动作基本覆盖八成需求。难的不是调接口是理解它给的坐标和粒度以及什么时候该放弃它去上 OCR。4.1 坐标体系与 page 编号TET 的 page 编号从 1 开始不是 0这一点和 pCOS 的数组下标完全相反混着用必错。TET_get_text的第二个参数传 0 表示「整份文档所有页」传具体页码则只出那一页。字符包围盒的坐标单位是 pt1/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 { /* page0 表示全文len0 表示不限制返回长度 */ 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这种写法在某些工具链上会把库放在目标文件之前处理符号还没被引用到就本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑