Wednesday, December 01, 2010

Using MS Word as a Spelling and Grammar Checker for Delphi

The COM interface exposed by MS Word gives a number of mechanisms for the use of the spelling engine.
  • The Word.Application object can call the engine for a single word or string. The return value is a Boolean indicating True (no errors) or False (errors). Further work must be done to provide spelling suggestions / correction.
  • The Document object contained by the Word.Application object exposes all the functionality of MS Word, in particular, the MS Word dialog boxes for spelling and grammar checking can be used.
The technique used in this component is the second method. Text is copied to the (hidden) word document, then the appropriate function (spelling or grammar) is called. To discover if the user made any changes to the text, MS Word's ability to track changes is utilised.

RTF text can be checked easily by using the CheckClipBoardSpell / Grammar functions.

Custom Dictionaries: Using custom dictionaries is a powerful feature of Word's spelling checker but there appears to be an error in the documentation (at least I can't get it to work as written in Delphi or VBA within Word itself!). Custom dictionaries are text files (usually with a '.dic' extension) with a sorted list of words - one word on each line. Included the ability to "install" a custom dictionary(s) which will be used in all spell checks. The down side is that the dictionary will now be seen by Word all the time, so it's probably a good idea to remove any such dictionary before exiting your program.

Checking if the user wants to cancel spell/grammar checking: In your own program you could check for the press of a "cancel" button or key. This isn't available when using Word's built in dialogs, so I tend to use the SpellingChecked and GrammarChecked properties to check if there are still errors in the text after a spell check has been completed. If the user has decided to quit, these properties should be False. I have mapped them into the UserCancel property for ease of programming.

Installation is very simple, just copy the code and save it as uSpellCheck.pas and include any of your existing/new dpk file.

How to use:
i) Drop the TActionList component onto a form.
ii) Select StandardAction from TActionList
iii)You will see new TSpellCheck action item under Edit category. select it ;-)
By default this action item is associated with F7 shortcut.
iv) Drop few Editable controls like TEdit, TMemo, TRichEdit etc and run the application and press F7 to trigger Spell Check.

That's it really!

{
Unit Name   : uSpellCheck.pas
Description : TSpellCheck is derived from TEditAction, which is available on
              TActionList. This newly created action acts as a standard EditAction
              for example: It gets enabled when the control is in Editable mode.
Enhancements: Couldn't think of any more as of now...
Create Date : 1st December 2010
Author      : Kiran Kumar Kurapaty
Code enhanced from original author: Andrew Baylis (ajbaylis@melbpc.org.au), Thanks Andrew.
}

unit uSpellCheck;

interface

uses Windows, SysUtils, Classes, ActnList, Forms, StdCtrls, Dialogs, StdActns, ActnRes, Messages, ComObj,
     ActiveX, Variants;

type
  TWordTimer = class(TThread)
  private
    FCaption: string;
    FDelay: Integer;
    FDialogWndClass: string;
    FPopUpExists: Boolean;
    procedure SetDelay(Value: Integer);
  protected
    procedure Execute; override;
  public
    constructor Create(CreateSuspended: Boolean);
    procedure Resume;
    property Caption: string read FCaption write FCaption;
    property Delay: Integer read FDelay write SetDelay;
    property DialogWndClass: string read FDialogWndClass write FDialogWndClass;
  end;

  TSpellLanguage = Integer;

  TSpellCheck = class(TEditAction)
  private
    FCancel: Boolean;
    FChangedText: string;
    FConnected: Boolean;
    FHandle: HWND;
    FNumChanges: Integer;
    FOleOn: Boolean;
    FSpellCaption: string;
    FTimer: TWordTimer;
    FUseSpellCaption: Boolean;
    FWordApp, FRange, FWordDoc, FCustDics: OLEVariant;
    FWordDialogClass: string;
    FWordVersion: string;
    FLanguage: TSpellLanguage;
    function GetCheckGWS: Boolean;
    function GetGrammarErrors: Integer;
    function GetSpellChecked: Boolean;
    function GetSpellErrors: Integer;
    function GetUserCancel: Boolean;
    function GetVersion: string;
    function GetWordVersion: string;
    procedure SetCheckGWS(const Value: Boolean);
    procedure SetLanguage(Value: TSpellLanguage);
  protected
    function Internal_checkGrammar: Boolean;
    function Internal_checkSpelling: Boolean;
    procedure StartTimer;
    procedure StopTimer;
  public
    procedure ExecuteTarget(Target: TObject); override;
    procedure UpdateTarget(Target: TObject); override;

    function AddCustomDic(const FileName: string): Integer;
    function CheckClipboardGrammar: Boolean;
    function CheckClipboardSpell: Boolean;
    function CheckGrammar(const Text: string): Boolean;
    function CheckSpelling(const Text: string): Boolean;
    procedure ClearText;
    procedure Connect;
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;
    procedure Disconnect;
    procedure RemoveCustomDic(const Name: string); overload;
    procedure RemoveCustomDic(const Index: Integer); overload;
    procedure ResetIgnoreAll;
    procedure SpellingOptions;

    property ChangedText: string read FChangedText;
    property CheckGrammarWithSpelling: Boolean read GetCheckGWS write SetCheckGWS;
    property Connected: Boolean read FConnected;
    property GrammarErrorCount: Integer read GetGrammarErrors;
    property NumChanges: Integer read FNumChanges;
    property SpellChecked: Boolean read GetSpellChecked;
    property SpellErrorCount: Integer read GetSpellErrors;
    property UserCancelled: Boolean read GetUserCancel write FCancel;
    property WordVersion: string read GetWordVersion;
  published
    property SpellCaption: string read FSpellCaption write FSpellCaption;
    property UseSpellCaption: Boolean read FUseSpellCaption write FUseSpellCaption;
    property Version: string read GetVersion;
    property Language: TSpellLanguage read FLanguage write SetLanguage;
  end;

  LangRecord = record
    LocalName: string;
    ID: Integer;
  end;

const // Spelling Language
   SpellLanguage: array[0..63] of LangRecord =
      ((LocalName: 'LanguageNone'; ID: $00000000),
      (LocalName: 'NoProofing '; ID: $00000400),
      (LocalName: 'Danish '; ID: $00000406),
      (LocalName: 'German '; ID: $00000407),
      (LocalName: 'SwissGerman '; ID: $00000807),
      (LocalName: 'EnglishAUS '; ID: $00000C09),
      (LocalName: 'EnglishUK '; ID: $00000809),
      (LocalName: 'EnglishUS '; ID: $00000409),
      (LocalName: 'EnglishCanadian '; ID: $00001009),
      (LocalName: 'EnglishNewZealand '; ID: $00001409),
      (LocalName: 'EnglishSouthAfrica '; ID: $00001C09),
      (LocalName: 'Spanish '; ID: $0000040A),
      (LocalName: 'French '; ID: $0000040C),
      (LocalName: 'FrenchCanadian '; ID: $00000C0C),
      (LocalName: 'Italian '; ID: $00000410),
      (LocalName: 'Dutch '; ID: $00000413),
      (LocalName: 'NorwegianBokmol '; ID: $00000414),
      (LocalName: 'NorwegianNynorsk '; ID: $00000814),
      (LocalName: 'BrazilianPortuguese '; ID: $00000416),
      (LocalName: 'Portuguese '; ID: $00000816),
      (LocalName: 'Finnish '; ID: $0000040B),
      (LocalName: 'Swedish '; ID: $0000041D),
      (LocalName: 'Catalan '; ID: $00000403),
      (LocalName: 'Greek '; ID: $00000408),
      (LocalName: 'Turkish '; ID: $0000041F),
      (LocalName: 'Russian '; ID: $00000419),
      (LocalName: 'Czech '; ID: $00000405),
      (LocalName: 'Hungarian '; ID: $0000040E),
      (LocalName: 'Polish '; ID: $00000415),
      (LocalName: 'Slovenian '; ID: $00000424),
      (LocalName: 'Basque '; ID: $0000042D),
      (LocalName: 'Malaysian '; ID: $0000043E),
      (LocalName: 'Japanese '; ID: $00000411),
      (LocalName: 'Korean '; ID: $00000412),
      (LocalName: 'SimplifiedChinese '; ID: $00000804),
      (LocalName: 'TraditionalChinese '; ID: $00000404),
      (LocalName: 'SwissFrench '; ID: $0000100C),
      (LocalName: 'Sesotho '; ID: $00000430),
      (LocalName: 'Tsonga '; ID: $00000431),
      (LocalName: 'Tswana '; ID: $00000432),
      (LocalName: 'Venda '; ID: $00000433),
      (LocalName: 'Xhosa '; ID: $00000434),
      (LocalName: 'Zulu '; ID: $00000435),
      (LocalName: 'Afrikaans '; ID: $00000436),
      (LocalName: 'Arabic '; ID: $00000401),
      (LocalName: 'Hebrew '; ID: $0000040D),
      (LocalName: 'Slovak '; ID: $0000041B),
      (LocalName: 'Farsi '; ID: $00000429),
      (LocalName: 'Romanian '; ID: $00000418),
      (LocalName: 'Croatian '; ID: $0000041A),
      (LocalName: 'Ukrainian '; ID: $00000422),
      (LocalName: 'Byelorussian '; ID: $00000423),
      (LocalName: 'Estonian '; ID: $00000425),
      (LocalName: 'Latvian '; ID: $00000426),
      (LocalName: 'Macedonian '; ID: $0000042F),
      (LocalName: 'SerbianLatin '; ID: $0000081A),
      (LocalName: 'SerbianCyrillic '; ID: $00000C1A),
      (LocalName: 'Icelandic '; ID: $0000040F),
      (LocalName: 'BelgianFrench '; ID: $0000080C),
      (LocalName: 'BelgianDutch '; ID: $00000813),
      (LocalName: 'Bulgarian '; ID: $00000402),
      (LocalName: 'MexicanSpanish '; ID: $0000080A),
      (LocalName: 'SpanishModernSort '; ID: $00000C0A),
      (LocalName: 'SwissItalian '; ID: $00000810));


  procedure Register;

implementation

uses Registry;

{ TSpellCheck }
procedure TSpellCheck.ExecuteTarget(Target: TObject);
begin
  if Assigned(GetControl(Target)) then
  begin
    try
      if CheckSpelling(GetControl(Target).Text) then
        GetControl(Target).Text := ChangedText;
    except
      on E:Exception do
        MessageDlg(Format('There was an error calling the Spell Checker (%s) - '+
                          'you may need to close some Windows Applications and try again',
                          [E.message]), mtError, [mbOk], 0);
    end;
  end;
end;

procedure TSpellCheck.UpdateTarget(Target: TObject);
begin
  inherited;
  Enabled := Length(GetControl(Target).Text) > 0;
end;



type
  TSpellCheckActionClass = class of TSpellCheck;
  TEditActionClass = class of TEditAction;

  TEditActionWrapper = class(StdActns.TEditAction);

  TSpellCheckEditActionInfo = record
    SpellCheckActionClass: TSpellCheckActionClass;
    EditActionClass: TEditActionClass;
    DefaultCaption: string;
    DefaultHint: string;
    DefaultImageIndex: Integer;
    DefaultShortCut: TShortCut;
  end;

  TStandardActionsEx = class(TStandardActions)
  protected
    procedure CreateByInfo(AEditActionsInfo: TSpellCheckEditActionInfo);
    function FindEditActionByClassType(AEditActionClass: TEditActionClass): TEditAction;
  public
    constructor Create(AOwner: TComponent); override;
  end;

{purpose: Add default properties of future actions here...}
const
  CH4EditActionsCount = 1;
  CH4EditActionsInfo: array [0..CH4EditActionsCount-1] of TSpellCheckEditActionInfo =
  (
    ( SpellCheckActionClass: TSpellCheck;
      EditActionClass: TEditAction;
      DefaultCaption: '&Spell Check';
      DefaultHint: 'Spell Check|Check for Spellings and Grammer.';
      DefaultImageIndex: -1;
      DefaultShortCut: VK_F7)
  );

constructor TStandardActionsEx.Create(AOwner: TComponent);
var
  I: Integer;
begin
  inherited;
  for I := Low(CH4EditActionsInfo) to High(Ch4EditActionsInfo) do
    CreateByInfo(CH4EditActionsInfo[I]);
end;

procedure TStandardActionsEx.CreateByInfo(AEditActionsInfo: TSpellCheckEditActionInfo);
var
  AEditAction: TEditAction;
  ASpellCheckEditAction: TEditAction;
begin
  AEditAction := FindEditActionByClassType(AEditActionsInfo.EditActionClass);
  ASpellCheckEditAction := AEditActionsInfo.SpellCheckActionClass.Create(Self);
  with ASpellCheckEditAction do
  begin
    if AEditAction <> nil then
    begin
      Caption := TEditActionWrapper(AEditAction).Caption;
      Hint := TEditActionWrapper(AEditAction).Hint;
      ImageIndex := TEditActionWrapper(AEditAction).ImageIndex;
      ShortCut := TEditActionWrapper(AEditAction).ShortCut;
    end
    else
    begin
      Caption := AEditActionsInfo.DefaultCaption;
      Hint := AEditActionsInfo.DefaultHint;
      ImageIndex := AEditActionsInfo.DefaultImageIndex;
      ShortCut := AEditActionsInfo.DefaultShortCut;
    end;
  end;
end;

function TStandardActionsEx.FindEditActionByClassType(AEditActionClass: TEditActionClass): TEditAction;
var
  I: Integer;
begin
  Result := nil;
  for I := 0 to ComponentCount - 1 do
    if (Components[I].ClassType = AEditActionClass) and (Components[I] is TEditAction) then
    begin
      Result := Components[I] as TEditAction;
      Break;
    end;
end;

procedure Register;
begin
  RegisterActions('Edit', [TSpellCheck], TStandardActionsEx);
end;

// these constants used to identify the window class of Word's dialog box
const
  //Constants for MS Word
   MSDialogWndClass2000 = 'bosa_sdm_Microsoft Word 9.0';
   MSDialogWndClass97 = 'bosa_sdm_Microsoft Word 8.0';
   MSWordWndClass = 'OpusApp';
   CSpellerVersion = '1.0';

function IsWordPresent: Boolean;
var
  Reg: TRegistry;
begin
  Reg := TRegistry.Create;
  try
    Reg.RootKey := HKEY_CLASSES_ROOT;
    Result := Reg.KeyExists('Word.Application');
  finally
    Reg.Free;
  end;
end;

{ TSpellCheck }
function TSpellCheck.GetCheckGWS: Boolean;
begin
  Result := False;
  if FConnected then Result := FWordApp.Options.CheckGrammarWithSpelling;
end;

function TSpellCheck.GetGrammarErrors: Integer;
begin
  if FConnected then
    Result := FRange.GrammaticalErrors.Count
  else
    Result := 0;
end;

function TSpellCheck.GetSpellChecked: Boolean;
// returns false if spelling has yet to be checked
begin
  Result := True;
  if FConnected then Result := not FRange.SpellingChecked;
end;

function TSpellCheck.GetSpellErrors: Integer;
begin
  if FConnected then
    Result := FRange.SpellingErrors.Count
  else
    Result := 0;
end;

function TSpellCheck.GetUserCancel: Boolean;
begin
  Result := FCancel;
end;

function TSpellCheck.GetVersion: string;
begin
  Result := CSpellerVersion;
end;

function TSpellCheck.GetWordVersion: string;
begin
  Result := FWordVersion;
end;

procedure TSpellCheck.SetCheckGWS(const Value: Boolean);
begin
  if FConnected then FWordApp.Options.CheckGrammarWithSpelling := Value;
end;

function TSpellCheck.Internal_checkGrammar: Boolean;
begin
  SetWindowPos(FHandle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE + SWP_HIDEWINDOW); // ensures dialogs appear in front
  FWordDoc.TrackRevisions := True; // note if changes are made
  FNumChanges := 0;

  FRange.GrammarChecked := False;
  if (FLanguage > 0) then // 0 means use the default setting
    FRange.LanguageID := SpellLanguage[FLanguage].ID;

  StartTimer;
  OleCheck(FRange.CheckGrammar);
  StopTimer;

  FCancel := not FRange.GrammarChecked;
  FWordApp.Visible := False; // need to stop ActiveDocument appearing

  FNumChanges := FRange.Revisions.Count; // seems revisions counts the old word and the new one separately
  Result := (FRange.Revisions.Count > 0);
  if Result then FRange.Revisions.AcceptAll; // accept all changes

  FWordDoc.TrackRevisions := False; // don't track future changes
end;

function TSpellCheck.Internal_checkSpelling: Boolean;
begin
  SetWindowPos(FHandle, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOACTIVATE + SWP_HIDEWINDOW); // ensures dialogs appear in front
  FWordDoc.TrackRevisions := True; // note if changes are made
  FNumChanges := 0;

  if (FLanguage > 0) then // 0 means use the default setting
     FRange.LanguageID := SpellLanguage[FLanguage].ID;

  StartTimer;
  OleCheck(FWordDoc.CheckSpelling);
  StopTimer;

  FCancel := not FWordDoc.SpellingChecked;
  FWordApp.Visible := False; // need to stop ActiveDocument appearing

  FNumChanges := FRange.Revisions.Count; // seems revisions counts the old word and the new one separately
  Result := (FRange.Revisions.Count > 0);
  if Result then FRange.Revisions.AcceptAll; // accept all changes

  FWordDoc.TrackRevisions := False; // don't track future changes
end;

procedure TSpellCheck.StartTimer;
begin
  if FUseSpellCaption then
  begin
    FTimer.Caption := FSpellCaption;
    FTimer.Resume;
  end;
end;

procedure TSpellCheck.StopTimer;
begin
  FTimer.Suspend;
end;

function TSpellCheck.AddCustomDic(const FileName: string): Integer;
begin
  FCustDics.Add(FileName);
  Result := FCustDics.Count;
end;

function TSpellCheck.CheckClipboardGrammar: Boolean;
// returns true if changes were made. Corrected text is on the clipboard
begin
   Result := False;
   if not FConnected then Connect;
   if not FConnected then Exit; // if still not connected then no MS Word!

   if FConnected then
   begin
      FRange.Paste; // replace with new text to check
      Result := Internal_CheckGrammar;
      if Result then FRange.Copy;
   end;
end;

function TSpellCheck.CheckClipboardSpell: Boolean;
// returns true if changes were made. Corrected text is on the clipboard
begin
   Result := False;
   if not FConnected then Connect;
   if not FConnected then Exit; // if still not connected then no MS Word!
   if FConnected then
   begin
      FRange.Paste; // replace with new text to check
      Result := Internal_checkSpelling;
      if Result then FRange.Copy; // put onto clipboard
   end;
end;

function TSpellCheck.CheckGrammar(const Text: string): Boolean;
// returns true if changes were made and the corrected text is placed in the Text string
begin
   Result := False;
   if not FConnected then Connect;
   if not FConnected then Exit; // if still not connected then no MS Word!
   if FConnected then
   begin
      FChangedText := EmptyStr;
      FRange.Text := Text; // replace with new text to check
      Result := Internal_CheckGrammar;
      if Result then FChangedText := FRange.Text;
   end;
end;

function TSpellCheck.CheckSpelling(const Text: string): Boolean;
// returns true if changes were made and the corrected text is placed in the Text string
begin
   Result := False;
   if not FConnected then Connect;
   if not FConnected then Exit; // if still not connected then no MS Word!
   if FConnected then
   begin
      FChangedText := EmptyStr;
      FRange.Text := Text; // replace with new text to check
      Result := Internal_CheckSpelling;
      if Result then FChangedText := FRange.Text;
   end
   else
      Result := False;
end;

procedure TSpellCheck.ClearText;
begin
  if FConnected then FRange.Text := EmptyStr;
end;

procedure TSpellCheck.Connect;
var
  s: string;
begin
  if FConnected then Exit; // don't create two instances
  try
    FWordApp := CreateOleObject('Word.Application');
    FConnected := True;
    FWordApp.Visible := False; // hides the application
    FWordApp.ScreenUpdating := False; // speed up winword's processing
    FWordApp.WindowState := $00000002; // minimise
    FWordDoc := FWordApp.Documents.Add(EmptyParam, False); // this will hold the text to be checked
    FRange := FWordDoc.Range;
    FRange.WholeStory; // makes FRange point to all text in document
    FCustDics := FWordApp.CustomDictionaries;
    FWordVersion := FWordApp.Version;
    s := FWordDoc.Name + ' - ' + FWordApp.Name;
    FHandle := FindWindow(MSWordWndClass, PChar(s)); // winword
    if FWordVersion[1] = '9' then
      FWordDialogClass := MSDialogWndClass2000
    else
      FWordDialogClass := MSDialogWndClass97;

    FTimer := TWordTimer.Create(True);
    FTimer.Delay := 50; // every 0.05 s
    FTimer.DialogWndClass := FWordDialogClass;
    FTimer.FreeOnTerminate := True;

  except
    FWordApp := Unassigned;
    FConnected := False;
    if Assigned(FTimer) then
    begin
      FTimer.Terminate;
      while FTimer.Suspended do
        FTimer.Resume;
    end;
    FTimer := nil;
    MessageDlg('Unable to initialise MS Word', mtError, [mbYes], 0);
  end;
end;

constructor TSpellCheck.Create(AOwner: TComponent);
var
  init: Integer;
begin
  inherited;
  FConnected := False;
  FCancel := False;
  FChangedText := EmptyStr;
  Init := CoInitialize(nil);
  if (Init = S_OK) or (Init = S_FALSE) then
    FOleOn := True
  else
    raise EOleSysError.CreateFmt('Error initialising COM library', []);

  FSpellCaption := EmptyStr;
  FUseSpellCaption := False;
  FLanguage := 0;
end;

destructor TSpellCheck.Destroy;
begin
  Disconnect;
  if FOleOn then CoUninitialize;
  inherited;
end;

procedure TSpellCheck.Disconnect;
var
  savechanges: OleVariant;
begin
  if not VarIsEmpty(FWordApp) then
  begin
    savechanges := False;
    FWordApp.Quit(savechanges); // don't save changes
    FRange := Unassigned;
    FWordDoc := Unassigned;
    FWordApp := Unassigned;
    FCustDics := Unassigned;
    FConnected := False;
    if Assigned(FTimer) then
    begin
      FTimer.Terminate;
      while FTimer.Suspended do
        FTimer.Resume; // need this in case thread was never started
    end;
    FTimer := nil;
  end;
end;

procedure TSpellCheck.RemoveCustomDic(const Name: string);
var
  dic: OleVariant;
begin
  dic := FCustDics.Item(Name);
  if not VarIsEmpty(dic) then
    dic.Delete;
  dic := Unassigned;
end;

procedure TSpellCheck.RemoveCustomDic(const Index: Integer);
var
  dic: OleVariant;
begin
  dic := FCustDics.Item(Index);
  if not VarIsEmpty(dic) then
    dic.Delete;
  dic := Unassigned;
end;

procedure TSpellCheck.ResetIgnoreAll;
begin
  if FConnected then
  begin
    FRange.Text := ''; // ResetIgnoreAll performs an automatic spell check
    FWordApp.ResetIgnoreAll;
  end;
end;

procedure TSpellCheck.SpellingOptions;
begin
  BringWindowToTop(FHandle); // ensures that dialog opens on top
  FWordApp.Dialogs.Item($000000D3).Show;
  FWordApp.Visible := False;
end;

procedure TSpellCheck.SetLanguage(Value: TSpellLanguage);
begin
  FLanguage := Value;
end;

{ TWordTimer }
procedure TWordTimer.SetDelay(Value: Integer);
begin
  if (Value > 0) then FDelay := Value;
end;

procedure TWordTimer.Execute;
var
  h: HWND;
begin
  while not Terminated do
  begin
    sleep(FDelay); // use this as a rough timer
    if (FDialogWndClass <> EmptyStr) then
    begin
      h := FindWindow(PChar(FDialogWndClass), nil);
      if (h <> 0) and (not FPopUpExists) then
      // only change caption if the window has just appeared
      begin
        SetWindowText(h, PChar(FCaption));
        FPopUpExists := True;
      end else
        FPopUpExists := False;
    end;
  end;
end;

constructor TWordTimer.Create(CreateSuspended: Boolean);
begin
  FDelay := 100; // default delay (ms)
  inherited Create(CreateSuspended);
end;

procedure TWordTimer.Resume;
begin
  FPopUpExists := False;
  inherited;
end;

end.