技術文章

HotXLS 在 Free Pascal:Unicode、COM 槽位與 zlib

HotXLS 能在 Windows 上的 Free Pascal 與 Lazarus 下建置,而這次移植取決於四個跟 Object Pascal 語法毫不相干的決定:核心維持在 DELPHIUNICODE 模式、把 OLE 結構化儲存介面宣告成手工管理參考計數的 CORBA 介面、以 Pascal 實作取代 Win32 AES 物件檔,以及修掉一個會把被截斷的 ZIP 當成完整檔收下的 inflate 迴圈

移植過成熟 Delphi 函式庫的人,都知道這種工程的形狀。編譯器第一輪幾乎全收。接下來的是一條長尾:編譯得乾乾淨淨、結果卻是錯的行為差異,而試算表引擎對它們異常沒有防備,因為它在同一條程式路徑裡碰了文字編碼、COM 結構化儲存、壓縮與密碼學

核心為什麼堅持 DELPHIUNICODE、而不是純 DELPHI?

因為公式引擎依賴 StringChar 承載 UTF-16 語意,而 ANSI 那條路會在東西抵達檔案之前就把字元弄丟。用 FPC 的 DELPHI 模式建核心很有誘惑力——那正是多數移植伸手去拿的相容性開關——而且程式碼編得過。然後一本帶中文工作表名稱或西里爾標籤的工作簿走完計算路徑的來回,寫入端看到的時候字元已經沒了,全程沒有任何錯誤

模式在整個程式庫裡並不統一,這是刻意的,不是凌亂。PNG 位元組解碼器與 LCL 覆寫真的需要 ANSI 簽名,因為它們處理的是位元組、以及 widgetset 遞過來的東西。那些 unit 打開一個獨立的 LX_FPC_ANSI 開關。一個程式庫裡有兩種模式聽起來像壞味道,直到您發現另一個選項是一個把輸入當文字對待的位元組解碼器

還有一個 companion 細節,晚點會咬人。在 FPC runtime 裡,DELPHIUNICODE 並不會讓 TFormatSettings.DecimalSeparator 變成 WideChar。帶 Unicode 小數分隔符的輸入,必須先在 Unicode 字串內部正規化成 ASCII 分隔符;而分隔符與預期不符的任何輸入都必須拒收,而不是悄悄截斷在解析器不認得的那個字元上

program ExportReport;
{$MODE DELPHI}
uses
  Interfaces,          // 必須放最前面:初始化 LCL widgetset
  SysUtils, lxHandle;  // 以及 UTF-8 轉換層

var
  Book: TXLSWorkbook;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('input.xls');
    Book.Sheets[0].AsString[1, 1] := 'Quarterly summary';
    Book.SaveToFile('output.xls');
  finally
    Book.Free;
  end;
end.

Interfaces unit 不是可選的,而且必須排第一。初始化 LCL widgetset 與 UTF-8 轉換層的就是它,而字型、檔案路徑或文字一旦跨越 RTL 與 LCL 邊界,HotXLS 兩者都要靠。跳過它的主控台程式編得過,卻會在任何非 ASCII 路徑上出錯。這也是為什麼編譯成功在這裡什麼都證明不了:這次移植要等帶著真實字型名稱與真實路徑的真實文件走完一整趟來回,才算真的能動

類別 VMT 不是 COM vtable

Free Pascal 不會讓您把類別 VMT 當成 COM 介面 vtable 遞給 Windows,就算宣告看起來跟 Delphi 收下的那個一模一樣。兩種版面在會導致呼叫進錯槽位的方向上不同,症狀則是與呼叫點毫無關係的某處當機。結構化儲存在這裡要緊,因為經典二進位工作簿格式是一個 OLE 複合檔案,讀寫它意味著實作 Windows 儲存 API 會回呼進來的 ILockBytes

行得通的安排是 CORBA 介面加明確宣告的 COM 槽位,AddRefRelease 手工管理。這意味著為這些型別放棄自動參考計數、自己扛下生命週期,對住在同一個 unit 裡的少數幾個介面來說是筆划算的交易。這件工作裡具體的陷阱是 QueryInterface:它必須回傳介面指標,不是物件指標。兩種都編得過。其中一種遞給 Windows 的位址,第一個機器字組不是 vtable

比較 Free Pascal 類別 VMT 與 HotXLS 必須呈給 Windows 結構化儲存 API 的 COM 介面 vtable 的圖解:同一份 Pascal 宣告卻是不同的槽位順序,外加 QueryInterface 陷阱——回傳物件指標而非介面指標,會把 ILockBytes 呼叫送進類別槽位,在離呼叫點很遠的地方當機
Free Pascal 拒絕把類別 VMT 當 COM vtable 伺候,所以 HotXLS 宣告帶明確 COM 槽位、AddRef 與 Release 手工管理的 CORBA 介面,QueryInterface 則回傳 Windows 解得了參照的介面指標

FPC 專屬的宣告住在 lxOleInterfaces.inc,與 FPC 原始碼目錄裡的 lxAESBackend.inclxZlibBackend.inc 作鄰,編譯器特定的選擇因此集中一處,而不是散落引擎各處。格式本身與程式庫怎麼在其中穿行,記錄在用 Pascal 讀 OLE2 複合檔案

同族還有一個型別細節。LargeInt 在 FPC 分支必須解析到 Int64,而兩套工具鏈對 Comp 的編譯器分類差得足以讓多載解析挑到不同的候選。測大偏移量行為時用檔案串流,不要用 HGLOBAL 串流:Windows 的全域記憶體串流在 seek 超過 4 GiB 時自己就會繞回,在那裡通過的測試,對您自己的算術什麼都證明不了

一個自我一致的 AES 實作藏了什麼

Delphi 建置所連結的 Win32 AES 物件檔是 OMF,Free Pascal 連結器吃不下,所以 FPC 分支改用 Pascal 寫的 AES 實作。Delphi 照舊連結它一直用的物件檔,既有客戶拿到的二進位檔因此一個位元組都沒變

值得帶進任何專案的是驗證要求。用同一個實作加密再解密,什麼都證明不了:金鑰排程錯、區塊順序錯或鏈接錯的對稱演算法,完美地自我一致,每次都把自己的輸出原樣往返。只有 known-answer 向量抓得到,對照公開值檢查金鑰擴充、區塊順序與 CBC 鏈接。出貨一個自我一致的錯誤實作,症狀出現在客戶第一次用 Excel 打開檔案的那一刻

壓縮那邊有一個性格不同的缺陷。Pascal 的 inflate 後端在吃光所有壓縮輸入之後,可能還有輸出沒吐完,所以呼叫端必須持續呼叫,直到串流回報結束。把輸入耗盡當成串流結束,會截掉最後一個區塊。更糟的是,它把一個損壞的封存變成被默默接受的封存,這恰恰是 驗證 ZIP end-of-central-directory 記錄一文的強化要防的失敗模式。規則是:沒有進度加上尚未完成,就是截斷錯誤,永遠不是 EOF

兩個燒掉真實工時的建置系統陷阱

LCL 搜尋路徑必須排在 FPC 套件萬用字元路徑之前,否則 Free Vision 的 Menus unit 會遮住 LCL 的同名 unit,然後您拿到一則對雙方都隻字未提的 PPU 檢查碼不符。安裝後被搬過家的 Lazarus 也可能在 fpc.cfg 裡留下過期路徑,所以建置進入點明確指定 unit 與 binary 路徑,不繼承環境碰巧給的任何東西

第二個陷阱跟 Pascal 無關。以 LF 行尾寫成的 .cmd 批次檔,在檔案長過直譯器讀取緩衝區之前都好好的,過了那條線,call :label 開始失敗,宣稱批次標籤不存在,而失敗出現在碰巧落在邊界之外的任何程式上。任何會改寫批次腳本的工具都必須把 CRLF 寫回去。另外 lazbuild --build-all 會在編譯前清空套件的 unit 輸出目錄,停在裡面的選項檔還沒被讀到就被刪了:把它放在外面,並且記得 @ 路徑是相對於套件目錄解析的,因為 lazbuild 是從那裡叫編譯器的

HotXLS 乾淨 Free Pascal 編譯背後兩層危險地帶的地圖:讓 String 與 Char 維持 UTF-16 的 DELPHIUNICODE 模式、給 PNG 位元組解碼器與 LCL 覆寫用的 LX_FPC_ANSI 逃生口,以及來自 Free Vision Menus 遮蔽、過期 fpc.cfg 路徑、純 LF 批次檔與 lazbuild 輸出清除的建置陷阱
第一次編譯成功證明不了什麼:模式地圖決定哪些字元活得到寫入端,建置系統陷阱則以檢查碼不符、幽靈般的缺失標籤與還沒被讀就被刪的選項檔現形
// Lazarus 網格匯出:TGridToXLS 隨 Lazarus 套件出貨,
// 所以同一份 DB-grid 匯出程式碼在 LCL 應用程式裡也能用
var
  Exporter: TGridToXLS;
begin
  Exporter := TGridToXLS.Create(nil);
  try
    Exporter.DBGrid := GridOrders;
    Exporter.WorksheetName := 'Orders';
    Exporter.ExportHeader := True;
    Exporter.SetColumnsWidth := True;
    Exporter.ExportDBGrid;
    Exporter.SaveAs('orders.xls');
  finally
    Exporter.Free;
  end;
end;

一則編譯器警告值多少

Free Pascal 會回報 Delphi 不報的未初始化區域變數,跑 FPC 建置把這個差異兌換成計算 unit 裡的兩個真實缺陷。一個函式讀了一個從未在使用前被指派的計數變數;另一個在某個分支裡用了兩個座標,而計算它們的程式碼在另一個分支才跑。在 Delphi 底下,兩者都按堆疊碰巧裝著什麼而行為——這正是「在甲機重現、在乙機不重現」那類 bug 的定義

實務結論是:即使產品主要在第一個編譯器上出貨,也值得把第二個編譯器留在迴圈裡。定期掃一遍 FPC 的警告類別,是對 Delphi 程式庫做的一次便宜靜態分析,它能找到任何測試套件都不可靠觸及的一類缺陷。這件事所處的更大版本矩陣紀律,記錄在跨編譯器建置矩陣

Windows 的 Free Pascal 與 Lazarus 支援以 Lazarus 套件的形式,隨 HotXLS Delphi spreadsheet component 與 Delphi、C++Builder 套件一起出貨,由同一份原始碼樹建置,不是分岔。這正是整件事的意義:一個引擎、四套工具鏈,編譯器特定的決定隔離在 include 檔裡,一次坐下來就能讀完