マルチバイト対応の文字列折り返し処理

一行n文字で自動的に折り返しする処理が必要になったのでその辺から拾って済ませようと思ったら、マルチバイト文字をきちんと処理できないものばかりで困った。特に半角カナとか記号が鬼門。


というわけで仕方なく自分で作った。
業務で必要なものだったから就業中にやりゃいいのに、技術者魂に火がついて3時過ぎまでコード書いてたので眠い。

仕様

  • 文字列または文字列の配列を引数に取る
    • 配列の場合は各要素が行とみなす(2要素なら2行の文字列)
    • 文字列には改行が含まれていても良い(CR, LF混合対応)
    • 改行が含まれた文字列の配列でももちろん良い
    • 文字列中の改行は尊重される(一端結合などはせずに、改行位置で改行される)
  • 任意の文字エンコーディングの文字列を渡すことができる
  • 一行の幅はバイト数で指定する(見た目の文字数ではない。全角=半角2文字とは限らない)
    • 一行の幅は4以上でなければならない(これは適当かつ微妙な仕様)
  • 日本語禁則処理は行わない(行頭禁止、行末禁止、分離禁止等)
  • ワードラップも行わない
  • 濁点/半濁点に限り、分離されないように処理する
  • 結果は各行の文字列を配列にしたものとなる(改行の入った文字列ではない)

注意が必要なのは文字エンコーディングとバイト数あたり。
内部エンコーディングと異なるエンコーディングで処理させたい場合は、あらかじめmb_convert_encoding()等で変換したものを渡す必要がある。
指定したエンコーディングに勝手に変換して処理してくれるわけではない。渡した文字列が何であるかを指定するのが$encodingパラメータである。これは他のmb系関数と同じ仕様。


見た目の文字数ではなくバイト数で、ってのは全角=半角2文字分とは限らないことを意味する。
例えばEUC-JPの場合、補助漢字は3バイトだし半角カナは2バイトなので、半角カナだらけの文字列を幅10で処理すると5文字ずつ改行されることになる。これは期待に反した結果かもしれない。Shift_JISとかにすればおおよそ期待通りになるんじゃないかな?かな?
実はmb_strwidth()とかいう関数を使うとその辺もうまく処理できそうなんだけど、いまいち信頼できないし、一行に許されるバイト数に収まるという仕様を尊重した。
そのうち見た目のほうで合わせるオプションとかつけてもいいかもしれない。

実装

<?php
/**
 * マルチバイト文字を考慮したfolding(折り畳み)処理
 *
 * @param mixed $str foldingを行う文字列or文字列の配列
 *                   文字列に改行が含まれている場合は改行位置でも分割される
 * @param integer $width 一行の幅(バイト数)。4以上でなければならない
 * @param string $encoding $strの文字エンコーディング
 *                         省略した場合は内部文字エンコーディングを使用する
 * @return array 一行ずつに分けた文字列の配列
 *
 * NOTE: いわゆる半角/全角といった見た目ではなく、
 *       バイト数によって処理が行われるので、文字エンコーディングによって
 *       結果が変わる可能性がある。
 *
 *       例えば半角カナはShift-JISでは1バイトだが、EUC-JPでは2バイトなので、
 *       $width=10の場合Shift-JISなら10文字だが、EUC-JPでは5文字になる。
 *
 *       全角/半角といった見た目で処理をするにはmb_strwidth()を利用した
 *       実装が必要となる。
 *
 * TODO: 日本語禁則処理(Japanese Hyphenation)
 *       行頭禁則文字は濁点/半濁点の応用でいけるので
 *       行末禁則文字の処理を加えれば対応できそう
 *
 *       ……と思ったけど、禁則文字が$widthを超える分だけ並んでたら
 *       どうすればいいんだろう
 *       禁則処理をした結果、桁あふれを起こす場合は禁則処理を無視して
 *       強制的に$widthで改行する、とか?
 */
function mb_fold($str, $width, $encoding = null)
{
    assert('$width >= 4');

    if (!isset($str)) {
        return null;
    }

    if (!isset($encoding)) {
        $encoding = mb_internal_encoding();
    }

    // 元々の配列も文字列中の改行もとにかく展開してひとつの配列にする
    $strings = array();
    foreach ((array)$str as $s) {
        // NOTE: 何故かmb_split()だと改行でうまく分割できない
        //       どうせメジャーなエンコーディングなら制御コードは
        //       leading byteにもtrailing byteにもかぶらないので
        //       preg_split()で良しとする ※JISはアウト
        // NOTE: mb_regex_encoding()を適切に設定してやることで
        //       mb_split()でも正常に分割できるようになったが、
        //       何故かmb_regex_encoding()がJISを受け入れてくれない
        $strings = array_merge($strings,
                        preg_split('/\x0d\x0a|\x0d|\x0a/', $s));
    }

    $lines = array();
    foreach ($strings as $string) {
        // 1文字ずつに分解して足していって、
        // バイト数が$widthを超えたら次の行に回す
        $len = mb_strlen($string, $encoding);
        for ($i = 0, $line = ''; $i < $len; $i++) {
            $char = mb_substr($string, $i, 1, $encoding);

            // 濁点や半濁点が続いていた場合のいい加減な禁則処理
            // ものすごく日本語依存...
            // TODO: Unicodeの結合文字の判定とかで汎用的に処理したい
            if ($i + 1 < $len) {
                $next = mb_substr($string, $i + 1, 1, $encoding);
                $uc = mb_convert_encoding($next, 'UCS-2', $encoding);
                if (in_array($uc, array("\x30\x99", "\x30\x9B", "\x30\x9C",
                                        "\xFF\x9E", "\xFF\x9F")))
                {
                    $char .= $next;
                    $i++;
                }
            }

            if (strlen($line . $char) > $width) {
                $lines[] = $line;
                $line = $char;
            } else {
                $line .= $char;
            }

        }
        $lines[] = $line;   // 端数or空行
    }

    return $lines;
}
?>
実行

テストの都合上、機種依存文字とか半角カナが含まれているので、正常に表示されない場合があります。
(テキストファイルで置きました)

ちなみに$strの'テスト'の直後は丸付き数字の1で、'漢字かな'の直後は(株)なんですが、'?'になってしまうっぽい(実際の処理は大丈夫です)。

mb_internal_encoding('eucJP-win');

$str   = 'テスト?:■漢字かな?イロイロ混じりASCII〜123半角カナもアリです。' . "\n\n"
       . '改行入り' . "\r\n" . 'もドンと来い!';
$str2  = 'ガギグゲゴパピプペポ濁点や半濁点の禁則処理もやっつけ対応';
$width = 11;

$str   = mb_convert_encoding($str,  'CP932');
$str2  = mb_convert_encoding($str2, 'CP932');

//$folded = mb_fold($str, $width, 'CP932');
$folded = mb_fold(array($str, $str2), $width, 'CP932');

// 内部エンコーディングに戻す
mb_convert_variables(mb_internal_encoding(), 'CP932', $folded);

// ルーラー
for ($i = 1; $i <= $width; $i++) {
    echo ($i % 10);
}
echo PHP_EOL . str_repeat('-', $width) . PHP_EOL;

// 結果出力
foreach ($folded as $line) {
    echo $line . PHP_EOL;
}
実行結果
12345678901
                    • -
テスト?:■ 漢字かな?イ ロイロ混じりAS CII〜123半 角カナもアリで す。 改行入り もドンと来い ! ガギグゲゴ パピプペポ 濁点や半濁 点の禁則処 理もやっつ け対応

課題

  • やはり日本語禁則処理はオプションで欲しい
    • 濁点/半濁点を応用すれば簡単かと思ったけど、意外に泥臭くて面倒くさかった
    • 禁則文字が一行文の幅を超える場合とか考えるのが面倒くさいので考えてない
  • 日本語の濁点/半濁点に限らず、Unicode的に結合(されうる)文字が分離されないような汎用的なロジックにしたい
    • つーか、濁点/半濁点の判定もかなり怪しい
  • 見た目的に全角=半角2文字分にするオプションも欲しい
  • 幅が最低4バイト以上ってのは、Unicodeで最低1文字文以上ないと処理が破綻するよね、とか思って決めたんだけど結合文字とかサロゲートペアとか考えると実は足りてない
  • preg_split()で大丈夫なのか気になる