Articol tehnic

Lățimea coloanelor Excel și Max Digit Width (MDW) în Delphi

PDF-ul exportat plasează fiecare limită de coloană cu jumătate de caracter în stânga locului unde Excel o desenează, iar fiecare celulă cu wrap se rupe acum într-un alt loc. Lățimea coloanelor Excel nu se măsoară în caractere sau puncte. Se măsoară în unități Max Digit Width (MDW) ale fontului Normal al registrului, iar HotXLS măsoară acel font cu GDI înainte de fiecare construire de paginare. Modul de eșec e tăcut: nimic nu aruncă excepție, lățimile stocate fac round-trip octet cu octet, iar geometria rămâne deviată cu câteva procente pe coloană până când deriva acumulată împinge un tabel de o pagină pe două

În ce unitate e lățimea coloanelor Excel?

O lățime de coloană într-o foaie de lucru este un număr de caractere-cifră ale fontului Normal al registrului, nu o măsurătoare absolută. ECMA-376 §18.3.1.13 definește atributul width al lui <col> în termenii Maximum Digit Width ai acelui font la 96 dpi și dă conversia dintr-o lățime stocată înapoi în pixeli ca o expresie cu trunchiere peste MDW. Pentru Calibri 11, care e ce livrează Excel ca stil Normal, MDW măsoară 7 pixeli. Treceți lățimea implicită de 8.43 unități prin formula specificației cu MDW 7 și obțineți exact 64 de pixeli, adică 48 de puncte la 96 dpi. Acestea sunt numerele pe care Excel însuși le raportează, deci constituie o verificare utilă: dacă conversia dumneavoastră reproduce 8.43 unități în 64 de pixeli, aritmetica e corectă și doar intrarea MDW mai poate fi greșită

const
  // Lățimea maximă a cifrei (MDW) a fontului implicit de corp în pixeli la 96 dpi.
  // Calibri 11 măsoară 7 px, ceea ce reproduce exact lățimile în pixeli
  // pe care Excel le stochează (8.43 unități -> 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; // pixeli 96 dpi -> puncte
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

HotXLS păstrează aritmetica asta într-o singură funcție, în unitatea lxPagination, astfel că există un singur loc unde rigla poate fi greșită. + 5 este padding-ul pe care Excel îl adaugă pentru gridlines și marginile celulelor, * 0.75 convertește pixelii de 96 dpi în puncte PostScript, iar plafonul de jos la MinimumColumnWidth există pentru ca o coloană patologic de îngustă să lase tot o bandă în care rendererul poate desena o bordură. Punctul de intrare public ColumnWidthToPoints își păstrează vechea semnătură cu un singur argument și trimite mai departe un MDW măsurat acestei funcții, ceea ce a permis schimbării de comportament să aterizeze fără să atingă vreun loc de apel

Lanțul de conversie a lățimii coloanelor HotXLS în Delphi, care alimentează formula specificației cu Max Digit Width măsurat al fontului Normal al registrului, astfel încât o lățime stocată de 8.43 unități devine 64 de pixeli și apoi 48 de puncte
Lățimea stocată e un număr de cifre, deci MDW-ul măsurat al fontului Normal e o intrare a formulei, nu un detaliu de stil, iar drumul dus-întors 8.43 la 64 la 48 verifică aritmetica

De ce un font Normal non-Calibri mută fiecare graniță

Deriva e multiplicativă, de aceea se citește ca un bug de randare, nu ca un bug de unități. MDW e un factor pe lățime, nu un offset. Împingeți MDW de la 7 la 8 și coloana implicită de 8.43 unități trece de la 64 de pixeli la 72, un salt de 8 pixeli sau 6 puncte pe o coloană. Zece coloane ca asta și marginea dreaptă a tabelului s-a mutat cu aproape un inch. Registrele care declanșează asta sunt perfect obișnuite: orice generat de o unealtă de rapoarte care ștampilează Arial sau Segoe UI în stilul Normal, orice salvat dintr-un șablon de export ERP, orice restilizat o dată de un client și apoi uitat

Două sisteme de layout înrudite moștenesc eroarea în loc să o provoace. Regiunile îmbinate însumează lățimile în puncte ale coloanelor membre, deci o îmbinare care încăpea pe o pagină în Excel poate depăși după deriva MDW, ceea ce merită reținut când construiți șabloane de rapoarte cu celule îmbinate. Shrink-to-fit compară lățimea măsurată a textului cu aceeași lățime de coloană, deci un MDW greșit schimbă și ce celule se comprimă și cu cât. Aceeași familie de confuzie de unități apare la ancorele de desen, unde geometria imaginilor și scalarea EMU are propriul lanț de conversie de greșit

Două rigle de coloane HotXLS comparate, una măsurată cu un MDW de 7 pixeli și una cu 8, arătând cum saltul de la 64 la 72 de pixeli pe coloană se acumulează peste zece coloane, în timp ce regiunile îmbinate și shrink-to-fit moștenesc eroarea
Pentru că MDW multiplică în loc să offsetuiască, o singură măsurătoare greșită mută fiecare limită de coloană, iar regiunile îmbinate și shrink-to-fit moștenesc deriva fără ca ceva să arunce excepție

Cum măsoară HotXLS MDW la rulare

HotXLS rezolvă MDW din registrul însuși în loc să presupună o constantă, iar două proceduri fac lucrarea. PaginationApplyNormalFont citește fontul stilului Normal de pe registru și rulează la începutul construirii paginării, înainte ca orice geometrie de coloană să fie calculată; se resetează întâi la Calibri 11, astfel încât un registru fără tabelă de fonturi nu poate moșteni stare învechită de la o construire anterioară. Fontul stilului Normal este fonts[0] în styles.xml, expus de componentă ca Workbook.Fonts[0]

// Citește fonts[0] (fontul stilului Normal) de pe registrul foii de lucru.
// Foile de lucru clasice fără tabelă de fonturi păstrează implicitul 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;

A doua procedură, PaginationMeasureMdW, cere GDI extensia singurului caracter '0' prin GetTextExtentPoint32W pe un canvas bitmap partajat în afara ecranului, revine la tmAveCharWidth din GetTextMetricsW când apelul de extensie eșuează, și revine la DefaultMDW când niciunul nu e disponibil. Cache-ul ei e un singur slot cheiat de (name, size), ceea ce sună primitiv până priviți tiparul de acces: o construire de paginare cere același font Normal pe fiecare coloană a fiecărei pagini, deci un slot are o rată de hit aproape perfectă și costă trei comparații per apel

Ce se întâmplă fără tabelă de fonturi, fără GUI sau cu un font lipsă?

HotXLS degradează la constanta Calibri 11 în fiecare caz în care fontul Normal real nu poate fi determinat, și face asta tăcut, prin design. Foile de lucru BIFF clasice sunt cazul comun: formatele legacy nu poartă niciun fond de fonturi XLSX la care să se refere fonts[0], deci garda de tip iese devreme și MDW-ul implicit de 7 rămâne în picioare. Nu e o remediere, e comportamentul anterior păstrat deliberat, astfel încât adăugarea măsurătorii pe calea XLSX să nu poată regresa ieșirea în format clasic

Dependența de GDI e avertismentul onest. Măsurătoarea rulează contra unui device context Windows, deci calea presupune o gazdă Windows cu fontul instalat. Într-un serviciu sau un agent de build headless, metricile de text GDI se rezolvă de regulă în continuare, dar un font care nu e instalat pe acea mașină e substituit de font mapper și dumneavoastră măsurați substitutul. Nu eșuează niciodată zgomotos; întoarce un număr plauzibil pentru typeface-ul greșit. Dacă exporturile server-side trebuie să se potrivească cu o referință desktop, instalați pe gazda de export fonturile pe care le numesc șabloanele dumneavoastră, sau fixați fontul Normal înainte de a invoca calea de export PDF a foii de lucru

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

    // Fixează fontul Normal astfel încât MDW-ul măsurat pe această gazdă să fie
    // cel pe care layoutul a fost proiectat, nu un substitut de 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;

Cache-uri de măsurătoare și cel care a crăpat pe Win64

Odată ce măsurătoarea textului e un drum dus-întors GDI în loc de o înmulțire, trebuie cache-uită, iar cache-uirea în interiorul unui pas de randare e locul unde lucrarea asta a scos sânge. Bucla shrink-to-fit coboară dimensiunea fontului în pași de 0.5 pt și re-măsoară după fiecare pas, deci o celulă poate apela PaginationMeasureTextWidth de o duzină de ori cu același șir, iar word wrap o apelează din nou per linie candidat. Un memo cheiat de nume de font, dimensiune și text comprimă asta la un apel GDI per șir distinct, stocat într-un TStringList ca perechi nume/valoare

Celălalt cache adăugat alături nu era la fel de îngrijit. Pasul de randare 5 rezolvă fondul de fonturi per celulă după FontIndex, iar memo-ul lui folosea tablouri dinamice paralele cu un FontMemoCount întreținut manual. Prima versiune uita să apeleze ResetFontMemo la începutul fiecărei pagini, astfel încât contorul continua să urce de la pagină la pagină în timp ce tablourile nu, iar codul scria dincolo de sfârșitul tuturor. Pe Win32 asta mâzgălea tăcut în heap-ul adiacent și termina; pe Win64 ridică o încălcare de acces la o scriere la 0x538 imediat. Lectura generalizabilă: un cache susținut de tablouri, ținut într-o variabilă la nivel de unitate, trebuie resetat la intrarea fiecărui pas care îl folosește, pentru că o listă de șiruri sau un dicționar iartă un reset lipsă crescând, iar tablourile paralele nu

Cum rezolvă HotXLS fontul Normal al registrului, îi măsoară Max Digit Width prin GDI cu două fallback-uri și cache-uiește rezultatul, alături de cele două memo-uri de pas de randare și regula de reset de care are nevoie un cache cu tablouri paralele
MDW e rezolvat din registru și măsurat cu GDI o dată per font, apoi cache-uit după cheie, în timp ce memo-urile de pas de randare arată de ce un cache cu tablouri paralele trebuie resetat la intrarea fiecărui pas

Verificarea propriei conversii

Nu aveți nevoie de componentă pentru a verifica vreuna dintre acestea. Luați un registru al cărui font Normal nu e Calibri 11, citiți o lățime din <col width="..."/> și treceți-o prin formula specificației de două ori, o dată cu MDW 7 și o dată cu MDW-ul pe care rendererul dumneavoastră îl măsoară efectiv pentru acel font; dacă răspunsurile diferă și ieșirea dumneavoastră se potrivește cu prima, ați găsit deriva. Geometria coloanelor e una dintre acele părți ale unui motor de foi de calcul care fie e invizibilă, fie e singurul lucru pe care toată lumea îl observă, iar a o face corect înseamnă a trata fontul Normal ca o intrare a layoutului, nu ca un detaliu de stil. Dacă construiți aplicații Delphi sau C++Builder care citesc, scriu, randează și tipăresc registre Excel fără Office instalat, componenta Delphi Excel HotXLS gestionează măsurătoarea MDW, modelul de paginare și pipeline-ul PDF în spatele unui singur set de clase VCL