私の金曜日を奪い続けていたもの
毎週金曜の午後、約1年間、同じ小さな儀式を繰り返していました。契約書は 3 つのファイルとして届きます――Word の本契約書、Excel の価格付録、PDF のパートナー条件シート――そしてそれらを 1 つのきれいな PDF にまとめて渡さなければなりませんでした。難しいことはありません。Word を開いて PDF にエクスポート。Excel を開いて PDF にエクスポート。無料の PDF 結合アプリを開き、3 ファイルをドラッグし、順序を確認して保存。
所要時間はおよそ 8 分。これを週に 15 件の契約で掛け算すると、マウスを動かすだけで 2 時間を失うことになります。さらに、数週間ごとに誰かが付録をページ 1 に置いたバインダーを出荷してしまい、結合アプリでファイル名がアルファベット順に並んでいたためです。
もしこれが心当たりがあるなら、この記事の残りは、私がついにこの儀式をコードに置き換えた午後の話です。
本当のコストは時間ではなく、50 件に 1 件の契約でページ順が間違っていて、クライアントが間違ったバージョンにサインするまで誰も気付かないことです。
実際に欲しかったもの
「派手なドキュメントパイプライン」ではありません。欲しかったのは次の 3 つだけです。
- メソッドにファイルのリスト(DOCX、XLSX、PDF の任意の組み合わせ)を渡し、1 つの PDF を取得する。
- 同じロジックをフォルダーに向け、ファイルリストを自動で取得させる。
- 完成したバインダーからページ範囲を抽出し、全体の結合をやり直さない。
これが全仕事です。ライブラリがこの 3 つをきれいに実現できないなら、知りたくもありません。
セットアップ
- .NET 6.0 以降
- GroupDocs.Merger for .NET 24.10+(一時ライセンス を取得して評価版の透かしを除去)
- 通常手作業で結合しているドキュメントのミックスが入ったフォルダー
dotnet add package GroupDocs.Merger
依存関係は以上です。外部コンバータも、ヘッドレス Office インストールも、PDF 操作ライブラリも不要です。
手順 1 — フォルダーを入力にする
現実的なエントリーポイントなので、まずここから始めます。実際には、アップロードハンドラやメール受信ジョブ、夜間の財務データダンプなどがディレクトリに多数のファイルを配置し、コードがそれらを処理します。
// ドロップフォルダー内のすべてのサポート対象ファイルを取得;PDF が
// 位置 0 のタイブレークになるので、結合結果は常に PDF になる
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 にします。
覚えておくべきことは 2 点です。
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はソースファイルのハンドルを保持します。Dispose し忘れると、ドロップフォルダーのワーカーが自分の入力を削除できなくなります。JoinOptionsはここでは空です。デフォルトが 95% のケースで欲しい動作です。ページ範囲、回転、挿入位置などはここで指定します。- Excel がバインダーに入るとき、シート→ページのレイアウトは元ブックの印刷領域で決まります。XLSX が 38 ページになってしまい 3 ページにしたい場合は、スプレッドシート側で調整し、
JoinOptionsではなくします。
保存直後に必ず行うサニティチェック:
using var verify = new Merger(outputPath);
Console.WriteLine($"Result pages: {verify.GetDocumentInfo().PageCount}");
この 2 行のコードが、私が書いたテスト以上に「付録が黙って除外された」バグを捕まえてくれました。
手順 3 — 後からスライスを抽出
毎回必ず来る追加要望は「表紙だけ送ってくれ」や「クライアントは署名ページだけ欲しい」というものです。バインダー全体を再構築して 2 ページだけ渡すのは無駄です――抽出機能を使えば直接取得できます。
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 は保持したい 1 ベースのページ番号の int[] です。その他はすべて除外されます。結果がすでに PDF なので高速です――変換ラウンドトリップはありません。
正直な比較:導入前 vs. 導入後
| 以前のやり方 | Merger.Join を使った場合 |
|
|---|---|---|
| 契約ごとの所要時間 | クリック作業で 5〜10 分 | エンドツーエンドで 30 秒未満 |
| 典型的な失敗 | ページ順が逆になるが誰も気付かない | ファイルリスト通りの順序で、常に再現可能 |
| 1 日 100 件へのスケール | 不可能 ― 人を雇う必要がある | ほとんど時間が空いた 1 人のワーカーで対応可能 |
| 保守するコード量 | 「Binder Process v4」 という Confluence ページ | 1 クラス、約 70 行 |
| 出力 | 3 つの PDF と祈り | ページ数をログに残せる 1 つのバインダー |
最も重要なのは「失敗」行です。手作業の結合は黙って失敗しますが、ページ数をログに残すコードは大きく失敗を知らせます。
小さなリーガルテックチームの実話
私が関わった 2 人体制のスタートアップには、朝は契約書の組み立てから始まるパラリーガルがいました。Word の合意書、Excel の価格表、PDF の付録をアプリでつなぎ、DocuSign にアップロード。1 パッケージあたり約 8 分、1 日 30 パッケージで実質朝の全時間でした。
彼らはこのフォルダー走査ロジックを、すでに受信メールを監視していたバックエンドサービスに組み込みました。1 パッケージあたり 20 秒、さらにページ数のログ行を出力。パラリーガルは組み立て作業から契約レビューにシフトしました。順序が間違ったバインダーが出荷されることはなくなりました――ライブラリが魔法だからではなく、コード上でファイルリストが明示的に管理され、差分が取れるからです。
string folder = @"C:\IncomingContracts";
string output = @"C:\Processed\ContractPackage.pdf";
var files = CreatePdfBinderFromFolder(folder, output);
Console.WriteLine($"Package created: {files}");
これが全統合です。上流(メールリスナー、保存パス)はすでに整っていました。
今日必要なかったが、明日必要になるかもしれない機能
同じライブラリは、この記事では触れなかった多くの機能を提供しています。大まかな順序は次の通りです。
- Watermarks on the output – 署名前コピーに「DRAFT」スタンプを付与
- Page rotation – 横向きスキャンの回転
- Custom page ordering – ソース順序が納品順序と異なる場合のカスタム順序
- PDF encryption – 外部取引先向けの暗号化
これらすべては同じ Merger API の背後にあります。完全な一覧は docs にありますが、ここでは「merge」が安価な入門で、必要に応じて他機能が利用できることだけを指摘しておきます。
過去の自分への助言
「DOCX から PDF への変換だけは自前で書く」つもりなら、やめてください。変換は静かに腐敗します――新しい Office 機能、スキャン画像の取り扱い、埋め込みフォントなどが次々に問題になります。別のものにその表層を任せ、金曜の午後はファイル名ソート以外のことに費やしましょう。
次に進むべき場所:
- Temporary license – 透かしなし出力に必須
- Advanced merging options – JoinOptions、保存オプション、圧縮
- Supported formats – ここで紹介した 3 種類をはるかに超える形式
- Sample projects on GitHub – 本記事のサンプルも含む