技術文章

Delphi 中的 Excel 欄寬與最大數字寬度(MDW)

匯出的 PDF 把每條欄邊界放在 Excel 繪製位置左邊半個字元處,每個自動換列的儲存格也都在不同的地方斷行。Excel 欄寬不是以字元數或點數測量,而是以活頁簿 Normal 字型的最大數字寬度(Max Digit Width,MDW)為單位;HotXLS 在每次分頁建置前都用 GDI 測量該字型。這種失效模式很安靜:沒有任何東西擲出例外,儲存的寬度逐位元組原樣往返,幾何上每欄卻仍差了幾個百分比,直到累積漂移把一頁的表格推到兩頁

Excel 欄寬的單位是什麼?

工作表中的欄寬是活頁簿 Normal 字型數字字元的計數,不是絕對度量。ECMA-376 §18.3.1.13 以該字型在 96 dpi 下的最大數字寬度定義 <col>width 屬性,並把「從儲存寬度換回像素」的公式寫成一個對 MDW 取截斷的運算式。Excel 出貨時 Normal 樣式用的 Calibri 11,MDW 測得 7 像素。把預設寬度 8.43 單位連同 MDW 7 代入規格公式,會得到正好 64 像素,在 96 dpi 下即 48 點。這些正是 Excel 自己回報的數字,所以是很有用的檢查點:如果您的轉換能把 8.43 單位重現為 64 像素,算術就對了,剩下還可能錯的只有 MDW 輸入

const
  // 預設內文字型的最大數字寬度(MDW),以 96 dpi 下的像素為單位
  // Calibri 11 測得 7 px,可重現 Excel 儲存的確切像素寬度
  //(8.43 單位 -> 64 px -> 48 pt)
  DefaultMDW = 7;
  MinimumColumnWidth = 24.0;

function ColumnWidthToPointsMdW(Value: Double; MdW: Integer): Double;
var
  Pixels: Integer;
begin
  if Value <= 0 then
    Value := 8.43;
  if MdW <= 0 then
    MdW := DefaultMDW;
  Pixels := Trunc(((256 * Value + Trunc(128 / MdW)) / 256) * MdW) + 5;
  Result := Pixels * 0.75; // 96 dpi 像素 -> 點
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

HotXLS 把這段算術放在 lxPagination 單元裡唯一一個函式中,於是這把尺可能出錯的地方只有一處。+ 5 是 Excel 為格線與儲存格邊距加入的邊框填料,* 0.75 把 96 dpi 像素換成 PostScript 點,而 MinimumColumnWidth 的下限存在,是為了讓病態窄的欄仍留下一條渲染器可畫邊框的細條。公開進入點 ColumnWidthToPoints 保留舊的單引數簽名,把測得的 MDW 轉送給這個函式——正因如此,這次行為變更才能在不動任何呼叫端的情況下落地

HotXLS 在 Delphi 中的欄寬轉換鏈:把測得的活頁簿 Normal 字型最大數字寬度餵入規格公式,讓 8.43 單位的儲存寬度變成 64 像素、再變成 48 點
儲存的寬度是數字計數,所以 Normal 字型測得的 MDW 是公式的輸入,而非樣式細節;8.43 到 64 再到 48 的往返可用來檢查算術

為什麼非 Calibri 的 Normal 字型會移動每條邊界

漂移是乘法性的,所以它看起來像渲染 bug,而不是單位 bug。MDW 是寬度上的乘數,不是偏移。把 MDW 從 7 推到 8,預設 8.43 單位的欄就從 64 像素變成 72,一欄跳了 8 像素、也就是 6 點。十個這樣的欄,表格右緣就移動了將近一吋。觸發這個問題的活頁簿完全平凡:報表工具把 Arial 或 Segoe UI 蓋進 Normal 樣式而產生的任何檔案、從 ERP 匯出範本存出的任何檔案、客戶改過一次樣式就忘了的任何檔案

兩個相關的版面系統是繼承這個錯誤,而非造成它。合併區域會把成員欄的點寬加總,所以在 Excel 裡放得下一頁的合併,在 MDW 漂移後可能溢位——建置合併儲存格報表範本時值得記住這點。自動縮小填滿(shrink-to-fit)會把測得的文字寬度與同一個欄寬相比,所以錯的 MDW 也會改變哪些儲存格要縮小、縮多少。同一類的單位混淆也出現在繪圖錨點裡,影像幾何與 EMU 縮放在那裡有自己另一條可以弄錯的轉換鏈

兩把 HotXLS 欄尺對照:一把以 MDW 7 像素測量、一把以 8 像素測量,顯示每欄從 64 到 72 像素的跳動如何跨十欄累積,而合併區域與自動縮小填滿繼承了這個錯誤
因為 MDW 是相乘而非偏移,一個錯誤的測量就會移動每條欄邊界,合併區域與自動縮小填滿則在沒有任何東西擲出例外的情況下繼承漂移

HotXLS 如何在執行階段測量 MDW

HotXLS 從活頁簿本身求出 MDW,而不是假設它是常數,這件工作由兩個程序完成。PaginationApplyNormalFont 從活頁簿讀出 Normal 樣式字型,在分頁建置一開始、任何欄幾何算出之前執行;它會先重設回 Calibri 11,所以沒有字型表的活頁簿不可能從上一次建置繼承過時狀態。Normal 樣式字型就是 styles.xml 裡的 fonts[0],由元件以 Workbook.Fonts[0] 暴露

// 從工作表所屬活頁簿讀取 fonts[0](Normal 樣式字型)
// 沒有字型表的傳統工作表保留 Calibri 11 預設值
procedure PaginationApplyNormalFont(Worksheet: TObject);
var
  Sh: TXLSXWorksheet;
  Fnt: TXLSXFont;
begin
  PaginationNormalFontName := 'Calibri';
  PaginationNormalFontSize := 11;
  if not (Worksheet is TXLSXWorksheet) then
    Exit;
  Sh := TXLSXWorksheet(Worksheet);
  if (Sh.Workbook = nil) or (Sh.Workbook.Fonts.Count < 1) then
    Exit;
  Fnt := Sh.Workbook.Fonts[0];
  if Fnt.Name <> '' then
    PaginationNormalFontName := Fnt.Name;
  if Fnt.Size > 0 then
    PaginationNormalFontSize := Fnt.Size;
end;

第二個程序 PaginationMeasureMdW 在共用的離幕點陣圖畫布上,透過 GetTextExtentPoint32W 向 GDI 詢問單一字元 '0' 的範圍;範圍呼叫失敗時退回 GetTextMetricsWtmAveCharWidth,兩者都不可用時再退回 DefaultMDW。它的快取是一個以 (name, size) 為鍵的單一槽位,聽起來克難,但看一下存取模式就懂了:一次分頁建置對每一頁的每一個欄都詢問同一個 Normal 字型,所以單一槽位有近乎完美的命中率,每次呼叫只花三次比較

沒有字型表、沒有 GUI 或缺字型時會怎樣?

在真正的 Normal 字型無法判定的每一種情況下,HotXLS 都降級為 Calibri 11 常數,而且刻意靜默為之。傳統 BIFF 工作表是常見的一種:舊格式不帶可供 fonts[0] 參考的 XLSX 字型池,所以型別防護提早離開,預設 MDW 7 維持成立。這不是修正,而是刻意保留的舊行為,好讓把測量加進 XLSX 路徑這件事,不會讓傳統格式的輸出退化

GDI 相依性是誠實的注意事項。測量是對 Windows 裝置內容執行,所以這條路徑假設 Windows 主機上裝有該字型。在服務或無頭建置代理中,GDI 文字度量通常仍能求得,但未安裝在該機器上的字型會被字型對應器取代,您測到的就是替代品。它絕不大聲失敗;它會為錯的字體回傳一個看似合理的數字。如果伺服器端匯出必須與桌面基準一致,請在匯出主機上安裝範本指名的字型,或在呼叫工作表 PDF 匯出路徑前先釘住 Normal 字型

var
  Book: TXLSXWorkbook;
  Exporter: TXLSPDFExport;
begin
  Book := TXLSXWorkbook.Create;
  Exporter := TXLSPDFExport.Create;
  try
    Book.Open('quarterly-report.xlsx');

    // 釘住 Normal 字型,讓在此主機上測得的 MDW 就是版面設計時
    // 所依據的那個,而不是字型對應器的替代品
    if Book.Fonts.Count > 0 then
    begin
      Book.Fonts[0].Name := 'Calibri';
      Book.Fonts[0].Size := 11;
    end;

    Exporter.UseWorksheetPageSetup := True;
    Exporter.SaveAsPDF(Book, 'quarterly-report.pdf');
  finally
    Exporter.Free;
    Book.Free;
  end;
end;

測量快取,以及在 Win64 上崩潰的那一個

當文字測量變成一趟 GDI 往返而不是一次乘法,它就必須被快取,而在渲染行程內快取正是這件工程見血之處。自動縮小填滿的迴圈以 0.5 點為遞減步距逐級調降字型大小,每步之後重新測量,所以一個儲存格可能用同一字串呼叫 PaginationMeasureTextWidth 十幾次,自動換列又對每條候選行再呼叫一次。一個以字型名稱、大小與文字為鍵的備忘錄把這壓縮成「每個不同字串一趟 GDI 呼叫」,以名值對形式存放在 TStringList

伴隨它加入的另一個快取就沒這麼整潔。渲染行程 5 按 FontIndex 逐儲存格解析字型池,它的備忘錄使用平行動態陣列,搭配一個手動維護的 FontMemoCount。第一個版本忘記在每頁開始時呼叫 ResetFontMemo,於是計數跨頁持續攀升,陣列卻沒有,程式碼就寫超過了所有陣列的末端。在 Win32 上,它悄悄塗寫到相鄰堆積然後結束;在 Win64 上,它在寫入 0x538 時立即引發存取違規。可通則化的教訓:存放在單元層級變數裡、以陣列為後盾的快取,必須在每個使用它的行程入口重設,因為字串清單或字典會以增長來原諒忘記重設,平行陣列不會

HotXLS 如何解析活頁簿 Normal 字型、透過 GDI 以兩道後退機制測量其最大數字寬度並快取結果,旁邊是兩個渲染行程備忘錄,以及平行陣列快取所需的重設規則
MDW 從活頁簿解析、每種字型用 GDI 測量一次,再按鍵快取;渲染行程備忘錄則說明為何平行陣列快取必須在每個行程入口重設

檢查您自己的轉換

要驗證這一切,您不需要這個元件。拿一個 Normal 字型不是 Calibri 11 的活頁簿,從 <col width="..."/> 讀出一個寬度,把它代入規格公式兩次:一次用 MDW 7,一次用您的渲染器為該字型實際測得的 MDW;如果兩個答案不同,而您的輸出符合第一個,您就找到了漂移。欄幾何是試算表引擎中那種「要嘛無人察覺、要嘛是唯一所有人都注意到」的部分,把它做對意味著把 Normal 字型當成版面的輸入,而不是樣式細節。如果您建置的 Delphi 或 C++Builder 應用程式要在未安裝 Office 的情況下讀取、寫入、渲染與列印 Excel 活頁簿,HotXLS Delphi Excel 元件在同一組 VCL 類別之後處理 MDW 測量、分頁模型與 PDF 管線