문자 또는 단어 경계에서 텍스트를 나누는 위치를 찾는 방법은 무엇인가요?
잘림, 줄 바꿈 및 커서 작업을 위한 안전한 텍스트 나누기 위치 찾기
소개
텍스트를 자르거나, 커서를 배치하거나, 텍스트 편집기에서 클릭을 처리할 때 한 문자가 끝나고 다른 문자가 시작되는 위치 또는 단어가 시작되고 끝나는 위치를 찾아야 합니다. 잘못된 위치에서 텍스트를 나누면 이모지가 분리되거나, 결합 문자가 잘리거나, 단어가 잘못 나뉩니다.
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의 이모지는 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 }
이는 대상 위치를 포함하는 완전한 문장을 경계와 함께 찾습니다.
위치 이후의 다음 경계 찾기
자소, 단어 또는 문장 단위로 앞으로 이동하려면 현재 위치 이후에 시작하는 세그먼트를 찾을 때까지 반복합니다:
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);
}
경계에 적합한 세분성 선택
필요에 따라 다른 세분성을 사용하세요:
-
자소: 커서 이동, 문자 삭제 또는 사용자가 단일 문자로 인식하는 것을 존중해야 하는 모든 작업을 구현할 때 사용합니다. 이모지, 결합 문자 또는 기타 복잡한 자소 클러스터가 분리되는 것을 방지합니다.
-
단어: 단어 선택, 맞춤법 검사, 단어 수 계산 또는 언어적 단어 경계가 필요한 모든 작업에 사용합니다. 단어 사이에 공백이 없는 언어를 포함하여 모든 언어에서 작동합니다.
-
문장: 문장 탐색, 텍스트 음성 변환 세분화 또는 문장 단위로 텍스트를 처리하는 모든 작업에 사용합니다. 약어 및 마침표가 문장을 끝내지 않는 기타 컨텍스트를 존중합니다.
문자 경계가 필요할 때 단어 경계를 사용하지 말고, 단어 경계가 필요할 때 자소 경계를 사용하지 마세요. 각각 특정 목적을 제공합니다.
경계 작업에 대한 브라우저 지원
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를 구현하는 폴리필을 사용하세요.
경계 찾기 시 흔한 실수
모든 코드 단위가 유효한 중단점이라고 가정하지 마세요. 많은 위치가 자소 클러스터나 단어를 분할하여 유효하지 않거나 예상치 못한 결과를 생성합니다.
끝 경계를 찾기 위해 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");
// "你好世界…"
이 함수는 최대 길이 이전의 마지막 완전한 단어를 찾고 말줄임표를 추가하여 단어를 자르지 않는 깔끔하게 잘린 텍스트를 생성합니다.