HotXLS 2.376.0 виправив drift довжини BIFF record у своєму classic XLS writer: SXEx emitter для PivotTable views оголошував у header body розміром 24 bytes, а потім додавав 26 bytes. BIFF reader довіряє declared length, тому два зайві bytes розсинхронізували все downstream, і workbooks, де PivotTable поєднувалася з chart sheet, втрачали chart після reopen
Цікава частина — не off-by-one word. Цікава відстань між помилкою та симптомом. У точці bug нічого не провалилося. Pivot records серіалізувалися чисто, file записувався без error, Excel відкривав його, а damage проявлявся лише за сотні bytes downstream у цілком unrelated substream. Така відстань характерна для кожного length-prefixed binary format, і її варто зрозуміти до того, як ви напишете для нього ще один emitter
Чому одна неправильна record length руйнує цілий worksheet stream?
BIFF8 workbook stream не має framing, крім власної арифметики. Кожен 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, обіцяні header, і став на два bytes раніше — на пару zeros, що залишилася від oversized body. Він прочитав ці zeros як record id $0000, потім прочитав id worksheet EOF ($000A) як length phantom record і сумлінно пропустив десять bytes у все, що йшло далі. Відтоді кожен header читався з неправильного offset. У failing workbook це створило chart sheet, у якого після reopen _Chart був nil, і debug dump, де $18AF інтерпретувався як record id. Жодне з цих values не лежить поблизу pivot code
Emitter і writer ніколи не звіряють свої записи
Structural reason, через яку 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 також не закриває gap: AddRec передає blob у TXLSBlobList.Append, який verbatim копіює Data.DataLength bytes у output stream. DataLength — справжня byte count, тому writer сумлінно виводить 26 body bytes за header, який заявляє 24. Обидві половини роблять саме те, що їм сказали, а суперечність між ними не входить ні до чиєї роботи. HotXLS уже уникає цього під час replay preserved payloads: TXLSWorkbook.StoreDConnBlobs обчислює header length word з actual body length, а саме тому blob replay ніколи не drift-ив
Що [MS-XLS] 2.4.282 фіксує про SXEx
Spec однозначно задає size, тому fix вийшов механічним. [MS-XLS] 2.4.282 визначає SXEx body як 4-byte grbit, за яким ідуть десять 2-byte fields: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle і cchVacateStyle. Four plus twenty — twenty-four. Старий emitter записував одинадцять zero words, хоча spec визначає десять, а anonymous AddWord(0) calls не мали field names, тому рахувати їх на око під час review було саме так надійно, як це звучить. Clue дала preallocation: TXLSBlob.Create(28) запитує рівно чотири header bytes плюс 24-byte body, але blob на кожному call виростав за цю hint, і виростав silently, бо AdjustBufferSize reallocates 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 match записаним bytes, інакше кожен наступний
// record буде parsed неправильно.
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, перевіряли in-memory model і зупинялися, а in-memory assertions не бачать length mismatch, який існує лише в serialized byte stream. Набір records, описаний у записі BIFF8 PivotTable records із Delphi, за цим стандартом був добре протестований і все одно випустив emitter, що corruption-ив stream. Defect також потребував другої feature, щоб стати видимим: pivoted worksheet, після якого майже нічого немає, усе ще reopen-ився, бо corruption ішов за кінцем substream, який ніхто не inspect-ив. Лише поєднання 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 задовго до data wrong повідомляє про себе структурно. Почніть із substream BOF ($0809), прочитайте id і length, перемістіться на four плюс 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-нула десь upstream
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, останній, який parsed-ився без complaint, бо той, хто бреше про власну length, сам parse-иться чудово. У цьому випадку walk зупинився на phantom $0000 record, а перед ним був SXEx. Порівняйте declared length цього record із field list у spec, byte by byte, і арифметика або зійдеться, або ні. Якщо walk взагалі не доходить до sane first record, проблема рівнем нижче — у OLE2 compound file, який містить Workbook stream, — і жоден record-level dump не допоможе
Emitter, який не може збрехати про власну length
Durable fix — не правильна constant, а усунення можливості записати неправильну. Зарезервуйте length word, emit-ьте body, а потім patch-те header за реально створеним byte count. HotXLS дає все потрібне: TXLSBlob.DataLength повертає current offset, а SetWord записує назад у вже emitted position
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;
Чесно назвіть межу цієї guarantee. Загальна 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 умовний: нижче limit emitted blob length має дорівнювати declared length плюс four, а вище splitter володіє арифметикою. Закодуйте цю відмінність у helper, а не в comment. Та саме reasoning переноситься на кожен tag-length-value format, не лише BIFF. Emitter, який оголошує size до того, як його знає, записує claim, який code не може перевірити, а reviewer — порахувати, і працює рівно доти, доки downstream першої feature не з’явиться друга
BIFF8 writer, pivot record emitters і chart substream, обговорений тут, постачаються як частина HotXLS Delphi spreadsheet component для Delphi та C++Builder, який читає й записує XLS, XLSX та ODS без установленого Excel