2026/10/26 16:45:37

iTextSharp PDF合并与分卷实战:从能跑通到能上线的避坑指南

iTextSharp PDF合并与分卷实战:从能跑通到能上线的避坑指南 简介这份资源面向具备一定C#基础的.NET开发者聚焦于使用iTextSharp库实现PDF文档的合并与分卷操作适合需要批量整合报告、整理票据资料或拆分大体积PDF的办公与开发场景。压缩包共63个文件约6.6MB以21个PDF示例文档、7个cs源码文件为主辅以dll依赖库、config配置、resx窗体资源及csproj、sln工程文件构成一个可直接运行的Windows Forms示例项目。项目通过PdfReader读取页面配合PdfCopy、PdfSmartCopy完成合并并借助PdfStamper按页码范围实现分卷同时涉及权限与元数据处理、资源释放等细节。已有492人学习下载读者可参考完整工程结构与源码快速理解iTextSharp在PDF合并与分卷中的实际用法并将其迁移到自己的文档处理工具中。1. iTextSharp 合并与分卷为什么“能跑通”和“能上线”是两回事做过 PDF 批处理的同学大概率都遇到过这个场景一批合同、发票或者扫描件需要按客户、按月份合并成一个大文件或者反过来一个几百页的 PDF 要按章节、按页码拆成若干小文件。用 iTextSharp 写个 demo几十行代码就能跑通但真放到生产环境内存、字体、书签、加密、并发这些问题会一个接一个冒出来。iTextSharp 是 .NET 平台下最老牌的 PDF 操作库之一合并Merge和分卷Split是它最常被用到的两个能力也是坑最集中的两个能力。这篇笔记不讲空泛的 API 罗列而是按“先搞清楚它怎么工作、再动手写能复现的代码、最后把踩过的坑摊开”这条线走。适合已经会用 C# 读写文件、但还没把 PDF 批处理真正跑稳的工程师也适合正在评估“这个方向值不值得投入”的技术负责人。2. iTextSharp 合并 PDF 的底层逻辑与最小可运行代码2.1 合并到底在做什么页面树、资源字典和交叉引用表很多人以为合并 PDF 就是把两个文件的字节拼在一起这是最大的误解。PDF 内部是一棵页面树Page Tree每个页面对象引用自己的资源字典字体、图像、颜色空间文件末尾还有交叉引用表xref和 trailer。合并的本质是把多个文档的页面对象重新挂到一棵新的页面树上同时把被引用到的资源对象复制过去最后重建 xref。iTextSharp 的PdfCopy类就是干这件事的它不像PdfWriter那样重新“画”一遍内容而是直接搬运页面对象所以速度快、保真度高。但代价是如果源文件里有表单域、注释、书签默认行为不一定符合预期需要显式配置。理解这一点后面所有参数和坑才有落脚点。2.2 用 PdfCopy 合并多个 PDF 的最小命令下面这段代码是我一般会先跑通的最小版本合并一个目录下所有 PDF按文件名排序using iTextSharp.text; using iTextSharp.text.pdf; using System; using System.IO; public static void MergePdfs(string inputDir, string outputPath) { // 按文件名排序保证合并顺序可控 var files Directory.GetFiles(inputDir, *.pdf); Array.Sort(files, StringComparer.OrdinalIgnoreCase); using (var fs new FileStream(outputPath, FileMode.Create)) using (var copy new PdfCopy(new Document(), fs)) { copy.Open(); foreach (var file in files) { using (var reader new PdfReader(file)) { int pageCount reader.NumberOfPages; for (int i 1; i pageCount; i) { // 直接搬运页面对象不重新渲染 var page copy.GetImportedPage(reader, i); copy.AddPage(page); } // 保留源文件书签后面章节会展开讲 copy.AddNamedDestinations( SimpleBookmark.GetBookmark(reader), copy.CurrentPageNumber - pageCount); } } copy.Close(); } }逻辑说明PdfCopy构造时传入一个Document和输出流Open()之后才能AddPage。GetImportedPage是关键它把源 reader 的第 i 页导入当前 copy 的上下文返回一个可添加的页面对象。AddNamedDestinations用来迁移书签第二个参数是页码偏移量这里用CurrentPageNumber - pageCount把源书签的页码映射到合并后的绝对页码。参数说明Document的页面尺寸默认是 A4但PdfCopy会以导入页面的实际尺寸为准所以一般不用改。如果源文件页面尺寸不一合并后每页保持原尺寸这是符合预期的。PdfReader默认会解析整个文件大文件建议用new PdfReader(file)后不要长期持有及时Close()。2.3 合并时保留书签、表单和页面标签的三个开关默认的PdfCopy不会自动保留书签、表单域和页面标签Page Labels需要手动处理。书签用SimpleBookmark.GetBookmark和AddNamedDestinations上面已经演示。表单域要用PdfCopy的SetMergeFields()方法但注意如果多个源文件有同名字段合并后会冲突常见做法是合并前给字段名加前缀。页面标签用copy.SetPageLabels()传入一个字符串数组比如new[] { i, ii, 1, 2 }用来控制罗马数字和阿拉伯数字的切换。这三个开关不是必须的但如果你合并的是合同或标书书签和页码标签缺失会直接被业务方打回来。提示PdfCopy不支持合并带密码的 PDF如果源文件有打开密码需要先用PdfReader的Decrypt方法解密或者用new PdfReader(file, new byte[] { })传空密码尝试。3. PDF 分卷的三种切法按页码、按书签、按内容边界3.1 按固定页码分卷最直接也最容易翻车按页码分卷是最常见的需求比如每 100 页切一个文件。核心逻辑是遍历源 PDF 的页面每满 N 页就新建一个PdfCopy输出。下面是一个可复现的版本public static void SplitByPageCount(string inputPath, string outputDir, int pagesPerFile) { using (var reader new PdfReader(inputPath)) { int total reader.NumberOfPages; int fileIndex 1; for (int start 1; start total; start pagesPerFile) { int end Math.Min(start pagesPerFile - 1, total); string outPath Path.Combine(outputDir, $part_{fileIndex:D3}.pdf); using (var fs new FileStream(outPath, FileMode.Create)) using (var copy new PdfCopy(new Document(), fs)) { copy.Open(); for (int i start; i end; i) { copy.AddPage(copy.GetImportedPage(reader, i)); } copy.Close(); } fileIndex; } } }逻辑说明外层循环控制起始页内层循环把start到end的页面导入新的PdfCopy。每个分卷文件独立打开和关闭互不影响。fileIndex用三位补零保证文件名排序正确。参数说明pagesPerFile建议不要超过 500否则单个文件过大后续传输和打开都慢。如果源文件有书签分卷后书签会丢失需要按页码区间重新映射这个在 3.2 节展开。3.2 按书签分卷让分卷结果和文档结构对齐如果源 PDF 有完整的书签树按书签分卷比按页码更符合业务预期。思路是读取书签拿到每个一级书签对应的页码然后以这些页码为切分点。SimpleBookmark.GetBookmark返回一个ListDictionarystring, object每个字典的Page键是页码字符串Title是书签标题。public static void SplitByBookmark(string inputPath, string outputDir) { using (var reader new PdfReader(inputPath)) { var bookmarks SimpleBookmark.GetBookmark(reader); if (bookmarks null || bookmarks.Count 0) throw new InvalidOperationException(源文件没有书签无法按书签分卷); for (int i 0; i bookmarks.Count; i) { int startPage int.Parse(bookmarks[i][Page].ToString().Split( )[0]); int endPage (i 1 bookmarks.Count) ? int.Parse(bookmarks[i 1][Page].ToString().Split( )[0]) - 1 : reader.NumberOfPages; string title bookmarks[i][Title].ToString(); string safeTitle string.Join(_, title.Split(Path.GetInvalidFileNameChars())); string outPath Path.Combine(outputDir, ${i 1:D2}_{safeTitle}.pdf); using (var fs new FileStream(outPath, FileMode.Create)) using (var copy new PdfCopy(new Document(), fs)) { copy.Open(); for (int p startPage; p endPage; p) { copy.AddPage(copy.GetImportedPage(reader, p)); } copy.Close(); } } } }逻辑说明书签的Page值格式是页码 坐标用空格分割取第一段就是页码。最后一个书签的结束页是总页数。文件名用书签标题但要去掉非法字符否则FileStream会直接抛异常。参数说明如果书签有多级GetBookmark返回的是扁平列表还是嵌套列表取决于源文件常见做法是只取一级书签。如果书签页码不是从 1 开始需要先做偏移校正。3.3 按内容边界分卷用文本特征找切分点有些 PDF 没有书签页码也不规律但每章开头有固定文本比如“第 X 章”或“Chapter X”。这时可以用 iTextSharp 的PdfTextExtractor逐页提取文本匹配到特征就记为切分点。这个方案比前两种慢因为要解析每一页的文本但灵活性最高。using iTextSharp.text.pdf.parser; public static Listint FindChapterPages(string inputPath, string pattern) { var pages new Listint(); using (var reader new PdfReader(inputPath)) { for (int i 1; i reader.NumberOfPages; i) { string text PdfTextExtractor.GetTextFromPage(reader, i); if (System.Text.RegularExpressions.Regex.IsMatch(text, pattern)) { pages.Add(i); } } } return pages; }逻辑说明PdfTextExtractor.GetTextFromPage返回当前页的纯文本用正则匹配章节标题。匹配到的页码就是切分点后续分卷逻辑和 3.2 节一致。参数说明pattern建议用^第[一二三四五六七八九十\d]章这种锚定行首的正则避免正文里偶然出现“第 X 章”造成误切。文本提取对扫描件无效扫描件需要先做 OCR这超出了 iTextSharp 的能力范围。4. 合并与分卷的避坑清单内存、字体、加密和并发4.1 大文件合并时 OutOfMemoryException 的排查现象合并几十个上百 MB 的 PDF 时进程内存飙升最终抛OutOfMemoryException。原因PdfReader默认会把整个文件读进内存PdfCopy也会在内存里维护对象映射表。解决一是用PdfReader的RandomAccessFileOrArray配合PartialRead但 iTextSharp 5.x 对部分读取支持有限二是分批合并比如每 20 个文件合并成一个中间文件再合并中间文件三是把目标平台从 32 位改成 64 位并设置gcAllowVeryLargeObjects。我一般会先算一下总页数和平均每页对象数超过 5000 页就主动分批。4.2 合并后字体乱码或中文变方块的根因现象合并后的 PDF 里中文显示为方块或乱码。原因源文件使用了非嵌入字体或者PdfCopy在复制资源时丢失了字体子集。解决合并前用PdfReader检查字体是否嵌入AcroFields和PdfReader.KillIndirect不是正确做法。更稳妥的方案是合并时用PdfSmartCopy替代PdfCopyPdfSmartCopy会去重资源对象对字体处理更友好。如果源文件本身字体就没嵌入任何库都救不了只能重新生成 PDF。4.3 加密 PDF 合并时抛 BadPasswordException 的处理现象合并带密码的 PDF 时抛BadPasswordException。原因PdfReader默认用空密码打开如果文件有用户密码或所有者密码就会失败。解决如果知道密码用new PdfReader(file, Encoding.UTF8.GetBytes(password))如果不知道密码但允许打印可以用PdfReader的Decrypt方法配合所有者密码。注意不要试图绕过加密这涉及合规问题正确做法是让业务方提供密码或未加密版本。4.4 多线程并发调用 PdfCopy 的线程安全问题现象在 ASP.NET 或控制台多线程环境下多个线程同时调用PdfCopy合并结果文件损坏或抛InvalidOperationException。原因iTextSharp 的PdfCopy和PdfReader都不是线程安全的内部有共享状态。解决每个线程用独立的PdfReader和PdfCopy实例不要复用。如果必须并发用lock包住整个合并过程或者用Parallel.ForEach时每个分区独立输出文件。我一般会在方法入口加一个lock对象虽然牺牲一点吞吐但稳定性优先。4.5 分卷后书签和页码标签丢失的补救现象分卷后的文件没有书签页码标签也重置了。原因PdfCopy默认不迁移书签和页面标签。解决分卷时如果源文件有书签按 3.2 节的逻辑把对应区间的书签迁移过去页码标签用SetPageLabels重新设置。如果分卷是按页码切的书签需要按切分点重新计算页码偏移。这个工作不复杂但容易被忽略导致交付的文件“看起来能用用起来别扭”。5. 把合并分卷做成可复用的批处理工具参数、日志和验证5.1 用配置文件驱动合并分卷任务把输入目录、输出目录、分卷模式、每卷页数、是否保留书签这些参数抽到 JSON 配置文件里代码只读配置执行。这样业务方改需求不用重新编译。下面是一个配置示例和读取代码{ mode: split, inputPath: D:\\pdf\\source.pdf, outputDir: D:\\pdf\\output, pagesPerFile: 200, keepBookmarks: true, keepPageLabels: true }using Newtonsoft.Json.Linq; public static JObject LoadConfig(string configPath) { string json File.ReadAllText(configPath); return JObject.Parse(json); }逻辑说明mode决定走合并还是分卷pagesPerFile只在分卷模式生效keepBookmarks和keepPageLabels控制是否迁移书签和页面标签。读取用JObject比强类型更灵活适合参数经常变的场景。参数说明inputPath在合并模式下应该是目录在分卷模式下应该是单个文件。如果配置写错代码要在入口处校验并给出明确错误信息不要等到PdfReader抛异常才发现。5.2 合并分卷的日志与结果验证批处理工具一定要有日志记录每个文件的页数、耗时、输出路径和异常。验证环节至少做两件事一是用PdfReader重新打开输出文件检查NumberOfPages是否等于预期二是抽查几页的文本确认没有乱码。下面是一个简单的验证方法public static bool ValidateOutput(string outputPath, int expectedPages) { try { using (var reader new PdfReader(outputPath)) { if (reader.NumberOfPages ! expectedPages) return false; // 抽查第一页文本确认可读 string text PdfTextExtractor.GetTextFromPage(reader, 1); return !string.IsNullOrWhiteSpace(text); } } catch { return false; } }逻辑说明NumberOfPages是最基本的校验文本抽查能发现字体丢失导致的空白页。如果验证失败日志里要记录输出路径和预期页数方便回溯。参数说明expectedPages在合并模式下是源文件页数之和在分卷模式下是每个分卷的页数。如果源文件有空白页文本抽查可能返回空这时要结合页数校验一起判断。5.3 性能调优批量合并时把耗时从分钟压到秒级合并 100 个 10 页的 PDF默认写法可能要几十秒。优化点有三个一是用PdfSmartCopy替代PdfCopy减少重复资源复制二是把PdfReader的Appendable设为false避免不必要的写入准备三是用MemoryStream做中间缓冲减少磁盘 IO。实测下来PdfSmartCopy在资源重复率高的场景能快 30% 以上。如果还是慢就要考虑把合并任务拆到多个进程每个进程处理一部分文件最后再合并中间结果。注意PdfSmartCopy会占用更多内存来维护对象哈希表如果内存紧张还是用PdfCopy更稳。5.4 一个容易忽略的细节输出文件的权限和占用在 Windows 上如果输出文件已经被其他进程打开比如 PDF 阅读器FileStream会抛IOException。解决输出前检查文件是否存在且被占用或者用带FileShare.None的FileStream并捕获异常。另外输出目录要有写权限ASP.NET 环境下IIS_IUSRS或应用程序池账户需要授权。这个坑不复杂但线上出问题时排查起来很费时间建议在日志里把输出路径和异常信息打全。写到这儿我的习惯是任何 PDF 批处理任务先跑通最小合并和分卷再拿真实业务文件压测最后把配置、日志、验证三件套补齐。iTextSharp 不新但足够稳关键是别把它当黑匣子用。希望帮到你。本文还有配套的精品资源点击获取