テキストを個々の文字に正しく分割するにはどうすればよいですか?

コードユニットではなく、ユーザーが認識する文字に文字列を分割するには Intl.Segmenter を使用してください

はじめに

JavaScriptの標準文字列メソッドを使用して絵文字「👨‍👩‍👧‍👦」を個々の文字に分割しようとすると、結果が壊れてしまいます。1つの家族絵文字の代わりに、別々の人物絵文字と不可視文字が表示されます。同様の問題は、「é」のようなアクセント付き文字、「🇺🇸」のような国旗絵文字、および画面上で単一の文字として表示される他の多くのテキスト要素でも発生します。

これは、JavaScriptの組み込み文字列分割が、ユーザーが認識する文字ではなく、UTF-16コードユニットのシーケンスとして文字列を扱うためです。画面上で1つの可視文字が、結合された複数のコードユニットで構成されることがあります。コードユニットで分割すると、これらの文字が分解されてしまいます。

JavaScriptは、これを正しく処理するためのIntl.Segmenter APIを提供しています。このレッスンでは、ユーザーが認識する文字とは何か、標準の文字列メソッドがそれらを適切に分割できない理由、そしてIntl.Segmenterを使用して実際の文字にテキストを分割する方法について説明します。

ユーザーが認識する文字とは

ユーザーが認識する文字とは、テキストを読むときに人が1つの文字として認識するものです。これらはUnicode用語ではグラフィームクラスターと呼ばれています。ほとんどの場合、グラフィームクラスターは画面上で1つの文字として表示されるものと一致します。

文字「a」は1つのUnicodeコードポイントで構成されるグラフィームクラスターです。絵文字「😀」は2つのコードポイントで構成される1つのグラフィームクラスターです。家族絵文字「👨‍👩‍👧‍👦」は、特殊な不可視文字で結合された7つのコードポイントで構成されるグラフィームクラスターです。

テキスト内の文字を数える場合、コードポイントやコードユニットではなく、グラフィームクラスターを数えたいものです。テキストを文字に分割する場合、クラスター内の任意の位置ではなく、グラフィームクラスターの境界で分割したいものです。

JavaScriptの文字列はUTF-16コードユニットのシーケンスです。各コードユニットは、完全なコードポイントまたはコードポイントの一部を表します。グラフィームクラスターは複数のコードポイントにまたがることがあり、各コードポイントは複数のコードユニットにまたがることがあります。これにより、JavaScriptがテキストを格納する方法とユーザーがテキストを認識する方法との間にミスマッチが生じます。

複雑な文字に対してsplitメソッドが失敗する理由

split('')メソッドは文字列を各コードユニット境界で分割します。これは各文字が1つのコードユニットである単純なASCII文字では正しく機能します。しかし、複数のコードユニットにまたがる文字では失敗します。

const simple = "hello";
console.log(simple.split(''));
// 出力: ["h", "e", "l", "l", "o"]

単純なASCIIテキストは各文字が1つのコードユニットであるため、正しく分割されます。しかし、絵文字やその他の複雑な文字は分解されてしまいます。

const emoji = "😀";
console.log(emoji.split(''));
// 出力: ["\ud83d", "\ude00"]

笑顔の絵文字は2つのコードユニットで構成されています。split('')メソッドはこれを2つの別々の部分に分解し、それぞれは単独では有効な文字ではありません。表示すると、これらの部分は代替文字として表示されるか、まったく表示されません。

国旗の絵文字は、地域指示記号を組み合わせて国旗を形成します。各国旗には2つのコードポイントが必要です。

const flag = "🇺🇸";
console.log(flag.split(''));
// 出力: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]

アメリカ国旗の絵文字は、2つの地域指示記号を表す4つのコードユニットに分割されます。どちらの指示記号も単独では有効な文字ではありません。国旗を形成するには両方の指示記号が一緒に必要です。

家族の絵文字は、ゼロ幅接合文字を使用して複数の人物絵文字を1つの複合文字に結合します。

const family = "👨‍👩‍👧‍👦";
console.log(family.split(''));
// 出力: ["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

家族の絵文字は、個々の人物絵文字と不可視の接合文字に分割されます。元の複合文字は破壊され、1つの家族の代わりに4人の別々の人物が表示されます。

アクセント付き文字はUnicodeでは2つの方法で表現できます。一部のアクセント付き文字は単一のコードポイントですが、他のものは基本文字と結合用発音区別記号を組み合わせています。

const combined = "é"; // e + 結合用アクセント記号
console.log(combined.split(''));
// 出力: ["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);
}
// 出力:
// "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);
// 出力: ["h", "e", "l", "l", "o"]

このパターンでは、イテレータをセグメントオブジェクトの配列に変換し、各セグメントからテキストのみを抽出します。結果は、各グラフィームクラスターに対応する文字列の配列になります。

絵文字を正しく文字に分割する

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);
// 出力: ["😀"]

絵文字は1つのグラフィームクラスターとして無傷のまま維持されます。セグメンターは両方のコードユニットが同じ文字に属していることを認識し、それらを分割しません。

国旗絵文字は、地域インジケータに分解されるのではなく、単一の文字として維持されます。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// 出力: ["🇺🇸"]

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);
// 出力: ["👨‍👩‍👧‍👦"]

すべての人物絵文字とゼロ幅接合子は1つのグラフィームクラスターを形成します。セグメンターは家族絵文字全体を1つの文字として扱い、その外観と意味を保持します。

アクセント文字を含むテキストの分割

Intl.Segmenter APIは、Unicodeでどのようにエンコードされているかに関わらず、アクセント文字を正しく処理します。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const precomposed = "café"; // 合成済みのé
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// 出力: ["c", "a", "f", "é"]

アクセント文字のéが単一のコードポイントとしてエンコードされている場合、セグメンターはそれを1つの文字として扱います。これは単語の分割方法に関するユーザーの期待と一致します。

同じ文字が基本文字と結合用発音区別符号としてエンコードされている場合でも、セグメンターはそれを1つの文字として扱います。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const decomposed = "café"; // eと結合用アキュートアクセント
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// 出力: ["c", "a", "f", "é"]

セグメンターは基本文字と結合マークが単一の書記素クラスターを形成することを認識します。基礎となるエンコーディングが異なっていても、結果は合成済みバージョンと同じように見えます。

この動作はアクセント記号を使用する言語のテキスト処理において重要です。ユーザーはアクセント付き文字が別々の基本文字とマークではなく、完全な文字として扱われることを期待しています。

文字を正確にカウントする

テキストを分割する一般的なユースケースの1つは、それに含まれる文字数をカウントすることです。split('')メソッドは複雑な文字を含むテキストに対して不正確なカウントを返します。

const text = "👨‍👩‍👧‍👦";
console.log(text.split('').length);
// 出力: 7

家族の絵文字は1つの文字として表示されますが、コードユニットで分割すると7としてカウントされます。これはユーザーの期待と一致しません。

Intl.Segmenterを使用すると、正確な文字数が得られます。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨‍👩‍👧‍👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// 出力: 1

セグメンターは家族の絵文字を1つの書記素クラスターとして認識するため、カウントは1になります。これは画面上でユーザーが見るものと一致します。

任意の文字列の書記素クラスターをカウントするヘルパー関数を作成できます。

function countCharacters(text) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  return [...segmenter.segment(text)].length;
}

console.log(countCharacters("hello"));
// 出力: 5

console.log(countCharacters("café"));
// 出力: 4

console.log(countCharacters("👨‍👩‍👧‍👦"));
// 出力: 1

console.log(countCharacters("🇺🇸"));
// 出力: 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]);
// 出力: "👋"

手を振る絵文字は、書記素クラスタを数えると位置6にあります。文字列に対して標準の配列インデックスを使用すると、絵文字が複数のコードユニットにまたがるため、無効な結果が得られます。

このアプローチは、文字の選択、文字のハイライト、または文字ごとのアニメーションなど、文字レベルの操作を実装する際に役立ちます。

テキストを正しく反転させる

コードユニットの配列を反転させることで文字列を反転させると、複雑な文字に対して不正確な結果が生じます。

const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// 出力: "�� 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);
// 出力: "👋 olleH"

反転の間、各書記素クラスタは無傷のままです。コードユニットが分離されないため、絵文字は有効なままです。

ロケールパラメータの理解

Intl.Segmenterコンストラクタはロケールパラメータを受け入れますが、書記素の分割においては、ロケールの影響は最小限です。書記素クラスタの境界は、主に言語に依存しない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);
// 両方の出力は同一

異なるロケール識別子でも、同じ書記素分割結果が得られます。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);
});
// 出力:
// ["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));
// 出力: "Hello 👋..."

console.log(truncateByCharacters("Family 👨‍👩‍👧‍👦 Photo", 8));
// 出力: "Family 👨‍👩‍👧‍👦..."

この切り詰め関数は、コードユニットではなく書記素クラスターをカウントします。切り詰める際に絵文字やその他の複雑な文字を保持するため、出力に壊れた文字が含まれることはありません。

文字列位置の操作

Intl.Segmenterから返されるセグメントオブジェクトには、元の文字列内の位置を示すindexプロパティが含まれています。この位置は書記素クラスターではなく、コードユニットで測定されます。

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";

for (const segment of segmenter.segment(text)) {
  console.log(`文字「${segment.segment}」は位置${segment.index}から始まります`);
}
// 出力:
// 文字「H」は位置0から始まります
// 文字「e」は位置1から始まります
// 文字「l」は位置2から始まります
// 文字「l」は位置3から始まります
// 文字「o」は位置4から始まります
// 文字「 」は位置5から始まります
// 文字「👋」は位置6から始まります

手を振る絵文字は、基礎となる文字列の位置6と7を占めていますが、コードユニット位置6から始まります。次の文字は位置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);
// 出力: []

空文字列は空のセグメント配列を生成します。特別な処理は必要ありません。

空白文字は別々の書記素クラスターとして扱われます。

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);
// 出力: ["a", " ", "b", "\t", "c", "\n", "d"]

スペース、タブ、改行はそれぞれ独自の書記素クラスターを形成します。これはキャラクターレベルのテキスト処理におけるユーザーの期待と一致します。