Техническая статья

Ширина столбца Excel и Max Digit Width (MDW) в Delphi

Экспортированный PDF ставит каждую границу столбца на полсимвола левее того места, где её рисует Excel, и каждая ячейка с переносом теперь ломается в другом месте. Ширина столбца Excel не меряется ни в символах, ни в пунктах. Она меряется в единицах Max Digit Width (MDW) шрифта Normal книги, и HotXLS измеряет этот шрифт через GDI перед каждой сборкой пагинации. Режим отказа тихий: ничего не бросается, сохранённые ширины проходят туда-обратно байт в байт, а геометрия всё равно уходит на несколько процентов на столбец, пока накопленный дрейф не вытолкнет одностраничную таблицу на две

В каких единицах ширина столбца Excel?

Ширина столбца в листе — это счёт цифровых символов шрифта Normal книги, а не абсолютное измерение. ECMA-376 §18.3.1.13 определяет атрибут width элемента <col> через Maximum Digit Width этого шрифта при 96 dpi и даёт конверсию из сохранённой ширины обратно в пиксели как усекающее выражение над MDW. Для Calibri 11 — того, что Excel поставляет как стиль Normal, — MDW равен 7 пикселям. Пропустите ширину по умолчанию 8.43 единицы через формулу спецификации с MDW 7, и вы получите ровно 64 пикселя, то есть 48 пунктов при 96 dpi. Это числа, которые сообщает сам Excel, так что они полезная проверка: если ваша конверсия воспроизводит 8.43 единицы в 64 пикселя, арифметика верна, и ошибиться ещё может только вход MDW

const
  // Maximum digit width (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: измеренный Max Digit Width шрифта Normal книги подаётся в формулу спецификации, поэтому сохранённая ширина 8.43 единицы становится 64 пикселями, а затем 48 пунктами
Сохранённая ширина — это счёт цифр, поэтому измеренный MDW шрифта Normal — вход формулы, а не деталь стиля, а круг 8.43 → 64 → 48 проверяет арифметику

Почему шрифт Normal не из Calibri двигает каждую границу

Дрейф мультипликативен, поэтому он выглядит багом рендеринга, а не багом единиц. 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 пикселей на столбец накапливается через десять столбцов, пока объединённые области и shrink-to-fit наследуют ошибку
Поскольку MDW умножает, а не смещает, одно неверное измерение двигает каждую границу столбца, и объединённые области со shrink-to-fit наследуют дрейф, не бросая ни исключения

Как HotXLS измеряет MDW во время выполнения

HotXLS выводит MDW из самой книги, а не предполагает константу, и работу делают две процедуры. PaginationApplyNormalFont читает шрифт стиля Normal из книги и запускается в начале сборки пагинации, до вычисления какой-либо геометрии столбцов; сначала он сбрасывается на Calibri 11, поэтому книга без таблицы шрифтов не может унаследовать протухшее состояние от предыдущей сборки. Шрифт стиля Normal — это fonts[0] в styles.xml, выставленный компонентом как 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, спрашивает у GDI размер одного символа '0' через GetTextExtentPoint32W на общем закулисном bitmap-канвасе, падает к tmAveCharWidth из GetTextMetricsW, если вызов размера провалился, и к DefaultMDW, когда недоступно ни то, ни другое. Её кэш — один слот с ключом (name, size), что звучит грубо, пока вы не посмотрите на паттерн доступа: сборка пагинации спрашивает тот же шрифт Normal у каждого столбца каждой страницы, так что один слот имеет почти идеальный hit rate и стоит три сравнения за вызов

Что происходит без таблицы шрифтов, без GUI или без нужного шрифта?

HotXLS деградирует до константы Calibri 11 в каждом случае, когда реальный шрифт Normal определить нельзя, и делает это по дизайну молча. Классические листы BIFF — распространённый случай: устаревшие форматы не несут пула шрифтов XLSX, на который мог бы ссылаться fonts[0], поэтому type guard выходит рано и умолчательный MDW 7 стоит. Это не фикс, это прежнее поведение, сохранённое намеренно, чтобы добавление измерения в XLSX-путь не могло регрессировать вывод классического формата

Зависимость от GDI — честная оговорка. Измерение идёт против контекста устройства Windows, поэтому путь предполагает хост Windows с установленным шрифтом. В сервисе или headless-агенте сборки метрики текста GDI обычно всё ещё разрешаются, но шрифт, не установленный на той машине, подменяется font mapper'ом, и вы измеряете подмену. Это никогда не проваливается громко; оно возвращает правдоподобное число для не той гарнитуры. Если серверные экспорты обязаны совпадать с настольным эталоном, установите шрифты, названные в ваших шаблонах, на экспортный хост или зафиксируйте шрифт Normal перед вызовом пути PDF-экспорта листа

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

    // Зафиксируйте шрифт Normal, чтобы MDW, измеренный на этом хосте, был тем,
    // под который проектировалась раскладка, а не подменой font mapper'а.
    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, а не умножением, его приходится кэшировать, и кэширование внутри прохода рендера — вот где эта работа пустила кровь. Цикл shrink-to-fit ступенчато снижает размер шрифта приращениями 0.5 pt и перемеряет после каждого шага, поэтому одна ячейка может вызвать PaginationMeasureTextWidth дюжину раз с той же строкой, а перенос слов зовёт его снова на каждую строку-кандидата. Мемо с ключом по имени шрифта, размеру и тексту схлопывает это в один вызов GDI на каждую различную строку, хранящийся в TStringList как пары имя/значение

Второй кэш, добавленный рядом, вышел не таким аккуратным. Проход рендера 5 разрешает пул шрифтов по каждой ячейке через FontIndex, и его мемо использовало параллельные динамические массивы с ручным FontMemoCount. Первая версия забыла вызвать ResetFontMemo в начале каждой страницы, поэтому счётчик продолжал расти по страницам, а массивы — нет, и код писал за концом всех них. На Win32 это тихо черкало в соседнюю кучу и заканчивало; на Win64 оно немедленно подняло access violation на записи в 0x538. Обобщаемый урок: кэш на массивах, живущий в переменной уровня модуля, обязан сбрасываться на входе каждого прохода, который его использует, потому что string list или словарь прощает пропущенный сброс ростом, а параллельные массивы — нет

Как HotXLS разрешает шрифт Normal книги, измеряет его Max Digit Width через GDI с двумя запасными вариантами и кэширует результат — рядом два мемо проходов рендера и правило сброса, которого требует кэш на параллельных массивах
MDW выводится из книги и измеряется через GDI один раз на шрифт, затем кэшируется по ключу, а мемо проходов рендера показывают, почему кэш на параллельных массивах обязан сбрасываться на входе каждого прохода

Проверка собственной конверсии

Вам не нужен компонент, чтобы проверить всё это. Возьмите книгу, чей шрифт Normal — не Calibri 11, прочтите ширину из <col width="..."/> и прогоните её через формулу спецификации дважды: один раз с MDW 7 и один раз с MDW, который ваш рендерер реально измеряет для того шрифта; если ответы различаются и ваш вывод совпадает с первым, вы нашли дрейф. Геометрия столбцов — из тех частей табличного движка, что либо невидимы, либо единственное, что кто-либо замечает, и сделать её правильно — значит считать шрифт Normal входом раскладки, а не деталью стиля. Если вы строите приложения Delphi или C++Builder, которые читают, пишут, рендерят и печатают книги Excel без установленного Office, HotXLS Delphi Excel component берёт на себя измерение MDW, модель пагинации и PDF-конвейер за одним набором VCL-классов