この日記はMozillaのプロダクトへの貢献者としての私の成果を中心に、気になったバグやWeb界隈の話題について書いていますが、 断り書きがある場合を除き、いかなる団体のオフィシャルな見解ではありません。あくまでも個人的なものです。 Mozilla Foundation、Mozilla Corporation、及び関連企業の公式情報ではないことに注意してください。

現在、XHTML 1.0 (もどき)から、HTML5なコンテンツに修正中です。古い日記は修正が完了していませんので表示が崩れます。 順次、修正していく予定ですのでしばらくお待ちください。

もずはっく日記(2018年2月)

2018年2月16日

WindowsのIME API、TSFTS_E_NOLAYOUT問題とは
初回投稿日時: 2018年02月17日01時03分16秒
最終更新日時: 2018年02月17日01時32分18秒
カテゴリ: IME TSF Windows
SNS: (list)

私がよく愚痴っているTSFのTS_E_NOLAYOUT問題について、少しまとめておこうかと思ったので、このエントリを書くことにしました。WindowsネイティブアプリのIMEとの連携部分の自前実装をするという、今どきはあまりやらない事に関する話なので、興味のない方には読む価値はまったく無いかと思います。

最初に各種用語とTSFでの候補ウインドウやサジェストウインドウといった、IMEの出すUIの位置がどのように決まるのかをざっくりと解説しておきます。このあたりが分かってるよ、という方は本題だけ読めば十分かと思います。

TSF周りのざっくりとした解説

まず、TSFとはなんぞやということですが、略さずに書くと、Text Services Framework。その名の通り、単なるIME APIの枠を超えて、アプリにあるテキストのコンテンツ(しかもリッチテキスト対応)に手書き入力や、音声入力、スクリーンリーダー等の外部から自由にアクセスできるようにしようというかなり野心的な代物です。ちなみに、IMEにあたる、テキストの入力アプリのことを、TIPと呼びます。

TSFはWindows XPから標準搭載され(正確にはMS Office XPをWindows 2000にインストールでもOK)、Windows Vistaで一通りの機能が落ち着いた新しいIME APIです。Windows XPの言語設定にあった、「詳細なテキストサービス」というのがTSFの有効・無効設定だったのですが、こう言われると、あれかってなる人も多いんじゃないかと思います。ちなみに、FirefoxもTSFサポートをデフォルト設定で有効にする際、Windows Vista以降に限定しなくてはリリースできないぐらいには不安定でした。

それ以前はアプリはIMMというAPIを使ってIMEと通信していました。しかし、TSFに対応するのは大変なので、2018年現在でも自前でIMEと通信しているデスクトップアプリの多くはエミュレートされたIMMを利用していると考えられます。TSF-awareなデスクトップアプリとして有名なのは、MS OfficeのWord、Windowsアクセサリのワードパッド、ブラウザではFirefoxのみです。一方、UWPアプリはそもそもTSFしかサポートしておらず、必ずTSF-awareアプリと言えます。古いIMEが「設定」や、Edgeで利用できないのは、これらがUWPアプリだからです。

TSFはCOMオブジェクト同士が通信しあう形になっていて、そのインターフェースの数はざっと、120強。はっきりいって、習得の難易度高めです。ただ、TIPからのテキスト入力を受け付けるためにアプリが実装する必要があるのはITextStoreACPITfContextOwnerCompositionSinkの二つのインターフェースと、マウスでの未確定文字列の操作等もサポートするならITfMouseTrackerACPの合計三つとなります。これらをひとつのクラスで実装すると良いだけなので、意外と実装した場合のコードは大きくなかったりします(IMMに比べるとやはり大きくなりますが)。

このうち、アプリの実装側の核となるのはITextStoreACPインターフェースで、これは、TIPやTSFがアプリのコンテンツにアクセスする時に利用する、アプリ側から公開しているAPIに相当します。実際にTIPはこのインターフェースを経由して、文字列の挿入、選択範囲(キャレット位置)の変更、任意の範囲のテキストの取得、任意の範囲のテキストの表示スタイルの取得、任意のテキストの矩形の取得、ドキュメント全体(エディタ全体)の矩形の取得、任意の座標での文字へのヒットテストといったことができます。

で、これらの全てを書いてると、こんな日記のエントリではどうしようもなくなるので、今回採り上げたい「候補ウインドウ等のTIPのウインドウの表示位置決定方法」についてのみ流れを紹介しておきます。

TS_E_NOLAYOUT問題とは

TIPが特定の文字の下に候補ウインドウかサジェストウインドウを表示したい場合、まず、文字の矩形をITfContextView::GetTextExt()を呼び出して取得します。そして矩形の情報が正常に得られたら、その矩形の上下左右のいずれか(スクリーンの端との距離や、横書き・縦書きで変化)に表示したいウインドウを移動させます。

ITfContextView::GetTextExt()が呼び出されると、TSFは、アプリ側のカウンターパートであるITextStoreACP::GetTextExt()を呼び出します。もちろん、単純なアプリであれば即座に文字の矩形を返すだけでTIPはユーザの期待通りに動きます。

しかし、ブラウザのような複雑なアプリになるとそうは簡単に行きません。TSFはFirefoxでは、メインプロセスのメインスレッドで動作します。それに対して、タブの中にあるWebページ内のエディタは別プロセスにありますし、メインプロセスのハングアップを防ぐ為に非同期通信しか出来ません。このため、入力された直後の文字の矩形をTIPもしくはTSFに要求されたとしても、別プロセス内でのレイアウト処理が未だに完了していないのが普通です。ですがTSFを設計された方は素晴らしく、なんと、TSFは非同期でのレイアウト処理を想定して設計されているのです(ちなみに、macOSのCocoaの設計もTSFと似たような設計のAPIなのですが、非同期レイアウト非対応という厳しすぎる現実があります)。

具体的な処理の流れを見てみましょう。アプリ側のITextStoreACP::GetTextExt()TS_E_NOLAYOUTエラーをまずは返し、レイアウト計算の完了を待ちます。次に、レイアウト情報更新されたら、ITextStoreACPSink::OnLayoutChange()ITfContextOwnerServices::OnLayoutChange()を呼び出します。すると、TIPはレイアウト情報を再度取得するチャンスが得られます(無論、この時点でまだ目的の文字のレイアウトが完了していなければ、再び待つ必要があります)。

しかし、ここの実装に重大なバグが存在しており、多くのTIPの現在の挙動から察するにWindows 10の現行バージョンであるFall Creators Updateでも未だにバグっているように思います。そのバグとは、アプリのITextStoreACP::GetTextExt()から返されたTS_E_NOLAYOUTをTSFがTIPに、ITfContextView::GetTextExt()の戻り値としてE_FAILとして返してしまうというものです。E_FAILは一般的なエラーを示すエラーコードであり、TIP側からすると、何らかのアクシデントがあったように見えてしまいます。

実際、ほとんど全てのTIPは、アプリのITextStoreACP::GetTextExt()からTS_E_NOLAYOUTを返すと、表示しようとしていたウインドウの表示を断念したり、スクリーンの左上の端や、フォーカスを持ったウインドウの端等に表示し、なんとかユーザのアクセシビリティを確保しようとします。

しかし、アプリ側から見ると、このTIP側の「努力」がまた、困った問題の原因になるのです。アプリ側からすると、TS_E_NOLAYOUTを返した後に、レイアウトが完了した時点でITextStoreACPSink::OnLayoutChange()ITfContextOwnerServices::OnLayoutChange()を呼び出わけです。するとITextStoreACP::GetTextExt()が再度呼び出され、今度はTIPが期待する矩形を返すことができます。その結果、TIPは一瞬だけ他の位置に表示していたウインドウを期待通りの場所に移動したり、前回のエラーで非表示になっていたウインドウを再表示したりします(希に、表示を一度断念すると、その後も表示しないTIPやケースもあります)。ユーザから見ると、前者は一瞬だけ変な位置にウインドウが表示され、後者はウインドウがちらついて見えます。これは、候補ウインドウ内の選択項目を変更していくだけ、もしくは未確定文字列を一文字ずつ入力していく課程で繰り返し発生するため、非常にできの悪いアプリに見えてしまいます。

TS_E_NOLAYOUTに対応するには

まず第一に、Microsoftさんには一日も早くITfContextView::GetTextExt()TS_E_NOLAYOUTを返すようにしていただきたいです。似たAPIである、ITfContextView::GetRangeFromPoint()はアプリがITextStoreACP::GetACPFromPoint()からTS_E_NOLAYOUTを返すと期待通りにTS_E_NOLAYOUTが返されます。この事実から想像するに、難しい修正とは思えないのです。

TIPは、たとえWindows側でこのバグが修正されていなくても(どのみち、修正されてもWin8.1等へのバックポートが期待できませんが)、対応が可能です。なぜなら、ITextStoreACP::GetACPFromPoint()TS_E_NOLAYOUTが返されるからです。実際にGoogle日本語入力は最後のWindows版のバージョンアップの際にこれに対する修正が入っており、私が知る限りは、唯一、この問題に完全対応したTIPとなっています。その修正方法とはITfContextView::GetTextExt()E_FAILを返してきた場合にITfContextView::GetRangeFromPoint()を呼び出し、これがTS_E_NOLAYOUTを返してきた場合、ITfContextView::GetTextExt()の本当の戻り値はTS_E_NOLAYOUTだったと確認することができます。

そして、TIPにはもう一点、やらなくてはいけないことがあります。それは、ITfContextView::GetTextExt()の本当の戻り値がTS_E_NOLAYOUTだった場合に、初回の表示であれば表示を保留し、既に表示済みであればそのままの位置に留めておくべきです。そうしなければ、レイアウト計算完了通知が来た際の再表示、もしくは表示位置修正で点滅したり、一瞬だけ変な位置に表示されたりというバグが発生してしまいます。

ちなみに、Microsoft社製のTIPは全体的にこの問題に対応できておらず、日本語版Microsoft IME、簡体中国語のMicrosoft Pinyin、Microsoft Wubi、繁体中国語のMicrosoft Changjie、Microsoft Quickの全てで、なんらかのバグを確認しています(韓国語版Microsoft IME、Microsoft Old Hangulと、繁体中国語のMicrosoft Bopomofoはウインドウの表示があるのか無いのかもよく分からないので未確認)。これらのバグは、Firefoxのabout:configから、intl.tsf.hack.ms_japanese_ime.do_not_return_no_layout_error_at_caretintl.tsf.hack.ms_japanese_ime.do_not_return_no_layout_error_at_first_charintl.tsf.hack.ms_simplified_chinese.do_not_return_no_layout_errorintl.tsf.hack.ms_simplified_chinese.query_insert_resultintl.tsf.hack.ms_traditional_chinese.do_not_return_no_layout_errorintl.tsf.hack.ms_traditional_chinese.query_insert_resultのそれぞれをfalseにすることで、Firefox側で対応しているハックを個別に無効化して確認することができます。

とにかく、Microsoftさんにはこれらの修正をどうにかやっていただきたいです。FirefoxではアクティブなTIPと取得される範囲と未確定文字列との位置関係からホワイトリスト形式で、TS_E_NOLAYOUTエラーを返さずに、それっぽい座標を返すという対応でなんとかこの問題に対処しているのですが、このハックのコードが膨れ上がり過ぎてて、正直なところ、そろそろ限界です。

関連するかもしれないエントリ

関連するかもしれないエントリを発見できませんでしたが、無いとは限りません。