HotXLS 2.376.0 поправи BIFF record length drift в classic XLS writer-а: SXEx emitter-ът за PivotTable views декларираше 24-byte body в header-а, а после добавяше 26 bytes. BIFF reader се доверява на декларираната length, така че двата излишни bytes разсинхронизираха всичко след тях и workbook-и, които съчетаваха PivotTable с chart sheet, губеха chart-а при reopen
Интересната част не е off-by-one word-ът. Интересна е дистанцията между грешката и symptom-а. Нищо не се провали на мястото на bug-а. Pivot records се serialize-ваха чисто, file-ът се записваше без error, Excel го отваряше, а damage-ът се появяваше едва стотици bytes по-нататък в напълно unrelated substream. Тази дистанция е характерна за всеки length-prefixed binary format и си струва да я разберете, преди да напишете още един emitter за такъв формат
Защо една грешна record length унищожава цял worksheet stream?
BIFF8 workbook stream няма framing извън собствената си arithmetic. Всеки record е 4-byte header от record id (2 bytes) плюс body length (2 bytes), последван от точно толкова payload bytes ([MS-XLS] 2.1.4). Няма separator, magic byte, checksum или resynchronization point. Reader-ът попада на следващия record само защото предишният му е казал истината за собствения си size. Declared length не е metadata за record-а; тя е pointer към следващия. Затова проследете какво направиха двата излишни bytes. Reader-ът прочете SXEx header-а, прескочи 24-те bytes, обещани от него, и попадна два bytes по-рано, върху двойка нули, останали от oversized body. Прочете тези нули като record id $0000, после прочете следващия 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-ът никога не сверяват бележките си
Structural reason-ът, поради който drift-ът беше възможен, е, че HotXLS изгражда BIFF record като TXLSBlob, чийто header и payload са две независими facts. EmitSXEx записва record id, после Blob.AddWord(24) за length, а след това append-ва body field по field. Това 24 е hand-counted constant, никога не се извежда от bytes, които следват, нито се проверява спрямо тях. Write path-ът също не затваря празнината: AddRec подава blob-а на TXLSBlobList.Append, който копира Data.DataLength bytes verbatim в output stream-а. DataLength е реалният byte count, така че writer-ът вярно emit-ва 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-ът е недвусмислен за размера, което направи fix-а mechanical. [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 плюс 24-byte body, но 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 завършват 24-byte body според [MS-XLS] 2.4.282
// Declared length MUST да съвпада със записаните 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;
Защо това е оцеляло през цялата PivotTable test suite?
Защото съществуващите pivot tests никога не са правили round-trip през file. Те изграждаха workbook, assert-ваха спрямо in-memory model и спираха, а in-memory assertions не могат да видят length mismatch, който съществува само в serialized byte stream. Record set-ът, покрит от записване на BIFF8 PivotTable records от Delphi, беше добре тестван по този стандарт и все пак достави stream-corrupting emitter. Defect-ът също се нуждаеше от втора feature, за да стане видим: pivoted worksheet, следван от почти нищо, все още се reopen-ваше, защото corruption-ът излизаше извън края на substream, който никой не проверяваше. Само комбинацията PivotTable и chart sheet, където chart sheets и drawings заемат substream, следващ worksheet-а, превърна silent misalignment-а във видимо липсващ 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);
Преди fix-а Wb.Sheets[3]._Chart беше nil на този ред, защото reader-ът беше изгубил substream boundary-то много преди да достигне chart BOF. Assertion-ът, който най-накрая улови pivot serialization bug, беше assertion за chart
Как да върнете misaligned BIFF stream към първия bad record
Обходете header chain-а и го отпечатайте, защото desynchronized BIFF stream се обявява structural много преди data-та да изглежда грешна. Започнете при substream BOF ($0809), прочетете id и length, advance-нете с четири плюс length и повторете. Докато stream-ът е aligned, попадате на plausible record ids и chain-ът завършва точно на EOF ($000A). Щом drift-не, получавате 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-ът вече е drift-нал по-назад
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-не, почти никога не е culprit-ът. Той е victim-ът. Culprit-ът е record-ът непосредствено преди него, последният, който е parse-нал без оплакване, защото record, който лъже за собствената си length, винаги parse-ва добре. В този случай walk-ът спря на phantom $0000 record и record-ът преди него беше SXEx. Сравнете declared length на този record с field list-а в spec-а, byte по byte, и arithmetic-ата или ще се събере, или няма. Ако walk-ът изобщо не достига до sane first record, проблемът е layer по-ниско, в OLE2 compound file, който държи Workbook stream-а, и никакъв record-level dump няма да помогне
Emitter, който не може да излъже за собствената си length
Durable fix-ът не е correct constant, а премахване на възможността да се запише incorrect one. Reserve-нете length word-а, emit-нете body-то, после patch-нете header-а от byte count-а, който действително сте произвели. HotXLS expose-ва точно това, което е нужно: TXLSBlob.DataLength дава текущия offset, а SetWord записва обратно в вече emit-ната позиция
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 за големи payload-и, така че invariant-ът е conditional: под limit-а emitted blob length трябва да е declared length плюс four, а над него splitter-ът притежава arithmetic-ата. Кодирайте тази разлика в helper-а, а не в comment. Същото reasoning се пренася към всеки tag-length-value format, не само към BIFF. Emitter, който декларира size, преди да го знае, е claim, който code-ът не може да провери и reviewer-ът не може да преброи, и работи точно до момента, в който втора feature се появи downstream от първата
BIFF8 writer-ът, pivot record emitter-ите и chart substream-ът, обсъдени тук, са част от HotXLS Delphi spreadsheet component за Delphi и C++Builder, който чете и записва XLS, XLSX и ODS без инсталиран Excel