この記事は grapheme_extract() を中心に WordPress などの記事抜粋処理を見直すことが目的です。
PHP マニュアルでは、grapheme_extract() は「UTF-8 でエンコードされたテキストバッファから、デフォルト書記素クラスターの列を取り出す関数」と説明されています。また、type 引数によって size の意味が変わることも明記されています。
GUI の表示処理なら grapheme_substr() への置き換えはかなり自然
まず、GUI の表示処理だけを見るなら、mb_substr() から grapheme_substr() へ置き換えるのはかなり自然だと思っています。
WordPress の管理画面、テーマのカード UI、記事一覧、検索結果スニペット、SNS 用の表示文などでは、利用者が画面上で「1文字」と認識する単位を途中で壊さないことが重要になります。
たとえば、絵文字、合成文字、濁点つき文字、肌色修飾つき絵文字などを途中で切ってしまうと、表示が崩れたり、意図しない文字列に見えたりします。
その意味では、次のようなコードは直感的です。
$excerpt = grapheme_substr( $text, 0, 110 );
これは「最大 110 個の書記素クラスターを表示する」という意味です。
表示上の文字を 110 個まで出したい、という用途なら、この考え方はかなり説明しやすいです。mb_substr() の移行先としても、grapheme_substr() は自然です。
ただし、これで全部解決するとは考えていません。
後方互換性の問題
たとえば WP Multibyte Patch には、たとえば次のような設定があります。
$wpmp_conf['excerpt_mblength'] = 110;
この 110 を、いきなり「110 書記素クラスター」と再定義してよいかというと、私は慎重に考えたほうがよいと思っています。
既存サイトでは、この値が従来の mb_strlen() / mb_substr() 相当の「文字数上限」として理解されている可能性があります。ここで急に書記素クラスター数へ意味を変えると、抜粋の表示量が変わるかもしれません。
そのため、後方互換性を考えるなら、excerpt_mblength は従来どおり、コードポイント数に近い「文字数上限」として維持したいです。
ただし、切断位置が書記素クラスターの途中に入る場合は、そこで壊さないようにしたい。
つまり、欲しい処理は次の単純なコードではありません。
$excerpt = grapheme_substr( $text, 0, 110 );
欲しいのは、次のような処理です。
従来の文字数上限は維持する。
ただし、切断位置が書記素クラスターの途中に入るなら、そこで壊さない。
言い換えると、こうです。
コードポイント数の上限を守りながら、
書記素クラスターを壊さずに、
先頭から take する。
grapheme_substr() は「書記素クラスター数」で取る API です。
一方で、今回欲しい処理は「コードポイント数の上限を守りながら、書記素クラスターを壊さずに取る」処理です。
ここで grapheme_extract() の GRAPHEME_EXTR_MAXCHARS が関係してきます。
grapheme_extract() の type は何を意味しているのか
grapheme_extract() は、初見ではかなり難しく見えます。
理由はいくつかあります。
substr より extract という名前が直感的ではありません。
type 引数があります。
GRAPHEME_EXTR_COUNT、GRAPHEME_EXTR_MAXBYTES、GRAPHEME_EXTR_MAXCHARS という定数名も、すぐには覚えにくいです。
MAXCHARS の chars が何を指しているのかも、PHP の文字列処理に慣れていないとわかりにくいです。
さらに、offset と next も出てきます。
しかし、私は type 引数を次のように理解するとよいと思っています。
どの制約で、書記素クラスターを壊さずに取り出すか
PHP マニュアルでは、type によって size の意味が変わると説明されています。GRAPHEME_EXTR_COUNT は書記素クラスター数、GRAPHEME_EXTR_MAXBYTES は最大バイト数、GRAPHEME_EXTR_MAXCHARS は最大 UTF-8 文字数を意味します。
GRAPHEME_EXTR_COUNT
GRAPHEME_EXTR_COUNT は、書記素クラスターを指定個数だけ取り出します。
表示上の文字を N 個取りたい場合に近いです。
$next = 0;
$part = grapheme_extract(
$text,
10,
GRAPHEME_EXTR_COUNT,
0,
$next
);
これは、ざっくり言えば「書記素クラスターを 10 個まで取る」という処理です。
GUI の表示文字数に近い制約です。
GRAPHEME_EXTR_MAXBYTES
GRAPHEME_EXTR_MAXBYTES は、返される文字列のバイト数が指定上限を超えないように取り出します。
保存容量、通信量、プロトコル上限などを意識する場面に近いです。
ただし、これも書記素クラスター境界で切ります。
つまり、バイト数上限を超えない範囲で、書記素クラスターを壊さないように取る、という考え方です。
GRAPHEME_EXTR_MAXCHARS
GRAPHEME_EXTR_MAXCHARS は、最大 UTF-8 文字数を上限として取り出します。
ここでは、実務上は「コードポイント数に近い文字数上限」と考えると理解しやすいです。
書記素クラスターは壊さない。
しかし、従来の文字数上限は守る。
この考え方が、今回の WP Multibyte Patch の抜粋処理にかなり近いと私は考えています。
たとえば、検証用には次のように書けます。
function grapheme_take_max_codepoints_by_extract( $s, $limit ) {
$next = 0;
return grapheme_extract(
$s,
$limit,
GRAPHEME_EXTR_MAXCHARS,
0,
$next
);
}
もちろん、これは valid UTF-8 前提の単純な検証コードです。
本番投入するなら、false が返るケース、不正 UTF-8、巨大入力、環境差などを追加で考える必要があります。
GRAPHEME_EXTR_MAXCHARS 的な考え方
ここで大事なのは、grapheme_substr( $text, 0, 1 ) とは意味が違うことです。
grapheme_substr() は、書記素クラスターを 1 つ取ります。
一方で、今回欲しい処理は、コードポイント数の上限を守りながら、書記素クラスターを壊さない処理です。
たとえば、次の文字列を考えます。
👍🏼abc
👍🏼 は、画面上では 1 文字に見えます。
しかし、内部的には複数のコードポイントから構成されています。
このとき、コードポイント数上限が 1 なら、👍🏼 は含めません。途中で切ると書記素クラスターを壊すからです。
コードポイント数上限が 2 なら、👍🏼 を含められます。
この考え方は、次のように言えます。
見た目の1文字を壊さない。
ただし、内部的な文字数上限は超えない。
これは、GUI 表示だけを見るなら少しまわりくどい処理です。
しかし、既存の excerpt_mblength = 110 の意味をなるべく変えずに、書記素クラスター破壊だけを避けたい場合には、かなり現実的な考え方だと思います。
不正 UTF-8 の置き換えは別工程にする
ここで、もう一つ重要な話があります。
grapheme_substr()、grapheme_extract()、PCRE の /u は、基本的に valid UTF-8 を前提にします。
不正なバイト列をそのまま渡すと、失敗したり、期待しない結果になったりします。
そのため、切り詰め処理の前に、不正 UTF-8 を U+FFFD に置き換える前処理が必要になる場合があります。
ここで気をつけたいのは、不正 UTF-8 の置き換えと、書記素クラスター境界処理を混ぜないことです。
私は、工程を分けて考えるべきだと思っています。
入力を valid UTF-8 に寄せる処理
↓
コードポイント数やバイト数などの上限処理
↓
書記素クラスター境界を壊さない切り詰め
↓
HTML エスケープなど、出力先に応じた処理
候補としては、次のようなものがあります。
mb_convert_encoding() や mb_scrub() を使う方法。
htmlspecialchars( ..., ENT_SUBSTITUTE, 'UTF-8' ) と htmlspecialchars_decode() を往復させる方法。
そして、WordPress 6.9 以降であれば wp_scrub_utf8() を使う方法です。
WordPress Developer Resources では、wp_scrub_utf8() は不正な UTF-8 バイト列を Unicode Replacement Character に置き換える関数として説明されています。また、WordPress 6.9.0 で導入されたことも示されています。
WordPress Core の解説記事でも、不正な UTF-8 は削除ではなく置き換えるべきであり、削除すると前後の有効な文字列が結合されるリスクがある、という趣旨の説明があります。
ただし、WP Multibyte Patch は古い WordPress 互換も意識するプラグインです。
そのため、WordPress 6.9 の wp_scrub_utf8() にすぐ全面依存できるとは限りません。
ここでも、実装できることと、プラグイン本体に入れることは別問題です。
GUI 以外で使われるなら、表示品質だけの問題ではない
WP Multibyte Patch のような基盤的なプラグインでは、抜粋処理が純粋な GUI 表示だけに使われるとは限りません。
たとえば、次のような場所に流れる可能性があります。
meta description
RSS
検索インデックス
OGP
メール
API レスポンス
別プラグインの入力
この場合、問題は「絵文字が壊れる」だけではありません。
不正 UTF-8 が混ざる可能性があります。
HTML エスケープ前提が崩れる可能性があります。
バイト列の削除によって、前後の文字が結合される可能性があります。
後続処理のパーサーや正規表現が失敗する可能性があります。
そして、表示用の文字数制限を、保存容量や通信量などのリソース制限と誤解する危険性もあります。
「見た目の文字数」は、リソース制限ではありません。
書記素クラスター数、コードポイント数、バイト数は、それぞれ別の単位です。
GUI だけなら、grapheme_substr() への置き換えで十分な場面は多いです。
しかし、基盤的なプラグインでは、valid UTF-8 化、上限管理、fallback、異常時の扱いまで分けて考える必要があります。
grapheme_extract() を使わずに考える:必要な部品を分ける
grapheme_extract() は便利ですが、type の意味を理解するまでは少し難しく見えます。
そこで、あえて grapheme_extract() を使わずに処理を分解して考えてみます。
必要な部品は、大きく分けて 2 つです。
書記素クラスター単位で走査する iterator
各 unit のコードポイント数を数える関数
この 2 つを組み合わせれば、GRAPHEME_EXTR_MAXCHARS 的な処理を実装できます。
grapheme_extract() の type が覚えづらい場合でも、処理を部品に分ければ、何をやっているのかは理解しやすくなります。
IntlBreakIterator を使った実装例
まず、IntlBreakIterator を使う例です。
PHP の関数一覧では、IntlBreakIterator::createCharacterInstance() は結合文字列の境界、IntlBreakIterator::createCodePointInstance() はコードポイント境界のための break iterator を作るメソッドとして掲載されています。
この 2 つを使えば、grapheme_extract() を直接使わずに、同じ考え方をより明示的に書けます。
以下は検証用コードです。
型宣言は使わず、PHP 5.6 互換を意識しています。
function utf8_codepoint_count_by_intl( $s ) {
if ( ! class_exists( 'IntlBreakIterator' ) ) {
return null;
}
$it = IntlBreakIterator::createCodePointInstance();
if ( ! $it ) {
return null;
}
$it->setText( $s );
$count = 0;
$it->first();
while ( IntlBreakIterator::DONE !== $it->next() ) {
$count++;
}
return $count;
}
次に、書記素クラスター単位で走査する関数です。
function grapheme_iter_by_intl( $s ) {
if ( ! class_exists( 'IntlBreakIterator' ) ) {
return;
}
$it = IntlBreakIterator::createCharacterInstance();
if ( ! $it ) {
return;
}
$it->setText( $s );
$start = $it->first();
while ( true ) {
$end = $it->next();
if ( IntlBreakIterator::DONE === $end ) {
break;
}
yield substr( $s, $start, $end - $start );
$start = $end;
}
}
これを組み合わせると、コードポイント数上限つきの take 関数を書けます。
function grapheme_take_max_codepoints_by_intl( $s, $limit ) {
if ( $limit <= 0 ) {
return '';
}
$out = '';
$used = 0;
foreach ( grapheme_iter_by_intl( $s ) as $unit ) {
$count = utf8_codepoint_count_by_intl( $unit );
if ( null === $count ) {
return null;
}
if ( $used + $count > $limit ) {
break;
}
$out .= $unit;
$used += $count;
}
return $out;
}
このコードのポイントは、書記素クラスター単位で取り出したうえで、その unit のコードポイント数を数えていることです。
つまり、切断判断はコードポイント数で行う。
しかし、実際に切る位置は書記素クラスター境界にする。
この 2 段構えです。
grapheme_strlen() と grapheme_substr() で簡易 iterator を作る案
説明用・検証用なら、もっと身近な API で書くこともできます。
grapheme_strlen() で書記素クラスター数を取り、grapheme_substr( $s, $i, 1 ) で 1 unit ずつ取り出す方法です。
function grapheme_iter_by_substr( $s ) {
$len = grapheme_strlen( $s );
if ( false === $len || null === $len ) {
return;
}
for ( $i = 0; $i < $len; $i++ ) {
yield grapheme_substr( $s, $i, 1 );
}
}
これは読みやすいです。
ただし、境界走査の API としては、IntlBreakIterator のほうが直接的です。
実装方針を検討する段階では、IntlBreakIterator 版のほうが「何をしているのか」を説明しやすいと思います。
PCRE \X で fallback を実装する
intl がない環境でも、PCRE の \X を使えば、書記素クラスター単位の走査はある程度可能です。
また、/./us で UTF-8 のコードポイント数を数えることもできます。
Symfony Polyfill などで見かける考え方に近いですが、preg_replace() の count を使えば、マッチ結果の配列を作らずに文字数を数えられます。
まず、コードポイント数を数える関数です。
function utf8_codepoint_count_by_pcre( $s ) {
preg_replace( '/./us', '', $s, -1, $len );
return 0 === $len && '' !== $s ? null : $len;
}
この関数は、valid UTF-8 前提です。
不正 UTF-8 が入る可能性があるなら、先に U+FFFD へ置き換える工程を入れるべきです。
次に、書記素クラスター単位の iterator です。
ここでの grapheme_iter_by_pcre() は現在 draft 段階の str_iter RFC とは単位が違います。
str_iter がコードポイント単位の iterator だとすると、こちらは書記素クラスター単位の iterator です。
function grapheme_iter_by_pcre( $s ) {
$offset = 0;
$length = strlen( $s );
while ( $offset < $length ) {
$matched = preg_match(
'/\G\X/u',
$s,
$matches,
PREG_OFFSET_CAPTURE,
$offset
);
if ( 1 !== $matched ) {
return;
}
$unit = $matches[0][0];
$pos = $matches[0][1];
if ( $pos !== $offset || '' === $unit ) {
return;
}
yield $unit;
$offset += strlen( $unit );
}
}
最後に、2 つを組み合わせます。
function grapheme_take_max_codepoints_by_pcre( $s, $limit ) {
if ( $limit <= 0 ) {
return '';
}
$out = '';
$used = 0;
foreach ( grapheme_iter_by_pcre( $s ) as $unit ) {
$count = utf8_codepoint_count_by_pcre( $unit );
if ( null === $count ) {
return null;
}
if ( $used + $count > $limit ) {
break;
}
$out .= $unit;
$used += $count;
}
return $out;
}
このコードも検証用です。
本番投入するなら、巨大入力、巨大な書記素クラスター、PCRE エラー、不正 UTF-8、環境差を考える必要があります。
PCRE \X fallback の注意点
PCRE \X 版は便利です。
しかし、intl の完全代替とは考えないほうがよいと思っています。
PHP 5.6 から 7.2 までは PCRE 8.x 系です。
PHP 7.3 以降は PCRE2 です。
\X の Unicode 規則は、PCRE / PCRE2 のバージョンや、その Unicode 対応状況に依存します。
絵文字仕様の更新にも影響されます。
また、入力は valid UTF-8 前提です。
不正 UTF-8 を渡した場合の扱いも、intl と同じように考えてはいけません。
さらに、巨大な書記素クラスターへの safety guard も必要です。
たとえば、悪意ある入力や極端な入力で、非常に長い結合文字列が作られる可能性があります。
GUI 表示上は「1文字」に見えても、内部的には大きなデータである場合があります。
つまり、「見た目の文字数」はリソース制限ではありません。
PCRE \X fallback は、実装できることと、長期的に保守できることを分けて考える必要があります。
実装できることと、本体に入れることは違う
ここまでのコードは、それほど大きくありません。
しかし、コードが短いからといって、そのまま入れられるとは限りません。
古い PHP や CMS (WordPress など) との互換性、intl がない環境、PCRE のバージョン差、不正 UTF-8、巨大入力を考えると、見た目以上に保守範囲が広がります。
まとめ
GUI の表示処理では、mb_substr() から grapheme_substr() へ移行するのはかなり自然です。
利用者が画面上で 1 文字として認識する単位を壊さない、という目的なら、grapheme_substr() は説明しやすい API です。
しかし、抜粋処理がさまざまな場所で使われる可能性がある場合は、単純な置き換えだけでは済まないかもしれません。
- 不正 UTF-8 の置き換え。
- 後方互換性。
- コードポイント数、書記素クラスター数、バイト数の上限管理。
- HTML、RSS、OGP、メール、API レスポンスなどの出力先。
- intl がない場合の fallback。
- PCRE / PCRE2 / Unicode バージョン差。
これらを分けて考える必要があります。
grapheme_extract() は難しく見えます。
しかし、type 引数は「どの制約で書記素クラスターを取り出すか」を表している、と考えると理解しやすくなります。
-
GRAPHEME_EXTR_COUNTは書記素クラスター数。 -
GRAPHEME_EXTR_MAXBYTESはバイト数上限。 -
GRAPHEME_EXTR_MAXCHARSは UTF-8 文字数、つまり実務上はコードポイント数に近い上限。
特に GRAPHEME_EXTR_MAXCHARS は、コードポイント数上限を維持しながら、書記素クラスターを壊さない抜粋処理に近い考え方です。
また、grapheme_extract() を直接使わなくても、IntlBreakIterator や PCRE \X を使って、書記素クラスター単位の iterator とコードポイント数カウントに分解して考えることができます。
ただし、fallback を取り込むかどうかは、実装の短さではなく、保守性とテスト体制で判断する必要があります。
今回の実験は、grapheme_extract() の使い方を学ぶだけでなく、小さなライブラリや WP Multibyte Patch のようなプラグインが Unicode の責任をどこまで背負うべきかを考えるためのたたき台でもあります。