Normalization¶
Unicode normalization ensures that equivalent sequences of characters are represented identically. disarm provides fast normalization using the Rust unicode-normalization crate.
Why normalize?¶
The same visible text can have multiple Unicode representations:
# These look identical but are different byte sequences:
a = "\u00e9" # U+00E9 (precomposed)
b = "\u0065\u0301" # U+0065 U+0301 (decomposed: e + combining acute)
assert (a == b) == False
Normalization resolves this by converting to a canonical form.
Normalization forms¶
| Form | Name | Description |
|---|---|---|
| NFC | Canonical Decomposition + Composition | Precomposed characters. Most common for storage and comparison. |
| NFD | Canonical Decomposition | Decomposed characters. Useful for accent stripping. |
| NFKC | Compatibility Decomposition + Composition | Like NFC but also normalizes compatibility characters (fi→fi, ²→2). |
| NFKD | Compatibility Decomposition | Like NFD with compatibility decomposition. |
Basic usage¶
from disarm import normalize
# NFC: compose into single codepoints
assert normalize("e\u0301") == "é"
# NFD: decompose into base + combining marks
assert normalize("é", form="NFD") == "é"
# NFKC: compatibility + compose
assert normalize("finance", form="NFKC") == "finance"
assert normalize("2²", form="NFKC") == "22"
# NFKD: compatibility + decompose
assert normalize("fi", form="NFKD") == "fi"
use disarm::api::{self, NormalizationForm};
// NFC: compose into single codepoints
assert_eq!(api::normalize("e\u{0301}", NormalizationForm::Nfc), "é");
// NFD: decompose into base + combining marks
assert_eq!(api::normalize("é", NormalizationForm::Nfd), "é");
// NFKC: compatibility + compose
assert_eq!(api::normalize("finance", NormalizationForm::Nfkc), "finance");
// NFKD: compatibility + decompose
assert_eq!(api::normalize("fi", NormalizationForm::Nfkd), "fi");
require "disarm"
# form: is :nfc (default), :nfd, :nfkc, or :nfkd
Disarm.normalize("finance", form: :nfkc) # => "finance"
Disarm.normalize("2²", form: :nfkc) # => "22"
Disarm.normalize("fi", form: :nfkd) # => "fi"
import { normalize } from 'disarm'
normalize('finance', { form: 'NFKC' }) // => 'finance'
normalize('2²', { form: 'NFKC' }) // => '22'
normalize('fi', { form: 'NFKD' }) // => 'fi'
Checking normalization¶
Test whether a string is already in a given form without performing the full normalization:
from disarm import is_normalized
assert is_normalized("hello") == True
assert is_normalized("é", form="NFC") == True
assert is_normalized("é", form="NFD") == False
assert is_normalized("e\u0301", form="NFD") == True
use disarm::api::{self, NormalizationForm};
assert_eq!(api::is_normalized("hello", NormalizationForm::Nfc), true);
assert_eq!(api::is_normalized("é", NormalizationForm::Nfc), true);
assert_eq!(api::is_normalized("é", NormalizationForm::Nfd), false);
assert_eq!(api::is_normalized("e\u{0301}", NormalizationForm::Nfd), true);
Disarm.normalized?("hello") # => true
Disarm.normalized?("fi", form: :nfkc) # => false
import { isNormalized } from 'disarm'
isNormalized('hello') // => true
isNormalized('fi', { form: 'NFKC' }) // => false
The NF enum¶
For programmatic use, the NF enum provides the four forms:
from disarm import NF, normalize
assert normalize("fi", form=NF.KC.value) == "fi"
| Member | Value |
|---|---|
NF.C |
"NFC" |
NF.D |
"NFD" |
NF.KC |
"NFKC" |
NF.KD |
"NFKD" |
Stream-Safe Text Format¶
UAX #15 defines a bound on how many non-starters may follow one starter — 30 — so that
text can be processed in fixed-size buffers without a normalization boundary landing
inside one. stream_safe() enforces it by inserting U+034F COMBINING GRAPHEME JOINER.
from disarm import stream_safe, is_normalized_stream_safe, normalize
assert stream_safe("Hello world") == "Hello world"
long_stack = "a" + "\u0301" * 40
assert "\u034f" in stream_safe(long_stack)
This is an interoperability primitive. Three things it is not:
Not canonically equivalent. It inserts a character, so the output is a different
string and normalizes differently. Never build a comparison key from it — use
search_key() or canonicalize().
assert stream_safe(long_stack) != long_stack
assert normalize(stream_safe(long_stack), form="NFC") != normalize(long_stack, form="NFC")
Not a zalgo control. Thirty non-starters is far above anything a reader would call
stacking abuse, and this function makes no judgement about whether text is abusive.
strip_zalgo() answers that question, with a different bound and a different purpose.
Ordinary stacking abuse passes straight through:
zalgo = "a" + "\u0301" * 8
assert stream_safe(zalgo) == zalgo # under the bound
Legitimate stacking is nowhere near it either — Hebrew points, Arabic harakat and Indic conjuncts are all untouched.
Not a size bound. The presets already cap produced output; stream_safe() does not
change how much text a call can return.
The predicate is a conjunction¶
is_normalized_stream_safe(text, form=...) answers "is this normalized and
stream-safe", not "is this stream-safe". That is what the underlying Unicode predicate
computes, and the name says so rather than leaving it to be discovered:
assert not is_normalized_stream_safe("e\u0301") # stream-safe, but not NFC
assert is_normalized_stream_safe(normalize("e\u0301", form="NFC"))
When to use which form¶
- NFC — Default for most applications. Store and compare text in NFC.
- NFD — Use when you need to manipulate combining marks (e.g.,
strip_accents()uses NFD internally). - NFKC — Use for search indexes and text matching where fi should match fi.
- NFKD — Use for deep decomposition before further processing.
Performance¶
Normalization is implemented in Rust via the unicode-normalization crate. Strings that are already in the target form are detected quickly via is_normalized() without allocation.