Teknisk artikel

Excel-kolumnbredd och Max Digit Width (MDW) i Delphi

Den exporterade PDF:en placerar varje kolumngräns ett halvt tecken till vänster om där Excel ritar den, och varje radbruten cell bryter nu på en annan plats. Excel-kolumnbredd mäts inte i tecken eller punkter. Den mäts i Max Digit Width-enheter (MDW) för arbetsbokens Normal-teckensnitt, och HotXLS mäter det teckensnittet med GDI före varje pagineringsbygge. Felläget är tyst: inget kastar, de lagrade bredderna round-trippar byte för byte, och geometrin är fortfarande fel med några procent per kolumn tills den ackumulerade driften trycker en en-sidastabell över på två

Vilken enhet mäts Excel-kolumnbredd i?

En kolumnbredd i ett kalkylark är ett antal siffertecken i arbetsbokens Normal-teckensnitt, inte ett absolut mått. ECMA-376 §18.3.1.13 definierar attributet width<col> i termer av Maximum Digit Width för det teckensnittet vid 96 dpi, och ger konverteringen från en lagrad bredd tillbaka till pixlar som ett trunkerande uttryck över MDW. För Calibri 11, vilket är vad Excel skeppar som Normal-stilen, mäter MDW 7 pixlar. Mata standardbredden 8,43 enheter genom specifikationsformeln med MDW 7 och du får exakt 64 pixlar, vilket är 48 punkter vid 96 dpi. Det är de tal Excel självt rapporterar, så de utgör en användbar kontroll: om din konvertering reproducerar 8,43 enheter till 64 pixlar är aritmetiken rätt och bara MDW-indatan kan fortfarande vara fel

const
  // Maximum digit width (MDW) för standardbrödtextteckensnittet i pixlar vid 96 dpi.
  // Calibri 11 mäter 7 px, vilket reproducerar de exakta pixelbredder
  // Excel lagrar (8,43 enheter -> 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-pixlar -> punkter
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

HotXLS håller den aritmetiken i exakt en funktion, i enheten lxPagination, så det finns en enda plats där linjalen kan vara fel. + 5 är den padding Excel lägger till för rutnätslinjer och cellmarginaler, * 0.75 konverterar 96 dpi-pixlar till PostScript-punkter, och golvet vid MinimumColumnWidth finns så att en patologiskt smal kolumn fortfarande lämnar en remsa som renderaren kan rita en ram i. Den publika ingångspunkten ColumnWidthToPoints behåller sin gamla signatur med ett argument och vidarebefordrar en uppmätt MDW till denna funktion, vilket är vad som lät beteendeändringen landa utan att röra en enda anropsplats

HotXLS-kolumnbreddskonverteringskedjan i Delphi, som matar den uppmätta Max Digit Width för arbetsbokens Normal-teckensnitt in i specifikationsformeln så att en lagrad bredd på 8,43 enheter blir 64 pixlar och sedan 48 punkter
Den lagrade bredden är ett sifferantal, så den uppmätta MDW för Normal-teckensnittet är en indata till formeln snarare än en stildetalj, och round tripen 8,43 till 64 till 48 kontrollerar aritmetiken

Varför ett icke-Calibri Normal-teckensnitt flyttar varje gräns

Driften är multiplikativ, vilket är därför den läses som en renderingsbugg snarare än en enhetsbugg. MDW är en faktor på bredden, inte en offset. Tryck MDW från 7 till 8 och standardkolumnen på 8,43 enheter går från 64 pixlar till 72, ett hopp på 8 pixlar eller 6 punkter på en kolumn. Tio kolumner av det och tabellens högra kant har flyttat större delen av en tum. Arbetsböcker som snubblar på detta är helt ordinära: allt genererat av ett rapportverktyg som stämplat Arial eller Segoe UI in i Normal-stilen, allt sparat ur en ERP-exportmall, allt en kund stylade om en gång och glömde bort

Två besläktade layoutsystem ärver felet i stället för att orsaka det. Sammanfogade regioner summerar punktbredderna för sina medlemskolumner, så en sammanfogning som fick plats på en sida i Excel kan svämma över efter MDW-drift, vilket är värt att komma ihåg när du bygger rapportmallar med sammanfogade celler. Shrink-to-fit jämför uppmätt textbredd mot samma kolumnbredd, så fel MDW ändrar också vilka celler som krymper och hur mycket. Samma familj av enhetsförvirring dyker upp i ritankare, där bildgeometri och EMU-skalning har sin egen konverteringskedja att få fel

Två HotXLS-kolumnlinjaler jämförda, en mätt med en MDW på 7 pixlar och en med 8, som visar hur hoppet per kolumn från 64 till 72 pixlar ackumuleras över tio kolumner medan sammanfogade regioner och shrink-to-fit ärver felet
Eftersom MDW multiplicerar snarare än förskjuter flyttar en felaktig mätning varje kolumngräns, och sammanfogade regioner och shrink-to-fit ärver driften utan att något kastas

Hur HotXLS mäter MDW vid körning

HotXLS löser MDW från själva arbetsboken i stället för att anta en konstant, och två procedurer gör arbetet. PaginationApplyNormalFont läser Normal-stilens teckensnitt från arbetsboken och körs högst upp i pagineringsbygget, innan någon kolumngeometri beräknas; den återställer till Calibri 11 först, så en arbetsbok utan teckensnittstabell inte kan ärva inaktuellt tillstånd från ett tidigare bygge. Normal-stilens teckensnitt är fonts[0] i styles.xml, exponerat av komponenten som Workbook.Fonts[0]

// Läser fonts[0] (Normal-stilens teckensnitt) från kalkylarkets arbetsbok.
// Klassiska kalkylark utan teckensnittstabell behåller Calibri 11-default.
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;

Den andra proceduren, PaginationMeasureMdW, frågar GDI om extenten för det enda tecknet '0' via GetTextExtentPoint32W på en delad bitmapskanvas utanför skärmen, faller tillbaka på tmAveCharWidth från GetTextMetricsW när extentanropet misslyckas, och faller tillbaka på DefaultMDW när inget av dem är tillgängligt. Dess cache är en enda slot nycklad på (name, size), vilket låter grovt tills man tittar på åtkomstmönstret: ett pagineringsbygge frågar efter samma Normal-teckensnitt på varje kolumn på varje sida, så en slot har en nästan perfekt träffrekvens och kostar tre jämförelser per anrop

Vad händer utan teckensnittstabell, utan GUI eller med ett saknat teckensnitt?

HotXLS degraderar till Calibri 11-konstanten i varje fall där det verkliga Normal-teckensnittet inte kan fastställas, och den gör det tyst by design. Klassiska BIFF-kalkylark är det vanliga fallet: de äldre formaten bär ingen XLSX-teckensnittspool som fonts[0] kan referera till, så typvakten avslutar tidigt och default-MDW:n på 7 står kvar. Det är inte en fix, det är det tidigare beteendet bevarat avsiktligt, så att att lägga till mätning på XLSX-vägen inte kunde regressera utdata för klassiskt format

GDI-beroendet är den ärliga brasklappen. Mätningen körs mot en Windows-enhetskontext, så vägen förutsätter en Windows-värd med teckensnittet installerat. I en tjänst eller en huvudlös byggagent löser GDI-textmått i allmänhet fortfarande, men ett teckensnitt som inte är installerat på den maskinen ersätts av fontmapparen och du mäter ersättningen i stället. Det misslyckas aldrig högljutt; det returnerar ett trovärdigt tal för fel typsnitt. Om export på serversidan måste matcha en skrivbordsreferens, installera de teckensnitt dina mallar namnger på exportvärden, eller fäst Normal-teckensnittet innan du anropar PDF-exportvägen för kalkylark

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

    // Fäst Normal-teckensnittet så att MDW:n som mäts på den här värden är den
    // layouten designades mot, inte en fontmapperersättning.
    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;

Mätcachar, och den som kraschade på Win64

När textmätning väl är en GDI-roundtrip snarare än en multiplikation måste den cachas, och cachning inuti ett renderingspass är där detta arbete drog blod. Shrink-to-fit-loopen stegar teckenstorleken ned i steg om 0,5 pt och mäter om efter varje steg, så en cell kan anropa PaginationMeasureTextWidth ett dussin gånger med samma sträng, och radbrytning anropar den igen per kandidatrad. En memo nycklad på teckensnittsnamn, storlek och text kollapsar det till ett GDI-anrop per distinkt sträng, lagrad i en TStringList som namn/värde-par

Den andra cachen som lades till bredvid den var inte lika snygg. Renderingspass 5 löser teckensnittspoolen per cell via FontIndex, och dess memo använde parallella dynamiska arrayer med en handunderhållen FontMemoCount. Den första versionen glömde anropa ResetFontMemo i början av varje sida, så räkningen fortsatte klättra över sidor medan arrayerna inte gjorde det, och koden skrev förbi slutet på dem alla. På Win32 kluddade det tyst in i intilliggande heap och avslutade; på Win64 kastade det ett access violation på en skrivning till 0x538 omedelbart. Den generaliserbara läxan: en arraybackad cache som hålls i en variabel på enhetsnivå måste återställas vid ingången av varje pass som använder den, eftersom en stränglista eller ordlista förlåter en missad återställning genom att växa, och parallella arrayer gör det inte

Hur HotXLS löser arbetsbokens Normal-teckensnitt, mäter dess Max Digit Width via GDI med två fallbacks och cachar resultatet, bredvid de två renderingspass-memona och återställningsregeln som en parallell-arraycache behöver
MDW löses från arbetsboken och mäts med GDI en gång per teckensnitt, sedan cachas den per nyckel, medan renderingspass-memona visar varför en parallell-arraycache måste återställas vid ingången av varje pass

Kontrollera din egen konvertering

Du behöver inte komponenten för att verifiera något av detta. Ta en arbetsbok vars Normal-teckensnitt inte är Calibri 11, läs en bredd från <col width="..."/>, och kör den genom specifikationsformeln två gånger, en gång med MDW 7 och en gång med den MDW din renderare faktiskt mäter för det teckensnittet; om svaren skiljer sig åt och din utdata matchar det första har du hittat driften. Kolumngeometri är en av de delar av en kalkylarksmotor som antingen är osynlig eller det enda någon märker, och att få den rätt betyder att behandla Normal-teckensnittet som en indata till layout snarare än en stildetalj. Om du bygger Delphi- eller C++Builder-applikationer som läser, skriver, renderar och skriver ut Excel-arbetsböcker utan Office installerat hanterar HotXLS Delphi Excel-komponent MDW-mätningen, pagineringsmodellen och PDF-pipelinen bakom en uppsättning VCL-klasser