概要
大文字小文字を変換する strtoupper
/strtolower
/ucfirst
/lcfirst
は U+0080 とそれ以降の大文字小文字を変換しないので、ドイツ語やトルコ語などのヨーロッパ諸言語の大文字小文字に対応していません。
また、これらの関数はロケールに依存するので、想定外の文字を破壊してしまう可能性があります。
MacOS で文字が破壊される現象
MacOS でロケールを ja_JP.UTF-8
にしたところ、strtoupper
を 0x00 から 0xFF までの1バイト文字に適用したところ、0x80 以降の文字も変換されました。このことはこれらの関数がさまざまな UTF-8 の文字を破壊してしまうことを意味します。UTF-8 の1バイト文字の範囲は 0x7F までであり、0x80 から 0xBF は後続バイトの範囲、0xC2 から 0xF4 までは先行バイトの範囲だからです。UTF-8 が ASCII の上位互換であっても、関数もそうであるとはかぎらないということです。ロケールを C
にしたとき、Ubuntu 16.04 では ASCII 以外の文字は変換されませんでした。
MacOS のそのほかの課題
ctype_lower
、ctype_alpha
、ctype_alnum
が誤検出する現象を確認しています。くわしくはこちらの記事をご参照ください。C 言語の標準関数がもともとの原因です。
UTF-8 対応の変換関数
従来の mb_convert_case
/mb_strtoupper
/mb_strtolower
に加えて、PHP 7.0 とそれ以降では IntlChar::foldCase
/tolower
/totitle
/toupper
が利用できます。また IntlChar::islower
/istitle
/isupper
も追加されました。
ISO-8859-1 と UTF-8 で定義されるすべての文字を生成する
文字列関数のふるまいを検査する場合、文字集合で定義されるすべての文字を出力するジェネレータを定義しておくと便利です。
function iso_8859_1_gen() {
for ($i = 0; $i < 0x100; ++$i) {
yield $i => chr($i);
}
}
function utf8_gen() {
for ($i = 0; $i < 0x110000; ++$i) {
if ($i > 0xD7FF && $i < 0xE000) {
continue;
}
yield $i => IntlChar::chr($i);
}
}
0x0 から 0xFF までの文字を調べる
// setlocale(LC_CTYPE, 'C');
setlocale(LC_CTYPE, 'ja_JP.UTF-8');
function iso_8859_1_gen() {
for ($i = 0; $i < 0x100; ++$i) {
yield $i => chr($i);
}
}
$gen = iso_8859_1_gen();
foreach ($gen as $cp => $before) {
$after = strtoupper($before);
if ($before !== $after) {
$format = "%s before: %s after: %s\n";
echo sprintf($format, dechex($cp), bin2hex($before), bin2hex($after));
}
}
UTF-8 すべての文字を対象に調べる
コードポイントから文字を生成するのに IntlChar::chr
を使います。このメソッドは PHP 7.0 で導入されました。PHP 7.2 からは mb_chr
を使うことができます。
// setlocale(LC_CTYPE, 'C');
setlocale(LC_CTYPE, 'ja_JP.UTF-8');
function utf8_gen() {
for ($i = 0; $i < 0x110000; ++$i) {
if ($i > 0xD7FF && $i < 0xE000) {
continue;
}
yield $i => IntlChar::chr($i);
}
}
$gen = utf8_gen();
foreach ($gen as $cp => $before) {
$after = strtoupper($before);
if ($before !== $after) {
$format = "%s before: %s after: %s\n";
echo sprintf($format, dechex($cp), bin2hex($before), bin2hex($after));
}
}
IntlChar::toupper
と mb_strtoupper
の違いを調べる
unicode.org で配布される変換表は定期的に更新されます。しかしながら mb_strtoupper
の開発活動は活発ではないので、変換表が古くなっている可能性があります。
$gen = utf8_gen();
foreach ($gen as $cp => $char) {
$intl = IntlChar::toupper($char);
$mbstring = mb_strtoupper($char);
if ($intl !== $mbstring) {
$format = "%s before: %s intl: %s mbstring: %s\n";
echo sprintf($format, dechex($cp), $char, $intl, $mbstring);
}
}
PHP や mbstring に求められる改善
strtoupper
/strtolower
/ucfirst
/lcfirst
を UTF-8 対応にすることが挙げられます。その場合、後方互換性を保つために文字エンコーディングのオプションを追加する必要があります。また mbstring で管理している変換表を PHP 本体に移行した上で内部 API を整備する必要があります。
Ruby はバージョン 2.4 で upcase
、downcase
、swapcase
、capitalize
が Unicode に対応しました (Unicode case mappings)。導入において後方互換性や言語ごとの対応のための取り組みはこちらをご参照ください。
uc_first
に対応する mb_ucfirst
を求める意見は PHP 公式マニュアルや stackoverflow で複数見られます。mb_convert_case
の MB_CASE_TITLE
オプションのショートカットになる mb_strtotitle
も追加する価値があるかもしれません。