技術文章

Delphi 渲染器中的 PDF 文字前進量與 q/Q 剪裁還原

內容串流先設 /F1 1 Tf,再來 12 0 0 12 72 700 Tm,然後顯示 (Hello world) Tj,完全合法。手刻的渲染器卻常常把十一個字元全擠在大約五個半點之內 — 本該是 66 點寬的一個詞,左邊界上只剩一團墨漬。那團墨漬背後的算術只有一行。ISO 32000-1 §9.4.4 定義每個字形的推進量在文字空間裡,所以位移必須先過文字矩陣才到得了頁面;而 q ... Q 內設下的剪裁跟線寬一樣,都是圖形狀態的一部分

PDF 用 Tf 1 時,文字為什麼擠成一團?

文字塌縮的原因,是渲染器把文字空間距離直接加到 Tm 的平移分量上,這只有在文字空間和使用者空間同尺度時才成立。現實中不少產品把字級寫成 1、把真正的字級塞進文字矩陣。/F1 1 Tf 加 12 0 0 12 72 700 Tm 之下,500 單位寬的字形在文字空間推進 0.5,Tm 把它放大成頁面上的 6 點。執行 Tm.e := Tm.e + Adv 的渲染器卻只挪筆 0.5 點。每個字形都落在前一個字元的十二分之一處,而遵循 §9.4.4 的檢視器把同一份檔案顯示成正常的內文

HotPDF 渲染器中 Tf 1 底下文字塌縮的原因示意圖:在 12 0 0 12 72 700 Tm 下,500 單位字形必須推進 0.5 個文字空間單位、由 Tm 放大為 6 點;直接把 0.5 加到 Tm.e 則把一行內文畫成每個字形擠十二分之一字元的墨漬
把字級編進文字矩陣的產品立刻暴露 Tm.e 捷徑:每個字形落在前一個字元的十二分之一處;用 Tf 帶實際字級的輸出永遠不會露出這個毛病

這個捷徑能存活很久,因為大家測試用的多數產品都寫 /F1 12 Tf 配未縮放的 Tm,那種情況下文字空間和使用者空間真的同尺寸。下面是天真的版本全文,頂上的註解就是弄壞它的內容串流:

// 來自把字級編進 Tm 而非 Tf 的產品之內容串流:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// 天真的推進量:把文字空間距離當成使用者空間直接加到 Tm.e
Adv := W * FontSize / 1000;                  // 500 單位字形得 0.5
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th 只乘在寬度上
Adv := Adv + CharSpace;                      // Tc 不被 Th 縮放
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw 被錯誤地乘上 Tfs
Tm.e := Tm.e + Adv;                          // 無視 Tm.a、Tm.b、Tm.c、Tm.d

// 天真的 TJ 調整量:沒有 Th,而且又只動 Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Tm.e 那一行是最吵的 bug,其他幾個安靜些,而且各自在不同檔案上現形。字間距 Tw 本來就以未縮放的文字空間單位表示,乘上 FontSize / 1000 後,Tf 12 之下的 3 Tw 只剩 0.036 單位,對齊段落的詞距幾乎全失。水平縮放 Th 必須同樣作用於 Tc、Tw 和 TJ 的字距微調數。Code = 32 的測試還會在雙位元組碼裡任何值為 32 的位元組上誤觸發,讓 CJK 文字到處冒出隨機空隙。這類文字狀態失誤很少大聲報錯:就像可能在畫出第一個字形前就把 Tc、Tw、Tz 歸零的運算元索引與資源名稱陷阱一樣,同一份檔案在這家產品下頁面可信,到下一家就碎掉

ISO 32000-1 §9.4.4 怎麼定義字形前進量?

ISO 32000-1 §9.4.4 全程在文字空間裡計算推進量,並以平移矩陣的形式套用到文字矩陣上,縮放、旋轉、斜切全部來自 Tm,從不來自推進量本身。水平書寫時,tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th,其中 w0 是以 em 千分之一表示的字形寬度,Tj 是 TJ 調整量,Th 是 Tz 除以 100。新矩陣是 [1 0 0 1 tx 0] × Tm。展開這個乘積:給 e 加上 a·tx、給 f 加上 b·tx,這正是文字空間的平移不能直接加到 e 上的原因,除非 a 是 1 且 b 是 0。按 §9.3.3,Tw 只作用於單位元組字元碼 32

HotPDF 渲染器中 ISO 32000-1 9.4.4 的字形推進量示意圖:tx 在文字空間由 w0、Tj、Tfs、Tc、Tw 與 Th 算出,再經文字矩陣的 a、b、c、d 係數套用,Td、TD、TJ 與隱形文字全都按同一規則移動
把推進量直接加到 Tm.e 只在文字空間等於使用者空間時成立;讓它走矩陣係數,縮放、旋轉與斜切的文字才保得住正確

HotPDF Delphi Component 把這條公式收在一處。HPDFContentStream 單元匯出逐字形推進量的 HPDFTextGlyphDisplacement 和處理 TJ 數字的 HPDFTextAdjustmentDisplacement,頁面渲染器和文字擷取直譯器都呼叫它們,所以渲染正確的頁面,回報的字形位置也與所見一致。若你在函式庫的解析器之上寫自己的內容串流消費端,同樣可以呼叫這兩個常式、自己完成矩陣那一步:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement、HPDFTextAdjustmentDisplacement

type
  // 你自己的文字矩陣記錄,不是 HotPDF 型別
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// 你自己的輔助函式:[1 0 0 1 Tx Ty] x Tm,經 a、b、c、d 移動
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0 是以 1/1000 em 為單位的字形寬度;Tz 是 Tz 運算元(100 = 不縮放)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw 只在單位元組空格上生效
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1、Tm 12 0 0 12 72 700、W0 = 500:Tx = 0.5,Tm.e 增加 6
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th 同樣作用於字距微調
end;

渲染器內部,同一個矩陣步驟驅動每個移動文字位置的運算子。Td、TD、T*、' 和 " 都經行矩陣的係數做平移,所以 12 倍文字矩陣下的 0 -1.2 Td 會把基線往下挪 14.4 點而不是 1.2。從不著墨的字形走的也是同一條路。渲染模式 3(OCR 文字圖層用的隱形模式)和隱藏選擇性內容裡的文字,推進 Tm 的方式與可見文字一模一樣。替它們配一份私有算術是經典錯誤:同一個 BT 區塊裡,緊跟在隱形段落後面的可見文字就會從稍微不同的位置起步

放置字形本身

字形放置遵循同一套邏輯。沒有內嵌外框可用、渲染器退回 GDI TextOutW 時,它用 CTM × Tm × rise × em 縮放建出完整的字形矩陣,Th 也包含在內。矩陣經 SetWorldTransform 以 GM_ADVANCED 模式送入,包在 SaveDC / RestoreDC 對裡。GDI 字型以固定的 1000 單位高度建立,尺寸全交給世界變換。旋轉與斜切文字因此保住方向,而不是在變換過的原點上被擺正了畫。描邊渲染模式在描邊前先重置世界變換,否則按 1000 單位設計尺寸量出來的筆寬會縮到幾乎沒有。直排書寫是唯一刻意的不對稱:WMode 1 字型沿 y 軸按垂直度量推進,Tc 和 Tw 沿該軸作用,Th 則完全不適用

q/Q 在 PDF 圖形狀態裡實際保存什麼?

ISO 32000-1 §8.4.2 把目前剪裁路徑列為圖形狀態的一部分,所以 Q 必須把剪裁復原到配對 q 時的原樣。聽起來理所當然。在以 GDI 為後盾的渲染器裡卻容易漏掉,因為 CTM、顏色、線條參數和文字狀態都住在渲染器自己的狀態堆疊裡,剪裁卻住在裝置內容 — 那個堆疊之外。只複製數值狀態,什麼都復原了,唯獨剪裁沒有。q ... Q 區塊裡的 W n 接著就一直裁剪頁面上後續的每個操作,症狀看起來像內容缺失,而且離肇事的運算子很遠

表單 XObject 是通往同一失敗的第二條路。§8.10 給表單的內容隱式的儲存與還原,並以 /BBox 剪裁它;現實中的表單內容有時讓自己的 q 和 Q 不成對,儘管規範要求它們成對。HotPDF 在執行表單前記錄剪裁並儲存裝置內容,表單結束時丟棄表單遺留的未關閉剪裁框、還原裝置內容,每個已儲存區域恰好釋放一次。表單裡遊離的 Q 若會彈掉呼叫端的狀態,會被拒絕而不是被套用。RenderFallbackPolicy 設為 rfpReport 時,這次拒絕會出現在診斷訊息裡,很容易據此區分粗心的產品和渲染問題:

// 診斷:THPDFRenderFallbackDiagnostics(宣告於 HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // 對 nil 呼叫 Free 是安全的
end;

只在剪裁框要改動剪裁時才捕捉

直接的修法是每次 q 都讀取 GDI 剪裁區域、每次 Q 都放回去。正確,但恰恰在最愛用 q 的頁面上代價高昂,因為產品喜歡把每個小繪圖包進 q 1 0 0 1 x y cm ... Q,純粹為了移動原點。這樣每一對都要建立 GDI 區域、把剪裁複製進去、再刪掉。多頁平行渲染時,執行緒最後在搶 GDI 區域物件而不是在光柵化,平行渲染管線的加速也跟著縮水

HotPDF 渲染器的 GDI 剪裁惰性捕捉示意圖:q 只記錄堆疊深度與目標表面,剪裁區域在 W、n、文字剪裁或進入表單即將改動剪裁前才讀取,Q 負責還原並釋放,整頁的 q cm Q 對完全不配置 GDI 區域
每個 q 都建立 GDI 區域會讓渲染執行緒搶區域物件,所以剪裁只在運算子即將改動剪裁時才捕捉

HotPDF 把昂貴的部分往後推。q 只記錄堆疊深度和所屬表面,框架陣列從 16 起倍增成長、整頁重用,一連串數值變換完全不配置 GDI 物件。剪裁在該框架裡第一次有東西要改動它時才捕捉:帶未決 W 或 W* 的路徑繪製或 n、圖樣填色、文字渲染模式 4 到 7 留下的剪裁,或進入表單。到 Q 時,捕捉過的框架把區域放回並釋放,從沒動過剪裁的框架只彈出數值狀態。常見的頁面於是完全不配置區域,會剪裁的頁面則為每次實際剪裁變更恰好付一個區域。若你要為 PDF 直譯器打造自己的 GDI 裝置,同樣的形狀幾十行就放得下:

// 給你自己的 GDI 後盾裝置的草稿;以下皆非 HotPDF API
// uses 子句需要 Windows、SysUtils 和 Math
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q:完全不碰 GDI
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // 在 W n、W f、文字剪裁、表單 BBox 之前呼叫
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 已複製、0 無剪裁、-1 錯誤
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // 不成對的 Q:忽略
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 移除剪裁
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

那段草稿裡有兩個細節撐起大半。GetClipRgn 回傳 0 不是錯誤:它表示裝置內容當下沒有剪裁。Q 時把零區域傳給 SelectClipRgn,正是移除「配對的 q 時不存在」的剪裁的手法。把 0 當失敗處理,內層剪裁就會留下來。另一個細節是 Captured 旗標,它防止同一框架裡第二次剪裁變更用已經收窄的區域覆蓋掉存好的那份

已存剪裁與推進規則的邊界

存下的剪裁是裝置像素裡的 GDI 區域。它對正在渲染的點陣圖精確無比,對任何其他表面則毫無意義,所以每個框架也記錄它取自哪個裝置內容,內容一旦改變就跳過還原——例如透明度群組渲染進自己的圖層點陣圖時

文字這邊,推進規則按交給它的寬度擺放每個字形。簡單字型省略 /Widths 陣列、又沒有內嵌字型程式可用時,推進量最多只能跟寬度後備一樣好,而 §9.4.4 對那個寬度從何而來隻字未提。這方面的迴歸測試住在簡單產生器幾乎不會產出的檔案上:一份 Tf 1 配縮放 Tm 的、一份在對齊文字上帶非零 Tz 和 Tw 的,還有一份在 q ... Q 裡放 W n 剪裁、再在其外畫內容的。第三份若渲染正確,外面的內容會完整出現

從應用程式端,這一切不需要任何設定:呼叫就是 把 PDF 頁面渲染成點陣圖展示的那一個,兩條規則對你以該方式渲染的每一頁都成立。支援的 Delphi 與 C++Builder 版本與授權細節,見 HotPDF Delphi PDF Component 頁面