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

BCP 47 言語タグを正しく理解する — ja-JP・zh-Hans・pt-BR の意味

BCP 47 の言語タグは、言語サブタグに文字体系・地域などをハイフンでつないだ文字列です。ja、zh-Hans、zh-Hant-HK、pt-BR がその例です。結論から書くと、中国語は文字体系で分け、ポルトガル語とスペイン語は地域で分けます。文字体系は画面に出る字そのものを変え、地域は使う語を変えるからです。以下に出てくるタグの出力はすべて Node 22 で実行して確認したもので、記憶から書いたものはありません。

タグの構造と、各サブタグの出所

BCP 47 は単独の文書ではなく、現在は2本の RFC を指す呼び名です。RFC 5646 がタグの構文を、RFC 4647 がタグ同士の照合を定めています。どちらにも有効なコードの一覧は載っていません。一覧は IANA の Language Subtag Registry で、コードが存在するか怪しいときに見る場所はここだけです。サブタグの順番は固定で、順序が違えばタグとして成立しません。

  • 言語。先頭で必須。ISO 639-1 にあれば2文字(ja、en、zh、pt)、なければ ISO 639-2 か ISO 639-3 の3文字(フィリピノ語の fil)。
  • 文字体系。ISO 15924 の4文字。Hans が簡体字、Hant が繁体字。複数の文字体系で書かれ、区別が必要なときだけ付ける。
  • 地域。ISO 3166-1 alpha-2 の2文字、または国より広い範囲を表す UN M.49 の3桁数字。419 はラテンアメリカ・カリブ地域で、これが es-419 の正体。
language[-script][-region][-variant...][-extension...][-x-private]

zh-Hant-HK
 |   |   |
 |   |   +-- 地域 ISO 3166-1 alpha-2: 香港
 |   +------ 文字体系 ISO 15924: 繁体字
 +---------- 言語 ISO 639-1: 中国語

es-419             国ではなく UN M.49 の地域コード
de-DE-1996         登録された変種(1996年正書法)
th-TH-u-nu-thai    u- 拡張: タイ数字
en-US-x-private    x- 私用: 自分のシステム内だけで有効

正規形と非推奨コードを実行結果で確認する

タグの表記を手で直すのはやめて Intl.getCanonicalLocales に渡します。RFC 5646 は言語を小文字、文字体系を先頭だけ大文字、地域を大文字にすることを推奨しますが、これは読みやすさの慣習で文法上の規則ではありません。タグの比較は大文字小文字を区別しないので、ja-jp と JA-JP と ja-JP は1つの ja-JP にまとまります。

出力の短い置き換えは、古いデータや取引先の表で今も出会う非推奨コードです。列見出しが iw ならその列はヘブライ語で、in はインドネシア語、ji はイディッシュ語、mo はルーマニア語です。

2行は注意が必要です。tl が fil になるのは CLDR の別名データによるもので、IANA レジストリ側の Preferred-Value ではありません。正式に非推奨だと説明する前にレジストリの該当項目を確認してください。no は no のままです。ノルウェー語のマクロ言語で、ブークモールの nb とニーノルスクの nn は置き換えではなく別の現行タグです。届いた訳文がブークモールなら、そのファイル名は no ではありません。

この関数はコードが存在するかを確認しません。見ているのは形だけで、jp も english も通ります。jp には CLDR の推定データがないので maximize は何も足さず、ロケールデータもないので lookup 方式の照合からも落ちます。

$ node -e 'for (const t of ["ja-jp","JA-JP","zh-hans-cn","sr-latn","en-us-x-private",
    "iw","in","ji","mo","tl","no"]) console.log(t, "->", Intl.getCanonicalLocales(t)[0])'
ja-jp            ->  ja-JP
JA-JP            ->  ja-JP
zh-hans-cn       ->  zh-Hans-CN
sr-latn          ->  sr-Latn
en-us-x-private  ->  en-US-x-private
iw               ->  he
in               ->  id
ji               ->  yi
mo               ->  ro
tl               ->  fil
no               ->  no

Intl.getCanonicalLocales(["ja-jp","JA-JP","ja-JP"])  ->  ["ja-JP"]

Intl.getCanonicalLocales("jp")                       ->  ["jp"]
Intl.getCanonicalLocales("english")                  ->  ["english"]
new Intl.Locale("jp").maximize()                     ->  jp
Intl.NumberFormat.supportedLocalesOf(["ja-JP","zh-Hant-TW","jp","xx-YY"],
  { localeMatcher: "lookup" })                       ->  ["ja-JP","zh-Hant-TW"]

// いずれも RangeError: Incorrect locale information provided を投げる
ja_JP   en-USA   ja-JPN   zh-CHS   zh-Hans-Hant   x-private   i-klingon

素のタグの意味と、zh-CN と zh-Hans の違い

zh や pt のようなサブタグ1つのタグは曖昧ではなく、既定値が決まっています。maximize が補う文字体系と地域は、短いタグで出したときに黙って受け入れている既定値の一覧として読んでください。ラテン文字のセルビア語なら sr では足りず sr-Latn が必要になります。

逆向きの minimize はほぼ罠です。zh-Hans-CN は zh まで縮み、避けたかった曖昧な名前に戻ります。zh-Hant-TW は zh-Hant ではなく zh-TW になります。文字体系を地域より先に落とすからです。世に zh-CN と zh-TW があふれる理由の一つがこれです。minimize は2つのタグが同義かの判定に使い、ファイルの命名には使いません。

ここから zh-Hans と zh-CN の違いがはっきりします。zh-Hans はテキストが簡体字で書かれていることを示し、読者の居場所は何も言いません。zh-CN は読者が中国本土にいることを示し、文字体系は推定に委ねます。タグ付けは文字体系で行ってください。シンガポールやマレーシアで読まれる簡体字ビルドも zh-Hans であり、地域で分けると、そうした読者と食い違うか、簡体字が読まれる場所ごとに地域タグを増やす羽目になります。既定値の決まり方は CLDR とロケールデータの決まり方 で扱っています。

new Intl.Locale(x).maximize()
  zh  ->  zh-Hans-CN      pt  ->  pt-Latn-BR      sr  ->  sr-Cyrl-RS
  ja  ->  ja-Jpan-JP      es  ->  es-Latn-ES      ar  ->  ar-Arab-EG
  en  ->  en-Latn-US      ko  ->  ko-Kore-KR      az  ->  az-Latn-AZ

new Intl.Locale(x).minimize()
  zh-Hans-CN  ->  zh        zh-Hant-TW  ->  zh-TW      zh-Hant-HK  ->  zh-HK
  ja-JP       ->  ja        pt-BR       ->  pt         en-Latn-US  ->  en

ゲームで実際に使うロケール一覧と選び方

これは標準ではなく実務用の出発点です。25本すべてを getCanonicalLocales に通して変化しないことを確認済みなので、表記を直さずそのまま設定ファイルに貼れます。

判断が要るのは中国語の行で、多くのタイトルは zh-Hans と zh-Hant で足ります。zh-Hant-HK を足すのは香港向けの語彙を実際にレビューできるときだけで、台湾版の複製を置く意味はありません。es-MX と es-AR を別々に用意できないときの現実的な1本が es-419 で、文面の違いは ラテンアメリカ向けとスペイン向けスペイン語の違い にまとめてあります。ポルトガル語はほぼ常に pt-BR が先で、細部は ブラジルポルトガル語で注意すること を参照してください。

ja-JP や en-US に付く地域サブタグは文面の選択にほとんど寄与せず、新規なら短いほうが綴り間違いの機会が1つ減ります。例外は en-US と en-GB、pt-BR と pt-PT、es-ES と es-419、fr-FR と fr-CA で、この4組では地域が文面を変えます。

en-US       アメリカ英語              pl-PL       ポーランド語
en-GB       イギリス英語              tr-TR       トルコ語
ja-JP       日本語                    ru-RU       ロシア語
ko-KR       韓国語                    it-IT       イタリア語
zh-Hans     簡体中国語                de-DE       ドイツ語
zh-Hant     繁体中国語                fr-FR       フランス語(フランス)
zh-Hans-CN  中国語(簡体字、中国)      fr-CA       フランス語(カナダ)
zh-Hant-TW  中国語(繁体字、台湾)      ar          アラビア語
zh-Hant-HK  中国語(繁体字、香港)      th-TH       タイ語
pt-BR       ポルトガル語(ブラジル)    vi-VN       ベトナム語
pt-PT       ポルトガル語(欧州)        id-ID       インドネシア語
es-ES       スペイン語(欧州)
es-419      スペイン語(ラテンアメリカ)
es-MX       スペイン語(メキシコ)

各社の「タグではない」呼び名と変換表の作り方

言語切り替えが壊れる原因で最も多いのは、プラットフォームが BCP 47 を話すと思い込むことです。内側では言語の正体を正規形の BCP 47 タグ1つで持ち、各社の綴りへの変換は端に置いた1枚の表で行ってください。

  • Steam の API 言語コードはタグではなく英単語で、BCP 47 タグから機械的に導く方法はありません。ストア側の扱いは Steam で対応言語として数えられる条件 にあります。
  • Unity には噛み合わない2系統があります。SystemLanguage の列挙は地域の軸を持たない英単語で、古い Chinese は簡体・繁体の2つより前からある値です。Localization パッケージは .NET の CultureInfo 名に基づく LocaleIdentifier を使い、こちらは ja-JP や zh-Hans の形です。正確な値は Unity スクリプトリファレンスの SystemLanguage のページで確認してください。
  • Unreal のカルチャ名は BCP 47 風で、ローカライズターゲット配下のフォルダ名も同じ文字列になります。使用中バージョンの一覧は Unreal 公式ドキュメントのローカライズの章で確認してください。
  • Android のリソース修飾子は独自表記で、地域だけなら r の接頭辞、文字体系が絡むと先頭に b+ を付けハイフンをプラスに変える形が必要です。b+ 形式は一定以上の最小 API レベルが前提なので、Android 公式ドキュメントのリソース修飾子の表で確認してください。詳細は strings.xml とリソース修飾子の仕組み にあります。
  • Apple のディレクトリ名は言語 ID に lproj を付けた形で、かなり古いプロジェクトには Japanese.lproj のような旧来の英語名が残ります。
BCP 47      Steam        Unity SystemLanguage   Android           Apple
zh-Hans     schinese     ChineseSimplified      values-b+zh+Hans  zh-Hans.lproj
zh-Hant     tchinese     ChineseTraditional     values-b+zh+Hant  zh-Hant.lproj
ja-JP       japanese     Japanese               values-ja         ja.lproj
ko-KR       koreana      Korean                 values-ko         ko.lproj
pt-BR       brazilian    Portuguese             values-pt-rBR     pt-BR.lproj
es-419      latam        Spanish                values-b+es+419   es-419.lproj
es-ES       spanish      Spanish                values-es         es.lproj

各行は出荷前に、各社の現行の公式ドキュメントから写して確認する

フォールバックが効くファイル名とキーの付け方

照合は RFC 4647 が定めています。Lookup は末尾からサブタグを削りながら1つだけ最良の一致を返し、途中の1文字サブタグは飛ばします。ローダーが欲しいのは Lookup で、zh-Hant-HK の要求は zh-Hant-HK、zh-Hant、zh、既定値の順に試されます。

下の2行目が問題の核心です。zh-Hans は zh-Hant-HK の接頭辞ではないので、切り詰めはそこを素通りして zh に進み、何も見つけずに終わります。フォールバックは接頭辞の連なりの上でしか働かないので、ファイル名はその接頭辞そのものでなければなりません。

  • 中国語を zh だけで分ける。簡体字と繁体字の読者を同じ文面へ押し込むことになり、症状はファイルの欠落ではなく字が違うという形で出るので、レビューまで誰も気づきません。zh-Hans と zh-Hant を出し、素の zh は意図した既定値としてだけ足します。
  • ポルトガル語やスペイン語を pt・es だけで分ける。文面は読み込まれて一見壊れていないまま、ポルトガルの読者がブラジルの綴りを読みます。
  • 表記の揺れを境界で止めない。ja_JP は POSIX 系のロケール識別子で、getCanonicalLocales は例外を投げます。大文字小文字はタグの比較では無視されても、ローダーの文字列比較とファイルシステムは無視しません。境界で一度正規形に通し、その形で保存してファイル名を付けます。
  • コードを自作する。jp は日本語ではありません。構文検査は通るのに解決先がなく、エラーも出ません。言語は ja で、JP は地域です。
  • 広いファイルを用意せずに具体的すぎる名前を付ける。繁体字のファイルが zh-Hant-TW だけなら、zh-Hant-HK の要求は何にも当たりません。実際に区別している粒度で名前を付けます。
  • ファイル名だけ直してキーを放置する。キー側の設計は 壊れにくい文字列キーの設計 が扱っています。
function lookup(requested, available) {
  const parts = requested.toLowerCase().split("-");
  const have = new Map(available.map((t) => [t.toLowerCase(), t]));
  while (parts.length > 0) {
    const candidate = parts.join("-");
    if (have.has(candidate)) return have.get(candidate);
    parts.pop();
    if (parts.at(-1)?.length === 1) parts.pop(); // 1文字サブタグは飛ばす
  }
  return null;
}

lookup("zh-Hant-HK", ["en", "zh-Hant", "ja"])  ->  "zh-Hant"
lookup("zh-Hant-HK", ["en", "zh-Hans", "ja"])  ->  null
lookup("ja-JP", ["en", "ja"])                  ->  "ja"
lookup("th-TH-u-nu-thai", ["en", "th"])        ->  "th"

関連記事