ファイル形式・標準規格Read this article in English

PO ファイルとは — msgid・msgstr・複数形と、POT / MO の関係

PO ファイルは、原文(msgid)と訳文(msgstr)を1組ずつ並べたテキストファイルです。POT は msgstr を空にした雛形で、ソースコードから抽出します。MO はそれをコンパイルしたバイナリで、プログラムが読むのは MO だけです。編集するファイルと動作に使われるファイルは別物です。

PO のトラブルはほぼここに集まります。この記事では完全な PO ファイルを1つ示して各フィールドを説明し、言語ごとの複数形の数を確かめ、訳が消える原因を並べます。以下のツールの挙動はすべて GNU gettext のコマンドで再現し、コンパイル後のカタログを読み戻して確認した結果です。

完全な PO ファイルの実例(msgfmt で検証済み)

次が日本語カタログの実例です。header、6種類のコメント記号、複数形エントリ、msgctxt で区別した2件、エスケープを含む複数行文字列、fuzzy、廃止エントリをすべて含みます。msgfmt の検査ではエラーは出ず、任意フィールド2件が無いという警告だけでした。

# Japanese translations for the demo package.
# Copyright (C) 2026 Example Studio
#
msgid ""
msgstr ""
"Project-Id-Version: demo 1.0\n"
"POT-Creation-Date: 2026-09-12 10:00+0900\n"
"PO-Revision-Date: 2026-09-12 11:30+0900\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"

#. Tutorial overlay. %s is a key name such as I.
#: src/ui/tutorial.c:42
#, c-format
msgid "Press %s to open your inventory."
msgstr "%s キーで持ち物を開きます。"

#: src/ui/inventory.c:118
#, c-format
msgid "%d item found"
msgid_plural "%d items found"
msgstr[0] "%d 個見つかりました"

#: src/ui/dialog.c:7
msgctxt "button"
msgid "Close"
msgstr "閉じる"

#: src/ui/map.c:210
msgctxt "distance"
msgid "Close"
msgstr "近い"

#: src/ui/quest.c:64
msgid ""
"The bridge is out.\n"
"Ask the ferryman about the \"north road\"."
msgstr ""
"橋が落ちています。\n"
"渡し守に「北の道」について聞いてください。"

#: src/ui/menu.c:31
#, fuzzy
#| msgid "Save game"
msgid "Save progress"
msgstr "ゲームを保存"

#~ msgid "Old tutorial text"
#~ msgstr "古いチュートリアル文"

header で効く2行と、コメント記号6種類の使い分け

header は msgid が空文字列の実在するエントリで、msgstr にメタ情報が1行ずつ入ります。動作に効くのは2行だけです。Content-Type は本体の文字コードを宣言し、Plural-Forms は実行時にどの複数形スロットを選ぶかを決めます。網羅的な定義は GNU gettext マニュアルの PO ファイルの章にあります。

文字列は C 言語風の二重引用符リテラルで、バックスラッシュと n が改行、二重引用符の前なら引用符そのものになります。1行に収まらない文字列は空文字列を先に置いて継続行を並べ、継続行は何も挟まず連結されるのでファイル上の改行は値に含まれません。引用符の中の空白はそのまま値になり、末尾に残った空白もキーの一部になります。

msgid の上のコメント記号は役割が違い、ツールは記号で処理を振り分けます。翻訳者向けのメモを間違った記号で書くと、次の抽出で消えることがあります。

  • 「#」+空白は翻訳者コメント。手で書き、再抽出をまたいでも残る
  • 「#.」は抽出コメント。開発者がソースコード側に書き、翻訳者に読ませる
  • 「#:」は参照。抽出元のファイル名と行番号
  • 「#,」はフラグ。c-format は printf 形式であることを示し検証対象になる。fuzzy は確認待ち
  • 「#|」は変更前の msgid。原文が変わったときに記録され、翻訳者が差分を読める
  • 「#~」は廃止エントリ。原文が消えた後もコメントアウトして残す

複数形は日本語1種類・ロシア語3種類 — 表に潜む2つの罠

Plural-Forms は2つを宣言します。nplurals はスロットの数、plural は個数からスロット番号を求める C 言語風の整数式です。数を間違えても、検査を頼まないかぎり気づけません。nplurals=3 と宣言して msgstr[0] しか無いファイルは、検査モードの msgfmt では致命的エラーで終了コード 1、それでも MO は書かれ、逆コンパイルすると壊れたまま読めました。検査オプション無しなら無言でコンパイルされ、終了コードは 0 です。

gettext が言語ごとの表を持ち、雛形から言語を初期化すると header を書いてくれます。ただし表には罠が2つあります。

1つ目は、gettext の数と Unicode のロケールデータのカテゴリ数が一致しないことです。JavaScript の実行環境に CLDR 由来のカテゴリを問い合わせると、ロシア語は4種類(one / few / many / other)、フランス語は3種類(one / many / other)を返しますが、gettext が書くのはロシア語3・フランス語2です。差分は小数や短縮表記の大きな数のように、整数式が届かない範囲です。nplurals は gettext 独自の数で、CLDR のカテゴリ数とは別物だと考えてください。CLDR が標準化している内容は別の体系です。性・格の切り替えまで必要ならICU MessageFormat の役割の方が合います。

2つ目は、初期化コマンドがアラビア語と簡体字中国語を知らなかったことです。Plural-Forms を1行も書かず、雛形から複製した2スロットを残したファイルが出ました。それでも検査は通ります。スロットが空だからです。埋めた状態で同じファイルを検査すると、nplurals と plural の両方が無いという致命的エラーに変わります。表に無い言語を足したら header は手で書いてください。収録言語は版で変わります。実際の出力がこれです。

en           nplurals=2; plural=(n != 1);
de, es       nplurals=2; plural=(n != 1);
fr, pt_BR    nplurals=2; plural=(n > 1);
ja, ko       nplurals=1; plural=0;
cs           nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
pl           nplurals=3; plural=(n==1 ? 0 : ...);
ru           nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : ...);
ar, zh_CN    Plural-Forms が書かれない

訳が画面に出なくなる4つの原因

1つ目、fuzzy が付いたエントリはプログラムに届きません。上の実例には日本語の msgstr が入った fuzzy エントリが1件あります。コンパイルして gettext のランタイムから読み戻すと、返ったのは日本語ではなく英語の原文でした。msgfmt は fuzzy を MO から落とします。統計オプションもこのファイルを「翻訳済み5件、fuzzy 1件」と数えました。

2つ目、原文を直すと訳が外れます。句点を感嘆符に変える1文字の修正を行い、雛形を作り直してマージしました。既定のあいまい一致では古い訳が引き継がれたうえで fuzzy が付き、前項のとおり画面には英語が出ます。あいまい一致を切ると、エントリは空になり古い訳は廃止ブロックへ移りました。msgctxt は衝突を分けるだけで、msgid が鍵の一部であることは変わりません。改稿が多いなら安定した識別子を鍵にする案と比べてください。判断材料は文字列キーの命名設計にあります。変更前の msgid を記録するオプションを付けてマージすれば、旧原文が「#|」で残り翻訳者が差分を読めます。

3つ目、文字コードの宣言ずれは無警告で文字化けになります。中身を UTF-8 のままにして header の charset だけ ISO-8859-1 に書き換えると、検査は何も言わずに正常終了しました。コンパイル後のカタログから「閉じる」を読み出すと、3文字のはずが9文字の Latin-1 文字列で、元の1バイトが1文字に化けた形です。UTF-8 で保存し直すと9バイトから18バイトに増えました。典型的な二重エンコードで、見分け方はUTF-8 を二重に読むと何が起きるかと文字化けの原因と直し方にあります。

4つ目、行の折り返しが意味の無い差分を作ります。長いエントリを1行で書いたファイルを整形コマンドに通すと、内容は同じまま7行から11行になり、両方をコンパイルした MO はバイト単位で一致しました。折り返しを止めるオプションは PO を書き出す側、つまり msgcat・msgmerge・msgattrib・xgettext・msginit にあります。msgfmt はバイナリを書くので折り返す対象が無く、受け付けません。規約を決めて両側で揃えてください。

どこで PO に出会うか、渡す前に見る6点

gettext は C の世界で育ったので、PO に出会う場所は2種類です。1つは gettext をそのまま使う環境で、C・C++ のプロジェクト、デスクトップ Linux、PHP 製の CMS、そして Python です。もう1つは、ツールが受け渡し形式として PO を選んでいる場合です。Unreal Engine のローカライズダッシュボードは PO を書き出し・読み込みしますが、パッケージ後の実行時は独自の形式を使います。この分かれ方はUnreal のローカライズの流れで扱っています。Godot は CSV と並ぶ2つの翻訳形式の一方として PO を受け付けます。それ以外はエンジンとバージョンで差があるので、各エンジンの公式ドキュメントのローカライズの章で確認してください。

渡す前の確認は次の6点です。資料全体の組み方はローカライズキットの準備にあります。

  • すべての PO を msgfmt の検査モードでコンパイルし、終了コードが 0 でなければビルドを止める。スロット数とプレースホルダーの不一致は致命的エラーになるが MO は書かれる
  • header の2行を読む。charset が実体のバイト列と合っているか、Plural-Forms がその言語に合っているか
  • fuzzy の件数を数えて扱いを決める。属性で絞り込むコマンドで fuzzy だけを別ファイルに取り出せる
  • 折り返しの規約を決めてファイルの隣に書き残す
  • プレースホルダーの意味をソースコード側の抽出コメントに書く。キー名か物の名前かはツールでは分からない
  • 並べ替えを許すか明記する。番号無しのプレースホルダーが複数あるとき、位置指定引数に対応しなければ順序は変えられない

マージが安いから残った — 強みと弱点

抽出コマンドが POT を書き、各言語の PO はそこから作られ、msgfmt が MO にコンパイルします。原文が変わると雛形を作り直して各言語の PO にマージし、変更の無いエントリはそのまま通り、新規は空で届き、原文が消えたエントリは削除ではなく廃止ブロックへ移ります。だから一時的に外した文字列も、戻したときに訳を失いません。すべての段階がテキストファイルなので差分が読め、サーバーもデータベースもアカウントも要りません。これが gettext が同時代の多くの仕組みより長く残っている理由です。

弱点はこの記事で挙げた3点です。別々のツールや外部の担当者との受け渡しが仕事の中心なら、そのために作られた形式の方が合います。該当するのが受け渡し形式としての XLIFFと、翻訳メモリの TMX と用語集の TBXです。

関連記事