Lazarus 项目支持多语言国际化

Lazarus 是一个与 Delphi 兼容的跨平台 RAD 集成开发环境。和 Delphi 一样, Lazarus 可以拖放组件,快速开发 GUI 应用程序。它使用 Free Pascal 作为后端编译器。当前的 Lazarus 版本是 2.2.6 ,内置的 Free Pascal 编译器版本是 3.2.2 。

修复 gettext 包的缺陷

Free Pascal 包含 gettext 包,可以用来实现软件国际化。但是当前版本 gettext 中的 GetLanguageIDs 过程在 Windows 系统下存在缺陷,无法获取到符合规范的 locale 名称:

PASCAL{$ifdef windows}
procedure GetLanguageIDs(var Lang, FallbackLang: string);
var
  Buffer: array[1..4] of {$ifdef Wince}WideChar{$else}char{$endif};
  Country: string;
  UserLCID: LCID;
begin
  //defaults
  Lang := '';
  FallbackLang:='';
  UserLCID := GetUserDefaultLCID;
  if GetLocaleInfo(UserLCID, LOCALE_SABBREVLANGNAME, @Buffer[1], 4)<>0 then
    FallbackLang := lowercase(copy(Buffer,1,2));
  if GetLocaleInfo(UserLCID, LOCALE_SABBREVCTRYNAME, @Buffer[1], 4)<>0 then begin
    Country := copy(Buffer,1,2);

    // some 2 letter codes are not the first two letters of the 3 letter code
    // there are probably more, but first let us see if there are translations
    if (Buffer='PRT') then Country:='PT';

    Lang := FallbackLang+'_'+Country;
  end;
end;
gettext.pp

在简体中文的 Windows 环境中,该过程输出的 Lang 值是 ch_CH ,而不是期望的 zh_CN 。虽然在最新的 FPCSource 源代码中已经修复了这个问题,但事实上修复方案是有问题的:

PASCAL{$ifdef windows}
procedure GetLanguageIDs(var Lang, FallbackLang:AnsiString );
var
  Buffer: array[1..4] of {$ifdef Wince}WideChar{$else}AnsiChar{$endif};
  Country: AnsiString;
  UserLCID: LCID;
begin
  //defaults
  Lang := '';
  FallbackLang:='';
  UserLCID := GetUserDefaultLCID;
  if GetLocaleInfo(UserLCID, LOCALE_SABBREVLANGNAME, @Buffer[1], 4)<>0 then begin
    FallbackLang := lowercase(copy(Buffer,1,2));

    // Chinese abbreviation should be zh instead of ch
    if (Copy(Buffer,1,3)='CHS') or (Copy(Buffer,1,3)='CHT') then FallbackLang:='zh';
  end;
  if GetLocaleInfo(UserLCID, LOCALE_SABBREVCTRYNAME, @Buffer[1], 4)<>0 then begin
    Country := copy(Buffer,1,2);

    // some 2 letter codes are not the first two letters of the 3 letter code
    // there are probably more, but first let us see if there are translations
    if (Buffer='PRT') then Country:='PT';
    
    if (Copy(Buffer,1,3)='CHN') then Country:='CN';  

    Lang := FallbackLang+'_'+Country;
  end;
end;
gettext.pp

可以看到,修复方法仅仅是为了中文环境做了特殊处理。然而引发这个缺陷的主要原因是代码中的 GetLocaleInfo 调用传递了错误的参数。

Windows API GetLocaleInfoA 的原型如下:

C++int GetLocaleInfoA(
  [in]            LCID   Locale,
  [in]            LCTYPE LCType,
  [out, optional] LPSTR  lpLCData,
  [in]            int    cchData
);

GetLanguageIDs 中调用该 API 时,第二个参数 LCType 分别传递的是 LOCALE_SABBREVLANGNAMELOCALE_SABBREVCTRYNAME 。事实上在 Windows SDK 中已经明确标注了这两个常量已经过时,需要用 LOCALE_SISO639LANGNAMELOCALE_SISO3166CTRYNAME 替代。

C++#define LOCALE_SABBREVLANGNAME        0x00000003   // DEPRECATED arbitrary abbreviated language name, LOCALE_SISO639LANGNAME instead.
#define LOCALE_SABBREVCTRYNAME        0x00000007   // DEPRECATED arbitrary abbreviated country/region name, LOCALE_SISO3166CTRYNAME instead.
winnls.h

正确的修复方法是将 GetLanguageIDs 代码替换为:

PASCAL{$ifdef windows}
procedure GetLanguageIDs(var Lang, FallbackLang:AnsiString );
var
  Buffer: array[1..4] of {$ifdef Wince}WideChar{$else}AnsiChar{$endif};
  Country: AnsiString;
  UserLCID: LCID;
begin
  Lang := '';
  FallbackLang:='';
  UserLCID := GetUserDefaultLCID;
  if GetLocaleInfo(UserLCID, LOCALE_SISO639LANGNAME, @Buffer[1], 4)<>0 then begin
    FallbackLang := lowercase(copy(Buffer,1,2));
  end;
  if GetLocaleInfo(UserLCID, LOCALE_SISO3166CTRYNAME, @Buffer[1], 4)<>0 then begin
    Country := copy(Buffer,1,2);
    Lang := FallbackLang+'_'+Country;
  end;
end;

可以在当前项目下新建 gettext.pas 单元,并将修复的代码写入保存,以覆盖编译器自带的 gettext 包。

在 Lazarus 项目中启用 i18n (国际化)

新建一个名为 project1 的 Lazarus 项目。在默认窗体中加入一个标题为 Hello 的按钮。然后在主窗体单元中定义 resourcestring 节,在此节中定义一个值为 Hello, World! 的资源字符串。接着设置按钮的点击事件,用 ShowMessage 过程显示该资源字符串内容。最后,在主窗体单元中引用 DefaultTranslator 模块。

PASCALunit Unit1;

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Forms, Controls, Graphics, Dialogs, StdCtrls,
  DefaultTranslator;

type

  { TForm1 }

  TForm1 = class(TForm)
    Button1: TButton;
    procedure Button1Click(Sender: TObject);
  private

  public

  end;

var
  Form1: TForm1;

resourcestring
  HelloWorld='Hello, World!';

implementation

{$R *.lfm}

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
begin
  ShowMessage(HelloWorld);
end;

end.

将修改过的 gettext.pas 文件(下载)保存到项目源代码根目录下。为了保证自定义的 gettext 代码生效,可以在下次编译时选择「运行 - 清理和构建」重新构建项目。

demo

项目编译后,会在根目录下生成 unit1.lsj 文件,以及在项目 lib\x86_64-win64 目录下生成 unit1.rsj 文件。这两个文件分别包含了窗体和资源字符串节中的文本。可以使用 Free Pascal 自带的命令行工具 rstconv 来生成 PO 文件:

CMDrstconv -i unit1.rsj -o unit1.po

所幸 Lazarus 支持自动生成 POT 文件。在主菜单中选择「工程 - 工程选项」,在「工程选项 - 国际化」中勾选「启用i18n(国际化)」选项:

工程选项

按 Ctrl+F9 编译项目。当编译成功后,会在项目根目录下生成一个名为 project1.pot 的模板文件。如果没有生成该文件,可以修改一下窗体的标题或者资源字符串的值,然后再次编译。

用 Poedit 打开 project1.pot 模板文件,将其中的条目全部翻译好,并将生成的 PO 文件命名为 project1.zh_CN.po 并保存到项目的 locale 目录下。

poedit

再次按 F9 运行程序,可以发现界面已经基本汉化,但消息框的确认按钮依然还是英文的。还需要本地化 LCL 和 Lazarus 提供的组件。

demo

可以在 Lazarus 安装路径下的 lcl\languages 目录中找到 lclstrconsts.zh_CN.po 文件。将该文件也复制到项目 locale 目录下便可实现 LCL 组件的中文本地化。不过官方提供的中文翻译质量很差,可以自己润色一下。另外,可以在 Lazarus 安装路径下的 components 目录中找到各组件的 languages 文件夹。如果使用了这些组件,也需要将对应组件 languages 文件夹下的 *.zh_CN.po 文件复制到项目 locale 目录下。此外,可以用 Poedit 或 msgfmt 命令将 PO 文件编译成 MO 文件,后者是二进制格式,加载速度能更快一些。

DefaultTranslator 最终通过调用 LCLTranslatorSetDefaultLang 过程来实现本地化的。它会先尝试加载 PO/POT 文件,如果失败则尝试加载 MO 文件,并会尝试通过下面的路径顺序来查找文件(以语言代码为 zh_CN 、应用程序名为 project1 举例):

  1. ./zh_CN/project1.po
  2. ./languages/zh_CN/project1.po
  3. ./locale/zh_CN/project1.po
  4. ./locale/zh_CN/LC_MESSAGES/project1.po
  5. /usr/share/locale/zh_CN/LC_MESSAGES/project1.po (Linux)
  6. ./zh/project1.po
  7. ./languages/zh/project1.po
  8. ./locale/zh/project1.po
  9. ./locale/zh/LC_MESSAGES/project1.po
  10. ./project1.zh_CN.po
  11. ./locale/project1.zh_CN.po
  12. ./languages/project1.zh_CN.po
  13. /usr/share/locale/zh/LC_MESSAGES/project1.po (Linux)
  14. ./project1.zh.po
  15. ./locale/project1.zh.po
  16. ./languages/project1.zh.po
  17. ./project1.pot
  18. ./locale/project1.pot
  19. ./languages/project1.pot

其中 5 和 13 仅在 Linux/Unix 平台下生效,17 - 18 仅在查找 PO 文件时生效。