Comment trouver où couper le texte aux limites de caractères ou de mots ?

Localiser les positions sûres de coupure de texte pour la troncature, le retour à la ligne et les opérations de curseur

Introduction

Lorsque vous tronquez du texte, positionnez un curseur ou gérez les clics dans un éditeur de texte, vous devez déterminer où un caractère se termine et où un autre commence, ou où les mots commencent et se terminent. Couper le texte à la mauvaise position divise les émojis, traverse les caractères combinés ou divise incorrectement les mots.

L'API Intl.Segmenter de JavaScript fournit la méthode containing() pour trouver le segment de texte à n'importe quelle position dans une chaîne. Cela vous indique quel caractère ou mot contient un index spécifique, où ce segment commence et où il se termine. Vous pouvez utiliser ces informations pour trouver des points de rupture sûrs qui respectent les limites des groupes de graphèmes et les limites linguistiques des mots dans toutes les langues.

Cet article explique pourquoi la coupure de texte à des positions arbitraires échoue, comment trouver les limites du texte avec Intl.Segmenter, et comment utiliser les informations de limite pour la troncation, le positionnement du curseur et la sélection de texte.

Pourquoi vous ne pouvez pas couper le texte à n'importe quelle position

Les chaînes JavaScript sont composées d'unités de code, pas de caractères complets. Un seul émoji, une lettre accentuée ou un drapeau peut s'étendre sur plusieurs unités de code. Si vous coupez une chaîne à un index arbitraire, vous risquez de diviser un caractère au milieu.

Considérez cet exemple :

const text = "Hello 👨‍👩‍👧‍👦 world";
const truncated = text.slice(0, 10);
console.log(truncated); // "Hello 👨‍�"

L'émoji de famille utilise 11 unités de code. Couper à la position 10 divise l'émoji, produisant une sortie cassée avec un caractère de remplacement.

Pour les mots, couper à la mauvaise position crée des fragments qui ne correspondent pas aux attentes des utilisateurs :

const text = "Hello world";
const fragment = text.slice(0, 7);
console.log(fragment); // "Hello w"

Les utilisateurs s'attendent à ce que le texte se coupe entre les mots, pas au milieu d'un mot. Trouver la limite avant ou après la position 7 produit de meilleurs résultats.

Trouver le segment de texte à une position spécifique

La méthode containing() renvoie des informations sur le segment de texte qui inclut un index spécifique :

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

const segment = segments.containing(6);
console.log(segment);
// { segment: "👋🏽", index: 6, input: "Hello 👋🏽" }

L'emoji à la position 6 s'étend sur quatre unités de code (de l'index 6 à 9). La méthode containing() renvoie :

  • segment : le cluster graphème complet sous forme de chaîne
  • index : où ce segment commence dans la chaîne originale
  • input : référence à la chaîne originale

Cela vous indique que la position 6 se trouve à l'intérieur de l'emoji, que l'emoji commence à l'index 6, et que l'emoji complet est "👋🏽".

Trouver des points de troncature sûrs pour le texte

Pour tronquer un texte sans casser les caractères, trouvez la limite graphème avant votre position cible :

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

  const segment = segments.containing(maxIndex);

  // Tronquer avant ce segment pour éviter de le casser
  return text.slice(0, segment.index);
}

truncateAtPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello " (s'arrête avant l'emoji, pas au milieu)

truncateAtPosition("café", 3);
// "caf" (s'arrête avant é)

Cette fonction trouve le segment à la position cible et tronque avant celui-ci, garantissant que vous ne divisez jamais un cluster graphème.

Pour tronquer après le segment au lieu d'avant :

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

  const segment = segments.containing(minIndex);
  const endIndex = segment.index + segment.segment.length;

  return text.slice(0, endIndex);
}

truncateAfterPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello 👨‍👩‍👧‍👦 " (inclut l'emoji complet)

Cela inclut le segment entier qui contient la position cible.

Trouver les limites des mots pour le retour à la ligne

Lors du retour à la ligne à une largeur maximale, vous voulez couper entre les mots, pas au milieu d'un mot. Utilisez la segmentation des mots pour trouver la limite du mot avant votre position cible :

function findWordBreakBefore(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  // Si nous sommes dans un mot, coupez avant celui-ci
  if (segment.isWordLike) {
    return segment.index;
  }

  // Si nous sommes dans un espace blanc ou une ponctuation, coupez ici
  return position;
}

const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (l'espace avant "world")

const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (la limite avant "世界")

Cette fonction trouve le début du mot qui contient la position cible. Si la position est déjà dans un espace blanc, elle renvoie la position inchangée.

Pour un retour à la ligne qui respecte les limites des mots :

function wrapTextAtWidth(text, maxLength, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  const lines = [];
  let currentLine = "";

  for (const { segment, isWordLike } of segments) {
    const potentialLine = currentLine + segment;

    if (potentialLine.length <= maxLength) {
      currentLine = potentialLine;
    } else {
      if (currentLine) {
        lines.push(currentLine.trim());
      }
      currentLine = isWordLike ? segment : "";
    }
  }

  if (currentLine) {
    lines.push(currentLine.trim());
  }

  return lines;
}

wrapTextAtWidth("Hello world from JavaScript", 12, "en");
// ["Hello world", "from", "JavaScript"]

wrapTextAtWidth("你好世界欢迎使用", 6, "zh");
// ["你好世界", "欢迎使用"]

Cette fonction divise le texte en lignes qui respectent les limites des mots et s'adaptent à la longueur maximale.

Trouver le mot contenant une position de curseur

Dans les éditeurs de texte, vous devez savoir dans quel mot se trouve le curseur pour implémenter des fonctionnalités comme la sélection par double-clic, la vérification orthographique ou les menus contextuels :

function getWordAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  if (!segment.isWordLike) {
    return null;
  }

  return {
    word: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world";
getWordAtPosition(text, 7, "en");
// { word: "world", start: 6, end: 11 }

getWordAtPosition(text, 5, "en");
// null (la position 5 est l'espace, pas un mot)

Cela renvoie le mot à la position du curseur ainsi que ses indices de début et de fin, ou null si le curseur n'est pas dans un mot.

Utilisez ceci pour implémenter la sélection de texte par double-clic :

function selectWordAtPosition(text, position, locale) {
  const wordInfo = getWordAtPosition(text, position, locale);

  if (!wordInfo) {
    return { start: position, end: position };
  }

  return { start: wordInfo.start, end: wordInfo.end };
}

selectWordAtPosition("Hello world", 7, "en");
// { start: 6, end: 11 } (sélectionne "world")

Trouver les limites des phrases pour la navigation

Pour la navigation dans les documents ou la segmentation de la synthèse vocale, trouvez quelle phrase contient une position spécifique :

function getSentenceAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "sentence" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  return {
    sentence: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world. How are you? Fine thanks.";
getSentenceAtPosition(text, 15, "en");
// { sentence: "How are you? ", start: 13, end: 26 }

Cela trouve la phrase complète qui contient la position cible, y compris ses limites.

Trouver la limite suivante après une position

Pour avancer d'un graphème, mot ou phrase, parcourez les segments jusqu'à en trouver un qui commence après votre position actuelle :

function findNextBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > position) {
      return segment.index;
    }
  }

  return text.length;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findNextBoundary(text, 0, "grapheme", "en");
// 1 (limite après "H")

findNextBoundary(text, 6, "grapheme", "en");
// 17 (limite après l'emoji famille)

findNextBoundary(text, 0, "word", "en");
// 5 (limite après "Hello")

Cela trouve où commence le segment suivant, qui est la position sûre pour déplacer le curseur ou tronquer le texte.

Trouver la limite précédente avant une position

Pour reculer d'un graphème, mot ou phrase, trouvez le segment avant votre position actuelle :

function findPreviousBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  let previousIndex = 0;

  for (const segment of segments) {
    if (segment.index >= position) {
      return previousIndex;
    }
    previousIndex = segment.index;
  }

  return previousIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findPreviousBoundary(text, 17, "grapheme", "en");
// 6 (limite avant l'emoji famille)

findPreviousBoundary(text, 11, "word", "en");
// 6 (limite avant "world")

Cela trouve où commence le segment précédent, qui est la position sûre pour déplacer le curseur en arrière.

Implémenter le déplacement du curseur avec les limites

Combinez la recherche de limites avec la position du curseur pour implémenter un déplacement correct du curseur :

function moveCursorForward(text, cursorPosition, locale) {
  return findNextBoundary(text, cursorPosition, "grapheme", locale);
}

function moveCursorBackward(text, cursorPosition, locale) {
  return findPreviousBoundary(text, cursorPosition, "grapheme", locale);
}

function moveWordForward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > cursorPosition && segment.isWordLike) {
      return segment.index;
    }
  }

  return text.length;
}

function moveWordBackward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  let previousWordIndex = 0;

  for (const segment of segments) {
    if (segment.index >= cursorPosition) {
      return previousWordIndex;
    }
    if (segment.isWordLike) {
      previousWordIndex = segment.index;
    }
  }

  return previousWordIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
moveCursorForward(text, 6, "en");
// 17 (déplace sur l'emoji entier)

moveWordForward(text, 0, "en");
// 6 (déplace au début de "world")

Ces fonctions implémentent le déplacement standard du curseur d'un éditeur de texte qui respecte les limites des graphèmes et des mots.

Trouver toutes les opportunités de rupture dans un texte

Pour trouver chaque position où vous pouvez couper le texte en toute sécurité, parcourez tous les segments et collectez leurs indices de début :

function getBreakOpportunities(text, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  return segments.map(segment => segment.index);
}

const text = "Hello 👨‍👩‍👧‍👦 world";
getBreakOpportunities(text, "grapheme", "en");
// [0, 1, 2, 3, 4, 5, 6, 17, 18, 19, 20, 21, 22]

getBreakOpportunities(text, "word", "en");
// [0, 5, 6, 17, 18, 22]

Cela renvoie un tableau de chaque position de rupture valide dans le texte. Utilisez ceci pour implémenter des fonctionnalités avancées de mise en page ou d'analyse de texte.

Gestion des cas particuliers avec les limites

Lorsque la position se trouve à la toute fin du texte, containing() renvoie le dernier segment :

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

const segment = segments.containing(5);
console.log(segment);
// { segment: "o", index: 4, input: "Hello" }

La position est à la fin, donc elle renvoie le dernier graphème.

Lorsque la position est avant le premier caractère, containing() renvoie le premier segment :

const segment = segments.containing(0);
console.log(segment);
// { segment: "H", index: 0, input: "Hello" }

Pour les chaînes vides, il n'y a pas de segments, donc l'appel de containing() sur une chaîne vide renvoie undefined. Vérifiez les chaînes vides avant d'utiliser containing() :

function safeContaining(text, position, granularity, locale) {
  if (text.length === 0) {
    return null;
  }

  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = segmenter.segment(text);
  return segments.containing(position);
}

Choisir la bonne granularité pour les limites

Utilisez différentes granularités selon vos besoins :

  • Graphème : Utilisez-le pour implémenter le déplacement du curseur, la suppression de caractères ou toute opération qui doit respecter ce que les utilisateurs perçoivent comme des caractères uniques. Cela évite de diviser les émojis, les caractères combinés ou d'autres clusters de graphèmes complexes.

  • Mot : Utilisez-le pour la sélection de mots, la vérification orthographique, le comptage de mots ou toute opération nécessitant des limites linguistiques de mots. Cela fonctionne dans toutes les langues, y compris celles sans espaces entre les mots.

  • Phrase : Utilisez-le pour la navigation entre phrases, la segmentation de synthèse vocale ou toute opération qui traite le texte phrase par phrase. Cela respecte les abréviations et autres contextes où les points ne terminent pas les phrases.

N'utilisez pas les limites de mots lorsque vous avez besoin de limites de caractères, et n'utilisez pas les limites de graphèmes lorsque vous avez besoin de limites de mots. Chacun sert un objectif spécifique.

Support des navigateurs pour les opérations de limites

L'API Intl.Segmenter et sa méthode containing() ont atteint le statut Baseline en avril 2024. Les versions actuelles de Chrome, Firefox, Safari et Edge la prennent en charge. Les navigateurs plus anciens ne la supportent pas.

Vérifiez la compatibilité avant utilisation :

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  const segments = segmenter.segment(text);
  const segment = segments.containing(position);
  // Utiliser les informations du segment
} else {
  // Solution de repli pour les navigateurs plus anciens
  // Utiliser des limites approximatives basées sur la longueur de chaîne
}

Pour les applications ciblant des navigateurs plus anciens, fournissez un comportement de repli utilisant des limites approximatives, ou utilisez un polyfill qui implémente l'API Intl.Segmenter.

Erreurs courantes lors de la recherche de limites

Ne supposez pas que chaque unité de code est un point de rupture valide. De nombreuses positions divisent les clusters de graphèmes ou les mots, produisant des résultats invalides ou inattendus.

N'utilisez pas string.length pour trouver la limite de fin. Utilisez plutôt l'index du dernier segment plus sa longueur.

N'oubliez pas de vérifier isWordLike lorsque vous travaillez avec des limites de mots. Les segments non-mots comme les espaces et la ponctuation sont également renvoyés par le segmenteur.

Ne supposez pas que les limites de mots sont les mêmes dans toutes les langues. Utilisez une segmentation adaptée à la locale pour des résultats corrects.

N'appelez pas containing() de façon répétée pour des opérations critiques en termes de performance. Si vous avez besoin de plusieurs limites, parcourez les segments une seule fois et construisez un index.

Considérations de performance pour les opérations de délimitation

La création d'un segmenteur est rapide, mais l'itération à travers tous les segments peut être lente pour des textes très longs. Pour les opérations nécessitant plusieurs délimitations, envisagez de mettre en cache les informations de segment :

class TextBoundaryCache {
  constructor(text, granularity, locale) {
    this.text = text;
    const segmenter = new Intl.Segmenter(locale, { granularity });
    this.segments = Array.from(segmenter.segment(text));
  }

  containing(position) {
    for (const segment of this.segments) {
      const end = segment.index + segment.segment.length;
      if (position >= segment.index && position < end) {
        return segment;
      }
    }
    return this.segments[this.segments.length - 1];
  }

  nextBoundary(position) {
    for (const segment of this.segments) {
      if (segment.index > position) {
        return segment.index;
      }
    }
    return this.text.length;
  }

  previousBoundary(position) {
    let previous = 0;
    for (const segment of this.segments) {
      if (segment.index >= position) {
        return previous;
      }
      previous = segment.index;
    }
    return previous;
  }
}

const cache = new TextBoundaryCache("Hello world", "grapheme", "en");
cache.containing(7);
cache.nextBoundary(7);
cache.previousBoundary(7);

Ceci met en cache tous les segments une fois et fournit des recherches rapides pour plusieurs opérations.

Exemple pratique : troncature de texte avec points de suspension

Combinez la recherche de délimitation avec la troncature pour créer une fonction qui coupe le texte au dernier mot complet avant une longueur maximale :

function truncateAtWordBoundary(text, maxLength, locale) {
  if (text.length <= maxLength) {
    return text;
  }

  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  let lastWordEnd = 0;

  for (const segment of segments) {
    const segmentEnd = segment.index + segment.segment.length;

    if (segmentEnd > maxLength) {
      break;
    }

    if (segment.isWordLike) {
      lastWordEnd = segmentEnd;
    }
  }

  if (lastWordEnd === 0) {
    return "";
  }

  return text.slice(0, lastWordEnd).trim() + "…";
}

truncateAtWordBoundary("Hello world from JavaScript", 15, "en");
// "Hello world…"

truncateAtWordBoundary("你好世界欢迎使用", 9, "zh");
// "你好世界…"

Cette fonction trouve le dernier mot complet avant la longueur maximale et ajoute des points de suspension, produisant un texte tronqué propre qui ne coupe pas les mots.