Intl.Segmenter API

JavaScriptで文字をカウント、単語を分割、文を区切る正しい方法

はじめに

JavaScriptのstring.lengthプロパティはコードユニットを数えるもので、ユーザーが認識する文字数ではありません。ユーザーが絵文字、アクセント付き文字、または複雑な文字体系のテキストを入力すると、string.lengthは誤ったカウントを返します。split()メソッドは単語間にスペースを使用しない言語では機能しません。正規表現の単語境界は中国語、日本語、またはタイ語のテキストでは機能しません。

Intl.Segmenter APIはこれらの問題を解決します。Unicodeの標準に従ってテキストをセグメント化し、各言語の言語学的ルールを尊重します。グラフィーム(ユーザーが認識する文字)をカウントしたり、言語に関係なくテキストを単語に分割したり、テキストを文に分割したりすることができます。

この記事では、基本的な文字列操作が国際的なテキストで失敗する理由、グラフィームクラスターと言語的境界とは何か、そしてIntl.Segmenterを使用してすべてのユーザーに対してテキストを正しく処理する方法について説明します。

なぜstring.lengthは文字カウントに失敗するのか

JavaScriptの文字列はUTF-16エンコーディングを使用しています。JavaScript文字列の各要素は完全な文字ではなく、16ビットのコードユニットです。string.lengthプロパティはこれらのコードユニットを数えます。

基本的なASCII文字の場合、1つのコードユニットは1つの文字に相当します。文字列"hello"の長さは5であり、ユーザーの期待と一致します。

他の多くの文字では、この関係は崩れます。以下の例を考えてみましょう:

"😀".length; // 2、1ではない
"👨‍👩‍👧‍👦".length; // 11、1ではない
"किं".length; // 5、2ではない
"🇺🇸".length; // 4、1ではない

ユーザーは1つの絵文字、1つの家族絵文字、2つのヒンディー語の音節、または1つの国旗を見ています。JavaScriptは基礎となるコードユニットを数えます。

これは、テキスト入力の文字カウンターを構築したり、長さ制限を検証したり、表示用にテキストを切り詰めたりする場合に重要です。JavaScriptが報告するカウントはユーザーが見ているものと一致しません。

グラフィームクラスターとは

グラフィームクラスターとは、ユーザーが1つの文字として認識するものです。以下のような構成が考えられます:

  • "a" のような単一のコードポイント
  • "é" のような基本文字と結合マーク(eと結合アクセント記号)
  • "👨‍👩‍👧‍👦" のような幅ゼロ接合子で結合された複数のコードポイント(男性+女性+女の子+男の子)
  • "👋🏽" のような肌の色修飾子付きの絵文字(手を振る+中間の肌の色)
  • "🇺🇸" のような国旗を表す地域指示子シーケンス(地域指示子U+地域指示子S)

Unicode標準ではUAX 29で拡張グラフィームクラスターを定義しています。これらの規則は、ユーザーが文字間の境界をどこに期待するかを決定します。ユーザーがバックスペースを押すと、1つのグラフィームクラスターが削除されることを期待します。カーソルが移動する際も、グラフィームクラスター単位で移動するべきです。

JavaScriptのstring.lengthはグラフィームクラスターを数えません。Intl.Segmenter APIはそれを行います。

Intl.Segmenterでグラフィームクラスターを数える

ユーザーが認識する文字を数えるために、グラフィーム粒度でセグメンターを作成します:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello 👋🏽";
const segments = segmenter.segment(text);
const graphemes = Array.from(segments);

console.log(graphemes.length); // 7
console.log(text.length); // 10

ユーザーは7つの文字を見ています:5つの文字、1つのスペース、そして1つの絵文字です。グラフィームセグメンターは7つのセグメントを返します。JavaScriptのstring.lengthは10を返します。これは絵文字が4つのコード単位を使用しているためです。

各セグメントオブジェクトには以下が含まれます:

  • segment:文字列としてのグラフィームクラスター
  • index:このセグメントが始まる元の文字列内の位置
  • input:元の文字列への参照(常に必要とは限りません)

for...ofを使用してセグメントを反復処理できます:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "café";

for (const { segment } of segmenter.segment(text)) {
  console.log(segment);
}
// ログ出力: "c", "a", "f", "é"

国際的に機能する文字カウンターの構築

正確な文字カウンターを構築するためにグラフェム分割を使用します:

function getGraphemeCount(text) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  return Array.from(segmenter.segment(text)).length;
}

// 様々な入力でテスト
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨‍👩‍👧‍👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1

この関数はユーザーの認識に合った数をカウントします。家族の絵文字を入力したユーザーは1文字と認識し、カウンターも1文字と表示します。

テキスト入力の検証には、string.lengthの代わりにグラフェムカウントを使用します:

function validateInput(text, maxGraphemes) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const count = Array.from(segmenter.segment(text)).length;
  return count <= maxGraphemes;
}

グラフェム分割による安全なテキスト切り詰め

表示用にテキストを切り詰める場合、グラフェムクラスターを分断してはいけません。任意のコード単位インデックスで切ると、絵文字や結合文字シーケンスが分割され、無効または破損した出力が生成される可能性があります。

安全な切り詰めポイントを見つけるためにグラフェム分割を使用します:

function truncateText(text, maxGraphemes) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = Array.from(segmenter.segment(text));

  if (segments.length <= maxGraphemes) {
    return text;
  }

  const truncated = segments
    .slice(0, maxGraphemes)
    .map(s => s.segment)
    .join("");

  return truncated + "…";
}

truncateText("Hello 👨‍👩‍👧‍👦 world", 7); // "Hello 👨‍👩‍👧‍👦…"
truncateText("Hello world", 7); // "Hello w…"

これにより、完全なグラフェムクラスターが保持され、有効なUnicode出力が生成されます。

なぜ単語分割にsplit()と正規表現が失敗するのか

テキストを単語に分割する一般的なアプローチでは、スペースまたは空白パターンでsplit()を使用します:

const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]

これは英語や単語間にスペースを使用する他の言語では機能します。しかし、単語間にスペースを使用しない言語では完全に失敗します。

中国語、日本語、タイ語のテキストでは単語間にスペースがありません。スペースで分割すると、文字列全体が1つの要素として返されます:

const text = "你好世界"; // 中国語で「Hello world」
const words = text.split(" "); // ["你好世界"]

ユーザーは4つの異なる単語を見ていますが、split()は1つの要素を返します。

正規表現の単語境界(\b)もこれらの言語では失敗します。なぜなら、正規表現エンジンはスペースのないスクリプトでの単語境界を認識しないからです。

言語間での単語分割の仕組み

Intl.Segmenter APIはUAX 29で定義されたUnicodeの単語境界ルールを使用します。これらのルールは、スペースのない文字体系を含む、すべての文字体系の単語境界を理解します。

単語の粒度でセグメンターを作成します:

const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const text = "你好世界";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment, isWordLike }) => {
  console.log(segment, isWordLike);
});
// "你好" true
// "世界" true

セグメンターはロケールと文字体系に基づいて単語境界を正確に識別します。isWordLikeプロパティは、そのセグメントが単語(文字、数字、表意文字)か非単語コンテンツ(スペース、句読点)かを示します。

英語テキストの場合、セグメンターは単語とスペースの両方を返します:

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world!";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment, isWordLike }) => {
  console.log(segment, isWordLike);
});
// "Hello" true
// " " false
// "world" true
// "!" false

isWordLikeプロパティを使用して、句読点や空白から単語セグメントをフィルタリングします:

function getWords(text, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);
  return Array.from(segments)
    .filter(s => s.isWordLike)
    .map(s => s.segment);
}

getWords("Hello, world!", "en"); // ["Hello", "world"]
getWords("你好世界", "zh"); // ["你好", "世界"]
getWords("สวัสดีครับ", "th"); // ["สวัสดี", "ครับ"] (タイ語)

この関数はスペース区切りの文字体系と非スペース区切りの文字体系の両方を処理し、あらゆる言語で機能します。

単語を正確にカウントする

国際的に機能する単語カウンターを構築します:

function countWords(text, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);
  return Array.from(segments).filter(s => s.isWordLike).length;
}

countWords("Hello world", "en"); // 2
countWords("你好世界", "zh"); // 2
countWords("Bonjour le monde", "fr"); // 3

これにより、あらゆる言語のコンテンツに対して正確な単語数が得られます。

カーソル位置を含む単語を見つける

containing() メソッドは、文字列内の特定のインデックスを含むセグメントを見つけます。これは、カーソルがどの単語の中にあるか、またはクリック位置がどのセグメントに含まれているかを判断するのに役立ちます。

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world";
const segments = segmenter.segment(text);

const segment = segments.containing(7); // インデックス7は「world」の中にあります
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

インデックスが空白や句読点内にある場合、containing()はそのセグメントを返します:

const segment = segments.containing(5); // インデックス5はスペースです
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

これをテキスト編集機能、検索ハイライト、またはカーソル位置に基づくコンテキストアクションに使用してください。

テキスト処理のための文の分割

文の分割は、文の境界でテキストを分割します。これは要約、テキスト読み上げ処理、または長い文書のナビゲーションに役立ちます。

ピリオドで分割するような基本的なアプローチは、ピリオドが略語、数字、その他の文の境界ではないコンテキストに表示されるため失敗します:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // 不正確:「Dr.」と「100.」で分割されます

Intl.Segmenter APIは文の境界ルールを理解しています:

const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
const segments = Array.from(segmenter.segment(text));

segments.forEach(({ segment }) => {
  console.log(segment);
});
// "Dr. Smith bought 100.5 shares. "
// "He sold them later."

セグメンターは「Dr.」と「100.5」を文の境界ではなく、文の一部として正しく扱います。

多言語テキストの場合、文の境界はロケールによって異なります。APIはこれらの違いを処理します:

const segmenterEn = new Intl.Segmenter("en", { granularity: "sentence" });
const segmenterJa = new Intl.Segmenter("ja", { granularity: "sentence" });

const textEn = "Hello. How are you?";
const textJa = "こんにちは。お元気ですか。"; // 日本語の句点を使用

Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2

各粒度の使い分け

必要なカウントや分割方法に基づいて粒度を選択してください:

  • 書記素(Grapheme):文字数のカウント、テキストの切り詰め、カーソル位置決め、またはユーザーの文字認識と一致させる必要がある操作に使用します。

  • 単語(Word):単語数のカウント、検索とハイライト、テキスト分析、または言語間で言語的な単語境界が必要な操作に使用します。

  • 文(Sentence):テキスト読み上げのセグメント化、要約、文書ナビゲーション、またはテキストを文ごとに処理する操作に使用します。

単語境界が必要な場合は書記素セグメント化を使用せず、文字数が必要な場合は単語セグメント化を使用しないでください。各粒度には明確な目的があります。

セグメンターの作成と再利用

セグメンターの作成は低コストですが、パフォーマンス向上のためにセグメンターを再利用できます:

const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });

// 複数の文字列に対してこれらのセグメンターを再利用
function processTexts(texts) {
  return texts.map(text => ({
    text,
    graphemes: Array.from(graphemeSegmenter.segment(text)).length,
    words: Array.from(wordSegmenter.segment(text)).filter(s => s.isWordLike).length
  }));
}

セグメンターはロケールデータをキャッシュするため、同じインスタンスを再利用することで繰り返しの初期化を避けられます。

ブラウザサポートの確認

Intl.Segmenter APIは2024年4月にベースラインステータスに達しました。Chrome、Firefox、Safari、Edgeの最新バージョンで動作します。古いブラウザではサポートされていません。

使用前にサポートを確認してください:

if (typeof Intl.Segmenter !== "undefined") {
  // Intl.Segmenterを使用
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // 古いブラウザ向けのフォールバック
  const count = text.length; // 正確ではないが利用可能
}

古いブラウザを対象とする本番アプリケーションでは、ポリフィルの使用または機能の低下を提供することを検討してください。

避けるべき一般的な間違い

ユーザーに文字数を表示する際に string.length を使用しないでください。絵文字、結合文字、複雑なスクリプトに対して不正確な結果を生成します。

多言語の単語分割にスペースで分割したり、正規表現の単語境界を使用したりしないでください。これらのアプローチは一部の言語でしか機能しません。

単語や文の境界が言語間で同じであると仮定しないでください。ロケール対応の分割を使用してください。

単語をカウントする際に isWordLike プロパティの確認を忘れないでください。句読点や空白を含めると、カウントが過大になります。

文字列を切り詰める際に任意のインデックスで切らないでください。常にグラフェームクラスタの境界で切断して、無効なUnicodeシーケンスが生成されないようにしてください。

Intl.Segmenter を使用しない場合

テキストに基本的なラテン文字のみが含まれていることがわかっている単純なASCIIのみの操作では、基本的な文字列メソッドの方が高速で十分です。

ネットワーク操作やストレージのために文字列のバイト長が必要な場合は、TextEncoder を使用してください:

const byteLength = new TextEncoder().encode(text).length;

低レベルの文字列操作に実際のコード単位数が必要な場合、string.length は正確です。これはアプリケーションコードではまれです。

ユーザー向けコンテンツを含む大部分のテキスト処理、特に国際的なアプリケーションでは、Intl.Segmenter を使用してください。

Intl.Segmenter と他の国際化APIとの関係

Intl.Segmenter APIはECMAScript国際化APIの一部です。このファミリーの他のAPIには以下が含まれます:

  • Intl.DateTimeFormat:ロケールに従って日付と時刻をフォーマット
  • Intl.NumberFormat:ロケールに従って数値、通貨、単位をフォーマット
  • Intl.Collator:ロケールに従って文字列をソートおよび比較
  • Intl.PluralRules:異なる言語での数値の複数形を決定

これらのAPIを組み合わせることで、世界中のユーザーに対して正しく機能するアプリケーションを構築するために必要なツールが提供されます。テキストセグメンテーションには Intl.Segmenter を使用し、フォーマットと比較には他の Intl APIを使用してください。

実践例:テキスト統計コンポーネントの構築

書記素と単語の分割を組み合わせてテキスト統計コンポーネントを構築します:

function getTextStatistics(text, locale) {
  const graphemeSegmenter = new Intl.Segmenter(locale, {
    granularity: "grapheme"
  });
  const wordSegmenter = new Intl.Segmenter(locale, {
    granularity: "word"
  });
  const sentenceSegmenter = new Intl.Segmenter(locale, {
    granularity: "sentence"
  });

  const graphemes = Array.from(graphemeSegmenter.segment(text));
  const words = Array.from(wordSegmenter.segment(text))
    .filter(s => s.isWordLike);
  const sentences = Array.from(sentenceSegmenter.segment(text));

  return {
    characters: graphemes.length,
    words: words.length,
    sentences: sentences.length,
    averageWordLength: words.length > 0
      ? graphemes.length / words.length
      : 0
  };
}

// どの言語でも機能します
getTextStatistics("Hello world! How are you?", "en");
// { characters: 24, words: 5, sentences: 2, averageWordLength: 4.8 }

getTextStatistics("你好世界!你好吗?", "zh");
// { characters: 9, words: 5, sentences: 2, averageWordLength: 1.8 }

この関数は、各ロケールに対して正しい分割ルールを使用して、あらゆる言語のテキストに対して意味のある統計を生成します。