¿Cómo encontrar dónde dividir texto en límites de caracteres o palabras?

Localiza posiciones seguras de división de texto para truncamiento, ajuste de línea y operaciones de cursor

Introducción

Cuando truncas texto, posicionas un cursor o manejas clics en un editor de texto, necesitas encontrar dónde termina un carácter y comienza otro, o dónde empiezan y terminan las palabras. Dividir texto en la posición incorrecta separa emojis, corta caracteres combinados o divide palabras incorrectamente.

La API Intl.Segmenter de JavaScript proporciona el método containing() para encontrar el segmento de texto en cualquier posición de una cadena. Esto te indica qué carácter o palabra contiene un índice específico, dónde comienza ese segmento y dónde termina. Puedes usar esta información para encontrar puntos de división seguros que respeten los límites de clústeres de grafemas y los límites lingüísticos de palabras en todos los idiomas.

Este artículo explica por qué dividir texto en posiciones arbitrarias falla, cómo encontrar límites de texto con Intl.Segmenter y cómo usar la información de límites para truncamiento, posicionamiento de cursor y selección de texto.

Por qué no puedes dividir texto en cualquier posición

Las cadenas de JavaScript consisten en unidades de código, no en caracteres completos. Un solo emoji, letra acentuada o bandera puede abarcar múltiples unidades de código. Si cortas una cadena en un índice arbitrario, corres el riesgo de dividir un carácter por la mitad.

Considera este ejemplo:

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

El emoji de familia usa 11 unidades de código. Cortar en la posición 10 divide el emoji, produciendo una salida rota con un carácter de reemplazo.

Para palabras, dividir en la posición incorrecta crea fragmentos que no coinciden con las expectativas del usuario:

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

Los usuarios esperan que el texto se divida entre palabras, no en medio de una palabra. Encontrar el límite antes o después de la posición 7 produce mejores resultados.

Encontrar el segmento de texto en una posición específica

El método containing() devuelve información sobre el segmento de texto que incluye un índice específico:

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 👋🏽" }

El emoji en la posición 6 abarca cuatro unidades de código (del índice 6 al 9). El método containing() devuelve:

  • segment: el clúster de grafemas completo como cadena
  • index: dónde comienza este segmento en la cadena original
  • input: referencia a la cadena original

Esto indica que la posición 6 está dentro del emoji, el emoji comienza en el índice 6 y el emoji completo es "👋🏽".

Encontrar puntos de truncamiento seguros para texto

Para truncar texto sin romper caracteres, encuentra el límite de grafema antes de tu posición objetivo:

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

  const segment = segments.containing(maxIndex);

  // Truncate before this segment to avoid breaking it
  return text.slice(0, segment.index);
}

truncateAtPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello " (stops before the emoji, not in the middle)

truncateAtPosition("café", 3);
// "caf" (stops before é)

Esta función encuentra el segmento en la posición objetivo y trunca antes de él, asegurando que nunca dividas un clúster de grafemas.

Para truncar después del segmento en lugar de antes:

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 👨‍👩‍👧‍👦 " (includes the complete emoji)

Esto incluye el segmento completo que contiene la posición objetivo.

Encontrar límites de palabras para ajuste de texto

Al ajustar texto a un ancho máximo, quieres romper entre palabras, no en medio de una palabra. Usa la segmentación de palabras para encontrar el límite de palabra antes de tu posición objetivo:

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

  const segment = segments.containing(position);

  // If we're in a word, break before it
  if (segment.isWordLike) {
    return segment.index;
  }

  // If we're in whitespace or punctuation, break here
  return position;
}

const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (the space before "world")

const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (the boundary before "世界")

Esta función encuentra el inicio de la palabra que contiene la posición objetivo. Si la posición ya está en un espacio en blanco, devuelve la posición sin cambios.

Para ajuste de texto que respeta los límites de palabras:

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");
// ["你好世界", "欢迎使用"]

Esta función divide el texto en líneas que respetan los límites de palabras y se ajustan dentro de la longitud máxima.

Encontrar qué palabra contiene una posición del cursor

En editores de texto, necesitas saber en qué palabra está el cursor para implementar funciones como selección con doble clic, corrección ortográfica o menús contextuales:

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 (position 5 is the space, not a word)

Esto devuelve la palabra en la posición del cursor junto con sus índices de inicio y fin, o null si el cursor no está en una palabra.

Usa esto para implementar la selección de texto con doble 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 } (selects "world")

Encontrar límites de oraciones para navegación

Para navegación de documentos o segmentación de texto a voz, encuentra qué oración contiene una posición específica:

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 }

Esto encuentra la oración completa que contiene la posición objetivo, incluyendo sus límites.

Encontrar el siguiente límite después de una posición

Para avanzar un grafema, palabra u oración, itera a través de los segmentos hasta encontrar uno que comience después de tu posición actual:

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 (boundary after "H")

findNextBoundary(text, 6, "grapheme", "en");
// 17 (boundary after the family emoji)

findNextBoundary(text, 0, "word", "en");
// 5 (boundary after "Hello")

Esto encuentra dónde comienza el siguiente segmento, que es la posición segura para mover el cursor o truncar texto.

Encontrar el límite anterior antes de una posición

Para retroceder un grafema, palabra u oración, encuentra el segmento antes de tu posición actual:

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 (boundary before the family emoji)

findPreviousBoundary(text, 11, "word", "en");
// 6 (boundary before "world")

Esto encuentra dónde comienza el segmento anterior, que es la posición segura para mover el cursor hacia atrás.

Implementar movimiento del cursor con límites

Combina la búsqueda de límites con la posición del cursor para implementar un movimiento de cursor adecuado:

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 (moves over the entire emoji)

moveWordForward(text, 0, "en");
// 6 (moves to the start of "world")

Estas funciones implementan el movimiento de cursor estándar de editores de texto que respeta los límites de grafemas y palabras.

Encontrar todas las oportunidades de ruptura en el texto

Para encontrar cada posición donde puedes romper texto de forma segura, itera a través de todos los segmentos y recopila sus índices de inicio:

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]

Esto devuelve un array de cada posición de ruptura válida en el texto. Usa esto para implementar funciones avanzadas de diseño o análisis de texto.

Manejar casos extremos con límites

Cuando la posición está al final del texto, containing() devuelve el último segmento:

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 posición está al final, por lo que devuelve el último grafema.

Cuando la posición está antes del primer carácter, containing() devuelve el primer segmento:

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

Para cadenas vacías, no hay segmentos, por lo que llamar a containing() en una cadena vacía devuelve undefined. Comprueba si hay cadenas vacías antes de usar 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);
}

Elegir la granularidad adecuada para los límites

Usa diferentes granularidades según lo que necesites encontrar:

  • Grafema: úsalo al implementar movimiento del cursor, eliminación de caracteres o cualquier operación que necesite respetar lo que los usuarios ven como caracteres individuales. Esto evita dividir emojis, caracteres combinados u otros clústeres de grafemas complejos.

  • Palabra: úsalo para selección de palabras, corrección ortográfica, recuento de palabras o cualquier operación que necesite límites lingüísticos de palabras. Esto funciona en todos los idiomas, incluidos aquellos sin espacios entre palabras.

  • Oración: úsalo para navegación de oraciones, segmentación de texto a voz o cualquier operación que procese texto oración por oración. Esto respeta abreviaturas y otros contextos donde los puntos no terminan oraciones.

No uses límites de palabras cuando necesites límites de caracteres, y no uses límites de grafemas cuando necesites límites de palabras. Cada uno tiene un propósito específico.

Compatibilidad del navegador para operaciones de límites

La API Intl.Segmenter y su método containing() alcanzaron el estado Baseline en abril de 2024. Las versiones actuales de Chrome, Firefox, Safari y Edge lo admiten. Los navegadores más antiguos no.

Comprueba la compatibilidad antes de usar:

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  const segments = segmenter.segment(text);
  const segment = segments.containing(position);
  // Use segment information
} else {
  // Fallback for older browsers
  // Use approximate boundaries based on string length
}

Para aplicaciones dirigidas a navegadores más antiguos, proporciona un comportamiento alternativo usando límites aproximados, o usa un polyfill que implemente la API Intl.Segmenter.

Errores comunes al encontrar límites

No asumas que cada unidad de código es un punto de ruptura válido. Muchas posiciones dividen clústeres de grafemas o palabras, produciendo resultados no válidos o inesperados.

No uses string.length para encontrar el límite final. Usa el índice del último segmento más su longitud en su lugar.

No olvides verificar isWordLike al trabajar con límites de palabras. Los segmentos que no son palabras, como espacios y puntuación, también son devueltos por el segmentador.

No asumas que los límites de palabras son iguales en todos los idiomas. Usa segmentación consciente de la configuración regional para obtener resultados correctos.

No llames a containing() repetidamente para operaciones críticas de rendimiento. Si necesitas múltiples límites, itera a través de los segmentos una vez y construye un índice.

Consideraciones de rendimiento para operaciones de límites

Crear un segmentador es rápido, pero iterar a través de todos los segmentos puede ser lento para textos muy largos. Para operaciones que necesitan múltiples límites, considera almacenar en caché la información de los segmentos:

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

Esto almacena en caché todos los segmentos una vez y proporciona búsquedas rápidas para múltiples operaciones.

Ejemplo práctico: truncamiento de texto con puntos suspensivos

Combina la búsqueda de límites con el truncamiento para construir una función que corte el texto en la última palabra completa antes de una longitud máxima:

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");
// "你好世界…"

Esta función encuentra la última palabra completa antes de la longitud máxima y añade puntos suspensivos, produciendo texto truncado limpio que no corta palabras.