技術記事

Delphi PDFiumの行列PrependとAppend:ピボット回転

PDFのアフィン行列はISO 32000-1 §8.3.3の行ベクトル規約を使っており、点は行列に左から掛かる:point' = point * Mである。DelphiとC++Builder向けのPDFium Componentでは、この1つの事実がTPdfMatrixのAPI表面全体を決めている:MultiplyはappendするのでM := M * Opであり、PreMultiplyはprependするのでM := Op * Mである

古典的な変換のバグはすべて、この一文を逆に覚えてしまったことに行き着く。テストファイルでは綺麗に回転するのに顧客のファイルではページの外に半分はみ出してしまう透かし。すでに90度回転していたページに対してさらに回転を加えたため二重に回転して出てくるサムネイル。A4では完璧なオフセットなのにLetterではずれていくスタンプ。これらはどれもレンダリングのバグではない。掛け算の順序のバグであり、それぞれの操作がどの空間で書かれているかを声に出して言えるようになれば、すべて直せるものばかりだ

規則を決める行ベクトル規約

TPdfMatrixは仕様が名付けた6つの要素を保持し、フォーマットが定義するとおりにそれらを適用する。したがって推論はまさにこの変換そのものから始まる。TPdfMatrix.TransformPointx' = x*a + y*c + ey' = x*b + y*d + fを計算する。これは、現在の変換行列に行列を連結するcm演算子に対してISO 32000-1 §8.3.4が定義する6要素形式である。組(a, b)が最初の行、(c, d)が2番目の行、(e, f)が平行移動の行だ。OpenGLや線形代数の講義で身についた列ベクトルの習慣はここであなたを誤らせるし、それは静かに誤らせる。順序の間違った行列も依然として完璧に有効な行列だからだ。合成を行の規約に沿って左から右へ読めば、適用の順序はただで手に入る:point * (M * Op)(point * M) * Opに等しいため、appendされた操作は既存の行列がすでに生成した座標、つまりページ空間に対して働き、prependされた操作は既存の行列が走る前、そのオブジェクト自身の入力空間の中で働く

var
  M: TPdfMatrix;
  Pt: FS_POINTF;
begin
  M := TPdfMatrix.Create;                // identity
  try
    // Append order: each call acts on what the previous calls produced.
    M.Scale(0.5, 0.5);                   // M := M * S   half size
    M.Rotate(90);                        // M := M * R   clockwise, degrees
    M.Translate(300, 400);               // M := M * T   then move on the page

    Pt := M.TransformPoint(0, 0);        // x*a + y*c + e, x*b + y*d + f
  finally
    M.Free;
  end;
end;

TPdfMatrix.Rotateはデフォルトで時計回り、単位は度であり、ソースデータが逆向きに符号付けされている場合のためにACounterClockwiseAAngleInRadiansも用意されている。読み取り専用のaからfまでのプロパティとHandleプロパティは、生のFS_MATRIXを返してくれる。これはまさにFPDFPageObj_SetMatrixが求めているものだ。このクラスの中には、この6つの数値をあなたから隠すものは何もなく、これは意図的なものだ:変換がおかしな振る舞いをしたとき、aからfを出力することが手に入る中で最も速い診断法だからである

なぜ平行移動をprependするには線形部分が必要なのか

prependされたシフトは行列の入力空間で書かれており、平行移動の行に加わる前に現在の線形部分を通じて運ばれなければならないからだ。したがってTPdfMatrix.PreTranslatee := dx*a + dy*c + ef := dx*b + dy*d + fを計算する。appendするほうが簡単な方向だ:TPdfMatrix.Translateはページ空間で書かれており、そこでは何も変換する必要がないため、単にdxeに、dyfに加えるだけである。誰かがPreTranslateを2回の足し算にまで「最適化」してしまったなら、そのシフトから回転とスケールを削除してしまったことになる

M := TPdfMatrix.Create;
try
  M.Rotate(90);              // a=0, b=-1, c=1, d=0

  M.Translate(10, 0);        // append: e := e + 10
                             // -> 10 points to the right on the page

  M.Reset;
  M.Rotate(90);
  M.PreTranslate(10, 0);     // prepend: e := 10*a + 0*c + e  (unchanged)
                             //          f := 10*b + 0*d + f  (f - 10)
                             // -> 10 points along the stamp own x axis,
                             //    which after the turn points down the page
finally
  M.Free;
end;

同じ非対称性はスケールのペアにも一貫して流れており、朝の3時にデバッグする前にどちらがどの要素に触れるかを知っておく価値がある。TPdfMatrix.PreScaleは行を掛け算する。abscaleXで、cdscaleYでスケールし、平行移動には触れない。そのシフトはすでに下流で起きているからだ。appendするTPdfMatrix.Scaleは代わりに列を掛け算し、acescaleXで、bdfscaleYで掛ける。したがって既存のオフセットも他のすべてと一緒にスケールされる。どちらも一般的な6要素の積をスキップする単一目的の経路であり、どちらも一般形の合成の意味論を正確に保っている

ピボット回転において2つの平行移動はどこに置かれるのか

行列全体の周りにではなく、その操作の周りに、そしてこの順序でである。TPdfMatrix.RotateAtTranslate(-pivot)、それから回転、それからTranslate(+pivot)をappendし、行ベクトル規約のもとではTranslate(-pivot) * Op * Translate(pivot)として合成される。この並びこそが、既存の行列に自分の座標を先に生成させてから受け渡すことを許しつつ、その新しい操作のもとでピボットを固定したままにするものだ。この組を列ベクトルのライブラリで正しいであろう順序で逆に書いてしまうと、オブジェクトはその場で回転する代わりに原点の周りを公転してしまう。これはまさに、中央に置いたはずの透かしがクロップボックスの外に出てしまう経緯そのものである

procedure RotateStampAboutPageCenter(AObj: FPDF_PAGEOBJECT;
  const AAngleDegrees, APageWidth, APageHeight: Single);
var
  M: TPdfMatrix;
  Raw: FS_MATRIX;
begin
  if not FPDFPageObj_GetMatrix(AObj, Raw) then
    raise Exception.Create('Page object carries no matrix');
  M := TPdfMatrix.Create(Raw);
  try
    // Appends Translate(-pivot) * Rotate * Translate(+pivot) in one call.
    M.RotateAt(AAngleDegrees, APageWidth / 2, APageHeight / 2);
    Raw := M.Handle;
    FPDFPageObj_SetMatrix(AObj, Raw);
  finally
    M.Free;
  end;
end;

同じ合成がScaleAtSkewAtHorizontalFlipAtVerticalFlipAtCentralFlipAtを支えているため、回転についてこのパターンを信頼できるようになれば、残りについても信頼できる。TPdfMatrix.CentralFlipは特筆に値する:これは6つの要素すべての符号を反転させることで、三角関数を一切使わずに180度の回転を与えてくれる。つまり本来ちょうどゼロであるべき値のcosを計算することもなく、ループの中で適用しても誤差が蓄積することもない。1つのマークを回転させるのではなく繰り返しマークを配置しているなら、その配置自体の仕組みはForm XObjectによる再利用可能なページスタンプで扱っており、ここでの行列の作業はその真上に乗っている

TryDecomposeは行列について何を教えてくれるのか

TPdfMatrix.TryDecomposeは、スケールしてから回転するという規約のもとで平行移動、スケール、回転、剪断、行列式、反転フラグを報告し、単なるログ用ではなく意思決定に使えるくらい正直にそれらを報告する。ScaleXは最初の行の長さ、Sqrt(a*a + b*b)から来るため、常に正である。ScaleYはその後Determinant / ScaleXとなり、これによって符号付きになる。回転はArcTan2(-b, a)から度単位で得られ、剪断は両方のスケールで正規化された2つの行の内積から得られる

ScaleYのその符号こそが人々が削除してしまう部分であり、それを削除することは見た目の問題ではなく本物のバグである。負の行列式は、その行列が反転を含んでいることを意味する。両方のスケール係数を強制的に正にして数値を綺麗に見せてしまえば、その反転を捨ててしまったことになり、その分解から再構築された行列は鏡映しになって戻ってくる:テキストは逆向きに読め、スキャンされたページは反転し、インポートされたロゴは間違った向きを向く。IsReflectedフィールドが存在するのは、あなたがそれを推測しなくて済むようにするためだ。これはまた、コードがすでに1つの回転を持っているページにさらに表示上の回転を加えてしまう典型的な二重回転を防ぐチェックでもある。この問題のビューア側のバージョンはサムネイルのフィット・ズーム・二重回転で扱っている

var
  D: TPdfMatrixDecomposition;
begin
  if M.TryDecompose(D) then
  begin
    // D.ScaleX is always positive; D.ScaleY carries the determinant sign.
    if D.IsReflected then
      Log('mirrored, ScaleY = %.3f', [D.ScaleY]);

    if Abs(D.RotationDegrees) > 0.5 then
      SkipDisplayRotation;      // the object already carries its own turn
  end
  else
    UseIdentityFallback;        // near-singular or non-finite: no answer
end;

推測なしである矩形を別の矩形に収める

TPdfMatrix.TryCreateRectMappingはソースから宛先への行列をあなたの代わりに構築し、pmfStretchpmfContainpmfCoverのいずれかのTPdfMatrixFitModeを受け取る。PDFの矩形は左が右より小さい、あるいは下が上より小さい形で届くとは限らないため、まず両方の矩形を正規化し、それから独立したXとYのスケールを導出する:pmfStretchはそれらを独立させたままにし、pmfContainは小さいほうを取ってレターボックスを中央に置き、pmfCoverは大きいほうを取ってクロップを中央に置く。付随するMapRectToRectは同じマッピングを既存の行列にappendし、NewRectMappingTry形式がFalseを返す場面でEPdfMatrixErrorを発生させる。これはN-up面付けとページの並べ替えにおけるあらゆるセル配置の下敷きになっているプリミティブであり、そこでは各ソースページが、レイアウトごとに演算を導き直すことなく計算済みのセルの内側に着地しなければならない

退化した行列と正直な失敗経路

有限の入力が有限の結果を保証するわけではない。そのためフィッティングのコードはDoubleで計算し、それから狭められたSingleの候補を公開する前にあらためて有限性をチェックする。無限大を含むマッピングが有効なものであるかのように返されることは決してない。同じ規律が逆行列の計算も支配している。TPdfMatrix.TryGetInverseは相対的な閾値を使ってある行列を拒否する。行列式を固定の定数ではなく、イプシロンに最大の線形要素の二乗を掛けたものと比較する。これが、単位がポイントであろうとマイクロメートルであろうとこのテストを意味あるものにし続ける理由だ。TryDecomposeも同じように、最初の行の長さや導出されたScaleYがイプシロン以下に落ちるときには手を引く

習慣でtry-exceptですべてを包むのではなく、その呼び出し箇所に合った失敗のスタイルを選ぶこと。TryInvertTryGetInverseTryInverseTransformPointTryTransformBoundsTryCreateRectMappingFalseを返しそのターゲットには触れないままにする。これはヒットテストやオブジェクトごとのループに向いている。退化したオブジェクトは致命的にではなくスキップされるべきだからだ。InvertInverseCopyInverseTransformPointMapRectToRectTransformBoundsは代わりにEPdfMatrixErrorを発生させる。これはセットアップのコードに向いている。特異な行列は呼び出し元が何かを間違って計算したことを意味するからだ。バッチ処理のために、TransformPointsTransformRectsは結果の配列をちょうど1回だけ確保し、TransformPointsInPlaceTransformRectsInPlaceはあなたのストレージを再利用し、TryTransformBoundsは変換された点をまず実体化するのではなく1回のパスでバウンディングボックスを積み上げていく

これはどれも奇特な数学ではない。1つの規約を一貫して適用しており、APIの名前はその規約が呼び出し箇所で見えるように付けられている:Multiplyと普通の動詞はappendし、Pre系はprependし、At系はその操作をピボットの組で挟む。組み立てるどんな合成のそばにも、コメントでその順序を書き留めておくこと。今日正しく読めるコードは、半年後に誰かが逆にしてしまうコードだからだ。この変換が供給するページオブジェクトとレンダリングAPIとともに、完全なTPdfMatrixのリファレンスは、DelphiとC++Builder向けのPDFium Componentに収められている