處理 Imm32 與 TSF 的相容性問題

稍微紀錄一下這一次 Debug 的過程。

背景與問題描述

Imm32 是 Windows 舊有的輸入法 API,一路沿用到 Windows XP 後被 TSF (Text Service Framework) 取代。許多平台的輸入法支援在切換輸入法時自動送出組字區的文字,例如打字到一半按 Ctrl + Shift 切換英文,可同時完成「送出組字」與「切換輸入法」兩個動作,無需先按 Enter 再切換。這對需快速切換中英文的使用者來說非常方便。

然而,在新酷音 Windows TSF 改用 TSF 實作後,此功能一直時好時壞。若使用者無法預期切換輸入法後未送出的字是「消失」還是「送出」,自然會選擇先按 Enter 再切換,失去便利性。我已多次嘗試修正此問題。

錯誤的修正方向

在 TSF API 中,未送出的文字稱為「組字區 (Composition)」,可設定「組字中止 (OnCompositionTerminated)」事件處理器。根據微軟文件,此事件僅在組字被「強迫終止」時觸發,例如開關或切換輸入法。文件提及「輸入法可藉此機會修改組字、還原文字或清除組字」,讓我誤以為必須在此處理器中強制送出組字。雖然修改後看似有效,但在使用 Imm32 API 的軟體中仍無法自動送出。這些軟體依賴系統橋接器使用 TSF 輸入法,行為與原生 TSF 應用不同,我一度認為這是橋接器的限制。

使用除錯工具找出差異

一位使用者回報:

使用新酷音輸入法已選字但未按 Enter,若用 Ctrl + Shift 切換輸入法,候選字會消失;但用「Shift 快速切換中英文」時卻會送出。能否增加設定選項?

剛好我正在測試日文輸入法,發現 Mozc 無論在哪個軟體都能在切換時自動送出組字。查看其 OnCompositionTerminated 實作,與我的寫法無顯著差異。

於是使用 WinDbg 比較呼叫堆疊(backtrace),發現關鍵差異:

Mozc 的 backtrace:

OnCompositionTerminated
CompComplete
IMM32!NotifyIME

新酷音 TSF 的 backtrace:

OnCompositionTerminated
CompCancel
IMM32!NotifyIME

兩者僅在 IMM32!NotifyIME 後分別觸發「完成組字」與「取消組字」,顯示問題根源在系統層級,非輸入法內部可控制。

發現系統層級的設定差異

這讓我聯想到 ibus 可設定切換時「取消」或「送出」組字。查閱資料後,發現 Imm32 有 IME_PROP_COMPLETE_ON_UNSELECT 屬性,若設定,輸入法在停用時會自動完成組字;否則取消。新酷音的 Imm32 版本有設定此值,但 TSF 橋接器無對應介面。

再拿出 WinDbg 來看看究竟 Mozc 是怎麼被設定的,行為會不一樣?因為這部份是 Windows 的系統函式庫,沒有原始碼可以參考,只好用機器碼單步執行,最後發現有一段機器碼跟 ReactOS 的這一段 TSF 橋接器的重新實做非常相似。

進一步研究 ReactOS 原始碼發現,TSF 橋接器初始化時會靜態設定此屬性。日文、韓文輸入法預設包含 IME_PROP_COMPLETE_ON_UNSELECT

IME_PROP_COMPLETE_ON_UNSELECT | IME_PROP_SPECIAL_UI |
IME_PROP_AT_CARET | IME_PROP_NEED_ALTKEY |
IME_PROP_KBD_CHAR_FIRST;

但中文輸入法未包含:

IME_PROP_SPECIAL_UI | IME_PROP_AT_CARET |
IME_PROP_NEED_ALTKEY | IME_PROP_KBD_CHAR_FIRST;

這解釋了為何 Mozc 可行而新酷音不行。由於屬性為靜態設定,似乎無標準方式自訂。

Hack 解法與未來改進方向

在 ReactOS 的 Imm32 實作中,我注意到 ImmLockImeDpi 這個未公開(undocumented)函式,可用於取得 IMM32 內部結構體指標。假設微軟實作類似,便可在初始化後動態修改屬性。實驗後,提交以下變更:

debug!("trying to override the default IMM32 property set by MSCTF.dll");
let hkl = unsafe { GetKeyboardLayout(0) };
let pimedpi = unsafe { ImmLockImeDpi(hkl) };
if let Some(imedpi) = unsafe { pimedpi.as_mut() } {
    imedpi.ime_info.fdw_property |= IME_PROP_COMPLETE_ON_UNSELECT;
    debug!("done adding IME_PROP_COMPLETE_ON_UNSELECT to IME property");
} else {
    debug!("unable to get the PIMEDPI pointer");
}
self.pimedpi.set(pimedpi);

此 hack 目前測試可行。由於 ImmLockImeDpiMSCTF.dll 內部使用的隱藏介面,未來可能變動。為避免執行期錯誤,應改以弱符號(weak symbol)方式動態載入此函式。

心得

這次除錯讓我體會到,問題有時不在應用層,而在系統與舊有 API 的相容性設計。透過逆向工程與底層探索,雖可突破限制,但也需承擔維護風險。期待未來能有公開 API 支援此類設定。▞


留言討論

本站使用 Pinka,歡迎使用你喜歡的聯邦網路訂閱與留言。