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
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:
// 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:
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
Posnã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çãoCDATAe 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