HotXLS 2.376.0 исправил смещение длины BIFF-записи в своём классическом XLS writer: эмиттер SXEx для представлений PivotTable объявлял в заголовке тело длиной 24 байта, а затем добавлял 26 байт. BIFF reader доверяет объявленной длине, поэтому два лишних байта рассинхронизировали всё последующее, и книги, где PivotTable соседствовала с chart sheet, теряли диаграмму после повторного открытия
Интересна не ошибка на единицу. Интересно расстояние между ошибкой и симптомом. В месте дефекта ничего не падало. Pivot records сериализовались чисто, файл записывался без ошибки, Excel открывал его, а повреждение проявлялось лишь через сотни байт в совершенно несвязанном substream. Такое расстояние характерно для любого length-prefixed binary format, и его стоит понять до того, как вы напишете ещё один emitter для такого формата
Почему одна неверная длина записи уничтожает целый worksheet stream?
В BIFF8 workbook stream нет иной framing, кроме собственной арифметики. Каждая record состоит из 4-байтного заголовка: id записи занимает 2 байта, длина body — ещё 2, после чего идут ровно столько payload bytes, сколько объявлено ([MS-XLS] 2.1.4). Нет ни разделителя, ни magic byte, ни checksum, ни точки resynchronization. Reader попадает на следующую запись только потому, что предыдущая сказала правду о собственном размере. Объявленная длина — не метаданные записи, а указатель на следующую запись. Проследим, что сделали два лишних байта. Reader прочитал заголовок SXEx, пропустил обещанные заголовком 24 байта и оказался на два байта раньше — на паре нулей, оставшихся от oversized body. Он прочитал эти нули как id записи $0000, затем прочитал следующий worksheet EOF id ($000A) как длину этой phantom record и добросовестно пропустил десять байт в то, что шло дальше. С этого места каждый заголовок читался по неправильному offset. В повреждённой книге это дало chart sheet, чей _Chart после повторного открытия был nil, и debug dump, где $18AF интерпретировался как id записи. Ни одно из этих значений не находится рядом с кодом pivot
Эмиттер и writer никогда не сверяют свои показания
Структурная причина, по которой смещение стало возможным, в том, что HotXLS строит BIFF record как TXLSBlob, у которого header и payload — два независимых факта. EmitSXEx записывает id записи, затем Blob.AddWord(24) для длины, а после этого добавляет поля body одно за другим. Число 24 — константа, сосчитанная вручную; оно никогда не выводится из следующих байт и не сверяется с ними. Write path тоже не закрывает зазор: AddRec передаёт blob в TXLSBlobList.Append, который побайтно копирует Data.DataLength байт в output stream. DataLength — реальное число байт, поэтому writer честно выдаёт 26 байт body за заголовком, обещающим 24. Обе стороны делают ровно то, что им сказали, а заметить противоречие некому. HotXLS уже избегает этого при воспроизведении сохранённых payload: TXLSWorkbook.StoreDConnBlobs вычисляет word длины заголовка по фактической длине body, а не по literal, и именно поэтому replay blob никогда не смещался
Что [MS-XLS] 2.4.282 фиксирует для SXEx
Спецификация однозначна насчёт размера, поэтому исправление оказалось механическим. [MS-XLS] 2.4.282 определяет тело SXEx как 4-байтный grbit, за которым следуют десять 2-байтных полей: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle и cchVacateStyle. Четыре плюс двадцать — двадцать четыре. Старый emitter записывал одиннадцать нулевых слов там, где спецификация определяет десять, а анонимные вызовы AddWord(0) не содержали имён полей, поэтому пересчитать их глазами при review было ровно настолько надёжно, насколько это звучит. Clue дала preallocation: TXLSBlob.Create(28) запрашивает ровно четыре байта header плюс body 24 байта, но blob при каждом вызове вырастал за эту подсказку и делал это молча, потому что AdjustBufferSize перераспределяет память по требованию. На подсказку capacity, которую код сразу перерастает, стоит взглянуть второй раз в любом serializer
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // 4-байтный header + body 24 байта
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Десять нулевых слов завершают body из 24 байт по [MS-XLS] 2.4.282
// Объявленная длина ДОЛЖНА совпадать с записанными байтами, иначе каждая
// следующая запись будет разобрана неверно
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?
Потому что существующие pivot tests никогда не проходили round-trip через файл. Они строили workbook, проверяли in-memory model и останавливались, а in-memory assertions не могут увидеть mismatch длины, существующий только в сериализованном byte stream. Набор records, описанный в статье о записи BIFF8 PivotTable records из Delphi, был хорошо протестирован по этому стандарту и всё равно выпускал emitter, портящий stream. Для проявления дефекта требовалась и вторая возможность: pivoted worksheet, после которого не следовало ничего существенного, всё ещё открывался, потому что повреждение уходило за конец substream, который никто не проверял. Только сочетание PivotTable и chart sheet, где chart sheets и drawings занимают substream после worksheet, превратило тихое смещение в явно пропавший объект
// PivotChartRoundTripThroughLinkRecords, сокращённый вариант
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 задолго до того, как добрался до chart BOF. Assertion, которая наконец поймала ошибку сериализации pivot, была assertion о chart
Как прочитать misaligned BIFF stream обратно до первой плохой записи
Пройдите по цепочке заголовков и распечатайте её, потому что рассинхронизированный BIFF stream объявляет себя структурно задолго до того, как данные начнут выглядеть неправильными. Начните с substream BOF ($0809), прочитайте id и length, перейдите на четыре байта плюс length и повторяйте. Пока stream выровнен, вы попадаете на правдоподобные record id, а цепочка точно завершается на EOF ($000A). После смещения появляются несуществующие id, длины, выходящие за buffer, или цепочка проходит прямо мимо места, где должен был быть 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)^;
// Нулевой id никогда не бывает допустимой записью, а body, выходящий за
// buffer, доказывает, что цепочка уже где-то выше сместилась
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;
Затем читайте результат с конца и держите в голове одно правило: первая запись, которую не удалось разобрать, почти никогда не виновата. Она жертва. Виновата запись непосредственно перед ней — последняя, которая разобралась без жалобы, — потому что лжец, неверно сообщающий собственную длину, сам разбирается прекрасно. В данном случае проход остановился на phantom record $0000, а предыдущей была SXEx. Сравните объявленную длину этой записи со списком полей в спецификации побайтно, и арифметика либо сойдётся, либо нет. Если проход вообще не достигает разумной первой записи, проблема находится уровнем ниже, в составном OLE2-файле, содержащем Workbook stream, и никакой объём record-level dump не поможет
Emitter, который не может соврать о собственной длине
Надёжное исправление — не правильная константа, а устранение возможности записать неправильную. Зарезервируйте word длины, выдайте body, затем исправьте header по фактическому числу произведённых байт. HotXLS предоставляет всё необходимое: TXLSBlob.DataLength сообщает текущий offset, а SetWord записывает обратно в уже выведенную позицию
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // запомнить позицию word длины
Blob.AddWord(0); // placeholder, исправляется в 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;
Честно обозначьте, где заканчивается эта гарантия. Простое утверждение, что emitted bytes равны 2 + 2 + declared, выполняется только для records, укладывающихся в BIFF8 limit 8224 payload bytes. Большие body законно объявляют в header значение 8224 и продолжаются в records $003C Continue, ровно так работают HotXLS pivot cache и connection writers с крупными payload, поэтому invariant условен: ниже лимита длина emitted blob должна равняться объявленной длине плюс четыре, а выше лимита арифметику контролирует splitter. Закодируйте это различие в helper, а не в comment. То же рассуждение переносится на любой tag-length-value format, не только BIFF. Emitter, объявляющий размер до того, как он его узнал, делает заявление, которое код не может проверить, а reviewer не может пересчитать, и всё работает ровно до появления второй функции downstream от первой
BIFF8 writer, pivot record emitters и chart substream, обсуждённый здесь, входят в состав табличного компонента HotXLS для Delphi для Delphi и C++Builder, который читает и записывает XLS, XLSX и ODS без установленного Excel