ベンダーのマニュアルに新しいラベル範囲を追加し、付録がA-1、A-2ではなくApp-a、App-bと表示されるようにして、保存してファイルを開き直したとします。ページボックスはまだA-1を示したままです。テキストエディタでcatalogを開くと、/PageLabels辞書が理由を語ります。プロデューサから引き継いだ/Kids配列を運んでおり、そのすぐ隣に、あなたの範囲を収めた新しい/Nums配列があるのです。ISO 32000-1はナンバーツリーのルートにどちらか一方のキーしか許さず、ルートに/Kidsを見つけたリーダーはその中へ降りて、それ以上は見ません
PDFはページラベル範囲をどう格納するか
ページラベルは、ビューアが物理ページインデックスの代わりにページボックスに表示する文字列であり、ISO 32000-1の§12.4.2はそれをcatalogキー/PageLabelsの下のナンバーツリーに置きます。各キーはラベリング範囲が始まる0ベースのページインデックスです。各値は最大3エントリを持つラベル辞書です。番号付けスタイル(D、R、r、A、a)の/S、プレフィックス文字列の/P、範囲内の最初のページの番号でデフォルトが1の/Stです。範囲は次のキーまで続きます。仕様はさらにページインデックス0のエントリを要求するため、ドキュメント内のすべてのページが何らかの範囲に収まります
PDF Library for Delphiでは、書き手はTPDFlib.AddPageLabels(Start, Style, Offset, Prefix)で、覚えておく価値のある3つの規則でその辞書へ対応します。Startはライブラリの他のページ引数と同じく1ベースで、ツリーにはStart - 1として入ります。Styleは0から5までの値を取ります。0は/Sをまったく書かず、ラベルはプレフィックスだけになります(Coverと名付けた1ページに便利です)。1から5はD、R、r、A、aになります。0..5の外のスタイルは呼び出しを0で返させ、ドキュメントは無傷のままです。Offsetはゼロより大きいときに限り/Stになります。0を渡すとキーは省略され、すべてのリーダーはそれを1と解釈します
レイアウトツールが生み出す典型的な書籍には3つの範囲が要ります。前置きの小文字ローマ数字、本文のアラビア数字、そしてプレフィックス付きの付録です
uses
PDFlibrary;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
// 1-4ページ:i、ii、iii、iv(小文字ローマ数字)
Lib.AddPageLabels(1, 3, 1, '');
// 5-120ページ:1、2、3 ...(10進、1から再開)
Lib.AddPageLabels(5, 1, 1, '');
// 121ページ以降:A-1、A-2 ...(プレフィックス付き10進)
Lib.AddPageLabels(121, 1, 1, 'A-');
WriteLn(Lib.GetPageLabel(5)); // 1
WriteLn(Lib.GetPageLabel(122)); // A-2
Lib.SaveToFile('handbook-labeled.pdf');
finally
Lib.Free;
end;
end;
ページラベルはPDF 1.3でやってきたので、各呼び出しはドキュメントに出力バージョンを少なくとも1.3へ引き上げるよう求めます。LockSaveVersionでバージョンを固定している場合、その要求は無視され、ロックが勝ちます。ライブラリは意図的に選ばれたバージョンに逆らわないからです。ロックするなら、ラベルを書くジョブでは1.3以上でロックしてください
ルートに/Kidsがあると新しい範囲はなぜ消えるのか
範囲が消えるのは、ISO 32000-1の§7.9.7(Table 37)がナンバーツリーのルートに/Kidsか/Numsのどちらか一方だけを許し、リーダーは存在する方の/Kidsを選ぶからです。長いドキュメントを書くプロデューサはしばしばツリーを中間ノードに分割し、各ノードに/Limitsペアを持たせ、それを/Kidsだけを持つルートから吊るします。/Numsしか知らない挿入ルーチンはそのルートを見て、配列を発見できず、既存の/Kidsの隣に1つ作り、新しい範囲をそこへ放り込みます。書き込みは成功します。ファイルはまだパース可能です。そしてすべてのビューアは/Kidsを降り、プロデューサの元の3つの範囲を見て、迷子の配列には決して触れません。これが冒頭のベンダーマニュアルの症状そのものです
抜け出す道は、ルートを2つ目のエントリポイントを育てるかもしれないコンテナとして扱うのをやめることです。AddPageLabelsはルートに/Kidsを見つけると、まず既存のツリーのすべてのリーフを左から右へたどり、各キーとそのラベル辞書を収集し、そのリストから1つの平坦な/Nums配列を組み立てます。/Kids、/Limits、残存する/Numsは根から取り除かれてから平坦な配列が付けられ、その後に新しい範囲が普通の整列挿入で入ります。/Limitsの除去も重要です。Table 37はそれを中間ノードとリーフノードにしか許さず、それを保持したままのルートは、今や/Numsを持っていても不正です
見落としやすい副作用が1つあります。ファイルがすでに壊れた状態で届く場合、つまり以前のツールが素朴な挿入をしたせいで両方のキーがルートに載っている場合、リーフのたどりはビューアと同じように/Kidsに従うため、迷子の配列の範囲は引き継がれません。ビューアもそれらの範囲を一度も表示していないので、編集後のラベルは読み手が既に見ていたものと一致します
平坦化されたツリーは後でバランスの取れた/Kidsノードへ組み直されませんが、この特定のツリーについてはそれが正しい判断だと考えています。バランス化が効くのは、何千もの宛先や埋め込みファイルを持つ名前ツリーです。ページラベルツリーはラベリング範囲ごとに1エントリしか持たず、大きなリファレンスマニュアルでも数十を超えることは稀です。単一リーフは、分割そのものを目的にしないほとんどのプロデューサが書く形でもあります。リバランスのパスは、そのような小さなツリーでは誰にも測れない参照速度の引き換えに、保存のたびに誤り得るコードを追加することになります
// /PageLabelsのルートが/Kidsを使うファイルで付録をラベルし直す
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // 例:A-1
// 既存の範囲と同じ開始ページなので、その値は置き換えられる
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
begin
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii、変化なし
Lib.SaveToFile('vendor-manual-relabeled.pdf');
end;
end;
新しい範囲が既存のキーと同じページインデックスから始まるため、挿入はページインデックス120の2つ目のエントリを追加する代わりに、そのキーの辞書を置き換えます。プロデューサのリーフから来たローマ数字と10進の範囲は、/St値も含めて元の辞書のまま通っていきます
リーフをたどる処理は、不正なツリーにも耐えなければなりません。自分の祖先の1つへ逆流する/Kidsエントリや、同じリーフを共有する2つの分岐は、壊れたパイプライン由来のファイルに現れます。たどりは既に訪れたノードを覚えているため、循環は1度だけ歩かれて置き去りにされ、共有されたリーフは範囲を1度だけ寄与します。名前ツリーに対する同じ懸念は、名前ツリーの循環、不正な/Limits、巨大なリーフの扱いで扱っています
/Nums配列をペアで歩く
/Nums配列は[key0 value0 key1 value1 ...]という交互のエントリの平坦な並びで、偶数位置だけがキーです。このレイアウトは古典的な誤りを誘います。1要素ずつ進みながら「これは数か?」と問うループは、値がたまたま数値であるたびに、値をキーのように比較します。その比較が未満判定に勝つと、挿入位置は奇数インデックスに着地し、新しいペアは既存のペアの中央に入り、後続のすべてのキーと値が場所を取り違えます
ページラベルの値は辞書なので、この誤りは/PageLabelsそのものの上に隠れています。姿を現すのは、同じヘルパーが値が素の数値であるナンバーツリーに再利用された日のことで、配列の構文にはどちらの種類を手にしているかを警告するものが何もありません。PDF Library for Delphiは、リーフのたどりと挿入の両方で、キーをインデックスX * 2で、値をX * 2 + 1で読み、キーの完全一致は値を置き換えて走査を止めます。生のオブジェクトに対して自前のナンバーツリーコードを書くなら、まずこのストライドを写してください
GetPageLabelでラベルを読み返す
TPDFlib.GetPageLabel(Page)は1ベースのページのラベルを返し、その2つのフォールバックは意図的に異なる振る舞いをします。/PageLabelsエントリをまったく持たないドキュメントは10進のページ番号を返すため、呼び出し側は表示のためにこの関数を無条件に使えます。ツリーはあるのに要求ページを覆う範囲がないドキュメントは空文字列を返します。2つ目のケースは、ページインデックス0の必須エントリを飛ばしたファイルが生むものです。最初の数ページは仕様の意味ではラベルを持たず、空文字列はそれをでっち上げの番号よりも正直に報告します。ラベル集合は必ずページ1から始めてください
文字スタイルはスプレッドシートの列ではなくPDF仕様に従います。Zの後はAA、次にBB、次にCCです。桁上げではなく文字の反復なので、A範囲の53ページはAAAです。ローマ数字はRでは大文字、rでは小文字で生成され、プレフィックスはどちらの場合も変更されずに前置されます
var
P: Integer;
Data: WideString;
begin
// ビューアがページボックスに表示するものの手早い監査
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// オプション値4はラベル範囲だけを書き出す。範囲ごとに1つの
// PageLabelBeginブロック
Data := Lib.ExportDocumentData(4);
// ... テキストを編集するか、別のファイルへ運ぶ ...
// 最初のラベルレコードが既存の範囲を消去し、その後の各レコードは
// AddPageLabelsを通して再生される。適用されたレコード数を返す
WriteLn(Lib.ImportDocumentData(Data, 4), ' label ranges applied');
end;
一括編集では、オプション値4のExportDocumentDataが各範囲を、PageLabelNewIndex(1ベースの開始ページ)、PageLabelStart、オプションのPageLabelPrefixとPageLabelNumStyleの行が続くPageLabelBeginブロックとして書き出し、LowercaseRomanNumeralsやDecimalArabicNumeralsのような名前を使います。ImportDocumentDataは最初に出会ったラベルレコードを完全な置き換えとして扱います。一度ClearPageLabelsを呼んでcatalogから/PageLabelsエントリ全体を取り除き、その後すべてのレコードをAddPageLabelsへ流し込みます。組み立て直されたツリーは最初から単一リーフなので、/Kids形のオリジナルを経由したテキストの往復は、ラベルし直しの経路が生み出すのと同じ平坦な形で戻ってきます
ラベルし直し後のリーフ順とページ移動
平坦化は範囲を見つけた順序のまま保持します。§7.9.7はキーの昇順を要求しますが、順序が乱れたリーフを持つファイルは実際に出回っており、GetPageLabelはその順序で、キーがページインデックス以下である最後の範囲を適用します。範囲を[0 roman, 120 A-, 4 decimal]、10進範囲は1から、と宣言するファイルでは、ページ122は10進範囲に解決され、期待したA-2ではなく118と読めます。上のページごとの監査がそのようなラベルを示したら、もう1つのAddPageLabels呼び出しで周辺を補修しないでください。ClearPageLabelsを呼び、全集合を昇順で書き直します。4回の呼び出しのほうが、どの範囲が勝つかの推理より安上がりです
ラベルはページオブジェクトではなくページインデックスに結び付いています。ページ数や順序を変えるものは何でも、範囲をその場所に留め置き、今それらのインデックスに座っているページを何であれ指します。オブジェクト番号を保持したままページを置き換えるようなその場の入れ替えはページ数を保つため、ラベルは整列したままです。インターリーブされた両面スキャンを整理するようなマージは新しいページ列を生むので、その出力には、マージの後にページ1から始まる新たに書かれた範囲集合を与えるべきです
ここで示したラベル呼び出し、/Kidsの平坦化、文書データのエクスポートとインポートは、Delphi、C++Builder、LazarusをターゲットとするPDF Library for Delphiの一部であり、AddPageLabelsのリファレンス項目がスタイル値と戻りコードを1つの表にまとめています