Le HotXLS Delphi Excel Component conserve les tableaux data pilot OpenDocument à travers un cycle d'ouverture-sauvegarde ODS en capturant le sous-arbre <table:data-pilot-tables> de content.xml tel quel à l'ouverture et en le rejouant à la sauvegarde, depuis la v2.382.0. Depuis la v2.382.1, le fragment porte aussi chaque liaison d'espace de noms XML déclarée par ses ancêtres, pour que la définition de pivot sauvegardée reste bien formée pour n'importe quel consommateur, pas seulement pour HotXLS
Le bug qui a imposé ces deux changements vient d'une passe de corpus stricte. L'échantillon official-pivot.ods, écrit par une version de développement de LibreOffice 6.1, contient un pivot nommé DataPilot1 qui lit Sheet1.A2:E30 et dépose son résultat dans Sheet1.G6:J18. Ouvrez-le avec HotXLS, sauvegardez-le sans modification, comptez les éléments <table:data-pilot-table> dans la sortie : un à l'entrée, zéro à la sortie, aussi bien en Win32 qu'en Win64. Rien dans le test ne touchait au pivot. La première série de sondes n'avait comparé que les constantes de cellules et passait ; c'est l'assertion structurelle qui a exposé la perte, ce qui rappelle que « les valeurs correspondent » est une définition faible de la fidélité d'un aller-retour
Pourquoi un tableau croisé ODS disparaît-il après une sauvegarde par la bibliothèque ?
Un tableau croisé ODS disparaît parce que HotXLS n'a pas de modèle en mémoire pour les tableaux data pilot OpenDocument, et que l'écrivain ODS construit content.xml entièrement à partir du modèle. L'écrivain assemble les styles automatiques, un <table:table> par feuille de calcul, <table:content-validations>, <table:named-expressions> et <table:database-ranges>, chacun généré depuis des objets que le classeur contient réellement. Une définition de pivot — ODF 1.3 Partie 3 §9.6, un conteneur <table:data-pilot-tables> avec un <table:data-pilot-table> par pivot, portant son table:source-cell-range, ses enfants table:data-pilot-field, son table:target-range-address et ses table:buttons — n'a aucun objet où vivre, donc la partie régénérée l'omet simplement
Le contraste avec XLSX est délibéré. HotXLS analyse les caches et les tableaux croisés dynamiques SpreadsheetML dans un vrai modèle que vous pouvez construire, étendre avec des champs calculés et rafraîchir depuis Delphi, si bien que ceux-là survivent à une sauvegarde parce qu'ils sont réécrits, pas recopiés. Les pivots ODS sont une demande bien plus rare, et modéliser le vocabulaire data pilot ODF au seul motif de l'aller-retour représenterait beaucoup de code que personne ne modifierait. La réponse pragmatique est celle que HotXLS applique déjà aux blocs extLst inconnus des fichiers XLSX : garder ce qu'on ne modélise pas, octet pour octet quand c'est possible, événement par événement sinon
Qu'est-ce que la première capture à base de Pos ratait ?
La capture de la v2.382.0 découpait la définition de pivot dans content.xml comme une simple chaîne, et le découpage omettait les déclarations d'espace de noms qui la rendaient interprétable. L'implémentation était aussi courte que son nom le suggère — décoder la partie en WideString, trouver la balise ouvrante avec Pos, trouver la balise fermante après, copier l'intervalle dans FRawOdsDataPilotTablesXml sur le classeur :
// HotXLS v2.382.0 -- remplacé une version plus tard
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); // tout content.xml en mémoire
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;
L'assertion de comptage est passée au vert, et le correctif a été livré. Ce qui l'a attrapé est une seconde vérification plus stricte ajoutée le jour même : chaque partie XML du paquet sauvegardé est confiée à un analyseur indépendant sensible aux espaces de noms, extérieur à HotXLS, et cet analyseur a rejeté le nouveau content.xml avec une erreur de préfixe non lié. Le pivot venu de LibreOffice porte des attributs d'extension du producteur — loext:ignore-selected-page="true" sur un champ de page, calcext:repeat-item-labels="false" sur chaque niveau — et la chaîne découpée contenait ces attributs mais pas les déclarations xmlns:loext et xmlns:calcext qui les lient. Ces déclarations se trouvaient sur la racine <office:document-content> du fichier source, trente-cinq en tout, à deux mille caractères du pivot
W3C Namespaces in XML 1.0 §6.1 définit la règle qui fait de ceci un échec dur et non un détail cosmétique : une déclaration d'espace de noms est en portée depuis la balise ouvrante de l'élément où elle apparaît jusqu'à la balise fermante de cet élément, et tout nom préfixé à l'intérieur de cette portée s'y résout. Découpez un sous-arbre du document et vous le découpez de la portée. HotXLS écrit sa propre racine <office:document-content> avec onze déclarations — office, table, text, style, number, fo, draw, svg, xlink, calcext, tableooo — donc calcext: se résolvait par chance, table: aussi, et loext: non. Un analyseur sensible aux espaces de noms traite un préfixe non lié comme une violation de bonne formation, ce qui veut dire que toute la partie est illisible, pas seulement un attribut
Comment HotXLS reporte-t-il les liaisons xmlns des ancêtres sur le fragment ?
HotXLS v2.382.1 a remplacé le découpage de chaîne par une passe sur content.xml via son propre TXMLReader en flux, qui tient une pile de liaisons d'espace de noms marquées par la profondeur à laquelle chacune a été déclarée, et recopie les liaisons encore en vigueur sur l'élément racine du fragment à l'instant où la cible est atteinte. Le lecteur tourne avec PreserveWhitespaceText activé pour que les nœuds texte reviennent exactement tels qu'écrits, et les balises reconstruites utilisent TXMLReader.RawName et TXMLReader.Attribute[I].RawName — l'orthographe du préfixe telle qu'elle figure dans le fichier — plutôt que les noms canoniques que le lecteur remet normalement aux analyseurs de parties. Voici le cœur de la boucle :
// Namespaces : TStringList de 'xmlns:p=uri' avec la profondeur de déclaration dans Objects[]
while Reader.Read do
begin
if CaptureDepth >= 0 then
XlsxAppendRawXmlReaderNode(Result, Reader); // élément, texte, CDATA, commentaire
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); // retirer d'abord le '>' ou '/>' final
...
// Reporter les liaisons effectives des ancêtres sur la racine du fragment
for I := Namespaces.Count - 1 downto 0 do
begin
AttrName := WideString(Namespaces.Names[I]);
if Seen.IndexOf(String(AttrName)) >= 0 then Continue; // la liaison la plus interne gagne
Seen.Add(String(AttrName));
if not Reader.HasAttribute(AttrName) then // déjà déclaré ici ? sauter
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; // sous-arbre refermé
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); // quitter la portée
end;
if CaptureDepth >= 0 then
raise Exception.Create('OpenDocument pivot definition ended inside an element');
Trois détails de cette boucle portent la correction. Parcourir la pile de la liaison la plus interne vers l'extérieur et mémoriser chaque préfixe dans Seen implémente le masquage : si un ancêtre plus proche relie à nouveau xmlns:table, c'est la valeur la plus proche qui gagne, exactement comme le prescrit le §6.1. Sauter les préfixes que l'élément déclare déjà lui-même évite d'émettre deux fois le même attribut, ce qui serait une autre erreur de bonne formation. Et la règle de dépilement se déclenche sur les balises fermantes et sur les éléments vides, parce que <x/> ne produit jamais d'événement EndElement — le même piège de l'auto-fermeture que la capture extLst côté XLSX a dû apprendre. Repérer la cible par Reader.Name plutôt que RawName est un gain plus discret : le lecteur canonise l'URI de l'espace de noms table ODF vers le préfixe table, donc un producteur qui l'écrit t:data-pilot-tables est quand même reconnu, tandis que le fragment émis conserve le préfixe qu'a utilisé le producteur
La boucle refuse aussi de deviner. Si la partie se termine alors que la capture est encore ouverte — un content.xml tronqué ou mal formé — OdsCaptureDataPilotTablesXml lève une exception au lieu de renvoyer un demi-fragment, parce qu'un demi-fragment serait réécrit à la sauvegarde et transformerait une entrée abîmée en sortie abîmée portant le nom de la bibliothèque
Où atterrit le fragment dans le content.xml sauvegardé ?
HotXLS écrit le fragment capturé dans <office:spreadsheet>, immédiatement après le <table:named-expressions> qu'il génère et avant <table:database-ranges>. Le modèle de contenu de <office:spreadsheet> d'ODF 1.3 Partie 3 prescrit une séquence fixe pour ces enfants de queue, donc un bloc tel quel ne peut pas être simplement ajouté là où l'écrivain se trouve ; il doit être déposé dans un emplacement précis. Du côté de l'appelant, il n'y a aucune API et rien à configurer ; la définition suit une ouverture et une sauvegarde ordinaires :
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; // modification dans la plage source du pivot
Book.SaveAsODS('official-pivot-out.ods');
// content.xml dans la sortie porte toujours DataPilot1 avec sa
// plage source, ses champs, sa plage cible, ses boutons et ses attributs loext:/calcext:
finally
Book.Free;
end;
end;
La redondance est intentionnelle et vaut la peine d'être connue. La racine du fragment répète désormais xmlns:table et xmlns:calcext alors que la racine du document sauvegardé les déclare aussi ; Namespaces in XML autorise la redéclaration d'un préfixe dans une portée imbriquée, donc les doublons sont inoffensifs. Pour l'échantillon LibreOffice, l'ensemble reporté représente les trente-cinq déclarations racine, environ deux kilo-octets en plus des 8 357 caractères de la définition, parce que la capture n'analyse pas quels préfixes le sous-arbre utilise réellement. Un balayage des préfixes utilisés réduirait cela, et viendra peut-être plus tard ; la correction d'abord, la compacité ensuite
Une règle pour découper des sous-arbres XML en vue d'un rejeu tel quel
La leçon générale est qu'un sous-arbre n'est autonome qu'une fois qu'on l'a rendu tel, et que la portée des espaces de noms est la première chose qui casse quand on oublie. La liste de contrôle que HotXLS applique désormais à toute capture du type « garder ce qu'on ne modélise pas » :
- Parcourez le document avec un vrai lecteur et suivez les liaisons en portée. Une recherche de chaîne avec
Posne voit pas la portée du tout, et elle se trompe aussi sur des éléments imbriqués de même nom, sur une chaîne correspondante à l'intérieur d'un commentaire ou d'une sectionCDATA, et sur des valeurs d'attribut qui contiennent par hasard le texte de la balise - Recopiez les liaisons effectives sur la racine du fragment, de la plus interne à la plus externe, une fois par préfixe, en sautant ce que la racine déclare déjà
- Conservez l'orthographe brute des préfixes dans les balises émises ; repérez la cible par espace de noms résolu, pas par préfixe littéral
- Préservez les nœuds texte d'espacement, et souvenez-vous qu'un élément vide referme sa propre portée sans événement de balise fermante
- Validez la partie sauvegardée avec un analyseur qui n'est pas la bibliothèque testée. La bibliothèque relira volontiers sa propre sortie par le même chemin de code indulgent qui l'a écrite
Le dernier point est celui qui a réellement trouvé HXLS-003 la seconde fois. La vérification d'acceptation de la v2.382.0 était une expression régulière comptant les balises ouvrantes data-pilot-table dans le content.xml sauvegardé, et une expression régulière voit une balise, pas un document — elle est aveugle au fait que les préfixes de cette balise sont liés ou non. Le lanceur de corpus strict ajouté en v2.382.1 analyse chaque partie XML et .rels du paquet sauvegardé avec un analyseur sensible aux espaces de noms, puis compare l'arbre du pivot — balise, attributs triés, texte, enfants, récursivement — à l'original. Cette comparaison est étendue en espaces de noms, donc une réécriture de préfixe passerait quand même et un préfixe non lié ne peut pas passer
Où s'arrête la garantie du tel quel
Le rejeu tel quel préserve une définition ; il ne la comprend pas, et les limites en découlent. HotXLS n'expose aucune API pour lire, modifier ou rafraîchir un pivot ODS, donc FRawOdsDataPilotTablesXml est un champ interne et le seul comportement observable est que la définition survit. Le fragment est resérialisé à partir des événements du lecteur, pas recopié en octets : le guillemetage des attributs et les formes auto-fermantes sont normalisés, tandis que le texte et les espaces sont conservés. Le XML capturé n'est émis que par l'écrivain de contenu ODS, donc un classeur ouvert depuis un .ods et sauvegardé en .xlsx perd le pivot, et un classeur ouvert depuis un .xlsx n'a rien à rejouer dans une sauvegarde .ods — les asymétries des chemins d'import et d'export ODS s'appliquent ici comme partout ailleurs. Et parce que la définition est opaque, elle ne peut pas suivre vos modifications : renommez Sheet1 ou déplacez les données source dans HotXLS et le pivot sauvegardé pointe toujours vers Sheet1.A2:E30, laissant le consommateur signaler une plage cassée au prochain rafraîchissement. Une réserve d'ordre a sa place ici aussi : HotXLS émet les plages AutoFilter sous forme de <table:database-ranges> après le fragment de pivot, et l'échantillon du corpus ne porte aucune plage de base de données, donc un classeur contenant à la fois un filtre et un pivot devrait passer par un validateur de schéma ODF avant que vous ne vous fiiez à l'ordre relatif de ces deux éléments
Testez avec les fichiers de votre propre producteur, pas seulement avec l'échantillon du corpus. Le report des espaces de noms gère tout préfixe qu'un producteur déclare sur un ancêtre, mais un document qui déclare un préfixe sur l'élément de pivot lui-même, ou qui utilise un espace de noms par défaut pour le vocabulaire table, exerce les branches de saut et de masquage que l'échantillon LibreOffice n'exerce pas. Les deux sont implémentées ; aucune n'a encore d'échantillon dans le corpus, et cette distinction est exactement le genre de chose qu'une entrée de changelog a tendance à brouiller
La capture data pilot telle quelle en v2.382.0 et le correctif de portée des espaces de noms en v2.382.1 sont livrés dans le HotXLS Delphi Excel Component actuel, dont la page produit liste la couverture complète de lecture-écriture ODS, XLSX et XLS pour Delphi et C++Builder