Artigo Técnico

Round-trip de data pilot ODS no Delphi: escopo de namespace

Um workbook ODS com uma tabela pivô passa por uma biblioteca, uma única célula de origem é editada, e o arquivo salvo abre com todos os valores de célula intactos e o pivô sumido: o content.xml continha um <table:data-pilot-table> na entrada e contém zero na saída. O reparo óvio é copiar o XML do pivô como string. Isso restaura a contagem de elementos, e aí um parser ciente de namespaces recusa a part salva de cara com um erro de unbound-prefix. As duas falhas têm uma raiz só. Um pedaço de XML só viaja com segurança quando as declarações de namespace de que ele depende viajam junto

Onde a definição de pivô ODS vive no content.xml

Uma tabela pivô OpenDocument, que o formato chama de data pilot, é uma definição armazenada depois das planilhas no content.xml, e um writer que reconstrói essa part do próprio object model dele só a mantém se o modelo tiver onde colocá-la. A ODF 1.3 Part 3 §9.6 descreve o contêiner: <table:data-pilot-tables> guarda um <table:data-pilot-table> por pivô, cada um com um table:source-cell-range, um conjunto de filhos table:data-pilot-field, um table:target-range-address e uma lista table:buttons. As células de resultado que o pivô exibe ficam dentro da worksheet como células comuns. Então um writer que só regenera células, estilos, validações, named expressions e database ranges produz uma planilha perfeitamente válida com os mesmos números na tela e nenhum pivô por trás deles

O HotXLS Delphi Excel Component lê a subárvore <table:data-pilot-tables> inteira do content.xml durante o OpenODS, antes de parsear uma única célula, e então tenta traduzir a coleção para o modelo de pivô dele. Uma definição na forma simples que o modelo representa, com intervalo de origem local, um campo de linha, medidas de agregação e resultados armazenados que batem com os dados de origem, vira uma TXLSPivotTable na lista PivotTables da worksheet, o mesmo objeto que você obtém de um pivô XLSX e pode estender com calculated fields e atualizar do Delphi. Qualquer coisa fora dessa forma mantém a coleção como XML capturado, e o SaveAsODS a grava de volta. Um atributo num namespace que o tradutor não conhece já basta: pivôs salvos pelo LibreOffice podem carregar atributos loext: e calcext:, e um atributo loext: manda a definição pelo caminho capturado

O fallback é tudo ou nada por workbook. Se um pivô numa coleção de três cai fora da forma traduzível, os três ficam como XML capturado. Separá-los deixaria o content.xml com duas fontes de verdade para um contêiner, metade reconstruída de objetos e metade repetida de texto, e qualquer discordância entre as metades cairia no arquivo do usuário. Manter a coleção inteira é o padrão mais seguro, e é a mesma política que o HotXLS aplica a blocos extLst desconhecidos no XLSX: o que o modelo não consegue carregar é mantido como foi escrito

Por que uma definição de pivô fatiada quebra o arquivo salvo?

Uma definição de pivô cortada do content.xml com busca de string perde as declarações de namespace que dão significado aos prefixos dela, e a spec Namespaces in XML trata um prefixo sem declaração como erro de boa formação. A abordagem por string é curta: decodifique a part para um WideString, ache <table:data-pilot-tables com Pos, ache a tag de fechamento correspondente depois dela e copie tudo no meio. O texto que sai parece completo. Ele contém loext:ignore-selected-page="true" num page field e calcext:repeat-item-labels="false" em todos os níveis, mas as declarações xmlns:loext e xmlns:calcext que ligam esses prefixos ficam na raiz do documento, <office:document-content>, milhares de caracteres antes

Namespaces in XML 1.0 §6.1 mantém uma declaração em escopo da start tag que a carrega até a end tag correspondente, e todo nome com prefixo dentro resolve contra ela. Corte uma subárvore do documento e você a corta desse escopo. Quando o fragmento é colado num documento novo, cada prefixo só resolve se a nova raiz por acaso o declarar. O HotXLS escreve o próprio <office:document-content> dele com os namespaces que o writer dele usa, entre eles office, table, text, style, fo, draw, calcext, tableooo e of. Na cópia fatiada table: resolve, calcext: resolve, e loext: não. Um prefixo sem ligação basta para um parser ciente de namespaces rejeitar a part inteira, então o pivô não degrada graciosamente; os dados de planilha em volta dele também ficam ilegíveis

O que a captura baseada em Pos de official-pivot.ods perdeu no HotXLS: a subárvore do pivô carrega atributos de extensão loext e calcext, enquanto as declarações xmlns que os ligam ficam na raiz office:document-content a trinta e cinco ligações de distância, então o fragmento fatiado deixou todo prefixo que ele usava sem ligação e um parser ciente de namespaces rejeitou o content.xml inteiro
Uma declaração de namespace vale da sua tag de abertura até a sua tag de fechamento, e cortar uma subárvore para fora do documento a corta para fora desse escopo, o que transforma um atributo numa part ilegível

Busca de string tem outros pontos cegos que uma captura baseada em reader nunca encontra. O Pos casa o texto da tag dentro de um comentário, dentro de uma seção CDATA ou dentro de um valor de atributo que o cite. Ele não distingue elementos aninhados de mesmo nome. E ele casa o prefixo literal table:, enquanto a ODF permite que um produtor ligue o namespace table a qualquer prefixo que preferir

Como o HotXLS leva o escopo de namespace para o fragmento

O HotXLS captura a subárvore do pivô com um reader XML de streaming que rastreia cada declaração de namespace junto com a profundidade em que apareceu, e copia as declarações ainda em vigor para a tag de abertura do fragmento no momento em que o elemento alvo chega. A pilha cresce a cada start tag que declara xmlns ou xmlns:prefix e encolhe quando o elemento que declarou uma ligação fecha. Elementos vazios precisam do mesmo tratamento: <x xmlns:p="..."/> nunca produz um evento separado de end-tag, então o pop precisa disparar na start tag vazia também, senão uma ligação vaza para os irmãos

No alvo, a captura percorre a pilha da ligação mais interna para fora e escreve cada prefixo uma vez. Percorrer de dentro para fora é o que implementa shadowing. Se a <office:spreadsheet> religa um prefixo que a raiz já declarou, o valor mais próximo é o que vale no pivô, exatamente como o §6.1 exige, e um conjunto de prefixos já escritos impede que o valor externo o siga. Um prefixo que o próprio elemento do pivô declara é pulado, porque escrever xmlns:table duas vezes na mesma tag é um outro erro de boa formação

Como o HotXLS captura a subárvore data pilot com o escopo de namespace dela: uma passada de reader XML de streaming mantém uma pilha de ligações xmlns marcadas com a profundidade de declaração, percorre ela do mais interno para fora no alvo table:data-pilot-tables, respeita o shadowing por um conjunto de prefixos já escritos, pula prefixos que o próprio elemento declara e faz pop das ligações tanto em end tags quanto em elementos vazios
Casar o alvo por namespace em vez de por prefixo literal mantém funcionando produtores que reescrevem o prefixo table, e uma subárvore que nunca fecha lança exceção em vez de gravar meio fragmento de volta no save

O alvo é casado por namespace, então um produtor que escreve <t:data-pilot-tables> com t ligado ao URI de table da ODF continua sendo reconhecido, enquanto as tags capturadas mantêm a grafia do arquivo. Nós de texto com whitespace voltam exatamente como escritos. O quoting de atributos e a escolha entre <x/> e <x></x> são normalizados, já que o fragmento é re-serializado dos eventos do reader em vez de copiado byte a byte. Se o content.xml termina com a subárvore do pivô ainda aberta, como acontece num arquivo truncado, a abertura falha com exceção em vez de guardar meia definição que seria gravada de volta no próximo save

O conjunto levado é deliberadamente generoso. A captura copia toda ligação em escopo, use a subárvore ou não, então a raiz do fragmento repete xmlns:table e xmlns:calcext mesmo que a raiz do documento salvo também as declare. Redeclarar um prefixo num escopo aninhado é legal sob a spec Namespaces in XML, e um arquivo de um produtor que declara dezenas de namespaces na raiz ganha alguns kilobytes. Podar a lista significaria escanear a subárvore atrás de cada prefixo em nomes de elemento, nomes de atributo e valores de atributo que guardam QNames, e um único erro aí traz de volta a falha de unbound-prefix. Umas declarações redundantes custam muito menos que esse risco

Abrindo, editando e salvando um workbook que carrega um pivô

Do lado de quem chama não há nada para ligar: a definição do pivô vem junto com um OpenODS e um SaveAsODS comuns. As worksheets são numeradas a partir de 1, e os dois métodos devolvem 1 em caso de sucesso:

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]);
    // A E2 está dentro de um intervalo de origem do pivô 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;

No content.xml salvo, a coleção de pivôs fica dentro da <office:spreadsheet> logo depois de <table:named-expressions> que o writer gera e antes de <table:database-ranges>. Um workbook que carrega tanto um AutoFilter, que o HotXLS grava como database range, quanto um pivô põe os dois elementos lado a lado, e se o seu pipeline roda um validador de schema ODF estrito, esse par é o primeiro lugar a olhar

Onde a definição de pivô capturada aterrissa num save ODS do HotXLS: table:content-validations e as tabelas geradas vêm primeiro, depois table:named-expressions, o fragmento verbatim de table:data-pilot-tables e table:database-ranges, sem API porque a definição vem junto com OpenODS e SaveAsODS
O bloco verbatim é gravado num ponto fixo dentro de office:spreadsheet, e as cópias das ligações dos ancestrais que ele carrega são inofensivas porque Namespaces in XML permite redeclarar um prefixo num escopo aninhado

A edição naquele exemplo muda um valor de origem e deixa as células de resultado do pivô como estavam. Uma definição capturada é repetida como escrita: o HotXLS não a recalcula, e o aplicativo de planilha que abre o arquivo recalcula o pivô no próximo refresh dele. O mesmo vale para os endereços. Renomeie Sheet1 ou mova o bloco de origem, e a definição salva ainda aponta para Sheet1.A2:E30, que o aplicativo consumidor reporta como intervalo quebrado quando atualiza. Mantenha nomes de planilha e o bloco de origem estáveis em qualquer workbook cujos pivôs viajem desse jeito

Que caminho um dado pivô tomou?

A verificação mais rápida é a lista PivotTables da worksheet: uma definição traduzida aparece lá como TXLSPivotTable, e uma capturada não. O relatório de conversão responde à outra metade da pergunta, o que aconteceria com o pivô num formato de saída diferente. A GetConversionReport inspeciona o workbook sem salvá-lo ou recalculá-lo, e quem chama é dono do relatório devolvido:

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 é base 1
    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;

O caminho de saída importa porque o XML capturado é vocabulário ODF e só o writer ODS o emite. Salvo como .xlsx, a coleção capturada não tem contraparte em SpreadsheetML, e o relatório a lista sob OpenDocumentPivotTables com xlsConversionDropped. O writer binário .xlsb recusa um workbook que ainda a contenha. Um pivô traduzido é um objeto de modelo comum e segue as regras do formato alvo em vez disso. As diferenças entre os caminhos de importação e exportação ODS valem aqui como em todo lugar, e para arquivos com pivôs a rota segura é .ods entra, .ods sai

Mais uma regra decorre do fallback tudo-ou-nada. Um workbook ou repete uma coleção capturada ou escreve os pivôs dele do modelo; ele nunca mistura os dois num mesmo content.xml. Adicionar um pivô com AddPivotTable a um workbook que já contém XML de pivô capturado faz o save ODS parar com erro em vez de emendar XML gerado ao lado de XML repetido

Conferindo a part salva com um segundo parser

Uma contagem de elementos não prova nada sobre namespaces. Uma expressão regular que acha start tags data-pilot-table no content.xml salvo vê texto, e a cópia fatiada da segunda seção passa nesse teste. Um parser ciente de namespaces que não tem nada a ver com a biblioteca que escreveu o arquivo é um juiz muito mais estrito, e o Delphi já vem com um. A função abaixo é uma verificação para você acrescentar à sua própria suíte de teste; TZipFile e TXMLDocument vêm da RTL do Delphi, não do HotXLS:

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

// Seu próprio helper de teste: True quando o content.xml de um pacote .ods
// carrega num parser DOM ciente 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);     // um prefixo sem ligação falha aqui
      Result := True;
    except
      on E: EDOMParseError do
      begin
        Error := E.Message;
        Result := False;
      end;
    end;
  finally
    Stream.Free;
  end;
end;

Rode em todo .ods que seu código salva, depois vá um passo além para arquivos com pivôs e compare a subárvore do pivô da entrada e da saída depois de parsear: tag, atributos ordenados, texto e filhos, recursivamente, com nomes expandidos para URI de namespace mais nome local. Essa comparação deixa passar um prefixo reescrito e pega um sem ligação, que é exatamente a linha entre inofensivo e quebrado. Alimente-a com arquivos de todo produtor que seus usuários realmente rodam. Um documento que liga o namespace table a um prefixo diferente, declara um prefixo no próprio elemento do pivô ou religa um na <office:spreadsheet> exercita as regras de pular e shadowing de um jeito que um único arquivo do LibreOffice não exercita

O reader e o writer ODS, o modelo de pivô para XLSX e ODS, e o relatório de conversão usado acima fazem parte do HotXLS Delphi Excel Component; a página do produto cobre o suporte completo de leitura e gravação XLSX, XLS e ODS para Delphi e C++Builder