Subterranean Flower

JavaScriptのIntl.Segmenterで文章の意味分割を行う

Author
古都こと
ふよんとえんよぷよぐやま!!!

コンピュータ上で文字列を扱う時、データ上は単にコードユニットの羅列でしかなく、そこに単語や文としての意味合いはありません。

しかし我々は人間であり、単なる文字列ではなく文章として処理したい場合があります。そんなとき、 Intl.Segmenter が役に立つかもしれません。

文章の分割と仕様の標準化

プログラムを書く上で、文字列の処理、というより「文章の処理」をしたくなることがしばしばあります。文章というのは単語や文で構成された、単なる文字コードの連続ではなく、人間的に意味を持つ単位が連続したものとなります。

今まで文字列の意味的な分割は Intl.v8BreakIterator を用いて行っていました。これはChromeやNode.jsで使用されているV8エンジンの独自の仕様であり、一般的な物ではありません。なおかつNode.jsでは意図的に無効化されています。

Intl.v8BreakIterator の代替はしばらく存在しなかったのですが、 Intl.Segmenter というProposalが上がり、そろそろ正式なものになりそうです。Chrome 87にも実装されましたし、まだ使用はできませんがFirefoxやSafariにも実装自体はされたので、紹介しておきたいと思います。

Intl.v8BreakIterator

Intl.Segmenter を紹介する前に、歴史のお勉強として Intl.v8BreakIterator について知っておきましょう。

V8エンジンに独自実装されていた Intl.v8BreakIterator は、文字列を単語や文に分割する機能を持つオブジェクトです。

V8エンジン独自実装ですが、Node.jsではデフォルトでは無効化されているので実質的にChrome専用のメソッドとなります。 非標準のAPI であるため、使用は推奨されません。

このような感じで使えます:

const text = 'メロスは激怒した。';

const itr = new Intl.v8BreakIterator(['ja-jp'], { type: 'word'});
itr.adoptText(text);

let pos = itr.first();

while(pos != -1) {
  const nextPos = itr.next();
  if(nextPos === -1) { break; }
  const str = text.slice(pos, nextPos);
  const type = itr.breakType();
  console.log(str, type);

  pos = nextPos;
}

見ればわかると思いますが、名前にIteratorとついているのにIterableではありません。

出力はこうなります:

メロス ideo
は ideo
激怒 ideo
した ideo
。 none

Intl.Segmenter

Intl.SegmenterIntl.v8BreakIterator と似た機能を持つJavaScript標準のAPIです。長らく存在しなかった Intl.v8BreakiTerator の標準機能版となります。

使い方は以下のようにします:

// Segmenterオブジェクトを作成する
// 日本語を対象とし、単語単位で分割するSegmenter
const segmenter = new Intl.Segmenter("ja", {granularity: "word"});

// 文字列をセグメントに分割する
const segments = segmenter.segment('メロスは激怒した。');

// segmentsはIterableなのでfor-ofなどが使える
for(const seg of segments) {
  // seg.segmentに単語が入っている
  console.log(`Word: ${seg.segment}`);
}

これで以下のように出力されます:

Word: メロス
Word: は
Word: 激怒
Word: した
Word: 。

まずはSegmenterオブジェクトを作成します。Segmenterオブジェクトには第一引数にロケールを、第二引数にオプションを渡します。オプションには分割単位を指定します。

// Segmenterオブジェクトを作成する
// 日本語を対象とし、単語単位で分割するSegmenter
const segmenter = new Intl.Segmenter("ja", {granularity: "word"});

第二引数で選べる分割単位 granularity (粒度)は以下のようになっています:

  • grapheme(書記素 ≒ 文字)
  • word(単語)
  • sentence(文)

省略した場合は自動的にgraphemeになります。

次に文字列を渡し、セグメントに分割してもらいます。

// 文字列をセグメントに分割する
const segments = segmenter.segment('メロスは激怒した。');

これで文字列を適切に分割した、Iterableな Segments オブジェクトが返ってきます。

Iterableなのでfor-ofなどで扱えます。

// segmentsはIterableなのでfor-ofなどが使える
for(const seg of segments) {
  // seg.segmentに単語が入っている
  console.log(`Word: ${seg.segment}`);
}

使い方はこれだけです。

Segmentデータ

各々のSegmentデータは以下のようになっています:

type Segment = {
  segment: string;      // セグメント文字列
  index: number;        // セグメントの入力文字列上の位置
  input: string;        // 入力文字列の全体
  isWordLike?: boolean; // 単語かどうか。word分割のときのみ
};

segment には文字や単語、文などが入っています。何が入っているかはSegmenterオブジェクトに指定した granularity によって変わります。 word を指定していれば単語が入っています。

index は文字列全体の中のインデックスです。 input は入力文字列そのものが入っています。

isWordLikegranularity: 'word' のときのみ使用でき、単語なら true で、それ以外の記号などは false になります。

実査に使うときは分割代入などで必要な値だけを取り出すと使いやすいでしょう。

for(const {segment, isWordLike} of segments) {
  if(isWordLike) {
    console.log(segment);
  }
}

Segmentsオブジェクト

Segmentsオブジェクトには containing というメソッドがひとつ実装されています。

containing メソッドは文字列全体上のインデックスを受け取り、その位置の文字を内包するセングメントを返します。

例えば以下のように使います:

// Segmenterオブジェクトを作成する
// 日本語を対象とし、単語単位で分割するSegmenter
const segmenter = new Intl.Segmenter("ja", {granularity: "word"});

// 文字列をセグメントに分割する
const segments = segmenter.segment('メロスは激怒した。');

// 'メロスは激怒した。' の 2文字目(indexは1)が含まれるセグメントは?
console.log(segments.containing(1));

出力は以下のようになります:

{segment: "メロス", index: 0, input: "メロスは激怒した。", isWordLike: true}

「メロスは激怒した。」の2文字目「ロ」を内包しているセグメントは「メロス」なので、メロスのセグメントが得られます。

サポート状況

実装は済んでいるけどChrome以外は有効化されていない、という状況です。有効化を待ちましょう。

  • Chrome

    • Chrome87で正式リリース済み
  • Firefox

    • 実装済み / 未リリース
  • Safari

    • 実装済み / 未リリース
  • Node.js

    • 未有効化