CSV를 XML로 변환
XML로 변환하고, 열 제목을 올바른 요소 이름으로 만듭니다.
모든 처리는 이 탭 안에서 이루어집니다. 붙여넣은 내용은 업로드되거나 기록되거나 전송되지 않습니다. 네트워크 패널을 열어 확인하세요.
CSV를 붙여넣으면 각 행이 하나의 요소가 됩니다. 첫 행이 요소 이름을 주고, 루트 이름과 행 이름, 구분 문자, 그리고 값을 자식 요소로 쓸지 속성으로 쓸지는 여러분이 고릅니다. 파서, 이름 정리, 직렬화는 이 탭의 웹 워커에서 도니 아무것도 업로드되지 않습니다.
스프레드시트가 XML만 받는 무언가에 데이터를 넣어야 할 때 쓰세요. 오래된 ERP로의 대량 가져오기, SOAP 엔드포인트로 보낼 페이로드, 카탈로그 피드, XML을 읽는 테스트의 픽스처. 보통은 한 방향 여행이고, 그래서 왕복보다 첫 번에 이스케이프를 제대로 하는 것이 더 중요합니다.
쉼표로 쪼개는 것과 다른 점이 둘 있습니다. 파서가 RFC 4180을 구현하므로 인용된 필드, 안에 든 쉼표, 안에 든 줄바꿈, 두 번 겹친 인용부호가 모두 살아남습니다. 그리고 CSV와 XML이 진짜로 어긋나는 두 곳, 즉 적법한 XML 이름이 아닌 머리글과 필드 수가 맞지 않는 행은, 여러분 등 뒤에서 조용히 고쳐지는 대신 보고됩니다.
쉼표로 쪼개는 것은 CSV를 파싱하는 것이 아니다
RFC 4180은 필드를 겹인용부호로 감싸는 것을 허용하고, 인용된 필드는 쉼표와 줄바꿈과 인용부호를 담을 수 있습니다. 글자로서의 인용부호는 두 개를 나란히 써서 나타냅니다. 쉼표 split 위에 만든 변환기는 이 네 가지를 모두 망가뜨리고, 조용히 망가뜨립니다. 행 수는 여전히 그럴듯해 보이므로, 망가진 사실은 그 XML을 받아먹은 쪽에서 나중에 드러납니다.
이 파서는 그 경우들을 다루고, RFC가 다루지 않는 경우에 대해서는 의도를 가지고 정해 두었습니다. 현실의 내보내기가 늘 규격에 맞지는 않기 때문입니다. CRLF와 홀로 쓰인 CR은 LF로 정규화하고, 맨 끝의 줄바꿈 하나는 유령 같은 빈 행을 만드는 대신 버리며, 빈 줄은 건너뜁니다. 인용부호가 필드를 여는 것은 그것이 그 필드의 첫 글자일 때뿐이므로, 인용되지 않은 값 안의 떠돌이 인용부호는 데이터로 남고 파일의 나머지를 어긋나게 하지 않습니다.
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>열 머리글은 요소 이름이 되지만, 대부분은 될 수 없다
XML 1.0은 이름에 엄격합니다. 이름은 글자나 밑줄, 또는 콜론으로 시작하고, 그다음은 글자, 숫자, 붙임표, 점, 콜론으로 이어집니다. 스프레드시트 머리글이 이를 지키는 일은 거의 없습니다. 「unit price」, 「2024 total」, 「price (GBP)」는 모두 적법하지 않고, 변환기는 각각에 대해 무언가를 해야 합니다.
모든 머리글을 검사하고, 통과하지 못하면 같은 방식으로 정리합니다. 적법한 집합 밖의 글자는 밑줄이 되고, 숫자로 시작하게 될 이름에는 앞에 밑줄을 붙이며, 대소문자를 가리지 않고 xml이라는 글자로 시작하는 이름에도 붙입니다. XML 1.0이 그 접두사를 예약해 두었기 때문입니다. 빈 머리글은 column1, column2 하는 식이 됩니다.
이름을 바꾼 것은 모두 「unit price에서 unit_price로」라는 꼴로 패널에 나열됩니다. 아래쪽의 XPath가 무엇에 맞춰야 하는지 볼 수 있게 하려는 것입니다. 그 목록을 세기만 하지 않고 적어 내놓는 데는 이유가 있습니다. 정리는 되돌릴 수 없으므로, 바뀌는 글자 부분만 다른 두 머리글이 같은 이름에 닿을 수 있고, XML은 형제 요소가 이름을 공유하도록 허용하므로 아무 오류도 나지 않습니다.
요소냐 속성이냐
요소가 기본값이고, 보통 그것이 맞습니다. 속성은 더 짧게 담기며, 일부 낡은 가져오기 장치가 기대하는 모양이기도 합니다. 「열을 속성으로」를 켜면 모든 값이 스스로 닫는 행 요소의 속성으로 적힙니다. 여러분이 적은 루트 이름과 행 이름은 정리 없이 입력한 그대로 쓰이므로, 적법하게 적어 두세요.
맞바꿈은 한 방향에서 실제로 일어납니다. XML 1.0은 파싱할 때 속성값을 정규화하며, 글자로서의 탭이나 줄바꿈을 공백으로 바꿉니다. 그래서 여러 줄짜리 칸은 왕복을 견디지 못합니다. 그래서 직렬화는 속성 안의 탭, 복귀, 줄바꿈을 정규화되지 않는 문자 참조 	, , 으로 적습니다. 텍스트 내용에는 그런 처리가 필요하지 않습니다.
다른 두 차이도 중요합니다. 같은 요소에서 속성은 되풀이될 수 없으므로 머리글 중복은 정형식이 아닌 문서를 만들고, 빈 속성은 없는 속성과 구별되지 않습니다.
들쭉날쭉한 행은 채우지 않고 보고한다
일부 행이 머리글보다 적거나 많은 필드를 가진 CSV는 보통 증상입니다. 이스케이프되지 않은 구분 문자, 망가진 내보내기, 또는 두 파일을 이어 붙인 것. 조용히 채우면 보기에는 멀쩡하고 실은 틀린 XML이 나오므로, 대신 개수를 보고합니다. 「4개 행이 머리글과 다른 수의 필드를 가지고 있습니다.」
출력은 그래도 만들어집니다. 빠진 값은 빈 요소로 적어 레코드 구조가 고르게 남도록 하고, 남는 값은 버리지 않고 생성된 columnN 이름 아래에 둡니다. 둘 다 출력 안에서 보이는 채로 남고, 둘 다 같은 것을 뜻합니다. 원본의 그 행들을 가서 보라는 것입니다.
CSV 파서를 손으로 쓴 이유
뻔한 답은 라이브러리이고, 그것은 무게 때문에 물렸습니다. 이 페이지는 호텔 와이파이의 휴대전화에서도 빨리 뜨기로 약속했는데, CSV 파서에 YAML 출력기와 XML 조립기를 더하면 여든 줄쯤 되는 행동을 위해 방문마다 몇백 킬로바이트가 붙습니다. 이 파서는 「인용부호 안인가」를 나타내는 참거짓 하나를 가진 글자 루프이고, 그것이 RFC 4180의 전부입니다.
대가는 방언 지원입니다. 위의 세 선택을 넘는 구분 문자 탐지도 없고, 겹인용부호 말고 다른 인용 문자도 없고, 주석 줄 관습도 없고, 형 추론도 없습니다. XML이 보기에 CSV의 값은 모두 문자열이기 때문입니다. 파일이 수직선 구분이나 역사선 이스케이프를 쓴다면, 붙여넣기 전에 고쳐 두세요.
코드로 하기
같은 변환을 XML을 다루는 언어들에서. 여기서의 위험은 XML에서 CSV로 가는 방향과 반대입니다. 안전하지 않게 파싱하는 것은 없지만 쓰는 쪽은 전부이고, 앰퍼샌드나 꺾쇠를 담은 값은 이스케이프하지 않으면 망가진 문서를 만듭니다. 각 예제는 문자열을 이어 붙이는 대신, 대신 이스케이프해 주는 작성기를 씁니다.
// 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'))여섯 가지가 함께 지키는 규칙은 이것입니다. 이스케이프는 작성기에 맡기세요. 문자열을 이어 붙여 XML을 만드는 방식은 앰퍼샌드가 든 첫 공급사 이름까지는 통하고, 그다음에는 어떤 파서도 받아 주지 않는 문서를 만들어 냅니다.
자주 묻는 질문
제 CSV가 어딘가로 업로드되나요?
아닙니다. CSV 파서, 이름 정리, XML 직렬화는 이 탭의 웹 워커에서 돌고, 무엇을 보낼 백엔드도 없습니다. 네트워크 패널을 열고 무언가 변환해 보세요. 페이지 자원이 한 번 불려 오고, 그 뒤로는 아무것도 없습니다.
CSV는 민감한 내보내기를 실어 나르는 형식입니다. 변환기에 붙여넣는 스프레드시트는 보통 고객 명단, 급여 추출, 비밀 유지 계약 아래의 가격 파일입니다. 입력은 새로 고쳐도 잃지 않도록 이 브라우저의 localStorage에 보관하며, 「지우기」로 사라집니다.
「unit price」나 「2024 total」 같은 열 머리글은 어떻게 되나요?
둘 다 적법한 XML 요소 이름이 아니므로 둘 다 정리됩니다. 적법한 집합 밖의 글자는 밑줄이 되어 unit_price가 되고, 숫자로 시작하게 될 이름은 앞에 밑줄이 붙어 _2024_total이 됩니다. 대소문자를 가리지 않고 xml이라는 글자로 시작하는 머리글에도 붙습니다. XML 1.0이 그 접두사를 예약해 두었기 때문입니다.
바뀐 이름은 모두 원래 이름을 새 이름 옆에 두어 결과 패널에 나열됩니다. 그 파일을 받아먹는 XPath나 XSD에 진짜 요소 이름을 그대로 옮길 수 있게 하려는 것입니다. 이름을 직접 정하고 싶다면, 변환 전에 머리글 줄을 고치세요.
값에 쉼표가 들어 있습니다. 그래도 열이 맞게 들어맞을까요?
그 값들이 인용부호로 감싸져 있다면 그렇습니다. 스프레드시트나 데이터베이스의 내보내기는 그것을 자동으로 합니다. 파서는 RFC 4180을 따릅니다. 인용된 필드는 쉼표와 줄바꿈과 인용부호를 담을 수 있고, 그 안에서 인용부호가 둘 연달아 오면 글자로서의 인용부호 하나를 뜻합니다.
인용된 필드 안의 줄바꿈은 보존되므로, 여러 줄짜리 주소는 하나의 값으로 남습니다. 「열을 속성으로」에서는 글자로서의 줄바꿈이 아니라 으로 적힙니다. XML은 속성값 안의 글자 줄바꿈을 공백으로 정규화하고, 문자 참조만이 살아남는 형태이기 때문입니다.
일부 행이 머리글보다 많거나 적은 필드를 가지면 어떻게 되나요?
알려 줍니다. 머리글 너비와 맞지 않는 행이 몇 개인지 패널이 보고합니다. 들쭉날쭉한 CSV는 거의 언제나 위쪽에서 무언가가 망가졌다는 뜻이기 때문입니다. 이스케이프되지 않은 구분 문자, 잘린 내보내기, 또는 두 파일을 이어 붙인 것.
변환 자체는 돌아갑니다. 빠진 값은 빈 요소로 적어 모든 레코드가 같은 모양을 유지하게 하고, 남는 값은 버리지 않고 생성된 columnN 이름 아래에 둡니다. 둘 다 이상함을 숨기지 않고 출력 안에 보이게 남깁니다.
값은 요소여야 하나요, 속성이어야 하나요?
아래쪽에서 속성을 요구하지 않는다면 요소입니다. 요소는 되풀이될 수 있고, 부호화 꾀 없이 여러 줄 텍스트를 담을 수 있고, XSD에서 제약하기 더 쉽고, 빈 값과 없는 값을 구별합니다. 속성은 더 짧게 담기며, 일부 더 오래된 가져오기 장치가 기대하는 모양입니다.
바꾼다면, 머리글 중복은 같은 요소의 속성 중복이 되고 그것은 정형식 XML이 아닙니다. 요소에는 그런 제약이 없습니다.
제 내보내기는 쉼표가 아니라 쌍반점을 씁니다. 그래도 되나요?
됩니다. 구분 문자 조절을 「쌍반점」으로 두세요. 유럽 대부분 로캘의 엑셀은 쌍반점으로 나뉜 파일을 쓰면서도 여전히 CSV라고 부릅니다. 거기서는 쉼표가 소수점이기 때문입니다. 쉼표를 전제한 변환기는 12,50을 두 열로 만들어 버립니다.
탭으로 나뉜 파일도 같은 식으로 됩니다. 수직선이나 캐럿 구분은 제공하지 않으며 먼저 찾아 바꾸기가 필요합니다. 엑셀이 파일 맨 앞에 써 넣는 UTF-8 바이트 순서 표시는 요소 이름의 일부가 되는 대신 첫 머리글에서 떼어 냅니다.