Convertitore da CSV a XML

Converte in XML, con le intestazioni rese nomi validi.

Input
Output
In attesaIncolla un documento per controllarlo. La convalida gira mentre scrivi.

Tutto viene eseguito in questa scheda. Nulla di ciò che incolli viene caricato, registrato o inviato da qualche parte. Apri il pannello di rete e verifica.

Incolla un CSV e ogni riga diventa un elemento. La prima riga fornisce i nomi degli elementi, e tu scegli il nome della radice, il nome della riga, il delimitatore, e se i valori vengono scritti come elementi figli o come attributi. Il parser, il sanificatore dei nomi e il serializzatore girano in un Web Worker di questa scheda, quindi nulla viene caricato.

Ci si ricorre quando un foglio di calcolo deve alimentare qualcosa che accetta solo XML: un’importazione di massa in un ERP più vecchio, un payload per un endpoint SOAP, un feed di catalogo, o una fixture per una suite di test che legge XML. Di solito è un viaggio di sola andata, ed è per questo che azzeccare l’escape la prima volta conta più dell’andata e ritorno.

Due cose distinguono questo da uno split sulle virgole. Il parser implementa la RFC 4180, quindi i campi fra apici, le virgole incorporate, le interruzioni di riga incorporate e gli apici raddoppiati sopravvivono tutti. E i due punti in cui CSV e XML sono davvero in disaccordo — intestazioni che non sono nomi XML leciti e righe con il numero sbagliato di campi — vengono segnalati anziché sistemati in silenzio alle tue spalle.

Spezzare sulle virgole non è analizzare il CSV

La RFC 4180 consente di racchiudere un campo fra apici doppi, e un campo fra apici può contenere virgole, interruzioni di riga e apici, con un apice letterale scritto come due apici. Un convertitore costruito su uno split per virgola rovina tutti e quattro i casi, e li rovina in silenzio: il conteggio delle righe sembra ancora plausibile, quindi la corruzione emerge più tardi, in qualunque cosa abbia consumato l’XML.

Il parser gestisce quei casi ed è deliberato su quelli che la RFC non copre, perché gli export reali non sono sempre conformi. I fine riga CRLF e CR da solo vengono normalizzati in LF, un’unica interruzione di riga finale viene scartata anziché produrre una riga vuota fantasma, e le righe vuote vengono saltate. Un apice apre un campo solo quando è il primo carattere di quel campo, quindi un apice smarrito dentro un valore non quotato viene tenuto come dato anziché disallineare il resto del file.

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>
Una virgola fra apici, apici raddoppiati, e un’intestazione che non è un nome XML lecito.

Le intestazioni di colonna diventano nomi di elemento, e quasi nessuna può

XML 1.0 è severo sui nomi: un nome inizia con una lettera, un trattino basso o i due punti, poi continua con lettere, cifre, trattini, punti o due punti. Le intestazioni dei fogli di calcolo quasi mai rispettano questo. «unit price», «2024 total» e «price (GBP)» sono tutte illecite, e un convertitore deve fare qualcosa con ognuna.

Ogni intestazione viene controllata e, quando non passa, sanificata nello stesso modo: i caratteri fuori dall’insieme lecito diventano trattini bassi, un nome che inizierebbe con una cifra riceve un trattino basso davanti, e un nome che inizia con le lettere xml in qualsiasi combinazione di maiuscole ne riceve uno anche lui, perché XML 1.0 riserva quel prefisso. Un’intestazione vuota diventa column1, column2 e così via.

Ogni rinomina è elencata nel pannello come «unit price in unit_price», così vedi su cosa dovrà corrispondere il tuo XPath a valle. Quell’elenco viene stampato anziché soltanto contato per un motivo: sanificare non è reversibile, quindi due intestazioni che differiscono solo per i caratteri sostituiti possono finire sullo stesso nome, e XML consente a elementi fratelli di condividere un nome, quindi nulla dà errore.

Elementi o attributi

Gli elementi sono l’impostazione predefinita e di solito sono la scelta giusta. Gli attributi sono più compatti e sono ciò che alcuni importatori legacy si aspettano; la casella Colonne come attributi scrive ogni valore come attributo su un elemento riga autochiuso. I nomi di radice e di riga che digiti vengono usati esattamente come li inserisci, senza sanificazione, quindi tienili leciti.

Il compromesso è reale in una direzione. XML 1.0 normalizza i valori degli attributi in fase di analisi, sostituendo una tabulazione o un’interruzione di riga letterale con uno spazio, quindi una cella su più righe non sopravvivrebbe a un andata e ritorno. Il serializzatore scrive perciò tabulazioni, ritorni a capo e avanzamenti riga dentro gli attributi come i riferimenti di carattere &#9;, &#13; e &#10;, che non vengono normalizzati. Il contenuto testuale non richiede questo trattamento.

Contano altre due differenze. Un attributo non può ripetersi sullo stesso elemento, quindi un’intestazione duplicata produce un documento non ben formato, e un attributo vuoto è indistinguibile da uno assente.

Le righe irregolari vengono segnalate, non riempite

Un CSV in cui alcune righe portano meno o più campi dell’intestazione è di solito un sintomo: un delimitatore non protetto, un export rotto, o due file concatenati. Riempirlo in silenzio produce un XML che sembra a posto ed è sbagliato, quindi viene segnalato invece il conteggio: «4 righe hanno un numero di campi diverso dall’intestazione.»

L’output viene prodotto comunque. I valori mancanti vengono scritti come elementi vuoti così che la struttura del record resti uniforme, e i valori in eccesso vengono tenuti sotto nomi generati columnN anziché scartati. Entrambi restano visibili nell’output, ed entrambi significano la stessa cosa: vai a guardare quelle righe nella sorgente.

Perché il parser CSV è scritto a mano

La risposta ovvia è una libreria, ed è stata scartata per il peso. Questa pagina promette di caricarsi in fretta su un telefono con il wifi di un albergo, e un parser CSV più un emettitore YAML più un costruttore XML aggiungono qualche centinaio di kilobyte a ogni visita per circa ottanta righe di comportamento. Il parser è un ciclo sui caratteri con un booleano per «dentro gli apici», e questa è tutta la RFC 4180.

Il costo è la copertura dei dialetti. Non c’è riconoscimento del delimitatore oltre alle tre scelte qui sopra, nessun carattere di quotatura diverso dall’apice doppio, nessuna convenzione per le righe di commento, e nessuna inferenza di tipo, perché per XML ogni valore di un CSV è una stringa. Se il tuo file usa una barra verticale come delimitatore o un escape con barra rovesciata, normalizzalo prima di incollare.

Farlo da codice

La stessa conversione nei linguaggi che consumano XML. Il rischio qui è l’opposto della direzione da XML a CSV: nulla viene analizzato in modo non sicuro, ma tutto viene scritto, e un valore che contiene una e commerciale o una parentesi angolare produce un documento rotto se non viene protetto. Ogni esempio usa uno scrittore che fa l’escape per te anziché concatenare stringhe.

// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

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 regola condivisa da tutti e sei: lascia fare l’escape allo scrittore. Costruire XML concatenando stringhe funziona fino al primo nome di fornitore che contiene una e commerciale, e poi produce un documento che nessun parser accetterà.

Domande frequenti

Il mio CSV viene caricato da qualche parte?

No. Il parser CSV, il sanificatore dei nomi e il serializzatore XML girano in un Web Worker di questa scheda, e non c’è alcun back end a cui mandare qualcosa. Apri il pannello di rete e converti qualcosa: le risorse della pagina si caricano una volta, e poi più nulla.

Il CSV è il formato che trasporta gli export delicati: un foglio di calcolo incollato in un convertitore è tipicamente un elenco clienti, un estratto di buste paga o un file di prezzi sotto accordo di riservatezza. Il tuo input resta nel localStorage di questo browser perché un aggiornamento non lo perda, e Pulisci lo rimuove.

Che ne è di un’intestazione di colonna come «unit price» o «2024 total»?

Sono entrambe nomi di elemento XML illeciti, quindi vengono entrambe sanificate. I caratteri fuori dall’insieme lecito diventano trattini bassi, dando unit_price; un nome che inizierebbe con una cifra riceve un trattino basso davanti, dando _2024_total. Un’intestazione che inizia con le lettere xml in qualsiasi combinazione di maiuscole ne riceve uno anche lei, perché XML 1.0 riserva quel prefisso.

Ogni rinomina è elencata nel pannello dei risultati con l’originale accanto al nuovo nome, così puoi copiare i nomi di elemento reali nell’XPath o nell’XSD che consuma il file. Per scegliere tu i nomi, modifica la riga di intestazione prima di convertire.

I miei valori contengono virgole. Le colonne resteranno allineate?

Sì, a condizione che quei valori siano fra apici, cosa che ogni export di foglio di calcolo o di database fa automaticamente. Il parser segue la RFC 4180: un campo fra apici può contenere virgole, interruzioni di riga e apici, e due apici di seguito al suo interno valgono un apice letterale.

Un’interruzione di riga dentro un campo quotato viene conservata, quindi un indirizzo su più righe resta un solo valore. Con Colonne come attributi viene scritta come &#10; anziché come interruzione letterale, perché XML normalizza le interruzioni letterali nei valori degli attributi trasformandole in spazi e il riferimento di carattere è la sola forma che sopravvive.

E se alcune righe hanno più o meno campi dell’intestazione?

Te lo si dice. Il pannello segnala quante righe non corrispondono alla larghezza dell’intestazione, perché un CSV irregolare quasi sempre significa che qualcosa a monte è rotto: un delimitatore non protetto, un export troncato, o due file concatenati.

La conversione avviene comunque. I valori mancanti vengono scritti come elementi vuoti così che ogni record mantenga la stessa forma, e i valori in eccesso vengono tenuti sotto nomi generati columnN anziché scartati. Entrambe le cose lasciano l’anomalia visibile nell’output invece di nasconderla.

I valori dovrebbero essere elementi o attributi?

Elementi, a meno che qualcosa a valle richieda attributi. Gli elementi possono ripetersi, portare testo su più righe senza trucchi di codifica, sono più facili da vincolare in un XSD, e distinguono un valore vuoto da uno assente. Gli attributi sono più compatti e sono ciò che si aspettano alcuni importatori più vecchi.

Se passi a quelli, un’intestazione duplicata diventa un attributo duplicato sullo stesso elemento, cosa che non è XML ben formato. Gli elementi non hanno questa restrizione.

Il mio export usa i punti e virgola, non le virgole. Funziona?

Sì. Porta il controllo Delimitatore su Punto e virgola. Excel, in gran parte delle lingue europee, scrive file separati da punto e virgola continuando a chiamarli CSV, perché lì la virgola è il separatore decimale, e un convertitore che presume le virgole trasforma 12,50 in due colonne.

I file separati da tabulazione funzionano allo stesso modo. Un delimitatore barra verticale o accento circonflesso non è offerto e richiede prima un trova e sostituisci. Un byte order mark UTF-8, che Excel scrive all’inizio del file, viene tagliato dalla prima intestazione anziché diventare parte del nome dell’elemento.

Strumenti correlati

Approfondimenti