Artículo técnico

Tablas dinámicas ODS en Delphi: ámbito de namespaces XML

Un libro ODS con una tabla dinámica pasa por una librería, se edita una única celda de origen, y el archivo guardado se abre con todos los valores de celda intactos y la tabla dinámica desaparecida: content.xml contenía un <table:data-pilot-table> al entrar y contiene cero al salir. La reparación obvia es copiar el XML de la tabla dinámica como una cadena. Eso restaura el número de elementos, y después un parser consciente de namespaces rechaza la parte guardada sin más con un error de prefijo sin enlazar. Ambos fallos tienen una única raíz. Un trozo de XML solo viaja con seguridad cuando las declaraciones de namespace de las que depende viajan con él

Dónde vive una definición de tabla dinámica ODS en content.xml

Una tabla dinámica de OpenDocument, que el formato llama data pilot, es una definición almacenada después de las hojas en content.xml, y un escritor que reconstruye esa parte desde su propio modelo de objetos solo la conserva si el modelo tiene dónde ponerla. ODF 1.3 Part 3 §9.6 describe el contenedor: <table:data-pilot-tables> alberga un <table:data-pilot-table> por tabla dinámica, cada uno con su table:source-cell-range, un conjunto de hijos table:data-pilot-field, un table:target-range-address y una lista table:buttons. Las celdas de resultado que la tabla dinámica muestra están dentro de la hoja como celdas ordinarias. Así que un escritor que solo regenera celdas, estilos, validaciones, expresiones con nombre y rangos de base de datos produce una hoja de cálculo perfectamente válida con los mismos números en pantalla y ninguna tabla dinámica detrás

HotXLS Delphi Excel Component lee el subárbol <table:data-pilot-tables> completo de content.xml durante OpenODS, antes de parsear una sola celda, y después intenta traducir la colección a su modelo de tablas dinámicas. Una definición con la forma llana que el modelo representa, con un rango de origen local, un campo de fila, medidas de agregación y resultados almacenados que concuerdan con los datos de origen, se convierte en una TXLSPivotTable en la lista PivotTables de la hoja, el mismo objeto que se obtiene de una tabla dinámica XLSX y que se puede ampliar con campos calculados y refrescar desde Delphi. Todo lo que caiga fuera de esa forma conserva la colección como XML capturado, y SaveAsODS lo reescribe. Basta un atributo en un namespace que el traductor no conozca: las tablas dinámicas guardadas por LibreOffice pueden llevar atributos loext: y calcext:, y un atributo loext: manda la definición por el camino capturado

La alternativa es todo o nada por libro. Si una tabla dinámica de una colección de tres cae fuera de la forma traducible, las tres se quedan como XML capturado. Partirlas dejaría content.xml con dos fuentes de verdad para un mismo contenedor, media reconstruida desde objetos y media reproducida desde texto, y cualquier desacuerdo entre ambas mitades acabaría en el archivo del usuario. Mantener la colección entera es el valor por defecto más seguro, y es la misma política que HotXLS aplica a los bloques extLst desconocidos en XLSX: lo que el modelo no puede cargar se conserva tal como está escrito

¿Por qué una definición de tabla dinámica recortada rompe el archivo guardado?

Una definición de tabla dinámica recortada de content.xml con una búsqueda de cadenas pierde las declaraciones de namespace que dan significado a sus prefijos, y Namespaces in XML trata un prefijo sin declaración como un error de buena formación. El enfoque de cadenas es corto: decodificar la parte a un WideString, buscar <table:data-pilot-tables con Pos, buscar la etiqueta de cierre correspondiente después y copiar todo el tramo intermedio. El texto resultante parece completo. Contiene loext:ignore-selected-page="true" en un campo de página y calcext:repeat-item-labels="false" en cada nivel, pero las declaraciones xmlns:loext y xmlns:calcext que enlazan esos prefijos están en la raíz del documento, <office:document-content>, miles de caracteres antes

Namespaces in XML 1.0 §6.1 mantiene una declaración en ámbito desde la etiqueta de apertura que la lleva hasta la etiqueta de cierre correspondiente, y cada nombre con prefijo del interior se resuelve contra ella. Recorta un subárbol del documento y lo recortas de ese ámbito. Cuando el fragmento se pega en un documento nuevo, cada prefijo se resuelve solo si la nueva raíz lo declara por casualidad. HotXLS escribe su propio <office:document-content> con los namespaces que usa su escritor, entre ellos office, table, text, style, fo, draw, calcext, tableooo y of. En la copia recortada table: se resuelve, calcext: se resuelve, y loext: no. Un solo prefijo sin enlazar basta para que un parser consciente de namespaces rechace la parte entera, así que la tabla dinámica no degrada con elegancia; los datos de la hoja alrededor tampoco se pueden leer

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

La búsqueda de cadenas tiene otros puntos ciegos que una captura basada en reader nunca encuentra. Pos encuentra el texto de la etiqueta dentro de un comentario, de una sección CDATA o de un valor de atributo que lo entrecomilla. No distingue elementos anidados del mismo nombre. Y encuentra el prefijo literal table:, mientras que ODF permite a un productor enlazar el namespace table al prefijo que le apetezca

Cómo traslada HotXLS el ámbito de namespaces al fragmento

HotXLS captura el subárbol de la tabla dinámica con un reader XML en streaming que registra cada declaración de namespace junto con la profundidad a la que apareció, y copia las declaraciones aún vigentes a la etiqueta de apertura del fragmento en el momento en que llega el elemento objetivo. La pila crece con cada etiqueta de apertura que declara xmlns o xmlns:prefijo y se encoge cuando cierra el elemento que declaró el enlace. Los elementos vacíos necesitan el mismo tratamiento: <x xmlns:p="..."/> nunca produce un evento de etiqueta de cierre aparte, así que la desapilación también tiene que dispararse en la etiqueta de apertura vacía, o un enlace se cuela en sus hermanos

En el objetivo, la captura recorre la pila desde el enlace más interno hacia fuera y escribe cada prefijo una vez. Recorrer de dentro hacia fuera es lo que implementa el sombreado. Si <office:spreadsheet> reenlaza un prefijo que la raíz ya declaró, el valor más cercano es el vigente en la tabla dinámica, exactamente como exige §6.1, y un conjunto de prefijos ya escritos impide que el valor externo lo siga. Un prefijo que el elemento de la tabla dinámica declara sobre sí mismo se salta, porque escribir xmlns:table dos veces en una etiqueta es otro error de buena formación distinto

Cómo captura HotXLS el subárbol data pilot con su ámbito de namespaces: una pasada de reader XML en streaming mantiene una pila de enlaces xmlns etiquetados con la profundidad de declaración, la recorre de dentro hacia fuera en el objetivo table:data-pilot-tables, honra el sombreado mediante un conjunto de prefijos ya escritos, salta los prefijos que el propio elemento declara y desapila en etiquetas de cierre y en elementos vacíos por igual
Emparejar el objetivo por namespace y no por prefijo literal mantiene funcionando a los productores que reescriben el prefijo table, y un subárbol que nunca se cierra lanza una excepción en vez de escribir medio fragmento al guardar

El objetivo se empareja por namespace, así que un productor que escribe <t:data-pilot-tables> con t enlazado al URI table de ODF sigue siendo reconocido, mientras que las etiquetas capturadas conservan la grafía del archivo. Los nodos de texto con espacios en blanco vuelven exactamente como están escritos. El entrecomillado de atributos y la elección entre <x/> y <x></x> se normalizan, porque el fragmento se reserializa a partir de eventos del reader en lugar de copiarse byte a byte. Si content.xml termina con el subárbol de la tabla dinámica aún abierto, como ocurre en un archivo truncado, la apertura falla con una excepción en lugar de conservar media definición que se escribiría en el siguiente guardado

El conjunto trasladado es deliberadamente generoso. La captura copia todo enlace en ámbito, lo use o no el subárbol, así que la raíz del fragmento repite xmlns:table y xmlns:calcext aunque la raíz del documento guardado también las declare. Redeclarar un prefijo en un ámbito anidado es legal según Namespaces in XML, y un archivo de un productor que declara docenas de namespaces en su raíz gana un par de kilobytes. Recortar la lista significaría escanear el subárbol en busca de cada prefijo en nombres de elementos, nombres de atributos y valores de atributo que contienen QNames, y un solo fallo ahí trae de vuelta el fallo de prefijo sin enlazar. Unas cuantas declaraciones redundantes cuestan mucho menos que ese riesgo

Abrir, editar y guardar un libro que lleva una tabla dinámica

Del lado del que llama no hay nada que activar: la definición de la tabla dinámica viaja con un OpenODS y un SaveAsODS normales. Las hojas se numeran desde 1, y ambos métodos devuelven 1 en caso de éxito:

uses
  System.SysUtils, lxHandleX;

procedure EditPivotSource(const InFile, OutFile: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.OpenODS(InFile) <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [InFile]);
    // E2 cae dentro del rango de origen de la tabla dinámica de Sheet1.A2:E30
    Book.Sheets[1].Cells[2, 5].Value := 1250.0;
    if Book.SaveAsODS(OutFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [OutFile]);
  finally
    Book.Free;
  end;
end;

En el content.xml guardado, la colección de tablas dinámicas queda dentro de <office:spreadsheet> justo después de las <table:named-expressions> que genera el escritor y antes de <table:database-ranges>. Un libro que lleva a la vez un AutoFilter, que HotXLS escribe como rango de base de datos, y una tabla dinámica pone los dos elementos uno al lado del otro, y si su canalización ejecuta un validador estricto del esquema ODF, ese par es el primer sitio donde mirar

Dónde aterriza la definición capturada de la tabla dinámica en un guardado ODS de HotXLS: table:content-validations y las tablas generadas van primero, luego table:named-expressions, el fragmento literal table:data-pilot-tables y table:database-ranges, sin ninguna API porque la definición viaja con OpenODS y SaveAsODS
El bloque literal se escribe en un punto fijo dentro de office:spreadsheet, y las copias de los enlaces de ancestros que lleva son inofensivas porque Namespaces in XML permite redeclarar un prefijo en un ámbito anidado

La edición del ejemplo cambia un valor de origen y deja las celdas de resultado de la tabla dinámica como estaban. Una definición capturada se reproduce tal como está escrita: HotXLS no la recalcula, y la aplicación de hojas de cálculo que abra el archivo recalcula la tabla dinámica en su siguiente refresco. Lo mismo aplica a sus direcciones. Renombre Sheet1 o mueva el bloque de origen, y la definición guardada seguirá apuntando a Sheet1.A2:E30, lo que la aplicación consumidora informará como rango roto cuando refresque. Mantenga estables los nombres de hoja y el bloque de origen en cualquier libro cuyas tablas dinámicas viajen así

¿Qué camino siguió una tabla dinámica concreta?

La comprobación más rápida es la lista PivotTables de la hoja: una definición traducida aparece ahí como una TXLSPivotTable, y una capturada no. El informe de conversión responde a la otra mitad de la pregunta, qué le pasaría a la tabla dinámica en un formato de salida distinto. GetConversionReport inspecciona el libro sin guardarlo ni recalcularlo, y el llamador es dueño del informe devuelto:

uses
  System.SysUtils, lxHandleX, lxDiagnostics;

function DescribeOdsPivots(Book: TXLSXWorkbook): string;
var
  I, Modeled: Integer;
  Report: TXLSConversionReport;
begin
  Modeled := 0;
  for I := 1 to Book.Sheets.Count do          // Sheets es 1-based
    Inc(Modeled, Book.Sheets[I].PivotTables.Count);

  Report := Book.GetConversionReport(xlsConversionOpenXmlWorkbook);
  try
    for I := 0 to Report.Count - 1 do
      if (Report[I].Feature = 'OpenDocumentPivotTables') and
         (Report[I].Fidelity = xlsConversionDropped) then
        Exit('Captured ODF pivot XML: keep this workbook on .ods');
  finally
    Report.Free;
  end;

  if Modeled > 0 then
    Result := Format('%d pivot table(s) in the pivot model', [Modeled])
  else
    Result := 'No pivot definitions';
end;

La ruta de salida importa porque el XML capturado es vocabulario ODF y solo el escritor ODS lo emite. Guardado como .xlsx, la colección capturada no tiene contrapartida en SpreadsheetML, y el informe la lista bajo OpenDocumentPivotTables con xlsConversionDropped. El escritor binario .xlsb rechaza un libro que todavía la contiene. Una tabla dinámica traducida es un objeto de modelo ordinario y sigue las reglas del formato de destino. Las diferencias entre las rutas de importación y exportación de ODS se aplican aquí como en todas partes, y para archivos con tablas dinámicas la ruta segura es .ods dentro, .ods fuera

De la alternativa de todo o nada se sigue una regla más. Un libro o reproduce una colección capturada o escribe sus tablas dinámicas desde el modelo; nunca mezcla ambas cosas en un mismo content.xml. Añadir una tabla dinámica con AddPivotTable a un libro que ya contiene XML capturado de tablas dinámicas hace que el guardado ODS se detenga con un error en lugar de empalmar XML generado junto a XML reproducido

Comprobar la parte guardada con un segundo parser

Un número de elementos no prueba nada sobre namespaces. Una expresión regular que encuentra etiquetas de apertura data-pilot-table en el content.xml guardado ve texto, y la copia recortada de la segunda sección pasa esa prueba. Un parser consciente de namespaces que no tenga nada que ver con la librería que escribió el archivo es un juez mucho más estricto, y Delphi ya trae uno. La función de abajo es una comprobación para añadir a su propia suite de pruebas; TZipFile y TXMLDocument vienen de la RTL de Delphi, no de HotXLS:

uses
  System.SysUtils, System.Classes, System.Zip,
  Xml.xmldom, Xml.XMLIntf, Xml.XMLDoc;

// Tu propio helper de pruebas: True cuando el content.xml de un paquete .ods
// carga en un parser DOM con soporte de namespaces
function OdsContentParses(const OdsFile: string; out Error: string): Boolean;
var
  Zip: TZipFile;
  Bytes: TBytes;
  Stream: TBytesStream;
  Doc: IXMLDocument;
begin
  Error := '';
  Zip := TZipFile.Create;
  try
    Zip.Open(OdsFile, zmRead);
    Zip.Read('content.xml', Bytes);
  finally
    Zip.Free;
  end;
  Stream := TBytesStream.Create(Bytes);
  try
    Doc := TXMLDocument.Create(nil);
    try
      Doc.LoadFromStream(Stream);     // un prefijo sin enlazar falla aquí
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

Execútelo en cada .ods que su código guarde, y luego dé un paso más con los archivos que llevan tablas dinámicas y compare el subárbol de la tabla dinámica de entrada y de salida tras parsear: etiqueta, atributos ordenados, texto e hijos, recursivamente, con los nombres expandidos a URI de namespace más nombre local. Esa comparación deja pasar un prefijo reescrito y caza uno sin enlazar, que es exactamente la línea entre inofensivo y roto. Aliméntelo con archivos de cada productor que sus usuarios usen de verdad. Un documento que enlaza el namespace table a un prefijo distinto, declara un prefijo en el propio elemento de la tabla dinámica o reenlaza uno en <office:spreadsheet> ejercita las reglas de salto y de sombreado de una forma que un único archivo de LibreOffice no hace

El reader y el escritor ODS, el modelo de tablas dinámicas para XLSX y ODS, y el informe de conversión usado arriba son parte del HotXLS Delphi Excel Component; su página de producto cubre el soporte completo de lectura y escritura XLSX, XLS y ODS para Delphi y C++Builder