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, not 1
"👨‍👩‍👧‍👦".length; // 11, not 1
"किं".length; // 5, not 2
"🇺🇸".length; // 4, not 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は、絵文字が4つのコードユニットを使用するため10を返します。

各セグメントオブジェクトには次の情報が含まれます。

  • 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);
}
// Logs: "c", "a", "f", "é"

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

書記素セグメンテーションを使用して正確な文字カウンターを構築します:

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

// Test with various inputs
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" in Chinese
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"); // ["สวัสดี", "ครับ"] (Thai)

この関数は、スペースで区切られたスクリプトとスペースで区切られていないスクリプトの両方を処理し、あらゆる言語で機能します。

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

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

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); // Index 7 is in "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

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

const segment = segments.containing(5); // Index 5 is the space
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

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

テキスト処理のための文のセグメント化

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

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

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrect: breaks at "Dr." and "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 = "こんにちは。お元気ですか。"; // Uses Japanese full stop

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

各粒度を使用するタイミング

カウントまたは分割する必要があるものに基づいて粒度を選択します。

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

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

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

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

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

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

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

// Reuse these segmenters for multiple strings
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") {
  // Use Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Fallback for older browsers
  const count = text.length; // Not accurate, but available
}

古いブラウザをターゲットとする本番アプリケーションの場合は、ポリフィルの使用または機能の縮退を検討してください。

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

ユーザーに文字カウントを表示するために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
  };
}

// Works for any language
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 }

この関数は、各ロケールに適した正しいセグメンテーションルールを使用して、あらゆる言語のテキストに対して意味のある統計を生成します。