文字エンコーディングを変換するとき、対応していない文字を数値実体参照に変換する


## 前書き
[前書き]:#%E5%89%8D%E6%9B%B8%E3%81%8D

昔、以下の質問をしました。

[PHPでShift-JISにある文字かどうか判定する方法を教えて下さい... - Yahoo!知恵袋][質問]

この質問で聞きたかった事は、UTF-8(Unicode)をShift_JISに変換するとき、UnicodeにはあるがShift_JISには無い文字をHTMLの[数値文字参照]に変換したいというものでした。
要は、以下の様なことがしたかったわけです。

```text:before(UTF-8)
神と神
艗神社
御鎽神社
ホッケの漢字は𩸽
```

```text:after(Shift_JIS&#32;+&#32;数値文字参照&#32;10進数指定)
神と&#64025;
&#33367;神社
御&#37821;神社
ホッケの漢字は&#171581;
```

```text:after(Shift_JIS&#32;+&#32;数値文字参照&#32;16進数指定)
神と&#xFA19;
&#x8257;神社
御&#x93BD;神社
ホッケの漢字は&#x29E3D;
```

参考: [神名地名難読漢字・ユニコード対照表](http://nire.main.jp/rouman/dic/hsgaiji.htm)

この投稿では、この処理を実現する方法について紹介します。

## 前提知識:対応していない文字とは?
[前提知識]:#%E5%89%8D%E6%8F%90%E7%9F%A5%E8%AD%98%E5%AF%BE%E5%BF%9C%E3%81%97%E3%81%A6%E3%81%84%E3%81%AA%E3%81%84%E6%96%87%E5%AD%97%E3%81%A8%E3%81%AF

世界には様々な文字が存在し、それをコンピュータで扱う場合は対応する数値に変換する必要があります。
この、数値に変換するときのルールがいわゆる[文字コード]で、この投稿でいうところの「文字エンコーディング」です。

このルールには様々な方式があります。例えばUTF-8やShift_JIS、EUC-JP、JIS(ISO-2022-JP)など。
これ以外にも、Big5、GBK、US-ASCIIなど、様々です。

このルールは、Unicode(後述)が登場するまでは国ごとにバラバラで、自国の文字を表示するために新しいルールを作ったり、既存のルールを拡張したりしていました。
そしてこのルールを作る時、データ量を減らすため、また文字の処理を容易にするために対応する文字の数を制限することになります。
特に既存のルールを拡張する場合、使用できる数値の数が少ない影響で対応する文字の数を制限する必要がありました。

この結果、Shift_JISでは難読漢字や他国の文字が扱えない、などのような「対応していない文字」が発生することになってしまったわけです。
Shift_JISで書かれたテキストファイルには、[インドの文字][ブラーフミー系文字]は書けません。
ただし、最初のコンピュータが英数字をベースとして動いていた関係から、どのルールでも[ASCII]に該当する半角英数字は扱えるようになっています。

Unicode(ユニコード)はこれを解決するために作られた新しいルールで、世界中のすべての文字を同じルールで扱えるようになっています。
(なお、UTF-8はこのUnicodeを表現する方式の1つです)
そして、HTMLの[数値文字参照]は、このUnicodeの番号を[ASCII]にある文字だけで指定することで、それに対応する文字が表示されるよう決められています[^文字参照の番号のルール]。

[^文字参照の番号のルール]: 正確にはUnicodeではなく、UCS([ISO/IEC 10646])の番号です。ただ、UCSはUnicodeとおおむね互換性があるため、区別する必要はあまり無いでしょう。

例えば、以下の文字列をShift_JISのHTML内で書いたとします。
すると、そのHTMLの文字エンコーディングがShift_JISであるにも関わらず、ブラウザが対応している場合、Unicodeにしか存在しない[うんこの絵文字][うんこマーク]が表示されるというわけです。

```html
&#x1F4A9;
```

この文字列は全て[ASCII]にある文字だけで記述されています。
そして、[ASCII]にある文字は全てのルールで対応しています。
これを利用することで、「対応していない文字」を全てのルールで利用できるというわけです。

ちなみに、Webブラウザがフォームから文字列を送信する場合も、これと同じことが行われています。
Shift_JISのHTMLページからUnicodeの文字を送信すると、サーバには該当の文字が[数値文字参照]に変換されて届きます。

[HTML Standard](https://html.spec.whatwg.org/#url-encoded-form-data)

<blockquote cite="https://html.spec.whatwg.org/#url-encoded-form-data"><p>For each character in the entry's name and value that cannot be expressed using the
     selected character encoding, replace the character by a string consisting of a U+0026 AMPERSAND
     character (&amp;), a U+0023 NUMBER SIGN character (#), one or more <a href="https://html.spec.whatwg.org/#ascii-digits">ASCII digits</a>
     representing the Unicode code point of the character in base ten, and finally a U+003B
     SEMICOLON character (;).</p></blockquote>

古いCGIの掲示板などでは、送信された[数値文字参照]がHTMLエスケープされ、[数値文字参照]がそのまま表示される文字化け?を起こしていたりします。

## 方法1:変換に失敗した文字を1つずつ数値文字参照に変換する
[方法1]:#%E6%96%B9%E6%B3%951%E5%A4%89%E6%8F%9B%E3%81%AB%E5%A4%B1%E6%95%97%E3%81%97%E3%81%9F%E6%96%87%E5%AD%97%E3%82%921%E3%81%A4%E3%81%9A%E3%81%A4%E6%95%B0%E5%80%A4%E6%96%87%E5%AD%97%E5%8F%82%E7%85%A7%E3%81%AB%E5%A4%89%E6%8F%9B%E3%81%99%E3%82%8B

[前書き]でリンクした[質問]にて、解答にあった方法です。
コードは以下になります[^質問のコードについて]。

[^質問のコードについて]: 元のコードと中核処理は同じですが、一部改変を行っています。元のコードは[質問]、または[Pastebinの投稿](https://pastebin.com/PUZLEn1t)を参照して下さい。

```php
$str_utf8 = '神と神?';

$str_sjis = utf8_to_escaped_sjis($str_utf8);
// $str_sjis: "神と&#64025;?"

function utf8_to_escaped_sjis($str_utf8) {
	$char_utf8_list = preg_split('//u', $str_utf8, -1, PREG_SPLIT_NO_EMPTY);
	$char_sjis_list = $char_utf8_list;
	mb_convert_variables('SJIS', 'UTF-8', $char_sjis_list);
	$char_sjis_list_escaped = array_map(function($char_sjis, $char_utf8){
		if ($char_sjis === '?' && $char_utf8 !== '?') {
			return mb_convert_encoding($char_utf8, 'HTML-ENTITIES', 'UTF-8');
		} else {
			return $char_sjis;
		}
	}, $char_sjis_list, $char_utf8_list);
	$str_sjis_escaped = implode('', $char_sjis_list_escaped);
	return $str_sjis_escaped;
}
```

PHPで文字エンコーディングを変換する関数[mb_convert_encoding]や[mb_convert_variables]は、変換できない文字を`?`に置換します[^変換できない文字を?に置換]。
このため、予め入力文字列を1文字ずつの配列に変換し、文字エンコーディングを変換対象のものに変換した後で、`?`を対応の文字の[数値文字参照]に変換します。

[^変換できない文字を?に置換]: `mb_substitute_character`で指定値を`63`以外のものに変えていた場合はこの限りではありません。

…文章では分かりにくいので、順を追って処理内容を説明すると:

```php
$str_utf8 = '神と神?';

/**
 * preg_split関数を利用し、1文字ずつに分割した配列に変換します。
 * 本来はstr_split関数で処理するべきものですが、str_split関数はマルチバイト文字に非対応です。
 */
$char_utf8_list = preg_split('//u', $str_utf8, -1, PREG_SPLIT_NO_EMPTY);
// $char_utf8_list: ['神', 'と', '神', '?']

$char_sjis_list = $char_utf8_list;

/**
 * UTF-8の文字列の配列を、Shift_JISに変換します。
 * mb_convert_variables関数は、引数に指定された配列の値の文字に対しエンコーディング変換を行う関数です。
 */
mb_convert_variables('SJIS', 'UTF-8', $char_sjis_list);
// $char_utf8_list: ['神', 'と', '神', '?']
// $char_sjis_list: ['神', 'と', '?', '?']

/**
 * Shift_JISに変換された文字で"?"のものを、対応するUTF-8文字を数値文字参照に変換したものに置き換える。
 * ただし、元のUTF-8においても"?"だったものは置き換えない。
 */
$char_sjis_list_escaped = array_map(function($char_sjis, $char_utf8){
	if ($char_sjis === '?' && $char_utf8 !== '?') {
		return mb_convert_encoding($char_utf8, 'HTML-ENTITIES', 'UTF-8');
	} else {
		return $char_sjis;
	}
}, $char_sjis_list, $char_utf8_list);
// $char_utf8_list:         ['神', 'と', '神', '?']
// $char_sjis_list:         ['神', 'と', '?', '?']
// $char_sjis_list_escaped: ['神', 'と', '&#64025;', '?']

$str_sjis_escaped = implode('', $char_sjis_list_escaped);
// $str_sjis_escaped: "神と&#64025;?"
```

以上の処理により、変換できない文字を数値文字参照にします。
ただし、マルチバイト文字列の配列化処理がUTF-8の文字列に対してしか出来ないため、**入力はUTF-8に限られます**。

## 方法2:[mb_substitute_character]に`entity`を指定する
[方法2]:#%E6%96%B9%E6%B3%952mb_substitute_character%E3%81%ABentity%E3%82%92%E6%8C%87%E5%AE%9A%E3%81%99%E3%82%8B

ぶっちゃけこれが一番簡単なんじゃないかな。
コードは以下になります。

```php
$str_utf8 = '神と神?';

mb_substitute_character('entity');
$str_sjis = mb_convert_encoding($str_utf8, 'SJIS', 'UTF-8');
// $str_sjis: "神と&#xFA19;?"
```

[mb_substitute_character]関数は、[mb_convert_encoding]関数や[mb_convert_variables]関数で変換できない文字の扱いを設定する関数です。
初期値は`63`で、これは10進数値のUnicode値で`?`に対応する指定値です。
[方法1]で変換できない文字が`?`になるのは、この設定値のためです。
これに`entity`を指定することで、変換できない文字が数値文字参照に変換されるようになります。

マルチバイト文字列関数の機能を利用するため、[方法1]とは異なり、PHPが変換できる文字エンコーディングであれば全ての入力エンコーディングに対応できるものと考えられます。
(未検証)

ただし、[mb_substitute_character]関数の設定はコード内で使用されている全ての`mb_*`系関数に影響があります[^mb_substitute_characterの影響範囲]。
このため、変換した後は元の設定値に戻したほうが良いでしょう。

[^mb_substitute_characterの影響範囲]: 正確には「全ての`mb_*`系関数」ではなく、[mb_convert_encoding]、[mb_convert_variables]、[mb_output_handler]、[mb_send_mail]に影響があります。

```php
$str_utf8 = '神と神?';

$default_substitute_char = mb_substitute_character();

mb_substitute_character('entity');
$str_sjis = mb_convert_encoding($str_utf8, 'SJIS', 'UTF-8');
// $str_sjis: "神と&#xFA19;?"

mb_substitute_character($default_substitute_char);
```

### 旧バージョンのPHPにおける問題

古いバージョンのPHPでは、[mb_substitute_character]関数の動作が不安定なようです。

[PHP-users - [PHP-users 34591] 変換不能文字の数値エンティティ化](http://php-japan.10936.n7.nabble.com/PHP-users-34591-td9169.html)

> さらに自己レスです。しつこくてすみません。 
> 手近にあったPHPで試したところ以下のようになりました 
> 下の2つはフランス語のカフェを変換したものです。 
> 
> PHP4.3.9 "entity"未対応 
> PHP5.1.6 "entity"未対応 
> PHP5.2.6 caf\&#E9; 
> PHP5.2.8 caf\&#xE9; 
> 
> 5.2.6で16進を示すxが付かないようなので気を付けましょう。 

ただし、このバージョンのPHPは公式のサポートが切れており、セキュリティアップデートも行われていません。

[PHP: Supported Versions](https://php.net/supported-versions)

今後、化石と言っても過言ではないこのバージョンに遭遇することは、極めて稀でしょう。よって、この問題は無視できるものと考えられます。

[質問]:https://detail.chiebukuro.yahoo.co.jp/qa/question_detail/q11117320181
[数値文字参照]:https://ja.wikipedia.org/wiki/%E6%96%87%E5%AD%97%E5%8F%82%E7%85%A7#.E6.95.B0.E5.80.A4.E6.96.87.E5.AD.97.E5.8F.82.E7.85.A7.EF.BC.88.E6.96.87.E5.AD.97.E5.8F.82.E7.85.A7.EF.BC.89
[文字コード]:https://ja.wikipedia.org/wiki/%E6%96%87%E5%AD%97%E3%82%B3%E3%83%BC%E3%83%89
[ASCII]:https://ja.wikipedia.org/wiki/ASCII
[ISO/IEC 10646]:https://ja.wikipedia.org/wiki/ISO/IEC_10646
[ブラーフミー系文字]:https://ja.wikipedia.org/wiki/%E3%83%96%E3%83%A9%E3%83%BC%E3%83%95%E3%83%9F%E3%83%BC%E7%B3%BB%E6%96%87%E5%AD%97
[うんこマーク]:https://ja.wikipedia.org/wiki/%E3%81%86%E3%82%93%E3%81%93%E3%83%9E%E3%83%BC%E3%82%AF

[mb_convert_encoding]:https://php.net/manual/ja/function.mb-convert-encoding.php
[mb_convert_variables]:https://php.net/manual/ja/function.mb-convert-variables.php
[mb_output_handler]:https://php.net/manual/ja/function.mb-output-handler.php
[mb_send_mail]:https://php.net/manual/ja/function.mb-send-mail.php
[mb_substitute_character]:https://php.net/manual/ja/function.mb-substitute-character.php