讓我每個星期五都被吃掉的事
每個星期五的下午,大約持續了一年,我都有同樣的小儀式。合約會以三個檔案的形式送來——Word 的主協議、Excel 的價格附件,以及合作夥伴的條款表格(PDF)。我必須把它們合併成一個乾淨的 PDF。這並不難。打開 Word,匯出為 PDF;打開 Excel,匯出為 PDF;打開某個免費的 PDF 合併工具,拖入三個檔案,檢查順序,儲存。
這大約需要八分鐘。每週十五份合約算起,就因為移動滑鼠而浪費了兩小時。更糟的是,每隔幾週就會有人把附件放在第一頁,因為合併工具會依檔名的字母順序排序。
如果這聽起來很熟悉,接下來的內容就是我在某個下午最終用程式碼取代這個儀式的過程。
真正的成本不是時間——而是每五十份合約中就有一份頁面順序錯誤,且在客戶簽署錯誤版本之前沒人發現。
我真正想要的
不是「華麗的文件流程」。只有三件事:
- 提供一個方法接受檔案清單(任意組合的 DOCX、XLSX、PDF),回傳一個 PDF。
- 將相同的邏輯指向一個資料夾,讓它自行找出檔案清單。
- 從完成的合併檔中抽取頁碼範圍,而不必重新執行整個合併。
這就是全部需求。如果函式庫無法乾淨利落地完成這三件事,我就不想知道它的存在。
設定
- .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}");
這就是完整的整合。所有上游(郵件監聽器、儲存路徑)已經就緒。
我今天沒用到但明天會用的東西
同一個函式庫還能做許多我此篇未提及的功能,因為文章篇幅限制。大致上我使用的順序如下:
- 在輸出上加上浮水印,例如在未簽署的副本上加上「DRAFT」標記。
- 頁面旋轉,針對側向掃描的檔案。
- 自訂頁面順序,當來源順序與交付順序不一致時。
- PDF 加密,用於傳送給外部對象的文件。
所有功能都在同一個 Merger API 之下。完整清單請參考 文件——我只是想說「合併」是入門的低成本功能,其他功能在需要時也都可用。
我想對過去的自己說的話
如果你正打算自行寫一個 DOCX 轉 PDF 的步驟,因為「只是一個方法」就想動手,請停下。轉換是最容易悄悄退化的部分——新 Office 功能、掃描圖像處理、內嵌字型等。讓其他工具負責這層面,將你的星期五下午花在不是檔名排序的事上。
接下來可以做什麼:
- 臨時授權 — 需要此授權才能輸出無浮水印的檔案
- 進階合併選項 — JoinOptions、儲存選項、壓縮
- 支援的格式 — 超出本文示範的三種格式
- GitHub 上的範例專案 — 包含本範例