Delphi PDFライブラリであるPDFlibPasでは、MovePageで移動したページが、かつて旧Pagesノードの保持していたMediaBox、CropBox、Resourcesオブジェクトそのものを受け取っていました。そのため、移動後のページへのSetPageBoxやDrawTextが、そのノードと、まだそこを継承しているすべての兄弟を静かに書き換えていたのです。v3.539.36以降、移動されたページは自分専用のコピーを受け取り、間接参照は参照のまま残ります。同じリリースで、関連する2つの経路も閉じられました。複数ページが共有する間接ボックスへのSetPageBoxと、ソースドキュメントのページをPagesノードに紐付けたままにし、CropBoxをMediaBoxに紐付けたままにするCopyPageRangesです
ここに至る報告は、オブジェクトの同一性には一切触れません。「7ページをトリミングしたら8から12ページまでトリミングされた」「CropBoxを狭めたらMediaBoxが一緒に動いた」、そして一番混乱させる「ページを新ドキュメントへコピーしたら元ファイルが変わった」。クラッシュもリークもなく、保存されたファイルは完璧に有効なPDFです。ただ、誰も頼んでいないジオメトリが入っているだけです
1ページへのSetPageBoxが兄弟ページのサイズを変える理由
SetPageBoxが兄弟をリサイズしたのは、2つのページツリーエントリーが1つのメモリ内配列を指しており、SetPageBoxがターゲット配列をその場で編集するからです。同じインスタンスを保持するページやPagesノードは、その編集を目にします。v3.539.36より前のPDFlibPasには、その共有を生む3つのコード経路がありました:
MovePageは親から切り離す前に継承可能な属性をページへ実体化しますが、コピーではなく祖先自身のオブジェクトを添付していたため、移動されたページと旧兄弟はボックス配列とResources辞書を共有していましたSetPageBoxは間接参照をたどって参照先の配列を編集していたため、複数ページが1つの/MediaBox 11 0 Rオブジェクトを指すファイルでは、MovePageが関わっていようといまいと、1回の呼び出しでそれら全ページがリサイズされましたCopyPageRangesはターゲットドキュメントへクローンする前にソースページへ継承値を実体化しますが、Pagesノードのインスタンスをソースページへ添付し、さらにデフォルトCropBoxとしてMediaBoxインスタンスそのものを添付していました
MovePageの件には短い歴史があります。v3.539.27より前、MovePageは/Resourcesだけを運び、別の親の下へ移動されたページは黙ってその親のサイズと回転を引き受けていました。v3.539.27は欠けていたMediaBox、CropBox、Rotateを修正しました。ページを並べ替えるCollateDocumentsExが頼っているのもこの動作です。ただし、祖先の値を共有インスタンスとして添付していました。v3.539.36が閉じるのはまさにこの窓です。SetPageBoxとCopyPageRangesの経路はもっと古く、v3.539.36より前のビルドならどれも持っています
直接値、間接参照、ページ属性継承
継承ページ属性の正しいコピーは、直接値を複製し、間接参照は参照のまま保ちます。それがISO 32000-1自身が引いている区別だからです。辞書の中に書かれた[0 0 400 300]のような直接オブジェクトは、その辞書だけのものです。一度11 0 objと定義され11 0 Rとして引用される間接オブジェクトは、設計上共有されるものです。ISO 32000-1 §7.3.10は、ファイル内のどこからでもアドレス可能にし、すべての11 0 Rが同じオブジェクトを意味させます
ページ属性継承(ISO 32000-1 §7.7.3.4)は3つ目のケースを加えます。Resources、MediaBox、CropBox、RotateはPagesノードに置かれ、自分自身を定義しないすべての子孫ページに適用され得ます。ページは値を保持しません。/Parentを通して値を引いてくるのです。この引き回しのチェーンは、ページが親を変わった瞬間に切れます。だからMovePageとBalancePageTreeは、まず実効値をページ自身に書き込まなければなりません。問題は、どう書くかだけです
オブジェクトプールが間違いを隠す理由
PDFlibPasでは、パースされた、あるいは作成されたすべてのPDFオブジェクトはドキュメントのTPDFStructureプールが所有し、辞書と配列はエントリーへの素のポインターを保存します。TPDFDictionary.Addが記録するのはポインターだけです。だから1つのインスタンスを2つの親コンテナに追加するのは、ランタイムが検査できる全レベルで合法です。テアダウン時の二重解放もなく、狂う参照カウントもなく、例外もありません。シリアライズも同様に寛容で、各コンテナは共有インスタンスの現在値をインラインで書き出します。編集が入る前の出力は、正しいコピーが作るものとバイト単位で同じです
エイリアシングが表面化するのは、誰かが共有インスタンスをその場で変更したときだけです。SetPageBoxは既存配列への矩形ラッパーを通してまさにそれをやりますし、ページへの描画は、フォントや画像が登録されるときResources辞書に対して同じことをします。編集は、ポインターを保持する他のすべてのコンテナへ、静かに着地します
PDFlibPas v3.539.36が共有の代わりにコピーする仕組み
PDFlibPas v3.539.36は問題の両端を直します。実体化はコピーを添付するようになり、ボックス書き込みはページが所有する配列だけを編集するようになりました。それぞれの修正が、相手ではカバーできないケースをカバーします
実体化ヘルパーのPLInheritPageAttributesは、Valueの代わりにPage.Owner.Decode(Value.Output)を添付するようになりました。シリアライザーを通すラウンドトリップは、大雑把ですが正確な、PDFセマンティクスをタダで手に入れる方法です。直接の配列や辞書はリテラルテキストにシリアライズされ、新しく独立したインスタンスへデコードされます。間接参照は11 0 Rにシリアライズされ、同じオブジェクト11を指す新しい参照オブジェクトへデコードされます。だからページはインライン化されたコピーを受け取る代わりに、共有オブジェクトを引き続き参照します。v3.539.27で導入された参照の挙動が保たれるのはこのためです。コピーの深さは直接構造とちょうど同じです。コピーされた辞書の中で参照を通して辿り着くものは、ファイルフォーマットの意図どおり、共有されたままです。BalancePageTreeは再親化するすべてのページで同じヘルパーを呼ぶので、そこで実体化されたページも別インスタンスを得ます
コピーだけでは足りません。参照のケースは依然として共有オブジェクトを指しているからです。もしSetPageBoxがその参照をたどってオブジェクト11を編集したら、移動されたページはまたしても旧親とその他の子をリサイズします。そこでボックスライターはコピーオンライトを適用します。ページ自身のエントリーが直接配列のときだけその場で編集し、間接参照や欠落のボックスは新しい直接配列で置き換えます。オブジェクト11は、それを引用する他のすべてのページのために、手つかずのまま残されます
| コード経路 | v3.539.36より前 | v3.539.36以降 |
|---|---|---|
MovePageの実体化 | ページが祖先自身の直接インスタンスを保持 | ページがデコード済みコピーを保持。参照は参照のまま |
SetPageBox | 参照をたどり共有配列を編集 | ページ上の直接配列だけを編集。そうでなければ新規に書き込む |
CopyPageRangesのソースページ | Pagesノードのボックスを共有。CropBoxはMediaBoxインスタンス | ソースページの実体化された値はすべてコピー |
| ページリソースのクローン時のデフォルトボックス | CropBox、BleedBox、TrimBox、ArtBoxが1つの配列を共有 | 各デフォルトボックスが独自の配列を得る |
最後の行は潜在病例です。ライブラリがページキャプチャやマージのためにページのリソースをクローンするとき、欠落しているCropBox、BleedBox、TrimBox、ArtBoxのエントリーを埋めますが、これらは以前、同じ配列インスタンスでした。現在の呼び出し元で、そのエイリアスが編集されるまで生き延びるものはありませんでした。次の呼び出し元なら、あり得ました。これらのデフォルトボックス値がどう選ばれるかは独立した話題で、PDFlibPasのTrimBox、BleedBox、CropBoxデフォルトのガイドで扱います
手組みのPDFでMovePageエイリアシングを再現する
任意のPDFlibPasビルドを検査する一番速い方法は、LoadFromStringでロードする小さな手書きPDFです。オブジェクト番号がすべて事前に分かっています。下のヘルパーは、正しく計算されたバイトオフセットで古典的なクロスリファレンステーブルを書くので、テストは壊れたファイル向けのパーサーの復旧挙動に頼りません
uses
System.SysUtils, PDFlibrary;
function BuildPdf(const Objects: array of AnsiString): AnsiString;
var
Offsets: array of Integer;
I, XRefPos: Integer;
begin
Result := '%PDF-1.4'#10;
SetLength(Offsets, Length(Objects));
for I := 0 to High(Objects) do
begin
Offsets[I] := Length(Result); // "N 0 obj"の0始まりバイトオフセット
Result := Result + AnsiString(IntToStr(I + 1)) + ' 0 obj'#10 +
Objects[I] + #10'endobj'#10;
end;
XRefPos := Length(Result);
Result := Result + 'xref'#10'0 ' + AnsiString(IntToStr(Length(Objects) + 1)) +
#10'0000000000 65535 f '#10;
for I := 0 to High(Offsets) do // 各エントリーはちょうど20バイト
Result := Result + AnsiString(Format('%.10d 00000 n ', [Offsets[I]])) + #10;
Result := Result + 'trailer'#10'<< /Size ' +
AnsiString(IntToStr(Length(Objects) + 1)) + ' /Root 1 0 R >>'#10 +
'startxref'#10 + AnsiString(IntToStr(XRefPos)) + #10'%%EOF'#10;
end;
function StreamObj(const Content: AnsiString): AnsiString;
begin
Result := '<< /Length ' + AnsiString(IntToStr(Length(Content))) +
' >>'#10'stream'#10 + Content + #10'endstream';
end;
テストドキュメントには中間Pagesノードが2つあります。ノード3は間接MediaBox(オブジェクト11、400×300ポイント)、直接CropBox、直接Resources辞書を運び、2ページを所有します。ノード4はレターサイズのMediaBoxを持ち、3ページ目を所有します。ページ1を位置3へ移動すると、ノード4の下へ再親化されます。実体化がまさに必要な移動です。なければ、ページはレターサイズに化けます
procedure Check(Condition: Boolean; const Msg: string);
begin
if not Condition then
raise Exception.Create(Msg);
end;
procedure CheckMovedPageIsIsolated;
var
Lib: TPDFlib;
FontID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 3 >>',
'<< /Type /Pages /Parent 2 0 R /Kids [5 0 R 6 0 R] /Count 2 ' +
'/MediaBox 11 0 R /CropBox [10 20 390 280] /Resources << >> >>',
'<< /Type /Pages /Parent 2 0 R /Kids [7 0 R] /Count 1 ' +
'/MediaBox [0 0 612 792] >>',
'<< /Type /Page /Parent 3 0 R /Contents 8 0 R >>',
'<< /Type /Page /Parent 3 0 R /Contents 9 0 R >>',
'<< /Type /Page /Parent 4 0 R /Contents 10 0 R >>',
StreamObj('1 w'), StreamObj('2 w'), StreamObj('3 w'),
'[0 0 400 300]']), '') = 1, 'load failed');
Lib.SelectPage(1);
Check(Lib.MovePage(3) = 1, 'MovePage failed');
Lib.SelectPage(3); // 今まさに移動したページ
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'inherited MediaBox lost');
Lib.SetPageBox(1, 0, 200, 200, 200); // MediaBoxを200 x 200に
Lib.SetPageBox(2, 0, 100, 100, 100); // CropBoxを100 x 100に
FontID := Lib.AddStandardFont(4); // Helveticaフォント
Lib.SelectFont(FontID);
Lib.SetTextSize(12);
Lib.DrawText(20, 20, 'MOVED');
// 別のページを選択する前に旧親を検査する(下記参照)
Check(Pos(AnsiString('/Font'), Lib.GetObjectToString(3)) = 0,
'font registered in the old Pages node');
Lib.SelectPage(1); // 旧ページ2、まだノード3の下
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling MediaBox changed');
Check(Abs(Lib.GetPageBox(2, 2) - 380) < 0.001, 'sibling CropBox changed');
Check(Pos(AnsiString('400'), Lib.GetObjectToString(11)) > 0,
'shared object 11 was rewritten');
finally
Lib.Free;
end;
end;
GetPageBox(BoxType, Dimension)はボックスタイプ1がMediaBox、2がCropBox、ディメンション2が幅を取ります。デフォルトの左下原点では、SetPageBox(1, 0, 200, 200, 200)は左0、上200、幅200、高さ200を意味します。v3.539.27からv3.539.35の間のビルドでは、兄弟の検査が失敗します。CropBoxの編集はノード3の直接配列に着地し、MediaBoxの編集は参照を通してオブジェクト11を書き換えます
CopyPageRangesはソースドキュメントを変えるのか
v3.539.36以降も、CopyPageRangesはソースページへ書き込みます。ただし書き込む値はすべて独立したコピーなので、後のソース側の編集は、編集したページにローカルに留まります。書き込み自体は意図的なものです。ターゲットへ辞書がクローンされる前に、ソースページは明示的なMediaBox、CropBox、Rotate、Resourcesを必要とします。なければ、コピーは継承していたすべてを失います。ページの採番し直しとターゲットへのコピーは、PDFlibPasのクロスドキュメントオブジェクトディープコピーで扱います。このバグはソース側に座っていました。コピーは読むだけだと、たいていの人が思い込んでいる側です
出力には決して現れませんでした。共有でもコピーでも、実体化された値は同じようにシリアライズされるので、両方のドキュメントは修正の前後でバイト単位で同じ保存結果になります。エイリアスを暴くのは、コピー後のソースドキュメントへの編集だけです:
procedure CheckSourceSurvivesCopy;
var
Lib: TPDFlib;
SourceID, TargetID: Integer;
begin
Lib := TPDFlib.Create;
try
Check(Lib.LoadFromString(BuildPdf([
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 ' +
'/MediaBox [0 0 400 300] /Resources << >> >>',
'<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>',
'<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>',
StreamObj('1 w'), StreamObj('2 w')]), '') = 1, 'load failed');
SourceID := Lib.SelectedDocument;
TargetID := Lib.NewDocument; // 選択中ドキュメントになる
Check(Lib.CopyPageRanges(SourceID, '1') = 1, 'copy failed');
Lib.SelectDocument(SourceID);
Lib.SelectPage(1);
Lib.SetPageBox(2, 50, 250, 100, 100); // CropBoxだけ狭める
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'MediaBox followed CropBox');
Lib.SetPageBox(1, 0, 200, 200, 200);
Lib.SelectPage(2);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'sibling page resized');
Lib.SelectDocument(TargetID); // コピーは元のサイズを保つ
Lib.SelectPage(Lib.PageCount);
Check(Abs(Lib.GetPageBox(1, 2) - 400) < 0.001, 'copied page resized');
finally
Lib.Free;
end;
end;
v3.539.36より前は、ここの両ページがルートノードの直接MediaBoxを継承し、コピーがそのインスタンスをソースページ1へ添付し、さらにページ1のCropBoxとしてもう一度添付していました。だからCropBoxを狭めるとMediaBoxが狭まり、MediaBoxをリサイズするとルートノードを通してページ2がリサイズされました。ページをコピーしてからソースの編集を続けるワークフロー、たとえば元帳票のトリミング前に両面スキャンを1つのPDFへ整理するような流れで、これが表面化しました
インスタンスエイリアシングはなぜテストが難しいのか
インスタンスエイリアシングのテストが難しいのは、観測可能な効果が、特定の順序での3ステップを必要とするからです。エイリアスを作り、片側を変更し、他の何かが触れる前に反対側を検査する。ほとんどのテストは最初のステップしかやらず、保存出力を比較します。エイリアスがあろうがなかろうが、同じ出力です
PDFlibPasでの順序の罠はSelectPageです。ページを選択すると、現在のフォントがSelectFont経由で再適用され、そのフォントがページのリソースへ登録されます。自分の/Resourcesを持たないページは親の辞書へ解決されるので、そういうページを選ぶだけで、Pagesノードに/Fontが正当に追加されます。上のMovePageテストでは、旧ページ2の選択がノード3へHelveticaエントリーを追加します。これは正しい挙動で、リークではありません。GetObjectToString(3)の検査がSelectPage(1)の前に走るのはそのためです。2つを入れ替えると、修正済みビルドでテストが失敗します
このルールは、v3.539.36があえて手をつけないものも示します。Resources辞書を継承するページへのリソース書き込みは、祖先の辞書へ書き込まれ、すべての兄弟がその新エントリーを見ます。これは仕様どおりに動く継承であって、インスタンス共有ではありません。共有辞書にフォントや画像の名前を追加しても、他のページの描き方は変わらないので、無害です。ページに継承をやめさせたいなら、まず専用のResources辞書を与えてください
PDFオブジェクトモデルコードのためのチェックリスト
この教訓は、プールとポインターコンテナの上に構築された任意のPDFオブジェクトモデルに一般化します。Delphiでも他でも:
- ISO 32000-1 §7.7.3.4に従って継承属性を実体化するときは、直接値をディープコピーし、間接参照は同じオブジェクトへの新しい参照として保つ
- 共有が意図され文書化されていない限り、既存インスタンスを2つ目のコンテナへ
Addしない。プールによる所有は、ランタイムが決して文句を言わないことを意味します - その場で編集するのは、現在のノードが直接オブジェクトとして所有するものだけ。間接や継承の値は新しい直接オブジェクトで置き換える(コピーオンライト)
- MediaBoxから派生するCropBoxのように、別のエントリーから導かれるデフォルト値には、専用のインスタンスが要る
- エイリアシングは、変更してから反対の保持者を検査するシーケンスでテストし、間に正当に書き込み得る呼び出しの順序も確認する
- 保存出力の比較はここでは何も証明しない。共有とコピーの値は、最初の編集が入るまで同じようにシリアライズされる
- PDFlibPasでは、
MovePage、CollateDocumentsEx、BalancePageTree、CopyPageRangesを呼んだ後にページボックスを編集したりページへ描画したりするなら、v3.539.36以降へアップグレードする
PDFlibPasは、ページツリー編集、クロスドキュメントコピー、ページボックス制御を、Delphi、C++Builder、Free Pascal向けの1つのTPDFlibクラスで公開しています。エディション、プラットフォーム、完全なAPIリファレンスは、PDFlibPas Delphi PDF library製品ページを参照してください