Artículo técnico

Validez de esquema de campos pivot de XLSX en HotXLS

HotXLS escribe definiciones de tablas dinámicas XLSX cuyos elementos pivotField y cacheField validan contra el esquema ECMA-376 Parte 1 §18.10: los atributos axis usan los tokens ST_Axis axisRow, axisCol y axisPage, los campos del área de valores llevan dataField="1", las listas de items nunca están vacías, y los campos de caché guardan un numFmtId numérico. Desde v2.384.33 el lector también honra los defaults del esquema que solía torcer

Los bugs detrás de esta limpieza comparten un rasgo poco halagüeño: ninguno suspendió jamás un test. HotXLS escribía un pivot, HotXLS lo releía, cada campo aterrizaba en el axis correcto, y la suite de round-trip estuvo en verde durante años. El problema era que el escritor y el lector habían acordado en secreto un dialecto privado. Un pivot construido desde Delphi se veía bien ante el componente que lo hizo, mientras que una comprobación contra CT_PivotField y CT_CacheField destapaba tokens de enumeración inválidos, un elemento vacío que el esquema prohíbe y flags que Excel espera pero nunca recibió. Si generas pivots en un servidor y se los envías a gente que los abre en Excel o se los pasa a sus propios parsers, el único contrato que cuenta es el esquema, no lo que tu propio lector le pase por alto

¿Por qué los round trips de HotXLS nunca cazaron los tokens de axis equivocados?

Los round trips de HotXLS nunca cazaron los tokens de axis equivocados porque el lector aceptaba ambas grafías. El viejo XlsxPivotAxisAttr emitía axis="rowAxis", colAxis y pageAxis, que se leen con naturalidad en inglés pero no existen en el esquema; ST_Axis define exactamente cuatro valores, axisRow, axisCol, axisPage y axisValues. Entretanto, PivotAxisFromToken en lxPivotXml.pas aceptaba tanto el token del esquema como el inventado, así que todos los autotests pasaban. El escritor ahora emite solo los tokens del esquema, y el lector sigue aceptando las viejas grafías para que los archivos guardados por versiones anteriores de HotXLS sigan cargando con su layout intacto

<!-- antes de v2.384.33: valor ST_Axis inválido, CT_Items vacío -->
<pivotField axis="rowAxis" defaultSubtotal="1"><items count="0"></items></pivotField>

<!-- desde v2.384.33 -->
<pivotField axis="axisRow" defaultSubtotal="1">
  <items count="4"><item x="0"/><item x="1"/><item x="2" h="1"/><item t="default"/></items>
</pivotField>
pivotField XML de HotXLS antes y después de v2.384.33 donde el valor de axis inventado rowAxis y un elemento items vacío violan CT_PivotField hasta que el escritor emite tokens ST_Axis como axisRow con entradas de item reales, un flag hidden conservado y un subtotal default final que el esquema acepta
El lector indulgente aceptaba ambas grafías, así que todos los round trips pasaban mientras el archivo suspendía cualquier comprobación estricta de esquema — escribe solo los cuatro tokens ST_Axis y deja que CT_Items lleve al menos un item

¿Qué exige CT_PivotField que el viejo escritor se saltaba?

CT_PivotField exige tres cosas que el viejo BuildPivotTableXml omitía o torcía. Primera, un campo agregado en el área de valores debe decirlo en su propia definición con dataField="1"; el escritor ahora pone ese flag en cada campo referenciado por una entrada de DataFields, y no solo en la lista <dataFields>. Segunda, CT_Items necesita al menos un item, así que un campo sin items ya no recibe un <items count="0"> vacío y el elemento entero simplemente se omite. Tercera, cada item conserva su estado: h="1" para un item oculto (TXLSPivotItem.IsHidden) y sd="0" para detalles plegados (IsDetailHidden), ambos caídos en cada guardado del viejo escritor

Lo sutil son los items de subtotal finales. Cuando un campo tiene items, Excel lista un item extra por función de subtotal después de los items de datos, tipados con ST_ItemType: <item t="default"/> para el subtotal automático, y luego sum, countA, avg, max, min, product, count, stdDev, stdDevP, var y varP para los explícitos. HotXLS deriva esas entradas de TXLSPivotField.Subtotals al guardar y las cuenta dentro de items count. Los campos creados por AddPivotTable arrancan con un conjunto Subtotals vacío, que escribe defaultSubtotal="0" y ningún item final, así que pide los subtotals explícitamente cuando el informe los necesita. Fíjate en la trampa de nombres: xlpsCount mapea a countA (todas las entradas) y xlpsCountNums mapea a count (solo números)

Anatomía de la lista de items de pivot de HotXLS donde las entradas de items de datos van seguidas de items de subtotal finales derivados de TXLSPivotField.Subtotals como t=default y t=avg y contados dentro del items count, con la trampa de nombres xlpsCount a countA y xlpsCountNums a count explicada
Los campos de AddPivotTable arrancan con un conjunto Subtotals vacío, que escribe defaultSubtotal=0 y ningún item final — pide las funciones que quieras y el escritor deriva un item por función dentro del count
uses
  lxHandleX, lxPivot;

var
  Book  : TXLSXWorkbook;
  Sheet : TXLSXWorksheet;
  Pivot : TXLSPivotTable;
  Region: TXLSPivotField;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('orders.xlsx');
    Sheet := Book.Sheets[1];                  // base 1, como el motor XLS
    Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 3, 6, 'RegionTotals');
    if Pivot = nil then
      raise Exception.Create('Bad source range or anchor');

    Region := Pivot.AddRowField('Region');    // nil si no existe tal campo
    if Region <> nil then
      Region.Subtotals := [xlpsDefault, xlpsAverage];  // -> t="default", t="avg"
    Pivot.AddColumnField('Quarter');
    Pivot.AddDataFieldByName('Revenue', xlpaSum);      // marca Revenue con dataField="1"

    Book.SaveAs('orders-pivot.xlsx');
  finally
    Book.Free;
  end;
end;

¿Cómo lee HotXLS los items de subtotal y los defaults del esquema ahora?

El lector de HotXLS ahora se salta cualquier item cuyo atributo t esté presente y no sea data, porque las entradas de subtotal, gran total y vacío no llevan índice de caché. Antes de v2.384.34 esas entradas se cargaban como items ordinarios con CacheItemIndex a -1, así que un pivot hecho por Excel volvía con miembros fantasma que no apuntaban a ninguna parte, y cualquier código que recorriera Items tenía que filtrarlos a mano. Como el escritor reconstruye las entradas finales desde Subtotals, el trabajo del lector es traducirlas a ese conjunto, no conservarlas como datos

El segundo fix del lector va de atributos ausentes. En el esquema, defaultSubtotal en CT_PivotField y containsString en CT_SharedItems valen true por defecto, y Excel los omite cuando llevan ese default. HotXLS leía un atributo ausente como false, con lo que cada pivot guardado por Excel perdía en silencio su subtotal por defecto al cargar, y un campo de caché de texto plano se clasificaba como mixto en vez de cadena. Es la imagen especular del bug del axis: un escritor que siempre escribe todos los atributos jamás ejercita el camino del default, así que solo los archivos de otro productor lo exponen

¿Por qué era inválido numFmtId="General" en los cache fields?

El valor numFmtId="General" era inválido porque ST_NumFmtId es un entero sin signo, no un nombre de formato. El viejo escritor de caché metía esa cadena a fuego en cada cacheField, tomando prestado el nombre que los usuarios ven en el diálogo Formato de celdas. HotXLS escribe ahora el NumberFormat del campo de caché como número, que es 0 (el formato General integrado) salvo que algo lo haya cambiado. Un parser estricto que tipa los atributos desde el esquema rechaza el viejo valor sin más, y esa es exactamente la clase de fallo que degenera en un diálogo de reparación; el artículo sobre las reglas OPC y de markup detrás del aviso de reparación de Excel cubre cómo se disparan esos diálogos

¿Por qué se cortaban las tablas dinámicas en la fila 65535 o inferior?

Las tablas dinámicas XLSX colocadas en la fila 65536 o por debajo se cortaban porque el modelo compartido de pivot guardaba FirstRow, LastRow, FirstHeaderRow, FirstDataRow y sus equivalentes de columna como Word, y el código de desplazamiento de filas los recortaba con Min(.., High(Word)). Es un resto del record SxView de BIFF8, donde 16 bits alcanzan, pero una hoja XLSX llega a 1.048.576 filas. Desde v2.384.37 esas propiedades de TXLSPivotTable son Integer, los recortes han desaparecido, y solo el escritor BIFF8 estrecha los valores. TXLSXWorksheet.AddPivotTable y AddPivotTableCopy devuelven ahora nil para un ancla fuera de 1..1048576 por 1..16384, o para una copia cuya extensión se saldría de la cuadrícula

Ancla de pivot de HotXLS en la fila 70001 contra el techo de 16 bits donde FirstRow y LastRow se guardaban como Word y se recortaban con Min contra High(Word) en 65535, cortando los pivots en o bajo la línea hasta que v2.384.37 movió el modelo a campos Integer con un retorno nil fuera de la cuadrícula
Los campos Word eran un resto de SxView de BIFF8 en un formato cuyas hojas llegan a 1048576 filas — un ancla más allá de la fila 65536 se envolvía al rango de 16 bits y perdía su pivot al guardar
var
  Pivot: TXLSPivotTable;
  Check: TXLSXWorkbook;
begin
  // La fila 70001 se envolvía al rango de 16 bits; ahora sobrevive a guardar y cargar
  Pivot := Sheet.AddPivotTable('Data!$A$1:$D$800', 70001, 1, 'LateTotals');
  if Pivot = nil then
    Exit;  // ancla fuera de la hoja o rango origen irresoluble
  Pivot.AddRowField('Region');
  Pivot.AddDataFieldByName('Revenue', xlpaSum);
  Book.SaveAs('late.xlsx');

  Check := TXLSXWorkbook.Create;
  try
    Check.Open('late.xlsx');
    Pivot := Check.Sheets[1].PivotTables.FindByName('LateTotals');
    Assert((Pivot <> nil) and (Pivot.FirstRow = 70001));
  finally
    Check.Free;
  end;
end;

El motor XLS clásico recibió el fix correspondiente en v2.384.38. Su modelo guardaba los valores crudos base 0 de SxView y DConRef y dejaba pasar las anclas de AddPivotTable tal cual, mientras la documentación, las demos y el motor XLSX usaban celdas base 1 como Cells[Row, Col]. Ambos motores guardan ahora posiciones base 1 en el modelo, el lector BIFF8 suma 1 y el escritor resta 1 en el límite del record, así que el código que anclaba en (0, 0) debe pasar a (1, 1), porque el AddPivotTable clásico devuelve ahora nil para un ancla fuera de 1..65536 por 1..256; la nueva llamada escribe los mismos bytes que la vieja. El layout del record en sí no cambia y está descrito en los records SX de BIFF8 detrás de las tablas dinámicas .xls clásicas

Valida contra el esquema, no contra tu propio lector

La lección generaliza más allá de los pivots: un lector indulgente esconde violaciones del escritor, así que un round trip por tu propio código prueba consistencia, no corrección. Cada bug de aquí sobrevivió porque el lado tolerante y el lado defectuoso vivían en la misma biblioteca. Las comprobaciones que de verdad cazan esta clase de defecto son una validación de esquema de las partes generadas, archivos producidos por Excel pasados por tu lector con atributos omitidos en sus defaults, y fixtures que fijen el token exacto en vez del resultado parseado. Los pivots que construyes vía la API, incluidos los campos calculados, los items calculados y los layouts de porcentaje del total que se muestran en construir y refrescar tablas dinámicas XLSX con campos calculados, reciben el XML corregido sin cambio de código, mientras que los pivots cargados de archivos de Excel siguen reproduciendo sus partes originales hasta que los modifiques

Todos estos fixes vienen en el componente de hojas de cálculo HotXLS para Delphi actual, que lee y escribe XLS, XLSX y tablas dinámicas desde Delphi y C++Builder sin Excel ni automatización COM en la máquina