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

Ширина на колони в 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
  // Максимална ширина на цифра (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 проверява аритметиката

Защо различен от Calibri Normal шрифт мести всяка граница

Отклонението е мултипликативно и точно затова изглежда като бъг в рендирането, а не като бъг в мерните единици. 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 шрифт за всяка колона на всяка страница, така че един слот има почти перфектен процент попадения и струва три сравнения на извикване

Какво става без таблица с шрифтове, без GUI или с липсващ шрифт?

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

Зависимостта от GDI е честната уговорка. Измерването се изпълнява спрямо контекст на устройство на Windows, така че този път предполага Windows хост с инсталиран шрифт. В услуга или в безглав 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 да е
    // този, за който е проектирано оформлението, а не заместител.
    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. Обобщимата поука: кеш, изграден върху масиви и държан в променлива на ниво модул, трябва да се нулира при входа на всеки проход, който го използва, защото списък от низове или речник прощава липсващото нулиране, като расте, а паралелните масиви не

Как 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 класове