【C#】wordwrapや禁則文字に対応しつつ文字を指定幅で折り返す

コードだけ手っ取り早く見たい方はここを参照。
https://github.com/anareta/TextWrapper/blob/master/TextWrapper/TextUtility.cs

 - インデントと折り返し幅を指定すると文章を折り返してくれる
 - wordwrapに対応
 - 禁則文字にも対応
 - サロゲートペアや結合文字にも対応
 - フォントによってはキレイに折り返されずにガタつくかも
 - 分かっているだけでも半角カナが含まれると正しく動かない、他にもあるかも

# 導入部
## 週報を書くのが面倒くさい
【C#】会社への反骨精神からツールを完成させた話
https://qiita.com/yukibacho/items/0265926f607ccf5489b8

> このフォーマットではまず書く意味があるのかと疑問を抱きます。
また最初のうちはOJT指導者などが週報を閲覧している形跡がありましたが入社して1年が過ぎたころには誰も閲覧しなくなりました。

> 週報を書きたくないと理由をつけて説明しても「OJT期間はやろう」の一点張りで、要求を受け入れてくれませんでした。

> これが会社か。。。
> と思いながらもOJT期間中は渋々週報を書くことにしました。

あるあるｗｗｗと思いながら読んでました。自分もだいたい同じような状況で、実際の効果はともかくとして、とにかく儀式として毎週「週報」を提出することを求められています。ちなみにわたしのところはOJT期間限定じゃないです。つらみ。

この記事の方の場合は

 - 週報を所定のフォーマットでテキストデータで作成
 - そのテキストをファイルにしてファイルサーバーに配置

という形で運用されていました。
ところが、自分のところの週報の運用には他にも要件があって、

 - テキストをファイルにしてサーバーに配置した上で上司にメールで送信

というのがあります。なんでサーバーに配置した上でメールまで送らないといけないのか理解に苦しみますが。
で、別に週報のテキストをペッと貼り付けて送信するだけなら楽でいいのですが、もうひとつ、

 - 週報は適切な位置で改行を入れないと上司が怒る

というのがあります。
…いや文字の折返しなんかお使いのツールでなんとかしろよと思わなくもないですが、過去に改行を入れないテキストデータを提出したところ「このような読みにくい週報を出してくるとは、この無礼者が！」と詰られたのでこれは動かせない要件なのです。わたしのような下っ端無礼者エンジニアは上司（いわゆるゴッド）の言うことには逆らえないのです。

長く書きましたが最終的には以下のようなテキストを作る必要があります（中身はダミーテキスト）。

```
○○様

お疲れ様です。○○部○○課のAzothです。
今週の週報をお収めください。

 2020年 1月 17日
 【XXXの開発】
   ■xxxxをxxxxする
   ASCIIは、7桁の2進数で表すことのできる整数の数値のそれぞれに、大小のラテン文字
   や数字、英文でよく使われる約物などを割り当てた文字コードである。1963年6月17日
   に、American Standards Association（ASA、後のANSI）によって制定された。当時
   の規格番号は ASA X3.4 、現在の規格番号は ANSI INCITS 4 である。
   https://www.jisc.go.jp/app/jis/general/GnrJISNumberNameSearchList?show&jisStdNo=X0201

   ■xxxxにxxxのxxxxxをやる
   ASCIIはISO標準7ビット文字コードISO/IEC 646の元となり、後に8ビット文字コード
   であるISO/IEC 8859が主流となって以降、世界中で使用されている様々な文字の符号
   化方式の多くは、ASCIIで使用されていない128番以降の部分に、その他の文字を割り
   当てたものである。

   ■xxxxの準備
   他の文字コードと同じく、ASCIIは整数で表されるデジタルデータと文字集合とが対応
   づけられたコードである。このコードに従い、文字等を整数に変換することで、通信、
   文字情報の処理や保存を行うのが容易になる。ASCIIやASCII互換コードは、ほとんど
   全てのコンピュータ（特にパーソナルコンピュータやワークステーション）で扱うこと
   ができる。MIMEでは、「US-ASCII」とするのが望ましい。

以上、よろしくお願いいたします。
```
手作業で週報を作る場合、エディタに週報の中身を書いてから、このテキストの折り返し位置に改行を入れていく作業をひたすらやっていくわけです。ちなみに改行を入れたあとで誤字脱字に気づいて修正すると、周囲の行の折り返しが全部ずれるので改行を入れ直して調整しないといけません。これが地獄か。

さすがにやっていられないので自動化を考えた、というのがこの記事の趣旨になります。

## 折り返しの要件を整理
折り返しの要件を整理します。

 - テキストは、折り返された位置が縦に揃っているように見える位置で折り返される
 - テキストの各行の先頭に固定幅のインデント（空白）を入れる必要がある

ちょっと考えてみると最初の要件はそもそも実現不可能であるということがわかります。なぜなら文字（グリフ）の幅はフォントによって違うからです。提出はテキストデータなので、そのデータを開くツールがどんなフォントでそれを表示するかはコントロールできません。

…とはいえ、弊社の上司がエディタやメーラーのフォント設定をデフォルトの**MSゴシック**から変えているとは思えないので、もう割り切って「ASCII範囲の文字は幅１」「ASCII範囲外の文字は幅２」として考えます。半角カナ？　知らんなあ。ちゃんとやるならMSゴシックのグリフ幅を取得して幅計算すべきですが、まあ大丈夫でしょう多分。
ちなみに弊社にmacOSを使っている上司はいないのでツールのデフォルトフォントはたいていMSゴシックです。そもそも業務にmacOS使わせてもらえないし。

ところで実際に折り返しをやってみて気づいたのですが、文章に英単語が入っていたときにその途中で折り返されるのは見栄えがかなり悪いです。英単語以外にもURLとかが含まれる行もあるのでその場合は折り返してほしくない。いわゆるwordwrapと呼ばれるやつです。

もうひとつ、文章に含まれる句読点（"。、"）が折り返しの結果行頭に来てしまうのも気になります。あと逆に行末に"「"や"（"が来てしまうパターンもあります。いわゆる禁則文字への対応も必要になります。

 - 半角英数字が連続している部分では折り返さない（いわゆるwordwrap）
 - 行頭に"」）、。"などの文字が来ないように折り返す（行頭禁則）
 - 行末に"「（"などの文字が来ないように折り返す（行末禁則）

# 指定文字の折り返し
というわけで、頑張って作りました。

TextWrapper
https://github.com/anareta/TextWrapper

```csharp
    public static class TextUtility
    {
        /// <summary>
        /// テキストを折り返す
        /// </summary>
        /// <param name="text">元テキスト</param>
        /// <param name="indent">テキスト全体に適用するインデント</param>
        /// <param name="maxWidth">インデントの分を含んだ折り返し幅（ASCII範囲の文字を1、それ以外の文字を2とカウント）</param>
        public static string Wrap(string text, string indent, int maxWidth)
        {
            var result = new StringBuilder();

            var lineWidth = maxWidth;
            if (maxWidth - indent.Width() < maxWidth / 2)
            {
                // インデントが長すぎて文章がmaxWidthの半分にも満たない場合は最低限の長さを確保する
                lineWidth = indent.Width() + maxWidth / 2;
            }

            var index = 0;

            // 改行コードが２文字だと都合が悪いので一時的に置き換え
            var content = text.Trim(Environment.NewLine.ToCharArray()).Replace(Environment.NewLine, "\r");

            while (index < content.Length)
            {
                if (result.Length > 0)
                {
                    result.LineBreak();
                }

                // コンテンツのすべての文字を処理しきるまで行ごとにループ
                var hasNewLine = false;
                var line = new StringBuilder(indent);

                while (true)
                {
                    // １文字ずつlineバッファに追加する

                    var currentChar = content.SafeSubstring(index, 1);

                    if (currentChar == "\r")
                    {
                        // 次に追加する文字が改行コードならループを抜ける（折り返す）
                        hasNewLine = true;
                        ++index; // "\r"は追加せずにスキップ
                        break;
                    }

                    // 1文字分だけ追加
                    line.Append(currentChar);
                    ++index;

                    if (currentChar == "<")
                    {
                        // この記号があったときは終端記号までまとめて追加する
                        var close = content.SafeSubstring(index).IndexOf('>');
                        if (close != -1)
                        {
                            line.Append(content.SafeSubstring(index, close + 1));
                            index += close + 1;
                        }
                    }

                    var nextChar = content.SafeSubstring(index, 1);

                    if (nextChar == "\r")
                    {
                        // 次に追加する文字が改行コードならループを抜ける（折り返す）
                        hasNewLine = true;
                        ++index; // "\r"は追加せずにスキップ
                        break;
                    }

                    if ((line.Width() >= lineWidth || index >= content.Length)
                        &&
                        CanLineBreak(currentChar, nextChar))
                    {
                        break;
                    }
                }

                if (!hasNewLine)
                {
                    // 行頭禁則の対応
                    while (content.SafeSubstring(index, 1).IsStart行頭禁則文字())
                    {
                        line.Append(content.SafeSubstring(index, 1));
                        ++index;
                    }

                    // 行末禁則の対応
                    if (line.ToString().SafeSubstring(line.Length - 1, 1).IsEnd行末禁則文字())
                    {
                        line = new StringBuilder(line.ToString().SafeSubstring(0, line.Length - 1));
                        --index;
                    }
                }

                if (!string.IsNullOrWhiteSpace(line.ToString()))
                {
                    result.Append(line);
                }
            }

            return result.ToString();
        }

        /// <summary>
        /// 前後の文字から折り返しが可能か判定する
        /// </summary>
        /// <param name="prevChar">判定したい位置の１つ前の文字</param>
        /// <param name="nextChar">判定したい位置の１つ後の文字</param>
        private static bool CanLineBreak(string prevChar, string nextChar)
        {
            if (string.IsNullOrWhiteSpace(nextChar))
            {
                // 後続に文字がないか、半角スペースの場合は折り返し可能
                return true;
            }

            // 最後に追加した文字が1バイト文字
            if (prevChar == "\r")
            {
                // 改行コード
                return true;
            }

            if (prevChar.Width() == 1)
            {
                if (nextChar.Width() == 1)
                {
                    // １バイト文字が連続している場合
                    return false;
                }

                // 1バイト文字と2バイト文字の境界
                return true;
            }

            // 最後に追加した文字がマルチバイト文字
            return true;
        }

    }

    internal static class StringEx
    {
        /// <summary>
        /// ASCII範囲の文字を１、ASCII範囲外の文字を２とカウントする
        /// </summary>
        internal static int Width(this string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                return 0;
            }

            var enumerator = StringInfo.GetTextElementEnumerator(s);
            var enc = Encoding.UTF8;
            int count = 0;

            while (enumerator.MoveNext())
            {
                // UTF-8で１バイトならASCII範囲の文字
                count += (enc.GetByteCount(enumerator.GetTextElement()) > 1 ? 2 : 1);
            }

            return count;
        }

        /// <summary>
        /// ASCII範囲の文字を１、ASCII範囲外の文字を２とカウントする
        /// </summary>
        internal static int Width(this StringBuilder s)
        {
            return s.ToString().Width();
        }

        /// <summary>
        ///サロゲートペアや結合文字に対応したLength
        /// </summary>
        public static int LengthInTextElements(this string str)
        {
            return new StringInfo(string.IsNullOrEmpty(str) ? string.Empty : str)
                .LengthInTextElements;
        }

        /// <summary>
        ///サロゲートペアや結合文字に対応したElementAt
        /// </summary>
        public static string ElementAtInTextElements(this string str, int index)
        {
            return new StringInfo(string.IsNullOrEmpty(str) ? string.Empty : str)
                .SubstringByTextElements(index, 1);
        }

        /// <summary>
        ///サロゲートペアや結合文字に対応したSubstring
        /// </summary>
        public static string SubstringByTextElements(this string str, int startingTextElement, int lengthInTextElements)
        {
            return new StringInfo(string.IsNullOrEmpty(str) ? string.Empty : str)
                .SubstringByTextElements(startingTextElement, lengthInTextElements);
        }

        /// <summary>
        ///サロゲートペアや結合文字に対応したSubstring
        /// </summary>
        public static string SubstringByTextElements(this string str, int startingTextElement)
        {
            return new StringInfo(string.IsNullOrEmpty(str) ? string.Empty : str)
                .SubstringByTextElements(startingTextElement);
        }

        /// <summary>
        /// 末尾に改行を追加する
        /// </summary>
        internal static StringBuilder LineBreak(this StringBuilder s, int times = 1)
        {
            for (int i = 0; i < times; i++)
            {
                s.Append(Environment.NewLine);
            }
            return s;
        }

        /// <summary>
        /// 例外を出さないSubstring（例外を出すケースでは空文字を返す）
        /// </summary>
        internal static string SafeSubstring(this string s, int startIndex)
        {
            if (startIndex < 0)
            {
                startIndex = 0;
            }

            if (startIndex > s.LengthInTextElements() - 1)
            {
                return "";
            }

            return s.SubstringByTextElements(startIndex);

        }

        /// <summary>
        /// 例外を出さないSubstring（例外を出すケースでは空文字を返す）
        /// </summary>
        internal static string SafeSubstring(this string s, int startIndex, int length)
        {
            if (startIndex < 0)
            {
                startIndex = 0;
            }

            if (length < 1)
            {
                return "";
            }

            if (startIndex > s.LengthInTextElements() - 1)
            {
                return "";
            }

            if (startIndex + length > s.LengthInTextElements())
            {
                return s;
            }

            return s.SubstringByTextElements(startIndex, length);
        }

        /// <summary>
        /// 文字列が行頭禁則文字から始まっていればtrue
        /// </summary>
        internal static bool IsStart行頭禁則文字(this string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                return false;
            }

            return 行頭禁則.Any(forbidden => s.ElementAtInTextElements(0) == new string(forbidden, 1));
        }

        /// <summary>
        /// 文字列が行末禁則文字で終わっていればtrue
        /// </summary>
        internal static bool IsEnd行末禁則文字(this string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                return false;
            }

            return 行末禁則.Any(forbidden => s.ElementAtInTextElements(s.LengthInTextElements() - 1) == new string(forbidden, 1));
        }

        private static readonly string 行頭禁則 = "。.?!‼⁇⁈⁉,)）]｝、〕〉》」』】〙〗〟’”｠»ゝゞ\"ーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇷ゚ㇺㇻㇼㇽㇾㇿ々〻";

        private static readonly string 行末禁則 = "(（[｛〔〈《「『【〘〖〝‘“｟«\"";

    }
```
長い。構造化されてなくてごめん。

## 解説
基本的な処理としては与えられたテキストを折り返しまでの幅だけとってきてStringBuilderに追加していきます。

### 文字列の操作
文字列を操作する機能はstringクラスに備わっていますが、サロゲートペア等の複数のUTF-16文字で構成される文字に対しては無力です。

英語圏の文字しか使わないのであればあまり考慮する必要はないと思いますが、日本語の文章だとたまにそういう文字が含まれる場合があって考慮しておくほうがいいです。これで絵文字も使える。

```csharp
        /// <summary>
        ///サロゲートペアや結合文字に対応したSubstring
        /// </summary>
        public static string SubstringByTextElements(this string str, int startingTextElement)
        {
            return new StringInfo(string.IsNullOrEmpty(str) ? string.Empty : str)
                .SubstringByTextElements(startingTextElement);
        }
```

### 文字の幅

文字の幅は以下で計算しています。

```csharp

        /// <summary>
        /// ASCII範囲の文字を１、ASCII範囲外の文字を２とカウントする
        /// </summary>
        internal static int Width(this string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                return 0;
            }

            var enumerator = StringInfo.GetTextElementEnumerator(s);
            var enc = Encoding.UTF8;
            int count = 0;

            while (enumerator.MoveNext())
            {
                // UTF-8で１バイトならASCII範囲の文字
                count += (enc.GetByteCount(enumerator.GetTextElement()) > 1 ? 2 : 1);
            }

            return count;
        }
```

中身は、ASCII範囲だったら1、ASCII範囲外だったら2として、文字列の合計幅数を計算します。

文字コードの判別ロジックってけっこう難しかったので、諦めて「UTF-8にして何バイトの領域か」を取得して判定しています。厳密じゃないです。これ書いてるときに「そういえば半角カナってどうなるんだ？」って気づきました。半角カナはUTF-8だと3バイト。
前述しましたが真面目にやるならMSゴシックのグリフ幅を取得して計算するのが確実です。

### 禁則文字
禁則文字の判定はクールな方法が見つからなかったので力技です。

```csharp
        /// <summary>
        /// 文字列が行頭禁則文字から始まっていればtrue
        /// </summary>
        internal static bool IsStart行頭禁則文字(this string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                return false;
            }

            return 行頭禁則.Any(forbidden => s.ElementAtInTextElements(0) == new string(forbidden, 1));
        }

        /// <summary>
        /// 文字列が行末禁則文字で終わっていればtrue
        /// </summary>
        internal static bool IsEnd行末禁則文字(this string s)
        {
            if (string.IsNullOrEmpty(s))
            {
                return false;
            }

            return 行末禁則.Any(forbidden => s.ElementAtInTextElements(s.LengthInTextElements() - 1) == new string(forbidden, 1));
        }

        private static readonly string 行頭禁則 = "。.?!‼⁇⁈⁉,)）]｝、〕〉》」』】〙〗〟’”｠»ゝゞ\"ーァィゥェォッャュョヮヵヶぁぃぅぇぉっゃゅょゎゕゖㇰㇱㇲㇳㇴㇵㇶㇷㇸㇹㇷ゚ㇺㇻㇼㇽㇾㇿ々〻";

        private static readonly string 行末禁則 = "(（[｛〔〈《「『【〘〖〝‘“｟«\"";
```

`IsStart行頭禁則文字`というメソッド名がわたしの投げやりな気持ちを表現している…。

### その他
どういう動きを想定しているかは、プロジェクトに単体テストも含まれてるのでそっちを見てください。

https://github.com/anareta/TextWrapper/blob/master/TextWrapperTest/TextUtilityTest.cs

```csharp
        [TestMethod]
        public void Wrap_空白位置で改行()
        {
            string lineFeed = Environment.NewLine;
            var url1_1 = @"a aa aaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
            var url1_2 = @"bbbbbbbbbbbbbbbbb";
            var url1 = url1_1 + " " + url1_2;
            var url2 = @"nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn";
            var content = url1 + lineFeed + url2;

            var result = TextUtility.Wrap(content, "  ", 30);

            var lines = result.Split(new[] { lineFeed }, StringSplitOptions.None);
            Assert.AreEqual(url1_1, lines[0].Trim());
            Assert.AreEqual(url1_2, lines[1].Trim());
        }
```

英語力がpoorなので単体テストでは日本語を使う派。

# 終わりに
週報書くのがつらいので、週報書かなくてもいい会社を探しています。