Merging DOCX, XLSX and PDF into a single binder — demo run

把我的星期五吃掉的事情

每个星期五下午,大约一年时间里,我都有同样的一个小仪式。一个合同会以三个文件的形式出现——Word 中的主协议、Excel 中的定价附件以及 PDF 格式的合作方条款表——我必须把它们合并成一个干净的 PDF。并不难。打开 Word,导出为 PDF。打开 Excel,导出为 PDF。打开某个免费 PDF 合并工具,拖入三个文件,检查顺序,保存。

大约需要八分钟。每周十五份合同的话,就会因为移动鼠标而浪费两个小时。更糟的是,每隔几周就会有人把附件放在第一页,因为合并工具按文件名的字母顺序排序。

如果这听起来很熟悉,那么接下来这篇文章的内容就是我终于用代码取代这个仪式的那天下午。

真正的成本不是时间——而是那五十份合同中有一份页面顺序错误,却没人注意,直到客户签署了错误的版本。

我真正想要的

不是“一个花哨的文档管道”。只要三件事:

  1. 给方法一个文件列表(任意组合的 DOCX、XLSX、PDF),返回一个 PDF。
  2. 把同样的逻辑指向一个文件夹,让它自行找出文件列表。
  3. 从生成的合并文件中提取页面范围,而不必重新进行整个合并。

这就是全部需求。如果库不能干净利落地完成这三件事,我不想知道它的其他功能。

环境搭建

  • .NET 6.0 或更高版本
  • GroupDocs.Merger for .NET 24.10+(获取临时许可证,这样就不会出现评估水印)
  • 一个文件夹,里面放着你平时手动合并的各种文档
dotnet add package GroupDocs.Merger

就这些依赖。无需外部转换器、无头 Office 安装,也不需要额外的 PDF 操作库。

步骤 1 — 让文件夹作为输入

我总是从这里开始,因为它是最实际的入口点。实际上,某个其他环节(上传处理器、邮件摄取任务、财务的夜间导出)会把一堆文件放进目录,我的代码必须处理它们能找到的任何文件。

// Pick up every supported file in the drop folder; the PDF wins
// the tie-break for position 0 so the merger keeps the output
// as a PDF regardless of how files are named.
string[] extensions = { ".pdf", ".docx", ".xlsx" };
var files = Directory.EnumerateFiles(folderPath)
    .Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
    .OrderBy(f => Path.GetExtension(f).ToLowerInvariant() == ".pdf" ? 0 : 1)
    .ThenBy(f => f)
    .ToArray();

if (files.Length == 0)
    throw new InvalidOperationException(
        $"No supported documents found in '{folderPath}'.");

OrderBy 小技巧是关键。GroupDocs.Merger 会根据你首先打开的文件决定输出格式——如果我把 DOCX 当作主文档,它会输出 DOCX。因为我的管道始终想要 PDF 输出,所以我确保文件夹里已有的 PDF 获得位置 0。

值得一提的两点:

  • ToLowerInvariant() 因为合作方有一天会发送 REPORT.PDF,如果只用小写过滤会悄悄丢掉它。
  • ThenBy(f) 只为让输出具有确定性。没有它,同一个文件夹的两次运行可能因文件系统的状态而产生不同顺序。

步骤 2 — 合并本身

一旦得到有序的路径列表,合并的代码比合并的描述还要短。

Console.WriteLine($"Primary source: {sourcePaths[0]}");
using var merger = new Merger(sourcePaths[0]);

var joinOptions = new JoinOptions();
for (int i = 1; i < sourcePaths.Length; i++)
{
    Console.WriteLine($"Joining: {sourcePaths[i]}");
    merger.Join(sourcePaths[i], joinOptions);
}

merger.Save(outputPath);
Console.WriteLine($"Unified PDF binder saved to: {Path.GetFullPath(outputPath)}");

使用时的几点注意:

  • using 很重要。Merger 会保持对源文件的句柄;如果忘记释放,负责清理的工作进程最终会因为无法删除输入文件而失败。
  • 这里的 JoinOptions 为空,因为默认设置已经满足我 95% 的需求。真正需要时,页面范围、旋转和插入位置都在这里配置。
  • 当 Excel 进入合并文件时,工作表到页面的布局由源工作簿的打印区域决定。如果你的 XLSX 最终生成了 38 页而你只想要三页,解决办法在电子表格本身,而不是 JoinOptions

保存后我总会加一个 sanity check:

using var verify = new Merger(outputPath);
Console.WriteLine($"Result pages: {verify.GetDocumentInfo().PageCount}");

这两行代码捕获了比我写的任何测试都多的“悄悄丢失附件”错误。

步骤 3 — 稍后提取片段

我每次都会收到的后续请求是:“能只给我封面页吗?”或者“客户只想要签名页”。重新生成整个合并文件只为拿两页太蠢了——直接提取即可。

using var merger = new Merger(binderPath);
merger.ExtractPages(new ExtractOptions(pages));
merger.Save(outputPath);
Console.WriteLine($"Extracted pages [{string.Join(",", pages)}] to " +
    Path.GetFullPath(outputPath));

pages 是一个 int[],包含你想保留的 1 基页码。其余全部丢弃。因为结果已经是 PDF,所以速度很快——没有转换往返。

前后对比,实话实说

过去的做法 使用 Merger.Join
每份合同耗时 5–10 分钟的点击 端到端不到 30 秒
常见错误 页面顺序错误,没人注意 按文件列表顺序,始终如一
扩展到 100 份/天 做不到——只能雇人 一个工作进程,大部分时间都在闲置
需要维护的代码 一篇标题为 “Binder Process v4” 的 Confluence 页面 一个类,约 70 行
输出 三个 PDF 加上祈祷 一个合并文件,并且可以记录页数

我最在意的是 “错误” 那一行。手动合并会悄悄出错;记录页数的代码会大声报错。

来自小型法律科技团队的真实故事

我曾合作的两人创业公司里,有一名法律助理的早晨从合同组装开始。Word 协议、Excel 定价、PDF 附件,在一个应用里拼接后上传到 DocuSign。每个包大约八分钟,30 包/天几乎占满了她的整个上午。

他们把文件夹扫描方法嵌入已经在监听收件邮件的后端服务。每个包只需二十秒,加上一行页数日志。法律助理于是转而审阅合同,而不是组装它们。再也没有出现顺序错误的合并文件——不是因为库有魔法,而是文件列表在代码里是显式的,你可以对比它。

string folder = @"C:\IncomingContracts";
string output = @"C:\Processed\ContractPackage.pdf";

var files = CreatePdfBinderFromFolder(folder, output);
Console.WriteLine($"Package created: {files}");

这就是完整的集成。上游(邮件监听器、存储路径)已经就绪。

我今天不需要但明天会用的东西

同一个库还能做很多我这里没涉及的功能,因为全部都在同一个 Merger API 里。大致按我使用的顺序列出:

  • 在输出上添加水印,用于在签署前的 “DRAFT” 标记。
  • 页面旋转,处理横向扫描的文档。
  • 自定义页面顺序,当源文件顺序不是交付顺序时使用。
  • PDF 加密,用于发送给外部对手方的文件。

所有这些都在同一套 API 中。完整列表请参阅文档——我只想说明 “合并” 是最基础的入门,其余功能在需要时随时可用。

我想对过去的自己说

如果你正打算自己写一个 DOCX 转 PDF 的步骤,因为 “只需要一个方法”,请停下来。转换是最容易暗中腐烂的环节——新 Office 功能、扫描图像处理、嵌入字体等等。让别的东西来负责这块表面工作,把你的星期五下午花在不只是文件名排序的事情上。

接下来该怎么做:

有用的链接