背景
自分のツールサイトに、HTML のエスケープとアンエスケープをするページを置いています。
https://hashitosystem.com/tools/htmlescape/
エスケープは & < > " ' を & などに置き換えるだけで、こちらは素直です。
気になったのは逆向きのアンエスケープでした。
ツールの結果がブラウザと同じになっているかを、ブラウザの HTML パーサーと同じ入力で突き合わせてみたら、– がブラウザでは「–」(エンダッシュ)なのに、ツールでは何も見えない文字になりました。
同じ – なのに結果が違う…。
調べたら、HTML の仕様に読み替えの表がありました。
数値文字参照とは
HTML では、文字を &#{10進の番号}; か &#x{16進の番号}; の形でも書ける。
これが数値文字参照で、番号は Unicode のコードポイント(文字に振られた通し番号)だ。
例えば ' と ' はどちらも 39番='(シングルクォート)になります。
& のように名前で書くものは文字実体参照(名前付き文字参照)と呼ばれる。
ツールの数値文字参照の処理は、下記のように番号を String.fromCodePoint に渡すだけでした。
str.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, function (match, entity) {
if (entity.charAt(0) === '#') {
var code = (entity.charAt(1) === 'x' || entity.charAt(1) === 'X')
? parseInt(entity.slice(2), 16)
: parseInt(entity.slice(1), 10);
if (isNaN(code) || code < 0 || code > 0x10FFFF) { return match; }
try { return String.fromCodePoint(code); } catch (e) { return match; }
}
// ここから下は & などの名前付きの処理(省略)
});
str はテキスト欄に貼られた文字列そのものです。
番号を数値にして、その番号の文字を返す、というだけの作りだ。
ブラウザと突き合わせる
ブラウザの結果は、DOMParser(HTML 文字列を実際にブラウザのパーサーで解析する標準 API)で取りました。
下記をブラウザの開発者ツールのコンソールで実行しています。
<p> の中に参照を1つ入れて解析し、出てきた文字のコードポイントを表示する。
for (const c of ['€', '–', '�', '�', ''']) {
const doc = new DOMParser().parseFromString('<p>' + c + '</p>', 'text/html');
const s = doc.querySelector('p').textContent;
console.log(c, [...s].map((ch) => 'U+' + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' '));
}
ツール側は、上の関数を Node.js 24 で同じ入力にかけました(公開中のツール画面でも同じ結果になることを確認しています)。
並べると下記です。
€ ブラウザ U+20AC (€) ツール U+0080(制御文字)
– ブラウザ U+2013 (–) ツール U+0096(制御文字)
� ブラウザ U+FFFD (�) ツール U+0000(NUL)
� ブラウザ U+FFFD (�) ツール U+D800(壊れたサロゲート)
' ブラウザ U+0027 (') ツール ' のまま
5つとも違いました。
違いの理由
HTML の仕様(WHATWG HTML Standard)の「numeric character reference end state」に、番号を文字にするときの規則が書いてある。
https://html.spec.whatwg.org/multipage/parsing.html#numeric-character-reference-end-state
1つめは 0x80〜0x9F の読み替えだ。
Unicode ではこの範囲は C1 制御文字という、画面に出ない文字になっている。
ところが昔の Windows で使われていた文字コード Windows-1252 では、同じ番号に € や ™ や “ ” が入っていた。
その時代に書かれた HTML には – や ™ のような書き方が残っているので、ブラウザはこれを Windows-1252 の文字として読む、と仕様で決めている。
ブラウザで 0x80〜0x9F の32個を全部試したら、読み替えられるのは27個で、0x81・0x8D・0x8F・0x90・0x9D の5個だけはそのままでした(Windows-1252 でも文字が割り当てられていない5つです)。
2つめは置換文字。
0番、サロゲート(UTF-16 で絵文字などを2つに分けて表すための 0xD800〜0xDFFF。単独では文字にならない)、0x10FFFF より大きい番号は、U+FFFD(文字化けのときに出る菱形の ?)にする。
ツールは 0x10FFFF 超を「変換しない」で残していたので、ここも違っていました。
3つめは ' で、正規表現が小文字の x しか拾っていなかっただけです。
中では大文字の X も判定しているのに、手前の正規表現で弾いていた(完全に私のミスです…)。
直した関数
仕様の規則を入れた版が下記です。
unescapeNumeric は文字列を受け取り、中の数値文字参照だけを文字に戻して返す。
名前付きの & などは扱わないので、名前付きの処理の前か後に通す想定です。
// 0x80〜0x9F のうち、ブラウザが Windows-1252 の文字に読み替える27個
const C1_REMAP = {
0x80: 0x20AC, 0x82: 0x201A, 0x83: 0x0192, 0x84: 0x201E, 0x85: 0x2026, 0x86: 0x2020,
0x87: 0x2021, 0x88: 0x02C6, 0x89: 0x2030, 0x8A: 0x0160, 0x8B: 0x2039, 0x8C: 0x0152,
0x8E: 0x017D, 0x91: 0x2018, 0x92: 0x2019, 0x93: 0x201C, 0x94: 0x201D, 0x95: 0x2022,
0x96: 0x2013, 0x97: 0x2014, 0x98: 0x02DC, 0x99: 0x2122, 0x9A: 0x0161, 0x9B: 0x203A,
0x9C: 0x0153, 0x9E: 0x017E, 0x9F: 0x0178
};
function decodeNumericRef(code) {
if (code === 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) {
return '�';
}
if (C1_REMAP[code] !== undefined) {
return String.fromCodePoint(C1_REMAP[code]);
}
return String.fromCodePoint(code);
}
function unescapeNumeric(str) {
return str.replace(/&#([xX][0-9a-fA-F]+|[0-9]+);/g, (match, body) => {
const hex = body[0] === 'x' || body[0] === 'X';
const code = hex ? parseInt(body.slice(1), 16) : parseInt(body, 10);
return decodeNumericRef(code);
});
}
これを Node.js 24 で動かし、0x80〜0x9F の32個をブラウザの結果と1つずつ比べたところ、不一致は0件でした。
ほかの入力も試すと下記のようになりました。
€ U+20AC
� U+FFFD
� U+FFFD
� U+FFFD
' U+0027
' U+0027
😀 U+1F600
絵文字(😀)のような普通の番号は、これまでどおりそのまま戻ります。
なぜ DOMParser にしないのか
ブラウザの中だけで動くなら、DOMParser に入れて textContent を読み出すのがいちばん確実だと思います。
規則を自分で書かなくても、ブラウザと完全に同じになるからです。
ただ、同じ処理を DOM の無い Node.js でも動かせるようにしておきたかったので、DOM に頼らない形で書きました。
その代わり、仕様の表を自分で持つことになるので、ブラウザとの突き合わせを残しておくのが大事な気がします。
ツールのほうも、この関数に差し替えるつもりです。
結び
数値文字参照を自前で文字に戻すときは、番号を String.fromCodePoint に渡すだけで終わらせず、0x80〜0x9F の27個の読み替えと、0・サロゲート・範囲外の U+FFFD 置換を入れてください。
これが無いと、– や ™ が、ブラウザでは – や ™ なのに、自前の処理では見えない制御文字になります。
本記事はAI補助で執筆した、個人開発の紹介記事です。