Convertisseur XML vers CSV

Aplatit en CSV, et vous prévient quand ça ne rentre pas.

Entrée
Sortie
En attenteCollez un document pour le vérifier. La validation se fait à la frappe.

Tout s'exécute dans cet onglet. Rien de ce que vous collez n'est envoyé, journalisé ni transmis où que ce soit. Ouvrez votre panneau réseau et vérifiez.

Collez un document XML formé d’une liste d’enregistrements et il devient un tableau séparé par des virgules, ouvrable dans Excel ou chargeable avec pandas. L’élément de ligne est détecté pour vous, les enfants imbriqués deviennent des noms de colonnes pointés comme address.city, et les attributs deviennent des colonnes préfixées par @. L’analyseur, l’aplatisseur et l’écrivain CSV tournent dans un Web Worker de cet onglet : rien n’est téléversé et rien n’est retenu derrière un compte.

On y a recours quand quelque chose en amont ne parle que XML et quelque chose en aval ne parle que lignes : un flux de produits, un export de relevé bancaire, un rapport sorti d’un vieil ERP, une réponse d’API que vous voulez regarder avant d’écrire du code contre elle. C’est aussi le moyen le plus rapide de transformer un sitemap en une colonne d’URL.

La différence, c’est que la conversion vous dit ce qu’elle n’a pas pu représenter. CSV est un rectangle et XML est un arbre : certains documents s’aplatissent exactement, d’autres perdent des données. Quand des éléments répétés sont repliés dans une seule cellule, le panneau le dit et donne le compte ; quand l’élément de ligne a été deviné, il nomme celui qu’il a choisi. La plupart des convertisseurs vous tendent un fichier et vous laissent le remarquer.

CSV est un rectangle et XML est un arbre

La RFC 4180 pose la contrainte en une ligne : « Chaque ligne devrait contenir le même nombre de champs dans tout le fichier. » XML n’a pas cette règle, et ce décalage est toute la difficulté. La conversion n’est fidèle que si le XML est déjà tabulaire : un enregistrement répété dont les enfants sont des feuilles à valeur unique.

Quand un enregistrement contient un enfant répété, un <order> portant trois éléments <line>, un convertisseur a trois options : éclater en une ligne par line et dupliquer les colonnes du parent, replier les répétitions dans une cellule, ou inventer des colonnes line.1.sku et line.2.sku dont le nombre dépend de l’enregistrement le plus large. Les trois perdent quelque chose, il n’y en a pas de quatrième, et cet outil prend la deuxième et le signale.

  • S’aplatit exactement : un élément répété sous la racine, des enfants feuilles, des attributs sur l’enregistrement ou sur ses feuilles.
  • S’aplatit avec perte : un enregistrement contenant un enfant répété. Les valeurs sont jointes et le compte est signalé.
  • Ne s’aplatit pas : contenu mixte, frères hétérogènes, ou un document dont l’imbrication est justement le propos. Utilisez JSON.

Comment l’élément de ligne et les colonnes sont choisis

Laissez la case Élément de ligne vide et l’enfant élément le plus fréquent de la racine devient la ligne, ce qui est juste pour presque toute entrée XML vers CSV. La décision est imprimée plutôt que cachée : « Les lignes ont été prises des 2 éléments <book> sous <catalog> ». Si la devinette est fausse, tapez le nom vous-même.

Une limite vaut d’être connue avant de coller. L’élément de ligne doit être un enfant direct de la racine, donc un document qui enveloppe sa liste un niveau plus bas, <catalog><books><book/><book/></books></catalog>, donne une seule ligne pour <books>. Retirez cette enveloppe, ou convertissez en JSON, où l’imbrication survit.

Les colonnes sont l’union de tous les chemins sur toutes les lignes, dans l’ordre de première apparition : un champ que seuls certains enregistrements portent obtient donc quand même une colonne, et les autres une cellule vide. Les attributs se posent à la profondeur où ils surviennent, donc un attribut currency sur price devient price.@currency au lieu d’entrer en collision avec price. Les préfixes d’espaces de noms sont gardés tels quels, les entités et les CDATA sont résolus, le texte des feuilles est débarrassé de ses blancs.

<catalog xmlns:dc="http://purl.org/dc/elements/1.1/">
  <book id="bk101" available="true">
    <dc:title>XML Developer's Guide</dc:title>
    <price currency="GBP">44.95</price>
  </book>
</catalog>

@id,@available,dc:title,price.@currency,price
bk101,true,XML Developer's Guide,GBP,44.95
Le document d’exemple et les colonnes qu’il produit. Les champs ne sont mis entre guillemets que si la RFC 4180 l’exige.

Éléments répétés, et exactement ce qui est perdu

Quand un enregistrement porte le même élément feuille plus d’une fois, les valeurs sont jointes par une barre verticale dans une seule cellule et comptées. La cellule reste correctement échappée, donc une virgule dans l’une de ces valeurs ne casse pas les colonnes. Ce que vous perdez, c’est la frontière : une valeur qui contient déjà une barre verticale devient ambiguë, et le séparateur n’est pas configurable.

Un cas que la jointure ne couvre pas : quand l’élément répété a ses propres enfants, chaque occurrence écrit dans les mêmes colonnes pointées, donc la dernière gagne et les précédentes sont écrasées sans mention. Les extensions d’image dans un <url> de sitemap, et les lignes de commande à plusieurs champs chacune, se comportent ainsi toutes les deux. Extrayez-les avec XPath, ou convertissez en JSON.

Les sitemaps, et l’usage SEO de tout ceci

Un sitemap est une liste d’enregistrements : il se convertit sans configuration. La racine est <urlset>, l’enfant répété est <url>, et vous obtenez loc, lastmod, changefreq et priority comme colonnes, une ligne par URL. Cela suffit pour trier par date de modification, compter face à la limite des 50 000 URL, ou coller la colonne loc à côté d’un export de crawl pour trouver les pages présentes dans l’un et pas dans l’autre. Un index de sitemaps se convertit de la même façon.

Deux faits à emporter dans le tableur. Google déclare ignorer priority et changefreq : ces colonnes auditent donc ce que votre CMS produit, et rien d’autre. Et Google n’utilise lastmod que lorsqu’il est « systématiquement et vérifiablement exact », donc une colonne où chaque ligne partage le même horodatage, ou porte une date future, est une colonne que Google écartera probablement. Pour vérifier des règles plutôt qu’extraire, utilisez plutôt le validateur de sitemaps, qui applique les plafonds, le vocabulaire de changefreq et le format W3C Datetime.

Le faire en code

Le même aplatissement dans les langages qui consomment du XML. Lire du XML est la direction dangereuse, donc chaque exemple utilise la configuration d’analyseur sûre : entités externes coupées, chargement des DTD coupé, pas de réseau. L’écriture du CSV est explicite, car l’échappement est l’endroit où les exports faits main cassent d’ordinaire.

// Browser or Deno. DOMParser never resolves external entities, so XXE is
// not reachable here. In Node use @xmldom/xmldom, which also does not.
function xmlToCsv(source, rowTag) {
  const doc = new DOMParser().parseFromString(source, 'application/xml');
  const error = doc.querySelector('parsererror');
  if (error) throw new Error(error.textContent.trim());

  const root = doc.documentElement;
  const rows = [...root.children].filter((el) => el.tagName === rowTag);
  if (rows.length === 0) throw new Error('No <' + rowTag + '> under <' + root.tagName + '>');

  const columns = [];
  const records = rows.map((row) => {
    const record = {};
    const put = (key, value) => {
      if (!columns.includes(key)) columns.push(key);
      // Repeated leaves are joined. That is lossy; count it in real code.
      record[key] = record[key] === undefined ? value : record[key] + '|' + value;
    };
    const walk = (el, prefix) => {
      for (const a of el.attributes) {
        put(prefix ? prefix + '.@' + a.name : '@' + a.name, a.value);
      }
      const kids = [...el.children];
      if (kids.length === 0) {
        put(prefix || el.tagName, el.textContent.trim());
        return;
      }
      for (const k of kids) walk(k, prefix ? prefix + '.' + k.tagName : k.tagName);
    };
    walk(row, '');
    return record;
  });

  // RFC 4180: quote a field containing a comma, a quote or a line break,
  // and double any quote inside it.
  const cell = (v) => (/[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v);
  const line = (values) => values.map((v) => cell(v ?? '')).join(',');

  return [line(columns), ...records.map((r) => line(columns.map((c) => r[c])))].join('\r\n');
}
# pip install defusedxml
# The standard library parser is not safe against entity expansion.
# defusedxml is a drop-in replacement that closes that and external entities.
import csv
import sys
from defusedxml.ElementTree import parse


def flatten(el, prefix, record, columns):
    for name, value in el.attrib.items():
        key = f"{prefix}.@{name}" if prefix else f"@{name}"
        if key not in columns:
            columns.append(key)
        record[key] = value

    children = list(el)
    if not children:
        key = prefix or el.tag
        if key not in columns:
            columns.append(key)
        record[key] = (el.text or "").strip()
        return

    for child in children:
        # ElementTree reports namespaced names in Clark notation,
        # '{http://purl.org/dc/elements/1.1/}title', not 'dc:title'.
        tag = child.tag.split("}")[-1]
        key = f"{prefix}.{tag}" if prefix else tag
        if key in record:  # repeated leaf: join, and note the loss
            record[key] += "|" + (child.text or "").strip()
        else:
            flatten(child, key, record, columns)


def xml_to_csv(path, row_tag, out):
    root = parse(path).getroot()
    columns, records = [], []
    for row in root.findall(row_tag):
        record = {}
        flatten(row, "", record, columns)
        records.append(record)
    writer = csv.DictWriter(out, fieldnames=columns, restval="", extrasaction="ignore")
    writer.writeheader()
    writer.writerows(records)


# newline="" or csv writes \r\r\n on Windows.
# utf-8-sig writes the BOM Excel needs to read the file as UTF-8.
with open("out.csv", "w", newline="", encoding="utf-8-sig") as out:
    xml_to_csv(sys.argv[1], sys.argv[2], out)
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import javax.xml.XMLConstants;
import javax.xml.parsers.*;
import org.w3c.dom.*;

public class XmlToCsv {

    static DocumentBuilder secureBuilder() throws Exception {
        DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
        // None of this is the default. Without it a document can read files
        // off the machine running the conversion.
        f.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
        f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
        f.setFeature("http://xml.org/sax/features/external-general-entities", false);
        f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        f.setXIncludeAware(false);
        f.setExpandEntityReferences(false);
        return f.newDocumentBuilder();
    }

    static void flatten(Element el, String prefix, Map<String, String> rec, List<String> cols) {
        NamedNodeMap attrs = el.getAttributes();
        for (int i = 0; i < attrs.getLength(); i++) {
            Node a = attrs.item(i);
            String key = prefix.isEmpty() ? "@" + a.getNodeName() : prefix + ".@" + a.getNodeName();
            if (!cols.contains(key)) cols.add(key);
            rec.put(key, a.getNodeValue());
        }

        List<Element> kids = new ArrayList<>();
        NodeList children = el.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            if (children.item(i) instanceof Element e) kids.add(e);
        }

        if (kids.isEmpty()) {
            String key = prefix.isEmpty() ? el.getNodeName() : prefix;
            if (!cols.contains(key)) cols.add(key);
            rec.put(key, el.getTextContent().trim());
            return;
        }
        for (Element k : kids) {
            String key = prefix.isEmpty() ? k.getNodeName() : prefix + "." + k.getNodeName();
            if (rec.containsKey(key)) {
                rec.put(key, rec.get(key) + "|" + k.getTextContent().trim());
            } else {
                flatten(k, key, rec, cols);
            }
        }
    }

    static String cell(String v) {
        if (v == null) return "";
        boolean needsQuotes = v.indexOf(',') >= 0 || v.indexOf('"') >= 0
                || v.indexOf('\r') >= 0 || v.indexOf('\n') >= 0;
        return needsQuotes ? '"' + v.replace("\"", "\"\"") + '"' : v;
    }

    public static void main(String[] args) throws Exception {
        Document doc = secureBuilder().parse(new File(args[0]));
        List<String> cols = new ArrayList<>();
        List<Map<String, String>> recs = new ArrayList<>();

        NodeList rows = doc.getDocumentElement().getElementsByTagName(args[1]);
        for (int i = 0; i < rows.getLength(); i++) {
            Map<String, String> rec = new LinkedHashMap<>();
            flatten((Element) rows.item(i), "", rec, cols);
            recs.add(rec);
        }

        try (PrintWriter out = new PrintWriter(new OutputStreamWriter(
                new FileOutputStream("out.csv"), StandardCharsets.UTF_8))) {
            out.print(String.join(",", cols.stream().map(XmlToCsv::cell).toList()) + "\r\n");
            for (Map<String, String> rec : recs) {
                out.print(String.join(",",
                        cols.stream().map(c -> cell(rec.get(c))).toList()) + "\r\n");
            }
        }
    }
}
using System.Text;
using System.Xml;
using System.Xml.Linq;

// A null XmlResolver means an external DTD or entity is never fetched.
var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
    MaxCharactersFromEntities = 1024 * 1024,
};

using var reader = XmlReader.Create(args[0], settings);
var doc = XDocument.Load(reader);
var rowName = args[1];

var columns = new List<string>();
var records = new List<Dictionary<string, string>>();

void Flatten(XElement el, string prefix, Dictionary<string, string> rec)
{
    foreach (var a in el.Attributes())
    {
        if (a.IsNamespaceDeclaration) continue;   // xmlns is not data
        var key = prefix.Length == 0 ? "@" + a.Name.LocalName : prefix + ".@" + a.Name.LocalName;
        if (!columns.Contains(key)) columns.Add(key);
        rec[key] = a.Value;
    }

    var kids = el.Elements().ToList();
    if (kids.Count == 0)
    {
        var key = prefix.Length == 0 ? el.Name.LocalName : prefix;
        if (!columns.Contains(key)) columns.Add(key);
        rec[key] = el.Value.Trim();
        return;
    }

    foreach (var k in kids)
    {
        var key = prefix.Length == 0 ? k.Name.LocalName : prefix + "." + k.Name.LocalName;
        if (rec.ContainsKey(key)) rec[key] += "|" + k.Value.Trim();
        else Flatten(k, key, rec);
    }
}

foreach (var row in doc.Root!.Elements().Where(e => e.Name.LocalName == rowName))
{
    var rec = new Dictionary<string, string>();
    Flatten(row, "", rec);
    records.Add(rec);
}

static string Cell(string? v)
{
    if (v is null) return "";
    return v.IndexOfAny(new[] { ',', '"', '\r', '\n' }) >= 0
        ? "\"" + v.Replace("\"", "\"\"") + "\""
        : v;
}

// UTF8Encoding(true) writes a BOM, which is what makes Excel on Windows
// read the file as UTF-8 rather than the system code page.
using var writer = new StreamWriter("out.csv", false, new UTF8Encoding(true));
writer.WriteLine(string.Join(",", columns.Select(Cell)));
foreach (var rec in records)
{
    writer.WriteLine(string.Join(",", columns.Select(c => Cell(rec.GetValueOrDefault(c)))));
}
<?php
// LIBXML_NONET blocks network access during the parse. PHP 8 does not load
// external entities by default; passing the flag keeps this correct on PHP 7
// and documents the intent.
libxml_use_internal_errors(true);

$doc = new DOMDocument();
if (!$doc->load($argv[1], LIBXML_NONET)) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "line %d: %s", $e->line, $e->message);
    }
    exit(1);
}

$rowName = $argv[2];
$columns = [];
$records = [];

$flatten = function (DOMElement $el, string $prefix, array &$rec) use (&$flatten, &$columns) {
    foreach ($el->attributes as $a) {
        $key = $prefix === '' ? '@' . $a->name : $prefix . '.@' . $a->name;
        if (!in_array($key, $columns, true)) $columns[] = $key;
        $rec[$key] = $a->value;
    }

    $kids = [];
    foreach ($el->childNodes as $n) {
        if ($n instanceof DOMElement) $kids[] = $n;
    }

    if (!$kids) {
        $key = $prefix === '' ? $el->nodeName : $prefix;
        if (!in_array($key, $columns, true)) $columns[] = $key;
        $rec[$key] = trim($el->textContent);
        return;
    }

    foreach ($kids as $k) {
        $key = $prefix === '' ? $k->nodeName : $prefix . '.' . $k->nodeName;
        if (array_key_exists($key, $rec)) {
            $rec[$key] .= '|' . trim($k->textContent);
        } else {
            $flatten($k, $key, $rec);
        }
    }
};

foreach ($doc->documentElement->childNodes as $node) {
    if ($node instanceof DOMElement && $node->nodeName === $rowName) {
        $rec = [];
        $flatten($node, '', $rec);
        $records[] = $rec;
    }
}

$out = fopen('out.csv', 'w');
fwrite($out, "\xEF\xBB\xBF");        // BOM, for Excel
fputcsv($out, $columns);              // fputcsv applies RFC 4180 quoting
foreach ($records as $rec) {
    fputcsv($out, array_map(fn($c) => $rec[$c] ?? '', $columns));
}
fclose($out);
# xmlstarlet is the quickest route for a one-off extraction.
# --net=false stops it fetching a DTD or an external entity. Not the default.
xmlstarlet sel --net=false -t \
  -o 'id,title,price' -n \
  -m '/catalog/book' \
    -v '@id' -o ',' \
    -v 'title' -o ',' \
    -v 'price' -n \
  catalog.xml > out.csv

# It does no CSV quoting at all. A title containing a comma, a quote or a
# newline breaks the column alignment silently, so either be certain the
# data is clean or re-quote the output:
xmlstarlet sel --net=false -t -m '/catalog/book' \
  -v 'concat(@id,",",title)' -n catalog.xml | csvformat -   # csvkit

# For files too large to hold in memory, iterate instead of loading:
#   python -c 'from xml.etree.ElementTree import iterparse; ...'

Chaque exemple désactive quelque chose dans l’analyseur. Le comportement non sûr est le réglage par défaut en Java et dans la bibliothèque standard de Python, et la conversion est précisément le contexte où le fichier est arrivé d’un fournisseur, d’un client ou d’une boîte de réception, et non de vous.

Questions fréquentes

Mon XML est-il téléversé quand je le convertis ?

Non. L’analyseur, l’aplatisseur et l’écrivain CSV sont du JavaScript exécuté dans un Web Worker de cet onglet, et il n’y a aucun back end vers lequel envoyer quoi que ce soit. Ouvrez votre panneau réseau et convertissez quelque chose : les ressources de la page se chargent une fois, puis plus rien.

Cela compte plus pour la conversion que pour la validation, à cause de la nature de ces fichiers. Personne ne convertit un extrait de tutoriel en CSV. Les gens convertissent des exports clients, des historiques de commandes et des listes de prix fournisseurs. Votre saisie est conservée dans le localStorage de ce navigateur pour qu’un rechargement ne la perde pas, et Effacer la supprime.

Pourquoi ai-je obtenu une ligne au lieu de centaines ?

Presque toujours parce que la liste se trouve un niveau sous la racine. L’élément de ligne est choisi parmi les enfants directs de la racine, donc <catalog><books><book/><book/></books></catalog> voit un enfant <books>, donne une ligne, et aplatit chaque <book> dans les mêmes colonnes, le dernier gagnant.

Retirez l’enveloppe pour que l’élément répété soit un enfant direct, ou convertissez en JSON. Nommer <book> dans la case Élément de ligne n’aide pas : il signale qu’aucun <book> n’a été trouvé sous la racine, ce qui est vrai.

Que signifie la barre verticale dans cette cellule ?

CSV ne peut pas exprimer un champ qui survient plus d’une fois : plusieurs copies du même élément feuille sont donc jointes par une barre verticale, et le panneau indique combien de fois cela s’est produit. La cellule est échappée correctement, donc les virgules dans ces valeurs ne cassent pas les colonnes. La perte est la frontière : une valeur qui contient déjà une barre verticale est désormais ambiguë.

Quand l’élément répété a ses propres enfants, la jointure ne s’applique pas. Chaque occurrence écrit dans les mêmes colonnes pointées et la dernière écrase les autres. Extrayez-les avec XPath, ou utilisez JSON, où les répétitions deviennent un tableau.

Le résultat s’ouvrira-t-il correctement dans Excel ?

Généralement oui, avec deux réserves dont on accuse le convertisseur. Excel sous Windows lit un fichier UTF-8 nu comme la page de codes du système à moins qu’il ne commence par une marque d’ordre des octets, ce qui transforme les noms accentués en charabia : importez par Données puis À partir d’un fichier texte/CSV et choisissez UTF-8 plutôt que de double-cliquer.

Excel reformate aussi les valeurs qu’il reconnaît. Un code produit 0012 perd ses zéros, 1-2 devient une date, un long identifiant devient de la notation scientifique. Marquez ces colonnes comme Texte à l’import, et si votre locale sépare par des points-virgules, changez le réglage Délimiteur ci-dessus.

Quelle taille de fichier puis-je convertir ?

Jusqu’à 20 millions de caractères, à peu près 20 Mo, sans compte, sans file d’attente et sans palier de taille, puisqu’il n’y a pas de serveur pour en compter. L’outil XML gratuit le plus visible de ce domaine plafonne l’entrée à 512 Ko et vous demande de confirmer que vos données sont stockées sur ses serveurs.

La limite est de mémoire et non de politique : tout est gardé dans cet onglet. En dessous, le coût est linéaire, 1 Mo en 110 millisecondes environ et 10 Mo en un peu plus d’une seconde. Au-dessus, traitez en flux en local avec iterparse, SAX ou xmlstarlet.

Faut-il convertir en CSV ou en JSON ?

CSV quand la destination est un tableur, un import en masse qui attend des colonnes, ou une personne qui va trier et filtrer. JSON quand les données ont une structure qui mérite d’être gardée.

Le test est rapide : regardez un enregistrement et demandez-vous si chaque champ apparaît exactement une fois et porte une seule valeur. Si oui, CSV est fidèle. Si l’un se répète ou porte des sous-champs, vous reconstruirez cette structure plus tard à partir de délimiteurs au fond des cellules.

Outils associés

Pour aller plus loin