Технічна стаття

Ширина стовпця 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 pixels -> points
  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 на спільному позаекранному бітовому полотні, повертається до tmAveCharWidth з GetTextMetricsW, коли виклик розміру зазнає невдачі, і до DefaultMDW, коли недоступне ні те, ні інше. Її кеш — один слот із ключем (name, size), що звучить грубо, доки не глянеш на шаблон доступу: побудова пагінації питає той самий шрифт Normal для кожного стовпця кожної сторінки, тож один слот має майже ідеальний коефіцієнт влучань і коштує три порівняння за виклик

Що станеться без таблиці шрифтів, без GUI чи з відсутнім шрифтом?

HotXLS деградує до сталої Calibri 11 в усіх випадках, коли справжній шрифт Normal визначити не можна, і робить це мовчки, навмисно. Класичні аркуші BIFF — найпоширеніший випадок: застарілі формати не несуть пулу шрифтів XLSX, на який міг би вказувати fonts[0], тож перевірка типу виходить рано і типовий MDW 7 залишається в силі. Це не виправлення — це свідомо збережена попередня поведінка, щоб додавання вимірювання до шляху XLSX не могло регресувати вихід класичного формату

Залежність від GDI — чесне застереження. Вимірювання виконується щодо контексту пристрою Windows, тож шлях припускає хост Windows із встановленим шрифтом. У сервісі чи headless build-агенті метрики тексту 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 це негайно кидало порушення доступу на записі до 0x538. Узагальнений урок: кеш на масивах, що тримається в юніт-рівневій змінній, мусить скидатися на вході кожного проходу, який його використовує, бо список рядків чи словник пробачає пропущений скид зростанням, а паралельні масиви — ні

Як 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 бере на себе вимірювання MDW, модель пагінації та PDF-конвеєр за одним набором VCL-класів