Wie findet man Stellen zum Umbrechen von Text an Zeichen- oder Wortgrenzen?

Sichere Textumbruchpositionen für Kürzung, Umbruch und Cursor-Operationen finden

Einführung

Wenn Sie Text kürzen, einen Cursor positionieren oder Klicks in einem Texteditor verarbeiten, müssen Sie herausfinden, wo ein Zeichen endet und ein anderes beginnt oder wo Wörter anfangen und enden. Das Umbrechen von Text an der falschen Position teilt Emojis, schneidet durch kombinierende Zeichen oder trennt Wörter falsch.

Die Intl.Segmenter-API von JavaScript bietet die containing()-Methode, um das Textsegment an jeder Position in einem String zu finden. Dies teilt Ihnen mit, welches Zeichen oder Wort einen bestimmten Index enthält, wo dieses Segment beginnt und wo es endet. Sie können diese Informationen verwenden, um sichere Umbruchpunkte zu finden, die Graphemcluster-Grenzen und linguistische Wortgrenzen in allen Sprachen respektieren.

Dieser Artikel erklärt, warum das Umbrechen von Text an beliebigen Positionen fehlschlägt, wie man Textgrenzen mit Intl.Segmenter findet und wie man Grenzinformationen für Kürzung, Cursor-Positionierung und Textauswahl verwendet.

Warum Sie Text nicht an beliebigen Positionen umbrechen können

JavaScript-Strings bestehen aus Code-Einheiten, nicht aus vollständigen Zeichen. Ein einzelnes Emoji, ein Buchstabe mit Akzent oder eine Flagge kann mehrere Code-Einheiten umfassen. Wenn Sie einen String an einem beliebigen Index schneiden, riskieren Sie, ein Zeichen in der Mitte zu teilen.

Betrachten Sie dieses Beispiel:

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

Das Familien-Emoji verwendet 11 Code-Einheiten. Das Schneiden an Position 10 teilt das Emoji und erzeugt eine fehlerhafte Ausgabe mit einem Ersatzzeichen.

Bei Wörtern erzeugt das Umbrechen an der falschen Position Fragmente, die nicht den Erwartungen der Benutzer entsprechen:

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

Benutzer erwarten, dass Text zwischen Wörtern umbricht und nicht mitten in einem Wort. Das Finden der Grenze vor oder nach Position 7 liefert bessere Ergebnisse.

Finden des Textsegments an einer bestimmten Position

Die Methode containing() gibt Informationen über das Textsegment zurück, das einen bestimmten Index enthält:

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

Das Emoji an Position 6 umfasst vier Code-Einheiten (von Index 6 bis 9). Die Methode containing() gibt zurück:

  • segment: das vollständige Graphem-Cluster als String
  • index: wo dieses Segment im ursprünglichen String beginnt
  • input: Referenz auf den ursprünglichen String

Dies teilt Ihnen mit, dass sich Position 6 innerhalb des Emojis befindet, das Emoji bei Index 6 beginnt und das vollständige Emoji "👋🏽" ist.

Finden sicherer Kürzungspunkte für Text

Um Text zu kürzen, ohne Zeichen zu trennen, finden Sie die Graphem-Grenze vor Ihrer Zielposition:

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

Diese Funktion findet das Segment an der Zielposition und kürzt davor, um sicherzustellen, dass Sie niemals ein Graphem-Cluster trennen.

Um nach dem Segment anstatt davor zu kürzen:

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)

Dies schließt das gesamte Segment ein, das die Zielposition enthält.

Finden von Wortgrenzen für Textumbruch

Beim Umbrechen von Text bei einer maximalen Breite möchten Sie zwischen Wörtern umbrechen und nicht mitten in einem Wort. Verwenden Sie Wortsegmentierung, um die Wortgrenze vor Ihrer Zielposition zu finden:

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 "世界")

Diese Funktion findet den Anfang des Wortes, das die Zielposition enthält. Wenn sich die Position bereits in einem Leerzeichen befindet, gibt sie die Position unverändert zurück.

Für Textumbruch, der Wortgrenzen respektiert:

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

Diese Funktion teilt Text in Zeilen auf, die Wortgrenzen respektieren und innerhalb der maximalen Länge passen.

Finden, welches Wort eine Cursorposition enthält

In Texteditoren müssen Sie wissen, in welchem Wort sich der Cursor befindet, um Funktionen wie Doppelklick-Auswahl, Rechtschreibprüfung oder Kontextmenüs zu implementieren:

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)

Dies gibt das Wort an der Cursorposition zusammen mit seinen Start- und Endindizes zurück, oder null, wenn sich der Cursor nicht in einem Wort befindet.

Verwenden Sie dies zur Implementierung der Doppelklick-Textauswahl:

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

Finden von Satzgrenzen für die Navigation

Für Dokumentnavigation oder Text-zu-Sprache-Segmentierung finden Sie heraus, welcher Satz eine bestimmte Position enthält:

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 }

Dies findet den vollständigen Satz, der die Zielposition enthält, einschließlich seiner Grenzen.

Finden der nächsten Grenze nach einer Position

Um um ein Graphem, Wort oder Satz vorwärts zu bewegen, iterieren Sie durch Segmente, bis Sie eines finden, das nach Ihrer aktuellen Position beginnt:

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

Dies findet heraus, wo das nächste Segment beginnt, was die sichere Position ist, um den Cursor zu bewegen oder Text abzuschneiden.

Finden der vorherigen Grenze vor einer Position

Um um ein Graphem, Wort oder Satz rückwärts zu bewegen, finden Sie das Segment vor Ihrer aktuellen Position:

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

Dies findet heraus, wo das vorherige Segment beginnt, was die sichere Position ist, um den Cursor rückwärts zu bewegen.

Implementierung der Cursorbewegung mit Grenzen

Kombinieren Sie das Finden von Grenzen mit der Cursorposition, um eine korrekte Cursorbewegung zu implementieren:

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

Diese Funktionen implementieren standardmäßige Texteditor-Cursorbewegung, die Graphem- und Wortgrenzen respektiert.

Alle Umbruchmöglichkeiten im Text finden

Um jede Position zu finden, an der Sie Text sicher umbrechen können, iterieren Sie durch alle Segmente und sammeln deren Startindizes:

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]

Dies gibt ein Array aller gültigen Umbruchpositionen im Text zurück. Verwenden Sie dies für die Implementierung erweiterter Textlayout- oder Analysefunktionen.

Umgang mit Grenzfällen bei Grenzen

Wenn sich die Position ganz am Ende des Textes befindet, gibt containing() das letzte Segment zurück:

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

Die Position befindet sich am Ende, daher wird das letzte Graphem zurückgegeben.

Wenn sich die Position vor dem ersten Zeichen befindet, gibt containing() das erste Segment zurück:

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

Bei leeren Strings gibt es keine Segmente, daher gibt der Aufruf von containing() auf einem leeren String undefined zurück. Prüfen Sie auf leere Strings, bevor Sie containing() verwenden:

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

Die richtige Granularität für Grenzen wählen

Verwenden Sie je nach Bedarf unterschiedliche Granularitäten:

  • Graphem: Verwenden Sie dies bei der Implementierung von Cursorbewegungen, Zeichenlöschung oder jeder Operation, die respektieren muss, was Benutzer als einzelne Zeichen wahrnehmen. Dies verhindert das Aufteilen von Emojis, kombinierenden Zeichen oder anderen komplexen Graphemclustern.

  • Wort: Verwenden Sie dies für Wortauswahl, Rechtschreibprüfung, Wortzählung oder jede Operation, die linguistische Wortgrenzen benötigt. Dies funktioniert sprachübergreifend, einschließlich Sprachen ohne Leerzeichen zwischen Wörtern.

  • Satz: Verwenden Sie dies für Satznavigation, Text-to-Speech-Segmentierung oder jede Operation, die Text satzweise verarbeitet. Dies respektiert Abkürzungen und andere Kontexte, in denen Punkte keine Sätze beenden.

Verwenden Sie keine Wortgrenzen, wenn Sie Zeichengrenzen benötigen, und keine Graphemgrenzen, wenn Sie Wortgrenzen benötigen. Jede dient einem spezifischen Zweck.

Browser-Unterstützung für Grenzoperationen

Die Intl.Segmenter-API und ihre containing()-Methode erreichten im April 2024 den Baseline-Status. Aktuelle Versionen von Chrome, Firefox, Safari und Edge unterstützen sie. Ältere Browser nicht.

Prüfen Sie die Unterstützung vor der Verwendung:

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
}

Für Anwendungen, die auf ältere Browser abzielen, stellen Sie Fallback-Verhalten mit ungefähren Grenzen bereit oder verwenden Sie ein Polyfill, das die Intl.Segmenter-API implementiert.

Häufige Fehler beim Finden von Grenzen

Gehen Sie nicht davon aus, dass jede Code-Einheit ein gültiger Trennpunkt ist. Viele Positionen teilen Graphem-Cluster oder Wörter und erzeugen ungültige oder unerwartete Ergebnisse.

Verwenden Sie nicht string.length, um die Endgrenze zu finden. Verwenden Sie stattdessen den Index des letzten Segments plus dessen Länge.

Vergessen Sie nicht, isWordLike zu prüfen, wenn Sie mit Wortgrenzen arbeiten. Nicht-Wort-Segmente wie Leerzeichen und Interpunktion werden ebenfalls vom Segmentierer zurückgegeben.

Gehen Sie nicht davon aus, dass Wortgrenzen in allen Sprachen gleich sind. Verwenden Sie sprachbewusste Segmentierung für korrekte Ergebnisse.

Rufen Sie containing() nicht wiederholt für leistungskritische Operationen auf. Wenn Sie mehrere Grenzen benötigen, iterieren Sie einmal durch die Segmente und erstellen Sie einen Index.

Leistungsaspekte für Grenzoperationen

Das Erstellen eines Segmentierers ist schnell, aber das Durchlaufen aller Segmente kann bei sehr langen Texten langsam sein. Für Operationen, die mehrere Grenzen benötigen, sollten Sie das Zwischenspeichern von Segmentinformationen in Betracht ziehen:

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

Dies speichert alle Segmente einmal zwischen und ermöglicht schnelle Abfragen für mehrere Operationen.

Praktisches Beispiel: Textkürzung mit Auslassungspunkten

Kombinieren Sie das Finden von Grenzen mit Kürzung, um eine Funktion zu erstellen, die Text am letzten vollständigen Wort vor einer maximalen Länge abschneidet:

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

Diese Funktion findet das letzte vollständige Wort vor der maximalen Länge und fügt Auslassungspunkte hinzu, wodurch sauber gekürzter Text entsteht, der Wörter nicht abschneidet.