Natural language processing
Unicode, Normalization, and the Boundaries of Text
Understand Unicode encoding, normalization, casing, whitespace, markup, and boundary decisions that shape every later NLP stage.
By the end you can
- Explain why visually identical text may have different Unicode representations
- Distinguish canonical normalization from task-specific text cleaning
- Preserve information needed for offsets, auditing, and reconstruction
- Design normalization tests for multilingual and user-generated text
Key idea
“Looks identical” is not a stable data type
Two strings can render the same glyph while using different code-point sequences. An accented character may be stored as one code point, or as a base character followed by a combining mark.
Software that compares raw bytes will then treat visually identical names as different. Aggressive normalization has the opposite failure: it collapses distinctions that matter for identifiers, transliteration, or legal evidence.
The rest of this lesson is mostly a list of the occasions on which that policy was left implicit and something broke. A compiler and a human read two different programs out of one file: CVE-2021-42574, CVSS 3.1 base score 8.3, HIGH. A text-layout engine shipped in four Apple operating systems until February 2018: CVE-2018-4124. A username canonicalizer handed Spotify accounts to strangers in June 2013. None of those was a modelling failure. Each was a decision about which strings count as the same string, taken by default rather than on purpose.
Normalization is a policy about equivalence, not a universal cleaning command.
Visual
From bytes to what a reader sees
These layers explain why character counts, offsets, and display can disagree.
The bottom layer has a specification and a date. RFC 3629, from November 2003, defines UTF-8 and confines it to the range U+0000 to U+10FFFF: “In UTF-8, characters from the U+0000..U+10FFFF range (the UTF-16 accessible range) are encoded using sequences of 1 to 4 octets”. The first octet of a multi-octet sequence says how many octets are in the sequence. One code point is therefore anywhere between one and four bytes.
The layer above it is still not the reader's layer. Unicode calls a user-perceived character a grapheme cluster, and treats the cluster as the programmatic approximation of it. Unicode Standard Annex #29 gives the smallest possible demonstration: “For example, “G” + grave-accent is a user-perceived character: users think of it as a single character, yet is actually represented by two Unicode code points.” The W3C supplies two more from other writing systems. The Vietnamese ề is 3 code points. The Bangla ksha is 3 code points. Byte length, code-point count and what a reader perceives as a character are three separate numbers. A system that uses one where it means another has a bug it will not see until the text stops being English.
The top layer, shaping and rendering, has failure modes of its own, and one of them reached a security bulletin. iOS 11.2.6 shipped on February 19, 2018 with a CoreText fix, credited to an anonymous researcher, under CVE-2018-4124. Apple's impact line is one sentence: “Impact: Processing a maliciously crafted string may lead to heap corruption”. NIST's NVD entry, published 2018-04-03, says what the string contained: a certain Telugu character, which let remote attackers cause a denial of service in iOS before 11.2.6, macOS before 10.13.3 Supplemental Update, tvOS before 11.2.6 and watchOS before 4.2.3. One cluster, in one script, took down four shipping operating systems — at a layer that never appears in a tokenizer's unit tests.
1. Encoded bytes
UTF-8 or another encoding maps bytes to Unicode code points.
2. Code-point sequence
The string contains abstract characters, combining marks, controls, and punctuation.
3. Grapheme clusters
User-perceived characters may contain several code points.
4. Shaping and rendering
Fonts and writing systems determine the visible glyph sequence.
Comparison
Operations that should not be confused
Each transformation is reversible to a different degree, and carries a different risk profile.
There are exactly four Unicode normalization forms, and no more. NFD is canonical decomposition. NFC is canonical decomposition followed by canonical composition. NFKD is compatibility decomposition, and NFKC is compatibility decomposition followed by canonical composition. The K is the letter to watch. Canonical equivalence is the strong relation: it holds between sequences that represent the same abstract character and that should be treated and displayed as identical. Compatibility equivalence is deliberately weaker. It can hold between characters whose visual appearance or behaviour differs — superscripts, circled letters, full-width forms.
That NFKC is a matching aid and not a storage format is not this lesson's editorial opinion. It is printed in Unicode Standard Annex #15, the annex that defines the forms: “Normalization Forms KC and KD must not be blindly applied to arbitrary text. Because they erase many formatting distinctions, they will prevent round-trip conversion to and from many legacy character sets, and unless supplanted by formatting markup, they may remove distinctions that are important to the semantics of the text.” Note what is being conceded. The loss is not a bug in an implementation. It is the definition working correctly.
A second standards body wrote the same warning into a protocol. The IETF's PRECIS framework, RFC 8264 of October 2017, sets the default for strings compared on the wire: “In accordance with [RFC5198], Normalization Form C (NFC) is RECOMMENDED”. It warns in the same section that “certain Unicode normalization forms, especially NFKC and NFKD, can result in significant loss of information in various circumstances”. One body defines the forms, the other specifies how strings are compared; they chose the same default and printed the same caution. Casing, cleaning and transliteration sit outside both documents entirely, which is precisely why they need a policy of their own.
Unicode normalization
Canonical or compatibility rules standardize certain equivalent sequences.
- Examples: NFC or NFKC
- Useful for consistent matching
- Compatibility forms may collapse distinctions
- Record effects on offsets
Case normalization
Transforms uppercase and lowercase using language-sensitive rules.
- Useful for some search tasks
- Can erase named-entity cues
- Not uniform across languages
- Compare with cased baselines
Text cleaning
Removes or rewrites markup, controls, boilerplate, or noise.
- Highly task-specific
- May destroy evidence
- Requires allowlists and tests
- Preserve the raw source
Transliteration
Maps writing systems or spelling conventions into another representation.
- Can improve matching
- Often many-to-one
- May hide identity distinctions
- Never replace original text
Example
Small preprocessing choices with large consequences
The safest policy is rarely “strip everything unusual.” Each of the first three cases below has a CVE number, a vendor document or a Unicode data file behind it. That is the difference between a preprocessing rule you can defend and one you inherited.
- Security logs and source repositories: source code can be encoded so that a compiler and a reader see two different programs. That is the claim the Trojan Source paper opens with, in 2021: “We present a new type of attack in which source code is maliciously encoded so that it appears different to a compiler and to the human eye.” Nicholas Boucher and Ross Anderson wrote it. The bidirectional-override attack became CVE-2021-42574, CVSS 3.1 base score 8.3, HIGH; the homoglyph attack became CVE-2021-42694. The Rust Security Response WG shipped Rust 1.56.1 on 2021-11-01 with two deny-by-default lints covering nine code points: U+202A, U+202B, U+202C, U+202D, U+202E, U+2066, U+2067, U+2068 and U+2069. A sweep of crates.io had found only 5 crates containing them, none malicious. Removing invisible directional controls is a display-safety decision. The forensic copy still needs the raw sequence, because the raw sequence is the evidence.
- Social media: one emoji on the screen can be seven code points underneath. Unicode's data file of emoji ZWJ sequences for Emoji Version 17.0 (header dated 2025-01-08) lists 1,614 RGI sequences — 411, 438, 628, 72 and 65 elements across its sections — whose components are joined by U+200D ZERO WIDTH JOINER. Emojipedia states the mechanism plainly: “An Emoji ZWJ Sequence is a combination of multiple emojis which display as a single emoji on supported platforms. These sequences are joined with a Zero Width Joiner character.” The sequence 1F9D1 1F3FF 200D 1FAEF 200D 1F9D1 1F3FB (“people wrestling: dark skin tone, light skin tone”) is seven code points, two of them joiners, rendering as one emoji. A naive character count returns seven. A naive truncation at the wrong index returns two separated people and a stray joiner.
- Turkish text: this is not a footnote. It is an exception written into the Unicode Character Database. SpecialCasing-17.0.0.txt, dated 2025-07-31, carries locale-tagged rules under the heading “Turkish and Azeri”. For the tr and az locales they map U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE down to U+0069, and U+0049 LATIN CAPITAL LETTER I down to U+0131, the dotless i. Microsoft's .NET guidance names the consequence: “Subtle and not so subtle bugs can emerge when non-linguistic string data is interpreted linguistically, or when string data from a particular culture is interpreted using the conventions of another culture. The canonical example is the Turkish-I problem.” The same page shows IsFileURI("file:") returning true under US English and false under Turkish, so a case-insensitive block on URIs beginning with "FILE:" can be bypassed on Turkish systems. It documents the parallel "Bill"/"BILL" StartsWith divergence too. Casing is a locale-dependent operation that happens to be spelled like a locale-independent one.
- Source code: collapsing whitespace can change indentation-sensitive meaning and line-column references.
- Clinical notes: replacing all digits can erase dosages, dates, measurements, and device identifiers.
Offsets are part of the data contract
Extraction systems often return character spans that another application highlights in the original document. If normalization changes string length, a span measured on processed text points to the wrong location. The units are not interchangeable either. The Vietnamese ề from the W3C's worked example is 3 code points and a single perceived character, so a span counted in one unit and applied in the other is silently wrong rather than loudly broken.
A robust pipeline either preserves a reversible alignment map or predicts spans against a stable representation. Raw documents, normalized views, and token offsets need explicit versioned relationships. The version matters as much as the map. NormalizationTest-17.0.0.txt and SpecialCasing-17.0.0.txt both carry a Unicode version in their names, and an offset computed under one release of the data files is a claim about that release.
Never apply a token offset directly to a differently normalized string.
Analogy
Restoring a historical manuscript
A conservator creates a readable edition while preserving photographs of the original manuscript. Smudges may be clarified, but every intervention is documented and reversible.
A conservator intervenes by hand. Normalization runs automatically across billions of strings. A convenient reading copy should not erase the evidentiary source. Apply the analogy to the cases above and the asymmetry is obvious: the bidirectional controls behind CVE-2021-42574 are exactly what a display pipeline wants to remove, and exactly what an investigator needs kept.
Keep raw text immutable and treat normalized views as derived artifacts.
Steps
Build a normalization test suite before scaling
A dozen carefully chosen strings can expose more risk than millions of ordinary examples. For normalization itself, tens of thousands of them have already been chosen and published.
Unicode ships the file. NormalizationTest-17.0.0.txt, dated 2025-06-30, contains 20,034 test lines in six parts: Part0 specific cases (45), Part1 character-by-character (17,086), Part2 canonical order (1,936), Part3 PRI #29 (194), Part4 canonical closures excluding Hangul (735) and Part5 chained primary composites (38). Each line gives five columns — source; NFC; NFD; NFKC; NFKD. The CONFORMANCE section of the header states the terms: “1. The following invariants must be true for all conformant implementations”.
That file is not a document to admire from a distance. CPython's own test suite downloads it and asserts c2 == NFC(c1) == NFC(c2) == NFC(c3), line by line, together with the parallel NFD, NFKC and NFKD invariants. That is what CPython treats as the floor, not the ceiling.
So step 1 below begins as a download rather than an act of invention. The equivalence pairs you still have to write yourself are the ones the Consortium cannot know: your users' names, your identifiers, your markup, your locales. The tr and az casing rules. The ZWJ sequences your product actually receives. The directional controls your ingest is about to strip. Those go into the same harness, and they run on every release of the data files, not once.
1. Collect equivalence pairs
Include composed and decomposed forms, full-width symbols, and mixed normalization.
2. Cover writing systems
Test scripts, diacritics, right-to-left text, punctuation, and language-specific casing.
3. Preserve round-trip evidence
Verify raw storage, transformation logs, and offset mappings.
4. Test task behavior
Compare search, classification, and extraction before and after normalization.
5. Monitor new code points
Track controls, replacement characters, malformed bytes, and parser failures.
Write the policy as transformations with reasons
A production policy should name encoding assumptions, normalization form, markup handling, casing, whitespace rules, URL treatment, and invalid-input behavior. Each rule needs a reason tied to a task or safety requirement.
What it costs to leave one of those unwritten has a date and a company attached. In June 2013 Spotify accounts were hijacked through the password-reset flow, one of them a support-forum manager's, and the company disabled account creation for hours. Spotify's engineering blog published the account on 18 June 2013. Its worked example is the username ᴮᴵᴳᴮᴵᴿᴰ — U+1D2E U+1D35 U+1D33 U+1D2E U+1D35 U+1D3F U+1D30 — which twisted's XMPP nodeprep.prepare canonicalized to 'BIGBIRD' on the first application and 'bigbird' on the second. The post says explicitly that the account 'bigbird' was not among the attacked accounts; it was picked as an example name. Mikael Goldmann's diagnosis is one sentence: “We were relying on nodeprep.prepare being idempotent, and it wasn’t.” Registration reopened behind a hand-rolled fixpoint check, X == canonical_username(X), and the permanent fix rejected any name where old_prepare(old_prepare(name)) != old_prepare(name).
The root cause sat upstream of Spotify, in an unwritten rule of exactly the kind this section asks for. The nodeprep draft fixes its input and output repertoire at Unicode 3.2 but never tells implementers to validate the input. The ᴮᴵᴳᴮᴵᴿᴰ code points are outside Unicode 3.2. The IETF eventually reached the same verdict about the whole framework and replaced Stringprep with PRECIS — first published as RFC 7564 in 2015, current version RFC 8264, October 2017 — giving the reason in its own words: “most significantly, Stringprep was tied to Unicode version 3.2”. A canonicalizer is a versioned dependency. Idempotence under it is a property to be tested, not assumed.
This detail makes training and inference consistent. It also lets future teams reverse a harmful choice instead of reconstructing it from scattered code.
Text preprocessing is versioned software, not an invisible prelude to modeling.
Key takeaways
- Unicode separates encoded bytes, code points, grapheme clusters, and rendered glyphs: RFC 3629 (November 2003) encodes U+0000..U+10FFFF in sequences of 1 to 4 octets, while Unicode Standard Annex #29 gives “G” + grave-accent as one user-perceived character made of two code points.
- Canonical and compatibility normalization are not interchangeable: Unicode Standard Annex #15 says Forms KC and KD must not be blindly applied to arbitrary text, and RFC 8264 makes Normalization Form C the RECOMMENDED rule for protocol strings.
- Invisible and confusable characters are an attack surface with case numbers: Boucher and Anderson's Trojan Source produced CVE-2021-42574 (CVSS 3.1 base score 8.3, HIGH) and CVE-2021-42694, answered by Rust 1.56.1 on 2021-11-01 with two deny-by-default lints over nine code points.
- Casing and character counting are locale- and script-dependent: SpecialCasing-17.0.0.txt maps U+0049 down to U+0131 for the tr and az locales, and 1,614 RGI emoji ZWJ sequences make one displayed emoji out of as many as seven code points.
- Raw text should remain immutable while normalized views, alignment maps, and their transformation versions are preserved — rendering itself can fail, as CVE-2018-4124 did across iOS, macOS, tvOS and watchOS.
- Normalization is testable and mostly already tested: NormalizationTest-17.0.0.txt ships 20,034 lines that CPython runs line by line, and the cost of skipping the written policy was Spotify's June 2013 account hijacking through a nodeprep.prepare that was not idempotent.