技術記事

DelphiのPDFページラベル:/Kidsナンバーツリーの修復

PDF Library for DelphiはAddPageLabelsでページラベルの範囲を書き込みます。v3.539.10以降、この呼び出しは、/PageLabelsナンバーツリーが/Kidsノードに分割された読み込み済みファイルでも動きます。新しい範囲を入れる前にルートを単一の/Numsリーフへ平坦化するので、ラベルは黙って無視される代わりに、ビューアーに実際に表示されます。典型的な被害者はレイアウトツールが作る書籍スタイルのPDFです。前付けにローマ数字、本文にアラビア数字、付録にはA-1、A-2のラベル。付録だけラベルし直したかったのに、何も変わらなかった、という場面です

PDFページラベルとは何か、どう格納されるか

ページラベルは、ビューアーが物理ページインデックスの代わりにページボックスに表示する文字列で、ISO 32000-1 §12.4.2はcatalogキー/PageLabelsの下にナンバーツリーとして格納します。各キーはラベル範囲の始まる0ベースのページインデックスで、各値は最大3エントリーを持つページラベル辞書です。番号スタイルの/S(D、R、r、A、a)、プレフィックス文字列の/P、そして範囲の最初のページの数値である/St。既定値は1です。範囲は次のキーまで続きます。そして仕様は、ツリーがページインデックス0の値を含むことを要求するので、すべてのページが何らかの範囲に覆われます

PDFlibPasの用語で見たページラベルの格納。/PageLabelsナンバーツリーは各範囲を0ベースの開始ページでキー付けし、値はどれも/Sスタイル、/Pプレフィックス、/Stの最初の番号を持つラベル辞書で、書籍の例はローマ数字の前付け、アラビア数字の本文ページ、A-の付録を3つの範囲へ対応させます
範囲は次のキーまで続き、仕様はページインデックス0の値を要求し、GetPageLabelはキーがページ以下である最後の範囲を適用するので、すべてのページは何かに解決されます
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進)
    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;

TPDFlib.AddPageLabels(Start, Style, Offset, Prefix)は、3つの規則を知ってしまえば、引数をその辞書へ驚きなく対応させます。Startはライブラリの他のページ引数と同じく1ベースで、ツリーにはStart - 1として書き込まれます。Styleは0から5までで、0はプレフィックスのみ、1から5は/Sの値D、R、r、A、aになります。この範囲の外は0を返し、何も触れません。Offsetはゼロより大きいときだけ/Stになり、0を渡せばキーは単に省略され、ビューアーは既定の1へフォールバックします。ページラベルはPDF 1.3で入ったので、この呼び出しはEnsureMinVersion('1.3', '/PageLabels')も実行します。保存バージョンを明示的にロックしていない限り、古いファイルの出力バージョンを引き上げます

ツリーが/Kidsを持つと新しいページラベルが消えるのはなぜか

新しいラベルが消えるのは、ISO 32000-1 §7.9.7(Table 37)がナンバーツリーのルートに/Kidsか/Numsのどちらか一方だけを持たせ、両方を持たせないからであり、以前のNumTreeSetヘルパーは/Numsの探し方しか知らなかったからです。長い文書を出すプロデューサーは、ツリーを中間ノードに分割し、各ノードに/Limitsのペアを付け、/Kidsだけを持つルートにぶら下げることがよくあります。旧コードはそのルートに/Numsを見つけられず、既存の/Kidsの隣に新しいものを作り、そこへ新しい範囲を挿入しました。結果は、互いに排他的な入り口を2つ持つルートです。ビューアーは/Kidsを下りていき、迷子の配列を決して見ません。ライブラリー自身のEnumNumTreeも/Kidsを先に確認し、NumTreeLookupはHasKids xor HasNumsが偽になるノードを拒否します。AddPageLabelsはそれでも1を返し、保存されたファイルはそれでも問題なく開きました。最悪の種類の失敗です。何も文句を言わず、ラベルはただ同じままです

NumTreeSetの修正は、何かを挿入する前にルートをリーフへ変換します。ルートが/Kidsを持っているとき、EnumNumTreeがすべてのリーフを順にたどってキーと値のペアを収集し、そのリストから新しい平坦な/Nums配列を作り、平坦な配列を付ける前に/Kidsと/Limitsと古い/Numsをルートから掃除します。/Limitsを落とすのは見た目の話ではありません。Table 37はこのエントリーを中間ノードとリーフにだけ許し、ルートには決して許さないからです。そこから先の挿入は、1つの配列への普通のソート済み挿入で、既存の範囲は元のラベル辞書のまま生き残ります。このトレードオフは意図的です。ツリーを後からバランスの取れた/Kidsノードへ作り直すことはしません。ページラベルについては、それで失うものはありません。大きなリファレンスマニュアルでも範囲は数十個を超えることはまれで、ほとんどのプロデューサーはもともと単一リーフを書くからです

PDFlibPasのナンバーツリー修復。/Kidsと迷子の/Nums配列を運ぶルートは、ISO 32000-1が両者のうち1つしか許さないためビューアーに見えません。そこでNumTreeSetは全リーフを単一の/Nums配列へ平坦化し、Table 37がルートに決して許さない/Kidsと/Limitsを掃除します
文句が出なかったのは、すべての検査が通ったからです。AddPageLabelsは1を返し、保存ファイルは問題なく開き、/Kidsを先に下りるリーダー、つまりビューアーもライブラリー自身もそうするのですが、そのやり方では新しい範囲は決して見つかりません
// /PageLabelsのルートが/Kidsを使うファイルで付録をラベルし直す
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
  WriteLn('Before: ', Lib.GetPageLabel(121));  // 例:A-1
  // 121ページから始まる範囲を置き換える:App-a、App-b ...
  if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
    Lib.SaveToFile('vendor-manual-relabeled.pdf');
  // 既存のローマ数字と10進の範囲は、平坦化されたリーフに残ったまま
  WriteLn('After: ', Lib.GetPageLabel(121));   // App-a
  WriteLn('Front: ', Lib.GetPageLabel(2));     // ii、変化なし
end;

/Nums配列がキーとして誤読される仕組み

/Nums配列が誤読されるのは、コードが要素を1つずつたどるときです。配列は[key0 value0 key1 value1 ...]という交互のペアの平坦な並びで、キーは偶数位置だけだからです。旧NumTreeSetのループはすべての要素を数値型かどうか検査していたので、たまたま数値だった値がキーとして比較されました。小なり比較が当たると挿入位置が奇数インデックスに設定され、新しいペアが既存のペアの真ん中に落ちて、後続のペアすべてがずれました。EnumNumTreeも同じ1ステップのたどり方をしていました。両方とも、いまはストライド2でペアを反復し、X * 2でキーを、X * 2 + 1で値を読み、キーが正確に一致したら値を置き換えてBreakで抜けます。公平に言えば、ページラベルの値は辞書なので、この2つ目のバグが/PageLabels自身で発火することはまれでした。しかし間違ったストライドで読むナンバーツリーのヘルパーは、値が数値になった瞬間にデータを壊します。同じパスで直されました

PDFlibPasナンバーツリーのペアストライド修正。/Nums配列はキーと値が交互に並ぶ平坦な並びなので、全要素を検査するたどり方は新しいペアを奇数インデックスに挿入し後続のペアをずらせました。修正後のたどり方はキーをX*2で、値をX*2+1で読みます
ラベルの値は辞書なので、このバグが/PageLabelsで発火することはまれでした。しかし間違ったストライドで読むナンバーツリーのヘルパーは、値が数値になった瞬間に壊れます。だから両方のたどり方は、いまはペアで歩みます

ラベルの読み返しと往復

TPDFlib.GetPageLabel(Page)は1ベースのページのラベルを返し、知っておく価値のあるフォールバックを2つ持っています。/PageLabelsのエントリーがまったくなければ10進のページ番号を返すので、呼び出し側は無条件に使えます。ツリーはあるのにページを覆う範囲がない場合は空文字列を返します。これは、ファイルが必須のインデックス0のエントリーを飛ばしていたときにちょうど起きる状況です。リファレンス文書は、ラベルが正しく表示されるためにはページ1から始まる範囲が存在しなければならないと述べており、コードはその要求を見える形にしています。文字スタイルはスプレッドシートの列ではなく仕様に従います。Zの次はAA、そしてBB。桁上げではなく、文字の繰り返しです

var
  P: Integer;
  Data: WideString;
begin
  // ビューアーがページボックスに表示するものの手早い監査
  for P := 1 to Lib.PageCount do
    WriteLn(P, ' -> ', Lib.GetPageLabel(P));

  // オプション値4はラベル範囲だけをPageLabelBeginレコードとして書き出す
  Data := Lib.ExportDocumentData(4);
  // インポートはClearPageLabels + AddPageLabelsを通して再生する
  Lib.ImportDocumentData(Data, 0);
end;

一括編集では、オプション値4のExportDocumentDataがすべての範囲を、PageLabelNewIndex、PageLabelStart、PageLabelPrefix、PageLabelNumStyleの各行を持つPageLabelBeginブロックとして書き出し、ImportDocumentDataは最初に見つけたラベルレコードを完全な置き換えとして扱います。ClearPageLabelsを1回呼び、それから各レコードをAddPageLabelsへ流し込むのです。元のファイルが/Kidsツリーを使っていても、テキストの往復は決定論的になります。クリアがcatalogエントリー全体を取り除き、作り直されたツリーは最初から単一リーフだからです

修正がそれでも保証しないこと

平坦化は一方向で、見つけた順序を信用します。EnumNumTreeはファイル順にペアを収集し、GetPageLabelはキーがページインデックス以下である最後の範囲を適用します。だからリーフが順不同の外部ファイル、§7.9.7が禁じてはいるものの実際に出回っているものは、ClearPageLabelsと新しいAddPageLabelsの呼び出しで範囲を作り直すまで、間違ったラベルを出し続けえます。ラベルはページオブジェクトではなくページインデックスに束縛されるので、ページ数や順序を変える操作は、範囲をその場に置き去りにします。オブジェクト番号を保ったままページを置き換えるようなインプレースの入れ替えはページ数を保つのでラベルも整列したままですが、インターリーブされた両面スキャンのCollateのようなマージは新しいページ順を作るので、新しく書いた範囲のセットに値します

ここで述べたページラベルの呼び出し、ナンバーツリーの処理、そして文書データのエクスポートとインポートは、Delphi、C++Builder、Lazarus向けのPDF Library for Delphiにすべて同梱されています。AddPageLabelsのリファレンス項目に、スタイル値と戻り値の文書があります