☰
C# PDF解析实战:PdfPig从文本提取到坐标定位与避坑指南
2026/10/16 19:08:47 网站建设 项目流程

简介:PdfPig 项目是流行开源组件 PDFBox 在 C# 语言环境下的移植与重构,适合需要在 .NET 技术栈中解析 PDF 文档的程序员。它主要解决两件事:一是从 PDF 页面中高效读取文本和各类内容,二是在文档中创建包含文字与几何形状的基础图形。其底层基于 netstandard 标准,因此可被不同平台调用,同时兼容 HOCR、ALTO-XML、Page-XML 等多种版面分析输出格式,便于将 PDF 解析结果接入 OCR 流程或文档管理平台。该压缩包采用 ZIP 格式封装,体积约 27.03 MB,内部包括项目源码、编译说明、使用示例以及从 0.0.x 迁移到 0.1.x 的升级指南;开发者下载后即可在 Visual Studio 中打开、编译并运行,省去环境配置的琐碎步骤。全站已有 572 人学习或浏览,热度较好。借助这份资料,读者可以获得一套从 PDFBox 移植到 C# 的现成解决方案,既能快速实现页面文字提取、坐标定位与结构化输出,也可参考其整体架构来设计自己的文档解析工具,或将 PDF 文本抽取能力集成到搜索索引、内容审核、资料归档等实际产品中,显著降低从零开发的风险与时间成本。资源内部结构清晰,方便按模块查阅,适合作为 PdfPig 学习与二次开发的参考资料。

1. PdfPig是什么:C#解析PDF时为什么值得考虑它

做合同批量录入或者发票归档时,很多C#开发者都遇到过同一个尴尬:需要从PDF里提取文本和坐标,翻遍方案却没一个顺手。直接用Java的PdfBox要额外包一层运行时,借用命令行工具又拿不到精确位置,这时PdfPig就值得认真看一下。它是纯C#实现的PDF解析库,定位上可以理解为PdfBox在.NET生态里的移植版本,目标是把读取PDF文件中的文本、图像、元数据这些能力带进C#。它的核心价值不是“能打开PDF”,而是把PDF当成一套有坐标、有结构的数据来读,适合做文本检索、版面定位、自动归档这一类落地任务。

我最早用PdfPig,是在某公司做电子回单解析。需求不复杂:从几百份PDF回单里把交易金额、对手方账户这些字段抓出来。试过直接拼正则、试过先转图片再OCR,最后发现PdfPig能用几行代码同时拿到文字和坐标,字段定位从“肉眼找”变成了“按位置算”。今天这篇就把从安装到避坑的完整路径讲清楚,新手能照着跑通,熟手能看到坐标系和图像处理这些容易翻车的边界。

2. 从打开PDF到提取第一段文本:PdfPig最小落地路径

2.1 为什么选PdfPig:托管库与组合结构的取舍

选解析库之前,先看清一个事实:PDF格式本身是一套页面描述语言,内容是“绘制指令”,不是像TXT那样的连续文本。这也是很多库提取结果乱序、缺字的根源。PdfPig的设计思路是解析页面内容流里的文本显示操作符(比如Tj、TJ),把每个字形的位置和Unicode映射关系记录下来,然后按视觉坐标聚合成单词。它整个库都是托管代码,不依赖Windows原生组件,也没有非托管DLL,部署到Linux服务器跑定时任务时很省心。

相比直接用Java版PdfBox,常见做法是包一层IKVM转换,但这样会把整个JVM环境带进进程,内存占用和排错成本都上去了。PdfPig把PdfBox的解析思路用C#重新实现,API风格对.NET开发者更友好,用NuGet装完就能跑。如果你的项目本身是.NET Core或.NET 5+的服务,选PdfPig比引一个外部进程要干净得多。

需要注意它的定位是“读”,不是“写”。做PDF生成、表单填写、页面合并这些逆操作,PdfPig不是首选;但只要你聚焦在提取和分析,它比很多商业库的授权约束少,也比自己解析PDF语法要可靠得多。选型时可以把这个边界画清楚:读PDF、提取内容、拿坐标,这是PdfPig的主场。

2.2 最小可运行代码:打开文档与遍历页面

先跑通一个控制台程序,这是所有后续操作的底子。创建一个.NET控制台项目,然后安装依赖,接着写下面的代码:

using PdfPig; using (var document = PdfDocument.Open("demo.pdf")) { foreach (var page in document.GetPages()) { Console.WriteLine($"第 {page.Number} 页:{page.Text}"); } }

这段代码做完三件事:打开PDF文件、按页遍历、输出每页的提取文本。PdfDocument.Open返回一个可释放的文档对象,using确保用完后由析构逻辑回收底层资源。GetPages()返回按页码顺序排列的页面序列,每页的Text属性就是该页所有文本内容的拼接结果,顺序按内容流里的绘制顺序来,不是严格按视觉阅读顺序。初次验证时你只需要确认“能不能提取出内容”,所以这个最小版本够用了。

如果遇到破损的PDF打开时抛异常,可以给Open方法传ParsingOptions参数,把UseLenientParsing设为true。它是让解析器在遇到不规范结构时尽量跳过而不是直接失败。这个开关在批量处理来源不明的文件时很实用,但也有代价:出错页面可能返回不完整内容。我的习惯是先不开,等确认文件来源混杂再按批次启用。

page.Text这个属性看着简单,内部其实做了不少事:读取页面内容流、识别字体资源、把字形编码映射到Unicode字符。这也是为什么它会比“拿原始字节手动解析”可靠得多。但你得知道一个前提:如果PDF里的字体没有ToUnicode映射表,Text可能拿到空字符串或乱码,这种情况后面会专门讲。

2.3 提取结果有两个层面:整页文本与单词坐标

page.Text适合快速预览,做精确位置提取时不够用。更实际的做法是用GetWords(),它返回的是带坐标的单词对象:

foreach (var word in page.GetWords()) { var box = word.BoundingBox; Console.WriteLine($"{word.Text} 坐标=({box.BottomLeft.X:0.##}, {box.BottomLeft.Y:0.##})"); }

每个Word包含文本内容和边界框。边界框的BottomLeft、TopRight给出的是PDF页面坐标系里的位置,单位是点(point,1/72英寸)。GetWords()并不是简单按空格拆字符串,它会把内容流里的字形按位置聚合成词,依据包括字形间距、字体变化和基线位置。这就是“提取文本”和“提取可定位文本”的区别:前者只给你字,后者给你字在哪。

这个阶段有个常见误解:以为GetWords()拿到的词一定按阅读顺序排列。实际上它返回的顺序仍然受PDF内部绘制顺序影响,双栏排版时经常出现右栏内容排在左栏前面。这不是库的bug,而是PDF本身不承诺视觉顺序。如果你要做严格的“从上到下、从左到右”排序,需要自己拿坐标重排,这是下一章的重点。

3. 页面坐标与阅读顺序:从“能取文本”升级为“取对位置”

3.1 坐标系原点在左下角:旋转页与扫描页的差别

前面提到坐标值,先把这个概念掰开。PDF页面的坐标系统由MediaBox定义,原点在页面左下角,x轴向右,y轴向上,单位是点。这和屏幕坐标系的“左上角为原点、y轴向下”正好相反。很多第一次用PdfPig的人,拿着坐标去画框或者裁剪图片,发现上下颠倒,根因就在这。

更隐蔽的是页面旋转。部分PDF会在页面字典里写一个Rotate值(通常是0、90、180、270),阅读器显示时会按这个角度旋转,但PdfPig暴露的Letter.StartBaseLine坐标是旋转前的逻辑坐标。换句话说,你看到的文字是横的,但拿到的坐标可能对应一个竖排页面。处理时需要读页面的旋转属性:

var rotation = page.Rotation; Console.WriteLine($"页面 {page.Number} 旋转角度:{rotation}");

拿到旋转角度后,可以把逻辑坐标换算成视觉坐标。这里给一个简易转换函数:

static (double vx, double vy) ToVisualCoordinate(Letter letter, Page page) { var x = letter.StartBaseLine.X; var y = letter.StartBaseLine.Y; return page.Rotation switch { 90 => (page.Width - y, x), 180 => (page.Width - x, page.Height - y), 270 => (y, page.Height - x), _ => (x, y) }; }

这个函数不追求极端严谨,但能覆盖绝大多数旋转页面的定位需求。公式思路是:先按旋转角度把逻辑坐标系映射回视觉方向,再换算到“从上到下”的常规表达。旋转90度时,原y轴方向变成了视觉x轴方向,所以视觉x坐标要用页面宽度减去逻辑y,视觉y坐标用逻辑x。真按这个公式去验证时,你会发现提取出的文字位置和阅读器里看到的位置基本一致了。

这类旋转页面常见于扫描后自动矫正方向的文档、报表系统导出的横向页面。如果项目里处理的PDF来源单一,可以先统计一下有多少页面带旋转,再决定要不要做全套坐标校正。不要为了“通用”而在一开始就写一套复杂的坐标矩阵变换,先跑一批真实样本,再看哪些页面确实需要。

3.2 把提取结果按阅读顺序重排:基于坐标的分组与排序

PdfPig的Text属性是按内容流顺序来的,双栏或多栏版面时乱序几乎必然发生。我在处理某批技术手册时就碰到过:PDF原本是双栏排版,page.Text输出先右栏后左栏,直接正则匹配全乱套。解决办法是拿到每一个字母的基线坐标,按“先按行分组、行内按x排序”的思路重排:

static string ExtractInReadingOrder(Page page) { var letters = page.Letters .Where(l => !string.IsNullOrWhiteSpace(l.Value)) .GroupBy(l => Math.Round(l.StartBaseLine.Y, 2)) .OrderByDescending(g => g.Key) .SelectMany(g => g.OrderBy(l => l.StartBaseLine.X)) .Select(l => l.Value); return string.Concat(letters); }

这里三个关键点。第一,StartBaseLine.Y表示字母基线的纵坐标,同一行的文字基线高度接近,所以按y坐标分组能粗粒度地聚成“行”。第二,Math.Round(…, 2)是为了抵消浮点误差,不取整的话同一行的两个字母可能因为0.001的差值被分到不同组。第三,分组后先按y降序,因为y坐标越大位置越靠上,视觉上要先读;组内再按x升序从左到右。这样一个简单的分组排序就能解决大部分单栏和双栏PDF的阅读顺序问题。

这个方法也有边界:遇到公式、上下标、旋转文本时,按基线分组会出问题。公式里的上下标基线高度不同,会被拆成独立行,打乱顺序。我的做法是把这个函数作为“通用兜底”,特殊版面走单独规则。比如遇到含数学公式的论文,先检测页面里是否有大量小型字母穿插在正文行中间,有就改用“最近邻连接”算法而不是简单按y分组。

排序算法的验证方法也很直接:挑三份不同排版的PDF,一份单栏合同、一份双栏杂志页、一份带复杂表格的报表,分别跑page.Text和上面的函数,把输出存成文本文件人工抽查前面几十行。如果双栏输出已经变成左边整块再右边整块,说明分组粒度对了;如果还是左右交错,就把Math.Round的精度往下调,比如改成1位小数,让同一行的容错范围更大。

3.3 用坐标做关键词定位:一个简单有效的命中方法

拿到坐标之后,最常见的需求是“找某个关键词在第几页、什么位置”。用GetWords()可以直接做单词级定位:

var matches = page.GetWords() .Where(w => w.Text.Equals("发票号", StringComparison.OrdinalIgnoreCase)) .Select(w => w.BoundingBox.BottomLeft) .ToList();

这段代码把整页单词过滤出等于“发票号”的词,返回其左下角坐标。实际业务里关键词往往不是独立单词,而是“发票号:123456”这种组合。这时可以把相邻词按位置拼起来再判断:

var words = page.GetWords().ToList(); for (var i = 0; i < words.Count - 1; i++) { var pair = words[i].Text + words[i + 1].Text; if (pair.Contains("发票号")) { var start = words[i].BoundingBox.BottomLeft; Console.WriteLine($"命中位置:({start.X}, {start.Y})"); break; } }

这种相邻词拼接的命中方法,比直接在整个页面文本里搜索更可靠。原因在于:GetWords()聚合的是视觉上属于同一个词的字形组,相邻两个Word在坐标上贴近,拼接后基本就是原文语序。你不用关心PDF内容流里这两个词中间隔了多少个绘制指令,只要它们视觉上相邻就行。

需要留意的是标题里带“发票号”三字但实际拆成“发票”和“号”两个词时,第二段代码的Contains能覆盖;如果拆成了“发”“票”“号”三个独立词,就得循环判断三个词的组合,或者换成按坐标距离合并词再匹配。判断词间距的阈值建议取当前字号的一半,小于阈值就认为属于同一短语。这个值不用太精确,只要能挡住“下一行开头的词被错误拼进来”的情况就够。

4. 提取图像与元数据:PdfPig在“其他内容”上能挖多深

4.1 页面里的图片:直接拿原始字节

标题里写的“其他内容”,很大一块是图像。PdfPig有一个遍历文档所有图片的入口,用起来非常直接:

using var document = PdfDocument.Open("素材.pdf"); foreach (var image in document.GetImages()) { if (image.TryGetPng(out var pngBytes)) { File.WriteAllBytes($"out_{Guid.NewGuid():N}.png", pngBytes); } else if (image.TryGetJpeg(out var jpegBytes)) { File.WriteAllBytes($"out_{Guid.NewGuid():N}.jpg", jpegBytes); } }

这里的逻辑是:每遇到一张图片,先尝试把它编码成PNG字节流,成功就落盘;不行再尝试JPEG。TryGetPng内部会处理PDF常见的FlateDecode无损解压,TryGetJpeg则对应DCTDecode压缩的JPEG数据。调用方不需要手动解zlib压缩,也不需要关心颜色空间的原始表示,PdfPig把这一层包装掉了。

但要记住,这两个方法不是万能的。PDF图像可能用JPXDecode(JPEG 2000)压缩,也可能用CCITT传真编码或JBIG2黑白压缩,后三种TryGetPng通常会返回false。遇到这种情况不要认为“库不行”,而是图像本身用了需要专门解码器的编码格式。处理JPEG 2000的常见做法是取原始字节后交给专门的解码库,不要硬在PdfPig里找万能转换。

还有一个容易忽略的点:PDF里有“图像蒙版”(ImageMask)这种1位深度的掩码图,它常常不是给人看的真彩图,而是用于控制另一张图的透明区域。GetImages()会把它们也枚举出来。如果你只想导出视觉上可见的插图,需要先根据图像的ColorSpace或尺寸过滤掉这些掩码,否则导出文件里会混入一堆黑白的奇怪小图。判断方法也很粗:看图像尺寸是否明显小于页面尺寸,掩码图通常只有几十乘几十像素。

4.2 标题、作者、创建时间:文档信息与内容流的区别

PDF除了页面绘制内容,还有一个Info字典,记录标题、作者、创建时间这类元数据。PdfPig把这块暴露在Information属性上:

var info = document.Information; Console.WriteLine($"标题:{info.Title}"); Console.WriteLine($"作者:{info.Author}"); Console.WriteLine($"创建工具:{info.Creator}"); Console.WriteLine($"Producer:{info.Producer}"); Console.WriteLine($"创建时间:{info.CreationDate}");

这些字段不一定都有值。很多PDF生成器只填Producer或Creator,不写标题和作者;还有一些从扫描仪直接输出的文件,整个Info字典都是空的。所以这组数据适合做“辅助判断”,不适合做文件唯一标识。用它来区分“这是哪家软件生成的”往往比区分“这是哪份文档”更有价值。

在实际项目里,我一般用Producer和Creator来判断PDF的来源类型,再决定解析策略。比如Creator里带扫描软件标识的,大概率是纯图像PDF,可以直接跳到OCR流程,省得先用page.Text试一遍。这块不复杂,但能把预处理流程的决策提前,减少无意义的文本提取尝试。

另外注意,Information里的时间和page.Text不一样,它不是从内容流里解析出来的,而是直接从文档的Info结构读取。如果PDF被某些工具重新保存过,原有Info可能被清空或改写。所以你拿到的“创建时间”可能是最近一次保存的时间,不一定是原始生成时间,做档案管理时别拿这个字段当唯一依据。

4.3 不得不读原始内容流时:Token解析的必要性

PdfPig把页面内容流解析成了Token序列,暴露出ContentStreamTokens属性。这个属性平时用不到,但遇到需要分析“为什么这个页面提取顺序不对”或“这个水印是文字还是图片”时,它就是唯一的观察窗口:

foreach (var token in page.ContentStreamTokens) { Console.WriteLine(token); }

输出内容大致是BT、Td、Tj、ET这类PDF图形操作符。BT/ET表示一段文本对象的开始和结束,Td设置文本位置,Tj显示字符串。如果你发现page.Text里某段文字重复出现,多是因为PDF内容流里同一个文本被绘制了两次——一次正常绘制,一次用于做阴影效果。这种情况看Token序列一眼就能确认,而只靠Text属性排查会绕很多弯路。

内容流Token属于“底层能力”,只建议在两种场景使用:一是排查提取结果异常时做诊断,二是做一些PDF内容流的定制分析,比如统计页面绘制指令数量、检测是否包含大段矢量图形。日常提取文本和坐标,完全不用碰它。原因很简单:内容流里的Token是PDF原始语法,要理解每个操作符的含义得熟读PDF规范,投入产出比不高,普通业务用PdfPig已经封装好的API就足够。真碰的时候,记得先在小样本上验证,不要一上来就写一套“通用内容流解释器”,那是重复造轮子。

5. PdfPig避坑指南:五条实战排错记录

5.1 页面提取为空但肉眼有字

现象:用阅读器打开PDF,页面文字清晰可见,但page.Text返回空字符串或大量空白。

原因:这个页面里的文字不是以“文本对象”绘制的,而是被转成了矢量路径。PDF允许把字体轮廓变成一系列曲线指令保存,相当于“画出来”而不是“写出来”的字,提取时就拿不到Unicode编码。另一个常见原因是字体缺少ToUnicode映射表,字形存在但不知道它对应哪个字符。

解决:先用阅读器的“选择文本”功能试试,如果鼠标在页面上选不出文本,基本就是路径绘制,换任何库都提取不了。这时把页面交给OCR是唯一出路。如果阅读器能选中但PdfPig提取为空,检查该页用到的字体资源是否包含ToUnicode映射。PdfPig里可以通过Letter.Glyph拿到字形名称,配合字体资源做二次映射,但工作量不小,普通场景不值得做。

5.2 提取出的坐标和肉眼看到的位置对不上

现象:拿BoundingBox去裁剪区域,结果裁剪出来的内容要么上下颠倒,要么整体偏移。

原因:没有考虑页面旋转和裁剪框。前面提过,PdfPig的坐标是逻辑坐标,阅读器显示时会应用Rotation和CropBox。旋转页面尤其坑,90度旋转的页面,逻辑坐标换算成视觉坐标时,x和y要互换并做减法,不少人在这一步翻了车。

解决:先打印page.Rotation和页面的宽高,确认页面有没有旋转。有旋转就套用前面给的ToVisualCoordinate函数做一次坐标缩放。如果页面是标准竖向但没有旋转,而坐标还是偏移,查看CropBox是否小于MediaBox。CropBox定义了实际显示区域,PdfPig坐标基于完整页面,需要减去裁剪框的偏移量再做后续计算。

5.3 大文件一次性遍历导致内存飙升

现象:处理几百页的PDF时,程序内存占用一路涨到几百MB,甚至OOM崩溃。

原因:很多人图省事,先把所有页面的Letters全部ToList放在内存里,再慢慢处理。Letters是每个字母的细粒度数据,一页几千个字母很常见,几十页累积下来就非常可观。而且PdfDocument没有及时Dispose,文件句柄和解析缓存一直占着。

解决:改用流式处理,遍历GetPages()后逐页提取、逐页处理,不保留中间对象。把提取结果直接写入输出文件或数据库,而不是攒在内存里做二次加工。还有一个容易忽略的习惯:整个文档处理完一定要释放PdfDocument,用using是底线。如果单页的Words对象确实需要跨页比较,只保留最小字段(文本、坐标、页码),不要持有整个Word对象。

5.4 加密PDF直接抛出异常,没有内置解密方案

现象:打开某些受保护的PDF时报错,提示文档加密或不支持该加密方式。

原因:PdfPig本身不内置解密能力。PDF有两种密码:所有者密码限制打印和修改,用户密码控制打开。PdfPig对加密文档支持很有限,遇到打不开很常见,这不是使用姿势不对。

解决:提前识别加密文档。可以用PdfDocument.Open包一层try/catch,捕获异常时标记为“需要解密”。解密这一步通常交给PDF处理工具完成,拿到明文PDF之后再喂给PdfPig。如果你的业务流里加密文件比例很高,建议在文档入库阶段就做统一解密,不要让PdfPig在运行时反复撞这个异常。

5.5 图像导出的颜色和原PDF不一致

现象:TryGetPng成功导出的图片,颜色整体偏暗、偏灰,或者透明区域变成纯黑。

原因:PDF图像有颜色空间概念,常见的有DeviceRGB、DeviceGray、Indexed颜色空间。PdfPig的TryGetPng在部分组合下是“按原始颜色空间转换”,如果原图用了不常见色彩空间或者依赖外部ICC配置文件,转换后颜色就会偏离。图片蒙版(ImageMask)导成PNG也确实会变成黑白块,因为它本来就是1位深度的透明通道数据。

解决:先看导出的图片尺寸和颜色类型,确认是不是掩码图。如果是,再从页面资源里找到对应的真彩图像导出。如果颜色只是轻微偏色,可以在后续图像处理管线里做一次颜色校正;如果严重偏色,考虑换底层的PDF渲染方案把整页渲染成位图,再从位图里截取图像区域。这个方案更重,但颜色保真度更高。选择哪个,取决于你的图片是给机器学习用还是给人看。

6. 进阶用法:把PdfPig接进文档处理管线,并加一道前置检查

6.1 提取结果结构化成记录,避免散落调用

写业务代码时最忌讳到处直接调page.GetWords(),后续换库或加缓存都要全局改。常见做法是把提取结果封装成记录类型,让上层只依赖数据而不依赖PdfPig本身:

public record WordRecord(string Text, double X, double Y, int Page); public class PdfExtractService : IDisposable { private readonly PdfDocument _document; public PdfExtractService(string path) => _document = PdfDocument.Open(path); public IEnumerable<WordRecord> ExtractAll() { foreach (var page in _document.GetPages()) { foreach (var word in page.GetWords()) { var box = word.BoundingBox; yield return new WordRecord(word.Text, box.BottomLeft.X, box.BottomLeft.Y, page.Number); } } } public void Dispose() => _document.Dispose(); }

PdfExtractService把“打开文档”和“提取数据”收敛到一个类里,上层代码只认WordRecord。这样以后想加缓存、加过滤逻辑,或者把文本提取换成OCR结果合并,都不影响调用方。yield return保证按需枚举,不会一次性把整本文档装进内存。

6.2 管线里加一道“文本可复制性”检查

接进真实管线时,我发现最划算的增强不是提高提取精度,而是提前判断这份PDF走“文本提取”还是“OCR兜底”。方法很粗糙但有效:统计每页Text里非空字符数,再除以页面高度乘宽度得到的面积,得到一个“文本密度”。密度过低,说明这页大概率是扫描件或路径绘制文本,直接分流到OCR:

var text = page.Text; var density = text.Count(c => !char.IsWhiteSpace(c)) / (page.Width * page.Height); bool needOcr = density < 0.001;

这个阈值是我在几十份不同来源PDF上试出来经验值。正常文本页的密度远高于它,纯扫描页则基本为0。判断后把需要OCR的页码和坐标区域记录下来,后续批量丢给OCR服务时,还能结合之前拿到的区域坐标做定向识别,比整页OCR快很多。

这条经验来自一次真实教训:某回单解析项目上线第一天,跑批结果里混入了大量“提取为空”的扫描件,当时排查到深夜才发现问题不在PdfPig,而是数据源里混了三种不同来源的PDF,其中一类是纯扫描。从那以后,我养成了在解析管线入口先做可复制性检测的习惯,把“这页能不能提”当成第一个检查点,而不是等提出来是空的再补查。这个方向做下来整体效果稳定不少,希望帮到你。

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

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

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

立即咨询