esspec

ECMAScript仕様輪読会:ロケール依存の大文字・小文字変換とUnicodeのケース変換

今回扱った範囲

今回の中心テーマは、文字列の大文字・小文字変換を行う次のメソッドです。

String.prototype.toLocaleUpperCase()
String.prototype.toLocaleLowerCase()
String.prototype.toUpperCase()
String.prototype.toLowerCase()

特に、通常版の toUpperCase() / toLowerCase() と、ロケール依存版の toLocaleUpperCase() / toLocaleLowerCase() がどのように違うのかを、ECMA-262、ECMA-402、Unicodeの仕様を行き来しながら確認しました。

今回の大きなポイントは次のとおりです。


最初に確認した通常版とロケール依存版の結果

まず、いくつかの文字列に対して toLocaleUpperCase() と toUpperCase() を実行し、違いが出るかを確認しました。

function print(value) {
  console.log(JSON.stringify(value));
}

print("abcde".toLocaleUpperCase());
print("abcde".toLocaleUpperCase());
print("ぁぃぅぇぉ".toLocaleUpperCase());
print("à".toLocaleUpperCase());
print("🔡🔠".toLocaleUpperCase());

print("abcde".toUpperCase());
print("abcde".toUpperCase());
print("ぁぃぅぇぉ".toUpperCase());
print("à".toUpperCase());
print("🔡🔠".toUpperCase());

結果は次のようになりました。

"ABCDE"
"ABCDE"
"ぁぃぅぇぉ"
"À"
"🔡🔠"
"ABCDE"
"ABCDE"
"ぁぃぅぇぉ"
"À"
"🔡🔠"

この例では、ロケール依存版と通常版の結果は同じです。

半角および全角ラテン文字

半角の小文字は、通常どおり半角の大文字になります。

"abcde".toUpperCase();
// "ABCDE"

全角の小文字も、対応する全角の大文字になります。

"abcde".toUpperCase();
// "ABCDE"

全角文字だから変換されないわけではありません。Unicode上で大文字・小文字の関係が定義されているため、その対応に従って変換されます。

日本語の小書き文字

"ぁぃぅぇぉ".toUpperCase();
// "ぁぃぅぇぉ"

「ぁ」と「あ」のような関係は、Unicodeにおけるアルファベットの大文字・小文字の関係ではありません。そのため、ケース変換を行っても変化しません。

アクセント付きラテン文字

"à".toUpperCase();
// "À"

アクセント付きのラテン文字についても、Unicodeに対応関係が定義されていれば大文字になります。

絵文字

"🔡🔠".toUpperCase();
// "🔡🔠"

絵文字には大文字・小文字のケース対応がないため、そのまま残ります。

この時点では toUpperCase() と toLocaleUpperCase() に差が見えなかったため、ロケールによって実際に結果が変わる例を調べました。


toLocaleUpperCase() のECMA-262上の位置づけ

ECMA-262には、String.prototype.toLocaleUpperCase() について、実装がECMA-402の国際化APIを含んでいるかどうかで扱いが分かれることが記載されています。

ECMA-402を実装している場合

Intl などの国際化機能を含む実装では、ECMA-402に定義された toLocaleUpperCase() の仕様が使われます。

ECMA-402側では、ロケール引数の解析、ロケールの正規化、利用可能なロケールとの照合、ロケール固有のケース変換などが定められています。

現在の一般的なJavaScript実行環境では、通常はこちらの動作を目にすることになります。

ECMA-402を実装していない場合

国際化APIを含まない実装では、ECMA-262に書かれた代替的な定義が使われます。

この場合の toLocaleUpperCase() は、おおむね toUpperCase() と同じように動作します。ただし、ホスト環境の現在のロケールに従った結果を返すことが意図されています。

仕様では、通常のUnicodeケース変換と各言語の慣習が衝突する少数の事例、特にトルコ語などで違いが出ると説明されています。

将来の拡張用に予約された引数

ECMA-402を実装しない環境であっても、ECMA-402側で定義されているオプション引数の位置を、実装独自の別機能に流用してはいけません。

これは、同じJavaScriptコードが実装によってまったく異なる意味にならないようにし、将来的な互換性を確保するための規定です。


文字列をUnicodeコードポイント列として扱う

仕様上、これらのメソッドは、対象の文字列をUTF-16で符号化されたコードポイントの列として解釈します。

JavaScriptの文字列は内部的にはUTF-16コード単位の列ですが、ケース変換を考える際には、単なる16ビット値の置換としてではなく、UnicodeコードポイントとUnicode文字データに基づく変換が行われます。

ただし、変換後の文字列の .length はJavaScriptの通常の文字列と同じく、UTF-16コード単位数を表します。


ロケール依存変換が必要になる例

トルコ語の i

もっとも分かりやすい例が、トルコ語の i です。

const city = "istanbul";

console.log(city.toLocaleUpperCase("en-US"));
console.log(city.toLocaleUpperCase("tr"));

結果は次のようになります。

ISTANBUL
İSTANBUL

英語では、小文字の i は大文字の I になります。

一方、トルコ語では、点付きと点なしの文字がそれぞれ別の文字として扱われます。

概略的には次の対応があります。

i  ⇔ İ
ı  ⇔ I

そのため、トルコ語ロケールで "istanbul" を大文字化すると、先頭は点付きの大文字 İ になります。

"istanbul".toLocaleUpperCase("tr");
// "İSTANBUL"

トルコ語と同様の規則は、アゼルバイジャン語にもあります。これらは、通常版とロケール依存版の違いを確認する代表例です。

ロケール指定の大文字・小文字

ロケールタグは大文字・小文字を区別せずに解釈されます。

"i".toLocaleUpperCase("TR");
// "İ"

一般的には正規化された "tr" のような表記を使う方が読みやすいものの、"TR" でもトルコ語ロケールとして処理されます。


Intl.Locale オブジェクトを渡す

ロケールは文字列だけでなく、Intl.Locale オブジェクトとして指定することもできます。

const loc = new Intl.Locale("tr");

console.log("i".toLocaleUpperCase(loc));
// "İ"

Intl.Locale は、言語、文字体系、地域などの情報を持つロケールオブジェクトです。

たとえば、ロケール識別子には次のような要素を含められます。

言語 - 文字体系 - 地域

例としては次のような形式があります。

ja-JP
zh-Hans-CN
sr-Cyrl-RS

すべての要素が必須ではなく、単に "tr" のように言語だけを指定することもできます。


BCP 47の言語タグ

Intl.Locale や toLocaleUpperCase() に渡すロケール文字列は、BCP 47で定義される言語タグを基礎としています。

代表的な構造は次のようになります。

language-script-region

たとえば次のようなタグがあります。

ja-JP
en-US
tr
ru-Cyrl-BY

最後の例は、概念的には次の情報を組み合わせています。

ru    ロシア語
Cyrl  キリル文字
BY    ベラルーシ

実際のタグには、さらに異体、拡張、カレンダー、番号体系などの情報が加わることがあります。

この領域は単なる「国コードの指定」ではありません。同じ言語が複数の文字体系で書かれる場合や、同じ言語でも地域ごとに慣習が異なる場合を表現できる仕組みです。


不正な言語タグ

正しい形式ではない言語タグを渡すと、RangeError が発生します。

Intl.Locale の生成時に失敗する例

const loc = new Intl.Locale("aaaa");
RangeError: Incorrect locale information provided

toLocaleUpperCase() に直接渡して失敗する例

console.log("i".toLocaleUpperCase("aaaa"));
RangeError: Invalid language tag: aaaa

どちらも不正な言語タグが原因ですが、どの処理で検証されたかによってエラーメッセージが異なります。

Intl.Locale を先に生成する場合は、そのオブジェクトの初期化時点で検証されます。文字列を toLocaleUpperCase() に直接渡す場合は、メソッド内部でロケールリストを解釈する際に検証されます。


ロケールはリストとして指定できる

ECMA-402の処理では、ロケール指定は単一の値とは限らず、優先順位を持つリストとして扱えます。

たとえば、概念的には次のように指定できます。

value.toLocaleUpperCase([
  "tr",
  "az",
  "en-US",
]);

これは「最初にトルコ語を希望し、利用できなければアゼルバイジャン語、その次に英語を希望する」というような候補の列です。

処理系が実際にどのロケールをサポートしているかは実装によって異なるため、要求されたロケール候補と、実装が利用できるロケールを照合する必要があります。

輪読会では、HTTPのコンテンツネゴシエーションに少し似ているという話も出ました。利用者が希望する候補を複数提示し、実装がその中から対応可能なものを選ぶ、というイメージです。

単一の文字列や Intl.Locale オブジェクトが渡された場合も、内部的にはロケール候補のリストとして整理されます。


ロケールの正規化

ECMA-402では、受け取ったロケール指定をそのまま利用するのではなく、まず正規化します。

ロケール識別子は複数の書き方を許すことがあり、拡張項目の並びや大文字・小文字なども含めて、比較しやすい標準的な形に整理する必要があるためです。

輪読会では、正規化処理の内部に入ると、次のような多数の仕組みが関連してくることを確認しました。

この部分はECMA-402全体のロケール処理に関係し、軽く追うだけでは済まないほど広い領域でした。そのため、今回は詳細なアルゴリズムを追い切らず、「ケース変換の前にロケール指定を正規化し、利用可能なロケールを選択する」という役割を理解するところまでとしました。


TransformCase が担当すること

ECMA-402のロケール依存ケース変換では、共通処理として TransformCase という抽象操作が使われます。

これは概念的に、次の情報を受け取ります。

変換対象の文字列
要求されたロケール
大文字化か小文字化か

たとえば toLocaleUpperCase() なら、「この文字列を、指定されたロケールに従って大文字へ変換する」という依頼になります。

この処理では、おおむね次のことが行われます。

  1. 指定されたロケール候補を正規化する
  2. 実装がサポートしているロケールと照合する
  3. 使用するロケールを決定する
  4. そのロケールに従ってUnicodeのケース変換を行う
  5. 新しい文字列として結果を返す

仕様本文では、変換結果が実装およびロケール依存であることを示す用語も使われています。

ILD

ILDは、Implementation- and Locale-Dependent、つまり「実装およびロケール依存」を表します。

同じ入力でも、選ばれるロケールや実装が持つ国際化データによって結果が変わり得る部分を示します。

ILND

ECMA-402にはILNDという用語もあります。こちらはImplementation-, Locale-, and Numbering System-Dependentで、実装、ロケール、番号体系に依存することを示します。

ケース変換では番号体系は関係しないため、ここでは主にILDが登場します。ILNDは数値書式などで関係する概念です。


リトアニア語のケース変換

リトアニア語にも、ロケール依存の特殊なケース変換があります。

特に i や j とアクセント記号を組み合わせた文字では、上部の点を保持したり追加したりする処理が必要になる場合があります。

これは、文字の上に複数の結合文字が付くケースにも関係します。

概念的には次のような構造です。

基底文字 + ドット + アクセント記号

Unicodeでは、見た目が一文字に見えるものが、複数のコードポイントの組み合わせとして表現されることがあります。そのため、ロケール依存のケース変換は単純な一文字ずつの置換では処理できません。


ギリシャ語のアクセント処理

ギリシャ語では、すべて大文字にする際にアクセント記号を落とす慣習があります。

次の例では、通常版とギリシャ語ロケール版で結果が変わります。

console.log(
  ["άι", "ιά"].map(value => [
    value.toUpperCase(),
    value.toLocaleUpperCase("el"),
  ])
);

出力は次のようになります。

ΆΙ,ΑΪ,ΙΆ,ΙΑ

配列として整理すると次の対応です。

[
  ["ΆΙ", "ΑΪ"],
  ["ΙΆ", "ΙΑ"],
]

通常の toUpperCase() では、アクセント付きの文字をそのまま大文字化します。

一方、ギリシャ語ロケールを指定した toLocaleUpperCase("el") では、ギリシャ語の大文字表記の慣習に従ってアクセントが除去されたり、文字の組み合わせに応じた記号が付いたりします。

さらに、変換結果が前後の文字関係によって変わる場合があります。つまり、ギリシャ語のロケール依存変換は、各コードポイントを独立に変換するだけでは実装できません。

輪読会では文字の順序を入れ替えて実行し、前後関係によって結果が異なることも確認しました。


ギリシャ語の語末シグマ

ギリシャ文字のシグマには、小文字で通常形と語末形があります。

Σ  大文字のシグマ
σ  通常位置の小文字
ς  語末位置の小文字

そのため、小文字化では周囲の文脈を見て、語末かどうかを判断します。

console.log("ΟΣ".toLowerCase());
console.log("ΟΣΑ".toLowerCase());

結果は次のとおりです。

ος
οσα

"ΟΣ" の末尾にある Σ は語末なので ς になります。

一方、"ΟΣΑ" の Σ は後ろに文字が続いているため、通常形の σ になります。

この変換はギリシャ語ロケールを明示しなくても発生します。

"ΟΣ".toLowerCase();
// "ος"

これは、語末シグマの規則がUnicodeの言語非依存な文脈依存ケース変換として定義されているためです。

「ギリシャ文字なら必ずギリシャ語と断定できるのか」という話も出ました。一般には、ある文字体系を使っていることと特定言語であることは必ずしも一致しません。たとえばカタカナは日本語だけでなくアイヌ語の表記にも使われます。

その一方で、Unicodeのケース変換では、ギリシャ文字のシグマについて、ロケール指定を必要とせず語末形を決定する規則が採用されています。


Unicodeのケース変換データ

通常の toUpperCase() と toLowerCase() は、ECMA-402のロケール選択処理ではなく、Unicodeのデフォルトケース変換に従います。

この変換には、主に次のUnicodeデータが関係します。

UnicodeData.txt

Unicodeの基本的な文字情報と、大文字・小文字・タイトルケースの基本的な対応関係が収録されています。

単純な一対一の変換の多くは、こちらのデータで表現されます。

SpecialCasing.txt

単純な一対一の対応では表現できない特殊な変換が収録されています。

具体的には次のようなケースです。

SpecialCasing.txt の中には、ロケールに依存しない規則と、特定言語に依存する規則の両方があります。

たとえば、ギリシャ語の語末シグマは文脈依存ですが、明示的なロケール指定を必要としません。一方、トルコ語やリトアニア語の一部の規則は言語依存です。

通常版の toUpperCase() / toLowerCase() は、基本データに加えて、SpecialCasing.txt に書かれたロケール非依存の特殊規則も利用します。

ロケール依存版では、さらにトルコ語やリトアニア語などの言語依存規則も考慮されます。


一文字が複数のコードポイントになる変換

ケース変換は、常に一文字を一文字へ置き換える処理ではありません。

ドイツ語の ß

ドイツ語の小文字 ß は、大文字化によって SS になることがあります。

console.log("straße".toUpperCase());
console.log("straße".toLocaleUpperCase("de"));

結果はどちらも次のようになります。

STRASSE
STRASSE

ここでは、一つの ß が二つの S に変換されています。

ß → SS

したがって、ケース変換後の文字列は元の文字列と異なる長さになることがあります。

なお、Unicodeには大文字の ẞ もありますが、JavaScriptのデフォルト大文字変換では、この例の ß は SS へ展開されます。

toLowerCase() で複数コードポイントになる例

小文字化でも、一つのコードポイントが複数のコードポイントになる場合があります。

const s = "İ";
const lowerS = s.toLowerCase();

console.log(s, lowerS);
console.log(s.length, lowerS.length);
console.log(
  JSON.stringify(s.split("")),
  JSON.stringify(lowerS.split(""))
);

結果は次のようになります。

İ i̇
1 2
["İ"] ["i","̇"]

大文字の点付きI İ は、通常の小文字変換によって次の二つのコードポイントになります。

i
COMBINING DOT ABOVE

概念的には次の構造です。

"İ".toLowerCase() === "i\u0307";

見た目では一文字に見えますが、変換後は基底文字 i と結合文字で構成されています。

この例では、どちらのコードポイントもBMP内にあるため、JavaScriptの .length も 1 から 2 に増えます。

"İ".length;
// 1

"İ".toLowerCase().length;
// 2

大文字化と小文字化は対称ではない

仕様には、toUpperCase() と toLowerCase() は対称な操作ではないことが明記されています。

つまり、次のような関係は保証されません。

s.toUpperCase().toLowerCase() === s.toLowerCase()

ドイツ語の ß を使うと違いを確認できます。

const s = "straße";

const sLower = s.toLowerCase();
const sUpperLower = s.toUpperCase().toLowerCase();

console.log(sLower);
console.log(sUpperLower);
console.log(sLower === sUpperLower);

結果は次のとおりです。

straße
strasse
false

処理の流れは次のようになります。

straße
  ↓ toUpperCase()
STRASSE
  ↓ toLowerCase()
strasse

大文字化の時点で ß が SS に展開されるため、その後に小文字化しても ß には戻りません。

同様に、次の関係も一般には保証されません。

s.toLowerCase().toUpperCase() === s.toUpperCase()

ケース変換は可逆変換ではなく、情報を失う可能性があります。そのため、文字列の同一性を保ったまま後から元に戻す用途には使えません。


toLowerCase() の仕様上の処理

toLowerCase() は、概念的には次のように動作します。

重要なのは、Unicodeのデフォルト変換が、単純な文字対応表だけではない点です。

ギリシャ語の語末シグマのような文脈依存変換や、İ のように複数コードポイントを生成する変換も含まれます。


toUpperCase() の仕様上の処理

toUpperCase() は基本的に toLowerCase() と同じ構造で、適用するUnicode変換だけが大文字方向になります。

つまり、対象値を文字列化してUnicodeコードポイント列として扱い、Unicodeのデフォルト大文字変換を適用します。

輪読会では、ECMA-262の多くのメソッドがアルゴリズムをほぼ完全に書き直すのに対して、ここでは「toLowerCase() と同様だが、大文字化のUnicodeアルゴリズムを使う」と差分で説明していることが少し珍しい、という話も出ました。


通常版とロケール依存版の違い

整理すると、通常版とロケール依存版の関係は次のようになります。

メソッド 主な基準 ロケール指定
toUpperCase() Unicodeのデフォルト大文字変換 受け取らない
toLowerCase() Unicodeのデフォルト小文字変換 受け取らない
toLocaleUpperCase() Unicodeと選択されたロケールの規則 受け取る
toLocaleLowerCase() Unicodeと選択されたロケールの規則 受け取る

通常版でも、次のような高度なUnicode処理は行われます。

"ΟΣ".toLowerCase();
// "ος"

"straße".toUpperCase();
// "STRASSE"

"İ".toLowerCase();
// "i\u0307"

ロケール依存版が必要になるのは、Unicodeのデフォルト規則だけでは利用者の言語的慣習に合わない場合です。

"istanbul".toUpperCase();
// "ISTANBUL"

"istanbul".toLocaleUpperCase("tr");
// "İSTANBUL"

ECMA-262の翻訳に関する話題

途中で、ECMA-262の日本語表示を提供するサイトも確認しました。

https://ecma262.com/j/

ブラウザーの表示言語が日本語の場合、日本語版へ移動するような挙動が見られました。翻訳は機械翻訳を利用しているように見える、という感想がありました。

このサイトに関係する人物として、WHATWG Organizationに参加しているエンジニアのGitHubページも確認されました。

https://github.com/JinDX

また、Web関連仕様の日本語訳を長年継続している triple-underscore 氏の活動も紹介されました。

https://triple-underscore.github.io/index.html#page-list

仕様翻訳について語られた mozaic.fm の回も紹介されました。

https://mozaic.fm/episodes/51/translation.html

AI翻訳が一般化する以前から、自力で継続的に仕様を翻訳してきた活動の価値や、Webプラットフォームを支える仕様・データを整備している人々への感謝についても話されました。


今回の結論

今回の輪読会では、JavaScriptのケース変換が想像以上に広い領域に支えられていることを確認しました。

一見すると、次のような単純な処理に見えます。

"hello".toUpperCase();
// "HELLO"

しかし実際には、次の要素が関係します。

特に重要な注意点は、次のコードが一般には成立しないことです。

// 必ずしも true ではない
s.toUpperCase().toLowerCase() === s.toLowerCase();

また、ケース変換によって文字列の長さが変わることもあります。

"ß".toUpperCase();
// "SS"

"İ".toLowerCase();
// "i\u0307"

単なるASCII文字の整形であれば意識する必要は少ないものの、多言語テキストの検索、比較、ユーザー名、識別子、正規化などにケース変換を使う場合は、これらの性質を理解しておく必要があります。

ECMA-402の内部にはロケールの正規化や照合など非常に多くの仕組みがあり、ECMA-262のケース変換を読む流れで軽く追うには範囲が広すぎることも分かりました。本格的に扱う場合は、ECMA-402やUnicode自体を独立したテーマとして読む必要があります。

次回は、文字列オブジェクトが持つプリミティブな文字列値を返す処理と、その後に続く String.prototype.toWellFormed() を確認する予定となりました。