LoginSignup
7
8

More than 1 year has passed since last update.

シェルスクリプトの国際化・多言語化の完全解説(POSIX準拠 / gettext.sh / bash / ksh)

Last updated at Posted at 2023-01-24

はじめに

シェルスクリプトのメッセージを国際化(多言語化・日本語化)したいと思う人はどれだけいるのでしょうか? 個人的にはあまりしようとは思わないのですが、シェルスクリプトだからしないと言うよりも CLI ツールは技術者向けの道具だから英語でいいじゃんというのが一番の理由です。もし技術に疎いエンドユーザー向けのシェルスクリプトであれば国際化する意味はあるでしょう。国際化するべきか否かはシェルスクリプト製かどうかやソフトウェアの規模(スクリプトの行数)とは全く関係ありません。それを必要としている人がいるかどうかです。何十年も前からシェルスクリプトで国際化を実現するコマンドやライブラリが存在し、標準的にインストールされ、POSIX でも標準化されるのは必要だと考えられている証拠といえます。であれば個人的にどう思っているかは関係なく、その考えを踏まえて情報を広めるべきでしょう。私はあまり必要ないとは思っていますが、このようなことを調べてまとめようとする人も少ないと思われるのでやりました。多分これでシェル(POSIX シェルに限る)の国際化手法の完全な解説になっていると思うのですが、他に一般的な手法があれば教えて下さい。

告知 この記事で書いていますがシェルスクリプト用の新しい国際化ライブラリを作りました⇒シェルスクリプト用の国際化ライブラリの決定版! sh-gettext を作りました ~ gettext.sh 代替・すべてのPOSIXシェルと環境に対応

この記事では標準的な Linux 環境で標準インストールされている GNU gettext と付属のシェルスクリプト用ライブラリ gettext.sh(+α)の解説を行っています。似たような記事は他にもすでにいくつかあるのですが、2023 年改定予定の POSIX Issue 8 での gettext(システムコール & コマンド)の標準化に伴い、シェルスクリプトの国際化手法にも変化があると予想されるため、GNU gettext だけではなく最新の POSIX の情報に加え、歴史的資料として過去の手法ついてもすべてまとめているのが他の記事との違いです。またチュートリアルではなく実用レベルの解説記事を目指しているため少々長いです。ただしメッセージカタログ(翻訳ファイル)に関しては最初の生成までで、メンテナンス方法については詳しく解説はしていません。他の言語と同じやり方のはずですし何より私は詳しくありません。🚩実際に動かしてみたい人のためにリポジトリ (sh-i18n-examples) を用意しています。

ちなみに POSIX Issue 8 では gettext に関連するシステムコールの他、国際化に関連するシェルスクリプトの機能として以下のものが標準化されます。

  • gettext コマンド、ngettext コマンド
  • printf<数値>$ 書式(例 printf '%2$s %1$s' arg1 arg2 => arg2 arg1)
  • xgettext コマンド(C 言語開発オプション)、msgfmt コマンド

最初に世の中で勘違いされている(?)点についていくつかまとめて訂正しておきます。この記事では GNU gettext をメインに扱っていますが、gettext を最初に実装したのは GNU ではなくサン・マイクロシステムズです。シェルスクリプトを国際化するには GNU gettext と付属の gettext.sh を使用する例が多いですが、変数展開を行わないのであれば gettext.sh は必須ではなく gettext コマンドと ngettext コマンドがあれば事足ります。これらは OS の基本的なコマンドなので Linux 以外でも標準構成でインストールされていることが多いです。GNU gettext.sh は GNU bash だけではなく、すべての POSIX シェル(sh, dash, ksh, zsh など)で動きます。簡易テンプレートエンジンとして紹介されることが多い envsubst コマンドは実は GNU gettext 付属の翻訳サポートツールで gettext.sh が内部的に使用しています。詳細は記事本文で。

GNU gettext の情報

参考リンク(公開日時の古い順)

基礎知識

gettext と catgets の簡単な歴史

補足 もう少し詳しい説明を「POSIX 準拠の二つの国際化機能 gettext と catgets について調べてみた」で書いています。

現在最も有名な国際化の仕組みは gettext でしょう。Linux が誕生するよりも前の時代、Unix では歴史的に「Uniforum が提案した gettext」と「X/Open が提案した catgets」の二つの国際化の仕組みがありました。この仕組みは OS 全体の国際化の仕組みでありシェルスクリプトに限定したものではありません。gettext と catgets はどちらも 1980 年代の終わり頃に誕生しました。gettext を最初に実装したのは(GNU ではなく)サン・マイクロシステムズで 1990 年頃に SunOS で使うために開発されました。現在の Linux では gettext が広く使われていますが、UNIX では UNIX 標準規格争いの末 gettext は次第に使われなくなり catgets が優勢となっていきました。

gettext と catgets の大きな違いは、catgets がメッセージごとに ID(番号)を定義するのに対して、gettext ではデフォルトのメッセージ(通常は英語)の文字列自体を ID として使用することです。catgets は効率は良いのですが、現在のコンピュータでは目に見える差はなく、メッセージを ID とすることによる gettext のメンテナンス性の高さに軍配が上がっています。ひとまずソースコードに英語で書いておき、それを後から置き換えられるようにするというのは ID とメッセージの対応付けを管理するよりも楽なのです。

最初は gettext も catgets も POSIX では標準化されませんでした。しかし catgets は、それを提案した X/Open の標準規格である X/Open Portability Guide (XPG) に組み込まれました。そして 1995 年に誕生する Single UNIX Specification (SUS) という現役の UNIX の標準規格のベースとして XPG が採用されます。その後 2001 年に UNIX の標準規格が統一される過程で POSIX へ XSI 拡張機能として追加され、2008 年に POSIX の基本機能へ変更されました。このような経緯で catgets は、なし崩し的に POSIX 標準規格に組み込まれました。

その一方で gettext は GNU に拾われます。GNU gettext が誕生したのは 1995 年頃で、catgets が組み込まれた XPG が SUS となった年です。UNIX で事実上 catgets を使っていくことになるまさにその年に、GNU (GNU is Not Unix) では gettext を使っていくことになりました。2000 年には gettext は Linux の国際化に関する標準化団体 LI18NUX2000 で標準化されます。LI18NUX2000 は 2003 年に OpenI18N と名称を変更し、いくつかの団体が合併して 2007 年より現在の Linux Foundation の一部となります。

元はサンで開発された gettext は GNU gettext として生まれ変わり、そして Linux が Unix に取って代わっていく流れと共に、Linux の世界で gettext は広く普及し、2023 年に改定予定の POSIX Issue 8 に組み込まれる予定です。

国際化に必要なもの

シェルスクリプトを国際化するには大きく分けて以下の二つの工程が必要です。

  1. シェルスクリプトを国際化に対応したコードにする
  2. メッセージカタログ(翻訳ファイル)を作成する

この二つは独立しているため、プログラムを国際化対応にしておけば、後からメッセージカタログを追加・修正することが可能です。この記事のメインの内容は、1 のシェルスクリプトを国際化に対応したコードにする方法と、コードの意味や仕組みを理解するための解説です。

国際化の仕組みはシェルスクリプトに固有のものではないため、2 のメッセージカタログについては他の言語の作り方が参考になるはずです。メッセージカタログ(PO ファイル や MO ファイル)のメンテナンスは GUI のツールを使用したほうが楽だと思うので、この記事では最低限必要なコマンドの使い方だけを説明しています。

シェルスクリプトの国際化の歴史

シェルスクリプトの国際化の手法には古い順(5 を除く)に以下のものがあります。

  1. ksh93 専用 - ksh93 の $"..." を使う方法(catgets API 使用)
  2. bash 2.x 専用 - bash の $"..." を使う方法(gettext API 使用)※非推奨
  3. POSIX シェル用 - gettext.sh (gettext + envsubst) を使う方法 ※現在の推奨
  4. POSIX Issue 8 準拠シェル用 - gettext + printf を使う方法
  5. 新しい翻訳ライブラリ(予想)

1. ksh93 専用(1993 年?~)

最初にシェルスクリプトに国際化の機能が実装されたのは Bourne シェルの後継として開発された AT&T の ksh93 です。シェルスクリプトに国際化の機能なんて必要なのだろうか?と思うかもしれませんが、当時(1990 年代後半から 2000 年代前半ぐらいまで?)はシェルスクリプトを使って簡単な GUI アプリケーションの開発が行われていました。こちらにデモを見つけました。GUI アプリはコンピュータに詳しくないエンドユーザーも使いますし、シェルスクリプトで GUI インターフェースを作るという前提であれば、国際化の機能が必要だということにも納得できるでしょう。

ksh93 はとても強力なシェル言語で、他のアプリケーションに組み込めるようになっていました。今で言えば Visual Studio Code に組み込まれている JavaScript のようなものです。1993 年に UNIX の GUI デスクトップ環境が CDE(共通デスクトップ環境)に統一される流れがありましたが、その CDE に組み込まれた ksh93 のフォーク版が dtksh です。また Tk と呼ばれる GUI ウィジェット・ツールキット用のフォークとして tksh がありました。少なくとも ksh93 は GUI アプリの作成に使える言語の一つだったのです。そうは言ってもシェルスクリプトでしょう?そんなに高度な事ができるの?と思うかもしれませんが、ksh93 の言語機能は一般的なシェル言語を大きく超えており浮動小数点演算やオブジェクト指向まで対応しています。さらに GUI ツールキットにアクセスするためにコマンドが拡張されており、当時の技術水準では十分な能力があったと言えるはずです。ksh93 の言語能力については「夢のシェルスクリプト言語 KornShell (ksh93) 〜すごいぞ!型とクラスは本当にあったんだ!〜」で詳しくまとめているので興味がある方は参照してください。

ksh93 では国際化の API として catgets API を使用しています。ただしシェルスクリプトから直接 API を使う必要はありません。ksh93 自体に国際化の仕組みが組み込まれており、$"..." という翻訳機能付きダブルクォートで簡単に使えるようになっています。例えば echo $"Hello World" を実行すると対応する翻訳文があれば翻訳されて出力されます。しかしこれは少しおかしな話です。すでに説明した通り catgets ではメッセージごとに ID(番号)を作成することになっています。しかし $"..." に書くのはメッセージであり ID ではありません。実は ksh93 では gettext と同じようにメッセージを ID として使えるような形で実装されています。それを実現するために例えば日本語ロケールで出力する場合でも C ロケールのメッセージカタログが必須となっており、英語→ID、ID→日本語と突き合わせを行うことで翻訳文を取得しています(のはずです。コードまでは確認していません)。そのようにした理由は使い勝手の点からプログラマとして catgets に反旗を翻したのか、互換性上の理由でそうせざるを得なかったのか正確なことはよくわかりませんが、ksh を開発した AT&T は、UNIX戦争の時代に gettext を実装したサンと手を組んでいたというのは、理由の一つになると思われます。つまり ksh の中には gettext の実装と文化が流れていたが、UNIX 標準化の過程で catgets に移行せざるを得なかったということなのだろうと考えています。ksh93 での国際化の方法も後半で紹介しているので興味がある方は参照してください。

2. bash 2.x 専用(2001 ~ 2003 年頃)

次に国際化の機能が実装されたのは bash です。bash は元々 ksh (ksh88, ksh93) のクローンを目指しており、ksh93 特有の高度な言語機能までは実装していませんが、ksh で実装された機能が多く含まれています。bashism として bash の拡張機能と言われているものの多くは実は ksh がオリジナルです。bash にはそれほど国際化の機能は必要なかったかもしれませんが、ksh のクローンを目指していた bash が ksh と同じ $"..." を実装しても不思議ではありません。調べてみると bash 2.0 (1996) からの機能のようなので、おそらく ksh93 で実装されたすぐ後にそれを真似て実装したのでしょう。

ksh93 が catgets API を使っているのに対して、bash は GNU のソフトウェアなので当然ともいえますが GNU gettext (1995) を内部的に使っています。実は bash には ksh93 を真似しただけか、catgets に対応しかけたのかわかりませんが、catgets の名残だろうと思われる機能があります。それが bash 2.0 で追加された -D (--dump-strings) オプションです。これはシェルスクリプトの中から翻訳対象文字列 $... を抽出する機能で、ksh93 では catgets 用のメッセージカタログを作成するために使われる機能です。機能的には抽出した文字列をそのまま出力するだけの単純なものです。そして bash 2.02 では --dump-po-strings が追加されますが、こちらはその名の通り gettext の PO ファイル形式で出力されます。

現在、bash では $"..." を使った方法は非推奨となっています。いつから非推奨になったのか気になったので調べたのですが、まず sh / bash のサポートが xgettext(翻訳対象のメッセージを抽出するツール)に追加されたのは 2003-09-03 です。しかし 2003-09-17 にはドキュメントから削除され 2003-10-06 には警告が出力されるようになっています。つまり xgettext が bash に対応した時点で $"..." は非推奨となっており、それとほぼ同時に gettext.sh を使った方法が追加されています。GNU gettext 0.13 (2003-11-30) は非推奨であることが公言された最初のバージョンです。xgettext の(おそらく)前身である po-utils では 2001-08-13 頃に bash(当時は bash 2.05) サポートが追加されているようですが、非推奨と公言された GNU gettext 0.13 のリリースは bash 3.0 (2004) のリリースより前なので、$"..." を使った国際化は完全に bash 2.x 系での方法だということです。非推奨となった理由はエンコーディングに伴うセキュリティーホールの問題と可搬性の問題(gettext API が存在しないシステムがある)のようですが、これって修正不可能なんでしょうか? $"..." を使った方法は使い方が簡単でパフォーマンスも良いので将来復活しないかなー?とひそかに思っています。もっとも複数形の対応などが不足しているので復活するだけでは不十分なのですが。

3. POSIX シェル用(2004 年頃~)

現在一般的に推奨されているのが GNU gettext に付属している gettext.sh というシェルスクリプト用のライブラリを使う方法です。これは内部的に gettext / ngettext コマンドと envsubst コマンドを使用しています。envsubst コマンドは簡易テンプレートエンジンのような使い方をされていたりしますが、GNU gettext プロジェクトに含まれているコマンドです。gettext.sh は GNU が開発しているため、同じ GNU の bash 専用に思えるかもしれませんが、明らかな zsh 対応コードが含まれていることから bash 以外で使うことも考慮していると思われ、実際に POSIX シェルにも対応している他、コードを読む限り古い Bourne シェルにも対応しているように見えます。

なお gettext.shenvsubst コマンドを使っている理由は、翻訳文字列で変数を使えるようにするためです。例えば eval_gettext 'Hello, ${name}' のような使い方ができます(文字列のシングルクォートは間違いではありません)。翻訳文字列で変数を使わない、または別の方法(printf コマンドや eval コマンド)で変数の値を埋め込むのであれば 実は gettext.sh を使う必要はありません。その場合 gettext コマンドを直接使います。gettext.sh のソースコードに書いてありますが、eval ではなく envsubt コマンドを内部で使っている理由はセキュリティ上の理由です。

4. POSIX Issue 8 準拠用(2023 年頃~)

POSIX Issue 8 以降に新しく広まっていくであろう方法が gettextprintf を組み合わせて使う方法です。この方法では gettext.sh を使わず、(POSIX コマンドではない)envsubt コマンドに依存することもありません。POSIX Issue 8 で新たに POSIX コマンドに加わる gettext コマンドを、printf コマンドと組み合わせて実装します。

これが可能になったのは POSIX Issue 8 で printf コマンドの機能が拡張され、以下のように <数値>$ で参照する引数の位置を指定することができるようになるからです。

$ printf '%2$s %1$s\n' FOO BAR
BAR FOO

言語によって単語の順番が異なるため、国際化をする時には参照する引数の位置を指定できる機能はほとんど必須です。gettext.sh では envsubst コマンドを使用して変数をそのまま使えるようにする仕組みで引数の位置を入れ替える機能を実現していましたが、POSIX Issue 8 では printf コマンドの機能を拡張することで対応しました。

ただし現時点でこの書式に対応している POSIX シェルは、ksh93、zsh 4.2.0 以降、FreeBSD 11 以降の sh だけで、外部コマンド版(/usr/bin/printf)は Solaris 10/11、macOS、FreeBSD 11 以降だけです。意外とあるようですが、特に重要な dash や bash や GNU コマンドは現時点でまだ対応してないので注意してください。この方法はまだ十分な移植性がないため、すぐに置き換わるわけではありません。しかし POSIX で標準化されたコマンドだけで実現できるため、将来的はこの方法に置き換わっていくことが予想されます。

5. 新しい翻訳ライブラリ(予想)

私は POSIX シェルに対応した 3, 4 の手法には満足していません。どちらにも共通する問題として記述が冗長というのがあります。他の言語では _("Hello, %s") のような短い書き方でメッセージを埋め込むことができますが、4 は eval_gettext 'Hello ${name}'、5 は gettext 'Hello, %s' のように長い単語が必要です。他の言語に合わせて _ 'Hello, %s' のような書き方が良いです。そしていくつか実装では printf コマンドは <数値>$ にまだ対応していません。

またもっと単純に(echo "$T_HELLO" みたいに)変数に翻訳テキストを設定して使うような gettext コマンドに依存しない翻訳ライブラリも考えられます。この方法は catgets の手法と本質的には同じで、変数リストを管理しなければならないという面倒さがあるのですが、gettext コマンド呼び出しが不要になるためパフォーマンスが良いというメリットがあります。歴史的にそのような効率が良いというメリットはメンテナンス性に破れたわけですが、シェルスクリプトの場合は外部コマンドの呼び出しがかなり遅く gettext コマンドを使わないメリットはそれなりにあります。

将来的に gettext.sh を置き換える新しい翻訳ライブラリが登場する可能性は十分にあります。というか、この記事を書きながら色々とアイデアが生まれたので今作っていたりします。gettext ベースで移植性が高いライブラリです。人気が出れば 4 の POSIX Issue 8 準拠用の手法が広らずに新しい翻訳ライブラリが普及するかもしれません。もっとも普及以前にそもそも誰もシェルスクリプトで国際化しようとしないという問題がありますがw

POSIX シェルで国際化する方法

メッセージカタログの話は後に回すとして、国際化対応にしたシェルスクリプトはどのようなものになるのかという話から説明していきます。gettext の仕組みであればメッセージカタログはなくても動きます。(正しくコードが動くか確証を持てないので私は先に作成していますが)

基礎コマンド (gettext / ngettext) の使い方

メッセージを国際化するための、もっとも基礎となるコマンドが gettext コマンドと ngettext コマンドです。翻訳にはこの二つのコマンドのどちらかを最終的に呼び出します。次で紹介する gettext.sh でもシェル関数内部でどちらかのコマンドを呼び出して翻訳しています。そして gettext コマンドと ngettext コマンドは POSIX Issue 8 で標準化されるコマンドです。

gettext コマンドと ngettext コマンドの基本的な機能は単純で、引数のメッセージ(通常は英語)を翻訳して出力するだけです。以下に使い方を示します。使い方とオプションは POSIX Issue 8 で標準化される範囲のものだけを書いていますが、GNU gettext であっても少し機能が増えた程度で大きく違っていたりはしません。

gettext  [-e|-E]         [-d textdomain] [textdomain] msgid
gettext  [-e|-E] [-n] -s [-d textdomain]              msgid...
ngettext [-e|-E]         [-d textdomain] [textdomain] msgid msgid_plural 数値

共通オプション -e, -E, -d

すべてに共通するオプションの -e-E は msgid に書いたエスケープシーケンスを解釈する (-e) かしない (-E) かを指定するオプションです。両方とも省略された場合、どちらがデフォルトかは POSIX では指定されていません。GNU gettext の場合はエスケープシーケンスを解釈しない -E オプションがデフォルトです。ただし正確には GNU gettext(0.21.1時点)の -E オプションは互換性のためのオプションで、エスケープシーケンスを解釈しないという効果はなく単に無視されています。つまり -e を指定した後に -E を指定しても無効にはなりません。また -s オプションを指定している場合は、-E オプションがデフォルトであると POSIX では指定されています。

GNU gettext だけを使っている場合には問題になりませんが POSIX で「指定されていない」と書かれている場合、その意味は -e がデフォルトの環境があるということです。調べてみると BSD 系の gettext では GNU と同じく -E がデフォルトでしたが Solaris 10 / 11 では -e がデフォルトで、さらに困ったことに -E オプションが実装されていませんでした。POSIX では「移植性が必要な場合には必ず -e / -E を指定しなければならない」ことになっているのですが、必ず指定するのは面倒なうえ、実は指定するといくつかの問題が発生します。先に結論をいうと少なくとも現状では POSIX の主張に反して、シェルスクリプトでは gettext コマンドは -e / -E を含むオプションは一切指定せずに使うことをおすすめします。理由については後半の「gettext はオプションを使わないほうが良い」で説明しています。もちろんシェルから手作業で実行するときは使って構いません。

-d で指定する)textdomain は一般的にはプログラム名です。シェルスクリプトから使用する場合は、環境変数 TEXTDOMAIN で指定した方が楽なので、あまり使う必要はないと思います。さらに上記と関連する理由でシェルスクリプトでは gettext コマンドのオプションまたは引数として textdomain を使わないことをおすすめします。

補足ですが GNU gettext は 2019 年にリリースされた 0.20 から -c (--context) オプションが追加されています。これは同じ単語に対して異なるコンテキスト(文脈)で異なる翻訳を行いたい時に、それぞれを区別するための機能です。このオプションは POSIX では標準化されていません。大きくて複雑なソフトウェアでは便利なのかもしれませんが移植性がありません。コンテキストについては「11.2.5 あいまいさの解決のためにコンテキストを使用する」を参照してください。

Solaris 11 の gettext の Usage を見ると以下のようになっていました。

$ gettext
Usage: gettext [-d domainname | --domain=domainname ]  [domain] "msgid"
       gettext -s [-d domainname | --domain=domainname] [-e] [-n] "msgid" ...

おそらく -e / -n オプションは本来 -s オプションのための機能です。したがって、(-s オプションを指定しない場合には)-e がデフォルトで -E オプションを実装していないというよりも、-E だけではなく -e / -n オプションも存在しないが、たまたま動いてしまったという扱いなのかもしれません。

GNU gettext 0.20.1 (2019-05-12) 以前には -e オプションで、エスケープシーケンスの \\\ooo を適切に扱えないバグがあります。このバグは 0.20.2 (2020-04-14) で修正されています。

$ gettext -e 'FOO\\tBAR\n' # 0.20.1: \\ が機能していない
FOOtBAR

$ gettext -e 'FOO\\tBAR\n' # 0.20.2: 以降で修正されている
FOO\tBAR

環境変数 TEXTDOMAIN / TEXTDOMAINDIR

メッセージカタログを見つけるために必要な環境変数です。通常シェルスクリプトの冒頭でこれらの環境変数を設定します。

export TEXTDOMAIN="sh-i18n"
export TEXTDOMAINDIR="$PWD/locale"
# ↑ 実際には PWD だとカレントディレクトリ以下の locale ディレクトリになるため
# この書き方ではダメ。dirname "$0" を使うことを思いつくかもしれないが
# シンボリックリンク経由での起動で問題になるので絶対パスもしくは
# readlink や realpath か私が作成した readlinkf を使う

環境変数 TEXTDOMAIN には一般的にはプログラム名を入れます。TEXTDOMAIN はメッセージカタログ(*.mo) のファイル名部分として使われます。メッセージカタログは環境変数 TEXTDOMAINDIR で指定したディレクトリから検索します。Debian のデフォルトは /usr/share/locale で例えば /usr/share/locale/ja/LC_MESSAGES/apt.mo のようなファイルがあります。LC_MESSAGES は固定です。システムにインストールする場合はこのようなディレクトリにコピーすることになると思いますが、そうでない場合はシェルスクリプトがある場所のサブディレクトリを指定したいと思うことでしょう。つまりスクリプトの位置を取得する必要があります。スクリプトの位置を取得するには readlink コマンドや realpath コマンドや、最大限の移植性が欲しい場合、私が以前 POSIX で標準化された範囲の機能だけを使って作った readlinkf 関数を使用すると良いでしょう。補足ですが readlink [-n]realpath [-e | -E] も POSIX Issue 8 で標準化されます。

環境変数 NLSPATH には少し注意が必要かもしれません。これは基本的には catgets のための環境変数ですが、POSIX の XSI オプションに準拠したシステム(Solaris 10 など)の場合、NLSPATH でも gettext のメッセージカタログの場所を指定することが可能(書式は NLSPATH のもの)で、その場合 NLSPATH の方が優先されます。POSIX でそのように規定されています。システムにインストールしたプログラムの場合、メッセージカタログの場所はデフォルトではシステムやユーザーが指定したディレクトリを使うべきであると考えられますが、シェルスクリプトがある場所のサブディレクトリにメッセージカタログがある場合 TEXTDOMAINDIRNLSPATH をどう扱うかは少々考えなければいけないかもしれません。

gettext コマンド

gettext コマンドはメッセージを翻訳して出力するコマンドです。最も一般的な使い方は以下のようなものです。ここでは説明のためにオプションを指定していますが、シェルスクリプトではオプションを使わないほうが良いです。詳細は後半の「gettext はオプションを使わないほうが良い」で説明しています。

gettext 'Hello World'      # 翻訳されて出力される
gettext -e 'Hello World\n' # エスケープシーケンスを解釈する(末尾の改行など)

引数の msgid は ID と呼ばれているものの、実際には翻訳文が見つからない時に使われるデフォルトのメッセージです。gettext ではメッセージをそのまま ID として利用します。

-s オプションを指定した場合は echo コマンドのように動作し echo コマンドと同じように -n を指定すると末尾の改行が出力されません。複数の msgid を指定するとスペースで区切られて出力されます。

gettext -s Apple Banana Orange    # スペース区切りでそれぞれ翻訳されて出力される
gettext -s -n Apple Banana Orange # 末尾の改行を出力しない

動作自体は理解できるのですが、-s オプションはどういう時に使うことを想定しているのかいまいちよくわかりませんでした。おそらく複数の単語を一度に翻訳したリストを取得したい場合に使うんじゃないかなと思っているのですが、-s オプションを指定すると現在の GNU xgettext はメッセージを翻訳対象の文字列としてみなさないため(将来は修正されそうですが)少なくとも今はシェルスクリプトからは使いづらいです。リストを生成したい場合は、複数の単語をスペース区切りなどで一つに詰め込んだほうがいいような気がします。シェルからデバッグ用として翻訳文の確認に使うのは便利だと思います。

ngettext コマンド

ngettext コマンドは数値が単数だった場合と複数だった場合とで文章を変更する場合に使います。日本語ではあまり必要なさそうな機能ですが英語などでは必須の機能です。以下のような使い方をします。

$ ngettext "I have an apple." "I have %d apples." 1
I have an apple.

$ ngettext "I have an apple." "I have %d apples." 2
I have %d apples.

数値が一つの場合は一番目の引数のメッセージ、それ以外の場合は二番目の引数のメッセージが返ってきます。メッセージカタログを正しく設定することで、0 の場合や任意の数値で異なるメッセージを出力することもできるようです(ポーランド語では単数と複数だけではなくもっと複雑なため、それに対応する機能が必要)。その場合 ngettext にメッセージの引数が増えていくのではなくメッセージカタログ側で対応します。詳細は Plural-Forms について 12.6 複数形の翻訳11.2.6 複数形(plural forms)にたいする追加の関数 を参照してください。(私は動作確認していません)

二番目のメッセージには %d が含まれています。これは printf の書式として使う事を前提としたメッセージで、以下のような使い方を想定しています。

$ num=1
$ printf "$(ngettext "I have an apple." "I have %d apples." "$num")\n" "$num"
I have an apple.

$ num=2
$ printf "$(ngettext "I have an apple." "I have %d apples." "$num")\n" "$num"
I have 2 apples.

\n が翻訳対象のメッセージに含まれていないことに注意してください。エスケープシーケンスは可能な限りメッセージの中に含めないほうが良く、特に末尾の改行はメッセージを使う側で制御すべきであるというのが私の考えです。そのほうが柔軟な使い方ができると思います。

gettext コマンドと ngettext コマンドは前提知識です。gettext.sh を使った場合、直接使うことはないかもしれませんが、POSIX で標準化されるコマンドであり、基礎となるコマンドなのでまずこれを知っておくことは重要です。

gettext.sh と組み合わせて使う

gettext.sh スクリプトについて

gettext.sh を使った方法は現在一般的に推奨されている方法です(が私は満足していません)。gettext.sh はシェルスクリプトで実装された GNU 製のライブラリですが GNU bash だけではなく POSIX シェル全て(sh, dash, ksh, mksh, yash, zsh など)とおそらく古い Bourne シェルでも使うことができます。ただし GNU の gettext.sh と GNU ではない gettext コマンドを組み合わせて使う場合には正しく動作するとは限りません。各環境の gettext が GNU gettext と完全に互換性があれば動くと思いますが、原則として gettext.sh は(シェルはどれでも使えるが)GNU gettext 用のシェルスクリプトライブラリと考えたほうが良いでしょう。

通常は GNU gettext パッケージをインストールすれば、GNU の gettextgettext.sh の両方がインストールされると思いますが、Solaris 11 では Solaris 版の gettext コマンドがすでに存在しており、GNU 版の gettextggettext という名前になっていました。ggettext はシンボリックリンクで、実際には /usr/gnu/bingettext という本来の名前でインストールされているので、環境変数 PATH で /usr/gnu/bin を優先させれば GNU 版の gettext が使われます。

gettext.sh の読み込みですが、以下のように書きます。一般的には gettext.sh の場所を絶対パスで指定する必要はありません。. (source) コマンドは環境変数 PATH から読み込むファイルの場所を探すからです。

. gettext.sh

# 以下のように絶対パスで書く必要はない
# . /usr/bin/gettext.sh

ちなみに gettext.sh は実行権限がついており、実行することが可能です。ついでなので --help の出力を見てみましょう。

$ gettext.sh --help
GNU gettext shell script function library version 0.21.1
Usage: . gettext.sh

このように使い方は . gettext.sh であると書かれています。

eval_gettext / eval_ngettext 関数

gettext.sh を使う場合、gettext / ngettext コマンドの代わりに gettext.sh で定義されるシェル関数 eval_gettext / eval_ngettext を基本的に使います。実は変数展開をしないのであれば gettext.sh を使う必要はありませんgettext / ngettext コマンドが持っている機能は元のメッセージから翻訳されたメッセージに変換する機能だけです。しばしばメッセージには変数の値を埋め込みますが、その機能を提供しているのが eval_gettext / eval_ngettext というわけです。頭の eval_ とは変数の値を埋め込む処理のことを指して(元々使おうと考えていた?)eval と言っているのでしょう。実際には eval の代わりにより安全な envsubst コマンドを使って変数を展開しています。なお envsubst については「envsubstの本来の使い方はシェルスクリプト用のテンプレートエンジンではない」で詳しくまとめています。

eval_gettext 関数と eval_ngettext 関数は、それぞれ gettext コマンドと ngettext コマンドに対応しており以下のように使います。シングルクォーテーションでくくっているので、引数を渡す時に展開されているのではありません。eval_gettext / eval_ngettext 関数の中で展開されています。

first_name="Koichi" last_name="Nakashima"
eval_gettext 'Hello, ${first_name} ${last_name}' && echo

num=2
eval_ngettext 'I have an apple.' 'I have ${num} apples.' "$num" && echo

記述できる変数の形式は $var${var} だけです。シェルは ${var:-default} のような書き方もできますが、それらには対応していません。

末尾に echo を書いているのは改行を行うためです。よく見かけるのは ; echo という書き方ですが、&& echo の方が良いと思います(; でつなぐと整形ツールで二行に整えられたりする)。他に以下のような書き方を見かけます。

first_name="Koichi" last_name="Nakashima"
echo "$(eval_gettext 'Hello, ${first_name} ${last_name}')"

見た目的にはスマートなのですが echo はエスケープシーケンスを解釈するかどうかがシェルによって異なるため移植性を考えるとおすすめできません。実はこの問題を解決するために gettext.sh$echo 変数を用意しており、以下のように書くことができるのですが、いくつか問題があるため、私は $echo 変数の使用はおすすめしません(将来のバージョンで問題が修正された場合はその限りではありません)。問題点とその解決方法については後述の「$echo 変数(は使わない方がいい)」を参照してください。

first_name="Koichi" last_name="Nakashima"
$echo "$(eval_gettext 'Hello, ${first_name} ${last_name}')"
# $echo の使用は私はおすすめしません。

eval_pgettext / eval_npgettext 関数

eval_pgettext / eval_npgettext 関数は 2019 年にリリースされたバージョン 0.20 で追加された比較的新しい関数です。p がつかないバージョンとの違いはコンテキスト (msgctxt) が指定できるようになったことで gettext / ngettext-c (--context) オプションを指定した場合と同じです。

-c (--context) は POSIX で標準化されていないので eval_pgettext / eval_npgettext も GNU gettext(もしくは -c に対応した他の実装)でしか動作しません。

eval_gettext / eval_ngettext との使い方の違いはコンテキストの引数が一つ増えただけです。

first_name="Koichi" last_name="Nakashima"
eval_pgettext 'context' 'Hello, ${first_name} ${last_name}' && echo

num=2
eval_npgettext 'contnext' 'I have an apple.' 'I have ${num} apples.' "$num" && echo

コンテキストについては「11.2.5 あいまいさの解決のためにコンテキストを使用する」を参照してください。

$echo 変数(は使わない方がいい)

gettext.sh を読み込むと echo 変数が定義されます。echo 変数にはエスケープシーケンスを解釈せずに出力するコマンド名が代入されており、echo コマンドの移植性の問題を解決することを意図しています。

# echo は実装ごとにエスケープシーケンスを解釈したりしなかったりするが
# $echo を使うとどの実装でもエスケープシーケンスを解釈しない動きになる
# $echo はダブルクォーテーションで括らずに使うことを想定している
$echo 'Hello World\n' # => Hello World\n

この機能はドキュメントにも記載されていますが、あまり良い実装とは言えないので使用しないほうが良いです。理由を echo 変数が定義されるまでの gettext.sh の以下の処理より説明します。

# Find a way to echo strings without interpreting backslash.
if test "X`(echo '\t') 2>/dev/null`" = 'X\t'; then
  echo='echo'
else
  if test "X`(printf '%s\n' '\t') 2>/dev/null`" = 'X\t'; then
    echo='printf %s\n'
  else
    echo_func () {
      cat <<EOT
$*
EOT
    }
    echo='echo_func'
  fi
fi

パッと見で古い書き方をしているなと気づきますが、古い書き方というだけでそこは問題ではありません。

一般的には echo='echo'(bash など)か echo='printf %s\n'(dash など)のどちらかを通るのですが、この二つは完全な互換性がなく -n オプションを渡した時や複数の引数が渡された場合に違いが出ます。ドキュメントには「最初の引数と改行を出力する」となっているので、引数は一つしか渡せないのが仕様だとは思います。

$echo The variable echo is set to a command that outputs its first argument and a newline, without interpreting backslashes in the argument string.

echo='echo_func' を通る環境は、おそらく現在は存在しません。printf コマンドが存在しない環境でしか通らず、1990 年前後の Unix ぐらいでしか必要にならないと思われます。現在の POSIX シェルでは IFS の最初文字が $* の区切り記号として使われるため問題があるコードですが、Bourne シェルではそのような効果はないので、バグではないとするなら Bourne シェル用と推測できます。ただ gettext.sh が追加されのは 2003 年なので当時の時点でこのコードが必要な環境がどれだけあったのか疑問です(注意 sh = Bourne シェルではありません。現在の sh は POSIX sh に置き換わっており Bourne シェルは使われていません)。コードから gettext.sh は(POSIX 準拠モードではない) zsh で読み込まれることが想定されていますが $echo は単語分割の仕様の違いによりデフォルトでは動作しません(単語分割を行うように設定すれば動きます)。

通常は問題にならないとは思いますが、小さな問題がいくつか含まれています。$echo のようなものが必要なら printf '%s\n' を直接使えば良いと思いますが、長くて面倒というのであれば以下のような関数を使用すると良いでしょう。オプションを取らずエスケープシーケンスを解釈しない echo の実装です。put が改行なし、putln が改行ありです。複数の引数は echo と同じようにスペースで区切られます。POSIX 準拠の sh でも dash でも bash でも、どのシェルでも同じように動きます。ただし Bourne シェル(古い sh)には対応していません。変数展開部分をいじれば動くよう作れますが、行数を増やしたところで今更対応しても誰も必要にならないでしょう。

# mksh は printf コマンドが外部コマンドで遅いため print コマンドを使用する
# mksh を含む ksh 系は全て print コマンドが実装されている
if [ "${KSH_VERSION:-}" ]; then
  put() {
    IFS=" $IFS" && set -- "$*" && IFS="${IFS# }"
    command print -nr -- "${1:-}"
  }
  putln() {
    IFS=" $IFS" && set -- "$*" && IFS="${IFS# }"
    command print -r -- "${1:-}"
  }
else
  put() {
    IFS=" $IFS" && set -- "$*" && IFS="${IFS# }"
    command printf '%s' "${1:-}"
  }
  putln() {
    IFS=" $IFS" && set -- "$*" && IFS="${IFS# }"
    command printf '%s\n' "${1:-}"
  }
fi

gettext.sh を使わない方法(POSIX Issue 8 向け)

printf の <数値>$ を利用する

ここまでの話でもすでに printf と組み合わせて使う例は出しているのですが、POSIX Issue 8 では新たに参照する引数の位置を指定する書式 <数値>$ が追加されました。この機能を printf に追加した理由は gettext の追加作業に伴う提案であったことが「0001592: Add %n$ support to the printf utility」で明確に述べられています。

During the work on adding gettext, it was suggested that we should also add support for %n$ in format strings to the printf utility, to allow argument reordering by translators.

printf<数値>$ が国際化に必要な理由は、引数の順番を入れ替えるためです。日本人なら誰しも日本語と英語で姓名の順番が異なることを知っているでしょう。住所の並びも逆です。したがって複数の値をメッセージに埋め込む場合、単なる %s %s ではだめで %2$s %1$s のように順番を入れ替えることができる必要があります。

first_name="Koichi" last_name="Nakashima"
printf "$(gettext 'Hello, %s %s')\n" "$first_name" "$last_name"
# 翻訳文を「こんにちは、%2$s %1$s」にすることで引数の順番を入れ替えることができる

gettext.sh を使った場合、変数名をそのままメッセージに書くので順番を入れ替えることができました。同等のことを gettext.sh を使わずに printf で実現するにはこのような拡張機能を POSIX に追加する必要があったというわけです。ただし現時点ではすべての実装が printf<数値>$ に対応しているわけではなく dash や bash では対応してないことに注意する必要があります。したがって今すぐ使うことは現実的には難しいでしょう。

なお <数値>$ を使わないのであれば gettext.sh を使わずとも今すぐ printf を使ったやり方に変更することはできます。例えば引数が一つしかない場合、順番を入れ替える必要はありません。

name="Koichi" num=2
printf "$(gettext 'Hello, %s')\n" "$name"
printf "$(ngettext "I have an apple." "I have %d apples." "$num")\n" "$num"

メッセージカタログ(翻訳ファイル)の作り方

POSIX Issue 8 では メッセージカタログ(翻訳ファイル)の作成に最低限必要な msgfmt コマンドと開発時にのみ必要な xgettext コマンドも標準化されます。国際化対応にした(gettext とメッセージを埋め込んだ)シェルスクリプトがあるとして、そこからメッセージカタログを作る流れにそってこれらのコマンドを簡単に説明します。基本的には以下のような流れです。

  1. 国際化したシェルスクリプトがある
    • gettexteval_gettext を使ってシェルスクリプトを書くこと
  2. xgettext コマンドを使ってシェルスクリプトから翻訳対象文字列を抽出する
    • PO ファイル(テキストファイル)の生成
    • (※ 本当は POT ファイル)
  3. msgfmt を使って PO ファイルから実際に使用するメッセージカタログに変換
    • MO ファイル(バイナリファイル)への変換
  4. 完成(シェルスクリプトを実行すると MO ファイルを参照して翻訳する)

なおメッセージカタログの作り方はコマンドの機能を説明する最低限のものです。メッセージカタログがすでに作成された状態からのメンテナンス(翻訳内容の追加・削除・変更)や具体的なワークフローについては説明していません。これらは他の言語でのやり方と基本的には同じだと思うので、それらを参考にしてください。

xgettext コマンド(PO ファイルの生成)

xgettext コマンドはソースコード(シェルスクリプト)に含まれる翻訳対象の文字列を抽出し、PO ファイル (portable message files) を作成するためのコマンドです。正確には拡張子 .pot の「PO テンプレート」を作成するのですが、正しい説明をするには実際のメッセージカタログのメンテナンスの話に踏み込む必要があり、この記事の対象範囲としては話がややこしくなるだけなので間違ったままにしておきます。PO ファイルはテキストファイルで portable と名前に含まれているように特定の環境に依存するものではありません。メッセージカタログを作るためのソースコードとしてバージョン管理システムにコミットするものです。

一つ注意点があります。それは POSIX Issue 8 では xgettext コマンドは C 言語用の開発ツールであるということです。GNU xgettext は多数の言語に対応していますが、POSIX xgettext が想定している言語は C 言語のみです。したがって C-Language Development Utilities (CD) ツールとしてオプショナルなコマンドとして扱われています。また実際に Solaris 11 の xgettext は C 言語にしか対応していません。ただし先に書いたように PO ファイルはポータブルなテキストファイルであるため、別の環境で GNU xgettext を使って生成した PO ファイルをそのまま使うことができます。

POSIX で標準化された範囲には、ソースコードのプログラミング言語を指定する -L オプションはありませんが、拡張子から判断しているので POSIX で標準化されたオプションだけを使ってシェルスクリプトを取り扱うことができます。せっかく POSIX で標準化したのに xgettext がシェルスクリプトに対応していないのは、どういうことなんだ?と思うかもしれませんが、Solaris 11 が対応していない以上、POSIX としては移植性があるとは言えないのでどうしようもないのです。gettext コマンドの -E オプションぐらい簡単なものなら実装を要求しても受け入れられると思いますが、さすがにシェルスクリプトをパースして翻訳対象文字列を抽出する機能は大きすぎます。

以下は xgettext コマンドの使い方です。POSIX で標準化されているオプションのみを書いています。-j / -n / -d / -p オプションは実際の作業では必要になるかもしれませんが、特別面白い機能でもないので説明は省略します。ドキュメントを参照してください。

xgettext    [-j] [-n] [-d default-domain] [-K keyword-spec]... \
    [-p pathname]                   file...
xgettext -a      [-n] [-d default-domain] \
    [-p pathname] [-x exclude-file] file...

-K (大文字 K)オプションには少し注意が必要です。このオプションは GNU xgettext の -k (小文字 k)オプションがベースとなって作られた新しいオプションであり、現在の GNU xgettext にはありません。新しいオプションを追加した理由はオプションの仕様が POSIX のコマンドライン引数のガイドラインに適合しなかったからです(と POSIX に書いてあります)。GNU xgettext の -k オプションは「省略可能なオプション引数を持つオプション」として実装されています。つまり -k[WORD][WORD] は省略可能)という使い方ができます。この構文は POSIX では認められておらず、オプション引数(WORD の部分)は「持つ」または「持たない」のどちらかです。POSIX の -K オプションは、-K WORD という形で指定し WORD を省略した場合と同等のオプションは -K "" です。

POSIX という標準規格は「既存の実装で移植性があるものを標準規格に組み込む」ものであり新機能の発明は原則として行わないのですが、-K オプションに関しては、オプションの指定方法をわずかに変更しただけで機能を発明したわけではないという扱いなのでしょう。近いうちに GNU xgettext などは対応すると思いますが、それまではオプションの仕様が少し異なるので注意が必要です。将来 -K が普及したときには「POSIX に準拠している -K オプションを使え」と言われるようになるかもしれませんが、現状を見れば明らかに POSIX で標準化されている -K オプションのほうが移植性が低いです。なお -K の使い方についてはこの項目の最後に書いています。

二番目の -a オプションを指定した使い方は、すべての文字列を抽出するという機能ですが、翻訳対象の文字列がどれだけあるのかを調べたり、-x オプションで作成済みの PO ファイルと比較することで、国際化対応がされていない箇所を見つけ出すために使うのだと思います。

xgettext コマンドの仕組みは、シェルスクリプトに書かれた gettext 'Hello World'eval_gettext 'Hello, ${first_name} ${last_name}' のようなコードの gettexteval_gettext などをキーワードとして扱い、それを目印に引数の文字列を翻訳対象の文字列であると認識し、その文字列の情報を抽出して PO ファイルを作成します。

例えば以下のようなシェルスクリプトを xgettext に処理させると、

#!/bin/sh

. "gettext.sh"
name="Koichi"

gettext "Hello World!" && echo
eval_gettext 'Hello, ${name}.' && echo
printf "$(gettext 'Hello, %s.')\n" "$name"

以下のような出力が得られます。詳細は他の記事に任せますが、このファイルを元に翻訳文(msgstr)埋めて行くことで翻訳作業を行います。

# 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: 2023-01-16 11:19+0000\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"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"

#: hello.sh:6
msgid "Hello World!"
msgstr ""

#: hello.sh:7
#, sh-format
msgid "Hello, ${name}."
msgstr ""

#: hello.sh:8
msgid "Hello, %s."
msgstr ""

このような仕組みであるため gettexteval_gettext というキーワードを使ってシェルスクリプトを書く必要があります。しかし、それにしても gettexteval_gettext って単語は長いですよね? このような長い名前を使ってシェルスクリプトを国際化したくありません。短い名前に置き換えたいと思うのは普通のことだと思います。このために -K (-k) オプション があります。 例えば gettext のエイリアスとして _ngettext エイリアスとして _n を使いたい場合、以下のような感じで指定します。

xgettext -k       # デフォルトのキーワードを無視する
xgettext -K ""    # POSIX gettext で同等のオプションを指定する場合

xgettext -k_      # 「_ "メッセージ"」を翻訳対象にする
xgettext -k_:1    # 上記と同等。一番目の引数を翻訳対象にするという意味
xgettext -k_n:1,2 # 一番目と二番目を翻訳対象にした複数形への対応

上記のオプションの指定方法から、とある事実が浮かび上がります。それは翻訳対象の引数の位置は指定できるが固定であるということです。つまり「_ "メッセージ"(改行あり)」「_ -n "メッセージ"(改行なし)」のように、オプションによって翻訳対象のメッセージの位置が変わるようなものには対応できません。

msgfmt コマンド(MO 形式への変換)

msgfmt コマンドは PO ファイル(メッセージカタログのソースコード)から MO (Machine Object) 形式のファイルに変換するツールです。メッセージカタログはプログラムではないので Machine と言っても機械語になっているのわけではありませんが、バイナリ形式でデータアクセスを最適化するためにパディングが埋め込まれたりと最適化されることがあるようで、環境依存する可搬性が低い形式です。今だとバイナリ形式でもポータブルにしようと考えると思いますが、gettext が開発された 1990 年頃のコンピュータでなるべくパフォーマンスを落とさないように考え出されたのしょう。したがって原則としては環境ごとに PO ファイルから MO ファイルに変換する必要があります。最終的に翻訳に必要になるのは MO ファイルのみです。

C 言語のプログラムだと環境ごとにソースコードからバイナリの実行ファイルをビルドするのは普通のことなので、それと同じ感じで考えれば良いのですが、シェルスクリプトの場合はビルドが必要ない作業なので余計な手間に感じますね。

使い方は以下のとおりです。POSIX で標準化されているオプションのみを書いています。詳細はドキュメントを参照してください。

msgfmt [-cfSv] [-D dir] [-o outputfile] pathname...

一つ気になるのは -S オプションです。これは GNU msgfmt で実装されていないようなので、なぜこれが POSIX で標準化されたのか気になったのですが、どうも生成されるファイル名に拡張子 .mo がつくかどうかは実装定義のようです。そのため移植性を高めるために追加したようです。将来的には移植性の改善につながるとは思いますが、現状(おそらく)どこにも実装されていないですし、出力ファイルを指定しても十分なわけで追加する必要があったんでしょうか? POSIX で標準化されていますが(しばらくは)使わないほうが良いと思います。

その他の便利コマンド

GNU gettext にはその他にも以下のようなコマンドが含まれています。これらは POSIX では標準化されていませんが、メッセージカタログのメンテナンスに役に立つでしょう。例えばメッセージカタログの更新には msgmerge コマンドが使われます。

gettextize  msgattrib   msgcat    msgcmp      msgcomm
msgconv     msgen       msgexec   msgfilter   msggrep
msginit     msgmerge    msgunfmt  msguniq     recode-sr-latin

メッセージカタログのメンテナンスに関することは「1.5 GNU gettextの概要」などを参照すると良いでしょう。

コードとメッセージの書き方に関する考察と指針

gettext はオプションを使わないほうが良い

私は POSIX に書いてあることに反し、シェルスクリプトでは gettext コマンドを -e / -E などのオプションをなにも指定せずに使ったほうが良いと思っています。その理由は複数あるのですが最終的には関連ツールが対応していないし、今後もおそらく対応しきれないと考えているからです。

xgettext の話の最後で、gettext というキーワードが長いから -k を使って短く置き換えられることができる。しかしその場合はエイリアスとしたキーワードで -e が使えないという話をしました。引数の位置がずれてしまうからです。

xgettext -k_      # gettext のエイリアスとした _ を使えるようにする
xgettext -k_:1    # 上記と同等で一番目の引数を翻訳対象とすると明示した場合

gettext "Hello World"     # 問題なし
gettext -e "Hello World"  # 問題なし(msgid はエスケープシーケンス解釈後の文字列)

_ "Hello World"           # 問題なし
_ -e "Hello World"        # 対応できない(引数が一番目ではないから)

gettext -e "Hello World" がうまく動くことが不思議だと思いますが、その理由は GNU xgettext の機能として キーワードが gettext の場合に特別な処理をしているからです。これと同じことを追加キーワードである _ で行うことはできません。もしかしたら将来的にそれができるような機能が追加されるかもしれませんが、POSIX xgettext ではそもそもシェルスクリプトに対応していないので標準化は期待できません。

なお少し前のバージョンの GNU xgettext では gettext を特別扱いする処理が含まれておらず、一番目の引数である -e をオプションではなくメッセージと扱ってしまいます。また -s オプションは現行のバージョンである 0.21.1 でも対応していません。比較的最近修正されたものであるため、古いバージョンでは動作が違うという問題があります。

このようなオプションの有無によって引数の位置がずれるという性質はシェルスクリプト特有の事情だと思います。他の言語では _("メッセージ") のように引数の位置は固定のはずです。後ろに追加の引数を加えることはあっても、前に引数を追加するようなものはシェルスクリプト以外ではあまり見かけません。xgettext は複数の言語に対応していますが、シェルスクリプト固有の事情によって引数の位置を柔軟に指定できるような複雑な機能を追加するとは考えにくいです。したがってシェルスクリプトでは gettext のオプションは使うべきではないという結論になります。もちろんシェルで使う場合にはオプションを使って問題ありません。

gettext のオプションを使わないと決めた場合、問題になるのが移植性です。POSIX でも指摘されている通り gettext はオプションを指定しない時、デフォルトが -e であるか -E であるか指定されていません。調査したところ大半は -E がデフォルトなので気にする必要がある人は少ないかもしれませんが、Solaris は例外で -e がデフォルトです。もし Solaris が -E オプションを実装していたならば、alias またはシェル関数でラップするだけで簡単にデフォルトを -E に変更することができます。

# もし Solaris が -E オプションを実装していたならば…
alias gettext="gettext -E"             # alias を使う場合
gettext() { command gettext -E "$@"; } # シェル関数を使う場合

# 上記のコードのどちらかを使えば Solaris でも -E として扱われる
gettext "Hello, World"

しかしながら、残念なことに Solaris の gettext-E オプションを実装していないのでこのような簡単な方法が使えません。-E オプションが実装されていない(= 常にエスケープシーケンスを解釈してしまう)のであれば、\\\ にエスケープ(文字列の置換)してやればこの問題を回避することができます。文字列の置換は Solaris 11 の /bin/sh として使われている ksh や、bash、zsh などの拡張機能を持ったシェルなら "${var//'\'/'\\'}" を使うだけで簡単に実装できます。しかし POSIX シェルでは使えません。その問題を解決し POSIX シェルでも実行可能したのが以下のコードです。文字列の置換部分は replace_all 関数です。以下では replace_all 関数を使い gettextngettext に移植性の問題を解決しています。(最新のコードには修正が入っている可能性があるのでリポジトリを参照してください)

if (eval ": \"\${PPID//?/}\"") 2>/dev/null; then
  # POSIX で標準化された機能ではないが速い
  replace_all() {
    eval "$1=\${2//\"\$3\"/\"\$4\"}"
  }
else
  # POSIX で標準化された機能のみを使った実装
  replace_all() {
    set -- "$1" "$2$3" "$3" "$4" ""
    while [ "$2" ]; do
      set -- "$1" "${2#*"$3"}" "$3" "$4" "$5${2%%"$3"*}$4"
    done
    eval "$1=\${5%\"\$4\"}"
  }
fi

if gettext -E "" >/dev/null 2>&1; then
  # gettext -E が使える実装は、-E オプションを使う
  gettext() {
    command gettext -E "$@"
  }
  ngettext() {
    command ngettext -E "$@"
  }
else
  # Solaris 11 では -E オプションがないので \ をエスケープして回避する
  gettext() {
    gettext_arg1=''
    replace_all gettext_arg1 "$1" '\' '\\'
    shift
    set -- "$gettext_arg1" "$@"
    unset gettext_arg1
    command gettext -e "$@"
  }
  ngettext() {
    ngettext_arg1='' ngettext_arg2=''
    replace_all ngettext_arg1 "$1" '\' '\\'
    replace_all ngettext_arg2 "$2" '\' '\\'
    shift 2
    set -- "$ngettext_arg1" "$ngettext_arg2" "$@"
    unset ngettext_arg1 ngettext_arg2
    command ngettext -e "$@"
  }
fi

上記のワークアラウンドコードを入れることで、移植性の問題を解決することができます。新しく再定義した gettext / ngettext 関数はオプションには対応していませんが、そもそも使わないと決めたので問題ありません。

gettext-e / -E オプションの問題は引数の位置の問題だけではありません。指定されたオプションによって xgettext で生成される msgid が異なります。

gettext 'Hello World\n'
gettext -e 'Hello World\n'
gettext -E 'Hello World\n'

msgid "Hello World\\n"  # \ という文字列はエスケープされて \\ で出力される
msgid "Hello World\n"   # 元の文字列の意味は「改行」だから、エスケープして \n
msgid "-E"              # -E に対応していない

# ↑ 注意 GNU xgettext 0.19.8.1 で生成すると二番目は -e と出力される

GNU xgettext が -e オプションを指定した時に挙動を変えて、別の msgid で出力する理由はわかると思います。では他の xgettext の実装はどうでしょうか? その答えは実装次第です。xgettext は他のプログラミング言語も考慮すると巨大なソフトウェアになるので、そんなものを実装するぐらいなら GNU xgettext をそのまま採用するでしょう。実際に GNU xgettext を使っているようですが、バージョンは古いかもしれません。そして Solaris 版の xgettext は独自実装ですがシェルスクリプトに対応していません。つまりオプションを指定すると使用する xgettext のバージョンによって出力が安定しないのです。この問題は将来収束するかもしれませんが、現状オプションを付けるとトラブルの元となります。ちなみにですが、Solaris でもパッケージからインストールすれば GNU xgettext を使うことはできます(プログラム名は gxgettext)。

Solaris の gettext がデフォルトでエスケープを解釈するので、POSIX としてはデフォルトを -e-E のどちらか片方に指定することはできません(既存の実装を壊してしまうことは POSIX はしない)が、事実上多くの環境で -E がデフォルトであり、それが今後も変わることはないでしょう。したがって POSIX で -e-E のどちらかを指定しなければならないと書いてあったとしても、現実のツールを活かすためにシェルスクリプトでは gettext のオプションは何も指定しないのが現実的です。指定しないことによって発生する問題はシェルスクリプトの技術で解決することができます。また xgettext は移植性がある PO ファイルを生成し、そのソースコードはリポジトリにコミットされるわけで、現実としては「誰かが GNU xgettext を使って PO ファイルを生成する」という流れになるでしょう。

エスケープシーケンスと printf 書式のジレンマ

翻訳対象とする文字列には可能な限りエスケープシーケンスを入れない方がいいだろうと私は思っています。印刷不可能な文字を出力した所で、印刷できない = 人間は読めないわけで、エスケープシーケンスを入れる場合で考えられるのは、おそらくタブ (\t) と 改行 (\n) ぐらいだと思います。

例えばメッセージに改行文字を含めるとします。その場合、正しい書き方は以下の上三つです。下は三つは改行ではなく \n という文字列が含まれてしまうので間違った書き方です。

# 期待したとおりに正しく改行が出力される
gettext "msg1
"
gettext $'msg2\n'
gettext -e 'msg3\n'

# 改行を出力したいのに \n という文字列が含まれてしまう
gettext 'msg4\n'   # => msg4\n
gettext "msg5\n"   # => msg5\n("" は \n というエスケープシーケンスには非対応)
gettext "msg6\\n"  # => msg6\n(上記の正しい書き方)

この時 xgettext の出力結果の msgid は以下のようになります。PO ファイルでも制御文字をエスケープするという仕様があるので以下のような出力になりますが、上三つはメッセージには改行文字が含まれていることを意味しています。

msgid "msg1\n"
msgid "msg2\n"
msgid "msg3\n"

msgid "msg4\\n"
msgid "msg5\\n"
msgid "msg6\\n"

あるべき姿の話をするのであれば、メッセージに改行文字やタブ文字を入れたい場合、ソースコードにそのまま改行文字やタブ文字を書くのが理想的です。しかしソースコードに改行文字やタブ文字をそのまま書くと読みづらく不格好になります。したがって $'...' を使うのが最適です。ソースコード上は \n が使われていますが、文字としては、正確に改行として扱われます。しかし $'...' は POSIX Issue 8 で標準化される新しい機能で、dash や yash など一部のシェルでは使えません。早くサポートしてほしいですね。ちなみに FreeBSD sh では使えます。

補足ですが、変数に改行文字を入れて gettext "msg${LF}" というように書くことはできません。変数を使うと xgettext はそれを翻訳対象のメッセージとして扱いません。変数を使うと実行時まで文字列が確定されず msgid がわからないので当然の話ではあります。

さて、ここで問題になってくるのが printf コマンドと組み合わせる場合です。書式(%s など)を使って変数の値をメッセージに埋め込む場合に printf コマンドを使用しますが、printf コマンドは書式と共にエスケープシーケンスも解釈してしまいます。そうなるともしメッセージの中に \n という文字列が含まれていると改行に変換されてしまいます。さあ困りました。

この問題の解決方法の一つは、メッセージを「ただのテキスト」として出力するのではなく「printf にわたす書式」の場合には \n という文字列になるように定義することです。つまりこの項の最初に書いた「間違った書き方」である以下の書き方が正しいことになります。

# 改行を出力したいのに \n という文字列が含まれてしまう
gettext 'msg4\n'   # => msg4\n

# が「printf にわたす書式」の場合にはこれが正しい
printf "$(gettext 'msg4\n')"   # => msg4

一応この方法で問題を解決することはできますが、メッセージの使われ方によって改行を \n にしたり \\n にしたりするのはナンセンスです。このような問題があるから、私は可能な限りメッセージにエスケープシーケンスを入れないほうがいいだろうと思っているわけです。もちろんエスケープシーケンスを入れたい場合もあると思います。普通は「諦める」で十分だと思います。しかし私は紛らわしいことが嫌いで混乱するようなものを使いたくありません。

この話の根本的な問題点は printf コマンドが % を使った書式と \ を使ったエスケープシーケンスの両方を解釈することにあります。本当に欲しいものは % を使った書式だけを解釈する printf コマンドです。ならば話は簡単です。書式に含まれる \\\ にエスケープすればよいだけです。似たような話を少し前にしましたよね? 先ほど提示した replace_all 関数を使えば % を使った書式だけを解釈する printf を作るのは簡単です。

replace_all() { ...; } # 省略。コードは上の方に書いています。

# printf と同等の機能だが、エスケープシーケンスを解釈しない
putf() {
  putf_arg1=''
  replace_all putf_arg1 "$1" '\' '\\'
  shift
  set -- "$putf_arg1" "$@"
  unset putf_arg1
  # shellcheck disable=SC2059
  printf -- "$@"
}

# 念のために補足しておくと printf の -- は
# debian 3.0 (2002) までの pdksh 5.2.14 や zsh 4.0.4 までは
# 非対応なのですが、誰も気にしないでしょう?

この putf 関数を使うことで、先に定義した putln 関数でそのままテキストとして出力した場合でも、putf 関数で使う場合でも、同じメッセージを使うことができます。

さて残る問題は $... に対応していないシェルがあるという問題です。この問題により、ソースコードに \t\n と書くことができません。実はこちらに関しても解決方法のアイデアが浮かんでおり、それで完全に解決できるのか十分検証できてないので簡単な紹介だけします。一言で言えば「対応していないシェルでも $... を使えばいいんじゃね?」というアイデアです。

# bash では「foo + 改行」と出力されるが
# dash では「$foo\n」と出力されてしまう
printf '%s' $'foo\n'

このように文字列の解釈に違いがありますが、dash(や私が調べた全ての POSIX シェル)でエラーにはならずエスケープシーケンスが含まれた元の文字列が情報を欠落させることなく保持されています。つまり自力でエスケープシーケンスを解釈すれば良いということです。実際には自力で解釈せずとも printfgettext -e が利用できそうですが、別に利用できなくともエスケープシーケンスの解釈を自力でやるコードはすでに似たようなものを書いた事があるので今更です(参照「printfコマンドが\xHHに対応してないなら自力で対応させれば良い」)。もちろんすべての箇所で $... が使えるようになるわけではありませんが、翻訳可能なメッセージなど限定的な場面では利用することが可能だと思います。これらのアイデアを組み込んで、高い移植性を実現した新しい翻訳ライブラリを設計しています。以下は概念実証コードです。

if [ $'x' = x ]; then        # もし $'...' に、
  p() { printf '%s' "$1"; }  # 対応していればそのまま出力し、
else
  p() { printf "${1#'$'}"; } #  対応していれば頭 $ を削って printf で出力する
fi

# $'...' に非対応の dash でも同じ出力が得られる
p $'foo\n' # => foo
p 'foo'    # 普通に書いても良い
p $'$foo'  # 頭が $ で始まる場合は注意が必要(レアケースのはず)

gettext がインストールされていない環境で動かすには?

国際化のために GNU gettext または gettext に依存してしまったら、それらがインストールされていない環境で動かなくなるじゃないか、と思う人がいるかも知れません。入っていない環境でも動作するようにするには自分でダミー関数を定義するだけです。もちろんダミー関数なので翻訳機能はありません。

if type gettext.sh >/dev/null 2>&1; then
  . gettext.sh
else
  gettext() { printf '%s' "$1"; }
  ngettext() {
    [ "$3" -eq 1 ] || shift
    printf '%s' "$1"
  }
fi

gettext.sh を使わない場合は以下のようになるでしょう。

if ! type gettext >/dev/null 2>&1; then
  gettext() { printf '%s' "$1"; }
fi

if ! type ngettext >/dev/null 2>&1; then
  ngettext() {
    [ "$3" -eq 1 ] || shift
    printf '%s' "$1"
  }
fi

参考 古いシェル依存の手法

ここから紹介するのは、過去(?)に使われていた古い手法です。通常は使う必要はないと思います。歴史的な資料として書き残しています。

bash の $"..." を使う方法(非推奨)

この方法は bash 2.x の頃には使われていた(可能性がある)方法です。他のシェルでは使えませんし、セキュリティ上の理由から非推奨となっています。xgettext はこの書き方に一応対応していますが、使ってはならないと警告が出力されます。

sh-i18n.bash2.sh:23: 警告: 文法 $"..." はセキュリティ上の理由で推奨されません. 代わりに eval_gettext を使ってください

非推奨の理由として移植性の問題(bash が国際化対応としてビルドされていなければならない)も挙げられていますが、gettext コマンドが使えるような現行のシステムなら条件はクリアしている気がします。bash 以外のシェルでは使えないというのは、自明であるためか述べられていません。詳しくは 15.5.13 bash - Bourne-Again Shell Script を参照してください。

この方法は現実的には選択肢になりませんが、最も簡潔に書くことができ、外部コマンドに依存せず、シェルに実装されている機能を使うので速度も速い方法なので、個人的に復活しないのだろうかと考えています。複数形の対応機能がないので何かしらの追加対応が必要になるとは思いますが。

コードの書き方は簡単で、以下のように書くだけです。bash は $"..." を見つけると内部で gettext API を呼び出し、メッセージカタログから該当する翻訳文字列を取得します。メッセージカタログの作成方法は gettext を使った方法と同じです。メッセージカタログの場所を見つけるために環境変数 TEXTDOMAINTEXTDOMAINDIR の設定も必要です。(環境変数が未設定の場合、デフォルトの位置とプログラム名から探すかもしれません。詳細は調べておらず未検証です。)

export TEXTDOMAIN="sh-i18n"
export TEXTDOMAINDIR="$PWD/locale"

name="Koichi"

echo   $"Hello"
print  $"Goodbye"
printf $"Welcome %s\n" "$name"

参考として bash のドキュメントでは、国際化の機能として「3.1.2.5 Locale-Specific Translation 」で $... の機能について説明があります。そしてここで説明されている bash --dump-po-strings スクリプトファイル を使うことで、翻訳対象の文字列を抽出することもできます。

$ bash -dump-po-strings  demo.sh
#: demo.sh:6
msgid "Hello"
msgstr ""
#: demo.sh:7
msgid "Goodbye"
msgstr ""
#: demo.sh:8
msgid "Welcome %s\\n"
msgstr ""

しかしながら、対象は $... だけで gettext などに対応しているわけではなく、機能も少ないのでわざわざ使うことはないでしょう。

ks93 の $"..." を使う方法

$"..." を使った翻訳は ksh でも対応しています。bash と同様の書き方で使うことができますが、bash が gettext API を内部で使用しているのに対して ksh では catgets を使用します。それに伴い環境変数の指定方法が違います。コードの書き方自体は bash と同じです。というよりも bash が ksh93 の仕様に合わせたというべきでしょう。コードの書き方は大したことがないので、説明の内容は catgets 用のメッセージカタログについての解説の話に集約されます。

私がこちら(Localizing Korn Shell Scripts)の記事を参考にしながら文章を書き始めた都合で、自分のリポジトリのコードとは違う説明になっています。書き直すのが面倒なのでご了承ください。おそらく誰も必要としないし、動くコードはリポジトリにあるので、この程度で十分だと思います。ちなみに参照元のページは環境が異なるからか使用しているツールがなくて困りました。実際には POSIX (初期の XPG2から)で標準化されている getcat コマンドだけで十分です。

まずシェルスクリプトは以下のようなものです。

demo.sh
# メッセージカタログの場所(%l や %N の意味は下記参照)
export NLSPATH="$PWD/locale/%l/%N.cat"

name="Koichi"

echo   $"Hello"
print  $"Goodbye"
printf $"Welcome %s\n" "$name"

NLSPATH の意味は参照元より引用します。%l%N は環境変数 LANGja_JP.UTF-8 のそれぞれの要素(ja など)に展開されます。以下は参照元からの囲繞です。

A leading colon or two adjacent colons (‘::’) is equivalent to specifying %N. A string describing the current locale is expected to have the form language[_territory[.codeset]], e.g. en_US.utf8, de_DE.utf8, as all three components are used by NLSPATH formatting elements.

  • %N This format element is substituted with the name of the message catalog file.
  • %L This format element is substituted with the current locale name.
  • %l This format element is substituted with the language component of the current locale name.
  • %t This format element is substituted with the territory component of the current locale name.
  • %c This format element is substituted with the codeset component of the current locale name.

環境変数 NLSPATH の設定により以下のディレクトリ構造となります。

$ tree locale/
locale/
├── C
│   ├── demo.cat
│   └── demo.msg
└── ja
     ├── demo.cat
     └── demo.msg

説明では *.cat ファイルがあるディレクトリと同じ場所に *.msg ファイルを置いていますが、これはメッセージカタログのソース(テキストファイル)であり、翻訳に必要なのは *.cat (バイナリファイル)だけです。実際には *.msg はリポジトリにコミットするものとして本来は別の場所に置いたほうが良いでしょう。*.msg ファイルの内容は以下のとおりです。

locale/C/demo.msg
$quote "
$set 3  This is the C locale message set
1 "Hello"
2 "Goodbye"
3 "Welcome %s\\n"
locale/ja/demo.msg
$quote "
$set 3  This is the ja locale message set
1 "こんにちは"
2 "さようなら"
3 "ようこそ %s\\n"

このファイルをどうやって生成し、メンテナンスしていくのかはよくわかりませんでしたが、ksh コマンドの -D オプションを使って翻訳対象の文字列を抽出することができます。ちなみに -D (--dump-strings) オプションは bash にも実装されているようですが、gettext を使う bash では意味がありません。参照元には -D オプションのことがドキュメントに書かれていないと書いてあるのですが、実際には書いてあります。

$ ksh -D demo.sh
"Hello"
"Goodbye"
"Welcome %s\n"

最初に生成する時は問題ありませんが、翻訳対象の文字列が変わった時にそれをどうやってメンテナンスするのだろうと疑問になりますね。おそらく何かしらのツールがあると思うのですが。

*.cat ファイルは .msg ファイルから生成します。これには POSIX で標準化されている getcat コマンドを使用します。

gencat locale/C/demo.cat locale/C/demo.msg
gencat locale/ja/demo.cat locale/ja/demo.msg

*.cat ファイルは環境依存するバイナリなので、環境ごとに生成する必要があります。互換性があればそのまま動くかもしれません。

重要なポイントとして日本語 (ja) ロケールで使う場合にも、C ロケールの cat ファイルが必要だということです。これは内部で catgets を使っているから(のはず)です。そこまで詳しく踏み込んで調べていないので、そのように説明しているものを見つけていないのですが、catgets は ID(番号)から翻訳文を取得します。例えば 2 は「さようなら」です。しかしシェルスクリプトには、C ロケールで表示するメッセージ($"Goodbye")が書かれています。ここから日本語の翻訳文を取得するとしたら、C ロケールのメッセージカタログから "Goodbye" の番号を検索するしかないはずです。

さいごに

以上、シェル固有の廃れた方法も含めて、全部で四つの手法を解説しました。いったいどれだけの人がこの記事を参考にするのか疑問ですが、これだけまとめていれば他の人は困らないんじゃないかなと思います。海外も含めて検索しても情報が少ないので。

そして、この記事をまとめながら思ったのですが、gettext.sh が使いづらい! なぜ eval_gettext なんて長い関数名なのでしょうか? また移植性周りの問題、変数名への依存、エスケープシーケンスの問題など、細かい不満点がいくつもあります。

ということで gettext.sh に代わる別の翻訳ライブラリを作りたくなってしまいました。基本的なところはできてると思うのですが、今すぐ使う予定はないのでテストは手作業でざっくりです。さすがに Bourne シェルは切り捨てますが、POSIX sh(dash、/bin/sh)にも対応して、どの環境でも動くように作っています。実行時に必要なコマンドは gettext コマンドと ngettext コマンドだけです。printf<数値>$ の機能は自力でシェルスクリプトで実装しています。つまりこれは「国際化のために <数値>$ を POSIX で標準化したのに実は不要だった」ということを意味してます。もっともこれはこれで便利なので撤回すべきなんて言うつもりはありません。シェルの拡張機能はいろいろ標準化していってほしいです。不可能ではないことと簡単にできるというのは別の話ですから。

ということで、シェルスクリプトの国際化手法の紹介でした。この記事の続きは「POSIX 準拠でどのシェルにも対応したシェルスクリプト用の新しい翻訳ライブラリを作った」という話になるでしょう。(追記 作りました ⇒ シェルスクリプト用の国際化ライブラリの決定版! sh-gettext を作りました ~ gettext.sh 代替・すべてのPOSIXシェルと環境に対応

7
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
8