Artigo Técnico

Deriva do comprimento de registos BIFF num writer XLS Delphi

O HotXLS 2.376.0 corrigiu uma deriva no comprimento de registos do seu writer XLS clássico: o emitter SXEx para vistas de PivotTable declarava um corpo de 24 bytes no cabeçalho e depois acrescentava 26 bytes. Um leitor BIFF confia no comprimento declarado, pelo que os dois bytes excedentes dessincronizavam tudo a jusante e os workbooks que emparelhavam 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 registos pivot eram serializados sem problemas, o ficheiro era escrito sem erro, o Excel abria-o e o dano só aparecia centenas de bytes mais tarde num substream completamente não relacionado. Essa distância é característica de todos os formatos binários com comprimento prefixado e vale a pena compreendê-la antes de escrever outro emitter para um deles

Porque é que um comprimento de registo errado destrói um stream de worksheet inteiro?

Um stream de workbook BIFF8 não tem framing para além da sua própria aritmética. Cada registo é um cabeçalho de 4 bytes com o ID do registo (2 bytes) e o comprimento do corpo (2 bytes), seguido exatamente desse número de bytes de payload ([MS-XLS] 2.1.4). Não há separador, magic byte, checksum nem ponto de ressincronização. O leitor chega ao registo seguinte apenas porque o registo anterior lhe disse a verdade sobre o seu próprio tamanho. O comprimento declarado não é metadado sobre o registo; é o ponteiro para o seguinte. Trace então o que os dois bytes excedentes fizeram. O leitor consumiu o cabeçalho SXEx, saltou os 24 bytes prometidos pelo cabeçalho e caiu dois bytes cedo, num par de zeros que sobrara do corpo sobredimensionado. Leu esses zeros como um ID de registo $0000, depois leu o ID do registo EOF da worksheet seguinte ($000A) como o comprimento desse registo fantasma e saltou obedientemente dez bytes para dentro do que viesse depois. A partir daí, todos os cabeçalhos foram lidos no offset errado. No workbook com falha, isso produziu uma chart sheet cujo _Chart era nil depois de reabrir e um debug dump que mostrava $18AF interpretado como ID de registo. Nenhum desses valores aparece perto do código pivot

O emitter e o writer nunca comparam notas

A razão estrutural que permitiu a deriva é que o HotXLS constrói um registo BIFF como um TXLSBlob cujo cabeçalho e payload são dois factos independentes. EmitSXEx escreve o ID do registo, depois Blob.AddWord(24) para o comprimento e depois acrescenta o corpo campo a campo. Esse 24 é uma constante contada à mão, nunca derivada dos bytes que se seguem nem comparada com eles. O percurso de escrita também não fecha a lacuna: AddRec encaminha o blob para TXLSBlobList.Append, que copia Data.DataLength bytes sem alterações para o stream de saída. DataLength é a contagem real de bytes, pelo que 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 como responsabilidade notar a contradição. O HotXLS já evita isto quando reproduz payloads preservados: TXLSWorkbook.StoreDConnBlobs calcula a palavra de comprimento do cabeçalho a partir do comprimento real do corpo, em vez de um literal, e é precisamente por isso que a reprodução de blobs nunca derivou

O que fixa [MS-XLS] 2.4.282 sobre SXEx

A especificação é inequívoca sobre o tamanho, que foi 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 escrevia onze palavras zero onde a especificação define dez e as chamadas AddWord(0) anónimas não tinham nomes de campos, pelo que contá-las a olho numa revisão era tão fiável como parece. A pré-alocação era a pista de que o layout tinha sido compreendido mas o ciclo não: TXLSBlob.Create(28) pede exatamente quatro bytes de cabeçalho mais um corpo de 24 bytes, mas o blob crescia para além dessa indicação em todas as chamadas e crescia silenciosamente, porque AdjustBufferSize realoca a pedido. Uma indicação de capacidade que o código ultrapassa imediatamente merece uma segunda análise em qualquer serializer

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // cabeçalho 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 palavras zero completam o corpo de 24 bytes segundo [MS-XLS] 2.4.282
  // O comprimento declarado TEM de corresponder aos bytes escritos, ou todos os registos
  // depois deste serão analisados 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;

Porque é que isto sobreviveu a uma suite inteira de testes de PivotTable?

Porque os testes pivot existentes nunca faziam round-trip através de um ficheiro. Construíam um workbook, faziam asserções sobre o modelo em memória e paravam aí, e as asserções em memória não conseguem ver um comprimento errado que só existe no stream de bytes serializado. O conjunto de registos coberto por escrever registos PivotTable BIFF8 a partir de Delphi estava bem testado por esse padrão e mesmo assim distribuía um emitter que corrompia o stream. O defeito também precisava de uma segunda funcionalidade para se tornar visível: uma worksheet pivotada seguida de pouco mais continuava a reabrir, porque a corrupção ultrapassava o fim de um substream que ninguém inspecionava. Só a combinação de uma PivotTable e de uma chart sheet, onde chart sheets e drawings ocupam um substream que segue a worksheet, transformava um desalinhamento silencioso num 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);                       // a análise errada acontece aqui
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Antes da correção, Wb.Sheets[3]._Chart era nil nessa linha, porque o leitor tinha perdido o limite do substream muito antes de chegar ao BOF do chart. A asserção que finalmente detetou um bug de serialização pivot era uma asserção sobre um gráfico

Como ler um stream BIFF desalinhado de volta até ao primeiro registo errado

Percorra a cadeia de cabeçalhos e imprima-a, porque um stream BIFF dessincronizado anuncia-se estruturalmente muito antes de os dados parecerem errados. Comece no BOF do substream ($0809), leia o ID e o comprimento, avance quatro mais o comprimento e repita. Enquanto o stream estiver alinhado, chega a IDs de registos plausíveis e a cadeia termina exatamente no EOF ($000A). Quando deriva, obtém IDs que não existem, comprimentos que ultrapassam o buffer ou uma cadeia que passa diretamente pelo local onde o EOF devia ter surgido

// Percorrer um stream de registos BIFF e parar no primeiro cabeçalho impossível
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 é um registo legal, e um corpo que ultrapassa o
    // buffer prova que a cadeia já derivou algures a montante
    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 para trás e agarre-se a uma regra: o primeiro registo que falha ao ser analisado quase nunca é o culpado. É a vítima. O culpado é o registo imediatamente anterior, o último que foi analisado sem queixas, porque um registo que mente sobre o seu próprio comprimento é sempre analisado sem problemas. Neste caso, o percurso parou num registo fantasma $0000 e o registo anterior era SXEx. Compare o comprimento declarado desse registo com a lista de campos da especificação, byte a byte, e a aritmética ou soma ou não soma. Se o percurso nem sequer chega a um primeiro registo razoável, o problema está uma camada abaixo, no ficheiro composto OLE2 que contém o stream Workbook, e não há quantidade de dumps ao nível do registo que ajude

Um emitter que não consegue mentir sobre o próprio comprimento

A correção duradoura não é uma constante correta, mas remover a oportunidade de escrever uma incorreta. Reserve a palavra de comprimento, emita o corpo e depois atualize o cabeçalho a partir da contagem de bytes que realmente produziu. O HotXLS expõe o que é necessário: TXLSBlob.DataLength fornece o offset atual e SetWord escreve de volta numa posição já emitida

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // lembrar onde fica a palavra de comprimento
  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 são iguais a 2 + 2 + o declarado só é válida para registos que caibam abaixo do limite BIFF8 de 8224 bytes de payload. Corpos maiores declaram legitimamente 8224 no cabeçalho e continuam em registos Continue $003C, que é exatamente o que os writers de pivot cache e de ligações do HotXLS fazem para payloads grandes, pelo que o invariável é condicional: abaixo do limite, o comprimento do blob emitido tem de ser igual ao comprimento declarado mais quatro; acima dele, o splitter é dono da aritmética. Codifique essa distinção no helper, não num comentário. O mesmo raciocínio passa para todos os formatos tag-length-value, não apenas BIFF. Um emitter que declara um tamanho antes de o conhecer fez uma afirmação que o código não pode verificar e o revisor não pode contar, e funciona até ao momento em que uma segunda funcionalidade aterra a jusante da primeira

O writer BIFF8, os emitters de registos pivot e o substream de charts aqui discutido são distribuídos como parte do componente de folhas de cálculo HotXLS para Delphi para Delphi e C++Builder, que lê e escreve XLS, XLSX e ODS sem o Excel instalado