☰
iTextSharp PDF合并与分卷实战:从内存优化到书签处理
2026/10/7 9:43:07 网站建设 项目流程

简介:这份资源面向.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 而不是 PdfWriter

iTextSharp 里能写 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 返回 PdfImportedPage,AddPage 写入输出 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 是常见归档要求,但实际值取决于业务。如果每页平均 200KB,500 页大约 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)而不是逐页AddPage,AddDocument会保留部分文档结构。

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 个文件,跑到一半发现书签丢了,只能重来。希望帮到你。

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

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

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

立即咨询