技術文章

用 HotPDF 在 Delphi 中將 PDF 頁面匯出為 SVG

HotPDF 只需一次呼叫 BuildLoadedPageSVG,就能把已載入 PDF 文件的任一頁面匯出為獨立的 SVG 標記,該呼叫會以字串形式回傳完整的 SVG 文件。匯出的標記帶有頁面幾何、以真正 SVG text 元素呈現的文字、內嵌點陣影像,以及 PDF 運算子在每次繪圖操作時所建立的描邊狀態

最後這一點,正是多數自製轉換器悄悄出問題的地方。把 PDF 頁面轉成 SVG 表面上看是座標問題,實際上卻是狀態問題。PDF 是一台堆疊機器,其圖形狀態會隨內容串流的解讀而變化;SVG 則是一棵宣告式樹狀結構,每個元素各自帶有自己的呈現屬性。解譯器在產生元素當下若未能擷取快照,該資訊就會從輸出中徹底消失,而且這種失誤是無聲的:你得到的是合法的 SVG,卻繪出一個微妙錯誤的頁面

為什麼 PDF 頁面無法直接轉換成 SVG?

有三處落差讓這項轉換並不單純,而且這三處在你把結果跟原始頁面並排比對之前,看起來都相當合理。第一處是 y 軸方向。PDF 使用者空間以頁面左下角為原點向上成長;SVG 則以左上角為原點向下成長。只做一次頁面層級的翻轉能修正繪圖座標,卻會破壞每個字符,因為翻轉整個畫布同時也會把字形鏡像

第二處落差是繼承機制。在 PDF 中,qQ 會將包含線寬、線帽、線接合、斜接限制、虛線陣列、虛線相位與透明度的圖形狀態推入與彈出堆疊。在 SVG 中,未指定某屬性的元素會從祖先群組繼承該屬性,這完全是另一套範圍規則。若匯出器只追蹤目前的變換矩陣而忽略描邊狀態,Q 還原後的狀態就會外洩到後續元素中

第三處在於 PDF 有些內容是靠慣例而非直接數值來表達。線帽與線接合是整數編碼,線寬為零代表裝置空間中的髮線而非隱形線條,繪製運算子的星號變體改變的是纏繞規則而非顏色。這些都需要轉譯,而非單純複製

常見情境只需一次呼叫

若只是要把頁面匯出給網頁檢視器、差異比對工具或設計交付使用,這個 API 表面就只有一個函式。BuildLoadedPageSVG 接受一個以零為起始的頁面索引(對應目前已載入的文件),並以 AnsiString 形式回傳 SVG 文件:

var
  Pdf: THotPDF;
  I: Integer;
  Svg: AnsiString;
  Output: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('statements.pdf', '') <= 0 then
      Exit;                     // LoadFromFile 回傳頁數
    for I := 0 to Pdf.LoadedPageCount - 1 do
    begin
      Svg := Pdf.BuildLoadedPageSVG(I);
      if Length(Svg) = 0 then
        Continue;
      Output := TFileStream.Create(Format('page-%d.svg', [I + 1]), fmCreate);
      try
        Output.WriteBuffer(Svg[1], Length(Svg));
      finally
        Output.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

HotPDF 命令列工具也以 export-svg 指令提供同樣的匯出功能,適合用在建置流程與回歸測試腳本中,讓你不必寫任何 Pascal 程式碼,就能取得頁面的可文字比對表示形式。由於 SVG 是純文字,它自然而然能與 將 PDF 頁面轉譯為點陣圖 一文所述的點陣路徑互補:點陣圖告訴你頁面看起來如何,SVG 則告訴你頁面是由什麼組成

PDF 文字如何對應到 SVG text 元素?

HotPDF 把文字矩陣鏈組成為前置矩陣乘上 CTM 再乘上文字矩陣再乘上字符翻轉,其中字符翻轉是右乘 matrix(1,0,0,-1,0,0)。這個右側因子的唯一作用,是抵銷字符外形上的頁面層級垂直翻轉,否則在翻轉後的局部座標系中繪製的 SVG 文字會呈現上下顛倒。把修正放進矩陣本身而非特殊情況程式碼中,代表旋轉、鏡像與傾斜的文字都能正確呈現,不需要額外分支

水平定位使用 SVG text 元素的多值 x 語法,每個字元對應一個座標,由當時生效的每個字符前進量加上字元間距 Tc 與字距 Tw 累加而得。水平縮放 Tz 會併入文字矩陣的 a、c 兩欄,而非另外輸出,因此即使檢視器忽略特殊文字屬性,每個字符仍會落在 PDF 原本安排的位置上。透過複雜文字塑形產生的文字,如 複雜文字腳本塑形 一文所述,也走同一條路徑,因為在內容串流被解讀時,塑形器早已把字符叢集解析為已定位的字符

旋轉與影像:兩種容易搞反方向的翻轉

/Rotate 項目非零的頁面,需要一個預先變換,由針對旋轉後畫布高度的翻轉,加上以 y 軸向上顯示空間表示的旋轉組成。三種旋轉矩陣分別是 90 度用 (0,-1,1,0,0,W)、180 度用 (-1,0,0,-1,W,H)、270 度用 (0,1,-1,0,H,0),其中 W、H 為旋轉前的頁面尺寸。手動推導這些矩陣恰好在三個地方容易出現正負號錯誤,因此匯出器透過處理其他所有變換所用的同一套矩陣乘法程序來組合它們

內嵌影像需要自己的翻轉,因為 PDF 影像空間把第一列樣本放在單位正方形的頂邊,而 SVG image 元素則採用 y 軸向下的局部座標系。因此輸出的變換是 CTM 右乘 matrix(1,0,0,-1,0,1)。這一步出錯,會在一個原本完美無瑕的頁面上產生上下鏡像的照片,這類缺陷審閱者一眼就能看出,自動化測試卻常常抓不到

圖形狀態裝置究竟保留了什麼?

HotPDF 透過另一個獨立的選用裝置介面來分派描邊狀態運算子 wJjMd,因此新增描邊精確度時,不需要改動現有內容裝置的 vtable,也不會破壞針對舊版本編譯之程式碼的二進位相容性。具體而言,匯出的 SVG 收到的是轉譯後的關鍵字,而非原始的 PDF 整數:

// PDF 整數列舉會轉換成 SVG 關鍵字屬性
//   線帽  0, 1, 2  ->  butt, round, square
//   線接合 0, 1, 2  ->  miter, round, bevel
//
// PDF 中線寬為零代表裝置空間的髮線,因此匯出器會輸出
// vector-effect="non-scaling-stroke",讓描邊在經過 CTM 之後
// 仍可見,且維持接近一個裝置像素的寬度
//
// f* B* b* 選用偶奇規則,會輸出 fill-rule="evenodd"
// 而 f B b 則維持 SVG 預設的非零纏繞規則

Q 還原狀態時,會一併涵蓋透明度、線寬、線帽、線接合、斜接限制、虛線陣列與虛線相位。巢狀 Form XObject 在其邊界處會擷取快照並還原同一整套狀態,因此定義在印章內的虛線邊框,其樣式不會外洩到後續的頁面內容中。若你因其他原因已在追蹤裁剪與 CTM 行為,這正是 EMF 與 WMF 向量匯入 一文中出現的同一套狀態模型,只是方向相反

上線前值得了解的邊界

這個匯出器對自身的範圍相當坦白,事先了解其邊界,遠比在正式環境中才發現要划算。顏色是透過 rgRGgG 運算子傳到 SVG 裝置。透過色彩空間加上 scn 建立的填色,也就是 Separation、DeviceN 與 ICCBased 顏色的繪製方式,並不會以已解析的 RGB 三元組形式傳到裝置,因此以這種方式使用專色的頁面,匯出時只有幾何形狀,沒有那些顏色。對於面向印刷的來源,改用點陣化,或先將專色平面化;繪製模型本身在 繪製 Separation 與 DeviceN 專色 一文中有說明

還有兩個小提醒能省下除錯時間。十六進位顏色字面值以大寫輸出,因此若測試斷言為 #ff0000,即使 #FF0000 完全正確也會判斷失敗。此外 SVG 裝置是透過介面做參照計數,所以釋放它的方式是讓介面離開作用範圍,而不是對物件呼叫 Free,這個差異在你打算擴充該裝置以在頁面內容旁輸出自訂標記時特別重要

當你需要判斷產生的文件在兩次建置之間是否真的有變化時,SVG 匯出與結構化比對是天然的搭配。圍繞已載入文件的更廣泛工具集,從轉譯、編輯到匯出,都記載於 HotPDF Delphi PDF 元件頁面