Article technique

Aller-retour d'un tableau croisé ODS en Delphi (namespaces)

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

Ce que la capture à base de Pos de official-pivot.ods ratait dans HotXLS : le sous-arbre du pivot porte des attributs d'extension loext et calcext alors que les déclarations xmlns qui les lient se trouvent sur la racine office:document-content à trente-cinq liaisons de là, donc le fragment découpé laissait chaque préfixe qu'il utilise non lié et un analyseur sensible aux espaces de noms rejetait tout content.xml
Une déclaration d'espace de noms est en portée de sa balise ouvrante à sa balise fermante, et découper un sous-arbre du document le découpe de cette portée, ce qui transforme un attribut en partie illisible

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 :

Comment HotXLS v2.382.1 capture le sous-arbre data pilot avec sa portée d'espaces de noms : une passe TXMLReader en flux tient une pile de liaisons xmlns marquées par leur profondeur de déclaration, la parcourt du plus interne au plus externe à la cible table:data-pilot-tables, respecte le masquage via un ensemble Seen, saute les préfixes que l'élément déclare lui-même et dépile les liaisons sur les balises fermantes comme sur les éléments vides
Repérer la cible par le nom canonique du lecteur garde fonctionnels les producteurs qui réécrivent le préfixe table, et un sous-arbre qui ne se referme jamais lève une exception au lieu de réécrire un demi-fragment à la sauvegarde
// 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 :

Où atterrit la définition de pivot capturée dans une sauvegarde ODS HotXLS : les enfants de office:spreadsheet suivent la séquence ODF fixe, des éléments table générés jusqu'à table:content-validations et table:named-expressions, le fragment table:data-pilot-tables tel quel s'insère avant table:database-ranges, et aucune API n'existe parce que la définition accompagne OpenODS et SaveAsODS
Un bloc tel quel ne peut pas être ajouté là où l'écrivain se trouve, et les copies des liaisons d'ancêtres qu'il transporte sont inoffensives parce que Namespaces in XML autorise une redéclaration de préfixe dans une portée imbriquée
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 Pos ne 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 section CDATA, 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