¿Cómo encontrar dónde cortar el texto en límites de caracteres o palabras?
Localiza posiciones seguras para cortar texto en operaciones de truncamiento, ajuste y cursor
Introducción
Cuando truncas texto, posicionas un cursor o manejas clics en un editor de texto, necesitas encontrar dónde termina un carácter y comienza otro, o dónde empiezan y terminan las palabras. Romper el texto en la posición incorrecta divide emojis, corta a través de caracteres combinados o divide palabras incorrectamente.
La API Intl.Segmenter de JavaScript proporciona el método containing() para encontrar el segmento de texto en cualquier posición de una cadena. Esto te indica qué carácter o palabra contiene un índice específico, dónde comienza ese segmento y dónde termina. Puedes usar esta información para encontrar puntos de ruptura seguros que respeten los límites de grupos de grafemas y los límites lingüísticos de palabras en todos los idiomas.
Este artículo explica por qué romper texto en posiciones arbitrarias falla, cómo encontrar límites de texto con Intl.Segmenter y cómo usar la información de límites para truncamiento, posicionamiento del cursor y selección de texto.
Por qué no puedes romper texto en cualquier posición
Las cadenas de JavaScript consisten en unidades de código, no en caracteres completos. Un solo emoji, letra acentuada o bandera puede abarcar múltiples unidades de código. Si cortas una cadena en un índice arbitrario, corres el riesgo de dividir un carácter por la mitad.
Considera este ejemplo:
const text = "Hello 👨👩👧👦 world";
const truncated = text.slice(0, 10);
console.log(truncated); // "Hello 👨�"
El emoji de familia utiliza 11 unidades de código. Cortar en la posición 10 divide el emoji, produciendo una salida rota con un carácter de reemplazo.
Para las palabras, romper en la posición incorrecta crea fragmentos que no coinciden con las expectativas del usuario:
const text = "Hello world";
const fragment = text.slice(0, 7);
console.log(fragment); // "Hello w"
Los usuarios esperan que el texto se rompa entre palabras, no en medio de una palabra. Encontrar el límite antes o después de la posición 7 produce mejores resultados.
Encontrar el segmento de texto en una posición específica
El método containing() devuelve información sobre el segmento de texto que incluye un índice específico:
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 👋🏽" }
El emoji en la posición 6 abarca cuatro unidades de código (desde el índice 6 hasta el 9). El método containing() devuelve:
segment: el grupo completo de grafemas como una cadenaindex: dónde comienza este segmento en la cadena originalinput: referencia a la cadena original
Esto te indica que la posición 6 está dentro del emoji, el emoji comienza en el índice 6, y el emoji completo es "👋🏽".
Encontrar puntos seguros de truncamiento para texto
Para truncar texto sin romper caracteres, encuentra el límite de grafema antes de tu posición objetivo:
function truncateAtPosition(text, maxIndex) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = segmenter.segment(text);
const segment = segments.containing(maxIndex);
// Truncar antes de este segmento para evitar romperlo
return text.slice(0, segment.index);
}
truncateAtPosition("Hello 👨👩👧👦 world", 10);
// "Hello " (se detiene antes del emoji, no en el medio)
truncateAtPosition("café", 3);
// "caf" (se detiene antes de é)
Esta función encuentra el segmento en la posición objetivo y trunca antes de él, asegurando que nunca dividas un grupo de grafemas.
Para truncar después del segmento en lugar de antes:
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 👨👩👧👦 " (incluye el emoji completo)
Esto incluye el segmento completo que contiene la posición objetivo.
Encontrar límites de palabras para el ajuste de texto
Al ajustar texto a un ancho máximo, se desea romper entre palabras, no en medio de una palabra. Utiliza la segmentación de palabras para encontrar el límite de palabra antes de tu posición objetivo:
function findWordBreakBefore(text, position, locale) {
const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
const segments = segmenter.segment(text);
const segment = segments.containing(position);
// Si estamos en una palabra, romper antes de ella
if (segment.isWordLike) {
return segment.index;
}
// Si estamos en un espacio en blanco o puntuación, romper aquí
return position;
}
const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (el espacio antes de "world")
const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (el límite antes de "世界")
Esta función encuentra el inicio de la palabra que contiene la posición objetivo. Si la posición ya está en un espacio en blanco, devuelve la posición sin cambios.
Para el ajuste de texto que respeta los límites de palabras:
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");
// ["你好世界", "欢迎使用"]
Esta función divide el texto en líneas que respetan los límites de palabras y se ajustan dentro de la longitud máxima.
Encontrar qué palabra contiene una posición del cursor
En los editores de texto, necesitas saber en qué palabra está el cursor para implementar funciones como la selección con doble clic, la corrección ortográfica o los menús contextuales:
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 (la posición 5 es el espacio, no una palabra)
Esto devuelve la palabra en la posición del cursor junto con sus índices de inicio y fin, o null si el cursor no está en una palabra.
Utiliza esto para implementar la selección de texto con doble clic:
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 } (selecciona "world")
Encontrar límites de oraciones para navegación
Para la navegación de documentos o la segmentación de texto a voz, encuentra qué oración contiene una posición específica:
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 }
Esto encuentra la oración completa que contiene la posición objetivo, incluyendo sus límites.
Encontrar el siguiente límite después de una posición
Para avanzar un grafema, palabra o frase, itera a través de los segmentos hasta encontrar uno que comience después de tu posición actual:
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 (límite después de "H")
findNextBoundary(text, 6, "grapheme", "en");
// 17 (límite después del emoji de familia)
findNextBoundary(text, 0, "word", "en");
// 5 (límite después de "Hello")
Esto encuentra dónde comienza el siguiente segmento, que es la posición segura para mover el cursor o truncar el texto.
Encontrar el límite anterior antes de una posición
Para retroceder un grafema, palabra o frase, encuentra el segmento antes de tu posición actual:
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 (límite antes del emoji de familia)
findPreviousBoundary(text, 11, "word", "en");
// 6 (límite antes de "world")
Esto encuentra dónde comienza el segmento anterior, que es la posición segura para mover el cursor hacia atrás.
Implementar el movimiento del cursor con límites
Combina la búsqueda de límites con la posición del cursor para implementar un movimiento adecuado del cursor:
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 (se mueve sobre todo el emoji)
moveWordForward(text, 0, "en");
// 6 (se mueve al inicio de "world")
Estas funciones implementan el movimiento estándar del cursor del editor de texto que respeta los límites de grafemas y palabras.
Encontrando todas las oportunidades de ruptura en el texto
Para encontrar cada posición donde puedes romper el texto de forma segura, itera a través de todos los segmentos y recopila sus índices de inicio:
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]
Esto devuelve un array de cada posición válida de ruptura en el texto. Utilízalo para implementar características avanzadas de diseño o análisis de texto.
Manejo de casos extremos con límites
Cuando la posición está al final del texto, containing() devuelve el último segmento:
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" }
La posición está al final, por lo que devuelve el último grafema.
Cuando la posición está antes del primer carácter, containing() devuelve el primer segmento:
const segment = segments.containing(0);
console.log(segment);
// { segment: "H", index: 0, input: "Hello" }
Para cadenas vacías, no hay segmentos, por lo que llamar a containing() en una cadena vacía devuelve undefined. Verifica si hay cadenas vacías antes de usar 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);
}
Elegir la granularidad adecuada para los límites
Utiliza diferentes granularidades según lo que necesites encontrar:
-
Grafema: Úsalo al implementar movimiento del cursor, eliminación de caracteres o cualquier operación que necesite respetar lo que los usuarios ven como caracteres individuales. Esto evita dividir emojis, caracteres combinados u otros grupos de grafemas complejos.
-
Palabra: Úsalo para selección de palabras, corrección ortográfica, conteo de palabras o cualquier operación que necesite límites lingüísticos de palabras. Esto funciona en todos los idiomas, incluidos aquellos sin espacios entre palabras.
-
Oración: Úsalo para navegación entre oraciones, segmentación de texto a voz o cualquier operación que procese texto oración por oración. Esto respeta abreviaturas y otros contextos donde los puntos no terminan oraciones.
No uses límites de palabras cuando necesites límites de caracteres, y no uses límites de grafemas cuando necesites límites de palabras. Cada uno tiene un propósito específico.
Soporte de navegadores para operaciones de límites
La API Intl.Segmenter y su método containing() alcanzaron el estado Baseline en abril de 2024. Las versiones actuales de Chrome, Firefox, Safari y Edge lo soportan. Los navegadores más antiguos no.
Verifica el soporte antes de usar:
if (typeof Intl.Segmenter !== "undefined") {
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const segments = segmenter.segment(text);
const segment = segments.containing(position);
// Usa la información del segmento
} else {
// Alternativa para navegadores antiguos
// Usa límites aproximados basados en la longitud de la cadena
}
Para aplicaciones dirigidas a navegadores más antiguos, proporciona un comportamiento alternativo usando límites aproximados, o utiliza un polyfill que implemente la API Intl.Segmenter.
Errores comunes al encontrar límites
No asumas que cada unidad de código es un punto de ruptura válido. Muchas posiciones dividen grupos de grafemas o palabras, produciendo resultados inválidos o inesperados.
No uses string.length para encontrar el límite final. Usa el índice del último segmento más su longitud en su lugar.
No olvides verificar isWordLike cuando trabajes con límites de palabras. Los segmentos que no son palabras, como espacios y puntuación, también son devueltos por el segmentador.
No asumas que los límites de palabras son iguales en todos los idiomas. Utiliza segmentación adaptada al idioma para obtener resultados correctos.
No llames a containing() repetidamente para operaciones críticas de rendimiento. Si necesitas múltiples límites, itera a través de los segmentos una vez y construye un índice.
Consideraciones de rendimiento para operaciones de límites
Crear un segmentador es rápido, pero iterar a través de todos los segmentos puede ser lento para textos muy largos. Para operaciones que necesitan múltiples límites, considera almacenar en caché la información de segmentos:
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);
Esto almacena en caché todos los segmentos una vez y proporciona búsquedas rápidas para múltiples operaciones.
Ejemplo práctico: truncamiento de texto con puntos suspensivos
Combina la búsqueda de límites con el truncamiento para construir una función que corte el texto en la última palabra completa antes de una longitud máxima:
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");
// "你好世界…"
Esta función encuentra la última palabra completa antes de la longitud máxima y añade puntos suspensivos, produciendo texto truncado limpio que no corta palabras.