PHPのmb_strlenは絵文字や濁点で文字数がズレる grapheme_strlenとNormalizerで見た目どおりに数える
grapheme_strlen()は、人間が1文字と感じる単位(書記素)で文字数を数えるintl拡張の関数です。mb_strlen()はコードポイント単位で数えるので、家族の絵文字や濁点が分離した文字では見た目とズレます。
「最大20文字まで」という入力チェックを、mb_strlen()で書いて安心していた時期が私にもあります。日本語でもちゃんと数えてくれるので、そこで満足してしまうんですよね。ただ、絵文字や濁点が絡むと、見た目の1文字が2文字にも5文字にも数えられます。
strlenとmb_strlenとgrapheme_strlenは何を数えているのか
strlen()はバイト数、mb_strlen()はコードポイント数、grapheme_strlen()は書記素クラスタ数を返します。同じ「👨👩👧」でも、数える単位でこれだけ値が変わります。
<?php
$family = "👨👩👧"; // 👨 + ZWJ + 👩 + ZWJ + 👧
var_dump(strlen($family)); // int(18)
var_dump(mb_strlen($family)); // int(5)
var_dump(grapheme_strlen($family)); // int(1)
見た目は1つの絵文字ですが、実体は3つの絵文字をゼロ幅接合子(ZWJ)でつないだ5コードポイントです。文字数制限をユーザーの感覚に合わせたいなら、数えるべきは最後のgrapheme_strlen()のほうだと思います。
濁点が分離した「が」はmb_strlenで2文字になる
もう一つの落とし穴が、Unicodeの正規化です。「が」には1コードポイントの形(NFC)と、「か」+濁点の2コードポイントの形(NFD)があり、見た目は同じでも別の文字列として扱われます。macOSのファイル名由来の文字列などで、たまに分離形が混ざってきます。
<?php
$composed = "が";
$decomposed = "か\u{3099}"; // か + 結合用濁点
var_dump($composed === $decomposed); // bool(false)
var_dump(mb_strlen($composed)); // int(1)
var_dump(mb_strlen($decomposed)); // int(2)
var_dump(grapheme_strlen($decomposed)); // int(1)
見た目が同じなのに===がfalseになるので、ユーザー名の重複チェックや検索で「あるはずなのに一致しない」という不思議な現象になります。数えるだけならgrapheme_strlen()で足りますが、比較や保存の前には正規化も必要です。
保存や比較の前にはNormalizerでNFCにそろえる
Normalizer::normalize()でNFCにそろえておけば、分離形も結合形も同じ文字列になります。入口で一度そろえておくと、後段の比較やユニーク制約が素直に動きます。
<?php
$n = Normalizer::normalize($decomposed, Normalizer::FORM_C);
var_dump($n === $composed); // bool(true)
var_dump(mb_strlen($n)); // int(1)
// "e" + 結合アクセント も同様
$e1 = "e\u{0301}";
$e2 = "\u{00e9}"; // é
var_dump($e1 === $e2); // bool(false)
var_dump(Normalizer::normalize($e1, Normalizer::FORM_C) === $e2); // bool(true)
ちなみにNormalizer::normalize()は、不正なUTF-8を渡すとfalseを返します。入力値をそのまま渡すなら、戻り値がfalseのケースも一応見ておくと安心です。
切り取りもmb_substrでは絵文字が壊れる
数える単位がズレるなら、切り取りもズレます。mb_substr()でZWJ絵文字の途中を切ると、中途半端な文字が残ります。grapheme_substr()なら書記素単位で切れます。
<?php
$s = "家族👨👩👧です";
echo mb_substr($s, 0, 4); // 家族👨 + ZWJ(家族の絵文字が壊れる)
echo grapheme_substr($s, 0, 3); // 家族👨👩👧
切れた断面に見えないZWJが残るので、画面上は「家族👨」のように見えて、原因に気づきにくいのも厄介なところです。ただしgrapheme_系の関数はintl拡張が必要なので、使う前に環境で有効かどうかを確認してください。
まとめ
文字数の上限チェックや切り取りは、見た目の1文字を数えるならgrapheme_系、保存や比較の前にはNormalizerでNFCにそろえる、という二段構えがいちばん安全かなと思っています。ただ、毎回そこまでやる必要があるかは用途次第で、日本語中心のフォームなら、まずNFCにそろえるだけでもだいぶ事故は減る気がしています。
よくある質問
Q. mb_strlen()とgrapheme_strlen()はどちらを使えばいいですか?
A. 見た目の文字数をユーザーに見せたり制限したりするならgrapheme_strlen()です。コードポイント数が欲しいだけ、またはintlが使えない環境ではmb_strlen()を使います。
Q. grapheme_strlen()が使えないときはどうなりますか?
A. intl拡張が無効だと関数自体が未定義になり、呼ぶとエラーになります。php -mでintlが入っているか確認し、無ければ拡張を有効にします。
Q. NFCとNFDのどちらにそろえればいいですか?
A. 一般的にはNFCにそろえることが多く、日本語の「が」も1コードポイントになって扱いやすくなります。大事なのは、保存と比較で同じ形にそろえることです。