PDFlibPas 是供 Delphi 與 C++Builder 使用的原生 VCL PDF 元件函式庫,透過 TPDFContentStateTracker 類別重播頁面的內容串流,完全不需要接觸任何繪圖畫布。每次餵入一個已剖析的運算子,追蹤器就會持續記錄圖形狀態,包括目前的轉換矩陣、文字矩陣、裁剪範圍與 q/Q 儲存堆疊,讓呼叫端能在每個運算子執行前後取得快照
如果要問一段文字實際落在列印頁面的哪個位置,單看內容串流中的原始數字,每次都會讓你誤判。TPDFContentProgram.GetTextRuns 已經透過 TPDFTextRun 上的 OriginX 與 OriginY 欄位,回報每個顯示文字指令的錨點;欄位註解也明確說明,此點位於文字空間,並且已經套用 Tm、Td、TD 與 T*。仍然缺少的部分,也是這些註解指出呼叫端必須提供的部分,是該指令執行當下的 CTM,也就是截至目前每個 cm 串接後的矩陣乘積,並且嵌套在內容串流此刻開啟的所有 q/Q 配對之中
為什麼要重播內容串流,而不是進行渲染
PDFlibPas 將圖形狀態分成兩個用途不同的概念,這個區分是刻意設計的。渲染器的內部狀態記錄包含即時裝置畫布控制代碼、裁剪區域控制代碼與字型點陣化快取,這些是真正繫結至目前繪製表面的資源,一旦該表面消失就失去意義。TPDFContentGraphicsState 完全不包含這些內容:它是單純的記錄,只保留 ISO 32000-1 §8.4 定義、可由內容串流運算子單獨取得的值,包括 CTM、線條樣式、色彩、文字狀態,以及衍生出的裁剪與路徑範圍。由於這筆記錄不持有畫布參考,也不持有開啟的檔案控制代碼,呼叫端可以剖析內容串流,使用 TPDFContentStateTracker 逐步走訪,並在產生這些位元組的來源消失很久後,繼續使用產生的快照
TPDFContentStateTracker 如何建立 CTM
TPDFContentStateTracker.Apply 會依照 PDF 本身指定的前乘方式,將 cm 運算子的六個運算元串接至追蹤器的 CTM:新矩陣 M2 會以 M2 × CTM 的方式與目前的 CTM 結合,採用點以 P′ = P × M 進行轉換的列向量慣例(ISO 32000-1 §8.4)。容易出錯的地方不在線性部分,而在平移項:M2 自己的平移必須先通過目前 CTM 的旋轉與縮放部分,之後才能加上目前 CTM 的平移。略過這個步驟,改用天真的逐分量組合方式硬編碼,第一次測試單獨的 cm 時看起來會正確,但第二或第三個嵌套 cm 之後的所有座標都會悄悄偏移;這正是很容易通過程式碼審查的錯誤,因為能捕捉它的單元測試至少需要兩個鏈式轉換才會失敗
var
Prog: TPDFContentProgram;
Runs: TPDFTextRunArray;
States: TPDFContentGraphicsStateArray;
DeviceX, DeviceY: Double;
I: Integer;
begin
Prog := TPDFContentProgram.Create;
try
Prog.Parse(ContentBytes);
Runs := Prog.GetTextRuns;
// One before-instruction snapshot per operator, computed in a single pass
States := Prog.TraceGraphicsStates(nil, False);
for I := 0 to High(Runs) do
begin
// OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
// at this instruction is still missing (ISO 32000-1 8.4)
with States[Runs[I].InstructionIndex].CTM do
begin
DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
end;
LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
end;
finally
Prog.Free;
end;
end;
上面的迴圈回答了開頭提出的難題:TPDFContentProgram.GetTextRuns 交回的 OriginX 與 OriginY 已經套用 Tm、Td、TD 與 T*,而 TraceGraphicsStates(nil, False) 則在整個程式上進行一次線性走訪,提供剩下的部分,也就是每個文字執行被擷取之確切索引位置上的指令前 CTM。傳入 nil 會讓方法為這次呼叫建立私有追蹤器,並在內部釋放它,這是一次性掃描的正確選擇;若改為傳入既有的 TPDFContentStateTracker,就能讓狀態在由多個內容串流組成的頁面之間持續,因為 ISO 32000-1 將頁面的 /Contents 陣列視為一個邏輯串流,而 q/Q 堆疊必須保持一致
文字矩陣會跨越 Q 保留;圖形狀態則不會
ISO 32000-1 §9.4.2 將 Td、TD、Tm 與 T* 定義為 BT/ET 區塊內建立文字矩陣與文字行矩陣的運算子,而 PDFlibPas 清楚維持這項區分:Td 與 TD 會在文字行矩陣上串接純平移,T* 使用目前行距的負值做同樣的處理,只有 Tm 會以提供的六個數字直接取代兩個矩陣。BT 會在文字物件開始時,恰好一次將兩個矩陣重設為單位矩陣,但 q 與 Q 完全不會碰觸它們。TPDFContentStateTracker.Apply 特別處理 coRestoreState 正是基於這個原因:從堆疊取出儲存狀態之前,它會先擷取目前的文字矩陣、文字行矩陣與 BT/ET 旗標,再將它們套用回取出的狀態,因為 q/Q 配對包住文字執行時,不應該讓文字位置倒退
var
Tracker: TPDFContentStateTracker;
Prog: TPDFContentProgram;
I: Integer;
begin
Prog := TPDFContentProgram.Create;
Tracker := TPDFContentStateTracker.Create;
try
Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
for I := 0 to Prog.Count - 1 do
begin
Tracker.Apply(Prog[I]);
if Prog[I].Op in [coShowText, coRestoreState] then
LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
end;
finally
Tracker.Free;
Prog.Free;
end;
end;
執行這個序列後,第二個 Tj 回報的 CTM 會回到 q 之前的單位縮放狀態,儲存與還原配對內的 2 0 0 2 0 0 cm 已經消失,這正是 q/Q 的要求。不過在同一個指令位置上,TextMatrix.DX 仍然是 100:設定它的 Td 在 q 之前執行,因此它從來不屬於 Q 可以處理的圖形狀態;若工具錯誤地假設相反,就會回報第二個字元執行在頁面上的錯誤水平位置
執行裁剪路徑運算子時會發生什麼事
W 或 W* 運算子不會立即縮小裁剪區域;它只會記錄要使用的填滿規則,實際交集會等到後續的路徑繪製運算子才發生,其中也包括不做任何操作的繪製器 n,PDF 作者經常正是利用它在不繪圖的情況下進行裁剪。TPDFContentStateTracker 精確反映這個兩步驟時序:coClip 與 coClipEvenOdd 只會設定待處理的裁剪規則旗標,而每個路徑繪製運算子都會呼叫 EndCurrentPath,實際將待處理路徑的範圍與 ClipMinX、ClipMinY、ClipMaxX 及 ClipMaxY 相交。正確處理這個階段對前後快照契約本身很重要:在 W 指令上精確取得的指令前快照仍然必須顯示舊的較寬裁剪範圍,因為裁剪在串流的那個位置尚未生效;若把兩個步驟合而為一,就會悄悄破壞所有依賴「指令前狀態」字面意義的呼叫端
ClipBoundsExact 會告訴呼叫端目前面對兩種情況中的哪一種,而且只有在空路徑上由 re 建立單一軸對齊矩形時才會是 True,這是 PDFlibPas 能以四個數字精確表示的唯一形狀。其他所有情況,包括旋轉矩形、曲線輪廓、包含多個子路徑的複合路徑,或由文字渲染模式建立的裁剪,仍然會產生 ClipMinX 到 ClipMaxY,但 ClipBoundsExact 會清除為 False,誠實表示這四個數字是安全的外接範圍,而不是真實裁剪形狀;只需要該範圍的呼叫端,例如在 將 PDF 頁面渲染為 1 位元單色中 GDI 半色調降階之前隔離矩形子區域的呼叫端,可以直接讀取它,而不必從頁面幾何重新推導
Bézier 曲線:精確範圍還是安全範圍
界定三次 Bézier 線段最省成本的方法,是取其四個控制點的凸包;由於曲線永遠不會離開凸包,因此這一定安全,但淺而寬的曲線可能回報遠大於實際曲線所佔範圍的邊界框,正好會在大型裝飾性路徑最需要時削弱以裁剪為基礎的篩選。PDFlibPas 解決的是更嚴格的問題:對每個軸,它會求出三次曲線導數在開區間 (0, 1) 內的根,並與兩個端點一起計算找到的根,這是取得曲線真正軸對齊範圍、而非過度估計值的標準閉式方法。不過每條曲線的精度不會延伸到裁剪本身:一旦曲線輪廓成為裁剪路徑,ClipBoundsExact 仍會對它清除為 False,因為無論邊界框多麼緊密,它仍然不是所界定曲線的相同形狀,而狀態追蹤器寧可明確說明這一點,也不讓呼叫端在實際是曲線時誤以為是矩形
讀取每個運算子之前與之後的狀態
呼叫端要取得指令前還是指令後的狀態,完全取決於運算子的作用:關於路徑或文字執行的繪圖或命中測試問題,需要的是運算子執行前一刻的狀態,因為那才是真正決定運算子如何繪製的狀態;而關於 gs 這類設定狀態運算子的診斷問題,通常希望看到它剛剛變更的內容。TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) 以單一布林值精確公開這個選擇,不論要求哪個時間點,都會在整個程式上以一次線性走訪,為每個指令計算一個 TPDFContentGraphicsState。GetGraphicsState(InstructionIndex, AfterInstruction, State) 為單一指令提供相同的前後選擇,而不是處理整個程式,但它每次呼叫都會從指令零開始重播;因此若在迴圈中呼叫它掃描許多索引,成本是 O(n²),相對於對同一個程式呼叫一次 TraceGraphicsStates 的 O(n)
var
Before, After: TPDFContentGraphicsState;
begin
// Same instruction index, two different instants: before vs. after it runs
Prog.GetGraphicsState(CmIndex, False, Before);
Prog.GetGraphicsState(CmIndex, True, After);
// Before.CTM reflects every earlier cm; After.CTM already folds in
// this instruction's own concatenation as well
end;
處理格式不正確的內容串流
真實 PDF 產生器產生的輸入中,有兩類格式不正確的內容很常見,因此 TPDFContentStateTracker 必須容忍它們,而不是因而失敗。第一類是跨越 q/Q 邊界的路徑:目前路徑、目前點與子路徑數量並不是圖形狀態參數,ISO 32000-1 §8.4 說明 q 與 Q 儲存及還原的內容,而正在建立的目前路徑不在其中;因此 TPDFContentStateTracker 完全在儲存狀態之外追蹤這些資料,q 之前開始的子路徑,在相符的 Q 之後仍然存在,只是尚未繪製。第二類是沒有任何先前 q 與之相符的單獨 Q,這在將內容串流片段串接起來卻弄錯記錄的產生器輸出中並不罕見。TPDFContentStateTracker.RestoreUnderflowCount 會計算每個這類事件,而不會擲出例外或破壞狀態:不相符的 Q 只會讓目前圖形狀態保持原樣,就像該指令是無操作一樣,因此串流其餘部分仍會在正常狀態上繼續重播,呼叫端也能在事後根據計數決定是否要向產生輸出的來源回報該輸入值得標記
CTM 組合、文字矩陣獨立於 q/Q,以及裁剪路徑的分階段實現,都不取決於內容串流是否曾經被繪製或如何被繪製,這正是重點:無論頁面完全不渲染,或即將交給 PDFlibPas 為該檔案選擇的任何後端,同一份 TPDFContentStateTracker 快照都是正確的,其中也包括PDFlibPas 多引擎 PDF 渲染指南所涵蓋的執行階段引擎切換。內容分析、座標對映與遮蔽工具都可以完全使用追蹤器的輸出,在要求渲染器介入之前很久,甚至完全不需要要求渲染器介入,就先執行完畢
透過 TPDFContentStateTracker 進行內容串流重播,是PDFlibPas內建結構化內容編輯架構的一部分;PDFlibPas 是供 Delphi 與 C++Builder 使用的原生 VCL PDF 元件函式庫