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 emoji, caracteres acentuados o texto en escrituras complejas, string.length devuelve el recuento incorrecto. El método split() falla para idiomas que no usan espacios entre palabras. Los límites de palabras de 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 clústeres 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 para contar caracteres

Las cadenas de JavaScript usan 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 falla. Considera estos ejemplos:

"😀".length; // 2, not 1
"👨‍👩‍👧‍👦".length; // 11, not 1
"किं".length; // 5, not 2
"🇺🇸".length; // 4, not 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 importa cuando construyes contadores de caracteres para entradas de texto, validas límites de longitud o truncas texto para mostrar. El recuento que JavaScript reporta no coincide con lo que los usuarios ven.

Qué son los clústeres de grafemas

Un clúster de grafemas es lo que los usuarios perciben como un solo carácter. Puede consistir en:

  • Un solo punto de código como "a"
  • Un carácter base más marcas de combinación como "é" (e + acento agudo de combinación)
  • Múltiples puntos de código unidos como "👨‍👩‍👧‍👦" (hombre + mujer + niña + niño unidos con conectores de ancho cero)
  • Emoji 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 clústeres de grafemas extendidos en UAX 29. Estas reglas determinan dónde los usuarios esperan límites entre caracteres. Cuando un usuario presiona retroceso, espera eliminar un clúster de grafemas. Cuando un cursor se mueve, debería moverse por clústeres de grafemas.

El string.length de JavaScript no cuenta clústeres de grafemas. La API Intl.Segmenter sí lo hace.

Contar clústeres de grafemas con Intl.Segmenter

Crea un segmentador con granularidad de grafemas 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 usa cuatro unidades de código.

Cada objeto de segmento contiene:

  • segment: el clúster de grafemas como cadena
  • index: la posición en la cadena original donde comienza este segmento
  • input: referencia a la cadena original (no siempre necesaria)

Puedes iterar sobre 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);
}
// Logs: "c", "a", "f", "é"

Construir un contador de caracteres que funcione internacionalmente

Usa 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;
}

// Test with various inputs
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨‍👩‍👧‍👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1

Esta función devuelve recuentos 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 recuentos 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;
}

Truncar texto de forma segura con segmentación de grafemas

Al truncar texto para su visualización, no debes cortar a través de un clúster de grafemas. Cortar en un índice de unidad de código arbitrario puede dividir secuencias de emoji o caracteres combinados, produciendo una salida inválida o rota.

Utiliza la segmentación de grafemas para encontrar puntos de truncamiento seguros:

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 clústeres de grafemas completos y produce una salida Unicode válida.

Por qué split() y regex fallan para la segmentación de palabras

El enfoque común para dividir texto en palabras utiliza split() con un espacio o patrón de espacios en blanco:

const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]

Esto funciona para 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 = "你好世界"; // "Hello world" in Chinese
const words = text.split(" "); // ["你好世界"]

El usuario ve cuatro palabras distintas, pero split() devuelve un elemento.

Los límites de palabra de expresiones regulares (\b) también fallan para estos idiomas porque el motor de regex no reconoce límites de palabra en escrituras sin espacios.

Cómo funciona la segmentación de palabras entre idiomas

La API Intl.Segmenter utiliza las reglas de límites de palabra Unicode definidas en UAX 29. Estas reglas comprenden los límites de palabra para todas las escrituras, incluidas aquellas 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 palabra según la configuración regional y la escritura. La propiedad isWordLike indica si el segmento es una palabra (letras, números, ideogramas) o contenido no verbal (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 los 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"); // ["สวัสดี", "ครับ"] (Thai)

Esta función funciona para cualquier idioma, manejando tanto escrituras separadas por espacios como no separadas por espacios.

Contar palabras con precisión

Crea 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 de palabras precisos 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); // Index 7 is in "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); // Index 5 is the space
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.

Segmentar oraciones para procesamiento de texto

La segmentación de oraciones divide el texto en límites de oración. Esto es útil para resumen, procesamiento de texto a voz o navegación de documentos largos.

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 oración:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrect: breaks at "Dr." and "100."

La API Intl.Segmenter comprende las reglas de límites de oración:

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 oración 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 = "こんにちは。お元気ですか。"; // Uses Japanese full stop

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: úsalo para contar caracteres, truncar texto, posicionar el cursor o cualquier operación donde necesites coincidir con la percepción del usuario de los caracteres.

  • Palabra: úsalo para contar palabras, buscar y resaltar, analizar texto o cualquier operación que necesite límites de palabras lingüísticos entre idiomas.

  • Oración: úsalo para segmentación de texto a voz, resumen, 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 tiene un propósito distinto.

Crear y reutilizar 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" });

// Reuse these segmenters for multiple strings
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 configuración regional, por lo que reutilizar la misma instancia evita inicializaciones repetidas.

Verificar compatibilidad del navegador

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 admiten.

Verifica la compatibilidad antes de usar:

if (typeof Intl.Segmenter !== "undefined") {
  // Use Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Fallback for older browsers
  const count = text.length; // Not accurate, but available
}

Para aplicaciones de producción dirigidas a navegadores más antiguos, considera usar un polyfill o proporcionar funcionalidad degradada.

Errores comunes que debes evitar

No uses string.length para mostrar recuentos de caracteres a los usuarios. Produce resultados incorrectos para emojis, caracteres combinados y escrituras complejas.

No dividas por espacios ni uses límites de palabras regex para segmentación de palabras multilingüe. Estos enfoques solo funcionan para un subconjunto de idiomas.

No asumas que los límites de palabras u oraciones son los mismos entre idiomas. Usa segmentación consciente de la configuración regional.

No olvides verificar la propiedad isWordLike al contar palabras. Incluir puntuación y espacios en blanco produce recuentos inflados.

No cortes cadenas en índices arbitrarios al truncar. Siempre corta en los límites de clústeres 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 recuento 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 de cara al 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 de esta familia incluyen:

  • Intl.DateTimeFormat: formatea fechas y horas según la configuración regional
  • Intl.NumberFormat: formatea números, monedas y unidades según la configuración regional
  • Intl.Collator: ordena y compara cadenas según la configuración regional
  • Intl.PluralRules: determina formas plurales para números en diferentes idiomas

Juntas, estas APIs proporcionan las herramientas necesarias para construir aplicaciones que funcionen correctamente para usuarios de todo el mundo. Usa Intl.Segmenter para segmentación de texto, y usa las otras APIs de Intl para formateo y comparación.

Ejemplo práctico: construir 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
  };
}

// Works for any language
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, usando las reglas de segmentación correctas para cada configuración regional.