JavaScriptでテキストを単語に分割するには?

Intl.Segmenterを使用して、あらゆる言語のテキストから単語を抽出できます。単語間にスペースがない言語も含みます。

はじめに

テキストから単語を抽出する必要がある場合、一般的なアプローチはsplit(" ")を使用して空白で分割することです。これは英語では機能しますが、単語間に空白を使用しない言語では完全に失敗します。中国語、日本語、タイ語、その他の言語では、単語の区切り文字なしで連続してテキストを書きますが、ユーザーはそのテキスト内の個別の単語を認識します。

Intl.Segmenter APIはこの問題を解決します。Unicodeの標準と各言語の言語学的ルールに従って単語の境界を識別します。言語が空白を使用するかどうかに関係なく、テキストから単語を抽出でき、セグメンターは単語の始まりと終わりを決定する複雑さを処理します。

この記事では、基本的な文字列分割が国際的なテキストで失敗する理由、異なる書記体系での単語境界の仕組み、そしてIntl.Segmenterを使用してすべての言語のテキストを正しく単語に分割する方法について説明します。

空白での分割が失敗する理由

split()メソッドは、区切り文字の各出現箇所で文字列を分割します。英語のテキストでは、空白で分割すると単語が抽出されます。

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

このアプローチは単語が空白で区切られていることを前提としています。多くの言語はこのパターンに従いません。

中国語のテキストには単語間に空白が含まれていません。

const text = "你好世界";
const words = text.split(" ");
console.log(words);
// ["你好世界"]

ユーザーは2つの異なる単語を見ていますが、分割する空白がないため、split()は文字列全体を単一の要素として返します。

日本語のテキストは複数の文字体系を混在させ、単語間に空白を使用しません。

const text = "今日は良い天気です";
const words = text.split(" ");
console.log(words);
// ["今日は良い天気です"]

この文には複数の単語が含まれていますが、空白で分割すると1つの要素が生成されます。

タイ語のテキストも空白なしで単語を連続して書きます。

const text = "สวัสดีครับ";
const words = text.split(" ");
console.log(words);
// ["สวัสดีครับ"]

テキストには2つの単語が含まれていますが、split()は1つの要素を返します。

これらの言語では、単語の境界を識別するために異なるアプローチが必要です。

単語境界に正規表現が失敗する理由

正規表現の単語境界は、単語文字と非単語文字の間の位置にマッチするために \b パターンを使用します。これは英語では機能します。

const text = "Hello world!";
const words = text.match(/\b\w+\b/g);
console.log(words);
// ["Hello", "world"]

このパターンは、スペースのない言語では失敗します。正規表現エンジンは中国語、日本語、タイ語などのスクリプトでは単語境界を認識しないからです。

const text = "你好世界";
const words = text.match(/\b\w+\b/g);
console.log(words);
// ["你好世界"]

正規表現は中国語の単語境界を理解していないため、文字列全体を1つの単語として扱います。

英語でさえ、句読点、縮約形、特殊文字があると、正規表現パターンは不正確な結果を生成することがあります。正規表現はすべての表記システムにおける言語的な単語分割を処理するようには設計されていません。

言語間での単語境界とは

単語境界とは、テキスト内で一つの単語が終わり、別の単語が始まる位置です。異なる表記システムでは、単語境界に異なる規則が使用されています。

英語、スペイン語、フランス語、ドイツ語などのスペース区切り言語では、スペースを使って単語境界を示します。「hello」という単語は「world」からスペースで区切られています。

中国語、日本語、タイ語などの連続書記言語では、単語間にスペースを使用しません。単語境界は意味的・形態的規則に基づいて存在しますが、これらの境界はテキスト内で視覚的に示されていません。中国語の読者は、視覚的な区切りではなく、言語に対する馴染みを通じて、ある単語がどこで終わり、別の単語がどこから始まるかを認識します。

いくつかの言語は混合した規則を使用します。日本語は漢字、ひらがな、カタカナを組み合わせ、文字タイプの遷移や文法構造に基づいて単語境界が発生します。

Unicode標準はUAX 29で単語境界規則を定義しています。これらの規則は、すべてのスクリプトの単語境界を識別する方法を指定しています。これらの規則は、単語の始まりと終わりを決定するために、文字プロパティ、スクリプトタイプ、言語パターンを考慮します。

Intl.Segmenterを使用してテキストを単語に分割する

Intl.Segmenterコンストラクタは、Unicodeルールに従ってテキストを分割するセグメンターオブジェクトを作成します。ロケールと粒度を指定します。

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

最初の引数はロケール識別子です。2番目の引数はオプションオブジェクトで、granularity: "word"はセグメンターに単語境界で分割するよう指示します。

segment()メソッドはセグメントを含む反復可能オブジェクトを返します。for...ofを使用してセグメントを反復処理できます。

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

for (const segment of segmenter.segment(text)) {
  console.log(segment);
}
// { segment: "Hello", index: 0, input: "Hello world!", isWordLike: true }
// { segment: " ", index: 5, input: "Hello world!", isWordLike: false }
// { segment: "world", index: 6, input: "Hello world!", isWordLike: true }
// { segment: "!", index: 11, input: "Hello world!", isWordLike: false }

各セグメントオブジェクトには以下のプロパティが含まれています:

  • segment:このセグメントのテキスト
  • index:このセグメントが開始する元の文字列内の位置
  • input:分割される元の文字列
  • isWordLike:このセグメントが単語か非単語コンテンツかどうか

isWordLikeプロパティの理解

テキストを単語で分割すると、セグメンターは単語セグメントと非単語セグメントの両方を返します。単語には文字、数字、表意文字が含まれます。非単語セグメントには空白、句読点、その他の区切り文字が含まれます。

isWordLikeプロパティは、セグメントが単語であるかどうかを示します。このプロパティは、単語文字を含むセグメントではtrue、空白、句読点、またはその他の非単語文字のみを含むセグメントではfalseです。

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

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "Hello" true
// "," false
// " " false
// "world" true
// "!" false

isWordLikeプロパティを使用して、句読点や空白から単語セグメントをフィルタリングします。これにより、区切り文字なしの単語だけが得られます。

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

console.log(words);
// ["Hello", "world"]

このパターンは、空白のない言語を含む任意の言語で機能します。

スペースのないテキストからの単語抽出

セグメンターは、スペースを使用しない言語の単語境界を正確に識別します。中国語テキストの場合、セグメンターはUnicodeルールと言語パターンに基づいて単語境界で分割します。

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

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "你好" true
// "世界" true

セグメンターはこのテキストで2つの単語を識別します。スペースはありませんが、セグメンターは中国語の単語境界を理解し、テキストを適切に分割します。

日本語テキストの場合、セグメンターは混合スクリプトの複雑さを処理し、単語境界を識別します。

const segmenter = new Intl.Segmenter("ja", { granularity: "word" });
const text = "今日は良い天気です";

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "今日" true
// "は" true
// "良い" true
// "天気" true
// "です" true

セグメンターはこの文を5つの単語セグメントに分割します。「は」のような助詞が別の単語であることと、「天気」のような複合語が単一のユニットを形成することを認識します。

タイ語テキストの場合、セグメンターはスペースなしで単語境界を識別します。

const segmenter = new Intl.Segmenter("th", { granularity: "word" });
const text = "สวัสดีครับ";

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "สวัสดี" true
// "ครับ" true

セグメンターはこの挨拶の中で2つの単語を正確に識別します。

単語抽出関数の構築

任意の言語でテキストから単語を抽出する関数を作成します。

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("今日は良い天気です", "ja");
// ["今日", "は", "良い", "天気", "です"]

getWords("Bonjour le monde!", "fr");
// ["Bonjour", "le", "monde"]

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("今日は良い天気です", "ja");
// 5

countWords("Bonjour le monde", "fr");
// 3

countWords("สวัสดีครับ", "th");
// 2

カウント結果は、各言語におけるユーザーの単語境界の認識と一致します。

特定の位置を含む単語を見つける

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

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

const segment = segments.containing(7);
console.log(segment);
// { segment: "world", index: 6, input: "Hello world", isWordLike: true }

インデックス7は単語「world」内にあり、その単語はインデックス6から始まります。このメソッドはその単語のセグメントオブジェクトを返します。

インデックスが空白や句読点内にある場合、メソッドはisWordLike: falseでそのセグメントを返します。

const segment = segments.containing(5);
console.log(segment);
// { segment: " ", index: 5, input: "Hello world", isWordLike: false }

これを使用して、ダブルクリックによる単語選択、カーソル位置に基づくコンテキストメニュー、現在の単語のハイライトなどのテキストエディタ機能を実装できます。

句読点と短縮形の処理

セグメンターは句読点を別々のセグメントとして扱います。英語の短縮形は通常、複数のセグメントに分割されます。

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "I can't do it.";

for (const { segment, isWordLike } of segmenter.segment(text)) {
  console.log(segment, isWordLike);
}
// "I" true
// " " false
// "can" true
// "'" false
// "t" true
// " " false
// "do" true
// " " false
// "it" true
// "." false

短縮形「can't」は「can」、「'」、「t」に分割されます。短縮形を単一の単語として保持する必要がある場合は、アポストロフィに基づいてセグメントをマージするための追加のロジックが必要です。

ほとんどのユースケースでは、短縮形が分割されていても、単語のようなセグメントをカウントすることで意味のある単語数が得られます。

ロケールが単語分割に与える影響

セグメンターに渡すロケールは、単語の境界がどのように決定されるかに影響します。異なるロケールでは、同じテキストに対して異なるルールが適用される場合があります。

単語境界のルールが明確に定義されている言語では、ロケールによって正しいルールが適用されることが保証されます。

const segmenterEn = new Intl.Segmenter("en", { granularity: "word" });
const segmenterZh = new Intl.Segmenter("zh", { granularity: "word" });

const text = "你好世界";

const wordsEn = Array.from(segmenterEn.segment(text))
  .filter(s => s.isWordLike)
  .map(s => s.segment);

const wordsZh = Array.from(segmenterZh.segment(text))
  .filter(s => s.isWordLike)
  .map(s => s.segment);

console.log(wordsEn);
// ["你好世界"]

console.log(wordsZh);
// ["你好", "世界"]

英語のロケールは中国語の単語境界を認識せず、文字列全体を1つの単語として扱います。中国語のロケールは中国語の単語境界ルールを適用し、2つの単語を正しく識別します。

分割するテキストの言語に適したロケールを常に使用してください。

パフォーマンス向上のための再利用可能なセグメンターの作成

セグメンターの作成はそれほど負荷の高い処理ではありませんが、複数の文字列に対して同じセグメンターを再利用することでパフォーマンスを向上させることができます。

const enSegmenter = new Intl.Segmenter("en", { granularity: "word" });
const zhSegmenter = new Intl.Segmenter("zh", { granularity: "word" });
const jaSegmenter = new Intl.Segmenter("ja", { granularity: "word" });

function getWords(text, locale) {
  const segmenter = locale === "zh" ? zhSegmenter
    : locale === "ja" ? jaSegmenter
    : enSegmenter;

  return Array.from(segmenter.segment(text))
    .filter(s => s.isWordLike)
    .map(s => s.segment);
}

このアプローチでは、セグメンターを一度作成し、getWords()のすべての呼び出しで再利用します。セグメンターはロケールデータをキャッシュするため、インスタンスを再利用することで繰り返しの初期化を避けることができます。

実用例:単語頻度分析ツールの構築

単語分割と計数を組み合わせてテキスト内の単語頻度を分析します。

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

  const frequency = {};
  for (const word of words) {
    frequency[word] = (frequency[word] || 0) + 1;
  }

  return frequency;
}

const text = "Hello world! Hello everyone in this world.";
const frequency = getWordFrequency(text, "en");
console.log(frequency);
// { hello: 2, world: 2, everyone: 1, in: 1, this: 1 }

この関数はテキストを単語に分割し、小文字に正規化して出現回数をカウントします。あらゆる言語で機能します。

const textZh = "你好世界!你好大家!";
const frequencyZh = getWordFrequency(textZh, "zh");
console.log(frequencyZh);
// { "你好": 2, "世界": 1, "大家": 1 }

同じロジックで、修正なしに中国語のテキストも処理できます。

ブラウザサポートの確認

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

APIを使用する前にサポート状況を確認してください。

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  // segmenterを使用
} else {
  // 古いブラウザ向けのフォールバック
}

古いブラウザをターゲットにした本番アプリケーションでは、フォールバック実装を提供してください。シンプルなフォールバックとして、英語テキストにはsplit()を使用し、他の言語には文字列全体を返します。

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

  // フォールバック:スペース区切りの言語でのみ機能
  return text.split(/\s+/).filter(word => word.length > 0);
}

これにより、古いブラウザでもコードは実行されますが、スペースで区切られていない言語では機能が制限されます。

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

多言語テキストに対してスペースや正規表現パターンで分割しないでください。これらのアプローチは一部の言語でしか機能せず、中国語、日本語、タイ語など、スペースのない言語では失敗します。

単語を抽出する際にisWordLikeでフィルタリングすることを忘れないでください。このフィルターがなければ、スペース、句読点、その他の非単語セグメントが結果に含まれてしまいます。

テキストを分割する際に間違ったロケールを使用しないでください。ロケールによって適用される単語境界ルールが決まります。中国語テキストに英語ロケールを使用すると、不正確な結果が生じます。

すべての言語が同じ方法で単語を定義すると仮定しないでください。単語境界は表記システムと言語的慣習によって異なります。これらの違いを処理するにはロケール対応の分割を使用してください。

国際的なテキストの単語数をsplit(" ").lengthを使用してカウントしないでください。これはスペースで区切られた言語でのみ機能し、他の言語では誤ったカウントを生成します。

単語分割を使用するタイミング

以下の場合に単語分割を使用してください:

  • 複数の言語にわたるユーザー生成コンテンツの単語数をカウントする
  • あらゆる表記システムで機能する検索とハイライト機能を実装する
  • 国際的なテキストを処理するテキスト分析ツールを構築する
  • テキストエディタで単語ベースのナビゲーションや編集機能を作成する
  • 多言語ドキュメントからキーワードや用語を抽出する
  • あらゆる言語を受け入れるフォームで単語数制限を検証する

文字数だけが必要な場合は単語分割を使用しないでください。文字レベルの操作にはグラフェム分割を使用してください。

文の分割には単語分割を使用しないでください。その目的には文の粒度を使用してください。

単語分割が国際化にどう適合するか

Intl.Segmenter APIはECMAScript国際化APIの一部です。このファミリーの他のAPIは国際化の異なる側面を処理します:

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

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