2014年12月3日水曜日

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

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

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


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

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

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

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

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);    //回転するので縦横入れ替える
    }
}


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

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をインスタンス化していたのですが、そのコストを抑えるためにこうやって再利用するようにしています。もしもすでに用意しているインスタンスより多くの行が必要なときは新たにインスタンスを作る処理を入れています。

/// <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];
}

こんな感じですね。

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

/// <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とか必要ありません。

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

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

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

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

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

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

[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を使ってください)

0 件のコメント:

コメントを投稿