處理 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 目前測試可行。由於 ImmLockImeDpi 是 MSCTF.dll 內部使用的隱藏介面,未來可能變動。為避免執行期錯誤,應改以弱符號(weak symbol)方式動態載入此函式。
心得
這次除錯讓我體會到,問題有時不在應用層,而在系統與舊有 API 的相容性設計。透過逆向工程與底層探索,雖可突破限制,但也需承擔維護風險。期待未來能有公開 API 支援此類設定。▞