如何确定文本在字符或单词边界处的断开位置?
定位安全的文本断点以用于截断、换行和光标操作
引言
当你需要截断文本、定位光标,或在文本编辑器中处理点击事件时,必须确定一个字符的结束和下一个字符的开始,或者单词的起止位置。如果在错误的位置断开文本,可能会拆分表情符号、切割组合字符,或错误地分割单词。
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");
// "你好世界…"
此函数会在最大长度前找到最后一个完整单词,并添加省略号,从而生成不会截断单词的整洁截断文本。