Τεχνικό Άρθρο

Πλάτος στήλης Excel και Max Digit Width (MDW) σε Delphi

Το εξαγόμενο PDF βάζει κάθε όριο στήλης μισό χαρακτήρα αριστερά από εκεί που το σχεδιάζει το Excel, και κάθε αναδιπλούμενο κελί σπάει τώρα σε διαφορετικό σημείο. Το πλάτος στήλης του Excel δεν μετριέται σε χαρακτήρες ούτε σε points. Μετριέται σε μονάδες Max Digit Width (MDW) της γραμματοσειράς Normal του βιβλίου εργασίας, και το HotXLS μετρά αυτή τη γραμματοσειρά με GDI πριν από κάθε χτίσιμο σελιδοποίησης. Ο τρόπος αποτυχίας είναι σιωπηλός: τίποτα δεν εγείρει εξαίρεση, τα αποθηκευμένα πλάτη κάνουν round-trip byte προς byte, και η γεωμετρία εξακολουθεί να αστοχεί κατά λίγα τοις εκατό ανά στήλη, μέχρι η συσσωρευμένη απόκλιση να σπρώξει έναν πίνακα μίας σελίδας σε δύο

Σε ποια μονάδα μετριέται το πλάτος στήλης του Excel;

Το πλάτος μιας στήλης σε ένα φύλλο εργασίας είναι ένα πλήθος χαρακτήρων ψηφίου της γραμματοσειράς Normal του βιβλίου εργασίας, όχι απόλυτη μέτρηση. Το ECMA-376 §18.3.1.13 ορίζει το attribute width του <col> ως προς το Maximum Digit Width εκείνης της γραμματοσειράς στα 96 dpi, και δίνει τη μετατροπή από ένα αποθηκευμένο πλάτος πίσω σε pixels ως μια έκφραση με περικοπή πάνω στο MDW. Για το Calibri 11, που είναι αυτό που στέλνει το Excel ως στυλ Normal, το MDW μετρά 7 pixels. Περάστε το προεπιλεγμένο πλάτος των 8.43 units μέσα από τον τύπο της προδιαγραφής με MDW 7 και παίρνετε ακριβώς 64 pixels, δηλαδή 48 points στα 96 dpi. Αυτοί είναι οι αριθμοί που αναφέρει το ίδιο το Excel, οπότε αποτελούν χρήσιμο έλεγχο: αν η μετατροπή σας αναπαράγει τα 8.43 units σε 64 pixels, η αριθμητική είναι σωστή και μόνο η είσοδος MDW μπορεί ακόμη να είναι λάθος

const
  // Μέγιστο πλάτος ψηφίου (MDW) της προεπιλεγμένης γραμματοσειράς κειμένου σε pixels στα 96 dpi.
  // Το Calibri 11 μετρά 7 px, που αναπαράγει τα ακριβή πλάτη σε pixels
  // που αποθηκεύει το Excel (8.43 units -> 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; // pixels 96 dpi -> points
  if Result < MinimumColumnWidth then
    Result := MinimumColumnWidth;
end;

Το HotXLS κρατά αυτή την αριθμητική σε ακριβώς μία συνάρτηση, μέσα στη μονάδα lxPagination, ώστε να υπάρχει ένα μοναδικό σημείο όπου ο χάρακας μπορεί να είναι λάθος. Το + 5 είναι το padding που προσθέτει το Excel για τις γραμμές πλέγματος και τα περιθώρια των κελιών, το * 0.75 μετατρέπει τα pixels των 96 dpi σε PostScript points, και το κατώφλι στο MinimumColumnWidth υπάρχει ώστε μια παθολογικά στενή στήλη να αφήνει ακόμη μια λωρίδα όπου ο renderer μπορεί να σχεδιάσει περίγραμμα. Το δημόσιο σημείο εισόδου ColumnWidthToPoints κρατά την παλιά υπογραφή του ενός ορίσματος και προωθεί ένα μετρημένο MDW σε αυτή τη συνάρτηση, κάτι που επέτρεψε στην αλλαγή συμπεριφοράς να προσγειωθεί χωρίς να αγγίξει ούτε ένα σημείο κλήσης

Η αλυσίδα μετατροπής πλάτους στήλης του HotXLS σε Delphi, που τροφοδοτεί το μετρημένο Max Digit Width της γραμματοσειράς Normal του βιβλίου εργασίας στον τύπο της προδιαγραφής ώστε ένα αποθηκευμένο πλάτος 8.43 units να γίνεται 64 pixels και μετά 48 points
Το αποθηκευμένο πλάτος είναι πλήθος ψηφίων, οπότε το μετρημένο MDW της γραμματοσειράς Normal είναι είσοδος στον τύπο και όχι λεπτομέρεια στυλ, και η διαδρομή 8.43 σε 64 σε 48 ελέγχει την αριθμητική

Γιατί μια γραμματοσειρά Normal εκτός Calibri μετακινεί κάθε όριο

Η απόκλιση είναι πολλαπλασιαστική, και γι αυτό διαβάζεται ως σφάλμα απόδοσης παρά ως σφάλμα μονάδων. Το MDW είναι συντελεστής πάνω στο πλάτος, όχι μετατόπιση. Ανεβάστε το MDW από 7 σε 8 και η προεπιλεγμένη στήλη των 8.43 units πάει από 64 pixels σε 72, ένα άλμα 8 pixels ή 6 points σε μία μόνο στήλη. Δέκα στήλες με αυτό και η δεξιά άκρη του πίνακα έχει μετακινηθεί σχεδόν μία ίντσα. Τα βιβλία εργασίας που σκοντάφτουν εδώ είναι απολύτως συνηθισμένα: οτιδήποτε παράγει ένα εργαλείο αναφορών που σφραγίζει Arial ή Segoe UI στο στυλ Normal, οτιδήποτε αποθηκεύεται από πρότυπο εξαγωγής ERP, οτιδήποτε ένας πελάτης ξαναστύλισε κάποτε και το ξέχασε

Δύο συγγενικά συστήματα διάταξης κληρονομούν το σφάλμα αντί να το προκαλούν. Οι συγχωνευμένες περιοχές αθροίζουν τα πλάτη σε points των στηλών που τις απαρτίζουν, οπότε μια συγχώνευση που χωρούσε σε μία σελίδα στο Excel μπορεί να ξεχειλίσει μετά από απόκλιση MDW, κάτι που αξίζει να θυμάστε όταν φτιάχνετε πρότυπα αναφορών με συγχωνευμένα κελιά. Το shrink-to-fit συγκρίνει το μετρημένο πλάτος κειμένου με το ίδιο πλάτος στήλης, οπότε το λανθασμένο MDW αλλάζει επίσης ποια κελιά συρρικνώνονται και κατά πόσο. Η ίδια οικογένεια σύγχυσης μονάδων εμφανίζεται στις άγκυρες σχεδίων, όπου η γεωμετρία εικόνων και η κλιμάκωση EMU έχει τη δική της αλυσίδα μετατροπής για να πάει στραβά

Δύο χάρακες στηλών του HotXLS σε σύγκριση, ένας μετρημένος με MDW 7 pixels και ένας με 8, που δείχνουν πώς το άλμα ανά στήλη από 64 σε 72 pixels συσσωρεύεται σε δέκα στήλες ενώ οι συγχωνευμένες περιοχές και το shrink-to-fit κληρονομούν το σφάλμα
Επειδή το MDW πολλαπλασιάζει αντί να μετατοπίζει, μία λανθασμένη μέτρηση μετακινεί κάθε όριο στήλης, και οι συγχωνευμένες περιοχές και το shrink-to-fit κληρονομούν την απόκλιση χωρίς τίποτα να εγείρει εξαίρεση

Πώς μετρά το HotXLS το MDW κατά την εκτέλεση

Το HotXLS επιλύει το MDW από το ίδιο το βιβλίο εργασίας αντί να υποθέτει μια σταθερά, και δύο procedures κάνουν τη δουλειά. Το PaginationApplyNormalFont διαβάζει τη γραμματοσειρά του στυλ Normal από το βιβλίο εργασίας και τρέχει στην αρχή του χτισίματος σελιδοποίησης, πριν υπολογιστεί οποιαδήποτε γεωμετρία στηλών· επαναφέρει πρώτα σε Calibri 11, ώστε ένα βιβλίο εργασίας χωρίς πίνακα γραμματοσειρών να μην μπορεί να κληρονομήσει παλιά κατάσταση από προηγούμενο χτίσιμο. Η γραμματοσειρά του στυλ Normal είναι το fonts[0] στο styles.xml, που το component το εκθέτει ως Workbook.Fonts[0]

// Διαβάζει το fonts[0] (τη γραμματοσειρά του στυλ Normal) από το βιβλίο εργασίας του φύλλου.
// Τα classic worksheets χωρίς πίνακα γραμματοσειρών κρατούν την προεπιλογή 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;

Η δεύτερη procedure, το PaginationMeasureMdW, ζητά από το GDI την έκταση του μοναδικού χαρακτήρα '0' μέσω GetTextExtentPoint32W σε ένα κοινόχρηστο off-screen bitmap canvas, υποχωρεί στο tmAveCharWidth από το GetTextMetricsW όταν η κλήση έκτασης αποτύχει, και υποχωρεί στο DefaultMDW όταν δεν είναι διαθέσιμο ούτε αυτό. Η cache του είναι μία μοναδική θέση με κλειδί (όνομα, μέγεθος), που ακούγεται πρόχειρο μέχρι να κοιτάξετε το μοτίβο πρόσβασης: ένα χτίσιμο σελιδοποίησης ζητά την ίδια γραμματοσειρά Normal σε κάθε στήλη κάθε σελίδας, οπότε μία θέση έχει σχεδόν τέλειο ποσοστό ευστοχίας και κοστίζει τρεις συγκρίσεις ανά κλήση

Τι γίνεται χωρίς πίνακα γραμματοσειρών, χωρίς GUI ή με γραμματοσειρά που λείπει;

Το HotXLS υποβαθμίζεται στη σταθερά Calibri 11 σε κάθε περίπτωση όπου η πραγματική γραμματοσειρά Normal δεν μπορεί να προσδιοριστεί, και το κάνει σιωπηλά εκ σχεδιασμού. Τα classic BIFF φύλλα εργασίας είναι η συνηθισμένη περίπτωση: οι παλαιές μορφές δεν μεταφέρουν δεξαμενή γραμματοσειρών XLSX στην οποία να αναφέρεται το fonts[0], οπότε ο έλεγχος τύπου βγαίνει νωρίς και ισχύει το προεπιλεγμένο MDW 7. Αυτό δεν είναι διόρθωση, είναι η προηγούμενη συμπεριφορά που διατηρήθηκε σκόπιμα, ώστε η προσθήκη μέτρησης στη διαδρομή XLSX να μην μπορεί να προκαλέσει οπισθοδρόμηση στην έξοδο των classic μορφών

Η εξάρτηση από το GDI είναι η τίμια επιφύλαξη. Η μέτρηση τρέχει πάνω σε ένα device context των Windows, οπότε η διαδρομή προϋποθέτει έναν host Windows με τη γραμματοσειρά εγκατεστημένη. Σε μια υπηρεσία ή σε έναν headless build agent, οι μετρικές κειμένου του GDI συνήθως εξακολουθούν να επιλύονται, αλλά μια γραμματοσειρά που δεν είναι εγκατεστημένη σε εκείνο το μηχάνημα αντικαθίσταται από τον font mapper και μετράτε την αντικαταστάτρια. Ποτέ δεν αποτυγχάνει θορυβωδώς· επιστρέφει έναν εύλογο αριθμό για λάθος τυπογραφικό στοιχείο. Αν οι εξαγωγές από τον server πρέπει να ταιριάζουν με μια αναφορά από desktop, εγκαταστήστε στον host εξαγωγής τις γραμματοσειρές που ονομάζουν τα πρότυπά σας, ή καρφώστε τη γραμματοσειρά Normal πριν καλέσετε τη διαδρομή εξαγωγής φύλλου εργασίας σε PDF

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

    // Καρφώστε τη γραμματοσειρά Normal ώστε το MDW που μετριέται σε αυτόν τον host
    // να είναι εκείνο για το οποίο σχεδιάστηκε η διάταξη, όχι υποκατάστατο του 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;

Caches μέτρησης, και εκείνη που κατέρρευσε σε Win64

Από τη στιγμή που η μέτρηση κειμένου είναι μια διαδρομή προς το GDI και όχι ένας πολλαπλασιασμός, πρέπει να αποθηκεύεται σε cache, και η αποθήκευση μέσα σε ένα render pass είναι εκεί όπου αυτή η δουλειά μάτωσε. Ο βρόχος shrink-to-fit κατεβάζει το μέγεθος γραμματοσειράς σε βήματα των 0.5 pt και ξαναμετρά μετά από κάθε βήμα, οπότε ένα μόνο κελί μπορεί να καλέσει το PaginationMeasureTextWidth δώδεκα φορές με την ίδια συμβολοσειρά, και η αναδίπλωση λέξεων το καλεί ξανά ανά υποψήφια γραμμή. Ένα memo με κλειδί το όνομα γραμματοσειράς, το μέγεθος και το κείμενο συμπτύσσει όλα αυτά σε μία κλήση GDI ανά διακριτή συμβολοσειρά, αποθηκευμένη σε μια TStringList ως ζεύγη ονόματος/τιμής

Η άλλη cache που προστέθηκε δίπλα της δεν ήταν τόσο τακτική. Το render pass 5 επιλύει τη δεξαμενή γραμματοσειρών ανά κελί μέσω του FontIndex, και το memo του χρησιμοποιούσε παράλληλους δυναμικούς πίνακες με ένα FontMemoCount που συντηρούνταν στο χέρι. Η πρώτη έκδοση ξέχασε να καλέσει το ResetFontMemo στην αρχή κάθε σελίδας, οπότε ο μετρητής ανέβαινε συνεχώς από σελίδα σε σελίδα ενώ οι πίνακες όχι, και ο κώδικας έγραφε πέρα από το τέλος όλων τους. Στα Win32 αυτό μουτζούρωνε αθόρυβα τον διπλανό σωρό και τελείωνε· στα Win64 εγείρει αμέσως access violation σε μια εγγραφή στη διεύθυνση 0x538. Το γενικεύσιμο μάθημα: μια cache που στηρίζεται σε πίνακες και κρατιέται σε μεταβλητή επιπέδου μονάδας πρέπει να μηδενίζεται στην είσοδο κάθε περάσματος που τη χρησιμοποιεί, επειδή μια string list ή ένα dictionary συγχωρεί έναν χαμένο μηδενισμό μεγαλώνοντας, ενώ οι παράλληλοι πίνακες όχι

Πώς το HotXLS επιλύει τη γραμματοσειρά Normal του βιβλίου εργασίας, μετρά το Max Digit Width της μέσω GDI με δύο εφεδρικές διαδρομές και αποθηκεύει το αποτέλεσμα, δίπλα στα δύο memos των render passes και τον κανόνα μηδενισμού που χρειάζεται μια cache με παράλληλους πίνακες
Το MDW επιλύεται από το βιβλίο εργασίας και μετριέται με GDI μία φορά ανά γραμματοσειρά, μετά αποθηκεύεται με κλειδί, ενώ τα memos των render passes δείχνουν γιατί μια cache με παράλληλους πίνακες πρέπει να μηδενίζεται στην είσοδο κάθε περάσματος

Πώς να ελέγξετε τη δική σας μετατροπή

Δεν χρειάζεστε το component για να επαληθεύσετε τίποτα από αυτά. Πάρτε ένα βιβλίο εργασίας του οποίου η γραμματοσειρά Normal δεν είναι Calibri 11, διαβάστε ένα πλάτος από το <col width="..."/>, και περάστε το από τον τύπο της προδιαγραφής δύο φορές, μία με MDW 7 και μία με το MDW που πραγματικά μετρά ο renderer σας για εκείνη τη γραμματοσειρά· αν οι απαντήσεις διαφέρουν και η έξοδός σας ταιριάζει με την πρώτη, βρήκατε την απόκλιση. Η γεωμετρία στηλών είναι ένα από εκείνα τα κομμάτια μιας μηχανής υπολογιστικών φύλλων που ή είναι αόρατο ή είναι το μόνο που προσέχει κανείς, και το να το κάνετε σωστά σημαίνει να αντιμετωπίζετε τη γραμματοσειρά Normal ως είσοδο της διάταξης και όχι ως λεπτομέρεια στυλ. Αν φτιάχνετε εφαρμογές Delphi ή C++Builder που διαβάζουν, γράφουν, αποδίδουν και εκτυπώνουν βιβλία εργασίας Excel χωρίς εγκατεστημένο Office, το HotXLS Delphi Excel component αναλαμβάνει τη μέτρηση MDW, το μοντέλο σελιδοποίησης και τη ροή PDF πίσω από ένα ενιαίο σύνολο κλάσεων VCL