API Intl.Segmenter
Cómo contar caracteres, dividir palabras y segmentar oraciones correctamente en JavaScript
Introducción
La propiedad string.length de JavaScript cuenta unidades de código, no caracteres percibidos por el usuario. Cuando los usuarios escriben emojis, caracteres acentuados o texto en scripts complejos, string.length devuelve un conteo incorrecto. El método split() falla para idiomas que no utilizan espacios entre palabras. Los límites de palabras en expresiones regulares no funcionan para texto en chino, japonés o tailandés.
La API Intl.Segmenter resuelve estos problemas. Segmenta el texto según los estándares Unicode, respetando las reglas lingüísticas de cada idioma. Puedes contar grafemas (caracteres percibidos por el usuario), dividir texto en palabras independientemente del idioma, o separar texto en oraciones.
Este artículo explica por qué las operaciones básicas de cadenas fallan para texto internacional, qué son los grupos de grafemas y los límites lingüísticos, y cómo usar Intl.Segmenter para manejar texto correctamente para todos los usuarios.
Por qué string.length falla al contar caracteres
Las cadenas de JavaScript utilizan codificación UTF-16. Cada elemento en una cadena de JavaScript es una unidad de código de 16 bits, no un carácter completo. La propiedad string.length cuenta estas unidades de código.
Para caracteres ASCII básicos, una unidad de código equivale a un carácter. La cadena "hello" tiene una longitud de 5, lo que coincide con las expectativas del usuario.
Para muchos otros caracteres, esto no funciona. Considera estos ejemplos:
"😀".length; // 2, no 1
"👨👩👧👦".length; // 11, no 1
"किं".length; // 5, no 2
"🇺🇸".length; // 4, no 1
Los usuarios ven un emoji, un emoji de familia, dos sílabas en hindi o una bandera. JavaScript cuenta las unidades de código subyacentes.
Esto es importante cuando construyes contadores de caracteres para entradas de texto, validas límites de longitud o truncas texto para mostrarlo. El conteo que JavaScript reporta no coincide con lo que los usuarios ven.
Qué son los grupos grafémicos
Un grupo grafémico es lo que los usuarios perciben como un solo carácter. Puede consistir en:
- Un único punto de código como
"a" - Un carácter base más marcas combinatorias como
"é"(e + acento agudo combinatorio) - Múltiples puntos de código unidos como
"👨👩👧👦"(hombre + mujer + niña + niño unidos con conectores de anchura cero) - Emojis con modificadores de tono de piel como
"👋🏽"(mano saludando + tono de piel medio) - Secuencias de indicadores regionales para banderas como
"🇺🇸"(indicador regional U + indicador regional S)
El Estándar Unicode define los grupos grafémicos extendidos en UAX 29. Estas reglas determinan dónde los usuarios esperan límites entre caracteres. Cuando un usuario presiona retroceso, espera eliminar un grupo grafémico. Cuando un cursor se mueve, debería moverse por grupos grafémicos.
El string.length de JavaScript no cuenta grupos grafémicos. La API Intl.Segmenter sí lo hace.
Contando grupos grafémicos con Intl.Segmenter
Crea un segmentador con granularidad de grafema para contar caracteres percibidos por el usuario:
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello 👋🏽";
const segments = segmenter.segment(text);
const graphemes = Array.from(segments);
console.log(graphemes.length); // 7
console.log(text.length); // 10
El usuario ve siete caracteres: cinco letras, un espacio y un emoji. El segmentador de grafemas devuelve siete segmentos. El string.length de JavaScript devuelve diez porque el emoji utiliza cuatro unidades de código.
Cada objeto de segmento contiene:
segment: el grupo grafémico como cadenaindex: la posición en la cadena original donde comienza este segmentoinput: referencia a la cadena original (no siempre necesaria)
Puedes iterar sobre los segmentos con for...of:
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "café";
for (const { segment } of segmenter.segment(text)) {
console.log(segment);
}
// Muestra: "c", "a", "f", "é"
Construyendo un contador de caracteres que funcione internacionalmente
Utiliza la segmentación de grafemas para construir contadores de caracteres precisos:
function getGraphemeCount(text) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
return Array.from(segmenter.segment(text)).length;
}
// Prueba con varias entradas
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨👩👧👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1
Esta función devuelve conteos que coinciden con la percepción del usuario. Un usuario que escribe un emoji de familia ve un carácter, y el contador muestra un carácter.
Para la validación de entrada de texto, utiliza conteos de grafemas en lugar de string.length:
function validateInput(text, maxGraphemes) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const count = Array.from(segmenter.segment(text)).length;
return count <= maxGraphemes;
}
Truncando texto de forma segura con segmentación de grafemas
Al truncar texto para su visualización, no debes cortar a través de un grupo de grafemas. Cortar en un índice arbitrario de unidad de código puede dividir secuencias de emoji o caracteres combinados, produciendo una salida inválida o rota.
Utiliza la segmentación de grafemas para encontrar puntos seguros de truncamiento:
function truncateText(text, maxGraphemes) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = Array.from(segmenter.segment(text));
if (segments.length <= maxGraphemes) {
return text;
}
const truncated = segments
.slice(0, maxGraphemes)
.map(s => s.segment)
.join("");
return truncated + "…";
}
truncateText("Hello 👨👩👧👦 world", 7); // "Hello 👨👩👧👦…"
truncateText("Hello world", 7); // "Hello w…"
Esto preserva los grupos completos de grafemas y produce una salida Unicode válida.
Por qué split() y las expresiones regulares fallan para la segmentación de palabras
El enfoque común para dividir texto en palabras utiliza split() con un patrón de espacio o espacio en blanco:
const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]
Esto funciona para el inglés y otros idiomas que separan palabras con espacios. Falla completamente para idiomas que no utilizan espacios entre palabras.
El texto en chino, japonés y tailandés no incluye espacios entre palabras. Dividir por espacios devuelve toda la cadena como un solo elemento:
const text = "你好世界"; // "Hola mundo" en chino
const words = text.split(" "); // ["你好世界"]
El usuario ve cuatro palabras distintas, pero split() devuelve un solo elemento.
Los límites de palabra de expresiones regulares (\b) también fallan para estos idiomas porque el motor de regex no reconoce los límites de palabra en scripts sin espacios.
Cómo funciona la segmentación de palabras en diferentes idiomas
La API Intl.Segmenter utiliza las reglas de límites de palabras de Unicode definidas en UAX 29. Estas reglas comprenden los límites de palabras para todos los sistemas de escritura, incluidos aquellos sin espacios.
Crea un segmentador con granularidad de palabra:
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const text = "你好世界";
const segments = Array.from(segmenter.segment(text));
segments.forEach(({ segment, isWordLike }) => {
console.log(segment, isWordLike);
});
// "你好" true
// "世界" true
El segmentador identifica correctamente los límites de palabras según la configuración regional y el sistema de escritura. La propiedad isWordLike indica si el segmento es una palabra (letras, números, ideogramas) o contenido que no es una palabra (espacios, puntuación).
Para texto en inglés, el segmentador devuelve tanto palabras como espacios:
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world!";
const segments = Array.from(segmenter.segment(text));
segments.forEach(({ segment, isWordLike }) => {
console.log(segment, isWordLike);
});
// "Hello" true
// " " false
// "world" true
// "!" false
Utiliza la propiedad isWordLike para filtrar segmentos de palabras de la puntuación y espacios en blanco:
function getWords(text, locale) {
const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
const segments = segmenter.segment(text);
return Array.from(segments)
.filter(s => s.isWordLike)
.map(s => s.segment);
}
getWords("Hello, world!", "en"); // ["Hello", "world"]
getWords("你好世界", "zh"); // ["你好", "世界"]
getWords("สวัสดีครับ", "th"); // ["สวัสดี", "ครับ"] (Tailandés)
Esta función funciona para cualquier idioma, manejando tanto sistemas de escritura separados por espacios como aquellos sin separación de espacios.
Contando palabras con precisión
Construye un contador de palabras que funcione internacionalmente:
function countWords(text, locale) {
const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
const segments = segmenter.segment(text);
return Array.from(segments).filter(s => s.isWordLike).length;
}
countWords("Hello world", "en"); // 2
countWords("你好世界", "zh"); // 2
countWords("Bonjour le monde", "fr"); // 3
Esto produce recuentos precisos de palabras para contenido en cualquier idioma.
Encontrar qué palabra contiene una posición del cursor
El método containing() encuentra el segmento que incluye un índice específico en la cadena. Esto es útil para determinar en qué palabra está el cursor o qué segmento contiene una posición de clic.
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world";
const segments = segmenter.segment(text);
const segment = segments.containing(7); // El índice 7 está en "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }
Si el índice está dentro de un espacio en blanco o puntuación, containing() devuelve ese segmento:
const segment = segments.containing(5); // El índice 5 es el espacio
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }
Utiliza esto para funciones de edición de texto, resaltado de búsqueda o acciones contextuales basadas en la posición del cursor.
Segmentación de oraciones para procesamiento de texto
La segmentación de oraciones divide el texto en los límites de las oraciones. Esto es útil para resumir, procesar texto a voz o navegar por documentos extensos.
Los enfoques básicos como dividir por puntos fallan porque los puntos aparecen en abreviaturas, números y otros contextos que no son límites de oraciones:
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrecto: divide en "Dr." y "100."
La API Intl.Segmenter comprende las reglas de límites de oraciones:
const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
const segments = Array.from(segmenter.segment(text));
segments.forEach(({ segment }) => {
console.log(segment);
});
// "Dr. Smith bought 100.5 shares. "
// "He sold them later."
El segmentador trata correctamente "Dr." y "100.5" como parte de la oración, no como límites de oración.
Para texto multilingüe, los límites de las oraciones varían según la configuración regional. La API maneja estas diferencias:
const segmenterEn = new Intl.Segmenter("en", { granularity: "sentence" });
const segmenterJa = new Intl.Segmenter("ja", { granularity: "sentence" });
const textEn = "Hello. How are you?";
const textJa = "こんにちは。お元気ですか。"; // Usa punto final japonés
Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2
Cuándo usar cada granularidad
Elige la granularidad según lo que necesites contar o dividir:
-
Grafema: Usa para contar caracteres, truncar texto, posicionar el cursor o cualquier operación donde necesites coincidir con la percepción del usuario sobre los caracteres.
-
Palabra: Usa para contar palabras, búsqueda y resaltado, análisis de texto o cualquier operación que necesite límites lingüísticos de palabras en diferentes idiomas.
-
Oración: Usa para segmentación de texto a voz, resúmenes, navegación de documentos o cualquier operación que procese texto oración por oración.
No uses segmentación de grafemas cuando necesites límites de palabras, y no uses segmentación de palabras cuando necesites contar caracteres. Cada granularidad sirve para un propósito distinto.
Creación y reutilización de segmentadores
Crear un segmentador es económico, pero puedes reutilizar segmentadores para mejorar el rendimiento:
const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });
// Reutiliza estos segmentadores para múltiples cadenas
function processTexts(texts) {
return texts.map(text => ({
text,
graphemes: Array.from(graphemeSegmenter.segment(text)).length,
words: Array.from(wordSegmenter.segment(text)).filter(s => s.isWordLike).length
}));
}
El segmentador almacena en caché los datos de localización, por lo que reutilizar la misma instancia evita la inicialización repetida.
Comprobación de compatibilidad con navegadores
La API Intl.Segmenter alcanzó el estado Baseline en abril de 2024. Funciona en las versiones actuales de Chrome, Firefox, Safari y Edge. Los navegadores más antiguos no la soportan.
Verifica la compatibilidad antes de usarla:
if (typeof Intl.Segmenter !== "undefined") {
// Usar Intl.Segmenter
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
// ...
} else {
// Alternativa para navegadores antiguos
const count = text.length; // No es preciso, pero está disponible
}
Para aplicaciones en producción dirigidas a navegadores más antiguos, considera usar un polyfill o proporcionar funcionalidad degradada.
Errores comunes a evitar
No utilices string.length para mostrar el conteo de caracteres a los usuarios. Produce resultados incorrectos para emojis, caracteres combinados y scripts complejos.
No dividas por espacios ni uses límites de palabras con expresiones regulares para la segmentación multilingüe de palabras. Estos enfoques solo funcionan para un subconjunto de idiomas.
No asumas que los límites de palabras o frases son iguales en todos los idiomas. Utiliza segmentación adaptada al idioma local.
No olvides verificar la propiedad isWordLike al contar palabras. Incluir puntuación y espacios en blanco produce conteos inflados.
No cortes cadenas en índices arbitrarios al truncar. Siempre corta en los límites de grupos de grafemas para evitar producir secuencias Unicode inválidas.
Cuándo no usar Intl.Segmenter
Para operaciones simples solo con ASCII donde sabes que el texto contiene únicamente caracteres latinos básicos, los métodos básicos de cadenas son más rápidos y suficientes.
Cuando necesites la longitud en bytes de una cadena para operaciones de red o almacenamiento, usa TextEncoder:
const byteLength = new TextEncoder().encode(text).length;
Cuando necesites el conteo real de unidades de código para manipulación de cadenas de bajo nivel, string.length es correcto. Esto es poco común en código de aplicación.
Para la mayoría del procesamiento de texto que involucra contenido visible para el usuario, especialmente en aplicaciones internacionales, usa Intl.Segmenter.
Cómo se relaciona Intl.Segmenter con otras APIs de internacionalización
La API Intl.Segmenter es parte de la API de Internacionalización de ECMAScript. Otras APIs en esta familia incluyen:
Intl.DateTimeFormat: Formatea fechas y horas según la configuración regionalIntl.NumberFormat: Formatea números, monedas y unidades según la configuración regionalIntl.Collator: Ordena y compara cadenas según la configuración regionalIntl.PluralRules: Determina formas plurales para números en diferentes idiomas
En conjunto, estas APIs proporcionan las herramientas necesarias para crear aplicaciones que funcionen correctamente para usuarios de todo el mundo. Usa Intl.Segmenter para la segmentación de texto, y usa las otras APIs de Intl para formateo y comparación.
Ejemplo práctico: construyendo un componente de estadísticas de texto
Combina la segmentación de grafemas y palabras para construir un componente de estadísticas de texto:
function getTextStatistics(text, locale) {
const graphemeSegmenter = new Intl.Segmenter(locale, {
granularity: "grapheme"
});
const wordSegmenter = new Intl.Segmenter(locale, {
granularity: "word"
});
const sentenceSegmenter = new Intl.Segmenter(locale, {
granularity: "sentence"
});
const graphemes = Array.from(graphemeSegmenter.segment(text));
const words = Array.from(wordSegmenter.segment(text))
.filter(s => s.isWordLike);
const sentences = Array.from(sentenceSegmenter.segment(text));
return {
characters: graphemes.length,
words: words.length,
sentences: sentences.length,
averageWordLength: words.length > 0
? graphemes.length / words.length
: 0
};
}
// Funciona para cualquier idioma
getTextStatistics("Hello world! How are you?", "en");
// { characters: 24, words: 5, sentences: 2, averageWordLength: 4.8 }
getTextStatistics("你好世界!你好吗?", "zh");
// { characters: 9, words: 5, sentences: 2, averageWordLength: 1.8 }
Esta función produce estadísticas significativas para texto en cualquier idioma, utilizando las reglas de segmentación correctas para cada localización.