Convertidor de CSV a XML

Convierte a XML, con las cabeceras vueltas nombres legales.

Entrada
Salida
En esperaPega un documento para comprobarlo. La validación se ejecuta mientras escribes.

Todo se ejecuta en esta pestaña. Nada de lo que pegues se sube, se registra ni se envía a ningún sitio. Abre el panel de red y compruébalo.

Pega un CSV y cada fila se convierte en un elemento. La primera fila aporta los nombres de elemento, y tú eliges el nombre de la raíz, el nombre de la fila, el delimitador, y si los valores se escriben como elementos hijos o como atributos. El analizador, el saneador de nombres y el serializador corren en un Web Worker de esta pestaña, así que nada se sube.

Recurres a esto cuando una hoja de cálculo tiene que alimentar algo que solo acepta XML: una importación masiva en un ERP antiguo, una carga para un endpoint SOAP, un feed de catálogo, o un fixture para una batería de pruebas que lee XML. Suele ser un viaje de ida, y por eso acertar con el escapado a la primera importa más que el viaje de vuelta.

Dos cosas separan esto de partir por comas. El analizador implementa el RFC 4180, así que los campos entrecomillados, las comas incrustadas, los saltos de línea incrustados y las comillas dobladas sobreviven todos. Y los dos puntos en los que CSV y XML están de verdad en desacuerdo —encabezados que no son nombres XML legales y filas con el número de campos equivocado— se informan en lugar de arreglarse calladamente a tus espaldas.

Partir por comas no es analizar CSV

El RFC 4180 permite envolver un campo en comillas dobles, y un campo entrecomillado puede contener comas, saltos de línea y comillas, escribiéndose una comilla literal como dos comillas. Un conversor construido sobre un split por comas destroza los cuatro casos, y los destroza en silencio: el número de filas sigue pareciendo plausible, así que la corrupción aflora más tarde, en lo que haya consumido el XML.

El analizador maneja esos casos y es deliberado con los que el RFC no cubre, porque las exportaciones reales no siempre son conformes. Los finales CRLF y CR solitario se normalizan a LF, un único salto de línea final se descarta en lugar de producir una fila vacía fantasma, y las líneas en blanco se omiten. Una comilla solo abre un campo cuando es el primer carácter de ese campo, así que una comilla perdida dentro de un valor sin entrecomillar se conserva como dato en vez de desalinear el resto del archivo.

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 coma entrecomillada, comillas dobladas y un encabezado que no es un nombre XML legal.

Los encabezados de columna se vuelven nombres de elemento, y casi ninguno puede

XML 1.0 es estricto con los nombres: un nombre empieza por una letra, un subrayado o dos puntos, y luego sigue con letras, dígitos, guiones, puntos o dos puntos. Los encabezados de hoja de cálculo casi nunca cumplen. «unit price», «2024 total» y «price (GBP)» son todos ilegales, y un conversor tiene que hacer algo con cada uno.

Cada encabezado se comprueba y, cuando falla, se sanea igual: los caracteres fuera del conjunto legal se convierten en subrayados, un nombre que empezaría por dígito recibe un subrayado delante, y un nombre que empieza por las letras xml en cualquier combinación de mayúsculas también, porque XML 1.0 reserva ese prefijo. Un encabezado vacío se convierte en column1, column2 y así sucesivamente.

Cada renombrado se lista en el panel como «unit price a unit_price», para que veas con qué tendrá que emparejar tu XPath aguas abajo. Esa lista se imprime en lugar de contarse por una razón: sanear no es reversible, así que dos encabezados que difieren solo en los caracteres que se sustituyen pueden acabar en el mismo nombre, y XML permite que elementos hermanos compartan nombre, así que nada da error.

Elementos o atributos

Los elementos son el valor por defecto y suelen ser lo correcto. Los atributos son más compactos y son lo que esperan algunos importadores heredados; la casilla Columnas como atributos escribe cada valor como atributo en un elemento de fila autocerrado. Los nombres de raíz y de fila que escribes se usan exactamente como los metes, sin sanear, así que mantenlos legales.

El intercambio es real en una dirección. XML 1.0 normaliza los valores de atributo al analizar, sustituyendo un tabulador o un salto de línea literales por un espacio, así que una celda de varias líneas no sobreviviría un viaje de ida y vuelta. El serializador escribe por eso los tabuladores, los retornos de carro y los saltos de línea dentro de atributos como las referencias de carácter &#9;, &#13; y &#10;, que no se normalizan. El contenido de texto no necesita ese tratamiento.

Importan otras dos diferencias. Un atributo no puede repetirse en el mismo elemento, así que un encabezado duplicado produce un documento que no está bien formado, y un atributo vacío es indistinguible de uno ausente.

Las filas desiguales se informan, no se rellenan

Un CSV donde algunas filas llevan menos o más campos que la cabecera suele ser un síntoma: un delimitador sin escapar, una exportación rota, o dos archivos concatenados. Rellenarlo en silencio produce XML que parece bien y está mal, así que en su lugar se informa el recuento: «4 filas tienen un número de campos distinto del de la cabecera».

La salida se produce igualmente. Los valores que faltan se escriben como elementos vacíos para que la estructura del registro se mantenga uniforme, y los valores de sobra se conservan bajo nombres generados columnN en vez de descartarse. Los dos quedan visibles en la salida, y los dos significan lo mismo: ve a mirar esas filas en el origen.

Por qué el analizador de CSV está escrito a mano

La respuesta obvia es una biblioteca, y se rechazó por peso. Esta página promete cargar rápido en un móvil con el wifi de un hotel, y un analizador de CSV más un emisor de YAML más un constructor de XML añaden unos cuantos cientos de kilobytes a cada visita a cambio de unas ochenta líneas de comportamiento. El analizador es un bucle de caracteres con un booleano para «dentro de comillas», que es todo el RFC 4180.

El coste es la cobertura de dialectos. No hay detección de delimitador más allá de las tres opciones de arriba, ningún carácter de comilla distinto de la comilla doble, ninguna convención de líneas de comentario, y ninguna inferencia de tipos, porque para XML todo valor de un CSV es una cadena. Si tu archivo usa una barra vertical como delimitador o una barra invertida de escape, normalízalo antes de pegarlo.

Hacer esto desde código

La misma conversión en los lenguajes que consumen XML. El riesgo aquí es el opuesto al de la dirección XML a CSV: no se analiza nada de forma insegura, pero se escribe todo, y un valor que contiene un ampersand o un signo de menor produce un documento roto si no se escapa. Cada ejemplo usa un escritor que escapa por ti en lugar de concatenar cadenas.

// 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 regla que comparten los seis: deja que escape el escritor. Construir XML concatenando cadenas funciona hasta el primer nombre de proveedor que contiene un ampersand, y entonces produce un documento que ningún analizador aceptará.

Preguntas frecuentes

¿Se sube mi CSV a algún sitio?

No. El analizador de CSV, el saneador de nombres y el serializador de XML corren en un Web Worker de esta pestaña, y no hay back end al que mandar nada. Abre tu panel de red y convierte algo: los recursos de la página cargan una vez, y después nada.

CSV es el formato que transporta las exportaciones sensibles: una hoja de cálculo pegada en un conversor suele ser una lista de clientes, un extracto de nóminas o un archivo de precios bajo acuerdo de confidencialidad. Tu entrada se guarda en el localStorage de este navegador para que una recarga no la pierda, y Limpiar la elimina.

¿Qué pasa con un encabezado de columna como «unit price» o «2024 total»?

Los dos son nombres de elemento XML ilegales, así que los dos se sanean. Los caracteres fuera del conjunto legal se convierten en subrayados, dando unit_price; un nombre que empezaría por dígito recibe un subrayado delante, dando _2024_total. Un encabezado que empieza por las letras xml en cualquier combinación de mayúsculas también lo recibe, porque XML 1.0 reserva ese prefijo.

Cada renombrado se lista en el panel de resultados con el original al lado del nuevo nombre, para que puedas copiar los nombres de elemento reales a lo que consuma el archivo, sea XPath o XSD. Para elegir los nombres tú, edita la fila de cabecera antes de convertir.

Mis valores contienen comas. ¿Seguirán cuadrando las columnas?

Sí, siempre que esos valores vayan entrecomillados, que es lo que hace automáticamente toda exportación de hoja de cálculo o de base de datos. El analizador sigue el RFC 4180: un campo entrecomillado puede contener comas, saltos de línea y comillas, y dos comillas seguidas dentro de él significan una comilla literal.

Un salto de línea dentro de un campo entrecomillado se conserva, así que una dirección de varias líneas sigue siendo un solo valor. Con Columnas como atributos se escribe como &#10; en vez de un salto literal, porque XML normaliza los saltos literales en los valores de atributo convirtiéndolos en espacios y la referencia de carácter es la única forma que sobrevive.

¿Y si algunas filas tienen más o menos campos que la cabecera?

Se te dice. El panel informa de cuántas filas no cuadran con el ancho de la cabecera, porque un CSV desigual casi siempre significa que algo aguas arriba está roto: un delimitador sin escapar, una exportación truncada, o dos archivos concatenados.

La conversión se hace igual. Los valores que faltan se escriben como elementos vacíos para que todo registro mantenga la misma forma, y los valores de sobra se conservan bajo nombres generados columnN en vez de descartarse. Las dos cosas dejan la anomalía visible en la salida en lugar de esconderla.

¿Los valores deberían ser elementos o atributos?

Elementos, a menos que algo aguas abajo exija atributos. Los elementos pueden repetirse, llevar texto de varias líneas sin trucos de codificación, son más fáciles de restringir en un XSD, y distinguen un valor vacío de uno ausente. Los atributos son más compactos y son lo que esperan algunos importadores antiguos.

Si cambias, un encabezado duplicado se convierte en un atributo duplicado en el mismo elemento, lo cual no es XML bien formado. Los elementos no tienen esa restricción.

Mi exportación usa punto y coma, no comas. ¿Funciona?

Sí. Pon el control Delimitador en Punto y coma. Excel, en la mayoría de las configuraciones regionales europeas, escribe archivos separados por punto y coma sin dejar de llamarlos CSV, porque allí la coma es el separador decimal, y un conversor que supone comas convierte 12,50 en dos columnas.

Los archivos separados por tabuladores funcionan igual. Un delimitador de barra vertical o de acento circunflejo no se ofrece y requiere un buscar y reemplazar antes. Una marca de orden de bytes UTF-8, que Excel escribe al principio del archivo, se recorta del primer encabezado en vez de pasar a formar parte del nombre del elemento.

Herramientas relacionadas

Lecturas de referencia