简介:这份资源面向.NET平台下需要处理PDF文档的C#开发者,聚焦于使用iTextSharp库实现PDF合并与分卷的完整示例工程。资源包共63个文件,约6.6MB,包含7个cs源码文件、21个pdf测试文档、4个dll依赖库以及csproj、sln、config等工程配置,另有nupkg包与说明文档,结构完整可直接编译运行。已有492人学习下载。工程以Windows Forms界面演示,核心逻辑涵盖PdfReader读取页面、PdfCopy与PdfSmartCopy合并文档、PdfStamper按页码范围分卷输出,并涉及权限与元数据处理、资源释放与异常处理等要点。配套的多个真实PDF样本可用于验证合并与拆分效果,帮助读者快速理解iTextSharp的API调用方式,并将其迁移到报告整合、资料归档等实际业务场景中。
1. iTextSharp 做 PDF 合并与分卷:为什么“能跑”和“能上线”是两回事
做过 PDF 批量处理的人多半有过这种经历:本地拿 iTextSharp 写个循环,几十个文件合并成一个,跑得挺顺;一放到生产环境,几百上千份、带扫描图、带中文、带表单的 PDF 一涌进来,内存直接飙到几个 G,进程被系统干掉,或者合并出来的文件打开是空白页。PDF 的合并与分卷看着只是“把文件拼起来”和“按页切开”,但真正决定能不能上线的,是内存模型、字体资源、页面尺寸和书签这几件事。iTextSharp 是 .NET 平台上最常被拿来干这活的库之一,它把 PDF 的底层对象模型暴露得比较充分,代价就是你必须理解 PdfReader、PdfWriter、PdfStamper 这几个类各自的内存行为。这篇笔记面向的是需要把 PDF 合并/分卷做成一个稳定服务的 .NET 工程师,从最小可跑代码一路讲到分卷边界、资源释放和线上排查,中间会给出可直接抄的参数和命令。如果你只是偶尔合两个文件,随便找个在线工具就行;但只要涉及批量、自动化、带业务规则,iTextSharp 这套东西值得花时间吃透。
2. iTextSharp 合并 PDF 的最小闭环:从 PdfReader 到 PdfWriter
2.1 为什么合并要用 PdfCopy 而不是 PdfWriter 直接写
很多人第一次写合并,直觉是 new 一个 Document,然后循环把每个源文件的内容“加”进去。这条路在 iTextSharp 里走不通,因为 PDF 不是流式文本,页面里引用了字体、图像、颜色空间等一堆间接对象,简单拼接会导致资源引用错乱,典型表现就是合并后中文变方框、图片丢失。正确做法是用PdfCopy(老版本叫PdfCopy,iText 5 之后是PdfCopy配合PdfReader),它会把每个源文档的页面对象连同其依赖资源一起复制到目标文档,并重建交叉引用表。
选型上还有一条分叉:PdfCopy适合“整页搬运”,PdfSmartCopy适合“有大量重复资源”的场景。PdfSmartCopy会做资源去重,比如一百份 PDF 用的是同一套字体,它能只存一份,输出体积明显小;代价是它需要在内存里维护一张资源哈希表,文件特别多时内存占用反而更高。我的经验是:源文件数量在 200 以内、且字体图片重复度高,用PdfSmartCopy;超过这个量级或者源文件彼此独立,老老实实用PdfCopy,把内存压住。
2.2 可抄作业的合并代码与参数说明
下面这段是合并的最小可用版本,基于 iTextSharp 5.x 的命名空间(iTextSharp.text、iTextSharp.text.pdf)。注意PdfReader必须显式关闭,否则文件句柄会一直挂着,Windows 上表现为“文件被占用无法删除”。
using System; using System.Collections.Generic; using System.IO; using iTextSharp.text; using iTextSharp.text.pdf; public class PdfMerger { // sources: 待合并的 PDF 绝对路径列表 // outputPath: 合并后输出路径 public static void Merge(List<string> sources, string outputPath) { if (sources == null || sources.Count == 0) throw new ArgumentException("sources 不能为空"); Document document = new Document(); PdfCopy copy = null; var readers = new List<PdfReader>(); try { // 用 FileStream 显式控制输出,避免覆盖时被占用 var fs = new FileStream(outputPath, FileMode.Create, FileAccess.Write); copy = new PdfCopy(document, fs); document.Open(); foreach (var path in sources) { var reader = new PdfReader(path); readers.Add(reader); int pageCount = reader.NumberOfPages; for (int i = 1; i <= pageCount; i++) // iTextSharp 页码从 1 开始 { var page = copy.GetImportedPage(reader, i); copy.AddPage(page); } } } finally { // 顺序很重要:先关 document,再关 reader if (document.IsOpen()) document.Close(); foreach (var r in readers) { r.Close(); } } } }逻辑说明:PdfCopy构造时绑定Document和输出流,document.Open()之后才能AddPage。GetImportedPage是关键,它把源页面“导入”到目标文档的上下文里,而不是直接引用源 reader,这样源 reader 关闭后目标文档依然完整。参数上,FileMode.Create会覆盖同名文件,如果你需要追加到已有 PDF,得换成PdfStamper那套,不能直接复用这段。
页码循环从 1 开始是 iTextSharp 的约定,写成 0 会抛IndexOutOfRangeException,这是新手最常见的翻车点之一。另外document.Close()会触发PdfCopy写交叉引用表和 trailer,如果只关 reader 不关 document,输出文件会损坏,用阅读器打开提示“文件已损坏或格式不正确”。
2.3 合并时的内存与句柄控制
上面代码在几十个文件时没问题,但源文件上百、单个几十兆时,PdfReader默认会把整个文件读进内存。iTextSharp 提供了PdfReader的随机访问模式,配合FileStream可以降低峰值内存:
var fsIn = new FileStream(path, FileMode.Open, FileAccess.Read); var reader = new PdfReader(fsIn); // 使用完毕后 reader.Close() 会一并关闭底层流注意PdfReader(Stream)这个重载在部分版本里不会自动关闭传入的流,稳妥做法是自己在 finally 里关fsIn。还有一个隐藏参数是PdfReader的partial构造,用于只读部分页面,合并场景一般用不上,但分卷时如果只想取某几页,可以用它避免全量解析。
内存控制的另一条经验是分批写:如果源文件超过 500 个,不要一次性全开 reader,而是每处理 50 个就copy.Flush()一次(PdfCopy有Flush方法),把已写页面刷到磁盘,减少内存驻留。这个技巧在生成月度报表合订本时救过我好几次。
3. PDF 分卷:按页数、按大小还是按书签切
3.1 三种分卷策略的适用场景
分卷的需求通常来自两个方向:一是输出文件有大小上限(比如邮件附件、上传接口限制),二是业务上需要按章节或按单据拆分。常见策略有三种:
| 策略 | 依据 | 适用场景 | 注意点 |
|---|---|---|---|
| 按固定页数 | 每 N 页一个文件 | 扫描件、页数均匀的文档 | 可能把一张表切两半 |
| 按文件大小 | 累计到 M MB 就切 | 有上传/邮件限制 | 需预估单页大小,波动大 |
| 按书签/目录 | 顶层书签为界 | 合同、标书、手册 | 依赖源文件书签规范 |
按页数最简单,PdfReader.NumberOfPages拿到总数,循环GetImportedPage写入不同的PdfCopy即可。按大小复杂一些,因为 PDF 是压缩的,你没法在写之前精确知道某一页占多少字节。实用做法是先用PdfReader读一遍,用reader.GetPageContent(i).Length估算未压缩大小,再乘一个经验压缩比(扫描件约 0.3~0.5,文字版约 0.1~0.2),累计到阈值就切。这个估算不精确,但配合“宁可切小不切大”的余量(比如限制 10MB 就按 8MB 切)足够用。
按书签分卷最贴合业务,但前提是源 PDF 有规范的 outline。iTextSharp 用reader.Outlines拿书签树,遍历顶层节点,每个节点对应一个起始页,下一页起始页减一就是结束页。坑在于有些 PDF 的书签指向的是“命名目标”而不是页码,需要调reader.GetNamedDestination解析,解析失败就退回按页数切。
3.2 按页数分卷的可运行实现
using System; using System.IO; using iTextSharp.text; using iTextSharp.text.pdf; public class PdfSplitter { // source: 源 PDF;outputDir: 输出目录;pagesPerChunk: 每卷页数 public static void SplitByPage(string source, string outputDir, int pagesPerChunk) { if (pagesPerChunk <= 0) throw new ArgumentException("pagesPerChunk 必须大于 0"); Directory.CreateDirectory(outputDir); var reader = new PdfReader(source); try { int total = reader.NumberOfPages; int chunkIndex = 1; for (int start = 1; start <= total; start += pagesPerChunk) { int end = Math.Min(start + pagesPerChunk - 1, total); string outPath = Path.Combine(outputDir, $"{Path.GetFileNameWithoutExtension(source)}_part{chunkIndex}.pdf"); using (var fs = new FileStream(outPath, FileMode.Create, FileAccess.Write)) { var doc = new Document(); var copy = new PdfCopy(doc, fs); doc.Open(); for (int p = start; p <= end; p++) { copy.AddPage(copy.GetImportedPage(reader, p)); } doc.Close(); // 触发写入 trailer } chunkIndex++; } } finally { reader.Close(); } } }逻辑说明:外层循环控制每卷的起止页,内层把该区间的页面导入新的PdfCopy。doc.Close()放在 using 内部、fs释放之前,保证 trailer 写完再关流。参数pagesPerChunk建议按业务定,比如发票每 100 张一卷、合同每份一卷。注意reader在整个分卷过程中只开一次,所有卷共享同一个 reader,这样比每卷重新打开源文件快得多,也避免句柄泄漏。
一个容易忽略的点是:分卷后每卷的页码会从 1 重新开始,如果业务需要保留原页码,得用PdfStamper在页脚叠加“第 X 页 / 共 Y 页”,这属于后处理,不在分卷本身。
3.3 分卷边界的业务校验
分卷最容易出问题的地方不是代码,而是边界。比如按页数切,第 100 页正好是一张跨页表格的中间,切完两卷都读不通。稳妥做法是在切之前做一次“边界探测”:如果某一页的文本以“续表”开头,或者页面顶部有重复表头,就把它归到下一卷。这个逻辑没有通用库,得按业务写规则。
另一个校验是分卷后的总页数必须等于源页数,写完一卷就累加end - start + 1,最后和total比对,不等就说明有页面丢失,通常是GetImportedPage抛异常被吞了。线上环境建议把每卷的页数和文件大小记进日志,出问题时能快速定位是哪一卷异常。
4. 中文、字体与表单:合并分卷里最容易翻车的三类资源
4.1 中文字体嵌入与子集化
iTextSharp 合并中文 PDF 时,如果源文件用的是非嵌入字体,合并后目标文档不会自动带上字体,阅读器找不到就显示方框。解决办法是在合并时显式注册字体并设置嵌入:
BaseFont bf = BaseFont.CreateFont( @"C:\Windows\Fonts\simsun.ttc,0", // 宋体,0 表示第一个字体 BaseFont.IDENTITY_H, // 中文必须用 IDENTITY_H BaseFont.EMBEDDED); // 嵌入字体IDENTITY_H是横排中文的编码方式,用默认的WINANSI会直接丢字。EMBEDDED会把字体子集写进 PDF,输出体积会增加,但换来的是跨机器可读。如果源 PDF 本身已经嵌入了字体,PdfCopy会连同字体对象一起复制,一般不用额外处理;问题多出在源文件是“引用系统字体”生成的,这种文件在生成它的机器上能看,换台机器就废。
4.2 表单域与注释的保留
合并带 AcroForm 表单的 PDF 时,PdfCopy默认不会合并表单域,合并后表单变成静态内容。要保留表单,得用PdfCopy的SetMergeFields()或者在 iText 5 里用copy.SetMergeFields(),然后copy.AddDocument(reader)而不是逐页AddPage。注意AddDocument会把整个文档(含表单、书签、注释)搬过去,但要求所有源文档的表单域名称不冲突,否则后一个会覆盖前一个。实务里更常见的做法是合并前先把表单“扁平化”(flatten),把填写内容烧进页面,避免域冲突。
注释(高亮、批注)同理,AddPage只搬页面内容,注释会丢;需要保留就用AddDocument,或者用PdfStamper单独处理。
4.3 页面尺寸与旋转
不同来源的 PDF 页面尺寸可能不一样,A4 和 Letter 混在一起,合并后阅读器会按各自尺寸显示,打印时可能被裁。如果业务要求统一,得在导入后用PdfCopy的AddPage重设MediaBox,或者用PdfStamper缩放。旋转属性(/Rotate)也要注意,有些扫描件靠/Rotate 90摆正,GetImportedPage会保留这个属性,但如果目标文档的页面尺寸和源不一致,旋转后可能超出边界。稳妥做法是合并前统一读一遍reader.GetPageSizeWithRotation(i),记录最大宽高,输出文档按最大尺寸建页。
5. 避坑与排查:合并分卷上线后最常遇到的五个问题
5.1 合并后文件打开提示“已损坏”
现象:用 Adobe Reader 打开合并结果,弹“文件已损坏或格式不正确”,但文件大小正常。 原因:Document没有正确Close(),trailer 和交叉引用表没写入;或者PdfCopy在document.Open()之前就调用了AddPage。 解决:确保document.Open()在所有AddPage之前,document.Close()在 finally 里且只调一次。用using包FileStream,但Document的关闭要显式控制顺序。
5.2 中文变方框或乱码
现象:合并后中文全部显示为方框,英文正常。 原因:源 PDF 字体未嵌入,目标文档也没有注册对应字体。 解决:合并前用PdfReader检查字体,或者统一在输出时用BaseFont.CreateFont注册中文字体并EMBEDDED。注意IDENTITY_H不能写成IDENTITY_V,后者是竖排。
5.3 内存暴涨、进程被 OOM 杀掉
现象:处理几百个文件时内存持续上升,最终进程崩溃。 原因:PdfReader未关闭,或者一次性把所有 reader 都 new 出来。 解决:reader 用完立即Close(),分批处理,每批copy.Flush()。大文件用FileStream构造 reader,避免全量读入。
5.4 分卷后页数对不上
现象:源 1000 页,分 10 卷,每卷 100 页,但某卷只有 99 页。 原因:GetImportedPage对某些页面抛异常被 catch 吞掉,或者循环边界算错(end用了start + pagesPerChunk而不是减一)。 解决:循环里不要吞异常,记录每卷实际页数,最后和总数比对。边界统一用Math.Min收口。
5.5 输出文件被占用无法删除
现象:合并完成后想删除源文件或覆盖输出,提示“文件正在被另一进程使用”。 原因:PdfReader或FileStream没关,句柄泄漏。 解决:所有PdfReader、FileStream都放进using或 finally 显式关闭。Windows 上可以用Handle工具查是哪个进程占着,定位到没关的 reader。
6. 进阶:用 PdfStamper 做分卷后的页码重排与书签重建
分卷本身只解决“切开”,但业务往往还要求每卷有独立页码、目录和书签。这时候PdfStamper比PdfCopy更合适,因为它能在已有页面上叠加内容而不重建页面对象。典型流程是:先用PdfCopy切出裸卷,再用PdfStamper给每卷加页脚“第 X 页 / 共 Y 页”,同时用PdfOutline重建书签树。
using (var reader = new PdfReader(chunkPath)) using (var fs = new FileStream(stampedPath, FileMode.Create)) { var stamper = new PdfStamper(reader, fs); int total = reader.NumberOfPages; BaseFont bf = BaseFont.CreateFont(BaseFont.HELVETICA, BaseFont.WINANSI, BaseFont.EMBEDDED); for (int i = 1; i <= total; i++) { var cb = stamper.GetOverContent(i); // 叠加在页面内容之上 cb.BeginText(); cb.SetFontAndSize(bf, 9); cb.SetTextMatrix(280, 20); // 页脚位置,单位 point cb.ShowText($"第 {i} 页 / 共 {total} 页"); cb.EndText(); } // 重建书签:顶层一个节点,指向第 1 页 var root = stamper.Outlines; var outline = new PdfOutline(root, new PdfDestination(PdfDestination.FIT), "卷首"); stamper.Close(); }参数说明:GetOverContent是叠加在页面内容上层,GetUnderContent是下层,页码一般用 over。SetTextMatrix的坐标原点在页面左下角,A4 高度约 842 point,页脚放在 y=20 左右合适。PdfDestination.FIT表示书签点击后整页适配窗口,也可以换成XYZ指定坐标。stamper.Close()会写回 reader 关联的流,注意这里reader和fs都要正确释放。
书签重建的难点在于层级:如果原文档有三级目录,分卷后每卷只保留属于本卷的节点,需要遍历原 outline,按页码区间过滤,再递归建新树。我的习惯是先把原书签导出成(title, page, level)的列表,分卷时按区间筛选,再用一个栈结构重建层级,这样比直接操作PdfOutline树清晰得多。
最后说个我自己的教训:早期做合并分卷时,我总想一步到位,在一个方法里既切又加页码又建书签,结果一出问题根本不知道是哪步坏了。后来改成“切裸卷 → 校验页数 → 加页码 → 建书签”四步分离,每步落盘一个中间文件,排查时直接看中间产物,效率高很多。PDF 这东西不像文本,坏了很难肉眼定位,多留中间文件就是给自己留后悔药。希望帮到你。
本文还有配套的精品资源,点击获取