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