技術文章

Delphi 中 PDFium 矩陣的前置與後置:樞軸旋轉

PDF 仿射矩陣,採用的是 ISO 32000-1 §8.3.3 的列向量慣例,一個點是從左側乘上矩陣:point' = point * M。在 Delphi 與 C++Builder 適用的 PDFium Component 中,光是這一項事實,就固定了 TPdfMatrix 整個 API 表面的形狀:Multiply 是後置(append),所以 M := M * Op;而 PreMultiply 是前置(prepend),所以 M := Op * M

每一個經典的變換錯誤,追根究柢,都源自於把這句話記反了。在你的測試檔案裡旋轉得乾淨俐落的浮水印,到了客戶的檔案裡,卻有一半跑到頁面外。縮圖出現了雙重旋轉,因為頁面本身早就帶著四分之一圈的旋轉。在 A4 上偏移量完美無缺的印章,到了 Letter 尺寸上卻開始飄移。這些通通不是渲染錯誤;它們都是乘法順序錯誤,而且一旦你能夠大聲說出每個運算是寫在哪個空間裡的,它們就通通能被修好

決定一切規則的列向量慣例

TPdfMatrix 儲存的是規範所命名的那六個元素,並完全按照格式所定義的方式套用它們,所以推理應該從變換本身開始。TPdfMatrix.TransformPoint 計算的是 x' = x*a + y*c + ey' = x*b + y*d + f,這正是 ISO 32000-1 §8.3.4 為 cm 運算子所定義的六元素形式,該運算子會把一個矩陣串接到目前的變換矩陣上。(a, b) 這一對是第一列,(c, d) 是第二列,(e, f) 則是平移列。從 OpenGL 或線性代數課程裡養成的行向量習慣,在這裡會誤導你,而且是無聲無息地誤導你,因為一個順序錯誤的矩陣,依然是一個完全合法的矩陣。用列慣例由左至右讀一個複合運算,套用順序自然就顯現出來:因為 point * (M * Op) 等於 (point * M) * Op,一個後置的運算,作用在既有矩陣已經產生出來的座標上,也就是頁面空間;而一個前置的運算,則在既有矩陣執行之前就先作用,作用在該物件自己的輸入空間裡

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。唯讀屬性 af,以及 Handle 屬性,會把原始的 FS_MATRIX 交還給你,這正是 FPDFPageObj_SetMatrix 所需要的東西。這個類別裡沒有任何一處會把這六個數字對你隱藏起來,這是刻意的設計:當一個變換出現異常行為時,把 af 印出來,是你能得到的最快診斷方式

為何前置一次平移需要用到線性部分?

因為一次前置的平移,是寫在矩陣的輸入空間裡的,它必須先經過目前的線性部分轉換,才能加入平移列。因此 TPdfMatrix.PreTranslate 計算的是 e := dx*a + dy*c + ef := dx*b + dy*d + f。後置則是簡單的方向:TPdfMatrix.Translate 是寫在頁面空間裡的,那裡沒有任何東西需要轉換,所以它只需要把 dx 加到 e 上、把 dy 加到 f 上。任何人如果把 PreTranslate「最佳化」成只剩兩次加法,等於就是把旋轉與縮放,從這次平移中硬生生刪掉了

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;

同樣的不對稱性,也貫穿了縮放這一對函式,在你半夜三點被迫除錯之前,先搞清楚每一個函式各自碰觸哪些元素,是值得的。TPdfMatrix.PreScale 做的是列的乘法,把 ab 乘上 scaleX、把 cd 乘上 scaleY,並且完全不動平移部分,因為那次位移已經在下游發生過了。後置版本 TPdfMatrix.Scale 做的則是欄的乘法,把 ace 乘上 scaleX、把 bdf 乘上 scaleY,所以既有的偏移量,會跟其他一切一起被縮放。這兩者都是跳過通用六元素乘法的單一用途路徑,而且兩者都精確地保留了通用形式的組合語意

在一次樞軸旋轉中,這兩次平移分別放在哪裡?

圍繞在該次運算的兩側,而不是圍繞整個矩陣,而且順序如此。TPdfMatrix.RotateAt 依序後置了 Translate(-pivot)、旋轉本身,再接著 Translate(+pivot),在列向量慣例下,這組合起來就是 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 特別值得單獨一提:它把全部六個元素取負,就能給你一次 180 度的翻轉,完全不需要任何三角函數,也就是說,不會出現理應恰好為零的值卻要算 cos 的情況,在迴圈裡反覆套用時也不會累積漂移誤差。如果你要放置的是重複出現的標記、而不是旋轉單一一個物件,置放本身的機制,涵蓋在 用 Form XObject 打造可重複使用的頁面印章一文 中,本文的矩陣運算,正好建構在它之上

TryDecompose 能告訴你關於一個矩陣的什麼資訊?

TPdfMatrix.TryDecompose 會依「先縮放、後旋轉」的慣例,回報平移量、縮放量、旋轉角度、切變量、行列式,以及一個反射旗標,而且它回報得夠誠實,足以拿來做決策依據,而不只是用來記日誌。ScaleX 來自第一列的長度,Sqrt(a*a + b*b),所以它永遠是正數。ScaleY 則是 Determinant / ScaleX,這讓它帶有正負號。旋轉角度來自以角度表示的 ArcTan2(-b, a),切變量則來自兩列的內積、再依兩個縮放量正規化而得

ScaleY 上的這個正負號,正是大家會刪掉的部分,而刪掉它是一個真正的錯誤,不只是外觀上不整齊而已。一個負的行列式,代表這個矩陣包含一次反射。為了讓數字看起來比較整齊,硬把兩個縮放因子都變成正數,就等於把這次反射給扔掉了,所以由分解結果重建出來的矩陣,會變成鏡像的:文字讀起來是反的,掃描出來的頁面會翻面,匯入的商標則會朝向錯誤的方向。IsReflected 欄位的存在,正是為了讓你永遠不必自己去推斷這件事。這同時也是能防止經典雙重旋轉問題的檢查:程式碼把一次顯示用的旋轉,加到一個本來就已經帶有旋轉的頁面上;這個問題在檢視器端的版本,在 縮圖適應、縮放與雙重旋轉一文 中有詳細說明

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 會替你建構出一個從來源到目的地的矩陣,並接受一個 TPdfMatrixFitMode 參數,可以是 pmfStretchpmfContain,或 pmfCover。它會先把兩個矩形正規化,因為 PDF 矩形並不保證左側一定小於右側、底部一定小於頂部,接著推導出獨立的 X 與 Y 縮放量:pmfStretch 讓兩者保持獨立,pmfContain 取較小的那一個、並把留白置中,pmfCover 則取較大的那一個、並把裁切置中。搭配使用的 MapRectToRect,會把同樣的映射後置到一個既有矩陣上,而 NewRectMapping 則會在 Try 形式回傳 False 的地方,改為拋出 EPdfMatrixError。這正是 N-up 拼版與頁面重排一文 中,每一次儲存格置放背後的基礎運算,讓每一張來源頁面,都能落在計算出來的儲存格裡,而不必為每種版面配置重新推導一次算式

退化矩陣與誠實的失敗路徑

有限的輸入,並不保證會得到有限的結果,所以適配運算的程式碼,是以 Double 精度計算的,並在發布結果之前,重新檢查縮窄後的 Single 候選值是否仍然有限;一個含有無限大的映射結果,絕不會被當成合法結果交還出去。同樣的紀律,也支配著求反矩陣的運算。TPdfMatrix.TryGetInverse 是用一個相對門檻來拒絕某個矩陣,比較的是行列式與「epsilon 乘以最大線性元素的平方」,而不是與一個固定常數比較,這正是讓這項檢查無論你的單位是點還是微米,都依然有意義的關鍵。TryDecompose 也採用同樣的方式提早退出,當第一列的長度、或推導出來的 ScaleY 落在等於或小於 epsilon 時,就拒絕繼續

依呼叫點的需求選擇合適的失敗風格,而不是出於習慣,把所有東西都包在 try-except 裡。TryInvertTryGetInverseTryInverseTransformPointTryTransformBoundsTryCreateRectMapping,會回傳 False,並讓目標保持原樣不動,這適合命中測試(hit-testing)以及逐物件迴圈的情境,在這些情境裡,一個退化物件應該被跳過,而不是造成致命錯誤。InvertInverseCopyInverseTransformPointMapRectToRectTransformBounds 則改為拋出 EPdfMatrixError,這適合初始化程式碼,在那裡一個奇異矩陣意味著呼叫端算錯了什麼東西。對於批次工作,TransformPointsTransformRects 只會配置一次結果陣列,TransformPointsInPlaceTransformRectsInPlace 則重複使用你自己的儲存空間,而 TryTransformBounds 是在單一一次掃描中累積出邊界框,而不是先把所有變換後的點都實體化出來

這一切都不是什麼稀奇古怪的數學。它就是一套慣例,被一以貫之地套用,而 API 的命名方式,也讓這套慣例在呼叫點上清晰可見:Multiply 與一般動詞是後置,Pre 家族是前置,At 家族則用一對樞軸,把運算本身括起來。在你建構的任何複合運算旁邊,用註解把順序寫下來,因為今天讀起來正確的程式碼,就是六個月後會被某人搞反的程式碼。完整的 TPdfMatrix 參考文件,連同這些變換所餵入的頁面物件與渲染 API,都收錄在適用於 Delphi 與 C++Builder 的 PDFium Component 之中