XML-Minifier

Entfernt unbedeutenden Leerraum. Gemischter Inhalt bleibt.

Leerraum in gemischtem Inhalt bleibt unangetastet, denn dort ist er Teil der Daten.
Eingabe
Ausgabe
WartetFügen Sie ein Dokument ein. Die Prüfung läuft während der Eingabe.

Alles läuft in diesem Tab. Nichts, was Sie einfügen, wird hochgeladen, protokolliert oder irgendwohin gesendet. Öffnen Sie Ihr Netzwerkpanel und prüfen Sie es.

Fügen Sie oben ein Dokument ein, und der Leerraum zwischen seinen Elementen wird entfernt. Das Ergebnis kommt als eine Zeile neben Ihrer Eingabe zurück, mit der Größe vorher, der Größe nachher und dem eingesparten Prozentsatz. Kommentare gehen mit, wenn Sie das Kästchen setzen. Alles läuft in diesem Tab: Parser und Minifier sind JavaScript, und Ihr Dokument wird nie irgendwohin geschickt.

Minimieren Sie, wenn die Größe des Dokuments selbst die Beschränkung ist. Hunderttausende Nutzlasten in einer Datenbankspalte, eine Envelope in einem JSON-Textfeld, eine Nachricht, die in das Größenlimit eines Brokers passen muss. Über die Leitung ist es weit weniger wert, als die plakativen Prozentzahlen nahelegen, und die Zahlen unten sagen, warum.

Die Regel ist die des Formatierers, umgedreht: Leerraum zwischen Elementen ist Layout, Leerraum in gemischtem Inhalt ist Teil der Daten. Enthält ein Element sowohl Text als auch Kindelemente, wird sein Teilbaum aus Ihrer Quelle übernommen statt neu gebaut, und CDATA wird Byte für Byte kopiert. Was übrig bleibt, kann nicht verändern, was ein Parser an die Anwendung übergibt.

Was sicher entfernt werden darf

Ein XML-Parser reicht jedes Zeichen des Dokuments an die Anwendung weiter, Leerraum eingeschlossen; er verwirft nichts stellvertretend für Sie. „Unbedeutend“ ist eine Aussage über das Inhaltsmodell, nicht über den Parser: Erklärt eine DTD oder ein Schema ein Element für nur-Element-Inhalt, kann Leerraum unmittelbar darin keine Daten sein – und nur diesen Leerraum darf ein Minifier anfassen.

Die meisten Dokumente kommen ohne Schema, also wendet ein Minifier eine Heuristik an, und die hier ist eng: Ein Textknoten, der ausschließlich aus Leerraum besteht und zwischen Geschwisterelementen steht, wird verworfen. Sonst nichts. Dasselbe Urteil fällt libxml2 unter --noblanks. Eine weitere Änderung: Ein leeres Element wird selbstschließend geschrieben, aus <status></status> wird also <status/>. Jeder konforme Parser sieht dasselbe Element, aber es sind nicht dieselben Bytes.

Was nicht entfernt wird

Gemischter Inhalt trennt einen echten Minifier von einem regulären Ausdruck. In <p>Hallo <b>Welt</b>!</p> ist das Leerzeichen nach „Hallo“ ein Zeichen im Dokument, und das Leerzeichen zwischen </b> und <i> in <p>a <b>x</b> <i>y</i></p> ebenso. Beide überleben, weil ein gemischter Teilbaum wortgetreu aus Ihrer Quelle kopiert und nicht neu gebaut wird. Nichts darin wird angefasst, die einzig vertretbare Haltung: Sobald Text und Markup verschränkt sind, bleibt kein Leerraum übrig, dessen Unbedeutsamkeit sich beweisen ließe.

  • CDATA-Abschnitte: Begrenzer und Inhalt exakt reproduziert. Leerraum darin ist per Definition Inhalt.
  • Attributwerte: ausgegeben wie geschrieben, mit dem ursprünglichen Anführungszeichen und unversehrten Entity-Referenzen.
  • Text in einem Blattelement: <name> Ada </name> verliert nur die äußere Polsterung, nie die Zeichen dazwischen.
  • Kommentare: bleiben, sofern Sie nichts anderes verlangen. Verarbeitungsanweisungen, XML-Deklaration und internes DTD-Subset: bleiben immer.
  • xml:space="preserve": die ehrliche Grenze. Es erhält keine Sonderbehandlung – minimieren Sie also eine Kopie und vergleichen Sie, wenn Ihr Dokument es benutzt.

Wie viel kleiner, wirklich

Zahlen aus einem Testdokument: 2.000 Bestelldatensätze, vier Ebenen tief. Mit zwei Leerzeichen Einrückung sind es 554 KB, minimiert 421 KB – 24 Prozent weniger. Mit vier Leerzeichen sind es 679 KB, das Minimieren nimmt also 36 Prozent weg. Tiefe Dokumente mit kleinen Elementen gewinnen am meisten, weil die Einrückung im Verhältnis zum umschlossenen Inhalt groß ist.

Nun komprimieren Sie beide. Die Zwei-Leerzeichen-Fassung gzippt auf 30.744 Byte, die minimierte auf 29.771: drei Prozent Unterschied. Mit vier Leerzeichen kehrt sich das Ergebnis um, 28.519 Byte formatiert gegen 29.771 minimiert – die minimierte Datei ist nach der Kompression also die größere. Wiederholte Einrückung ist genau das, worin ein Kompressor gut ist. Minimieren ist nicht sinnlos; Minimieren fürs Netz ist es, sobald Content-Encoding an ist.

  • Lohnt sich: rohes XML in einer Datenbankspalte, eine größenbeschränkte Queue-Nachricht, ein in eine andere Nutzlast eingebettetes Dokument, eine Fixture, in der die Einrückung den Diff beherrscht.
  • Lohnt sich nicht: alles, was über HTTP mit aktivierter Kompression ausgeliefert wird – und genau daher stammt der Prozentsatz im Marketing der meisten Minifier.
  • Aktiv schädlich: ein signiertes Dokument. Kanonisches XML behält den Leerraum im Elementinhalt, die Prüfsumme deckt also Bytes ab, die Sie gerade ändern wollen.

Das Layout zurückholen, und die Grenzen

Schicken Sie die Ausgabe durch den Formatierer, und Sie haben wieder ein eingerücktes Dokument mit identischen Werten. Was Sie nicht zurückbekommen, ist Ihr ursprüngliches Layout: Leerzeilen zwischen Abschnitten, eine ungewöhnliche Einrückungsbreite, der innere Abstand eines gemischten Teilbaums, in dem zwei Tags nebeneinander standen. Für nur-Element-Inhalt war nichts davon Information. War es doch welche, dann minimieren Sie nicht.

Ein nicht wohlgeformtes Dokument wird nicht minimiert: Ihre Eingabe kommt unverändert zurück, mit allen Fehlern nach Zeile und Spalte aufgelistet. Ein 1-MB-Dokument wird in rund 180 Millisekunden minimiert, 5 MB in etwa einer Sekunde, in einem Web Worker, damit die Seite bedienbar bleibt. Die Obergrenze liegt bei 20 MB, weil alles im Speicher dieses Tabs gehalten wird.

XML in Code minimieren

In einem Build-Schritt ist das ein Parse und eine Reserialisierung, nie eine Stringoperation. Jedes Beispiel parst sicher, denn die Standardeinstellungen in Java, PHP und Pythons Standardbibliothek lösen externe Entities auf. Achten Sie darauf, wie jede Bibliothek entscheidet, welcher Leerraum ignorierbar ist – diese Entscheidung ist das ganze Werkzeug.

// 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

Jedes dieser Beispiele parst und serialisiert neu. Keines ist ein regulärer Ausdruck über spitzen Klammern, denn die Entscheidung eines Minifiers (steht dieser Leerraum in nur-Element-Inhalt?) braucht den Baum. Ein Werkzeug, das Minimieren ohne Parsen verspricht, sagt Ihnen damit, dass es gemischten Inhalt falsch behandeln wird.

Häufige Fragen

Wird mein Dokument beim Minimieren hochgeladen?

Nein. Parser und Minifier laufen in diesem Tab als JavaScript in einem Web Worker. Es gibt keinen Endpunkt zum Senden, keine Analytics mit Zugriff auf den Editor und kein Skript Dritter. Öffnen Sie den Netzwerk-Tab und sehen Sie ihm beim Leerbleiben zu.

Das ist hier nicht nebensächlich: Dokumente werden auf dem Weg in die Ablage oder in ein anderes System minimiert, sind also echte Nutzlasten mit Bestelldatensätzen, Bezeichnern und Token darin. Mehrere Werkzeuge, die für diese Suche ranken, verarbeiten Ihr Dokument auf einem Server, und eines der größten veröffentlicht gespeicherte Dokumente standardmäßig.

Wie viel kleiner wird mein XML?

Bei typischem eingerücktem XML zwischen etwa 10 und 35 Prozent, fast ausschließlich bestimmt von Verschachtelungstiefe und Einrückungsbreite. Bei einem Testdokument mit 2.000 Datensätzen brachten zwei Leerzeichen 24 Prozent, vier Leerzeichen 36. Dokumente mit viel Textinhalt gewinnen deutlich weniger.

Der ehrliche Vergleich erfolgt nach der Kompression, und dort sind es etwa drei Prozent: 30.744 Byte gzippt formatiert gegen 29.771 gzippt minimiert. Bei vier Leerzeichen Einrückung war die formatierte Datei die kleinere. Entscheiden Sie danach, wohin das Dokument geht.

Kann Minimieren mein XML kaputt machen?

Das kann es, und deshalb ist dieser hier konservativ. Der Fehlerfall ist das Entfernen von Leerraum, der Daten war: gemischter Inhalt, CDATA und alles, was xml:space="preserve" regiert. Die ersten beiden sind abgedeckt, denn gemischte Teilbäume werden wortgetreu aus Ihrer Quelle kopiert und CDATA Byte für Byte. Ein Vorbehalt bleibt: xml:space="preserve" bekommt keine Sonderbehandlung, ein reines Textelement damit wird also trotzdem gekürzt.

Der dritte Fehlerfall ist unsichtbar. Trägt das Dokument eine XML-Signatur, nimmt kanonisches XML den Leerraum im Elementinhalt in die Prüfsumme auf – ein signiertes Dokument zu minimieren macht die Signatur ungültig. Minimieren Sie vor dem Signieren, nie danach.

Entfernt es Kommentare?

Nur wenn Sie es verlangen. Das Kästchen ist standardmäßig aus.

Kommentare sind oft die einzige Dokumentation an einer Konfigurationsdatei. Sie aus einem Maven-POM oder einem XSLT-Stylesheet zu entfernen kostet Sie die Erklärung, warum ein merkwürdiges Element dort steht – für eine Ersparnis, die gzip ohnehin eingesammelt hätte. Gehört die Datei Ihnen und minimieren Sie für die Ablage, dann raus damit; reichen Sie das Dokument eines anderen weiter, lassen Sie sie drin.

Bekomme ich die formatierte Fassung später zurück?

Ja, indem Sie das minimierte Dokument durch den Formatierer schicken. Jeder Wert, jedes Attribut, jede Entity-Referenz und jeder CDATA-Abschnitt ist identisch, es hat sich also nichts geändert, was ein Parser melden würde.

Was Sie nicht zurückbekommen, ist Ihr ursprüngliches Layout: Leerzeilen zwischen Abschnitten, eine bestimmte Einrückungsbreite oder der innere Abstand eines gemischten Teilbaums, in dem zwei Tags nebeneinander standen. Für nur-Element-Inhalt trug nichts davon Information. Tat es das doch, behalten Sie die Originaldatei.

Wie große Dateien schafft es?

Bis 20 MB. Ein 1-MB-Dokument wird in etwa 180 Millisekunden minimiert und 5 MB in ungefähr einer Sekunde, in einem Web Worker, damit die Seite bedienbar bleibt.

Die Obergrenze ist eine Speichergrenze, keine Richtlinie. Es wird nichts hochgeladen, das Dokument lebt also in diesem Tab, und jenseits von 20 MB kann der Parse-Baum mehrere hundert Megabyte belegen und der Browser reagiert nicht mehr. Für größere Dateien erledigt xmllint --noblanks dieselbe Arbeit mit derselben Regel über ignorierbaren Leerraum.

Verwandte Werkzeuge

Zum Weiterlesen