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