テキストを個々の文字に正しく分割する方法
コードユニットではなく、ユーザーが認識する文字に文字列を分割するためにIntl.Segmenterを使用する
はじめに
JavaScriptの標準的な文字列メソッドを使用して絵文字「👨👩👧👦」を個々の文字に分割しようとすると、壊れた結果が得られます。1つの家族の絵文字ではなく、別々の人物の絵文字と不可視文字が表示されます。同じ問題は、「é」のようなアクセント付き文字、「🇺🇸」のような国旗の絵文字、および画面上で単一の文字として表示される他の多くのテキスト要素でも発生します。
これは、JavaScriptの組み込み文字列分割が、ユーザーが認識する文字ではなく、UTF-16コードユニットのシーケンスとして文字列を扱うために発生します。1つの可視文字は、結合された複数のコードユニットで構成される場合があります。コードユニットで分割すると、これらの文字が分解されます。
JavaScriptは、これを正しく処理するためにIntl.Segmenter APIを提供しています。このレッスンでは、ユーザーが認識する文字とは何か、標準的な文字列メソッドがそれらを適切に分割できない理由、およびIntl.Segmenterを使用してテキストを実際の文字に分割する方法について説明します。
ユーザーが認識する文字とは
ユーザーが認識する文字とは、テキストを読むときに人が単一の文字として認識するものです。これらはUnicode用語では書記素クラスタと呼ばれます。ほとんどの場合、書記素クラスタは画面上で1つの文字として表示されるものと一致します。
文字「a」は、1つのUnicodeコードポイントで構成される書記素クラスタです。絵文字「😀」は、1つの絵文字を形成する2つのコードポイントで構成される書記素クラスタです。家族の絵文字「👨👩👧👦」は、特殊な不可視文字で結合された7つのコードポイントで構成される書記素クラスタです。
テキスト内の文字数をカウントする際は、コードポイントやコードユニットではなく、書記素クラスタをカウントする必要があります。テキストを文字に分割する際は、クラスタ内の任意の位置ではなく、書記素クラスタの境界で分割する必要があります。
JavaScriptの文字列はUTF-16コードユニットのシーケンスです。各コードユニットは、完全なコードポイントまたはコードポイントの一部を表します。書記素クラスタは複数のコードポイントにまたがることができ、各コードポイントは複数のコードユニットにまたがることができます。これにより、JavaScriptがテキストを格納する方法とユーザーがテキストを認識する方法との間に不一致が生じます。
splitメソッドが複雑な文字で失敗する理由
split('')メソッドは、すべてのコードユニット境界で文字列を分割します。これは、各文字が1つのコードユニットである単純なASCII文字では正しく機能します。複数のコードユニットにまたがる文字では失敗します。
const simple = "hello";
console.log(simple.split(''));
// Output: ["h", "e", "l", "l", "o"]
単純なASCIIテキストは、各文字が1つのコードユニットであるため、正しく分割されます。しかし、絵文字やその他の複雑な文字は分解されてしまいます。
const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\ud83d", "\ude00"]
笑顔の絵文字は2つのコードユニットで構成されています。split('')メソッドは、それを単独では有効な文字ではない2つの別々の部分に分割します。表示されると、これらの部分は置換文字として表示されるか、まったく表示されません。
国旗絵文字は、組み合わせて国旗を形成する地域指示記号を使用します。各国旗には2つのコードポイントが必要です。
const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
米国の国旗絵文字は、2つの地域指示記号を表す4つのコードユニットに分割されます。どちらの指示記号も単独では有効な文字ではありません。国旗を形成するには、両方の指示記号が一緒に必要です。
ファミリー絵文字は、ゼロ幅結合文字を使用して複数の人物絵文字を1つの合成文字に結合します。
const family = "👨👩👧👦";
console.log(family.split(''));
// Output: ["👨", "", "👩", "", "👧", "", "👦"]
ファミリー絵文字は個別の人物絵文字と不可視の結合文字に分割されます。元の合成文字は破壊され、1つの家族ではなく4人の別々の人物が表示されます。
アクセント付き文字は、Unicodeで2つの方法で表現できます。一部のアクセント付き文字は単一のコードポイントですが、他の文字は基本文字と結合分音記号を組み合わせます。
const combined = "é"; // e + combining acute accent
console.log(combined.split(''));
// Output: ["e", "́"]
文字éが2つのコードポイント(基本文字と結合アクセント)として表現されている場合、分割すると別々の部分に分かれます。アクセント記号が単独で表示されますが、これはテキストを文字に分割する際のユーザーの期待とは異なります。
Intl.Segmenterを使用してテキストを正しく分割する
Intl.Segmenterコンストラクタは、ロケール固有のルールに従ってテキストを分割するセグメンターを作成します。最初の引数としてロケール識別子を、2番目の引数として粒度を指定するオプションオブジェクトを渡します。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
grapheme粒度は、書記素クラスタの境界でテキストを分割するようセグメンターに指示します。これにより、ユーザーが認識する文字の構造が尊重され、文字が分解されることはありません。
文字列を指定してsegment()メソッドを呼び出すと、セグメントのイテレータが取得されます。各セグメントには、テキストと位置情報が含まれます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const segments = segmenter.segment(text);
for (const segment of segments) {
console.log(segment.segment);
}
// Output:
// "h"
// "e"
// "l"
// "l"
// "o"
各セグメントオブジェクトには、文字テキストを含むsegmentプロパティと、その位置を含むindexプロパティがあります。セグメントを直接反復処理して、各文字にアクセスできます。
文字の配列を取得するには、イテレータを配列にスプレッドし、セグメントテキストにマップします。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
// Output: ["h", "e", "l", "l", "o"]
このパターンは、イテレータをセグメントオブジェクトの配列に変換し、各セグメントからテキストのみを抽出します。結果は文字列の配列で、各書記素クラスタに対して1つずつ含まれます。
絵文字を正しく文字に分割する
Intl.Segmenter APIは、複数のコードポイントを使用する合成絵文字を含む、すべての絵文字を正しく処理します。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Output: ["😀"]
絵文字は1つの書記素クラスタとして保持されます。セグメンタは両方のコードユニットが同じ文字に属することを認識し、分割しません。
旗の絵文字は、地域インジケータに分割されることなく、単一の文字として保持されます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Output: ["🇺🇸"]
2つの地域インジケータシンボルは、米国の旗を表す1つの書記素クラスタを形成します。セグメンタはそれらを1つの文字としてまとめて保持します。
家族の絵文字やその他の合成絵文字は、単一の文字として保持されます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Output: ["👨👩👧👦"]
すべての人物の絵文字とゼロ幅接合文字が1つの書記素クラスタを形成します。セグメンタは家族の絵文字全体を1つの文字として扱い、その外観と意味を保持します。
アクセント記号付き文字を含むテキストの分割
Intl.Segmenter APIは、Unicodeでのエンコード方法に関係なく、アクセント記号付き文字を正しく処理します。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
アクセント記号付き文字éが単一のコードポイントとしてエンコードされている場合、セグメンタはそれを1つの文字として扱います。これは単語を分割する際のユーザーの期待と一致します。
同じ文字が基本文字と結合分音記号としてエンコードされている場合でも、セグメンタはそれを1つの文字として扱います。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
セグメンタは、基本文字と結合記号が単一の書記素クラスタを形成することを認識します。結果は、基礎となるエンコードが異なっていても、合成済みバージョンと同一に見えます。
この動作は、発音区別符号を使用する言語でのテキスト処理において重要です。ユーザーはアクセント付き文字を、別々の基本文字と記号としてではなく、完全な文字として扱われることを期待しています。
文字を正しくカウントする
テキストを分割する一般的な使用例の1つは、含まれる文字数をカウントすることです。split('')メソッドは、複雑な文字を含むテキストに対して不正確なカウントを返します。
const text = "👨👩👧👦";
console.log(text.split('').length);
// Output: 7
ファミリー絵文字は1つの文字として表示されますが、コードユニットで分割すると7としてカウントされます。これはユーザーの期待と一致しません。
Intl.Segmenterを使用すると、正確な文字数が得られます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Output: 1
セグメンターはファミリー絵文字を1つの書記素クラスターとして認識するため、カウントは1になります。これは画面上でユーザーが見るものと一致します。
任意の文字列内の書記素クラスターをカウントするヘルパー関数を作成できます。
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// Output: 5
console.log(countCharacters("café"));
// Output: 4
console.log(countCharacters("👨👩👧👦"));
// Output: 1
console.log(countCharacters("🇺🇸"));
// Output: 1
この関数は、ASCIIテキスト、アクセント付き文字、絵文字、その他のUnicode文字に対して正しく動作します。カウントは常にユーザーが認識する文字数と一致します。
特定の位置の文字を取得する
特定の位置の文字にアクセスする必要がある場合は、まずテキストを書記素クラスターの配列に変換できます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters[6]);
// Output: "👋"
手を振る絵文字は、書記素クラスターをカウントすると位置6にあります。文字列に対して標準的な配列インデックスを使用した場合、絵文字が複数のコードユニットにまたがっているため、無効な結果が得られます。
このアプローチは、文字選択、文字ハイライト、文字単位のアニメーションなどの文字レベルの操作を実装する際に役立ちます。
テキストを正しく反転する
コードユニットの配列を反転することで文字列を反転すると、複雑な文字に対して不正確な結果が生成されます。
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"
絵文字が壊れるのは、そのコードユニットが個別に反転されるためです。結果の文字列には無効な文字シーケンスが含まれます。
Intl.Segmenterを使用してテキストを反転すると、文字の整合性が保たれます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// Output: "👋 olleH"
各書記素クラスターは反転中もそのまま保たれます。絵文字はコードユニットが分離されないため、有効なままです。
localeパラメータの理解
Intl.Segmenterコンストラクタはlocaleパラメータを受け取りますが、書記素セグメンテーションにおいては、localeの影響は最小限です。書記素クラスターの境界は、ほとんど言語に依存しないUnicodeルールに従います。
const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const text = "Hello 👋 こんにちは";
const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);
console.log(charactersEn);
console.log(charactersJa);
// Both outputs are identical
異なるロケール識別子でも、同じ書記素セグメンテーション結果が生成されます。Unicode標準は、言語を超えて機能する方法で書記素クラスターの境界を定義しています。
ただし、他のIntl APIとの一貫性を保つため、また将来のUnicodeバージョンでロケール固有のルールが導入される可能性に備えて、ロケールを指定することは依然として良い習慣です。
パフォーマンスのためのセグメンターの再利用
新しいIntl.Segmenterインスタンスを作成すると、ロケールデータの読み込みと内部構造の初期化が行われます。同じ設定で複数の文字列をセグメント化する必要がある場合は、セグメンターを一度作成して再利用してください。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const texts = [
"Hello 👋",
"Café ☕",
"World 🌍",
"Family 👨👩👧👦"
];
texts.forEach(text => {
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨👩👧👦"]
このアプローチは、文字列ごとに新しいセグメンターを作成するよりも効率的です。大量のテキストを処理する場合、パフォーマンスの差が顕著になります。
書記素セグメンテーションと他の操作の組み合わせ
書記素セグメンテーションを他の文字列操作と組み合わせて、より複雑なテキスト処理関数を構築できます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
function truncateByCharacters(text, maxLength) {
const characters = [...segmenter.segment(text)].map(s => s.segment);
if (characters.length <= maxLength) {
return text;
}
return characters.slice(0, maxLength).join('') + '...';
}
console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."
console.log(truncateByCharacters("Family 👨👩👧👦 Photo", 8));
// Output: "Family 👨👩👧👦..."
この切り詰め関数は、コードユニットではなく書記素クラスターをカウントします。切り詰め時に絵文字やその他の複雑な文字を保持するため、出力に壊れた文字が含まれることはありません。
文字列位置の操作
Intl.Segmenterによって返されるセグメントオブジェクトには、元の文字列内の位置を示すindexプロパティが含まれています。この位置は書記素クラスタではなく、コードユニットで測定されます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
for (const segment of segmenter.segment(text)) {
console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6
手を振る絵文字はコードユニット位置6から始まりますが、基礎となる文字列では位置6と7を占めています。次の文字は位置8から始まります。この情報は、部分文字列の抽出などの操作で書記素位置と文字列位置の間をマッピングする必要がある場合に役立ちます。
空文字列とエッジケースの処理
Intl.Segmenter APIは空文字列やその他のエッジケースを正しく処理します。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Output: []
空文字列は空のセグメント配列を生成します。特別な処理は必要ありません。
空白文字は個別の書記素クラスタとして扱われます。
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]
スペース、タブ、改行はそれぞれ独自の書記素クラスタを形成します。これは文字レベルのテキスト処理に対するユーザーの期待と一致します。