Convertisseur CSV vers XML
Convertit en XML, en-têtes transformés en noms valides.
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 CSV et chaque ligne devient un élément. La première ligne fournit les noms d’éléments, et vous choisissez le nom de la racine, le nom de la ligne, le délimiteur, et si les valeurs s’écrivent comme éléments enfants ou comme attributs. L’analyseur, l’assainisseur de noms et le sérialiseur tournent dans un Web Worker de cet onglet : rien n’est téléversé.
On y a recours quand un tableur doit alimenter quelque chose qui n’accepte que du XML : un import en masse dans un ERP ancien, une charge pour un point d’accès SOAP, un flux de catalogue, ou une fixture pour une suite de tests qui lit du XML. C’est d’ordinaire un aller simple, et c’est pourquoi réussir l’échappement du premier coup compte plus que l’aller-retour.
Deux choses distinguent ceci d’un découpage sur les virgules. L’analyseur met en œuvre la RFC 4180 : les champs entre guillemets, les virgules incluses, les sauts de ligne inclus et les guillemets doublés survivent tous. Et les deux endroits où CSV et XML se contredisent vraiment — des en-têtes qui ne sont pas des noms XML licites, et des lignes au mauvais nombre de champs — sont signalés plutôt que corrigés en silence dans votre dos.
Découper sur les virgules n’est pas analyser du CSV
La RFC 4180 autorise un champ entouré de guillemets doubles, et un champ entre guillemets peut contenir des virgules, des sauts de ligne et des guillemets, un guillemet littéral s’écrivant avec deux guillemets. Un convertisseur bâti sur un découpage par virgule massacre les quatre cas, et les massacre en silence : le nombre de lignes paraît toujours plausible, donc la corruption remonte plus tard, dans ce qui a consommé le XML.
L’analyseur traite ces cas et tranche délibérément ceux que la RFC ne couvre pas, car les exports réels ne sont pas toujours conformes. Les fins CRLF et CR seul sont normalisées en LF, un unique saut de ligne final est écarté plutôt que de produire une ligne vide fantôme, et les lignes vides sont sautées. Un guillemet n’ouvre un champ que s’il est le premier caractère de ce champ : un guillemet égaré dans une valeur non citée est donc gardé comme donnée au lieu de désaligner le reste du fichier.
sku,description,qty,unit price
WID-9,"Widget, large",3,12.50
GRM-2,"Grommet ""heavy duty""",1,27.35
<row>
<sku>GRM-2</sku>
<description>Grommet "heavy duty"</description>
<qty>1</qty>
<unit_price>27.35</unit_price>
</row>Les en-têtes de colonnes deviennent des noms d’éléments, et la plupart ne peuvent pas
XML 1.0 est strict sur les noms : un nom commence par une lettre, un tiret bas ou deux-points, puis continue par des lettres, des chiffres, des traits d’union, des points ou des deux-points. Les en-têtes de tableur ne s’y conforment presque jamais. « unit price », « 2024 total » et « price (GBP) » sont tous illicites, et un convertisseur doit faire quelque chose de chacun.
Chaque en-tête est vérifié et, s’il échoue, assaini de la même façon : les caractères hors du jeu licite deviennent des tirets bas, un nom qui commencerait par un chiffre reçoit un tiret bas en tête, et un nom commençant par les lettres xml en quelque casse que ce soit en reçoit un aussi, car XML 1.0 réserve ce préfixe. Un en-tête vide devient column1, column2, et ainsi de suite.
Chaque renommage est listé dans le panneau sous la forme « unit price vers unit_price », pour que vous voyiez ce que votre XPath en aval devra viser. Cette liste est imprimée plutôt que comptée pour une raison : assainir n’est pas réversible, donc deux en-têtes qui ne diffèrent que par les caractères remplacés peuvent aboutir au même nom, et XML autorise des éléments frères à partager un nom, donc rien ne lève d’erreur.
Éléments ou attributs
Les éléments sont le réglage par défaut et sont généralement le bon choix. Les attributs sont plus compacts et sont ce qu’attendent certains importeurs anciens ; la case Colonnes en attributs écrit chaque valeur comme attribut sur un élément de ligne auto-fermant. Les noms de racine et de ligne que vous tapez sont utilisés exactement tels quels, sans assainissement : gardez-les donc licites.
Le compromis est réel dans un sens. XML 1.0 normalise les valeurs d’attributs à l’analyse, en remplaçant une tabulation ou un saut de ligne littéral par une espace : une cellule multiligne ne survivrait donc pas à un aller-retour. Le sérialiseur écrit pour cela les tabulations, retours chariot et sauts de ligne dans les attributs comme les références de caractères 	, et , qui ne sont pas normalisées. Le contenu textuel n’a pas besoin de ce traitement.
Deux autres différences comptent. Un attribut ne peut pas se répéter sur le même élément, donc un en-tête en double produit un document mal formé, et un attribut vide est indiscernable d’un attribut absent.
Les lignes irrégulières sont signalées, pas complétées
Un CSV où certaines lignes portent moins ou plus de champs que l’en-tête est d’ordinaire un symptôme : un délimiteur non échappé, un export cassé, ou deux fichiers concaténés. Le compléter en silence produit du XML qui a l’air bon et qui est faux, donc le compte est signalé à la place : « 4 lignes ont un nombre de champs différent de l’en-tête. »
La sortie est tout de même produite. Les valeurs manquantes sont écrites comme éléments vides pour que la structure de l’enregistrement reste uniforme, et les valeurs en trop sont gardées sous des noms générés columnN plutôt que supprimées. Les deux restent visibles dans la sortie, et les deux veulent dire la même chose : allez regarder ces lignes dans la source.
Pourquoi l’analyseur CSV est écrit à la main
La réponse évidente est une bibliothèque, et elle a été écartée pour son poids. Cette page promet de se charger vite sur un téléphone au wifi d’hôtel, et un analyseur CSV plus un émetteur YAML plus un constructeur XML ajoutent quelques centaines de kilooctets à chaque visite pour quatre-vingts lignes de comportement. L’analyseur est une boucle sur les caractères avec un booléen « à l’intérieur des guillemets », et c’est là toute la RFC 4180.
Le coût est la couverture des dialectes. Pas de détection de délimiteur au-delà des trois choix ci-dessus, pas d’autre caractère de citation que le guillemet double, pas de convention de lignes de commentaire, et pas d’inférence de types, car pour XML toute valeur de CSV est une chaîne. Si votre fichier utilise un délimiteur barre verticale ou un échappement par antislash, normalisez-le avant de coller.
Le faire en code
La même conversion dans les langages qui consomment du XML. Le risque est ici l’inverse du sens XML vers CSV : rien n’est analysé de façon non sûre, mais tout est écrit, et une valeur contenant une esperluette ou un chevron produit un document cassé si elle n’est pas échappée. Chaque exemple utilise un écrivain qui échappe pour vous au lieu de concaténer des chaînes.
// No dependencies. The parser is RFC 4180: one loop and one flag.
function parseCsv(text, delimiter = ',') {
const src = text.replace(/\r\n?/g, '\n').replace(/\n$/, '');
const rows = [];
let row = [], field = '', quoted = false;
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (quoted) {
if (c === '"' && src[i + 1] === '"') { field += '"'; i++; }
else if (c === '"') quoted = false;
else field += c;
continue;
}
if (c === '"' && field === '') quoted = true;
else if (c === delimiter) { row.push(field); field = ''; }
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
else field += c;
}
if (field !== '' || row.length) { row.push(field); rows.push(row); }
return rows;
}
// XML 1.0 names: start with a letter, underscore or colon; the 'xml'
// prefix is reserved in any case combination.
function xmlName(heading, index) {
const base = heading.trim() || 'column' + (index + 1);
if (/^[A-Za-z_][\w.\-]*$/.test(base)) return base;
let name = base.replace(/[^\w.\-]/g, '_');
if (!/^[A-Za-z_]/.test(name)) name = '_' + name;
if (/^xml/i.test(name)) name = '_' + name;
return name;
}
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
function csvToXml(text, { root = 'rows', row = 'row', delimiter = ',' } = {}) {
const rows = parseCsv(text, delimiter);
const headers = rows[0].map(xmlName);
const out = ['<?xml version="1.0" encoding="UTF-8"?>', '<' + root + '>'];
for (const r of rows.slice(1)) {
if (r.length === 1 && r[0] === '') continue; // blank line
if (r.length !== headers.length) {
console.warn('row has ' + r.length + ' fields, header has ' + headers.length);
}
out.push(' <' + row + '>');
headers.forEach((h, i) => out.push(' <' + h + '>' + esc(r[i] ?? '') + '</' + h + '>'));
out.push(' </' + row + '>');
}
out.push('</' + root + '>');
return out.join('\n');
}# Standard library only. csv handles RFC 4180 and ElementTree escapes
# the output, so neither quoting nor & and < are your problem.
import csv
import re
import sys
import xml.etree.ElementTree as ET
NAME_OK = re.compile(r"^[A-Za-z_][\w.\-]*$")
def xml_name(heading, index):
base = heading.strip() or f"column{index + 1}"
if NAME_OK.match(base):
return base
name = re.sub(r"[^\w.\-]", "_", base)
if not re.match(r"^[A-Za-z_]", name):
name = "_" + name
if name[:3].lower() == "xml": # reserved by XML 1.0 section 2.3
name = "_" + name
return name
def csv_to_xml(path, root_name="rows", row_name="row", delimiter=","):
# newline="" lets csv handle line breaks inside quoted fields itself.
with open(path, newline="", encoding="utf-8-sig") as f:
rows = list(csv.reader(f, delimiter=delimiter))
headers = [xml_name(h, i) for i, h in enumerate(rows[0])]
root = ET.Element(root_name)
for r in rows[1:]:
if not any(field.strip() for field in r):
continue
if len(r) != len(headers):
print(f"row has {len(r)} fields, header has {len(headers)}", file=sys.stderr)
row = ET.SubElement(root, row_name)
for i, name in enumerate(headers):
ET.SubElement(row, name).text = r[i] if i < len(r) else ""
tree = ET.ElementTree(root)
ET.indent(tree, space=" ") # Python 3.9+
tree.write(sys.stdout.buffer, encoding="UTF-8", xml_declaration=True)
csv_to_xml(sys.argv[1])// Apache Commons CSV for RFC 4180 parsing, StAX for escaped output.
// <dependency>org.apache.commons:commons-csv:1.11.0</dependency>
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import javax.xml.stream.*;
import org.apache.commons.csv.*;
public class CsvToXml {
static String xmlName(String heading, int index) {
String base = heading == null ? "" : heading.trim();
if (base.isEmpty()) return "column" + (index + 1);
if (base.matches("[A-Za-z_][\\w.\\-]*")) return base;
String name = base.replaceAll("[^\\w.\\-]", "_");
if (!name.matches("^[A-Za-z_].*")) name = "_" + name;
if (name.regionMatches(true, 0, "xml", 0, 3)) name = "_" + name;
return name;
}
public static void main(String[] args) throws Exception {
CSVFormat format = CSVFormat.RFC4180.builder()
.setHeader().setSkipHeaderRecord(true).build();
try (Reader in = Files.newBufferedReader(Path.of(args[0]), StandardCharsets.UTF_8);
CSVParser parser = CSVParser.parse(in, format)) {
List<String> raw = parser.getHeaderNames();
List<String> names = new ArrayList<>();
for (int i = 0; i < raw.size(); i++) names.add(xmlName(raw.get(i), i));
XMLStreamWriter out = XMLOutputFactory.newInstance()
.createXMLStreamWriter(new OutputStreamWriter(
new FileOutputStream("out.xml"), StandardCharsets.UTF_8));
out.writeStartDocument("UTF-8", "1.0");
out.writeStartElement("rows");
for (CSVRecord record : parser) {
if (record.size() != names.size()) {
System.err.printf("row %d has %d fields, header has %d%n",
record.getRecordNumber(), record.size(), names.size());
}
out.writeStartElement("row");
for (int i = 0; i < names.size(); i++) {
out.writeStartElement(names.get(i));
// writeCharacters escapes &, < and >. Never concatenate.
out.writeCharacters(i < record.size() ? record.get(i) : "");
out.writeEndElement();
}
out.writeEndElement();
}
out.writeEndElement();
out.writeEndDocument();
out.close();
}
}
}// dotnet add package CsvHelper
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using CsvHelper;
using CsvHelper.Configuration;
static string XmlName(string heading, int index)
{
var b = (heading ?? "").Trim();
if (b.Length == 0) return "column" + (index + 1);
if (Regex.IsMatch(b, @"^[A-Za-z_][\w.\-]*$")) return b;
var name = Regex.Replace(b, @"[^\w.\-]", "_");
if (!Regex.IsMatch(name, @"^[A-Za-z_]")) name = "_" + name;
if (name.StartsWith("xml", StringComparison.OrdinalIgnoreCase)) name = "_" + name;
return name;
}
var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ",",
HasHeaderRecord = true,
BadDataFound = ctx => Console.Error.WriteLine($"bad quoting on row {ctx.RawRecord}"),
};
using var reader = new StreamReader(args[0], Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
using var csv = new CsvReader(reader, config);
csv.Read();
csv.ReadHeader();
var names = csv.HeaderRecord!.Select(XmlName).ToArray();
var settings = new XmlWriterSettings
{
Indent = true,
Encoding = new UTF8Encoding(false),
// Entitize is the default and is what keeps a newline inside an
// attribute value from being normalised away on the next parse.
NewLineHandling = NewLineHandling.Entitize,
};
using var writer = XmlWriter.Create("out.xml", settings);
writer.WriteStartDocument();
writer.WriteStartElement("rows");
while (csv.Read())
{
writer.WriteStartElement("row");
for (var i = 0; i < names.Length; i++)
{
// WriteElementString escapes the value for you.
writer.WriteElementString(names[i], csv.TryGetField<string>(i, out var v) ? v : "");
}
writer.WriteEndElement();
}
writer.WriteEndElement();
writer.WriteEndDocument();<?php
// fgetcsv implements RFC 4180 quoting, and DOMDocument escapes text nodes,
// so the two halves that usually break are both handled for you.
function xml_name(string $heading, int $index): string {
$base = trim($heading);
if ($base === '') return 'column' . ($index + 1);
if (preg_match('/^[A-Za-z_][\w.\-]*$/', $base)) return $base;
$name = preg_replace('/[^\w.\-]/', '_', $base);
if (!preg_match('/^[A-Za-z_]/', $name)) $name = '_' . $name;
if (stripos($name, 'xml') === 0) $name = '_' . $name;
return $name;
}
$handle = fopen($argv[1], 'r');
$header = fgetcsv($handle);
// Strip a UTF-8 BOM from the first heading; Excel writes one.
$header[0] = preg_replace('/^\xEF\xBB\xBF/', '', $header[0]);
$names = [];
foreach ($header as $i => $h) $names[] = xml_name($h, $i);
$doc = new DOMDocument('1.0', 'UTF-8');
$doc->formatOutput = true;
$root = $doc->createElement('rows');
$doc->appendChild($root);
$line = 1;
while (($fields = fgetcsv($handle)) !== false) {
$line++;
if ($fields === [null] || $fields === ['']) continue; // blank line
if (count($fields) !== count($names)) {
fprintf(STDERR, "line %d has %d fields, header has %d\n",
$line, count($fields), count($names));
}
$row = $doc->createElement('row');
foreach ($names as $i => $name) {
// createTextNode escapes; createElement($name, $value) does not.
$el = $doc->createElement($name);
$el->appendChild($doc->createTextNode($fields[$i] ?? ''));
$row->appendChild($el);
}
$root->appendChild($row);
}
fclose($handle);
echo $doc->saveXML();# Import-Csv parses RFC 4180 quoting, including embedded newlines.
# ConvertTo-Xml escapes the values.
Import-Csv -Path .\data.csv -Delimiter ',' |
ConvertTo-Xml -As String -NoTypeInformation |
Out-File -FilePath .\out.xml -Encoding utf8
# Note the shape it produces. Headings become Name attributes, not
# element names, so nothing needs sanitising and nothing round-trips
# into the schema you probably wanted:
#
# <Objects>
# <Object>
# <Property Name="unit price">12.50</Property>
# </Object>
# </Objects>
#
# For heading-as-element-name output, build it explicitly:
$rows = Import-Csv -Path .\data.csv
$doc = New-Object System.Xml.XmlDocument
$root = $doc.AppendChild($doc.CreateElement('rows'))
foreach ($r in $rows) {
$row = $root.AppendChild($doc.CreateElement('row'))
foreach ($p in $r.PSObject.Properties) {
$name = $p.Name -replace '[^\w.\-]', '_'
if ($name -notmatch '^[A-Za-z_]') { $name = "_$name" }
$el = $row.AppendChild($doc.CreateElement($name))
$el.InnerText = $p.Value # InnerText escapes, InnerXml does not
}
}
$doc.Save((Join-Path $PWD 'out.xml'))La règle commune aux six : laissez l’écrivain échapper. Construire du XML en concaténant des chaînes marche jusqu’au premier nom de fournisseur contenant une esperluette, et produit ensuite un document qu’aucun analyseur n’acceptera.
Questions fréquentes
Mon CSV est-il téléversé quelque part ?
Non. L’analyseur CSV, l’assainisseur de noms et le sérialiseur XML tournent 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.
Le CSV est le format qui transporte les exports sensibles : un tableur collé dans un convertisseur est typiquement une liste de clients, un extrait de paie ou un fichier de prix sous accord de confidentialité. Votre saisie est conservée dans le localStorage de ce navigateur pour qu’un rechargement ne la perde pas, et Effacer la supprime.
Qu’advient-il d’un en-tête de colonne comme « unit price » ou « 2024 total » ?
Les deux sont des noms d’éléments XML illicites, donc les deux sont assainis. Les caractères hors du jeu licite deviennent des tirets bas, ce qui donne unit_price ; un nom qui commencerait par un chiffre reçoit un tiret bas en tête, ce qui donne _2024_total. Un en-tête commençant par les lettres xml en quelque casse que ce soit en reçoit un aussi, car XML 1.0 réserve ce préfixe.
Chaque renommage est listé dans le panneau de résultat avec l’original à côté du nouveau nom, pour que vous puissiez copier les vrais noms d’éléments dans le XPath ou le XSD qui consommera le fichier. Pour choisir les noms vous-même, modifiez la ligne d’en-tête avant de convertir.
Mes valeurs contiennent des virgules. Les colonnes resteront-elles alignées ?
Oui, tant que ces valeurs sont entre guillemets, ce que tout export de tableur ou de base de données fait automatiquement. L’analyseur suit la RFC 4180 : un champ entre guillemets peut contenir des virgules, des sauts de ligne et des guillemets, et deux guillemets d’affilée à l’intérieur valent un guillemet littéral.
Un saut de ligne dans un champ cité est conservé, donc une adresse multiligne reste une seule valeur. Sous Colonnes en attributs, il est écrit plutôt qu’un saut littéral, car XML normalise les sauts littéraux dans les valeurs d’attributs en espaces et la référence de caractère est la seule forme qui survive.
Et si certaines lignes ont plus ou moins de champs que l’en-tête ?
On vous le dit. Le panneau signale combien de lignes ne correspondent pas à la largeur de l’en-tête, car un CSV irrégulier veut presque toujours dire que quelque chose en amont est cassé : un délimiteur non échappé, un export tronqué, ou deux fichiers concaténés.
La conversion a tout de même lieu. Les valeurs manquantes sont écrites comme éléments vides pour que chaque enregistrement garde la même forme, et les valeurs en trop sont gardées sous des noms générés columnN plutôt que supprimées. Les deux laissent l’anomalie visible dans la sortie au lieu de la cacher.
Les valeurs doivent-elles être des éléments ou des attributs ?
Des éléments, sauf si quelque chose en aval exige des attributs. Les éléments peuvent se répéter, porter du texte multiligne sans artifice d’encodage, sont plus faciles à contraindre dans un XSD, et distinguent une valeur vide d’une valeur absente. Les attributs sont plus compacts et sont ce qu’attendent certains importeurs anciens.
Si vous basculez, un en-tête en double devient un attribut en double sur le même élément, ce qui n’est pas du XML bien formé. Les éléments n’ont pas cette restriction.
Mon export utilise des points-virgules, pas des virgules. Cela marche-t-il ?
Oui. Mettez le réglage Délimiteur sur Point-virgule. Excel, dans la plupart des locales européennes, écrit des fichiers séparés par des points-virgules tout en les appelant CSV, parce que la virgule y est le séparateur décimal, et un convertisseur qui suppose des virgules transforme 12,50 en deux colonnes.
Les fichiers séparés par des tabulations marchent pareil. Un délimiteur barre verticale ou accent circonflexe n’est pas proposé et demande un rechercher-remplacer d’abord. Une marque d’ordre des octets UTF-8, qu’Excel écrit au début du fichier, est retirée du premier en-tête plutôt que de devenir une partie du nom de l’élément.