¿Cómo dividir texto en caracteres individuales correctamente?

Utiliza Intl.Segmenter para dividir cadenas en caracteres percibidos por el usuario en lugar de unidades de código

Introducción

Cuando intentas dividir el emoji "👨‍👩‍👧‍👦" en caracteres individuales usando los métodos estándar de cadenas de JavaScript, obtienes un resultado incorrecto. En lugar de un emoji de familia, ves emojis de personas separados y caracteres invisibles. El mismo problema ocurre con letras acentuadas como "é", emojis de banderas como "🇺🇸", y muchos otros elementos de texto que aparecen como caracteres únicos en pantalla.

Esto sucede porque la división de cadenas incorporada en JavaScript trata las cadenas como secuencias de unidades de código UTF-16 en lugar de caracteres percibidos por el usuario. Un solo carácter visible puede consistir en múltiples unidades de código unidas. Cuando divides por unidades de código, rompes estos caracteres.

JavaScript proporciona la API Intl.Segmenter para manejar esto correctamente. Esta lección explica qué son los caracteres percibidos por el usuario, por qué los métodos estándar de cadenas fallan al dividirlos correctamente, y cómo usar Intl.Segmenter para dividir texto en caracteres reales.

Qué son los caracteres percibidos por el usuario

Un carácter percibido por el usuario es lo que una persona reconoce como un solo carácter al leer texto. Estos se llaman clusters grafémicos en la terminología Unicode. La mayoría de las veces, un cluster grafémico coincide con lo que ves como un carácter en pantalla.

La letra "a" es un cluster grafémico que consiste en un punto de código Unicode. El emoji "😀" es un cluster grafémico que consiste en dos puntos de código que forman un solo emoji. El emoji de familia "👨‍👩‍👧‍👦" es un cluster grafémico que consiste en siete puntos de código unidos con caracteres invisibles especiales.

Cuando cuentas caracteres en un texto, quieres contar clusters grafémicos, no puntos de código o unidades de código. Cuando divides texto en caracteres, quieres dividir en los límites de clusters grafémicos, no en posiciones arbitrarias dentro de un cluster.

Las cadenas de JavaScript son secuencias de unidades de código UTF-16. Cada unidad de código representa un punto de código completo o parte de un punto de código. Un cluster grafémico puede abarcar múltiples puntos de código, y cada punto de código puede abarcar múltiples unidades de código. Esto crea una discrepancia entre cómo JavaScript almacena el texto y cómo los usuarios perciben el texto.

Por qué el método split falla con caracteres complejos

El método split('') divide una cadena en cada límite de unidad de código. Esto funciona correctamente para caracteres ASCII simples donde cada carácter es una unidad de código. Falla para caracteres que abarcan múltiples unidades de código.

const simple = "hello";
console.log(simple.split(''));
// Resultado: ["h", "e", "l", "l", "o"]

El texto ASCII simple se divide correctamente porque cada letra es una unidad de código. Sin embargo, los emojis y otros caracteres complejos se descomponen.

const emoji = "😀";
console.log(emoji.split(''));
// Resultado: ["\ud83d", "\ude00"]

El emoji de cara sonriente consta de dos unidades de código. El método split('') lo divide en dos piezas separadas que no son caracteres válidos por sí mismos. Cuando se muestran, estas piezas aparecen como caracteres de reemplazo o no aparecen en absoluto.

Los emojis de banderas utilizan símbolos indicadores regionales que se combinan para formar banderas. Cada bandera requiere dos puntos de código.

const flag = "🇺🇸";
console.log(flag.split(''));
// Resultado: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]

El emoji de la bandera de EE. UU. se divide en cuatro unidades de código que representan dos indicadores regionales. Ningún indicador es un carácter válido por sí mismo. Necesitas ambos indicadores juntos para formar la bandera.

Los emojis de familia utilizan caracteres de unión de ancho cero para combinar múltiples emojis de personas en un solo carácter compuesto.

const family = "👨‍👩‍👧‍👦";
console.log(family.split(''));
// Resultado: ["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

El emoji de familia se divide en emojis individuales de personas y caracteres de unión invisibles. El carácter compuesto original se destruye, y ves cuatro personas separadas en lugar de una familia.

Las letras acentuadas pueden representarse de dos maneras en Unicode. Algunas letras acentuadas son puntos de código únicos, mientras que otras combinan una letra base con una marca diacrítica combinada.

const combined = "é"; // e + acento agudo combinado
console.log(combined.split(''));
// Resultado: ["e", "́"]

Cuando la letra é se representa como dos puntos de código (letra base más acento combinado), la división la separa en piezas distintas. La marca de acento aparece sola, lo que no es lo que los usuarios esperan al dividir texto en caracteres.

Uso de Intl.Segmenter para dividir texto correctamente

El constructor Intl.Segmenter crea un segmentador que divide el texto según las reglas específicas del idioma. Pasa un identificador de configuración regional como primer argumento y un objeto de opciones que especifica la granularidad como segundo argumento.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

La granularidad grapheme indica al segmentador que divida el texto en los límites de los grupos de grafemas. Esto respeta la estructura de los caracteres percibidos por el usuario y no los separa.

Llama al método segment() con una cadena para obtener un iterador de segmentos. Cada segmento incluye el texto y la información de posición.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const segments = segmenter.segment(text);

for (const segment of segments) {
  console.log(segment.segment);
}
// Output:
// "h"
// "e"
// "l"
// "l"
// "o"

Cada objeto de segmento contiene una propiedad segment con el texto del carácter y una propiedad index con su posición. Puedes iterar directamente sobre los segmentos para acceder a cada carácter.

Para obtener un array de caracteres, expande el iterador en un array y mapea al texto del segmento.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);

console.log(characters);
// Output: ["h", "e", "l", "l", "o"]

Este patrón convierte el iterador en un array de objetos de segmento, y luego extrae solo el texto de cada segmento. El resultado es un array de cadenas, una para cada grupo de grafemas.

Dividir emojis en caracteres correctamente

La API Intl.Segmenter maneja todos los emojis correctamente, incluidos los emojis compuestos que utilizan múltiples puntos de código.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Output: ["😀"]

El emoji permanece intacto como un solo grupo de grafemas. El segmentador reconoce que ambas unidades de código pertenecen al mismo carácter y no las divide.

Los emojis de banderas permanecen como caracteres únicos en lugar de dividirse en indicadores regionales.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Output: ["🇺🇸"]

Los dos símbolos indicadores regionales forman un grupo de grafemas que representa la bandera de EE. UU. El segmentador los mantiene juntos como un solo carácter.

Los emojis de familia y otros emojis compuestos permanecen como caracteres únicos.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const family = "👨‍👩‍👧‍👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Output: ["👨‍👩‍👧‍👦"]

Todos los emojis de personas y los unificadores de ancho cero forman un grupo de grafemas. El segmentador trata todo el emoji de familia como un solo carácter, preservando su apariencia y significado.

División de texto con letras acentuadas

La API Intl.Segmenter maneja correctamente las letras acentuadas independientemente de cómo estén codificadas en Unicode.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const precomposed = "café"; // é precompuesta
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Resultado: ["c", "a", "f", "é"]

Cuando la letra acentuada é está codificada como un solo punto de código, el segmentador la trata como un solo carácter. Esto coincide con las expectativas del usuario sobre cómo dividir la palabra.

Cuando la misma letra está codificada como una letra base más una marca diacrítica combinada, el segmentador aún la trata como un solo carácter.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const decomposed = "café"; // e + acento agudo combinado
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Resultado: ["c", "a", "f", "é"]

El segmentador reconoce que la letra base y la marca combinada forman un solo grupo de grafemas. El resultado se ve idéntico a la versión precompuesta, aunque la codificación subyacente sea diferente.

Este comportamiento es importante para el procesamiento de texto en idiomas que utilizan diacríticos. Los usuarios esperan que las letras acentuadas se traten como caracteres completos, no como letras base y marcas separadas.

Contando caracteres correctamente

Un caso de uso común para dividir texto es contar cuántos caracteres contiene. El método split('') proporciona recuentos incorrectos para texto con caracteres complejos.

const text = "👨‍👩‍👧‍👦";
console.log(text.split('').length);
// Resultado: 7

El emoji de familia aparece como un carácter pero cuenta como siete cuando se divide por unidades de código. Esto no coincide con las expectativas del usuario.

Usar Intl.Segmenter proporciona recuentos de caracteres precisos.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨‍👩‍👧‍👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Resultado: 1

El segmentador reconoce el emoji de familia como un grupo de grafemas, por lo que el recuento es uno. Esto coincide con lo que los usuarios ven en pantalla.

Puedes crear una función auxiliar para contar grupos de grafemas en cualquier cadena.

function countCharacters(text) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  return [...segmenter.segment(text)].length;
}

console.log(countCharacters("hello"));
// Resultado: 5

console.log(countCharacters("café"));
// Resultado: 4

console.log(countCharacters("👨‍👩‍👧‍👦"));
// Resultado: 1

console.log(countCharacters("🇺🇸"));
// Resultado: 1

Esta función funciona correctamente para texto ASCII, letras acentuadas, emojis y cualquier otro carácter Unicode. El recuento siempre coincide con el número de caracteres percibidos por el usuario.

Obtener un carácter en una posición específica

Cuando necesitas acceder a un carácter en una posición específica, puedes convertir primero el texto en un array de clusters de grafemas.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);

console.log(characters[6]);
// Output: "👋"

El emoji de mano saludando está en la posición 6 cuando se cuentan los clusters de grafemas. Si utilizaras la indexación estándar de arrays en la cadena, obtendrías un resultado inválido porque el emoji abarca múltiples unidades de código.

Este enfoque es útil cuando se implementan operaciones a nivel de carácter como selección de caracteres, resaltado de caracteres o animaciones carácter por carácter.

Invertir texto correctamente

Invertir una cadena mediante la inversión de su array de unidades de código produce resultados incorrectos para caracteres complejos.

const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� olleH"

El emoji se rompe porque sus unidades de código se invierten por separado. La cadena resultante contiene secuencias de caracteres inválidas.

Utilizar Intl.Segmenter para invertir texto preserva la integridad de los caracteres.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// Output: "👋 olleH"

Cada cluster de grafemas permanece intacto durante la inversión. El emoji sigue siendo válido porque sus unidades de código no se separan.

Entendiendo el parámetro de localización

El constructor Intl.Segmenter acepta un parámetro de localización, pero para la segmentación de grafemas, la localización tiene un impacto mínimo. Los límites de los clusters de grafemas siguen reglas Unicode que son mayormente independientes del idioma.

const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });

const text = "Hello 👋 こんにちは";

const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);

console.log(charactersEn);
console.log(charactersJa);
// Ambas salidas son idénticas

Diferentes identificadores de localización producen los mismos resultados de segmentación de grafemas. El estándar Unicode define los límites de clusters de grafemas de una manera que funciona en todos los idiomas.

Sin embargo, especificar una localización sigue siendo una buena práctica para mantener la consistencia con otras APIs de Intl y en caso de que futuras versiones de Unicode introduzcan reglas específicas de localización.

Reutilización de segmentadores para mejorar el rendimiento

Crear una nueva instancia de Intl.Segmenter implica cargar datos de localización e inicializar estructuras internas. Cuando necesites segmentar múltiples cadenas con la misma configuración, crea el segmentador una vez y reutilízalo.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const texts = [
  "Hello 👋",
  "Café ☕",
  "World 🌍",
  "Family 👨‍👩‍👧‍👦"
];

texts.forEach(text => {
  const characters = [...segmenter.segment(text)].map(s => s.segment);
  console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨‍👩‍👧‍👦"]

Este enfoque es más eficiente que crear un nuevo segmentador para cada cadena. La diferencia de rendimiento se vuelve significativa cuando se procesan grandes cantidades de texto.

Combinación de segmentación de grafemas con otras operaciones

Puedes combinar la segmentación de grafemas con otras operaciones de cadenas para construir funciones de procesamiento de texto más complejas.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

function truncateByCharacters(text, maxLength) {
  const characters = [...segmenter.segment(text)].map(s => s.segment);

  if (characters.length <= maxLength) {
    return text;
  }

  return characters.slice(0, maxLength).join('') + '...';
}

console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."

console.log(truncateByCharacters("Family 👨‍👩‍👧‍👦 Photo", 8));
// Output: "Family 👨‍👩‍👧‍👦..."

Esta función de truncamiento cuenta los grupos de grafemas en lugar de unidades de código. Preserva los emojis y otros caracteres complejos al truncar, por lo que la salida nunca contiene caracteres rotos.

Trabajando con posiciones de cadenas

Los objetos de segmento devueltos por Intl.Segmenter incluyen una propiedad index que indica la posición en la cadena original. Esta posición se mide en unidades de código, no en grupos de grafemas.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";

for (const segment of segmenter.segment(text)) {
  console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6

El emoji de mano saludando comienza en la posición de unidad de código 6, aunque ocupa las posiciones 6 y 7 en la cadena subyacente. El siguiente carácter comenzaría en la posición 8. Esta información es útil cuando necesitas mapear entre posiciones de grafemas y posiciones de cadenas para operaciones como la extracción de subcadenas.

Manejo de cadenas vacías y casos extremos

La API Intl.Segmenter maneja correctamente las cadenas vacías y otros casos extremos.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Resultado: []

Una cadena vacía produce un array vacío de segmentos. No se requiere un manejo especial.

Los caracteres de espacio en blanco se tratan como clusters de grafemas separados.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// Resultado: ["a", " ", "b", "\t", "c", "\n", "d"]

Los espacios, tabulaciones y saltos de línea forman cada uno sus propios clusters de grafemas. Esto coincide con las expectativas del usuario para el procesamiento de texto a nivel de caracteres.