
简介这份资源面向.NET平台下需要处理PDF文档的C#开发者聚焦iTextSharp库在PDF合并与分卷场景中的实际应用。内容以Windows Forms示例工程为载体演示如何通过PdfReader、PdfCopy、PdfSmartCopy与PdfStamper等核心类完成多文档合并、按页码范围拆分、权限与元数据处理以及资源释放和异常处理等关键环节适合具备一定C#基础、希望快速上手PDF批处理的中级开发者参考。压缩包共63个文件约6.6MB包含21个PDF示例文档、7个cs源码文件、4个dll依赖库以及config配置、resx窗体资源、csproj与sln工程文件等覆盖从源码到可执行程序的完整结构。目前已有492人学习下载。借助这套工程读者可对照源码理解合并与分卷的调用流程掌握NuGet依赖引入、界面事件绑定与后台处理逻辑并可直接运行exe验证效果为报表整合、资料归档等批量PDF处理需求提供可复用的实现思路。1. 从一份 3000 页的合同归档说起iTextSharp 合并与分卷到底解决什么问题去年接手一个法务系统的归档模块客户丢过来一个目录里面躺着 1200 多份单页扫描件要求按合同编号合并成整本 PDF同时每本超过 500 页必须自动切成多卷卷名带序号还要保留原始书签。手工用 PDF 编辑器点了一下午翻车三次——页码错位、书签丢失、文件体积暴涨。后来用 iTextSharp 写了个批处理二十分钟跑完全部。这就是 iTextSharp 在 PDF 合并与分卷场景里的真实价值它不是给你一个图形界面而是给你一套可以嵌进业务流程的 API让「合并」和「分卷」变成两个可配置、可复现、可回滚的操作。iTextSharp 是 iText 的 .NET 移植版本核心能力围绕PdfReader读和PdfWriter/PdfCopy/PdfSmartCopy写展开。合并的本质是把多个PdfReader的页面逐个ImportPage到一个输出文档分卷的本质是在写入过程中监控页数或文件体积达到阈值就关闭当前Document、换一个新文件名继续写。听起来简单但真正落地时会遇到三类问题一是书签和表单域在合并后失效二是分卷时页面内容被截断或重复三是大文件合并时内存直接爆掉。这篇笔记按「先跑通最小合并 → 再讲分卷策略 → 最后处理书签和体积」的顺序展开适合正在做文档归档、报表批量导出、扫描件整理的 .NET 开发者。如果你用的是 Java 版 iText思路完全一致只是类名和包路径不同。2. 用 PdfCopy 跑通最小合并从两个文件到一百个文件的命令与参数2.1 为什么选 PdfCopy 而不是 PdfWriteriTextSharp 里能写 PDF 的类不止一个。PdfWriter适合从零生成内容PdfCopy适合把已有页面原样搬运PdfSmartCopy在PdfCopy基础上做了去重适合大量重复资源的场景。合并已有 PDF 必须用PdfCopy或PdfSmartCopy因为只有它们提供了AddPage和ImportPage这类页面级操作。用PdfWriter硬合并会导致字体、图片资源重复嵌入一个 10MB 的文件合并五次能涨到 80MB这是血泪经验。选型判断很简单如果待合并文件之间共享大量相同字体或图片比如同一套模板生成的报表用PdfSmartCopy如果文件来源杂、资源差异大用PdfCopy更稳因为PdfSmartCopy的去重逻辑在极端情况下会引入额外的内存开销。我一般默认用PdfCopy只有在明确知道有大量重复资源且内存充足时才切PdfSmartCopy。2.2 最小可运行合并代码下面这段代码合并指定目录下所有 PDF按文件名排序输出到merged.pdf。用的是 iTextSharp 5.x 的命名空间如果你用的是 iText 7类名要换成PdfDocument和PdfMerger逻辑一致。using System; using System.IO; using System.Linq; using iTextSharp.text; using iTextSharp.text.pdf; class PdfMergeBasic { static void Main() { string inputDir D:\contracts\scans; string outputFile D:\contracts\merged.pdf; // 按文件名排序保证合并顺序可控 var files Directory.GetFiles(inputDir, *.pdf) .OrderBy(f f) .ToArray(); if (files.Length 0) { Console.WriteLine(没有找到 PDF 文件); return; } // 用第一个文件初始化 Document 的页面尺寸 using (var firstReader new PdfReader(files[0])) using (var fs new FileStream(outputFile, 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) { // ImportPage 返回 PdfImportedPageAddPage 写入输出 var page copy.GetImportedPage(reader, i); copy.AddPage(page); } } } copy.Close(); } Console.WriteLine($合并完成共 {files.Length} 个文件); } }逻辑说明PdfReader负责读取源文件PdfCopy负责写入目标文件。GetImportedPage把源页面转成可写入的PdfImportedPage对象AddPage把它追加到输出文档。注意copy.Open()必须在循环之前调用copy.Close()在循环之后调用否则会抛「文档未打开」异常。参数说明Document构造函数不传参数时使用默认 A4 尺寸但合并场景下页面尺寸由ImportPage自动继承源页面所以这里传不传影响不大。FileStream的FileMode.Create会覆盖同名文件如果要追加到已有文件需要改用PdfStamper那是另一套逻辑。2.3 合并一百个文件时的内存控制上面的代码在合并 10 个文件时没问题合并 100 个、每个 50MB 的文件时内存会飙到 2GB 以上。原因是PdfReader默认把整个文件缓存在内存里。解决办法是在PdfReader构造函数里传入RandomAccessFileOrArray并启用部分读取模式using (var raf new RandomAccessFileOrArray(file)) using (var reader new PdfReader(raf, null)) { // 页面处理逻辑不变 }RandomAccessFileOrArray让 iTextSharp 按需读取文件片段而不是一次性加载。实测合并 100 个 50MB 文件时内存峰值从 2.3GB 降到 400MB 左右。代价是读取速度略慢大约多花 15% 的时间但换来的是不会 OOM。提示如果源文件在网络上比如共享目录先把文件复制到本地临时目录再合并直接读网络路径会让RandomAccessFileOrArray的随机读取性能急剧下降。3. 分卷策略按页数切、按体积切还是两者结合3.1 按页数分卷的实现分卷的核心思路是在写入每一页之前检查当前卷的页数如果达到阈值就关闭当前PdfCopy新建一个PdfCopy和新的输出文件继续写下一页。下面是一个按页数分卷的完整实现每卷最多 500 页。using System; using System.IO; using System.Linq; using iTextSharp.text; using iTextSharp.text.pdf; class PdfSplitByPage { static void Main() { string inputDir D:\contracts\scans; string outputDir D:\contracts\volumes; int maxPagesPerVolume 500; Directory.CreateDirectory(outputDir); var files Directory.GetFiles(inputDir, *.pdf) .OrderBy(f f) .ToArray(); int volumeIndex 1; int currentPageCount 0; Document document null; PdfCopy copy null; FileStream fs null; try { foreach (var file in files) { using (var reader new PdfReader(file)) { for (int i 1; i reader.NumberOfPages; i) { // 达到阈值关闭当前卷开新卷 if (currentPageCount maxPagesPerVolume) { copy.Close(); fs.Close(); volumeIndex; currentPageCount 0; } // 首次或刚切卷时初始化输出 if (copy null || currentPageCount 0) { string outputFile Path.Combine( outputDir, $volume_{volumeIndex:D3}.pdf); fs new FileStream(outputFile, FileMode.Create); document new Document(); copy new PdfCopy(document, fs); copy.Open(); } var page copy.GetImportedPage(reader, i); copy.AddPage(page); currentPageCount; } } } } finally { // 确保最后一卷被关闭 if (copy ! null) copy.Close(); if (fs ! null) fs.Close(); } Console.WriteLine($分卷完成共 {volumeIndex} 卷); } }逻辑说明currentPageCount记录当前卷已写入的页数。每写一页之前检查是否达到maxPagesPerVolume达到就关闭当前PdfCopy和FileStream卷号加一计数器归零。注意copy.Close()会同时关闭底层的FileStream所以不需要重复关闭但为了代码清晰我显式写了fs.Close()。参数说明maxPagesPerVolume设为 500 是常见归档要求但实际值取决于业务。如果每页平均 200KB500 页大约 100MB适合邮件传输如果每页是高清扫描图可能 2MB 一页500 页就是 1GB这时候应该按体积分卷。3.2 按体积分卷的难点与折中方案按体积分卷比按页数复杂因为 iTextSharp 在写入过程中不提供「当前文件已写多少字节」的实时查询。FileStream.Length在PdfCopy缓冲未刷新时不准。可行的做法是先按页数分卷分完后检查每卷体积如果超过阈值就对该卷再按页数二次切分。或者用估算方式在合并前计算所有源文件的平均页体积反推每卷页数。我一般用二次切分法因为准确且不依赖估算。具体做法是第一轮按 500 页分卷第二轮遍历输出目录对每个超过 200MB 的卷用PdfReader重新读取并按页数对半切。这样最多两轮就能得到体积可控的卷。// 二次切分对超大卷按页数对半切 static void SplitLargeVolume(string volumePath, string outputDir, long maxSizeBytes) { var fi new FileInfo(volumePath); if (fi.Length maxSizeBytes) return; using (var reader new PdfReader(volumePath)) { int totalPages reader.NumberOfPages; int half totalPages / 2; // 前半部分 using (var fs new FileStream( Path.Combine(outputDir, Path.GetFileNameWithoutExtension(volumePath) _a.pdf), FileMode.Create)) using (var copy new PdfCopy(new Document(), fs)) { copy.Open(); for (int i 1; i half; i) copy.AddPage(copy.GetImportedPage(reader, i)); copy.Close(); } // 后半部分 using (var fs new FileStream( Path.Combine(outputDir, Path.GetFileNameWithoutExtension(volumePath) _b.pdf), FileMode.Create)) using (var copy new PdfCopy(new Document(), fs)) { copy.Open(); for (int i half 1; i totalPages; i) copy.AddPage(copy.GetImportedPage(reader, i)); copy.Close(); } } }这个方法的边界是如果单页体积就超过阈值对半切也没用只能接受。实际项目中单页超过 200MB 的情况极少真遇到了应该先压缩图片再合并。3.3 分卷时的书签与页码连续性分卷后每卷的页码会从 1 重新开始但业务上往往要求「第 2 卷第 1 页」显示为「第 501 页」。这需要在写入时用PdfCopy的SetPageAction或后续用PdfStamper添加页脚。更简单的做法是在分卷时记录每卷的起始页码生成一个索引文件让用户在索引里跳转。书签的处理更麻烦。PdfCopy不会自动复制源文件的书签需要用PdfOutline手动重建。如果源文件没有书签分卷后也不需要如果源文件有书签建议在合并前先用PdfReader读取书签树合并时按卷重新映射页码。这部分代码量较大下一章会给出一个简化版实现。4. 合并分卷中的避坑与排查书签丢失、内存溢出、页面错位4.1 合并后书签全部消失现象源文件有完整书签合并后输出文件书签为空。原因PdfCopy的AddPage只搬运页面内容不搬运文档级结构书签、命名目标、元数据都不在搬运范围内。解决用PdfReader的GetOutlines读取源书签在输出文档上用PdfOutline重建。简化做法是只保留一级书签按合并顺序映射页码。如果书签层级很深建议用PdfSmartCopy并手动调用copy.AddDocument(reader)而不是逐页AddPageAddDocument会保留部分文档结构。4.2 合并大文件时 OutOfMemoryException现象合并 50 个以上大文件时进程崩溃异常信息为OutOfMemoryException。原因PdfReader默认全量加载文件到内存且PdfCopy的缓冲区不会及时释放。解决用RandomAccessFileOrArray包装文件流启用按需读取同时在每处理完一个源文件后调用reader.Close()并置空引用让 GC 有机会回收。如果还是不够把合并任务拆成多个批次每批 20 个文件批间输出中间文件最后再合并中间文件。4.3 分卷后页面内容被截断现象某一卷的最后一页只显示了一半内容或者页面尺寸异常。原因在copy.Close()之前没有正确结束当前页面或者Document的页面尺寸与源页面不一致导致缩放。解决确保每一页都通过AddPage写入后再关闭。如果页面尺寸不一致在Document构造函数里传入PageSize.A4并设置copy.SetMargins(0, 0, 0, 0)让 iTextSharp 自动缩放。但自动缩放会改变内容比例更好的做法是保持源页面尺寸在Document打开前用reader.GetPageSize(i)获取尺寸并设置。4.4 分卷文件命名冲突导致覆盖现象分卷输出目录里只有最后一卷前面的卷被覆盖。原因卷号变量在循环外初始化但文件名生成逻辑用了固定名称或者FileMode.Create在每次打开时覆盖了同名文件。解决卷号必须在每次切卷时递增且文件名包含卷号。用FileMode.CreateNew替代FileMode.Create如果文件已存在会抛异常能及早发现命名冲突。另外注意volumeIndex的初始值和递增时机建议在切卷逻辑里先递增再生成文件名。4.5 合并后文件体积异常膨胀现象10 个 5MB 的文件合并后输出 200MB。原因每个源文件的字体、图片资源被重复嵌入PdfCopy不做去重。解决改用PdfSmartCopy它会检测重复资源并只嵌入一次。如果PdfSmartCopy效果不理想可以在合并前用PdfStamper对每个源文件做一次资源压缩或者用Ghostscript预处理。实测PdfSmartCopy在报表类 PDF 上能把体积压到PdfCopy的 30% 左右。5. 进阶用 PdfStamper 做分卷后的页码重排与索引生成分卷完成后业务方通常要求每卷有独立的页码和一份总索引。页码重排用PdfStamper实现读取分卷文件在每一页的页脚位置写入全局页码。全局页码的计算方式是当前卷起始页码 当前页在卷内的序号。起始页码在分卷时记录到一个字典里。using System; using System.Collections.Generic; using System.IO; using iTextSharp.text; using iTextSharp.text.pdf; class PageNumberStamper { // volumeStartPages: 卷文件名 - 该卷的全局起始页码 static void AddGlobalPageNumbers( string volumePath, int startPageNumber, string outputPath) { using (var reader new PdfReader(volumePath)) using (var fs new FileStream(outputPath, FileMode.Create)) using (var stamper new PdfStamper(reader, fs)) { int pageCount reader.NumberOfPages; var font BaseFont.CreateFont( BaseFont.HELVETICA, BaseFont.WINANSI, BaseFont.NOT_EMBEDDED); for (int i 1; i pageCount; i) { // 获取页面内容层在页脚位置写页码 var content stamper.GetOverContent(i); var pageSize reader.GetPageSize(i); ColumnText.ShowTextAligned( content, Element.ALIGN_CENTER, new Phrase( (startPageNumber i - 1).ToString(), new Font(font, 10)), pageSize.Width / 2, 30, // 距底部 30 点 0); } stamper.Close(); } } }逻辑说明PdfStamper在已有 PDF 上叠加内容GetOverContent获取页面内容层ColumnText.ShowTextAligned在指定坐标写入文本。startPageNumber是该卷第一页对应的全局页码由分卷时的记录传入。参数说明pageSize.Width / 2是水平居中30是距底部距离单位是点1 点约 0.35 毫米。字体用HELVETICA不嵌入避免体积增加如果页码需要中文必须嵌入中文字体体积会增加 2-3MB。索引生成更简单分卷完成后遍历输出目录用PdfReader读取每卷的页数和起始页码生成一个index.txt或一个单独的 PDF 索引页。我一般生成一个 CSV包含卷文件名、起始页码、结束页码、页数方便业务系统导入。注意PdfStamper会重写整个文件如果分卷文件很大超过 500MB这个过程可能耗时几分钟。建议在分卷时直接写入页码而不是分卷后再用PdfStamper二次处理。分卷时写入页码的方法是在PdfCopy的AddPage之后用copy.SetPageAction或直接在Document上添加页脚但PdfCopy对页脚的支持有限实际项目中还是PdfStamper更可控。一个我踩过的坑PdfStamper在Close时会重写文件如果输出路径和输入路径相同会抛「文件被占用」异常。必须输出到新文件然后用File.Replace替换原文件。另外如果分卷文件有加密PdfReader需要传入密码否则PdfStamper会失败。最后说一个习惯每次合并分卷前先拿 3 个文件跑一遍全流程检查书签、页码、体积、命名四项。这四项没问题了再上全量。我见过太多人直接跑 1000 个文件跑到一半发现书签丢了只能重来。希望帮到你。本文还有配套的精品资源点击获取