Microsoft WordまたはExcelが生成したPDFを開き、ページをめくってみても、特に異常はありません。それをDelphiプログラムに読み込み、ページ数を読み取っても、数字は正しいです。しかし、暗号化をオンにして再保存しようとすると、保存ステップが例外を投げたり、出力されたファイルを開くと破損した相互参照の警告が表示されたりします。ファイルが破損していたわけではありません。それはハイブリッド参照ファイルであり、15年前のビューアで開くことを可能にしている構造そのものが、早すぎる段階で読み取りを停止するローダーを打ち負かす構造なのです
これは、すべての内部テストに合格したPDFパイプラインが、ラウンドトリップできないファイルに遭遇する最も一般的な方法の1つです。入力はすべて社内で生成されたため、ハイブリッドになることはありませんでした。最初のハイブリッドファイルは、顧客がスプレッドシートからエクスポートされた請求書を転送してきた日に到着します
WordとExcelが実際に書き込むもの
ISO 32000-1は、§7.5.8.4でハイブリッド参照レイアウトについて説明しています。PDF 1.4リーダーでファイルを開けるようにしつつ、オブジェクトストリームなどのPDF 1.5機能を必要とするアプリケーションは、相互参照情報を2回書き込みます。古典的な相互参照テーブル(バージョン1.4までのすべてのPDFの終わりにある固定幅のASCII行)と、残りのインデックスを作成する相互参照ストリームがあります。古典的なセクションのトレーラーには/XRefStmエントリがあり、その値はそのストリームのバイトオフセットです
役割の分担は意図的です。カタログやページツリーなど、古いリーダーが到達する必要があるオブジェクトは、古典的なテーブルからアドレス指定可能です。圧縮されたオブジェクトストリームに折り込まれたオブジェクトは、古典的なテーブルではタイプfのエントリで空きとしてマークされるため、1.4リーダーはそれらをスキップし、解析できない構造でつまずくことはありません。それらの実際の場所は、相互参照ストリームにのみ存在します。そのようなファイルのシグネチャはその末尾にあります。レガシーリーダーが必要とするオブジェクトだけをカバーする短い古典的なセクションで、そのトレーラーは、実際の復旧データが存在する/XRefStmを指し示しています
/XRefStm オフセットが、残りのオブジェクトインデックスを担うストリームへ通じます正しいページ数が何も証明しない理由
カタログとページツリーは古典的なテーブルから意図的に到達可能であるため、そのテーブルのみを読み取るローダーは/Rootを見つけ、ページツリーを歩き、正しいページ数を報告します。古いリーダーが必要とするものはすべて揃っているため、ファイルは正常に見えます。行方不明になったオブジェクトは、オブジェクトストリームにパックされたものです:AcroFormフィールド辞書、タグ付きPDF構造要素、レガシービューアに表示される必要がなかった小さな辞書の長い尾などです
何かがそれらのオブジェクトに触れるまでギャップに気付くことはありません。そして完全な再保存はそれらすべてに触れます。再暗号化または再書き込みのためにドキュメントを歩くことは、まさに順番にすべてのオブジェクト番号を要求する操作であるため、症状はロード時ではなく保存時に、その原因から遠く離れた場所で表面化します
罠はxrefを見て停止する検出器です
ファイルがどのようにインデックス付けされているかを決定する安価な方法は、startxrefをたどり、それが指し示す最初のバイトを検査することです。キーワードxrefは古典的なテーブルを意味します。ストリームオブジェクトは相互参照ストリームを意味します。このテストは、1つのスキームをコミットするファイルに対しては正しいです。しかし、ハイブリッドファイルに対しては間違っています。ハイブリッドファイルのstartxrefは、古いリーダーを満足させることだけを目的として古典的なセクションを指していますが、ドキュメントの大部分が実際にインデックス付けされているのはそのセクションのトレーラーにある/XRefStmです。最初に出会ったxrefで「クラシック」を返す検出器は、決して/XRefStmを読み取らず、ストリーム内にのみ存在するすべてのオブジェクトが不可視になります
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // ページはクラシックテーブルから到達可能
// ここで読み込み済みドキュメントを検査または編集します
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // すべてのオブジェクトが必要。ストリーム専用のものも
finally
Pdf.Free;
end;
end;
早期終了の検出器を軸に組み立てられたローダーでこの一連の流れを走らせると、ロードは正常に見えます。不在のオブジェクトが姿を現すのは再保存のときです。修正は、開始時により多くのバイトを読み取ることではありません。ハイブリッドトレーラーを認識し、ファイルが完了したと判断する前に/XRefStmをたどることです
マージの順序は交渉不可能
両方のインデックスが読み取られると、それらは一方向にのみ結合できます。相互参照ストリームを最初にマージし、その周囲に古典的なエントリを入力する必要があります。その理由は、フォーマットの核心にある小さな欺瞞にあります。ハイブリッドファイルは、古いリーダーがそれらを無視するように、古典的なテーブルで圧縮されたオブジェクトを空きとしてマークします。最初に見たものが勝つというポリシーを尊重し、古典的なテーブルを最初に読み取るローダーは、それらのオブジェクト番号を空きとして記録し、スロットがすでに取得されているため、実際にそれらを配置するストリームエントリを破棄します。順序を逆にすると、ストリームからのタイプ2のエントリ(それぞれがオブジェクトストリーム番号とインデックス)が、所有することを意図したスロットを獲得し、古典的なエントリがその周囲に落ち着きます
同じ規律は、古いリビジョンが削除されたオブジェクトを復活させるのを防ぎます。増分更新は/Prevを通じて逆方向にチェーンされ、タイプ0の空きエントリは、より最近のセクションがオブジェクト番号をリタイアさせたというセンチネルです。チェーン内の後続の古いセクションが、そのセンチネルを古い場所で上書きすることは許可されてはなりません。空きマーカーについては最初に見たものを権威あるものとして扱うと、削除されたオブジェクトは削除されたままになります。不注意に扱うと、ファイル自身の履歴が最新のリビジョンが削除したコンテンツを再アニメーション化します
2つのビューはどうズレていくのか
これらすべては、2つのインデックスが一致していることを前提にします。Wordから出た直後は一致しています。各ビューは、自分が宣言する範囲内で同じ文書を記述しているのです。トラブルが始まるのは、ファイルがどちらか片方しか理解しないツールを通るときです。よくある犯人はスタンプユーティリティです。あるツールは、新しいオブジェクトをいくつか、新しいxrefセクション、前のセクションへの/Prevリンク、新しいトレーラーと一緒に、クラシック形式のインクリメンタルアップデートを追記します。そのトレーラーが/XRefStmを落とせば、ストリームビューは孤児になります。古い値をそのままコピーすれば、ストリームビューはスタンプが乗る前の文書を記述したままです。どちらにせよ、インデックスはもう一致しません
シグネチャは、知ってしまえば見分けやすいものです。あるビューから見えるオブジェクトが、もう片方では欠けているか古い。ストリーム経由で解決するリーダーは、更新されたオブジェクトの編集前バージョンを見つけたり、追記されたオブジェクトのエントリをまったく見つけられなかったりします。テーブルだけを信頼するリーダーは、編集は見えるのに、ストリームにしか位置情報のない圧縮オブジェクトをすべて失います。ユーザーの表現を借りれば、あるビューアでは出るのに別のビューアでは消えるフォームフィールド、スタンプ処理が消したように見える注釈、まったく別のオブジェクトに着地するルックアップです
これらのファイルの追跡コストを跳ね上げているのはAdobe Acrobatです。宣言されたインデックスがバイトと食い違うとき、Acrobatはたいてい、オブジェクトヘッダーを走査して相互参照データを静かに再構築します。だから壊れたファイルを作った本人が開いてみると何もおかしくなく、そのまま送り出します。失敗は後で、別の場所に現れます。プレフライトバリデーター、署名サービス、アーカイブの取り込みジョブ、宣言された構造を信頼して欠落オブジェクトや相互参照の不一致を報告する消費者ならどこでもです。「Acrobatでは普通に開ける」——ハイブリッド非同期のチケットは、ほぼすべてここから始まります
これがHotPDFで意味するもの
エンジンはあなたのためにハイブリッド参照ファイルを解決し、相互参照データを解析する必要があるすべてのパスでそれを行います。LoadFromFileまたはLoadFromStreamでドキュメントを読み込み、変更を加え、SaveLoadedDocumentを呼び出します。または、入力を読み込んで出力を書き込むEncryptFileのようなワンショット操作を実行します。いずれにせよ、リカバリは/XRefStmを読み取り、ストリームセクションを古典的なエントリの前にマージし、書き込みがそれらを列挙する前にストリーム内に存在するオブジェクトを解決します。AES-256暗号化パスは、これが最もはっきり効く場所です。ドキュメントを暗号化するとすべてのオブジェクトが書き換えられるため、すべてのオブジェクトがすでに特定されていることを要求するからです
// ワンショット:ハイブリッド入力を読み込み、AES-256 で暗号化したコピーを書き出します
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
持ち帰る価値のある詳細は、APIの上流にあります。Word、Excel、PowerPoint、および長いリストの「PDFとして保存」パイプラインから到着するファイルは日常的にハイブリッドであるため、自身のジェネレーター出力に対してのみ実行するローダーは、テストでそれに出会うことは決してないかもしれません。自身のコードが生成したファイルだけでなく、実際のOfficeアプリケーションからエクスポートされたドキュメントでフィクスチャをシードしてください
疑わしいファイルの確認
2つの検査で質問はすぐに解決します。ファイルを16進ビューで開き、最後のstartxrefの後のバイトを読み取ります。ハイブリッドファイルは、トレーラー辞書に/XRefStmを含む短い古典的なセクションを示します。または、完全な解析が報告するオブジェクト数と、/Sizeがトレーラーで宣言する最高のオブジェクト番号を比較します。大きなギャップは、ローダーが開いていないストリームにオブジェクトが隠れていることを意味します。これは、後で保存時の失敗に変わるのと同じ不足です
典型的なExcelエクスポートの末尾は、最初のチェックを具体的にします。最後のxrefキーワードの後のすべてはプレーンなASCIIであるため、シグネチャは16進ビューから直接読み取ることができます(オフセットは説明用であり、注釈が追加されています)
xref
0 16 % レガシーリーダーが必要とするオブジェクト0〜15
0000000000 65535 f
0000000017 00000 n
... % 残りの14行。残りはオブジェクトストリームの中
trailer
<< /Size 216 % 使用中の最高オブジェクト番号より 1 大きい値
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % 相互参照ストリームのバイトオフセット
>>
startxref
88710 % 上のクラシックセクションを指します
%%EOF
トレーラーにある/XRefStmキーが証拠です。上のクラシックセクションが載せるのは、ファイルが使う216のオブジェクト番号のうち16だけです。オブジェクトストリームにパックされた残りは、ここに載らないか、空きとしてマークされています。xrefキーワードで停止する検出器は、この時点でその部分的なインデックスしか見ていません。目視するよりもチェックをスクリプト化したい場合、マーカーはファイルの最後の数キロバイト以内に座っているので、境界のある後方読み取りで十分です。ファイルが後からインクリメンタル更新された場合、末尾には複数のトレーラーが乗り、数え上げるべきは最後の1つです
// ファイル末尾の最新トレーラーから /XRefStm のオフセットを返す。
// マーカーがなければ -1 を返す(ハイブリッドでない、または PDF ですらない)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P, Q: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // トレーラーは末尾にあります
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // 後方への境界付き読み取り:最大 2 KB
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := 0; // 最後のマーカーを残す:インクリメンタル
Q := Pos(AnsiString('/XRefStm'), Tail); // 更新は新しいトレーラーを追記する
while Q > 0 do
begin
P := Q;
Q := Pos(AnsiString('/XRefStm'), Tail, P + 1);
end;
if P = 0 then
Exit; // 末尾にハイブリッドマーカーはありません
Inc(P, Length('/XRefStm'));
while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
Inc(P); // キー後の空白をスキップします
Result := 0;
while (P <= Len) and (Tail[P] in ['0'..'9']) do
begin
Result := Result * 10 + Ord(Tail[P]) - Ord('0');
Inc(P);
end;
end;
// 使い方:非負の結果が、ストリームが始まるバイト位置を示します
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
Writeln('hybrid-reference file: resave will need the /XRefStm section');
プローブはパーサーではなくトリアージとして扱ってください。再保存ジョブが実行される前に、バッチ内のどのファイルに注意を払うべきかを伝えるだけで、それ以上のことはありません
実入力のフォルダーに向ければ、ファイルは3つのグループに分かれます。クラシック専用のファイルは、末尾にtrailerキーワードを持つが/XRefStmを持たないので、プローブは-1を返します。クロスリファレンスストリームに完全に移行したファイルも、正しく-1を返します。trailerキーワードをまったく持たないのです。トレーラーキーはストリーム辞書の中に住んでいます。オフセットを返すのは、二重インデックスのレイアウトだけです。トリアージレポートが最初の2グループを区別する必要があるなら、捨てる前に同じ末尾をtrailerで検索してください。Posもう1回で済みます
プローブを無人で走らせる前に、2か所を硬くしてください。オフセットは主張にすぎません。そこへシークして、辞書に/Type /XRefと書かれたオブジェクトが本当にそこに座っていることを確認します。壊れたり切り詰められたりしたファイルは、キーを運んだまま、名指されたストリームを失っていることがあるからです。そのファイルは、健康なハイブリッドとは別のバケツに入れます。チェックは同じファイル処理の上に数行足すだけです
// OffsetはFindXRefStmが返した値。FSは同じやり方で開いた
// TFileStream、HeadはAnsiString(MinはSystem.Mathから)
if Offset >= FS.Size then
Exit(False); // キーは生き延びたが、ストリームは生き延びなかった
FS.Position := Offset;
SetLength(Head, Integer(Min(Int64(512), FS.Size - Offset)));
FS.ReadBuffer(Head[1], Length(Head));
Result := (Pos(AnsiString('/Type /XRef'), Head) > 0) or
(Pos(AnsiString('/Type/XRef'), Head) > 0); // PDF構文は両方の綴りを許す
次に、2 KBのウィンドウをパラメータにしてください。2キロバイトは普通のOffice出力をカバーしますが、尋常でなく大きなトレーラー辞書はマーカーを範囲外へ押し出せます。窓を広げる方が、誤ってクラシックと判定するよりましです
ハイブリッドファイルをパイプラインに流す
検出が実を結ぶのは、ファイルの次の行き先を変えるときです。行き先は、パイプラインがこれからファイルに何をするかで決まります
読むだけ、描画だけ、検証だけのファイルは、両ビューを解決するローダーへ回します。PDFium Componentはロード時に/XRefStmチェーンをパースするので、コードが見るオブジェクトテーブルはすでにマージ済みです。そしてオブジェクトストリームとクロスリファレンスストリームの検証の記事にある検査は、そのまま使えます。非同期のハイブリッドが壊れすぎてロードを拒むとき、エンジンはロードエラーコードで報告し、構造的損傷が出すのはFPDF_ERR_FORMATです。きれいにロードできたことは、より弱い証拠です。PDFiumは設計上寛容で、矛盾するファイルのほとんどを黙って再構築します。成功が証明するのはファイルが復旧可能だったことだけで、2つのビューが一致しているかについては何も言いません。その問いには、オブジェクト全体の歩行が見つけたものとトレーラーの/Sizeの比較に戻ってください
パイプラインが変更するファイルは話が別です。ここでの私たちの選択はぶっきらぼうです。そもそもハイブリッドのままにしない。HotPDFでドキュメントをロードして全体保存すれば、出力は1つの形式の、自己整合した相互参照を1つだけ運びます。/XRefStmなし、ズレ得る2つ目のビューなし、すべてのオブジェクトがちょうど1つのインデックスエントリに所有される。アーカイブ取り込みの前、厳格な下流のRIPや署名サービスの前、ハイブリッド入力へのどんな編集の後にも、欲しいのはこの正規化です。これは前に述べたストリーム優先のマージに完全に依存します。全体保存が書き出せるのは、ロードが配置できたオブジェクトだけなので
署名済み文書は例外です。全体の書き換えはすべてのバイトを動かし、元のバイト範囲に対して計算された署名を無効にします。署名済みハイブリッドへの変更は、両ビューを歩調を合わせたままにする正しいインクリメンタルアップデートとして入れるしかありません。そして読むだけの署名済みファイルは、パイプラインに触れずに通してください
この話のライター側、つまりオブジェクトストリームと圧縮された相互参照がそもそもどのように生成されるかについては、オブジェクトストリームと増分更新に関する記事で説明されています。問題のハイブリッドファイルが非常に大きい場合、大規模なPDFワークフローのためのDirect File APIのウォークスルーの読み込み手法を使用すると、すべてをメモリに読み込むことなく検査できます。どちらも、このブログの他の場所で説明されているロード、編集、暗号化、および署名APIと並んで、DelphiおよびC++Builder用のHotPDF Delphi Componentの一部として出荷される、ここで説明したリカバリと自然にペアになります