技術記事

DelphiでExcel列幅とMax Digit Widthを正しく扱う

エクスポートされたPDFでは各列の境界がExcelの描画位置より半文字左にずれ、折り返しセルはみんな別の場所で折れるようになります。Excelの列幅は文字数でもポイントでもなく、ブックのNormalフォントのMax Digit Width(MDW)単位で測られます。HotXLSはページネーション構築の前に、そのフォントをGDIで実測します。失敗モードは静かです。何も例外を投げず、格納された幅はバイト単位で往復し、それでも幾何は列ごとに数パーセントずれ続け、蓄積したドリフトが1ページの表を2ページへ押しやるまで気づかれません

Excelの列幅は何単位か

ワークシートの列幅は、ブックNormalフォントの数字文字の個数であり、絶対測定ではありません。ECMA-376 §18.3.1.13は<col>width属性を、96 dpiにおけるそのフォントのMaximum Digit Widthで定義し、格納幅からピクセルへの逆変換をMDW上の切り捨て式として与えます。ExcelがNormalスタイルとして出荷するCalibri 11では、MDWは7ピクセルです。既定幅の8.43単位をMDW 7で仕様の式に通すと、ちょうど64ピクセル、96 dpiで48ポイントが出ます。Excel自身が報告する数値そのものなので、有用な検算になります。変換が8.43単位を64ピクセルに再現するなら算術は正しく、まだ間違い得るのはMDW入力だけです

const
  // 96 dpi における既定本文フォントの最大数字幅(MDW)。ピクセル単位
  // 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ユニット内のちょうど1つの関数に保ちます。物差しが間違い得る場所が1か所だけあるということです。+ 5はExcelが罫線とセル余白のために足すパディング、* 0.75は96 dpiピクセルからPostScriptポイントへの変換、MinimumColumnWidthの下限は病的に狭い列でも描画器が罫線を引ける帯を残すためのものです。公開エントリポイントColumnWidthToPointsは旧来の単一引数シグネチャを保ち、実測したMDWをこの関数へフォワードします。だから挙動の変更が1つの呼び出し箇所にも触れずに着地できたのです

DelphiにおけるHotXLSの列幅変換チェーン。ブックNormalフォントの実測Max Digit Widthを仕様の式に通し、格納幅8.43単位が64ピクセル、さらに48ポイントになる
格納幅は数字の個数なので、Normalフォントの実測MDWはスタイルの詳細ではなく式への入力です。8.43から64、48への往復が算術を検算します

Calibri以外のNormalフォントがすべての境界を動かす理由

ドリフトは乗算的です。だからユニットのバグではなく描画バグに見えます。MDWは幅への係数でありオフセットではありません。MDWを7から8へ動かすと、既定の8.43単位列は64ピクセルから72ピクセルへ、1列で8ピクセル、6ポイントの跳躍です。これが10列続けば表の右端は1インチ近く動きます。これを踏むブックはごく普通に存在します。ArialやSegoe UIをNormalスタイルに押し込むレポートツールが生成したもの、ERPのエクスポートテンプレートから保存されたもの、顧客が一度だけ装いを変えて忘れたもの

関連する2つのレイアウトシステムはエラーを引き起こすのではなく引き継ぎます。結合領域はメンバー列のポイント幅を合計するので、Excelで1ページに収まった結合がMDWドリフトの後であふれます。結合セルのレポートテンプレートを組むとき覚えておく価値があります。縮小して合わせる(shrink-to-fit)は実測テキスト幅を同じ列幅と比較するので、間違ったMDWはどのセルがどれだけ縮むかも変えます。同じ族の単位混乱は描画アンカーにも現れ、画像ジオメトリとEMUスケーリングには独自の変換チェーンと間違い方があります

MDW 7ピクセルで実測したHotXLSの列物差しと8ピクセルの物差しの比較。列ごとの64から72ピクセルへの跳躍が10列にわたって蓄積し、結合領域とshrink-to-fitがエラーを引き継ぐようす
MDWはオフセットではなく乗算なので、1つの間違った測定がすべての列境界を動かします。結合領域とshrink-to-fitは何も投げずにドリフトを引き継ぎます

HotXLSが実行時にMDWを測る方法

HotXLSは定数を仮定するのではなくブック自身からMDWを解決し、2つのプロシージャがその仕事をします。PaginationApplyNormalFontはブックからNormalスタイルのフォントを読み、列の幾何が1つも計算される前にページネーション構築の先頭で走ります。まずCalibri 11へリセットするので、フォントテーブルのないブックが前回構築の古い状態を引き継ぐことはありません。Normalスタイルのフォントはstyles.xmlfonts[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;

2番目のプロシージャPaginationMeasureMdWは、共有オフスクリーンのビットマップキャンバス上でGetTextExtentPoint32Wを通じて1文字'0'のエクステントをGDIに問い、エクステント呼び出しが失敗すればGetTextMetricsWtmAveCharWidthへフォールバックし、どちらも使えなければDefaultMDWへフォールバックします。キャッシュは(name, size)をキーとする単一スロットです。粗く聞こえますがアクセスパターンを見てください。ページネーション構築は全ページの全列で同じNormalフォントを要求するので、1スロットでほぼ完全なヒット率になり、呼び出しごとのコストは比較3回です

フォントテーブルなし、GUIなし、フォント欠落では何が起こるか

HotXLSは実際のNormalフォントを決定できないすべてのケースで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でクラッシュした1つ

テキスト測定が乗算ではなくGDIの往復になった瞬間、キャッシュが必要になり、レンダーパス内のキャッシュこそこの仕事が血を吸った場所です。shrink-to-fitのループはフォントサイズを0.5 pt刻みで下げては再測定するので、1つのセルが同じ文字列でPaginationMeasureTextWidthを十数回呼び得ます。ワードラップは候補行ごとにさらに呼びます。フォント名、サイズ、テキストをキーとするメモは、これを文字列ごと1回のGDI呼び出しへ畳み、TStringListに名前と値のペアとして格納されます

それと並行して追加されたもう1つのキャッシュは整然としていませんでした。レンダーパス5はセルごとにFontIndexでフォントプールを解決し、そのメモは手管理のFontMemoCount付きの並列動的配列を使っていました。初版は各ページの先頭でResetFontMemoを呼び忘れ、カウントはページをまたいで登り続ける一方、配列はそうせず、コードは全配列の末尾を越えて書きました。Win32では隣接ヒープへ静かに塗りつぶして終わり、Win64では書き込みの瞬間に0x538へのアクセス違反を投げました。一般化できる教訓はこうです。ユニットレベル変数に保持された配列バックのキャッシュは、それを使うすべてのパスの入口でリセットしなければならない。文字列リストや辞書はリセット忘れを成長で許しますが、並列配列は許しません

HotXLSがブックNormalフォントを解決し、GDIで2段階のフォールバック付きでMax Digit Widthを実測してキーでキャッシュする流れと、2つのレンダーパスメモ、並列配列キャッシュが必要とするリセット規則
MDWはブックから解決され、フォントごとにGDIで一度実測され、キーでキャッシュされます。レンダーパスのメモは、並列配列キャッシュがすべてのパスの入口でリセットを必要とする理由を示します

自分の変換を検査する

このどれも検証するのにコンポーネントは要りません。NormalフォントがCalibri 11でないブックを1つ取り、<col width="..."/>から幅を1つ読み、仕様の式を2回通します。MDW 7で1回、レンダラがそのフォントに対して実際に測るMDWで1回。答えが異なり、出力が前者に一致するなら、ドリフトを見つけたということです。列の幾何はスプレッドシートエンジンの構成要素のうち、不可視であるか、誰の目にも留まる唯一のものであるかのどちらかです。正しく扱うということは、Normalフォントをスタイルの詳細ではなくレイアウトへの入力として扱うことです。OfficeをインストールせずにExcelブックの読み書き、描画、印刷を行うDelphiやC++Builderのアプリケーションを構築しているなら、HotXLS Delphi Excel componentがMDW測定、ページネーションモデル、PDFパイプラインを1組のVCLクラスの背後で処理します