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

BIFF8 XLUnicodeString στο Delphi: cch και fHigh

Το HotXLS αποκωδικοποιεί ένα BIFF8 XLUnicodeString διαβάζοντας πρώτα τα cch και flag fHigh και μετά επιλέγοντας reader που ταιριάζει στο encoding: TXLSBlob.GetWideString με byte count cch * 2 όταν το fHigh είναι 1 και TXLSBlob.GetString όταν το fHigh είναι 0. Αν τα ζευγαρώσεις ανάποδα, το record δίνει empty ή half-length string και ποτέ exception

Αυτό είναι που κάνει ακριβή αυτή την κατηγορία bug. Ένα chart ανοίγει, οι series σχεδιάζονται σωστά, οι axes είναι σωστοί και ένα trendline caption είναι απλώς blank. Τίποτε στο log, τίποτε στον exception handler και κανένα corrupt-file dialog. Το file ήταν σωστό όλο το διάστημα· ο reader ζήτησε λάθος αριθμό bytes και πήρε ακριβώς όσα ζήτησε

Γιατί ένα BIFF8 string επιστρέφει empty

Ένα BIFF8 string επιστρέφει empty επειδή ένα length guard απέρριψε το payload πριν γίνει το read ή επειδή ο reader σταμάτησε στο πρώτο NUL που βρήκε. Και οι δύο διαδρομές είναι σιωπηλές από κατασκευή. Στο HotXLS ο guard είναι συνήθως explicit DataLength check στον record handler και πρέπει να υπολογίζεται ανά encoding: ένα 16-bit payload χρειάζεται 8 + cch * 2 bytes για body SXViewLink, ενώ ένα 8-bit payload χρειάζεται μόνο 8 + cch. Εφάρμοσε τη wide-character arithmetic σε 8-bit record και κάθε σύντομο name αποτυγχάνει το gate. Η συμπεριφορά NUL είναι η δεύτερη παγίδα, επειδή και τα TXLSBlob.GetString και TXLSBlob.GetWideString σαρώνουν το decoded result για terminator και κόβουν εκεί, επιστρέφοντας empty string όταν ο terminator πέφτει στη θέση ένα. Διάβασε 16-bit body με μισό byte count και κρατάς τους πρώτους cch div 2 χαρακτήρες· διάβασε 8-bit body μέσω wide reader και τα byte pairs σχηματίζουν αυθαίρετα code points. Μόνο το over-long read είναι loud: το TXLSBlob.EnsureReadable σηκώνει Blob read exceeds data size όταν το request περνά το blob. Το under-reading δεν έχει τέτοιο alarm

Το GetWideString μετρά bytes και όχι characters

Το TXLSBlob.GetWideString(Index, Count) παίρνει το Count σε bytes. Εσωτερικά κάνει SetString πάνω σε PWideChar με Count div SizeOf(WideChar), οπότε αν περάσεις character count κόβεις σιωπηρά το string στη μέση. Τα BIFF8 record layouts, στο μεταξύ, εκφράζουν string length σε characters. Κάθε 16-bit call site πρέπει επομένως να μεταφέρει μόνο του τη μετατροπή * 2, και κάθε 8-bit call site πρέπει να μην τη μεταφέρει. Είναι το ίδιο encoding boundary που εμφανίζεται όταν γράφεις text και όχι μόνο όταν το διαβάζεις, και αξίζει να διαβαστεί μαζί με το Unicode-safe spreadsheet export στο Delphi αν το pipeline σου μετακινεί strings και προς τις δύο κατευθύνσεις

// 16-bit XLUnicodeStringNoCch: cch characters, cch * 2 bytes
Name := Data.GetWideString(Start, cch * 2);        // σωστό
Name := Data.GetWideString(Start, cch);            // μισό text, χωρίς error

// 8-bit XLUnicodeStringNoCch: cch characters, cch bytes
Name := WideString(Data.GetString(Start, cch));    // σωστό
Name := Data.GetWideStringWithZero(Start, cch);    // εξακολουθεί να είναι wide reader

Η σύμβαση ισχύει παντού όπου το byte stream διατρέχεται με το χέρι. Όταν το HotXLS ενώνει ξανά ένα μεγάλο String record ($0207, [MS-XLS] 2.4.268) από τα Continue records του ($003C), το wide branch υπολογίζει το segCh από το segment length και μετά καλεί GetWideString(3, segCh * 2), επειδή το record body αρχίζει στο offset 3 και το count παραμένει bytes. Ο rich-text reader κάνει το ίδιο από offset 1 στο πρώτο Continue segment. Το [MS-XLS] 2.5.293 εγγυάται ότι το break βρίσκεται σε double-byte character boundary όταν το fHighByte είναι 1, οπότε δεν χρειάζεται bookkeeping για partial character, όμως η byte arithmetic εξακολουθεί να είναι δική σου ευθύνη

Τι κάνει πραγματικά το GetWideStringWithZero

Το TXLSBlob.GetWideStringWithZero είναι wide-character reader που διατηρεί embedded NULs. Το suffix WithZero δηλώνει retention των NUL και όχι character width: εσωτερικά τρέχει το ίδιο SetString πάνω σε PWideChar με Count div SizeOf(WideChar) όπως το GetWideString, μείον το terminator scan. Ο one-byte counterpart είναι το TXLSBlob.GetStringWithZero, που επιστρέφει AnsiString. Τίποτε στο name δεν λέει ποιο είναι ποιο, και αυτή η ασάφεια έχει κοστίσει πραγματικά bugs στο codebase. Αξίζει να ονομαστεί η συγκεκριμένη λανθασμένη ανάγνωση, επειδή μοιάζει τόσο λογική: το GetWideString χρειάζεται cch * 2, άρα το GetWideStringWithZero πρέπει να παίρνει απευθείας cch. Πράγματι παίρνει cch χωρίς παράπονο, επιστρέφει WideString και ο compiler είναι ευχαριστημένος. Επιστρέφει επίσης τους μισούς χαρακτήρες, συναρμολογημένους από τα λάθος byte pairs. Η σωστή 8-bit path είναι TXLSBlob.GetString με απλό cch byte count, cast σε WideString κατά την assignment. Το HotXLS 2.376.0 διόρθωσε ακριβώς αυτή την κακή χρήση σε δύο chart decoders

SXViewLink και το length gate ανά encoding

Το SXViewLink ($0858, [MS-XLS] 2.4.316) είναι το καθαρότερο worked example επειδή πακετάρει και τις δύο ασυμμετρίες σε header οκτώ bytes. Το layout είναι rt(2), unused(2), reserved(2), cch(1), fHigh(1), ακολουθούμενο από XLUnicodeStringNoCch body: fHigh = 1 σημαίνει cch * 2 bytes UTF-16, fHigh = 0 σημαίνει cch bytes χαρακτήρων ενός byte και το cch περιορίζεται στο 255 επειδή το length field είναι ένα byte. Το HotXLS γράφει το record στα chart globals πριν από το Units, δίπλα στο PivotChartBits ($0859, [MS-XLS] 2.4.196), όταν chart sheet συνδέεται με PivotTable view· η record-level πλευρά αυτού του machinery καλύπτεται στο writing BIFF8 PivotTable records στο Delphi

// SXViewLink ([MS-XLS] 2.4.316): rt(2) unused(2) reserved(2) cch(1)
// μετά ένα XLUnicodeStringNoCch - fHigh(1) ακολουθούμενο από τους χαρακτήρες
PivCch := Item.FData.GetByte(6);
if (PivCch > 0) and (Item.FData.GetByte(7) <> 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch * 2)) then
begin
  Result.PivotSourceName := Item.FData.GetWideString(8, PivCch * 2);
  Result.IsPivotChart := True;
end
else if (PivCch > 0) and (Item.FData.GetByte(7) = 0) and
   (Item.FData.DataLength >= LongWord(8 + PivCch)) then
begin
  Result.PivotSourceName := WideString(Item.FData.GetString(8, PivCch));
  Result.IsPivotChart := True;
end;

Τα δύο branches δεν είναι cosmetic. Μια παλαιότερη έκδοση έβαζε και τα δύο encodings πίσω από την wide-character έκφραση 8 + cch * 2, οπότε ένα 8-bit view name γραμμένο από το Excel αποτύγχανε το guard και ο decoder επέστρεφε empty PivotSourceName με το IsPivotChart να μένει false. Το pivot link εξαφανιζόταν από το model χωρίς ούτε ένα diagnostic. Το ίδιο λάθος υπήρχε στον Trendline decoder ($2050, [MS-XLS] 2.4.328), όπου το name field ακολουθεί 28 bytes numeric payload με two-byte cch στο offset 28, fHigh στο offset 30 και τους χαρακτήρες στο offset 31· trendline captions που το Excel είχε γράψει με 8-bit characters αποκωδικοποιούνταν ως empty strings. Και τα δύο διορθώθηκαν στο ίδιο release. Η 8-bit περίπτωση επίσης δεν είναι legacy περιέργεια περιορισμένη σε Excel 2.0 έως 4.0 files: το current Excel εξακολουθεί να γράφει 8-bit BIFF8 payloads όποτε κάθε χαρακτήρας χωρά σε ένα byte

Πώς αποκωδικοποιείς με ασφάλεια ένα νέο BIFF8 record στο Delphi

Όταν το field είναι πράγματι standard XLUnicodeString, χρησιμοποίησε TXLSBlob.GetBiffString αντί να γράψεις μόνος σου το branch. Διαβάζει το length field, διαβάζει το option byte, κάνει dispatch στον matching reader και προχωρά τον cursor πέρα από το body. Οι δύο Boolean parameters είναι το σημείο που χρειάζεται προσεκτικό διάβασμα: το is8bit περιγράφει το width του length field και όχι το width των characters, ενώ το iswide λέει αν υπάρχει καθόλου option byte fHigh. Οι BIFF versions κάτω από $0600 δεν έχουν κανένα από τα δύο

var
  Offset: LongWord;
begin
  Offset := 6;  // στο SXViewLink το cch byte αρχίζει εδώ
  // is8bit = το length field έχει πλάτος ενός byte
  // iswide = μετά το length field ακολουθεί fHigh option byte
  Name := Data.GetBiffString(Offset, True, True);
  // Το Offset δείχνει τώρα στο πρώτο byte μετά το string body

Τα hand-rolled branches εξακολουθούν να έχουν θέση όταν ο handler πρέπει να αντέχει truncated ή hostile input, επειδή το GetBiffString βασίζεται στο EnsureReadable να σηκώσει exception αντί για bounds check που ελέγχεις εσύ. Γι’ αυτό ο HotXLS chart decoder ελέγχει το DataLength και επιστρέφει nothing αντί να πετάξει exception: malformed third-party workbook πρέπει να σου κοστίσει ένα caption και όχι ολόκληρο το document. Το trade-off είναι σκόπιμο και γι’ αυτό ακριβώς το encoding-specific guard πρέπει να είναι σωστό, αφού ο guard είναι αυτός που μετατρέπει ένα bad read σε silence

Μία τελευταία διαδικαστική λεπτομέρεια, κερδισμένη δύσκολα στο ίδιο release. Κάνε assert σε saved και reopened workbook και όχι στο in-memory model που μόλις έχτισες. Το batch 2.376.0 αποκάλυψε επίσης SXEx emitter ([MS-XLS] 2.4.282) που δήλωνε body 24 bytes και έγραφε μόνο 22, αποσυγχρονίζοντας κάθε record μετά το PivotTable view, μαζί με το worksheet EOF και κάθε chart sheet substream που ακολουθούσε. Τα υπάρχοντα pivot tests δεν το έπιασαν επειδή όλα έκαναν assert στη μνήμη. Το string decoding έχει την ίδια ιδιότητα: μόνο round trip μέσω file ασκεί πραγματικά τα byte counts

Αν δουλεύεις με classic XLS internals σε Delphi ή C++Builder και δεν θέλεις να συντηρείς δικό σου BIFF8 record reader, οι παραπάνω encoding rules έχουν ήδη υλοποιηθεί και δοκιμαστεί με regressions στο HotXLS Delphi spreadsheet component, το οποίο διαβάζει και γράφει XLS και XLSX χωρίς Excel ή OLE automation