【Unity】日本語テキストの自動改行

1 はじめに

こんにちは。ソーシャルゲーム事業部の額田です。 この記事はカヤックUnityアドベントカレンダー2018の21日目の記事です。 今回はTextコンポーネントでの自動改行についてお話ししていきたいと思います。

日本語のテキストを表示するときに文章の区切りとしては不自然な箇所で改行されてしまい、 TextViewのサイズに合わせて\nを自前で入れた経験はないでしょうか? 変更しないテキストならよいですが、セリフなど動的に変更するテキストの場合は、自前で改行文字を入れるのは労力がかかります。 なので、GameObjectのサイズに合わせて、いい感じに改行してくれるTextコンポーネントを作ってみたいと思います。

// Before
我輩は猫である。名前はま
だない。
// After
吾輩は猫である。名前は\n
まだない。

プロジェクトによっては「このTextにはデザインの都合上200文字しか表示したくない」というケースもあります。 そのような場合に、意図しない改行で文字制限を超えてしまうという問題も生じるかと思います。 今回は自動改行をする1つのやり方を紹介するに留め、その辺りは考慮しないこととします。

2 UnityのTextコンポーネント

2.1 いい感じに改行ができない理由

UnityのTextコンポーネントでは、英語のように単語と単語の間に空白がある言語は空白を区切りにして改行が行われます。

f:id:nu-nuchi:20181219111250g:plain

しかし、日本語や中国語、韓国語など空白を使わない言語の場合は改行をする目印がありません。 そのため、1 はじめに で述べたように不自然な位置で改行が行われてしまいます。

f:id:nu-nuchi:20181219111307g:plain

2.2 改行をするためには?

わかち書き

わかち書きとは空白を用いて単語や文節を区切って記述する方法です。 昔の某RPGゲームなどを遊んだことがある人には懐かしい文章だと思います。 ひらがな文章の場合に使われたりしますが、通常は使われない記述方法です。

空白で明示的に区切り位置を指定することができ、英語文章と同じく空白区切りでの改行をすることが可能になります。 ですが、可読性はよくないためオススメしません。

f:id:nu-nuchi:20181219111335g:plain

あきらめて かわいさ ろせんを めざし この ほうほうで げーむを つくるのも ありかも しれません。

日本語に不慣れな外国人あるいは日本語学習者がひらがなを主体としたわかち書きの文章を用いる例があり[2]、そのたどたどしいさまが、けなげさ、可愛らしさにつながりより深い共感を生む場合がある[3]。

Wikipedia - わかち書き

形態素解析

空白を用いずに単語や文節の単位で区切って改行をするためには、文章の中でどこからどこまでが意味のまとまった語句なのかを知る必要があります。

「が」「を」「に」などを目印に意味のまとまりを抽出するという方法も考えられますが、 日本語は複雑であるためアドホックな方法では限界があります。

これを実現するには 形態素解析 を行う方法があります。 簡単に言うと、文章を形態素と呼ばれるその言語の最小単位に分解して、それぞれの品詞が何かというのを解析します。

形態素解析を行うことで、意味のある最小単位で区切ることができます。 また、品詞情報を使うことで文節単位で改行をするなどより柔軟な改行も可能になると思います。

文字列読み原形品詞の種類活用の種類活用形
お待ちオマチお待ち名詞-サ変接続  
する動詞-自立サ変・スル連用形
助詞-接続助詞  
おりオリおる動詞-非自立五段・ラ行連用形
ますマスます助動詞特殊・マス基本形
記号-句点  
Wikipedia - わかち書き

実際にUnityで形態素解析をする方法については3 形態素解析で紹介し、 4 自動改行させようではTextコンポーネントを継承したクラスで簡易的に自動改行を行ってみたいと思います。

3 形態素解析

3.1 MeCabとは

形態素解析を行うオープンソースにMeCabがあります。 今回はUnity上で行うために、MeCabを.NETに移植したNMeCabを使用します。

導入と使い方について、本記事だけで読み進められるよう簡単な説明をしていきますが、 以下のサイトを参考にしていただくとよいかと思います。 - Unityで形態素解析をする方法 - Qiita - NMeCabで形態素解析をしてみよう - Qiita

3.1 導入

NMeCabの最新版(現時点ではNMeCab 0.07)を ここからダウンロードしてきます。 必要なのは以下の2つです。 - NMeCab0.07/dic - NMeCab0.07/bin/LibNMeCab.dll

適当にNMeCabディレクトリなどをUnity側に用意して、これらを入れておきましょう。

f:id:nu-nuchi:20181219111303p:plain

3.2 使い方

まずは簡単なサンプルコードを動かしてみましょう。

  1. MeCabParamに形態素解析で使用する辞書を設定します。 先ほど配置したNMeCab/dic/ipadicを指定します。 なお、今回はUnityEditor上での実行を想定しています。 必要に応じて適切なファイルの配置とパスの指定を行なってください
MeCabParam param = new MeCabParam();
param.DicDir = @"Assets/NMeCab/dic/ipadic";
  1. 文章を解析するためのMeCabTaggerを生成し、文章を解析して形態素に分解します。 各形態素は双方向リストになっており、node.Nextに次のノードの参照が入っています。 node.Surfaceには形態素の文字列が入っています。 node.Featureには解析結果として、素性(品詞や活用形などの解析情報)がCSV形式の文字列で入っています。
MeCabTagger tagger = MeCabTagger.Create(param);
string text = "吾輩は猫である。名前はまだない。";
MeCabNode node = tagger.ParseToNode(text);
while (node != null)
{
    // 文頭と文末にIDのない空ノードがあるためそれ以外を対象とする
    if (node.PosId != 0)
    {
        Debug.Log(node.Surface + "," + node.Feature);
    }
    node = node.Next;
}

以下が全ソースコードです。これを空のGameObjectにアタッチしてUnityを実行してみましょう。

using UnityEngine;
using NMeCab;

public class Sample : MonoBehaviour
{
    void Start()
    {
        MeCabParam param = new MeCabParam();
        // 導入で置いたdic以下の辞書ディレクトリを指定
        param.DicDir = @"Assets/NMeCab/dic/ipadic";
        MeCabTagger tagger = MeCabTagger.Create(param);

        string text = "吾輩は猫である。名前はまだない。";
        MeCabNode node = tagger.ParseToNode(text);
        while (node != null)
        {
            // 文頭と文末にIDのない空ノードがあるためそれ以外を対象とする
            if (node.PosId != 0)
            {
                Debug.Log(node.Surface + "," + node.Feature);
            }
            node = node.Next;
        }
    }
}

f:id:nu-nuchi:20181219111316p:plain

4 自動改行させよう

それでは実際にTextコンポーネントを継承して、新しいコンポーネントを作っていこうと思います。

4.1 サンプルコード

Textが継承しているGraphicクラスに ExecuteInEditMode 属性がついているため、Updateメソッドに改行処理を書けばEditorで動的に改行されるのが確認できます。

以下がソースコードです。Textコンポーネントの代わりにこのコンポーネントをアタッチして動かしてみてください。

using System.Text;
using UnityEngine;
using UnityEngine.UI;
using UnityEditor;
using NMeCab;
using NMeCab.Extension.IpaDic;

public class WordWrapText : Text
{
    private MeCabTagger _tagger;
    private MeCabNode _node;
    private Rect _rect;

    /// <summary>
    /// Textの横幅
    /// </summary>
    private float _rectWidth { get { return this.rectTransform.sizeDelta.x; } }

    /// <summary>
    /// テキストを更新したときに形態素解析する
    /// </summary>
    public override string text
    {
        set
        {
            base.text = value;
            _node = Parse(value);
        }
    }

    /// <summary>
    /// Inspectorからテキストを変更した際に形態素解析する
    /// </summary>
    private void OnValidate()
    {
        _node = Parse(this.text);
    }

    private void Awake()
    {
        _node = Parse(this.text);
    }

    private void SetTagger()
    {
        MeCabParam param = new MeCabParam();
        param.DicDir = @"Assets/NMecab/dic/ipadic";
        _tagger = MeCabTagger.Create(param);
    }

    private MeCabNode Parse(string text)
    {
        if (_tagger == null) { SetTagger(); }
        return _tagger.ParseToNode(text.Replace("\n", ""));
    }

    /// <summary>
    /// preferredWidthを計算する
    /// [参考URL] https://bitbucket.org/Unity-Technologies/ui/src/9f418c4767c47d0c71f1727eb42a9a9024e9ecc0/UnityEngine.UI/UI/Core/Text.cs?fileviewer=file-view-default#Text.cs-502:509
    /// </summary>
    private float CalcPreferredWidth(string text)
    {
        var settings = GetGenerationSettings(Vector2.zero);
        return cachedTextGeneratorForLayout.GetPreferredWidth(text, settings) / pixelsPerUnit;
    }

    private void Update()
    {
        if (_node == null) { return; }

        // rectに変更がなければ更新を行わない
        if (rectTransform.rect.Equals(_rect)) { return; }
        _rect = rectTransform.rect;

        // 改行文字を入れて、形態素の文字列を連結していく
        MeCabNode node = _node;
        StringBuilder builder = new StringBuilder();
        string accumulator = string.Empty;
        while (node != null)
        {
            if (node.PosId != 0)
            {
                // 改行する長さになるまでaccumulatorに蓄積していく
                string newText = accumulator + node.Surface;
                if (CalcPreferredWidth(newText) < _rectWidth)
                {
                    accumulator = newText;
                }
                else
                {
                    builder.AppendLine(accumulator);
                    accumulator = node.Surface;
                }
            }
            node = node.Next;
        }
        builder.Append(accumulator);
        this.text = builder.ToString();
    }
}

f:id:nu-nuchi:20181219111344g:plain

比較として、以下が通常のTextコンポーネントです。

f:id:nu-nuchi:20181219111319g:plain

4.2 解説

横幅に文字列が収まるかどうかは preferredWidth とRectTransformの横幅を比較しています。 TextのpreferredWidthは自身のtextのサイズを計算するため、実際にテキストを更新するまで取得できません。 そこで、任意の文字列に対してpreferredWidthを計算するためのCalcPreferredWidthを定義しました。 node.Surfaceを追加していき、まだ収まるなら蓄積し、収まらなくなったら改行文字を入れています。

// 改行する長さになるまでaccumulatorに蓄積していく
string newText = accumulator + node.Surface;
if (CalcPreferredWidth(newText) < _rectWidth)
{
    accumulator = newText;
}
else
{
    builder.AppendLine(accumulator);
    accumulator = node.Surface;
}

4.3 発展

4 自動改行させようのコンポーネントでは句読点も改行の区切り対象になっています。 そのため、「。」だけであっても改行されてしまっています。 日本語の文書では、このように行頭に句読点が位置するのは避けるべきとされています。 そのために字詰などをすることを 禁則処理 と言います。

形態素解析により文章を最小の単位に分解し、それらの品詞を取得することで禁則処理やより柔軟な改行を行うことができます。 今回紹介したコンポーネントを改良して禁則処理を追加してみても面白いかもしれません! 興味のある方はぜひやってみてください!

(MeCabでは「。」や「、」は品詞が記号となっています。 Tipsでは、形態素の品詞の取得の仕方について簡単に補足しています。)

Tips

◆NMeCabの品詞の取得の仕方 形態素の品詞はnode.FeatureにCSV形式で入っています。 それを","で分割して品詞の入っているカラムを取り出してもいいですが、 NMeCabに拡張が入っているのでそれを使ってみましょう。

拡張のソースコードは3.1 導入でダウンロードした NMeCab0.07/src/LibNMeCab/Extensionに入っているので、UnityのAssets/NMeCabに入れます。

拡張のソースコードは#if EXT ~ #endifで囲われているので、#defineディレクティブにEXTを追加しましょう。 ここ を参考にPlayerSettingsのOtherSettingsパネルから追加してください。

NMeCabNodeにGetPartsOfSpeechという拡張メソッドが追加され、簡単に品詞を取得することができるようになりました。

using NMeCab.Extension.IpaDic;

string sentence = "今日はいい天気です。";
var node = _tagger.ParseToNode(sentence);
while (node != null)
{
    if (node.PosId != 0)
    {
        var surface = node.Surface;
        var partsOfSpeech = node.GetPartsOfSpeech();
        Debug.Log(surface + ":" + partsOfSpeech);
    }
    node = node.Next;
}

f:id:nu-nuchi:20181219111259p:plain

おわりに

Unityで日本語の自動改行をMeCabを使ってやってみました。 今回はHowToということで確認用にUpdateで書いていたり、モバイルでの動作は考慮していないので実用化するにはもう一手間かかると思いますが、興味を持っていただければ幸いです。

明日は藤井さんの「簡単に水に近い表現を実現したい」です!