技術文章

Delphi HotPDF 中的 PDF 平鋪圖樣渲染

渲染成一整塊灰色實心的剖面線,是平鋪圖樣最經典的失敗案例。HotPDF,這套給 Delphi 與 C++Builder 用的原生 VCL PDF 元件,繪製 PatternType 1 的方式,是把目前路徑轉成一個臨時裁切區域,然後每個可見圖磚重播一次圖樣內容串流,而圖樣的選取則保存在圖形狀態裡,由 qQ 負責還原

症狀會以兩種樣貌出現,在你知道成因之前它們看起來毫無關聯。一份 CAD 繪圖失去了剖面線,變回實心填色,因為渲染器把圖樣解析成一個平均色再塗上去。或者剖面線逃逸了:一個本該是純白的標題欄,沾上了幾個路徑之前一張細部圖的斜線。兩者都是圖樣狀態問題,只是其中一個才真正涉及圖磚繪製本身

為何一個平鋪圖樣會滲到下一個路徑上?

因為被選取的圖樣名稱屬於圖形狀態的一部分,而不是使用它的運算子的屬性。ISO 32000-1 §8.6.6.2 把 Pattern 色彩空間定義成一種色彩值是圖樣名稱、供給 scnSCN 使用的空間,而色彩狀態的其他每個組成部分都由 q 儲存、Q 還原。圖樣名稱必須遵守同一條規則。HotPDF 把它存在狀態記錄裡,即 FillPatternNameStrokePatternName,與填色和描邊的色彩空間家族放在一起,所以一個 Q 會把先前的選取精確地放回去,就像它對先前的 CTM 所做的一樣

如果反而把這個名稱存在運算子調度器裡的一個區域變數,它就會在串流中的每個 Q 後存活下來。失敗接著會出現在意想不到的地方:一個在已上圖樣路徑之後繪製的 Form XObject,會繼承一個自己的內容串流從未做過的圖樣選取,而它的填色就會冒出剖面線。巢狀表單讓情況更糟,因為每一層巢狀都會推入並彈出狀態,而那個雜散的變數卻置身事外。用 csCS 設定非圖樣色彩空間,或發出一個單純的 grgk,也必須清除圖樣名稱,否則過期的選取會在賦予它意義的色彩空間之後繼續存活

q
  /Pattern cs              % pattern colour space, ISO 32000-1 8.6.6.2
  /P1 scn                  % coloured tiling pattern, PaintType 1
  10 10 200 120 re f       % this rectangle is hatched
Q
0 0 300 200 re f           % must be black again, not hatched

q
  /Cs2 cs                  % [/Pattern /DeviceCMYK] array
  0 0.6 1 0 /P2 scn        % uncoloured pattern plus its underlying colour
  20 20 160 90 re f*
Q

圖樣是透過裁切繪製,絕不是當成填色繪製

正確的模型是減法式的:先把裝置裁切區域限縮成被繪製形狀的範圍,再在裁切區域內執行圖樣內容。HotPDF 絕不會先畫一個實心近似值再覆蓋上去,因為那個中間的實心色塊會從圖磚之間的縫隙透出來,也會與圖磚內容中的任何透明度打架。§8.7.3.2 把平鋪圖樣描述成一個以固定水平與垂直間距複製的內容串流,而複製只有在對著一個已經有正確形狀的裁切區域時才有意義。對填色而言,這個轉換是直接的:HPDFSelectFillPathClipf*B*b* 把多邊形填色模式設成 ALTERNATE,為非零版本設成 WINDING,建構 GDI 路徑,再用 SelectClipPath 把它與裁切區域相交。就是這一行,讓一個奇偶規則的圖樣填色,留下與奇偶規則實心填色一樣的洞,這正是一個甜甜圈形狀的剖面線區域所需要的

描邊是容易做錯的部分。一條描邊路徑沒有內部,所以把路徑本身相交進裁切區域只會得到一個空區域,什麼都畫不出來。因此 HPDFSelectStrokePathClip 會先用目前狀態建構一支幾何筆刷,使用 PS_GEOMETRIC,端點取自 J,連接方式取自 j,斜接限制取自 M,虛線陣列生效時則用 PS_USERSTYLE,接著呼叫 WidenPath 把描邊輪廓轉成一個可填色的區域,然後才做裁切。端點、連接、斜接與虛線在圖樣描邊路徑上的行為,因此透過建構就與一般描邊一致,而不是靠第二套實作。這裡有兩個誠實的限制:小於一個裝置單位的線寬會被鉗制成一個像素,虛線陣列會被截斷在十六個項目,這是 ExtCreatePen 接受的上限

哪些圖磚實際可見?

可見範圍來自把變換反著跑一遍。圖磚的擺放發生在圖樣空間裡,但唯一知道頁面有多少範圍被觸及的,是裝置空間裡的裝置裁切框。HotPDF 組出 BaseMatrix := CTM * PatternMatrix,將其反轉,再把 GDI 裁切框的四個角透過反矩陣映射回去。這四個映射後角點的軸對齊邊界,給出圖樣空間中可能被涵蓋的矩形,再拿這個矩形除以 XStepYStep,對照圖樣的 BBox,得出封閉的索引範圍。接著每個格子以 CTM * PatternMatrix * Translate(i * XStep, j * YStep) 為 CTM 進行渲染,並針對自己變換後的 BBox 多邊形再裁切一次。當 XStep 小於邊界框寬度時,第二次裁切就很重要,這正是重疊圖磚設計的表達方式;沒有它,相鄰格子就會在各自宣告的範圍之外互相覆蓋。如果每格的裁切結果是 NULLREGION,這一格就會被跳過,不做任何 token 化或執行

// Map the device clip box back into pattern space through the inverse of
// CTM * PatternMatrix, then convert those bounds into tile index ranges.
BaseMatrix := HPDFMatMul(FGSStack.State.CTM, PatternMatrix);
if not HPDFMatInvert(BaseMatrix, InverseMatrix) then Exit;   // singular: refuse
if GetClipBox(FDC, ClipRect) = ERROR then Exit;

// MinX..MaxY are the axis-aligned bounds of the four mapped clip corners.
I0 := Floor((MinX - BBox[2]) / StepXAbs);
I1 := Ceil ((MaxX - BBox[0]) / StepXAbs);
J0 := Floor((MinY - BBox[3]) / StepYAbs);
J1 := Ceil ((MaxY - BBox[1]) / StepYAbs);

PlannedTiles := Int64(I1 - I0 + 1) * Int64(J1 - J0 + 1);
if (PlannedTiles <= 0) or (PlannedTiles > FPatternTilesRemaining) then Exit;
Dec(FPatternTilesRemaining, Integer(PlannedTiles));

無色圖樣,以及從外部來的顏色

PaintType 2 圖樣帶有形狀但沒有顏色,顏色隨圖樣名稱一起到來。§8.7.3.2 指定無色圖樣只能與宣告了底層空間的 Pattern 色彩空間搭配使用,所以 scn 會先接收分量值,最後才接收圖樣名稱。HotPDF 透過儲存在圖樣色彩空間項目上的底層空間來解析這些分量,這代表一個無色的剖面線可以用一個 Separation 油墨或一個 DeviceN 組合來染色,就跟其他任何填色一樣;這種解析的機制涵蓋於渲染 Separation 與 DeviceN 特別色一文。在圖磚內部,兩種繪製型別分道揚鑣。對 PaintType 2 而言,渲染器會在該圖磚期間設定一個色彩運算子抑制旗標,所以圖樣內容裡的任何 grgkscn 都會被忽略,每個標記都採用外部提供的顏色。對 PaintType 1 則正好相反:填色與描邊狀態會被重設回 PDF 預設值,即帶有一個識別色彩空間的 DeviceGray 黑色,圖磚自己上色。跳過這道重設,會讓 f 運算子當下剛好用的顏色,滲進一個本該自我描述的圖樣裡

為何圖形狀態堆疊深度必須在每個圖磚之後恢復?

因為圖樣內容串流可以是不平衡的,而傷害會跨格子累積。一個圖磚串流若含有三個 q 運算子與兩個 Q 運算子,會讓堆疊比開始時深一層。如果格子之間只恢復目前的狀態記錄,深度就會持續增長,於是第兩百格會從屬於第一百九十九格的堆疊框執行,帶著那一框所攜帶的任何 CTM 與裁切區域。因此 HotPDF 在圖磚迴圈之前會為狀態記錄與堆疊深度拍一張快照,並在每次迭代開頭呼叫 RestoreSnapshot,一步就把堆疊截回儲存的長度,並重新裝回儲存的狀態。頁面 Resources 字典與色彩運算子抑制旗標在同一個邊界上恢復,因為一個圖磚可能參照自己的資源,絕不能把它們交給下一個鄰居。GDI 裁切狀態也用一對 SaveDC / RestoreDC 包住每一格,讓一個安裝了自己的 W n 裁切的圖磚,不會縮小下一格可用的區域

預算、拒絕,以及渲染器不會畫的東西

平鋪圖樣是 PDF 裡最容易寫出阻斷服務檔案的地方,所以這裡的限制是硬性數字,而非經驗法則。圖樣巢狀深度上限為 4,與 Form XObject 遞迴使用的防護一樣,用來阻止一個透過自己資源字典參照自己的圖樣。單一路徑上色最多可執行 16,384 個圖磚,在巢狀圖樣間往下計數,只在最外層圖樣上色開始時重置。一個計畫格數超出剩餘預算的圖磚網格會被直接拒絕,一格都不會先跑

退化的幾何會被拒絕而非近似處理。缺失或零面積的 BBox、量級低於 1e-6 的 XStepYStep、一個沒有反矩陣的 CTM * PatternMatrix 乘積、超過 1e9 的映射裁切座標,或超過一百萬的索引量級,全都會讓圖樣上色不繪製任何東西就返回。結果是一塊未上色的區域,而不是一條卡死的渲染執行緒,這正是批次轉換器想要的取捨。效能來自一個決定:圖樣串流每次上色只用 HPDFTokenizeContentStream token 化一次,token 陣列會在每個可見格子間重複使用,所以圖磚數量會讓執行成本倍增,卻永遠不會讓詞法分析成本倍增

從 Delphi 渲染一頁帶圖樣的頁面

圖樣支援不會改變任何呼叫端程式碼。載入文件、要求一頁,平鋪的工作就發生在頁面轉點陣圖渲染已經在驅動的內容串流解譯器裡。同一個解譯器餵給點陣圖、中繼檔與印表機裝置內容,所以一份在預覽縮圖裡看起來正確的剖面線繪圖,列印出來會有一樣的圖磚幾何。PatternType 2 陰影圖樣走的是另一條分支,與純粹的 sh 運算子共用同一條求值路徑,詳細說明於軸向與放射狀陰影渲染

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('assembly-drawing.pdf') > 0 then
    begin
      // Section hatching that previously flattened to a solid block now
      // replays the tile content once per visible cell.
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 200);
      if Assigned(Bmp) then
      try
        Bmp.SaveToFile('sheet1.bmp');
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

當一塊上了圖樣的區域看起來還是不對,依序檢查這三類失敗。一塊完全空白的區域通常代表被拒絕:檢查 XStepYStepBBox 有無退化值,或算算這個網格需要多少圖磚、對照 16,384 的上限。一塊塗成單一實心顏色的區域,代表圖樣名稱從未抵達上色運算子,這指向串流裡 csscn 的順序。一個出現在不該出現位置的圖樣,代表的是狀態恢復問題,該看的地方是那個表單或路徑周圍的 q / Q 處理,是它繼承了這個圖樣

平鋪圖樣是那種在需要它的檔案落到你的收件匣之前一直隱形、之後就是你全部工作的 PDF 功能之一。如果你正在 Delphi 或 C++Builder 上建構繪圖檢視器、工程文件轉換器或報表渲染器,完整的元件與其渲染 API 都記載在 HotPDF Delphi PDF 元件頁面