技術記事

Delphiでページボックスを壊さずPDFページ回転をフラット化

HotPDFはTHotPDF.FlattenLoadedPageRotationでPDFページの回転をフラット化します。このメソッドは、回転した各ページのコンテンツを時計回りのcm変換で包み、ページが実際に持つすべてのページボックスを書き直し、注釈のジオメトリ、アピアランスマトリクス、明示的デスティネーション、タグ付き構造のジオメトリを同じ角度で回し、それから/Rotateを0に設定します。ビューアーでの見た目は同一ですが、座標系はもう真っ直ぐです。下流のツール、印刷RIP、あるいは自分のスタンプコードが/Rotateを無視して生のユーザー空間に配置する瞬間、この違いが効いてきます

典型的な引き金は、横向きページを縦向きメディアと/Rotate 90として書くスキャナーやモバイルのキャプチャーアプリです。どのビューアーも正しく表示するので、誰かが「右下」にページ番号をスタンプして左端に横倒しで着地したり、/MediaBoxしか読まない面付け工程が横向きページに縦向きスロットを割り当てたりするまで、誰も気付きません。フラット化は1行のマトリクス作業に聞こえます。実際には5つのページボックス、3種類の注釈ジオメトリ、ドキュメントのリンク先、構造ツリーに触れ、それぞれがISO 32000-1に固有のルールを持っています

/RotateはPDFページをどちらへ回すか

/Rotateは表示と印刷のためにページを時計回りに回します。90度の倍数で(ISO 32000-1 §7.7.3.3、Table 30)。90度ではメディアの左端が上になり、上端は右側になります。y下向きのデバイス空間では、マッピングはX = (y - Bottom) * Scale、Y = (x - Left) * Scaleです。270度では右端が上になります。/Rotateは継承可能なページ属性4つのうちの1つでもあります。/Resources、/MediaBox、/CropBoxとともに(§7.7.3.4)。つまり自分の/Rotateを持たないページ辞書でも、/Pagesの祖先によって回されます。THotPDF.GetLoadedPageRotationは/Parentチェーンをたどり、結果を0〜359へ正規化します。欲しいのはこの値で、ページ上の生のキーではありません

方向は、テストを生き延びる形で間違えやすく、以前のHotPDFビルドはまさにそうでした。旧ページからデバイスへのマトリクスは、90と270でy成分を入れ替えていました。これは回転ではなく、対角線を軸とする鏡映を生みます。マトリクスの向きが、無回転の場合と比べて反転するのです。どちらの角度でも「回転して見え」、ビットマップは入れ替わった幅と高さを持ち、ページからビューへ戻る往復は開始点を返します。寸法検査も往復テストも、すべて通ってしまうのです。信頼できる唯一の検査は、コーナーマーカーがどこに着くかを、リファレンスレンダラーとピクセル単位で比較することです。ビューアーモデル、SIMDレンダーバックエンド、ハイライトマッピングが同じマトリクスをコピーしていたため、すべて一緒に修正され、フラット化コードは今、レンダラーと同じ時計回りの規約を使います

HotPDFがDelphiでページ回転をフラット化する方法。/Rotate 90付きで保存された縦向きページは時計回りに792 × 612の横向きビューとして表示されます。デバイスマッピングX = (y - Bottom) * Scale、Y = (x - Left) * Scaleが各コーナーを動かし、マトリクスのy成分の入れ替えは鏡映を生み、コーナーマーカーの比較だけがそれを捕まえます
ビューアーは表示のためにページを時計回りに回しますが、バイト列は縦向きのままです。GetLoadedPageRotationはまず/Parentチェーンをたどります。/Rotateは継承可能なページ属性4つのうちの1つだからです

FlattenLoadedPageRotationがページを書き直す方法

FlattenLoadedPageRotation(PageRange, Info)は、実効回転が90、180、270であるPageRange内の全ページを処理し、フラット化したページ数を返します。空のPageRangeは全ページを意味し、そうでなければ文字列はいつもの1ベースの'1-3,7'構文を使い、範囲外のページ番号はスキップではなく例外を上げます。元のコンテンツストリームは再エンコードされません。メソッドは、q 0 -1 1 0 -Bottom Width+Left cm(90度の場合)を含む新しいストリームをページの/Contentsへ前置し、Qを含むストリームを後置し、最後に明示的な/Rotate 0をページ辞書へ書き込みます。/Pagesノードの継承値が2度目の回転をしかけられないようにするためです

var
  Pdf: THotPDF;
  Info: THPDFRotationFlattenInfo;
  Flattened: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
    begin
      // '' = 全ページ。0度のページは走査対象だが手つかず
      Flattened := Pdf.FlattenLoadedPageRotation('', Info);
      Writeln(Format('Scanned %d, flattened %d pages', [Info.ScannedPageCount, Info.FlattenedPageCount]));
      Writeln(Format('Turned %d annotations, %d destinations, %d tagged geometry entries',
        [Info.TransformedAnnotationCount, Info.TransformedDestinationCount,
         Info.TransformedStructureGeometryCount]));
      if Flattened > 0 then
        Pdf.SaveLoadedDocument('scanned-batch-upright.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

THPDFRotationFlattenInfoレコードは、捨てずにログへ残す価値があります。ScannedPageCountは範囲のサイズ、FlattenedPageCountは戻り値と等しく、3つのTransformed...カウンターは、文書に回したページを指すリンク、ブックマーク、タグ付きジオメトリがあったかどうかを教えます。どのファイルもゼロデスティネーションを報告するバッチなら大丈夫です。図のバウンディングボックスを期待していたタグ付きPDF/UAファイルが、構造ジオメトリゼロを報告するなら、手で検査すべきサインです

フラット化はどのページボックスをどの順で書き直すか

フラット化が書き直すのは、ページがすでに持つボックスだけです。しかも何かを書く前にすべてのボックスを読みます。順序が効くのはデフォルトチェーンのためです。GetLoadedPageBox(PageIndex, pbCropBox, ...)は、ページに/CropBoxがなければ/MediaBoxを返し、/BleedBox、/TrimBox、/ArtBoxはCropBoxへデフォルトします(§14.11.2)。以前のバージョンは、1つのボックスを読み、変換し、書く、を順にやっていました。MediaBoxを先に書き直し、それから「CropBox」を読むと、すでに回ったMediaBoxが返り、もう1度回して、ページが持ったことのないCropBoxを書きました。横向きページが正方形に刈り込まれたのです。継承ルールも同じように分かれます。MediaBoxとCropBoxは/Parentチェーンに沿って検索されますが、Bleed、Trim、ArtBoxはページ辞書自身に座っているときしか数えません。だから/Pagesノードの迷子の/TrimBoxは不在として扱われ、ページへコピーされることはありません

procedure DumpPageGeometry(Pdf: THotPDF; PageIndex: Integer);
var
  L, B, R, T: Single;
begin
  Writeln('Effective /Rotate: ', Pdf.GetLoadedPageRotation(PageIndex));
  if Pdf.GetLoadedPageBox(PageIndex, pbMediaBox, L, B, R, T) then
    Writeln(Format('MediaBox [%g %g %g %g]', [L, B, R, T]));
  // /TrimBoxキーなしでもTrue:値はCropBox、次にMediaBoxへフォールバック
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Letterをプリセット。GetLoadedPageVisibleBoxは失敗時も出力を触らない
  L := 0; B := 0; R := 612; T := 792;
  Pdf.GetLoadedPageVisibleBox(PageIndex, L, B, R, T);
  Writeln(Format('Visible  [%g %g %g %g]', [L, B, R, T]));
end;

フラット化の前後でこのヘルパーを走らせれば、数字が自分で説明します。MediaBox [0 0 612 792]の90度ページでは、フラット化後のMediaBoxは[0 0 792 612]になります。書き直されたすべてのボックスは、元のMediaBoxの原点を基準に同じ時計回りの回転を通ります。新しいMediaBoxは常に原点から始まり、他のボックスはその内側の位置を保つのです。GetLoadedPageVisibleBoxは、ビューアーが表示しプリンターが印刷するもの、つまりMediaBoxへクリップされLeftがRightより小さくなるよう正規化されたCropBoxを返します。HotPDFのレンダラー、SVGエクスポート、ビューアー、印刷経路はすべてこの同じボックスを使います。人間の見るページサイズが必要なときは、/MediaBoxを読むのではなくGetLoadedPageVisibleBoxを呼んでください

FlattenLoadedPageRotationでHotPDFが書く前にすべてのページボックスを読む理由。BleedBox、TrimBox、ArtBoxはCropBoxへデフォルトし、CropBox自身はMediaBoxへフォールバックします。だからボックスを1つずつ回すと、CropBoxがすでに書き直されたMediaBoxを読み、2度目の回転がページの持ったことのないボックスを書き、横向きページを正方形に刈り込みました
デフォルトチェーンでは、あるボックスの出力が別のボックスの入力です。まず全部読み、元のMediaBoxの原点に対して変換し、それから書く

/Rectだけ回すとなぜ注釈が壊れるのか

注釈が壊れるのは、アピアランスストリームが/Rectへ直接描かれないからです。§12.5.5のもとで、ビューアーはまずフォームの/BBoxをその/Matrixで変換し、それからその結果のバウンディングボックスを/Rectへスケール、平行移動します。/Rectだけ回すと、200 × 40のスタンプが40 × 200のスロットへ絞め込まれ、横向きのまま読めません。そこでFlattenLoadedPageRotationは、ページの時計回りの回転を各アピアランスの/Matrixへ右から乗算します(90度なら行ベクトル規約で[0 -1 1 0 0 0])。/N、/R、/Dのアピアランスと、その中のすべての状態にわたってです。1つのアピアランスストリームは複数の注釈や状態で共有されうるので、各ストリームは呼び出しごとに正確に1度だけ回されます。きれいな答えのない唯一のケースは、異なる回転を持つ複数ページにわたって共有されるストリームです。先に到達したページに従います

あと2つのルールが、フォームフィールドと付箋を所定の位置に保ちます。ウィジェットの/MK /Rエントリー(§12.5.6.19)は反時計回りの角度なので、そこからページの時計回りの角度を引きます。360でモジュロ。これを飛ばすと、次のアピアランス再生成がフィールドテキストを逆方向へ描きます。NoRotateフラグ(ビット位置5、値16、§12.5.3)を持つ注釈は、回転したページでも真っ直ぐに保たれ、自分の/Rectの左上コーナーをピボットにします。フラット化は幅、高さ、直立した見た目を保ち、そのコーナーを回転が置く場所へ動かすだけです。注釈を越えて、メソッドは/QuadPoints、/Vertices、/L、/InkListも回し、ページを名指す明示的デスティネーションを書き直し(/XYZの点、/FitRの矩形、90度と270度で入れ替わる/FitH//FitV、§12.3.2.2)、/Pgがそのページである構造要素の属性/BBoxエントリーといったタグ付きジオメトリも変換します

HotPDFページを/Rectの回転だけでフラット化すると注釈が壊れる理由。200 × 40のスタンプは40 × 200のスロットへスケールされ、読めなくなります。だからFlattenLoadedPageRotationは時計回りの回転を各アピアランス/Matrixへ右から乗算し、/N、/R、/Dにわたり、反時計回りの/MK /Rを調整し、NoRotate注釈を左上コーナーでピボットさせます
ビューアーはアピアランスの変換済みBBoxを/Rectへはめ込みます。だからストリーム自身を回さなければなりません。共有アピアランスごとに1パス、呼び出しごとに正確に1度

フラット化がカバーしないもの

フラット化は1ページ自身のオブジェクトの幾何学的書き直しであり、いくつかの状況は派手にではなく静かにその外へ外れます

  • 実効回転がすでに0であるページや、MediaBoxが欠けているか幅か高さがゼロのページは、エラーなしでスキップされます。戻り値を、変化を期待したページ数と比較してください
  • ページリソースから参照されるフォームXObjectは、フォーム空間の自分の/BBoxを保ちます。外側のcmがすでに回しているからです。構造ツリーの走査は/Kと/Aだけをたどるので、ページのリソースや注釈へ2度目に入り込むことはありません
  • デスティネーションは、フラット化したページごとにすべての間接オブジェクトを1回走査して見つけます。数百の回転ページを持つ大きな文書は、その歩行の代金を各ページで払います
  • HotPDFのページレンダラーは注釈を描かないので、回したスタンプの目視検査には、まずFlattenLoadedAnnotationsが要ります
// アピアランスをコンテンツへ焼き、レンダラーが表示できるようにしてから、
// /Rotateを外す前後でページ1をレンダリングする
Pdf.FlattenLoadedAnnotations('1');
Before := Pdf.RenderLoadedPageToBitmap(0, 96);
try
  Pdf.FlattenLoadedPageRotation('1', Info);
  After := Pdf.RenderLoadedPageToBitmap(0, 96);
  try
    Assert((Before.Width = After.Width) and (Before.Height = After.Height));
    // ここで寸法だけでなくコーナーマーカーのピクセルを比較する
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

さらに深い背景として、この話の注釈側はフラット化前のアピアランス合成へ続きます。前後比較の背後にあるレンダラーはロード済みPDFページのビットマップへのレンダリングで扱い、ロード済みPDFのリダクションとN-up連結は、回転のプレフィックスとサフィックスが頼るのと同じコンテンツストリーム追記技法を見せます。HotPDFは、FlattenLoadedPageRotationとページボックスリーダーを含め、DelphiとC++Builder向けにHotPDF Delphi PDF component pageで入手できます