mb_strwidth の代わりに IntlChar を使って東アジアの文字幅を求める

PHP 7.0 で intl エクステンションに導入された IntlChar を使って東アジアの文字幅を求める関数を定義しました。

IntlChar について
---------------

IntlChar は [Unicode Character Database](http://www.unicode.org/ucd/) で配布されているデータを文字の種類の判定に使います。東アジアの文字幅の判定には [EastAsianWidth.txt](http://www.unicode.org/Public/UCD/latest/ucd/EastAsianWidth.txt) が使われます。

IntlChar の実装には ICU の [uchar.h](http://icu-project.org/apiref/icu4c/uchar_8h.html) が使われています。

ほかの言語の実装
-------------
Java の場合、ICU4J の `UCharacter.getIntPropertyValue(codePoint, UProperty.EAST_ASIAN_WIDTH)` を使います。Python の場合、標準モジュールの `unicodedata.east_asian_width` を使います。Go の場合、`golang.org/x/text/width` があります。使い方は「[Go で UTF-8 の文字列を扱う](http://qiita.com/masakielastic/items/01a4fb691c572dd71a19) の記事をご参照ください。

文字幅の種類を調べる
-----------------

`IntlChar::getIntPropertyValue` の第2引数に `IntlChar::PROPERTY_EAST_ASIAN_WIDTH` を指定します。

```php
var_dump(
    IntlChar::EA_FULLWIDTH === IntlChar::getIntPropertyValue("Ｋ", IntlChar::PROPERTY_EAST_ASIAN_WIDTH),
    IntlChar::EA_HALFWIDTH === IntlChar::getIntPropertyValue("ｶ", IntlChar::PROPERTY_EAST_ASIAN_WIDTH),
    IntlChar::EA_WIDE === IntlChar::getIntPropertyValue("カ", IntlChar::PROPERTY_EAST_ASIAN_WIDTH),
    IntlChar::EA_NARROW === IntlChar::getIntPropertyValue("a", IntlChar::PROPERTY_EAST_ASIAN_WIDTH),
    IntlChar::EA_AMBIGUOUS === IntlChar::getIntPropertyValue("α", IntlChar::PROPERTY_EAST_ASIAN_WIDTH),
    IntlChar::EA_NEUTRAL === IntlChar::getIntPropertyValue("À", IntlChar::PROPERTY_EAST_ASIAN_WIDTH)
);
```

関数を定義する
------------

関数を定義してみましょう。UAX #11 の[推奨](http://www.unicode.org/reports/tr11/#Recommendations)によれば、東アジアの文字エンコーディングでは Ambiguous に属する文字は全角として扱い、東アジアではない文字エンコーディングでは Ambiguous を半角として扱うとのことです (via 「[文字コード地獄秘話 第1話：Unicodeにおける全角・半角](http://tech.albert2005.co.jp/blog/2014/04/21/mco-eaw/)」)。コードポイント単位で文字を取り出すには `grapheme_extract` の第3引数に `GRAPHEME_EXTR_MAXCHARS` を指定します。

```php
function intl_east_asian_width($str, $east = true)
{
    $size = strlen($str);
    $list = [IntlChar::EA_FULLWIDTH, IntlChar::EA_WIDE];

    if ($east) {
        $list[] = IntlChar::EA_AMBIGUOUS;
    }

    $width = 0;
    $current = 0;
    $next = 0;

    while ($next < $size) {
        $c = grapheme_extract($str, 1, GRAPHEME_EXTR_MAXCHARS, $current, $next);
        $current = $next;

        $v = IntlChar::getIntPropertyValue($c, IntlChar::PROPERTY_EAST_ASIAN_WIDTH);
        $width += in_array($v, $list, true) ? 2 : 1;
    }

    return $width;
}
```

定義した関数を試してみましょう。

```php
$str = "あいうえお";

var_dump(
    10 === intl_east_asian_width($str),
    10 === mb_strwidth($str)
);
```

ベンチマーク
----------

`mb_strwidth` と比べて `IntlChar` を使うやり方は7分の1もしくは8分の1の速度になりました。

```
time mb_strwidth.php
20000

real	0m0.055s
user	0m0.037s
sys	0m0.013s
time php intl_east_asian_width.php
20000

real	0m0.427s
user	0m0.408s
sys	0m0.014s
```

```php:mb_strwidth.php
$str = str_repeat("あいうえおかきくけこ", 1000);

echo mb_strwidth($str), PHP_EOL;
```

```php:intl_eat_asian_width.php
$str = str_repeat("あいうえおかきくけこ", 1000);

echo intl_east_asian_width($str), PHP_EOL;

function intl_east_asian_width($str, $east = true)
{
    $size = strlen($str);
    $list = [IntlChar::EA_FULLWIDTH, IntlChar::EA_WIDE];

    if ($east) {
        $list[] = IntlChar::EA_AMBIGUOUS;
    }

    $width = 0;
    $current = 0;
    $next = 0;

    while ($next < $size) {
        $c = grapheme_extract($str, 1, GRAPHEME_EXTR_MAXCHARS, $current, $next);
        $current = $next;

        $v = IntlChar::getIntPropertyValue($c, IntlChar::PROPERTY_EAST_ASIAN_WIDTH);
        $width += in_array($v, $list, true) ? 2 : 1;
    }

    return $width;
}
```

mb_strwidth の問題
-----------------

2015年11月時点では `mb_strwidth` が文字の種類の判定に使うデータ ([eaw_table.h](https://github.com/php/php-src/blob/9d3f6f431954a575c12bf2240b56e1d6f99796c3/ext/mbstring/libmbfl/mbfl/eaw_table.h)) が古く、最新の EastAsianWidth.txt を反映していません。

mb_strwidth の判定テーブルと EastAsianWidth.txt の違いは次のコードで調べることができます。

```php
$list = [IntlChar::EA_FULLWIDTH, IntlChar::EA_WIDE, IntlChar::EA_AMBIGUOUS];

for ($i = 0; $i < 0x110000; ++$i) {

    if ($i > 0xD7FF && $i < 0xE000) {
        continue;
    }

    $char = IntlChar::chr($i);

    $v = IntlChar::getIntPropertyValue($char, IntlChar::PROPERTY_EAST_ASIAN_WIDTH);
    $width = in_array($v, $list, true) ? 2 : 1;
    $width2 = mb_strwidth($char);

    if ($width !== $width2) {
        $hex = strtoupper(dechex($i));
        var_dump([$hex, $width, $width2]);
    }
}
```