2024年8月31日土曜日

ListView Extensions ver.1.1.0

唐突ですがListView Extensions ver.1.1.0をリリースしました。実に前回の更新から6年ぶりです。

Github:

Nuget:
https://www.nuget.org/packages/ListViewExtensions/

今回の変更点

  • Githubでソースコードを公開
  • ライセンスをMITライセンスに変更
  • ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更
  • ListViewのヘッダーサポートを強化
    • SortableGridViewColumnHeader、SortableGridViewColumnを追加
    • SortedHeaderをObsolete指定にした
  • ISortableObservableCollectionのSortメソッドの引数を変更、古いメソッドはObsolete指定にした
  • コードのリファクタリング、Nullableの有効化、単体テストの追加など

主な変更点を説明していきます。

Githubで公開 / MITライセンス化

最近久しぶりにこのListView Extensionsをいじろうとしたとき、そういえばはコードを公開していなかったなと思ってGithubで公開することにしました。併せてライセンスもMITに変更しています(今までは明記なし)。すっかり私もオープンソースの人(?)になってきました。

ターゲットを.NET Framework 4.5.2 / .NET Core 3.1 / .NET 6に変更

直前のver.1.0.1 では.NET Framework 4.5のみでした。これが.NET系統にも対応するようにしました。実はこれが今回のアップデートの大きなモチベーションだったりします。

ListViewのヘッダーサポートを強化

ListViewのヘッダーを簡単に作成

今までのListView Extensionsではヘッダーの記述がとても冗長でした。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name" >
                    <lv:SortedHeader Content="Name" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation" >
                    <lv:SortedHeader Content="Pronunciation" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age" >
                    <lv:SortedHeader Content="Age" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday" >
                    <lv:SortedHeader Content="Birthday" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <GridViewColumnHeader Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height_cm" >
                    <lv:SortedHeader Content="Height" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height_cm'}" />
                </GridViewColumnHeader>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

これはサンプルコードの抜粋ですが、ListViewで1列作るのに5行もコードが必要でした。そして似たような記述も繰り返し行われとても冗長です。XAMLの構造、というよりもどういう仕組みになっているかはこの状態でわかりやすいんですがね。

これを、今回のバージョンでは以下のように書き換えることができるようになりました。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

だいぶすっきりしました。

このSortableGridViewColumnはGridViewColumnを継承して作られたものですが、少しトリッキーです。上のコードは下のコードとほぼ同等です。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <lv:SortingConditionConverter x:Key="ConditionToDirectionConverter" />
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Name}">
                <lv:SortableGridViewColumnHeader Content="Name" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Name'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Name"/>
            </GridViewColumn>
            <GridViewColumn Width="150" DisplayMemberBinding="{Binding Pronunciation}" >
                <lv:SortableGridViewColumnHeader Content="Pronunciation" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Pronunciation'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Pronunciation"/>
            </GridViewColumn>
            <GridViewColumn Width="70" DisplayMemberBinding="{Binding Age}" >
                <lv:SortableGridViewColumnHeader Content="Age" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Age'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Age"/>
            </GridViewColumn>
            <GridViewColumn Width="120" DisplayMemberBinding="{Binding Birthday}" >
                <lv:SortableGridViewColumnHeader Content="Birthday" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Birthday'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Birthday"/>
            </GridViewColumn>
            <GridViewColumn Width="100" DisplayMemberBinding="{Binding Height}" >
                <lv:SortableGridViewColumnHeader Content="Height" SortingArrowLocation="Right" SortingDirection="{Binding People.SortingCondition, Mode=OneWay, Converter={StaticResource ConditionToDirectionConverter}, ConverterParameter='Height'}"
                                                 Command="{Binding People.SortByPropertyCommand}" CommandParameter="Height"/>
            </GridViewColumn>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

SortableGridViewColumnHeaderはGridViewColumnHeaderを継承して作られたもので、GridViewColumnHeaderに並び替えのアイコン「▲」「▼」を表示する機能を追加したものです。SortingDirectionプロパティに並び替えの向きを指定することでアイコンが表示されます。

SortableGridViewColumnは、HeaderプロパティがSortableGridViewColumnHeaderではない場合に自動的にSortableGridViewColumnHeaderを作ってそのインスタンスにHeaderプロパティに入っていたものを渡しますます。そして、そのSortableGridViewColumnHeaderのSortingDirectionプロパティ、Command / CommandParameterプロパティにSortableSourceプロパティにバインディングされたオブジェクトのプロパティを自動的にバインディングします。ソートのキーにはDisplayMemberBindingの値を使うので、万が一DisplayMemberBindingの値と異なるプロパティをソートのキーにする必要がある場合はGridViewColumnHeaderを直接触る必要があります。

これらの機能が追加されたことによって、SortedHeaderはObsolete扱いとなりました。

ListViewのヘッダーをカスタマイズ① - 並び替え矢印の位置を変更

SortableGridViewColumnはSortingArrowLocationプロパティを持っています。これはDock列挙型になっているので、上下左右好きな場所に配置することができます。直接プロパティを触っても良いのですが、GridView.ColumnHeaderContainerStyleプロパティによる一括スタイル指定をするのが良いでしょう。

<ListView ItemsSource="{Binding People}" >
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

↓Top

Top

↓Right

Right

画像ではTopとRightの例を出していますが、LeftとBottomを指定することもできます。あまり馴染みのないデザインかとは思いますが。

あとはSortingArrowMarginプロパティを使えばArrowの周囲のMarginを設定することができるので、「Name」などのヘッダーテキストの間隔を調整したいときに使えます。

ListViewのヘッダーをカスタマイズ② - 並び替え矢印をカスタマイズ

ListViewのヘッダーの矢印は専用のクラス「AscendingArrow」「DescendingArrow」で表現されています。ですので、このクラスのTemplateを丸々置き換えてしまうことで矢印の見た目をカスタマイズすることができます。

<ListView ItemsSource="{Binding People}" >
    <ListView.Resources>
        <Style TargetType="lv:AscendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,5 5,0,10,5" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
        <Style TargetType="lv:DescendingArrow">
            <Setter Property="Template">
                <Setter.Value>
                    <ControlTemplate>
                        <Polyline Points="0,0 5,5,10,0" StrokeThickness="1" Stroke="Black" />
                    </ControlTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </ListView.Resources>
    <ListView.View>
        <GridView>
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Name}" Header="Name" />
            <lv:SortableGridViewColumn Width="150" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Pronunciation}" Header="Pronunciation" />
            <lv:SortableGridViewColumn Width="70"  SortableSource="{Binding People}" DisplayMemberBinding="{Binding Age}" Header="Age" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Birthday}" Header="Birthday" />
            <lv:SortableGridViewColumn Width="120" SortableSource="{Binding People}" DisplayMemberBinding="{Binding Height}" Header="Height" />
            <GridView.ColumnHeaderContainerStyle>
                <Style TargetType="lv:SortableGridViewColumnHeader">
                    <Setter Property="SortingArrowLocation" Value="Top" />
                </Style>
            </GridView.ColumnHeaderContainerStyle>
        </GridView>
    </ListView.View>
    <!-- 中略 -->
</ListView>

このように全く違う見た目になりました。これで、仮に私のデザインした▲が気に入らない人がいたとしても、クレームを受けることなく「自分で好きに変えてください」と言えるようになりました。こういった表現力の豊かさがさすがWPFという感じですね。

ISortableObservableCollectionのSortメソッドの引数を変更

ISortableObservableCollectionのSortメソッドの形が少し変わっています。破壊的変更です。

public interface ISortableObservableCollection<T> : IList<T>, INotifyPropertyChanged, INotifyCollectionChanged
{
    // 中略

    [Obsolete]
    void Sort(string propertyName, SortingDirection direction);

    /// <summary>
    /// 自身をソートするメソッド
    /// </summary>
    /// <param name="direction">ソート方向。Noneの場合は何もしない。</param>
    /// <param name="propertyName">ソートに使用するプロパティ名。Nullの場合は要素自身をキーとしてソート。</param>
    void Sort(SortingDirection direction, string? propertyName = null);
}

まあ、直接SortableObservableCollectionからソートを手動でかけていない限り影響は無いとは思いますが、注意してください。Obsoleteの警告が出てもパラメーターの順序を入れ替えるだけでOKです。

この変更は、propertyNameをnullでも受け取れるようにしたところにあります。nullにすると要素自身をキーにして並び替えることができます。SortableObservableCollection<int>などの場合を想定しています。

***

以上で今回の変更分の説明は終わりです。あまり使用面で影響のない修正は割愛させてもらいます。

2024年8月23日金曜日

Prism + DryIoc 入門②(お作法編)

どんなフレームワークにも決まったお作法というか、こう使ってほしいという想定があります。逆に言えば、それをマスターすることがそのフレームワークを自由自在に使いこなせるようになる近道というわけです。今回はそのようなPrismのお作法を紹介していきます。

1. プロジェクトテンプレート

Prismを使ったWPFアプリを作る場合は、プロジェクトテンプレートを使いましょう。Visual Studio 2022の場合は、メニューの「拡張機能」→「拡張機能の管理」からPrismと検索するとPrism Template Packが出てきてインストールすることができます。

インストールをすると、プロジェクトの新規作成からPrismを選択できるようになります。今回は「Prism Blank App (WPF)」を選択します。

そうするといつも通りプロジェクトの名前と保存名が聞かれるので適当に入力します。

ここで「作成」を押すと、PrismのDIコンテナを選択するダイアログが出てきますので、「DryIoc」を選択します。

これにて無事Prism+DryIocのプロジェクトが出来上がりました。

最後にお好みで、.NETのバージョンを最新に上げたり、Nullableを有効化したり、Nugetからパッケージを最新にしたりしておきましょう。ビルドすると空のWPFアプリが立ち上がるはずです。

ちなみに、Prismはバージョン8まではMITライセンスですが、バージョン9からはPrism Community License / Prism Commercial Licenseという独自の商用ライセンスに切り替わっているようです。バージョンアップには要注意です。

2. 領域(Region)の作成と配置

ウィンドウにUI要素(コントロールなど)を配置するとき、MainWindowに直接配置することもできますが、もう少し小さいパーツ単位でUI要素を作って配置したいときがあると思います。そうしないとMainWindowに全部入りになってしまいますもんね。そんな時にはRegionと呼ばれるものを作ると良いです。

ちなみに、Regionの実体はただのUserControlです。 新しい項目の追加から、Prismテンプレートの"Prism UserControl (WPF)"を選択します。

今回は、MainWindowの左半分と右半分のRegionということで、LeftRegionとRightRegionの2つを追加しました。UserControlを新規作成すると、対応するViewModelも自動的に作ってくれます。

MainWindow.xamlでは次のようにContentControlにてRegionを配置します。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="1*" />
            <ColumnDefinition Width="1*" />
        </Grid.ColumnDefinitions>

        <ContentControl Grid.Column="0" prism:RegionManager.RegionName="LeftRegion" />
        <ContentControl Grid.Column="1" prism:RegionManager.RegionName="RightRegion" />
    </Grid>
</Window>

これだけでは実は不十分で、Region名と実際のUserControl型を紐づけなければなりません。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));
    }
}

MainWindowViewModelのコンストラクタにIRegionManager型の引数を設定します。こうするとDIコンテナが良い感じにIRegionManagerの実体を割り振ってくれるので、ここでRegisterViewWithRegionメソッドを呼び出してRegion名と実際のUserControl型を結び付けておきます。

これでこのようにRegionが所定の場所に割り付けられるようになりました。

ちなみに上の画像に"Left Region"と"Right Region"と表示しているのは、空っぽだと見た目でよくわからないので、そういうコードを追加したからです。

<UserControl x:Class="PrismTest.Views.LeftRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"             
             prism:ViewModelLocator.AutoWireViewModel="True">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Left Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>
<UserControl x:Class="PrismTest.Views.RightRegion"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:prism="http://prismlibrary.com/"             
             prism:ViewModelLocator.AutoWireViewModel="True">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="Right Region" HorizontalAlignment="Center" VerticalAlignment="Center" />
    </Border>
</UserControl>

3. デザイン時のデータ

実装を進めているとあることに気が付きます。ViewのXAMLを編集する際、ViewModelのプロパティのサジェストがVisual Studioで働かないのです。これはViewModelLocator.AutoWireViewModelにてViewModelが実行時に動的に紐づけられるからで、XAMLにはどこにもViewModelの型を明記していないからです。それはさすがにサジェストが働かなくて当然ですね。

このようなときに便利なのがデザイン時のデータという機能です。

一番大元の要素に以下のような呪文を追加します。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:vm="clr-namespace:PrismTest.ViewModels"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance Type=vm:MainWindowViewModel}"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="{Binding Title}" Height="350" Width="525" >
    <!-- 中略 -->
</Window>

5から9行目が呪文部分です。これを入力することによって、Visual Studioでのデザイン時のみDataContextをMainWindowViewModelとみなすことができ、プロパティなどのサジェストが働くようになります。

4. インターフェースとクラスのマッピング

最後にインターフェースとクラスのマッピングです。前回の記事でも説明した通り、DIを使用した実装では、各クラス間の依存性を抑えるため、コンストラクタでインターフェースを受け取るようにし、DIコンテナがそれに対応するクラスをインスタンス化して渡してくれます。

それを実現するためには、インターフェースとクラスの対応付けをあらかじめ設定しておく必要があります。その設定は、App.xaml.cs内で記述することになります。

public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

RegisterTypesメソッドをオーバーライドして、そのなかでRegisterメソッドを呼び出すことでインターフェースとクラスのマッピングをすることができます。

public class MainWindowViewModel : BindableBase
{
    private string _title = "Prism Application";
    public string Title
    {
        get { return _title; }
        set { SetProperty(ref _title, value); }
    }

    public MainWindowViewModel(IRegionManager regionManager, IMainWindowModel model)
    {
        regionManager.RegisterViewWithRegion("LeftRegion", typeof(LeftRegion));
        regionManager.RegisterViewWithRegion("RightRegion", typeof(RightRegion));

        Title = model.Title;
    }
}

マッピングしてさえいれば、コンストラクタに引数を追加することで、DIコンテナにて対応するインスタンスを作ってくれます。

なお、グローバルなデータ、設定値などはRegisterSingletonメソッドでDIコンテナに登録することによって、同じインターフェースには唯一のインスタンス(実体が同じもの)が返されるようになります。

***

以上で必要最低限のお作法は紹介できたはずです。 ここから自分の作りたいアプリをどんどん実装していきましょう。

2024年8月14日水曜日

Prism + DryIoc 入門①(DI編)

このブログを普段から読んでくださっている方(どれくらいいるかわかりませんが…)なら、私はMVVMフレームワークとしてLivetを使っていることはよくご存じと思います。ですが、ついにPrismにも手を出してみようと思い、最近Prismを使い始めましたので、その内容をブログに残しておこうと思います。

今回はなぜPrismを使う必要があるのか、DI (Dependency Injection)の対応という観点から説明をしていきたいと思います。

DI (Dependency Injection)とは

DI (Dependency Injection)とは日本語で「依存性の注入」とか言われたりするそうです。ここで言う依存性とは、クラス間の依存性のことです。クラスA内でクラスBを使用していれば、クラスAはクラスBに依存しているということになります。DIでは、それをクラスA内部にクラスBを使った実装を直接書くのではなく、もう少し抽象化して実装して、クラスBを使うということを後から外部から注入できるようにしようとものです。

なぜそんなことをするかというと、ひとえに単体テストをしやすくするためです。クラスA内でクラスBとクラスCとクラスDを使用し…となっていると、クラスAのテストをしたいのにクラスB, C, Dすべて用意してテストしなければならなくなってきます。クラスBがエラーを起こした時の処理をテストしたいけど、クラスBにエラーを起こさせるのが難しいとかいうことがあるかもしれません。もしくはクラスCはサーバーと通信していて、いろいろなパターンを自動でテストしているとサーバーに負担がかかるとか通信で時間がかかるとかがあるかもしれません。ただ単にクラスA内のコードが正しく動くかを試験したいだけなのに、それではテストがとてもしにくいですよね。

そこで、クラスAの実装時にはクラスB, C, Dに対応したインターフェースのみをコンストラクタで受け取って使い、クラスB, C, Dそのものには触れないようにします。そして、実行時にコンストラクタに本物のクラスB, C, Dのインスタンスを与えてあげるという実装のしかたをすれば、テストのときはダミーのクラスB, C, Dを渡してあげることで本物のクラスB, C, Dには依存しないテストをすることができるようになります。これが「Dependency Injection」という考え方です。

LivetでMVVMを実装した場合(DI無し)

さて、LivetはDIをサポートしていませんが、LivetでMVVMなソフトを作った場合どのようになって何が問題になるでしょうか。以下の画像のようにテキストボックスに入力した文字を大文字に変換して表示するソフトを例に考えてみましょう。

これをLivetで実装した場合、おおむね以下のような感じになるかと思います。

<Window x:Class="LivetTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:LivetTest.Views"
        xmlns:vm="clr-namespace:LivetTest.ViewModels"
        Title="Livet Test" Width="525" Height="350">

    <Window.DataContext>
        <vm:MainWindowViewModel />
    </Window.DataContext>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>
        
        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : ViewModel
{
    readonly MainWindowModel model;

    public MainWindowViewModel()
    {
        model = new MainWindowModel();

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        { 
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        { 
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public class MainWindowModel : NotificationObject
{
    public MainWindowModel()
    {
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                OutputText = InputText.ToUpper();
                break;
        }
    }
}

少し長いですが、重要なのはMainWindow.xaml内で

<Window.DataContext>
    <vm:MainWindowViewModel />
</Window.DataContext>

と名指しでMainWindowViewModelをインスタンス化しているところ、およびMainWindowViewModel.cs内で

readonly MainWindowModel model;

public MainWindowViewModel()
{
    model = new MainWindowModel();

    // 中略
}

と名指しでMainWindowModelをインスタンス化しているところです。MainWindow.xamlをテストするにはMainWindowViewModelの本物が必要で、MainWindowViewModelのテストをするにはMainWindowModelの本物が必要になります。これでは単体テストと呼べず、ただの結合テストになってしまいますね。

PrismでMVVMを実装した場合(DryIoc)

Prismは標準でDIをサポートしていますが、Prismで直接DIコンテナが実装されているわけではなく、別のライブラリを参照する形となっています。WPFではUnityDryIocが使えるようですが、ここではDryIocを使います。DryIocを選んだ理由としては、こちらのほうが速度が速く、かつ、UnityはC#がよく使われているゲームライブラリと名前が同じで検索汚染がひどく、目的の情報にたどり着きにくいからです。

さて、先ほどのLivetと同様にPrism+DryIocで実装すると以下のようになります。

<Window x:Class="PrismTest.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:prism="http://prismlibrary.com/"
        prism:ViewModelLocator.AutoWireViewModel="True"
        Title="Prism Test" Height="350" Width="525" >

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="1*" />
        </Grid.RowDefinitions>

        <TextBox Grid.Row="0" Text="{Binding InputText, UpdateSourceTrigger=PropertyChanged}" />
        <TextBlock Grid.Row="1" Text="{Binding OutputText}" />
    </Grid>
</Window>
public class MainWindowViewModel : BindableBase
{
    readonly IMainWindowModel model;

    public MainWindowViewModel(IMainWindowModel model)
    {
        this.model = model;

        model.PropertyChanged += Model_PropertyChanged;
        PropertyChanged += This_PropertyChanged;
    }

    public string InputText
    {
        get => _InputText;
        set
        {
            if(_InputText == value)
                return;
            _InputText = value;
            RaisePropertyChanged();
        }
    }
    private string _InputText;

    public string OutputText
    {
        get => _OutputText;
        private set
        {
            if(_OutputText == value)
                return;
            _OutputText = value;
            RaisePropertyChanged();
        }
    }
    private string _OutputText;

    private void Model_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(model.OutputText):
                OutputText = model.OutputText;
                break;
        }
    }

    private void This_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        switch(e.PropertyName) {
            case nameof(InputText):
                model.InputText = InputText;
                break;
        }
    }
}
public interface IMainWindowModel : INotifyPropertyChanged
{
    string InputText { get; set; }

    string OutputText { get; }
}
    public class MainWindowModel : BindableBase, IMainWindowModel
    {
        public MainWindowModel()
        {
            PropertyChanged += This_PropertyChanged;
        }

        public string InputText
        {
            get => _InputText;
            set
            {
                if(_InputText == value)
                    return;
                _InputText = value;
                RaisePropertyChanged();
            }
        }
        private string _InputText;

        public string OutputText
        {
            get => _OutputText;
            private set
            {
                if(_OutputText == value)
                    return;
                _OutputText = value;
                RaisePropertyChanged();
            }
        }
        private string _OutputText;

        private void This_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
        {
            switch(e.PropertyName) {
                case nameof(InputText):
                    OutputText = InputText.ToUpper();
                    break;
            }
        }
    }
public partial class App
{
    protected override Window CreateShell()
    {
        return Container.Resolve<MainWindow>();
    }

    protected override void RegisterTypes(IContainerRegistry containerRegistry)
    {
        containerRegistry.Register<IMainWindowModel, MainWindowModel>();
    }
}

こんな感じになります。注目していただきたいところは、

prism:ViewModelLocator.AutoWireViewModel="True"

と書いてあるところと、

readonly IMainWindowModel model;

public MainWindowViewModel(IMainWindowModel model)
{
    this.model = model;

    // 中略
}

と書いてあるところです。

Viewでは、Windowに添付プロパティとしてViewModelLocator.AutoWireViewModelを追加することができます。これをTrueにすると、Windowのクラス名の末尾に「ViewModel」を付けたクラスをそのViewに対応したViewModelとして認識し、自動的にDataContextプロパティにそれをインスタンス化してくっつけてくれるようになります。敢えて名指しでViewModelを指定しなくて良くなるわけです。

ViewModelでは、直接内部でModelをインスタンス化する代わりに、コンストラクタの引数でIMainWindowModelというインターフェースを受け取っています。IMainWindowModelインターフェースはその名の通りMainWindowModelのpublicなプロパティのみを抜き出したインターフェースですが、こうすることによって、本番ではMainWindowModelのインスタンスをコンストラクタでに渡してあげれば良いですし、単体テストをするときはIMainWindowModelを実装した適当なダミーのクラスを渡してあげればテストができるようになります。こうすることで、ViewModelを動かす際に対応するModelが必須ではなくなり(=依存性が弱まり)、テストの自由度が上がるわけです。

ちなみに、実際にMainWindowModelをインスタンス化するところを我々が自分で実装することは無く、DIコンテナが良い感じにインスタンス化してくれます。その際に、MainWindowModelのコンストラクタに必要なインスタンスを作って渡してくれるのですが、あらかじめ「IMainWindowModelの本番実装はMainWindowModelだから、IMainWindowModelが出てきたらMainWindowModelをインスタンス化して渡してあげてね」と教えてあげなければなりません。そのことは、App.xaml.cs内に記述するようになっています。

まとめ

ここまで、Prismを使うにあたって必要なDIの基本的な考え方を説明してきました。今までは実装全体でしかテストできなかったものが、パーツごとに分けてテストができるようになるというのはとても良いですね。

ただ、その「依存性を減らす」という目的と裏表ですが、DIはコードの見通しが悪くなるのが欠点です。上の例だとIMainWindowModelの本番実装が何かというのはVisual StudioのF12キーだけで追いかけるのは難しく、App.xaml.csの中身を能動的に見に行かなければならなくなります。自分で実装したのならそういうマナーはわかっていますが、世の中に転がっている任意の流儀で実装されたDIのコードを追いかけるのは至難の業です…。

ちなみに、今回の記事で「LivetはDIに対応していない」と何度も書きましたが、LivetがPrismに対して劣っているという意図はありません。次回の記事くらいで出てくるかもしれませんが、LivetはできるけどPrismができないことというのもあります。ですので、実際のアプリ開発においては、PrismとLivetの二者択一ではなく、PrismとLivetの併用で作っていくことになると思います。その辺の話もまたそのうち記事にできればなと思っています。

2024年5月11日土曜日

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

Tategakiをアップデートしました。

Github:

Nuget:
https://www.nuget.org/packages/Tategaki/

今回の変更点

今回もマイナーチェンジです。

  • フォント読み込み時のパフォーマンス改善
  • 必要最低限のフォントファイルのみを読み込むように修正

説明していきます。

フォント読み込み時のパフォーマンス改善

前回のソフト更新で、縦書きフォントの検索時のパフォーマンス改善というものがありました。 GSUBテーブルのみを読み込むようにすることでパフォーマンスを飛躍的に向上させたというものでした。しかし、この検索のためにファイルの読み込みルーチンに2種類の実装ができてしまいました。イケてないです。プログラマーというのはコピペコードを嫌がるものです。ですので、実装を統一する作業を実施しました。

これにより、検索時以外にフォントファイルの中身を読み込む際のパフォーマンスも向上しました。1~2割程度の改善だと思います。

古い実装ではいったんすべてFile.ReadAllBytesメソッドでメモリに読み込んだうえで解析していましたが、フォントファイル内の多くのテーブルを読み込む場合でもStreamでファイルの必要な部分のみを都度読み込むほうが速いみたいです。まあ、ファイルのランダムアクセスみたいになってしまいますので、もしかしたらHDDとかなら重くなるのかもしれませんが、最近システムドライブがHDDのパソコンもそうそう無いでしょうからこれで良いでしょう。

必要最低限のフォントファイルのみを読み込むように修正

今回実施したかった改善はまさにこれです。

前回のソフト更新で「特定のフォントファイルを読み込むと落ちる不具合の修正」というものがありました。行儀の悪いフォントファイルがあると落ちるというものです。この時に思ったのですが、潜在的なリスクとして、ほかにも想定外のフォントファイルにより例外が発生するということは充分にあり得ます。私のパソコンでは落ちないけど誰かほかの人のパソコンに入っている特定のフォントなら落ちる…そんなのは検証しきれませんね。

前バージョンまででは、アプリの立ち上げ時に(=staticクラスのコンストラクタで)全フォントを読み込むようにしていました。そのため、行儀が悪いフォントファイルを持っている人の環境では、このライブラリを参照する限りアプリが一切立ち上げられなくなってしまいます。たとえそのフォントを使わなかったとしてもです。それはあまりにもよろしくない挙動で、初めてこのライブラリを使ってくださった方の印象を悪くさせかねません。

そこで、全フォント読み込みをできる限り行わないように修正しました。

具体的には、TategakiText.FontFamilyプロパティに指定されたフォントが縦書きを持っているフォントの場合、全探索は行いません。そのフォントのみを読み込みます。全フォントを読み込むのは以下の2パターンのみです。

  • TategakiText.FontFamilyプロパティに設定されたフォントが存在しない/縦書きに対応しないフォントだった場合
  • TategakiText.AvailableFontsプロパティを読みに行ったとき

後者は単純です。すべての縦書き対応フォントを参照してきたのですから全フォントを読み込むことになります。

前者については、フォールバック処理が入っているためです。使えないフォントだった場合、類似の使えるフォントを探すことになるのですが、そのために全フォントを読み込んだうえで類似フォントを探すような処理にしております。

いずれにせよ、「とりあえずTategakiTextを使ってみよう」レベルでは上記2パターンに抵触することは無いので、仮に行儀の悪いフォントファイルがあったとしてもアプリが立ち上がらない状況までは回避できるようになります。

余談:Tategakiのダウンロード数

NugetでTategakiのダウンロード数が見られるのですが、約1週間ごとにリリースしてきたver.3.0.0以降の各バージョンがだいたい75~85ダウンロードされているみたいです。

まあ、有名ライブラリなどに比べれば全然なのですが、80も使ってくれている人がいるんだ、というのが正直な感想です(ありがとうございます)。使ってくださった方からのフィードバックみたいなのはなかなか無いので、こちらとしてはあまり実感が無いというのが正直なところなのですがね。

引き続きよろしくお願いいたします。何かご意見・ご要望等あればいつでもコメントください。

2024年5月3日金曜日

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

前回の記事で「今度こそこんなものですかね」と言ったにもかかわらずまたアップデートしました。まあ前回よりインターバル長いしセーフ。

Github:

Nuget:
https://www.nuget.org/packages/Tategaki/

今回の変更点

今回は下一桁のバージョンアップで、マイナーチェンジです。新たな機能の追加は無しです。Readmeにも書いていますが以下の点をアップデートしました。

  • 縦書きフォントの検索パフォーマンスを改善
  • 下線/中線/上線の配置を改善
  • 特定のフォントファイルを読み込むと落ちる不具合を修正

一つひとつ説明していきます。

縦書きフォントの検索パフォーマンスを改善

TategakiText.AvailableFontsプロパティを呼び出すとき、全フォントを読み込んで、縦書きに対応しているか、すなわちGSUBテーブルのvertタグを持つデータがあるか確認します。しかし、ここに意外と処理に時間がかかります。特に久々にノートパソコンを出してきてこのプロパティにアクセスしてみたところ、制御が返ってくるまでに大体4秒くらいかかることがわかりました。これじゃあさすがに遅すぎて困りますね。

いろいろと原因を調査してみたところ、改善の余地として以下の3点が見つかりました。

  • GSUBテーブル以外のテーブルのテーブルも解析している
  • フォントファイルのバイナリを最初にいったんすべてメモリに読み込んでいる
  • 並列処理をしていない

一つ目は、GSUBテーブル以外のテーブルも解析しているという点です。実際に縦書き描画をする上ではそれ以外の情報も多数必要になるので最終的には読み込むことになるのですが、縦書きに対応しているフォントかどうかを調べるだけなら全体を解析する必要はありません。というわけで、縦書きに対応しているかどうかを調べるだけの専用パーサーを別途実装しました。

二つ目は、フォントファイルはいったんすべてFile.ReadAllBytesメソッドでメモリに読み込んだうえで処理をかけている点です。この読み込むだけの操作が意外と重いようで、処理時間の半分くらいを占めていたようです。読み込んだデータの大部分を解析するならメモリに置いても損は無いとは思いますが、GSUBテーブルがvertタグを持っているかどうかを調べるだけなら全体をわざわざ読み込む必要はありません。というわけで、一つ目の点と併せて専用パーサーでは必要な部分だけをFileStreamでSeekしながら読み込むようにしました。

三つ目は単純です。それぞれのフォントファイルの読み込みを並列に処理するようにしました。環境にもよりますが、これで2割くらい処理速度の向上がされるようです。

これら3つの改善をすることで、読み込みが0.8秒くらいまで縮まりました。5倍くらいの改善ですね。実用的なレベルまで圧縮することができました。

実は、まだ現状はGSUBテーブルすべてを解析しているのですが、ここをFeatureTagの解析だけにするともう少し改善が見込めるかもしれません。ただ、めんどくさいので似たような実装を増やすのもあまりよくないので、それはまた必要になったときにでも。

下線/中線/上線の配置を改善

実は今までのTategakiは下線/中線/上線の場所があまりイケていませんでした。


こちらが今回の更新前(ver.3.2.0)で下線を引いたときの画像です。下線は引けているのですが、左隣の行のほうが近いですね。イケてないです。

この下線の位置はGlyphTypeface.UnderlinePositionプロパティからもらっていたのですが、まあ、標準ライブラリにあることからもわかる通りこれは横書き用の位置なんですね。それをそのまま縦書きで使っちゃそりゃイケてませんわ。

ということで、ちゃんとフォントデータのBASEテーブルを読み込むようにしました。ここでは縦書き用のベースラインなどの寸法情報が格納されていて、それをもとに下線/中線/上線などを描画することで、イケている位置に線を引けるようになります。

良い感じですね。離れすぎず、重なるわけでもなく、良い位置に線を引けるようになりました。

ちなみに、TategakiTextでは「Underline(下線)」と言うと文字列の左側、「Overline(上線)」と言うと右側に描画するのですが、これは縦書きは横書きを90度時計回りに回して描画しているという考えに基づくものです。ですが、Microsoft Wordでは縦書きで下線を設定すると右側に線が引かれるのですね。あまり深く意識したことが無かったですが、昔、国語のテストとかで線が引かれていたのも右側でしたっけ。まあ、TategakiTextで右側に線が欲しい人はTextDecorationsでOverlineを指定してください。

Wordで下線を指定したもの

特定のフォントファイルを読み込むと落ちる不具合を修正

行儀の悪いフォントファイルがあったときに落ちるバグみたいなのは…なかなか対処が難しいですね。これも私のノートパソコンでTategakiTextを使用したときに発覚した不具合でした。

OpenTypeフォントは、Headテーブルにて作成日時/更新日時情報を持っています(ファイルシステムのタイムスタンプとは別です)。これは1904年1月1日午前0時0分からの経過秒数を64bit型で記録されているのですが、この数値が大きすぎたとき、.NETのTimeSpan構造体に入りきらず例外を吐くようです。まあ実際にそんな日時は遠い未来ですのであり得ないのですが、お行儀が悪いフォントファイルだとこういう例外の原因になってしまうようでした。

この不具合は、境界チェックを入れるを入れることで回避するよう修正しました。

余談:WPFのContent

余談ですが、WPFにはContentという仕組みがあり、コントロールの内部に別のコントロールを入れ込むことができます。もちろんTategakiTextもWPFのコントロールですので、別のコントロールに内包することができます。

<Button>
    <tg:TategakiText Text="縦書きのボタン" />
</Button>

素晴らしいですね。WPFに縦書きコントロールがあるだけで、いとも簡単にこういったものも作れてしまう表現力がWPFのすごいところです。

今回のアップデートは、WPFでこういうことができることに気が付いて実際にコーディングしてみたところ、左右の余白の大きさが違って非常に不格好になってしまっているのに気が付いたところから始まりました。

その原因には実は心当たりがあって、Underlineを描画する際に、その座標を前述のとおりGlyphTypeface.UnderlinePositionから貰ったところ、値がマイナスにまで触りきってしまっていたので描画領域をはみ出て見切れるという事象があったのです。ですので、Underlineをの分を見越して左側に大きめの余白を用意していました。ですが、それでバランスが崩れるのはやはりイケていないです。そもそも上で述べた通り、その座標が左に寄りすぎていました。なぜだ?と調べていったところ、たどり着いたのが、その下線の座標は横書きを前提にしたものということでした。

コンピューターの文字の描画の原点は英語、すなわちアルファベットですからね。ご存じのとおり、アルファベットをきれいに書く練習を人間がするときは、4本の横線の中に字を書く練習をします。そして、下から2番目の線が基準線となっているわけです。ですので、文字と被ることがない下線を引こうと思ったら、マイナスの座標にまで入ることになるのです。

日本語ではそうではありません。日本語は、手書きの練習をするときは田の字のマスに字を書く練習をします。Underlineと言えば迷わず字の外殻に沿うように線を引けばいいだけなんですね。

そんな字体の違いから始まる文字描画の違い、それがこのTategakiを開発するうえでプログラミングの枠を超えて面白いところですね。

2024年4月21日日曜日

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

前回の記事で「まあこれくらいで良いかな」と言ったにもかかわらずまたアップデートしました。

Github:

Nuget:
https://www.nuget.org/packages/Tategaki/

今回の変更点

今回は目新しい新機能の追加等々はそんなに無いのですが、フォント周りの処理を一新しました。

今まではTypeLoaderというライブラリを使用して読み込んでいたのですが、これを自前のコードで実装しました。TypeLoaderで実装されていない情報を使用したかったのですが、やはり他人の書いたコードに手を入れるのは好きになれず…と言うよりあまり中身を理解しないまま触るのに抵抗感があり、自分での実装に踏み切りました。

それに伴って、以下の機能が実装されています。

  1. プロポーショナルフォントに対応
  2. 使用できるフォントが増えた
  3. 代替描画機能を実装

 順に説明していきます。

プロポーショナルフォントへの対応

世の中には二種類のフォントがある。等幅フォントとプロポーショナルフォントだ。

とまあ大げさに言うほどではないのですが、皆さんよくご存じと思います。

等幅フォントはプログラミングの際のテキストエディターなどでもよく使われていて、すべてのフォントの幅が同じものです。それに対してプロポーショナルフォントは、字によって幅が異なるフォントです。状況に応じて使い分けられるものですが、まあ、実装する側としては等幅フォントのほうが扱いやすいのは言うまでもありません。

プロポーショナルフォントには何種類か実装方法があるようですが、最も近代的な方法は、フォントファイルに含まれているVertical Proportional Alternateと呼ばれる情報をもとに文字の位置や幅を調整するものです。Windowsパソコンだと游ゴシックや游明朝などが対応しているようです。

このオプションを実装しました。

↓等幅フォント

↓プロポーショナルフォント

オプションで有効/無効を切り替えられるので、好みに合わせて使えば良いでしょう。

ちなみに、Vertical Proportional Alternateが含まれないフォントは、このオプションを有効にしてもプロポーショナルフォントにならないだけです。対応しているフォントはそんなに多くはなさそうでした。

使用できるフォントの増加

縦書きを実現するには、かっこや句読点など横書きと縦書きで異なる字体を取るものを置き換えて表示しなければなりません。

そもそも文字コードから字体を得るには、グリフインデックスと呼ばれるIDに変換したうえで、そのグリフインデックスをもとにフォントファイル内の描画情報を取得せねばなりません。縦書きの字体を得るためには、グリフインデックスを縦書きのグリフインデックスに読み替えたうえで縦書きの字体を得る必要があります。

この縦書きに変換するテーブルは、フォントファイル内のGSUBテーブル(Glyph Substitution Table)と呼ばれるものの中に含まれていて、縦書き以外にも様々な変換がこのテーブルに含まれています。例えばアラビア語は複数の文字がつながって一体になって描画されるため、複数のグリフインデックスを別の一つのグリフインデックスに変換するテーブルなどもあるそうです。そのため、テーブルの実装が8種類くらいあって、すべての機能を使用するならばすべての実装をせねばなりません。

ただ、縦書き変換に使用するのはGSUBの中でも特にSingle Substitutionと呼ばれる種類のテーブルだけですので、もともと使っていたTypeLoaderはこれを含む限定的な種類のテーブルにのみ対応していました。

しかし、実際にはExtension Substitutionと呼ばれるテーブルにも縦書きが格納されることがあるようです。と言うよりも入れ子になっていて、Extension Substitutionの中にSingle Substitutionテーブルが入っているという構造になっています。TypeLoaderはこのExtension Substitutionに対応しておらず、例えばこれを使用するYu Gothic UIなどでは縦書きを表示することができませんでした。

TategakiではこのExtension Substitutionにも対応させましたので、Yu Gothic UIを含むあらゆる縦書き対応フォントで描画することができるようになりました。

代替描画機能の実装

さて、気付いている人もいたかもしれませんが、実は、MS P明朝やMS PゴシックでTategakiTextを使用すると、若干表示が乱れます。

よく見ると、例えば1行目の「太刀の鞘(さや)」の部分を見るだけでも「あっ…」となりますね。

「の」と「鞘」も少しかぶっていますし、「(」は完全に「さ」とかぶっています。

これの原因は正直よくわからないのですが、DrawGlyphRunメソッドを使わずに、グリフをジオメトリに変換してDrawGeometryメソッドで描画することで回避することができるようです。

この機能をなんと名付けようか少し悩んだのですが、結局は「代替描画機能」としました。プロパティ名としてはEnableAlternateRenderingで、これを有効にするとGlyphRunを使わずにジオメトリで描画します。

見ての通り、見違えるほどきれいに描画できています。

ただ、描画処理は少し重くて、このサンプルアプリでウィンドウをリサイズしたりフォントサイズなどのスライダーを動かすとカクツキを感じます。あくまでもMS P明朝やMS Pゴシックをきれいに描画するための限定的なものと考えておきたいです。

ちなみにですが、代替描画ではなくても、フォントサイズを大きくすれば MS P明朝やMS Pゴシックでもきれいに描画されるようです。その境目で、フォントがビットマップからベクターに変化したように見えるので、もしかしたらそのあたりの不具合なのかもしれませんね。

フォントファイルの構造のお勉強

さて、最初にも述べた通り、今回のバージョンからフォントファイルを読み込むのに自前のコードを使用しています。

現代のWindowsパソコンなどで使われるフォントはOpenTypeと呼ばれるフォーマットになっていて、 これを読み取る必要があります。このフォーマットを勉強する必要があるのですが、結局は以下の2つのサイトが中心となりました。

前者はOpenTypeフォーマットの開発者の一人であるMicrosoftの公式ドキュメントで、網羅的に仕様が書いてあります。後者は、その中から特に日本語フォントで必要な内容をピックアップして日本語で解説されているサイトです。どちらも有用で大変お世話になりました。

もう一つ、GlyphLoaderです。

おそらくTypeLoaderと同じ作者が作ったライブラリで、TypeLoaderの後継と思われます。このソースコードが大変参考になりました。 

ところで、このようなバイナリーデータを読み込むにはうってつけの機能がSpan<T>で、ファイル内のデータをいったんすべてメモリに読み込みさえすれば、その先は部分部分を切り出して、わかりやすく、かつ高速にデータを切り出せます。さらに、BinaryPrimitivesというクラスがあり、ReadOnlySpan<byte>から任意のサイズ/エンディアンのデータを取り出すことができるので、ushortやintなどへの変換も簡単です。あとはIndex / Rangeさえ使えれば言うことは無かったのですが…この機能は.NET Frameworkでは使えないようですね…。Tategakiを.NET Frameworkで使ってくださっている方もいるようなので、ひたすらSliceしまくりました。



今度こそこんなものですかね。だいたいやりたいことはやり切った気がします。

ここのところ本業から帰宅した後、夜の時間をひたすらこのソフトの開発やフォントファイルのお勉強に使っていたので、寝不足気味なうえ疲れもあまり抜けていませんでした。

もう週末もほぼほぼ終わりになってしまいましたが、アニメでも消化しながらゆっくりするとしますか。

2024年4月13日土曜日

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

 最近Tategaki熱が再燃しています。大幅に機能を追加しました。

Github:

Nuget:
https://www.nuget.org/packages/Tategaki/

追加した機能

TategakiTextのプロパティ

追加した機能はReadmeのほうにも書いてありますが、TextBlockにある主要なプロパティを TategakiTextにも追加し実装しました。追加したプロパティは以下の通りです。

  1. TextWrapping
  2. TextDecorations
  3. LineHeight
  4. TextAlignment
  5. Padding
  6. LastForbiddenChars / HeadForbiddenChars / LastHangingChars
  7. EnableHalfWidthCharVertical

1番から5番まではTextBlockにもあるプロパティで、その挙動も極力TextBlockに似せているので特段使い方の説明はいらないと思います。

TextWrappingが実装されたことにより、今まで1行表示しかできなかったTategakiTextが複数行表示もできるようになりました。改行文字も認識しますので、下のデモソフトのように小説のような長い文章でもちゃんと折り返して正確に表示できます。TategakiMultilineは多数のTategakiTextをItemsPanelで並べて折り返しを実現していたので、それに比べればだいぶ動作が軽快になりました。

禁則文字 / ぶらさげ文字

TategakiMultilineには禁則文字(文末禁止文字 / 文頭禁止文字)が設定できました。TategakiTextでも同様の設定ができるようになったうえ、文末ぶらさげ文字も設定できるようになっています。文末に来た場合、はみ出し前提で下にぶらさげる文字ですね。

また、TextWrapping列挙型にはWrapとWrapOverflowという2種類のオプションがあります。

TextWrapping.Wrap


TextWrapping.WrapOverflow

長い一単語があって幅が入りきらなくなったとき、単語の途中で折り返すのがWrap、単語を右側にはみ出させるのがWrapOverflowです。もちろんTategakiTextもこの機能に対応しています。禁則文字の処理の一環として実装されています。

EnableHalfWidthCharVertical

半角の文字を縦書きにするかどうかのオプションです。

上に2つのテキストがありますが、左がこのオプションがOFF、右がONです。ちなみにフォントにvrt2が含まれている場合はこのオプションにかかわらず左側のスタイルになります。

フォント読み込み処理回り

従来はEnvironment.GetFolderPathメソッドを使ってシステムのフォントファイルを読み込んで縦書きが有効なフォントを抽出していました。

string FontDir = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);

var uris = Directory.GetFiles(FontDir, "*.ttf").Concat(Directory.GetFiles(FontDir, "*.otf")).Select(p => new Uri(p))
    .Concat(Directory.GetFiles(FontDir, "*.ttc").SelectMany(p => {
        using(var fs = new FileStream(p, FileMode.Open, FileAccess.Read)) {
            return Enumerable.Range(0, TypefaceInfo.GetCollectionCount(fs)).Select(i => new UriBuilder("file", "", -1, p, "#" + i).Uri);
        }
    })
);

これはグリフレベルで描画をする際にどうしてもフォントのURIが必要だからです。ですが、いわゆる「游ゴシック」などのフォントファミリー名からURIを取得する手法がわからず、逆にURIからフォントファミリー名を取得してテーブルとして保持していました。

しかし、フォントはWindowsの特定のユーザーのみにインストールすることもでき、その場合はシステムフォルダ(C:\Windows\Fonts;GetFolderPathで取得できるフォルダ)にフォントファイルは入りません。ですので今までそのようなフォントは読み込めませんでした。

ユーザー用フォントのフォルダを足すのは簡単ですが、Windowsの仕様変更があればまたソフト側でも対応する必要が出てきます。ですので、やはり何かOSやフレームワークが提供する何らかの方法でフォントを取得したいですよね。

そしていろいろと調べていった結果、最終的に以下のような処理にたどり着きました。

var fonttable = new Dictionary<string, VerticalFontInfo>();
var namelist = new List<string>();

foreach(var ff in Fonts.SystemFontFamilies) {
    var tf = new Typeface(ff.Source);
    if(!tf.TryGetGlyphTypeface(out var gtf))    // GlyphTypefaceが取得できなければ用無し
        continue;

    int num = gtf.FontUri.Fragment == "" ? 0 : int.Parse(gtf.FontUri.Fragment.Replace("#", ""));
    var tfi = new TypefaceInfo(gtf.GetFontStream(), num);

    VerticalConverterType convtype = VerticalConverterType.None;
    if(tfi.GetVerticalGlyphConverter().Count > 0)
        convtype |= VerticalConverterType.Normal;
    if(tfi.GetAdvancedVerticalGlyphConverter().Count > 0)
        convtype |= VerticalConverterType.Advanced;
    if(convtype == VerticalConverterType.None)    // 縦書きコンバーターが取得できなければ用無し
        continue;

    var vfi = new VerticalFontInfo(gtf, ff.Source, convtype);
    namelist.Add(vfi.OutstandingFamilyName);
    foreach(var name in vfi.FamilierFamilyNames.Select(p => p.familyname).Distinct())
        fonttable[name] = vfi;
}

namelist.Sort();

まず、Fonts.SystemFontFamiliesでシステムに存在するフォントファミリー名を取得します。その後、そのフォントファミリー名をもとにTypefaceクラスをインスタンス化し、そこからGlyphTypefaceを取得することで、そのメンバからURIを取得することができるのです。

ただ、Fonts.SystemFontFamiliesで取得したフォントファミリー名とGlyphTypeface内にあるフォントファミリー名が違うことがあるようです。この辺のWindowsの挙動がほんとよくわからないですが、Tategakiではどのファミリー名でも目的のURIにたどり着けるように一生懸命キャッシュしています。

 

さて、ここまで作ったら、まあだいたいの用途は満たせそうなのでこんなもんで良いかな…。 TextTrimmingの実装とかをやっても良いかもしれませんが、本家TextBlockって実はHTMLみたいな結構強力な表現ができるみたいで、いろいろ実装していたらきりが無いですからね。まあ、複数フォントを混ぜたかったら複数TategakiTextを並べれば良いだけですし。