後編

無料公開中公開翌日4時まで

文字数カウントから検索まで、日本語の実用例

Intl.Segmenterを使うと、日本語の文字列処理でつまずきやすい場面を標準APIだけで扱えます。後編では、見た目どおりの文字数カウント、単語単位での折り返しと絞り込み、文単位での分割という実用パターンを、コードとともに紹介します。

(701号)

目次
シェア

はじめに

前編では、標準APIであるIntl.Segmenterの基本構文と返り値の構造を解説しました。

Intl.Segmenterの基本形
const segmenter = new Intl.Segmenter(locales, options);
const segments = segmenter.segment(string);

optionsのgranularity(粒度)プロパティによって、分割する基準を指定します。

  • "grapheme"(書記素): 文字の結合やサロゲートペアを考慮し、人間が見たまま認識している「1文字」を正しく取り出すための粒度
  • "word"(単語): スペース区切りの分かち書きではない日本語の文章から、単語の区切りを見つけ出す粒度
  • "sentence"(文): 「。」や「!」などの句点・感嘆符を手がかりに、文単位でテキストを区切る粒度

後編では、こうした仕組みを踏まえて、実際の開発で役立つ具体的なユースケースを見ていきましょう。

1. 見た目どおりに数える文字数カウンター

前編の冒頭でも触れたとおり、結合絵文字や異体字セレクター、結合濁点を含む文字列を「ユーザーの見た目どおりの1文字」として数えるには、granularity: "grapheme"が最適です。

スプレッド構文([...segments])を使って配列化することで、シンプルに長さを取得できます。

見た目どおりの文字数をカウントする
const graphemeSegmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });

function countGraphemes(text) {
  return [...graphemeSegmenter.segment(text)].length;
}

// 通常の文字
console.log(countGraphemes("こんにちは")); // 5

// サロゲートペア(絵文字)
console.log(countGraphemes("🍣🍺")); // 2

// ZWJ結合絵文字(家族)
console.log(countGraphemes("👨‍👩‍👧‍👦")); // 1

// 国旗絵文字(地域指示記号2文字の組み合わせ)
console.log(countGraphemes("🇯🇵")); // 1

// 異体字セレクター
console.log(countGraphemes("葛󠄀")); // 1  <= "葛󠄀".length は 3

フォームの入力欄など、日常的なUIで扱う長さの文字列であれば、この書き方で十分実用的です。

大量のテキストや長文を扱う場合

数万文字を超えるような長文や、大量のドキュメント原稿を何度も繰り返しカウントするような場面では、スプレッド構文による配列化のコストが無視できなくなる可能性があります。

segmenter.segment()が返す反復可能オブジェクトをそのままfor...ofで走査すれば、中間配列をメモリ上に生成せずに文字数だけをカウントできます。

メモリ効率を意識したカウント
function countGraphemesFast(text) {
  let count = 0;
  for (const _ of graphemeSegmenter.segment(text)) {
    count++;
  }
  return count;
}

要件や扱うデータ量に応じて、手軽なスプレッド構文とループによるカウントを使い分けるとよいでしょう。

これで文字数カウントは完全解決なのか?

Intl.Segmenterの登場により、「ユーザーが見た目どおりに1文字と認識する数を数える」という長年の課題は、実行環境のUnicodeバージョンの違いによる影響を除けば、ほぼ解決したと言えます。

ただし、あらゆる文字数カウントがこれに置き換わるわけではありません。

文字数の数え方は文脈によって異なります。全角・半角を区別したいケース、保存先のシステムやデータベースの制限(バイト数など)に合わせるケース、あるいはサービス独自のルール(SNSの文字数制限など)に従うケースなど、用途ごとの「1文字の定義」は依然として存在します。

画面上の表示として「人間が見たままの文字数」を扱いたい場面ではIntl.Segmenterを活用し、システムや慣習上の要件がある場合はこれまでどおりそれぞれのルールに合わせて実装する、という使い分けを意識しておくとよいでしょう。

2. UI向けの自然なテキスト折り返し(ワードラップ)

Webデザインにおいて、日本語の見出しやキャッチコピーが不自然な位置で折り返されて読みにくくなる問題があります。

CSSではword-break: auto-phrase(内部でBudouXなどを利用した折り返し規則)のサポートが進んでいますが、CanvasやSVGへの描画、あるいはCSSに頼らず動的にDOMを組みたい場面ではJavaScript側で単語に分解する必要があります。

BudouXをそのまま使えればいいのですが、ライブラリが使えないという場面もあります。そういった場面ではgranularity: "word"を活用します。

単語単位でspanタグで囲む
const wordSegmenter = new Intl.Segmenter("ja", { granularity: "word" });

function wrapWords(text) {
  const fragments = [];

  for (const { segment, isWordLike } of wordSegmenter.segment(text)) {
    if (isWordLike) {
      // 単語は折り返されないよう inline-block の span で囲む
      fragments.push(`<span class="word-unit">${segment}</span>`);
    } else {
      // 記号や空白はそのまま出力する
      fragments.push(segment);
    }
  }

  return fragments.join("");
}

const html = wrapWords("最新のWebフロントエンド技術を学ぶ。");
console.log(html);
// Chrome 154による分割(見やすさのため改行):
//
// <span class="word-unit">最新</span>
// <span class="word-unit">の</span>
// <span class="word-unit">Web</span>
// <span class="word-unit">フロント</span>
// <span class="word-unit">エンド</span>
// <span class="word-unit">技術</span>
// <span class="word-unit">を</span>
// <span class="word-unit">学ぶ</span>
// 。

あとはword-unitクラスが付与された要素に対して適切にCSSを設定するという手法です。

3. クライアントサイドでのシンプルな単語絞り込み

「検索」というと、サーバーサイドの専用検索エンジンや形態素解析ライブラリを思い浮かべるかもしれません。数万件規模の全文検索や、表記揺れの吸収、動詞の活用形を考慮したマッチなどが必要なら、専用の検索サービスや形態素解析エンジンを採用するべきです。

しかし、「FAQの絞り込み」や「数十件程度のリスト内検索」など、外部サービスを契約したり数MBの辞書ファイルをダウンロードさせたりするほどではない、シンプルな検索UIを作りたい場面もあります。

そうしたケースにおいて、includes()による単純な部分一致検索には「単語の一部の文字にヒットしてしまう」という課題があります。

単純な部分一致の課題
const items = [
  "フライパンで炒める",
  "朝食にパンを食べる"
];

// 「パン」で検索したい
const query = "パン";

// 単純な includes では「フライパン」の末尾にも一致してしまう
const result = items.filter(text => text.includes(query));
console.log(result);
// ["フライパンで炒める", "朝食にパンを食べる"]

英語であれば\b(単語境界)やスペース区切りで単語の一致を判定できますが、日本語には単語間の空白がありません。

ここでIntl.Segmenter(granularity: "word")を使い、文章をあらかじめ単語に分解しておくことで、意図しないヒットを防いだ一致判定がブラウザ標準だけで実現できます。

単語単位で厳密に絞り込む
const wordSegmenter = new Intl.Segmenter("ja", { granularity: "word" });

function filterByWord(items, targetWord) {
  return items.filter(text => {
    // 文章を単語に分解し、記号や空白を除外する
    const words = [...wordSegmenter.segment(text)]
      .filter(item => item.isWordLike)
      .map(item => item.segment);

    return words.includes(targetWord);
  });
}

const items = [
  "フライパンで炒める",
  "朝食にパンを食べる"
];

console.log(filterByWord(items, "パン"));
// ["朝食にパンを食べる"]

"フライパン"という単語と"パン"という単語が正しく区別されるため、「フライパン」の誤爆を排除して目的の文章だけを抽出できます。

ただし、先ほど述べたとおり、専門用語や造語、それほど一般的ではない単語が正しく分割されないという問題もありますので注意が必要です。

本格的な検索システムとの使い分け

  • 専用の検索ツールを使うべきケース:

    • ドキュメント数が膨大
    • 類義語や表記揺れ(「リンゴ」と「りんご」)の吸収が必要
    • 活用の変化(「走る」「走った」)を同一視したい
  • Intl.Segmenterで十分なケース:

    • 設定画面やヘルプ・FAQなどの軽量なクライアントサイド検索
    • 巨大な辞書ライブラリをバンドルしてページ表示速度を落としたくない
    • 単純な部分一致(includes())による意図しないヒットを避けたい

4. 文単位での処理

granularity: "sentence"を指定すると、句点(「。」)や感嘆符(「!」「?」)などを基準にして文単位で分割できます。

文章を1文ずつ区切って段階的にアニメーション表示させたい場合などに使えます。

文単位で分割して処理する
const sentenceSegmenter = new Intl.Segmenter("ja", { granularity: "sentence" });

const text = "こんにちは! 本日の天気は晴れです。お出かけには最適な一日になるでしょう。";

for (const { segment, index } of sentenceSegmenter.segment(text)) {
  console.log(`[位置: ${index}] ${segment.trim()}`);
}
// [位置: 0] こんにちは!
// [位置: 7] 本日の天気は晴れです。
// [位置: 18] お出かけには最適な一日になるでしょう。

Intl.Segmenterを使えば、言語に応じた境界規則を考慮して文を切り出してくれます。

しかし、これでどんな場合もOKというわけではありません。代表的なのは括弧類が混ざった場合です。

特にカギカッコは会話、強調、引用などさまざまな場面で用いられるため、その中に句点が入っていると「どこまでが1文か」の判断が難しくなります。これは人間が判断しても同様でしょう。

カギカッコを含む文の分割
const segmenter = new Intl.Segmenter("ja", { granularity: "sentence" });

const text = "彼は悪い笑みを浮かべながら「ほら、こう言うんだ。『これはペンです。リンゴではありません』って。はっきりとね」と言って、私を見つめた。";

for (const { segment, index } of segmenter.segment(text)) {
  console.log(`[位置: ${index}] ${segment.trim()}`);
}
// [位置: 0] 彼は悪い笑みを浮かべながら「ほら、こう言うんだ。『
// [位置: 25] これはペンです。
// [位置: 33] リンゴではありません』って。
// [位置: 47] はっきりとね」と言って、私を見つめた。

このような分割結果になります。また、カギカッコは無限に入れ子にできてしまうため、正規表現を使ってもあらゆる場面に対応することは難しいでしょう。そもそも、カギカッコを含む文では「どこまでを1文とするか」を先に定義しなければならないからです。

Intl.Segmenterは句点や感嘆符を基準に文を分割するもので、あらゆる場面で使える万能処理ではないと考えておく必要があります。

まとめ

JavaScriptの文字列処理は長年、サロゲートペアや結合文字、非スペース区切り言語といったさまざまな複雑さに悩まされてきました。

Intl.Segmenterを活用することで、外部ライブラリを抱え込むことなく、標準機能だけで人間にとって自然な単位の文字列分割が可能になります。

  • "grapheme"(書記素): 見た目どおりの正確な文字数を数えるときに使う
  • "word"(単語): 自然な改行(ワードラップ)や軽量な検索・絞り込みに使う(isWordLikeで記号を除外)
  • "sentence"(文): 句点を基準にした1文ごとのUI処理に使う
  • 形態素解析やIMEのように辞書を追加できるわけではないため、独自の固有名詞などの分割限界を理解しておく
  • "sentence"の区切りは、あくまでも句点や感嘆符を基準としたもの

「絵文字のカウントがずれる」「単語単位でテキストを扱いたいがライブラリを重くしたくない」という課題にぶつかったときは、ぜひIntl.Segmenterの利用を検討してみてください。

この記事の内容についての意見・感想を送る
CodeGridのDiscordで知識を深めよう。参加する。

人気のシリーズ