Conversor de CSV para XML
Converte para XML, com cabeçalhos virando nomes válidos.
Tudo roda nesta aba. Nada do que você colar é enviado, registrado ou transmitido para lugar nenhum. Abra o painel de rede e confira.
Cole um CSV e cada linha se torna um elemento. A primeira linha fornece os nomes dos elementos, e você escolhe o nome da raiz, o nome da linha, o delimitador, e se os valores são escritos como elementos filhos ou como atributos. O analisador, o saneador de nomes e o serializador rodam num Web Worker desta aba, então nada é enviado.
Você recorre a isto quando uma planilha precisa alimentar algo que só aceita XML: uma importação em massa num ERP antigo, uma carga para um endpoint SOAP, um feed de catálogo, ou uma fixture para uma suíte de testes que lê XML. Costuma ser uma viagem de ida, e é por isso que acertar o escape na primeira vez importa mais que a ida e volta.
Duas coisas separam isto de dividir por vírgulas. O analisador implementa a RFC 4180, então campos entre aspas, vírgulas embutidas, quebras de linha embutidas e aspas dobradas todos sobrevivem. E os dois pontos em que CSV e XML realmente discordam — cabeçalhos que não são nomes XML legítimos e linhas com o número errado de campos — são relatados em vez de consertados em silêncio às suas costas.
Dividir por vírgulas não é analisar CSV
A RFC 4180 permite envolver um campo em aspas duplas, e um campo entre aspas pode conter vírgulas, quebras de linha e aspas, com uma aspa literal escrita como duas aspas. Um conversor construído sobre um split por vírgula destrói os quatro casos, e destrói em silêncio: a contagem de linhas continua parecendo plausível, então a corrupção aparece depois, no que consumiu o XML.
O analisador trata esses casos e é deliberado quanto aos que a RFC não cobre, porque exportações reais não são sempre conformes. Terminações CRLF e CR solitário são normalizadas para LF, uma única quebra de linha final é descartada em vez de produzir uma linha vazia fantasma, e linhas em branco são puladas. Uma aspa só abre um campo quando é o primeiro caractere daquele campo, então uma aspa perdida dentro de um valor sem aspas é mantida como dado em vez de desalinhar o resto do arquivo.
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>Cabeçalhos de coluna viram nomes de elemento, e a maioria não pode
O XML 1.0 é rigoroso com nomes: um nome começa com uma letra, um sublinhado ou dois-pontos, e depois segue com letras, dígitos, hifens, pontos ou dois-pontos. Cabeçalhos de planilha quase nunca obedecem. «unit price», «2024 total» e «price (GBP)» são todos ilegítimos, e um conversor precisa fazer algo com cada um.
Todo cabeçalho é verificado e, quando falha, saneado do mesmo jeito: caracteres fora do conjunto legítimo viram sublinhados, um nome que começaria com dígito recebe um sublinhado à frente, e um nome que começa pelas letras xml em qualquer caixa recebe um também, porque o XML 1.0 reserva esse prefixo. Um cabeçalho vazio vira column1, column2 e assim adiante.
Cada renomeação é listada no painel como «unit price para unit_price», para que você veja com o que o seu XPath rio abaixo terá de casar. Essa lista é impressa em vez de apenas contada por um motivo: sanear não é reversível, então dois cabeçalhos que diferem apenas nos caracteres substituídos podem pousar no mesmo nome, e o XML deixa elementos irmãos compartilharem nome, então nada dá erro.
Elementos ou atributos
Elementos são o padrão e normalmente são o certo. Atributos são mais compactos e são o que alguns importadores legados esperam; a caixa Colunas como atributos escreve cada valor como atributo num elemento de linha autofechado. Os nomes de raiz e de linha que você digita são usados exatamente como entrados, sem saneamento, então mantenha-os legítimos.
A troca é real numa direção. O XML 1.0 normaliza valores de atributo na análise, substituindo uma tabulação ou quebra de linha literal por um espaço, então uma célula de várias linhas não sobreviveria a uma ida e volta. O serializador, por isso, escreve tabulações, retornos de carro e quebras de linha dentro de atributos como as referências de caractere 	, e , que não são normalizadas. Conteúdo textual não precisa desse tratamento.
Outras duas diferenças importam. Um atributo não pode se repetir no mesmo elemento, então um cabeçalho duplicado produz um documento que não é bem formado, e um atributo vazio é indistinguível de um ausente.
Linhas irregulares são relatadas, não preenchidas
Um CSV em que algumas linhas carregam menos ou mais campos que o cabeçalho costuma ser um sintoma: um delimitador sem escape, uma exportação quebrada, ou dois arquivos concatenados. Preencher em silêncio produz XML que parece bom e está errado, então em vez disso a contagem é relatada: «4 linhas têm um número de campos diferente do cabeçalho.»
A saída é produzida de todo modo. Valores faltantes são escritos como elementos vazios para que a estrutura do registro fique uniforme, e valores extras são mantidos sob nomes gerados columnN em vez de descartados. Os dois ficam visíveis na saída, e os dois significam o mesmo: vá olhar essas linhas na fonte.
Por que o analisador de CSV é escrito à mão
A resposta óbvia é uma biblioteca, e ela foi rejeitada pelo peso. Esta página promete carregar rápido num celular no wifi de hotel, e um analisador de CSV mais um emissor de YAML mais um construtor de XML acrescentam algumas centenas de quilobytes a cada visita em troca de umas oitenta linhas de comportamento. O analisador é um laço de caracteres com um booleano para «dentro de aspas», e isso é toda a RFC 4180.
O custo é a cobertura de dialetos. Não há farejamento de delimitador além das três escolhas acima, nenhum caractere de aspa além da aspa dupla, nenhuma convenção de linha de comentário, e nenhuma inferência de tipo, porque para o XML todo valor de CSV é uma cadeia. Se o seu arquivo usa barra vertical como delimitador ou escape com contrabarra, normalize antes de colar.
Fazer isso em código
A mesma conversão nas linguagens que consomem XML. O risco aqui é o oposto da direção XML para CSV: nada é analisado de forma insegura, mas tudo é escrito, e um valor contendo um e comercial ou um sinal de menor produz um documento quebrado se não for escapado. Cada exemplo usa um escritor que escapa por você em vez de concatenar cadeias.
// 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'))A regra compartilhada pelos seis: deixe o escritor escapar. Construir XML concatenando cadeias funciona até o primeiro nome de fornecedor contendo um e comercial, e então produz um documento que nenhum analisador vai aceitar.
Perguntas frequentes
Meu CSV é enviado para algum lugar?
Não. O analisador de CSV, o saneador de nomes e o serializador de XML rodam num Web Worker desta aba, e não há back end para onde mandar nada. Abra o painel de rede e converta algo: os recursos da página carregam uma vez, e depois nada.
CSV é o formato que carrega as exportações sensíveis: uma planilha colada num conversor é tipicamente uma lista de clientes, um extrato de folha de pagamento ou um arquivo de preços sob acordo de confidencialidade. Sua entrada fica no localStorage deste navegador para que um recarregamento não a perca, e Limpar a remove.
O que acontece com um cabeçalho de coluna como «unit price» ou «2024 total»?
Os dois são nomes de elemento XML ilegítimos, então os dois são saneados. Caracteres fora do conjunto legítimo viram sublinhados, dando unit_price; um nome que começaria com dígito recebe um sublinhado à frente, dando _2024_total. Um cabeçalho começando pelas letras xml em qualquer caixa recebe um também, porque o XML 1.0 reserva esse prefixo.
Cada renomeação é listada no painel de resultado com o original ao lado do novo nome, para que você possa copiar os nomes de elemento verdadeiros para o XPath ou o XSD que consumir o arquivo. Para escolher os nomes você mesmo, edite a linha de cabeçalho antes de converter.
Meus valores contêm vírgulas. As colunas continuarão alinhadas?
Sim, desde que esses valores estejam entre aspas, o que toda exportação de planilha ou de banco faz automaticamente. O analisador segue a RFC 4180: um campo entre aspas pode conter vírgulas, quebras de linha e aspas, e duas aspas seguidas dentro dele significam uma aspa literal.
Uma quebra de linha dentro de um campo entre aspas é mantida, então um endereço de várias linhas continua sendo um valor. Em Colunas como atributos ela é escrita como em vez de uma quebra literal, porque o XML normaliza quebras literais em valores de atributo para espaços e a referência de caractere é a única forma que sobrevive.
E se algumas linhas tiverem mais ou menos campos que o cabeçalho?
Você é avisado. O painel relata quantas linhas não batem com a largura do cabeçalho, porque um CSV irregular quase sempre quer dizer que algo rio acima está quebrado: um delimitador sem escape, uma exportação truncada, ou dois arquivos concatenados.
A conversão roda de todo modo. Valores faltantes são escritos como elementos vazios para que todo registro mantenha a mesma forma, e valores extras são mantidos sob nomes gerados columnN em vez de descartados. Os dois deixam a anomalia visível na saída em vez de escondê-la.
Os valores devem ser elementos ou atributos?
Elementos, a menos que algo rio abaixo exija atributos. Elementos podem se repetir, carregar texto de várias linhas sem truques de codificação, são mais fáceis de restringir num XSD, e distinguem um valor vazio de um ausente. Atributos são mais compactos e são o que alguns importadores mais antigos esperam.
Se você trocar, um cabeçalho duplicado vira um atributo duplicado no mesmo elemento, o que não é XML bem formado. Elementos não têm essa restrição.
Minha exportação usa ponto e vírgula, não vírgulas. Isso funciona?
Funciona. Ponha o controle de Delimitador em Ponto e vírgula. O Excel, na maioria das configurações regionais europeias, escreve arquivos separados por ponto e vírgula e ainda os chama de CSV, porque lá a vírgula é o separador decimal, e um conversor que supõe vírgulas transforma 12,50 em duas colunas.
Arquivos separados por tabulação funcionam do mesmo jeito. Um delimitador de barra vertical ou circunflexo não é oferecido e precisa de um localizar e substituir antes. Uma marca de ordem de bytes UTF-8, que o Excel escreve no início do arquivo, é aparada do primeiro cabeçalho em vez de virar parte do nome do elemento.