使用 gettext 方案製做多國語系程式 - 程式編寫

首先,若應用程式的程式碼本身不支援任何一種多語系工具的設計的話,那麼後面就什麼都沒得玩了! 這樣的程式肯定是無法支援不同語言的切換顯示行為。 因此對於一個想要支援多語系的專案而言,需要進行的第一步就是修改專案程式碼, 使其可以支援所使用的多語系工具進行後續處理、翻譯、和發佈等工作。

本篇將針對程式開發人員編寫程式碼的部份, 以一個範例來描述如何修改程式碼以支援 gettext 多語系方案的工作。

程式碼中使用 gettext 大概分為兩個部份,分別是: 在程式碼中使用 gettext 的功能來標記需要翻譯的字串、以及載入所需要的語系檔案資料, 以下將分別進行描述。

程式碼中使用 gettext 提供的功能

程式碼裡面所有需要被翻譯的文字都要特別標記起來,這樣後續工具才能知道哪些地方需要翻譯處理。 標記的方式是將需要翻譯的字串放進「gettext( )」裡面,例如將:

const char *message = "This is a simple message.";

改成:

const char *message = gettext("This is a simple message.");

gettext 有兩個作用,在後面使用翻譯工具解析檔案時,會以該關鍵字抓出所有需要翻譯的字串。 而在程式執行時期,gettext 其實就是個函式(需要引用 libintl.h), 它會以傳入的字串做為 ID 來尋找並傳回對應的翻譯字串; 若找不到對應的字串、或根本語系資源檔就沒有成功載入,則便會將輸入的字串直接傳回, 也就是形同沒有翻譯的意思。

使用原始字串作為 ID 的好處是, 程式開發人員只管標記需要翻譯的字串,不需再為每一個字串想一個 ID; 但也由於原始字串會被程式庫當作 ID 使用, 因此建議原始字串使用比較單純、沒有什麼編碼爭議的語言,通常會建議使用英文。

有鑑於 gettext 這個關鍵字本身可能單字長度太長,會太影響程式碼版面, 有時人們會在程式碼裡定義一個更簡單簡短的別名,最常見的大概就是一個底線符號「_」了, 例如:

#define _(str) gettext(str)

......

const char *message = _("This is a simple message.");

當然若這麼做的話,後面在使用解析程式時就要記得通知它需要一併搜尋我們所定義的符號, 這個問題到後面相關部份再描述。

除此之外,有時候程式碼裡面可能會有一些只光看字串難以理解其意義, 讓翻譯人員不知該如何翻譯的字串, 這時就需要由開發人員寫上一些合適的解釋註解,讓後面翻譯人員可以參考閱讀。 問題是一般的註解通常都會被任何工具直接忽略,那該怎麼辦呢? 這裡就要介紹 gettext 工具有一個特別的設計,他在掃描解析程式碼的時候, 會把那些以約定的符號做為開頭的註解一併掃描進來使用。 這樣程式人員在寫出可能會讓翻譯人員糊塗的字串時,就可以以特別的符號做為開頭, 寫出可供翻譯人員參考的註解說明,例如:

//+ This is a special comment that will be output to the PO file.
printf(gettext("I will go to %s by %s.\n"), _("Taipei"), _("bus"));

在這個範例裡,我們約定以加號「+」起頭的註解是需要被掃描的註解, 然而實際上這可以是任何符號、也可以是多個字元, 重點是與開發人員約定好要使用什麼符號做為註解開頭即可, 後面在使用 gettext 掃描工具時, 再透過選項來告知掃描工具我們所使用的特殊註解開頭符號是什麼就可以了。

載入語系資源

當然,我們必須要先載入正確的語系資源檔案,後面也才有資料能夠讓 gettext 函式來搜尋取用, 這部份的工作通常會被併入應用程式初始化工作的一部份。 設定以及載入語系資源檔案主要由先後呼叫幾個函式完成: setlocal、putenv、bindtextdomain、和 textdomain。

#include <locale.h>
setlocale(LC_ALL, "");

首先使用 setlocale 設定程式要使用的 locale。 函式需要輸入所要使用的 locale 名稱,理論上這個名稱的格式是由作業系統決定的, 但因為 gettext 大多使用在 Linux 上,因此我們也習慣使用 LL_CC 的格式, 例如要設定使用臺灣中文的話就需要輸入 zh_TW。 除了指名使用的 locale 名稱外,一般我們會直接輸入空字串,直接套用系統當前的語系設定; 而這就是為什麼 Linux 下的軟體大多不用特別設定使用的語言, 軟體就會自動依據使用者的語系設定顯示該語系文字的原因。

char langenv[64];
snprintf(langenv, sizeof(langenv), "LANGUAGE=%s", setlocale(LC_ALL, ""));
putenv(langenv);

此外, 在有些版本的 Linux 平臺上,可能會若遭遇無法成功載入正確的語系檔, 這時也許就需要一併設定 LANGUAGE 環境變數。

#include <libintl.h>
bindtextdomain("gettext-demo", "/usr/share/locale");

再來是使用 bintextdomain 設定語系資源檔所在位置、以及 domain name。 先說 domain,各程式可能都會有各自的翻譯,為了將它們良好的區隔,以致不會互相混淆覆蓋, 我們需要為應用程式的語系資源檔取一個 domain name, 對於絕大部分的情況下,建議直接使用應用程式的名稱就可以了。

此外我們還要告訴程式庫我們的語系資源檔案所存放的位置。 對於設計在 Linux 上面運作的程式來說,在絕大部分的情況下直接把這個目錄設為 /usr/share/locale 即可; 對於其他情況,我們就需要計算並設定一個我們實際放置語系資源檔的位置, 例如後面的完整範例裡,我就設定使用了當前工作目錄下的 locale 資料匣, 而在發佈於 Windows 平臺上的程式,我習慣把語系檔案放在該程式所在目錄下的 locale 資料匣。

#include <libintl.h>
textdomain("gettext-demo");

最後我們使用 textdomain 完成語系資源檔案的載入。 textdomain 函式需要傳入所要使用的 domain name, 也就是之前我們呼叫 bindtextdomain 函式時所傳入的 domain name。

然後程式庫會依據我們前前後後所設定的各種資料來計算出要載入的語系檔案, 最終被載入的語系檔案為:

<RESOURCE-DIR>/<LOCALE>/<CATEGORY>/<DOMAIN>.mo

其中:

  • <RESOURCE-DIR> 是我們呼叫 bindtextdomain 時所設定的路徑;

  • <LOCALE> 是我們呼叫 setlocale 時所設定,或由系統取得的 locale name;

  • <CATEGORY> 一般就是 LC_MESSAGES,作者我孤陋寡聞,還沒看過有其他的情況;

  • <DOMAIN> 就是我們呼叫 bindtextdomain 時所設定的 domain name。

以我們前面的範例來說,最終它載入的語系資源檔案可能就是:

/usr/share/locale/zh_TW/LC_MESSAGES/gettext-demo.mo

編譯與執行

編譯這個範例程式碼很簡單,只需把後面的完整範例程式碼存檔為 gettext-demo.c, 然後執行下列指令:

gcc -o gettext-demo gettext-demo.c

就如同編譯一般的程式一樣,如同從前所述,gettext 相關程式庫已被併入 Glibc, 所以不需要再額外做什麼。 那麼如何執行呢?只要這樣即可:

./gettext-demo

當然,現階段不管怎麼執行它跑出來的都會是英文, 那是因為我們還沒有進行翻譯的工作、也還沒有把語系檔放在正確的位置, 這個部份在下篇會做解釋。

當我們完成了翻譯檔之後,若要測試成果的話, 除了更改使用者語系然後重新登出登入再執行程式外, 其實還可以透過程式環境變數簡單的改變程式所使用的語系, 例如使用下列方法強制程式在 en_GB 語系下運作:

LANUGAGE=en_GB ./gettext-demo

完整範例程式碼

#ifdef NDEBUG
#   undef NDEBUG        // To force enable assert.
#endif

#include <assert.h>     // To use assert, For test use.
#include <locale.h>     // To use setlocale.
#include <stdio.h>      // To use printf.
#include <stdlib.h>     // To use putenv.
#include <unistd.h>     // To use getcwd, for test use.
#include <libintl.h>    // To use bindtextdomain, textdomain, and gettext.

#define _(str) gettext(str) // Tips: Use this define to short the "gettext" statement.

int main(int argc, char *argv[])
{
    printf("---- setting environments begin ----\n");

    /*
     * First, calculate the locale resource path.
     * This is designed for "testing in any path by any user" purpose
     * and may not be needed for the real application.
     * Normal applications usually use the absolute path, for example:
     * /usr/share/locale
     */

    char cwd[FILENAME_MAX];
    assert(getcwd(cwd, sizeof(cwd)));
    printf("CWD: %s\n", cwd);

    char locale_dir[FILENAME_MAX];
    snprintf(locale_dir, sizeof(locale_dir), "%s/%s", cwd, "locale");
    printf("Locale DIR: %s\n", locale_dir);

    // Setting locale.
    const char *locale_name = setlocale(LC_ALL, "");    // Using system locale setting.
    printf("Set locale: %s\n", locale_name);
    assert(locale_name);

    // Setting language environment variable.
    char langenv[64];
    snprintf(langenv, sizeof(langenv), "LANGUAGE=%s", locale_name);
    putenv(langenv);

    // Define the domain name.
    // It is recommended to use the application name.
    static const char *domain = "gettext-demo";

    // Locate the path of locale resource files.
    const char *binded_dir = bindtextdomain(domain, locale_dir);
    printf("Bind DIR: %s\n", binded_dir);
    assert(binded_dir);

    // Load the locale resource file.
    // Which file will be loaded is:
    // <LOCALE-DIR>/<LANGUAGE>[_<COUNTRY>]/<CATEGORY>/<DOMAIN>.mo
    // for example:
    // /usr/share/locale/zh_TW/LC_MESSAGES/vlc.mo
    const char *curr_domain = textdomain(domain);
    printf("Load text: %s\n", curr_domain); // The domain can be NULL to
                                            // use the previous setting.
    assert(curr_domain);

    printf("---- setting environments end ----\n");
    printf("\n");

    /*
     * Now start using gettext functions.
     * Every strings which be embraced by "gettext"
     * will be marked then try to translated.
     */

    const char *message = gettext("This is a simple message.");
    printf("%s\n", message);

    //+ This is a special comment that will be output to the PO file.
    printf(gettext("I will go to %s by %s.\n"), _("Taipei"), _("bus"));

    return 0;
}