很早之前,就想用vc+开发一款输入法。但我必须熟悉自然输入法原理。
自然语言处理的输入法作业成品没有做出来,但不想再在蛋疼的Win32上面耗费时间了,整理文档,记录一下心得,新手再来研究也不会迷路太远。
1. IME简介
2. IME结构
3. IME调试环境配置及安装
3.1. 配置步骤
3.2. 配置说明及注意事项
3.3. IME安装及卸载
4. IME编程心得
4.1. 准备工作
4.2. IME数据结构介绍
4.3. IME接口调用顺序
4.4. 感想
1 IME简介
什么是IME (Input Method Editors)?广义上讲,IME是微软提供的Windows平台的一套输入法编程规范,依照这套规范(框架),你不需要处理太多输入法特性相关的操作(光标跟随,输入捕获,字码转换后输出到应用程序等),你只需要使用IME规范里面提供的工具函数(imm32库),实现规范所指定必须导出的接口即可。实际上你要做的就是写一个导出函数包含IME规范规定的接口的dll,所以,狭义上讲IME就是你写的这个dll。
IME源于Windows 95和Windows NT 4.0时代,用于统一Windows系统输入法编程规范,随着Windows系统版本更新换代,IME的结构基本没有改变。作者所能找到的最新IME官方文档为Windows 98/Windows 2000版,原始文档位于Windows98DDK内(《Win32 Multilingual IME Overview for IME Development》和《Win32 Multilingual IME Application Programming Interface》)。
进行Windows输入法编程必须使用IME方式,否则你必须自己解决前文所述诸如光标跟随,消息截获(通常会被杀毒软件视为病毒行为)等输入法特性问题。实际上使用IME方式也不必完全遵循IME框架的规定实现完全的IME方式输入法,开发人员完全可以只把IME当做一个转换接口——只实现其中几个重要的接口函数,收发消息,然后讲包括输入法逻辑的实现全部交给自己的库。所以无论从哪方面讲,要实现Windows输入法编程必须学会使用IME。
2 IME结构
如前文所述,狭义IME开发就是实现类似“输入法名字.ime”这样一个动态库(编译的时候通常将.dll后缀改为.ime后缀)。这个库需要导出如下15个接口函数:
ImeConversionList
ImeConfigure
ImeDestroy
ImeEscape
ImeInquire
ImeProcessKey
ImeSelect
ImeSetActiveContext
ImeSetCompositionString
ImeToAsciiEx
NotifyIME
ImeRegisterWord
ImeUnregisterWord
ImeGetRegisterWordStyle
ImeEnumRegisterWord
而这些函数中很多都是不重要的,进行编程的时候可以不去具体实现。
注意:如果你查看别人的IME源码,你会发现他们还导出了UIWndProc, StatusWndProc, CompWndProc, CandWndProc四个函数,这四个函数其实不是IME规定需要导出的接口函数,而是四个窗口过程函数,为回调函数。其中UIWndProc是输入法主窗口的窗口过程函数,实际上是一个没有界面的空窗口,StatusWndProc, CompWndProc, CandWndProc分别为状态窗口,输入窗口,候选码窗口的窗口过程函数。源码的作者们通常在动态库初始化的时候注册这四个窗口过程函数所属的窗口类,由于这些窗口是非模态窗口,与模态窗口不同,它们的消息必须经过主程序的消息循环,所以如果不导出其窗口过程函数,上层IME框架无法将属于这些窗口的消息发送到这些窗口过程函数处理。例如搜狗输入法由于不是采用WIN32窗口类方式实现图形界面,所以没有上述到处函数,只有标准的15个IME导出函数。如果你熟悉WIN32编程和Windows消息循环机制就明白我在说什么了。
要实现这些IME规范规定的接口函数,必须用到IMM (Input Method Manager)库,位于system32目录的imm32.dll,这个库提供了访问控制IME内部结构的方法,这些方法盖含imm ui functions, imm support functions, himc and himcc managerment functions, ime hot keys and hot key functions, imm soft keyboard functions五个部分,函数均以Imm开头。与imm32.dll配套的头文件和lib库文件为imm.h和imm32.lib,在Win98DDK中可以找到(名字不是这个名字,需要重命名),或者直接从别人的源码里面找到。另外imm.h里面还定义了一些IME的结构体,这些结构体在IME编程中必须用到,具体介绍参考《Win32 Multilingual IME Application Programming Interface》。
3 IME调试环境配置及安装
构建IME文件结构我们不必从零开始,可以在别人的源码上进行修改,这里以启程的示例源码为例(《浅谈输入法编程》http://www.setoutsoft.cn/Html/?256.html源码imesample.rar),该源码结构清晰简洁。
3.1 配置步骤
第一步:下载源码解压。在解压目录(假定名为imesample)发现该项目是VC++6.0项目,如果你的IDE是Visual Studio就需要进行格式转换。直接双击imesample.dsw转换后就得到了自己的VS解决方案imesample.sln(我的IDE是VS2012)。
第二步:配置IDE。编译上述源码,发现170个错误,是没有配置imm.h和imm32.lib路径所致。在项目属性->VC++目录->包含目录下面添加源码文件夹下的IMM文件夹路径(包含imm.h和imm32.lib)。编译成功。
第三步:imesample.ime安装程序制作。创建Win32控制台应用程序命名为IMEInstaller,将imm.h和imm32.lib拷贝到该工程目录下,修改主函数代码为:
// IMEInstaller.cpp : 定义控制台应用程序的入口点。 // #include "stdafx.h" #include <Windows.h> #include "Imm.h" #pragma comment(lib,"imm32.lib") int _tmain(int argc, _TCHAR* argv[]) { HKL IME = ImmInstallIME(L"imesample.ime", L"我的输入法"); if(IME==0) { printf("注册输入法失败,请注销(或重启)计算机再试验!\n"); } else { printf("注册输入法成功!\n"); } printf("按任意键退出!\n"); getchar(); return 0; }
主要是添加Windows.h和Imm.h头文件(顺序不能颠倒,因为imm.h需要依赖前一个文件的定义)和引用imm32.lib,从而使用ImmInstallIME函数注册输入法。
第四步:安装imesample.ime。编译上一步的安装程序,得到“IMEInstaller.exe”可执行程序,将其拷贝到imesample工程编译结果imesample.dll所在目录,将imesample.dll重命名为imesample.ime,然后运行IMEInstaller.exe,如果显示注册成功的结果则安装成功,你能在输入法选择里面看到你刚注册的输入法了,如下图中“我的输入法”。
如果注册失败,重启计算机也无法解决,原因有几种可能,在后面的说明中解释。
第五步:测试刚才注册的输入法。打开记事本,如果输入法选择菜单项的图标无法显示,则表示此应用中无法加载对应输入法的库文件,此输入法无法使用(可能跟编译版本或者系统有关,我的是WIN7 32位系统)
但我的UltraEdit内可以正常使用新装的输入法,如下图。记录下一个可以正常使用新输入法应用程序的路径,例如我的UltraEdit路径为:"E:\Program Files (x86)\IDM Computer Solutions\UltraEdit\Uedit32.exe"。
第六步:配置调试程序。打开imesample工程,找到项目属性->调试->命令项,将上一步记下的调试程序路径填入其中,如下图。
确认修改后,就可以调试了。打开源码内的
imm.c文件,找到ImeToAsciiEx函数,在其内设置一个断点。按F5,IDE会启动你设置的调试程序,将输入法切换到“我的输入法”,输入字母会发现IDE
到达断点处,调试成功。
以后修改代码,直接调试即为最新代码效果(系统能在你的工程目录下找到最新版本dll并加载?非也,看后文解释)。
3.2 配置说明及注意事项
下面来分析一下上述步骤的过程及原理。
本来的流程应该是这样的:
1) 编译生成imesample.ime(假设直接生成的就是.ime后缀的文件)
2) 然后将imesample.ime拷贝到C:\Windows\System32\目录(如果你是64位系统,必须拷贝到SysWOW64目录下,因为64位系统只从SysWOW64中寻找输入法库文件)
3) 用ImmInstallIME函数注册输入法时,必须保证SysWOW64和注册函数所在目录都有imesample.ime库文件,否则无法注册成功。可以重复注册多次,但只在注册表添加一条记录。
4) 进行调试的时候,系统使用的是System32(64位系统为SysWOW64)下的imesample.ime库文件,所以每次修改代码编译后都必须将新版imesample.ime拷贝到System32 (SysWOW64)下才可执行调试,否则断点为失效状态。
而实际上我们并没有复制imesample.ime到System32 (SysWOW64)啊,原来是作者在工程中做了一个小动作——加入了后期生成时间命令行。即打开工程,工程属性->生成事件->后期生成事件->命令行,可以发现多了一条命令”copy .\debug\imesample.dll C:\Windows\system32\imesample.ime”。
这条命令在每次编译之后都会执行一次,将生成的dll重命名为ime复制到系统文件夹中。如果你是64位系统也不必修改其中的system32文件夹为SysWOW64,因为在执行命令时会自动修改为SysWOW64去执行。
当某个应用正在使用输入法时,会动态加载输入法的库文件,其库文件被锁定无法修改。所以,如果在编译时出错,错误指向上述copy命令,则需要关闭使用该输入法的进程才行。另外,IME有一种使输入法启动就不再退出的机制,需要在ImeInquire内设置不采用这种机制,否则无法调试(详情见启程作者描述)。
配置步骤第二步的改进:工程属性->常规->目标文件扩展名,将dll改为ime,这样每次编译就直接生成.ime文件了,修改后还需要将上述copy命令行里的imesample.dll改为imesample.ime。
3.3 IME安装及卸载
上一节介绍了使用imm32.dll内的ImmInstallIME函数安装输入法的方法,实际上这个函数只是在注册表内添加了两条记录。
第一条记录位于HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Keyboard Layouts\在展开项中,以0804结尾的是简体中文输入法项目,一般新注册的输入法都在最后面,如图,我们注册的输入法注册表编号为E02C0804。
另外一条记录位于
HKEY_CURRENT_USER\Keyboard Layout\Preload
下,表示当前输入法选择菜单中列出的输入法列表,如下图。
所以,输入法的卸载就是安装的逆过程:删除上述两条注册表项目,然后删除System32 (SysWOW64)下的ime库文件即可。
输入法注册失败有几种可能原因,争对启程输入法的注册,如果失败,第一是System32 (SysWOW64)和安装程序当前目录下没有对应的ime库文件,第二是ime库文件编译问题,需要重新编译一下,第三是注册过程被杀毒软件拦截(在第一条注册表内会出现空的注册表项)。
如果是自己编写空壳输入法,则可能还会出现其他一些问题,如导出库文件名与def定义不一致,资源信息为设置成输入法信息等,详情见《Win下的输入法(IME)编程(1)》和《Win下的输入法(IME)编程(2)》。
4 IME编程心得
本文中提到的很多别人的文档都记录了自己进行IME编程的一些心得体会,在这里我就不作额外介绍了,只是做一下整理和补充。
4.1 准备工作
首先应该确认手头有《Win32 Multilingual IME Overview for IME Development》和《Win32 Multilingual IME Application Programming Interface》两份原始文档的打印版,因为随时可能去查阅上面的信息资料,在我写这篇介绍文的时候,MSDN上没有跟多的输入法编程相关资料(以前有,但是现在无法在MSDN上查阅到了),所以面对资料的匮乏,只能从网络上依靠关键词寻求一些帮助信息,或者阅读别人的源码。
然后,通过上面的资料你应该能了解IME框架的三大部分知识(我理解的三大部分)。第一是ime*15个导出函数中重要的几个导出函数功能(参考《我的win32 输入法编程心得》https://code.google.com/p/windows-config/wiki/Win32IME);第二是了解imm*众多函数中重要几个的功能,例如ImmLockIMC, ImmUnlockIMC, ImmLockIMCC, ImmUnlockIMCC等(参考《输入法(IME)实现原理》http://blog.sina.com.cn/s/blog_56a388c20100004u.html);第三是了解IME框架内的主要几个结构体,IME的所有信息都保存在这些结构体中,只有理解了它的结构才能自如地控制IME存取数据。
4.2 IME数据结构介绍
我要重点介绍的是tagINPUTCONTEXT, tagCOMPOSITIONSTR, tagCANDIDATEINFO和tagCANDIDATELIST四个结构体,他们具体的参数含义可以参考前文所述各文档。
tagINPUTCONTEXT是IME最重要的内部数据结构,存储输入上下文数据,定义如下:
typedef struct tagINPUTCONTEXT {
HWND hWnd;
BOOL fOpen;
POINT ptStatusWndPos;
POINT ptSoftKbdPos;
DWORD fdwConversion;
DWORD fdwSentence;
union {
LOGFONTA A;
LOGFONTW W;
} lfFont;
COMPOSITIONFORM cfCompForm;
CANDIDATEFORM cfCandForm[4];
HIMCC hCompStr;
HIMCC hCandInfo;
HIMCC hGuideLine
HIMCC hPrivate;
DWORD dwNumMsgBuf;
HIMCC hMsgBuf;
DWORD fdwInit
DWORD dwReserve[3];
} INPUTCONTEXT;
可以认为它是IME内部数据的根节点,通过它可以读写IME内部所有信息,它由HIMC句柄表示,可以通过LPINPUTCONTEXT lpIMC = (LPINPUTCONTEXT)ImmLockIMC(hIMC);的形式通过HIMC句柄获取该结构体。这里要提到的是hCompStr和hCandInfo两个变量,他们都是由HIMCC句柄表示,hCompStr负责用户输入字符串的信息,hCandInfo负责候选码部分的信息,将在后面介绍。
tagCOMPOSITIONSTR是负责管理用户输入字符串信息的结构体,为tagINPUTCONTEXT的子节点,定义如下:
typedef struct tagCOMPOSITIONSTR {
DWORD dwSize;
DWORD dwCompReadAttrLen;
DWORD dwCompReadAttrOffset;
DWORD dwCompReadClsLen;
DWORD dwCompReadClsOffset;
DWORD dwCompReadStrLen;
DWORD dwCompReadStrOffset;
DWORD dwCompAttrLen;
DWORD dwCompAttrOffset;
DWORD dwCompClsLen;
DWORD dwCompClsOffset;
DWORD dwCompStrLen;
DWORD dwCompStrOffset;
DWORD dwCursorPos;
DWORD dwDeltaStart;
DWORD dwResultReadClsLen;
DWORD dwResultReadClsOffset;
DWORD dwResultReadStrLen;
DWORD dwResultReadStrOffset;
DWORD dwResultClsLen;
DWORD dwResultClsOffset;
DWORD dwResultStrLen;
DWORD dwResultStrOffset;
DWORD dwPrivateSize;
DWORD dwPrivateOffset;
} COMPOSITIONSTR;
该结构体可以通过LPCOMPOSITIONSTRING lpCompStr = (LPCOMPOSITIONSTRING) ImmLockIMCC(lpIMC->hCompStr)的方式从HIMCC句柄中取得。其中dwCompStrLen和dwCompStrOffset分别表示用户输入字符串的长度和偏移量(即lpCompStr首地址+ dwCompStrOffset值指向的内存地址为用户输入字符串存放位置的首地址)。
tagCANDIDATEINFO是负责管理候选码信息的结构体,为tagINPUTCONTEXT的子节点,定义如下:
typedef struct tagCANDIDATEINFO {
DWORD dwSize;
DWORD dwCount;
DWORD dwOffset[32];
DWORD dwPrivateSize;
DWORD dwPrivateOffset;
} CANDIDATEINFO;
该结构体可以通过LPCANDIDATEINFO lpCandInfo = (LPCANDIDATEINFO) ImmLockIMCC(lpIMC->hCandInfo)的方式从HIMCC句柄中取得。其中dwCount和dwOffset[32]分别表示候选码表的个数和码表内存位置的偏移量,即通过lpCandInfo首地址+dwOffset[i]就能得到码表tagCANDIDATELIST的首地址。
tagCANDIDATELIST用于存储码表数据,是上面tagCANDIDATEINFO的子节点,定义如下:
typedef struct tagCANDIDATELIST {
DWORD dwSize; // the size of this data structure.
DWORD dwStyle; // the style of candidate strings.
DWORD dwCount; // the number of the candidate strings.
DWORD dwSelection; // index of a candidate string now selected.
DWORD dwPageStart; // index of the first candidate string show in the candidate window. It maybe varies with page up or page down key.
DWORD dwPageSize; // the preference number of the candidate strings shows in one page.
DWORD dwOffset[]; // the start positions of the first candidate strings. Start positions of other (2nd, 3rd, ..) candidate strings are appened after this field. IME can do this by reallocating the hCandInfo memory handle. So IME can access dwOffset[2] (3rd candidate string) or dwOffset[5] (6st candidate string).
// TCHAR chCandidateStr[]; // the array of the candidate strings.
} CANDIDATELIST;
该结构体存储了一张候选码表的字符串信息,dwCount为候选码个数,dwOffset[i]为第i个候选码的首地址偏移量,同样通过偏移量加结构体首地址的方式可以定位到候选码字符串的首地址。
通过上述结构体,已经可以在ImeToAsciiEx函数内做字码转换的操作了(主要是存取用户输入字符和修改转换后的候选码表),然后在用户输入窗口和候选码窗口分别从上述结构体中取出对应字符串显示出来。
4.3 IME接口调用顺序
测试切换到“我的输入法”输入两个字符,然后切换出“我的输入法”,发现IME接口函数调用顺序大致如下:ImeInquire, ImeSelect, UIWndProc, StatusWndProc, ImeProcessKey, ImeToAsciiEx, CompWndProc, CandWndProc, UIWndProc, NotifyIME, UIWndProc, CompWndProc, CandWndProc, ImeProcessKey, ImeToAsciiEx, NotifyIME, UIWndProc, NotifyIME, ImeProcessKey, ImeProcessKey, NotifyIME, StatusWndProc, UIWndProc, CompWndProc, CandWndProc, StatusWndProc, UIWndProc, ImeSelect。
其中ImeInquire是最先被调用的函数,当应用程序第一次切换到“我的输入法”时会调用该函数进行一些初始化操作,应用程序多次切换输入法均不会再调用该函数。ImeSelect是当用户切换入“我的输入法”或者切换出“我的输入法”时调用的。ImeProcessKey是当用户发生一次按键时调用,开发人员可以获取按键信息,用于判断按键信息是否需要输入法来处理,如果需要则返回true,截获按键信息交给ImeToAsciiEx函数处理。NotifyIME是应用程序与输入法交互的函数,可以不用理会。UIWndProc, StatusWndProc, CompWndProc,CandWndProc四个函数之前已经介绍了,分别是主窗口,状态窗口,用户输入字符串窗口,候选码窗口四个窗口类的窗口过程函数,如果你使用Win32的窗口类来构建自己的窗口,则显示数据的任务在这些回调函数内编写,弱否,则不必理会。所以,编写IME,大部分任务就是编写好ImeProcessKey和ImeToAsciiEx两个函数,如果不是编写完全符合IME规范的输入法,只需要注意几个重要函数即可。
4.4 感想
IME的框架体系还算清晰,但开发文档匮乏,没有能够详细介绍IME开发的资料,进行IME开发最大的难处就是对这些结构体的操作了,没有比较清晰的范例可以拿来作参考。特别对于不熟悉WIN32API和Windows内部消息循环机制的开发人员,涉及到这种用C语言对结构体内存直接做操作的细节处,要正确进行IME开发是难上加难。
实际上,如果你不是希望编写完全IME规范的输入法,有几种方式可以适度地脱离WIN32API的苦海:
1) 定义自己的结构体。IME开发文档定义了众多复杂的结构体,这些结构体无非是为了组织一套IME编码规范,依照这套规范你可以不用太关注过多的IME内部细节。而实际上,如果你只希望用到其中一部分特性,可以通过自定义结构体的方式,强制将IME内部结构体转换为自己的方便操作的结构体,加速开发,自由拼音输入法即是如此。
2) IME+外挂dll方式。这种方式将IME作为一个与Windows系统沟通的桥梁,而输入法逻辑完全由外部dll实现,最大的好处就是可以利用现今高效的开发语言和工具进行开发。搜狗拼音输入法即是如此,它的ime只有15个标准导出函数,因此其窗口过程应该是外包给外部逻辑去处理。但要实现这种方式的前提是对15个导出函数有充分理解,其中某些会要求填充一些数据。虽然没有亲自实现,但个人认为可以将IME导出函数包装在C++工程内,然后实现与C#库的通讯,以ImeToAsciiEx作为桥梁,将输入法的界面逻辑完全交给C#工程去处理。
掌握了输入法原理,也可仿照类似搜狗输入法的软件。
最后,多实践。