API Intl.Segmenter

Comment compter les caractères, diviser les mots et segmenter les phrases correctement en JavaScript

Introduction

La propriété string.length de JavaScript compte les unités de code, pas les caractères perçus par l'utilisateur. Lorsque les utilisateurs saisissent des émojis, des caractères accentués ou du texte dans des scripts complexes, string.length renvoie un compte erroné. La méthode split() échoue pour les langues qui n'utilisent pas d'espaces entre les mots. Les limites de mots des expressions régulières ne fonctionnent pas pour le texte chinois, japonais ou thaïlandais.

L'API Intl.Segmenter résout ces problèmes. Elle segmente le texte selon les normes Unicode, en respectant les règles linguistiques de chaque langue. Vous pouvez compter les graphèmes (caractères perçus par l'utilisateur), diviser le texte en mots quelle que soit la langue, ou découper le texte en phrases.

Cet article explique pourquoi les opérations de base sur les chaînes échouent pour le texte international, ce que sont les clusters de graphèmes et les limites linguistiques, et comment utiliser Intl.Segmenter pour traiter correctement le texte pour tous les utilisateurs.

Pourquoi string.length échoue pour le comptage des caractères

Les chaînes JavaScript utilisent l'encodage UTF-16. Chaque élément dans une chaîne JavaScript est une unité de code de 16 bits, pas un caractère complet. La propriété string.length compte ces unités de code.

Pour les caractères ASCII de base, une unité de code équivaut à un caractère. La chaîne "hello" a une longueur de 5, ce qui correspond aux attentes des utilisateurs.

Pour de nombreux autres caractères, cela ne fonctionne pas. Considérez ces exemples :

"😀".length; // 2, pas 1
"👨‍👩‍👧‍👦".length; // 11, pas 1
"किं".length; // 5, pas 2
"🇺🇸".length; // 4, pas 1

Les utilisateurs voient un émoji, un émoji de famille, deux syllabes hindi, ou un drapeau. JavaScript compte les unités de code sous-jacentes.

Cela est important lorsque vous créez des compteurs de caractères pour les champs de saisie, validez des limites de longueur, ou tronquez du texte pour l'affichage. Le compte que JavaScript rapporte ne correspond pas à ce que les utilisateurs voient.

Que sont les clusters graphémiques

Un cluster graphémique est ce que les utilisateurs perçoivent comme un seul caractère. Il peut être constitué de :

  • Un seul point de code comme "a"
  • Un caractère de base plus des marques combinatoires comme "é" (e + accent aigu combinatoire)
  • Plusieurs points de code joints ensemble comme "👨‍👩‍👧‍👦" (homme + femme + fille + garçon joints avec des joigneurs de largeur nulle)
  • Des émojis avec modificateurs de teint comme "👋🏽" (main qui salue + teint moyen)
  • Des séquences d'indicateurs régionaux pour les drapeaux comme "🇺🇸" (indicateur régional U + indicateur régional S)

Le standard Unicode définit les clusters graphémiques étendus dans UAX 29. Ces règles déterminent où les utilisateurs s'attendent à trouver des limites entre les caractères. Lorsqu'un utilisateur appuie sur la touche retour arrière, il s'attend à supprimer un cluster graphémique. Lorsqu'un curseur se déplace, il devrait se déplacer par clusters graphémiques.

La propriété string.length de JavaScript ne compte pas les clusters graphémiques. L'API Intl.Segmenter le fait.

Compter les clusters graphémiques avec Intl.Segmenter

Créez un segmenteur avec une granularité graphémique pour compter les caractères perçus par l'utilisateur :

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

L'utilisateur voit sept caractères : cinq lettres, un espace et un émoji. Le segmenteur graphémique renvoie sept segments. La propriété string.length de JavaScript renvoie dix car l'émoji utilise quatre unités de code.

Chaque objet segment contient :

  • segment : le cluster graphémique sous forme de chaîne
  • index : la position dans la chaîne originale où ce segment commence
  • input : référence à la chaîne originale (pas toujours nécessaire)

Vous pouvez itérer sur les segments avec for...of :

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "café";

for (const { segment } of segmenter.segment(text)) {
  console.log(segment);
}
// Affiche : "c", "a", "f", "é"

Construire un compteur de caractères qui fonctionne à l'échelle internationale

Utilisez la segmentation graphémique pour créer des compteurs de caractères précis :

function getGraphemeCount(text) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  return Array.from(segmenter.segment(text)).length;
}

// Test avec diverses entrées
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨‍👩‍👧‍👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1

Cette fonction renvoie des comptages qui correspondent à la perception de l'utilisateur. Un utilisateur qui tape un emoji de famille voit un caractère, et le compteur affiche un caractère.

Pour la validation des entrées de texte, utilisez le comptage des graphèmes au lieu 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;
}

Tronquer du texte en toute sécurité avec la segmentation graphémique

Lors de la troncature de texte pour l'affichage, vous ne devez pas couper à travers un cluster graphémique. Couper à un index d'unité de code arbitraire peut diviser des séquences d'emoji ou de caractères combinants, produisant une sortie invalide ou défectueuse.

Utilisez la segmentation graphémique pour trouver des points de troncature sûrs :

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…"

Cela préserve les clusters graphémiques complets et produit une sortie Unicode valide.

Pourquoi split() et les regex échouent pour la segmentation des mots

L'approche commune pour diviser le texte en mots utilise split() avec un espace ou un modèle d'espace blanc :

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

Cela fonctionne pour l'anglais et d'autres langues qui séparent les mots par des espaces. Cela échoue complètement pour les langues qui n'utilisent pas d'espaces entre les mots.

Le texte chinois, japonais et thaï n'inclut pas d'espaces entre les mots. La division sur les espaces renvoie la chaîne entière comme un seul élément :

const text = "你好世界"; // "Hello world" en chinois
const words = text.split(" "); // ["你好世界"]

L'utilisateur voit quatre mots distincts, mais split() renvoie un seul élément.

Les limites de mots des expressions régulières (\b) échouent également pour ces langues car le moteur regex ne reconnaît pas les limites de mots dans les scripts sans espaces.

Comment fonctionne la segmentation des mots dans différentes langues

L'API Intl.Segmenter utilise les règles de délimitation des mots Unicode définies dans UAX 29. Ces règles comprennent les limites des mots pour tous les scripts, y compris ceux sans espaces.

Créez un segmenteur avec une granularité de mot :

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

Le segmenteur identifie correctement les limites des mots en fonction de la locale et du script. La propriété isWordLike indique si le segment est un mot (lettres, chiffres, idéogrammes) ou un contenu non-mot (espaces, ponctuation).

Pour le texte en anglais, le segmenteur renvoie à la fois les mots et les espaces :

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

Utilisez la propriété isWordLike pour filtrer les segments de mots de la ponctuation et des espaces :

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"); // ["สวัสดี", "ครับ"] (thaï)

Cette fonction fonctionne pour n'importe quelle langue, gérant à la fois les scripts séparés par des espaces et ceux sans espaces.

Compter les mots avec précision

Construisez un compteur de mots qui fonctionne internationalement :

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

Cela produit des décomptes de mots précis pour du contenu dans n'importe quelle langue.

Trouver quel mot contient une position de curseur

La méthode containing() trouve le segment qui inclut un index spécifique dans la chaîne. Cela est utile pour déterminer dans quel mot se trouve le curseur ou quel segment contient une position de clic.

const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world";
const segments = segmenter.segment(text);

const segment = segments.containing(7); // L'index 7 est dans "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

Si l'index se trouve dans un espace blanc ou une ponctuation, containing() renvoie ce segment :

const segment = segments.containing(5); // L'index 5 est l'espace
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

Utilisez ceci pour les fonctionnalités d'édition de texte, la mise en évidence des recherches ou les actions contextuelles basées sur la position du curseur.

Segmentation des phrases pour le traitement de texte

La segmentation des phrases divise le texte aux limites des phrases. Cela est utile pour la synthèse, le traitement de la synthèse vocale ou la navigation dans de longs documents.

Les approches basiques comme la division sur les points échouent car les points apparaissent dans les abréviations, les nombres et d'autres contextes qui ne sont pas des limites de phrases :

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrect : se divise à "Dr." et "100."

L'API Intl.Segmenter comprend les règles de délimitation des phrases :

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

Le segmenteur traite correctement "Dr." et "100.5" comme faisant partie de la phrase, et non comme des limites de phrase.

Pour le texte multilingue, les limites des phrases varient selon la locale. L'API gère ces différences :

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

const textEn = "Hello. How are you?";
const textJa = "こんにちは。お元気ですか。"; // Utilise le point final japonais

Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2

Quand utiliser chaque granularité

Choisissez la granularité en fonction de ce que vous devez compter ou diviser :

  • Graphème : Utilisez pour le comptage de caractères, la troncature de texte, le positionnement du curseur, ou toute opération où vous devez correspondre à la perception utilisateur des caractères.

  • Mot : Utilisez pour le comptage de mots, la recherche et la mise en évidence, l'analyse de texte, ou toute opération nécessitant des limites linguistiques de mots dans différentes langues.

  • Phrase : Utilisez pour la segmentation texte-parole, la synthèse, la navigation dans les documents, ou toute opération qui traite le texte phrase par phrase.

N'utilisez pas la segmentation par graphème lorsque vous avez besoin de limites de mots, et n'utilisez pas la segmentation par mot lorsque vous avez besoin de compter des caractères. Chaque granularité sert un objectif distinct.

Création et réutilisation des segmenteurs

La création d'un segmenteur est peu coûteuse, mais vous pouvez réutiliser les segmenteurs pour améliorer les performances :

const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });

// Réutilisez ces segmenteurs pour plusieurs chaînes
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
  }));
}

Le segmenteur met en cache les données de locale, donc réutiliser la même instance évite l'initialisation répétée.

Vérification de la compatibilité des navigateurs

L'API Intl.Segmenter a atteint le statut Baseline en avril 2024. Elle fonctionne dans les versions actuelles de Chrome, Firefox, Safari et Edge. Les navigateurs plus anciens ne la prennent pas en charge.

Vérifiez la compatibilité avant utilisation :

if (typeof Intl.Segmenter !== "undefined") {
  // Utiliser Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Solution de repli pour les navigateurs plus anciens
  const count = text.length; // Pas précis, mais disponible
}

Pour les applications en production ciblant des navigateurs plus anciens, envisagez d'utiliser un polyfill ou de fournir une fonctionnalité dégradée.

Erreurs courantes à éviter

N'utilisez pas string.length pour afficher le nombre de caractères aux utilisateurs. Cela produit des résultats incorrects pour les emoji, les caractères combinés et les scripts complexes.

Ne divisez pas sur les espaces et n'utilisez pas les limites de mots regex pour la segmentation multilingue des mots. Ces approches ne fonctionnent que pour un sous-ensemble de langues.

Ne supposez pas que les limites de mots ou de phrases sont les mêmes dans toutes les langues. Utilisez une segmentation adaptée à la locale.

N'oubliez pas de vérifier la propriété isWordLike lors du comptage des mots. L'inclusion de la ponctuation et des espaces produit des comptages gonflés.

Ne coupez pas les chaînes à des indices arbitraires lors de la troncation. Coupez toujours aux limites des clusters graphémiques pour éviter de produire des séquences Unicode invalides.

Quand ne pas utiliser Intl.Segmenter

Pour les opérations simples uniquement en ASCII où vous savez que le texte ne contient que des caractères latins de base, les méthodes de chaîne de base sont plus rapides et suffisantes.

Lorsque vous avez besoin de la longueur en octets d'une chaîne pour des opérations réseau ou de stockage, utilisez TextEncoder :

const byteLength = new TextEncoder().encode(text).length;

Lorsque vous avez besoin du nombre réel d'unités de code pour la manipulation de chaînes de bas niveau, string.length est correct. Cela est rare dans le code d'application.

Pour la plupart des traitements de texte impliquant du contenu destiné aux utilisateurs, en particulier dans les applications internationales, utilisez Intl.Segmenter.

Comment Intl.Segmenter se rapporte aux autres API d'internationalisation

L'API Intl.Segmenter fait partie de l'API d'internationalisation ECMAScript. Les autres API de cette famille comprennent :

  • Intl.DateTimeFormat : Formatage des dates et heures selon la locale
  • Intl.NumberFormat : Formatage des nombres, devises et unités selon la locale
  • Intl.Collator : Tri et comparaison des chaînes selon la locale
  • Intl.PluralRules : Détermination des formes plurielles pour les nombres dans différentes langues

Ensemble, ces API fournissent les outils nécessaires pour créer des applications qui fonctionnent correctement pour les utilisateurs du monde entier. Utilisez Intl.Segmenter pour la segmentation de texte, et utilisez les autres API Intl pour le formatage et la comparaison.

Exemple pratique : création d'un composant de statistiques textuelles

Combinez la segmentation des graphèmes et des mots pour créer un composant de statistiques textuelles :

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

// Fonctionne pour n'importe quelle langue
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 }

Cette fonction produit des statistiques pertinentes pour du texte dans n'importe quelle langue, en utilisant les règles de segmentation appropriées pour chaque locale.