Un xlsx valide n'est pas obligé de contenir xl/worksheets/sheet1.xml. HotXLS, le composant de feuille de calcul Excel natif pour Delphi et C++Builder, localise chaque partie via le graphe de relations OPC plutôt que de deviner des noms, car ISO/IEC 29500-2 garantit seulement que les parties sont atteignables depuis _rels/.rels, jamais qu'elles siègent à des chemins conventionnels
Pourquoi mon analyseur échoue-t-il sur un xlsx valide ?
Parce que les noms de parties que vous avez mémorisés sont une convention d'un producteur, pas une exigence du format. Chaque chemin que vous avez jamais codé en dur, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml, est ce que l'écrivain Excel de bureau se trouve émettre. Un paquet conforme peut placer le classeur à office/book.xml et la première feuille à xl/custom/data-sheet.xml et rester du SpreadsheetML légal, tant que les relations pointent vers là. C'est la raison la plus fréquente pour laquelle un lecteur maison rapporte « impossible de trouver sheet1.xml » sur un fichier qu'Excel, LibreOffice et Numbers ouvrent tous sans se plaindre
Les producteurs qui font cela ne sont pas exotiques. Les générateurs de rapports côté serveur réutilisent un paquet modèle et conservent sa disposition d'origine. Les pipelines d'export qui fusionnent deux classeurs renumérotent les feuilles et laissent des trous, si bien qu'un classeur à cinq feuilles a sheet1, sheet2, sheet4, sheet7 et sheet9. Les outils qui suppriment une feuille ne renumérotent pas toujours les survivantes. Dans chacun de ces cas, la supposition basée sur l'index xl/worksheets/sheet + IntToStr(i + 1) + .xml lit silencieusement la mauvaise feuille ou ne lit rien, ce qui est pire qu'une exception car le classeur se charge et les chiffres sont faux. Le paquet minimal ci-dessous exerce tout le problème, et c'est la forme sur laquelle HotXLS effectue ses tests de non-régression
<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="office/book.xml"/>
</Relationships>
<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId42"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="../xl/custom/data-sheet.xml"/>
</Relationships>
<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="note7"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
Target="../notes/review.xml"/>
</Relationships>
Que garantit réellement ISO/IEC 29500-2 ?
Elle garantit l'atteignabilité, pas l'emplacement. ISO/IEC 29500-2 est la partie Open Packaging Conventions de la norme, et sa clause sur les relations définit exactement un point d'entrée fixe : la partie de relation du paquet à _rels/.rels. À partir de là, on suit la relation dont le Type est http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument pour atteindre la partie classeur, et chaque autre partie est découverte en lisant la propre partie de relation de cette partie et en suivant des arêtes typées vers l'extérieur
Deux règles supplémentaires de la même norme font le vrai travail. La clause de nommage de partie fixe où vit une partie de relation : pour une partie à <folder>/<name>, ses relations sont à <folder>/_rels/<name>.rels, et pour une partie à la racine du paquet le dossier est simplement _rels/. La clause de balisage des relations énonce que Target est une référence URI résolue par rapport à l'URI de la partie source, au sens ordinaire de la RFC 3986, sauf si TargetMode="External" la marque comme pointant en dehors du paquet. La résolution relative à la source est l'étape que tout le monde saute, et c'est pourquoi le même littéral ../notes/review.xml signifie une chose à l'intérieur de xl/custom/_rels/data-sheet.xml.rels et tout autre chose à l'intérieur d'un fichier rels un dossier plus bas. Une dernière subtilité se situe entre le modèle logique et les octets sur disque : les noms de parties dans le modèle logique sont absolus et commencent par une barre oblique, mais la clause de correspondance physique ZIP retire cette barre oblique quand elle transforme un nom de partie en nom d'élément ZIP, si bien qu'un résolveur qui l'oublie recherche /xl/sharedStrings.xml dans l'archive et ne trouve rien
À l'intérieur de XlsxResolveRelationshipTarget
HotXLS concentre toute la règle de résolution dans une seule fonction, XlsxResolveRelationshipTarget, déclarée dans lxHandleX.pas comme function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString. Elle prend le nom d'élément ZIP de la partie source et l'attribut Target brut, et retourne un nom d'élément ZIP sans barre oblique de tête, prêt à être transmis directement à l'archive. Passer un OwnerPartName vide résout contre la racine du paquet, ce qui est exactement ce dont la partie de relation du paquet a besoin. L'ordre des opérations compte plus que les étapes individuelles : les barres obliques inverses sont d'abord normalisées en barres obliques normales, car certains producteurs écrivent des séparateurs Windows dans Target ; tout fragment introduit par # est coupé avant le traitement du chemin, si bien que ../charts/chart1.xml#Sheet1 se résout en un nom de partie plutôt qu'en une entrée d'archive inexistante ; ce n'est qu'ensuite que la fonction sépare l'absolu du relatif
// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
Delete(combined, 1, 1) // package-absolute: strip the slash only
else
begin
p := LastDelimiter('/', String(OwnerPartName));
if p > 0 then
baseName := Copy(OwnerPartName, 1, p)
else
baseName := '';
combined := baseName + combined; // relative to the source part folder
end;
source.StrictDelimiter := True; // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
segment := WideString(source[i]);
if (segment = '') or (segment = '.') then
Continue; // empty and dot segments vanish
if segment = '..' then
begin
if parts.Count > 0 then
parts.Delete(parts.Count - 1); // pop, and never below the root
end
else
parts.Add(String(segment));
end;
La boucle sur les segments est un simple parcours de pile : les segments vides et . sont abandonnés, .. retire un niveau, et un .. qui s'échapperait de la racine du paquet est absorbé plutôt que de produire un index négatif ou un nom commençant par ../. L'affectation StrictDelimiter := True n'est pas cosmétique. Sans elle, un TStringList Delphi traite les espaces comme des délimiteurs et honore les caractères de guillemet, ce qui abîme tout nom de partie contenant un espace, et les noms de parties avec des espaces sont légaux
Suivre le graphe : classeur, feuille, dessin
HotXLS parcourt trois niveaux de parties de relation sur le chemin de TXLSXWorkbook.Open. Le niveau paquet est géré par XlsxFindOfficeDocumentPart, qui lit _rels/.rels et retourne la cible officeDocument. Le niveau classeur lit la partie de relation du classeur et construit deux cartes à la fois : une carte d'identifiants pour les recherches r:id et une carte de types pour les parties singleton. Les niveaux feuille et dessin répètent le motif avec ParseWorksheetRelsXml et ParseDrawingRelsXml, chacun passant son propre nom de partie comme base de résolution, si bien qu'un dessin référençant ../media/image3.png atterrit sur le bon blob
// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
WorkbookPartName := 'xl/workbook.xml'; // legacy fallback
if not zip.Exists(WorkbookPartName) then
Exit;
// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParsePartRelationshipsXml(relsStream, WorkbookPartName,
WorkbookTargetById, WorkbookTargetsByType);
finally
relsStream.Free;
end;
end;
// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
PartName := 'xl/sharedStrings.xml';
Les feuilles spécifiquement doivent passer par la carte d'identifiants, pas par la carte de types. Les éléments <sheet> dans la partie classeur portent des attributs r:id, et cet identifiant est la seule chose liant un nom de feuille à une partie. HotXLS collecte ces identifiants pendant ParseWorkbookXml et résout chacun contre la carte de relations du classeur, ne retombant sur le nom numéroté conventionnel que lorsque l'identifiant est absent ou non résoluble
// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));
// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParseWorksheetRelsXml(relsStream, PartName,
FParRels[i], ParTableTargets[i], ParPartTargets[i]);
finally
relsStream.Free;
end;
end;
Tout ce qui est en aval s'appuie sur ce même mécanisme. Les chaînes partagées, les styles, le thème, le projet VBA sous le type à espace de noms Microsoft http://schemas.microsoft.com/office/2006/relationships/vbaProject, les liens externes, la partie personne à portée classeur, les commentaires historiques, les commentaires en fil, le dessin VML qui porte la géométrie des bulles de commentaire, les dessins, images, graphiques, tableaux et tableaux croisés dynamiques atteignent tous leurs octets via des cibles résolues. La partie thème en particulier doit être localisée correctement, sans quoi un aller-retour écrase silencieusement la palette de marque d'un client par le thème Office standard, l'un des modes d'échec couverts dans les notes sur l'aller-retour XLSX sans perte du thème, extLst et calcChain. La lecture des relations explique aussi pourquoi le chargement est échelonné de cette façon : tout accès à l'archive se produit sur un seul thread avant que le XML de la feuille ne soit analysé, car l'état d'inflation d'une archive ZIP n'est pas thread-safe, une contrainte expliquée dans l'article sur l'analyse XLSX parallèle et l'allocateur mémoire
Pourquoi un rId dupliqué casse-t-il le routage basé sur le type ?
Parce qu'une entrée malformée ultérieure peut écraser une entrée valide antérieure et détourner la recherche. Les identifiants de relation sont censés être uniques à l'intérieur d'une partie de relation, mais des paquets malformés les réutilisent, et une affectation naïve Values[Id] := suit la règle du dernier écrivain gagnant. Si rId3 pointe d'abord vers une vraie feuille et qu'un second rId3 pointe vers une cible non prise en charge ou vide, la règle du dernier gagnant perd la feuille. ParsePartRelationshipsXml applique donc une règle du premier gagnant avec deux conditions : la cible résolue doit être non vide, et l'identifiant ne doit pas déjà être présent. Les deux conditions ensemble sont ce qui rend cela sûr, car le test de non-vacuité empêche une relation avec un Target manquant de s'emparer de l'emplacement avant qu'une cible utilisable n'arrive
if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
(TargetById.IndexOfName(String(Id)) < 0) then
TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
TargetsByType.Add(String(relType + '=' + resolvedTarget));
Notez l'asymétrie délibérée dans cet extrait. La carte d'identifiants est une véritable carte avec une garde du premier gagnant, tandis que la collection de types est une liste en ajout seul de paires type=target. Cette distinction porte du poids : un classeur a exactement une relation de chaînes partagées mais de nombreuses relations de feuille et de lien externe, si bien que la recherche de type via Values[] retourne la première correspondance pour les singletons, et les types à valeurs multiples tels que externalLink sont énumérés en parcourant la liste
Où s'arrête le suivi des relations
Des limites honnêtes comptent plus qu'une histoire propre. HotXLS retombe sur des noms conventionnels chaque fois qu'une relation est absente, si bien qu'un paquet avec une partie de relation endommagée ou manquante s'ouvre quand même s'il se trouve suivre la disposition Excel ; ce repli est une fonctionnalité de compatibilité, pas une seconde source de vérité, et il peut masquer un bogue de producteur pendant les tests. Trois autres limites méritent d'être connues. Les cibles marquées TargetMode="External" sont stockées telles quelles plutôt que résolues, ce qui est correct pour les hyperliens et pour la relation externalLinkPath portant une URL de classeur distant, mais cela signifie que la valeur récupérée est exactement ce que le producteur a écrit. Les parties graphique découvertes via une partie de relation de dessin sont appariées aux ancrages de dessin par position plutôt que par identifiant, si bien qu'un ordre d'ancrage inhabituel peut désaligner les liaisons de graphique. Et le lecteur direct en flux dans lxDirectRead.pas conserve sa propre gestion de chemin plus légère indexée sur xl/, si bien que le résolveur complet décrit ici régit les points d'entrée TXLSXWorkbook.Open et GetSheetNames, pas le chemin de balayage à faible allocation documenté dans l'article sur le lecteur direct en flux pour Delphi
Si vous construisez cela vous-même, le résumé correct le plus court est : ne jamais construire un nom de partie, toujours en résoudre un. Lisez _rels/.rels, suivez officeDocument, résolvez chaque Target contre la partie qui l'a déclaré, et routez les feuilles par r:id. Si vous préférez que cela soit déjà testé contre des parties renommées, une numérotation de feuille non contiguë et des identifiants de relation dupliqués, le résolveur décrit ici est livré dans le composant de feuille de calcul Delphi HotXLS, avec la mécanique d'aller-retour qui garde intactes les parties qu'il n'analyse pas