XML ミニファイア

無意味な空白だけを削除。混在内容はそのまま。

混在内容の中の空白はデータなので、そのまま残します。
入力
出力
待機中文書を貼り付けると検査します。入力中にそのまま検証されます。

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

上に文書を貼り付けると、要素と要素のあいだの空白が取り除かれます。結果は入力の隣に 1 行で返り、前後のサイズと削減率が添えられます。チェックを入れればコメントも消えます。すべてこのタブで動きます。パーサーもミニファイアも JavaScript で、あなたの文書がどこかへ送られることはありません。

圧縮する意味があるのは、文書そのもののサイズが制約になっているときです。データベースの列に入る何十万件ものペイロード、JSON の文字列フィールドに埋め込まれたエンベロープ、ブローカーのサイズ上限に収めなければならないメッセージ。ネットワーク越しの効果は、宣伝されるパーセンテージよりずっと小さく、その理由は下の数字が示します。

規則はフォーマッターの規則を裏返したものです。要素間の空白はレイアウト、混在内容の中の空白はデータ。要素がテキストと子要素の両方を持つとき、その部分木は組み直さず元のソースから取り出し、CDATA はバイト単位で複製します。残ったものは、パーサーがアプリケーションに渡す内容を変えることはできません。

取り除いて安全なもの

XML パーサーは文書のすべての文字を、空白も含めてアプリケーションに渡します。勝手に捨ててくれるものはありません。「無意味」とは内容モデルについての主張であって、パーサーについての主張ではありません。DTD やスキーマがある要素を「要素のみの内容」と宣言しているなら、その直下の空白はデータではありえません。ミニファイアが触ってよいのは、その空白だけです。

多くの文書はスキーマなしでやって来るので、ミニファイアはヒューリスティックを使います。ここで採るものは狭い基準です。兄弟の要素に挟まれた、全体が空白だけのテキストノードを落とす。それ以外は落としません。libxml2 の --noblanks が下すのと同じ判断です。もう 1 つの変更として、空要素は自己終了形で書くので、<status></status> は <status/> になります。適合するパーサーはどちらも同じ要素と見なしますが、バイト列としては別物です。

取り除かないもの

混在内容こそが、本物のミニファイアと正規表現とを分けるものです。<p>Hello <b>world</b>!</p> の "Hello" のあとの空白は文書中の 1 文字ですし、<p>a <b>x</b> <i>y</i></p> の </b> と <i> のあいだの空白も同じです。どちらも残ります。混在した部分木は組み直さず、元のソースからそのまま複製するからです。その内部には何も手を触れません。これが唯一弁護できる立場です。テキストとマークアップが交ざった時点で、無意味だと証明できる空白はもう残っていないからです。

  • CDATA セクション:区切りも中身も完全にそのまま複製します。そこにある空白は定義上、内容です。
  • 属性値:書かれたとおりに出力し、元の引用符も実体参照もそのまま残します。
  • 葉要素の中のテキスト:<name> Ada </name> は外側の余白だけを失い、あいだの文字は決して失いません。
  • コメント:頼まない限り残します。処理命令、XML 宣言、内部 DTD サブセットは常に残します。
  • xml:space="preserve":正直に言うべき限界です。特別扱いしないので、文書がこれを使っているならコピーを圧縮して見比べてください。

実際にどれだけ小さくなるか

1 つのテスト文書での数字です。4 段の入れ子に注文レコードが 2,000 件。スペース 2 個のインデントで 554 KB、圧縮後は 421 KB で 24 パーセント減。スペース 4 個なら 679 KB なので、圧縮で 36 パーセント減ります。小さな要素が深く入れ子になった文書ほど効きます。包んでいる内容に対してインデントが大きいからです。

次に両方を gzip します。スペース 2 個版は 30,744 バイト、圧縮済み版は 29,771 バイト。差は 3 パーセントです。スペース 4 個のインデントでは結果が逆転し、整形済みが 28,519 バイト、圧縮済みが 29,771 バイトで、gzip 後は圧縮したファイルのほうが大きくなります。繰り返されるインデントは、まさに圧縮器が得意とするものです。ミニファイに意味がないのではありません。Content-Encoding が有効な世界で「ネットワークのために」圧縮するのが無意味なのです。

  • 意味がある:データベースの列に入る生の XML、サイズ上限のあるキューのメッセージ、別のペイロードに埋め込む文書、インデントが差分を埋め尽くすフィクスチャ。
  • 意味がない:圧縮を有効にした HTTP で配信されるもの全般。ミニファイアの宣伝文句にあるパーセンテージは、たいていここから来ています。
  • むしろ有害:署名された文書。Canonical XML は要素内容の空白を保持するので、ダイジェストはこれから変えようとしているバイトを含んでいます。

レイアウトを取り戻す方法と、その限界

出力をフォーマッターに通せば、値がすべて同一のまま、インデントされた文書に戻せます。戻ってこないのは元のレイアウトです。セクション間の空行、変わったインデント幅、2 つのタグが隣り合っていた混在部分木の内部の間隔。要素のみの内容にとって、それらはどれも情報ではありませんでした。もし情報だったのなら、圧縮してはいけません。

整形式でない文書は圧縮しません。入力がそのまま返り、すべてのエラーが行と列つきで並びます。1 MB の文書は約 180 ミリ秒、5 MB は 1 秒ほどで圧縮され、Web Worker で動くのでページは反応を保ちます。上限は 20 MB。すべてがこのタブのメモリー上にあるからです。

コードで XML を圧縮する

ビルド工程では、これは解析と再直列化であって、決して文字列操作ではありません。どのサンプルも安全に解析します。Java、PHP、Python の標準ライブラリは既定で外部実体を解決するからです。各ライブラリがどの空白を「無視してよい」と判断するかに注目してください。その判断こそがツールのすべてです。

// Remove whitespace-only text nodes, but only where the element's children
// are all elements. Anything else is mixed content and belongs to the data.
function minifyXml(source) {
  const doc = new DOMParser().parseFromString(source, 'application/xml');
  if (doc.querySelector('parsererror')) {
    throw new Error(doc.querySelector('parsererror').textContent.trim());
  }

  const strip = (el) => {
    const kids = [...el.childNodes];
    const elementOnly =
      kids.some((n) => n.nodeType === 1) &&
      kids.every((n) => n.nodeType !== 3 || !n.nodeValue.trim());
    if (elementOnly) {
      for (const n of kids) if (n.nodeType === 3) el.removeChild(n);
    }
    for (const child of el.children) strip(child);
  };

  strip(doc.documentElement);
  return new XMLSerializer().serializeToString(doc);
}

// XMLSerializer emits <a/> for an empty element, so the output is not
// byte-identical to input that wrote <a></a>. Same document, different bytes.
# lxml's remove_blank_text is the closest equivalent, and it is honest about
# being a guess: with no DTD it keeps blank text whose siblings carry real
# characters, which is what saves mixed content.
from lxml import etree

parser = etree.XMLParser(
    remove_blank_text=True,
    resolve_entities=False,   # no entity expansion
    no_network=True,          # never fetch a DTD or an include
    load_dtd=False,
    huge_tree=False,
)
tree = etree.fromstring(source.encode('utf-8'), parser)
minified = etree.tostring(tree, encoding='unicode')

# Standard library, no dependency, same rule applied by hand:
#
#   from defusedxml.ElementTree import fromstring
#   root = fromstring(source)
#   for el in root.iter():
#       if len(el) and not (el.text or '').strip():
#           el.text = None
#       if not (el.tail or '').strip():
#           el.tail = None
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.*;
import java.io.StringWriter;
import org.w3c.dom.*;

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setXIncludeAware(false);
Document doc = dbf.newDocumentBuilder().parse(new java.io.File("in.xml"));

// setIgnoringElementContentWhitespace only works when a DTD or schema tells
// the parser which content is element-only, which is usually not the case.
// So select the blank text nodes explicitly. The second predicate leaves
// mixed content alone: it skips blanks whose siblings hold real characters.
XPath xpath = XPathFactory.newInstance().newXPath();
NodeList blanks = (NodeList) xpath.evaluate(
    "//text()[not(normalize-space())][not(../text()[normalize-space()])]",
    doc, XPathConstants.NODESET);
for (int i = 0; i < blanks.getLength(); i++) {
    Node n = blanks.item(i);
    n.getParentNode().removeChild(n);
}

TransformerFactory tf = TransformerFactory.newInstance();
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
Transformer t = tf.newTransformer();
t.setOutputProperty(OutputKeys.INDENT, "no");
StringWriter out = new StringWriter();
t.transform(new DOMSource(doc), new StreamResult(out));
System.out.println(out);
using System.Text;
using System.Xml;

// IgnoreWhitespace drops every whitespace-only text node the reader sees.
// Without a schema that includes the space between two inline elements in
// mixed content, so use this on element-only documents and set it false when
// the document carries prose.
var readerSettings = new XmlReaderSettings
{
    IgnoreWhitespace = true,
    DtdProcessing = DtdProcessing.Prohibit,   // no external entities
    XmlResolver = null,
    MaxCharactersFromEntities = 1024 * 1024,
};

var writerSettings = new XmlWriterSettings
{
    Indent = false,
    NewLineHandling = NewLineHandling.None,
};

var output = new StringBuilder();
using (var reader = XmlReader.Create(new StringReader(source), readerSettings))
using (var writer = XmlWriter.Create(output, writerSettings))
{
    writer.WriteNode(reader, defattr: true);
}
Console.WriteLine(output.ToString());
<?php
$doc = new DOMDocument();

// preserveWhiteSpace must be false before the load, not after. libxml2 then
// applies its blank-node heuristic, which keeps blanks inside mixed content.
$doc->preserveWhiteSpace = false;
$doc->formatOutput = false;

libxml_use_internal_errors(true);
if (!$doc->loadXML($source, LIBXML_NONET)) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "XML error at line %d, column %d: %s\n",
            $e->line, $e->column, trim($e->message));
    }
    libxml_clear_errors();
    exit(1);
}

// saveXML() still ends the document with a newline; trim it if the byte
// count is what you are optimising.
echo rtrim($doc->saveXML());
# --noblanks is libxml2's minifier. It removes ignorable whitespace only.
xmllint --noblanks --nonet document.xml > minified.xml

# xmllint has no flag for dropping comments, and sed cannot do it correctly
# (a comment may contain a > character). Use an identity transform with no
# template for comment() nodes:
cat > strip-comments.xsl <<'XSL'
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="no"/>
  <xsl:template match="@*|node()">
    <xsl:copy><xsl:apply-templates select="@*|node()"/></xsl:copy>
  </xsl:template>
  <xsl:template match="comment()"/>
</xsl:stylesheet>
XSL
xsltproc --nonet strip-comments.xsl document.xml | xmllint --noblanks --nonet -

# Measure the two questions separately:
wc -c document.xml minified.xml
gzip -9 -c document.xml | wc -c
gzip -9 -c minified.xml | wc -c

ここに挙げたものはすべて、解析して再直列化します。山かっこに対する正規表現ではありません。ミニファイアが下す判断(この空白は要素のみの内容の中か?)には木が要るからです。解析せずに圧縮できると謳うツールは、混在内容を間違えると自分で言っているようなものです。

よくある質問

圧縮するとき文書はアップロードされますか。

いいえ。パーサーもミニファイアも、このタブの Web Worker で動く JavaScript です。送信先のエンドポイントも、エディターにアクセスできる解析ツールも、サードパーティのスクリプトもありません。ネットワークタブを開いて、作業中も空のままなのを見てください。

ここではそれが些細な話ではありません。文書が圧縮されるのは、保管先や別のシステムへ渡る途中です。つまり注文レコードや識別子、トークンの入った本物のペイロードだということです。この検索で上位に出るツールのいくつかはサーバー側で処理しますし、最大手の 1 つは保存した文書を既定で公開します。

XML はどのくらい小さくなりますか。

一般的なインデント済み XML でおおむね 10 〜 35 パーセントで、ほぼ入れ子の深さとインデント幅だけで決まります。2,000 レコードのテスト文書では、スペース 2 個で 24 パーセント、4 個で 36 パーセント減りました。長いテキストを多く含む文書では効果はずっと小さくなります。

正直な比較は圧縮後のもので、そこでは約 3 パーセントです。gzip した整形済みが 30,744 バイト、gzip した圧縮済みが 29,771 バイト。スペース 4 個のインデントでは整形済みのほうが小さくなりました。文書がどこへ行くかで判断してください。

圧縮で XML が壊れることはありますか。

ありえます。だからこのツールは保守的です。壊れ方は、データだった空白を取り除いてしまうことです。混在内容、CDATA、そして xml:space="preserve" の支配下にあるもの。前の 2 つには対処してあります。混在した部分木は元のソースからそのまま複製し、CDATA はバイト単位で複製します。ひとつ注意が残ります。xml:space="preserve" は特別扱いしないので、それを持つテキストのみの要素でも前後は削られます。

3 つめの壊れ方は目に見えません。文書に XML 署名が付いている場合、Canonical XML は要素内容の空白をダイジェストに含めるため、署名済み文書を圧縮すると署名が無効になります。署名の前に圧縮してください。あとからは決して。

コメントは削除されますか。

頼んだときだけです。チェックボックスは既定でオフです。

コメントは、設定ファイルに付いている唯一の説明であることがよくあります。Maven の POM や XSLT スタイルシートからそれを消すと、なぜ妙な要素がそこにあるのかという説明を失います。しかも、その節約分は gzip がどのみち回収してくれたはずのものです。自分のファイルで保管のために圧縮するなら削ってかまいません。他人の文書を先へ渡すだけなら、残しておいてください。

あとから整形済みの版に戻せますか。

戻せます。圧縮した文書をフォーマッターに通してください。値も属性も実体参照も CDATA セクションも同一なので、パーサーが報告する内容は何も変わっていません。

戻らないのは元のレイアウトです。セクション間の空行、特定のインデント幅、2 つのタグが隣り合っていた混在部分木の内部の間隔。要素のみの内容にとって、それらは情報ではありませんでした。もし情報だったのなら、元のファイルを保管しておいてください。

どのくらいの大きさのファイルを扱えますか。

20 MB までです。1 MB の文書はおよそ 180 ミリ秒、5 MB は 1 秒ほどで圧縮され、Web Worker で動くのでページは反応を保ちます。

この上限は方針ではなくメモリーの制約です。何もアップロードしないので文書はこのタブに載り、20 MB を超えると解析木が数百 MB を占めてブラウザーが応答しなくなります。それ以上のファイルには、xmllint --noblanks が同じ「無視してよい空白」の規則で同じ仕事をします。

関連ツール

関連する解説