如何确定文本在字符或单词边界处的断开位置?

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

引言

当你需要截断文本、定位光标,或在文本编辑器中处理点击事件时,必须确定一个字符的结束和下一个字符的开始,或者单词的起止位置。如果在错误的位置断开文本,可能会拆分表情符号、切割组合字符,或错误地分割单词。

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);

  // 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 }

此函数会找到包含目标位置的完整句子及其边界。

查找当前位置后的下一个边界

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

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")

此函数会找到下一个分段的起始位置,这也是安全移动光标或截断文本的位置。

查找当前位置前的上一个边界

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

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);
}

选择合适的边界粒度

根据需求选择不同的粒度:

  • 字素(Grapheme):用于实现光标移动、字符删除或任何需要遵循用户视觉上单个字符的操作。这样可以避免拆分 emoji、组合字符或其他复杂的字素簇。

  • 单词(Word):用于单词选择、拼写检查、字数统计或任何需要语言学单词边界的操作。适用于包括无空格分词的语言。

  • 句子(Sentence):用于句子导航、文本转语音分段或任何按句处理文本的操作。能够识别缩写等不以句号结尾的情境。

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

边界操作的浏览器支持

Intl.Segmenter API 及其 containing() 方法于 2024 年 4 月达到 Baseline 状态。目前,最新版的 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 的 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");
// "你好世界…"

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