文字または単語の境界でテキストを分割する位置を見つける方法

切り詰め、折り返し、カーソル操作のための安全なテキスト分割位置を特定する

はじめに

テキストを切り詰めたり、カーソルを配置したり、テキストエディタでクリックを処理したりする場合、ある文字が終わり別の文字が始まる位置、または単語の開始位置と終了位置を見つける必要があります。誤った位置でテキストを分割すると、絵文字が分割されたり、結合文字が切断されたり、単語が不適切に分割されたりします。

JavaScriptのIntl.Segmenter APIは、文字列内の任意の位置にあるテキストセグメントを見つけるためのcontaining()メソッドを提供します。これにより、特定のインデックスを含む文字または単語、そのセグメントの開始位置と終了位置がわかります。この情報を使用して、すべての言語で書記素クラスタ境界と言語的な単語境界を尊重する安全な分割ポイントを見つけることができます。

この記事では、任意の位置でテキストを分割することが失敗する理由、Intl.Segmenterを使用してテキスト境界を見つける方法、および切り詰め、カーソル配置、テキスト選択のために境界情報を使用する方法について説明します。

任意の位置でテキストを分割できない理由

JavaScriptの文字列は、完全な文字ではなくコードユニットで構成されています。1つの絵文字、アクセント付き文字、または旗は、複数のコードユニットにまたがることがあります。任意のインデックスで文字列を切断すると、文字の途中で分割するリスクがあります。

次の例を考えてみましょう。

const text = "Hello 👨‍👩‍👧‍👦 world";
const truncated = text.slice(0, 10);
console.log(truncated); // "Hello 👨‍�"

家族の絵文字は11個のコードユニットを使用します。位置10で切断すると絵文字が分割され、置換文字を含む壊れた出力が生成されます。

単語の場合、誤った位置で分割すると、ユーザーの期待に一致しない断片が作成されます。

const text = "Hello world";
const fragment = text.slice(0, 7);
console.log(fragment); // "Hello w"

ユーザーは、単語の途中ではなく、単語と単語の間でテキストが改行されることを期待します。位置7の前後の境界を見つけることで、より良い結果が得られます。

特定の位置のテキストセグメントを見つける

containing()メソッドは、特定のインデックスを含むテキストセグメントに関する情報を返します。

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

const segment = segments.containing(6);
console.log(segment);
// { segment: "👋🏽", index: 6, input: "Hello 👋🏽" }

位置6の絵文字は4つのコードユニット(インデックス6から9)にまたがっています。containing()メソッドは次を返します。

  • segment: 完全な書記素クラスタを文字列として
  • index: このセグメントが元の文字列で開始する位置
  • input: 元の文字列への参照

これは、位置6が絵文字の内部にあり、絵文字はインデックス6から始まり、完全な絵文字が「👋🏽」であることを示しています。

テキストの安全な切り詰めポイントを見つける

文字を分割せずにテキストを切り詰めるには、目標位置の前の書記素境界を見つけます。

function truncateAtPosition(text, maxIndex) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(maxIndex);

  // Truncate before this segment to avoid breaking it
  return text.slice(0, segment.index);
}

truncateAtPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello " (stops before the emoji, not in the middle)

truncateAtPosition("café", 3);
// "caf" (stops before é)

この関数は、目標位置のセグメントを見つけ、その前で切り詰めることで、書記素クラスタを分割しないことを保証します。

セグメントの前ではなく後で切り詰めるには次のようにします。

function truncateAfterPosition(text, minIndex) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(minIndex);
  const endIndex = segment.index + segment.segment.length;

  return text.slice(0, endIndex);
}

truncateAfterPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello 👨‍👩‍👧‍👦 " (includes the complete emoji)

これは、目標位置を含むセグメント全体を含みます。

テキスト折り返しのための単語境界を見つける

最大幅でテキストを折り返す場合、単語の途中ではなく、単語と単語の間で改行したいものです。単語セグメンテーションを使用して、目標位置の前の単語境界を見つけます。

function findWordBreakBefore(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  // If we're in a word, break before it
  if (segment.isWordLike) {
    return segment.index;
  }

  // If we're in whitespace or punctuation, break here
  return position;
}

const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (the space before "world")

const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (the boundary before "世界")

この関数は、目標位置を含む単語の開始位置を見つけます。位置がすでに空白内にある場合は、位置を変更せずに返します。

単語の境界を尊重するテキストの折り返しの場合:

function wrapTextAtWidth(text, maxLength, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  const lines = [];
  let currentLine = "";

  for (const { segment, isWordLike } of segments) {
    const potentialLine = currentLine + segment;

    if (potentialLine.length <= maxLength) {
      currentLine = potentialLine;
    } else {
      if (currentLine) {
        lines.push(currentLine.trim());
      }
      currentLine = isWordLike ? segment : "";
    }
  }

  if (currentLine) {
    lines.push(currentLine.trim());
  }

  return lines;
}

wrapTextAtWidth("Hello world from JavaScript", 12, "en");
// ["Hello world", "from", "JavaScript"]

wrapTextAtWidth("你好世界欢迎使用", 6, "zh");
// ["你好世界", "欢迎使用"]

この関数は、単語の境界を尊重し、最大長に収まるようにテキストを行に分割します。

カーソル位置を含む単語の検索

テキストエディタでは、ダブルクリック選択、スペルチェック、コンテキストメニューなどの機能を実装するために、カーソルがどの単語にあるかを知る必要があります:

function getWordAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  if (!segment.isWordLike) {
    return null;
  }

  return {
    word: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world";
getWordAtPosition(text, 7, "en");
// { word: "world", start: 6, end: 11 }

getWordAtPosition(text, 5, "en");
// null (position 5 is the space, not a word)

これは、カーソル位置にある単語とその開始および終了インデックスを返します。カーソルが単語内にない場合はnullを返します。

ダブルクリックによるテキスト選択の実装に使用します:

function selectWordAtPosition(text, position, locale) {
  const wordInfo = getWordAtPosition(text, position, locale);

  if (!wordInfo) {
    return { start: position, end: position };
  }

  return { start: wordInfo.start, end: wordInfo.end };
}

selectWordAtPosition("Hello world", 7, "en");
// { start: 6, end: 11 } (selects "world")

ナビゲーション用の文の境界の検索

ドキュメントナビゲーションやテキスト読み上げのセグメンテーションのために、特定の位置を含む文を検索します:

function getSentenceAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "sentence" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  return {
    sentence: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world. How are you? Fine thanks.";
getSentenceAtPosition(text, 15, "en");
// { sentence: "How are you? ", start: 13, end: 26 }

これは、対象位置を含む完全な文とその境界を検索します。

位置の後の次の境界の検索

1つの書記素、単語、または文だけ前に進むには、現在の位置の後に開始するセグメントが見つかるまでセグメントを反復処理します:

function findNextBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > position) {
      return segment.index;
    }
  }

  return text.length;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findNextBoundary(text, 0, "grapheme", "en");
// 1 (boundary after "H")

findNextBoundary(text, 6, "grapheme", "en");
// 17 (boundary after the family emoji)

findNextBoundary(text, 0, "word", "en");
// 5 (boundary after "Hello")

これは、次のセグメントが開始する位置を検索します。これは、カーソルを移動したりテキストを切り詰めたりするための安全な位置です。

位置の前の前の境界の検索

1つの書記素、単語、または文だけ後ろに戻るには、現在の位置の前のセグメントを検索します:

function findPreviousBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  let previousIndex = 0;

  for (const segment of segments) {
    if (segment.index >= position) {
      return previousIndex;
    }
    previousIndex = segment.index;
  }

  return previousIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findPreviousBoundary(text, 17, "grapheme", "en");
// 6 (boundary before the family emoji)

findPreviousBoundary(text, 11, "word", "en");
// 6 (boundary before "world")

これは、前のセグメントが開始する位置を検索します。これは、カーソルを後方に移動するための安全な位置です。

境界を使用したカーソル移動の実装

境界検索とカーソル位置を組み合わせて、適切なカーソル移動を実装します:

function moveCursorForward(text, cursorPosition, locale) {
  return findNextBoundary(text, cursorPosition, "grapheme", locale);
}

function moveCursorBackward(text, cursorPosition, locale) {
  return findPreviousBoundary(text, cursorPosition, "grapheme", locale);
}

function moveWordForward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > cursorPosition && segment.isWordLike) {
      return segment.index;
    }
  }

  return text.length;
}

function moveWordBackward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  let previousWordIndex = 0;

  for (const segment of segments) {
    if (segment.index >= cursorPosition) {
      return previousWordIndex;
    }
    if (segment.isWordLike) {
      previousWordIndex = segment.index;
    }
  }

  return previousWordIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
moveCursorForward(text, 6, "en");
// 17 (moves over the entire emoji)

moveWordForward(text, 0, "en");
// 6 (moves to the start of "world")

これらの関数は、書記素と単語の境界を尊重する標準的なテキストエディタのカーソル移動を実装します。

テキスト内のすべての分割位置の検索

テキストを安全に分割できるすべての位置を見つけるには、すべてのセグメントを反復処理し、その開始インデックスを収集します。

function getBreakOpportunities(text, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  return segments.map(segment => segment.index);
}

const text = "Hello 👨‍👩‍👧‍👦 world";
getBreakOpportunities(text, "grapheme", "en");
// [0, 1, 2, 3, 4, 5, 6, 17, 18, 19, 20, 21, 22]

getBreakOpportunities(text, "word", "en");
// [0, 5, 6, 17, 18, 22]

これにより、テキスト内のすべての有効な分割位置の配列が返されます。高度なテキストレイアウトや分析機能の実装に使用してください。

境界のエッジケースの処理

位置がテキストの最後にある場合、containing()は最後のセグメントを返します。

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

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

位置が末尾にあるため、最後の書記素が返されます。

位置が最初の文字の前にある場合、containing()は最初のセグメントを返します。

const segment = segments.containing(0);
console.log(segment);
// { segment: "H", index: 0, input: "Hello" }

空文字列の場合、セグメントが存在しないため、空文字列に対してcontaining()を呼び出すとundefinedが返されます。containing()を使用する前に空文字列をチェックしてください。

function safeContaining(text, position, granularity, locale) {
  if (text.length === 0) {
    return null;
  }

  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = segmenter.segment(text);
  return segments.containing(position);
}

境界に適した粒度の選択

検索する内容に応じて、異なる粒度を使用します。

  • 書記素: カーソル移動、文字削除、またはユーザーが単一の文字として認識するものを尊重する必要がある操作を実装する場合に使用します。これにより、絵文字、結合文字、またはその他の複雑な書記素クラスターの分割が防止されます。

  • 単語: 単語選択、スペルチェック、単語数カウント、または言語的な単語境界が必要な操作に使用します。これは、単語間にスペースがない言語を含め、すべての言語で機能します。

  • : 文のナビゲーション、音声合成のセグメンテーション、または文ごとにテキストを処理する操作に使用します。これは、略語やピリオドが文を終了しないその他のコンテキストを尊重します。

文字境界が必要な場合に単語境界を使用せず、単語境界が必要な場合に書記素境界を使用しないでください。それぞれに特定の目的があります。

境界操作のブラウザサポート

Intl.Segmenter APIとそのcontaining()メソッドは、2024年4月にベースラインステータスに達しました。Chrome、Firefox、Safari、Edgeの現行バージョンはこれをサポートしています。古いブラウザはサポートしていません。

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

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  const segments = segmenter.segment(text);
  const segment = segments.containing(position);
  // Use segment information
} else {
  // Fallback for older browsers
  // Use approximate boundaries based on string length
}

古いブラウザを対象とするアプリケーションの場合は、近似境界を使用したフォールバック動作を提供するか、Intl.Segmenter APIを実装するポリフィルを使用してください。

境界を見つける際のよくある間違い

すべてのコードユニットが有効な区切り点であると仮定しないでください。多くの位置は書記素クラスタや単語を分割し、無効または予期しない結果を生成します。

終了境界を見つけるためにstring.lengthを使用しないでください。代わりに、最後のセグメントのインデックスにその長さを加えたものを使用してください。

単語境界を扱う際は、isWordLikeの確認を忘れないでください。スペースや句読点などの非単語セグメントもセグメンターによって返されます。

単語境界が言語間で同じであると仮定しないでください。正確な結果を得るには、ロケールを考慮したセグメンテーションを使用してください。

パフォーマンスが重要な操作でcontaining()を繰り返し呼び出さないでください。複数の境界が必要な場合は、セグメントを一度反復処理してインデックスを構築してください。

境界操作のパフォーマンスに関する考慮事項

セグメンターの作成は高速ですが、非常に長いテキストのすべてのセグメントを反復処理すると遅くなる可能性があります。複数の境界が必要な操作の場合は、セグメント情報のキャッシュを検討してください:

class TextBoundaryCache {
  constructor(text, granularity, locale) {
    this.text = text;
    const segmenter = new Intl.Segmenter(locale, { granularity });
    this.segments = Array.from(segmenter.segment(text));
  }

  containing(position) {
    for (const segment of this.segments) {
      const end = segment.index + segment.segment.length;
      if (position >= segment.index && position < end) {
        return segment;
      }
    }
    return this.segments[this.segments.length - 1];
  }

  nextBoundary(position) {
    for (const segment of this.segments) {
      if (segment.index > position) {
        return segment.index;
      }
    }
    return this.text.length;
  }

  previousBoundary(position) {
    let previous = 0;
    for (const segment of this.segments) {
      if (segment.index >= position) {
        return previous;
      }
      previous = segment.index;
    }
    return previous;
  }
}

const cache = new TextBoundaryCache("Hello world", "grapheme", "en");
cache.containing(7);
cache.nextBoundary(7);
cache.previousBoundary(7);

これにより、すべてのセグメントを一度キャッシュし、複数の操作に対して高速な検索を提供します。

実用例: 省略記号付きテキストの切り詰め

境界検索と切り詰めを組み合わせて、最大長の前の最後の完全な単語でテキストを切り取る関数を構築します:

function truncateAtWordBoundary(text, maxLength, locale) {
  if (text.length <= maxLength) {
    return text;
  }

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

  let lastWordEnd = 0;

  for (const segment of segments) {
    const segmentEnd = segment.index + segment.segment.length;

    if (segmentEnd > maxLength) {
      break;
    }

    if (segment.isWordLike) {
      lastWordEnd = segmentEnd;
    }
  }

  if (lastWordEnd === 0) {
    return "";
  }

  return text.slice(0, lastWordEnd).trim() + "…";
}

truncateAtWordBoundary("Hello world from JavaScript", 15, "en");
// "Hello world…"

truncateAtWordBoundary("你好世界欢迎使用", 9, "zh");
// "你好世界…"

この関数は最大長の前にある最後の完全な単語を見つけ、省略記号を追加することで、単語を途中で切らない整った省略テキストを生成します。