[Electron] 多言語対応する - i18n

WindowsmacOSも世界中で利用されていますし、日本国内にも外国語が母国語な方も大勢いらっしゃいます。そこで今回はElectronをユーザーの言語環境に合わせる国際化(i18n)を行ってみます。

Electron自体にはそのための専用の機能は用意されていないようなので、Node.jsで利用されているモジュールを利用するか、自分で用意する必要があります。今回は後者で行います。

基本的な原理

大雑把なイメージ

「多言語対応」と言うと何だか難しそうなイメージがありますが、要は以下のように連想配列から取り出してやれば良いだけですね。

// ユーザーの言語環境
const locale = 'ja'

// 翻訳用のテキスト
const trans = {
  ja: { greetings: 'こんにちは!' },
  en: { greetings: 'Hello!' },
  fr: { greetings: 'salut!' }
}

// 言語に対応するテキストを出力
console.log( trans[locale]['greetings'] )

これらに機能を追加したモジュールがNode.jsには色々と存在していますが、今回は自作することにしました。詳しくは後述します。

ユーザーの言語環境を取得する

何はともあれユーザーが普段使っている言語を取得する必要があります。 Electronには言語環境を取得する機能が用意されており、app.getLocale()をElectronの準備が整った段階で呼んでやります。

const {app} = require('electron')

app.whenReady().then(()=>{
  // 日本語環境なら "ja" が返る
  app.getLocale()
})

詳細については過去の記事を参照ください。 blog.katsubemakito.net

注意点としては「英語」のOSを使っている人が必ずしもアプリも「英語」で利用したいとは限らない点です。実装する際にはデフォルトはOSと同じ言語環境にしつつ、ユーザーが任意で変更できるのがベストですね。

国際化クラス

Node.jsで多言語対応(i18n)やローカライズ(l10n)をやろうとすると、i18ni18nextあたりに行き着くことが多いのですが、もっとシンプルな物で必要十分だったので、今回は必要最低限の物をスクラッチで書きました。

以下のクラスを利用していきます。コメント込みで80行程度のお手軽コードです。

/**
 * 翻訳クラス
 *
 */

const p = require('../package.json')

/**
 * i18nクラス
 *
 * @example
 *   const i18n = require('./i18n')
 *   const _ = new i18n('ja', 'page1')
 *   console.log( _.t('foo') );
 */
module.exports = class i18n{
  // 対応言語
  static SUPPORT_LANG = ['ja', 'en']

  // デフォルト言語
  static DEFAULT_LANG = 'ja'

  /**
   * コンストラクタ
   *
   * @param {string} lang 言語コード('ja', 'en'...)
   * @param {string} ns   ネームスペース
   * @param {string} dir  言語ファイルがあるディレクトリ
   */
  constructor(lang=null, ns='default', dir='./locales'){
    if( lang === null || ! this.isSupport(lang) ){
      lang = i18n.DEFAULT_LANG
    }

    this._lang = lang   // 言語
    this._ns   = ns     // ネームスペース
    this._dir  = dir    // 言語ファイルのディレクトリ
    this._dic  = null   // 翻訳データ入れ

    if( ! this.load() ){
      throw 'Can not load language file'
    }
  }

  /**
   * 言語ファイルを読み込む
   *
   * @return {boolean}
   */
  load(){
    try{
      this._dic = require(`${this._dir}/${this._lang}/${this._ns}.json`)
      return(true)
    }
    catch(e){
      return(false)
    }
  }

  /**
   * サポートしている言語かチェック
   *
   * @param {string} lang
   * @return {boolean}
   */
  isSupport(lang){
    return( i18n.SUPPORT_LANG.indexOf(lang) !== -1 )
  }

  /**
   * 指定言語のテキストを返却
   *
   * @param {string} key
   * @return {string|undefined}
   */
  t(key){
    if( key in this._dic ){
      let value = this._dic[key]
                          .replace('{{name}}', p.name);
      return( value )
    }
    return(undefined)
  }
}

利用方法

翻訳データを準備

手前味噌ですが非常に簡単に書けますw まずは以下のようなJSONファイルを所定の場所に保存します。

日本語 (locales/ja/default.json)

{
  "FOO": "フゥ~"
}

英語 (locales/en/default.json)

{
  "FOO": "Fooo"
}

独自クラスの利用方法

メインプロセス、またはレンダラーから以下のように利用します。

const {app} = require('electron')
const i18n = require('./i18n')

app.whenReady().then(()=>{
  const locale = app.getLocale();
  const _ = new i18n(locale);

  // "フゥ~" or "Fooo"
  console.log( _.t('FOO') );
})

Electronの多言語対応サンプル

先ほどのクラスの利用例です。

ユーザーの言語環境にあわせてメインプロセスではメニューやダイアログを、レンダラーでは画面上に表示する文字を変更するサンプルです。デフォルトではOSの言語環境を採用しますが、ユーザーが任意で変更することができます。

デモ


www.youtube.com

ソースコード

最終的なコード

GitHubにアップしました。掲載していないファイルやディレクトリ構造などはこちらを参考にしてください。 github.com

メインプロセス - index.js

メニューやダイアログなどの具体的な処理はモジュールとして分けました。

/**
 * 言語設定を行うサンプル
 *
 */

//------------------------------------
// モジュール
//------------------------------------
const { app, BrowserWindow, ipcMain } = require('electron')
const menu = require('./src/menu')
const config = require('./src/config')
const dialog = require('./src/dialog')

//------------------------------------
// グローバル変数
//------------------------------------
// ウィンドウ管理用
let mainWin;

/**
 * ウィンドウを作成する
 */
function createWindow () {
  // ウィンドウを新たに開く
  mainWin = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true
    }
  })

  // ファイルを開く
  mainWin.loadFile('public/index.html')
}

// 初期化が終了したらウィンドウを新規に作成する
app.whenReady().then(()=>{
  // 言語設定を取得する
  const locale = config.get('locale') || app.getLocale();

  // メニューを適用する
  menu.setTemplate(locale)

  // ウィンドウを開く
  createWindow()
})


//------------------------------------
// [app] イベント処理
//------------------------------------
// すべてのウィンドウが閉じられたときの処理
app.on('window-all-closed', () => {
  // macOS以外はアプリを終了する
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

// アプリがアクティブになった時の処理
// (macOSはDocのアイコンがクリックされたとき)
app.on('activate', () => {
  // ウィンドウがすべて閉じられている場合は新しく開く
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

//----------------------------------------
// IPC通信
//----------------------------------------
// 設定情報を取得
ipcMain.handle('getConfig', async (event, data) => {
  return( config.get(data) )
});

// 言語設定を保存
ipcMain.handle('setLocale', async (event, data) => {
  config.set('locale', data)
  dialog.reboot(mainWin)      // 再起動する?
});

メニュー設定 - src/menu.js

アプリ自体のメニューに関する設定を行っています。labelを指定するところをすべてi18nクラスに置き換えた感じですね。

const { app, Menu } = require('electron');
const i18n = require('./i18n')

// 実行環境がmacOSならtrue
const isMac = (process.platform === 'darwin');  // 'darwin' === macOS

/**
 * メニュー用テンプレートを返却
 *
 * @param {string} lang 言語コード
 * @return {object} Menu
 */
const setTemplate = (lang='ja') => {
  const _ = new i18n(lang, 'menu');
  const template = Menu.buildFromTemplate([
    ...(isMac ? [{
        label: app.name,
        submenu: [
          {role:'about',      label: _.t('ABOUT') },
          {type:'separator'},
          {role:'services',   label: _.t('SERVICE')},
          {type:'separator'},
          {role:'hide',       label: _.t('HIDE')},
          {role:'hideothers', label: _.t('HIDEOTHERS')},
          {role:'unhide',     label: _.t('UNHIDE')},
          {type:'separator'},
          {role:'quit',       label: _.t('QUIT-MAC')}
        ]
      }] : []),
    {
      label: _.t('FILE'),
      submenu: [
        isMac ? {role:'close', label:_.t('CLOSE')} : {role:'quit', label:_.t('QUIT')}
      ]
    },
    {
      label: _.t('EDIT'),
      submenu: [
        {role:'undo',  label:_.t('UNDO')},
        {role:'redo',  label:_.t('REDO')},
        {type:'separator'},
        {role:'cut',   label:_.t('CUT')},
        {role:'copy',  label:_.t('COPY')},
        {role:'paste', label:_.t('PASTE')},
        ...(isMac ? [
            {role:'pasteAndMatchStyle', label:_.t('PASTEANDMATCHSTYLE')},
            {role:'delete',    label:_.t('DELETE')},
            {role:'selectAll', label:_.t('SELECTALL')},
            {type:'separator'},
            {
              label:_.t('SPEECH'),
              submenu: [
                {role:'startSpeaking', label:_.t('STARTSPEAKING')},
                {role:'stopSpeaking',  label:_.t('STOPSPEAKING')}
              ]
            }
          ] : [
            {role:'delete',    label:_.t('DELETE')},
            {type:'separator'},
            {role:'selectAll', label:_.t('SELECTALL')}
          ])
       ]
    },
    {
      label: _.t('VIEW'),
      submenu: [
        {role:'reload',         label:_.t('RELOAD')},
        {role:'forceReload',    label:_.t('FORCERELOAD')},
        {role:'toggleDevTools', label:_.t('TOGGLEDEVTOOLS')},
        {type:'separator'},
        {role:'resetZoom',      label:_.t('RESETZOOM')},
        {role:'zoomIn',         label:_.t('ZOOMIN')},
        {role:'zoomOut',        label:_.t('ZOOMOUT')},
        {type:'separator'},
        {role:'togglefullscreen', label:_.t('TOGGLEFULLSCREEN')}
      ]
    },
    {
      label: _.t('WINDOW'),
      submenu: [
        {role:'minimize', label:_.t('MINIMIZE')},
        {role:'zoom',     label:_.t('ZOOM')},
        ...(isMac ? [
             {type:'separator'} ,
             {role:'front',  label:_.t('FRONT')},
             {type:'separator'},
             {role:'window', label:_.t('WINDOW')}
           ] : [
             {role:'close',  label:_.t('CLOSE')}
           ])
      ]
    },
    {
      label: _.t('HELP'),
      submenu: [
        {label: _.t('APPHELP')},    // ToDo
        ...(isMac ? [ ] : [
          {type:'separator'} ,
          {role:'about',  label: _.t('ABOUT') }
        ])
      ]
    }
  ])

  Menu.setApplicationMenu(template)
}

//--------------------------------
// exports
//--------------------------------
module.exports = {
  setTemplate: setTemplate
}

メニュー用翻訳データ - src/locales/ja/menu.json

英語版も同様の形式で用意しています。GitHubのコードをご覧ください。

{
  "ABOUT": "{{name}} について",
  "SERVICE": "サービス",
  "HIDE": "{{name}} を隠す",
  "HIDEOTHERS": "ほかを隠す",
  "UNHIDE": "すべて表示",
  "QUIT-MAC": "{{name}} を終了",

  "FILE": "ファイル",
  "CLOSE": "ウィンドウを閉じる",
  "QUIT": "終了",

  "EDIT": "編集",
  "UNDO": "元に戻す",
  "REDO": "やり直す",
  "CUT": "切り取り",
  "COPY": "コピー",
  "PASTE": "貼り付け",
  "PASTEANDMATCHSTYLE": "ペーストしてスタイルを合わせる",
  "DELETE": "削除",
  "SELECTALL": "すべてを選択",
  "SPEECH": "スピーチ",
  "STARTSPEAKING": "読み上げを開始",
  "STOPSPEAKING": "読み上げを停止",

  "VIEW": "表示",
  "RELOAD": "再読み込み",
  "FORCERELOAD": "強制的に再読み込み",
  "TOGGLEDEVTOOLS": "開発者ツールを表示",
  "RESETZOOM": "実際のサイズ",
  "ZOOMIN": "拡大",
  "ZOOMOUT": "縮小",
  "TOGGLEFULLSCREEN": "フルスクリーン",

  "WINDOW": "ウィンドウ",
  "MINIMIZE": "最小化",
  "ZOOM": "ズーム",
  "FRONT": "ウィンドウを手前に表示",

  "HELP": "ヘルプ",
  "APPHELP": "{{name}} ヘルプ"
}

レンダラープロセス - public/index.html

レンダラーからも同様にi18nクラスを利用できます。レンダラーでもJavaScriptでユーザーの言語環境を取得することはできますが、今回はユーザーが任意で言語を変更できることからメインプロセスからIPC通信経由で取得しています。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
  <title>Electron Smaple - i18n</title>
  <style>
    body{ background-color:white; }
  </style>
</head>
<body>

<h1></h1>

<form>
  <button type="button" id="btn-ja" data-lang="ja"></button>
  <button type="button" id="btn-en" data-lang="en"></button>
</form>

<script>
  const {ipcRenderer} = require('electron');
  const i18n = require('../src/i18n');

  // メインプロセスから言語環境を取得し、ページに必要なテキストを表示
  (async ()=>{
    const locale = await ipcRenderer.invoke('getConfig', 'locale')
    const _ = new i18n(locale);

    document.querySelector("h1").textContent = _.t('TITLE')
    document.querySelector("#btn-ja").textContent = _.t('BUTTON-JA')
    document.querySelector("#btn-en").textContent = _.t('BUTTON-EN')
  })()

  // ボタンクリック時の動作
  document.querySelectorAll("button").forEach( (elem) =>{
    elem.addEventListener("click", ()=>{
      const lang = elem.getAttribute("data-lang");
      ipcRenderer.invoke('setLocale', lang)
    })
  })
</script>
</body>
</html>

参考ページ