技術文章

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

HotPDF Delphi Component 的頁面渲染器現在這樣推進文字:按 ISO 32000-1 §9.4.4 的定義,在文字空間裡算出每個字形的位移量,tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th,再用 HPDFTranslateTextMatrix 讓文字矩陣沿線性部分移動。剪裁按 q 框保存、在 Q 還原,但只有在該框真的改動剪裁時才捕獲 GDI 區域。兩個修復都落在 HotPDF 2.754.0,都來自真實世界的頁面:有的渲染出擠成一團的字,有的剪裁區域越過自己的 Q 繼續外洩。第一個 bug 是那種直到製造器把字號寫進矩陣之前都看起來沒錯的算術;第二個是正確性修復,差點賠上平行渲染的提速,而我們把速度拿回來的方式,值得每個寫 GDI 系 PDF 裝置的人知道

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

因為舊的前進量程式碼把文字空間距離直接加到 Tm 的平移分量上,好像文字空間與使用者空間永遠同尺度。現實中不少製造器用 Tf 把字號設成 1,把真正的字號放在文字矩陣裡。帶 /F1 1 Tf 與 12 0 0 12 72 700 Tm 時,一個 500 單位寬的字形在文字空間前進 0.5,經 Tm 縮放後在頁面上是 6 點。舊渲染器執行 Tm.e := Tm.e + Adv,筆只移了 0.5 點。每個字形落在前一個字形的十二分之一字符之後,於是一行正文渲染成左邊距上的一抹深色,同一份檔案在其他所有檢視器裡卻完美無缺

HotPDF 渲染器中 Tf 1 之下文字擠成一團的原因:帶 12 0 0 12 72 700 Tm 時,500 單位的字形必須在文字空間前進 0.5 個單位,經 Tm 縮放成 6 點,而舊程式碼把 0.5 直接加到 Tm.e,一行正文渲染成每個字形十二分之一字符的拖影
把字號編進文字矩陣的製造器,讓每個字形落在前一個之後的十二分之一字符處,這個缺陷在函式庫自己的輸出上根本看不見
// 來自把字號編在 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 捷徑不是那個區塊裡唯一的缺陷。字間距 Tw 以未縮放的文字空間單位表示,舊程式碼卻乘上 FontSize / 1000,所以 Tf 12 之下,對齊過的一行幾乎丟光字間空隙。水平縮放 Th 作用在字形寬度、卻不作用在 Tc 或 Tw,TJ 的緊排調整更是完全跳過它。推進渲染模式 3 隱形文字(OCR 文字層用的那種)的非繪製路徑,以及隱藏 optional content 裡的文字,各自帶著同一套算術的私有副本,所以隱形串之後畫的任何東西都從錯誤位置出發。渲染器裡的文字狀態 bug 很少大聲失敗:像曾經一聲不響把 Tc、Tw 與 Tz 歸零的運算元索引與資源名稱 bug 一樣,它們在函式庫自己的輸出上產出看似合理的頁面,只在別家製造器的檔案上壞掉

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

ISO 32000-1 §9.4.4 完全在文字空間裡定義前進量,並把它當成平移矩陣套到文字矩陣上,所以答案是先算 tx,讓 Tm 去做縮放、旋轉與傾斜。水平書寫時,tx 等於 ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th,其中 w0 是以千分之一 em 計的字形寬度,Tj 是 TJ 調整量,Th 是 Tz 除以 100。新的 Tm 是 [1 0 0 1 tx 0] × Tm,在 HotPDF 裡就是輔助函式 HPDFTranslateTextMatrix:它透過矩陣係數 a、b、c、d 加上 X 與 Y,而不是直接寫 e 與 f。按 §9.3.3,Tw 只作用在單位元組字元碼 32,所以多位元組 CID 碼在水平路徑上永遠不吃字間距。同一個輔助函式現在驅動 Td、TD、T*、' 與 " 運算元、TJ 調整與隱藏文字路徑,一條規則只有一個主人

HotPDF 渲染器中的 ISO 32000-1 9.4.4 字形前進量:tx 由 w0、Tj、Tfs、Tc、Tw 與 Th 在文字空間算出,經 HPDFTranslateTextMatrix 套用,位移過 a、b、c、d 矩陣係數,Td、TD、TJ 與隱藏文字路徑共用同一規則
把前進量直接加到 Tm.e,只在文字空間等於使用者空間時成立;走矩陣係數,縮放、旋轉與傾斜的文字才站得住
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
  Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
  Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;

// 水平字形前進量,ISO 32000-1 9.4.4
W   := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
  Adv := Adv + State.Text.WordSpace;         // Tw 在文字空間,未縮放
Adv := Adv * State.Text.HorizScale / 100;    // Th 作用在整個和
HPDFTranslateTextMatrix(Tm, Adv, 0);

// TJ 數值元素:同一空間,同一 Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);

字形擺放也得跟上同一套邏輯。沒有內嵌輪廓可用、渲染器退回 GDI TextOutW 時,它現在從 CTM × Tm × rise × em 縮放建出完整字形矩陣,包括 Th,並在 SaveDC / RestoreDC 對裡以 GM_ADVANCED 模式用 SetWorldTransform 裝上。GDI 字型以固定 1000 單位高度建立,尺寸交給變換,所以旋轉與傾斜的文字保住方向,不再以變換過的原點直挺挺地畫出來。直書模式是刻意留下的唯一不對稱:WMode 1 的字型沿 y 軸按垂直度量前進,水平縮放不作用於那個軸

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

ISO 32000-1 §8.4.2 把目前剪裁路徑列為圖形狀態的一部分,所以 Q 必須把剪裁原樣還原到配對 q 時的狀態,不能只還數值參數。HotPDF 本來就維護一個帶 CTM、顏色、線條參數與文字狀態的圖形狀態堆疊,但 GDI 把剪裁放在裝置上下文裡、在堆疊之外。只複製數值狀態,還原了一切、唯獨沒還剪裁,q ... Q 區塊裡用 W n 裝上的剪裁,繼續裁掉頁面上之後的每個操作。Form XObjects 為同一失敗添了第二條路,因為 §8.10 給 form 的內容外圍一個隱式的保存與還原,而真實世界的 form 內容有時把自己的 q 留得不平衡,即使規格要求它們成對。渲染器現在在跑 form 之前呼叫 CaptureClipBeforeChange 與 SaveDC,form 跑完後丟棄任何比進入深度更深的已保存區域、呼叫 RestoreDC,每個保存的 HRGN 於是恰好有一條釋放路徑

用 THPDFSavedClipState 做惰性剪裁捕獲

出貨的修法為每個 q 保存一筆 THPDFSavedClipState 記錄,但把昂貴的部分延後到該框第一次改動剪裁。記錄裝著區域 handle、所屬的堆疊深度、取自哪個裝置上下文,以及一個 Captured 旗標。DevPushState 只填深度與 DC,框陣列從 16 起倍增擴容,所以塞滿 q 1 0 0 1 x y cm ... Q 的內容串流完全不配置任何 GDI 物件。即將改動剪裁的運算元,帶待決 W 或 W* 的路徑繪製、n 運算元、圖樣填充與 form 進入,先呼叫 CaptureClipBeforeChange

HotPDF 渲染器的惰性 GDI 剪裁捕獲:DevPushState 在每個 q 只記深度與 DC,CaptureClipBeforeChange 在 W、n 或 form 進入改動剪裁前一刻才讀區域,DevPopState 在 Q 還原並刪除,而每個 q 都捕獲的急切版本把平行吞吐拖到約單執行緒的 1.15 倍
每個 q 都建一個 GDI 區域,把渲染執行緒餓壞了;現在只在運算元即將改動剪裁時捕獲,1.5 倍提速閘門重新通過
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
  Index, ClipResult: Integer;
  Region: HRGN;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index < 0) or FSavedClips[Index].Captured or
     (FSavedClips[Index].StackDepth <> FGSStack.Count) or
     (FSavedClips[Index].DC <> FDC) then Exit;   // 已保存,或不屬於我們
  Region := CreateRectRgn(0, 0, 0, 0);
  if Region = 0 then RaiseLastOSError;
  ClipResult := GetClipRgn(FDC, Region);         // 0 代表根本沒有剪裁
  if ClipResult <= 0 then
  begin
    DeleteObject(Region);
    Region := 0;
    if ClipResult < 0 then RaiseLastOSError;
  end;
  FSavedClips[Index].Region := Region;
  FSavedClips[Index].Captured := True;
end;

procedure THPDFPageRenderer.DevPopState;
var
  Index: Integer;
begin
  ClearSavedClipRegions(FGSStack.Count);
  Index := FSavedClipCount - 1;
  if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
  begin
    if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
      SelectClipRgn(FDC, FSavedClips[Index].Region);  // Region 0 移除剪裁
    if FSavedClips[Index].Region <> 0 then
      DeleteObject(FSavedClips[Index].Region);
    Dec(FSavedClipCount);
  end;
  FGSStack.Pop;
end;

急切版本量到的代價,就是這個設計存在的理由。第一個正確的實作在每個 q 上建立並讀取一個 GDI 區域,在以數值變換為主的頁面上,渲染執行緒把時間花在爭搶 GDI 區域物件、而不是光柵化。平行渲染管線從預期的收益跌到約單執行緒吞吐的 1.13 到 1.20 倍,基準套件裡的 1.5 倍提速閘門不及格。換上惰性捕獲與重用的框容量,同一個基準重新通過原本的 1.5 倍閘門。小型 TrueType 字形反鋸齒落在同一版,本是頭號嫌疑,但迴歸追到的是區域配置——量了再怪新功能,是個好提醒

這個方法的極限在哪裡?

保存的剪裁是裝置像素裡的 GDI 區域,對正在渲染的點陣圖精確,對任何其他目標無意義。所以每個框記下自己的裝置上下文,DevPopState 在 DC 變了就跳過還原,例如透明群組渲染進自己的圖層點陣圖時。GetClipRgn 回傳零是合法結果,意思是沒有剪裁,用 SelectClipRgn(FDC, 0) 還原它,正是正確移除配對 q 時不存在的那個剪裁的方法。文字側,修復校正的是每個字形去哪,不是無中生有寬度:字型省略 /Widths 陣列、內嵌程式又不可用時,前進量仍然只有寬度後備那麼好。做這個區域的迴歸測試,至少留一個帶 Tf 1 與縮放 Tm 的治具、一個帶非零 Tz 與 Tw 的、一個在 q ... Q 裡放剪裁、後面跟框外內容的,因為這些在函式庫自己生成的文件裡一個都不會出現

從應用程式碼驅動渲染器的話,把 PDF 頁面渲染成點陣圖描述的呼叫模式一概不變,之前顯示拖影或被裁掉內容的頁面,在 2.754.0 之後就應該正常渲染。元件細節、支援的 Delphi 與 C++Builder 版本與授權,都在 HotPDF Delphi PDF Component 產品頁上