Escapar y desescapar XML
Escapa caracteres especiales, o convierte entidades de vuelta a texto.
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 texto a la izquierda y vuelve con cada carácter que XML trata como marcado sustituido por una referencia a entidad. Invierte la dirección y las referencias se decodifican de vuelta a los caracteres que nombran. La entrada es texto plano, no un documento, así que no tiene por qué analizarse: un fragmento, un único valor de atributo o una URL suelta funcionan igual.
Recurres a esto después de que un analizador se haya quejado de un ampersand. "The entity name must immediately follow the '&'" de Xerces, "EntityRef: expecting ';'" de libxml2 y "invalid character in entity name" de Expat son todos el mismo problema: un & suelto en texto o en un atributo, normalmente de una cadena de consulta como ?a=1&b=2 pegada dentro de un elemento.
Lo que aquí es distinto es lo que la herramienta se niega a hacer. XML define cinco entidades con nombre y ninguna más. La mayoría de los escapadores de la web son escapadores de HTML con etiqueta de XML y te entregarán , que ningún analizador XML acepta. Este conoce las cinco y las referencias numéricas en decimal y hexadecimal, deja cualquier otra cosa exactamente como estaba escrita, y no sube nada.
Cinco entidades predefinidas, y ninguna más
La sección 4.6 de XML 1.0 define exactamente cinco entidades con nombre. Ese es el conjunto completo. No hay ninguna tabla de entidades HTML heredada, que es la sorpresa habitual cuando un bloque de HTML se mueve a un archivo de configuración XML o a una description de RSS.
Escribir en un documento sin DTD no es un problema de estilo, es un error de buena formación: el analizador tiene un nombre y nada le dice qué significa ese nombre. Usa   o   en su lugar, y lo mismo para © y é. Una DTD puede declarar nombres adicionales, que es como funciona &companyName; en DocBook, así que la dirección de desescapado deja cualquier nombre que no reconozca exactamente como estaba escrito.
- & para &
- < para <
- > para >
- " para "
- ' para '. HTML 4 nunca definió ', así que los serializadores que alimentan tuberías mixtas suelen emitir ' en su lugar.
Referencias numéricas de carácter
Cualquier carácter legal puede escribirse como su punto de código: é en decimal, é en hexadecimal. Los dos son el mismo carácter, se permiten ceros a la izquierda, y la x debe ir en minúscula, así que A no es una referencia de carácter y se rechaza como entidad no declarada.
La dirección de desescapado admite las dos formas y comprueba que el resultado esté dentro del rango Unicode; una referencia mal formada o fuera de rango se deja tal como estaba escrita en vez de sustituirse por un signo de interrogación. La dirección de escapado nunca emite referencias numéricas: UTF-8 lleva acentos, CJK y emoji tal cual, así que escaparlos cuesta legibilidad y no aporta nada.
Cuándo hay que escapar de verdad cada carácter
Las reglas son más estrechas de lo que casi todo el mundo supone, lo cual importa cuando estás leyendo el documento de otra persona y decidiendo si está roto. El & y el < son obligatorios en todas partes. El > se exige en un solo sitio: la sección 2.4 de XML 1.0 dice que debe escaparse dentro de la secuencia literal ]]> en contenido, cuando eso no esté cerrando una sección CDATA, así que <code>if (a]]>b)</code> es obligatorio y <note>a > b</note> es legal.
Esta herramienta escapa los cinco sin condiciones, un superconjunto de lo que necesita cualquier contexto, así que la salida se puede soltar en cualquier sitio sin tener que llevar la cuenta de en qué contexto estás. No es el escapado mínimo, y ahora ya sabes qué caracteres volver a poner.
- & y <: obligatorios en el contenido de elementos y en los valores de atributo, siempre.
- >: opcional, salvo dentro de la secuencia ]]>.
- ": obligatorio solo dentro de un valor de atributo entre comillas dobles.
- ': obligatorio solo dentro de un valor de atributo entre comillas simples. Ninguna de las dos comillas necesita escaparse en el contenido de elementos.
Los caracteres que no se pueden escapar en absoluto
XML 1.0 define qué caracteres puede contener un documento, la producción Char, y casi todo el rango de control C0 no está en ella: de U+0001 a U+0008, U+000B, U+000C y de U+000E a U+001F. Solo sobreviven el tabulador, el salto de línea y el retorno de carro. U+0000 es ilegal en todas las versiones de XML, y los sustitutos sueltos también lo son.
Así que  no es un escape del carácter ESC. Viola la restricción Legal Character, porque el carácter que nombra no puede aparecer en un documento XML 1.0 por ningún medio, y escribirlo como referencia no lo blanquea. Cuando Python informa de "not well-formed (invalid token)" sobre un archivo de log lleno de secuencias de escape ANSI, es exactamente esto; para bytes arbitrarios la respuesta es base64.
La herramienta es honesta con esto en lugar de lista: al escapar deja pasar el carácter de control tal cual, y al desescapar convierte  en un U+0001 real, porque es lo que dice la referencia. Pasa el resultado por el comprobador de sintaxis antes de devolverlo a un documento.
Hacer esto desde código
Todos los lenguajes traen algo para esto y la mayoría trae lo que no es, porque la función obvia es un escapador de HTML. Estos usan la vía específica de XML en cada ecosistema, y dicen en qué se equivoca cada uno.
// XML defines exactly five named entities. A single regex pass avoids the
// classic bug of replacing & after < and turning < into &lt;.
const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
// Element content: & and < are mandatory, > only inside the sequence ]]>.
function escapeText(s) {
return s.replace(/[&<]/g, (c) => ESCAPES[c]).replace(/]]>/g, ']]>');
}
// Attribute value: escape the delimiter you are using, plus tab, newline and
// carriage return. Attribute-value normalisation turns a literal one of those
// into a plain space, so a reference is the only way to keep it.
function escapeAttribute(s) {
return s
.replace(/[&<"]/g, (c) => ESCAPES[c])
.replace(/\t/g, '	')
.replace(/\n/g, ' ')
.replace(/\r/g, ' ');
}
// The reverse. Note the deliberate absence of an HTML entity table: is
// undefined in XML, so leaving it alone is more honest than decoding it.
const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
function unescapeXml(s) {
return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);/g, (whole, body) => {
if (body[0] === '#') {
const hex = body[1] === 'x';
const cp = parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : whole;
}
return NAMED[body] !== undefined ? NAMED[body] : whole;
});
}from xml.sax.saxutils import escape, quoteattr, unescape
import re
# escape() handles & < > only and knows nothing about quotes. The > is not
# strictly required but it is harmless and covers the ]]> case for free.
text = escape('Terms & conditions <see clause 4>')
# Terms & conditions <see clause 4>
# The two quote entities have to be supplied yourself.
FIVE = {'"': '"', "'": '''}
text = escape(source, FIVE)
# quoteattr() returns the value WITH its delimiters, picking single quotes when
# the value contains a double quote, and turning tab, newline and carriage
# return into numeric references so normalisation cannot flatten them.
attr = quoteattr('say "hello" then stop')
# 'say "hello" then stop' <- single-quoted, so the " needs no escape
# unescape() reverses & < > plus whatever mapping you pass. It does NOT decode
# numeric character references, so é comes back unchanged. html.unescape()
# does decode them, but it also decodes and 250 other HTML names XML
# never defined, which silently rewrites your data. Do it explicitly instead:
def unescape_xml(s: str) -> str:
s = unescape(s, {'"': '"', ''': "'"})
return re.sub(
r'&#(x[0-9a-fA-F]+|[0-9]+);',
lambda m: chr(int(m.group(1)[1:], 16) if m.group(1)[0] == 'x' else int(m.group(1))),
s,
)// The JDK has no public XML escaper for a bare string. Two right answers.
// 1. Let a writer do it. XMLStreamWriter escapes for the context it is
// writing into, the only approach that gets attribute values right.
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamWriter;
XMLStreamWriter w = XMLOutputFactory.newInstance().createXMLStreamWriter(out);
w.writeStartElement("note");
w.writeAttribute("href", "https://example.com/?a=1&b=2"); // escaped for you
w.writeCharacters("Terms & conditions <apply>"); // escaped for you
w.writeEndElement();
w.close();
// 2. Escape a standalone string with commons-text. Do NOT use commons-lang3's
// deprecated StringEscapeUtils.escapeXml, which knew nothing about the
// characters XML cannot represent.
import org.apache.commons.text.StringEscapeUtils;
String safe = StringEscapeUtils.escapeXml10("Terms & conditions <apply>");
// Terms & conditions <apply>
// escapeXml10 does something its name does not advertise: it DELETES the
// characters XML 1.0 cannot hold (U+0001 to U+0008, U+000B, U+000C,
// U+000E to U+001F, unpaired surrogates) rather than escaping them, because
// no escape for them exists. escapeXml11 writes the control characters as
// numeric references instead, which is only legal if the document declares
// version="1.1", and it still drops U+0000 and unpaired surrogates.
String back = StringEscapeUtils.unescapeXml(safe);
// The five predefined entities plus decimal and hex character references.
// Names it does not know are left as written.using System.Linq;
using System.Security;
using System.Xml;
// SecurityElement.Escape does the five predefined entities in one call:
// < > & " ' every time, a superset of what any single context needs.
string safe = SecurityElement.Escape("Terms & conditions <apply>");
// Terms & conditions <apply>
// It does not remove the characters XML cannot carry, so check those yourself.
// XmlConvert.IsXmlChar implements the Char production from XML 1.0. Keep
// surrogates or astral characters (emoji, rarer CJK) are destroyed.
static string StripIllegal(string s) =>
string.Concat(s.Where(c => XmlConvert.IsXmlChar(c) || char.IsSurrogate(c)));
// In practice prefer a writer. It escapes for the context and throws on a
// character the document cannot hold, instead of producing a file that fails
// to parse somewhere downstream.
var settings = new XmlWriterSettings { Indent = true, CheckCharacters = true };
using var writer = XmlWriter.Create(Console.Out, settings);
writer.WriteStartElement("note");
writer.WriteAttributeString("href", "https://example.com/?a=1&b=2");
writer.WriteString("Terms & conditions <apply>");
writer.WriteEndElement();
// There is no framework unescaper, and WebUtility.HtmlDecode is the wrong
// tool: it decodes and the rest of the HTML set. Read a fragment
// instead, which decodes exactly what XML defines and nothing else.
static string Unescape(string escaped)
{
var rs = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
};
using var r = XmlReader.Create(new StringReader("<r>" + escaped + "</r>"), rs);
r.ReadToFollowing("r");
return r.ReadElementContentAsString();
}<?php
// ENT_XML1 is the flag that matters and the one everybody omits. Without it
// you get the HTML entity table: an apostrophe becomes ' (harmless), and
// with ENT_HTML5 you can get names XML will reject outright.
$safe = htmlspecialchars(
$text,
ENT_XML1 | ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
// & < > " ' become & < > " '
//
// ENT_SUBSTITUTE replaces invalid UTF-8 with U+FFFD. Before PHP 8.1 the
// default was to return an empty string on invalid input, which is very easy
// to miss in a feed generator fed by a legacy database.
$back = html_entity_decode($safe, ENT_XML1 | ENT_QUOTES, 'UTF-8');
// With ENT_XML1 this decodes the five predefined entities and numeric
// character references and leaves alone. Drop the flag and it decodes
// into U+00A0, silently rewriting your data.
// Building a document rather than a string: DOMDocument escapes on save and
// rejects characters XML cannot represent, which htmlspecialchars passes
// straight through.
$doc = new DOMDocument('1.0', 'UTF-8');
$note = $doc->createElement('note');
$note->appendChild($doc->createTextNode($text));
$doc->appendChild($note);
echo $doc->saveXML();El patrón en los cinco: el escapador a nivel de cadena es la respuesta cómoda y el writer es la correcta, porque solo el writer sabe si está escribiendo contenido de elemento o un valor de atributo, y solo el writer rechaza los caracteres que no se pueden escapar de ninguna manera.
Preguntas frecuentes
¿Por qué rompe mi XML?
Porque XML nunca lo definió. Las cinco entidades predefinidas son &, <, >, " y ', y esa es la lista completa. Todo lo demás que te da HTML viene de una tabla de entidades que XML deliberadamente no heredó.
Un analizador que se encuentra sin una DTD en ámbito informa de una entidad no declarada: tiene un nombre y nada le dice qué significa. Usa   o   en su lugar. La excepción es un documento cuya DTD declara el nombre, que es por lo que el mismo marcado funciona en XHTML y falla en un archivo de configuración XML normal.
¿Tengo que escapar el signo mayor que?
Casi nunca. XML 1.0 lo exige en una situación: cuando > aparece en la secuencia literal ]]> dentro del contenido de un elemento y no está cerrando una sección CDATA, donde un analizador que busca el final de una sección CDATA se confundiría.
En todos los demás sitios es opcional, incluso dentro de valores de atributo, así que <note>a > b</note> está bien formado tal cual. Esta herramienta lo escapa igualmente para que la salida se pueda pegar en cualquier contexto. Para la forma mínima, vuelve a poner > en todas partes salvo dentro de una secuencia ]]>.
¿Debería usar CDATA en vez de escapar?
CDATA suprime el reconocimiento de < y & como marcado. Eso es todo lo que hace, y es la herramienta adecuada cuando una persona edita el contenido a mano: código fuente incrustado, una expresión XSLT llena de corchetes angulares, SQL mantenido a mano.
Es la herramienta equivocada el resto del tiempo. No puede contener la secuencia ]]> porque eso la termina, lo que la convierte en un vector de inyección real para contenido no confiable. No expande entidades, así que <![CDATA[&]]> produce seis caracteres literales, y no permite caracteres ilegales. La mayoría de serializadores tampoco la conservan como nodo distinto, así que nunca trates el "ser CDATA" como algo significativo.
¿Se envía a algún sitio el texto que pego?
No. Escapar es una sustitución de cadenas ejecutándose como JavaScript en esta pestaña. No hay petición que hacer, así que no hay servidor al que hacérsela.
Aquí eso importa más de lo que parece: esta herramienta se usa sobre la parte del payload que se rompió, lo que en la práctica significa URLs con tokens en la cadena de consulta, cadenas de conexión y texto de error sacado de un log. Abre tu panel de red mientras escribes y seguirá vacío. Tu entrada se guarda en el localStorage de este navegador para que un refresco no la pierda, nunca se transmite, y Limpiar la elimina al instante.
¿Por qué mi & se convirtió en &amp;?
El texto se escapó dos veces, normalmente por un escape manual aplicado a una cadena que un writer ya había escapado, o por una plantilla que escapa a la salida alimentada con un valor escapado a la entrada.
La dirección de desescapado deshace una capa, así que ejecútala una vez para volver a & y otra para volver a &. Si &amp; sigue apareciendo en producción, el error casi siempre es un escape hecho a mano delante de un writer de DOM o de flujo que ya estaba haciendo el trabajo. Escapar en el orden equivocado falla al revés: sustituir < antes que & convierte < en &lt;, y por eso el ejemplo de JavaScript usa una única pasada con expresión regular.