Aller au contenu
OneKitly

XML vers JSON : attributs, répétition et le piège du tableau à un seul élément

Publié le 20/07/2026 · 15 min de lecture · Outils pour développeurs

Daniel Okonkwo

Daniel OkonkwoDéveloppeur front-end et rédacteur Tech chez OneKitly

Performance web · Formats de fichiers

Vérifié à partir de 4 sources

Voir le profil
En bref

Le XML n'a aucun moyen de dire qu'un élément est une liste. Les documents <items><item>a</item></items> et <items><item>a</item><item>b</item></items> ne diffèrent que par le nombre d'enfants : un convertisseur qui lit l'un des deux ne peut pas savoir si item est un élément répétable qui se trouve n'avoir qu'une occurrence. Cet outil prend la voie habituelle : un enfant renvoie {"items":{"item":"a"}}, une chaîne, et deux renvoient {"items":{"item":["a","b"]}}, un tableau. Tout consommateur qui écrit items.item[0] fonctionne jusqu'au jour où une liste n'a qu'un élément, et lit alors la lettre a — le premier caractère de la chaîne — au lieu de l'élément. Le même document peut produire les deux formes à la fois : <r><g><i>1</i><i>2</i></g><g><i>3</i></g></r> renvoie g en tableau de deux objets, le premier avec i en tableau de deux et le second avec i en simple chaîne. Quatre conventions existent pour y répondre. Déclarer la forme dans un schéma, seul endroit où la cardinalité est jamais écrite ; donner au convertisseur une liste explicite de chemins toujours tableaux ; préfixer les noms d'attributs pour qu'ils ne heurtent pas les noms d'éléments ; et réserver une clé pour le texte d'un élément qui porte aussi des attributs. Cet outil fait les deux dernières : les attributs deviennent @nom, et #text porte le texte propre d'un élément, que celui-ci ait des attributs, des enfants élément, ou les deux. Le contenu mixte est conservé : <p>Hello <b>world</b>!</p> renvoie {"p":{"#text":["Hello","!"],"b":"world"}}, une entrée de tableau par passage de texte. Ce que rien ne consigne, c'est l'entrelacement : la sortie ne dit pas que Hello précédait <b> et que le point d'exclamation le suivait. Les passages qui ne sont que des espaces sont écartés et le reste est détouré, sauf si xml:space="preserve" est en vigueur, auquel cas l'espacement est gardé tel quel : <code xml:space="preserve"> keep </code> renvoie {"code":{"@xml:space":"preserve","#text":" keep "}}. Toute valeur est une chaîne : <n>42</n> devient "42".

Deux documents qui ne diffèrent que par le nombre d'enfants produisent deux formes JSON différentes, et aucun convertisseur ne peut les distinguer sans schéma. Plus ce que celui-ci fait réellement des attributs, du contenu mixte et des espaces — et la seule chose qu'il ne sait toujours pas consigner.

Le défaut est dans le format, pas dans le convertisseur

Écris en XML une commande à trois articles et tu répètes trois fois l'élément line. Écris une commande à un seul article et tu écris l'élément une fois. Rien dans le document ne distingue cela d'un élément qui n'est tout simplement pas répétable — pas de marque de pluriel, pas de cardinalité, pas de crochet. La spécification XML définit à quoi ressemble un document bien formé et ne dit strictement rien du nombre de fois qu'un enfant peut apparaître ; cette question relève d'un schéma, et un document n'a aucune obligation d'en avoir un.

Le convertisseur devine donc, et il n'existe que deux façons de deviner. Produire toujours un tableau, ce qui transforme chaque élément à valeur unique en tableau d'un élément et double le bruit dans la sortie. Ou ne produire un tableau que lorsqu'on observe une répétition, ce que fait presque tous les outils, celui-ci compris. Donne-lui <items><item>a</item></items> et il renvoie {"items":{"item":"a"}}. Donne-lui <items><item>a</item><item>b</item></items> et il renvoie {"items":{"item":["a","b"]}}. Donne-lui <items></items> et il renvoie {"items":""} — une chaîne vide, pas un tableau vide, car sans enfant l'élément est traité comme une feuille et son contenu textuel est vide.

La conséquence, c'est que la forme de ton JSON dépend de tes données, et qu'elle peut changer à l'intérieur d'un même document. Convertis <r><g><i>1</i><i>2</i></g><g><i>3</i></g></r> et tu obtiens g en tableau de deux objets : dans le premier, i est un tableau de deux chaînes ; dans le second, i est la chaîne 3. Un code qui parcourt g puis indexe i fonctionne sur le premier élément et lit en silence le caractère 3 sur le second — pas d'erreur, pas de plantage, juste la mauvaise valeur qui part vers la suite. C'est de loin la façon la plus courante dont une intégration XML qui marchait casse en production, et elle casse le jour où les données rétrécissent, pas le jour où elles grossissent.

Les quatre conventions existantes, et les deux qu'utilise cet outil

La première, c'est un schéma. XML Schema est le seul endroit de toute la pile où la cardinalité est écrite : une déclaration d'élément porte minOccurs et maxOccurs, et un maxOccurs supérieur à un est exactement l'affirmation « cet élément est une liste ». Un convertisseur qui dispose du schéma peut produire correctement un tableau d'un élément pour un document qui n'en contient qu'un. Sans le schéma, cette information n'existe nulle part dans le fichier, et aucune astuce ne la retrouve.

La deuxième, c'est une liste de chemins toujours-tableaux fournie au convertisseur par quelqu'un qui connaît les données. La plupart des bibliothèques XML sérieuses en acceptent une : tu nommes order.lines.line et cela devient un tableau quel que soit le nombre. C'est un schéma en miniature, écrit une fois par un humain qui connaît la réponse, et c'est la parade pragmatique quand aucun schéma formel n'existe. Cet outil n'offre pas cette option : si tu consommes sa sortie dans du code, la forme défensive consiste à normaliser avant usage — forcer toi-même la valeur en tableau si elle n'en est pas un, puis indexer.

Les troisième et quatrième portent sur les clés, et cet outil applique les deux. Les attributs sont préfixés par @, donc <book id="1"><title>Dune</title></book> renvoie {"book":{"@id":"1","title":"Dune"}} et un attribut ne peut jamais heurter un enfant du même nom — <book title="A"><title>B</title></book> conserve les deux, en @title et title. Et #text porte le texte propre d'un élément dès que ce texte doit partager l'objet avec autre chose, attributs ou enfants élément : <book id="1" lang="en">Dune</book> renvoie {"book":{"@id":"1","@lang":"en","#text":"Dune"}}. Un élément sans attributs ni enfants élément saute complètement l'enveloppe et devient directement son texte, ce qui explique que <title>Dune</title> soit la chaîne Dune et non un objet à une clé.

Le contenu mixte atterrit dans #text, l'ordre non

Le contenu mixte, c'est un élément dont les enfants mélangent du texte et d'autres éléments — un paragraphe avec un mot en gras au milieu, une description avec un lien en ligne, une clause juridique avec un terme mis en valeur. Le XML le prend en charge et s'en sert constamment ; c'est même l'essentiel de ce à quoi sert le XML documentaire. Le JSON n'a pas d'endroit naturel où le loger, car une clé d'objet peut contenir le mot en gras, mais rien ne permet de consigner que ce mot se trouvait entre deux fragments de texte.

Ce convertisseur règle le problème en rassemblant le texte dans #text. Donne-lui <p>Hello <b>world</b>!</p> et il renvoie {"p":{"#text":["Hello","!"],"b":"world"}} : une entrée par passage de texte, dans l'ordre du document, le balisage qui les a séparés siégeant à côté du tableau sous sa propre clé. Un passage unique reste une simple chaîne plutôt qu'un tableau d'un élément : <r>lead<a>1</a></r> donne "#text":"lead". Les attributs n'y changent rien : <p id="1">Hello <b>x</b> tail</p> renvoie {"p":{"@id":"1","#text":["Hello","tail"],"b":"x"}}. Plus rien n'est jeté : une notice ONIX, un fragment DocBook ou une description RSS contenant du balisage gardent leur prose. Ce que la forme ne peut pas te dire, c'est où se trouvait le balisage : rien dans cet objet ne dit que Hello précédait <b> et le point d'exclamation le suivait, et échanger les deux passages dans la source produit un JSON identique. Si la séquence porte du sens — une révision, une transcription, tout ce où l'élément en ligne marque une position dans la phrase — garde le paragraphe comme chaîne XML plutôt que de le convertir.

Les espaces sont traités en deux couches. Un passage qui n'est que des espaces ou des sauts de ligne entre deux balises relève de la mise en page et non du contenu : il est écarté — c'est pourquoi un document mis en forme ne remplit pas ton JSON de chaînes vides — et le texte qui subsiste est détouré, si bien que <code> indented line</code> revient en indented line. Le XML a un attribut dont c'est toute la raison d'être de dire ne fais pas ça, et le convertisseur lui obéit désormais : <code xml:space="preserve"> keep </code> renvoie {"code":{"@xml:space":"preserve","#text":" keep "}}, espaces compris. Il s'hérite comme la spécification le prévoit : un preserve sur un ancêtre protège tous ses descendants jusqu'à ce que l'un d'eux redéclare xml:space="default".

Ordre, espaces de noms et types : trois autres choses qui ne survivent pas

L'ordre du document entre noms d'éléments différents est perdu. <r><a>1</a><b>x</b><a>2</a></r> renvoie a en tableau de 1 et 2, et b en x, ce qui est correct dans l'absolu — les deux éléments a sont collectés bien qu'ils ne soient pas adjacents — mais le fait que b se trouvait entre eux a disparu. Dans un objet, les clés n'ont pas d'ordre sur lequel un consommateur puisse s'appuyer : il n'y a donc nulle part où le consigner. Pour du XML en forme d'enregistrement, cela n'a pas d'importance. Pour tout ce où la séquence porte du sens — un journal de processus, un historique de modifications, un récit entrelacé — cela en a énormément, et la conversion est destructrice d'une façon qu'aucune comparaison de tailles ne révèle.

Les espaces de noms sont transportés comme du texte plutôt que compris. Un enfant préfixé conserve son préfixe dans la clé : <r xmlns:ns="http://example.com"><ns:a>1</ns:a></r> donne la clé ns:a, et la déclaration elle-même apparaît en attribut @xmlns:ns. Un espace de noms par défaut est déclaré en @xmlns, puis les enfants en perdent toute trace : un élément a dans un espace de noms et un élément a hors espace de noms produisent la même clé. Si deux vocabulaires sont fusionnés dans un même document et utilisent le même nom local, tu obtiens une collision à laquelle le convertisseur est aveugle. Le préfixe est en outre un choix d'auteur, pas une part de l'identité de l'élément : le même document resérialisé avec un autre préfixe produit des clés JSON différentes pour des données identiques.

Les types ne sont pas devinés, et sur ce point l'outil a raison. <a><n>42</n><f>1.0</f><z>007</z><t>true</t></a> renvoie quatre chaînes, et non un nombre, un flottant, une chaîne à zéros et un booléen. Un document XML sans schéma n'a pas de types non plus — tout est de la donnée caractère — donc les inventer reviendrait à inventer de l'information. Conséquence pratique : tu compareras à "true" et non à true, et tu feras toi-même la conversion là où tu as besoin d'un nombre. C'est le bon arbitrage : passer d'une chaîne à un nombre est une décision qui demande de connaître le champ, et le convertisseur ne le connaît pas.

Ce que ce convertisseur refuse vraiment

L'analyseur sous-jacent est celui du navigateur, et il est strict comme il faut : <br> seul est rejeté, une balise non fermée est rejetée, deux éléments racines sont rejetés, et un attribut dupliqué est rejeté. La rigueur est justement l'intérêt d'un analyseur XML plutôt que HTML, et tout cela est un comportement correct.

Détecter l'échec est plus difficile qu'il n'y paraît, parce qu'un analyseur de navigateur ne lève pas d'exception. Il renvoie un document dans lequel il a greffé un élément nommé parsererror, à charge pour l'appelant d'aller le chercher. Chercher le nom seul est l'implémentation évidente, et la mauvaise : elle refuse tout document valide qui porte son propre élément parsererror, ce que contient précisément un journal de compilation ou un rapport de validation. Les moteurs ne s'accordent même pas sur l'endroit où le marqueur atterrit : Firefox enracine tout le document sur un marqueur dans son propre espace de noms d'erreur, tandis que Blink et WebKit en injectent un dans l'espace de noms XHTML au milieu de l'arbre et laissent ta racine en place. L'outil interroge donc le moteur au lieu de deviner : une fois par session, il analyse quelque chose de délibérément cassé, lit l'espace de noms du marqueur qui revient, et ne regarde plus que là. <log><parsererror>none</parsererror><n>1</n></log> se convertit, et renvoie {"log":{"parsererror":"none","n":"1"}}.

XML en entrée, JSON en sortie — chaque ligne est la sortie réellement renvoyée par le convertisseur
XMLJSON renvoyéCe que cela t'apprend
<items><item>a</item></items>{"items":{"item":"a"}} — une chaîneUn enfant n'est pas une liste ; indexer [0] renvoie le premier caractère
<items><item>a</item><item>b</item></items>{"items":{"item":["a","b"]}} — un tableauLa forme de la sortie dépend du nombre, pas du vocabulaire
<items></items> ou <items/>{"items":""} — une chaîne videNi tableau vide ni null : trois états se réduisent à un
<book id="1" lang="en">Dune</book>{"book":{"@id":"1","@lang":"en","#text":"Dune"}}Les attributs prennent @, le texte propre prend #text : aucun ne masque un enfant
<p>Hello <b>world</b>!</p>{"p":{"#text":["Hello","!"],"b":"world"}} — une entrée par passage de texteLa prose survit ; l'entrelacement non — rien ne dit que Hello précédait <b>
<code xml:space="preserve"> keep </code>{"code":{"@xml:space":"preserve","#text":" keep "}}L'instruction est respectée et héritée ; sans elle, chaque passage est détouré
<log><parsererror>none</parsererror><n>1</n></log>{"log":{"parsererror":"none","n":"1"}} — converti, pas refuséLe contrôle d'échec demande au moteur l'espace de noms de son marqueur : ton propre élément est à l'abri
XML en JSONConvertis du XML — collé ou depuis un fichier — en JSON propre et lisible.Essayer l'outil

Questions fréquentes

Comment écrire un code qui survit à une liste d'un seul élément ?
Normalise avant de lire. Partout où ton code attend une liste, force d'abord la valeur : si c'est déjà un tableau, garde-le ; sinon, enveloppe-le ; et traite une valeur absente ou vide comme le tableau vide. Trois lignes à la frontière de ton analyseur, appliquées à chaque chemin que tu sais répétable, et le cas à un élément cesse d'exister pour tout le reste du programme. Le faire à la frontière compte plus que le code exact : une conversion saupoudrée à chaque point d'usage finira par être oubliée à l'un d'eux, et ce sera celui qui tourne en fin de mois. Si tu consommes le même flux régulièrement, écris la liste des chemins répétables en constante à côté de l'analyseur, pour que le suivant voie quelle forme tu attendais.
Pourquoi ne pas toujours produire un tableau ?
Parce que c'est illisible, et que la lisibilité est l'essentiel de ce pour quoi on convertit du XML en JSON. Enveloppe chaque élément et un enregistrement à quinze champs à valeur unique devient quinze tableaux d'un élément, chacun à déballer à la main avant d'être affiché. Les bibliothèques qui proposent cette stratégie la rendent en général optionnelle chemin par chemin, précisément pour cette raison. Il y a aussi un coût plus subtil : un tableau d'un élément affirme que l'élément est répétable, et s'il ne l'est pas, tu as consigné sur le vocabulaire un fait faux. Entre les deux erreurs, un convertisseur qui devine à partir des données ne ment au moins jamais sur le document qu'on lui a donné — il t'en dit seulement moins que nécessaire.
Qu'advient-il du texte autour de ma balise <b> ?
Il va dans #text, à côté de l'élément enfant plutôt qu'autour de lui. <p>Hello <b>world</b>!</p> renvoie {"p":{"#text":["Hello","!"],"b":"world"}} — une entrée par passage, dans l'ordre du document, et une simple chaîne au lieu d'un tableau quand il n'y a qu'un passage. Les passages qui ne sont que des espaces sont écartés, sauf si xml:space="preserve" est en vigueur. Ce que tu ne récupères pas, c'est l'entrelacement : le JSON ne peut pas te dire que Hello précédait le mot en gras et le point d'exclamation le suivait, et si un paragraphe compte trois passages et trois éléments en ligne, recomposer la phrase à partir de cet objet relève de la devinette. Si tu n'as besoin que de la prose, lis #text et joins les passages. Si tu as besoin de la phrase exactement telle qu'écrite, ne convertis pas le paragraphe du tout : garde-le comme chaîne XML, ou utilise un convertisseur conçu pour le contenu mixte, qui représente les enfants d'un élément en un seul tableau ordonné de nœuds texte et élément au lieu de clés d'objet.
Le convertisseur gère-t-il les CDATA et les entités ?
Oui, les deux, et correctement. Une section CDATA est déballée et son contenu devient du texte ordinaire, donc <a><![CDATA[<not>markup</not>]]></a> renvoie la chaîne <not>markup</not> — les chevrons survivent en tant que caractères, ce qui est tout l'intérêt du CDATA. Les entités nommées sont résolues, donc &amp; et &lt; reviennent en esperluette et en signe inférieur, et les références numériques de caractères aussi : caf&#233; renvoie café. Les commentaires et les instructions de traitement, y compris la déclaration XML elle-même, sont entièrement supprimés, n'étant pas du contenu d'élément. Rien de tout cela n'est le travail propre du convertisseur : c'est l'analyseur XML du navigateur qui fait ce que dit la spécification — bonne raison de préférer un vrai analyseur à une expression régulière pour tout ce qui dépasse un flux figé que tu maîtrises.
Mon document est bien formé mais l'outil le dit invalide. Que faire ?
Vérifie deux choses, dans cet ordre. D'abord, traque les habitudes HTML que le XML n'autorise pas — un <br> ou un <img> sans barre de fermeture, une esperluette non échappée dans une URL, un attribut dupliqué sur un élément, ou un caractère parasite avant la déclaration XML, comme une marque d'ordre des octets arrivée par un copier-coller. Ensuite, confirme qu'il y a exactement un élément racine : deux frères au premier niveau forment un fragment, pas un document, et il faut les envelopper pour que quoi que ce soit les analyse. Si les deux sont propres et que le refus persiste, passe le fichier dans un validateur XML, qui te désignera une ligne au lieu de te dire seulement que quelque chose ne va pas.

Articles qui pourraient t'intéresser

Tous les guides
ComparatifJSON vs XML : quelle différence ?JSON et XML stockent tous deux des données structurées en texte, mais avec des compromis différents. Voici à quoi ressemble chacun, où chacun l'emporte et comment choisir.ExplicationYAML a l'air aimable et il mordYAML, c'est JSON plus une couche d'inférence de types, et c'est l'inférence qui est dangereuse. Le même fichier passé dans un analyseur YAML 1.2 puis 1.1 : no est une chaîne dans l'un et false dans l'autre, 01234 vaut 1234 dans l'un et 668 dans l'autre, et 12:30:00 est un nombre dans l'un des deux.TutorielComment convertir du JSON en CSV : aplatir des tableaux d'objets en lignes et colonnesUn guide pratique pour transformer un tableau JSON d'objets en fichier CSV propre, avec l'aplatissement des champs imbriqués et la gestion des cas limites.ExplicationÉchapper une chaîne pour JSON : trois caractères sont obligatoires, et un seul est un piègeLa RFC 8259 n'impose l'échappement que de trois choses dans une chaîne JSON. Tout le reste est facultatif. Celle qui casse réellement les chaînes de traitement est le demi-surrogate isolé : légal dans un texte JSON, impossible en UTF-8, et remplacé en silence dès que tes données sont écrites.ExplicationQu'est-ce que l'encodage Base64 ?Le Base64 transforme des données binaires en texte sûr. Voici ce qu'il fait, pourquoi il existe, pourquoi ce n'est pas du chiffrement, et le surcoût de taille qu'il ajoute.TutorielLes bases des regex : guide du débutantUne expression régulière est un motif pour rechercher du texte. Voici les briques de base — classes de caractères, quantificateurs et ancres — avec un exemple.

Outils similaires

Ceci décrit ce que font ces convertisseurs aujourd'hui, vérifié en les exécutant, et non ce qu'une norme imposerait à un convertisseur. Le CSV n'a pas de norme prescriptive : la RFC 4180 est informative et décrit un usage courant, si bien que deux outils apparemment corrects peuvent diverger sur le même fichier sans qu'aucun ait tort. L'aplatissement, la détection de types et celle des tableaux sont des conventions, pas des règles. Avant de convertir des données que tu ne pourras pas réexporter, passe d'abord sur une copie et compare le nombre de lignes et de colonnes aux deux bouts.

Sources

Tu as repéré une erreur dans cet article ?

XML vers JSON : attributs, répétition et le piège du tableau à un seul élément — OneKitly