Article technique

Pourquoi Excel répare un XLSX valide : règles OPC Delphi

Excel affiche « We found a problem with some content » sur un XLSX que LibreOffice et tous les lecteurs maison ouvrent sans broncher, parce qu'Excel impose deux choses que ces lecteurs ignorent : les attributs exigés par le schéma et les règles d'unicité de l'Open Packaging Conventions. HotXLS, le composant tableur Excel natif pour Delphi et C++Builder, est tombé pile dessus en v2.382.5 la première fois que sa sortie est passée par une vraie instance COM d'Excel, et les trois causes étaient un <phoneticPr> sans fontId, un Override dupliqué dans [Content_Types].xml, et deux relations racine partageant rId4

Pourquoi Excel rejette-t-il un paquet que tous les autres lecteurs acceptent ?

Parce que l'invite de réparation est un validateur de schéma et de paquet, pas un échec d'analyseur. Le corpus HotXLS faisait faire l'aller-retour à un modèle de prêt de 4805 formules via la bibliothèque, via LibreOffice et via les validateurs XML de la suite de tests depuis des semaines. Le fichier sauvegardé était structurellement sain au sens OPC utilisé dans l'article sur la résolution des relations OPC dans XLSX : chaque partie accessible, chaque cible résoluble. Puis une machine Windows avec Excel 16.0 build 20326 est devenue disponible, le lanceur de corpus a ouvert le modèle sauvegardé via Workbooks.Open dans une instance COM isolée avec DisplayAlerts désactivé, et l'appel a échoué purement et simplement. En interactif, le même fichier produit la boîte de dialogue familière qui propose de réparer, et le journal de réparation, quand Excel prend la peine d'en écrire un, nomme la partie mais pas la règle. Trois défauts indépendants se cachaient dans cette unique invite, et Excel ne les signale pas un par un ; il rejette le classeur et vous laisse les trouver et les analyser. Ce qui suit est chaque règle, la ligne de HotXLS qui l'enfreignait, et le correctif livré, parce que chacune d'elles est une règle sur laquelle n'importe quel écrivain XLSX en Delphi peut trébucher

Règle 1 : phoneticPr fontId est obligatoire, même quand il vaut zéro

L'élément <phoneticPr> porte un attribut fontId déclaré use="required" dans ECMA-376 Partie 1 §18.4.3, et une valeur de 0 est un index de police légal, pas une absence. L'ancien écrivain de feuille de HotXLS traitait zéro comme « non défini » et n'émettait l'attribut que si Sheet.PhoneticFontId > 0. C'est un réflexe Delphi naturel, puisque les champs entiers valent zéro par défaut, mais cela produit <phoneticPr type="noConversion"/> pour tout classeur dont la police phonétique se trouve être la première police de styles.xml, ce que portait précisément le modèle de prêt du corpus HotXLS. Excel rejette alors, au retour, une valeur qu'il avait écrite lui-même

Pourquoi Excel exigeait une réparation pour la partie de feuille HotXLS : l'élément phoneticPr déclare fontId avec use required dans ECMA-376 Partie 1, un index de police 0 est une valeur légale, et l'ancien écrivain qui omettait l'attribut quand PhoneticFontId valait zéro produisait phoneticPr type noConversion, alors que le schéma donne des valeurs par défaut à type et alignment et aucune à fontId
Omettre un attribut quand il vaut la valeur par défaut n'est sûr que si le schéma déclare cette valeur par défaut, et le modèle de prêt portait sa police phonétique comme toute première entrée de styles.xml
// lxHandleX.pas, écrivain de feuille — avant la v2.382.5
phoneticXml:= '<phoneticPr';
if Sheet.PhoneticFontId > 0 then
  phoneticXml:= phoneticXml+ ' fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';

// v2.382.5 — l'attribut est obligatoire, zéro compris
phoneticXml:= '<phoneticPr fontId="'+ IntToStr(Sheet.PhoneticFontId)+ '"';
phoneticXml:= phoneticXml+ ' type="'+ XlsxEscapeAttr(Sheet.PhoneticType)+ '"';
if Sheet.PhoneticAlignment<> '' then
  phoneticXml:= phoneticXml+ ' alignment="'+ XlsxEscapeAttr(Sheet.PhoneticAlignment)+ '"';

HotXLS n'émet toujours l'élément que lorsque TXLSXWorksheet.PhoneticType est non vide, donc les classeurs qui n'ont jamais porté de réglages phonétiques ne sont pas concernés. Le test de régression PhoneticSettings_DefaultFontIsExplicit met PhoneticFontId à zéro sur une feuille neuve, sauvegarde, et vérifie que <phoneticPr fontId="0" est présent dans xl/worksheets/sheet1.xml. La leçon plus large est que « omettre quand c'est la valeur par défaut » n'est sûr que si le schéma déclare une valeur par défaut ; type et alignment en ont une dans cet élément, fontId non

Règle 2 : un seul Override par nom de partie dans [Content_Types].xml

Le flux de types de contenu peut déclarer chaque nom de partie au plus une fois, et Excel traite un second Override pour le même PartName comme une corruption, même quand les deux entrées portent le même ContentType. HotXLS a deux écrivains qui alimentent ce flux. BuildContentTypesXml déclare chaque partie que le modèle objet génère : classeur, styles, chaînes partagées, thème, feuilles de calcul et, quand TXLSXWorkbook.CustomProperties.Count > 0, /docProps/custom.xml. Quand PreserveUnsupportedParts est actif, TXLSXOpaquePackage ajoute ensuite un Override pour chaque partie qu'il a capturée telle quelle depuis le paquet source, afin que ces octets restent déclarés à la sortie. La collision vient d'une partie qui vit des deux côtés. Les propriétés de document personnalisées sont analysées dans le modèle, mais le docProps/custom.xml du paquet source a aussi été capturé de façon opaque, donc le flux fusionné le déclarait deux fois, et les parties de cache de graphique et de tableau croisé dynamique peuvent atterrir au même endroit quand le modèle régénère une partie que la couche opaque a également conservée. Avant la v2.382.5, ContentTypeOverridesXml n'avait aucune vue de ce que le modèle avait déjà écrit, donc il ne pouvait pas le savoir

Comment deux écrivains HotXLS se heurtaient dans [Content_Types].xml : BuildContentTypesXml déclarait docProps/custom.xml depuis le modèle objet tandis que TXLSXOpaquePackage ajoutait un Override pour la même partie capturée telle quelle, et depuis la v2.382.5 la couche opaque analyse d'abord le flux généré, normalise les noms avec OpcLowerPartName et laisse le modèle gagner chaque collision
Chaque écrivain était cohérent pris isolément, et la contrainte qu'un nom de partie ne peut apparaître qu'une fois n'existe qu'à la jointure où leurs sorties sont concaténées, ce qui explique pourquoi le correctif passe le flux du modèle en entrée
<!-- Ce qu'Excel voyait avant la v2.382.5 -->
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>
...
<Override PartName="/docProps/custom.xml"
    ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>

Le correctif passe le XML généré à ContentTypeOverridesXml et laisse l'écrivain opaque l'analyser avant d'émettre quoi que ce soit. Deux détails portent la correction. OpcLowerPartName met en minuscules, convertit les barres obliques inverses en barres obliques et retire les barres obliques initiales avant la comparaison, parce que les noms de parties OPC se comparent sans tenir compte de la casse et que le modèle les écrit avec une barre oblique initiale alors que la couche opaque stocke les noms d'éléments ZIP sans. Et l'appelant dans BuildContentTypesXml passe Result + '</Types>', ce qui ferme le document partiellement construit pour que TXMLReader voie une entrée bien formée plutôt qu'un flux tronqué. La règle qui émerge est premier arrivé, premier servi, avec le modèle devant : ce que déclare le modèle objet fait autorité, et le rejeu opaque ne fait que combler les trous

// lxOpcPackage.pas — TXLSXOpaquePackage.ContentTypeOverridesXml
function TXLSXOpaquePackage.ContentTypeOverridesXml(const ExistingXml: WideString): WideString;
begin
  ...
  UsedNames.Sorted:= True;
  UsedNames.Duplicates:= dupIgnore;
  if ExistingXml<> '' then
    // Analyser le flux généré par le modèle et collecter chaque PartName déclaré
    while Reader.Read do
      if (Reader.NodeType= xmlntElement)and (Reader.Name= 'Override') then
      begin
        Index:= Reader.AttributeIndex('PartName');
        if Index>= 0 then
          UsedNames.Add(String(OpcLowerPartName(Reader.Attribute[Index].Value)));
      end;
  for i:= 0 to FParts.Count- 1 do
  begin
    Part:= TXLSXOpaquePart(FParts[i]);
    if (Part.ContentType= '')or (LowerCase(ExtractFileExt(String(Part.PartName)))= '.rels')or
      (UsedNames.IndexOf(String(OpcLowerPartName(Part.PartName)))>= 0) then
      Continue;                       // déjà déclaré, ou partie rels
    UsedNames.Add(String(OpcLowerPartName(Part.PartName)));
    Result:= Result+ '<Override PartName="/'+ OpcXmlEscapeAttribute(Part.PartName)+
      '" ContentType="'+ OpcXmlEscapeAttribute(Part.ContentType)+ '"/>';
  end;
end;

Règle 3 : les identifiants de relation sont uniques dans une partie de relations

Chaque Relationship d'une partie .rels a besoin d'un Id unique dans cette partie, et Excel refuse le paquet quand deux en partagent un. HotXLS écrit le _rels/.rels au niveau du paquet avec des identifiants fixes : rId1 pour le classeur, rId2 et rId3 pour les propriétés de document principales et étendues, et rId4 pour les propriétés personnalisées quand le modèle en a. Le paquet opaque ajoute ensuite les relations racine qu'il a conservées de la source, en renumérotant tout identifiant déjà présent dans une liste UsedIds. La liste connaissait rId1 à rId3. Elle ne connaissait pas rId4, et elle ne savait pas que le modèle allait émettre sa propre relation de propriétés personnalisées, donc un paquet source dont la relation de propriétés personnalisées était aussi rId4, ce qu'Excel écrit par défaut, ressortait avec deux entrées rId4 pointant vers la même cible. L'appelant, BuildRootRelsXml, passe désormais Workbook.FCustomProps.Count > 0 comme second argument, de sorte que la réservation et le saut sont pilotés par la même condition qui décide si le modèle émet rId4 ou non. La renumérotation est sans risque à la racine du paquet parce que rien à l'intérieur du classeur ne référence les identifiants de relation racine par leur nom ; le même tour serait faux un niveau plus bas, où les attributs r:id de workbook.xml se lient à des identifiants de la partie de relations du classeur, et c'est pourquoi MergeWorkbookRelationshipsXml garde une table d'identifiants séparée

La collision d'identifiants de relation à la racine d'un paquet HotXLS : le modèle écrit rId1 à rId4 avec rId4 réservé aux propriétés personnalisées, la couche opaque rejouait une relation source arrivée elle aussi sous rId4 parce que UsedIds ne connaissait que rId1 à rId3, et le correctif réserve rId4 d'emblée dès que EmitCustomProps est vrai et renumérote le reste
La renumérotation est sans risque à la racine du paquet parce que rien dans le classeur ne référence les identifiants racine par leur nom, et le même tour un niveau plus bas casserait toutes les liaisons r:id de workbook.xml
// lxOpcPackage.pas — TXLSXOpaquePackage.RootRelationshipsXml
UsedIds.CaseSensitive:= False;
UsedIds.Add('rId1');
if EmitCustomProps then UsedIds.Add('rId4');   // réservé par l'écrivain du modèle
if EmitDocProps then
begin
  UsedIds.Add('rId2');
  UsedIds.Add('rId3');
end;
for i:= 0 to FRootRelationships.Count- 1 do
begin
  Rel:= TXLSXOpaqueRelationship(FRootRelationships[i]);
  // Le modèle possède désormais les propriétés personnalisées ; ne pas rejouer la copie source
  if EmitCustomProps and OpcEndsWith(LowerCase(Rel.RelType), '/custom-properties') then
    Continue;
  Id:= Rel.Id;
  if (Id= '')or (UsedIds.IndexOf(String(Id))>= 0) then
    Id:= AllocateRelationshipId(UsedIds);      // plus petit rIdN libre
  UsedIds.Add(String(Id));
  ...
end;

Qu'ont en commun les trois défaillances ?

Toutes trois sont des symptômes d'un écrivain à deux sources sans propriétaire unique des invariants du paquet. Le modèle objet génère les parties qu'il comprend ; la couche opaque rejoue celles qu'il ne comprend pas, pour qu'un aller-retour conserve les graphiques, les caches de tableau croisé dynamique, le XML personnalisé et tout le reste décrit dans les notes sur l'aller-retour sans perte des thèmes, des extLst et de calcChain. Chaque côté était cohérent pris isolément. Les contraintes qu'OPC impose au paquet entier, des noms de Override uniques et des identifiants de relation uniques par partie, n'existent qu'à la jointure où les deux sont concaténés, et jusqu'à la v2.382.5 personne ne vérifiait la jointure. Le bug du fontId a la même forme un niveau plus bas : l'écrivain savait ce qu'il voulait omettre mais ne consultait jamais le schéma qui dit qu'il n'en a pas le droit. Le correctif retenu par HotXLS est une préséance fixe plutôt qu'une heuristique de fusion. Le modèle écrit d'abord, la couche opaque voit ce qui a été écrit et cède sur toute collision, et le lanceur de corpus fait désormais respecter les invariants depuis l'extérieur avec verify_opc_uniqueness, qui lit [Content_Types].xml et chaque élément .rels d'un paquet sauvegardé et fait échouer le cas sur tout PartName, Extension ou Id dupliqué. Cette vérification est bon marché, ne nécessite pas Excel, et aurait attrapé deux des trois défauts dès la première passe de corpus

Même lot : des zones d'impression qui sont des formules, pas des plages

La passe Excel a aussi signalé le _xlnm.Print_Area du modèle de prêt, qu'Excel rapportait comme $A$1:$J$29 sur l'original et devait rapporter à l'identique sur la copie sauvegardée. Deux bugs distincts se cachaient derrière cette seule assertion. À l'import, XlsxStripSheetPrefix coupait tout jusqu'au premier ! non cité, donc une zone d'impression dynamique comme OFFSET('Print Data'!$A$1,0,0,2,2) revenait sous la forme $A$1,0,0,2,2), et une union qualifiée comme 'Print Data'!$A$1:$B$2,'Print Data'!$D$1:$E$2 perdait le préfixe sur son premier segment uniquement. À l'export, l'écrivain préfixait le nom de feuille une fois pour tout le PrintArea stocké, donc une union simple $A$1:$B$2,$D$1:$E$2 ressortait de la bibliothèque avec un premier segment qualifié et un second nu, ce qu'Excel n'accepte pas comme définition de _xlnm.Print_Area au titre d'ECMA-376 Partie 1 §18.2.5

// Import : ne retirer le préfixe que si ce qui reste est un sqref simple
function XlsxPrintAreaFromDefinition(const Formula: WideString): WideString;
begin
  Result:= Formula;
  if not XlsxReadFormulaSheetPrefixAt(Formula, 1, Prefix, SheetPart, Start) then
    Exit;
  Area:= Copy(Formula, Start, Length(Formula));
  if XlsxParseSqrefPart(Area, R1, C1, R2, C2) then
    Result:= Area;                    // 'Sheet'!$A$1:$J$29 -> $A$1:$J$29
end;                                  // OFFSET(...) est renvoyé tel quel

// Export : qualifier chaque segment séparé par des virgules, ou aucun
function XlsxPrintAreaDefinition(const SheetName, Area: WideString): WideString;
begin
  Result:= Area;
  ... split Area on ',' with StrictDelimiter ...
  for I:= 0 to Parts.Count- 1 do
    if not XlsxParseSqrefPart(WideString(Trim(Parts[I])), R1, C1, R2, C2) then
      Exit;                           // une formule : émettre tel quel
  Result:= '';
  for I:= 0 to Parts.Count- 1 do
  begin
    if I> 0 then Result:= Result+ ',';
    Result:= Result+ XlsxQuoteSheetName(SheetName)+ '!'+ WideString(Trim(Parts[I]));
  end;
end;

La règle d'appariement est la même des deux côtés : une zone d'impression n'est une plage nue que si chaque segment s'analyse comme telle, sinon c'est une formule et elle voyage telle quelle. PrintArea_FormulaDefinitionSurvivesRoundTrip couvre la base nommée, la base qualifiée par la feuille et l'union sur deux cycles de sauvegarde-réouverture. La façon dont les zones d'impression interagissent avec la mise en page et le reste du modèle d'impression est traitée dans l'article sur la protection de feuille, la mise en page et l'impression

Comment trouver à quelle règle Excel objecte ?

Partez du principe que votre propre validateur a tort, puisqu'il a laissé passer. Le validateur du Open XML SDK nommera une violation de schéma comme le fontId manquant avec la partie et le XPath, et la couche de packaging en dessous refuse d'ouvrir un paquet contenant des entrées de type de contenu dupliquées, donc lancez-le avant tout le reste. Quand il reste silencieux et qu'Excel répare quand même, bissectez le paquet : décompressez, supprimez une partie, sa relation et son Override, recompressez, rouvrez, et divisez l'ensemble des candidats par deux à chaque fois jusqu'à ce que l'invite disparaisse. Les trois défauts décrits ici sont tombés dans cet ordre, et aucun d'eux n'aurait été visible dans le fichier réparé qu'Excel propose d'enregistrer, puisque la réparation supprime ou renumérote silencieusement les entrées fautives. Les limites du correctif v2.382.5 méritent d'être énoncées tout aussi clairement. La déduplication est premier arrivé, premier servi, avec le modèle devant : si le paquet source déclarait un type de contenu différent pour une partie que le modèle génère aussi, la déclaration du modèle gagne et celle de la source est écartée, ce qui est correct pour les parties que HotXLS régénère et ne constitue pas une fusion générale. verify_opc_uniqueness ne vérifie que l'unicité ; il ne valide pas les schémas, donc un futur attribut obligatoire aurait encore besoin d'Excel ou d'un validateur de schéma pour remonter. Et la passe TXMLReader supplémentaire sur le flux de types de contenu généré s'exécute à chaque sauvegarde avec PreserveUnsupportedParts activé, un coût modeste face à un flux qui dépasse rarement quelques kilo-octets. Avec tout cela en place, les versions Win32 et Win64 du modèle de prêt s'ouvrent maintenant dans Excel sans invite, recalculent les 4805 formules vérifiées sans aucune divergence, et rapportent la même zone d'impression que l'original

Si vous écrivez vous-même du XLSX depuis Delphi, la liste de contrôle est courte : émettez chaque attribut que le schéma marque comme obligatoire quelle que soit sa valeur, déclarez chaque nom de partie une seule fois, et tenez une seule liste d'identifiants utilisés par partie de relations pour tous les écrivains qui la touchent. Si vous préférez que cette liste existe déjà et soit testée contre Excel plutôt que contre votre seul lecteur, l'écrivain de paquet décrit ici est livré dans le composant tableur Delphi HotXLS, avec l'aller-retour des parties opaques qui rendait la jointure digne d'être surveillée