LoginSignup
10
9

More than 5 years have passed since last update.

人名や地名に含まれる異体字を壊さないようにするために grapheme_substr もしくは grapheme_extract を使う

Last updated at Posted at 2014-11-19

多くの CMS やフレームワークでは UTF-8 の文字列から部分文字列を取り出すのに mb_substr が使われていますが、人名や地名に含まれる異体字を壊してしまうおそれがあります。

東京都葛飾区の「カツ」を例に異体字を含む文字列から部分文字列を取り出してみましょう。「カツ」の字体をコードポイントであらわすと「U+845B U+E0101」です。1番目の文字は基底文字と呼ばれ、2番目の文字は 異体字セレクターと呼ばれます。字の形は漢字字形データベースの該当ページをご参照ください。

次のコードは文字列から最初の1文字を取り出したものです。mb_substr は異体字セレクターを認識できないために「U+845B U+E0101」の列が壊れています。一方で、grapheme_substr は異体字セレクターを考慮してくれています。

$str = "葛\xF3\xA0\x84\x81飾区";

var_dump(
    "葛" === mb_substr($str, 0, 1, 'UTF-8'),
    "葛\xF3\xA0\x84\x81" === mb_substr($str, 0, 2, 'UTF-8'),
    "葛\xF3\xA0\x84\x81" === grapheme_substr($str, 0, 1)
);

ほかに grapheme_extract を使う選択肢もあります。ただし、PHP 5.6.3 を試したところ、後続の1文字を余計に取り出してしまうバグがあります (Bug #68447)。

$str = "葛\xF3\xA0\x84\x81飾区";
var_dump(
    "葛\xF3\xA0\x84\x81" !== grapheme_extract($str, 1)
);

なお PHP 7.0 から Unicode エスケープシーケンスが使えるようになります。

$str = "葛\u{E0101}飾区";

異体字のサイズのバリデーションの必要性

基底文字の後ろに続く異体字セレクターはいくらでも追加できるため、現実の社会では使われない巨大なバイト列が1文字として処理されてしまう問題があります。

$c = "葛".str_repeat("\xF3\xA0\x84\x81", 1000);
$str = $c."飾区";

var_dump(
    1 === grapheme_strlen($c),
    $c === grapheme_substr($str, 0, 1)
);

対策として、grapheme_extract の第3引数に GRAPHEME_EXTR_MAXCHARS や GRAPHEME_EXTR_MAXBYTES を指定すれば、コードポイント、バイト列を基準にしつつ、異体字を壊さずに文字を取り出すことができます。

$str = "葛\xF3\xA0\x84\x81飾区";

var_dump(
    "葛\xF3\xA0\x84\x81飾" === grapheme_extract($str, 3, GRAPHEME_EXTR_MAXCHARS),
    "葛\xF3\xA0\x84\x81飾" === grapheme_extract($str, 10, GRAPHEME_EXTR_MAXBYTES)
);

grapheme_extract の代替

intl 拡張モジュールが利用できなかったり、grapheme_extract のバグを避ける必要がある場合など、grapheme_extract の代替方法を紹介します。不正なバイト列が含まれる場合、代替文字の U+FFFD に置き換えることにします。

PCRE

PHP 5.3.0 とそれ以降であれば PCRE は標準モジュールなので、intl 拡張モジュールがインストールされていない環境のためのフォールバック関数の定義に使うことができます。

PCRE の正規表現で異体字を考慮して1文字を取り出すには \X を使います。ただし、\X が使える PHP のバージョンは PHP 5.4.14 とそれ以降のバージョンで、PHP 5.4.14 よりも前のバージョンであれば「(?>\PM\pM*) 」を使います。文字数は preg_match_all の戻り値から求めます。PHP 5.4.0 とそれ以降のバージョンであれば、preg_match_all の第3引数である $matches は省略できます。

$str = "葛\xF3\xA0\x84\x81飾区";

var_dump(
    "葛\xF3\xA0\x84\x81" === grapheme_truncate_by_chars($str, 2)
);


function utf8_length($str) {
    return preg_match_all('/./u', $str);
}

function grapheme_truncate_by_chars($str, $length) {

    if (!is_string($str)) {
        exit("$str is not string\n");
    }

    if (!is_int($length) || $length < 0) {
        exit("$length is not positive integer\n");
    }

    $str = htmlspecialchars_decode(htmlspecialchars($str, ENT_COMPAT, 'UTF-8'));

    preg_match_all('/\X/u', $str, $graphemes);

    $ret = '';
    $ret_length = 0;

    foreach ($graphemes[0] as $g) {

        $g_length = utf8_length($g);

        if($ret_length + $g_length > $length) {
            break;
        }

        $ret .= $g;
        $ret_length += $g_length;
    }

    return $ret;
}

IntlBreakIterator

コードポイント単位での文字数を求めるには createCodePointInstance メソッドでインスタンスを生成します。createCharacterInstance ではローケルを指定する必要があります。海外での利用を考えて、ローケルは 'en_US.UTF-8' としました。

$str = "葛\xF3\xA0\x84\x81飾区";

var_dump(
    "葛\xF3\xA0\x84\x81" === grapheme_truncate_by_chars2($str, 2)
);

function utf8_length2($str) {

    $it = IntlBreakIterator::createCodePointInstance();
    $it->setText($str);

    $len = -1;

    foreach ($it as $pos) {
        ++$len;
    }

    return $len;

}


function grapheme_truncate_by_chars2($str, $length) {

    if (!is_string($str)) {
        exit("$str is not string\n");
    }

    if (!is_int($length) || $length < 0) {
        exit("$length is not positive integer\n");
    }

    $str = htmlspecialchars_decode(htmlspecialchars($str, ENT_COMPAT, 'UTF-8'));

    $it = IntlBreakIterator::createCharacterInstance('en_US.UTF-8');
    $it->setText($str);

    $ret = "";
    $ret_length = 0;
    $prev = 0;

    foreach ($it as $pos) {

        $char = substr($str, $prev, $pos - $prev);
        $char_length = utf8_length2($char);

        if ($ret_length + $char_length > $length) {
            break;
        }

        $ret .= $char;
        $ret_length += $char_length;

        $prev = $pos;
    }

    return $ret;
}

簡易ベンチマーク

mb_substr に比べて grapheme_extract、grapheme_substr は約1.7倍遅くなりました。IntlBreakIterator は grapheme_substr と比べて一桁遅くなりました。

array (
  'mb_substr' => 0.12627196311950684,
  'grapheme_extract' => 0.21210885047912598,
  'grapheme_substr' => 0.2148129940032959,
  'PCRE' => 0.88468599319458008,
  'IntlBreakIterator' => 3.0020408630371094,
)
$str = "葛\xF3\xA0\x84\x81飾区";

$ret = [
    'mb_substr' => timer(function() use($str) { mb_substr($str, 0, 3, 'UTF-8'); }),
    'grapheme_substr' => timer(function() use($str) { grapheme_substr($str, 2); }),
    'grapheme_extract' => timer(function() use($str) { grapheme_extract($str, 3, GRAPHEME_EXTR_MAXCHARS); }),
    'PCRE' => timer(function() use($str) { grapheme_truncate_by_chars($str, 3); }),
    'IntlBreakIterator' => timer(function() use($str) { grapheme_truncate_by_chars2($str, 3); })
];

asort($ret);
var_export($ret);

function timer($callable, $repeat = 100000) {

    if (!is_int($repeat)) {
        exit("$repeat is not integer");
    }

    if ($repeat < 0) {
        exit("$repeat is not positive integer");
    }

    $start = microtime(true);

    do {
        $callable();
    } while($repeat -= 1);

    $stop = microtime(true);

    return  $stop - $start;
}
10
9
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
10
9