HotXLS Delphi Excel Component conserva las tablas dinámicas de OpenDocument tras un ciclo de apertura y guardado ODS capturando literalmente el subárbol <table:data-pilot-tables> de content.xml al abrir y reproduciéndolo al guardar, desde la v2.382.0. Desde la v2.382.1 el fragmento también arrastra todas las declaraciones de namespace XML que hicieron sus ancestros, de modo que la definición de tabla dinámica guardada sigue siendo válida para cualquier consumidor, no solo para HotXLS
El bug que obligó a ambos cambios salió de una pasada estricta del corpus. La muestra official-pivot.ods, escrita por una build de desarrollo de LibreOffice 6.1, contiene una tabla dinámica llamada DataPilot1 que lee Sheet1.A2:E30 y deja su resultado en Sheet1.G6:J18. Ábrela con HotXLS, guárdala sin tocar nada, cuenta los elementos <table:data-pilot-table> de la salida: entra uno y sale cero, tanto en Win32 como en Win64. Nada en la prueba tocaba la tabla dinámica. La primera ronda de sondeos solo había comparado constantes de celda y pasó; la aserción estructural fue lo que dejó al descubierto la pérdida, y eso recuerda que «los valores coinciden» es una definición floja de fidelidad de ida y vuelta
¿Por qué desaparece una tabla dinámica de ODS tras guardar con la librería?
Una tabla dinámica de ODS desaparece porque HotXLS no tiene modelo en memoria para las data pilot tables de OpenDocument, y el escritor ODS construye content.xml entero a partir del modelo. El escritor arma los estilos automáticos, un <table:table> por hoja, <table:content-validations>, <table:named-expressions> y <table:database-ranges>, cada uno generado desde objetos que el libro realmente tiene. Una definición de tabla dinámica — ODF 1.3 Part 3 §9.6, un contenedor <table:data-pilot-tables> con un <table:data-pilot-table> por tabla dinámica, que lleva su table:source-cell-range, sus hijos table:data-pilot-field, su table:target-range-address y table:buttons — no tiene ningún objeto donde vivir, así que la parte regenerada simplemente la omite
El contraste con XLSX es deliberado. HotXLS parsea las cachés y las tablas dinámicas de SpreadsheetML a un modelo real que puedes crear, ampliar con campos calculados y refrescar desde Delphi, y por eso sobreviven al guardado: se reescriben, no se copian. En ODS las tablas dinámicas son una petición mucho más rara, y modelar el vocabulario data pilot de ODF solo por la fidelidad de ida y vuelta sería un montón de código que nadie edita. La respuesta pragmática es la misma que HotXLS ya aplica a los bloques extLst desconocidos en XLSX: conserva lo que no modelas, byte a byte si puedes, evento a evento si no
¿Qué hizo mal la primera captura basada en Pos?
La captura de la v2.382.0 cortaba la definición de la tabla dinámica de content.xml como una cadena plana, y al trozo le faltaban las declaraciones de namespace que le daban sentido. La implementación era tan corta como suena — decodificar la parte a un WideString, buscar la etiqueta de apertura con Pos, buscar la de cierre después y copiar el tramo a FRawOdsDataPilotTablesXml en el libro:
// HotXLS v2.382.0 -- reemplazado una versión después
function OdsCaptureDataPilotTablesXml(Stream: TStream): WideString;
const
OpenTag: WideString = '<table:data-pilot-tables';
CloseTag: WideString = '</table:data-pilot-tables>';
var
Text: WideString;
StartPos, ClosePos: Integer;
begin
Result := '';
Text := LoadPartAsWideString(Stream); // content.xml entero en memoria
StartPos := Pos(OpenTag, Text);
if StartPos = 0 then Exit;
ClosePos := Pos(CloseTag, Copy(Text, StartPos, MaxInt));
if ClosePos = 0 then Exit;
Result := Copy(Text, StartPos, ClosePos + Length(CloseTag) - 1);
end;
La aserción de conteo se puso en verde y el arreglo se publicó. Lo que lo cazó fue una segunda comprobación más estricta, añadida ese mismo día: cada parte XML del paquete guardado pasa por un parser independiente con soporte de namespaces, externo a HotXLS, y ese parser rechazó el nuevo content.xml con un error de prefijo sin enlazar. La tabla dinámica de LibreOffice trae atributos de extensión del productor — loext:ignore-selected-page="true" en un campo de página, calcext:repeat-item-labels="false" en todos los niveles — y la cadena recortada contenía esos atributos pero no las declaraciones xmlns:loext y xmlns:calcext que los enlazaban. Esas declaraciones estaban en la raíz <office:document-content> del archivo de origen, treinta y cinco de ellas, a dos mil caracteres de la tabla dinámica
W3C Namespaces in XML 1.0 §6.1 define la regla que convierte esto en un fallo duro y no en algo cosmético: una declaración de namespace está en ámbito desde la etiqueta de apertura del elemento donde aparece hasta su etiqueta de cierre, y todo nombre con prefijo dentro de ese ámbito se resuelve contra ella. Corta un subárbol del documento y lo cortas también del ámbito. HotXLS escribe su propia raíz <office:document-content> con once declaraciones — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — así que calcext: daba la casualidad de resolverse, table: también, y loext: no. Un parser con soporte de namespaces trata un prefijo sin enlazar como una violación de buena formación, lo que significa que la parte entera es ilegible, no solo un atributo
¿Cómo traslada HotXLS las declaraciones xmlns de los ancestros al fragmento?
HotXLS v2.382.1 sustituyó el recorte de cadena por una pasada sobre content.xml con su propio TXMLReader en streaming, manteniendo una pila de declaraciones de namespace etiquetadas con la profundidad a la que se declaró cada una y copiando las que siguen vigentes al elemento raíz del fragmento en el momento de alcanzar el objetivo. El reader corre con PreserveWhitespaceText activado para que los nodos de texto vuelvan exactamente como se escribieron, y las etiquetas reconstruidas usan TXMLReader.RawName y TXMLReader.Attribute[I].RawName — la grafía del prefijo tal cual está en el archivo — en vez de los nombres canónicos que el reader entrega normalmente a los parsers de parte. Aquí está el núcleo del bucle:
// Namespaces: TStringList de 'xmlns:p=uri' con la profundidad de declaración en Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // elemento, texto, CDATA, comentario
if Reader.NodeType = xmlntElement then
begin
for I := 0 to Reader.AttributeCount - 1 do
begin
AttrName := Reader.Attribute[I].RawName;
if (AttrName = 'xmlns') or (Pos(WideString('xmlns:'), AttrName) = 1) then
Namespaces.AddObject(String(AttrName) + '=' + String(Reader.Attribute[I].Value),
TObject(NativeInt(Depth)));
end;
if (CaptureDepth < 0) and (Reader.Name = 'table:data-pilot-tables') then
begin
Opening := XlsxRawXmlReaderOpenTag(Reader); // quitar primero el '>' o '/>' final
...
// Traslada los enlaces efectivos de los ancestros a la raíz del fragmento.
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // gana el enlace más interno
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // ¿ya declarado aquí? saltar
Opening := Opening + ' ' + AttrName + '="' +
XlsxEscapeAttr(WideString(Namespaces.ValueFromIndex[I])) + '"';
end;
...
CaptureDepth := Depth;
end;
if not Reader.IsEmptyElement then Inc(Depth);
end
else if Reader.NodeType = xmlntEndElement then
begin
Dec(Depth);
if Depth = CaptureDepth then Exit; // subárbol cerrado
end;
if (Reader.NodeType = xmlntEndElement) or
((Reader.NodeType = xmlntElement) and Reader.IsEmptyElement) then
while (Namespaces.Count > 0) and
(NativeInt(Namespaces.Objects[Namespaces.Count - 1]) >= Depth) do
Namespaces.Delete(Namespaces.Count - 1); // salir del ámbito
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Tres detalles de ese bucle sostienen la corrección. Recorrer la pila desde el enlace más interno hacia fuera y recordar cada prefijo en Seen implementa el sombreado: si un ancestro más cercano reenlaza xmlns:table, gana el valor más cercano, justo como §6.1 dice que debe ser. Saltar los prefijos que el propio elemento ya declara evita emitir el mismo atributo dos veces, que sería otro error de buena formación distinto. Y la regla de desapilado se dispara en las etiquetas de cierre y también en los elementos vacíos, porque <x/> nunca genera un evento EndElement — la misma trampa del autocierre que tuvo que aprender la captura de extLst en XLSX. Emparejar el objetivo por Reader.Name en vez de RawName es una victoria más silenciosa: el reader canoniza el URI del namespace table de ODF al prefijo table, así que un productor que lo escriba t:data-pilot-tables sigue emparejando, mientras el fragmento emitido conserva el prefijo que usó el productor
El bucle tampoco se permite adivinar. Si la parte termina con la captura todavía abierta — un content.xml truncado o mal formado — OdsCaptureDataPilotTablesXml lanza una excepción en lugar de devolver medio fragmento, porque ese medio fragmento se escribiría al guardar y convertiría una entrada dañada en una salida dañada con el nombre de la librería encima
¿Dónde acaba el fragmento dentro del content.xml guardado?
HotXLS escribe el fragmento capturado dentro de <office:spreadsheet> justo después de las <table:named-expressions> que genera y antes de <table:database-ranges>. El modelo de contenido que ODF 1.3 Part 3 da a <office:spreadsheet> prescribe una secuencia fija para esos hijos finales, así que un bloque literal no se puede añadir sin más allá donde esté el escritor; hay que colocarlo en una ranura concreta. Del lado del que llama no hay API ni nada que configurar; la definición viaja con un open y un save normales:
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.OpenODS('official-pivot.ods') <> 1 then
raise Exception.Create('open failed');
Book.Sheets[0].Cells[2, 5].Value := 1250.0; // edita dentro del rango de origen de la tabla dinámica
Book.SaveAsODS('official-pivot-out.ods');
// el content.xml de salida todavía lleva DataPilot1 con su
// rango de origen, campos, rango destino, botones y atributos loext:/calcext:
finally
Book.Free;
end;
end;
La redundancia es intencionada y conviene conocerla. La raíz del fragmento ahora repite xmlns:table y xmlns:calcext aunque la raíz del documento guardado también las declare; Namespaces in XML permite redeclarar un prefijo en un ámbito anidado, así que los duplicados son inofensivos. En la muestra de LibreOffice el conjunto trasladado son las treinta y cinco declaraciones de la raíz, unos dos kilobytes por encima de la definición de 8.357 caracteres, porque la captura no analiza qué prefijos usa realmente el subárbol. Un escaneo de prefijos usados lo recortaría, y puede llegar más adelante; primero la corrección, después la compacidad
Una regla para recortar subárboles de XML y reproducirlos literalmente
La lección general es que un subárbol solo es autocontenido cuando lo has hecho tú así, y el ámbito de namespaces es lo primero que se rompe cuando lo olvidas. La lista de comprobación que HotXLS aplica ya a cualquier captura del tipo «conserva lo que no modelamos»:
- Recorre el documento con un reader de verdad y lleva la cuenta de los enlaces en ámbito. Una búsqueda de cadenas con
Posno ve el ámbito en absoluto, y además falla con elementos anidados del mismo nombre, con una cadena que coincide dentro de un comentario o de una secciónCDATA, y con valores de atributo que contienen por casualidad el texto de la etiqueta - Copia los enlaces efectivos a la raíz del fragmento, de dentro hacia fuera, una vez por prefijo, saltando lo que la raíz ya declara
- Conserva la grafía cruda del prefijo en las etiquetas emitidas; empareja el objetivo por namespace resuelto, no por el prefijo literal
- Preserva los nodos de texto con espacios en blanco, y recuerda que un elemento vacío cierra su propio ámbito sin evento de etiqueta de cierre
- Valida la parte guardada con un parser que no sea la librería bajo prueba. La librería releerá su propia salida encantada por el mismo camino de código permisivo que la escribió
El último punto es el que de verdad encontró HXLS-003 la segunda vez. La comprobación de aceptación de la v2.382.0 era una expresión regular que contaba etiquetas de apertura data-pilot-table en el content.xml guardado, y una expresión regular ve una etiqueta, no un documento — es ciega a si los prefijos de esa etiqueta están enlazados. El runner estricto de corpus añadido en la v2.382.1 parsea cada parte XML y .rels del paquete guardado con un parser con soporte de namespaces y luego compara el árbol de la tabla dinámica — etiqueta, atributos ordenados, texto, hijos, recursivamente — contra el original. Esa comparación está expandida por namespace, así que una reescritura del prefijo seguiría pasando y un prefijo sin enlazar no puede
Dónde termina la garantía de literalidad
La reproducción literal conserva una definición; no la entiende, y los límites salen de ahí. HotXLS no expone ninguna API para leer, editar o refrescar una tabla dinámica ODS, así que FRawOdsDataPilotTablesXml es un campo interno y el único comportamiento observable es que la definición sobrevive. El fragmento se reserializa a partir de eventos del reader, no se copia como bytes: el entrecomillado de atributos y las formas autocerradas se normalizan, mientras que el texto y los espacios en blanco se conservan. El XML capturado solo lo emite el escritor de contenido ODS, así que un libro abierto desde .ods y guardado como .xlsx pierde la tabla dinámica, y un libro abierto desde .xlsx no tiene nada que reproducir en un guardado .ods — las asimetrías de las rutas de importación y exportación de ODS se aplican aquí como en todo lo demás. Y como la definición es opaca, no puede seguir tus ediciones: renombra Sheet1 o mueve los datos de origen en HotXLS y la tabla dinámica guardada seguirá apuntando a Sheet1.A2:E30, dejando que el consumidor informe de un rango roto la próxima vez que refresque. Aquí encaja también una advertencia de orden: HotXLS emite los rangos de AutoFilter como <table:database-ranges> después del fragmento de la tabla dinámica, y la muestra del corpus no lleva ningún rango de base de datos, así que un libro con filtro y tabla dinámica a la vez debería pasar por un validador de esquema ODF antes de fiarte del orden relativo de esos dos elementos
Prueba con archivos de tu propio productor, no solo con la muestra del corpus. El traslado de namespaces resuelve cualquier prefijo que un productor declare en un ancestro, pero un documento que declare un prefijo en el propio elemento de la tabla dinámica, o que use un namespace por defecto para el vocabulario table, ejercita las ramas de salto y de sombreado que la muestra de LibreOffice no toca. Ambas están implementadas; ninguna tiene todavía una muestra en el corpus, y esa distinción es justo el tipo de cosa que una entrada de changelog tiende a difuminar
La captura literal de data pilot de la v2.382.0 y el arreglo del ámbito de namespaces de la v2.382.1 vienen en el HotXLS Delphi Excel Component actual, cuya página de producto lista toda la cobertura de lectura y escritura de ODS, XLSX y XLS para Delphi y C++Builder