Artículo técnico

Round-trip de tablas dinámicas ODS en Delphi: ámbito XML

El componente HotXLS Delphi Excel conserva las tablas dinámicas de OpenDocument a lo largo de un ciclo de abrir y guardar ODS capturando tal cual 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 lleva todos los enlaces de espacio de nombres XML que declaraban sus ancestros, así que la definición de tabla dinámica guardada sigue siendo bien formada para cualquier consumidor, no solo para HotXLS

El bug que forzó ambos cambios salió de una corrida estricta del corpus. El ejemplo official-pivot.ods, escrito por una build de desarrollo de LibreOffice 6.1, contiene un pivot llamado DataPilot1 que lee Sheet1.A2:E30 y deja su resultado en Sheet1.G6:J18. Ábralo con HotXLS, guárdelo sin cambios y cuente los elementos <table:data-pilot-table> en la salida: uno a la entrada, cero a la salida, tanto en Win32 como en Win64. Nada en la prueba tocó el pivot. La primera ronda de sondeos solo comparaba constantes de celda y pasaba; la aserción estructural fue lo que dejó al descubierto la pérdida, lo cual recuerda que "los valores coinciden" es una definición débil de fidelidad de round-trip

¿Por qué desaparece una tabla dinámica ODS tras un guardado de la librería?

Una tabla dinámica ODS desaparece porque HotXLS no tiene modelo en memoria para las tablas dinámicas de OpenDocument, y el writer de ODS construye content.xml por completo a partir del modelo. El writer arma los estilos automáticos, un <table:table> por hoja, <table:content-validations>, <table:named-expressions> y <table:database-ranges>, cada uno generado a partir de objetos que el libro realmente tiene. Una definición de pivot — 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 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 usted puede construir, extender con campos calculados y refrescar desde Delphi, así que esas sobreviven al guardado porque se reescriben, no se copian. Las tablas dinámicas ODS son un pedido mucho más raro, y modelar el vocabulario de data pilot de ODF solo por el round-trip 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: conservar lo que no se modela, byte por byte si se puede, evento por evento si no

¿Qué hizo mal la primera captura basada en Pos?

La captura de la v2.382.0 recortaba la definición del pivot de content.xml como una cadena plana, y al recorte le faltaban las declaraciones de espacio de nombres que la volvían significativa. La implementación era tan corta como suena — decodificar la parte a un WideString, encontrar la etiqueta de apertura con Pos, encontrar la de cierre después de esa 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);   // todo content.xml 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 dio verde y el fix se publicó. Lo que lo atrapó fue una segunda comprobación, más estricta, añadida ese mismo día: cada parte XML del paquete guardado se pasa a un parser independiente con soporte de espacios de nombres, fuera de HotXLS, y ese parser rechazó el nuevo content.xml con un error de prefijo no ligado. El pivot de LibreOffice lleva atributos de extensión del productor — loext:ignore-selected-page="true" en un campo de página, calcext:repeat-item-labels="false" en cada nivel — y la cadena recortada contenía esos atributos pero no las declaraciones xmlns:loext y xmlns:calcext que los ligaban. Esas declaraciones estaban en la raíz <office:document-content> del archivo fuente, treinta y cinco de ellas, a dos mil caracteres de distancia del pivot

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 espacio de nombres está en ámbito desde la etiqueta de apertura del elemento donde aparece hasta la etiqueta de cierre de ese elemento, y todo nombre con prefijo dentro de ese ámbito se resuelve contra ella. Corte un subárbol fuera del documento y lo corta fuera 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 espacios de nombres trata un prefijo no ligado como una violación de buena formación, lo que significa que toda la parte queda ilegible, no solo un atributo

Qué se le escapó a la captura basada en Pos de official-pivot.ods en HotXLS: el subárbol del pivot lleva atributos de extensión loext y calcext mientras las declaraciones xmlns que los ligan están en la raíz office:document-content a treinta y cinco enlaces de distancia, así que el fragmento recortado dejó sin ligar cada prefijo que usaba y un parser con soporte de espacios de nombres rechazó todo el content.xml
Una declaración de espacio de nombres está en ámbito desde su etiqueta de apertura hasta su etiqueta de cierre, y recortar un subárbol fuera del documento lo saca de ese ámbito, lo que convierte un atributo en una parte ilegible

¿Cómo lleva HotXLS los enlaces xmlns de los ancestros al fragmento?

HotXLS v2.382.1 reemplazó el recorte de cadena por un recorrido de content.xml con su propio TXMLReader en streaming, manteniendo una pila de enlaces de espacio de nombres etiquetados con la profundidad a la que se declaró cada uno, y copiando al elemento raíz del fragmento los enlaces que siguen vigentes en el momento en que se alcanza el objetivo. El reader corre con PreserveWhitespaceText activado, así que los nodos de texto vuelven exactamente como se escribieron, y las etiquetas reconstruidas usan TXMLReader.RawName y TXMLReader.Attribute[I].RawName — la grafía del prefijo tal como está en el archivo — en lugar de los nombres canónicos que el reader suele entregar a los parsers de partes. Aquí está el núcleo del bucle:

Cómo captura HotXLS v2.382.1 el subárbol de data pilot con su ámbito de espacios de nombres: un recorrido de TXMLReader en streaming mantiene una pila de enlaces xmlns etiquetados con la profundidad declarante, la recorre de dentro hacia fuera al llegar al objetivo table:data-pilot-tables, respeta el sombreado mediante un conjunto Seen, omite los prefijos que el propio elemento declara y desapila enlaces tanto en etiquetas de cierre como en elementos vacíos
Emparejar el objetivo por el nombre canónico del reader mantiene funcionando a los productores que renombran el prefijo table, y un subárbol que nunca cierra lanza una excepción en vez de reescribir medio fragmento al guardar
// Namespaces: TStringList de 'xmlns:p=uri' con la profundidad declarante 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
      ...
      // Llevar 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í? omitir
          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 cargan 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 vuelve a ligar xmlns:table, gana el valor más cercano, exactamente como manda §6.1. Omitir los prefijos que el elemento ya declara por su cuenta evita emitir dos veces el mismo atributo, que sería otro error de buena formación distinto. Y la regla de desapilado se dispara en las etiquetas de cierre y en los elementos vacíos, porque <x/> nunca produce un evento EndElement — la misma trampa del auto-cierre que tuvo que aprender la captura de extLst en XLSX. Emparejar el objetivo por Reader.Name y no por RawName es una victoria más silenciosa: el reader canoniza el URI del espacio de nombres de tabla de ODF al prefijo table, así que un productor que lo escribe t:data-pilot-tables igual coincide, mientras el fragmento emitido conserva el prefijo que haya usado el productor

El bucle tampoco se permite adivinar. Si la parte termina mientras la captura sigue abierta — un content.xml truncado o malformado — OdsCaptureDataPilotTablesXml lanza una excepción en vez de devolver medio fragmento, porque medio fragmento se reescribirí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 queda el fragmento dentro del content.xml guardado?

HotXLS escribe el fragmento capturado dentro de <office:spreadsheet> justo después del <table:named-expressions> que genera y antes de <table:database-ranges>. El modelo de contenido de <office:spreadsheet> en ODF 1.3 Part 3 prescribe una secuencia fija para esos hijos finales, así que un bloque literal no se puede simplemente añadir donde al writer le toque estar; hay que dejarlo caer en una ranura concreta. Del lado de quien llama no hay API ni nada que configurar; la definición viaja junto con un abrir y guardar normales:

Dónde queda la definición del pivot capturada en un guardado ODS de HotXLS: los hijos de office:spreadsheet siguen la secuencia fija de ODF desde los elementos table generados hasta table:content-validations y table:named-expressions, el fragmento literal table:data-pilot-tables se inserta antes de table:database-ranges, y no existe API porque la definición viaja junto con OpenODS y SaveAsODS
Un bloque literal no se puede añadir donde al writer le toque estar, y las copias de los enlaces de ancestros que lleva son inofensivas porque Namespaces in XML permite redeclarar un prefijo en un ámbito anidado
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;   // edición dentro del rango de origen del pivot
    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 intencional y vale la pena 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. Para el ejemplo de LibreOffice el conjunto que se lleva 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 recortaría eso, y puede llegar más adelante; primero la corrección, después la compacidad

Una regla para recortar subárboles de XML y reproducirlos tal cual

La lección general es que un subárbol solo es autónomo cuando usted lo ha vuelto así, y el ámbito de espacios de nombres es lo primero que se rompe cuando se olvida. La lista de verificación que HotXLS aplica ahora a cualquier captura del tipo "conservar lo que no modelamos":

  • Recorra el documento con un reader de verdad y siga los enlaces en ámbito. La búsqueda de cadenas con Pos no puede ver el ámbito en absoluto, y además falla con elementos anidados que se llaman igual, con una cadena coincidente dentro de un comentario o una sección CDATA, y con valores de atributo que contienen el texto de la etiqueta por casualidad
  • Copie al elemento raíz del fragmento los enlaces efectivos, de dentro hacia fuera, una vez por prefijo, omitiendo lo que la raíz ya declara
  • Conserve la grafía cruda del prefijo en las etiquetas emitidas; empareje el objetivo por el espacio de nombres resuelto, no por el prefijo literal
  • Conserve los nodos de texto de espacios en blanco, y recuerde que un elemento vacío cierra su propio ámbito sin un evento de etiqueta de cierre
  • Valide la parte guardada con un parser que no sea la librería bajo prueba. La librería va a releer su propia salida tan contenta 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 ligados. El runner estricto del corpus añadido en la v2.382.1 parsea cada parte XML y .rels del paquete guardado con un parser con soporte de espacios de nombres y luego compara el árbol del pivot — etiqueta, atributos ordenados, texto, hijos, recursivamente — contra el original. Esa comparación está expandida por espacios de nombres, así que un renombrado de prefijo seguiría pasando y un prefijo no ligado 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 un pivot ODS, así que FRawOdsDataPilotTablesXml es un campo interno y el único comportamiento observable es que la definición sobrevive. El fragmento se re-serializa a partir de eventos del reader, no se copia como bytes: las comillas de los atributos y las formas auto-cerradas se normalizan, mientras el texto y los espacios en blanco se conservan. El XML capturado solo lo emite el writer de contenido ODS, así que un libro abierto desde .ods y guardado como .xlsx pierde el pivot, 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 aplican aquí como en todo lo demás. Y como la definición es opaca, no puede seguir sus ediciones: renombre Sheet1 o mueva los datos de origen en HotXLS y el pivot guardado seguirá apuntando a Sheet1.A2:E30, dejando que el consumidor reporte un rango roto la próxima vez que refresque. Una advertencia de orden va aquí también: HotXLS emite los rangos de AutoFilter como <table:database-ranges> después del fragmento del pivot, y el ejemplo del corpus no lleva ningún rango de base de datos, así que un libro con filtro y pivot a la vez debería pasar por un validador de esquema ODF antes de que usted confíe en el orden relativo de esos dos elementos

Pruebe con archivos de su propio productor, no solo con el ejemplo del corpus. El traslado de espacios de nombres maneja cualquier prefijo que un productor declare en un ancestro, pero un documento que declare un prefijo en el propio elemento del pivot, o que use un espacio de nombres por defecto para el vocabulario de tabla, ejercita las ramas de omisión y sombreado que el ejemplo de LibreOffice no toca. Ambas están implementadas; ninguna tiene todavía un ejemplo en el corpus, y esa distinción es justo el tipo de cosa que una entrada de changelog suele difuminar

La captura literal de data pilot de la v2.382.0 y el fix de ámbito de espacios de nombres de la v2.382.1 vienen en el HotXLS Delphi Excel Component actual, cuya página de producto lista la cobertura completa de lectura y escritura de ODS, XLSX y XLS para Delphi y C++Builder