WordPressタイトルの不自然な改行を
解決するプラグインを作った
(Chiilabo Title Auto Turn)

ブログのタイトルが変なところで改行されて、読みにくくなることありませんか?
私のサイトでは「OneNoteの2つのバージョンの違いと統一さ/れる理由」のように、助詞が次の行に追いやられてしまう問題がありました。

CSSの word-breakoverflow-wrap では解決できないこの問題に、WordPressプラグインで挑戦した記録です。

関連記事

1. 最初のアプローチ:意味を考慮した改行

単純に文字数で区切るのではなく、日本語として自然な位置で改行したい。
そう考えて、最初は以下のような仕様を立てました。

  • 括弧や引用符の中は分断しない
  • 固有名詞(「macOS」「ChatGPT」など)は保護する
  • 改行候補にスコアを付けて評価する

たとえば句読点の後は改行しやすいので +20点、助詞1文字が行頭に来るなら -60点といった具合です。

改行候補のスコアリング + 句読点の後 +20点 + 【…】の直後 +40点 助詞1文字が行頭 -60点 固有名詞保護 macOS, ChatGPT スコアで改行位置の自然さを評価 最も高いスコアの位置で改行
// 改行候補点にスコアを付与
private function score_candidate($text, $pos, $protected_ranges) {
    $score = 0;
    
    // 句読点・中黒の後なら加点
    if ($this->is_punctuation($text, $pos - 1)) {
        $score += 20;
    }
    
    // 【...】の直後なら強く推奨
    if ($pos > 0 && mb_substr($text, $pos - 1, 1) === '】') {
        $score += 40;
    }
    
    // 助詞1文字が行頭に来るなら減点
    if ($this->is_short_particle($text, $pos)) {
        $score -= 60;
    }
    
    return $score;
}
Code language: PHP (php)

1.1. 方針転換:レスポンシブを諦めた理由

このスコアリングをPHP側で行い、画面幅に応じた改行位置の決定はJavaScript側で行う。そんな分業体制を想定していました。

方針転換:レスポンシブ対応を諦める 当初の計画 ✓ 意味を考慮した改行 ✓ 画面幅で動的変更 ✗ ロジック複雑化 最終的な方針 ✓ 固定文字数で決定 ✓ PHP側で完全制御 ✓ 実装シンプル化 レスポンシブ対応の問題点 • ロジック複雑化でデバッグ困難 • 環境により結果が変わる → 再現性低下

実装を進めるうちに、この設計には問題があることに気づきます。

画面幅に応じて動的に改行位置を変えようとすると、ロジックが複雑になりすぎる。
デバッグも難しく、同じタイトルでも環境によって結果が変わってしまう。
再現性が低いと、改行位置の調整も手探りになります。

そこで思い切って、レスポンシブ対応を諦めることにしました。

代わりに「1行の最大文字数」を管理画面で指定できるようにし、PHP側で改行位置を完全に決定する方式に切り替えたんです。静的に解決することで、実装もシンプルになり、結果の予測もしやすくなりました。

2. 動的計画法(DP)で各行のバランスを取る

改行位置を決める際、最初は単純な貪欲法を使っていました。
前から順に「ここまでなら入る」という位置で改行する方法です1

ただこれだと、最初の行だけ長くて残りが短い、といった偏りが生じます。

動的計画法で行のバランスを均等化 貪欲法の問題 最初の行だけ長い 動的計画法の解決 各行の長さが均等 動的計画法のアプローチ 1. 全体を見渡して最適な改行位置を選択 2. 各行の長さの偏りを最小化 ※ 候補不足時は貪欲法にフォールバック

そこで動的計画法(DP)を導入しました2
全体を見渡して、各行の長さができるだけ均等になるように改行位置を選ぶアルゴリズムです。
これにより、見た目のバランスが大きく改善しました。

private function select_breaks_dp($text, $scores, $max_chars) {
    $len = mb_strlen($text, 'UTF-8');
    $dp = array_fill(0, $len + 1, INF);
    $parent = array_fill(0, $len + 1, -1);
    $dp[0] = 0;
    
    // 各位置から次の改行位置を探索
    for ($i = 0; $i < $len; $i++) {
        if ($dp[$i] === INF) continue;
        
        for ($j = $i + 1; $j <= $len; $j++) {
            $line_text = mb_substr($text, $i, $j - $i, 'UTF-8');
            $width = $this->text_width($line_text);
            
            // 最大幅を超えたら打ち切り
            if ($width > $max_chars) break;
            
            // コスト計算:行幅の偏りとスコアを考慮
            $cost = $dp[$i] + pow($max_chars - $width, 2);
            if (isset($scores[$j])) {
                $cost -= $scores[$j]; // スコアが高いほど優先
            }
            
            if ($cost < $dp[$j]) {
                $dp[$j] = $cost;
                $parent[$j] = $i;
            }
        }
    }
    
    // 経路を復元して改行位置を確定
    $breaks = [];
    $pos = $len;
    while ($parent[$pos] !== -1) {
        $breaks[$pos] = true;
        $pos = $parent[$pos];
    }
    
    return $breaks;
}
Code language: PHP (php)

ただし、改行候補が少なすぎる場合はDPでも最適解が見つからないことがあります。
そんなときのフォールバックとして、貪欲法に切り替える仕組みも入れています。

2.1. 特有のルールを追加

実装を進める中で、いくつか特有の処理が必要だとわかりました。

たとえば「【Bootstrapper】OneNoteを入れようとしたら…」のように、角括弧のプレフィックスが付いている場合。
これは の直後で必ず改行したほうが読みやすい。

// 【...】プレフィックスを強制改行
if (preg_match('/^【[^】]+】/u', $text, $match)) {
    $forced_break = mb_strlen($match[0], 'UTF-8');
    $breaks[$forced_break] = true;
}
Code language: PHP (php)
2.1. 特有のルールを追加

また、末尾に「(透過ウィンドウとマイク入力)」のような補足が付く場合も、括弧の前で改行すると体裁が整います。

// 末尾の括弧ブロックを独立行にする
if (preg_match('/([^)]+)\s*$/u', $text, $match, PREG_OFFSET_CAPTURE)) {
    $pos = $this->byte_to_char_index($text, $match[0][1]);
    $breaks[$pos] = true;
}
Code language: PHP (php)
2.1. 特有のルールを追加

こうした文章構造を考慮したルールを、少しずつ追加していきました。

2.2. 管理画面でプレビュー確認

実装が進んでくると、「この改行位置で本当に良いのか?」を確認したくなります。

そこで管理画面にプレビュー機能を追加しました。

2.2. 管理画面でプレビュー確認

サンプルタイトルを入力すると、改行結果と各行の文字数が表示されます。
「最大文字数: 20 | 行1: 18.6 / 行2: 19.2」といった具合です。

半角文字は0.6、全角文字は1.0としてカウントしているので、小数点以下も表示されます。

AJAX経由でPHPの改行ロジックを呼び出しているので、フロント側と同じ結果が確認できるのもポイントです。

3. 技術的な詰まりポイント

開発中にいくつか壁にぶつかりました。主なものを振り返ります。

3.1. 行幅が指定を超える問題

DPで改行位置を決めても、実際の行幅が max_chars を超えるケースがありました。
改行候補が少なすぎて分割できない場合です。

これには二段構えで対処しました。
まずDPが失敗したら候補点を増やして再挑戦。
それでもダメなら貪欲法にフォールバックします。

private function ensure_max_width_breaks($text, $scores, $max_chars, $breaks) {
    // 各行の幅を計算
    $widths = $this->calculate_line_widths($text, $breaks);
    
    // 最大幅を超える行があれば貪欲法に切り替え
    foreach ($widths as $width) {
        if ($width > $max_chars) {
            return $this->greedy_breaks($text, $scores, $max_chars);
        }
    }
    
    return $breaks;
}
Code language: PHP (php)

3.2. HTMLタグとSVGの扱い

タイトルにアイコン用の <svg> タグが含まれている場合、これを解析対象から除外する必要がありました。
タイトルをセグメントに分割し、テキスト部分だけ解析してから再合成する仕組みを入れています。

4. 現在の構成とこれから

最終的に、以下のような構成に落ち着きました。

  • PHP側で改行位置を完全に決定
  • <br class="chiilabo-br is-on"> として出力
  • JavaScript側の閾値探索機能は削除
  • 管理画面でプレビューと文字数調整
// is-on の場所だけ <br> を出力
if (isset($breaks_on[$global_index])) {
    $result .= '<br class="chiilabo-br is-on">';
}
Code language: PHP (php)

実際の出力例:

<h1 class="entry-title">
  【Bootstrapper】<br class="chiilabo-br is-on">
  OneNoteを入れようとしたら<br class="chiilabo-br is-on">
  「Microsoft 365 and Office」の<br class="chiilabo-br is-on">
  「許可」が出てきた
</h1>
Code language: HTML, XML (xml)

当初想定していた「サーバーが意味を判断、クライアントがレイアウトを決定」という分業体制からは離れましたが、実装のシンプルさと再現性を重視した結果です。

レスポンシブ対応を諦めたことで、スマホとPCで改行位置が同じになるという制約はあります。ただ、タイトルは比較的短いテキストなので、実用上は問題ないと判断しました。

改行ロジックの精度向上や、固有名詞辞書の拡充など、まだ改善の余地はあります。でも、最初に感じていた「統一され/る理由」問題は、確実に解決できました。

  1. 貪欲法は、各段階で局所的に最も良い選択を行うアルゴリズム設計手法です。動的計画法と異なり、一度選択した要素を再考することはなく、必ずしも最適解が得られる保証はありませんが、実装が簡単で計算効率が良い特徴があります。 – 貪欲法 – Wikipedia
  2. 動的計画法は、複雑な問題を小さな部分問題に分割し、各部分問題の解を記録して再利用することで全体の最適解を求める手法です。最短経路問題、ナップサック問題、文字列処理など幅広い分野で応用されています。 – 動的計画法 – Wikipedia