技術文章

How PDF Graphics Work: Content Streams and Operators

PDF 頁面不儲存像素,也不像 SVG 那樣儲存形狀物件樹;它儲存的是一個程式;頁面上的每條線、曲線、填滿和放置的影像,都是在內容串流中針對執行中繪圖狀態由上而下執行一系列運算子的結果;理解這一點,該格式的大多數行為就不再顯得奇怪:為什麼在建立路徑後填滿需要單獨的繪製運算子、為什麼色彩和線條寬度會從一個形狀滲透到下一個形狀(除非您使用括號將其括起來)、為什麼相同的繪圖程式碼在單次座標轉換後會出現在完全不同的位置;這是對 ISO 32000 中定義的執行模型的導覽:您在開啟內容串流時遇到的運算子,以及決定頁面上顯示內容的規則

內容串流是後置位元組碼

內容串流是運算元後跟運算子的扁平位元組序列;運算元在前,取用它們的運算子在後,這與函式呼叫相反,而與堆疊機器相同:先推入數字,然後發出動詞;這裡沒有巢狀結構、沒有運算式語法,也沒有變數;三角形輪廓是以下五行程式碼:

100 100 m    % moveto:在 (100, 100) 開始新的子路徑
200 200 l    % lineto: add a segment to (200, 200)
300 100 l    % lineto: add a segment to (300, 100)
h            % closepath:連回起點
S            % stroke:繪製路徑外框

運算子局限得很簡短;一個實際的頁面包含數千個這樣的運算子,通常使用 FlateDecode 進行壓縮;這種緊湊性的代價是串流不攜帶任何您可以查詢的結構:檢視器無法詢問「此頁面上的標題在哪裡」,它只能執行程式並觀察墨水落在何處;這正是從任意 PDF 中擷取文字非常困難的根本原因

原點在左下角,且 Y 軸向上遞增

在任何座標有意義之前,您必須知道 (0, 0) 在哪裡;PDF 將原點置於頁面的左下角,X 向右遞增,Y 向上遞增,以點為單位測量,每英吋為 72 點(ISO 32000-2 §8.3.2);在美規 Letter 頁面上,頂部邊緣位於 y = 792,而不是 y = 0;任何習慣螢幕繪圖(原點在左上角且 Y 向下遞增)的人,第一次嘗試時都會弄反,將第一條線畫到頁面底部之外;該單位也與介質無關:無論頁面是算繪到手機螢幕還是影像輸出機,72 個單位都是一英吋

PDF 座標系統圖解:原點在左下、Y 向上遞增,對比左上原點的螢幕慣例——同樣座標會畫到頁面底部附近
PDF 從左下量起、Y 向上增長,螢幕直覺因此會讓你的第一筆繪圖落在頁面底部

大多數頁面繪圖函式庫都直接繼承了這一慣例;例如在 HotPDF 中,TextOut 和路徑呼叫都是以點為單位從左下角開始測量,因此接近頁面高度的值會將內容置於頂部:

// HotPDF、Delphi:y 從底邊向上量測,單位為 points
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700);   // 靠近頁面頂端
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke;             // 輸出 moveto/lineto/stroke operators

該呼叫序列會精確地編譯為上述的 m、l 和 S 運算子;函式庫只是內容串流的打字員,僅此而已,當形狀落在您未預料到的地方時,了解它所輸出的內容能讓您推導出輸出的原因

建立路徑,然後繪製它

PDF 將路徑建置與路徑繪製分開,這種分離並非多此一舉;您首先使用不添加任何可見內容的建置運算子來描述形狀,然後發出單個繪製運算子來決定如何處理累積的路徑;同一個三角形可以是輪廓、實心填滿或兩者兼具,這完全取決於您結尾的動詞

建置運算子很少;m 在某個點開始新的子路徑;l 新增一條直線線段;c 從六個運算元、兩個控制點和一個端點新增一條三次貝茲曲線;re 是一個快捷方式,可從 x、y、寬度、高度四元組新增整個矩形;h 將目前的子路徑關閉回其起點;它們都不會在頁面上著色,而只是累積幾何形狀

200 250 m                    % 開始子路徑
300 350 400 450 500 250 c    % cubic Bezier:兩個控制點,再加端點
150 200 re                   % 150 x 200 矩形,作為自己的子路徑加入
h                            % close

原始範例使用了現已廢棄的曲線運算子 y 變體;具有三個明確點的 c 是您在實踐中會看到的格式,也是首選格式;路徑存在後,一個繪製運算子就會完成它;其詞彙量很小且值得記住,因為每個頁面上的每個形狀都以其中之一結尾:

PDF 路徑模型圖解:建構運算子 m、l、c、re、h 累積不可見幾何,收尾運算子 S、f、f*、B 或 n 決定描邊、填色、描邊加填色或裁剪
路徑運算子只收集幾何;收攏路徑的那個動詞一錘定音,決定你得到外框、填色、兩者,還是一個裁剪
  • S 使用目前的線條寬度和筆劃色彩繪製路徑輪廓
  • f 使用目前的填滿色彩和非零繞數規則填滿內部
  • f* 使用奇偶規則進行填滿,這對於自我相交的形狀和有孔的形狀很重要
  • B 在一次操作中進行填滿然後繪製筆劃;b 則先關閉路徑
  • n 不繪製任何內容,這就是路徑如何成為裁剪區域而不留下可見標記的方法

繞數規則是人們容易弄錯的地方;非零規則(f、B)計算從測試點出發的射線的有符號交叉數,並在計數不為零的任何地方進行填滿,因此只有當孔的子路徑繞行方向與外側路徑相反時,孔才會保持空白;奇偶規則(f*、B*)則在每次交叉時切換,無論方向如何;如果一個「甜甜圈」形狀出來是實心的,說明內圈與外圈的繞行方向相同,您需要反轉它或切換到奇偶規則

色彩是一種模式,而非參數

內容串流中的色彩是具有粘性的;您設定了一種色彩,它就會保持設定狀態,直到您設定另一種色彩或還原先前的狀態為止,這就是為什麼未加括號的色彩變更會悄悄為其後繪製的所有內容著色;PDF 還將填滿色彩和筆劃色彩保留為兩個獨立的設定,填滿使用小寫運算子,筆劃使用大寫運算子;裝置色彩空間各有其速記法:

0.5 g                % DeviceGray fill, mid gray (0 = black, 1 = white)
0.2 0.6 0.8 rg       % DeviceRGB fill
0.8 0.2 0.1 RG       % DeviceRGB stroke (uppercase = stroke)
0.2 0.8 0.0 0.1 k    % DeviceCMYK fill

DeviceRGB 適合螢幕輸出,DeviceCMYK 是印刷生產所期望的,而 DeviceGray 是單色內容的最小選擇;裝置空間雖然方便,但未經校準:同一個 RGB 三元組在兩個螢幕上可能算繪出不同的效果,這正是基於 ICC 的色彩空間和 PDF/A 輸出目的意圖要解決的問題;對於色彩要求嚴格的工作,您可以使用 cs 和 CS 選擇校準空間,並使用 sc 和 scn 設定分量,但對於普通文件,裝置速記法即可勝任;函式庫會將這些包裝在具型別的呼叫中;例如,HotPDF 接受單一 TColor 並輸出相匹配的運算子:

Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150);  // x, y, width, height
Pdf.CurrentPage.Fill;

Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50);           // x, y, radius
Pdf.CurrentPage.Fill;

繪圖狀態與 q/Q 堆疊

路徑本身之外的所有內容都存在於繪圖狀態中:目前轉換矩陣、填滿和筆劃色彩、線條寬度、虛線樣式、裁剪區域、Alpha;狀態是全域且可變的,因此進行局部變更的唯一安全方法是儲存整個狀態、對其進行修改、繪製,然後將其還原;這就是 q and Q 所做的事情;q 將目前狀態的複本推入堆疊中;Q 則將其彈出,捨棄自匹配的 q 以來所做的每一次變更

q                    % 儲存整個 graphics state
2 0 0 2 100 100 cm   % concatenate a transform: scale 2x, translate to (100,100)
0.8 g                % 灰階填色,作用域限於此區塊
% ... draw scaled, gray content ...
Q                    % restore:還原轉換與色彩

未配對的 q 和 Q 是手動建置或拼接內容串流出錯的常見原因;沒有相符 Q 的孤立 q 會在頁面結束時讓堆疊過深;而多餘的 Q 則會導致堆疊下溢;不論是哪種情況,檢視器都可能保持舊的裁剪或轉換有效,導致內容消失或出現在錯誤的位置;當圖形無故消失而路徑又無法解釋時,請先稽核狀態堆疊

CTM 轉換每個座標

目前轉換矩陣(CTM)介於運算子中的數字與實際頁面之間;在繪製任何內容之前,每個座標都會與 CTM 相乘,因此在不觸動任何路徑座標的情況下,變更矩陣就會改變所有後續繪圖出現的位置和方式;cm 運算子將新矩陣串聯到目前矩陣上,接受六個對應於仿射矩陣 [a b c d e f] 的運算元:

1 0 0 1 100 50 cm        % 以 (100, 50) 平移:e 與 f 攜帶偏移
2 0 0 1.5 0 0 cm         % x 縮放 2、y 縮放 1.5:a 與 d 是縮放因子
0.707 0.707 -0.707 0.707 0 0 cm   % rotate 45 degrees (cos/sin in a, b, c, d)

有兩件事容易讓人出錯;首先,cm 是組合而非取代,因此轉換會累積且順序很重要:先縮放再平移與先平移再縮放是不同的;其次,旋轉和縮放是圍繞目前的原點進行的,而不是圍繞您形狀的中心;因此要在原地旋轉某物,您需要將其平移到原點、旋轉,然後再平移回來,這一切都包裝在 q/Q 中;這個相同的矩陣也是放置影像的矩陣,這是最後值得了解的部分

影像與可重複使用的內容是 XObject

點陣影像不直接嵌入在內容串流中;它們儲存為影像 XObject,這是一些外部物件,擁有自己的字典,描述寬度、高度、位元深度、色彩空間和壓縮濾鏡,而內容串流僅引用它們;以 JPEG 為底層的相片聲明如下:

/Photo <<
  /Type /XObject
  /Subtype /Image
  /Width 640
  /Height 480
  /BitsPerComponent 8
  /ColorSpace /DeviceRGB
  /Filter /DCTDecode        % 影像資料是 JPEG 串流
>>

影像 XObject 繪製在單位正方形中:它總是佔用使用者空間中從 (0, 0) 到 (1, 1) 的區域;您不需要傳遞位置或大小給它;相反地,您設定 CTM,使該單位正方形映射到您想要的矩形,然後使用 Do 呼叫它;這就是為什麼放置影像總是先進行轉換然後進行呼叫,並包裝在儲存/還原中,以防縮放比例滲透到下一個操作中:

PDF 影像 XObject 放置圖解:字典在內容串流之外定義像素,Do 畫到單位方格,cm 包在 q 與 Q 中把它映射到目標矩形
串流不存放像素 — 它把 XObject 引進單位方塊,cm 再把方塊映射到目標矩形
q
640 0 0 480 50 300 cm    % 將單位正方形映射到 (50, 300) 的 640x480 方框
/Photo Do                % 繪製影像 XObject
Q

相同的 Do 機制也驅動著表單 XObject,這些物件將可重複使用的圖形區塊(如標誌或重複的印章)作為擁有週邊方框的專屬內容串流儲存;定義一次,使用不同的 CTM 呼叫多次,位元組在檔案中僅出現一次;大多數函式庫將此隱藏在單個放置呼叫背後:HotPDF 使用 AddImage 註冊點陣圖並使用 ShowImage 放置它,接受明確的 x、y、寬度和高度,而不是要求您手動建置矩陣:

var
  Bmp: TBitmap;
  ImgIndex: Integer;
begin
  Bmp := TBitmap.Create;
  try
    Bmp.LoadFromFile('logo.bmp');
    ImgIndex := Pdf.AddImage(Bmp, icFlate);
    // x, y (bottom-left), width, height, rotation angle
    Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
  finally
    Bmp.Free;
  end;
end;

在該行程式碼之下,函式庫寫入影像 XObject 字典,設定 CTM 以調整單位正方形的大小和位置,並輸出 Do;其底層的模型非常值得了解,因為它解釋了每一個奇怪的結果:拉伸的影像表示具有不匹配縮放因子的 CTM、在四十個頁面上完全相同的標誌是呼叫了四十次的單個表單 XObject,而顛倒算繪的影像是矩陣中的符號翻轉,而不是檔案損壞

總結

一旦您看清了繪圖模型的輪廓,它其實很精簡;內容串流是在可變狀態下執行的後置位元組碼;座標從左下角開始並通過 CTM;路徑是靜默建置的,並使用一個特定的運算子進行繪製;色彩和線條設定會持續存在,除非您使用 q/Q 將它們括起來;影像和可重複使用的圖形是透過轉換單位正方形來放置的 XObject;幾乎所有令人困惑的算繪結果都可以歸結為這五條規則之一;如果您想了解這些繪圖運算子如何置於更大的物件模型(指向它們的頁面字典和交叉引用表)中,PDF 檔案結構的技術概述 涵蓋了該層級,而 從頭開始建置簡單的 PDF 則帶您從頭到尾瀏覽位元組;文字繪製存在於其專屬的運算子系列中,並有其自身的陷阱,這在關於 PDF 文字與字型處理 的配套文章中有所介紹

此處顯示的 Delphi 繪圖呼叫 MoveTo、LineTo、Stroke、Rectangle、Fill、SetRGBFillColor、AddImage 和 ShowImage,是適用於 Delphi 和 C++Builder 的 HotPDF 元件 的一部分,該元件會為您輸出這些內容串流運算子