Minificatore XML

Toglie gli spazi non significativi. Il contenuto misto resta.

Gli spazi dentro il contenuto misto restano intatti, perché lì sono dati.
Input
Output
In attesaIncolla un documento per controllarlo. La convalida gira mentre scrivi.

Tutto viene eseguito in questa scheda. Nulla di ciò che incolli viene caricato, registrato o inviato da qualche parte. Apri il pannello di rete e verifica.

Incolla un documento qui sopra e lo spazio bianco fra i suoi elementi viene rimosso. Il risultato torna su una sola riga accanto al tuo input, con la dimensione prima, la dimensione dopo e la percentuale risparmiata. Anche i commenti spariscono se spunti la casella. Tutto gira in questa scheda: il parser e il minificatore sono JavaScript, e il tuo documento non viene mai spedito da nessuna parte.

Minifica quando il vincolo è la dimensione del documento stesso. Centinaia di migliaia di payload in una colonna di database, una envelope incorporata in un campo testo JSON, un messaggio che deve stare nel limite di dimensione di un broker. Sulla rete vale molto meno di quanto suggeriscano le percentuali da titolo, e i numeri qui sotto dicono perché.

La regola è quella del formattatore, rovesciata: lo spazio fra elementi è impaginazione, lo spazio dentro il contenuto misto è un dato. Quando un elemento contiene sia testo sia elementi figli, il suo sottoalbero viene preso dal tuo sorgente invece di essere ricostruito, e il CDATA è copiato byte per byte. Quel che resta non può cambiare ciò che un parser consegna all’applicazione.

Che cosa si può togliere senza rischi

Un parser XML consegna all’applicazione ogni carattere del documento, spazi compresi; non ce n’è nessuno che scarti per conto tuo. "Insignificante" è un’affermazione sul modello di contenuto, non sul parser: se una DTD o uno schema dichiarano che un elemento ha contenuto di soli elementi, lo spazio immediatamente al suo interno non può essere un dato, ed è l’unico spazio che un minificatore possa toccare.

La maggior parte dei documenti arriva senza schema, quindi un minificatore applica un’euristica, e quella usata qui è stretta: un nodo di testo composto interamente di spazi, che sta fra elementi fratelli, viene scartato. Nient’altro. È lo stesso giudizio che dà libxml2 con --noblanks. Un’altra modifica: un elemento vuoto viene scritto autochiudente, quindi <status></status> diventa <status/>. Qualunque parser conforme vede lo stesso elemento, ma non sono gli stessi byte.

Che cosa non viene tolto

Il contenuto misto è ciò che separa un vero minificatore da un’espressione regolare. In <p>Ciao <b>mondo</b>!</p> lo spazio dopo "Ciao" è un carattere del documento, e così lo spazio fra </b> e <i> in <p>a <b>x</b> <i>y</i></p>. Entrambi sopravvivono, perché un sottoalbero misto viene copiato alla lettera dal tuo sorgente invece che ricostruito. Nulla al suo interno viene toccato, ed è l’unica posizione difendibile: una volta che testo e markup si intrecciano, non resta spazio di cui si possa dimostrare l’insignificanza.

  • Sezioni CDATA: delimitatori e contenuto riprodotti esattamente. Lo spazio lì dentro è contenuto per definizione.
  • Valori degli attributi: emessi come sono scritti, con la virgoletta originale e gli eventuali riferimenti a entità intatti.
  • Testo in un elemento foglia: <name> Ada </name> perde solo il riempimento esterno, mai i caratteri in mezzo.
  • Commenti: mantenuti se non chiedi altrimenti. Istruzioni di elaborazione, dichiarazione XML e sottoinsieme DTD interno: sempre mantenuti.
  • xml:space="preserve": il limite da dire onestamente. Non riceve alcun trattamento speciale, quindi minifica una copia e confronta se il tuo documento lo usa.

Quanto più piccolo, davvero

Numeri da un documento di prova: 2.000 record d’ordine annidati su quattro livelli. Con indentazione di due spazi pesa 554 KB e si minifica a 421 KB, il 24 per cento in meno. Con quattro spazi pesa 679 KB, quindi la minificazione toglie il 36 per cento. I documenti profondi con elementi piccoli guadagnano di più, perché l’indentazione è grande rispetto al contenuto che avvolge.

Ora comprimi entrambi. La versione a due spazi va a 30.744 byte con gzip e quella minificata a 29.771: tre per cento di differenza. Con indentazione a quattro spazi il risultato si capovolge, 28.519 byte formattato contro 29.771 minificato, quindi dopo la compressione il file minificato è il più grande dei due. L’indentazione ripetuta è esattamente ciò in cui un compressore è bravo. Minificare non è inutile; minificare per la rete lo è, una volta acceso il Content-Encoding.

  • Ne vale la pena: XML grezzo in una colonna di database, un messaggio di coda con limite di dimensione, un documento incorporato in un altro payload, una fixture in cui l’indentazione domina il diff.
  • Non ne vale la pena: qualunque cosa servita su HTTP con la compressione attiva, ed è da lì che il marketing della maggior parte dei minificatori tira fuori la sua percentuale.
  • Attivamente dannoso: un documento firmato. L’XML canonico mantiene lo spazio nel contenuto degli elementi, quindi l’impronta copre byte che stai per cambiare.

Riprendersi l’impaginazione, e i limiti

Passare l’output nel formattatore ti restituisce un documento indentato con ogni valore identico. Quello che non riprendi è la tua impaginazione originale: righe vuote fra le sezioni, una larghezza di indentazione insolita, la spaziatura interna di un sottoalbero misto in cui due tag stavano accanto. Per un contenuto di soli elementi nulla di ciò era informazione. Se lo era, non minificare.

Un documento che non è ben formato non viene minificato: il tuo input torna invariato con ogni errore elencato per riga e colonna. Un documento da 1 MB si minifica in circa 180 millisecondi e 5 MB in poco più di un secondo, in un Web Worker così la pagina resta reattiva. Il tetto è 20 MB, perché tutto è tenuto nella memoria di questa scheda.

Minificare XML da codice

In uno step di build questo è un parse e una riserializzazione, mai un’operazione su stringhe. Ogni esempio analizza in modo sicuro, dato che le impostazioni predefinite di Java, PHP e della libreria standard di Python risolvono le entità esterne. Guarda come ciascuna libreria decide quale spazio sia ignorabile, perché quella decisione è l’intero strumento.

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

Ognuno di questi analizza e riserializza. Nessuno è un’espressione regolare sulle parentesi angolari, perché la decisione che prende un minificatore (questo spazio sta dentro contenuto di soli elementi?) richiede l’albero. Uno strumento che promette di minificare senza analizzare ti sta dicendo che sbaglierà sul contenuto misto.

Domande frequenti

Il mio documento viene caricato quando lo minifico?

No. Il parser e il minificatore girano in questa scheda come JavaScript in un Web Worker. Non c’è un endpoint a cui inviare, né analytics con accesso all’editor, né script di terze parti. Apri la scheda Rete e guardala restare vuota mentre lavori.

Qui non è un dettaglio: i documenti si minificano mentre vanno verso l’archiviazione o un altro sistema, il che significa che sono payload veri, con record d’ordine, identificatori e token dentro. Diversi strumenti ben posizionati su questa ricerca elaborano il tuo documento su un server, e uno dei più grandi pubblica per impostazione predefinita i documenti salvati.

Di quanto si ridurrà il mio XML?

Fra circa il 10 e il 35 per cento per un XML indentato tipico, determinato quasi interamente dalla profondità di annidamento e dalla larghezza dell’indentazione. Su un documento di prova da 2.000 record, due spazi hanno tolto il 24 per cento e quattro spazi il 36. I documenti con molto testo guadagnano assai meno.

Il confronto onesto è dopo la compressione, e lì siamo attorno al tre per cento: 30.744 byte gzippati per il formattato contro 29.771 per il minificato. Con indentazione a quattro spazi il file formattato era il più piccolo dei due. Giudica in base a dove è diretto il documento.

Minificare può rompere il mio XML?

Può, ed è per questo che questo è prudente. Il modo in cui si rompe è togliere spazio che era un dato: contenuto misto, CDATA e tutto ciò che è governato da xml:space="preserve". I primi due sono gestiti, dato che i sottoalberi misti sono copiati alla lettera dal tuo sorgente e il CDATA byte per byte. Resta un avvertimento: xml:space="preserve" non riceve trattamento speciale, quindi un elemento di solo testo che lo porta viene comunque tagliato.

Il terzo modo è invisibile. Se il documento porta una firma XML, l’XML canonico include lo spazio nel contenuto degli elementi nell’impronta: minificare un documento firmato lo invalida. Minifica prima di firmare, mai dopo.

Rimuove i commenti?

Solo se lo chiedi. La casella è disattivata per impostazione predefinita.

I commenti sono spesso l’unica documentazione allegata a un file di configurazione. Toglierli da un POM Maven o da un foglio XSLT ti costa la spiegazione del perché un elemento strano sia lì, per un risparmio che gzip avrebbe comunque raccolto. Se il file è tuo e minifichi per archiviarlo, toglili; se stai passando avanti il documento di qualcun altro, lasciali.

Posso riavere la versione formattata dopo?

Sì, ripassando il documento minificato nel formattatore. Ogni valore, attributo, riferimento a entità e sezione CDATA è identico, quindi nulla di ciò che un parser riporterebbe è cambiato.

Quello che non riavrai è la tua impaginazione originale: righe vuote fra le sezioni, una particolare larghezza di indentazione, o la spaziatura interna di un sottoalbero misto in cui due tag erano attaccati. Per un contenuto di soli elementi nulla di ciò portava informazione. Se la portava, conserva il file originale.

Quanto può essere grande il file?

Fino a 20 MB. Un documento da 1 MB si minifica in circa 180 millisecondi e 5 MB in circa un secondo, in un Web Worker così la pagina resta reattiva.

Il tetto è un limite di memoria, non una scelta di policy. Non viene caricato nulla, quindi il documento vive in questa scheda, e oltre i 20 MB l’albero di parsing può occupare diverse centinaia di megabyte e il browser smette di rispondere. Per file più grandi, xmllint --noblanks fa lo stesso lavoro con la stessa regola sullo spazio ignorabile.

Strumenti correlati

Approfondimenti