1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

– をアンエスケープしたら、ブラウザは – にするのに自作ツールは見えない制御文字にした

1
Posted at

背景

自分のツールサイトに、HTML のエスケープとアンエスケープをするページを置いています。
https://hashitosystem.com/tools/htmlescape/
エスケープは & < > " ' を &amp; などに置き換えるだけで、こちらは素直です。
気になったのは逆向きのアンエスケープでした。
ツールの結果がブラウザと同じになっているかを、ブラウザの HTML パーサーと同じ入力で突き合わせてみたら、&#150; がブラウザでは「–」(エンダッシュ)なのに、ツールでは何も見えない文字になりました。
同じ &#150; なのに結果が違う…。
調べたら、HTML の仕様に読み替えの表がありました。

数値文字参照とは

HTML では、文字を &#{10進の番号}; か &#x{16進の番号}; の形でも書ける。
これが数値文字参照で、番号は Unicode のコードポイント(文字に振られた通し番号)だ。
例えば &#39; と &#x27; はどちらも 39番='(シングルクォート)になります。
&amp; のように名前で書くものは文字実体参照(名前付き文字参照)と呼ばれる。

ツールの数値文字参照の処理は、下記のように番号を 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; }
  }
  // ここから下は &amp; などの名前付きの処理(省略)
});

str はテキスト欄に貼られた文字列そのものです。
番号を数値にして、その番号の文字を返す、というだけの作りだ。

ブラウザと突き合わせる

ブラウザの結果は、DOMParser(HTML 文字列を実際にブラウザのパーサーで解析する標準 API)で取りました。
下記をブラウザの開発者ツールのコンソールで実行しています。
<p> の中に参照を1つ入れて解析し、出てきた文字のコードポイントを表示する。

for (const c of ['&#128;', '&#150;', '&#0;', '&#xD800;', '&#X27;']) {
  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 で同じ入力にかけました(公開中のツール画面でも同じ結果になることを確認しています)。
並べると下記です。

&#128;    ブラウザ U+20AC (€)    ツール U+0080(制御文字)
&#150;    ブラウザ U+2013 (–)    ツール U+0096(制御文字)
&#0;      ブラウザ U+FFFD (�)    ツール U+0000(NUL)
&#xD800;  ブラウザ U+FFFD (�)    ツール U+D800(壊れたサロゲート)
&#X27;    ブラウザ U+0027 (')    ツール &#X27; のまま

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 には &#150; や &#153; のような書き方が残っているので、ブラウザはこれを 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つめは &#X27; で、正規表現が小文字の x しか拾っていなかっただけです。
中では大文字の X も判定しているのに、手前の正規表現で弾いていた(完全に私のミスです…)。

直した関数

仕様の規則を入れた版が下記です。
unescapeNumeric は文字列を受け取り、中の数値文字参照だけを文字に戻して返す。
名前付きの &amp; などは扱わないので、名前付きの処理の前か後に通す想定です。

// 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件でした。
ほかの入力も試すと下記のようになりました。

&#128;      U+20AC
&#0;        U+FFFD
&#xD800;    U+FFFD
&#x110000;  U+FFFD
&#X27;      U+0027
&#39;       U+0027
&#x1F600;   U+1F600

絵文字(&#x1F600;)のような普通の番号は、これまでどおりそのまま戻ります。

なぜ DOMParser にしないのか

ブラウザの中だけで動くなら、DOMParser に入れて textContent を読み出すのがいちばん確実だと思います。
規則を自分で書かなくても、ブラウザと完全に同じになるからです。
ただ、同じ処理を DOM の無い Node.js でも動かせるようにしておきたかったので、DOM に頼らない形で書きました。
その代わり、仕様の表を自分で持つことになるので、ブラウザとの突き合わせを残しておくのが大事な気がします。
ツールのほうも、この関数に差し替えるつもりです。

結び

数値文字参照を自前で文字に戻すときは、番号を String.fromCodePoint に渡すだけで終わらせず、0x80〜0x9F の27個の読み替えと、0・サロゲート・範囲外の U+FFFD 置換を入れてください。
これが無いと、&#150; や &#153; が、ブラウザでは – や ™ なのに、自前の処理では見えない制御文字になります。


本記事はAI補助で執筆した、個人開発の紹介記事です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?