UTF-8 の文字列に strtoupper/strtolower/ucfirst/lcfirst を適用することを避ける


概要
---

大文字小文字を変換する `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` が誤検出する現象を確認しています。くわしくは[こちら](http://blog.sarabande.jp/post/53053570309)の記事をご参照ください。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 で定義されるすべての文字を生成する
------------------------------------------------

文字列関数のふるまいを検査する場合、文字集合で定義されるすべての文字を出力するジェネレータを定義しておくと便利です。

```php
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 までの文字を調べる
----------------------------

```php
// 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` を使うことができます。


```php
// 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` の開発活動は活発ではないので、変換表が古くなっている可能性があります。

```php
$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)。導入において後方互換性や言語ごとの対応のための取り組みは[こちら](https://bugs.ruby-lang.org/issues/10085)をご参照ください。

`uc_first` に対応する `mb_ucfirst` を求める意見は PHP 公式マニュアルや stackoverflow で複数見られます。`mb_convert_case` の `MB_CASE_TITLE` オプションのショートカットになる `mb_strtotitle` も追加する価値があるかもしれません。