Comparar XML
Compara dos documentos. Ninguno sale de tu navegador.
Todo se ejecuta en esta pestaña. Nada de lo que pegues se sube, se registra ni se envía a ningún sitio. Abre el panel de red y compruébalo.
Pega un documento a la izquierda y el otro a la derecha y las diferencias aparecen línea a línea, con las adiciones numeradas contra el documento de la derecha y las eliminaciones contra el de la izquierda. Los tramos largos sin cambios se colapsan en un marcador para que los cambios sigan siendo localizables. Los dos documentos se quedan en esta pestaña, lo cual no es cierto en la mayoría de herramientas posicionadas para esta búsqueda.
La comparación es estructural, no textual. Los dos documentos se reformatean primero, a sangría de dos espacios y con los atributos ordenados alfabéticamente, y el diff corre sobre los resultados. Dos documentos que solo difieran en la sangría, o solo en el orden de los atributos, salen idénticos y el panel de resultados lo dice con esas mismas palabras.
Eso es lo que quieres al comparar un payload esperado contra uno real, que es para lo que está construido. Es el valor por defecto equivocado cuando el espacio en blanco tiene significado, y las secciones de abajo dicen exactamente dónde está esa línea.
Qué deja de ser una diferencia
Los dos lados pasan por el mismo formateador antes de comparar nada. XML no considera significativos ni el orden de los atributos ni la sangría fuera del contenido mixto, así que un diff que informe de cualquiera de las dos cosas está describiendo el serializador y no los datos. Por eso un documento escrito por Jackson y los mismos datos escritos por un XmlWriter de .NET se pueden comparar directamente.
- La sangría, los saltos de línea y dónde se sitúan las etiquetas en la línea.
- El orden de los atributos: los dos lados se ordenan alfabéticamente por nombre.
- La sintaxis de elemento vacío: <status></status> y <status> </status> se convierten los dos en <status/>.
- El espacio inicial y final de un elemento de solo texto, así que <name> Priya </name> coincide con <name>Priya</name>.
- El orden de version, encoding y standalone en la declaración.
Qué sigue contando como diferencia, a propósito
El formateador no cambia nada que no pueda cambiar con seguridad. Los valores de atributo se emiten exactamente como estaban escritos, con su carácter de comilla original incluido: volver a escapar convertiría & en &amp;, y decodificar también es imposible, porque un valor que referencia una entidad declarada en la DTD, como &companyName;, necesita esa DTD.
El contenido mixto, un elemento que contiene texto y elementos hijos a la vez, se reproduce byte a byte, porque allí el espacio entre texto y marcado es parte de los datos. Ese es el único sitio donde la sangría aparece de verdad en el diff, y está apareciendo correctamente.
- El estilo de comillas: id='A-991' frente a id="A-991". Y también la forma de escribir una referencia, & frente a &.
- CDATA frente a texto escapado: <note><![CDATA[a<b]]></note> y <note>a<b</note> entregan caracteres idénticos pero se informan como distintos.
- Los comentarios, que se conservan en vez de eliminarse: en un archivo de configuración, un comentario cambiado suele ser justo el cambio que buscabas.
- Los prefijos de espacio de nombres. Reasignar soap: a s: con la misma URI es semánticamente idéntico y aun así se muestra como una diferencia de documento entero.
Cuándo normalizar es el valor por defecto equivocado
Algunos documentos son bytes, no estructura. La firma digital XML resume una secuencia de bytes canónica, así que cualquier cambio, incluida la sangría que esta herramienta normaliza, rompe la firma. Un diff que diga que dos aserciones firmadas son idénticas te dice que su contenido coincide, no que las dos sigan verificando.
El otro caso es un documento que declaró xml:space="preserve", o que lleva texto preformateado. Los subárboles de contenido mixto están a salvo, pero un elemento de solo texto cuyo espacio inicial importa no lo está: ese espacio se recorta. Usa un diff de texto plano cuando tu documento dependa de ello.
Esperado contra real
El caso para el que existe: falla un test de integración, tienes el fixture que esperaba y el payload que el servicio devolvió de verdad, y están formateados de forma distinta porque uno se escribió a mano y el otro llegó minificado por el cable. Un diff de texto plano de esos dos es inservible; normalizar los dos primero lo reduce a las tres líneas que realmente cambiaron.
Los dos documentos tienen que estar bien formados antes. Si alguno no se analiza, la herramienta se detiene y lo dice, y los errores del documento de la izquierda se marcan en el editor con su línea y columna, así que una respuesta truncada se diagnostica en el sitio en vez de parecer un cambio estructural.
Es un diff de líneas, no de árboles
La comparación es un diff de subsecuencia común más larga sobre líneas, el mismo algoritmo que usa git. Mover un elemento dentro de su padre aparece por tanto como una eliminación en un sitio y una adición en otro, no como un movimiento. Reordenar hermanos se muestra como cambio incluso donde el esquema trata el orden como insignificante, porque el orden de elementos en XML es significativo por defecto. Los node matchers de XMLUnit, en el código de abajo, son la respuesta cuando eso importa.
Hay un techo. Por encima de 3.000 líneas tras el formateo la herramienta se niega y te pide comparar una sección cada vez: la tabla de LCS es cuadrática, y dos documentos grandes bloquearían la pestaña en vez de ser simplemente lentos.
Hacer esto desde código
La misma idea en una suite de tests o un paso de build: canonicaliza los dos lados y después compara. Cada uno de estos desactiva la resolución de entidades externas, porque normalmente los estás apuntando a un payload que no produjiste tú.
// 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.Merece la pena nombrar la división: canonicalización más un diff de líneas te da algo que una persona puede leer, y una comparación de árboles como la de XMLUnit le da a un test algo sobre lo que aseverar con un mensaje de fallo útil.
Preguntas frecuentes
¿Se suben mis dos documentos para compararlos?
No. Los dos editores, el formateador y el algoritmo de diff son JavaScript ejecutándose en esta pestaña, y no hay componente de servidor al que enviar nada.
Por eso existe la herramienta. El payload real es tráfico de producción real: nombres de clientes reales, importes de pedidos reales, a menudo un token de portador en una cabecera SOAP. Varias herramientas posicionadas para esta búsqueda piden los dos archivos por subida. Abre tu panel de red mientras pegas y seguirá vacío.
Los dos documentos se guardan en el localStorage de este navegador para que un refresco no pierda tu trabajo. Eso nunca sale de tu máquina, y Limpiar los elimina los dos al instante.
¿Por qué dice que dos documentos son idénticos cuando claramente no lo son?
Porque compara estructura, no bytes. Los dos lados se reformatean primero a la misma sangría con los atributos ordenados, así que un documento minificado y los mismos datos con sangría de cuatro espacios salen iguales, y también <order id="A-991" total="64.85"> y <order total="64.85" id="A-991">.
XML no considera significativos ni el orden de atributos ni la sangría fuera del contenido mixto, así que un diff que informe de cualquiera de las dos está describiendo el serializador y no los datos. Si necesitas una comparación a nivel de bytes, y para un documento firmado la necesitas, usa un diff de texto plano. El banner sobre el resultado siempre dice que la comparación se hizo tras normalizar la sangría y el orden de los atributos, así que esto nunca es silencioso.
¿Los dos documentos tienen que ser XML válido?
Tienen que estar bien formados, que no es lo mismo que ser válidos. Bien formado significa que la sintaxis es correcta: etiquetas cerradas y bien anidadas, un elemento raíz, caracteres especiales escapados, valores de atributo entrecomillados. Válido significa además cumplir un esquema, y aquí no interviene ningún esquema.
La comparación no puede correr sobre un documento que no se analiza, porque no hay estructura que normalizar. Si algún lado falla, la herramienta se detiene y lo dice, en vez de caer en un diff de texto y darte un resultado que parezca significativo. Eso ya detecta por sí solo una clase real de error: una respuesta truncada aparece como fallo de análisis con línea y columna en lugar de como un muro de rojo.
¿Puede comparar documentos que usan prefijos de espacio de nombres distintos?
Los informará como distintos, y esa es una limitación real. soap:Envelope y s:Envelope vinculados a la misma URI son idénticos para cualquier consumidor consciente de espacios de nombres, pero el prefijo forma parte del nombre del elemento tal como está escrito y el formateador no reescribe prefijos.
Reescribir no es seguro en general: un prefijo puede aparecer dentro de valores de atributo, en un xsi:type, en una expresión XPath dentro de una hoja de estilos, en un QName de un WSDL, donde un formateador no puede verlo. Cuando lo que necesitas es ver más allá de las diferencias de prefijo, usa una comparación canonicalizadora: el ejemplo de XMLUnit de arriba con checkForSimilar lo resuelve, igual que C14N en los ejemplos de shell y Python.
¿Qué tamaño de par de documentos admite?
Hasta 3.000 líneas cada uno, medidas tras el formateo y no como las pegaste, así que un documento minificado que se expande a 8.000 líneas supera el límite aunque llegara en una sola línea.
El tope es deliberado. Un diff de subsecuencia común más larga construye una tabla proporcional al producto de los dos recuentos de líneas, así que dos documentos de 20.000 líneas necesitarían cientos de millones de celdas y bloquearían la pestaña en vez de ser simplemente lentos. Para archivos de ese tamaño, git diff sobre la salida de xmllint --format, o la receta de shell de arriba, hace lo que ninguna pestaña de navegador debería intentar.