Το HotXLS αριθμεί κάθε αναφορά font του BIFF8 όπως την ορίζει το [MS-XLS] §2.5.129 FontIndex: οι τιμές 0 έως 3 είναι zero-based, οι τιμές πάνω από 4 είναι one-based, και το 4 δεν εμφανίζεται ποτέ, οπότε το πέμπτο FONT record είναι ifnt 5 και το μεγαλύτερο έγκυρο ifnt ισούται με τον αριθμό των FONT records. Από το HotXLS 2.384.4 ο XF writer, ο XF reader, τα runs των rich text strings και η migration των runs μεταξύ workbooks ακολουθούν όλοι τον κανόνα, και τα 2.384.5 με 2.384.6 τον επεκτείνουν στα runs των σχολίων και των text boxes, συμπεριλαμβανομένων αντιγράφων και row inserts
Ο κανόνας μοιάζει με typo μέχρι να τον χτυπήσετε. Κάποιος ανοίγει ένα workbook με οκτώ FONT records, βρίσκει ένα XF που δείχνει στο font 8, και συμπεραίνει ότι ο παρήγαγε out-of-range δείκτη. Αυτή η ακριβώς συλλογιστική μπήκε στο HotXLS 2.384.1 ως «fix», και μετέτρεψε μια σωστή υλοποίηση σε μία όπου κάθε custom font σε αρχείο που άνοιξε το Excel κατέληγε μια θέση νωρίτερα. Το ενδιαφέρον δεν είναι το off-by-one καθαυτό, είναι πόσες θέσεις μιας βιβλιοθήκης BIFF8 κουβαλάνε την ίδια σύμβαση, και πώς ένα δέσιμο font μπορεί να επιβιώσει από ένα save και να σπάσει στο δεύτερο. Αν έχετε ήδη παλέψει με τις ιδιοτροπίες μήκους και κωδικοποίησης που καλύπτει το decoding των BIFF8 XLUnicodeString cch και fHigh, αυτό είναι η ίδια οικογένεια bug: το αρχείο είναι μια χαρά, η αριθμητική όχι
Τι λέει στην πραγματικότητα ο κανόνας FontIndex του [MS-XLS];
Το [MS-XLS] §2.5.129 λέει ότι ένα FontIndex κάτω από 4 είναι zero-based θέση record, ένα FontIndex πάνω από 4 είναι one-based θέση record, και η τιμή 4 ΔΕΝ ΠΡΕΠΕΙ να χρησιμοποιείται. Ο ίδιος τύπος FontIndex χρησιμοποιείται από τα XF records, τα formatting runs του SST και τα formatting runs του TXO, οπότε ένας παρανοημένος κανόνας χαλάει και τους τρεις. Η απόδειξη αναπαράγεται εύκολα με αρχεία γραμμένα από Excel: το SOLVSAMP.XLS που έρχεται με το Office έχει 19 FONT records και μέγιστο XF ifnt το 19, ένα workbook με 43 records δεν ξεπερνά το 43, και ένα αρχείο αποθηκευμένο από Excel 16 με 30 FONT records δείχνει τα κελιά του Courier New στο ifnt 22, το 22ο record. Κανένα δεν περιέχει ποτέ ένα 4. Αν χρειάζεστε να αναλύσετε την αντιστοίχιση μόνοι σας σε ένα diagnostic εργαλείο, η μετατροπή είναι δύο σύντομες συναρτήσεις
// [MS-XLS] 2.5.129 FontIndex: 0..3 zero-based, > 4 one-based, 4 άκυρο
function FontIndexToRecordNo(Ifnt: Word): Integer; // FONT record 1-based
begin
if Ifnt < 4 then
Result := Ifnt + 1
else if Ifnt > 4 then
Result := Ifnt
else
Result := -1; // το 4 δεν πρέπει να εμφανίζεται
end;
function RecordNoToFontIndex(RecordNo: Integer): Word;
begin
if RecordNo <= 4 then
Result := RecordNo - 1
else
Result := RecordNo;
end;
Μέσα στο HotXLS ο ίδιος κανόνας ζει σε δύο κατοπτρισμένα σημεία. Το TXLSFontList.GetSaveIndex παίρνει τη θέση 1-based ενός font στη referred λίστα και μειώνει μόνο τις θέσεις 1 έως 4, οπότε η θέση 5 γράφεται ως ifnt 5. Το TXLSReader.ParseXF κάνει το αντίστροφο στο load: κάθε ifnt 5 και πάνω μειώνεται σε zero-based slot της λίστας fonts, και ό,τι από κάτω μένει ως έχει. Το remap των rich runs του SST και το CountRichRunFontRefs εφαρμόζουν την ίδια μετατροπή ifnt >= 5, που είναι και το νόημα: μία σύμβαση, κάθε καταναλωτής
// TXLSFontList.GetSaveIndex (πλευρά writer)
Result := inherited GetSaveIndex(Index); // θέση referred 1-based
if (Result > 0) and (Result < 5) then
Dec(Result); // τα 1..4 γίνονται 0..3, τα 5+ αμετάβλητα
// TXLSReader.ParseXF (πλευρά reader)
fnti := Data.GetWord(0);
if fnti >= 5 then
Dec(fnti); // το ifnt 5 είναι slot 4 της λίστας fonts
Γιατί ένα «fix» μηδενικής βάσης μετατόπισε κάθε custom font κατά ένα;
Το zero-based ξαναγράψιμο στο HotXLS 2.384.1 μετατόπισε κάθε custom font επειδή διάβαζε έναν one-based δείκτη ως zero-based και μετά άλλαξε τέσσερα call sites για να ταιριάζουν αυτή την παρανόηση: GetSaveIndex, ParseXF, το remap των runs του SST και η migration των runs μεταξύ workbooks στο Sheets.AddCopy. Τα round trips του HotXLS έμοιαζαν μια χαρά, επειδή writer και reader συμφωνούσαν μεταξύ τους. Το Excel δεν συμφωνούσε. Ένα αρχείο γραμμένο από το 2.384.1 έβαζε το πρώτο custom font στο ifnt 4, που το Excel μεταχειρίζεται ως default font, και κάθε επόμενο custom font ένα record νωρίτερα· το άνοιγμα αρχείου Excel πήγαινε στην αντίθετη κατεύθυνση, δένοντας κάθε font ένα record αργότερα
Το σημάδι που έπρεπε να σταματήσει την αλλαγή καθόταν στην ίδια codebase. Το CountRichRunFontRefs, το remap FONTX και FBI των charts, και η λίστα fonts της μηχανής styles δεν αγγίχτηκαν ποτέ και εξακολουθούσαν να χρησιμοποιούν skip-4, οπότε η βιβλιοθήκη αυτοαντικρούσει τη στιγμή που έφτασε το 2.384.1, και μόνο η σύμπτωση ότι τα fonts των rich-text αναφέρονταν συνήθως και από κάποιο XF κράτησε την αντίφαση κρυφή. Όταν μία σύμβαση εμφανίζεται σε επτά σημεία και αλλάζετε τέσσερα, υποψιαστείτε την αλλαγή σας πριν υποψιαστείτε τα άλλα τρία. Η έκδοση 2.384.4 επανέφερε την αρίθμηση του spec και στα τέσσερα σημεία, και το παλιό regression τεστ, που έκανε assert ifnt < FontCount και συνεπώς κωδικοποιούσε την παρανόηση, αντικαταστάθηκε από τεστ που αντιστοιχίζουν κάθε γραμμένο ifnt πίσω σε όνομα FONT record μέσω του τύπου του spec. Μένει ένας τίμιος περιορισμός: αρχεία αποθηκευμένα από 2.384.1 έως 2.384.3 με πέντε ή περισσότερα fonts κουβαλάνε μετατοπισμένους δείκτες που ένας reader δεν μπορεί να ξεχωρίσει από έγκυρα δεδομένα, οπότε η μόνη θεραπεία είναι να ξαναπαραχθούν
Γιατί τα font runs των σχολίων σπάνε μόνο στο δεύτερο save;
Τα runs των σχολίων και των text boxes έσπαγαν στο δεύτερο save επειδή το HotXLS κρατούσε τα πρώτα N-1 FONT records άνευ όρων και έπεφτε μόνο το τελευταίο όταν κανένα XF δεν το αναφερόταν, ενώ τα formatting runs του TXO ([MS-XLS] §2.4.329) ξαναγράφονταν byte προς byte χωρίς επαναρίθμηση. Τα .xls αρχεία γραμμένα από Excel τελειώνουν πάντα με ένα μη αναφερόμενο τελικό font (ένα 9pt DengXian σε σύστημα με κινεζικό locale), οπότε στο πρώτο save το font που χρησιμοποιούσε μόνο ένα run σχολίου δεν ήταν ποτέ τελευταίο, και τίποτα δεν μετακινήθηκε ορατά. Εκείνο το πρώτο save, όμως, έπεσε το τελικό font, και το font μόνο-για-σχόλιο προβιβάστηκε στην τελευταία θέση. Το δεύτερο save μετά το απόρριψε ως μη αναφερόμενο, το ifnt του run έδειχνε πέρα από το τέλος, και το Excel έπεφτε πίσω στο default font· αν το workbook είχε αποκτήσει νέο font στο μεταξύ, το run δένθηκε ήσυχα μ' εκείνο, που στις δοκιμές μετέτρεψε ένα στολισμένο run text box σε Arial. Αρχεία γεμάτα σχόλια όπως αυτά που περιγράφει το χτίσιμο ενός workflow ελέγχου με σχόλια και hyperlinks είναι ακριβώς εκεί όπου δαγκώνει, επειδή ανοίγονται, σχολιάζονται και αποθηκεύονται επανειλημμένα
Το HotXLS 2.384.5 μεταχειρίζεται τα runs του TXO όπως τα runs του SST. Το CountRichRunFontRefs περπατά πλέον κάθε TMSOShapeTextBox σε κάθε φύλλο εργασίας, μετατρέπει το skip-4 ifnt κάθε run σε slot, και το μετρά ως αναφορά, ώστε ένα font μόνο-για-runs να επιβιώνει από το φίλτρο του save. Ο προκύπτων πίνακας slot-σε-save-index μπαίνει στο FontRunRemap κάθε drawing, και το TMSOShapeTextBox.Store ξαναγράφει τους δείκτες των runs πάνω σε ιδιωτικό αντίγραφο των raw bytes των runs, αφήνοντας το τελικό TxOLastRun ήσυχο επειδή δεν κουβαλά font. Για τον κώδικα εφαρμογών το συμβόλαιο είναι απλό: τα TXLSComment.TextRuns.FontIndex και TXLSTextBox.TextRuns.FontIndex χρησιμοποιούν την αρίθμηση του αρχείου, με παράλειψη του 4, ακριβώς όπως διαβάστηκαν· οι δείκτες των runs είναι 1-based και το CharIndex είναι η μετατόπιση χαρακτήρα όπου ξεκινά το run. Μετά από save ο αποθηκευμένος αριθμός μπορεί να διαφέρει από αυτόν που ορίσατε, αλλά εξακολουθεί να δείχνει το ίδιο font
var
Book: IXLSWorkbook;
Note: TXLSComment;
I: Integer;
Ifnt: Word;
begin
Book := TXLSWorkbook.Create;
if Book.Open('review-notes.xls') <> 1 then
raise Exception.Create('Cannot open review-notes.xls');
Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
if Note <> nil then
for I := 1 to Note.TextRuns.Count do
begin
Ifnt := Note.TextRuns.FontIndex[I]; // αρίθμηση αρχείου, με παράλειψη του 4
if Ifnt = 4 then
raise Exception.CreateFmt('Run %d uses invalid ifnt 4', [I]);
Writeln(Format('run %d at char %d: ifnt %d = FONT record #%d',
[I, Note.TextRuns.CharIndex[I], Ifnt, FontIndexToRecordNo(Ifnt)]));
end;
end;
Αντίγραφα, row inserts και migration runs μεταξύ workbooks
Από το HotXLS 2.384.6 κάθε μονοπάτι αντιγραφής της classic μηχανής κρατά τα formatting runs των σχολίων, επειδή το Range.Copy, το CopyRange, το Sheets.AddCopy και οι μετατοπίσεις κελιών πίσω από Range.Insert και Range.Delete περνούν όλοι από το TXLSRange.CopyCell, και το CopyCell αντέγραφε μόνο το κείμενο και τον συγγραφέα του σχολίου. Μια μετατόπιση είναι αντίγραφο συν καθάρισμα, οπότε η εισαγωγή μίας γραμμής πάνω από μια σημείωση με δύο runs την άφηνε με μηδέν runs και ένα font. Το fix αντιγράφει κάθε run και μετακινεί το font του μέσω TXLSWorkbook.MigrateRunFontIndex, που μετατρέπει τον skip-4 δείκτη σε slot, μεταναστεύει το font με αξία στον πίνακα fonts του προορισμού, και ξαναμετατρέπει σε αρίθμηση αρχείου· η migration των rich-text του SST στο Sheets.AddCopy καλεί πλέον την ίδια συνάρτηση αντί να κουβαλά δικό της αντίγραφο της αριθμητικής. Δύο edge cases ήρθαν μαζί: ένα paste επιτόπου όπου πηγή και προορισμός είναι το ίδιο σχόλιο δεν πρέπει να καθαρίσει τα runs του πριν τα διαβάσει, και το Sheets.AddCopy κάνει πλέον δεύτερο πέρασμα για σχόλια δεμένα με κελιά χωρίς αποθηκευμένο cell record, που παλιά τα προσπερνούσε ολότελα. Η πλευρά του πίνακα fonts στην αντιγραφή μεταξύ workbooks ακολουθεί την ίδια λογική με αξία όπως η πλευρά των τύπων που καλύπτει το αντίγραφο μεταξύ workbooks και rebinding τύπων. Στη μηχανή XLSX τα μονοπάτια αντιγραφής ήδη κλωνοποιούσαν runs με αξία· το κενό ήταν στο ίδιο το τμήμα σχολίων, όπου ο reader αγνοούσε rFont, strike, u και vertAlign και ο writer δεν εξέπεμπε ποτέ u ή vertAlign, οπότε τα runs πλέον επιβιώνουν από save και επανάνοιγμα συμμετρικά
Πώς πρέπει να τεστάρετε δείκτες font σε αρχεία BIFF8;
Τεστάρετε τους δείκτες font αποθηκεύοντας και ξανανοίγοντας, ιδανικά πάνω από μία γενιά, και αντιστοιχίζοντας κάθε ifnt πίσω σε FONT record αντί να κάνετε assert ένα αριθμητικό εύρος. Κάθε bug σε αυτή την ιστορία πέρασε τεστ στη μνήμη: το regression του 2.384.1 ζούσε σε ένα ταίριαστο ζεύγος writer και reader, η μετατόπιση του TXO χρειαζόταν δύο saves με αλλαγή του πίνακα fonts ανάμεσα, και τα χαμένα runs σχολίων στο XLSX φάνηκαν μόνο μετά από επανάνοιγμα. Ένα χρήσιμο harness ανοίγει ένα δείγμα γραμμένο από Excel, το αποθηκεύει δύο φορές μέσω HotXLS, προσθέτει ή αφαιρεί ένα font ανάμεσα στα saves, και μετά ελέγχει τις θέσεις των runs συν, σε επίπεδο bytes, τα ονόματα fonts πίσω από κάθε ifnt. Μην συγκρίνετε τιμές FontIndex πριν και μετά από save, αφού η επαναρίθμηση είναι νόμιμη
procedure CheckNoteSurvivesShiftAndSave(const SrcFile, OutFile: string);
var
Book: IXLSWorkbook;
Note: TXLSComment;
RunCount: Integer;
SecondRunAt: Word;
begin
Book := TXLSWorkbook.Create;
Assert(Book.Open(SrcFile) = 1); // γραμμένο από Excel, το C2 έχει δύο runs
Note := Book.Sheets[1].Range['C2', 'C2'].Comment;
RunCount := Note.TextRuns.Count;
SecondRunAt := Note.TextRuns.CharIndex[2];
Book.Sheets[1].Range['C2', 'C2'].Copy(Book.Sheets[1].Range['E5', 'E5']);
Book.Sheets[1].Range['C1', 'C1'].Insert(xlShiftDown); // το C2 πάει στο C3
Assert(Book.SaveAs(OutFile) = 1);
Book := TXLSWorkbook.Create; // επανάνοιγμα, μην εμπιστεύεσαι τη μνήμη
Assert(Book.Open(OutFile) = 1);
Note := Book.Sheets[1].Range['C3', 'C3'].Comment;
Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
Assert(Note.TextRuns.CharIndex[2] = SecondRunAt);
Note := Book.Sheets[1].Range['E5', 'E5'].Comment;
Assert((Note <> nil) and (Note.TextRuns.Count = RunCount));
end;
Αν διαβάζετε και γράφετε classic XLS από Delphi ή C++Builder και προτιμάτε να μη δίνετε μάχη με το ποιος από τους πολλούς καταναλωτές fonts μιας βιβλιοθήκης συμφωνεί ακόμα με το [MS-XLS] §2.5.129, η αρίθμηση skip-4, η επαναρίθμηση των runs στο save και η migration των runs με αξία που περιγράφονται εδώ είναι χτισμένα μέσα στο HotXLS Delphi spreadsheet component, που διαβάζει και γράφει XLS και XLSX χωρίς Excel ή OLE automation