PDFium Component 的 FPDF_RenderPageBitmap 函式接受一個 rotate 參數,而 PDFium 每次都會把它疊加到頁面自身 /Rotate 項目中的旋轉值上。因此,讀取頁面已儲存的旋轉值,再把同一個值傳回轉譯呼叫,就會讓頁面旋轉兩次。適合縮放計算也會出現同類錯誤:如果根據頁面未旋轉時的寬高來确定縮圖尺寸,那麼 /Rotate 為 90 或 270 度時寬高已經交換,最終長寬比就會錯誤
一旦知道該觀察什麼,這個故障很容易發現;在知道之前却很容易漏掉。假設一批掃描發票同時包含直向與橫向原稿,其中一半在封存前被人用 Acrobat 旋轉了 90 度,而基于 PDFium 建置的 Delphi 檢視器縮圖條恰好把這些頁面轉譯成横着、倒置,或者挤进適合另一種方向的框中。整個程序不會擲出出例外,也不會紀錄錯誤。像素只是錯了,而且只錯在後來被旋轉过的那部分頁面上——這種問題正好可以躲过针对未旋轉測試 PDF 的完整 QA 檢查,最後在生產环境真实檔案的第 47 页暴露出來
為什麼 PDFium 會把頁面旋轉兩次?
PDFium 每次轉譯點陣圖時,都會自動應用程式頁面自身的 /Rotate 值,不论傳给轉譯器的是什麼。PDFiumPas 将 FPDF_RenderPageBitmap 的 rotate 參數暴露為 TPdf.RenderPage、TPdf.RenderTile 與 TPdf.RenderPageThumbnail 上的 TRotation 值 ro0、ro90、ro180 與 ro270。這個參數並不是设置頁面最終应處的角度,而是把額外的旋轉疊加到頁面字典已經指定的旋轉上,這正是這些方法默認使用 ro0 的原因
TPdf.PageRotation 透過 FPDFPage_GetRotation 讀取同一個 /Rotate 值,而應用程式程式可能需要它來完成與轉譯無关的工作,例如决定如何在頁面坐標空白間中配置註解。陷阱就在這一行:把 PageRotation 傳给 RenderPage 的 Rotation 參數,以為這样就能把頁面正向化為正向。一個已經以 /Rotate 90 儲存的頁面,在任何符合正向的檢視器中都會正確顯示為旋轉状态,PDFium 也一样;如果再疊加一個 ro90,頁面就會轉成 180 度,而非預期的 90 度;沒有任何旋轉的頁面则會無缘無故被轉过四分之一圈
// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);
// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);
Rotation 參數究竟用於什麼
Rotation 參數在 API 中承担的是另一項确实不同的工作:新增與頁面已儲存方向無关、只影响檢視的旋轉,也就是旋轉工具列按鈕在不修改底層檔案的情况下應用程式的那種旋轉。TPdfView 正是因此把兩個概念拆成兩個獨立屬性。TPdfView.PageRotation 映射頁面自身的 /Rotate,并且可以透過 FPDFPage_SetRotation 将新值寫回檔案;TPdfView.Rotation 是暫時的、只影响檢視的屬性,默認值為 ro0,永遠不會修改檔案。讀取前一個屬性,再把它寫入後一個屬性,就完整概括了這個 bug
// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
case PdfView.Rotation of
ro0: PdfView.Rotation := ro90;
ro90: PdfView.Rotation := ro180;
ro180: PdfView.Rotation := ro270;
ro270: PdfView.Rotation := ro0;
end;
end;
// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
case PdfView.PageRotation of
ro0: PdfView.PageRotation := ro90;
ro90: PdfView.PageRotation := ro180;
ro180: PdfView.PageRotation := ro270;
ro270: PdfView.PageRotation := ro0;
end;
end;
為什麼適合縮放也會出現同样的問題?
適合縮放出錯的原因恰好相反:計算使用了錯誤的一对數字,而非錯誤的角度。典型的縮圖尺寸計算會向 PDFium 請求頁面寬高,将其長寬比與可用框进行比較,再計算能放入框內的最大矩形;對於未旋轉頁面,這種方法執行良好。但如果寬高來自一個回報頁面固有未旋轉尺寸的呼叫,那麼 /Rotate 為 90 或 270 度時,同样的計算會悄悄失败:带有 /Rotate 90 的 A4 直向頁面仍會回報大约 595 × 842 點,尽管旋轉生效後 PDFium 會正確地以大约 842 × 595 轉譯,而根據未旋轉尺寸計算出的適合框最終完全采用了錯誤的方向
FPDF_GetPageSizeByIndex 就是一個按设计回報頁面固有未旋轉尺寸的具體呼叫,因此它適合在不載入每個頁面的情况下掃描頁面尺寸,却不適合在忘记考虑旋轉時直接用於適合縮放計算。修正方法也很直接:在进行適合运算前檢查頁面旋轉角度,在旋轉為 90 或 270 度時交換寬高,用交換後的數值計算適合框,并且在實際轉譯呼叫中仍傳入 ro0,因為真正應用程式旋轉的仍然是 PDFium
無需重新實現適合計算,正確產生縮圖
TPdf.RenderPageThumbnail 已經包含了這個修正,因此產生正確縮圖的最短路徑是直接呼叫它,而非手动重新拼装適合與旋轉逻辑。給定从 1 開始的頁碼以及最大寬高,RenderPageThumbnail 會計算適合框,在內部针对 /Rotate 為 90 或 270 度的情况进行修正,并返回由呼叫方负责释放的點陣圖,同時不會改變檔案目前頁面,也不會觸發 OnPageChange 事件——對於在同一個 TPdf 实例旁邊建置即時檢視器的縮圖條來說,這一點很重要
// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
PgW, PgH, Swap: Integer;
begin
PgW := Round(PageW);
PgH := Round(PageH);
if PgW < 1 then PgW := 1;
if PgH < 1 then PgH := 1;
if Rotation in [ro90, ro270] then
begin
Swap := PgW;
PgW := PgH;
PgH := Swap;
end;
Result := (MaxW > 0) and (MaxH > 0);
if not Result then
Exit;
if PgW * MaxH > PgH * MaxW then
begin
FitW := MaxW;
FitH := (MaxW * PgH) div PgW;
end
else
begin
FitH := MaxH;
FitW := (MaxH * PgW) div PgH;
end;
end;
FitBox 辅助函式本身也值得保留,因為 RenderPageThumbnail 只涵蓋單一點陣圖的情况。自定義縮圖網格、列印預覽條或頁面選擇對話方塊如果要把多個頁面分別配置到獨立框中,就需要同样具备旋轉感知的適合計算,却未必希望為每個圖块都创建一张新點陣圖;TPdfView 自身的適合頁面與適合宽度縮放模式內部也依赖同一思路:在與可用客戶区比較前,根據檢視目前旋轉状态,在頁面寬高之間選擇用於計算縮放比例的數值。如果這類檢視器的下一個問題是縮放與捲動效能,那麼关于 基于 PDFium 的 Delphi 檢視器中的轉譯缓存與平滑縮放的配套文章正好从正確尺寸計算之後繼續展開
如何在客戶發現之前辨識重複旋轉
重複旋轉有一個可靠的視觉特徵:原本在輸入時被旋轉 90 度的頁面,最終相對於檔案其他頁面會多轉 180 度,而非維持 90 度,因為額外的 ro90 疊加在頁面自身的 ro90 上,而非取代它。只包含 /Rotate 0 頁面的測試樣本永遠發現不了這個問題,因為 ro0 加 ro0 仍然是 ro0,bug 會繼續隐藏;在信任縮圖或適合縮放代碼路徑之前,測試樣本至少要包含一個以 /Rotate 90 儲存的頁面與一個以 /Rotate 270 儲存的頁面
使用 PDFium Component 将 PDF 頁面轉譯為 JPEG中介绍的基礎頁面到點陣圖流程,已經可以在不新增特殊處理代碼的情况下正確轉譯旋轉頁面,正是因為它讓 Rotation 維持默認值 ro0,并由 PDFium 自行應用程式 /Rotate。只有当應用程式程式開始讀回 PageRotation,并把它傳到不該出現的位置時,重複旋轉 bug 才會出現
這里介绍的旋轉感知轉譯呼叫與縮圖尺寸計算屬於面向 Delphi 與 C++Builder 的 PDFium Component,與其他基于 TPdf 與 TPdfView 類建置的轉譯、查看與文字提取 API 一样