Electronアプリケーションの翻訳(l10n)

Electronアプリケーションはクロスプラットフォームなのですから、可能なら世界各地で使えるようにしたいものです。そこで必要となるのが国際化(internationalization, i18n)と各言語対応(localization, l10n)。

文言からはじまってRTL(アラビア語など右から左へ書く言語への対応)、数字や日時の表記など色々とありますが、とりあえずここではアプリケーション上のメッセージの翻訳についてだけ考えます。

i18nl10nについて考える際には、次の記事を予め読んでおくと、「後で困らない設計」が採れるのでいいと思います。

qiita.com

また、Electronのl10nについて検索すると、だいたい出てくるのは i18nextl10n.js でしょうか。

これらも検討したものの、自分のニーズとは少し違うのと、それほどメッセージ規模が大きくないことから、もっとシンプルにTypeScriptでデータとして入れてしまうことにしました(データそのものはexcelで管理してCSVあたりから適当なスクリプトでTypeScriptコードの該当部分を出力するつもりですが、まだ途中段階なので試行錯誤の分も手作業してます)。

自分が採用した方針は次のようなものです。

  • gettextのように、フォールバック言語での表現を与えて翻訳先表現を取得する形とする。
  • 同一の文言が場面によって異なる意味をもつ可能性があるので、翻訳先表現を取得する際にはフォールバック言語表現に加えてコンテキスト(文脈)を指定する文字列(自分で決められる)を与える。
  • 文中には変動要素は入れない(変動要素は、性数などの問題を回避するため、「○○の数:XXX」のようなコロン表現を採用)。
  • ソース上はリテラルを英語(フォールバック言語)で書いておく。
  • コード部分(TypeScriptレベル)では、辞書クラスのstaticメソッドで翻訳先表現を取得する。
  • HTML上のリテラルも英語(フォールバック言語)で書き、コード側から一括で翻訳先表現に置き換える(※別記事として記載予定)。

これをざっくりなコードで書いてみます。

まずは、翻訳クラスTをstaticインスタンス化。

export class T {

  private static _instance : T | null = null;
  private constructor() {
    this.initDict();
  }
  static getInstance() : T {
    if (T._instance === null) { T._instance = new T(); }
    return T._instance;
  }

getInstance() は内部しか使いません。

次に、データ(プロパティ)を用意してみます。インスタンスプロパティです。

  _locale = "";
  _dicts : {[keyLocale:string]: {[keyCategory:string]: {[keyBase:string]: string}}} = {};

_localeロケール(言語と、必要なら地域などのサブカテゴリを含んだ情報)です。Electronの場合、mainプロセス(Node.js)でも、rendererプロセス(chromium)でもロケールは文字列として得られます。なお、ロケールのデフォルト値(空文字列)は、フォールバック先言語( enen-US )か、辞書をもたないロケールを指定された場合に該当します。ちなみに、Electronで得られうるロケールロケールに関する公式ドキュメント にありますが、日本語は js だけで ja-JP はないようですね。

次の _dicts が、翻訳辞書の全体です。ロケール、カテゴリ、フォールバック先言語表現という3段階の先に翻訳先言語表現を格納する連想配列です。

_dicts は、(簡単にするため)コンストラクタから呼ばれるメソッドで代入してしまいます。ロケール指定がされた場合だけ当該ロケール分だけが設定されるようにすると効率的になると思います。

  private initDict() {
    this._dicts["ja"] = {
      "General" : {
        "OK" : "OK",
        "Cancel" : "キャンセル",
        "Error" : "エラー",
        "Warning" : "警告",
      },
    // 以下、辞書データ
    };
  }

では、翻訳先言語表現を取得するstaticメソッドを書いてみます。

  static t(basePhrase:string, context:string) : string {
    // フォールバックするか?
    if (T.getInstance()._locale === "") return basePhrase;

    // 指定されたコンテキストはあるか?
    const phrases = T.getInstance()._dict[context];
    if (phrases === undefined) { return basePhrase; }

    // 指定された表現はあるか?
    const phrase = phrases[basePhrase];
    if (phrase === undefined) { return basePhrase; }

    // 全部ある場合は辞書の翻訳先表現を返す。
    return phrase;
  }

これで、T.t("Cancel","General") みたいにすると、「キャンセル」という日本語が戻ってきます。

ただし、そのためにはロケールの指定が必要です。これもstaticメソッドとして書いてみます。

  static setLocale(locale:string) : boolean {
    const d = T.getInstance()._dicts[locale];
    if (d !== undefined) {
      T.getInstance()._locale = locale;
      return true;
    }

    // 例えば"pt-PT"(ポルトガルのポルトガル語)が見つからない場合、包括ロケールの"pt"(ポルトガル語)をチェック
    const indexHyphen = locale.indexOf("-");
    if (indexHyphen <= 0) return false; // サブカテゴリがなかったら諦める
    const lang = locale.substr(0,indexHyphen);
    const d2 = T.getInstance()._dicts[lang];
    if (d2 !== undefined) {
      T.getInstance()._locale = lang;
      T.getInstance()._dict = d2;
      return true;
    }

    // ロケールが辞書中で見つからなかった場合
    return false;
  }
}

こんな感じでしょうか。

これをrendererプロセスから使う場合、最初に T.setLocale(navigator.language) といった感じでロケールを設定しておく必要があります。

なお、もし翻訳辞書をrendererプロセスだけではなくmainプロセスでも用いる場合(アプリケーションメニューなどを考えると、使うことが多いでしょう)、たとえ今回示したシングルトンのコードでも、インスタンスの実体はmain側とrenderer側で異なります。このため、ロケールもmainとrendererそれぞれで設定する必要があります(main側でOSデフォルトのロケールを設定する場合は T.setLocale(app.getLocale()) )。

個人的には、この程度の、しかも読み出し専用のデータなら重複しても構わないかなと思っています(辞書データを丸ごとIPCでやり取りするか、表現の取得ごとにIPCでやり取り、という方法もありますが、そこまでする必要は感じませんでした)。