ラベル Extended WPF Toolkit の投稿を表示しています。 すべての投稿を表示
ラベル Extended WPF Toolkit の投稿を表示しています。 すべての投稿を表示

2015年7月4日土曜日

Windowsの「追加の時計」をいじる

さて、Windowsには追加の時計というものがありますね。


このように、タスクバーの右下の時計を押したら出てくるやつに世界時計を2つまで追加できる機能です。家族、職場の知り合い等が海外に出かけているとき、これを設定しておくとその人が今何時なのかが一目でわかるので、電話を掛けるタイミングが見当付くようになるなど、とても便利なものです。

しかしこれ、設定のUIがとてつもなく不便です。


このように、一覧から表示するタイムゾーンを選択しなければなりません。しかも、表示されるのは国名ではなく、都市名、もしくは地域名です。そして、このリストにはUTCとの時差が同じ都市がいくつもありますが、これの選択を誤るとサマータイムの反映がうまくされず、実際とは異なる時刻が表示されることがあります。
時刻を表示したい地域について、周辺都市やタイムゾーンを熟知している人なら簡単に選択できるでしょうが、それができるのは多分その国に住んでいる人か、もしくはよほどの地理オタクくらいなものです。

すなわち、普通に使うには、これはとても不便なのです。

これをもうちょっと、例えば地図で選択した地点から直接タイムゾーンを判定して表示したり、 国名を入力したら自動的に選択してくれたり、はたまたTwitterの位置情報からタイムゾーンを入力したりするようなUIが必要ですよね。
そういった設定ができるようなソフトウェア開発について考えていきましょう。

「追加の時計」の設定を変更する

さて、それではどうやってこの時計を設定すればいいでしょう。
たいてい、こういうのはレジストリに書き込まれていますから、レジストリでそれっぽいキーを探します。
そうすると、見事に出てきました。

HKEY_CURRENT_USER\Control Panel\TimeDate\AdditionalClocks

ここに「1」というキーと「2」というキーがあり、これがそれぞれ追加の時計1,2に対応しています。


値は非常にシンプルです。
DisplayNameは表示名ですね。時計のタイトルで、任意の文字列です。
Enableはその時計が有効かどうかです。0で無効、1で有効ですね。
TzRegKeyNameがタイムゾーンを表すキー名です。なんのこっちゃってなるかもしれませんが、HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Time Zonesに格納されている各タイムゾーンに対応するレジストリキー名となります。

さて、となると、このタイムゾーンのレジストリキーまでパースしてやらなきゃいけないのかという話になりそうですが、そんなことはありません。.NETのTimeZoneInfoクラスIdプロパティの値が、このレジストリキーに対応しています。TimeZoneInfoはGetSystemTimeZonesメソッドによって一覧を取得できるので、これを適当に選択してレジストリに書き込んであげれば時計は設定できるわけですね。

というわけで、追加の時計を変更するメソッドは下記のような形になります。

/// <summary>
/// 「追加の時計」に時刻を設定するメソッド
/// </summary>
/// <param name="ClockNumber">時計の番号。1または2</param>
/// <param name="DisplayName">表示名</param>
/// <param name="IsEnable">有効にするか、無効にするか。</param>
/// <param name="TimeZone">設定するタイムゾーン</param>
public static void SetAdditionalClock(int ClockNumber, string DisplayName, bool IsEnable, TimeZoneInfo TimeZone)
{
    if(ClockNumber < 1 || ClockNumber > 2)
        throw new ArgumentOutOfRangeException("ClockNumber");
    if(IsEnable) {
        if(DisplayName == null)
            throw new ArgumentNullException("DisplayName");
        if(TimeZone == null)
            throw new ArgumentNullException("TimeZone");
    }

    using(RegistryKey Key = Registry.CurrentUser.CreateSubKey(string.Format(@"Control Panel\TimeDate\AdditionalClocks\{0}", ClockNumber))) {
        if(IsEnable) {
            Key.SetValue("DisplayName", DisplayName, RegistryValueKind.String);
            Key.SetValue("Enable", 1, RegistryValueKind.DWord);
            Key.SetValue("TzRegKeyName", TimeZone.Id, RegistryValueKind.String);
        } else
            Key.SetValue("Enable", 0, RegistryValueKind.DWord);
    }
}

今まで追加の時計を設定したことが無い環境ではレジストリキーすら存在しないようなので、そのような環境のためにCreateSubKeyにてキーを作成(もしくは存在する場合はそれを開く)しています。
あとは上記の3つのパラメーターを書き込んであげているだけなので至ってシンプルです。これで追加の時計の設定ができてしまいます。驚くほどあっさりしていました。

緯度経度からタイムゾーンを取得する

さて、追加の時計の設定変更は簡単にできましたが、そもそもTimeZoneInfo.GetSystemTimeZonesが先ほどのWindows標準のタイムゾーン一覧を表示するコンボボックスの内容を返すだけなので、どうにかして何かしら別の情報(緯度経度など)をタイムゾーンに変換する必要があります。

まあ、もっともお手頃そうなのはGoogle Time Zone APIですかね。
時刻と緯度経度を指定すると、JSONでその夏時刻を含めたタイムゾーンを返してくれます。なので、いつも通りJson.NETを使ってタイムゾーンを取得するクラスを作りました。

public class GoogleTimeZone
{
    private GoogleTimeZone(InternalGoogleTimeZone Source)
    {
        DaylightSavingTimeOffset = TimeSpan.FromSeconds(Source.dstOffset);
        RawOffset = TimeSpan.FromSeconds(Source.rawOffset);
        Status = Source.status;
        TimeZoneId = Source.timeZoneId;
        TimeZoneName = Source.timeZoneName;
    }

    /// <summary>
    /// サマータイムのオフセット
    /// </summary>
    public TimeSpan DaylightSavingTimeOffset { get; private set; }

    /// <summary>
    /// サマータイム関係なしのオフセット
    /// </summary>
    public TimeSpan RawOffset { get; private set; }

    /// <summary>
    /// ステータス
    /// </summary>
    public string Status { get; private set; }

    /// <summary>
    /// タイムゾーンのID
    /// </summary>
    public string TimeZoneId { get; private set; }

    /// <summary>
    /// タイムゾーン名
    /// </summary>
    public string TimeZoneName { get; private set; }

    public override string ToString()
    {
        if(Status != "OK")
            return Status;
        else {
            string RawOffsetText = (RawOffset < TimeSpan.Zero ? "-" : "+") + RawOffset.ToString("hh\\:mm");
            string DSTText = string.Empty;

            if(DaylightSavingTimeOffset != TimeSpan.Zero) {
                DSTText = (DaylightSavingTimeOffset < TimeSpan.Zero ? "-" : "+") + DaylightSavingTimeOffset.ToString("hh\\:mm");
                DSTText += "(DST)";
            }

            return string.Format("(UTC{0}{1}) {2}/{3}", RawOffsetText, DSTText, TimeZoneName, TimeZoneId);
        }
    }

    public static async Task<GoogleTimeZone> CoordinateToTimeZoneAsync(double Latitude, double Longitude, DateTime? UtcTime = null, string ApiKey = null)
    {
        if(UtcTime == null)
            UtcTime = DateTime.UtcNow;

        long UnixTime = (long)(UtcTime.Value - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds;

        string url = string.Format(@"https://maps.googleapis.com/maps/api/timezone/json?location={0},{1}&timestamp={2}", Latitude, Longitude, UnixTime);

        if(!string.IsNullOrEmpty(ApiKey))
            url += "&key=" + ApiKey;

        string json;

        using(WebClient wc = new WebClient()) {
            json = await wc.DownloadStringTaskAsync(url);
        }
        return new GoogleTimeZone(JsonConvert.DeserializeObject<InternalGoogleTimeZone>(json));
    }
}

internal class InternalGoogleTimeZone
{
    public int dstOffset { get; set; }
    public int rawOffset { get; set; }
    public string status { get; set; }
    public string timeZoneId { get; set; }
    public string timeZoneName { get; set; }
}

Json.NET用に単純なInternalGoogleTimeZoneクラスを作り、そこからプロパティのアクセスレベルや型をしっかり作ったクラスに変換してあげる形にしています。これで、緯度経度をタイムゾーンに変換できるようになりました。
ちなみに、このAPIでは緯度経度のほかに時刻を渡す必要があります。その時刻によって、サマータイムの季節かどうかを判別してくれるようです。

GoogleのタイムゾーンをWindowsのタイムゾーンに変換する

さて、Googleからタイムゾーンが取得できるようになりましたが、Windowsの形式とは若干異なります。UTCからの時差はどちらも出ますが、サマータイムが採用されているかどうかの判別はGoogleのほうには無く、というよりも、タイムゾーンを求める段階で時刻を指定してサマータイム中ならばサマータイム中だという返答が返ってくるだけになります。
なので、この辺を上手く扱って、GoogleのタイムゾーンをWnidowsのタイムゾーンに変換してやらねばなりません。これは結構苦労しました。

 結果から言うと、下記の手順で求めました。
  1. 子午線が一致するタイムゾーンをWindowsのタイムゾーンのリストから抜き出す
  2. 都市名等を単語ごとに分解し、都市名にかかわらないstandard, time, daylight, city, summerなどの文字列を除外する(それで残った単語を『キーワード』と呼ぶことにする)
  3. GoogleタイムゾーンとWindowsタイムゾーンで、一致するキーワードの個数がもっとも多いペアが、そのGoogleタイムゾーンに対応するWindowsのタイムゾーンということにする。キーワードの個数が同じ組み合わせが複数あった場合は、キーワードの個数がもっとも少ないものを採用する。
まあ何を言いたいかというと、Googleのタイムゾーンでは、例えばロサンゼルス周辺のタイムゾーンを取得しようとすると
America/Los_Angeles
Pacific Standard Time
の2種類の文字列を返してきます。一方、Windowsのタイムゾーンでは
Pacific Standard Time
という文字列が返ってきます。
この「Pacific」という文字列の一致から、対応するタイムゾーンを探し出そうというわけです。
Windowsのタイムゾーンにはほかにも
Pacific Standard Time (Mexico)
などがあります(メキシコの太平洋標準時)が、それらはGoogleのタイムゾーン側にMexicoとかそういった単語が無い場合は最も短い名前の都市名と一致を掛けるので、該当しなくなるわけです。

public async Task GetTimeZone()
{
    GoogleTimeZone gtz = await GoogleTimeZone.CoordinateToTimeZoneAsync(Latitude, Longitude);

    var WindowsTimeZones = TimeZoneInfo.GetSystemTimeZones().Where(p => p.BaseUtcOffset == gtz.RawOffset)
        .Select(p => new { Source = p, Keywords = TimeZoneTextToCityKeywords(p.Id) }).ToArray();
    var GoogleTimeZoneTexts = TimeZoneTextToCityKeywords(gtz.TimeZoneId + " " + gtz.TimeZoneName);

    //子午線が一致する都市のうち
    //1. 一致するキーワードが多いもの
    //2. そのうち、キーワードの長さが最も短いもの
    //を抽出する
    TimeZone = WindowsTimeZones.Select(p => new {
        Source = p.Source,
        SameTextCounts = p.Keywords.Concat(GoogleTimeZoneTexts).Count() - p.Keywords.Concat(GoogleTimeZoneTexts).Distinct().Count(),
        SourceTextCounts = p.Keywords.Count()
    }).OrderByDescending(p => p.SameTextCounts).ThenBy(p => p.SourceTextCounts).First().Source;
}

/// <summary>
/// タイムゾーンのテキストからその地域名に係るキーワードを抜き出すメソッド
/// </summary>
/// <param name="TimeZoneName"></param>
/// <returns></returns>
private static string[] TimeZoneTextToCityKeywords(string TimeZoneName)
{
    string[] spacers = { "/", "_" };
    string[] removes = { "(", ")", "standard", "time", "daylight", "city", "summer" };

    TimeZoneName = TimeZoneName.ToLower();

    foreach(string s in spacers)
        TimeZoneName = TimeZoneName.Replace(s, " ");
    foreach(string r in removes)
        TimeZoneName = TimeZoneName.Replace(r, string.Empty);

    return TimeZoneName.Split(' ').Where(p => !string.IsNullOrEmpty(p)).OrderBy(p => p).Distinct().ToArray();
}

実装はこんな感じになっています。
まあ、本当にこれで完全かと聞かれるとちょっと自信は無いですが、少なくとも最初に子午線の一致を確認しているので、誤判定があったところでサマータイムの問題くらいでしょう。
もしも問題が見つかれば個別にちょっと考えていこうとは思っています。

LINQ to Twitterで位置情報を取得する

さて、緯度経度をどこから持ってくるかと言う話ですが、海外に行っていることをTwitterでつぶやいている人がいたので、Twitterから持ってくることにしました。

しかしこれ、意外とドツボです。

最近、Twitter公式アプリとかを見てもらうとわかりますが、位置情報をつぶやくときに「正確な位置を共有」とかいうオプションがあります。従来のTwitterでは緯度経度ベースの正確な位置情報しかつぶやけませんでしたが、個人情報に配慮したのでしょうか、最近はこういうオプションが生まれました。
そのせいで、位置情報にかかわる機能がややこしくなっているんですね。

Twitterが返す生のJSONまでは調べていませんが、LINQ to Twitterではそのあたりが下記のような仕様になっているようです。

従来の(正確な)位置情報の場合

Status.Coordinates.Latitude
Status.Coordinates.Longitude
に緯度経度が入っています。正確な位置情報が共有されていない場合や、そもそも位置情報を付けてつぶやいていない場合は緯度経度がともにゼロになっています。
なお、正確な位置情報が共有されている場合でも、下記の大雑把な位置情報も入っていますので、そちらも併せて利用することができます。

大雑把な位置情報の場合

Status.Coordinatesはnullにはなりませんが、緯度経度がともにゼロになります。
そして、Status.Place.BoundingBox.Coordinatesに緯度経度がいくつか(多分4つ)入ります。Twitterの開発者向けサイトには
A bounding box of coordinates which encloses this place. 
と書いてあるので、その位置情報のエリアを囲う座標が格納されているという意味なのでしょう。
というわけで、正確な位置情報が共有されていない場合は、そのエリアの頂点の各座標の重心(座標の平均値)を現在位置とすることとしました。

ちなみに、このStatus.Placeには地名の情報も入っています。

Status.Place.FullName: 地域名(東京都千代田区など)
Status.Place.Country: 国名(日本など)

なので、このあたりも使えそうですね。


昔のTwitterクライアントなんかではこの大雑把な位置情報には対応していなかったりしますが、特に今回のタイムゾーンを表示する程度の場合では大雑把な位置情報でも問題はないので、積極的にこちら側の情報も使っていくこととしました。



まあ要するに、
  1. Twitterから緯度経度を取ってくる
  2. Google Time Zone APIでその緯度経度に対応したタイムゾーンを調べる 
  3. キーワード比較でWindowsのタイムゾーンに変換する
  4. その設定を時計に転送する
みたいな流れで時計を自動設定できるようなソフトを作ってみました。はい。
できればGoogleマップで表示している場所のタイムゾーンをみたいな機能も付けたかったのですが、何せWPFのWebBrowserコントロールの闇が深すぎて…

Twitter to WorldClock ver.0.1.0
 ※今回は諸事情あってソースコードは配布しておりません。悪しからず。

2014年12月4日木曜日

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

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

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

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

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

//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インスタンスを用意し、必要なパラメーターを与えるとそのインスタンスを使って縦書きテキストのサイズを測るメソッドを作りました。

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

2014年6月25日水曜日

ラングレーの問題ソルバー

ここんとこ組み込みの話ばかりしていましたが、一応プログラミングのブログということで、今度はパソコン上で動くアプリケーションのプログラミングの話をしていきます。

ラングレーの問題という問題があります。

ラングレーの問題 - Wikipedia

はい。中学だか高校だかで一度は見たことある図形ですね。
各々の角度を計算して書きこむだけでは答えが出ないので、何か補助線を引いたり、何かしらの図形の相似を証明するなどしながら解いていかなければいけないので、結構難問として図形を覚えてる人もいるかと思います。

このラングレーの問題、別にこの形のこの角度である必要はどこにもなく、底辺の1本の線分に対してそれぞれの頂点から2本線を生やして交点をいい感じに結べばそれはラングレーの問題になるっぽいです。特に、それぞれの角度を10°の倍数にしておいたらいい感じに求める角xも10°の倍数になる問題が100以上もできるそうです。すごいですねー。

昨日出会ったのはこの問題でした。


(a,b,c,d)=(10°,70°,60°,20°)です。解いてみるとさっぱりわかりません。
結局、このような神がかった補助線を引くことで答えが出るらしいです。


はい。点Eは△DBCの外心、点Fは△ABCの内心です。点EはAC上に乗り、点D,E,Fは一直線に並びます。そして点D,A,F,Cは同一円周上に乗るので、ゴニョゴニョ計算してやると∠ADBが求まるわけです。こんなの思いつかねえよ…。

ところで、このラングレーの問題、神がかった補助線を思いつくかどうかは別問題として、線分BCの両端から生える線と線分BCのなす角を固定してやれば、点A,B,C,Dの位置は確定するので、力技で角度を求めることができるようになります。
ということは、コンピューターで答えを計算したくなりますよね?なってください。

早速ソフトを書いてみました。

パソコン上で動作するアプリケーションを開発するときは、私はいつもC#+WPF+Livetで作っちゃいます。そして、たいてい便利なコントロールを導入するためにExtended WPF Toolkitを入れます。
Livetはプロジェクトテンプレートを簡単にインストールできますし、Extended WPF ToolkitはNugetから簡単にインストールできます。とても便利です。


そしてこのようなウィンドウができました。右のプロパティ設定欄のInput Angleに4つの角度を入力するだけで、角度xを計算して表示してくれます。

計算はとても簡単です。
まず、図を描画するためにも、点Aと点Dの座標を計算します。
線分AB、線分DBの長さが分かれば後は角度のサインやコサインを掛けてやれば座標は出るのでその線分を求めればいいのですが、この線分は正弦定理を使うとすぐに求まります。
座標がわかってて角度を求めたかったら、ベクトルの内積ですよね。DBベクトルとDAベクトルの内積を取って、DBベクトルの長さとDAベクトルの長さで割って、arccos取ったら∠ADBが出てきます。
やっているのはそれだけのことです。

PointB = new Point() { X = 0, Y = 0 };
PointC = new Point() { X = BaseLineLength, Y = 0 };

double diameter = BaseLineLength / Math.Sin(DegToRad(180 - (AngleA + AngleB + AngleC)));
double length = diameter * Math.Sin(DegToRad(AngleA + AngleB));
PointA = new Point() {
    X = PointC.X - length * Math.Cos(DegToRad(AngleC)),
    Y = PointC.Y - length * Math.Sin(DegToRad(AngleC))
};

diameter = BaseLineLength / Math.Sin(DegToRad(180 - (AngleB + AngleC + AngleD)));
length = diameter * Math.Sin(DegToRad(AngleC + AngleD));
PointD = new Point() {
    X = PointB.X + length * Math.Cos(DegToRad(AngleB)),
    Y = PointB.Y - length * Math.Sin(DegToRad(AngleB))
};

const double margin = 20;

Point LeftTop = new Point() {
    X = Math.Min(Math.Min(PointA.X, PointB.X), Math.Min(PointC.X, PointD.X)) - margin,
    Y = Math.Min(Math.Min(PointA.Y, PointB.Y), Math.Min(PointC.Y, PointD.Y)) - margin,
};
Point RightBottom = new Point() {
    X = Math.Max(Math.Max(PointA.X, PointB.X), Math.Max(PointC.X, PointD.X)) + margin,
    Y = Math.Max(Math.Max(PointA.Y, PointB.Y), Math.Max(PointC.Y, PointD.Y)) + margin,
};

Vector VectorDB = Vector.FromPointDifference(PointD, PointB);
Vector VectorDA = Vector.FromPointDifference(PointD, PointA);

AnswerAngle = RadToDeg(Math.Acos(VectorDB.DotProduct(VectorDA) / VectorDA.Length / VectorDB.Length));

計算はこんなかんじです。
WPFはY軸が下へ行くと大きくなるので、その点だけ注意してください。
PointクラスやVectorクラスも同時に実装しちゃいましたが、そんな大した実装じゃないですし、C#なのでこのコードを見れば何をやっているかはわかると思います。

<Line Stroke="{Binding LineBrush}" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointA.X}" Y1="{Binding PointA.Y}"
  X2="{Binding PointB.X}" Y2="{Binding PointB.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointB.X}" Y1="{Binding PointB.Y}"
  X2="{Binding PointC.X}" Y2="{Binding PointC.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointC.X}" Y1="{Binding PointC.Y}"
  X2="{Binding PointD.X}" Y2="{Binding PointD.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointD.X}" Y1="{Binding PointD.Y}"
  X2="{Binding PointA.X}" Y2="{Binding PointA.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointB.X}" Y1="{Binding PointB.Y}"
  X2="{Binding PointD.X}" Y2="{Binding PointD.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointC.X}" Y1="{Binding PointC.Y}"
  X2="{Binding PointA.X}" Y2="{Binding PointA.Y}" />

描画のほうはもちろんXAMLに記述しています。
Polylineでやったほうがスマートだったかもしれませんが、なんていうか、この図形は一筆書きできないので、そういう観点からしたらPolyline使うのもなーって感じだったので、まあとりあえずLineにしておきました。

座標や角度の計算とWPFの描画に関わることはこのへんですかね。

まあ、他にもPropertyGridを使うためのプログラムや、LayoutTransformを使った図形の拡大縮小、各頂点や角の名前を表示するためのプログラム等々書きましたが、ラングレーの問題とその図形の本質ではあまりないので、今回は省略します。
またなにか機会があったら記事にしてみようかなって思います。はい。

こういうちょっとしたプログラムって大したこと無いんで、ソースコードからソフトまで丸々公開しちゃってもいいんですけど、他人が作ったライブラリ使ってるといろいろと面倒なんですよね…。ライセンスを熟読しなきゃいけませんし、どんなにゆるいライブラリでも、「アプリ内のどこかにこのソフトウェアのThanksみたいなのを書いてね」っていうものが結構あったりしますし、今回使っているExtended WPF Toolkitはまさにそれです。
つまり、公開するためにはわざわざAboutダイアログを作らなきゃいけないんですね…。そこまで熱意を持って作ってるソフトでもなしにそこまでやるのはなかなか大変だったりします…。はい…。

余談


上の方の図で補助線を引いたものも用意しましたが、これもせっかくなんでWPFで全部補助線を描いてみました。
直線の補助線は、まあ計算しやすい座標を適当に計算して結べばいいだけですが、円はなかなかめんどくさかったですね。
円の方程式を一般形で記述します。\[x^2+y^2+lx+mx+n=0\]これに、円が通る点のうち3点の座標を代入してやればl,m,nが求まるわけですが、それはすなわち3元1次方程式なので、コンピューターで計算するには行列を計算させるのがスマートですね。\[\begin{pmatrix} x_1 &y_1 &1 \\ x_2 &y_2 &1\\ x_3 &y_3 &1 \end{pmatrix}\begin{pmatrix} l \\m \\n \end{pmatrix}=\begin{pmatrix} -x_1^2-y_1^2 \\-x_2^2-y_2^2 \\-x_3^2-y_3^2 \end{pmatrix}\]この行列に関して左側から逆行列をぶつけてやれば一発でl,m,nが求まります。しかし、どうもC#は算術計算のための行列ライブラリっていうのは標準で提供してないっぽいですね…。算術計算のための座標、ベクトルライブラリみたいなのも無くて今回は適当に実装しましたし、その辺は改善してもらいたいところです。
はい。3x3の逆行列を求めるプログラムまで実装していては気が遠くなってしまいます。掃き出し法だの何だのあったと思いますが、やってられません。というわけで、 このあたりのライブラリを使用させてもらいました。

C# .NET Generic Matrix Maths Library

というわけで、補助線部分のプログラムはこんなかんじになっています。

PointE = new Point() {
    X = PointC.X - BaseLineLength * Math.Cos(DegToRad(AngleC)),
    Y = PointC.Y - BaseLineLength * Math.Sin(DegToRad(AngleC)),
};
Point2E = PointE - PointD + PointE;

diameter = BaseLineLength / Math.Sin(DegToRad(180 - (AngleC / 2 + 40)));
length = diameter * Math.Sin(DegToRad(40));
PointF = new Point() {
    X = PointC.X - length * Math.Cos(DegToRad(AngleC / 2)),
    Y = PointC.Y - length * Math.Sin(DegToRad(AngleC / 2)),
};

DoubleMatrix m1 = new double[,] {
    { PointD.X, PointD.Y, 1 },
    { PointA.X, PointA.Y, 1 },
    { PointC.X, PointC.Y, 1 },
};
DoubleMatrix m2 = new double[,] {
    { -PointD.X * PointD.X - PointD.Y * PointD.Y },
    { -PointA.X * PointA.X - PointA.Y * PointA.Y },
    { -PointC.X * PointC.X - PointC.Y * PointC.Y },
};

DoubleMatrix res = m1.Inverse * m2;

double Radius = Math.Sqrt(res[0, 0] * res[0, 0] / 4 + res[0, 1] * res[0, 1] / 4 - res[0, 2]);
CircleDiameter = Radius * 2;
CircleLeft = -res[0, 0] / 2 - Radius;
CircleTop = -res[0, 1] / 2 - Radius;

doubleの2次元配列をそのまま行列に変換できて便利なライブラリですね。
これで円の中心の座標と半径が求まりますが、WPFで楕円を描画するのに必要なのは幅、高さ、左上の座標なので、直径と左上の座標を計算しています。

<Line Stroke="{Binding LineBrush}" StrokeDashArray="1 2" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointD.X}" Y1="{Binding PointD.Y}"
  X2="{Binding Point2E.X}" Y2="{Binding Point2E.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeDashArray="1 2" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointE.X}" Y1="{Binding PointE.Y}"
  X2="{Binding PointB.X}" Y2="{Binding PointB.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeDashArray="1 2" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointC.X}" Y1="{Binding PointC.Y}"
  X2="{Binding PointF.X}" Y2="{Binding PointF.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeDashArray="1 2" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointB.X}" Y1="{Binding PointB.Y}"
  X2="{Binding PointF.X}" Y2="{Binding PointF.Y}" />
<Line Stroke="{Binding LineBrush}" StrokeDashArray="1 2" StrokeThickness="{Binding LineThickness}" StrokeEndLineCap="Round"
  X1="{Binding PointA.X}" Y1="{Binding PointA.Y}"
  X2="{Binding PointF.X}" Y2="{Binding PointF.Y}" />
<Ellipse Stroke="{Binding LineBrush}" StrokeDashArray="1 2" StrokeThickness="{Binding LineThickness}"
         Canvas.Left="{Binding CircleLeft}" Canvas.Top="{Binding CircleTop}" Width="{Binding CircleDiameter}" Height="{Binding CircleDiameter}" />

補助線は点線にするために、StrokeDashArrayというプロパティに値を入れています。
WPFはこんなふうにかなり柔軟に点線が引けるんですね。驚きました。

これでめでたく補助線が引けました。

あ、これ、問題の角度が変わってくると解き方も全くもって変わるので、この補助線が使えなくなることはお間違え無きよう。