技術文章

Delphi 中 PDFium 頁面物件控制代碼在變換後過期

FPDFPage_TransFormWithClip 重寫一頁時,你手上已持有的每個 FPDF_PAGEOBJECT 控制代碼,描述的依然是變換之前那次剖析。Delphi 與 C++Builder 版的 PDFium Component,在 TransformPageContent 內部解決了這個問題,它會卸載文字頁、重新產生內容,然後重新載入頁面,讓之後的查詢能看見新的座標

症狀很安靜。你套用一個 0.9 的縮放來加一道列印邊界,接著讀取 PageObjectInfo,得到的數字跟呼叫前一模一樣。沒有例外,沒有錯誤碼,日誌裡什麼都沒有。這和編輯後文字頁過期一文所描述的快取文字頁是不同的失效:那裡的快取是單一個 FPDF_TEXTPAGE 控制代碼,你可以丟掉再重建;這裡的問題出在你自己變數裡的每一個頁面物件控制代碼,再加上一整類透過大多數呼叫端會直接丟掉的回傳碼來回報失敗的 getter

為何頁面物件邊界會悄悄過期,不帶任何錯誤?

因為一個頁面物件控制代碼是指向某個特定內容串流已剖析表示法的指標,而一次全頁變換會用一個新的內容串流取代它。PDFium 不會去翻你的呼叫堆疊、找出該修補哪些控制代碼。它會建構一個全新的物件圖,把舊的那個原封不動留在原地,所以對舊控制代碼的一次讀取,是對一個不再對應檔案實際內容的結構的一次完全有效的讀取

ISO 32000-1 §7.8.2 把內容串流定義為繪製一頁的運算子序列,§8.3.3 定義了目前變換矩陣如何把使用者空間映射到裝置空間。一次頁面層級的變換,是透過包裹並重寫那些運算子來表達的,而不是就地編輯逐物件座標。所以物件攜帶的座標可能根本沒變;改變的是繪製時生效的矩陣。任何在舊矩陣下被剖析出來的控制代碼,回答幾何問題時用的都是舊矩陣,而且回答時不會有任何抱怨

FPDFPage_TransFormWithClip 實際上重寫的是什麼

它重寫的是頁面,不是你的快照。FPDFPage_TransFormWithClip 接受一個 FS_MATRIX 和一個 FS_RECTF 裁切矩形,把兩者套用到整頁內容。它是做邊界、拼版縮放,以及把一個尺寸奇怪的頁面正規化對齊目標框的正確呼叫。若你期望既有控制代碼能跟著走,這就是個錯誤的呼叫,也值得記住它只碰頁面內容:註解是獨立的一層,需要 TransformPageAnnotations,這個函式會把同樣的六個矩陣係數轉發給 FPDFPage_TransformAnnots

var
  Info: TPdfPageObjectInfo;
  Scale: FS_MATRIX;
  Clip: TPdfRectangle;
begin
  Pdf.PageNumber:= 1;
  Info:= Pdf.PageObjectInfo(0);           // snapshot taken before the transform

  Scale.a:= 0.9;   Scale.b:= 0.0;
  Scale.c:= 0.0;   Scale.d:= 0.9;
  Scale.e:= 29.7;  Scale.f:= 42.0;        // 5% margin, A4 in points
  Clip:= Pdf.GetPageBox(pbMedia);
  Pdf.TransformPageContent(Scale, Clip);

  // Info.Bounds still holds pre-transform geometry, and Info.Handle now
  // points into a page that TransformPageContent has already replaced
end;

TransformPageContent 使用的刷新順序

四個步驟,依此順序:卸載文字頁、變換、產生內容、重新載入頁面。TPdf.TransformPageContent 執行的正是這個順序。它呼叫 CheckPageActive,把矩陣與裁切區域複製進它們的原生記錄形狀,呼叫 UnloadTextPage,再呼叫 FPDFPage_TransFormWithClip,接著是 UpdatePage(也就是包裹 FPDFPage_GenerateContent 的封裝),最後是 ReloadPage

每個步驟都有它存在的理由。UnloadTextPage 排第一,因為快取的 FPDF_TEXTPAGE 持有的是在舊矩陣下計算出來的字元框,它也會一併丟掉衍生出來的網頁連結清單和任何進行中的尋找作業,那些都是根據它建構出來的。FPDFPage_GenerateContent 必須在重新載入之前執行,因為變換會停留在記憶體內的頁面中,直到它被序列化回內容串流,重新載入否則只會重新剖析未修改的串流。ReloadPage 以針對目前頁面索引的 FPDF_LoadPage 收尾,這是唯一真正能給你一份全新物件圖的動作

// After the transform, re-enumerate. Do not reuse anything captured earlier.
var
  I: Integer;
  Info: TPdfPageObjectInfo;
begin
  Pdf.TransformPageContent(Scale, Clip);   // unload text page, transform,
                                           // generate content, reload page
  for I:= 0 to Pdf.ObjectCount- 1 do
  begin
    Info:= Pdf.PageObjectInfo(I);          // handle and bounds from the new parse
    if Info.Bounds.Right> PageWidth then
      Log('object '+ IntToStr(I)+ ' still overflows after scaling');
  end;
end;

ReloadPage 裡有個細節,如果你自己要寫這個順序,值得抄下來。它先載入新頁面,只有之後才把它提交進欄位,所以一次失敗的頁面載入,會讓目前的原生頁面與它所有的衍生快取維持完整,而不是把你丟進一個拆到一半的狀態裡。重新載入不是免費的——你付出的代價是一次完整的頁面重新剖析——但這個代價每次變換只付一次,而不是每次查詢都付一次,而且沒有更便宜的正確替代方案

不要把控制代碼帶過重新載入

重新載入之後,舊的控制代碼不只是過期了,它們是懸空的。先前的 FPDF_PAGE 已經關閉,屬於它的 FPDF_PAGEOBJECT 值都是指向已釋放記憶體的指標。TPdfPageObjectInfo 在它的 Handle 欄位裡公開了原生控制代碼,這對於把一個物件直接傳進更底層的呼叫確實很有用,但把它保存在一個表單欄位或一個清單裡、跨越一次會重新載入頁面的操作,同樣是貨真價實的危險。把一筆快照記錄只當成在下一次重新產生內容的呼叫之前有效,這與ABI 與記憶體安全筆記裡討論的所有權規則精神一致

一個 getter 能不能失敗、卻依然看起來像有效資料?

可以,這是同一個問題的第二半。FPDFPageObj_GetRotatedBoundsFPDFPageObj_GetIsActive 是帶輸出參數的 getter:它們回傳一個 int 成功旗標,把真正的答案寫進一個參照引數。對一個已經被建立、但其頁面尚未被重新剖析的物件而言,兩者都可能回傳 FALSE。這種情況發生時,輸出參數不會被觸碰,而一筆用 Default(TPdfPageObjectInfo) 初始化的 Pascal 記錄全部是零,所以呼叫端看到的是一個四個點都在原點的四邊形,以及一個 False 的 Active 旗標。一次失敗的呼叫,被悄悄升格成了看似合理的資料

TPdfPageObjectInfo 用明確的崗哨值回應這個問題。HasRotatedBounds 攜帶 FPDFPageObj_GetRotatedBounds 呼叫的結果,HasActiveState 攜帶 FPDFPageObj_GetIsActive 呼叫的結果,幾何與狀態欄位只有在對應的崗哨值為 True 時才會被寫入。同樣的形狀在記錄裡對其他帶輸出參數的 getter 重複出現,所以 HasMatrixHasFillColorHasStrokeColorHasStrokeWidth 全都意味著同一件事:原生呼叫成功了,旁邊的欄位才有意義

Info:= Pdf.PageObjectInfo(I);

if Info.HasRotatedBounds then
  // RotatedBounds is array [1..4] of TPdfPoint, in draw order
  UseQuad(Info.RotatedBounds[1], Info.RotatedBounds[2],
          Info.RotatedBounds[3], Info.RotatedBounds[4])
else
  // the native call failed; fall back to the axis-aligned rectangle
  UseRect(Info.Bounds);

if Info.HasActiveState and (not Info.Active) then
  SkipObject(I);         // genuinely inactive
// if HasActiveState is False, the object state is unknown, not inactive

這個模式適用於每個遵循「回傳碼加輸出參數」慣例的 PDFium getter,而這樣的 getter 有很多。如果一個包裝把這個慣例摺疊成一個單純的函式回傳值,它就等於丟掉了唯一能區分「答案就是零」和「根本沒有答案」的訊號。每個欄位多帶一個布林值,成本是一個位元組,卻能消滅一整類「一筆預設值記錄被誤認為一次測量結果」的錯誤

這裡仍然會咬人的地方

三個誠實的限制。第一,這個刷新是逐頁的:變換第二頁時,你為第一頁持有的任何控制代碼不受影響,但你現在有兩頁在不同時間被剖析,記得哪些快照來自哪一頁是你的責任。第二,跨越一次內容重新產生時,索引穩定性不受保證——重新載入之後,索引 3 是新剖析裡的索引 3,不管原本是什麼,所以應該靠型別與幾何重新辨識物件,而不是假設位置維持不變。第三,FPDFPage_TransFormWithClip 裡的裁切矩形套用在頁面內容上,不會調整任何一個頁面框的大小;如果你把內容縮小以製造邊界,MediaBox 依然是原本的大小,閱讀器看到的會是原本尺寸的紙張,裡面縮著一份繪圖。這一切都不奇特——這正是一個把指標交給呼叫端、把生命週期留給呼叫端管理的 C API 的尋常後果。修法到哪都一樣:明確定義一份快照何時過期,在那個邊界上刷新,絕不讓一次失敗的呼叫偽裝成一個值

如果你更廣泛地在處理矩陣行為,決定變換落點的乘法順序,涵蓋於前置、後置與樞紐矩陣一文。這裡描述的變換與頁面物件 API,隨 Delphi 與 C++Builder 版的 PDFium Component 一併出貨,其產品頁面收錄頁面物件快照記錄與其崗哨欄位的完整參考