技术文章

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 属性,并给出了从存储宽度换算回像素的截断表达式。以 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 的往返用于校验算术

为什么 Normal 字体不是 Calibri 时所有边界都会移动

漂移是乘法性的,这正是它读起来像渲染 bug 而不是单位 bug 的原因。MDW 是宽度的乘数,不是偏移量。把 MDW 从 7 推到 8,默认 8.43 单位的列就从 64 像素变成 72,单列跳了 8 像素、即 6 磅。十列累积下来,表格右缘挪了近一英寸。踩中这个问题的工作簿普通得不能再普通:凡是报表工具把 Arial 或 Segoe UI 写进 Normal 样式生成的文件,凡是 ERP 导出模板存出来的文件,凡是客户改过一回样式又忘了的文件

两个相关的布局系统继承这个误差,而不是制造误差。合并区域把成员列的磅宽相加,所以在 Excel 里一页放得下的合并区域在 MDW 漂移后可能溢出,构建合并单元格报表模板时值得记住这一点。缩小字体填充把实测文本宽度与同一份列宽比较,所以错误的 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 工作表是常见情形:旧格式没有 XLSX 字体池供 fonts[0] 引用,类型守卫提前退出,默认 MDW 7 维持不变。这不是修复,而是有意保留的旧行为,给 XLSX 路径加测量绝不能回归经典格式的输出

GDI 依赖是必须坦白的一点。测量对着 Windows 设备上下文进行,所以这条路径假设宿主是装了该字体的 Windows。在 Service 或无头构建代理上,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 组件把 MDW 测量、分页模型和 PDF 管线收在一组 VCL 类背后