脳みそスワップアウト

揮発性なもので。おもにPHPのこととか。

PHP で GNU gettext

GNU gettext は、ソフトウェアの国際化のためのライブラリ・コマンド群。

大まかな流れとしては以下のようになる

  1. ソース中から多言語対応すべき文字列を抽出 (POT作成)
    gettext コマンド。
  2. それをもとに各言語用に対訳ファイルを作成 (PO作成)
    msginit コマンド。
  3. 対訳ファイルをバイナリ化 (MO作成)
    msgfmt コマンド。

関連用語

msgid

メッセージID。多言語対応すべき文字列のこと。
プログラムソース中に書かれることになる。

msgstr

msgid に対する翻訳。

ファイル

POT (Portable Object Template)

ソースから抽出された、多言語対応すべき文字列(msgid)のリスト。

PO (Portable Object)

POT を元に、対訳(msgstr)が書かれたファイル。各言語分存在。
SCMの管理対象とすべきもの。

MO (Machine Object)

PO をコンパイルしてバイナリ化したもの。
実際に利用されるランタイムはこのファイルのみ。
バイナリファイルなので、SCMの管理対象とはせず、デプロイ時にPOから生成するのがよい。

コマンド

xgettext

ソースから多言語対応すべき文字列を抽出して POT を作成する

msginit

POT から PO を作成

msgfmt

PO を MO にコンパイル

msgmerge

変更された新しい POT と 既存の PO をマージする。

PHPでやってみる

gettext と、 php-gettext が導入済みであること。

ソース作成

<?php
setlocale(LC_MESSAGES, ''); // use envvar

$domain = 'sample';
bindtextdomain($domain, __DIR__.'/locale');
textdomain($domain);
bind_textdomain_codeset('test', 'UTF-8');

echo _("開始しました"), "\n";

POT作成

$ xgettext --from-code=utf-8 --output=sample.pot test.php

sample.pot が作られた。中身はテキストファイル。
メッセージID(msgid) とそれに対する対訳(msgstr) の対応になっている。
msgstrは空になっている。POTはテンプレートなのでこれでよい。

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-04-15 01:14+0900\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#: test.php:9
msgid "開始しました"
msgstr ""

PO作成

POT をもとに PO を作る。
配置先は決められていて、 {LANG}/LC_MESSAGES/*.po となる。
ここでは locale という階層の下に作ることにした。

$ mkdir -p locale/en_US/LC_MESSAGES
$ msginit -i sample.pot -o locale/en_US/LC_MESSAGES/sample.po --locale=en_US --no-translator
Created locale/en_US/LC_MESSAGES/sample.po.

locale/en_US/LC_MESSAGES/sample.po ができたので中身を見てみる。
単なる POT のコピーだが、 msgstr に msgid と同じものが入っている。
POT をコピーしても問題ない。

# English translations for PACKAGE package.
# Copyright (C) 2015 THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Automatically generated, 2015.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2015-04-15 01:14+0900\n"
"PO-Revision-Date: 2015-04-15 01:14+0900\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

#: test.php:9
msgid "開始しました"
msgstr "開始しました"

対訳を書く。
これは en_US のファイルなので、英語を書く。

--- locale/en_US/LC_MESSAGES/test.po.org   2015-04-15 01:29:55.455065174 +0900
+++ locale/en_US/LC_MESSAGES/test.po       2015-04-15 01:30:42.101061980 +0900
@@ -18,4 +18,4 @@

 #: test.php:9
 msgid "開始しました\n"
-msgstr "開始しました\n"
+msgstr "started\n"

LANG は、環境によって使えるものが違う。
何が使えるのかは locale -a で確認できる。

PO をコンパイル

PO をコンパイルして MO を作成する。

$ msgfmt -o locale/en_US/LC_MESSAGES/sample.mo locale/en_US/LC_MESSAGES/sample.po

動作確認

$ php test.php
開始しました

$ LC_MESSAGES=en_US php test.php
started

エディタ

POT, PO はテキストファイルのため、テキストエディタで編集可能。
しかし、規模が大きくなったり、Fuzzyが増えてきたりすると非効率になってくる。

オープンソースである poedit が編集に使いやすい。
編集には基本的にこれを使った方がよい。

gettext自体も内包しており、xgettext, msginit, msgfmt, msgmerge もこのソフト単体で可能。
(しかし多くの場合はスクリプトで一括して行った方が効率はよい)

マージ

ソースに変更が行われた場合、ソースからPOT抽出~POTからPO作成&翻訳~MO作成 を再度行う必要がある。
しかし PO には既に翻訳済みのメッセージも存在するため、単純な上書きをするわけにはいかない。

再生成した POT と、既存の PO をマージをするためのツールが用意されている。
POT はいつでも再生成、上書きして問題ない。

msgmerge -U locale/en_US/LC_MESAGES/sample.po sample.pot

ソースから削除された == POT から削除された msgid は PO からも削除されるし、
ソースに追加された == POT に追加された msgid は PO にも追加される。

msgid が変更された == POT から削除されつつ似たようなものが追加された ものについては、
候補になりうるものを PO から削除せず、候補としてマークする (Fuzzy)。
Fuzzy状態のものは無効であり、 gettext() の対象にはならない。
Fuzzyのコメントを削除すれば対象となるが、専用のエディタを使った方がわかりやすく、安全である。

gettext関数

変数の埋め込み

「こんにちは、xxさん」のように、変数を埋め込みたい場合がある。
これは sprintf() を使うことで対応できる。

ソース

echo sprintf(_('こんにちは %s さん'), '則巻アラレ'), "\n";

PO

msgid "こんにちは %s さん"
msgstr "Hello, %s"

実行結果

$ php test.php
こんにちは 則巻アラレ さん

$ LC_MESSAGES=en_US php test.php
Hello, 則巻アラレ

順序の入れ替え

日付の y-m-dd-m-y 、名前の lastName firstNamefirstName lastName など、
言語によってメッセージ中に変数の表示順が変わるケースがある。
これはこのように対応する。

ソース

$dayMsg = _('今日は %1$s年 %2$s月 %3$s日 です');
echo sprintf($dayMsg, '2015', '04', '15'), "\n";

PO

msgid "今日は %1$s年 %2$s月 %3$s日 です"
msgstr "%3$s-%2$s-%1$s"

実行結果

$ php test.php
今日は 20150415日 です

$ LC_MESSAGES=en_US php test.php
15-04-2015

複数形

言語によって、単数形・複数形で表現方法が違う場合がある。
意識しなくてよい例が日本語、意識する必要がある例が英語。

メッセージIDがどちらの言語かによって書きかたが違う。
日本語→英語の場合は以下のようになる。

ソース

echo sprintf(ngettext('%d 件のユーザがみつかりました', '%d 件のユーザがみつかりました', 1), 1), "\n";
echo sprintf(ngettext('%d 件のユーザがみつかりました', '%d 件のユーザがみつかりました', 2), 2), "\n";

PO

"Plural-Forms: nplurals=2; plural=n != 1;\n"

msgid "%d 件のユーザがみつかりました"
msgid_plural "%d 件のユーザがみつかりました"
msgstr[0] "One user found."
msgstr[1] "%d users found."

実行結果

$ php test.php
1 件のユーザがみつかりました
2 件のユーザがみつかりました

$ LC_MESSAGES=en_US php test.php
One user found.
2 users found.

英語→日本語の場合は、POはこのようになる。

"Plural-Forms: nplurals=1; plural=0;\n"

msgid "One user found."
msgid_plural "%d users found"
msgstr[0] "%s 件のユーザがみつかりました"

まとめると、

  • ngettext(単数時メッセージID, 複数時メッセージID, 数) をソースに書く
  • Plural-Forms で、その言語の単数/複数ルールを定義。
    日本語のような区別のない言語であれば nplurals=1; plural=0;
    英語のような区別のある言語であれば nplurals=2; plural=n != 1;
    3個以上の場合などより複雑な場合もここにルールを定義して対応する
  • msgid が単数時メッセージID、msgid_plural が複数時メッセージID
  • msgstr[0] が単数時の対訳、 msgstr[1] が複数時の対訳

ドメイン分け

PO を用途ごとに複数ファイルに分けることができる。
これらのPOファイルの名前を ドメイン と呼ぶ。

dgettext($domain, $msgid) で、ドメインを指定してメッセージを取得できる。

文脈

文字列としては同じでも、文脈により意味が異なるケースがある。
たとえば Post は記事そのものでもあるし、投稿する行為でもある。

これらを別物として扱う必要がある場合は Post をそのまま msgid にすることができない。
追加の文字列を与えたものを msgid として定義、gettext() 後に外すのがよい。

echo preg_replace('/__Noun\z/u', '', _('Post__Noun'));

msgid は Post__Noun、それに対応する翻訳がなければ Post が出力される。