Το HotXLS 2.376.0 διόρθωσε drift στο μήκος BIFF record του classic XLS writer: ο SXEx emitter για PivotTable views δήλωνε body 24 bytes στο header και μετά πρόσθετε 26 bytes. Ένας BIFF reader εμπιστεύεται το δηλωμένο length, οπότε τα δύο surplus bytes αποσυγχρόνισαν όλα τα επόμενα και workbooks που συνδύαζαν PivotTable με chart sheet έχαναν το chart στο reopen
Το ενδιαφέρον μέρος δεν είναι η λέξη off-by-one. Είναι η απόσταση ανάμεσα στο λάθος και το σύμπτωμα. Τίποτε δεν απέτυχε στο σημείο του bug. Τα pivot records serialized καθαρά, το file γράφτηκε χωρίς error, το Excel το άνοιξε και η ζημιά εμφανίστηκε μόνο εκατοντάδες bytes παρακάτω σε εντελώς άσχετο substream. Αυτή η απόσταση είναι χαρακτηριστική κάθε length-prefixed binary format και αξίζει να την κατανοήσεις πριν γράψεις άλλον emitter για ένα τέτοιο format
Γιατί ένα λάθος record length καταστρέφει ολόκληρο worksheet stream
Ένα BIFF8 workbook stream δεν έχει framing πέρα από τη δική του arithmetic. Κάθε record είναι header 4 bytes από record id (2 bytes) και body length (2 bytes), ακολουθούμενο από ακριβώς τόσα payload bytes ([MS-XLS] 2.1.4). Δεν υπάρχει separator, magic byte, checksum ή resynchronization point. Ο reader φτάνει στο επόμενο record μόνο επειδή το προηγούμενο του είπε την αλήθεια για το μέγεθός του. Το declared length δεν είναι metadata για το record· είναι ο pointer προς το επόμενο. Δες τι έκαναν τα δύο surplus bytes. Ο reader κατανάλωσε το SXEx header, προσπέρασε τα 24 bytes που υποσχέθηκε το header και βρέθηκε δύο bytes νωρίτερα, πάνω σε ζεύγος μηδενικών που είχαν περισσέψει από το oversized body. Διάβασε αυτά τα μηδενικά ως record id $0000, μετά διάβασε το following worksheet EOF record id ($000A) ως length αυτού του phantom record και ευσυνείδητα προσπέρασε δέκα bytes μέσα σε ό,τι ακολουθούσε. Από εκεί και μετά κάθε header διαβαζόταν σε λάθος offset. Στο failing workbook αυτό παρήγαγε chart sheet του οποίου το _Chart ήταν nil μετά το reopen και debug dump που έδειχνε το $18AF να ερμηνεύεται ως record id. Καμία από αυτές τις τιμές δεν εμφανίζεται κοντά στον pivot code
Ο emitter και ο writer δεν συγκρίνουν ποτέ τις σημειώσεις τους
Ο δομικός λόγος που ήταν δυνατό το drift είναι ότι το HotXLS χτίζει ένα BIFF record ως TXLSBlob του οποίου το header και το payload είναι δύο ανεξάρτητα facts. Το EmitSXEx γράφει record id, μετά Blob.AddWord(24) για το length και ύστερα προσθέτει το body field by field. Το 24 είναι hand-counted constant, δεν παράγεται ούτε ελέγχεται από τα bytes που ακολουθούν. Ούτε το write path κλείνει το κενό: το AddRec προωθεί το blob στο TXLSBlobList.Append, το οποίο αντιγράφει verbatim στο output stream Data.DataLength bytes. Το DataLength είναι το πραγματικό byte count, άρα ο writer εκπέμπει πιστά 26 bytes body πίσω από header που δηλώνει 24. Και τα δύο μισά κάνουν ακριβώς ό,τι τους είπαν, και η αντίφαση ανάμεσά τους δεν είναι δουλειά κανενός να την προσέξει. Το HotXLS το αποφεύγει ήδη όπου κάνει replay preserved payloads: το TXLSWorkbook.StoreDConnBlobs υπολογίζει το header length word από το πραγματικό body length και όχι από literal, γι’ αυτό το blob replay δεν έχει παρουσιάσει ποτέ drift
Τι ορίζει ακριβώς το [MS-XLS] 2.4.282 για το SXEx
Το spec είναι σαφές για το μέγεθος, και αυτό έκανε τη διόρθωση μηχανική. Το [MS-XLS] 2.4.282 ορίζει το SXEx body ως 4-byte grbit και δέκα 2-byte fields: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle και cchVacateStyle. Τέσσερα συν είκοσι είναι είκοσι τέσσερα. Ο παλιός emitter έγραφε έντεκα zero words ενώ το spec ορίζει δέκα, και τα anonymous AddWord(0) calls δεν μετέφεραν field names, οπότε το να τα μετρήσεις με το μάτι σε review ήταν ακριβώς τόσο αξιόπιστο όσο ακούγεται. Η preallocation ήταν το clue ότι το layout είχε κατανοηθεί αλλά ο loop όχι: το TXLSBlob.Create(28) ζητά ακριβώς τέσσερα header bytes συν body 24 bytes, όμως το blob μεγάλωνε πάνω από αυτό το hint σε κάθε call και μεγάλωνε σιωπηρά, επειδή το AdjustBufferSize κάνει realloc on demand. Capacity hint που ο code το ξεπερνά αμέσως αξίζει δεύτερη ματιά σε κάθε serializer
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // 4-byte header + 24-byte body
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Δέκα zero words ολοκληρώνουν το body των 24 bytes κατά [MS-XLS] 2.4.282
// Το declared length ΠΡΕΠΕΙ να ταιριάζει με τα bytes που γράφτηκαν, αλλιώς κάθε
// record μετά από αυτό γίνεται parse λάθος
Blob.AddWord(0); // csxformat
Blob.AddWord(0); // cchErrorString
Blob.AddWord(0); // cchNullString
Blob.AddWord(0); // cchTag
Blob.AddWord(0); // csxselect
Blob.AddWord(0); // crwPage
Blob.AddWord(0); // ccolPage
Blob.AddWord(0); // cchPageFieldStyle
Blob.AddWord(0); // cchTableStyle
Blob.AddWord(0); // cchVacateStyle
AddRec(DataList, Blob);
Result := 1;
end;
Γιατί επέζησε αυτό από ολόκληρο test suite του PivotTable
Επειδή τα υπάρχοντα pivot tests δεν έκαναν ποτέ round-trip μέσα από file. Έχτιζαν workbook, έκαναν assertions στο in-memory model και σταματούσαν, ενώ τα in-memory assertions δεν μπορούν να δουν length mismatch που υπάρχει μόνο στο serialized byte stream. Το record set που καλύπτει το writing BIFF8 PivotTable records από Delphi ήταν καλά ελεγμένο με αυτό το standard και παρ’ όλα αυτά παρέδιδε emitter που corrupts το stream. Το defect χρειαζόταν επίσης δεύτερο feature για να γίνει ορατό: pivoted worksheet που ακολουθείται από ελάχιστα ακόμη εξακολουθούσε να ανοίγει, επειδή η corruption έτρεχε πέρα από το τέλος substream που κανείς δεν επιθεωρούσε. Μόνο ο συνδυασμός PivotTable και chart sheet, όπου τα chart sheets και drawings καταλαμβάνουν substream που ακολουθεί το worksheet, μετέτρεψε σιωπηλή misalignment σε ορατά missing object
// PivotChartRoundTripThroughLinkRecords, condensed
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath); // το misparse συμβαίνει εδώ
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Πριν από τη διόρθωση, το Wb.Sheets[3]._Chart ήταν nil σε εκείνη τη γραμμή επειδή ο reader είχε χάσει το substream boundary πολύ πριν φτάσει στο chart BOF. Το assertion που τελικά έπιασε bug στο pivot serialization ήταν assertion για chart
Πώς διαβάζεις misaligned BIFF stream πίσω στο πρώτο κακό record
Διέτρεξέ το header chain και τύπωσέ το, επειδή ένα desynchronized BIFF stream αναγγέλλει δομικά τον εαυτό του πολύ πριν φανούν λάθος τα data. Ξεκίνα από το substream BOF ($0809), διάβασε id και length, προχώρα κατά τέσσερα συν το length και επανάλαβε. Όσο το stream είναι aligned φτάνεις σε plausible record ids και η chain τελειώνει ακριβώς στο EOF ($000A). Μόλις ξεφύγει, παίρνεις ids που δεν υπάρχουν, lengths που ξεπερνούν το buffer ή chain που περνά κατευθείαν από το σημείο όπου θα έπρεπε να βρίσκεται το EOF
// Διέτρεξε BIFF record stream και σταμάτα στο πρώτο header που δεν μπορεί να είναι πραγματικό
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
Pos: LongWord;
Id, Len: Word;
begin
Pos := 0;
while Pos + 4 <= Size do
begin
Id := PWord(Buf + Pos)^;
Len := PWord(Buf + Pos + 2)^;
// Zero id δεν είναι ποτέ legal record, και body που περνά το buffer
// αποδεικνύει ότι η chain είχε ήδη ξεφύγει κάπου νωρίτερα
if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
begin
WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
Break;
end;
WriteLn(Format('%6d id=$%.4x len=%d', [Pos, Id, Len]));
if Id = $000A then
WriteLn('-- EOF, substream ends cleanly --');
Inc(Pos, 4 + LongWord(Len));
end;
end;
Μετά διάβασε το output προς τα πίσω και κράτα έναν κανόνα: το πρώτο record που δεν κάνει parse είναι σχεδόν ποτέ ο ένοχος. Είναι το θύμα. Ένοχο είναι το record ακριβώς πριν από αυτό, το τελευταίο που έκανε parse χωρίς παράπονο, επειδή όποιο record λέει ψέματα για το δικό του length κάνει parse κανονικά. Εδώ το walk σταμάτησε πάνω σε phantom $0000 record, και το προηγούμενο record ήταν SXEx. Σύγκρινε το declared length εκείνου του record με το field list στο spec, byte προς byte, και η arithmetic είτε βγαίνει είτε όχι. Αν το walk δεν φτάνει ποτέ ούτε σε λογικό πρώτο record, το πρόβλημα βρίσκεται χαμηλότερα, στο OLE2 compound file που κρατά το Workbook stream, και κανένα record-level dump δεν θα βοηθήσει
Ένας emitter που δεν μπορεί να πει ψέματα για το δικό του length
Η durable διόρθωση δεν είναι μια σωστή constant αλλά η αφαίρεση της ευκαιρίας να γραφτεί λάθος μία. Κράτησε κενό το length word, εξέπεμψε το body και μετά κάνε patch το header από το byte count που παρήγαγες πραγματικά. Το HotXLS εκθέτει όσα χρειάζονται: το TXLSBlob.DataLength δίνει το τρέχον offset και το SetWord γράφει πίσω σε θέση που έχει ήδη εκδοθεί
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // θυμήσου πού βρίσκεται το length word
Blob.AddWord(0); // placeholder, γίνεται patch από το EndRecord
end;
procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
Body: LongWord;
begin
Body := Blob.DataLength - LenPos - SizeOf(Word);
if Body > 8224 then
raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
Blob.SetWord(Word(Body), LenPos);
end;
Χρειάζεται ειλικρίνεια για το πού σταματά η εγγύηση. Η blanket assertion ότι emitted bytes ισούνται με 2 + 2 + declared ισχύει μόνο για records που χωρούν κάτω από το BIFF8 limit των 8224 payload bytes. Oversize bodies δηλώνουν νόμιμα 8224 στο header και συνεχίζουν σε $003C Continue records, ακριβώς όπως κάνουν οι HotXLS pivot cache και connection writers για μεγάλα payloads, οπότε το invariant είναι conditional: κάτω από το limit το emitted blob length πρέπει να ισούται με declared length συν τέσσερα, ενώ πάνω από αυτό ο splitter κατέχει την arithmetic. Κωδικοποίησε αυτή τη διάκριση στον helper αντί για comment. Η ίδια λογική μεταφέρεται σε κάθε tag-length-value format και όχι μόνο στο BIFF. Emitter που δηλώνει size πριν το γνωρίζει έχει γράψει claim που δεν μπορεί να ελέγξει ο code και δεν μπορεί να μετρήσει ο reviewer, και λειτουργεί μέχρι να προστεθεί δεύτερο feature downstream από το πρώτο
Ο BIFF8 writer, οι pivot record emitters και το chart substream που συζητήθηκε εδώ διατίθενται ως μέρος του HotXLS Delphi spreadsheet component για Delphi και C++Builder, το οποίο διαβάζει και γράφει XLS, XLSX και ODS χωρίς εγκατεστημένο Excel