Edited at

UTF-8 の4バイト文字を HTML 数値文字参照に変換する


対象とする読者層

この記事は何らかの理由で MySQL 5.5 で導入された utf8mb4 を使えないもしくは古い CMS のために utf8mb3 を使い続ける必要がある人を対象とします。


2019年の状況

WordPress.org の統計によれば WordPress が導入されているサーバーの9割で MySQL 5.5 以上が動いています。


問題と対策

MySQL の utf8mb3 (utf8) の仕様では、4バイト文字 (U+10000 から U+10FFFF) をそのまま保存することができないので、サロゲートペア (U+D800 から U+DFFF) に対応する2組の3バイトの文字に変換するか、HTML 数値文字参照など復号可能なデータ形式に変換する必要があります。前者についてはこちらの記事をご参照ください。

HTML 数値文字参照を選ぶメリットは標準関数で変換できることやブラウザーが自動的に文字に変換してくれることです。

数値文字参照を導入するにあたって、考慮する必要があることは HTML や JSON として出力する際に、数値文字参照をエスケープして壊さないようにすることです。既存の CMS やフレームワークの多くは重複エスケープを考慮していないので、データベースに取り出した後で数値文字参照を文字に戻してから HTML の特殊エスケープする方法が採用しやすいのではないでしょうか (ngyuki さんご指摘ありがとうございます)。

サロゲートペアに変換する方法を選ぶ場合、UTF-8 の仕様ではサロゲートペアの範囲のコードポイントは定義されないので、プログラミング言語が暗黙のうちに ? や U+FFFD などの代替文字に置き換えてしまう可能性を配慮する必要があります。

ほかの対策として、4バイト文字を削除する方法を見かけることがありますが、一般ユーザーのアプリケーションの信頼を損なうことになります。保存できない文字であるなら、投稿できないことをユーザーに伝えて、投稿の受付を拒否することが望ましいのではないでしょうか。


MySQL クライアントで文字からコードポイントを調べる

SELECT HEX(CONVERT("あ" USING utf32));

「あ」の場合、00003042 が得られますので、コードポイントは U+3042 となります。ほかの変換についてはこちらの記事をご参照ください。


JavaScript

EcmaScript 6 の String.prototype.codePointAt および String.fromCodePoint を使えば、次のように書けます。

function utf8mb4_encode_numericentity(str) {

var re = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
return str.replace(re, function(match) {
return '&#x' + match.codePointAt(0).toString(16).toUpperCase() + ';';
});
}

function utf8mb4_decode_numericentity(str) {
var re = /&#(x([0-9a-fA-F]{5,6})|\d{5,7});/g;
return str.replace(re, function(m) {
if (typeof arguments[2] !== 'undefined') {
var cp = parseInt(arguments[2], 16);
} else {
var cp = parseInt(arguments[1], 10);
}

return String.fromCodePoint(cp);
});
}

var input = '𠮷野家';
var output = '𠮷野家';
console.log(output === utf8mb4_encode_numericentity(input));
console.log(input + '𠮷' === utf8mb4_decode_numericentity(output + '𠮷'));


Ruby

ワンライナーで書くことができます (scivola さんありがとうございます)。

def utf8mb4_encode_numericentity(str)

str.gsub(/[^\u{0}-\u{FFFF}]/) { '&#x%X;' % $&.ord }
end

def utf8mb4_decode_numericentity(str)
str.gsub(/&#(x(([0-9a-fA-F]{5,6}))|\d{5,7});/) {
(defined?($2) ? $2.to_i(16): $1.to_i(10)).chr('UTF-8')
}
end

input = '𠮷野家'
output = '𠮷野家'
puts output === utf8mb4_encode_numericentity(input)
puts input + '𠮷' === utf8mb4_decode_numericentity(output + '𠮷')


PHP

preg_replace_callback を使えば、次のように書けます。


$input = '𠮷野家';
$output = '𠮷野家';

var_dump(
'𠮷野家' === mb_encode_numericentity($input, [0x10000, 0x10FFFF, 0, 0xFFFFFF], 'UTF-8'),
$input === mb_decode_numericentity($output, [0x10000, 0x10FFFF, 0, 0xFFFFFF], 'UTF-8'),
$output === utf8mb4_encode_numericentity($input),
$input.'𠮷' === utf8mb4_decode_numericentity($output.'𠮷')
);

function utf8mb4_encode_numericentity($str)
{
$re = '/[^\x{0}-\x{FFFF}]/u';
return preg_replace_callback($re, function($m) {
$char = $m[0];
$x = ord($char[0]);
$y = ord($char[1]);
$z = ord($char[2]);
$w = ord($char[3]);
$cp = (($x & 0x7) << 18) | (($y & 0x3F) << 12) | (($z & 0x3F) << 6) | ($w & 0x3F);
return sprintf("&#x%X;", $cp);
}, $str);
}

function utf8mb4_decode_numericentity($str)
{
$re = '/&#(x[0-9a-fA-F]{5,6}|\d{5,7});/';
return preg_replace_callback($re, function($m) {
return html_entity_decode($m[0]);
}, $str);
}


Go

package main

import (
"fmt"
"regexp"
"unicode/utf8"
"strconv"
)

func main() {
input := "𠮷野家"
output := "&#x20BB7;野家"

println(output == utf8mb4_encode_numericentity(input))
println(input + "𠮷" == utf8mb4_decode_numericentity(output + "&#134071;"))
}

func utf8mb4_encode_numericentity(str string) string {
re := regexp.MustCompile("[^\u0000-\uFFFF]");
return re.ReplaceAllStringFunc(str, func(match string) string {
r, _ := utf8.DecodeLastRuneInString(match)
return fmt.Sprintf("&#x%X;", r);
});
}

func utf8mb4_decode_numericentity(str string) string {
re := regexp.MustCompile("&#(x([0-9a-fA-F]{5,6})|[0-9]{5,7});");
return re.ReplaceAllStringFunc(str, func(match string) string {
var cp int64
if match[2] == 0x78 {
cp, _ = strconv.ParseInt(match[3:len(match)-1], 16, 32)
} else {
cp, _ = strconv.ParseInt(match[2:len(match)-1], 10, 32)
}

return fmt.Sprintf("%c", cp)
});
}