Échapper et déséchapper du XML

Échappez les caractères spéciaux, ou décodez les entités.

Gère les cinq entités prédéfinies et les références numériques en décimal et en hexadécimal.
Entrée
Sortie
En attenteCollez un document pour le vérifier. La validation se fait à la frappe.

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 du texte à gauche et il revient avec chaque caractère que XML traite comme du balisage remplacé par une référence d’entité. Inversez le sens et les références sont décodées vers les caractères qu’elles nomment. L’entrée est du texte brut et non un document : elle n’a donc pas besoin d’être analysable. Un fragment, une seule valeur d’attribut ou une URL isolée fonctionnent tous.

On y vient après qu’un analyseur s’est plaint d’une esperluette. « The entity name must immediately follow the '&' » chez Xerces, « EntityRef: expecting ';' » chez libxml2 et « invalid character in entity name » chez Expat désignent le même problème : un & brut dans du texte ou dans un attribut, généralement une chaîne de requête comme ?a=1&b=2 collée dans un élément.

Ce qui change ici, c’est ce que l’outil refuse de faire. XML définit cinq entités nommées et pas une de plus. La plupart des échappeurs du web sont des échappeurs HTML étiquetés XML et vous rendront  , qu’aucun analyseur XML n’accepte. Celui-ci connaît les cinq et les références numériques en décimal et en hexadécimal, laisse tout le reste exactement tel quel, et n’envoie rien.

Cinq entités prédéfinies, et pas davantage

La section 4.6 de XML 1.0 définit exactement cinq entités nommées. C’est tout l’ensemble. Il n’y a pas de table d’entités HTML héritée, ce qui est la surprise habituelle quand un bloc de HTML est déplacé dans un fichier de configuration XML ou dans une description RSS.

Écrire   dans un document sans DTD n’est pas un problème de style, c’est une erreur de bonne formation : l’analyseur a un nom et rien ne lui dit ce que ce nom signifie. Utilisez   ou   à la place, et de même pour © et é. Une DTD peut déclarer des noms supplémentaires, c’est ainsi que &companyName; fonctionne dans DocBook, et le sens déséchappement laisse donc intact tout nom qu’il ne reconnaît pas.

  • & pour &
  • &lt; pour <
  • &gt; pour >
  • &quot; pour "
  • &apos; pour '. HTML 4 n’a jamais défini &apos;, aussi les sérialiseurs alimentant des chaînes mixtes émettent-ils souvent &#39; à la place.

Références numériques de caractère

Tout caractère légal peut s’écrire par son point de code : &#233; en décimal, &#xE9; en hexadécimal. Les deux désignent le même caractère, les zéros en tête sont permis, et le x doit être en minuscule : &#X41; n’est donc pas une référence de caractère et est rejeté comme entité non déclarée.

Le sens déséchappement gère les deux formes et vérifie que le résultat est dans la plage Unicode ; une référence malformée ou hors plage est laissée telle quelle plutôt que remplacée par un point d’interrogation. Le sens échappement n’émet jamais de référence numérique : UTF-8 transporte les accents, les caractères CJK et les emoji tels quels, les échapper coûte en lisibilité et n’apporte rien.

Quand chaque caractère doit vraiment être échappé

Les règles sont plus étroites qu’on ne le suppose, ce qui compte quand vous lisez le document de quelqu’un d’autre et décidez s’il est cassé. Le & et le < sont obligatoires partout. Le > n’est exigé qu’à un seul endroit : la section 2.4 de XML 1.0 dit qu’il doit être échappé à l’intérieur de la séquence littérale ]]> dans le contenu, lorsque celle-ci ne ferme pas une section CDATA. Ainsi <code>if (a]]&gt;b)</code> est obligatoire et <note>a > b</note> est légal.

Cet outil échappe les cinq sans condition, un sur-ensemble de ce qu’exige n’importe quel contexte : la sortie peut donc être déposée n’importe où sans avoir à suivre le contexte où vous êtes. Ce n’est pas l’échappement minimal, et vous savez maintenant quels caractères remettre.

  • & et < : obligatoires dans le contenu d’élément et dans les valeurs d’attribut, toujours.
  • > : facultatif, sauf à l’intérieur de la séquence ]]>.
  • " : obligatoire uniquement dans une valeur d’attribut entre guillemets doubles.
  • ' : obligatoire uniquement dans une valeur d’attribut entre apostrophes. Aucun des deux n’a besoin d’être échappé dans le contenu d’élément.

Les caractères qu’on ne peut pas échapper du tout

XML 1.0 définit les caractères qu’un document peut contenir, la production Char, et l’essentiel de la plage de contrôle C0 n’y figure pas : U+0001 à U+0008, U+000B, U+000C et U+000E à U+001F. Seuls la tabulation, le saut de ligne et le retour chariot survivent. U+0000 est illégal dans toutes les versions de XML, et les substituts isolés le sont aussi.

Donc &#x1B; n’est pas un échappement du caractère ESC. Il viole la contrainte Legal Character, parce que le caractère qu’il nomme ne peut apparaître dans un document XML 1.0 par aucun moyen, et l’écrire sous forme de référence ne le blanchit pas. Quand Python signale « not well-formed (invalid token) » sur un fichier de journal plein de séquences d’échappement ANSI, c’est exactement cela ; pour des octets arbitraires, la réponse est base64.

L’outil est honnête là-dessus plutôt qu’astucieux : l’échappement laisse passer un caractère de contrôle tel quel, et le déséchappement transforme &#1; en un véritable U+0001, parce que c’est ce que dit la référence. Passez le résultat par le vérificateur de syntaxe avant de le remettre dans un document.

Le faire en code

Chaque langage livre quelque chose pour cela, et la plupart livrent la mauvaise chose, parce que la fonction évidente est un échappeur HTML. Ces exemples utilisent la voie spécifique à XML dans chaque écosystème, et disent ce que chacun rate.

// XML defines exactly five named entities. A single regex pass avoids the
// classic bug of replacing & after < and turning &lt; into &amp;lt;.
const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };

// Element content: & and < are mandatory, > only inside the sequence ]]>.
function escapeText(s) {
  return s.replace(/[&<]/g, (c) => ESCAPES[c]).replace(/]]>/g, ']]&gt;');
}

// 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, '&#9;')
    .replace(/\n/g, '&#10;')
    .replace(/\r/g, '&#13;');
}

// The reverse. Note the deliberate absence of an HTML entity table: &nbsp; 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 &amp; conditions &lt;see clause 4&gt;

# The two quote entities have to be supplied yourself.
FIVE = {'"': '&quot;', "'": '&apos;'}
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 &#233; comes back unchanged. html.unescape()
# does decode them, but it also decodes &nbsp; 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, {'&quot;': '"', '&apos;': "'"})
    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 &amp; conditions &lt;apply&gt;

// 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 &amp; conditions &lt;apply&gt;

// 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 &nbsp; 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 &#039; (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 &amp; &lt; &gt; &quot; &apos;
//
// 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 &nbsp; alone. Drop the flag and it decodes
// &nbsp; 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();

Le motif commun aux cinq : l’échappeur au niveau de la chaîne est la réponse commode, et le writer est la réponse correcte, car seul le writer sait s’il écrit du contenu d’élément ou une valeur d’attribut, et seul le writer refuse les caractères qu’on ne peut pas échapper du tout.

Questions fréquentes

Pourquoi &nbsp; casse-t-il mon XML ?

Parce que XML ne l’a jamais défini. Les cinq entités prédéfinies sont &amp;, &lt;, &gt;, &quot; et &apos;, et c’est la liste complète. Tout le reste que vous donne HTML vient d’une table d’entités que XML n’a délibérément pas héritée.

Un analyseur qui rencontre &nbsp; sans DTD dans la portée signale une entité non déclarée : il a un nom et rien ne lui dit ce que ce nom veut dire. Utilisez &#160; ou &#xA0; à la place. L’exception est un document dont la DTD déclare le nom, d’où le fait que le même balisage marche en XHTML et échoue dans un simple fichier de configuration XML.

Dois-je échapper le signe supérieur ?

Presque jamais. XML 1.0 l’exige dans une seule situation : quand > apparaît dans la séquence littérale ]]> à l’intérieur du contenu d’un élément sans fermer une section CDATA, cas où un analyseur cherchant la fin d’une section CDATA serait autrement induit en erreur.

Partout ailleurs c’est facultatif, y compris dans les valeurs d’attribut : <note>a > b</note> est donc bien formé tel quel. Cet outil l’échappe quand même pour que la sortie soit sûre à coller dans n’importe quel contexte. Pour la forme minimale, remettez > partout sauf dans une séquence ]]>.

Vaut-il mieux utiliser CDATA que d’échapper ?

CDATA supprime la reconnaissance de < et & comme balisage. C’est tout ce qu’il fait, et c’est le bon outil quand un humain édite le contenu à la main : code source intégré, expression XSLT pleine de chevrons, SQL maintenu à la main.

C’est le mauvais outil le reste du temps. Il ne peut pas contenir la séquence ]]>, qui le termine, ce qui en fait un vrai vecteur d’injection pour du contenu non fiable. Il n’expanse pas les entités, donc <![CDATA[&amp;]]> donne six caractères littéraux, et il n’autorise pas les caractères illégaux. La plupart des sérialiseurs ne le conservent pas non plus comme nœud distinct : ne traitez donc jamais le fait d’« être du CDATA » comme porteur de sens.

Le texte que je colle est-il envoyé quelque part ?

Non. L’échappement est un remplacement de chaîne exécuté en JavaScript dans cet onglet. Il n’y a pas de requête à faire, donc pas de serveur à qui la faire.

Cela compte ici plus qu’il n’y paraît : cet outil sert sur la partie d’une charge utile qui a cassé, c’est-à-dire en pratique des URL avec des jetons dans la chaîne de requête, des chaînes de connexion et du texte d’erreur tiré d’un journal. Ouvrez votre panneau réseau pendant que vous tapez : il restera vide. Votre saisie est conservée dans le localStorage de ce navigateur pour qu’un rafraîchissement ne la perde pas, n’est jamais transmise, et Effacer la supprime immédiatement.

Pourquoi mon &amp; est-il devenu &amp;amp; ?

Le texte a été échappé deux fois, en général par un échappement manuel appliqué à une chaîne qu’un writer avait déjà échappée, ou par un gabarit qui échappe à la sortie et reçoit une valeur échappée à l’entrée.

Le sens déséchappement défait une couche : lancez-le une fois pour revenir à &amp; et une seconde pour revenir à &. Si &amp;amp; ne cesse d’apparaître en production, le bug est presque toujours un échappement maison placé devant un writer DOM ou flux qui faisait déjà le travail. Échapper dans le mauvais ordre échoue dans l’autre sens : remplacer < avant & transforme &lt; en &amp;lt;, et c’est pourquoi l’exemple JavaScript fait une passe unique avec une expression régulière.

Outils associés

Pour aller plus loin

Erreurs que cet outil résout