¿Cómo dividir texto en caracteres individuales correctamente?
Usa 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 separadas 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 individuales en pantalla.
Esto sucede porque la división de cadenas integrada 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, separas 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 no logran 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 clústeres de grafemas en la terminología Unicode. La mayoría de las veces, un clúster de grafemas coincide con lo que ves como un carácter en pantalla.
La letra "a" es un clúster de grafemas que consiste en un punto de código Unicode. El emoji "😀" es un clúster de grafemas que consiste en dos puntos de código que forman un solo emoji. El emoji de familia "👨👩👧👦" es un clúster de grafemas que consiste en siete puntos de código unidos con caracteres invisibles especiales.
Cuando cuentas caracteres en un texto, quieres contar grupos de grafemas, no puntos de código o unidades de código. Cuando divides texto en caracteres, quieres dividir en los límites de grupos de grafemas, no en posiciones arbitrarias dentro de un grupo.
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 grupo de grafemas 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(''));
// Output: ["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 rompen.
const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\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í mismas. Cuando se muestran, estas piezas aparecen como caracteres de reemplazo o no aparecen en absoluto.
Los emojis de banderas usan símbolos indicadores regionales que se combinan para formar banderas. Cada bandera requiere dos puntos de código.
const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\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 usan caracteres de unión de ancho cero para combinar múltiples emojis de personas en un carácter compuesto.
const family = "👨👩👧👦";
console.log(family.split(''));
// Output: ["👨", "", "👩", "", "👧", "", "👦"]
El emoji de familia se divide en emojis de personas individuales 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 formas 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 + combining acute accent
console.log(combined.split(''));
// Output: ["e", "́"]
Cuando la letra é se representa como dos puntos de código (letra base más acento combinado), dividirla la rompe en piezas separadas. La marca de acento aparece sola, lo cual no es lo que los usuarios esperan al dividir texto en caracteres.
Usar Intl.Segmenter para dividir texto correctamente
El constructor Intl.Segmenter crea un segmentador que divide el texto según reglas específicas de la configuración regional. 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 clústeres de grafemas. Esto respeta la estructura de los caracteres percibidos por el usuario y no los divide.
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, luego extrae solo el texto de cada segmento. El resultado es un array de cadenas, una por cada grupo de grafemas.
Dividir emojis en caracteres correctamente
La API Intl.Segmenter maneja correctamente todos los emojis, 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 cluster de grafemas. El segmentador reconoce que ambas unidades de código pertenecen al mismo carácter y no las separa.
Los emojis de banderas permanecen como caracteres individuales 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 cluster 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 individuales.
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 caracteres de unión de ancho cero forman un cluster de grafemas. El segmentador trata todo el emoji de familia como un solo carácter, preservando su apariencia y significado.
Dividir 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é"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
Cuando la letra acentuada é está codificada como un solo punto de código, el segmentador la trata como un 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 carácter.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
El segmentador reconoce que la letra base y la marca combinada forman un solo cluster 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.
Contar 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);
// Output: 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);
// Output: 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"));
// Output: 5
console.log(countCharacters("café"));
// Output: 4
console.log(countCharacters("👨👩👧👦"));
// Output: 1
console.log(countCharacters("🇺🇸"));
// Output: 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 carácter en 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 grupos 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 grupos de grafemas. Si usaras indexación de array estándar en la cadena, obtendrías un resultado inválido porque el emoji abarca múltiples unidades de código.
Este enfoque es útil al implementar 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 invirtiendo 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.
Usar 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 grupo de grafemas permanece intacto durante la inversión. El emoji permanece válido porque sus unidades de código no se separan.
Comprender el parámetro de configuración regional
El constructor Intl.Segmenter acepta un parámetro de configuración regional, pero para la segmentación de grafemas, la configuración regional tiene un impacto mínimo. Los límites de los grupos de grafemas siguen reglas Unicode que son en su mayoría 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);
// Both outputs are identical
Diferentes identificadores de configuración regional producen los mismos resultados de segmentación de grafemas. El estándar Unicode define los límites de los clusters de grafemas de una manera que funciona en todos los idiomas.
Sin embargo, especificar una configuración regional sigue siendo una buena práctica para mantener la coherencia con otras API de Intl y en caso de que futuras versiones de Unicode introduzcan reglas específicas de configuración regional.
Reutilizar segmentadores para mejorar el rendimiento
Crear una nueva instancia de Intl.Segmenter implica cargar datos de configuración regional 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 al procesar grandes cantidades de texto.
Combinar la 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 clusters 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.
Trabajar 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);
// Output: []
Una cadena vacía produce un array vacío de segmentos. No se requiere ningún manejo especial.
Los caracteres de espacio en blanco se tratan como clústeres 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);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]
Los espacios, tabulaciones y saltos de línea forman cada uno su propio clúster de grafemas. Esto coincide con las expectativas del usuario para el procesamiento de texto a nivel de caracteres.