技術文章

在 Delphi 中做多引擎 PDF 算繪

三套點陣化引擎可以讀同一份 PDF,卻對它到底寫了什麼各說各話。PDF Library for Delphi 裡的內建引擎,是那個不必附帶任何額外檔案、而且什麼都算繪得稱職的引擎,這也是它憑什麼拿下預設位置。Cairo 帶來的是另一套透明度與反鋸齒管線,當柔邊遮罩或混合模式在別處出錯時,人們往往就伸手拿它。PDFium 承載的是 Chrome 的算繪程式碼,所以一頁在瀏覽器裡看起來對的東西,在 PDFium 底下通常也對,代價是一個相當大的 DLL,以及一個它堅持要對上的位元數。這三者沒有哪一個是抽象意義上正確的。正確與否是逐份文件而定的,而要弄清楚哪一套引擎吃得下某一批語料,唯一誠實的方法就是把那批語料分別餵給每一套跑一遍

這就是把引擎當成執行期選項、而不是建置期選項的理由。PDF Library for Delphi 是 losLab 出品、給 Delphi 與 C++Builder 用的 PDF 函式庫,它把三套引擎都放在同一個算繪呼叫介面之後,於是這個決定的代價是一個整數,而不是一段程式碼分支。接下來要談的,就是如何在它們之間安全地選擇、如何確認一份已部署的執行檔實際帶了哪些引擎,以及如何不讓算繪狀態悄悄毒害下一份工作

一個呼叫介面背後的三套點陣化引擎

函式庫給它的引擎編了號。引擎 1 是內建算繪器,也就是預設值,在 Windows 上帶有 GDI+ 的平滑化選項。引擎 2 是 Cairo,引擎 3 是 PDFium,兩者都在執行期透過 SelectRenderer 選取。這兩套外部引擎會從 DLL 載入,路徑由您在選取它們之前以 SetCairoFileNameSetPDFiumFileName 提供。不論作用中的是哪一套引擎,工作都走同一組呼叫:RenderPageToFileRenderPageToStreamRenderDocumentToFile。切換引擎動的是一個數字,您其餘的算繪程式碼永遠不會察覺

目的端模型的觸角遠遠伸出點陣圖之外。算繪器類別同時支援中繼檔(WMF、EMF、EMF+)、EPS、直接的裝置內容、印表機以及 HTML5,而 Cairo 與 PDFium 只有在被編譯進來時才會以額外目的端的身分現身。點陣輸出是三套引擎歧異得最明顯的地方,所以這裡的範例用的就是它

一個呼叫介面背後的三套 PDF 算繪引擎:SelectRenderer 在內建引擎、Cairo 與 PDFium 之間切換,而應用程式碼始終呼叫同一組算繪函式
SelectRenderer 用一個整數就把工作在內建引擎、Cairo 與 PDFium 之間搬動。不論像素是哪一套引擎產的,應用程式碼照樣呼叫 RenderPageToFile 這一家子

永遠別假設某套引擎存在:在啟動時探測

Cairo 與 PDFium 是條件編譯的功能,這表示一份執行檔可以完全不含它們。這種情況下,去要求引擎 2 或 3 不會拋出任何東西。SelectRenderer 只是傳回一個不等於您所要求的 ID 的值,而忽略傳回值的程式碼就繼續用原本作用中的那套引擎算繪下去。防線是一段啟動時的探測,要求每一套引擎報上名來,並把答案記下:

PDF Library for Delphi:啟動時的引擎探測流程圖,每套算繪器都先確認自己的 DLL 路徑與 SelectRenderer 的回覆,再把可用性摘要記錄在每次算繪工作旁邊
路徑呼叫失敗指證的是 DLL,而 SelectRenderer 結果對不上則代表這份執行檔根本沒把該引擎編譯進來。探測只跑一次,它那一行摘要就能了結大多數客戶的算繪疑問
function ProbeEngines(PDF: TPDFlib): string;
begin
  Result := 'built-in';                        // 引擎 1 永遠都在
  if (PDF.SetCairoFileName('cairo.dll') = 1) and (PDF.SelectRenderer(2) = 2) then
    Result := Result + ', cairo';
  if (PDF.SetPDFiumFileName('pdfium.dll') = 1) and (PDF.SelectRenderer(3) = 3) then
    Result := Result + ', pdfium';
  PDF.SelectRenderer(1);                       // 在真正開工前還原預設值
end;

在啟動時把那段探測跑一次,並把結果連同每一次算繪工作一起寫進日誌。當客戶回報算繪差異時,最常見的頭一個問題就是他們那套安裝實際上有哪些引擎,而一行躺在日誌裡的答案,不必開遠端桌面就能了結這件事。還有一個有用的副作用:如果 SetPDFiumFileName 本身傳回 0,您就已經知道問題出在 DLL(路徑不對、位元數不對、少了相依項),而不是一份沒把 PDFium 支援編進去的執行檔,因為那次路徑呼叫在 SelectRenderer 有機會執行之前就什麼都沒解析到

一個 Options 整數背後的十種輸出格式

算繪呼叫上的 Options 參數選的是輸出編碼:0 是 BMP,1 是 JPEG,2 是 WMF,3 是 EMF,4 是 EPS,5 是 PNG,6 是 GIF,7 是 TIFF,8 是 EMF+,9 是 HTML5。PNG(5)對預覽與封存用的頁面影像來說是合理的預設值。JPEG(1)搭配 SetJPEGQuality,則是照片式掃描件更好的選擇,那種場合檔案大小比銳利邊緣更要緊

有一種格式藏著一項對目標串流的要求。BMP 路徑會先寫出影像資料,然後倒回位移 0x26 去修補標頭裡的解析度欄位。把它指向一個只能往前走的串流,比方說壓縮包裝或網路通訊端,這次呼叫就會以一種讀起來很像引擎故障、其實不是的方式失敗。當不可搜尋的目標避無可避時,改算繪成 PNG,或是讓 BMP 先過一道記憶體串流,等它完整了再往前複製

您傳進去的 DPI 不是您拿到的 DPI

每一次算繪呼叫都收一個 DPI 引數,但您實際拿到的解析度,是那個值乘上全域算繪縮放比。SetRenderScale 起始於 1.0,而您一旦改了它,這個新係數就會無聲地套用到該實例上之後的每一次算繪:

PDF.SetRenderScale(2.0);                    // 之後每一次算繪都變兩倍
PDF.RenderPageToFile(150, 1, 5, 'p1.png');  // 實際上是 300 DPI
PDF.SetRenderScale(1.0);                    // 重設,否則您的縮圖會大到嚇人

同樣的黏性也適用於 SetRenderCropType 與 JPEG 品質設定。在一個用單一共用實例產出縮圖、預覽與列印解析度影像的服務裡,這些殘留的設定,才是那張偶爾冒出來的「縮圖突然變成 40 MB」工單背後的真正原因。有兩條乾淨的出路:在每次操作開頭重設相關狀態,或是替每一種輸出設定檔各配一個獨立實例,讓狀態彼此不外洩

在伸手拿別套引擎之前,先調校預設引擎

在「我們需要換一套引擎」這類請求裡,有出乎意料高的比例,最後查出來是設定問題披了層偽裝。內建算繪器透過 SetGDIPlusOptions 以及範圍更廣的 SetRenderOptions 家族公開它的平滑化行為,而 SetGDIPlusFileName 讓您在部署環境帶了個不尋常的 GDI+ 執行階段時,能把它瞄準特定的那一份。低 DPI 下鋸齒狀的線條圖、縮圖裡糊掉的文字、漸層上的色階斷帶:這些全都吃這幾個旋鈕,而轉這些旋鈕在安裝程式那邊一毛錢都不必付。相對地,加進 Cairo 或 PDFium,意味著要多出貨幾個 DLL、要追第二甚至第三種位元數變體,還要扛下更新它們的義務

所以一件品質抱怨有它天然的處理順序。先在客戶那組確切的 DPI 與縮放比下重現它,因為有一半的時候,這兩項一對上,差異就蒸發了。接著試內建引擎的平滑化選項。走到這一步才把那一頁在各引擎之間並排比較,並讓其他每一個變數都保持不變:以相同 DPI 分別經引擎 1、2、3 算繪成 PNG,三張全部附上。通常三者裡有兩者一致,而這個多數就告訴您,那個異數是文件被詮釋得不一樣,還是您自己的基準期待歪了。三張具體的影像了結一場「算繪錯了」的爭執,遠比一整段形容詞來得快

一條會自我說明的後備鏈

一旦探測與狀態紀律都就位,後備鏈本身就很短了。偵測失敗靠的是 LastRenderError,它保存最近一次算繪時引擎自己的訊息文字,算繪成功時則為空:

PDF 算繪後備鏈:內建引擎先試,失敗被記錄下來,PDFium 重試,所有可用引擎都對一頁失敗時則拋出例外回報
每一次嘗試都在切換引擎前檢查 LastRenderError 並記下原因。唯有每一套已安裝的引擎都失敗了,這條鏈才會拋出,而蒐集到的成因早就躺在日誌裡
procedure RenderPageWithFallback(PDF: TPDFlib; Page: Integer; const OutFile: string);
begin
  PDF.SelectRenderer(1);                            // 內建的先來
  PDF.RenderPageToFile(200, Page, 5, OutFile);      // 5 = PNG
  if PDF.LastRenderError = '' then Exit;
  LogEngineFailure('built-in', Page, PDF.LastRenderError);
  if PDF.SelectRenderer(3) = 3 then                 // PDFium 當作重量級後備
  begin
    PDF.RenderPageToFile(200, Page, 5, OutFile);
    if PDF.LastRenderError = '' then Exit;
    LogEngineFailure('pdfium', Page, PDF.LastRenderError);
  end;
  raise Exception.CreateFmt('Page %d failed on all available engines', [Page]);
end;

這裡有兩個設計要點份量很重。這條鏈會記下每一次切換為何發生,因為一行寫著「這一頁自 3.7 版起改由 PDFium 接手」的日誌,是一個您會希望它在監控裡走出趨勢、而不是消失無蹤的迴歸訊號。至於後備順序本身,則是一項值得按工作負載逐一決定的政策。內建引擎部署時不必附帶任何額外 DLL,這讓它在大多數安裝環境裡是對的第一順位;而透明度群組或不尋常的漸層著色密集的文件,通常正是一個團隊當初會接上替代引擎的理由。沒有哪一套引擎在一般情況下最快,這正是逐次呼叫選擇的全部意義所在:拿您真實文件的樣本、在您真實的 DPI 下替每一套做效能基準,並在引擎 DLL 或文件組成改變時重做那次量測。每一次,都是語料贏得爭論

單頁之外:TIFF 批次與活的裝置內容

逐頁呼叫的兩位鄰居讓這套工具箱完整。RenderAsMultipageTIFFToFile 會把一段頁碼範圍運算式直接算繪成一份多頁 TIFF,那是交付給早於 PDF 的文件管理系統做封存時最自然的形狀。RenderPageToDC 則直接畫到 Windows 的裝置內容上供預覽控制項使用,並由它自己那三個具黏性的設定(SetRenderDCOffsetSetRenderDCErasePage,再加上裁切類型)管轄,它們需要跟縮放係數同樣的重設紀律。螢幕預覽與列印路徑的算繪本身陷阱夠多,值得專門寫一篇,連結列在下方

接下來往哪走

有一個習慣值得一路帶著:因為 SelectRenderer 對該實例上之後的每一次呼叫都生效,所以某一頁特別頑固時,可以只把它改用另一套引擎重試,而文件其餘部分留在預設引擎上。關於預覽繪製、印表機選取與 DevMode 處理,請接著看 列印預覽與裝置內容一文。當算繪要餵給處理超大檔案的高流量管線時,直接存取指南裡以控制代碼為主的做法,跟透過 DARenderPageToFile 的逐頁算繪搭起來很自然

引擎的封裝方式、支援的格式與試用建置,詳列於 PDF Library for Delphi 產品頁