12k
All articles

Bereinigung von Text, den Nutzer in Ihre App einfügen

Bereinigen Sie eingefügten Text in Webformularen mit Unicode-Normalisierung, Entfernung von Formatzeichen, Zusammenfassen von Leerraum und sicherem Umgang mit ZWJ und NBSP.

OpenReplay Team
OpenReplay Team
Bereinigung von Text, den Nutzer in Ihre App einfügen

Text, der in ein Webformular eingefügt wird, enthält routinemäßig unsichtbare Zeichen, die niemand getippt hat und die keine Schriftart darstellt: Zero-Width Spaces, weiche Trennstriche, Byte Order Marks und geschützte Leerzeichen – allesamt Zeichen, die es bis in Ihre Datenbank schaffen und dort klammheimlich exakte Vergleiche, Längenvalidierung und Suche zerstören.

Wer diesem Bug schon einmal hinterhergejagt ist, kennt den Ablauf. Jemand kopiert einen Absatz seines Lebenslaufs aus einer Textverarbeitung in ein Bewerbungsfeld, das Feld sieht auf dem Bildschirm völlig normal aus, und Ihr Validator weist es zurück. Oder es wird problemlos gespeichert, und anschließend findet niemand den Datensatz jemals wieder, weil der Name im Index ein Zeichen enthält, das über das Suchfeld gar nicht eingegeben werden kann.

Dieser Artikel zeigt, was tatsächlich im Feld landet, erklärt, warum ein Flicken Code Point für Code Point niemals zum Ziel führt, und liefert Ihnen eine vierstufige Bereinigungsfunktion, die die gesamte Problemklasse abdeckt – einschließlich der unsichtbaren Zeichen, die Sie auf keinen Fall löschen dürfen.

Die wichtigsten Erkenntnisse

  • Die unsichtbaren Zeichen, die mit eingefügtem Text ankommen, gehören zur Unicode-Hauptkategorie Format, in einem JavaScript-Regex geschrieben als \p{Cf}. Ein einziger Kategorie-Match ersetzt damit eine ständig wachsende Liste einzelner Code Points.
  • normalize("NFC") korrigiert die Schreibweise, nicht die Unsichtbarkeit: Es sorgt dafür, dass zwei Kodierungen desselben Akzentbuchstabens als gleich verglichen werden, und lässt ein Zero-Width Space genau dort, wo es war.
  • Das geschützte Leerzeichen bei U+00A0 ist ein Leerzeichen und kein Formatzeichen, überlebt also ein \p{Cf}-Strippen unversehrt und muss im Schritt zur Leerraum-Reduzierung behandelt werden.
  • U+200D ZERO WIDTH JOINER leistet echte Arbeit: Er bindet die Bestandteile eines Mehrpersonen-Emojis zu einem einzigen Glyphen zusammen, und Joiner tragen in arabischen und indischen Schriften orthografische Bedeutung.
  • \p{...} bedeutet nur dann eine Unicode-Eigenschaft, wenn das Regex das u- oder v-Flag trägt; ohne eines davon ist es ein Identity Escape für den Literalbuchstaben p.

Welche unsichtbaren Zeichen kommen tatsächlich im Feld an?

Ein eingefügter String aus einer Textverarbeitung oder einem Rich-Text-Editor mischt typischerweise sichtbare typografische Ersetzungen mit unsichtbaren Formatzeichen. Typografische Anführungszeichen, Halbgeviert- und Geviertstriche sowie das Ellipsen-Zeichen sind sichtbar und meist harmlos. Das geschützte Leerzeichen, der weiche Trennstrich, das Zero-Width Space und das Byte Order Mark sind überhaupt nicht sichtbar – und genau sie zerstören Gleichheitsprüfungen.

Geben Sie den String als Code Points aus, und das Argument spricht für sich:

function inspect(str) {
  return [...str]
    .map((ch) => ch.codePointAt(0))
    .filter((cp) => cp > 0x7f)
    .map((cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"));
}

const pasted = "\uFEFFSenior\u00A0Engineer\u200B, 2019\u20132024";

console.log(inspect(pasted)); // [ 'U+FEFF', 'U+00A0', 'U+200B', 'U+2013' ]
console.log(pasted.length);   // 28, for 26 characters a human would count

Verwenden Sie den Spread-Operator statt split(""): Der String-Iterator liefert vollständige Code Points, während split("") astrale Zeichen an der UTF-16-Grenze in zwei Hälften zerschneidet.

Um zu sehen, was wirklich in einem vorliegenden String steckt, fügen Sie ihn in den Invisible Character Cleaner ein und lesen Sie die Code Points ab.

Diese Fehlerklasse ist dadurch definiert, unsichtbar zu sein. Das Feld wird korrekt gerendert, ein Screenshot des Bugs zeigt also keinerlei Auffälligkeit, und der Meldende kann nicht beschreiben, was er anders gemacht hat. Session Replay ist eine der wenigen Techniken, die das überhaupt sichtbar macht, denn Replays abgebrochener Formulare zeigen das Einfügen und danach die Wiederholungsschleife: Jemand leert ein Feld und tippt einen Wert erneut ein, der identisch zu dem aussah, der gerade abgelehnt wurde.

Warum wächst eine Sperrliste von Zero-Width-Zeichen immer weiter?

Eine Zeichenklasse aus konkreten Code Points ist nicht etwa schlecht geschrieben, sondern schlicht der falsche Lösungsansatz – denn sie kann immer nur die Zeichen enthalten, zu denen bereits jemand einen Bug gemeldet hat. Sie löschen U+200B nach der ersten Meldung, ergänzen U+FEFF, wenn ein CSV-Import scheitert, fügen U+00AD hinzu, wenn ein Doppelname nicht mehr matcht – und die Klasse wächst weiter, weil sie die Mitglieder einer Menge aufzählt, statt die Menge zu benennen.

Die Menge hat einen Namen. Zero-Width Space (U+200B), Zero-Width Non-Joiner (U+200C), Zero-Width Joiner (U+200D), weicher Trennstrich (U+00AD) und Byte Order Mark (U+FEFF) tragen laut der Unicode Character Database alle General_Category=Cf, und diese Zuordnungen sind über viele Versionen des Standards hinweg stabil geblieben. Das geschützte Leerzeichen bei U+00A0 gehört nicht dazu: Es ist Zs, ein Space Separator – weshalb ein reines Kategorie-Strippen das häufigste Textverarbeitungs-Artefakt unangetastet lässt.

Normalisieren, Strippen, Reduzieren, Trimmen

Die Lösung ist ein Durchlauf mit vier geordneten Schritten: die Kodierung normalisieren, Formatzeichen entfernen, jede Leerraum-Variante auf ein gewöhnliches Leerzeichen reduzieren und anschließend trimmen.

const FORMAT_CHARS = /[\p{Cf}--[\u200C\u200D]]/gv;
const SPACE_RUN = /[\p{Zs}\t\n\r]+/gu;

export function cleanPastedText(input) {
  return input
    .normalize("NFC")               // one canonical spelling per character
    .replace(FORMAT_CHARS, "")      // BOM, zero-width space, soft hyphen, bidi controls
    .replace(SPACE_RUN, " ")        // NBSP, thin spaces, tabs, newlines -> one space
    .trim();
}

Entfernen Sie \n\r aus SPACE_RUN, wenn das Feld eine Textarea ist, in der Zeilenumbrüche zum Inhalt gehören.

Zwei Dinge verdienen hier besondere Beachtung. \p{...} trägt seine Unicode-Bedeutung nur, wenn das Regex im Unicode-fähigen Modus läuft; lassen Sie sowohl u als auch v weg, liest die Engine \p als maskiertes Literal p – das Pattern kompiliert, läuft und matcht nichts von dem, was Sie beabsichtigt hatten. Und der Reduzierungsschritt nutzt eine explizite Klasse auf Basis von \p{Zs} statt \s, damit an der Aufrufstelle klar ist, dass U+00A0 abgedeckt ist.

Die Reihenfolge ist entscheidend. normalize("NFC") löst Kodierungsfragen, nicht Unsichtbarkeit, und lässt ein Zero-Width Space daher für den Strip-Schritt stehen. Vor dem Reduzieren zu strippen bedeutet, dass das Byte Order Mark gelöscht und nicht in ein überflüssiges Leerzeichen umgewandelt wird. Und das Trimmen zum Schluss fängt das führende Leerzeichen ab, das zurückbleibt, wenn ein entferntes Formatzeichen daneben stand.

Was Sie nicht entfernen dürfen

Ein blindes Strippen sämtlicher Formatzeichen beschädigt echten Inhalt. U+200D ZERO WIDTH JOINER ist das Zeichen, das Emojis zu einem einzigen Glyphen verbindet: Im Unicode-Emoji-Standard macht gerade die Anwesenheit eines Joiners einen String überhaupt erst zu einer Emoji-ZWJ-Sequenz. Nimmt man die Joiner heraus, bleiben mehrere Glyphen zurück, wo zuvor einer war.

const family = "👨‍👩‍👧";

console.log([...family].length);                          // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length);  // 3 -> 👨👩👧

Auch außerhalb von Emojis sind Joiner keine Dekoration. Im Arabischen setzen Schreibende einen Non-Joiner zwischen zwei Buchstaben, um zu verhindern, dass diese in der kursiven Schreibweise zusammenlaufen, und die Unicode Core Specification warnt, dass um diese Steuerzeichen bereinigter Text entweder etwas anderes aussagt oder gar keinen Sinn mehr ergibt. Im Devanagari wählt ein ZWJ nach einem Virama die Halbform eines Konsonanten statt der vollständigen Ligatur. Die Regel lautet: Entfernen Sie die unsichtbaren Zeichen, die in Ihrem Feld keine Bedeutung tragen, und behalten Sie diejenigen, die strukturelle Arbeit leisten.

Genau das drückt [\p{Cf}--[\u200C\u200D]] aus: Das v-Flag ergänzt Mengenoperatoren für Zeichenklassen, und -- ist derjenige, der subtrahiert. In einer Laufzeitumgebung ohne unicodeSets lautet das Äquivalent im u-Modus /(?![\u200C\u200D])\p{Cf}/gu. Setzen Sie nicht beide Flags an einem Regex; sie schließen sich gegenseitig aus.

Warum sollten Sie NFC und nicht NFKC verwenden?

NFC löst die beiden Möglichkeiten auf, wie Unicode dasselbe Zeichen schreiben kann, und ändert sonst nichts. NFKC geht weiter und schreibt Kompatibilitätszeichen um: Die ff-Ligatur wird zu zwei f, und ein eingekreistes Ⓓ wird zu einem einfachen D, wie die normalize()-Beispiele zeigen. Das ist eine inhaltsverändernde Entscheidung, die im Unicode-Normalisierungs-Annex als Kompatibilitätsäquivalenz statt kanonischer Äquivalenz definiert ist – und sie sollte bewusst getroffen und nicht als Nebeneffekt der Bereinigung eines Formularfelds mitgeschleppt werden.

Eine verwandte Falle: Das Löschen eines weichen Trennstrichs ist Aufgabe Ihres Cleaners, nicht von normalize("NFKC"). Die Unicode-Operation, die U+00AD auf einen leeren String abbildet, ist NFKC_Casefold – eine andere Transformation als die, die String.prototype.normalize("NFKC") durchführt.

Führen Sie den Cleaner auch auf dem Server aus

Die Bereinigung im Browser ist eine Gefälligkeit gegenüber der tippenden Person; die Normalisierung, auf die sich Ihre Datenbank und Ihr Suchindex verlassen, muss auf dem Server laufen, denn ein Request kann abgesetzt werden, ohne dass Ihre Seite jemals geladen wurde. Alles, was über eine öffentliche API, einen CSV-Import, einen Webhook oder einen Mobile Client eintrifft, umgeht den Input-Handler vollständig, und eine einzige unbereinigte Zeile genügt, damit ein Unique Constraint oder eine Exact-Match-Abfrage sich inkonsistent verhält. Dieselbe Funktion läuft in Node wie im Browser – setzen Sie sie also an der Grenze ein, an der Daten in die Persistenz gelangen, und lassen Sie den clientseitigen Aufruf das schnelle Feedback sein, nicht die Garantie.

Hören Sie auf, Code Points zu einer Zeichenklasse hinzuzufügen, und beginnen Sie damit, die Kategorie zu benennen. Vier Zeilen, an jedem Eingangspunkt angewendet, entfernen die unsichtbaren Zeichen, die in Ihrem Feld keine Bedeutung tragen, und lassen jene stehen, die echten Text zusammenhalten. Schreiben Sie eine Fixture, die einen kombinierenden Akzent, ein geschütztes Leerzeichen, einen weichen Trennstrich, ein Byte Order Mark, ein Zero-Width Space und ein ZWJ-Emoji mischt, und prüfen Sie dann, dass Ihr Cleaner Ersteres komponiert, das Zweite zu einem einfachen Leerzeichen reduziert, die nächsten drei löscht und das Emoji unverändert zurückgibt.

FAQs

Entfernt trim ein geschütztes Leerzeichen oder ein Zero-Width Space?

trim entfernt Leerraum und Zeilenendezeichen ausschließlich an beiden Enden, und die ECMAScript-Leerraumliste umfasst das geschützte Leerzeichen U+00A0 und das Byte Order Mark U+FEFF – beide verschwinden also an den Rändern. Es entfernt niemals das Zero-Width Space U+200B, das ein Formatzeichen und kein Leerraum ist, und es rührt keines dieser Zeichen in der Mitte eines Strings an.

Entfernt ein Strippen von Formatzeichen auch Emoji-Variation-Selectors wie U+FE0F?

Nein. Die Variation Selectors U+FE00 bis U+FE0F tragen die General_Category Mn, also Nonspacing Mark, und nicht Cf. Ein Strippen der Formatkategorie lässt sie daher stehen, und Emojis behalten ihre beabsichtigte Darstellung. Nur die Joiner benötigen eine Ausnahmeliste. Einen Cleaner so zu erweitern, dass er auch Marks entfernt, würde Variation Selectors und jeden kombinierenden Akzent gleich mitlöschen.

Entfernt das Strippen von Formatzeichen auch Right-to-Left-Override-Zeichen?

Ja. Die bidirektionalen Steuerzeichen, darunter U+200E, U+200F, die Embeddings und Overrides von U+202A bis U+202E sowie die Isolates von U+2066 bis U+2069, tragen alle die General_Category Cf. Ein einziger Kategorie-Match entfernt sie also zusammen mit dem Zero-Width Space. Das ist relevant für Anzeigenamen und Dateinamen, wo ein Right-to-Left Override gerenderten Text umkehrt und eine Dateiendung verschleiern kann.

Warum scheitert ein eingefügter Wert an maxlength, obwohl er kurz genug aussieht?

maxlength zählt UTF-16-Code-Units, nicht sichtbare Zeichen – jeder unsichtbare Mitreisende verbraucht also Budget: Ein Byte Order Mark oder ein Zero-Width Space kostet eine Unit, und ein aus einem Surrogate Pair aufgebautes Emoji kostet zwei. Bereinigen Sie den Wert, bevor Sie seine Länge validieren, und zählen Sie über die Spread-Form, wenn das Limit dem entsprechen soll, was Nutzer sehen.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.