HTMLアンエスケープで数値参照128が€にならない理由とセミコロン無し参照

HTML の文字参照を元の文字に戻す処理(アンエスケープ)を正規表現で書くと、& や <、' のような普通の入力ではまず問題が出ない。ずれるのは、ブラウザが「壊れた参照」をどう読むかが仕様で細かく決まっている部分である。

たとえば € は、Unicode の U+0080(C1 制御文字)ではなく、ブラウザでは €(U+20AC)として表示される。� は NUL ではなく U+FFFD(置換文字)になり、&copy 2026 はセミコロンが無くても © 2026 になる。

この記事では、ハシトシステムの HTMLエスケープ/アンエスケープ の変換関数を、WHATWG HTML の規則を実装した Node.js のライブラリと同じ入力で比べ、どこが違うかを確かめる。最後に、数値文字参照を仕様どおりに戻す関数を作り、全コードポイントで答え合わせをする。

検証環境は Windows 11、Node.js v24.11.1、entities 7.0.0、parse5 8.0.0 である。entities は HTML の文字参照を扱うライブラリで、parse5 は WHATWG HTML のパーサーを JavaScript で実装したものである。

仕様で決まっていること

WHATWG HTML の 13.2.5 節(トークン化)に、文字参照の読み方が書かれている。数値文字参照は numeric character reference end state、名前付き文字参照は named character reference state で扱う。要点は次のとおりである。

入力 ブラウザが返す文字 決まり
� U+FFFD 0 はパースエラーで U+FFFD
� 以上 U+FFFD Unicode の範囲外はパースエラーで U+FFFD
�〜� U+FFFD サロゲートはパースエラーで U+FFFD
€〜Ÿ 表の文字 27個は表で置き換える(0x80 は U+20AC、0x9F は U+0178)
 など表に無い5個 そのまま 0x81 0x8D 0x8F 0x90 0x9D は表に無い
&copy 2026 © 2026 一部の名前はセミコロン無しでも読む
属性値の ?a=1&copy=2 そのまま 属性値の中で、次が = か英数字なら読まない

0x80〜0x9F の表は、WHATWG Encoding Standard の windows-1252 のインデックスと突き合わせると、27個すべてが同じ文字だった。表に無い5個は、windows-1252 のインデックスでも同じ番号の制御文字になっている。Windows-1252 の番号のまま数値文字参照を書いたページでも、意図した文字が出るようにする規則である。

名前付き文字参照の一覧は仕様の entities.json にある。2026-11-13 に取得して数えると2231個あり、そのうち106個がセミコロン無しの形(&amp &copy &lt など)だった。名前は大文字小文字を区別する。& と " は一覧にあるが、&Amp; は無い。

属性値の規則は、URL のクエリで &copy=2 のような書き方が昔から多かったためにある。仕様は理由を「for historical reasons」としている。本文中の &copy=2 は ©=2 になるが、href の中では &copy=2 のまま残る。

ツールの変換関数と比べる

HTMLエスケープ/アンエスケープ は、入力したテキストを文字実体参照に変換するか、その逆に戻すツールである。2026-11-13 に本番の HTML から unescapeHtml を取り出し、entities 7.0.0 の decodeHTML、parse5 8.0.0 で <p> の中身として読んだ結果と並べた。

入力 ツール entities / parse5(本文)
&#128; U+0080 U+20AC(€)
&#x9F; U+009F U+0178(Ÿ)
&#0; U+0000 U+FFFD
&#xD800; U+D800(単独のサロゲート) U+FFFD
&#x110000; &#x110000; のまま U+FFFD
&copy; &copy; のまま ©
&hellip; &hellip; のまま …
&Amp; & &Amp; のまま
&copy 2026 &copy 2026 のまま © 2026
&amp;lt; &lt; &lt;

ツールの関数は、名前付き参照を amp lt gt quot apos nbsp の6個だけ持っている。ほかの名前は残し、名前は小文字にしてから引く。数値参照は String.fromCodePoint にそのまま渡している。違いはこの3点から出ている。

  • C1 の表が無い。 &#128; は U+0080 になる。U+0080 は表示する文字を持たない制御文字なので、€ のつもりで書かれた文字が出てこない。
  • U+FFFD にする規則が無い。 &#0; は NUL、&#xD800; は単独のサロゲートになる。単独のサロゲートは UTF-8 にできないので、TextEncoder を通すと U+FFFD になり、encodeURIComponent に渡すと URIError になる。範囲外の値は参照のまま残る。
  • 大文字小文字を区別しない。 &Amp; を & にする。ブラウザでは &Amp; は文字のまま表示される。

&amp;lt; を &lt; にするのは正しい。正規表現の replace は1回しか走らないので、&amp; を戻した結果の &lt; をもう一度 < にすることはない。2回かけると < になり、エスケープした意味が無くなる。

エスケープ側の escapeHtml は、& を最初に置き換えてから < > " ' を置き換える。順番が正しいので、&lt; を入れると &amp;lt; になる。エスケープ側には問題は見つからなかった。

アンエスケープ側の C1 の表と U+FFFD の扱い、大文字小文字の区別は、修正対象として記録した。入力したテキストはブラウザ内で処理され、サーバーには送られない。

どこまで自作するか

数値文字参照だけなら、規則は上の表で全部なので自作できる。名前付き参照は2231個あり、セミコロン無しの形では「一覧にある最長の名前」を選ぶ規則も加わる。本文の &notit; は &not が一覧にあるので ¬it; になり、属性値の中ではそのまま残る。ここまで正規表現で再現するのは割に合わない。

Node.js では entities の decodeHTML(本文用)と decodeHTMLAttribute(属性値用)を使い分けると、parse5 で読んだ結果と一致した。

入力 decodeHTML decodeHTMLAttribute
?a=1&copy=2 ?a=1©=2 ?a=1&copy=2
?a=1&copy;=2 ?a=1©=2 ?a=1©=2
&notit; ¬it; &notit;

属性値の中でも、セミコロンがあれば読む。&copy;=2 は ©=2 になる。URL を HTML に埋めるときに & を &amp; にしておけば、どちらの読み方でも元の URL に戻る。

JSON の文字列に入った \uD83D のような単独のサロゲートの扱いは サロゲートペアのエスケープの記事 に、JSON 側のエスケープは JSON 文字列のエスケープの記事 に書いた。

実際に試す

前提: Node.js(確認は v24.11.1)。1つ目のスクリプトは外部パッケージ不要である。

数値文字参照を、仕様の numeric character reference end state と同じ規則で文字に戻す。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// numref.mjs(Node.js v24.11.1 で確認・外部パッケージ不要)
// 使い方: node numref.mjs
// HTML の数値文字参照(&#...; / &#x...;)を WHATWG HTML の
// numeric character reference end state と同じ規則で文字に戻す
const C1 = {
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,
};

export function numericRef(digits, hex) {
// 桁が長くても BigInt で比べるので、大きすぎる値も取りこぼさない
const n = BigInt(hex ? '0x' + digits : digits);
if (n === 0n || n > 0x10FFFFn) return '�';
const code = Number(n);
if (code >= 0xD800 && code <= 0xDFFF) return '�';
if (C1[code]) return String.fromCodePoint(C1[code]);
return String.fromCodePoint(code);
}

export function decodeNumericRefs(str) {
// セミコロンが無くても数字が終わったところで参照は終わる(パースエラーだが解釈される)
return str.replace(/&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));?/g,
(_, h, d) => numericRef(h ?? d, h !== undefined));
}

import { fileURLToPath } from 'node:url';
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const hex = (s) => [...s].map(c => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' ');
for (const s of ['&#128;', '&#x80;', '&#x81;', '&#x9F;', '&#0;', '&#xD800;', '&#x110000;', '&#99999999999;', '&#39;', '&#x1F600;', '&#169 2026']) {
console.log(s.padEnd(16), hex(decodeNumericRefs(s)));
}
}

実行結果は次のとおりである。

1
2
3
4
5
6
7
8
9
10
11
12
$ node numref.mjs
&#128; U+20AC
&#x80; U+20AC
&#x81; U+0081
&#x9F; U+0178
&#0; U+FFFD
&#xD800; U+FFFD
&#x110000; U+FFFD
&#99999999999; U+FFFD
&#39; U+0027
&#x1F600; U+1F600
&#169 2026 U+00A9 U+0020 U+0032 U+0030 U+0032 U+0036

&#x81; は表に無いので U+0081 のまま返る。&#169 2026 はセミコロンが無いが、数字が終わった位置で参照が終わり、© と 2026 になる。

この関数が entities と同じ結果を返すかを、0 から 0x110001 までの全コードポイントについて、10進と16進の両方で確かめる。空のディレクトリで npm i entities@7.0.0 を実行し、上の numref.mjs を同じ場所に置いてから次を実行する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// verify.mjs
import { decodeHTML } from 'entities';
import { decodeNumericRefs } from './numref.mjs';
let diff = 0, total = 0;
for (let n = 0; n <= 0x110001; n++) {
for (const ref of ['&#' + n + ';', '&#x' + n.toString(16) + ';']) {
total++;
if (decodeNumericRefs(ref) !== decodeHTML(ref)) { if (diff++ < 5) console.log('diff', ref); }
}
}
for (const ref of ['&#99999999999;', '&#169 2026', '&#x41x']) {
total++;
if (decodeNumericRefs(ref) !== decodeHTML(ref)) { diff++; console.log('diff', ref); }
}
console.log('checked', total, 'diff', diff);
1
2
$ node verify.mjs
checked 2228231 diff 0

222万8231通りで差は0だった。名前付き参照も扱う必要があるなら、自作の表を増やすより entities の decodeHTML と decodeHTMLAttribute を使い、本文と属性値で使い分ける。

まとめ

  • &#128;〜&#159; のうち27個は、ブラウザでは Windows-1252 の文字に置き換わる。&#128; は U+0080 ではなく € になる。
  • &#0;、サロゲート(&#xD800;〜&#xDFFF;)、&#x110000; 以上は U+FFFD になる。String.fromCodePoint にそのまま渡すと NUL や単独のサロゲートができる。
  • 名前付き参照は2231個あり、大文字小文字を区別する。106個はセミコロン無しでも読まれる。
  • 属性値の中では、セミコロンが無く次が = か英数字の参照は読まない。本文用と属性値用でデコードを分ける。
  • アンエスケープは1回だけかける。&amp;lt; は &lt; に戻るのが正しい。

結果は 2026-11-13 時点の上記の環境とバージョンで得たものである。

参考

  • WHATWG HTML Living Standard — 13.2.5 Tokenization(Character reference state、Named character reference state、Numeric character reference end state)、13.5 Named character references
  • WHATWG HTML — entities.json(名前付き文字参照の一覧)
  • entities 7.0.0(decodeHTML / decodeHTMLAttribute)、parse5 8.0.0 のソースと実行結果
  • WHATWG Encoding Standard — windows-1252 のインデックス

HTMLアンエスケープで数値参照128が€にならない理由とセミコロン無し参照
https://blog.hashito.biz/2026/11/13/html-unescape-numeric-ref-128-euro-c1-fffd-named-semicolon/
著者
hashito
作成日
2026年11月13日
著作権

このバージョンはその範囲に入るのか

依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。