C#でgettextを使う

ソフトウェアを作る時、日本語だけでOKというケースは多くはないでしょう。現代ではUnicodeが普及するなど、国際化(i18n)の基本部分は整備されてきましたが、文字列リテラルをソースに埋め込んだ状態だと、各国語対応(l10n)の手間が膨大になってしまいます。

真面目に考えていくと、下のリンク先のように色々と大変です。

qiita.com

ただしここでは、メッセージ翻訳に絞って考えてみます。

C#は、マイクロソフトが開発したものなので、ソフトの翻訳もVisual Studioの機能が基本になることが多いのですが、より広い範囲で使われているのがgettextです(日本語で検索するとWordPressのテーマで使う事例が多い印象ですね)。Visual Studioなど見たこともないような人に翻訳してもらう場合などは、こういった方式をベースにしておくと楽になるんじゃないかと思います。

gettextでは、文字列リテラルをキーにして関数を呼び出します。辞書がない場合は呼び出したリテラルがそのまま使われます。

ここでは、C#プロジェクトでgettextを使うための一連の流れを紹介します。

gettextを使う手順

gettextを使ったアプリケーション開発の流れは、一般論としては次のようなものになります。

  1. ソースコード作成に際して、gettextを使う準備をしておく。
  2. ソースコードを作成する。この時、翻訳が必要なメッセージは、何らかの書式で記載すること。なお、この時点でのメッセージは英語が望ましい(後述)。
  3. 翻訳が必要なメッセージをツールで抽出し、翻訳テンプレートファイル(POTファイル)を生成(更新)する。
  4. POTファイルをもとに、翻訳ファイル(POファイル)を作成(更新)する。
  5. 翻訳者は、POファイルを翻訳する。
  6. 翻訳されたPOファイルから、機械処理向けのMOファイルを作成する。
  7. MOファイルを適切な場所に配置する。
  8. アプリケーションがMOファイルを読み込み、アプリケーション利用者の環境に応じた言語でメッセージを表示する。

この前提をスムーズに進めるために、次の準備を前提としたいと思います。

準備1 : gettextライブラリ

gettextは、以前からC#に対応しています。しかし最も普及しているGNUの実装では、辞書を使うためにリソースを作成するなど少しややこしいので、ここではシンプルに使えるNGettextを使うことにします。

github.com

NGettextのメリットは、上記Githubページに記載があります。

  • クロスプラットフォームで、サードパーティのライブラリは一切不要。
  • 複数ドメインに対応。モジュールやプラグインごとに別の翻訳ファイルを適用可能。
  • アプリケーション内で複数のロケールを扱うことが可能。切り替えも簡単。
  • moファイルをそのまま使用可能。またファイルやストリームから翻訳データを読み込むことも可能。
  • 「コンテキスト」にも対応
  • シンプルなAPIで、コンソールやGUIなどどんなアプリケーションにも適用可能。

Visual Studioでも、nuget管理画面で「NGettext」と検索すれば簡単に見つかります。

f:id:mokake:20170921094721p:plain

準備2 : 翻訳管理ツール

GNUのgettextをコマンドラインで使うこともできますが、翻訳が必要なメッセージの抽出元のファイル(ソースファイル)を全て個別に指定しなければならないなど煩雑なので、ツールを使うと便利です。

ここでは翻訳ツールpoeditを使うことにします。poeditは翻訳者向けツールではありますが、POT/PO/MOファイルの生成、管理もできるので、開発者にとっても便利です。Windows, macOS, Unix/Linuxに対応し、基本機能だけなら無償です。

poedit.net

また、gettextは翻訳データのキーが文字列リテラルであり、同時にフォールバック先です。このため、キーは英語にしておくのが適切です。つまり、日本語アプリケーションにする時点で既に翻訳が必要となります。翻訳ツールを用意しておく価値は十分あるといえます。

ちなみに、poeditのライセンスには、一部機能がサードパーティのサービスを利用するとあります。基本機能の範囲で思い浮かぶのは、翻訳メモリ関連でしょうか。デフォルトでは外部接続ありになっているので。残念ながら、検索してもこの部分の詳細は分かりませんでした。作者を信用するか、コードを自分で調べるか、他の方法を使うかは各自での判断をお願いします。

この記事では、バージョン2.0.3を使ってみました。

poeditを使わずに管理する場合

Linuxなら、gettextはパッケージで簡単に導入できます。一方、Windowsの場合、GNUのgettextにバイナリへのリンクがあるので、そこからダウンロードして実行できます。

翻訳対象メッセージの抽出元ファイルは、LinuxmacOSならfindコマンドで抽出できます。下記は、PHPで、一気にメッセージ抽出(xgettextコマンド)まで行っていますが、同様の方法でC#のソースも処理できるでしょう。

stackoverflow.com

Windowsの場合でも、次のようにして抽出元ファイルのパス一覧をファイルに入れることができます。これを抽出ツール(xgettext)の-fオプションに指定すればいいでしょう。ただ、このコマンドは(ソースファイルの変化にあわせて)適宜実行しなおす必要があります。

' Visual Studioのプロジェクトフォルダの直下に'po'フォルダを作り実行する前提。
dir ..\*.cs /s /b | find /V "\obj\" > sources.txt
' find /Vは「指定文字列の除外」。途中生成ファイルの混入を避けるために「\obj\」フォルダを省く。
' なお、findstrコマンドは、パイプと/Vオプションを併用するとうまくいかなかった。

準備3 : ソース側

gettextは、アプリケーションのソース側では、多くの場合、_T("英語リテラル")という形式で使います。C言語などだとグローバル関数で処理すれば済む話ですが、C#だと各ソースファイルでメソッドを定義しないと、こういう記載はできません。

NGettextのexampleには、サンプルとしてpublic static class Tを定義して、そこにメソッド(_(string) など)を作る方法を提案しています。この場合、(必要に応じて参照も追加すれば)次のように翻訳メッセージを利用できます。

string text = T._("File");

クラスやメソッドの名前は変えてもいいとは思います(アンダースコア打ちづらいし)が、とにかく先に決めておいた方がベターです。

サンプルプログラムの作成/コードの準備

準備ができたので、実際にプログラムを作ってみます。

サンプルはEto.FormsのHello, World!ベースとします。もっとも、GUIもコードで生成しますから、コンソールでも似たようなものです。

まず、Eto.Formsアプリケーションを「Code」で作成します。ソリューション名は「GettextTest」としました。

次に、nuget設定で必要なプロジェクトにNGettextを追加しました。

そして、翻訳メッセージが使えるように、NGettextのexampleのようなstaticなクラスを作成します。

あとはフォームのコードを翻訳対応にします。ついでに、中身がないのでフォームの高さも減らしておきます。

using System;
using Eto.Forms;
using Eto.Drawing;

namespace GettextTest
{
    public class MainForm : Form
    {
        public MainForm()
        {
            Title = T._("NGettext Test");
            ClientSize = new Size(400, 200); // shorten height

            Content = new StackLayout
            {
                Padding = 10,
                Items =
                {
                    T._("Hello World!"),
                }
            };

            var clickMe = new Command { 
                MenuText = T._p("Menu|File|", "Click Me!"), 
                ToolBarText = T._p("Tool|", "Click Me!") 
            };
            clickMe.Executed += (sender, e) => MessageBox.Show(this, T._("I was clicked!"));

            var editCommand = new Command { MenuText = T._("Edit"), ToolBarText = T._p("Tool|", "Edit") };
            editCommand.Executed += (sender, e) => MessageBox.Show(this, T._("I was edited!"));

            // TRANSLATORS: Form's pull-down menu item
            var quitCommand = new Command { MenuText = T._p("Menu|File|","Quit"), 
                Shortcut = Application.Instance.CommonModifier | Keys.Q };
            quitCommand.Executed += (sender, e) => Application.Instance.Quit();

            var aboutCommand = new Command { MenuText = T._p("Menu|Help|","About...") };
            aboutCommand.Executed += (sender, e) => MessageBox.Show(this, T._("About my app..."));

            Menu = new MenuBar
            {
                Items =
                {
                    new ButtonMenuItem { Text = T._p("Menu|","&File"), Items = { editCommand } },
                    new ButtonMenuItem { Text = T._("Edit"), Items = { clickMe } },
                },
                ApplicationItems =
                {
                    new ButtonMenuItem { Text = "&Preferences..." },
                },
                QuitItem = quitCommand,
                AboutItem = aboutCommand
            };

            ToolBar = new ToolBar { Items = { clickMe } };
        }
    }
}

この例では、単なる置き換え(「_(string)」メソッド)以外に「_p(string,string)」メソッドも使っています。これはコンテキスト指定で、(この場合)第1引数にこの語が出てくる文脈(コンテキスト)を、第2引数に英語リテラルを、それぞれ入れています。翻訳側はコンテキストを見ながら訳すことができますし、同じ語句が異なる場面で出てきても区別できます。

この状態でも実行はできます。メッセージは全て英語のままですが。

補足:コメントについて

他の人に翻訳を依頼する可能性がある場合、コメントをつけておくと有効な場合があります。gettextでは、コメントは「キー文字列の前の行に言語ごとのコメント(とタグ)がある場合、その行をキー文字列へのコメントと扱う」ので、適宜入れておくと便利です。

プログラム言語のコメント(C#なら普通は//)が全て抽出されると不都合な場合もあるので、タグを指定するといいでしょう。タグは私の見る限りでは「コメント開始文字列の後の最初の非空白文字列」という扱いのようです。

例えば定番タグTRANSLATORS:を指定した場合。

// TRANSLATORS: this 'like' is facebook's.
var text = T._("Show like");

とすると、「Show like」のところにコメントとして「this ‘like’ is facebook’s.」が入ります。

コメントタグの指定方法ですが、xgettextコマンドの場合はコマンドオプションで-c=TRANSLATORS:と指定し、poeditの場合はカタログ設定の「高度な抽出設定」で設定できます。

f:id:mokake:20170921173945p:plain

サンプルプログラムの作成/翻訳テンプレート(POT)の準備

GNU gettextのxgettextコマンドやpoeditで、ソースから翻訳テンプレートを生成します。

poeditの場合、新規カタログを英語あたりで作り、メニューのカタログ→設定を選び、「ソースの検索パス」でソースにあるフォルダを指定します。中のサブフォルダは自動的に抽出対象に含まれるようなので問題ありません。その後、更新するとソースから翻訳対象メッセージが抽出されるので、拡張子potで保存します。

なお、ソースの読み込みでエラーが出る場合、文字コードの問題が考えられます。xgettextコマンドなら--from-code=UTF-8のようにオプションで指定します。poeditの場合は、カタログの設定の「ソースコードの文字符号化法」という項目で選べますが、デフォルトは空欄でした。ここを実際にあわせてみてください。

f:id:mokake:20170921174411p:plain

そして、xgettextコマンドでもpoeditでも大切なのが、キーワード指定(xgettextなら-k、poeditならカタログの設定にある「ソース中のキーワード」)です。

NGettextのGithubページ最下部にある、次の設定が基本となります。

GetString;GetPluralString:1,2;GetParticularString:1c,2;GetParticularPluralString:1c,2,3;_;_n:1,2;_p:1c,2;_pn:1c,2,3

xgettextだと、キーワード指定部分は次のような感じです。ただしここでは正式メソッド名(GetStringなど)は割愛し、省略形のみ示します。またメソッド名を変えた場合は、ここもあわせる必要があります(開きカッコの直前までをキーワードとすればOKです)。

xgettext -k"_" -k"_n:1,2" -k"_p:1c,2" -k"_pn:1c,2,3"

poeditの場合は、「ソース中のキーワード」に、xgettextの-kオプション引数( _ _n:1,2 _p:1c,2 _pn:1c,2,3)を全て記載すればOKです。

f:id:mokake:20170921172805p:plain

メソッド名が他のクラスと被るようなら、クラス名を含めて T._ T._n などとすればいいでしょう。

翻訳ファイル(POファイル)の作成

GNU gettextならmsginitコマンド(初回)やmsgmergeコマンド(2回目以降)、poeditなら新しいカタログを翻訳先言語にあわせて作成してPOTファイルを読み込んで、翻訳ファイル(POファイル)を作成します。最初の翻訳は自ら行う日本語へのものとなる場合が多いでしょう。

POファイルの名前は、私の見る例だと、ロケール名+「.po」が多いようです。日本語なら「ja.po」(日本語(日本)なら「ja_JP.po」)、簡体字中国語なら「zh_CN.po」でしょうか。

翻訳作業そのものは割愛します。

機械処理向けのMOファイルの作成

GNU gettextならmsgfmtコマンド、poeditならデフォルトでは自動でMOファイルを生成します。通常、MOファイルのファイル名はドメイン(例えばアプリケーション名)に拡張子「.mo」となるようです。今回のサンプルなら「GettextTest.mo」ですね。

これだと言語間で被ってしまいますが、出力結果は所定のフォルダに配置されるので、衝突はしません。

NGettextの場合、GithubページのサンプルではICatalog catalog = new Catalog("Example", "./locale");となっており、実行ファイルのあるフォルダを基準として ./locale/<CurrentUICulture>/LC_MESSAGES/Example.mo という配置が想定されています。「LC_MESSAGES」はリテラルなので、そのまま従う必要があるでしょう。

今回の例では、実行ファイルの下に「locale」「ja」「LC_MESSAGES」と入れ子のフォルダを作っていき、その下に「GettextTest.mo」を配置しました。

.
└locale
 └ja
  └LC_MESSAGES
   └GettextTest.mo

アプリケーションを実行する

正しくMOファイルが配置されていれば、この段階でメッセージが日本語化されたアプリケーションが起動します。

うまくいかない場合は、NGettextのページの「Debugging」を参考に、トレースリスナを登録します。

先ほどのサンプルプログラムなら、クラスTの中に、次のようなstaticコンストラクタを入れればOKです。ついでにカタログ生成部分もチェックしてみましょう。

public static class T
{
    private static ICatalog _catalog;
    //private static ICatalog _catalog = new Catalog(
    // "GettextTest", 
    // "./locale");

    static T()
    {
        var culture = System.Threading.Thread.CurrentThread.CurrentUICulture;
        System.Diagnostics.Debug.WriteLine($"CurrentCulture={culture.ToString()}");
        _catalog = new Catalog("GettextTest", "./locale", culture);
        System.Diagnostics.Trace.Listeners.Add(
            new System.Diagnostics.TextWriterTraceListener(Console.Out));
    }
// (略)
}

Debugモードで動かせば、MOファイルが読めているかなど、色々確認できます。

言語を変えてみる

せっかくですから、言語を変えてみたいところです。

ただ、NGettextのロケール設定は、初期設定でThread.CurrentThread.CurrentUICultureWindowsでこれを変えるには、MUI版(Windows7だとEnterpriseなど限定でしたが、Windows8以降はMUIが普通になりました)で言語パックを入れる必要があります。

そこで、Catalogのコンストラクタの第3引数に、Thread.CurrentThread.CurrentCulture(「UI」がない方)を入れます。こちらは「地域と言語」の、「形式」(日時などの表示形式)で指定するロケールが反映されるので、比較的簡単に変更ができます。

日本語状態で動かした場合。

f:id:mokake:20170921154236p:plain

ここで表示形式を英語(米国)にしてみます。

f:id:mokake:20170921161005p:plain

再度、アプリケーションを起動。

f:id:mokake:20170921154459p:plain

無事、メッセージが英語になりました。

ちなみにLinux環境では、LANG環境変数により決まります。日本語前提で普通にインストールすると LANG=ja_JP.UTF-8 といった感じになりますが、この場合、今回のサンプルも日本語で表示します。

LANG=en_US.UTF-8

といった感じで英語にすれば(この場合は「日本語以外にすれば」ですが)、サンプルも英語になります。

あとはソースへのメッセージ登録、POT更新、PO更新&翻訳、MO更新&配置、とすれば各国語メッセージに対応したアプリケーションができます。

ちなみにEto.FormsだとStackLayoutが便利ですが、これを使うと自然にGUI要素が半自動で配置されるため、メッセージ長の変動には比較的強いと思います。