Comparateur XML
Comparez deux documents. Aucun ne quitte le navigateur.
Tout s'exécute dans cet onglet. Rien de ce que vous collez n'est envoyé, journalisé ni transmis où que ce soit. Ouvrez votre panneau réseau et vérifiez.
Collez un document à gauche et l’autre à droite : les différences apparaissent ligne par ligne, les ajouts numérotés d’après le document de droite et les suppressions d’après celui de gauche. Les longs passages inchangés se replient en un repère pour que les changements restent trouvables. Les deux documents restent dans cet onglet, ce qui n’est pas le cas de la plupart des outils bien placés sur cette recherche.
La comparaison est structurelle et non textuelle. Les deux documents sont d’abord reformatés, en indentation de deux espaces avec les attributs triés alphabétiquement, et le diff porte sur les résultats. Deux documents ne différant que par l’indentation, ou seulement par l’ordre des attributs, ressortent identiques, et le panneau de résultat le dit dans ces termes.
C’est ce que vous voulez en comparant une charge utile attendue à une charge utile réelle, et c’est pour cela que l’outil existe. C’est le mauvais réglage par défaut quand les espaces portent du sens, et les sections ci-dessous disent exactement où passe cette frontière.
Ce qui cesse d’être une différence
Les deux côtés passent par le même formateur avant toute comparaison. XML ne considère comme significatifs ni l’ordre des attributs ni l’indentation hors contenu mixte : un diff qui signale l’un ou l’autre décrit le sérialiseur plutôt que les données. C’est pourquoi un document écrit par Jackson et les mêmes données écrites par un XmlWriter .NET se comparent directement.
- L’indentation, les sauts de ligne et la place des balises sur la ligne.
- L’ordre des attributs : les deux côtés sont triés alphabétiquement par nom.
- La syntaxe d’élément vide : <status></status> et <status> </status> deviennent tous deux <status/>.
- Les espaces de début et de fin d’un élément purement textuel : <name> Priya </name> correspond donc à <name>Priya</name>.
- L’ordre de version, encoding et standalone dans la déclaration.
Ce qui reste une différence, volontairement
Le formateur ne change rien qu’il ne puisse changer sans risque. Les valeurs d’attribut sont émises exactement telles qu’écrites, guillemet d’origine compris : ré-échapper transformerait & en &amp;, et décoder est impossible aussi, car une valeur référençant une entité déclarée dans la DTD comme &companyName; a besoin de cette DTD.
Le contenu mixte, un élément contenant à la fois du texte et des éléments enfants, est reproduit octet pour octet, parce que l’espace entre texte et balisage y fait partie des données. C’est le seul endroit où l’indentation apparaît réellement dans le diff, et elle y apparaît à juste titre.
- Le style de guillemets : id='A-991' contre id="A-991". De même pour l’écriture d’une référence, & contre &.
- CDATA contre texte échappé : <note><![CDATA[a<b]]></note> et <note>a<b</note> livrent des caractères identiques mais sont signalés comme différents.
- Les commentaires, conservés plutôt que supprimés : dans un fichier de configuration, un commentaire modifié est souvent le changement que vous cherchiez.
- Les préfixes d’espace de noms. Relier soap: à s: avec la même URI est sémantiquement identique et s’affiche pourtant comme une différence sur tout le document.
Quand normaliser est le mauvais réglage par défaut
Certains documents sont des octets plutôt qu’une structure. La signature numérique XML calcule l’empreinte d’une séquence d’octets canonique : le moindre changement, y compris l’indentation que cet outil normalise, casse la signature. Un diff déclarant deux assertions signées identiques vous dit que leur contenu correspond, pas que les deux se vérifient encore.
L’autre cas est un document ayant déclaré xml:space="preserve", ou portant du texte préformaté. Les sous-arbres de contenu mixte sont à l’abri, mais un élément purement textuel dont les espaces de tête comptent ne l’est pas : ces espaces sont rognés. Utilisez un diff de texte brut quand votre document en dépend.
Attendu contre réel
Le cas pour lequel l’outil existe : un test d’intégration échoue, vous avez la fixture attendue et la charge utile réellement renvoyée par le service, et elles sont formatées différemment parce que l’une a été écrite à la main et l’autre est arrivée minifiée sur le réseau. Un diff de texte brut de ces deux-là est inutilisable ; normaliser les deux d’abord le réduit aux trois lignes qui ont vraiment changé.
Les deux documents doivent d’abord être bien formés. Si l’un ne s’analyse pas, l’outil s’arrête et le dit, et les erreurs du document de gauche sont marquées dans l’éditeur avec leur ligne et leur colonne : une réponse tronquée est diagnostiquée sur place au lieu de ressembler à un changement de structure.
C’est un diff de lignes, pas d’arbre
La comparaison est un diff par plus longue sous-séquence commune sur les lignes, le même algorithme que git. Déplacer un élément à l’intérieur de son parent apparaît donc comme une suppression à un endroit et un ajout à un autre, pas comme un déplacement. Réordonner des frères s’affiche comme un changement même là où le schéma tient l’ordre pour insignifiant, car l’ordre des éléments XML est significatif par défaut. Les node matchers de XMLUnit, dans le code ci-dessous, sont la réponse quand cela compte.
Il y a un plafond. Au-delà de 3 000 lignes après formatage, l’outil décline et vous demande de comparer une section à la fois : la table de la plus longue sous-séquence commune est quadratique, et deux gros documents bloqueraient l’onglet plutôt que d’être simplement lents.
Le faire en code
La même idée dans une suite de tests ou une étape de build : canonicalisez les deux côtés, puis comparez. Chacun de ces exemples désactive la résolution des entités externes, car vous les pointez en général vers une charge utile que vous n’avez pas produite.
// Browsers do not resolve external entities, so DOMParser is safe here. It
// does not throw on malformed input: it returns a document containing a
// <parsererror> element, which is why so much code accepts broken XML.
function parse(source, label) {
const doc = new DOMParser().parseFromString(source, 'application/xml');
const err = doc.querySelector('parsererror');
if (err) throw new Error(label + ': ' + err.textContent.trim());
return doc;
}
// Canonical text: two spaces per level, attributes sorted by name, empty
// elements written one way. This is what makes the comparison structural.
function canonicalise(node, depth, out) {
const pad = ' '.repeat(depth);
if (node.nodeType === Node.TEXT_NODE) {
const t = node.data.trim();
if (t) out.push(pad + t);
return out;
}
if (node.nodeType !== Node.ELEMENT_NODE) return out;
const attrs = Array.from(node.attributes)
.sort((a, b) => a.name.localeCompare(b.name))
.map((a) => ' ' + a.name + '="' + escapeAttr(a.value) + '"')
.join('');
const kids = Array.from(node.childNodes).filter(
(c) =>
c.nodeType === Node.ELEMENT_NODE ||
(c.nodeType === Node.TEXT_NODE && c.data.trim() !== ''),
);
if (kids.length === 0) {
out.push(pad + '<' + node.nodeName + attrs + '/>');
return out;
}
out.push(pad + '<' + node.nodeName + attrs + '>');
for (const c of kids) canonicalise(c, depth + 1, out);
out.push(pad + '</' + node.nodeName + '>');
return out;
}
function escapeAttr(s) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
}
const left = canonicalise(parse(expected, 'expected').documentElement, 0, []);
const right = canonicalise(parse(actual, 'actual').documentElement, 0, []);
// Equality is now a string compare. For a rendered diff, feed the two arrays
// to a line differ; this page runs an LCS over exactly these lines.
console.log(left.join('\n') === right.join('\n') ? 'identical' : 'different');from lxml import etree
import difflib
# resolve_entities=False and no_network=True are the two that matter: without
# them a payload from an untrusted source can read local files (XXE).
PARSER = etree.XMLParser(resolve_entities=False, no_network=True,
load_dtd=False, huge_tree=False)
def canonical_lines(path: str) -> list[str]:
with open(path, 'rb') as fh:
doc = etree.parse(fh, PARSER)
# C14N 2.0 sorts attributes, normalises namespace declarations and writes
# empty elements one way. strip_text drops insignificant whitespace, which
# is what makes indentation irrelevant to the comparison. Note that it
# strips whitespace inside mixed content too, which is lossy.
canon = etree.canonicalize(etree.tostring(doc), strip_text=True)
reparsed = etree.fromstring(canon.encode(), PARSER)
etree.indent(reparsed, space=' ') # lxml 4.5 and later
return etree.tostring(reparsed, encoding='unicode').splitlines(keepends=True)
diff = difflib.unified_diff(
canonical_lines('expected.xml'),
canonical_lines('actual.xml'),
fromfile='expected.xml',
tofile='actual.xml',
)
for line in diff:
print(line, end='')// XMLUnit 2 compares trees, not lines, so it can tell you "attribute 'total'
// differs at /order[1]/total[1]" rather than showing two lines and leaving
// you to spot it.
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import org.xmlunit.builder.DiffBuilder;
import org.xmlunit.builder.Input;
import org.xmlunit.diff.DefaultNodeMatcher;
import org.xmlunit.diff.Diff;
import org.xmlunit.diff.ElementSelectors;
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setNamespaceAware(true);
Diff diff = DiffBuilder.compare(Input.fromFile("expected.xml"))
.withTest(Input.fromFile("actual.xml"))
.withDocumentBuilderFactory(dbf)
.ignoreComments()
.ignoreWhitespace() // drops whitespace-only text nodes
.normalizeWhitespace() // collapses runs inside the text that remains
// byNameAndText pairs repeated elements up by content rather than by
// position, so a reordered list is not reported as every row changing.
.withNodeMatcher(new DefaultNodeMatcher(ElementSelectors.byNameAndText))
.checkForSimilar() // "similar" ignores attribute order and prefixes
.build();
if (diff.hasDifferences()) {
diff.getDifferences().forEach(d -> System.out.println(d));
System.exit(1);
}using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
// Load() without LoadOptions.PreserveWhitespace drops insignificant
// whitespace, so indentation never reaches the comparison. Prohibiting DTDs
// and nulling the resolver closes XXE.
static XDocument LoadSafely(string path)
{
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
};
using var reader = XmlReader.Create(path, settings);
return XDocument.Load(reader);
}
var expected = LoadSafely("expected.xml");
var actual = LoadSafely("actual.xml");
// XNode.DeepEquals already ignores attribute order. Element order is
// significant to it, as it is to XML itself.
if (XNode.DeepEquals(expected, actual))
{
Console.WriteLine("Identical.");
return;
}
// Not equal: write both out normalised so a text diff is readable.
static void SortAttributes(XElement e)
{
var sorted = e.Attributes()
.OrderBy(a => a.Name.NamespaceName)
.ThenBy(a => a.Name.LocalName)
.ToList();
e.RemoveAttributes();
e.Add(sorted);
foreach (var child in e.Elements()) SortAttributes(child);
}
SortAttributes(expected.Root!);
SortAttributes(actual.Root!);
File.WriteAllText("expected.norm.xml", expected.ToString());
File.WriteAllText("actual.norm.xml", actual.ToString());
Console.Error.WriteLine("Documents differ. Diff the two .norm.xml files.");# xmllint ships with libxml2 and is almost certainly already installed.
# --c14n implements Canonical XML 1.0: attributes sorted, empty elements
# expanded to a start/end pair, namespace declarations normalised.
# --nonet stops it fetching a DTD the document points at.
xmllint --nonet --c14n expected.xml > /tmp/a.c14n
xmllint --nonet --c14n actual.xml > /tmp/b.c14n
# C14N does not re-indent, so pretty-print afterwards or the whole document
# arrives on one line and the diff is useless. --format leaves an element
# alone when it contains text of its own, so mixed content is not reflowed.
xmllint --nonet --format /tmp/a.c14n > /tmp/a.xml
xmllint --nonet --format /tmp/b.c14n > /tmp/b.xml
diff -u /tmp/a.xml /tmp/b.xml
# Exit status 1 from diff means "they differ" and is not an error. Guard for
# it explicitly in CI, or set -e will kill the job on a successful comparison.
# C14N converts to UTF-8 and drops the XML declaration, so this will not tell
# you the two files declared different encodings. Check that with head -c 100.La distinction mérite d’être nommée : canonicalisation plus diff de lignes donne quelque chose qu’un humain peut lire, et une comparaison d’arbres comme XMLUnit donne à un test quelque chose sur quoi assertir, avec un message d’échec utile.
Questions fréquentes
Mes deux documents sont-ils envoyés pour être comparés ?
Non. Les deux éditeurs, le formateur et l’algorithme de diff sont du JavaScript qui tourne dans cet onglet, et il n’existe aucun composant serveur à qui envoyer quoi que ce soit.
C’est pour cela que l’outil existe. La charge utile réelle, c’est du trafic de production : de vrais noms de clients, de vrais montants de commande, souvent un jeton dans un en-tête SOAP. Plusieurs outils bien placés sur cette recherche prennent les deux fichiers par téléversement. Ouvrez votre panneau réseau pendant que vous collez : il restera vide.
Les deux documents sont conservés dans le localStorage de ce navigateur pour qu’un rafraîchissement ne perde pas votre travail. Cela ne quitte jamais votre machine, et Effacer les supprime tous les deux immédiatement.
Pourquoi dit-il que deux documents sont identiques alors qu’ils ne le sont visiblement pas ?
Parce qu’il compare la structure, pas les octets. Les deux côtés sont d’abord reformatés à la même indentation avec les attributs triés : un document minifié et les mêmes données mises en forme à quatre espaces ressortent identiques, tout comme <order id="A-991" total="64.85"> et <order total="64.85" id="A-991">.
XML ne considère comme significatifs ni l’ordre des attributs ni l’indentation hors contenu mixte : un diff qui signale l’un ou l’autre décrit le sérialiseur plutôt que les données. S’il vous faut une comparaison au niveau des octets, et pour un document signé il vous la faut, utilisez un diff de texte brut. Le bandeau au-dessus du résultat indique toujours que la comparaison a été faite après normalisation de l’indentation et de l’ordre des attributs : ce n’est donc jamais silencieux.
Les deux documents doivent-ils être du XML valide ?
Ils doivent être bien formés, ce qui n’est pas la même chose que valides. Bien formé signifie que la syntaxe est correcte : balises fermées et correctement imbriquées, un élément racine, caractères spéciaux échappés, valeurs d’attribut entre guillemets. Valide signifie en plus correspondre à un schéma, et aucun schéma n’intervient ici.
La comparaison ne peut pas tourner sur un document qui ne s’analyse pas, faute de structure à normaliser. Si l’un des côtés échoue, l’outil s’arrête et le dit plutôt que de se rabattre sur un diff de texte et de vous donner un résultat qui a l’air significatif. Cela attrape déjà à soi seul une vraie classe de bugs : une réponse tronquée apparaît comme un échec d’analyse avec ligne et colonne, et non comme un mur de rouge.
Peut-il comparer des documents utilisant des préfixes d’espace de noms différents ?
Il les signalera comme différents, et c’est une vraie limitation. soap:Envelope et s:Envelope liés à la même URI sont identiques pour tout consommateur conscient des espaces de noms, mais le préfixe fait partie du nom de l’élément tel qu’écrit, et le formateur ne réécrit pas les préfixes.
Réécrire n’est pas sûr en général : un préfixe peut apparaître dans des valeurs d’attribut, dans un xsi:type, dans une expression XPath d’une feuille de style, dans un QName d’un WSDL, là où un formateur ne le voit pas. Quand ce sont justement les différences de préfixe que vous devez ignorer, utilisez une comparaison canonicalisante : l’exemple XMLUnit ci-dessus avec checkForSimilar s’en charge, comme C14N dans les exemples shell et Python.
Quelle taille de paire de documents accepte-t-il ?
Jusqu’à 3 000 lignes chacun, mesurées après formatage et non telles que vous les avez collées : un document minifié qui se déplie en 8 000 lignes dépasse donc la limite même s’il est arrivé sur une seule ligne.
Le plafond est délibéré. Un diff par plus longue sous-séquence commune construit une table proportionnelle au produit des deux nombres de lignes : deux documents de 20 000 lignes réclameraient des centaines de millions de cellules et bloqueraient l’onglet plutôt que d’être seulement lents. Pour des fichiers de cette taille, git diff sur la sortie de xmllint --format, ou la recette shell ci-dessus, fait ce qu’aucun onglet de navigateur ne devrait tenter.