最近更新: 2011-02-14

Vala with GNU gettext

Vala 作為 GNOME 開發環境下新興的開發語言,帶入了許多新的功能,其中亦包含國際化(i18n)的支援項目。 儘管 Vala 的線上教學文件沒有隻字片語提到 i18n/l10n,但事實上 Vala 已經將 GNU gettext 作為內建的語言功能,使用它實現 i18n/l10n 能力。 Vala 提供了名為 _ 的函數,只要我們的 vala 程式碼使用了 _() 函數,就會使用 GNU gettext 取得本地訊息。

但是現階段使用這個內建功能時,還有一個文件未記載的不完善之處必須解決。待我說來。

Vala 轉譯後的 C 源碼,將會引入 gi18n-lib.h 負責 gettext 的程式碼。 而 gi18n-lib.h 要求程序員必須使用 GETTEXT_PACKAGE 符號定義你的內容範圍(text domain)。 但是目前的 valac 在調用 gcc 時,並不會幫我們產生 GETTEXT_PACKAGE 的符號定義, 所以 valac 會丟出一個乍看之下莫名奇妙的錯誤訊息: #error You must define GETTEXT_PACKAGE before including gi18n-lib.h. Did you forget to include config.h?

若未追踪它轉譯後的 C 源碼,很難理解我們的 vala 程式碼怎麼會跟 gi18n-lib.h 扯上關係。

解決方法是使用 valac 時,自己加上一個額外傳遞給 gcc 的參數選項,即: -X -DGETTEXT_PACKAGE="your_text_domain" 。 下列是一個完整的使用範例:


$ valac -X -DGETTEXT_PACKAGE="pcmanfm" hello.vala

See also: http://www.mail-archive.com/vala-list@gnome.org/msg02887.html

或許 vala 未來的版本會提供適當地處理方式。但目前僅能使用這個不優雅的解法。

Hello gettext

GNU gettext 將本地訊息包裝在副檔名為 .mo 的文件中(以下以 MO 文件稱呼包裝過的本地訊息文件),它是獨立於應用軟體之外的資料。 換言之,你的軟體也可以直接使用別的軟體的 MO 文件。 在說明如何產生自己的 MO 文件之前,我們先在自己的應用軟體中使用別人的 MO 文件,體驗一下 gettext 的能力。

我的 Ubuntu 系統中安裝了 pcmanfm 這套檔案管理工具,我將使用它的 MO 文件示範如何顯示我的程式中要區域化的訊息。

// 說明 1

const string GETTEXT_PACKAGE = "pcmanfm"; 

void main() {
    // 說明 2

    Intl.setlocale(LocaleCategory.MESSAGES, "");

    // 說明 3

    //Intl.textdomain(GETTEXT_PACKAGE);


    // 說明 4

    Intl.bind_textdomain_codeset(GETTEXT_PACKAGE, "utf-8");

    // 說明 5

    Intl.bindtextdomain(GETTEXT_PACKAGE, "/usr/share/locale"); // 區域化內容放置處


    // 說明 6

    stdout.printf(_("Execute"));
    stdout.printf("\n");
}
程式說明
  1. GETTEXT_PACKAGE: 很遺憾,雖然我在這裡定義了常值 GETTEXT_PACKAGE ,但 valac 並不會用它帶出傳給 gcc 的符號參數 -DGETTEXT_PACKAGE=? ,所以我們還是要手動加上那個參數。但是它還是有一個安全作用:當 vala 程式碼中的 GETTEXT_PACKAGE 與符號參數 -DGETTEXT_PACKAGE 兩者之值不相同時, gcc 會抱怨 GETTEXT_PACKAGE 被重新定義了。
  2. Intl.setlocale(): 使用 GNU gettext 的本地化功能前,必須指定我們要啟用的類型。 在本例中,我只用到訊息的本地化功能,所以我只啟用 LocaleCategory.MESSAGES。 第二個參數指定區碼,若為空字串,則表示由環境變數指定區碼。具影響力的環境變數,依優先等級排列為: LANGUAGE, LC_ALL, LC_MESSAGES, LANGLANGUAGE 是 GNU gettext 的擴充項目,不是 POSIX 規範項目 (參考 Locale Environment Variables)。若為 null ,則是查詢目前使用的區碼;但是 Vala 的實作內容似乎有 bug ,它總是回傳環境變數 LANG 之值。
    區域化內容類型,除了訊息的本地化外,還有貨幣符號、數字格式、日期格式等等的本地化。如果我們啟用 LocaleCategory.ALL ,則會影響所有可以本地化的內容類型。有時這並非我們想要的結果,應謹慎使用。
  3. Intl.textdomain(): Vala 的 _() 函數,實際上是調用 g_dgettext(GETTEXT_PACKAGE, msgid)。由於 g_dgettext() 會自帶範圍名稱,所以我們不需要用 Intl.textdomain() 設定預設範圍。
  4. Intl.bind_textdomain_codeset(): 當我僅啟用本地化訊息功能時(LocaleCategory.MESSAGES),我必須指定本地訊息的字元編碼,否則 gettext 會用當地慣用的區域編碼系統,例如 zh_TW 預設是 BIG5。 在早期,這個動作很有用。但是隨著 UTF-8 編碼系統的推廣,現在大部份的本地訊息文件也都是用 UTF-8 編碼,故現在要指定字元編碼為 'utf-8',否則輸出的訊息通常是所謂的亂碼。
  5. Intl.bindtextdomain(): 若你的 MO 文件放置的位置不在系統預設路徑內,則你必須調用 Intl.bindtextdomain() 指示訊息文件的搜尋路徑。 在本例中,pcman.mo 正放置於系統預設路徑內,其實可以省略此動作。
  6. _(): 將原始訊息交給內建函數 _(),取得本地化的訊息文字。如果你使用的 MO 文件中未包含對應的本地訊息,則會直接傳回原始訊息。

執行範例如下。我透過環境變數 LANG 調整程式輸出的訊息內容。


$ valac -X -DGETTEXT_PACKAGE="pcmanfm" hello1.vala
$ export LANG=zh_TW.utf8
$ ./hello1
執行
$ export LANG=en_US.utf8
$ ./hello1
Execute

前述曾提到環境變數 LANGUAGE 是 GNU gettext 的擴充項目。它必須配合 LANGLC_ALL 使用,用於列出可選用的替代語系。請參考 2.3.3 Specifying a Priority List of Languages。通常我們並不使用這個變數。

建立你的訊息文件

前一節中說明了如何利用 GNU gettext 函數庫,取得原始訊息的本地訊息後輸出。接下來的內容,則要說明如何建立自己的訊息文件。相關內容包括區域訊息文件的編寫格式、操作工具等。

編寫區域訊息文件

上節提過 MO 文件是包裝過的本地訊息文件,它的內容是索引資料檔,這顯然不是可用文字編輯器編寫的格式。 其實我們主要的操作對象並不是 MO 文件,而是未包裝過的純文字本地訊息文件;GNU gettext 稱為 PO 文件。 PO 文件的格式說明請參閱《The Format of PO Files》。其實它的格式非常簡單,主體只是由成對的 msgid/msgstr 描述所組成。其他項目都是選擇性的註釋內容。

下列是一個最簡單的 PO 文件全文。你可以用你最慣用的文字編輯器編寫這些內容。

msgid  "Hello"
msgstr "你好"

msgid  "Welcome to my blog, Rock's saying.\n"
msgstr "歡迎來到我的部落格 - 石頭閒語。\n"

按照 GNU 開發者慣例,PO 文件採用區碼作為主檔名。上列的訊息文件是漢語系臺灣區( zh_TW,這是根據文化別所作為區分)的 PO 文件,就取名為 zh_TW.po。再使用 GNU gettext 提供的工具 msgfmt 將 PO 文件包裝成 MO 文件,就可以讓我們的軟體使用了。

下列指令可將 zh_TW.po 包裝成 hello.mo 文件,並將它按區碼與類型,放置於 locale 目錄下。我們放置區域化內容的目錄,慣例命名為 locale。其目錄結構則有標準規範:第一層是區碼,第二層是類型。故 zh_TW 區碼的本地訊息文件,就要放在 zh_TW/LC_MESSAGES 目錄下。至於 MO 文件的主檔名,則是此訊息文件的範圍名稱。


# 建立本文所需的區域化內容的目錄結構。
$ mkdir -p locale/zh_TW/LC_MESSAGES
$ mkdir -p locale/eu_US/LC_MESSAGES

$ msgfmt --output=locale/zh_TW/LC_MESSAGES/hello.mo zh_TW.po

MO 文件的主檔名即為訊息的範圍名稱(text domain)。我將使用 hello.mo 訊息文件的內容,故接下來的 vala 程式碼中,我要指定的範圍名稱就是 "hello"。在範例中,我都將自製的 MO 文件放在現行目錄的 locale 子目錄內,故 Intl.bindtextdomain() 指向 "./locale"。實務上,則應以絕對路徑表示。通常我們自製的 MO 文件,會放在 /usr/local/share/locale

const string GETTEXT_PACKAGE = "hello"; 

void main() {
    Intl.setlocale(LocaleCategory.MESSAGES, "");
    Intl.bind_textdomain_codeset(GETTEXT_PACKAGE, "utf-8");
    Intl.bindtextdomain(GETTEXT_PACKAGE, "./locale"); // 區域化內容放置處


    stdout.printf("%s gettext.\n", _("Hello"));
    stdout.printf(_("Welcome to my blog, Rock's saying.\n"));
}

下列為執行結果。


$ valac -X -DGETTEXT_PACKAGE="hello" hello2.vala
$ export LANG=zh_TW.utf8
$ ./hello2
你好 gettext.
歡迎來到我的部落格 - 石頭閒語。
$ export LANG=en_US.utf8
$ ./hello2
Hello gettext.
Welcome to my blog, Rock's saying.

從程式碼中擷取訊息

實務上,我們通常不是先寫 PO 文件再套進程式中,那太麻煩了。一般是把要區域化的訊息寫在程式碼中,再用 GNU gettext 的工具 xgettext 將需翻譯的訊息從程式碼中擷取出來。如下例 hello3.vala。我先寫好程式,並把需要區域化的訊息改成用 _() 函數處理。

const string GETTEXT_PACKAGE = "hello3"; 

void main() {
    Intl.setlocale(LocaleCategory.MESSAGES, "");
    Intl.bind_textdomain_codeset(GETTEXT_PACKAGE, "utf-8");
    Intl.bindtextdomain(GETTEXT_PACKAGE, "./locale"); // 區域化內容放置處


    stdout.printf( "hello3. 這一行訊息不需要翻譯\n" );

    stdout.printf( "%s gettext\n", _("好你") );
    stdout.printf( _("行一第的息訊長。\n行二第的息訊長。\n") );
}

接著使用 xgettext 擷取訊息。因為 xgettext 還不認得 Vala 的語法,所以我要指定一個最接近的語言(--language)給它參考;在此我用 C#。Vala 的 gettext 函數名稱是 _ ,所以指定辨識的關鍵字(--keyword) 為 _。最後,按照 GNU 開發慣例,xgettext 擷取出來的待翻譯訊息文件的副檔名取為 .pot (.pot 之意為 PO Template。正在翻譯或已經翻好的訊息文件,則是 .po。)。


$ xgettext --language="C#" --keyword=_ --from-code=utf-8 \
  --output=hello3.pot hello3.vala
$ cp hello3.pot hello3-zh_TW.po
$ cp hello3.pot hello3-en_US.po

最後,我們將 POT 文件複製給翻譯人員,填入翻譯的本地訊息。將翻譯者譯好的 PO 文件收集起來之後,再用 msgfmt 包裝成 MO 文件使用。hello3-zh_TW.po 是翻譯好的區域訊息文件。眼尖的讀者會發覺我這份文件少了很多欄位。再次強調,只有 msgid/msgstr 是必要的主體,其他都是選擇性的註釋,可有可無,刪了也不會影響後續操作。

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#: hello3.vala:10
msgid "好你"
msgstr "你好"

#: hello3.vala:11
msgid ""
"行一第的息訊長。\n"
"行二第的息訊長。\n"
msgstr ""
"長訊息的第一行。\n"
"長訊息的第二行。\n"

下列為本小節的示範操作過程。


# 1.撰寫程式碼 hello3.vala

# 2. xgettext 擷取訊息
$ xgettext --language="C#" --keyword=_ --from-code=utf-8 \
  --output=hello3.pot hello3.vala

# 3. 散佈 POT 文件給其他人翻譯
$ cp hello3.pot hello3-zh_TW.po
$ cp hello3.pot hello3-en_US.po

# 4. 收集 PO 文件包裝成 MO 文件
$ msgfmt --output=locale/zh_TW/LC_MESSAGES/hello3.mo hello3-zh_TW.po
$ msgfmt --output=locale/en_US/LC_MESSAGES/hello3.mo hello3-en_US.po

$ valac -X -DGETTEXT_PACKAGE="hello3" hello3.vala

$ export LANG=en_US.utf8
$ ./hello3
hello3. 這一行訊息不需要翻譯
Hello gettext
First line of long message.
Second line of long message.

$ export LANG=zh_TW.utf8
$ ./hello3
hello3. 這一行訊息不需要翻譯
你好 gettext
長訊息的第一行。
長訊息的第二行。

$ unset LANG
$ ./hello3
hello3. 這一行訊息不需要翻譯
好你 gettext
行一第的息訊長。
行二第的息訊長。

實務上,我們會持續編寫程式碼與擷取訊息的工作,再使用 GNU gettext 工具 msgmerge 將 POT 文件新增的待翻譯項目併入已經翻譯好的 PO 文件中。不必每次擷取 POT 文件後都得重頭再翻譯一次。

參考文件與後記

基於歷史與傳統因素,I18N 的區碼並沒有採用嚴格的格式,同一個文化語系在不同的系統上,可能是用不同的區碼表示。這多少產生了程式人員在編寫跨平台的多語化應用軟體時的困擾。或是使用者會抱怨明明設定了環境變數,但軟體顯示的訊息還是沒變。這都是區碼表達格式不匹配的結果。請程序員秉持國際觀,以寬容的文化包容心態多了解區域性差異。善用 setlocale() 的回傳值或是環境變數 LANGUAGE 改進你的程式內容,提高環境的包容程度。

GNU gettext 在執行 setlocale() 時,採取完全相符的比對策略。但是執行 gettext() 則是從寬搜尋。例如你的系統區碼表中只有 en_US.utf8 的區碼,那麼執行 setlocale(TYPE, "en_US") 就會判定不匹配。但是執行 gettext(msgid) 時,則會依序從 $locale_dir/en_US.utf8/LC_MESSAGES$locale_dir/en_US/LC_MESSAGES$locale_dir/en/LC_MESSAGES 這些目錄中搜尋指定的 MO 文件,條件寬鬆許多。這幾點是初學者需要留意之處。

GNU gettext 包含了相當豐富的內容,在本文中並沒有詳細說明。請自行閱讀 GNU gettext manual。

相關文章
樂多舊網址: http://blog.roodo.com/rocksaying/archives/15171511.html

樂多舊回應
未留名 (#comment-21638751)
Sat, 05 Mar 2011 18:10:17 +0800
好文章,受益良多,謝謝