はじめに

同僚が契約書の 2 つの改訂版を送ってきて、差分を取ってほしいと言われました。両方とも比較サービスに投入し、結果が返ってきて、すべてが正常に見えました。見落としていたのは、どちらかの文書に URL を指すリンク画像が埋め込まれており、ファイルが開かれた瞬間にサーバーがそのホストにアクセスしていたことです。出力結果にはそれが起きたことは一切示されていません。

これはバグではなく、文書を忠実に読み込むということの意味です。OOXML ファイルは、パッケージ内ではなく Web サーバー上にある画像を参照でき、Word もその文書を正しく読み込むライブラリもその参照を解決します。GroupDocs.Comparison for .NET は LoadOptions に 2 つのプロパティを公開しており、参照を解決するかどうかを決められます:SkipExternalResources と WhitelistedResources。

この 2 つのプロパティから 3 つの構成が得られ、本稿ではそれらすべて(許容的なデフォルト、すべてブロック、名前付き参照を除くすべてブロック)を比較します。最後まで読めば、文書の出所に応じてどれを選べばよいか、そして設定が機能していないように見える 2 つのミスが分かります。

💡 完全に動作するサンプル: block-external-resources-on-document-load-dotnet – 参照画像を自前で提供し、すべてのリクエストをログに記録するコンソール プロジェクトです。設定ごとの挙動を確認できます。

外部参照はどこに隠れているか

設定を選ぶ前に、何を選んでいるのかを知っておく価値があります。.docx には外部参照が 2 か所に格納されており、どちらも文書テキスト上には見えないため見落としがちです。

  1. word/_rels/document.xml.rels にあるリレーションシップで、TargetMode="External" と絶対 URL が記載されています。画像は本文中では描画オブジェクトとして表示され、ID でリレーションシップを指すため、URL 自体はコンテンツの近くに現れません。

  2. 文書本文中の INCLUDEPICTURE フィールドコードで、フィールド指示子の中に URL が格納されています。Word はページ描画時に解決し、比較ライブラリは文書読み込み時に解決します。

両方のメカニズムは以下で説明する 2 つのロードオプションに従います。文書はどちらか、または両方を使用できるため、リレーションシップファイルで参照を見つけても、フィールドコードに別の参照がある可能性があります。

アプローチ 1: デフォルト – 参照を解決する

SkipExternalResources のデフォルトは false です。したがって、設定なしで読み込んだ文書はリモート参照が解決されます。

LoadOptions loadOptions = new LoadOptions
{
    SkipExternalResources = false
};

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

これが最も忠実度の高い結果をもたらします。比較対象の文書は、Word が描画するのと同じように、参照しているすべての要素を含みます。自分のアプリケーションやテンプレートで生成した文書で、すべての参照 URL が自社インフラを指す場合はこの選択が正解です – ただし、リンク画像が欠落すると比較結果が誤解を招く可能性があります。

欠点は、参照先がどこであれすべてにアクセスする点です。さらに、信頼性とは無関係にタイミングコストが発生します。解決できない URL があると、比較ごとに接続試行のタイムアウト待ちが発生します。

アプローチ 2: すべての外部リソースをブロック

以下のプロパティで文書のリモート参照解決をオフにします。

LoadOptions loadOptions = new LoadOptions
{
    SkipExternalResources = true
};

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

リクエストは一切送信されません。参照画像は結果に含まれず、ここが重要なポイントです – それ以外の挙動は変わりません。設定は「何をロードするか」を決めるものであり、差分検出方法には影響しません。したがって、テキストや構造の変更は従来通り検出されます。失われるのは、ロードされなかった画像内部の変更を検出できなくなることだけです。

この構成は、自分で作成していない文書(Web アプリへのユーザーアップロード、メールで受信したファイル、ビルドエージェント上で比較するものなど)を扱う際のベースラインとして推奨します。ただし、実際に必要なリンク画像までブロックされる点に注意が必要です。結果から画像が欠けていることは分かりますが、何が欠けたかは通知されません。

アプローチ 3: 名前付き参照を除くすべてをブロック

3 番目の構成は、細部まで読み込む必要がある場合に有効です。WhitelistedResources は List<string> を受け取り、SkipExternalResources が true のときだけ参照されます。

LoadOptions loadOptions = new LoadOptions
{
    SkipExternalResources = true,
    WhitelistedResources = new List<string> { "includepicture-field.png" }
};

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

リストに入れるのは URL のフラグメント(部分文字列) であり、ファイル名ではありません。各フラグメントは参照 URL 全体に対して部分一致で評価され、そのリソースのロードを許可します。これがホワイトリストをポータブルにするポイントです。たとえば "includepicture-field.png" だけを書けば、スキーム・ホスト・パスが何であっても一致し、開発環境と本番環境で同じリストを使えます。

逆に、短すぎるフラグメント – 例: logo.png、さらには .png – は意図しない参照まで許可してしまう危険があります。許可したいリソースだけを特定できるだけの長さにしてください。

サンプルの参照では、この構成によりホワイトリストに登録した画像だけが取得され、リストに無い 2 番目の画像はブロックされます。許容的なデフォルトでは 5 件のリクエストが発生したのに対し、ここでは 3 件(ホワイトリスト分だけ)となり、ログにもホワイトリスト対象のファイル名だけが記録されます。

どの構成を使うべきか?

文書の出所に合わせて設定を選びます。自社アプリやテンプレートで生成した文書はデフォルトで構いません。外部から持ち込まれるもの(ユーザーアップロード、メール添付、サードパーティのファイルなど)は SkipExternalResources = true が基本です。信頼できる参照がどうしても必要な場合にだけ、狭いフラグメントで WhitelistedResources を追加します。

3 つの構成の比較

項目 デフォルト すべてブロック ブロック + ホワイトリスト
設定するプロパティ数 0 1 2
発信リクエスト すべての参照 なし ホワイトリスト対象のみ
参照単位での制御 なし なし あり
死んだ URL がロード時間に与える影響 あり なし ホワイトリスト対象のみ
推奨シーン 自社で生成した文書 外部からの文書全般 信頼できるテンプレートが混在するが、特定の参照だけ許可したいケース

判断基準は「文書の出所」ではなく「信頼性」です。自社生成文書はデフォルトで問題ありません。外部文書はブロックが基本です。特定の参照だけが必要な場合は、ホワイトリストで例外を設けます(例: 社内 URL からヘッダー画像を取得する企業テンプレート)。

2 つのミス

どちらも同じ症状を引き起こします:オプションを設定したのに何も変わらないように見える。

スイッチなしのホワイトリスト
WhitelistedResources は SkipExternalResources が true のときだけ参照されます。単独で設定しても何も起きません – ブロック対象が無いので例外を適用できないからです。ホワイトリストが無視されているように見える場合はまずこの点を確認してください。

ソース側だけにオプションを渡す
これは微妙なミスです。ロードオプションは「1 つの文書」の読み込み方法を指示します。Comparer コンストラクタはソース文書用のオプションを受け取り、Add() 呼び出しはそれぞれのターゲット文書用のオプションを受け取ります。

using (Comparer comparer = new Comparer(sourcePath, loadOptions))
{
    comparer.Add(targetPath, loadOptions);
    comparer.Compare(outputPath);
}

コンストラクタにだけ渡して Add() に渡し忘れると、ソース文書は保護されますが、すべてのターゲット文書は依然として参照を取得します。比較は成功し、結果は妥当でも、半分の文書がネットワークにアクセスし続けます。ソースとターゲットで処理を分けたい場合は、文書ごとに別々の LoadOptions インスタンスを渡す必要があります。これが API が文書単位でオプションを受け取る理由です。

実際に機能したかの検証方法

ブロックされたリソースはほとんど痕跡を残しません。出力文書に画像が欠けているだけでは、元々画像が無かった文書と区別がつきません。したがって、設定が有効かどうかを確認するには サーバー側のログ を見るのが確実です。

サンプルは意図的にこの方法を取っています。ローカルのループバックポートで小さな HTTP リスナーを起動し、デモ文書はそのポートの画像を指すように書き込み、受信したすべてのリクエストをログに記録します。比較ごとに「5 件 → 0 件 → 3 件」のようにカウントが変化します。実際の文書ソースに対してネットワークトレースを取ることでも同様の確信が得られます。

結論

構成は 3 つ、判断基準は 1 つです。自分で生成した文書はデフォルトのままにし、その他は SkipExternalResources = true を設定します。特定の信頼できる参照だけが必要な場合は、狭い URL フラグメントでホワイトリストを追加します。

そして、作業を無効化してしまう 2 つの落とし穴 – 「SkipExternalResources = true が無いホワイトリスト」と「Comparer コンストラクタにだけオプションを渡し、Add() に渡さない」 – を必ずチェックし、設定効果は出力ファイルではなくサーバー側のログで確認してください。

追加リソース