Comment diviser correctement un texte en caractères individuels ?
Utilisez Intl.Segmenter pour diviser les chaînes en caractères perçus par l'utilisateur plutôt qu'en unités de code
Introduction
Lorsque vous essayez de diviser l'emoji "👨👩👧👦" en caractères individuels en utilisant les méthodes standard de chaîne de JavaScript, vous obtenez un résultat incorrect. Au lieu d'un seul emoji de famille, vous voyez des emojis de personnes séparés et des caractères invisibles. Le même problème se produit avec les lettres accentuées comme "é", les emojis de drapeaux comme "🇺🇸", et de nombreux autres éléments textuels qui apparaissent comme des caractères uniques à l'écran.
Cela se produit parce que la division de chaîne intégrée à JavaScript traite les chaînes comme des séquences d'unités de code UTF-16 plutôt que comme des caractères perçus par l'utilisateur. Un seul caractère visible peut être composé de plusieurs unités de code jointes ensemble. Lorsque vous divisez par unités de code, vous fragmentez ces caractères.
JavaScript fournit l'API Intl.Segmenter pour gérer cela correctement. Cette leçon explique ce que sont les caractères perçus par l'utilisateur, pourquoi les méthodes standard de chaîne échouent à les diviser correctement, et comment utiliser Intl.Segmenter pour diviser le texte en caractères réels.
Ce que sont les caractères perçus par l'utilisateur
Un caractère perçu par l'utilisateur est ce qu'une personne reconnaît comme un caractère unique lors de la lecture d'un texte. Ces éléments sont appelés clusters graphémiques dans la terminologie Unicode. La plupart du temps, un cluster graphémique correspond à ce que vous voyez comme un caractère unique à l'écran.
La lettre "a" est un cluster graphémique composé d'un seul point de code Unicode. L'emoji "😀" est un cluster graphémique composé de deux points de code qui forment un seul emoji. L'emoji de famille "👨👩👧👦" est un cluster graphémique composé de sept points de code joints ensemble avec des caractères invisibles spéciaux.
Lorsque vous comptez les caractères dans un texte, vous voulez compter les clusters graphémiques, pas les points de code ou les unités de code. Lorsque vous divisez un texte en caractères, vous voulez diviser aux limites des clusters graphémiques, pas à des positions arbitraires au sein d'un cluster.
Les chaînes JavaScript sont des séquences d'unités de code UTF-16. Chaque unité de code représente soit un point de code complet, soit une partie d'un point de code. Un cluster graphémique peut s'étendre sur plusieurs points de code, et chaque point de code peut s'étendre sur plusieurs unités de code. Cela crée un décalage entre la façon dont JavaScript stocke le texte et la façon dont les utilisateurs perçoivent le texte.
Pourquoi la méthode split échoue avec des caractères complexes
La méthode split('') divise une chaîne à chaque limite d'unité de code. Cela fonctionne correctement pour les caractères ASCII simples où chaque caractère est une unité de code. Cela échoue pour les caractères qui s'étendent sur plusieurs unités de code.
const simple = "hello";
console.log(simple.split(''));
// Résultat: ["h", "e", "l", "l", "o"]
Le texte ASCII simple se divise correctement car chaque lettre est une unité de code. Cependant, les emoji et autres caractères complexes se décomposent.
const emoji = "😀";
console.log(emoji.split(''));
// Résultat: ["\ud83d", "\ude00"]
L'emoji visage souriant se compose de deux unités de code. La méthode split('') le décompose en deux parties distinctes qui ne sont pas des caractères valides en elles-mêmes. Lorsqu'elles sont affichées, ces parties apparaissent comme des caractères de remplacement ou rien du tout.
Les emoji de drapeaux utilisent des symboles indicateurs régionaux qui se combinent pour former des drapeaux. Chaque drapeau nécessite deux points de code.
const flag = "🇺🇸";
console.log(flag.split(''));
// Résultat: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
L'emoji du drapeau américain se divise en quatre unités de code représentant deux indicateurs régionaux. Aucun indicateur n'est un caractère valide par lui-même. Vous avez besoin des deux indicateurs ensemble pour former le drapeau.
Les emoji de famille utilisent des caractères de jointure sans largeur pour combiner plusieurs emoji de personnes en un seul caractère composite.
const family = "👨👩👧👦";
console.log(family.split(''));
// Résultat: ["👨", "", "👩", "", "👧", "", "👦"]
L'emoji de famille se divise en emoji de personnes individuelles et en caractères de jointure invisibles. Le caractère composite original est détruit, et vous voyez quatre personnes séparées au lieu d'une famille.
Les lettres accentuées peuvent être représentées de deux façons en Unicode. Certaines lettres accentuées sont des points de code uniques, tandis que d'autres combinent une lettre de base avec une marque diacritique combinante.
const combined = "é"; // e + accent aigu combinant
console.log(combined.split(''));
// Résultat: ["e", "́"]
Lorsque la lettre é est représentée comme deux points de code (lettre de base plus accent combinant), la division la décompose en parties distinctes. La marque d'accent apparaît seule, ce qui n'est pas ce que les utilisateurs attendent lorsqu'ils divisent du texte en caractères.
Utilisation d'Intl.Segmenter pour diviser correctement le texte
Le constructeur Intl.Segmenter crée un segmenteur qui divise le texte selon des règles spécifiques à la locale. Passez un identifiant de locale comme premier argument et un objet d'options spécifiant la granularité comme second argument.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
La granularité grapheme indique au segmenteur de diviser le texte aux limites des clusters de graphèmes. Cela respecte la structure des caractères perçus par l'utilisateur et ne les sépare pas.
Appelez la méthode segment() avec une chaîne pour obtenir un itérateur de segments. Chaque segment inclut le texte et les informations de position.
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);
}
// Sortie :
// "h"
// "e"
// "l"
// "l"
// "o"
Chaque objet segment contient une propriété segment avec le texte du caractère et une propriété index avec sa position. Vous pouvez itérer directement sur les segments pour accéder à chaque caractère.
Pour obtenir un tableau de caractères, répartissez l'itérateur dans un tableau et mappez-le au texte du segment.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters);
// Sortie : ["h", "e", "l", "l", "o"]
Ce modèle convertit l'itérateur en un tableau d'objets segment, puis extrait uniquement le texte de chaque segment. Le résultat est un tableau de chaînes, une pour chaque cluster de graphèmes.
Diviser correctement les emoji en caractères
L'API Intl.Segmenter gère correctement tous les emoji, y compris les emoji composites qui utilisent plusieurs points de code.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// Sortie : ["😀"]
L'emoji reste intact en tant qu'un seul cluster de graphèmes. Le segmenteur reconnaît que les deux unités de code appartiennent au même caractère et ne les divise pas.
Les emoji de drapeaux restent comme des caractères uniques au lieu de se décomposer en indicateurs régionaux.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Sortie : ["🇺🇸"]
Les deux symboles d'indicateur régional forment un cluster de graphèmes représentant le drapeau américain. Le segmenteur les maintient ensemble comme un seul caractère.
Les emoji de famille et autres emoji composites restent comme des caractères uniques.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Sortie : ["👨👩👧👦"]
Tous les emoji de personnes et les joigneurs de largeur zéro forment un cluster de graphèmes. Le segmenteur traite l'emoji de famille entier comme un seul caractère, préservant son apparence et sa signification.
Diviser du texte avec des lettres accentuées
L'API Intl.Segmenter gère correctement les lettres accentuées, quelle que soit leur encodage en Unicode.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // é précomposé
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Résultat : ["c", "a", "f", "é"]
Lorsque la lettre accentuée é est encodée comme un seul point de code, le segmenteur la traite comme un seul caractère. Cela correspond aux attentes des utilisateurs quant à la façon de diviser le mot.
Lorsque la même lettre est encodée comme une lettre de base plus un signe diacritique combinant, le segmenteur la traite toujours comme un seul caractère.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + accent aigu combinant
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Résultat : ["c", "a", "f", "é"]
Le segmenteur reconnaît que la lettre de base et le signe combinant forment un seul groupe graphémique. Le résultat semble identique à la version précomposée, même si l'encodage sous-jacent est différent.
Ce comportement est important pour le traitement de texte dans les langues qui utilisent des signes diacritiques. Les utilisateurs s'attendent à ce que les lettres accentuées soient traitées comme des caractères complets, et non comme des lettres de base et des signes séparés.
Compter correctement les caractères
Un cas d'utilisation courant pour la division de texte est de compter combien de caractères il contient. La méthode split('') donne des comptages incorrects pour les textes contenant des caractères complexes.
const text = "👨👩👧👦";
console.log(text.split('').length);
// Résultat : 7
L'emoji de famille apparaît comme un seul caractère mais compte pour sept lorsqu'il est divisé par unités de code. Cela ne correspond pas aux attentes des utilisateurs.
L'utilisation de Intl.Segmenter donne des comptages de caractères précis.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Résultat : 1
Le segmenteur reconnaît l'emoji de famille comme un seul groupe graphémique, donc le comptage est de un. Cela correspond à ce que les utilisateurs voient à l'écran.
Vous pouvez créer une fonction d'aide pour compter les groupes graphémiques dans n'importe quelle chaîne.
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// Résultat : 5
console.log(countCharacters("café"));
// Résultat : 4
console.log(countCharacters("👨👩👧👦"));
// Résultat : 1
console.log(countCharacters("🇺🇸"));
// Résultat : 1
Cette fonction fonctionne correctement pour le texte ASCII, les lettres accentuées, les emojis et tous les autres caractères Unicode. Le comptage correspond toujours au nombre de caractères perçus par l'utilisateur.
Obtenir un caractère à une position spécifique
Lorsque vous avez besoin d'accéder à un caractère à une position spécifique, vous pouvez d'abord convertir le texte en un tableau de clusters graphémiques.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
console.log(characters[6]);
// Résultat: "👋"
L'emoji de la main qui fait signe est à la position 6 lorsqu'on compte les clusters graphémiques. Si vous utilisiez l'indexation standard de tableau sur la chaîne, vous obtiendriez un résultat invalide car l'emoji s'étend sur plusieurs unités de code.
Cette approche est utile lors de l'implémentation d'opérations au niveau des caractères comme la sélection de caractères, la mise en surbrillance de caractères ou les animations caractère par caractère.
Inverser correctement le texte
Inverser une chaîne en inversant son tableau d'unités de code produit des résultats incorrects pour les caractères complexes.
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Résultat: "�� olleH"
L'emoji se brise car ses unités de code sont inversées séparément. La chaîne résultante contient des séquences de caractères invalides.
L'utilisation d'Intl.Segmenter pour inverser le texte préserve l'intégrité des caractères.
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);
// Résultat: "👋 olleH"
Chaque cluster graphémique reste intact pendant l'inversion. L'emoji reste valide car ses unités de code ne sont pas séparées.
Comprendre le paramètre de locale
Le constructeur Intl.Segmenter accepte un paramètre de locale, mais pour la segmentation graphémique, la locale a un impact minimal. Les limites des clusters graphémiques suivent les règles Unicode qui sont majoritairement indépendantes de la langue.
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);
// Les deux résultats sont identiques
Différents identifiants de locale produisent les mêmes résultats de segmentation graphémique. La norme Unicode définit les limites des clusters graphémiques d'une manière qui fonctionne dans toutes les langues.
Cependant, spécifier une locale reste une bonne pratique pour maintenir la cohérence avec les autres API Intl et au cas où les futures versions d'Unicode introduiraient des règles spécifiques à la locale.
Réutilisation des segmenteurs pour la performance
La création d'une nouvelle instance Intl.Segmenter implique le chargement des données de locale et l'initialisation des structures internes. Lorsque vous devez segmenter plusieurs chaînes avec les mêmes paramètres, créez le segmenteur une seule fois et réutilisez-le.
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", " ", "👨👩👧👦"]
Cette approche est plus efficace que de créer un nouveau segmenteur pour chaque chaîne. La différence de performance devient significative lors du traitement de grandes quantités de texte.
Combinaison de la segmentation graphémique avec d'autres opérations
Vous pouvez combiner la segmentation graphémique avec d'autres opérations sur les chaînes pour construire des fonctions de traitement de texte plus complexes.
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 👨👩👧👦..."
Cette fonction de troncature compte les clusters graphémiques plutôt que les unités de code. Elle préserve les emoji et autres caractères complexes lors de la troncature, de sorte que la sortie ne contient jamais de caractères brisés.
Travailler avec les positions de chaîne
Les objets de segment retournés par Intl.Segmenter incluent une propriété index qui indique la position dans la chaîne originale. Cette position est mesurée en unités de code, pas en clusters graphémiques.
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
L'emoji de la main qui salue commence à la position d'unité de code 6, même s'il occupe les positions 6 et 7 dans la chaîne sous-jacente. Le caractère suivant commencerait à la position 8. Cette information est utile lorsque vous devez établir une correspondance entre les positions graphémiques et les positions de chaîne pour des opérations comme l'extraction de sous-chaînes.
Gestion des chaînes vides et des cas limites
L'API Intl.Segmenter gère correctement les chaînes vides et autres cas limites.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// Résultat : []
Une chaîne vide produit un tableau vide de segments. Aucun traitement spécial n'est requis.
Les caractères d'espacement sont traités comme des clusters graphémiques distincts.
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);
// Résultat : ["a", " ", "b", "\t", "c", "\n", "d"]
Les espaces, tabulations et sauts de ligne forment chacun leur propre cluster graphémique. Cela correspond aux attentes des utilisateurs pour le traitement de texte au niveau des caractères.