Artigo Técnico

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

O HotXLS Delphi Excel Component preserva tabelas data pilot do OpenDocument num ciclo de abrir e salvar ODS capturando a subárvore <table:data-pilot-tables> de content.xml verbatim na abertura e a reproduzindo no save, desde a v2.382.0. Desde a v2.382.1 o fragmento também carrega toda ligação de namespace XML que seus ancestrais declararam, então a definição de pivô salva continua bem formada para qualquer consumidor, e não só para o HotXLS

O bug que forçou as duas mudanças saiu de uma rodada estrita de corpus. A amostra official-pivot.ods, escrita por um build de desenvolvimento do LibreOffice 6.1, carrega um pivô chamado DataPilot1 que lê Sheet1.A2:E30 e deposita o resultado em Sheet1.G6:J18. Abra no HotXLS, salve sem alterações, conte os elementos <table:data-pilot-table> na saída: um na entrada, zero na saída, tanto em Win32 quanto em Win64. Nada no teste tocou no pivô. A primeira rodada de sondagens só tinha comparado constantes de célula e passou; foi a asserção estrutural que expôs a perda, e isso lembra que valores batendo é uma definição fraca de fidelidade de round-trip

Por que uma tabela dinâmica ODS desaparece depois de um save da biblioteca?

Uma tabela dinâmica ODS desaparece porque o HotXLS não tem modelo em memória para tabelas data pilot do OpenDocument, e o writer de ODS monta o content.xml inteiramente a partir do modelo. O writer monta estilos automáticos, um <table:table> por worksheet, <table:content-validations>, <table:named-expressions> e <table:database-ranges>, cada um gerado a partir de objetos que a pasta de trabalho realmente tem. Uma definição de pivô — ODF 1.3 Part 3 §9.6, um contêiner <table:data-pilot-tables> com um <table:data-pilot-table> por pivô, carregando seu table:source-cell-range, seus filhos table:data-pilot-field, seu table:target-range-address e os table:buttons — não tem objeto onde morar, então a part regenerada simplesmente a omite

O contraste com o XLSX é deliberado. O HotXLS analisa caches de pivô e tabelas dinâmicas do SpreadsheetML num modelo de verdade que você pode montar, estender com campos calculados e atualizar a partir do Delphi, então esses sobrevivem a um save porque são reescritos, não copiados. Pivôs ODS são um pedido muito mais raro, e modelar o vocabulário data pilot do ODF só por causa do round-trip seria um monte de código que ninguém edita. A resposta pragmática é a mesma que o HotXLS já aplica a blocos extLst desconhecidos no XLSX: guarde o que você não modela, byte a byte se conseguir, evento por evento se não conseguir

O que a primeira captura baseada em Pos errou?

A captura da v2.382.0 fatiou a definição de pivô para fora do content.xml como string simples, e a fatia perdia as declarações de namespace que a tornavam significativa. A implementação era tão curta quanto parece — decodificar a part para um WideString, achar a tag de abertura com Pos, achar a tag de fechamento depois dela, copiar o trecho para FRawOdsDataPilotTablesXml na pasta de trabalho:

// HotXLS v2.382.0 -- substituído uma release 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);   // content.xml inteiro 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 foi publicada. O que a pegou foi uma segunda checagem, mais estrita, adicionada no mesmo dia: toda part XML do pacote salvo é entregue a um parser independente e ciente de namespaces, fora do HotXLS, e esse parser rejeitou o novo content.xml com um erro de prefixo não ligado. O pivô vindo do LibreOffice carrega atributos de extensão do produtor — loext:ignore-selected-page="true" num campo de página, calcext:repeat-item-labels="false" em todo nível — e a string fatiada continha esses atributos mas não as declarações xmlns:loext e xmlns:calcext que os ligavam. Essas declarações ficavam na raiz <office:document-content> do arquivo de origem, trinta e cinco delas, a dois mil caracteres de distância do pivô

A W3C Namespaces in XML 1.0 §6.1 define a regra que faz disso uma falha dura e não um problema cosmético: uma declaração de namespace está no escopo da tag de abertura do elemento onde aparece até a tag de fechamento desse elemento, e todo nome prefixado dentro desse escopo resolve por ela. Corte uma subárvore para fora do documento e você a cortou para fora do escopo. O HotXLS escreve sua própria raiz <office:document-content> com onze declarações — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — então calcext: por acaso resolvia, table: por acaso resolvia, e loext: não. Um parser ciente de namespaces trata um prefixo não ligado como violação de boa formação, o que significa que a part inteira fica ilegível, e não apenas um atributo

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

Como o HotXLS leva as ligações xmlns dos ancestrais para o fragmento?

A v2.382.1 substituiu a fatia de string por uma passagem pelo content.xml com o próprio TXMLReader em streaming, mantendo uma pilha de ligações de namespace marcadas com a profundidade em 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 roda com PreserveWhitespaceText habilitado para que os nós de texto voltem exatamente como escritos, e as tags reconstruídas usam TXMLReader.RawName e TXMLReader.Attribute[I].RawName — a grafia do prefixo conforme o arquivo — em vez dos nomes canônicos que o reader normalmente entrega aos parsers de part. Aqui está o núcleo do laço:

Como o HotXLS v2.382.1 captura a subárvore data pilot com o escopo de namespace dela: uma passagem em streaming do TXMLReader mantém uma pilha de ligações xmlns marcadas com a profundidade de declaração, a percorre do mais interno para fora no alvo table:data-pilot-tables, respeita sombreamento por um conjunto Seen, pula prefixos que o próprio elemento declara e desempilha ligações tanto em tags de fechamento quanto em elementos vazios
Casar o alvo pelo nome canônico do reader mantém funcionando produtores que grafam o prefixo table de outro jeito, e uma subárvore que nunca fecha levanta exceção em vez de gravar meio fragmento no save
// 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);   // tira antes o '>' ou '/>' final
      ...
      // Leva as ligações efetivas dos ancestrais 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;   // a ligação mais interna vence
        Seen.Add(String(AttrName));
        if not Reader.HasAttribute(AttrName) then               // já declarado aqui? pula
          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);                   // sai do escopo
end;
if CaptureDepth >= 0 then
  raise Exception.Create('OpenDocument pivot definition ended inside an element');

Três detalhes nesse laço sustentam a correção. Percorrer a pilha da ligação mais interna para fora e lembrar cada prefixo em Seen implementa o sombreamento: se um ancestral mais próximo religar xmlns:table, o valor mais próximo vence, exatamente como a §6.1 diz que deve ser. Pular prefixos que o elemento já declara por conta própria evita emitir o mesmo atributo duas vezes, o que seria outro erro de boa formação. E a regra de desempilhar dispara em tags de fechamento e em elementos vazios, porque <x/> nunca produz um evento EndElement — a mesma armadilha de tag auto-fechada que a captura de extLst do XLSX teve que aprender. Casar o alvo por Reader.Name em vez de RawName é uma vitória mais discreta: o reader canoniza a URI do namespace table do ODF para o prefixo table, então um produtor que a grafe como t:data-pilot-tables ainda casa, enquanto o fragmento emitido mantém o prefixo que o produtor usou

O laço também se recusa a adivinhar. Se a part termina enquanto a captura ainda está aberta — um content.xml truncado ou malformado — OdsCaptureDataPilotTablesXml levanta exceção em vez de devolver meio fragmento, porque meio fragmento seria gravado de volta no save e transformaria uma entrada danificada numa saída danificada com o nome da biblioteca nela

Onde o fragmento cai no content.xml salvo?

O HotXLS escreve o fragmento capturado dentro de <office:spreadsheet> logo depois do <table:named-expressions> que ele gera e antes de <table:database-ranges>. O modelo de conteúdo do <office:spreadsheet> na ODF 1.3 Part 3 prescreve uma sequência fixa para esses filhos finais, então um bloco verbatim não pode simplesmente ser anexado onde o writer estiver; ele precisa ser encaixado num ponto específico. Do lado do chamador não há API nem nada a configurar; a definição viaja junto com um abrir e salvar comum:

Onde a definição de pivô capturada cai num save ODS do HotXLS: os filhos de office:spreadsheet seguem a sequência fixa do ODF, dos elementos table gerados por table:content-validations e table:named-expressions, o fragmento verbatim table:data-pilot-tables se encaixa antes de table:database-ranges, e não existe API porque a definição viaja junto com OpenODS e SaveAsODS
Um bloco verbatim não pode ser anexado onde o writer estiver, e as cópias das ligações de ancestrais que ele carrega são inofensivas porque Namespaces in XML permite redeclarar um prefixo num escopo 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 do pivô
    Book.SaveAsODS('official-pivot-out.ods');
    // o content.xml da saída ainda carrega DataPilot1 com 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 conhecer. A raiz do fragmento agora repete xmlns:table e xmlns:calcext mesmo com a raiz do documento salvo também declarando elas; Namespaces in XML permite redeclarar um prefixo num escopo aninhado, então as duplicatas são inofensivas. Na amostra do LibreOffice o conjunto carregado são todas as trinta e cinco declarações da raiz, cerca de dois kilobytes em cima de uma definição de 8.357 caracteres, porque a captura não analisa quais prefixos a subárvore realmente usa. Uma varredura de prefixos usados reduziria isso, e pode vir depois; correção primeiro, compacidade depois

Uma regra para recortar subárvores de XML para replay verbatim

A lição geral é que uma subárvore só é autocontida depois que você a torna assim, e o escopo de namespace é a primeira coisa que quebra quando você esquece. A lista de verificação que o HotXLS agora aplica a qualquer captura do tipo guarde o que não modelamos:

  • Percorra o documento com um reader de verdade e acompanhe as ligações em escopo. Busca de string com Pos não enxerga escopo nenhum, e ainda erra em elementos aninhados com o mesmo nome, numa string coincidente dentro de um comentário ou de uma seção CDATA e em valores de atributo que por acaso contenham o texto da tag
  • Copie as ligações efetivas para a raiz do fragmento, da mais interna para fora, uma vez por prefixo, pulando o que a raiz já declara
  • Mantenha a grafia crua do prefixo nas tags emitidas; case o alvo pelo namespace resolvido, não pelo prefixo literal
  • Preserve os nós de texto com espaços em branco, e lembre que um elemento vazio fecha o próprio escopo sem evento de tag de fechamento
  • Valide a part salva com um parser que não seja a própria biblioteca em teste. A biblioteca vai reler a própria saída de bom grado pelo mesmo caminho tolerante de código que a escreveu

O último ponto é o que de fato encontrou o HXLS-003 na segunda vez. A checagem de aceitação da v2.382.0 era uma expressão regular contando tags de abertura data-pilot-table no content.xml salvo, e uma expressão regular vê uma tag, não um documento — ela é cega para se os prefixos naquela tag estão ligados. O runner estrito de corpus adicionado na v2.382.1 analisa toda part XML e .rels do pacote salvo com um parser ciente de namespaces e depois compara a árvore do pivô — tag, atributos ordenados, texto, filhos, recursivamente — com a original. Essa comparação é expandida em namespaces, então uma regrafia de prefixo ainda passaria e um prefixo não ligado não pode passar

Onde a garantia verbatim termina

O replay verbatim preserva uma definição; ele não entende uma, e os limites decorrem disso. O HotXLS não expõe API para ler, editar ou atualizar um pivô ODS, então FRawOdsDataPilotTablesXml é um campo interno e o único comportamento observável é que a definição sobrevive. O fragmento é reserializado a partir de eventos do reader, não copiado como bytes: aspas de atributos e formas auto-fechadas são normalizadas, enquanto texto e espaços em branco são mantidos. O XML capturado só é emitido pelo writer de conteúdo ODS, então uma pasta de trabalho aberta de .ods e salva como .xlsx perde o pivô, e uma pasta de trabalho aberta de .xlsx não tem nada para reproduzir num save .ods — as assimetrias dos caminhos de importação e exportação ODS valem aqui como em todo lugar. E, como a definição é opaca, ela não consegue acompanhar suas edições: renomeie Sheet1 ou mova os dados de origem no HotXLS e o pivô salvo continua apontando para Sheet1.A2:E30, deixando o consumidor reportar um intervalo quebrado na próxima atualização. Uma ressalva de ordem também cabe aqui: o HotXLS emite intervalos de AutoFilter como <table:database-ranges> depois do fragmento do pivô, e a amostra do corpus não carrega intervalo de banco de dados, então uma pasta de trabalho com filtro e pivô juntos deveria passar por um validador de esquema ODF antes de você confiar na ordem relativa desses dois elementos

Teste com arquivos do seu próprio produtor, não só com a amostra do corpus. A cópia de namespaces dá conta de qualquer prefixo que um produtor declare num ancestral, mas um documento que declare um prefixo no próprio elemento de pivô, ou que use um namespace padrão para o vocabulário table, exercita os ramos de descarte e de sombreamento que a amostra do LibreOffice não cobre. Os dois estão implementados; nenhum dos dois tem amostra no corpus ainda, e essa distinção é exatamente o tipo de coisa que uma entrada de changelog costuma borrar

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