Artigo Técnico

Round-trip de tabelas dinâmicas ODS e âmbito de namespaces

O HotXLS Delphi Excel Component mantém as tabelas data pilot do OpenDocument ao longo de um ciclo de abrir e gravar ODS, capturando textualmente a subárvore <table:data-pilot-tables> do content.xml no momento de abrir e repetindo-a ao gravar, desde a v2.382.0. Desde a v2.382.1, o fragmento transporta também todas as ligações de namespace XML que os seus antepassados declaravam, para que a definição de tabela dinâmica gravada continue bem formada para qualquer consumidor, e não apenas para o HotXLS

O bug que obrigou às duas alterações saiu de uma corrida estrita do corpus. O exemplo official-pivot.ods, escrito por uma build de desenvolvimento do LibreOffice 6.1, contém uma tabela dinâmica chamada DataPilot1 que lê Sheet1.A2:E30 e coloca o resultado em Sheet1.G6:J18. Abra-o com o HotXLS, grave-o sem alterações e conte os elementos <table:data-pilot-table> na saída: um à entrada, zero à saída, tanto em Win32 como em Win64. Nada no teste tocou na tabela dinâmica. A primeira ronda de sondagens só tinha comparado constantes de célula e passou; foi a asserção estrutural que expôs a perda, o que serve de lembrete de que «os valores coincidem» é uma definição fraca de fidelidade de round-trip

Porque é que uma tabela dinâmica ODS desaparece depois de a biblioteca gravar?

Uma tabela dinâmica ODS desaparece porque o HotXLS não tem modelo em memória para as tabelas data pilot do OpenDocument, e o writer ODS constrói o content.xml inteiramente a partir do modelo. O writer monta os estilos automáticos, um <table:table> por folha de cálculo, <table:content-validations>, <table:named-expressions> e <table:database-ranges>, todos gerados a partir de objetos que o livro realmente contém. Uma definição de tabela dinâmica — ODF 1.3 Parte 3 §9.6, um contentor <table:data-pilot-tables> com um <table:data-pilot-table> por tabela, transportando o seu table:source-cell-range, os seus filhos table:data-pilot-field, o seu table:target-range-address e o table:buttons — não tem objeto onde viver, pelo que a parte regenerada simplesmente a omite

O contraste com o XLSX é deliberado. O HotXLS analisa as caches e as tabelas dinâmicas do SpreadsheetML para um modelo real que pode construir, estender com campos calculados e atualizar a partir de Delphi, pelo que essas sobrevivem à gravação porque são reescritas e não copiadas. As tabelas dinâmicas ODS são um pedido muito mais raro, e modelar o vocabulário data pilot do ODF só para efeitos de round-trip seria muito código que ninguém edita. A resposta pragmática é a mesma que o HotXLS já aplica aos blocos extLst desconhecidos no XLSX: guardar o que não modelamos, byte a byte se possível, evento a evento se não for

O que é que a primeira captura baseada em Pos fazia mal?

A captura da v2.382.0 cortava a definição da tabela dinâmica do content.xml como uma string simples, e ao corte faltavam as declarações de namespace que a tornavam significativa. A implementação era tão curta como parece — descodificar a parte para uma WideString, encontrar a tag de abertura com Pos, encontrar a tag de fecho depois dela, copiar o intervalo para FRawOdsDataPilotTablesXml no livro:

// HotXLS v2.382.0 -- substituído uma versão depois
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 o content.xml em memória
  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;

A asserção de contagem ficou verde e a correção saiu. O que a apanhou foi uma segunda verificação, mais estrita, acrescentada no mesmo dia: cada parte XML do pacote gravado é entregue a um parser independente com consciência de namespaces, fora do HotXLS, e esse parser rejeitou o novo content.xml com um erro de prefixo não ligado. A tabela dinâmica vinda do LibreOffice transporta atributos de extensão do produtor — loext:ignore-selected-page="true" num campo de página, calcext:repeat-item-labels="false" em todos os níveis — e a string cortada continha esses atributos mas não as declarações xmlns:loext e xmlns:calcext que os ligavam. Essas declarações estavam na raiz <office:document-content> do ficheiro de origem, trinta e cinco delas, a dois mil caracteres de distância da tabela dinâmica

A secção §6.1 do W3C Namespaces in XML 1.0 define a regra que faz disto uma falha grave e não um detalhe cosmético: uma declaração de namespace está em vigor desde a tag de abertura do elemento onde aparece até à tag de fecho desse elemento, e todos os nomes com prefixo dentro desse âmbito resolvem contra ela. Corte uma subárvore do documento e corta-a também desse âmbito. O HotXLS escreve a sua própria raiz <office:document-content> com onze declarações — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — pelo que o calcext: acabou por resolver, o table: acabou por resolver e o loext: não. Um parser com consciência de namespaces trata um prefixo não ligado como uma violação de boa formação, o que significa que a parte inteira fica ilegível e não apenas um atributo

O que a captura baseada em Pos do official-pivot.ods deixava escapar no HotXLS: a subárvore da tabela dinâmica transporta atributos de extensão loext e calcext enquanto as declarações xmlns que os ligam estão na raiz office:document-content a trinta e cinco ligações de distância, pelo que o fragmento cortado deixava todos os prefixos que usava sem ligação e um parser com consciência de namespaces rejeitava o content.xml inteiro
Uma declaração de namespace está em vigor desde a sua tag de abertura até à sua tag de fecho, e cortar uma subárvore do documento retira-a desse âmbito, o que transforma um atributo numa parte ilegível

Como é que o HotXLS transporta as ligações xmlns dos antepassados para o fragmento?

A v2.382.1 do HotXLS substituiu o corte de string por uma passagem sobre o content.xml através do seu próprio TXMLReader em streaming, mantendo uma pilha de ligações de namespace etiquetadas com a profundidade a que cada uma foi declarada e copiando as ligações ainda em vigor para o elemento raiz do fragmento no momento em que o alvo é alcançado. O reader corre com PreserveWhitespaceText ativado, para que os nós de texto voltem exatamente como foram escritos, e as tags reconstruídas usam TXMLReader.RawName e TXMLReader.Attribute[I].RawName — a grafia do prefixo tal como está no ficheiro — em vez dos nomes canónicos que o reader entrega normalmente aos parsers de partes. Aqui está o núcleo do ciclo:

Como o HotXLS v2.382.1 captura a subárvore data pilot com o seu âmbito de namespaces: uma passagem em streaming do TXMLReader mantém uma pilha de ligações xmlns etiquetadas com a profundidade de declaração, percorre-a do interior para fora no alvo table:data-pilot-tables, respeita o sombreamento através de um conjunto Seen, salta os prefixos que o próprio elemento declara e retira ligações tanto nas tags de fecho como nos elementos vazios
Fazer corresponder o alvo pelo nome canónico do reader mantém a funcionar os produtores que escrevem o prefixo table de outra forma, e uma subárvore que nunca fecha lança exceção em vez de gravar meio fragmento de volta
// Namespaces: TStringList de 'xmlns:p=uri' com a profundidade de declaração em Objects[]
while Reader.Read do
begin
  if CaptureDepth >= 0 then
    XlsxAppendRawXmlReaderNode(Result, Reader);   // elemento, texto, CDATA, comentário
  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);   // retirar primeiro o '>' ou '/>' final
      ...
      // Transportar as ligações efetivas dos antepassados para a raiz do fragmento.
      for I := Namespaces.Count - 1 downto 0 do
      begin
        AttrName := WideString(Namespaces.Names[I]);
        if Seen.IndexOf(String(AttrName)) >= 0 then Continue;   // ganha a ligação mais interior
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // já declarado aqui? 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árvore fechada
  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);                   // sair do âmbito
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Três pormenores naquele ciclo sustentam a correção. Percorrer a pilha da ligação mais interior para fora e memorizar cada prefixo em Seen implementa o sombreamento: se um antepassado mais próximo voltar a ligar o xmlns:table, ganha o valor mais próximo, exatamente como a §6.1 diz que tem de ser. Saltar os prefixos que o próprio elemento já declara evita emitir o mesmo atributo duas vezes, o que seria um erro de boa formação diferente. E a regra de retirada dispara nas tags de fecho e nos elementos vazios, porque <x/> nunca produz um evento EndElement — a mesma armadilha de auto-fecho que a captura de extLst do XLSX teve de aprender. Fazer corresponder o alvo pelo Reader.Name em vez do RawName é uma vitória mais discreta: o reader canoniza o URI do namespace table do ODF para o prefixo table, pelo que um produtor que o escreva como t:data-pilot-tables continua a corresponder, enquanto o fragmento emitido mantém o prefixo que o produtor usou

O ciclo também se recusa a adivinhar. Se a parte terminar com a captura ainda aberta — um content.xml truncado ou malformado — o OdsCaptureDataPilotTablesXml lança exceção em vez de devolver meio fragmento, porque meio fragmento seria gravado de volta e transformaria uma entrada danificada numa saída danificada com o nome da biblioteca em cima

Onde é que o fragmento aterra no content.xml gravado?

O HotXLS escreve o fragmento capturado dentro de <office:spreadsheet>, imediatamente a seguir ao <table:named-expressions> que gera e antes de <table:database-ranges>. O modelo de conteúdo do ODF 1.3 Parte 3 para <office:spreadsheet> prescreve uma sequência fixa para esses filhos finais, pelo que um bloco textual não pode ser simplesmente acrescentado onde calhe ao writer; tem de ser colocado numa ranhura específica. Do lado de quem chama não há API nem nada para configurar; a definição segue com um abrir e gravar normais:

Onde a definição de tabela dinâmica capturada aterra numa gravação ODS do HotXLS: os filhos de office:spreadsheet seguem a sequência fixa do ODF, desde os elementos table gerados até table:content-validations e table:named-expressions, o fragmento textual table:data-pilot-tables encaixa antes de table:database-ranges, e não existe API porque a definição segue com o OpenODS e o SaveAsODS
Um bloco textual não pode ser acrescentado onde calhe ao writer, e as cópias das ligações de antepassados que transporta são inofensivas porque o Namespaces in XML permite voltar a declarar um prefixo num âmbito aninhado
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;   // edição dentro do intervalo de origem da tabela dinâmica
    Book.SaveAsODS('official-pivot-out.ods');
    // o content.xml da saída ainda transporta o DataPilot1 com o seu
    // intervalo de origem, campos, intervalo de destino, botões e atributos loext:/calcext:
  finally
    Book.Free;
  end;
end;

A redundância é intencional e vale a pena conhecê-la. A raiz do fragmento repete agora xmlns:table e xmlns:calcext, mesmo que a raiz do documento gravado também as declare; o Namespaces in XML permite voltar a declarar um prefixo num âmbito aninhado, pelo que as duplicações são inofensivas. No exemplo do LibreOffice, o conjunto transportado são as trinta e cinco declarações da raiz, cerca de dois kilobytes em cima da definição de 8 357 caracteres, porque a captura não analisa que prefixos a subárvore usa de facto. Um varrimento dos prefixos usados reduziria isso, e pode vir mais tarde; primeiro a correção, depois a compacidade

Uma regra para cortar subárvores de XML para repetição textual

A lição geral é que uma subárvore só é autónoma depois de a tornarmos autónoma, e o âmbito de namespaces é a primeira coisa que se quebra quando nos esquecemos. A lista de verificação que o HotXLS aplica agora a qualquer captura de «guardar o que não modelamos»:

  • Percorrer o documento com um reader a sério e acompanhar as ligações em vigor. A procura de strings com Pos não vê âmbito nenhum, e além disso falha em elementos aninhados com o mesmo nome, numa string coincidente dentro de um comentário ou de uma secção CDATA, e em valores de atributo que por acaso contenham o texto da tag
  • Copiar as ligações efetivas para a raiz do fragmento, do interior para fora, uma vez por prefixo, saltando o que a raiz já declara
  • Manter a grafia crua do prefixo nas tags emitidas; fazer corresponder o alvo pelo namespace resolvido e não pelo prefixo literal
  • Preservar os nós de texto com espaços, e lembrar que um elemento vazio fecha o seu próprio âmbito sem um evento de fim de tag
  • Validar a parte gravada com um parser que não seja a biblioteca em teste. A biblioteca relê alegremente a sua própria saída pelo mesmo caminho de código tolerante que a escreveu

O último ponto é o que na verdade apanhou o HXLS-003 à segunda vez. A verificação de aceitação da v2.382.0 era uma expressão regular a contar tags de abertura data-pilot-table no content.xml gravado, e uma expressão regular vê uma tag, não um documento — é cega a se os prefixos dessa tag estão ligados. O runner estrito do corpus acrescentado na v2.382.1 analisa todas as partes XML e .rels do pacote gravado com um parser com consciência de namespaces e depois compara a árvore da tabela dinâmica — tag, atributos ordenados, texto, filhos, recursivamente — com o original. Essa comparação é expandida por namespace, pelo que uma grafia diferente do prefixo ainda passa e um prefixo não ligado não pode passar

Onde acaba a garantia de repetição textual

A repetição textual preserva uma definição; não a compreende, e os limites decorrem daí. O HotXLS não expõe nenhuma API para ler, editar ou atualizar uma tabela dinâmica ODS, pelo que FRawOdsDataPilotTablesXml é um campo interno e o único comportamento observável é que a definição sobrevive. O fragmento é reserializado a partir de eventos do reader e não copiado como bytes: as aspas dos atributos e as formas de auto-fecho são normalizadas, enquanto o texto e os espaços são mantidos. O XML capturado só é emitido pelo writer de conteúdo ODS, pelo que um livro aberto a partir de .ods e gravado como .xlsx perde a tabela dinâmica, e um livro aberto a partir de .xlsx não tem nada que repetir numa gravação .ods — as assimetrias dos caminhos de importação e exportação ODS aplicam-se aqui como em todo o lado. E, como a definição é opaca, não consegue acompanhar as suas edições: mude o nome de Sheet1 ou mova os dados de origem no HotXLS e a tabela dinâmica gravada continua a apontar para Sheet1.A2:E30, deixando ao consumidor a tarefa de reportar um intervalo quebrado na próxima atualização. Aqui cabe também uma ressalva de ordenação: o HotXLS emite os intervalos de AutoFilter como <table:database-ranges> depois do fragmento da tabela dinâmica, e o exemplo do corpus não traz nenhum intervalo de base de dados, pelo que um livro com filtro e tabela dinâmica ao mesmo tempo deve passar por um validador de esquema ODF antes de confiar na ordem relativa desses dois elementos

Teste com ficheiros do seu próprio produtor e não apenas com o exemplo do corpus. O transporte de namespaces trata de qualquer prefixo que um produtor declare num antepassado, mas um documento que declare um prefixo no próprio elemento da tabela dinâmica, ou que use um namespace predefinido para o vocabulário table, exercita os ramos de salto e de sombreamento que o exemplo do LibreOffice não exercita. Ambos estão implementados; nenhum tem ainda um exemplo no corpus, e essa distinção é exatamente o tipo de coisa que uma entrada de changelog tende a desfocar

A captura textual das tabelas data pilot na v2.382.0 e a correção do âmbito de namespaces na v2.382.1 vêm no atual HotXLS Delphi Excel Component, cuja página de produto lista a cobertura completa de leitura e escrita de ODS, XLSX e XLS para Delphi e C++Builder