2014年7月22日火曜日

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

(2024/05/11追記)
Google等からこのページに直接来られる方が多いようですが、最新版はver.3.2.2になっていますので、こちらからどうぞ。

今回はWPFでの縦書きテキストについてです。

Win32API等を叩いたことがある人に「縦書きのテキストを表示したいときはどうする?」と聞いたら、おそらく即答で「縦書きフォント(通常のフォント名の頭に@を付けたもの)で90°回転させて文字列を描画する」と言うかと思います。LOGFONT構造体に文字列の角度を指定するメンバがあるので、そこに90°を指定して縦書きフォントを選択したうえでTextOut関数などを呼びだせば縦書きのテキストが完成します。

しかし、WPFではそう簡単にいきません。

というのも、なぜか縦書きフォントが封印されています。まあそもそもWPFはGDIベースではないので仕組みが全く違うと言えばそれまでなんですが、フォントファミリ名に@を付けたところで縦書きフォントになってくれません。

困りました。

じゃあどうするか。グリフというものを使えば良いようです。
フォントはあらゆる字に対しての統一的な書体のことを言いますが、グリフっていうのは個々の字そのものの形を意味する言葉らしいです。初めて聞きました。
C#では一般的にUnicodeで文字列が管理されていますが、そのある字に対して、そのUnicodeを「グリフインデックス」と呼ばれるあるフォント特有のインデックスに変換することで、フォントデータからその字を呼び出し画面に表示するという処理をしているようです。そのレベルでテキストをいじるクラスがGlyphsクラスのようです。普段は手を出さなくてもよさそうな低レイヤーなところですね。

当然、縦書きと横書きなんかで字のUnicode値が変わることはありませんが、グリフでは変わるものが出てきます。
例えば、句読点、括弧、記号などです。横書きの文字をそのまま縦に並べると、句読点の打たれる位置に違和感が出るでしょう。それ以外にも、括弧や記号などの形がおかしくなります。










このように書けば一目瞭然ですね。なので、縦書きは縦書き用のグリフに変換してやる必要があります。

一般的にこのUnicode値とグリフインデックスの変換に関しては法則性は無く、テーブルを介して変換するようです。
例えば、C#ではGlyphTypeface.CharacterToGlyphMapというプロパティを使って文字をグリフインデックスに変換してやることができます。しかしこれは横書き用の変換で、縦書き用の変換に関するものはC#には用意されていないようです。

非常に不便ですね。嫌になっちゃいます。なんでWin32APIであんなに簡単にできていたことでC#でこんなに苦労せにゃならんのだと。ついでに言えば、英語圏の人は別に縦書きなんて必要としないので、そういう意味でもこの縦書きに関する記事は非常に少ないです。


さて、愚痴だらけになってしまいましたが、今回の記事の目標はWPFでテキストブロックの縦書き版みたいなものを作るところにしたいと思います。

結論から言いますと、UniscribeというMicrosoftのテキストレンダリングエンジンを使うことで頑張って縦書き用グリフインデックスを取得しちゃえば良いという話です。これに関しては、えムナウ氏のブログに参考になりそうなコードが載っておりました。

WPF の縦書き
縦書きライブラリ

ネイティブの関数を呼び出さなければいけないのでDllImportだの[StructLayout(LayoutKind.Sequential)]だのC#らしくないところで散々苦労しなければならなくなってしまっています。が、えムナウ氏がその辺のラッパーを書いてMITライセンスで配布してくださっていますので、こちらを今回は使わせていただくことにしました。えムナウ氏のコードは縦書き用グリフインデックスを取得するあたりに特化しており、WPFのコントロール部はほとんど入っておりません。なので、今回はこれを実装しました。

割とXAMLはシンプルで、グリフを1つ用意し、縦書き用に90°回転させてやるだけです。

<UserControl x:Class="Tategaki.TategakiText"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" >
    <Grid>
        <Glyphs Name="glyphs1" Fill="Black" UnicodeString=" " IsSideways="true">
            <Glyphs.LayoutTransform>
                <RotateTransform Angle="90"/>
            </Glyphs.LayoutTransform>
        </Glyphs>
    </Grid>
</UserControl>

GlyphsはUnicodeStringとIndicesの両方を空にしては行けないようなので、初期値でスペースを与えています。

コードビハインドでは結構ごちゃごちゃやっています。

/// <summary>
/// TategakiText.xaml の相互作用ロジック
/// </summary>
public partial class TategakiText : UserControl
{
    public TategakiText()
    {
        InitializeComponent();
    }

    static Dictionary<string, string> fontPathDictionary = null;
    static Dictionary<string, string> FontPathDictionary
    {
        get
        {
            if(fontPathDictionary == null)
                fontPathDictionary = SearchFontNamePathPair(new CultureInfo[] { CultureInfo.CurrentCulture, new CultureInfo("en-US") });
            return fontPathDictionary;
        }
    }

    /// <summary>
    /// 有効なフォントの一覧を取得するメソッド
    /// </summary>
    /// <param name="cultures">フォントのカルチャの配列</param>
    /// <returns></returns>
    static Dictionary<string, string> SearchFontNamePathPair(IEnumerable<CultureInfo> cultures)
    {
        Dictionary<string, string> ret = new Dictionary<string, string>();

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

        //キーを読み取り専用で開く
        RegistryKey regkey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts", false);
        string[] FontFiles = regkey.GetValueNames().Select(p => (string)regkey.GetValue(p)).ToArray();

        foreach(string file in FontFiles) {
            try {
                string path = FontDir + System.IO.Path.DirectorySeparatorChar + file;
                GlyphTypeface typeface = new GlyphTypeface(new Uri(path));

                foreach(CultureInfo culture in cultures) {
                    string FamilyName = typeface.FamilyNames[culture];

                    if(!string.IsNullOrEmpty(FamilyName) && !ret.ContainsKey(FamilyName))
                        ret.Add(FamilyName, path);
                }
            }
            catch(FileFormatException) { }
            catch(NotSupportedException) { }
        }

        return ret;
    }

    /// <summary>
    /// このコントロールで使えるフォント名を列挙するメソッド
    /// </summary>
    /// <returns>使えるフォントファミリ名</returns>
    public static string[] GetAvailableFonts()
    {
        return FontPathDictionary.Keys.ToArray();
    }

    /// <summary>
    /// 表示テキスト
    /// </summary>
    public static readonly DependencyProperty TextProperty = DependencyProperty.Register(
        "Text", typeof(string), typeof(TategakiText), new PropertyMetadata((d, e) => {
            TategakiText me = (TategakiText)d;
            me.RedrawText();
        }));

    /// <summary>
    /// 表示テキスト
    /// </summary>
    public string Text
    {
        get { return (string)GetValue(TextProperty); }
        set { SetValue(TextProperty, value); }
    }

    /// <summary>
    /// 文字間隔
    /// </summary>
    public static readonly DependencyProperty SpacingProperty = DependencyProperty.Register(
        "Spacing", typeof(double), typeof(TategakiText), new PropertyMetadata((double)100, (d, e) => {
            TategakiText me = (TategakiText)d;
            me.RedrawText();
        }));

    /// <summary>
    /// 文字間隔
    /// </summary>
    public double Spacing
    {
        get { return (double)GetValue(SpacingProperty); }
        set { SetValue(SpacingProperty, value); }
    }

    protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
    {
        switch(e.Property.Name) {
            case "FontFamily":
                try {
                    this.glyphs1.FontUri = new Uri(FontPathDictionary[FontFamily.Source]);
                    RedrawText();
                }
                catch(KeyNotFoundException) {
                    throw new ArgumentException("Cannot use this font.");
                }
                break;
            case "FontSize":
                this.glyphs1.FontRenderingEmSize = FontSize;
                RedrawText();
                break;
            case "Foreground":
                this.glyphs1.Fill = Foreground;
                RedrawText();
                break;
        }
        base.OnPropertyChanged(e);
    }

    void RedrawText()
    {
        if(string.IsNullOrEmpty(Text))
            this.glyphs1.UnicodeString = " ";
        else {
            ushort[] glyphs = Uniscribe.GetGlyphs(Text, FontFamily.Source, (int)FontSize);
            string[] IndicesTexts = glyphs.Select((p, i) => {
                StringBuilder sb = new StringBuilder();
                sb.Append(p);

                if(i < glyphs.Length - 1)
                    sb.AppendFormat(",{0}", Spacing);

                return sb.ToString();
            }).ToArray();

            this.glyphs1.UnicodeString = Text;
            this.glyphs1.Indices = string.Join(";", IndicesTexts);
        }
    }
}

まず、フォントファミリ名とフォントファイルの対応付けをやっています。
フォントのディレクトリを取得し、一方、レジストリからインストールされているフォントの一覧を読みだして、その一つ一つについてフォントファミリ名を取得しています。使えないのは省いています。
フォントディレクトリ内のファイルを列挙して総当りする処理もやってみましたが、かなり時間が掛かったので(おそらく例外周りのせい)とりあえずレジストリの方法にしています。

あとは、表示テキストや文字間隔の依存関係プロパティを作り、OnPropertyChangedをオーバーライドして特定のプロパティが変更されたときにグリフの設定を変更する作業などもしています。
RedrawTextメソッドがそのメソッドで、取得したグリフや文字間隔の値を使いながらIndicesをフォーマットし、設定しています。

このコントロールを使用すとこんな表示ができるようになります。


これは、次のようなXAMLで実現しています

<Window x:Class="Frontend.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
        xmlns:ei="http://schemas.microsoft.com/expression/2010/interactions"
        xmlns:l="http://schemas.livet-mvvm.net/2011/wpf"
        xmlns:v="clr-namespace:Frontend.Views"
        xmlns:vm="clr-namespace:Frontend.ViewModels"
        xmlns:tg="clr-namespace:Tategaki;assembly=Tategaki"
        Title="Tategaki Sample" Height="640" Width="480">
    
    <Window.DataContext>
        <vm:MainWindowViewModel/>
    </Window.DataContext>
    
    <i:Interaction.Triggers>
    
        <!--Viewに特別な要件が存在しない限りは、トリガーやアクションの自作にこだわらず積極的にコードビハインドを使いましょう -->
        <!--Viewのコードビハインドは、基本的にView内で完結するロジックとViewModelからのイベントの受信(専用リスナを使用する)に限るとトラブルが少なくなります -->
        <!--Livet1.1からはコードビハインドでViewModelのイベントを受信するためのWeakEventLisnterサポートが追加されています --> 
        
        <!--WindowのContentRenderedイベントのタイミングでViewModelのInitializeメソッドが呼ばれます-->
        <i:EventTrigger EventName="ContentRendered">
            <l:LivetCallMethodAction MethodTarget="{Binding}" MethodName="Initialize"/>
        </i:EventTrigger>

        <!--Windowが閉じたタイミングでViewModelのDisposeメソッドが呼ばれます-->
        <i:EventTrigger EventName="Closed">
            <l:DataContextDisposeAction/>
        </i:EventTrigger>

        <!--WindowのCloseキャンセル処理に対応する場合は、WindowCloseCancelBehaviorの使用を検討してください-->

    </i:Interaction.Triggers>
    
    <StackPanel Orientation="Horizontal" FlowDirection="RightToLeft">
        <tg:TategakiText Text="羅生門"
                         FontFamily="メイリオ" FontSize="36" Spacing="200"
                         HorizontalAlignment="Center" VerticalAlignment="Center"
                         RenderTransformOrigin="0.5,0.5">
            <tg:TategakiText.RenderTransform>
                <ScaleTransform ScaleX="-1" />
            </tg:TategakiText.RenderTransform>
        </tg:TategakiText>
        <tg:TategakiText Text="芥川龍之介"
                         FontFamily="メイリオ" FontSize="18" Spacing="100"
                         HorizontalAlignment="Center" VerticalAlignment="Bottom"
                         RenderTransformOrigin="0.5,0.5">
            <tg:TategakiText.RenderTransform>
                <ScaleTransform ScaleX="-1" />
            </tg:TategakiText.RenderTransform>
        </tg:TategakiText>
        <tg:TategakiText Text="すると、老婆は、見開いていた眼を、一層大きくして、じっとその下人の"
                         FontFamily="メイリオ" FontSize="18" Spacing="100"
                         HorizontalAlignment="Center" VerticalAlignment="Top"
                         RenderTransformOrigin="0.5,0.5">
            <tg:TategakiText.RenderTransform>
                <ScaleTransform ScaleX="-1" />
            </tg:TategakiText.RenderTransform>
        </tg:TategakiText>
        <!-- 中略 -->
    </StackPanel>
</Window>

スタイルとか使えばいいじゃんって突っ込まれそうですけど、まあサンプルなのでプロパティの使い方がわかりやすいようにこういう記述のしかたをしています。
このTategakiTextには改行機能は無いので現状1行ずつインスタンスを作っていますが、それを順に並べるのにはStackPanelを使っています。ごく自然なStackPanelの使い方だとは思いますが、右から順に並べたいのでFlowDirectionをRightToLeftに指定しています。しかし、これを指定するとStackPanel内の表示内容も左右反転してしまうようなんですね。アラビア語とかの使用を想定したインターフェースなのでしょうか。とりあえず、左右が逆転していては仕方ないので、各TategakiTextをRenderTransformで左右反転しています。

これでめでたく縦書きをWPFコントロールというコンテナ化された状態で使えるようになりましたとさ。めでたしめでたし。


サンプルコードを含むこのライブラリのダウンロードはこちらからできます。
WPF用縦書きテキストブロック Tategaki ver.1.0.0
((2015/1/22)都合により削除しました。ver1系はver.1.1.2を使ってください)

0 件のコメント:

コメントを投稿