简介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页面坐标系里的位置单位是点point1/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图像可能用JPXDecodeJPEG 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 IEnumerableWordRecord 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里非空字符数再除以页面高度乘宽度得到的面积得到一个“文本密度”。密度过低说明这页大概率是扫描件或路径绘制文本直接分流到OCRvar 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其中一类是纯扫描。从那以后我养成了在解析管线入口先做可复制性检测的习惯把“这页能不能提”当成第一个检查点而不是等提出来是空的再补查。这个方向做下来整体效果稳定不少希望帮到你。本文还有配套的精品资源点击获取