6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-09-11

概要

大文字小文字を変換する 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_lowerctype_alphactype_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::touppermb_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 で upcasedowncaseswapcasecapitalize が Unicode に対応しました (Unicode case mappings)。導入において後方互換性や言語ごとの対応のための取り組みはこちらをご参照ください。

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

6
6
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
6
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?