Artículo técnico

Enmarcar records de PivotCache BIFF con HotXLS en Delphi

El substream PivotCache de BIFF almacena el dataset cacheado de un PivotTable separado de la vista que lo muestra, y HotXLS lee y escribe ese substream inspeccionando los cuerpos de los records en lugar de confiar en los números de record. Esa distinción es toda la historia: el mismo número de record carga dos layouts de cuerpo incompatibles según qué writer produjo el archivo, así que el lector decide el framing a partir del primer cuerpo de record que ve

Usted se encuentra con esta capa en el momento en que un PivotTable tiene que sobrevivir a un round trip. Una vista pivot sin su caché es una cáscara, y Excel reconstruirá el caché desde el rango fuente cuando abra el archivo, lo cual está bien justo hasta el punto donde el rango fuente ya no existe, los datos se pegaron desde una consulta, o el workbook es un cierre archivado que no debe cambiar cuando alguien lo abra

Dos estructuras, dos lugares en el archivo

Los datos cacheados y la definición del caché viven en partes distintas del workbook, y confundirlos es lo primero que hay que evitar. Los records cacheados forman su propio substream, dado en [MS-XLS] §2.1.7.12 como PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Note lo que falta: no hay BOF a la cabeza de esa producción

La definición vive en cambio en los workbook globals, como PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), posicionada después de los records de formato y antes de los records BoundSheet y Country. Así, un solo caché queda descrito en dos lugares separados por cientos de records, y el enlace entre ellos es un identificador de stream que tiene que coincidir en tres ubicaciones a la vez

Framing PivotCache de HotXLS en BIFF8: el PIVOTCACHEDEFINITION con su SXStreamID vive en los workbook globals después del formato y antes de BoundSheet, mientras que los records cacheados viven en un stream bajo el storage _SX_DB_CUR nombrado con hex mayúsculo de cuatro dígitos que contiene records SXDB, SXDBEx, SXFORMULA, FDB y DBB sin BOF, y SXStreamID.idStm, el campo idstm de SXDB y el nombre del stream deben coincidir
Un solo caché pivot queda descrito en dos lugares separados por cientos de records, unidos por un identificador de stream que tiene que coincidir en los globals, el encabezado SXDB y el nombre del substream a la vez

Cada caché pertenece a un stream bajo _SX_DB_CUR cuyo nombre es la grafía hexadecimal mayúscula de cuatro dígitos de su identificador. SXStreamID.idStm, el campo idstm repetido en el encabezado SXDB, y ese nombre de stream tienen que coincidir todos. Cuando asigne un identificador nuevo, reserve primero cada número ya leído del archivo, o un caché nuevo puede reclamar un número que pertenece a un caché viejo al que el lector aún no llegó

Un identificador más atrapa a la gente. El valor iCache de una vista pivot es la posición base cero del SXStreamID correspondiente en la secuencia global, no un identificador de caché que usted pueda elegir. Al escribir tiene que mapearse desde el objeto caché hacia su posición real de salida, y las vistas existentes tienen que renumerarse junto con él, o actualizar un caché apunta silenciosamente una vista hacia otro distinto

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET, MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // confiar en los records cacheados
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // limpiar, y luego dimensionar la grilla
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

El doble SetRecordCount no es superstición. RecordCount es una escritura de propiedad plana que no asigna, y la ruta interna de crecimiento solo inicializa las filas recién agregadas, así que un caché cuyo conteo se fijó por la ruta del encabezado puede terminar con una grilla de índices de longitud cero. Las escrituras a RecordIndices se descartan entonces sin error. Fijar el conteo a cero y de vuelta re-establece la grilla, y tiene que ocurrir después de que cada campo haya sido agregado, porque el ancho de fila sale del conteo de campos

¿Por qué un número de record no puede decirle el layout del cuerpo?

Porque los números de record y los layouts de cuerpo cambiaron en momentos distintos, así que el mapeo entre ellos no es una función. Un número del set legado solo aparece jamás en archivos de writers viejos, lo que lo vuelve una señal confiable en una dirección. Otro número es genuinamente ambiguo: aparece tanto en archivos correctos como en un rango de versiones intermedias que usaban el número nuevo con el layout de cuerpo viejo

Por eso el framing tiene que decidirse desde el cuerpo, y una vez por substream de caché en lugar de por record. HotXLS fija el dialecto a partir de la longitud del primer record SXDBB de cada substream. En el framing de la especificación, un SXDBB contiene exactamente un record de caché, así que su longitud iguala un ancho de fila. En el framing empaquetado viejo, el primer record contiene tantas filas como quepan, así que para cualquier caché con más de una fila es de al menos dos anchos de fila. La comparación es decisiva siempre que las dos predicciones difieran

Latch de framing SXDBB de HotXLS: un número de record carga dos layouts de cuerpo incompatibles, así que el lector compara la longitud del primer record SXDBB contra el ancho de fila; un ancho de fila fija el dialecto de la especificación mientras dos o más anchos de fila fijan el framing empaquetado legado, los empates toman la lectura de la especificación, y el dialecto se fija una vez por substream de caché, no por record
Los números de record no pueden decidir el layout del cuerpo porque ambos cambiaron en momentos distintos, así que HotXLS fija el dialecto una vez por substream a partir de la primera longitud SXDBB y toma la lectura de la especificación en caso de empate

Cuando no difieren, el lector toma la lectura de la especificación, bajo el principio de que los archivos escritos por Excel superan en número a los archivos escritos por una build intermedia. Ese punto ciego es angosto por construcción y, cuando ocurre, el archivo en sí se sigue reproduciendo byte a byte. Solo los índices tipados expuestos a quienes llaman resultan afectados

El ancho del índice vive en otro record

SXDBB (§2.4.276) carga un índice por campo de caché cuyo flag de valores distintos esté activo, en orden de campos, y el ancho de cada índice se decide en otra parte: el record de campo SXFDB correspondiente (§2.4.283) declara un flag de short items, y ese flag dice si el índice ocupa dos bytes o uno. Dos records, un contrato implícito, y una sola oración en la especificación conectándolos

Ese acoplamiento es exactamente donde una codificación casera se equivoca. Un writer anterior de HotXLS empaquetaba cada campo en el mínimo número de bits, rellenando hasta un límite de byte entre filas, lo cual es defendible en aislamiento y contradice directamente el ancho que el mismo writer acababa de declarar en SXFDB. Un campo con tres valores distintos quedaba descrito con un byte de ancho en un record y ocupaba dos bits en el otro. El arreglo no fue corregir la aritmética sino extraer la decisión de ancho a una función que ambos emisores llaman, para que los dos records ya no puedan separarse. Es la misma clase de defecto descrita en drift entre declaración de longitud y cuerpo real en records BIFF, donde un tamaño declarado y un cuerpo real se separan

La consecuencia de no leer estos records en absoluto vale la pena deletrearla, porque es fácil subestimarla. Cuando el lector saltaba los índices de record, cada caché cargado desde un archivo reportaba índice cero para cada campo de cada fila, lo que significa que cada fila apuntaba al primer valor de cada campo. Eso no es meramente introspección reducida: la ruta de evaluación pivot y la ruta de llenado caché-a-celda consumen ambas esa grilla. Y un test de round-trip no puede detectarlo, porque un caché que sigue en raw replay se escribe de vuelta desde sus bytes originales

// Los flags de provenance le dicen qué está sosteniendo y qué puede reescribirse
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // Re-emitir solo es lossless cuando cada record tiene un modelo aquí
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

¿Cuándo es lossless reescribir un caché?

Solo cuando tres condiciones se sostienen juntas, y CanUpgradeFraming es la única propiedad que responde la pregunta. El caché debe seguir en raw replay, el substream debe estar en uno de los framings que esta biblioteca escribió incorrectamente en el pasado, y el lector debe haber construido un modelo tipado completo de cada record dentro de él. Un caché que escribió Excel nunca califica, porque su substream trae records para los que HotXLS no tiene modelo, y re-emitir desde el modelo los perdería

La prueba de completitud es más estricta de lo que parece a primera vista. Un record que el lector conservó solo como bytes opacos marca el modelo como incompleto. También un conteo declarado de records de fórmula que el emisor no puede reproducir, porque re-emitir reescribiría una declaración de varios records de fórmula como una declaración de ninguno, y un valor en el archivo que no se puede reproducir equivale a un record que no se puede reproducir

El conservadurismo deliberado atraviesa también al writer. Los índices se hacen clamp al rango legal en lugar de codificarse como un sentinel fuera de banda, porque la especificación define un índice dentro de la secuencia de valores distintos y nada más, y una celda vacía es en sí misma un valor en esa secuencia. Un cuerpo de record de caché que exceda el techo de record BIFF no se escribe en absoluto, lo que requeriría miles de campos de caché y de todos modos es inalcanzable dentro del límite de columnas de BIFF8; el fallback es que Excel refresca desde el rango fuente, que es comportamiento definido y no un archivo corrupto

Las fechas cargan la última dependencia cruzada de records. La conversión serial-a-fecha depende del sistema de fechas del workbook, y el emisor de records no puede ver el workbook, así que la elección de fecha base se pasa como parámetro que toma por default el sistema 1900 y es suministrado por la ruta de guardado a nivel workbook. Bajo el sistema 1900 el número serial es el valor directo; el sistema 1904 difiere por 1462 días. El tratamiento más amplio de los seriales de fecha está en seriales de fecha, el sistema 1904 y formatos numéricos

Si usted trabaja en la capa de vista y no en la de caché, los records que describen el pivot visible están cubiertos en el set de records PivotTable de BIFF8, y el comportamiento del lado de cálculo en campos calculados, items calculados y refresh. Las tres capas vienen en el HotXLS Delphi spreadsheet component, que es lo que hace posible cargar un workbook legado, inspeccionar qué contiene realmente su caché, y decidir si reescribirlo es seguro antes de hacerlo