技術文章

HotPDF 在 Free Pascal 上:Deflate、AES 與編解碼器限制

HotPDF 在 Free Pascal 3.2.2 與 Lazarus 下編譯並執行,而這個移植誠實的總結只要兩句話。文件建立、載入、儲存、壓縮、解壓縮、加密與解密都在純 Pascal 後端上運作,所以 Lazarus 應用程式不需要任何 C 相依就能產生與讀取真正的 PDF。可選的原生影像編解碼器則不然,因為預建的 Win64 目標檔使用兩個 Free Pascal 連結器都無法消化的 COFF 變體,所以在那個工具鏈上,這些進入點解析到以失敗收場的 stub

HotPDF Free Pascal 能力地圖:可用的 Pascal deflate、AES 與文件後端,旁邊是以失敗收場的影像編解碼器 stub
文件、壓縮與加密功能跑在純 Pascal 後端上,原生影像編解碼器則解析到以失敗收場的 stub

從「能編譯」走到「能運作」需要一組具體的修復,而其中每一項都是陷阱,任何其他遷移到 Free Pascal 的 Delphi 程式碼庫都會踩到。值得按它們造成傷害的順序寫下來

為什麼單元能編譯證明不了任何事?

因為 Pascal 單元可以引用一個永遠不會做任何有用之事的符號,同時仍讓編譯器滿意。當全部 113 個程式庫單元在 Free Pascal 下乾淨建置時,檔案容器處理器確實可用,由一支開啟 CBZ 並轉成 PDF 的煙霧測試驗證。XFA 表單扁平化則完全不可用,因為扁平化必須解壓縮 /XFA 封包串流,而 deflate 進入點還是 stub。建置輸出裡沒有任何東西區分這兩種情況

由此得出的規則很短。在發布說明裡寫下某功能在新工具鏈上可用之前,先寫一支在該工具鏈上端對端演練該功能的執行期探針。編譯涵蓋是前提,永遠不是證據。移植涵蓋範圍的全貌見Free Pascal 與 Lazarus Win64 支援說明

cdecl stub 內拋出的例外到不了呼叫端

這一個值得獨立一節,因為症狀太具誤導性。stub 單元像靜態程式庫那樣暴露 C 進入點,所以 stub 看起來像這樣

// 看起來合理。其實不是。
function inflate(Strm: Pointer; Flush: Integer): PtrUInt; cdecl;
  public name 'inflate';
begin
  raise ENotSupportedException.Create('codec unavailable');
end;

在 Free Pascal for Win64 上,那個例外不會傳播到呼叫端。沒有任何 try..except 處理器能看到它,因為以這種方式宣告的 cdecl 邊界,堆疊展開不會攜帶 Pascal 例外框架;行程以結束碼 217 終止。從應用程式那側看,沒有錯誤、沒有訊息、沒有日誌行,只有一個消失的程式。這嚴格來說比錯誤答案更糟,因為錯誤答案還能被處理

為什麼 cdecl stub 內拋出的例外會以結束碼 217 終結 Free Pascal 行程,以及閘控 Pascal 進入點如何修復
Pascal 例外框架無法跨越 cdecl 邊界展開,所以行程無聲死亡;修復方式是在觸及 stub 之前就閘控

誘人的修法是讓 stub 回傳失敗碼,對 inflate 這是對的,因為 zlib 有定義良好的錯誤回傳。但一般來說這是錯的:jpeg_read_header 的 stub 回傳零,會告訴呼叫端帶著一個沒有人初始化的結構繼續走下去。持久的修法是在 Pascal 進入點閘控,而不是在 C 形狀的 stub 內部,並使用該 API 既有的失敗慣例

function TryDecodeJPEG(const Data: TBytes; out Bitmap: TBitmap): Boolean;
begin
{$IFDEF FPC}
  // 在觸及 stub 之前就拒絕,使用這個 API 自己的
  // 失敗慣例,而不是跨越 cdecl 拋例外
  Bitmap := nil;
  Result := False;
  Exit;
{$ENDIF}
  Result := DecodeJPEGNative(Data, Bitmap);
end;

paszlib 不是 zlib,差別是兩類文件

Free Pascal 上可用的 Pascal deflate 實作處理兩種封裝:zlib 包裝與原始 deflate。它不處理 gzip 封裝——zlib 透過 16 到 31 的 windowBits 值選擇它——也不處理 32 到 47 選擇的自動偵測模式。HotPDF 兩者都需要。安全的 SVG 匯入路徑請求 31,載入器有一個回退階梯,在串流封裝不明時請求 47。少掉任何一個,一整族文件就停止開啟,而且解碼錯誤指向串流,而不是指向缺失的封裝

paszlib 與 zlib 的 windowBits 涵蓋對照:HotPDF 的 SVG 匯入與載入器回退缺少 gzip 封裝與自動偵測範圍
paszlib 處理 zlib 包裝與原始 deflate,但 HotPDF 還需要 windowBits 31 與 47,所以墊層必須自行供應 gzip 封裝

還有一個更尖銳的不相容。paszlib 宣告的 z_stream 記錄與 C 版的記憶體配置不同:它的 msg 欄位是短字串而不是指標,total_intotal_out 是 64 位元,而 C ABI 是機器字組。因此呼叫端記錄無法直接傳入。可行的安排是把 paszlib 狀態藏在公開記錄已保留的 state 指標後面,並在每次呼叫前後把公開欄位複製進出。gzip 的 CRC 與八位元組長度尾端也在同一個墊層裡結算,那裡是它們自然的家,因為墊層本來就擁有封裝決策

把動態陣列傳給無型別 var 參數

這是此刻最可能正躺在您的程式碼裡的錯誤。當您把動態陣列傳給無型別的 var 參數,被呼叫端收到的是陣列變數的位址,也就是指標的位址,而不是負載的位址。所以一次對它的讀入會覆寫變數本身以及緊鄰的任何東西

var
  FBuffer: TBytes;
begin
  SetLength(FBuffer, 65536);

  // 錯誤:交出 FBuffer 變數本身的位址
  FStream.Read(FBuffer, Length(FBuffer));

  // 正確:交出第一個負載位元組的位址
  FStream.Read(FBuffer[0], Length(FBuffer));
end;

在 Delphi 上,錯誤寫法常常看起來能用,因為它破壞的是一個事後沒有人讀的相鄰堆疊槽。在 Free Pascal 上,同一行第一次使用就區段錯誤。肉眼難以察覺的原因是靜態陣列沒有這個問題——靜態陣列變數本身就是它的負載——所以在同一個檔案裡,兩種寫法都可能是對的,取決於幾百行之外的宣告

沒有 System.Zip 的 ZIP 容器

Free Pascal 沒有對應 RTL zip 單元的東西,而現成的替代品既有一套不同的 API 介面,又不支援較舊容器格式仍在使用的舊式加密,所以一個小的程式庫內建讀取器比改造它更短。有兩個格式細節耗時且容易出錯

第一個是加密標頭檢查位元組。它的第十二個位元組通常是 CRC 的高位元組,但當一般用途旗標位元 3 被設定時——意思是大小存在尾端資料描述器裡、CRC 尚未知——檢查位元組改取修改時間的高位元組。只實作 CRC 形式,每個以串流模式寫出的壓縮檔都會拒絕正確的密碼。第二個是 ZIP64 額外欄位:它的三個 64 位元欄位按固定順序出現,但只在對應的 32 位元欄位飽和時才會寫入,所以按固定偏移讀取在您測過的壓縮檔上可用,在下一個就失敗。要按哪些 32 位元欄位飽和來做位置解析

一個值得知道的便利:Free Pascal 的解壓縮串流建構子接受第二個引數,可跳過 zlib 標頭,而這正是 ZIP 項目需要的,因為它們儲存原始 deflate。那條路徑完全不經過程式庫的 zlib 墊層,所以不受缺失 C 後端影響

LCL 下的彩色字形透明度

讀取點陣化彩色字形的 Alpha 色版,是唯一沒有直接對譯的圖形細節。LCL 的 PNG 類別沒有暴露 Alpha 的掃描線存取器,而把 PNG 指派給點陣圖會丟棄它,所以彩色 emoji 會完全不透明地到達,並在黑色方塊上合成。可行的路線是介面影像:從 PNG 建立它,然後透過色彩存取器讀像素,記得它的分量是 16 位元、需要右移八位元才變成位元組。那個介面也使用自然的由上而下列順序,所以 VCL 掃描線程式碼需要的 Height - 1 - Y 反轉必須移除,而不是移植

在提交錯誤報告之前的兩個建置系統事項

完整重建偶爾會以一個未定義符號失敗,符號名稱以 $crc 後綴與一個十六進位值結尾。那個後綴由參數型別計算而來,當一次建置在同一輪裡針對兩個不同的介面版本編譯同一單元時會對不上。重跑建置即可清除;簽章本身沒有錯

第二,Free Pascal 3.2.2 沒有匿名方法,所以程式庫凡是使用閉包接起平行管線的地方,Free Pascal 建置都改用確定性的序列後備。輸出相同,輸送量不同;如果您的業務依賴平行頁面渲染,這是暫時留在 Delphi 的理由,管線設計在平行渲染管線文章中描述。影像編解碼器的處境是另一個工具鏈選擇會改變能力而不只是速度的地方,所以 Lazarus 部署應據此規劃影像格式;目前的各工具鏈矩陣在 HotPDF Delphi PDF component 產品頁