HotXLS 2.376.0 corrigió una deriva de longitud de registro BIFF en su writer XLS clásico: el emisor SXEx de las vistas PivotTable declaraba un cuerpo de 24 bytes en la cabecera y después añadía 26. Un lector BIFF confía en la longitud declarada, así que los dos bytes sobrantes desincronizaban todo lo posterior y los workbooks que combinaban una PivotTable con una hoja de gráfico perdían el gráfico al volver a abrirse
Lo interesante no es la palabra de diferencia. Es la distancia entre el error y el síntoma. Nada falló en el punto del bug. Los registros del pivot se serializaron correctamente, el archivo se escribió sin error, Excel lo abrió y el daño solo apareció cientos de bytes después, en un substream completamente no relacionado. Esa distancia es característica de cualquier formato binario con longitudes prefijadas y conviene entenderla antes de escribir otro emisor para uno
¿Por qué una longitud de registro incorrecta destruye todo un worksheet stream?
Un stream de workbook BIFF8 no tiene más framing que su propia aritmética. Cada registro es una cabecera de 4 bytes formada por el ID del registro (2 bytes) y la longitud del cuerpo (2 bytes), seguida exactamente por ese número de bytes de payload ([MS-XLS] 2.1.4). No hay separador, byte mágico, checksum ni punto de resincronización. El lector llega al registro siguiente solo porque el anterior le ha dicho la verdad sobre su propio tamaño. La longitud declarada no es metadato del registro: es el puntero al siguiente. Siga lo que hicieron los dos bytes sobrantes. El lector consumió la cabecera SXEx, saltó los 24 bytes que prometía la cabecera y cayó dos bytes antes, sobre una pareja de ceros sobrantes del cuerpo sobredimensionado. Leyó esos ceros como un ID de registro $0000, después leyó el ID del registro EOF de worksheet ($000A) como la longitud de ese registro fantasma y saltó obedientemente diez bytes hacia lo que viniera después. Desde ese punto, todas las cabeceras se leyeron en un offset incorrecto. En el workbook que fallaba, eso produjo una hoja de gráfico cuyo _Chart era nil tras reabrir y un volcado de depuración que mostraba $18AF interpretado como ID de registro. Ninguno de esos valores aparece cerca del código del pivot
El emisor y el writer nunca comparan sus notas
El motivo estructural por el que la deriva era posible es que HotXLS construye un registro BIFF como un TXLSBlob cuya cabecera y payload son dos hechos independientes. EmitSXEx escribe el ID del registro, después Blob.AddWord(24) para la longitud y luego añade el cuerpo campo a campo. Ese 24 es una constante contada a mano, nunca derivada de los bytes que le siguen ni comparada con ellos. La ruta de escritura tampoco cierra la brecha: AddRec reenvía el blob a TXLSBlobList.Append, que copia literalmente Data.DataLength bytes al stream de salida. DataLength es el recuento real de bytes, por lo que el writer emite fielmente 26 bytes de cuerpo detrás de una cabecera que afirma 24. Las dos mitades hacen exactamente lo que se les pidió y nadie tiene asignado detectar la contradicción. HotXLS ya evita esto al reproducir payloads conservados: TXLSWorkbook.StoreDConnBlobs calcula la palabra de longitud de la cabecera a partir de la longitud real del cuerpo en lugar de un literal, y por eso la reproducción de blobs nunca ha derivado
Qué fija [MS-XLS] 2.4.282 sobre SXEx
La especificación no deja dudas sobre el tamaño, lo que hizo mecánica la corrección. [MS-XLS] 2.4.282 define el cuerpo SXEx como un grbit de 4 bytes seguido de diez campos de 2 bytes: csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle y cchVacateStyle. Cuatro más veinte son veinticuatro. El emisor antiguo escribía once palabras cero donde la especificación define diez, y las llamadas anónimas a AddWord(0) no llevaban nombres de campo, así que contarlas a ojo durante una revisión era tan fiable como suena. La preasignación era la pista de que el layout se había entendido y el bucle no: TXLSBlob.Create(28) solicita exactamente cuatro bytes de cabecera más un cuerpo de 24 bytes, pero el blob crecía más allá de esa sugerencia en cada llamada y crecía en silencio porque AdjustBufferSize realoca bajo demanda. Una pista de capacidad que el código rebasa inmediatamente merece una segunda mirada en cualquier serializador
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // cabecera de 4 bytes + cuerpo de 24 bytes
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// Diez palabras cero completan el cuerpo de 24 bytes según [MS-XLS] 2.4.282
// La longitud declarada DEBE coincidir con los bytes escritos, o todos los registros
// posteriores se analizarán mal
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;
¿Cómo sobrevivió esto a toda una suite de pruebas de PivotTable?
Porque las pruebas de pivot existentes nunca hacían round-trip mediante un archivo. Construían un workbook, comprobaban el modelo en memoria y terminaban, y las aserciones en memoria no pueden ver un desajuste de longitud que solo existe en el stream serializado. El conjunto de registros cubierto por la escritura de registros PivotTable BIFF8 desde Delphi estaba bien probado con ese criterio y aun así puso en producción un emisor que corrompía el stream. Además, el defecto necesitaba una segunda función para hacerse visible: un worksheet con un pivot seguido de poco más todavía se reabría porque la corrupción se salía por el final de un substream que nadie inspeccionaba. Solo la combinación de una PivotTable y una hoja de gráfico, donde las hojas de gráfico y los dibujos ocupan un substream posterior al worksheet, convirtió una desalineación silenciosa en un objeto que faltaba a la vista
// PivotChartRoundTripThroughLinkRecords, resumido
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); // el análisis erróneo ocurre aquí
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
Antes de la corrección, Wb.Sheets[3]._Chart era nil en esa línea, porque el lector había perdido el límite del substream mucho antes de llegar al BOF del gráfico. La aserción que finalmente detectó un bug de serialización de pivot era una aserción sobre un gráfico
Cómo leer un stream BIFF desalineado hasta el primer registro incorrecto
Recorra la cadena de cabeceras e imprímala, porque un stream BIFF desincronizado se anuncia estructuralmente mucho antes de que los datos parezcan incorrectos. Empiece en el BOF de substream ($0809), lea el ID y la longitud, avance cuatro más la longitud y repita. Mientras el stream está alineado, aterriza sobre IDs de registro plausibles y la cadena termina exactamente en EOF ($000A). Cuando deriva, aparecen IDs que no existen, longitudes que sobrepasan el buffer o una cadena que sigue caminando mucho más allá de donde debería haber estado EOF
// Recorrer un stream de registros BIFF y detenerse en la primera cabecera imposible
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)^;
// Un ID cero nunca es un registro legal, y un cuerpo que sobrepasa el
// buffer demuestra que la cadena ya se desvió en algún punto 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;
Después lea la salida hacia atrás y quédese con una regla: el primer registro que no se puede analizar casi nunca es el culpable. Es la víctima. El culpable es el registro inmediatamente anterior, el último que se analizó sin quejarse, porque quien miente sobre su propia longitud siempre se analiza correctamente. En este caso el recorrido se detuvo en un registro fantasma $0000 y el registro anterior era SXEx. Compare la longitud declarada de ese registro con la lista de campos de la especificación, byte a byte, y la aritmética suma o no suma. Si el recorrido ni siquiera llega al primer registro sensato, el problema está en una capa inferior, en el archivo compuesto OLE2 que contiene el stream Workbook, y no servirá de nada volcar más datos a nivel de registro
Un emisor que no puede mentir sobre su propia longitud
La corrección duradera no es una constante correcta, sino eliminar la oportunidad de escribir una incorrecta. Reserve la palabra de longitud, emita el cuerpo y después modifique la cabecera a partir del recuento de bytes que realmente produjo. HotXLS expone lo necesario: TXLSBlob.DataLength proporciona el offset actual y SetWord vuelve a escribir en una posición ya emitida
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // recordar dónde se encuentra la palabra de longitud
Blob.AddWord(0); // marcador provisional, modificado 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;
Conviene ser honesto sobre dónde termina esa garantía. Una afirmación general de que los bytes emitidos equivalen a 2 + 2 + declarado solo se sostiene para registros que caben por debajo del límite BIFF8 de 8224 bytes de payload. Los cuerpos sobredimensionados declaran legítimamente 8224 en la cabecera y continúan en registros Continue $003C, que es exactamente lo que hacen los writers de pivot cache y conexiones de HotXLS con payloads grandes, así que el invariante es condicional: por debajo del límite, la longitud del blob emitido debe ser igual a la declarada más cuatro; por encima, el splitter es quien posee la aritmética. Codifique esa distinción en el helper y no en un comentario. El mismo razonamiento se traslada a cualquier formato tag-length-value, no solo a BIFF. Un emisor que declara un tamaño antes de conocerlo ha escrito una afirmación que el código no puede comprobar y el revisor no puede contar, y funciona hasta que una segunda función aparece detrás de la primera
El writer BIFF8, los emisores de registros pivot y el substream de gráficos tratado aquí forman parte del componente de hojas de cálculo Delphi HotXLS para Delphi y C++Builder, que lee y escribe XLS, XLSX y ODS sin tener Excel instalado