Electronアプリケーションの翻訳(l10n)
Electronアプリケーションはクロスプラットフォームなのですから、可能なら世界各地で使えるようにしたいものです。そこで必要となるのが国際化(internationalization, i18n)と各言語対応(localization, l10n)。
文言からはじまってRTL(アラビア語など右から左へ書く言語への対応)、数字や日時の表記など色々とありますが、とりあえずここではアプリケーション上のメッセージの翻訳についてだけ考えます。
i18nやl10nについて考える際には、次の記事を予め読んでおくと、「後で困らない設計」が採れるのでいいと思います。
また、Electronのl10nについて検索すると、だいたい出てくるのは i18next と l10n.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)でもロケールは文字列として得られます。なお、ロケールのデフォルト値(空文字列)は、フォールバック先言語( en
、 en-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でやり取り、という方法もありますが、そこまでする必要は感じませんでした)。