O HotXLS 2.376.0 corrigiu uma deriva no tamanho de registro BIFF em seu writer XLS clássico: o emissor SXEx de views de PivotTable declarava um corpo de 24 bytes no cabeçalho e depois anexava 26 bytes. Um reader BIFF confia no tamanho declarado, então os dois bytes excedentes dessincronizavam tudo que vinha depois, e workbooks que combinavam uma PivotTable com uma chart sheet perdiam o gráfico ao reabrir
A parte interessante não é a palavra off-by-one. É a distância entre o erro e o sintoma. Nada falhou no ponto do bug. Os registros do pivô foram serializados sem problemas, o arquivo foi gravado sem erro, o Excel abriu, e o dano só apareceu centenas de bytes depois, em um substream completamente sem relação. Essa distância é característica de todo formato binário com tamanho prefixado, e vale entendê-la antes de escrever outro emitter para um desses formatos
Por que um tamanho de registro errado destrói um stream de worksheet inteiro?
Um stream de workbook BIFF8 não tem framing além da própria aritmética. Cada registro é um cabeçalho de 4 bytes formado por id do registro (2 bytes) mais tamanho do corpo (2 bytes), seguido exatamente daquela quantidade de bytes de payload ([MS-XLS] 2.1.4). Não há separador, magic byte, checksum ou ponto de ressincronização. O reader chega ao próximo registro somente porque o registro anterior disse a verdade sobre o próprio tamanho. O tamanho declarado não é metadado sobre o registro; ele é o ponteiro para o próximo. Então acompanhe o que os dois bytes excedentes fizeram. O reader consumiu o cabeçalho SXEx, pulou os 24 bytes prometidos pelo cabeçalho e caiu dois bytes antes, em um par de zeros deixado pelo corpo maior. Leu esses zeros como um id de registro $0000, depois leu o id do registro EOF da worksheet ($000A) como o tamanho desse registro fantasma e pulou obedientemente dez bytes para dentro do que viesse a seguir. A partir daí, todo cabeçalho foi lido no offset errado. No workbook que falhava, isso produziu uma chart sheet cujo _Chart era nil depois da reabertura e um debug dump mostrando $18AF interpretado como id de registro. Nenhum desses valores aparece perto do código do pivô
O emitter e o writer nunca conferem o próprio trabalho
A razão estrutural para a deriva ser possível é que o HotXLS constrói um registro BIFF como um TXLSBlob cujo cabeçalho e payload são dois fatos independentes. EmitSXEx grava o id do registro, depois Blob.AddWord(24) para o tamanho e então anexa o corpo campo a campo. Esse 24 é uma constante contada à mão, nunca derivada nem verificada contra os bytes que vêm depois. O caminho de escrita também não fecha a lacuna: AddRec encaminha o blob para TXLSBlobList.Append, que copia verbatim Data.DataLength bytes para o stream de saída. DataLength é a contagem real de bytes, portanto o writer emite fielmente 26 bytes de corpo atrás de um cabeçalho que afirma 24. As duas metades fazem exatamente o que lhes foi pedido, e ninguém tem a responsabilidade de notar a contradição. O HotXLS já evita isso ao reproduzir payloads preservados: TXLSWorkbook.StoreDConnBlobs calcula a palavra de tamanho do cabeçalho a partir do tamanho real do corpo, e é justamente por isso que a reprodução de blobs nunca derivou
O que [MS-XLS] 2.4.282 fixa sobre SXEx
A especificação é inequívoca sobre o tamanho, o que tornou a correção mecânica. [MS-XLS] 2.4.282 define o corpo SXEx como um grbit de 4 bytes seguido de dez campos de 2 bytes: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle e cchVacateStyle. Quatro mais vinte são vinte e quatro. O emitter antigo gravava onze words zero onde a especificação define dez, e as chamadas anônimas a AddWord(0) não carregavam nomes de campos, então contá-las visualmente durante a revisão era tão confiável quanto parece. A pré-alocação era a pista de que o layout tinha sido entendido, mas o loop não: TXLSBlob.Create(28) pede exatamente quatro bytes de cabeçalho mais um corpo de 24 bytes, mas o blob crescia além dessa dica em toda chamada, silenciosamente, porque AdjustBufferSize realoca sob demanda. Uma dica de capacidade que o código ultrapassa imediatamente merece uma segunda olhada em qualquer serializer
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // cabecalho de 4 bytes + corpo de 24 bytes
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Dez words zero completam o corpo de 24 bytes segundo [MS-XLS] 2.4.282
// O tamanho declarado DEVE corresponder aos bytes gravados, ou todo registro
// depois deste sera analisado incorretamente
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;
Por que isso sobreviveu a uma suíte inteira de testes de PivotTable?
Porque os testes de pivô existentes nunca faziam round-trip por um arquivo. Eles construíam um workbook, verificavam o modelo na memória e paravam, e as asserções em memória não conseguem enxergar um mismatch de tamanho que só existe no stream de bytes serializado. O conjunto de registros coberto por gravar registros de PivotTable BIFF8 no Delphi era bem testado por esse padrão e ainda assim distribuía um emitter que corrompia o stream. O defeito também precisava de um segundo recurso para ficar visível: uma worksheet com pivô seguida de pouca coisa ainda reabria, porque a corrupção escapava pelo fim de um substream que ninguém inspecionava. Somente a combinação de uma PivotTable com uma chart sheet, em que chart sheets e drawings ocupam um substream depois da worksheet, transformou um desalinhamento silencioso em um objeto visivelmente ausente
// PivotChartRoundTripThroughLinkRecords, condensado
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); // o parsing incorreto acontece aqui
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Antes da correção, Wb.Sheets[3]._Chart era nil naquela linha, porque o reader havia perdido o limite do substream muito antes de chegar ao BOF do gráfico. A asserção que finalmente encontrou um bug de serialização do pivô era uma asserção sobre um gráfico
Como ler um stream BIFF desalinhado de volta até o primeiro registro errado
Percorra a cadeia de cabeçalhos e imprima-a, porque um stream BIFF dessincronizado se anuncia estruturalmente muito antes de os dados parecerem errados. Comece no BOF do substream ($0809), leia id e tamanho, avance quatro mais o tamanho e repita. Enquanto o stream está alinhado, você chega a ids de registro plausíveis e a cadeia termina exatamente no EOF ($000A). Quando deriva, surgem ids inexistentes, tamanhos que passam do buffer ou uma cadeia que segue diretamente além de onde o EOF deveria estar
// Percorra um stream de registros BIFF e pare no primeiro cabecalho impossivel
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)^;
// Um id zero nunca e um registro legal, e um corpo que passa do
// buffer prova que a cadeia ja derivou em algum ponto anterior
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;
Depois leia a saída de trás para frente e segure uma regra: o primeiro registro que falha ao analisar quase nunca é o culpado. Ele é a vítima. O culpado é o registro imediatamente anterior, o último que foi analisado sem reclamação, porque um registro mentindo sobre o próprio tamanho sempre é analisado com sucesso. Neste caso, a caminhada parou em um registro fantasma $0000, e o registro anterior era SXEx. Compare o tamanho declarado desse registro com a lista de campos da especificação, byte a byte, e a aritmética fecha ou não fecha. Se a caminhada nem chega a um primeiro registro razoável, o problema está em uma camada inferior, no arquivo composto OLE2 que contém o stream Workbook, e nenhuma quantidade de dump no nível de registro ajudará
Um emitter que não consegue mentir sobre o próprio tamanho
A correção durável não é uma constante correta, mas remover a oportunidade de escrever uma incorreta. Reserve a palavra de tamanho, emita o corpo e depois atualize o cabeçalho a partir da contagem de bytes realmente produzida. O HotXLS expõe exatamente o necessário: TXLSBlob.DataLength fornece o offset atual e SetWord grava de volta em uma posição já emitida
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // lembre onde fica a palavra de tamanho
Blob.AddWord(0); // placeholder, atualizado por 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;
Seja honesto sobre onde essa garantia termina. Uma asserção geral de que os bytes emitidos equivalem a 2 + 2 + o declarado só vale para registros que cabem abaixo do limite BIFF8 de 8224 bytes de payload. Corpos maiores declaram legitimamente 8224 no cabeçalho e continuam em registros Continue $003C, exatamente como fazem os writers de cache de pivô e conexão do HotXLS para payloads grandes, portanto o invariante é condicional: abaixo do limite, o comprimento do blob emitido precisa ser igual ao comprimento declarado mais quatro; acima dele, o splitter é dono da aritmética. Codifique essa distinção no helper, não em um comentário. O mesmo raciocínio se transfere para todo formato tag-length-value, não apenas BIFF. Um emitter que declara um tamanho antes de conhecê-lo faz uma afirmação que o código não consegue conferir e o revisor não consegue contar, e funciona até o momento em que um segundo recurso chega depois do primeiro
O writer BIFF8, os emitters de registros de pivô e o substream de gráfico descritos aqui fazem parte do componente de planilhas Delphi HotXLS para Delphi e C++Builder, que lê e grava XLS, XLSX e ODS sem Excel instalado