C# 修改 PDF 页面尺寸:MediaBox 与内容缩放实战指南
使用 C# 修改 PDF 页面尺寸听起来是个特别小的需求真做起来才发现弯弯绕绕一堆。很多时候你以为改一下纸张大小就行结果内容缩在角落改了 MediaBox打开一看还是原来的裁剪框批量处理几百个文件突然某个文件还提示签名失效。我最早碰上这需求是在做一个文档归档系统扫描件、电子签、程序导出件混在一起尺寸五花八门被业务方反复打回。后来专门花时间把 PDF 的页面模型、内容流变换捋清楚才彻底解决。这篇文章就以 C# 和 iText 7 为主线把修改 PDF 页面尺寸的原理、代码、踩坑经验一次性讲透。适合正在被 PDF 尺寸问题折磨的 .NET 开发者也适合要做批量文档处理的自动化工程师。哪怕你只打算用现成工具手动改理解这几个概念也能帮你少走很多弯路。1. 为什么有人要写代码改 PDF 尺寸需求场景与方案选型1.1 这些场景每天都在催你改 PDF 尺寸页面尺寸不对这事放在单个文件上不算严重手动用 Acrobat 打开改一下就行但一旦批量出现就变成让人头皮发麻的体力活。我归纳一下最常见的需求来自这几类一是文印归档。政府窗口、银行、医院这种每天产生大量扫描件的场景扫描仪来源不同A3、A4、B5甚至长条票据都有。归档系统要求最终 PDF 必须是统一纸型否则打印、装订、电子档案著录都会出问题。二是平台上传限制。很多政务系统、税务平台、招投标网站对上传的 PDF 有严格限制比如“页面尺寸必须为 A4”或者“单页宽度必须超过某个阈值”。这种校验通常写在服务端文件不满足直接拒收人工去一张张转又耽误时间只能靠程序批量处理。三是多来源 PDF 合并。一份报告里既有系统导出的电子文件又有扫描件、拍照件尺寸参差不齐。合并成一个 PDF 之后翻页体验非常差页面上内容时大时小。做合并的人被逼无奈只能先统一页面尺寸再合。四是 C# 上位机或办公自动化项目中的报表导出。比如设备检测报告、产品标签、物流面单这类程序生成 PDF 时如果不严格控制页面大小打印机一打就裁切或者客户平台解析出来尺寸异常。网上搜“C# 上位机”经常能翻到类似求助本质都是尺寸模型没搞对。所以“使用 C# 修改 PDF 页面尺寸”并不是一个冷门需求它往往藏在各种业务流程的边上。你要是能提供一套稳定、可控的批量处理方案在团队里是非常加分的。1.2 方案选型为什么我最终用 iText 7C# 生态里能操作 PDF 的库不少我简单列一下常见的方案授权方式优势短板iText 7开源 AGPL / 商业授权功能最底层能直接操作 MediaBox、CropBox、内容流、表单、签名授权要留意API 和新手教程之间的版本断层较大PdfSharp开源 MIT上手快创建简单 PDF 方便修改已有 PDF 页面结构的能力偏弱不适合复杂处理Aspose.PDF商业付费API 友好功能全文档完善收费贵对小项目不友好qpdf / Ghostscript开源命令行批量处理方便不适合嵌入 C# 服务功能固定如果只是“把 PDF 第一页改成 A4”PdfSharp 也能做但一旦涉及“内容等比缩放”“保留书签和表单”“旋转页面后再统一尺寸”这些需求PdfSharp 就有点吃力了。iText 7 最狠的地方在于它保留了 PDF 底层对象模型的访问能力你可以拿到页面、内容流、资源字典自己控制一切。这对我们处理复杂 PDF 来说几乎是必须的。还有一点iText 的 NuGet 包名是itext7而网上很多老教程讲的是iTextSharp。iTextSharp 是 iText 5.x 时代的 .NET 移植版API 和 iText 7 差别非常大。搜解决方案的时候千万先看版本不然代码抄过来大概率编译不过。2. 改尺寸之前先弄懂 PDF 的页面模型2.1 MediaBox 和 CropBox页面尺寸不是你想的那样PDF 阅读器显示的“页面大小”在 PDF 内部并不是一个简单的 Width 和 Height 属性。每个页面有一组矩形框最核心的是 MediaBox介质框和 CropBox裁剪框。可以这么理解MediaBox 是这张纸的物理尺寸相当于画布本身CropBox 是显示时取了画布中的哪一块相当于取景框。如果只有 MediaBox 没有 CropBox阅读器默认拿 MediaBox 当显示尺寸但很多文件在前期处理时被人为裁剪过CropBox 比 MediaBox 小这时阅读器显示的是 CropBox 而不是 MediaBox。我之前处理过一个在线签名项目客户说某个 PDF 页面尺寸不对程序里读出来 MediaBox 是 600×900但 Acrobat 属性里显示 595×842。查了半天才发现CropBox 是 595×842MediaBox 是 600×900两边不一致。这种情况下你只改 MediaBox 是不生效的因为阅读器优先按 CropBox 显示。PDF 内部的坐标单位是 ptpoint1 pt 1/72 英寸A4 纸对应 595.28 × 841.89 ptA3 是 841.89 × 1190.55 pt。坐标原点默认在页面左下角x 轴向右y 轴向上。页面上的所有文字、图形定位都是基于这个坐标系的。所以修改页面尺寸本质上是修改这些矩形框的值而不是“把页面变宽”这么简单。2.2 你到底是改画布还是缩放内容做开发的人最容易犯的错是拿到需求“把 A3 改成 A4”就直接把 MediaBox 改小。做完打开一看内容全都跑到页面外面去了或者缩在左下角一小块。原因很简单PDF 里的内容坐标不会因为你改变了画布大小而自动重排。这里要把需求拆成两种语义第一种只改画布大小内容坐标保持不变。比如扫描件明明是 A4 内容但纸张被定义成了 A3你只需要把 MediaBox 和 CropBox 改成 A4内容还是在原来位置这种场景用最简单的方式就能搞定。合并多份 PDF 时把每页都扩展到最大页面的尺寸也属于这一类只是内容不缩放、多余区域留白。第二种内容也要跟着缩放。比如一张 A3 的图纸你想让它完整落到一张 A4 纸上这就不能只改 MediaBox 了必须同时对内容流做变换让页面里的所有元素按比例缩小再平移到新页面合适的位置。需求确认这一步特别重要。我一般会直接问业务方你是要文件属性里显示成 A4还是要把 A3 内容按比例放到一张 A4 纸上这两个答案对应的代码完全不同猜错的话整个方案都要推翻。3. 基于 iText 7 的实操两种核心改法3.1 环境准备和版本注意先用 NuGet 装包在项目里执行Install-Package itext7如果你的项目是 .NET Framework也可以用相同命令安装iText 7 对 .NET Framework 4.5 和 .NET Core/.NET 5 都有支持。装完以后写代码之前我建议你先确认两件事。第一项目里是否已经引用了其他 PDF 库如果有很可能出现类型冲突尤其是iText.Kernel.Pdf和旧版iTextSharp.text.pdf命名空间容易搞混。第二iText 7 的 AGPL 授权问题。如果只是公司内部工具风险相对可控如果是要发布闭源商业产品切记评估商业授权别给自己埋雷。3.2 场景 A把整份 PDF 统一成固定尺寸先看最简单的场景不做内容缩放只把所有页面统一成同一个尺寸。比如一份 PDF 里有 A4 页也有 B5 页你想全部统一成 A4但内容本身不需要调整代码如下using iText.Kernel.Geom; using iText.Kernel.Pdf; public static void SetAllPagesToFixedSize(string inputPath, string outputPath) { using var reader new PdfReader(inputPath); using var writer new PdfWriter(outputPath); using var pdfDoc new PdfDocument(reader, writer); for (int i 1; i pdfDoc.GetNumberOfPages(); i) { PdfPage page pdfDoc.GetPage(i); page.SetMediaBox(PageSize.A4); page.SetCropBox(PageSize.A4); } }这里有两个关键点。一个是PageSize.A4其实是Rectangle的子类它表示从坐标(0, 0)到(595, 842)的一个矩形。你也可以自定义new Rectangle(0, 0, 595, 842)。另一个是SetCropBox。我前面说过 CropBox 残留会导致阅读器显示尺寸和预期不符所以凡是我用代码设置 MediaBox 的地方几乎都会同步设置 CropBox除非业务上明确需要保留裁剪区域。这种写法的特点是内容位置不动所以适合“内容已经是正确尺寸只是纸张定义错了”的场景。比如你收到一个 PDF内容排版本身就是 A4但 MediaBox 被某个工具设置成了 A3运行这段代码后页面尺寸修正为 A4内容位置刚好合适。3.3 场景 B内容等比缩放并居中到新尺寸如果要把 A3 的图纸“塞进”A4只改 MediaBox 肯定不行。这时候必须给页面内容流加一个仿射变换矩阵。先看代码再解释数学原理using iText.Kernel.Geom; using iText.Kernel.Pdf; using iText.Kernel.Pdf.Canvas; public static void ScalePagesToFit(string inputPath, string outputPath, Rectangle targetSize) { using var reader new PdfReader(inputPath); using var writer new PdfWriter(outputPath); using var pdfDoc new PdfDocument(reader, writer); for (int i 1; i pdfDoc.GetNumberOfPages(); i) { PdfPage page pdfDoc.GetPage(i); Rectangle mediaBox page.GetMediaBox(); float oldW mediaBox.GetWidth(); float oldH mediaBox.GetHeight(); float newW targetSize.GetWidth(); float newH targetSize.GetHeight(); // 等比缩放取宽度和高度缩放比例中较小的那个保证内容不超出目标页面 float scale Math.Min(newW / oldW, newH / oldH); // 平移量让缩放后的内容在新页面中居中 float tx (newW - oldW * scale) / 2f; float ty (newH - oldH * scale) / 2f; // 在最前面插入一个新的内容流写入变换矩阵 PdfStream prepend page.NewContentStreamBefore(); PdfCanvas canvas new PdfCanvas(prepend, page.GetResources(), pdfDoc); canvas.ConcatMatrix(scale, 0, 0, scale, tx, ty); page.SetMediaBox(targetSize); page.SetCropBox(targetSize); } }调用方式ScalePagesToFit(input.pdf, output.pdf, PageSize.A4);这段代码的核心是ConcatMatrix(scale, 0, 0, scale, tx, ty)。PDF 的变换矩阵是 3×3 矩阵简写为六个参数[a b c d e f]其中 a 是 x 方向缩放d 是 y 方向缩放e 是 x 方向平移f 是 y 方向平移。这里 a d scale所以内容等比缩放b c 0所以不产生切变和旋转e tx、f ty把内容移动到目标位置。为什么用NewContentStreamBefore()因为 PDF 页面可以包含多个内容流渲染顺序是从前到后。如果你把变换矩阵追加到现有内容流末尾那么只有后面的内容流受影响已经绘制过的内容不会被变换。插到最前面等于让整页原有内容从第一个元素开始就整体处于这个矩阵变换下效果就对了。这段代码有个细节要注意我假定了原页面的 MediaBox 左下角是 (0, 0)。但少数 PDF 的 MediaBox 可能是(20, 30, 595, 842)也就是原点不在 (0,0)这时候计算平移量要做修正float offsetX mediaBox.GetX(); float offsetY mediaBox.GetY(); float tx (newW - oldW * scale) / 2f - offsetX * scale; float ty (newH - oldH * scale) / 2f - offsetY * scale;原因在于变换矩阵作用的是页面坐标系中的坐标值如果原内容本身是从 x20 开始的缩放以后这个 20 也要跟着乘以 scale。3.4 顺带一提合并 PDF 时怎么统一尺寸如果你要做的是把好几份不同来源的 PDF 合并成一个第一步通常就是统一页面尺寸。有两种思路思路一统一到最大尺寸。先遍历所有页面找到最大的宽和高然后把每页都扩展到这个尺寸。这样做的好处是所有内容都不会缩小适合“不允许阉割内容”的扫描件归档场景。代码如下float maxW 0; float maxH 0; for (int i 1; i pdfDoc.GetNumberOfPages(); i) { Rectangle box pdfDoc.GetPage(i).GetMediaBox(); maxW Math.Max(maxW, box.GetWidth()); maxH Math.Max(maxH, box.GetHeight()); } for (int i 1; i pdfDoc.GetNumberOfPages(); i) { PdfPage page pdfDoc.GetPage(i); page.SetMediaBox(new Rectangle(0, 0, maxW, maxH)); page.SetCropBox(new Rectangle(0, 0, maxW, maxH)); }思路二统一到标准尺寸比如全部 A4。如果内容比 A4 大就用场景 B 的等比缩放方式如果内容比 A4 小可以考虑居中加白边也可以用缩放把它放大到合适尺寸。具体看业务需求但绝大多数时候等比缩放加居中是最安全、最不会出错的。4. 改尺寸过程中的常见问题与排查经验4.1 改完以后内容缩在左下角这是最常见的翻车现场。原因就是我前面说的只改了 MediaBox没有对内容做变换。PDF 内容是在绝对坐标系里绘制的MediaBox 扩大以后原有内容还是按照原来的坐标位置显示看起来就像缩在左下角。解决方式很简单判断一下你的需求属于哪一类。如果不需要缩放内容比如只是统一尺寸做合并那就接受这种情况多余区域自然留白如果需要内容跟着页面一起缩放那就用场景 B 的变换矩阵写法。4.2 改了 MediaBox 但阅读器还是显示旧尺寸继续排查三步第一步确认 CropBox 有没有同步设置。很多时候 MediaBox 已经改对了但 CropBox 还是老的阅读器优先按 CropBox 显示。处理办法是SetMediaBox和SetCropBox一起调用。第二步看看代码是不是真的执行到了对应的页面。有些 PDF 页面数量很多你改的是第 1 页但业务方看的是第 5 页这是低级错误但很容易发生。第三步确认有没有经过特殊加密或权限限制。少数加密 PDF 在只读模式下虽然能打开但写入新文件时会丢属性。这种情况一般要先解除密码保护再处理。4.3 页面有旋转属性宽高反了有些 PDF 页面存储时 MediaBox 是 842×595也就是横向的 A4但页面属性里有一个 Rotation90导致阅读器显示出来是纵向。你说奇怪不奇怪这种文件真实存在还不少。处理这种页面时如果只按 MediaBox 的宽高去计算缩放比例结果就是旋转之后内容被裁切。稳妥的做法是先把页面的旋转角读出来决定宽高是否要交换判断int rotation page.GetRotation() % 360; bool isPortrait (rotation 0 || rotation 180) ? mediaBox.GetWidth() mediaBox.GetHeight() : mediaBox.GetWidth() mediaBox.GetHeight();如果你要统一的目标是 A4 纵向而源页面是旋转过的横向最好先page.SetRotation(0)把页面转正再统一尺寸。这里务必要和业务确认最终的横竖版要求。4.4 大文件处理慢、内存占用高PDF 处理最怕几百 MB 的大文件。iText 7 在默认模式下读写 PDF 已经做了不少优化但如果你在一个循环里反复读取同一个大文件内存还是会涨得飞快。我的实践经验是大文件优先用增量更新模式把结果写到一个新文件不要在原文件上直接覆盖处理完记得把PdfReader、PdfWriter、PdfDocument都及时释放。代码里用using是个好习惯一旦用完立刻释放文件句柄也能避免 Windows 下文件被占用的问题。如果文件真的特别大建议先用 Ghostscript 压缩一遍再做尺寸修改性能会好很多。注意压缩可能影响字体内嵌和清晰度归档场景慎用。4.5 带数字签名或表单域的文件被改坏了这个坑最隐蔽。数字签名是对 PDF 内容的哈希签名任何修改页面尺寸的操作都会让签名失效。表单域的外观也可能和新的页面尺寸不匹配因为 AcroForm 字段的矩形位置是基于旧页面计算的。我的建议是在处理带签名或复杂表单的 PDF 之前先和业务方确认能不能接受签名失效。如果必须保留签名那就只能放弃修改或者让业务方在修改后重新走一遍签名流程。这种场景没有技术上的银弹只有流程上的取舍。4.6 常见问题排查速查表现象可能原因解决思路内容缩在左下角只改 MediaBox没做内容变换用变换矩阵缩放并平移到目标位置显示尺寸和设置不一致CropBox 残留SetMediaBox 和 SetCropBox 一起处理页面旋转后尺寸不对Rotate 属性影响宽高判断先读 Rotation转正后再统一尺寸文件被占用无法写入资源未释放使用 using 或 try-finally 及时 Dispose打开报页面尺寸异常MediaBox 与 CropBox 冲突重置为一致的新矩形签名失效内容被改动哈希不匹配业务上重新走签名流程5. 进阶操作旋转、加白边和自动化自检5.1 遇到旋转页面怎么处理如果你的 PDF 是从各种扫描设备、手机拍照、第三方系统里来的旋转页面几乎是绕不开的问题。有些扫描件明明内容是横向的但页面属性里 MediaBox 是纵向的阅读器显示时靠 Rotation 转过来打印时却又乱了。处理旋转场景我建议先统一旋转再统一尺寸。比如你想把所有页面最终都变成纵向 A4并且内容朝向一致可以先用下面这段思路int rotation page.GetRotation(); if (rotation 90 || rotation 270) { // 旋转后宽高互换 } page.SetRotation(0);这里要注意SetRotation(0)之后阅读器不再自动旋转原先依靠旋转显示的横向内容会变成横躺状态。要想让内容跟着转 90 度光改 Rotation 不够还得对内容流做旋转变换这就更复杂了。所以实际项目里我通常建议客户在扫描环节就固定方向处理环节能不做旋转就不做旋转避免一连串连带问题。5.2 只想加白边、不想缩放内容有一种需求是给原有内容四周加上留白比如为了打印装订A4 内容要放到 A3 纸张上但内容大小不变只在周围多一圈空白。这种操作的代码反而最简单因为内容坐标不需要动。假设源页面是 A4595×842目标页面是 A3842×1191你只需要把 MediaBox 和 CropBox 设置成新尺寸即可page.SetMediaBox(PageSize.A3); page.SetCropBox(PageSize.A3);这样内容会留在原位置也就是 A3 画布的左下角其余区域空白。如果你希望内容居中在白纸中间那就要结合前面讲的平移变换在内容流前面加一个 translate 矩阵把坐标系往右上角挪一段距离。5.3 改完之后的自动化自检批量处理 PDF 最怕“程序没报错但某一页结构坏了”。所以我强烈建议在处理完以后写一个简单的自检脚本遍历输出文件把每页的 MediaBox、CropBox、Rotation 都打出来using var reader new PdfReader(outputPath); using var pdfDoc new PdfDocument(reader); for (int i 1; i pdfDoc.GetNumberOfPages(); i) { PdfPage page pdfDoc.GetPage(i); Rectangle box page.GetMediaBox(); Console.WriteLine($Page {i}: MediaBox({box.GetLeft()},{box.GetBottom()},{box.GetWidth()}x{box.GetHeight()}), CropBox({page.GetCropBox()}), Rotation{page.GetRotation()}); }这些日志一方面用来给人看另一方面可以写进自动化流程里做断言比如“所有页面 MediaBox 必须等于 595×842”。有了这层校验你才敢说自己这套修改方案是真正可靠的。我在实际项目里还习惯把输出文件的第一页渲染成图片用 Ghostscript 或 PDFium 转一下肉眼扫一眼内容有没有明显偏移。虽然不能替代全面检查但能把最明显的低级错误拦在交付之前。最后说两句实在的我做 PDF 处理这几年最大的体会是改尺寸这种需求的难点从来不在 API 调用而在搞清楚 PDF 阅读器到底拿哪个框当页面尺寸、内容要不要跟着动。你真正理解了 MediaBox、CropBox 和内容流变换之后这套方法还能延伸到很多场景比如给 PDF 加水印、页面旋转、缩放套打、按需拆分合并底层都是同一套坐标和变换思路。最后再分享一个检查小习惯输出完不要急着删原文件先用阅读器检查三五页再看一遍每页 MediaBox 的日志。PDF 这东西表面上是文档格式骨子里其实是图形引擎你把它当图形系统来理解就不会再被这些需求吓住。