如何找到按字符或单词边界断开文本的位置?

定位用于截断、换行和光标操作的安全文本断点

介绍

当您在文本编辑器中截断文本、定位光标或处理点击事件时,您需要找到一个字符结束和另一个字符开始的位置,或者单词的起始和结束位置。在错误的位置分割文本可能会导致表情符号被拆分、组合字符被切断,或者单词被错误地分割。

JavaScript 的 Intl.Segmenter API 提供了 containing() 方法,可以在字符串中的任意位置找到对应的文本片段。这可以告诉您哪个字符或单词包含特定的索引,以及该片段的起始和结束位置。您可以利用这些信息找到安全的分割点,从而尊重字形簇边界和所有语言的语言学单词边界。

本文将解释为什么在任意位置分割文本会失败,如何使用 Intl.Segmenter 找到文本边界,以及如何利用边界信息进行截断、光标定位和文本选择。

为什么不能在任意位置分割文本

JavaScript 字符串由代码单元组成,而不是完整的字符。一个表情符号、带重音的字母或旗帜可能跨越多个代码单元。如果您在任意索引处截断字符串,可能会在字符中间将其分割。

请看以下示例:

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 的表情符号跨越了四个代码单元(从索引 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);

  // 在此段之前截断以避免破坏它
  return text.slice(0, segment.index);
}

truncateAtPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello " (在表情符号之前停止,而不是中间)

truncateAtPosition("café", 3);
// "caf" (在 é 之前停止)

此函数在目标位置找到段,并在其之前截断,确保不会拆分字形簇。

如果要在段之后而不是之前截断:

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 👨‍👩‍👧‍👦 " (包含完整的表情符号)

这将包含包含目标位置的整个段。

查找用于文本换行的单词边界

在按最大宽度换行时,您希望在单词之间换行,而不是在单词中间。使用单词分割来找到目标位置之前的单词边界:

function findWordBreakBefore(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 segment.index;
  }

  // 如果我们在空格或标点符号中,则在当前位置换行
  return position;
}

const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 ("world" 前的空格)

const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 ("世界" 前的边界)

此函数找到包含目标位置的单词的起始位置。如果位置已经在空格中,它将返回未更改的位置。

对于尊重单词边界的文本换行:

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 } (选择 "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 }

这将找到包含目标位置的完整句子,包括其边界。

在某个位置之后找到下一个边界

要按字素、单词或句子向前移动一个单位,可以遍历段落,直到找到一个起始位置在当前位置之后的段落:

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("H"之后的边界)

findNextBoundary(text, 6, "grapheme", "en");
// 17(家庭表情符号之后的边界)

findNextBoundary(text, 0, "word", "en");
// 5("Hello"之后的边界)

这将找到下一个段落的起始位置,这是移动光标或截断文本的安全位置。

在某个位置之前找到上一个边界

要按字素、单词或句子向后移动一个单位,可以找到当前位置之前的段落:

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(家庭表情符号之前的边界)

findPreviousBoundary(text, 11, "word", "en");
// 6("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(跨过整个表情符号)

moveWordForward(text, 0, "en");
// 6(移动到"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);
}

为边界选择合适的粒度

根据需要查找的内容使用不同的粒度:

  • 字素(Grapheme):在实现光标移动、字符删除或任何需要尊重用户视为单个字符的操作时使用。这可以防止拆分表情符号、组合字符或其他复杂的字素簇。

  • 单词(Word):用于单词选择、拼写检查、单词计数或任何需要语言学单词边界的操作。这适用于包括没有单词间空格的语言在内的各种语言。

  • 句子(Sentence):用于句子导航、文本转语音分段或任何逐句处理文本的操作。这会考虑缩写和其他情况下句号并不结束句子的情境。

在需要字符边界时不要使用单词边界,在需要单词边界时不要使用字素边界。每种粒度都有其特定用途。

边界操作的浏览器支持

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);
  // 使用分段信息
} else {
  // 针对旧版浏览器的回退方案
  // 使用基于字符串长度的近似边界
}

对于面向旧版浏览器的应用程序,提供基于近似边界的回退行为,或使用实现 Intl.Segmenter API 的 polyfill。

查找边界时的常见错误

不要假设每个代码单元都是有效的断点。许多位置会拆分字素簇或单词,导致无效或意外的结果。

不要使用 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");
// "你好世界…"

这个函数会在最大长度之前找到最后一个完整单词,并添加省略号,从而生成干净的截断文本,不会切断单词。