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