Unicode Text Segmentation (UAX #29)
"Split on whitespace" is the most common text-processing bug. It fails for CJK (no spaces between words), Thai/Lao (no spaces between words), German compounds, and emoji sequences. Unicode Annex #29 defines the correct algorithms.
Three Boundary Types
| Boundary | Purpose | Example |
|---|---|---|
| Grapheme cluster | What users perceive as one "character" | 👨👩👧 = 1 grapheme (5 code points) |
| Word | Tokenization, cursor movement, double-click selection | "don't" = 1 word or 3 segments? |
| Sentence | Sentence counting, capitalization | "Dr. Smith went to D.C." = 1 sentence |
Grapheme Cluster Boundaries
A grapheme cluster is the smallest unit a user perceives as a single character. UAX #29 defines break rules using character properties:
Do NOT break:
- Between a base character and combining marks (e + ◌́ = é)
- Between Regional Indicator pairs (🇯 + 🇵 = 🇯🇵)
- In emoji ZWJ sequences (👨 + ZWJ + 👩 + ZWJ + 👧 = 👨👩👧)
- Between Hangul jamo in a syllable block
- Between CR and LF
DO break:
- Between every other pair of characters
Implementation:
// Correct: Intl.Segmenter (UAX #29 compliant)
function graphemes(str) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(str)].map(s => s.segment);
}
graphemes("👨👩👧👦") // ["👨👩👧👦"] — 1 grapheme
graphemes("café") // ["c", "a", "f", "é"] — 4 graphemes (NFC)
// Incorrect: spread operator (splits on code points, not graphemes)
[..."👨👩👧👦"] // ["👨", "", "👩", "", "👧", "", "👦"] — 7 code points
Word Boundaries
Word segmentation is language-dependent:
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
const words = [...segmenter.segment("I can't believe it's 3:30pm")]
.filter(s => s.isWordLike)
.map(s => s.segment);
// ["I", "can't", "believe", "it's", "3:30pm"]
// For CJK: requires dictionary-based segmentation
const zhSegmenter = new Intl.Segmenter('zh', { granularity: 'word' });
const zhWords = [...zhSegmenter.segment("今天天气很好")]
.filter(s => s.isWordLike)
.map(s => s.segment);
// ["今天", "天气", "很", "好"] (dictionary-based)
For CJK languages, Intl.Segmenter uses ICU's dictionary-based word breaking. Without it, naive character-counting gives wrong word counts for Chinese, Japanese, and Thai.
Sentence Boundaries
Sentence boundary detection must handle abbreviations, decimal numbers, and ellipsis:
"Dr. Smith earned $3.5M in Q4. Impressive!"
→ 2 sentences (not 4, despite 4 periods)
Rules:
- Don't break after abbreviations (Dr., Mr., U.S., etc.)
- Don't break inside numbers (3.14, $1,000.00)
- Don't break inside ellipsis (...)
- DO break after sentence-terminal punctuation followed by uppercase
Locale-Aware Case Mapping
The Turkic I Problem
In most languages, i uppercases to I and I lowercases to i. In Turkish and Azerbaijani:
Turkish rules:
i → İ (U+0130, LATIN CAPITAL LETTER I WITH DOT ABOVE)
I → ı (U+0131, LATIN SMALL LETTER DOTLESS I)
English rules:
i → I
I → i
This is why "FILE".toLowerCase() gives different results in tr_TR locale vs en_US:
// JavaScript: locale-sensitive
"FILE".toLocaleLowerCase('tr') // "fıle" (dotless i)
"FILE".toLocaleLowerCase('en') // "file"
// Python: casefold() is locale-independent
"straße".casefold() // "strasse" (German ß → ss)
"straße".lower() // "straße" (lower() preserves ß)
Case Mapping is Not 1:1
| Operation | Input | Output | Code points |
|---|---|---|---|
| Uppercase | ß | SS | 1 → 2 |
| Uppercase | fi | FI | 1 → 2 |
| Lowercase | İ | i̇ | 1 → 2 (in non-Turkic) |
| Titlecase | dž | Dž | 1 → 1 (special titlecase form) |
This means str.length can change after case conversion:
text = "straße"
print(len(text)) # 6
print(len(text.upper())) # 7 ("STRASSE")
print(len(text.casefold())) # 7 ("strasse")
Case-Insensitive Comparison
The correct algorithm for case-insensitive string comparison (per Unicode Standard):
1. Normalize to NFD
2. Apply case folding (toCasefold mapping)
3. Normalize to NFD again (case folding may introduce decomposable characters)
4. Compare code point by code point
Python's str.casefold() performs step 2. For full correctness:
import unicodedata
def case_insensitive_equal(a, b):
def normalize(s):
s = unicodedata.normalize('NFD', s)
s = s.casefold()
s = unicodedata.normalize('NFD', s)
return s
return normalize(a) == normalize(b)
case_insensitive_equal("straße", "STRASSE") # True
case_insensitive_equal("file", "FILE") # True (compatibility casefold)
Programming Case Conventions: The Algorithm
Converting between naming conventions (camelCase, snake_case, kebab-case) requires proper word boundary detection:
import re
def split_identifier(name: str) -> list[str]:
"""Split an identifier into word components.
Handles: camelCase, PascalCase, snake_case, kebab-case,
SCREAMING_SNAKE, dot.case, and mixed patterns like HTMLParser.
"""
# Insert boundary before uppercase letters following lowercase
# But keep consecutive uppercase together (HTML → [HTML] not [H,T,M,L])
name = re.sub(r'([a-z])([A-Z])', r'\1_\2', name)
name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', name)
# Split on non-alphanumeric characters
return [w for w in re.split(r'[_\-.\s/]+', name) if w]
def to_camel(words: list[str]) -> str:
return words[0].lower() + ''.join(w.capitalize() for w in words[1:])
def to_snake(words: list[str]) -> str:
return '_'.join(w.lower() for w in words)
def to_kebab(words: list[str]) -> str:
return '-'.join(w.lower() for w in words)
# Examples:
split_identifier("HTMLParser") # ["HTML", "Parser"]
split_identifier("getHTTPResponse") # ["get", "HTTP", "Response"]
split_identifier("my-kebab-case") # ["my", "kebab", "case"]
Readability Metrics: Mathematical Foundations
Flesch-Kincaid Grade Level
FKGL = 0.39 × (total words / total sentences)
+ 11.8 × (total syllables / total words)
− 15.59
Interpretation: the US school grade level required to understand the text. A score of 8.0 means an 8th grader can understand it.
Flesch Reading Ease
FRE = 206.835
− 1.015 × (total words / total sentences)
− 84.6 × (total syllables / total words)
| Score | Difficulty | Typical audience |
|---|---|---|
| 90–100 | Very easy | 5th grader |
| 60–70 | Standard | 8th–9th grader |
| 30–50 | Difficult | College |
| 0–30 | Very difficult | College graduate |
Coleman-Liau Index
Unlike Flesch-Kincaid, Coleman-Liau uses character counts instead of syllable counts, making it easier to compute programmatically:
CLI = 0.0588 × L − 0.296 × S − 15.8
Where:
L = average number of characters per 100 words
S = average number of sentences per 100 words
Limitations of Readability Formulas
These metrics measure surface complexity, not comprehension difficulty:
- "Quantum entanglement violates Bell's inequality" scores as easy (short words, simple structure)
- "The implementation of the aforementioned regulatory requirements necessitates..." scores as hard (long words) despite being conceptually simple
They also fail for:
- Non-English languages: syllable counting rules differ; CJK has no syllables in the same sense
- Technical content: domain jargon is short but conceptually dense
- Lists and code: structural formatting distorts sentence/word ratios
Syllable Counting Algorithm
English syllable counting is an approximation (English spelling is not phonetically regular):
def count_syllables(word: str) -> int:
word = word.lower().strip()
if len(word) <= 3:
return 1
# Remove silent e
if word.endswith('e') and not word.endswith('le'):
word = word[:-1]
# Count vowel groups
vowels = 'aeiouy'
count = 0
prev_vowel = False
for char in word:
is_vowel = char in vowels
if is_vowel and not prev_vowel:
count += 1
prev_vowel = is_vowel
return max(1, count)
# Approximation accuracy: ~85% for English
# For production use: CMU Pronouncing Dictionary or phonetic lookup
Slug Generation: Proper Unicode Handling
The Problem Space
A URL slug must contain only ASCII lowercase letters, digits, and hyphens. Converting arbitrary Unicode text requires:
- Normalization: NFKD (compatibility decomposition) separates base characters from combining marks
- Transliteration: Map non-ASCII to ASCII equivalents
- CJK romanization: Convert ideographs to romanized forms
- Cleanup: Remove remaining non-ASCII, collapse separators
NFKD-Based Transliteration
import unicodedata
import re
def slugify(text: str, max_length: int = 80) -> str:
# NFKD decomposition separates accents from base characters
text = unicodedata.normalize('NFKD', text)
# Remove combining marks (accents, diacritics)
text = ''.join(c for c in text if not unicodedata.combining(c))
# Transliterate common non-ASCII characters
transliterations = {
'ß': 'ss', 'æ': 'ae', 'œ': 'oe', 'ø': 'o',
'đ': 'd', 'ð': 'd', 'þ': 'th', 'ł': 'l',
}
for src, dst in transliterations.items():
text = text.replace(src, dst)
# Lowercase
text = text.lower()
# Replace non-alphanumeric with hyphens
text = re.sub(r'[^a-z0-9]+', '-', text)
# Collapse multiple hyphens and strip from ends
text = re.sub(r'-+', '-', text).strip('-')
# Truncate at word boundary
if len(text) > max_length:
text = text[:max_length].rsplit('-', 1)[0]
return text
slugify("Café au lait — Recipe") # "cafe-au-lait-recipe"
slugify("Ångström unit ≈ 0.1nm") # "angstrom-unit-0-1nm"
slugify("C++ für Anfänger") # "c-fur-anfanger"
CJK Romanization Challenges
Chinese text requires pinyin conversion, which is ambiguous without word segmentation:
"银行" → "yín háng" (bank) or "yín xíng" (walk) depending on context
"长城" → "cháng chéng" (Great Wall)
Japanese requires distinguishing kanji readings:
"東京" → "tōkyō" (place name) — but kanji have multiple readings
"今日" → "kyō" (today) or "konnichi" (this day) depending on context
Production slug generators typically use dictionary-based segmentation + most-common-reading lookup.
Number-to-Words: Cross-Language Complexity
The Short Scale vs Long Scale Problem
| Number | Short scale (US/UK modern) | Long scale (France/Germany) |
|---|---|---|
| 10⁶ | million | million |
| 10⁹ | billion | milliard (billion = 10¹²) |
| 10¹² | trillion | billion |
| 10¹⁵ | quadrillion | billiard |
US English, modern British English, and most programming contexts use the short scale. French, German, Spanish (in Europe), and many other languages use the long scale.
Grammatical Agreement
In English, number-to-words is relatively simple. Other languages require:
French: gender agreement
21 → "vingt et un" (masculine) / "vingt et une" (feminine)
German: inversion and compound words
21 → "einundzwanzig" (one-and-twenty, single compound word)
Russian: case agreement
1 рубль, 2 рубля, 5 рублей (different noun forms for 1, 2-4, 5+)
Japanese: counter words
1本 (ippon), 2本 (nihon), 3本 (sanbon) — counters change pronunciation
The Algorithm (English)
ONES = ['', 'one', 'two', 'three', 'four', 'five',
'six', 'seven', 'eight', 'nine', 'ten',
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
'sixteen', 'seventeen', 'eighteen', 'nineteen']
TENS = ['', '', 'twenty', 'thirty', 'forty',
'fifty', 'sixty', 'seventy', 'eighty', 'ninety']
SCALES = ['', 'thousand', 'million', 'billion', 'trillion']
def number_to_words(n: int) -> str:
if n == 0:
return 'zero'
if n < 0:
return 'negative ' + number_to_words(-n)
parts = []
scale_idx = 0
while n > 0:
chunk = n % 1000
if chunk:
chunk_words = _chunk_to_words(chunk)
if SCALES[scale_idx]:
chunk_words += ' ' + SCALES[scale_idx]
parts.append(chunk_words)
n //= 1000
scale_idx += 1
return ' '.join(reversed(parts))
def _chunk_to_words(n: int) -> str:
"""Convert 1-999 to words."""
if n >= 100:
return ONES[n // 100] + ' hundred' + (
' ' + _chunk_to_words(n % 100) if n % 100 else '')
if n >= 20:
return TENS[n // 10] + ('-' + ONES[n % 10] if n % 10 else '')
return ONES[n]
Currency Formatting
Currency amounts require special handling for the fractional part:
def amount_to_words(amount: float, currency: str = 'USD') -> str:
currencies = {
'USD': ('dollar', 'dollars', 'cent', 'cents'),
'EUR': ('euro', 'euros', 'cent', 'cents'),
'GBP': ('pound', 'pounds', 'penny', 'pence'),
}
singular_main, plural_main, singular_sub, plural_sub = currencies[currency]
# Avoid floating point: work with integer cents
cents = round(amount * 100)
main_part = cents // 100
sub_part = cents % 100
main_unit = singular_main if main_part == 1 else plural_main
main_words = number_to_words(main_part) + ' ' + main_unit
if sub_part == 0:
return main_words
sub_unit = singular_sub if sub_part == 1 else plural_sub
return main_words + ' and ' + number_to_words(sub_part) + ' ' + sub_unit
Word Counting: Harder Than You Think
The Definition Problem
What constitutes a "word"?
| Text | Naive whitespace split | UAX #29 word boundary | User expectation |
|---|---|---|---|
| "don't" | 1 | 1 | 1 |
| "e-mail" | 1 | 3 (e, -, mail) | 1 |
| "3.14" | 1 | 1 | 1 number |
| "今天很好" | 1 | 4 (zh segmentation) | 4 words |
| "rock'n'roll" | 1 | 5 | 1 |
There is no universally correct answer. Word count depends on context:
- Academic essay: "don't" = 1 word
- Scrabble: "don't" = not a valid play
- NLP tokenization: "don't" → ["do", "n't"] (Penn Treebank)
- Search indexing: "rock'n'roll" → ["rock", "n", "roll"]
Production Word Count Algorithm
import re
from typing import NamedTuple
class TextStats(NamedTuple):
characters: int
characters_no_spaces: int
words: int
sentences: int
paragraphs: int
reading_time_minutes: float
def analyze_text(text: str, locale: str = 'en') -> TextStats:
characters = len(text)
characters_no_spaces = len(text.replace(' ', '').replace('\t', '').replace('\n', ''))
# Word count: CJK characters each count as one word
cjk_pattern = re.compile(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]')
cjk_chars = len(cjk_pattern.findall(text))
# Non-CJK words: split on whitespace
non_cjk_text = cjk_pattern.sub(' ', text)
latin_words = len([w for w in non_cjk_text.split() if w.strip()])
words = latin_words + cjk_chars
# Sentences: terminal punctuation followed by space or end
sentences = len(re.findall(r'[.!?。!?]+(?:\s|$)', text)) or 1
# Paragraphs: blocks separated by blank lines
paragraphs = len([p for p in text.split('\n\n') if p.strip()])
# Reading time: 200 wpm for Latin, 300 cpm for CJK
reading_time = (latin_words / 200) + (cjk_chars / 300)
return TextStats(
characters=characters,
characters_no_spaces=characters_no_spaces,
words=words,
sentences=sentences,
paragraphs=paragraphs,
reading_time_minutes=round(reading_time, 1)
)
Line Deduplication at Scale
Algorithm Complexity
For small inputs (< 100K lines), hash-set deduplication is straightforward:
def deduplicate(lines: list[str], ignore_case: bool = False) -> list[str]:
seen = set()
result = []
for line in lines:
key = line.casefold() if ignore_case else line
if key not in seen:
seen.add(key)
result.append(line)
return result
Time complexity: O(n). Space complexity: O(n) for the hash set.
Large-Scale Deduplication
For files too large to fit in memory, probabilistic approaches are used:
Bloom filter: Test set membership with bounded false positive rate, zero false negatives.
import mmh3
from bitarray import bitarray
class BloomFilter:
def __init__(self, size: int, num_hashes: int):
self.size = size
self.num_hashes = num_hashes
self.bits = bitarray(size)
self.bits.setall(0)
def add(self, item: str):
for i in range(self.num_hashes):
idx = mmh3.hash(item, i) % self.size
self.bits[idx] = 1
def might_contain(self, item: str) -> bool:
return all(
self.bits[mmh3.hash(item, i) % self.size]
for i in range(self.num_hashes)
)
External sort + merge: Sort the file on disk, then scan linearly to remove adjacent duplicates. Works for arbitrarily large files with O(1) memory (beyond sort buffer).
# Unix one-liner for deduplication preserving order:
awk '!seen[$0]++' input.txt > output.txt
# Sort-based (doesn't preserve order, but handles huge files):
sort -u input.txt > output.txt
# Sort-based preserving order:
nl input.txt | sort -k2 -u | sort -n | cut -f2- > output.txt
Summary
| Task | Naive approach | Correct approach |
|---|---|---|
| Character counting | .length |
Intl.Segmenter grapheme clusters |
| Word counting | Split on whitespace | UAX #29 + CJK dictionary segmentation |
| Case conversion | .toUpperCase() |
Locale-aware mapping + casefold for comparison |
| Slug generation | Regex replace non-ASCII | NFKD + transliteration table + CJK romanization |
| Readability | Word count / sentence count | Flesch-Kincaid with syllable model (and acknowledge limitations) |
| Deduplication | Set() |
Hash set for small data; Bloom filter or external sort for scale |
| Number to words | English-only | Locale-aware with scale system, gender, and counter words |
References
- Unicode Standard Annex #29 — Unicode Text Segmentation
- Unicode Standard Annex #44 — Unicode Character Database (case mapping properties)
- Unicode Technical Standard #39 — Unicode Security Mechanisms (confusables)
- Flesch, R. (1948). "A New Readability Yardstick." Journal of Applied Psychology
- Kincaid, J. P. et al. (1975). "Derivation of New Readability Formulas." Naval Technical Training Command Research Branch Report 8-75