CSV から XML への変換

XML へ変換。列見出しは正しい要素名になります。

入力
出力
待機中文書を貼り付けると検査します。入力中にそのまま検証されます。

すべてこのタブ内で実行されます。貼り付けた内容がアップロード・記録・送信されることはありません。 ネットワークパネルを開いて確認する.

CSV を貼り付けると、各行が 1 つの要素になります。最初の行が要素名を供給し、ルート名、行の名前、区切り文字、そして値を子要素として書くか属性として書くかは、あなたが選びます。パーサー、名前の整え、直列化はこのタブの Web Worker で動くので、何もアップロードされません。

表計算が、XML しか受け取らない何かに食わせなければならないときに使ってください。古い ERP への一括取り込み、SOAP エンドポイントへのペイロード、カタログのフィード、XML を読むテストのフィクスチャ。たいていは片道の旅で、だからこそ往復より、一度目でエスケープを正しくやることが大事になります。

カンマで割るのと違う点が 2 つあります。パーサーは RFC 4180 を実装しているので、引用符でくくられたフィールド、埋め込まれたカンマ、埋め込まれた改行、二重にした引用符はすべて生き残ります。そして CSV と XML が本当に食い違う 2 か所、つまり XML 名として正当でない見出しと、フィールド数が合わない行は、あなたの背中で黙って直されるのではなく報告されます。

カンマで割ることは、CSV を解析することではない

RFC 4180 はフィールドを二重引用符で包むことを許し、引用されたフィールドはカンマ、改行、引用符を含められます。引用符そのものは 2 つ並べて書きます。カンマでの split に載せたコンバーターは 4 つすべてを壊し、しかも黙って壊します。行数はそれらしく見えたままなので、壊れたことは後になって、その XML を食べた側で表に出ます。

パーサーはそれらの場合を扱い、RFC が扱っていない場合については意図をもって決めています。現実の書き出しは必ずしも規格に沿わないからです。CRLF と単独の CR は LF に正規化し、末尾の改行 1 つは幽霊のような空行を作るのではなく落とし、空行は飛ばします。引用符がフィールドを開くのは、それがそのフィールドの最初の文字であるときだけなので、引用されていない値の中の迷子の引用符はデータとして保たれ、ファイルの残りをずらすことはありません。

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 名として正当でない見出し。

列の見出しは要素名になるが、ほとんどはなれない

XML 1.0 は名前に厳しいです。名前は文字、アンダースコア、またはコロンで始まり、その後は文字、数字、ハイフン、ピリオド、コロンで続きます。表計算の見出しがこれに従うことはほとんどありません。「unit price」「2024 total」「price (GBP)」はどれも不正で、コンバーターはそれぞれに何かをしなければなりません。

すべての見出しを検査し、通らなければ同じやり方で整えます。正当な集合の外の文字はアンダースコアにし、数字で始まってしまう名前には先頭にアンダースコアを付け、大文字小文字を問わず xml という字で始まる名前にも付けます。XML 1.0 がその接頭辞を予約しているからです。空の見出しは column1、column2 などになります。

改名はすべて「unit price から unit_price へ」という形でパネルに並びます。下流の XPath が何に合わせることになるのかが見えるようにです。その一覧を数えるだけにせず印字するのには理由があります。整える操作は元に戻せないので、置き換えられる文字のところだけが違う 2 つの見出しは同じ名前に着地しえますし、XML は兄弟要素が名前を共有することを許すので、何もエラーになりません。

要素か属性か

要素が既定で、たいていそれが正解です。属性はより小さくまとまり、一部の古い取り込み機構が期待するものでもあります。「列を属性に」のチェックを入れると、すべての値が自己完結した行要素の属性として書かれます。あなたが書いたルート名と行の名前は、整えることなく入力のまま使われるので、正当な名前にしておいてください。

引き換えは片方向で現実になります。XML 1.0 は解析時に属性値を正規化し、リテラルのタブや改行を空白に置き換えるので、複数行のセルは往復を生き延びません。そこで直列化では、属性の中のタブ・復帰・改行を、正規化されない文字参照 &#9;、&#13;、&#10; として書きます。テキスト内容にはその処置は要りません。

ほかに 2 つの違いが効きます。同じ要素で属性を繰り返すことはできないので、見出しの重複は整形式でない文書を生みます。そして空の属性は、ない属性と見分けがつきません。

凹凸のある行は、埋めるのではなく報告する

一部の行が見出しより少ない、または多いフィールドを持つ CSV は、たいてい何かの症状です。エスケープされていない区切り文字、壊れた書き出し、あるいは 2 つのファイルの連結。黙って埋めれば、見た目は問題なく実は誤った XML ができあがるので、代わりに件数を報告します。「4 行が見出しと異なるフィールド数を持っています」。

出力はそれでも作ります。欠けた値は空の要素として書き、レコードの構造が一様に保たれるようにします。余った値は捨てずに、生成した columnN という名前の下に保ちます。どちらも出力の中で見えたままになり、どちらも同じことを意味します。元のそれらの行を見に行ってください。

CSV パーサーを手書きしている理由

当然の答えはライブラリを使うことで、それは重さの理由で退けました。このページはホテルの Wi-Fi の電話でも速く読み込むと約束していますが、CSV パーサーに YAML の出力器と XML の組み立てを足すと、80 行ぶんの振る舞いのために訪問ごとに数百キロバイトが増えます。このパーサーは「引用符の中か」を表す真偽値 1 つを持つ文字のループで、それが RFC 4180 の全部です。

代償は方言への対応です。上の 3 つの選択肢を超える区切り文字の推定はなく、二重引用符以外の引用文字もなく、コメント行の約束事もなく、型の推論もありません。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, '&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'))

6 つに共通する規則はこれです。エスケープはライターに任せること。文字列の連結で XML を組み立てる方法は、アンパサンドを含む最初の仕入先名まではうまくいき、そこからはどのパーサーも受け取らない文書を生みます。

よくある質問

私の CSV はどこかにアップロードされますか。

いいえ。CSV パーサー、名前の整え、XML の直列化は、このタブの Web Worker で動き、送る先のバックエンドもありません。ネットワークパネルを開いて何か変換してみてください。ページのアセットが一度読み込まれ、そのあとは何もありません。

CSV は機密の書き出しを運ぶ形式です。コンバーターに貼り付けられる表計算は、ふつう顧客名簿、給与の抜き出し、秘密保持契約下の価格ファイルです。入力は、再読み込みで失われないようこのブラウザーの localStorage に保存し、「クリア」で消えます。

「unit price」や「2024 total」のような列見出しはどうなりますか。

どちらも XML の要素名としては不正なので、どちらも整えられます。正当な集合の外の文字はアンダースコアになり unit_price に、数字で始まってしまう名前は先頭にアンダースコアが付いて _2024_total になります。大文字小文字を問わず xml という字で始まる見出しにも付きます。XML 1.0 がその接頭辞を予約しているからです。

改名はすべて、元の名前を新しい名前の隣に並べて結果パネルに出ます。そのファイルを食べる XPath や XSD に、本当の要素名をそのまま写せるようにです。名前を自分で決めたいなら、変換の前に見出し行を編集してください。

値にカンマが入っています。それでも列は揃いますか。

はい。その値が引用符でくくられていれば揃います。表計算やデータベースの書き出しは、それを自動でやります。パーサーは RFC 4180 に従います。引用されたフィールドはカンマ、改行、引用符を含められ、その中で引用符が 2 つ続けば、それはリテラルの引用符 1 つを意味します。

引用されたフィールドの中の改行は保たれるので、複数行の住所は 1 つの値のままです。「列を属性に」では、リテラルの改行ではなく &#10; として書かれます。XML は属性値の中のリテラルな改行を空白に正規化し、文字参照だけが生き残る形だからです。

一部の行が見出しより多い、または少ないフィールドを持っていたら。

知らせます。見出しの幅に合わない行が何行あるかをパネルが報告します。凹凸のある CSV は、ほぼいつも上流の何かが壊れていることを意味するからです。エスケープされていない区切り文字、途中で切れた書き出し、あるいは 2 つのファイルの連結。

変換そのものは走ります。欠けた値は空の要素として書き、どのレコードも同じ形を保ちます。余った値は捨てずに、生成した columnN という名前の下に保ちます。どちらも異常を隠さず、出力の中に見えるまま残します。

値は要素にすべきですか、属性にすべきですか。

下流が属性を要求しないかぎり、要素です。要素は繰り返せますし、符号化の小細工なしに複数行のテキストを持てますし、XSD で縛るのも容易で、空の値とない値を見分けられます。属性はより小さくまとまり、古い取り込み機構が期待するものでもあります。

切り替えるなら注意してください。見出しの重複は、同じ要素の属性の重複になり、それは整形式の XML ではありません。要素にはその制約がありません。

書き出しがカンマではなくセミコロンです。それでも動きますか。

動きます。区切り文字の設定を「セミコロン」にしてください。ヨーロッパの多くのロケールの Excel は、セミコロン区切りのファイルを書きながら、なお CSV と呼びます。そこではカンマが小数点だからです。カンマを前提にしたコンバーターは 12,50 を 2 列にしてしまいます。

タブ区切りのファイルも同じように扱えます。縦棒やキャレットの区切りは用意しておらず、先に検索と置換が必要です。Excel がファイルの先頭に書く UTF-8 のバイト順マークは、要素名の一部になるのではなく、最初の見出しから取り除かれます。

関連ツール

関連する解説