65.9K
CodeProject 正在变化。 阅读更多。
Home

WTL Hunspell 检查编辑控件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (9投票s)

2009 年 6 月 22 日

CPOL

12分钟阅读

viewsIcon

44241

downloadIcon

2950

一个 WTL Hunspell 拼写检查的编辑控件。

引言

我有一个应用程序,用于设定澳大利亚新南威尔士州的土地价值。使用该程序的人员需要为他们的估值输入备注和理由,但由于各种原因,他们的备注中会悄悄地出现拼写错误。这可能是因为数据库被妖精在夜间篡改了,因为他们自己根本不会犯错。为了帮助他们找到错误,他们要求我在程序中包含一个拼写检查器。

背景

我的应用程序是用 WTL 编写的。我实际上对这个有点厌倦了……现在已经有很多源代码了,而且要编译完需要很长时间。 sloccount[^] 显示有大约 110,000 行 C++ 代码,并且当任何重要内容发生变化时,至少有 1/2 的代码需要重新构建和重新链接。太痛苦了。出于好奇,我检查了主源文件预处理器输出:在包含 stdafx.h 后,有大约 50,000 行非空白、非注释的代码,而包含 stdafx.h 的代码则有 400,000 多行。我并不是想吹嘘项目的大小(它实际上并不那么大);我的意思是,如果你正在考虑 WTL,并且项目会很大,那么请仔细考虑。构建时间会失控。

无论如何,我去找了一个拼写检查器。我一直使用 VSSPELL 6 版本为不同的客户进行简单的后台拼写检查,但那个版本有点过时了。而且,虽然我几年前就买了这个东西,但当我想要使用 GUI 组件时,它似乎会弹出催促升级的屏幕。我真的想使用 GUI 组件,这样它就可以挂钩到我的编辑窗口并为我进行红色下划线提示。所以需要其他东西。

我先去了 The Code Project,找到了 Matt Gullett 的 Spell Checking Engine。这是为 MFC 编写的,所以对我来说不是直接可用的。我还想要一个澳大利亚的词典。搜索还在继续。我进行了常规的网络搜索,找到了 aspell、Hunspell 以及其他几家商业产品。未能找到 Matt Gullett 的 www.spellican.com。最终,我选择了 Hunspell。它是开源的,人们似乎都很满意,有一个不错的 MSVC 项目可用于该库,并且有澳大利亚英语词典。而且,天哪……如果它对 OpenOffice 来说足够好,对我来说也足够好。

于是我下载了它,编译了它,然后必须想办法使用它。API 文档不是很完善……或者我就是找不到。我找到了 NHunspell[^],这有助于弄清楚如何使用 API。所以一切都很好。

在那时,我需要将检查器集成到编辑窗口中。我拿出 Matt Gullett 的代码,并厚颜无耻地偷了他的编辑控件代码,并将其移植到 WTL 环境中。

Using the Code

有三个主要方面我想阐述:使用我为 Hunspell 代码构建的包装器,使用 CSpellCheckEdit 类,以及 CSpellCheckEdit 类的结构。

使用单例拼写检查包装器类

我在整个应用程序中都使用了 STL 来处理字符串和集合,所以在这里我继续沿用相同的约定。这与 WTL 的功能非常契合。即便如此,我在合理的地方也提供了 const char*const std::string& 两种方法版本。随时可以添加你自己的 const CString& 方法。

请注意,我下载的 Hunspell 版本不包含 Unicode 方法。如果你正在 Unicode 环境中编写代码,你应该看看 NHunspell[^],因为它包含了你所需的所有宽字符到多字节字符串的转换代码。

初始化

SpellCheck 包装器类是一个 单例 类。为什么?因为启动 Hunspell 检查器很耗时,我只想做一次。要开始,你只需要获取单例的引用,然后告诉它你的词典在哪里。

SpellCheck& sc = SpellCheckS::instance();
sc.loadDicts("en_AU.aff", "en_AU.dic", "custom.dic");

当你调用 loadDicts() 方法时,SpellCheck 对象会启动一个线程来创建实际的 Hunspell 对象并加载词典。它还会读取(每行一个单词)“custom.dic”文件中的单词,并将它们添加到词典中。

检查单词

要检查一个单词,调用单例的 spell() 方法。如果词典尚未准备好进行检查,或者词典中有该单词并且是有效的,此方法将返回 true(表示拼写有效);如果单词被拼写引擎确定为不正确,则返回 false

SpellCheck& sc = SpellCheckS::instance();
if (sc.wordIsOK(lpszWord))
{
    // word is OK, don't need to check any further
    return;
}

我上面提到,在你想检查单词的时候,词典可能还没有准备好。原因是词典是通过单独的线程加载的。我发现词典加载的速度不够快,尤其是在调试模式下。我的用户习惯于立即看到登录屏幕,我不想延迟该窗口的出现,或者引入一个在后台加载东西的闪屏。

所以,我在对象中有一个 `_ready` 标志。当词典加载完成后,它会被设置为 `true`。在此之前,所有检查过的单词都会被显示为正确。我不能因为拼写检查加载缓慢而让屏幕上布满红色的墨迹。一旦成功加载,一切都会无缝切换到按预期进行检查。

所有与实际词典或自定义词典交互的代码都受到临界区的保护。

获取建议

显然,用户期望的不仅仅是红色的标记来告知他们拼写错了单词。Hunspell 确实包含一个 `suggest()` 方法,所以你可以用它来获取用户建议列表。

SpellCheck& sc = SpellCheckS::instance();
STRINGLIST options;

sc.suggest(lpszWord, options);
for (STRINGLIST::iterator it = options.begin(); it != options.end(); it++)
{
    ATLTRACE("Suggestion: %s\n", it->c_str());
}

处理此类列表的最好方法是将其放入上下文菜单中,这样用户就可以右键单击错误消息,然后简单地用正确的单词替换拼写错误的单词。

添加单词

当我刚开始使用 Hunspell 库时,我想知道如何让用户能够将单词添加到词典中。作为财产估价师,我的用户有他们自己的术语和缩写,这些在通用词典中不一定存在。我之前并没有真正考虑过,但是库中提供的词典基本上是只读的。这都没关系,但是自定义词典呢?Hunspell 有一个 `add()` 方法,允许你将单词添加到词典中,但这仅在 `Hunspell` 对象存在期间有效。它不会传播到词典本身。那时我真不知道该怎么办。

然后,我经历了一个“顿悟”时刻,拍了拍自己的额头。好吧,当用户向词典中添加一个单词时,我也会将该单词写入他们的“custom.dic”文件。当我启动时加载词典时,我会读取该文件,并在使拼写检查器对整个应用程序可用之前添加这些单词。好的。搞定了。

SpellCheck& sc = SpellCheckS::instance();
sc.add(lpszWord);

简单。

清理

程序完成后,你应该关闭单例 `SpellCheck` 对象。

SpellCheckS::close();

这确保了 Hunspell 对象被删除,因此在调试时不会出现海量的内存泄漏。咳咳。

使用 CSpellCheckEdit 类

好的,我们有了拼写检查器。暂时假设我们已经有了 CSpellCheckEdit 类。在给定的 WTL 对话框中应该如何使用它?我们需要做几件事情。

  1. 包含 SpellCheckEdit.h 文件。
  2. 创建一个 CSpellCheckEdit 变量。
  3. 对对话框上的编辑控件进行子类化。
  4. 反射通知。

代码看起来是怎样的?

#include "SpellCheckEdit.h"  // (1) above
class CMainDlg : public CDialogImpl<CMainDlg>
{
    /* ... */
    
public:
    CSpellCheckEdit scEdit;  // (2) above
    
    /* ... */
    
    BEGIN_MSG_MAP(CMainDlg)
        MESSAGE_HANDLER(WM_INITDIALOG, OnInitDialog)
        /* ... */
        REFLECT_NOTIFICATIONS()  // (4) above
    END_MSG_MAP()
    
    /* ... */
    
public:
    LRESULT OnInitDialog(UINT /*uMsg*/, WPARAM /*wParam*/, 
            LPARAM /*lParam*/, BOOL& /*bHandled*/)
    {
        /* ... */
        scEdit.SubclassWindow(GetDlgItem(IDC_EDIT));  // (3) above
        /* ... */
        return TRUE;
    }

CSpellCheckEdit 类的结构

管理部分

文件开头有一个枚举,其中包含稍后讨论的 TrackPopupMenu 调用将返回的命令 ID。基本上, Deal 是建议被添加到上下文菜单中,这些值与它们相关联。

enum
{
    ID_SPELLCHECK_OPT0=0x8000,
    ID_SPELLCHECK_OPT1,
    ID_SPELLCHECK_OPT2,
    ID_SPELLCHECK_OPT3,
    ID_SPELLCHECK_OPT4,
    ID_SPELLCHECK_OPT5,
    ID_SPELLCHECK_OPT6,
    ID_SPELLCHECK_OPT7,
    ID_SPELLCHECK_OPT8,
    ID_SPELLCHECK_OPT9,
    ID_SPELLCHECK_ADD
};

CSpellCheckEdit 类本身是从 CWindowImpl<CSpellCheckEdit, CEdit> 派生的,所以你将创建该类的实例,而不是你自己派生它。

该类有一个内部结构(SpError),用于表示在文本中找到的错误。这些错误包含拼写错误所在的矩形、拼写错误的单词以及在编辑控件中该单词的起始字符位置。我还包含了一个 typedef,允许我将这些的 std::list 引用为 SPERRLIST

struct SpError
{
    CRect rcArea;
    CString word;
    int posn;
};
typedef std::list<SpError> SPERRLIST;

查找和绘制错误

涉及查找和绘制错误的函数包括:

  • RedrawErrors(两个签名:一个由事件处理程序调用,一个由内部调用)
  • IsWordBreak
  • DrawError
  • DrawSquiggly
  • InvalidateCheck
RedrawErrors (1)

RedrawErrors(由事件处理程序调用的那个)清除之前找到的错误列表,然后遍历控件中的每一行可见文本。它为每一行调用内部的 RedrawErrors 方法。

Matt Gullett 项目的原始代码错误地使用了 CEdit::LineLength 调用。原始代码假设你将行号传递给 CEdit::LineLength 以获取行的长度。事实并非如此。你传递一行中某个字符的字符偏移量来获取包含该字符的行的长度。结果是这个

// FPSSpellingEditCtrl.cpp:
190: int iLine = GetFirstVisibleLine();
191: int iChar = LineIndex(iLine);
192: int iLineLen = LineLength("color: red;">iLine);

被改成了这样

// SpellCheckEdit.h
91: int iLine = GetFirstVisibleLine();
92: int iChar = LineIndex(iLine);
93: int iLineLen = LineLength("color: red;">iChar);

FPSSpellingEditCtrl.cpp (216, 217) 到 SpellCheckEdit.h (116, 117) 中,还有另一个相同的错误得到了修正。

虽然原始代码起作用了,但它似乎检查了每一行很多次。可能多达行中的字符数。我没有验证这一点……我只是看到事情被检查的频率远超应有。

RedrawErrors (2)

内部调用的 RedrawErrors 方法从给定行中获取每个单词(使用 IsWordBreak 来确定单词的断开处),进行修剪,并将其传递给 DrawError

DrawError

DrawError 是实际与 SpellCheck 对象通信的方法。给定需要检查的单词,该方法调用 SpellCheck::wordIsOK。如果单词是 OK 的,DrawError 会简单地返回,而不会进一步操作。

如果单词不在词典中,DrawError 会计算单词的位置和大小,如果计算出的矩形的底部在编辑窗口的边界内,则调用 DrawSquiggly

最后,它创建一个带有错误信息的 SpError 对象,并将其添加到控件的 SPERRLIST 中。

DrawSquiggly

此方法(我将其从 DrawSquigly 重命名为 DrawSquiggly)只是在拼写错误的单词下方绘制红色的虚线。

“波浪线”最初是一条锯齿线,通过一系列振荡的 LineTo 调用绘制。我以为我可以做得更好,并使用 GDI+ 在单词下方绘制了一条抗锯齿的多点贝塞尔曲线。看起来很酷。然后,我看到了 Firefox 中的拼写检查器,觉得看起来更好。所以现在,我的代码在拼写错误的单词下方绘制单条虚线。

为了有趣起见,我保留了 GDI+ 代码,你可以选择启用它。要启用此代码,请对 DrawSquiggly 中的代码以及从 *wtlspell.cpp* (20) 开始的块进行 `#ifdef`。你还需要链接 gdiplus.lib

处理事件

SubclassWindow

虽然不是一个事件,但此方法会启动计时器。

OnDestroy (WM_DESTROY)

此事件处理程序会杀死计时器,并允许默认处理继续。

OnTimer (WM_TIMER)

这是我最不喜欢的方法。当计时器触发时,它会被调用。它做的第一件事就是杀死计时器。之后,它会重新创建它。

OnSetText (WM_SETTEXT)

我将此方法添加到原始类中,因为它会导致在设置窗口文本(例如,通过 DDX_TEXT 宏)时自动检查窗口文本。

OnChange (WM_COMMAND:EN_CHANGE)

当程序收到 EN_CHANGE 消息时,它会使当前检查状态无效,并导致再次检查可见文本。

OnPaint (WM_PAINT)

此处理程序会导致程序在存在错误的情况下重绘错误。

OnScroll (WM_HSCROLL, WM_VSCROLL)

因为程序仅检查和重绘编辑框的可见行中的文本,所以滚动意味着可见区域发生变化,因此需要重新进行检查。与错误相关的矩形也会改变,这就是为什么我们需要处理水平滚动消息。

OnLButton (WM_LBUTTONDOWN, WM_LBUTTONUP)

实际上我不知道为什么我要处理这些消息。一定是有原因的。

OnKeyDown (WM_KEYDOWN)

并非每次按键都会导致文本更改(EN_CHANGE 消息),因此此消息处理文本未更改但应重绘错误的那些情况。也许正在使用 Shift+Arrow 扩展选择。在这种情况下,需要重绘错误波浪线。

OnContextMenu (WM_CONTEXTMENU)

这是最有趣的事件处理程序(我认为)。此处理程序会构建并显示拼写检查器的上下文菜单。以下是其概要:

  1. 获取左键单击发生在该控件中的位置(客户坐标)。
  2. 如果单击不在拼写错误的单词内,则允许框架以默认方式处理事件。
  3. 创建一个弹出菜单。请注意,你**不**创建一个菜单,你创建一个弹出菜单。我花了好长时间才再次弄清楚这一点。唉。
  4. SpellCheck 单例获取建议列表。
  5. 将最多 10 条建议添加到菜单中。
  6. 添加分隔符、“添加单词”项,以及另一个分隔符。
  7. 添加正常的编辑上下文菜单项(撤销、剪切、复制、粘贴、删除、全选)。
  8. 调用 TrackPopupMenu 方法。
  9. 处理用户的选择。

关注点

在我完成所有这些代码并安顿下来写文章时,我发现了 Curtis J 的 Spell Checking Edit Control (Using HunSpell) 文章。啊!我本可以使用他的,然后将其编辑控件移植到 WTL!算了,他的代码和我之间还有其他几个不同之处……我强烈建议你查看他的文章,其中包含“忽略”功能、不同语言的词典、更全面的“用户词典”以及比我更多的 VERIFYs。

历史

  • 2009-06-22:v1.0 - 初始发布。
© . All rights reserved.