技術記事

PDFlibPasページボックス:TrimBox、BleedBox、CropBox既定値

PDFページにTrimBoxがないとき、その実効TrimBoxはページのCropBoxであり、CropBoxもなければMediaBoxになります。BleedBoxとArtBoxも同じルールに従います。Delphi向けPDF LibraryのPDFlibPasは、v3.539.44以降、このデフォルトチェーンをGetPageBox、HasPageBox、CapturePageExで一貫して適用し、/Pagesノードに置かれたプロダクションボックスは無視します。ISO 32000-1がそれらの継承を許していないからです

面付けの案件を扱うまで、この話は脚注にしか見えません。6.25×9.25インチのMediaBoxを持ち、CropBoxが6×9インチの仕上がりに設定され、TrimBoxを持たない書籍の本文を想像してください。書き出した人がTrimBoxを書こうとは思わなかったのです。仕上がりボックスを要求したら、代わりにメディアボックスが返ってきて、印刷シート上のすべてのセルが隣のセルへ、1/8インチのブリードとスラグを引きずり込みます。PDFlibPasはまさにこの領域に不具合を抱えており、v3.539.42とv3.539.44で修正しました。その直し方は、ページボックスのセマンティクスをどんなPDFライブラリでもどう実装すべきかについて、何かを語っています

ページにTrimBoxがないとき、どのボックスが適用されるのか

答えはISO 32000-1 §14.11.2の固定されたデフォルトチェーンです。CropBoxはMediaBoxをデフォルトとし、BleedBox、TrimBox、ArtBoxはそれぞれCropBoxをデフォルトとします。CropBox以外でMediaBoxへ直接デフォルトするものはありません。だからMediaBoxだけを定義するページは5つの同一ボックスを持ち、MediaBoxとCropBoxを定義するページは、CropBoxに等しい4つのボックスを持ちます

ボックスPDFlibPas BoxType欠落時のデフォルト/Pagesからの継承
MediaBox1なし。エントリーは必須はい
CropBox2MediaBoxはい
BleedBox3CropBoxいいえ
TrimBox4CropBoxいいえ
ArtBox5CropBoxいいえ

2段階のチェーンが効いてくるのは、CropBox自身が継承され得るからです。TrimBoxもCropBoxも自分で持たないページの実効TrimBoxは、それを持つ最も近い祖先のCropBoxであり、それもなければ継承されたMediaBoxです。仕様には忘れやすいルールがもう1つあります。crop、bleed、trim、artの各ボックスはメディアボックスを越えてはならず、越えるなら実質的にメディアボックスとの共通部分へ縮められます。PDFlibPasは各ボックスをファイルに格納されたとおりに報告するので、信頼できない入力を扱うバリデーターは、MediaBoxに対して自前でクランプすべきです

PDFlibPasのページボックスデフォルトチェーンを示す図。CropBoxはMediaBoxをデフォルトとし、BleedBox、TrimBox、ArtBoxはそれぞれCropBoxをデフォルトとします。450×666ポイントのMediaBoxと432×648ポイントのCropBoxを持つ書籍本文が並んで描かれ、TrimBoxが存在しなければCropBoxが実効仕上がりになります
MediaBoxへ直接デフォルトするのはCropBoxだけです。だからMediaBoxしか持たないページは5つの同一ボックスを持ちます

/Pagesノードが受け渡せるページ属性はどれか

ちょうど4つです。Resources、MediaBox、CropBox、Rotate。ISO 32000-1 §7.7.3.4が属性継承を定義し、Table 30はこの4つのページオブジェクトエントリーだけを継承可能としています。BleedBox、TrimBox、ArtBoxはリーフページの持ち物です。/Pagesノードへ書き込まれたTrimBoxは継承値ではなく、適合リーダーが無視する非標準キーです

その種の非標準ファイルは実在します。たいていは「すべてのページがこの仕上がりを持つ」の略記として、ルートページツリーノードにTrimBoxを1つ置いたものです。この略記は、すべてのキーで/Parentを歩くどんなツールでも正しく見えます。それが問題です。ファイルは読む人によって2通りの意味を持つようになったのです。仕様に従うリーダーはTrimBoxを見ずにCropBoxを使い、すべてを継承するリーダーは親の値を見ます。プリプレスのパイプラインでは、この曖昧さが最終的に印刷シートへ降りてきます

PDFlibPasのページツリー継承を示す図。Pagesノードを下って渡せるのはResources、MediaBox、CropBox、Rotateだけで、ルートに置かれたTrimBoxは適合リーダーが無視する非標準キーです。v3.539.44より前は2つの独立したコード経路がそれを継承し、1つのドキュメントに対して異なる仕上がりサイズを報告しました
ファイルは読む人によって2通りの意味を持ちます。プリプレスのパイプラインでは、その曖昧さは印刷シートに着地します

PDF/X(ISO 15930)のワークフローは仕上がりサイズをTrimBoxに依存し、PDF/Xプロファイルは各ページにTrimBoxかArtBoxの宣言を要求します。/Pagesノードに置かれたボックスはその要件を満たしません。キーはページオブジェクトへ届かないからです。プリフライトは、黙ってどちらかの読み方をするのでなく、そういうファイルにフラグを立てるべきです

v3.539.44より前のPDFlibPasは何を間違えていたのか

PDFlibPasには3つの独立した不具合があり、すべて、仕様の言うことと2つの独立したコード経路のやっていたことの間の隙間にありました。1つ目はv3.539.42で、残る2つはv3.539.44で修正済みです

キャプチャ時にプロダクションボックスがMediaBoxをデフォルトにしていた

v3.539.42より前、ページをキャプチャ用に準備する内部ルーチンは(継承エントリーをページへコピーし、欠落ボックスを埋めるものです)、BleedBox、TrimBox、ArtBoxが欠けているときにMediaBoxの値を与えていました。オプション2から4のCapturePageExは、バウンディング矩形をまさにその埋め込まれたエントリーから読みます。だからCropBoxだけを定義するページでtrimボックスを要求すると、メディアボックス全体がキャプチャされていたのです。GetPageBoxはすでにCropBoxデフォルトを適用しており、CapturePageExのリファレンスも昔から、要求されたボックスが欠けるときはcropボックスが使われると書いていました。キャプチャコードだけが両方に反対していたわけです。v3.539.42以降、3つのプロダクションボックスはページのCropBoxをデフォルトとします。その時点でCropBoxはすでにページの上にあります(自分のもの、祖先からコピーされたもの、MediaBoxから埋められたもののいずれか)。MediaBoxへフォールバックするのはCropBox自身だけです

2つの継承経路、1つのセマンティクスルール

2つ目の不具合は非標準の継承そのもので、厄介な部分は、PDFlibPasが2つの独立した経路でボックスを解決していたことでした。ボックス照会(GetPageBoxとHasPageBox)はあるヘルパー経由で/Parentチェーンを歩き、キャプチャは別のローカルヘルパーで歩いていました。どちらも、プロダクションボックスを含めてすべてのキーを継承します。片方だけ直したら、1つのドキュメントの中に矛盾が生まれていたでしょう。/Pagesノードに幅180ポイントのTrimBox、ページに幅380ポイントのCropBoxがあるとき、GetPageBoxは仕上がり幅180を報告し続け、CapturePageExは幅380のフォームを組み立てる。v3.539.44では、両経路とも/Parentの歩行を4つの継承可能キーへ制限し、プロダクションボックスはリーフからだけ読み、迷子の親エントリーはファイルに手つかずで残ります。削除も書き換えもしません

PDFlibPas HasPageBoxの戻り値0、1、2と、v3.539.44以降は直接配列も間接配列も継承として数えるようすを示す図。その隣にCapturePageExのオプション0から4があり、v3.539.42以降、BleedBox、TrimBox、ArtBoxはMediaBoxではなくCropBoxへフォールバックします
1つの仕様ルールに対する2つの実装入口は、一緒に直し、18シナリオのマトリクスとしてテストされます。照会とキャプチャはどのファイルでも一致します

HasPageBoxは直接の親配列を見落としていた

HasPageBoxは、要求されたタイプのボックスをページが持たないとき0、ページが自分のボックスを持つとき1(直接格納か間接参照かを問わず)、MediaBoxやCropBoxが祖先から継承されているとき2を返します。旧コードは継承値が間接参照のときだけ2を返していたので、継承された直接配列は0を返していました。修正は参照外しを配列テストから分離し、両表現とも2を返すようになりました。v3.539.44以降、BleedBox、TrimBox、ArtBoxのHasPageBoxは0か1しか返せません

この教訓はページボックスを遥かに越えて一般化します。仕様のセマンティクス1つに、ライブラリ内に実装入口が2つあるときは、それらを一緒に直し、1つのハッピーパスファイルでなくマトリクスとしてテストすることです。PDFlibPasのリグレッションセットは、2つの親ボックス表現(直接と間接配列)に、3つのリーフ状態(欠落、直接配列、間接配列)と3つのキャプチャオプション(bleed、trim、art)を交差させ、18シナリオを作ります。それぞれが、照会結果、キャプチャされた境界、正当なMediaBoxとCropBoxの継承、手つかずの親エントリーを検査します

Delphiで実効TrimBoxはどう読むか

選択中のページでGetPageBox(4, Dimension)を呼んでください。PDFlibPasがデフォルトチェーンを適用してくれるので、ページがTrimBoxを持とうが持つまいが、結果は実効TrimBoxです。値がどこから来たかを知る必要があるときはHasPageBoxと組み合わせます。プリフライトレポートはたいていそれを必要とします

uses
  System.SysUtils, PDFlibrary;

const
  BOX_CROP   = 2;
  BOX_TRIM   = 4;
  DIM_LEFT   = 0;
  DIM_WIDTH  = 2;
  DIM_HEIGHT = 3;
  DIM_BOTTOM = 5;

function DescribeTrim(Lib: TPDFlib; Page: Integer): string;
var
  Source: string;
begin
  Lib.SelectPage(Page);
  if Lib.HasPageBox(BOX_TRIM) = 1 then
    Source := 'own TrimBox'
  else if Lib.HasPageBox(BOX_CROP) <> 0 then   // 1 = 自前、2 = 継承
    Source := 'defaulted to the CropBox'
  else
    Source := 'defaulted to the MediaBox';
  Result := Format('page %d: trim %.2f x %.2f pt at (%.2f, %.2f), %s',
    [Page,
     Lib.GetPageBox(BOX_TRIM, DIM_WIDTH),
     Lib.GetPageBox(BOX_TRIM, DIM_HEIGHT),
     Lib.GetPageBox(BOX_TRIM, DIM_LEFT),
     Lib.GetPageBox(BOX_TRIM, DIM_BOTTOM),
     Source]);
end;

var
  Lib: TPDFlib;
  Page: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('interior.pdf', '') = 1 then
      for Page := 1 to Lib.PageCount do
        Writeln(DescribeTrim(Lib, Page));
  finally
    Lib.Free;
  end;
end.

GetPageBoxもSetPageBoxも、ドキュメントの現在の座標設定で動きます。ここの例はデフォルトで走っています。原点0(左下、PDFユーザー空間に一致)と、測定単位はポイント。だからTopディメンションは、ページの下から上へ測った上端です。SetOrigin(1)の後は、TopとBottomディメンションは代わりにページの上から下へ測られ、SetMeasurementUnits(1)の後はすべての値がミリメートルで返ります。幅と高さは原点に依存しません

/Pagesノードに取り残されたプロダクションボックスを見つける

v3.539.44以降、ボックスAPIは/Pagesノード上のTrimBoxを見なくなりました。それが正しい挙動ですが、プリフライトツールはたいてい、黙って仕様どおりに読むのでなく、そういうファイルを報告したがります。ページツリーノードは普通のオブジェクトなので、低レベルのオブジェクトAPIで見つけられます。GetMaxObjectNumberまでオブジェクト番号を歩き、それぞれをGetObjectToStringで読み、プロダクションボックスキーを運ぶ/Pages辞書を探します。検査の後半は、PDF/Xが気にかけるページごとのテストです。HasPageBoxは今や、PDF/Xバリデーターと同じやり方でそれに答えます。親のTrimBoxはもう数えられないからです

procedure PreflightTrim(Lib: TPDFlib; Log: TStrings);
const
  ProductionKeys: array[0..2] of string = ('/BleedBox', '/TrimBox', '/ArtBox');
var
  ObjNum, K, Page, Missing: Integer;
  Src: string;
begin
  // 1. ページツリーノード上のプロダクションボックス:非標準であり無視される
  for ObjNum := 1 to Lib.GetMaxObjectNumber do
  begin
    Src := '';                                // 空き番号はテキストを返さない
    Src := string(Lib.GetObjectToString(ObjNum));
    if Pos('/Type /Pages', Src) = 0 then
      Continue;
    for K := Low(ProductionKeys) to High(ProductionKeys) do
      if Pos(ProductionKeys[K] + ' ', Src) > 0 then
        Log.Add(Format('object %d: %s on a /Pages node is not inheritable',
          [ObjNum, ProductionKeys[K]]));
  end;

  // 2. PDF/X:各ページには専用のTrimBoxかArtBoxが必要
  Missing := 0;
  for Page := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(Page);
    if (Lib.HasPageBox(4) = 0) and (Lib.HasPageBox(5) = 0) then
    begin
      Inc(Missing);
      Log.Add(Format('page %d: no TrimBox or ArtBox', [Page]));
    end;
  end;

  // 3. 任意の修復:6.25 x 9.25インチのメディアボックス内の6 x 9インチ仕上がり
  //    (ポイント、左下原点:Left、Top、Width、Height)
  if Missing > 0 then
    Log.Add(Format('TrimBox written on %d pages',
      [Lib.SetPageBoxRange('', 4, 9, 657, 432, 648)]));
end;

テキストマッチは実務的な検査であって、パーサーではありません。PDFlibPasが各辞書エントリーを、キー、スペース1つ、値としてシリアライズすることに依存しています。これはGetObjectToStringで読み戻したオブジェクトについては成立します。修復ステップは、反射でなく判断を要します。迷子の親値は作者の意図どおりかもしれないので、公式にする前に作業チケットと突き合わせてください。空の範囲でのSetPageBoxRangeはボックスを全ページへ適用し、更新されたページ数を返します。ページの既存ボックスが間接配列のとき——別のページや/Pagesノードが共有しているかもしれません——SetPageBoxは共有オブジェクトを書き換える代わりに、そのページへ新しい直接配列を与えます。BleedBox、TrimBox、ArtBoxの設定は、ロックされていないドキュメントをPDF 1.3へ引き上げもします。それらのエントリーを導入したバージョンです

CapturePageExでTrimBoxに面付けする

CapturePageEx(Page, 3)はページを、バウンディングボックスがそのページの実効TrimBoxであるフォームXObjectへ変え、DrawCapturedPageがそのフォームを任意のサイズで別のページへ置きます。v3.539.42以降、TrimBoxを持たないページでのオプション3は、リファレンスの記述どおり、スラグだらけのMediaBoxではなくCropBoxをくれます

キャプチャの2つの性質がコードを形作ります。キャプチャは破壊的です。キャプチャされたページはドキュメントから除去され、ドキュメントが0ページへ落ちることは決して許されません。だから何かをキャプチャする前に、最初の出力シートを追加しておきます。キャプチャは1つのドキュメント内でしか働かないので、すべての入力を先に1つのドキュメントへ集めます。PDFソースを1パスで整理・インターリーブする技法がそのまま使えます

procedure ImposeTwoUp(const InFile, OutFile: string);
var
  Lib: TPDFlib;
  Captures: array of Integer;
  SourceCount, I: Integer;
  TrimW, TrimH: Double;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(InFile, '') <> 1 then
      raise Exception.Create('Cannot open ' + InFile);
    SourceCount := Lib.PageCount;

    // ページ1の実効仕上がりサイズ(このレイアウトは一律の仕上がりを想定)
    Lib.SelectPage(1);
    TrimW := Lib.GetPageBox(4, 2);
    TrimH := Lib.GetPageBox(4, 3);

    // 最初のシートを追加してサイズ設定。NewPageが新ページを選択する
    Lib.NewPage;
    Lib.SetPageDimensions(2 * TrimW, TrimH);

    // キャプチャごとにページ1が除去され、次のソースページが繰り上がる
    SetLength(Captures, SourceCount);
    for I := 0 to SourceCount - 1 do
    begin
      Captures[I] := Lib.CapturePageEx(1, 3);   // 3 = TrimBox
      if Captures[I] = 0 then
        raise Exception.CreateFmt('Capture of source page %d failed', [I + 1]);
    end;

    // 残りはシートだけ:シートごとに仕上げ済みページ2枚を並べる
    Lib.SelectPage(1);
    for I := 0 to SourceCount - 1 do
    begin
      if (I > 0) and (I mod 2 = 0) then
        Lib.NewPage;                            // 現在のシートと同じサイズ
      // デフォルト原点:Topは下から測った上端
      Lib.DrawCapturedPage(Captures[I], (I mod 2) * TrimW, TrimH, TrimW, TrimH);
    end;
    Lib.SaveToFile(OutFile);
  finally
    Lib.Free;
  end;
end;

trimベースのキャプチャはTrimBoxの外側をすべてクリップします。デジタル校正やカット&スタックレイアウトならそれが欲しいものです。印刷後に断ち切られる印刷シートでは、ブリードを生き延びさせるためにオプション2でキャプチャし、セルの間隔をブリード幅で空けます。キャプチャはソースページを除去するので、それらを指していたブックマークやリンクはターゲットを失います。ナビゲーションがまだ必要なドキュメントを編集するのでなく、別の出力ファイルへ面付けしてください。ブックマークを壊さずにページを置き換えるが、ページ手術のその側面を扱います

ソースを無傷のままにしなければならないときは、ImportPageAsFormXObject(SourceDocumentID, SourcePage, Options)が同じ0から4のオプション値を取ります(現在のドキュメントにはLib.SelectedDocumentを渡します)。ソースのページツリーは無変更のまま、継承されたページ回転はフォームマトリクスへ正規化され、DrawCapturedPageが受け付けるハンドルを返します。CapturePageExは/Rotateを取り消さないので、回転入力はまずそのステップが要ります。ページボックスを壊さずにページ回転をフラット化するが、そうしたとき各ボックスに何が起こるかを示します。/Pagesノードにプロダクションボックスを運び得る入力への1つの注意。インポート経路は、v3.539.44で整列された2経路とは別の、独自の祖先ルックアップでボックスを解決します。だからまずソースページでHasPageBox(4)を確認し、0を返したらオプション1(CropBox)を渡してください。そうすれば結果は、ファイルがたまたまそう書かれたことではなく、仕様に結び付きます

ページボックスのクイックリファレンス

  • 実効CropBox:ページ自身のCropBox、なければ最も近い継承CropBox、なければ実効MediaBox(ISO 32000-1 §14.11.2)
  • 実効BleedBox、TrimBox、ArtBox:リーフページ自身のエントリー、なければ実効CropBox
  • /Pagesノードから継承するのはResources、MediaBox、CropBox、Rotateだけ(§7.7.3.4、Table 30)。/Pagesノード上のプロダクションボックスは無視される
  • GetPageBox(BoxType, Dimension):BoxTypeは1がMediaBox、2がCropBox、3がBleedBox、4がTrimBox、5がArtBox。Dimensionは0がLeft、1がTop、2がWidth、3がHeight、4がRight、5がBottom
  • HasPageBox(BoxType):0はボックスなし、1はページ自身のボックス(直接か間接)、2は継承されたMediaBoxかCropBox(直接か間接)
  • CapturePageEx(Page, Options):0はMediaBox、1はMediaBoxフォールバック付きCropBox、2から4はCropBoxフォールバック付きのBleedBox、TrimBox、ArtBox
  • ボックス照会とキャプチャ全体で一貫したデフォルトと継承を得るには、v3.539.44以降へアップグレードする

ページボックスは、PDFの静かなデフォルトが、ミリメートルの端数で測られるプリプレスの公差と出会う場所です。ライブラリは、それらのデフォルトをどこでも同じやり方で適用するか、さもなくば1つの質問に2つの答えを手渡すかのどちらかです。ボックス、キャプチャ、フォームXObjectの完全なAPIは、PDFlibPas PDF Library for Delphi製品ページで文書化されています