문자 또는 단어 경계에서 텍스트를 어떻게 나눌 위치를 찾나요?
잘라내기, 줄 바꿈 및 커서 작업을 위한 안전한 텍스트 분할 위치 찾기
소개
텍스트를 잘라내거나, 커서를 위치시키거나, 텍스트 편집기에서 클릭을 처리할 때, 한 문자가 끝나고 다른 문자가 시작되는 위치 또는 단어가 시작되고 끝나는 위치를 찾아야 합니다. 잘못된 위치에서 텍스트를 나누면 이모지가 분리되거나, 결합 문자를 통과하거나, 단어가 잘못 나뉘게 됩니다.
자바스크립트의 Intl.Segmenter API는 문자열 내 특정 위치에서 텍스트 세그먼트를 찾기 위한 containing() 메서드를 제공합니다. 이 메서드는 특정 인덱스를 포함하는 문자나 단어, 해당 세그먼트의 시작 위치와 끝 위치를 알려줍니다. 이 정보를 사용하여 모든 언어에서 자소 클러스터 경계와 언어적 단어 경계를 존중하는 안전한 분할 지점을 찾을 수 있습니다.
이 글에서는 임의의 위치에서 텍스트를 나누는 것이 실패하는 이유, Intl.Segmenter로 텍스트 경계를 찾는 방법, 그리고 텍스트 잘라내기, 커서 위치 지정, 텍스트 선택을 위해 경계 정보를 사용하는 방법을 설명합니다.
텍스트를 아무 위치에서나 나눌 수 없는 이유
자바스크립트 문자열은 완전한 문자가 아닌 코드 유닛으로 구성됩니다. 하나의 이모지, 악센트가 있는 문자, 또는 국기는 여러 코드 유닛에 걸쳐 있을 수 있습니다. 임의의 인덱스에서 문자열을 자르면 문자를 중간에 분리할 위험이 있습니다.
다음 예제를 살펴보세요:
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 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 (위치 5는 공백이므로 단어가 아님)
이 함수는 커서 위치에 있는 단어와 그 시작 및 끝 인덱스를 반환하거나, 커서가 단어 안에 없으면 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를 구현하는 폴리필을 사용하세요.
경계 찾기에서 흔한 실수
모든 코드 단위가 유효한 중단점이라고 가정하지 마세요. 많은 위치가 자소 클러스터나 단어를 분할하여 유효하지 않거나 예상치 못한 결과를 생성합니다.
끝 경계를 찾기 위해 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");
// "你好世界…"
이 함수는 최대 길이 이전의 마지막 완전한 단어를 찾고 말줄임표를 추가하여 단어를 자르지 않는 깔끔하게 잘린 텍스트를 생성합니다.