Loading [MathJax]/extensions/tex2jax.js

2014年12月25日木曜日

LINQの重箱の隅

C#の便利な言語機能のLINQですが、ちょっと気になったことがあったのでいくつか試したり、まとめてみました。LINQの入門が終わった人が次に読むくらいの中身のつもりです。

AsEnumerable

LINQにはAsEnumerableというメソッドがあります。MSDNには、LINQの拡張メソッドと同じ名前のpublicなメソッドを持つクラスでその拡張メソッドを呼び出したいときにこれを使うと書いています。
例えばListクラスにはReverse()というメソッドがあります。これはLINQのReverse()メソッドと(表記上の)パラメーターが同じなので区別できず、そういう時は優先的にListクラス側のReverse()が呼ばれるようになっています。そういう時に、AsEnumerableを使えばIEnumerableになるので、LINQのReverse()が呼べるようになるよというためのメソッドのようです。
もちろん、AsEnumerableを使わなくても、例えばEnumerable.Reverse(hoge);と書いたり、((IEnumerable<hogeT>)hoge).Reverse();と書いたりすることでLINQ側のReverseを呼び出せるようになりますが、こういう書き方よりAsEnumerableのほうがLINQらしい書き方ですね。



ところで、ここで気になったことが1つありました。
例えば、なんかのクラスでIEnumerable<T>型の読み取り専用プロパティみたいなのを定義することがあったとしましょう。そのようなプロパティの値は、クラス内でprivateなListなどで管理していることがよくあります。そんな時、

1
2
3
4
5
6
List<int> value = new List<int>();
 
public IEnumerable<int> Value
{
    get { return value; }
}

みたいな書き方をしてしまうと、このクラスの利用者側は、

1
(List<int>)hoge.Value).Add(13);

というキャストさえすれば本来privateなはずのvalueをクラス外から自由に操作できてしまいます。これはもしもこうやって操作されることを意図していなかったのなら、このようなプロパティの実装のしかたは大問題ですよね。

ここで、AsEnumerableがあるんですよ~。

というのは嘘です。
ここでAsEnumerableを使うことができたらすごくそれらしい書き方で便利なんですが、AsEnumerableの内部実装としては、私が試してみたところ、単にIEnumerable<T>にキャストされているだけっぽいです。
本当に、上記のMSDNに書いてあるような使い方にしか使えないっぽいですね。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
List<string> hoge = new List<string>();
IEnumerable<string> foo;
 
try {
    foo = hoge.AsEnumerable();
    ((List<string>)foo).Add("hoge");
    Console.WriteLine("OK");
}
catch(InvalidCastException e) {
    Console.WriteLine(e.Message);
}
 
try {
    foo = hoge.Select(p => p);
    ((List<string>)foo).Add("foo");
    Console.WriteLine("OK");
}
catch(InvalidCastException e) {
    Console.WriteLine(e.Message);
}

このコードの実行結果は
OK
型 'WhereSelectListIterator`2[System.String,System.String]' のオブジェクトを型 'System.Collections.Generic.List`1[System.String]' にキャストできません。
となります。AsEnumerableはListへのキャストに成功してしまうんですね。

なので、外から無理やりいじられないプロパティ等を実装したかったら、上記のようにSelect(p => p);を使うか、もしくはyield returnで書いてしまうかになりそうですね。

Cast<T>()とOfType<T>()

比較的マイナーだと思われるメソッドなので取り上げました。Cast<T>()OfType<T>()はLINQの中では珍しく型推論がきかないメソッドです。明示的に型を示してあげなければなりません。
どちらもIEnumerable型(IEnumerable<T>ではない)の要素を型パラメーターの型に変換してIEnumerable<T>にして返すメソッドです。Cast<T>()は変換できない要素があると例外を発生させ、OfType<T>()は変換できない要素は省いたコレクションを返します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
System.Collections.ArrayList nongeneric = new System.Collections.ArrayList();
 
nongeneric.Add(123);
nongeneric.Add("hoge");
nongeneric.Add("foo");
nongeneric.Add(456);
 
foreach(var o in nongeneric)
    Console.WriteLine(o);
Console.WriteLine("-----");
foreach(var o in nongeneric.OfType<int>())
    Console.WriteLine(o);
Console.WriteLine("-----");
try {
    foreach(var o in nongeneric.Cast<int>())
        Console.WriteLine(o);
}
catch(InvalidCastException e) {
    Console.WriteLine(e.Message);
}

こんなコードを書いて実行すると、実行結果は下のようになります。
123
hoge
foo
456
-----
123
456
-----
123
指定されたキャストは有効ではありません。
非ジェネリックのコレクションなんて使う機会はほとんど無い気がしますが、例えば正規表現周りのクラスにあるMatchCollectionなんかは非ジェネリックなので(これはジェネリックスが導入される前の.NET1.1の時代からあるクラスだから非ジェネリックのまま今まで来てしまったものだと私は勝手に思っています)、これをLINQで扱いたいときはCast<T>()を使うと便利です。

要素のインデックス

要素のインデックスが欲しいことっていうのも多々あります。ElementAt()を使うと任意のインデックスの要素を取得することはできますが、逆にある要素のインデックスを取得したいときなんかはそれに見合ったメソッドはありません。
で、どうするかと言うと、Selectのオーバーロードを使います。例えば、シーケンスの中から条件に一致する要素のインデックスを取得するには、このような書き方で解決できます。

1
2
3
4
5
6
7
8
9
IEnumerable<string> hoge = new[] {"apple", "orange", "application", "operating system"};
 
var indeces = hoge
    .Select((p, i) => new { Content = p, Index = i })
    .Where(p => p.Content.StartsWith("app"))
    .Select(p => p.Index);
 
foreach(int i in indeces)
    Console.WriteLine(i);

Selectメソッドはインデックスも与えてくれるオーバーロードがあるので、それで要素とインデックスを抱き合わせた匿名クラスを作ってあげればあとはWhereなどで任意のフィルターを掛けたりすることができます。

隣り合う要素

これは個人的に自分が情弱だっただけなんでしょうが、長い間、コレクションの隣り合った要素同士の関係に関して何かしら計算するのが、スマートにできずにいました。上に要素のインデックスを得る方法を書いたのはこの記事の布石だったのですが、私は長い間、要素とJoinを使う方法を取っていましたが、とても冗長でパッと見で何をやっているのかがわからないような式になってしまいました。

例えば、パスカルの三角形を計算するプログラムを書くとします。その場合、私は今までずっとこんな式を書いていました。

1
2
3
4
5
6
7
8
9
10
11
12
IEnumerable<int> Row = new[] { 1 };
 
for(int j = 0; j < 20; j++) {    //20段目まで
    Console.WriteLine(string.Join(" ", Row.Select(p => p.ToString())));    //表示
 
    var RowWithZero = new[] { 0 }.Concat(Row).Concat(new[] { 0 });    //両端にゼロを付ける
 
    var RowWithIndex = RowWithZero.Select((p, i) => new { Number = p, Index = i });    //インデックスを抱き合わせる
    var ShiftedRowWithIndex = RowWithZero.Skip(1).Select((p, i) => new { Number = p, Index = i });    //最初の1つ飛ばしたものにもインデックスを抱き合わせる
 
    Row = RowWithIndex.Join(ShiftedRowWithIndex, p => p.Index, p => p.Index, (o, i) => o.Number + i.Number).ToArray();    //ToArrayしないととても重くなる
}

計算アルゴリズムとしては、段の数列に対して両端に0を付加し、その数列と、その数列の1つ目をSkipした数列の2つに関してそれぞれ上記のようにインデックスを抱き合わせた匿名クラスを作り、その2つの数列に対してインデックスが等しい組み合わせをJoinで抽出し、その組み合わせの要素を足したものを数列化して次の段を求めています。
数列の先頭を1つSkipしてインデックスを付けているので、2つのコレクションのインデックスが同じ要素は隣り合ったものというわけですね。

はい。
自分で説明文書いてて 意味わからなくなってしまうくらい簡潔さに欠けます。そもそも「隣り合う要素の組み合わせをSelectしたい」ってだけなのに、こんなにまどろっこしいことをやっているわけです。

はい。
Zip()を使いましょう。
Zip()というメソッドは、2つのコレクションを同時にSelectする拡張メソッドみたいな感じです。

1
2
3
4
5
6
7
8
9
IEnumerable<int> Row = new[] { 1 };
 
for(int j = 0; j < 20; j++) {    //20段目まで
    Console.WriteLine(string.Join(" ", Row.Select(p => p.ToString())));    //表示
 
    var RowWithZero = new[] { 0 }.Concat(Row).Concat(new[] { 0 });    //両端にゼロを付ける
 
    Row = RowWithZero.Zip(RowWithZero.Skip(1), (p, n) => p + n).ToArray();
}

さっきに比べてだいぶスッキリしましたね。Zip()はSelect()やWhere()ほど頻出ではないですが、かなり有用なメソッドだと思います。

Aggregate()

LINQには一般的な集計処理をするためのAggregate()というメソッドがあります。しかし、このメソッドはオーバーロードがいくつかあり、全体像をつかむのに苦労しました。


まず1つ目のAggregate<TSource>(IEnumerable<TSource>, Func<TSource, TSource, TSource>)についてです。
このメソッドで指定するデリゲートでは、1つ目の引数で今までの集計値、2つ目の引数で現在の要素が渡されます。ちなみに、現在の要素は2つ目の要素から始まり、その2つ目の要素に関しては今までの集計値として1つ目の要素の値が渡されます。このメソッドが現時点での集計値を返すことによって、次の要素に対する呼び出しの第1引数がその値になります。

1
2
IEnumerable<int> array = new[] { 1, 3, 5, 7, 9, 2, 4, 6, 8, 10 };
Console.WriteLine(array.Aggregate((working, now) => Math.Min(working, now)));

このコードはコレクションの最小値を求めるものです。ラムダ式の1回目の呼び出しにはworkingに1が入り、nowに3が入ってきます。Math.Minで1を返すので、次の呼び出しではworkingが1(前回の呼び出しの返却値)、nowに5が入ってきます。
ちなみに、要素数が1個のコレクションだとデリゲートは1回も呼ばれずに1つ目の要素が返され、要素数が0個のコレクションだと例外(InvalidOperationException)が吐かれます。


次に、2つ目のAggregate<TSource, TAccumulate>(IEnumerable<TSource>, TAccumulate, Func<TAccumulate, TSource, TAccumulate>)です。
このメソッドではシード、すなわち初期値を指定することができます。上述のAggregateのほうでは初期値がコレクションの1つ目の要素になるので自動的に集計値が要素の型と同じになりますが、こちらのAggregateでは初期値を指定できるので任意の型にすることが可能です。

1
2
3
4
5
6
7
IEnumerable<Func<int, int>> calc = new Func<int, int>[]{
    (i) => i * 9,
    (i) => i / 10 + i % 10,
    (i) => i + 1,
};
 
Console.WriteLine(calc.Aggregate(3, (working, now) => now(working)));

例えば、1~10の任意の整数を与えても答えが10に落ち着く計算みたいなのがあります。子供だまし?のちょっとした電卓遊びですね。
それぞれの計算アクションをFunc<int, int>の配列として用意しておきます。そして、初期値から順にこのメソッドを順に呼び出していきます。
計算結果は1~10の任意の整数を与えても必ず10になります。


最後に、3つ目のAggregate<TSource, TAccumulate, TResult>IEnumerable<TSource>, TAccumulate, Func<TAccumulate, TSource, TAccumulate>, Func<TAccumulate, TResult>)です。
このメソッドは2つ目のAggregateの計算が終わった後、その値を変換するデリゲートが付いています。

1
2
3
int[] array = new[] { 1, 3, 5, 7, 9, 2, 4, 6, 8, 10 };
 
Console.WriteLine(array.Aggregate(0, (working, now) => working + now, p => (double)p / array.Aggregate(0, (w, n) => w + 1)));

これはAggregateだけで平均を求めるメソッドです。平均は、全要素を足してから最後に要素数で割って求めますよね。(要素をそれぞれ要素数で割ってから足してもいいですが…)
すなわち、全て足した後のアクションとして、要素数で割るというアクションを追加する必要がでてきます。というわけで、処理後のselectorを使うわけです。
ちなみに、要素数を数えるのもAggregateでできますね。


はい、ここまでAggregateを見てきました。ここまで来てお気づきの人も多いかと思いますが、Count()Min()Max()Sum()Average()はこのAggregateの特別な形です。


余談:
intとかdoubleとかの一般的な数値型の元締めとなるような親構造体(or インターフェース)が無いせいで、Min()、Max()などはあらゆる数値型に対してオーバーロードが定義されています。なんていうか、実装した人お疲れ様ですって感じですね…。

ThenBy()

これもまただいぶマイナーなメソッドだと思っています。
ThenBy()は、ソートされたコレクションに対して、そのうちのソート基準が同じだったものを別の条件でソートするメソッドです。
これだけ言われると何を言っているんだ?と思ってしまうかもしれませんが、例えば、ファイルの一覧をまず種類(拡張子)でソートして、そのうち同じ拡張子のファイルは日付順に並べたいなんてことがあると思います。

1
2
3
4
5
6
7
8
9
IEnumerable<FileInfo> files = Directory.GetFiles(@"C:\Windows").Select(p => new FileInfo(p));
 
files = files
    .OrderBy(p => p.Extension.ToLower())
    .ThenByDescending(p => p.LastWriteTime)
    .ThenBy(p => p.Name);
 
foreach(FileInfo f in files)
    Console.WriteLine("{0:yyyy/MM/dd hh:mm:ss} {1}", f.LastWriteTime, f.Name);

このコードでは、まず拡張子で昇順にソートし、同じ拡張子のものに関しては最終更新日時で降順にソートし、さらに同じ最終更新日時のものに関してはファイル名で昇順にソートしています。

OrderBy()安定なソートなので、そのままでは同じ拡張子のファイルに関してGetFilesの返却値の順序がそのまま使われます。そこで、ThenByの出番ってわけですね。



ここまで、私の独断と偏見に基づいてLINQの重箱の隅をつついてみました。いかがでしたでしょうか。
一度、一通りEnumerableクラスのメソッドを眺めておくとLINQで書くときに最もスマートな方法で書くことができるようになると思います。

これであなたもLINQ星人♪

2014年12月4日木曜日

WPF用縦書きテキストブロック Tategaki ver.1.1.2

昨日のリリースに引き続いてさっそく次のバージョンを作ってしまいました。

昨日の記事でまた細かいテストは追々とか言っていましたが、さっそくフラグを回収してしまいました。というのも、Meiryo UIの縦書きフォントが取得できない環境で正常に表示できないバグを持っていたんですね。UIの標準のフォントがMeiryo UIになっている環境がほとんどだと思うのですが、最初起動時にそのフォントがどうしても読み出されて、その時にフォントが見つからなくて例外を吐くようでした。

というわけで、フォントが見つからなかったら例外を吐くのではなく、勝手にMSゴシック→MS明朝の優先順位でフォントを勝手に変更するようにしました。もしもそのどちらも読み込めなかったら、読み込めるフォントのうちの最初のものを読み込むようにしました。

そのほか、例の改行位置の計算にかかわるプログラムの整理をしました。高速化はあまりできていないとは思いますが、少なくとも遅くはなっていないし、コードの綺麗さは一気に上がったと思っています。
並列化をしようかと思ったのですが、GlyphsなどのUIに係るクラスは単一のスレッドでしか動かないのでしようがありませんでした…。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
//Redrawすべきかどうかを判定するための以前の状態を保持する変数
string beforeText = null;
double? beforeHeight = null;
double? beforeFontSize = null;
double? beforeSpacing = null;
FontFamily beforeFontFamily = null;
string beforeLastForbiddenChars = null;
string beforeHeadForbiddenChars = null;
string beforeLastHangingChars = null;
double AverageCharHeight = 0;
 
/// <summary>
/// 必要があったらテキストを再描画するメソッド
/// </summary>
void RedrawText()
{       
    double height = ParentHeight;    //コスト削減
 
    if((beforeHeight != height) || (Text != beforeText) || (beforeSpacing != Spacing) ||
       (beforeLastForbiddenChars != LastForbiddenChars) || (beforeHeadForbiddenChars != HeadForbiddenChars) || (beforeLastHangingChars != LastHangingChars) ||
       (beforeFontFamily != FontFamily) || (beforeFontSize != FontSize)) {
        if((beforeFontSize != FontSize) || (beforeSpacing != Spacing) || (beforeFontFamily != FontFamily))    //この条件が揃えば文字の高さを再計算
            AverageCharHeight = CalcAverageCharHeight(Text.Split('\n').OrderByDescending(p => p.Length).FirstOrDefault());    //最も長い行を実行する
 
        try {
            if(!string.IsNullOrEmpty(Text) && (height > 0)) {    //空文字じゃなくてウィンドウの高さがあったら実行する
                string[] DisplayText = Text.Split('\n').SelectMany(p => SplitLine(p, height)).Reverse().ToArray();
                //※SelectManyの前にAsParallel入れたくなるけど、GUI関係はGUIのスレッドじゃないとダメよ
 
                while(stackPane1.Children.Count < DisplayText.Length)    //StackPanelが少なかったら
                    stackPane1.Children.Add(new TategakiText());        //足りない分を追加する
                 
                if(stackPane1.Children.Count > DisplayText.Length)    //StackPanelが多かったら
                    stackPane1.Children.RemoveRange(0, stackPane1.Children.Count - DisplayText.Length);    //多い分を捨てる
 
                Thickness margin = new Thickness(0, 0, LineMargin, 0);
                for(int i = 0; i < DisplayText.Length; i++) {
                    TategakiText tategaki = (TategakiText)stackPane1.Children[i];
 
                    tategaki.FontFamily = FontFamily;
                    tategaki.FontSize = FontSize;
                    tategaki.Spacing = Spacing;
                    tategaki.Margin = margin;
                    tategaki.Text = DisplayText[i];
                }
            }
        }
        catch(ArgumentException) {
            stackPane1.Children.Clear();    //フォントがダメなときはクリアする
        }
        finally {
            beforeText = Text;
            beforeHeight = height;
            beforeFontSize = FontSize;
            beforeSpacing = Spacing;
            beforeFontFamily = FontFamily;
        }
    }
}
 
double CalcAverageCharHeight(string Text)
{
    if(Text != null) {
        try {
            Size size = TategakiText.CalcTextSize(Text, FontFamily.Source, FontSize, FontWeights.Normal, FontStyles.Normal, Spacing);
            return size.Height / Text.Length;    //1文字の平均長(縦書きはだいたい等幅)を計算する
        }
        catch(ArgumentException) {
            return 0;
        }
    } else
        return 0;
}
 
/// <summary>
/// 行を最大の高さ以内に分割するメソッド
/// </summary>
/// <param name="LineText">行のテキスト</param>
/// <param name="MaxHeight">最大高さ</param>
/// <returns>分割した行</returns>
/// <exception cref="ArgumentException">フォントが読み込めるものではない</exception>
string[] SplitLine(string LineText, double MaxHeight)
{
    List<string> ret = new List<string>();
 
    if(string.IsNullOrEmpty(LineText))
        ret.Add(string.Empty);
    else {
        while(LineText.Length > 1) {
            int i;
            if(AverageCharHeight <= 0)    //ゼロ除算回避
                i = LineText.Length;
            else {
                for(i = (int)(MaxHeight / AverageCharHeight) + 1; i < LineText.Length; i++) {    //平均長から1行のおよその文字長を割り出す
                    Size size = TategakiText.CalcTextSize(LineText.Substring(0, i), FontFamily.Source, FontSize, FontWeights.Normal, FontStyles.Normal, Spacing);
 
                    if(size.Height > MaxHeight)    //長さが超えていたらブレーク
                        break;
                }
                i = Math.Max(Math.Min(i - 1, LineText.Length), 1);    //長さが超えたらその1つ小さくなったものから調べればよく、また、最初に決め打った長さがLineText.Lengthを超えてる可能性があるのでそれを合わせ込むが、1より小さくはしない。
            }
            for(; i > 0; i--) {
                Size size = TategakiText.CalcTextSize(LineText.Substring(0, i), FontFamily.Source, FontSize, FontWeights.Normal, FontStyles.Normal, Spacing);
                if(size.Height <= MaxHeight)    //減らしていって長さが切ったらブレーク
                    break;
            }
            i = Math.Max(i, 1);    //0になってたら1にする
 
 
            //禁則処理等をする
            while((i < LineText.Length) && LastHangingChars.Contains(LineText[i]))    //次の行の行頭がぶら下げ文字じゃないか?
                i++;    //次の行を取り込む
 
            while((i > 1) && (i < LineText.Length) && HeadForbiddenChars.Contains(LineText[i]))    //次の行の行頭が禁則文字じゃないか?
                i--;    //行末を差し出す
             
            while((i > 1) && LastForbiddenChars.Contains(LineText[i - 1]))    //文末に禁則文字が含まれていないか?
                i--;    //行末を差し出す
 
 
            ret.Add(LineText.Substring(0, i));
            LineText = LineText.Substring(i);
        }
        if(LineText.Length == 1) {    //文字列が1文字だったら強制的に書きだす
            ret.Add(LineText);
            LineText = string.Empty;
        }
    }
 
    return ret.ToArray();
}

大幅に整理したコードはこんな感じになっています。
テキストをまずは\nで分割し、その分割された行をまた表示領域の高さに合わせて分割します。従来は分割しつつコントロールに転送していましたが、今回は分割するだけでまだstringの配列に入れるだけにしています。これをSelectManyでつなげれば最終的に表示する行が1つのコレクションになりますね。縦書きの行は右から順に並べますがStackPanelは左から並べるので、Reverseでその行の順序を逆にしています。

そして出来上がった行の個数に合わせてコントロールをインスタンス化したり消したりして、最後にまとめて行の内容を登録しています。

結構整理できたと思っています。あの忌々しく何段も続くインデントが無くなったので、見通しが良くなったのは確かです。

ちなみに、各行を分割する処理をするSplitLineメソッドの中で禁則処理もしています。ぶら下げ組み、行頭の禁則処理、行末の禁則処理の順にしています。その処理自体はシンプルですが、何も考えずにその文字が文頭もしくは文末にあるかだけをチェックすると文字数ゼロの行ができて無限ループに陥るのでそこは気を使ってあげました。


というわけで、 羅生門を表示するとこんな感じになっています。


ぶら下げ組み、行頭の禁則処理、行末の禁則処理がしっかりできていることが分かるかと思います。

ダウンロードはこちら。
WPF用縦書きテキストブロック Tategaki ver.1.1.2

2014年12月3日水曜日

WPF用縦書きテキストブロック Tategaki ver.1.1.1

久しぶりにTategakiのプログラムをブラッシュアップしてみました。前回は8月の頭でしたね。

というわけでこんな感じになりました。


[バグフィックス]
  • 一部、プロパティを変更しても画面に反映されないバグを修正(多くのプロパティでそうなっていましたorz
[機能追加]
  • ライブラリ自体は特になし(フロントエンドはかなり強化した)
[破壊的変更]
  • 利用できるフォントを取得するGetAvailableFontsメソッドをAvailableFontsプロパティに変更した
[その他]
  • 行のサイズを計算するプログラムをリファクタリングして2倍くらい高速化した

バグはバグとして今回の一番の工夫はそのリファクタリングですかね。
複数行の縦書きでは、例えばフォントサイズを変えたりウィンドウを変えたりしたら1行に表示できる文字数が変わってしまいます。というわけで、そういう1行の文字数が変わりうるイベントが発生したら行の文字数を計算し直す処理を入れているわけですが、そこの処理が重いとウィンドウサイズやフォントサイズの変更がのっそりしてしまいます。

今までも、およそ縦書きは固定幅なので、適当な長さの文字列から1文字あたりの平均長を計算してそこから1行の文字数をおおよそ決定して後は実際にその長さを計算して合わせこみをするといった処理を導入して高速化をしてきましたが、まだまだ改善の余地がありそうな状態でした。

今までの処理では、サイズが変わるたびに行を全てリセットして、行ごとにTategakiTextクラスのインスタンスを作って、そのTategakiTextインスタンスに実際に適当な長さの文字列を与えてみてそのコントロールがどれくらいのサイズになるかを計算していました。
しかし、わざわざコントロールのインスタンスを作って大きさを測っていたんじゃ処理が重そうなので、1つstaticなGlyphsインスタンスを用意し、必要なパラメーターを与えるとそのインスタンスを使って縦書きテキストのサイズを測るメソッドを作りました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static Glyphs GlyphForGetLength = new Glyphs();
 
internal static Size CalcTextSize(string Text, string FontFamilyName, double FontSize, System.Windows.FontWeight FontWeight, FontStyle FontStyle, double Spacing)
{
    if(string.IsNullOrEmpty(Text))
        return Size.Empty;
    else {
        Size infinitySize = new Size(double.PositiveInfinity, double.PositiveInfinity);
 
        try {
            GlyphForGetLength.FontUri = new Uri(FontPathDictionary[FontFamilyName]);
        }
        catch(KeyNotFoundException) {
            throw new ArgumentException("Cannot use this font.");
        }
 
        GlyphForGetLength.FontRenderingEmSize = FontSize;
 
        GlyphForGetLength.StyleSimulations =
            ((FontWeight != FontWeights.Normal) ? StyleSimulations.BoldSimulation : StyleSimulations.None) |
            ((FontStyle != FontStyles.Normal) ? StyleSimulations.ItalicSimulation : StyleSimulations.None);
 
        GlyphForGetLength.Indices = GetIndices(Text, FontFamilyName, (int)FontSize, Spacing);
        GlyphForGetLength.UnicodeString = Text;
 
        GlyphForGetLength.UpdateLayout();
        GlyphForGetLength.Measure(infinitySize);
 
        return new Size(GlyphForGetLength.DesiredSize.Height, GlyphForGetLength.DesiredSize.Width);    //回転するので縦横入れ替える
    }
}


さらに、現在表示している行がある場合はそのインスタンスを再利用して、新たにインスタンス化するコストを抑えました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
string beforeText = null;
double? beforeHeight = null;
double? beforeFontSize = null;
double? beforeSpacing = null;
FontFamily beforeFontFamily = null;
 
void RedrawText()
{
    double height = ParentHeight;
 
    if((beforeHeight == null) || (beforeHeight != height) || (beforeFontSize != FontSize) || (beforeSpacing != Spacing) || (beforeFontFamily != FontFamily) || !object.Equals(Text, beforeText)) {
        int lineIndex = 0;
                         
        if(!string.IsNullOrEmpty(Text)) {
            string[] Lines = Text.Split('\n');
            Size infinitySize = new Size(double.PositiveInfinity, double.PositiveInfinity);
 
            if((height <= 0) || (Lines.Length == 0))    //そのまま表示
                GetLineControl(lineIndex++).Text = Text;
            else {
                foreach(string line in Lines) {
                    string text = line;
 
                    if(line.Length == 0)
                        GetLineControl(lineIndex++).Text = string.Empty;    //行が空なら空にする
                    else {
                        while(text.Length > 1) {
                            Size size = TategakiText.CalcTextSize(text, FontFamily.Source, FontSize, FontWeights.Normal, FontStyles.Normal, Spacing);
                            double charHeight = size.Height / text.Length;    //1文字の平均長(縦書きはだいたい等幅)を計算する
 
                            int i;
                            if(charHeight == 0)    //ゼロ除算回避
                                i = text.Length;
                            else {
                                for(i = (int)(height / charHeight) + 1; i < text.Length; i++) {    //平均長から1行のおよその文字長を割り出す
                                    size = TategakiText.CalcTextSize(text.Substring(0, i), FontFamily.Source, FontSize, FontWeights.Normal, FontStyles.Normal, Spacing);
 
                                    if(size.Height > height)    //長さが超えていたらブレーク
                                        break;
                                }
                                i = Math.Max(Math.Min(i - 1, text.Length), 1);    //長さが超えたらその1つ小さくなったものから調べればよく、また、最初に決め打った長さがtext.Lengthを超えてる可能性があるのでそれを合わせ込むが、1より小さくはしない。
                            }
                            for(; i > 0; i--) {
                                size = TategakiText.CalcTextSize(text.Substring(0, i), FontFamily.Source, FontSize, FontWeights.Normal, FontStyles.Normal, Spacing);
                                if(size.Height <= height)    //減らしていって長さが切ったらブレーク
                                    break;
                            }
                            GetLineControl(lineIndex++).Text = text.Substring(0, i);
                             
                            text = text.Substring(Math.Max(i, 1));    //iが0になってきたらそれは1文字
                        }
                        if(text.Length == 1) {    //文字列が1文字だったら強制的に書きだす
                            GetLineControl(lineIndex++).Text = text;
                            text = string.Empty;
                        }
                    }
                }
            }
        }
        beforeText = Text;
        beforeHeight = height;
        beforeFontSize = FontSize;
        beforeSpacing = Spacing;
        beforeFontFamily = FontFamily;
 
        FixLineControls(lineIndex);
    }
}

こんな感じです。あんまりインデントが深くなるコードは好ましくないんですがね。という意味でまだ改良の余地はありそうな気もします。
GetLineControl()メソッドが、行インデックスを与えるとそのコントロールを返してくれるメソッドです。今までは最初にstackPanel1.Children.Clear()をして、GetLineControl()の代わりに新たにTategakiTextをインスタンス化していたのですが、そのコストを抑えるためにこうやって再利用するようにしています。もしもすでに用意しているインスタンスより多くの行が必要なときは新たにインスタンスを作る処理を入れています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 行のコントロールを取得するメソッド
/// </summary>
/// <param name="LineIndex">行番号</param>
/// <returns>その行のインスタンス</returns>
TategakiText GetLineControl(int LineIndex)
{
    if(LineIndex < 0)
        throw new ArgumentOutOfRangeException("The index must not be negative.");
 
    int index = stackPane1.Children.Count - LineIndex - 1;
 
    if(index < 0) {
        TategakiText tategaki = new TategakiText();
        stackPane1.Children.Insert(0, tategaki);
        return tategaki;
    } else
        return (TategakiText)stackPane1.Children[index];
}

こんな感じですね。

最後に、もしも更新前から用意されていた行より少ない行のテキストだった場合にその余ったインスタンスを消す作業と、あと、全体に共通なフォント等の更新を行っています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// 行のコントロールを整理するメソッド
/// </summary>
/// <param name="LineCount">行の総数</param>
void FixLineControls(int LineCount)
{
    if(LineCount < 0)
        throw new ArgumentOutOfRangeException("The index must not be negative.");
 
    //まず、いらない行を削除する
    int deleteCnt = stackPane1.Children.Count - LineCount;
 
    if(deleteCnt > 0)
        stackPane1.Children.RemoveRange(0, deleteCnt);
     
    //次に、パラメーターを調整する
    Thickness margin = new Thickness(0, 0, LineMargin, 0);
    foreach(TategakiText c in stackPane1.Children) {
        c.FontFamily = FontFamily;
        c.FontSize = FontSize;
        c.Spacing = Spacing;
        c.Margin = margin;
    }
}

これで、実測で1回のサイズ計算処理が8msから4msに短くなりました。


さて、それ以外にもフロントエンドは相当の機能追加をしています。
まずはExtended WPF ToolkitをNugetから参照しDoubleUpDownとColorPickerを活用しています。
そして、GUIでGUIを変更する処理をしているので、もはやXAMLですべてを表現してしまっています。ViewModelとか必要ありません。

多くはバインディングで実現できています。例えば、

1
<Slider Grid.Row="2" Grid.Column="1" Name="slider_fontsize" Minimum="5" Maximum="72" Value="18"/>

このようにスライダーでフォントサイズを適当な範囲で動かせるようにした上で

1
<tg:TategakiMultiline FontSize="{Binding ElementName=slider_fontsize, Path=Value}" />

このようにエレメント名とパスを指定してやることでバインディングできます。

また、ColorPickerはColor構造体を返しますが、文字の色(Foreground)はBrushになるので、その変換をしてやる必要があります。同様に、BoldやItalicのチェックボックスもFontWeightやFontStyleに変換してやる必要がありますね。
それは、ValueConverterという技を使えば実現することができます。

1
2
3
4
5
6
7
8
9
10
11
12
13
[ValueConversion(typeof(bool), typeof(FontStyle))]
public class BooleanToFontStyleConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return (bool)value ? FontStyles.Italic : FontStyles.Normal;
    }
 
    public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
    {
        return (FontStyle)value != FontStyles.Normal;
    }
}

このようににIValueConverterインターフェースを実装してあげて、あとはXAMLで適当にインスタンス化してConverterに登録してあげるだけですね。

こんな調子で適当に各プロパティの実装をしてあげればおkです。

最後に、TategakiTextやTategakiMultilineを入れたStackPanelをScrollViewerに入れてやりました。これでスクロールができるようになります。が、VerticalScrollBarVisibilityをDisaabledにしないと任意の縦方向の長さになってしまうので、ここだけは注意する必要があります。


こんな感じで、どんどん縦書きテキストブロックに磨きがかかってきています。
まだまだテストが不十分なところもありそうな気もしますが、それは追々またやっていくということで。

ダウンロードはこちらからどうぞ。
WPF用縦書きテキストブロック Tategaki ver.1.1.1
((2015/1/22)都合により削除しました。ver1系はver.1.1.2を使ってください)