--- name: unicode-text-fields description: >- Handle styled Unicode text (๐›๐จ๐ฅ๐, ๐“ผ๐“ฌ๐“ป๐“ฒ๐“น๐“ฝ, โ’ธโ“˜โ“กโ“’โ“›โ“”โ““, ๏ฝ๏ฝ…๏ฝ“๏ฝ”๏ฝˆ๏ฝ…๏ฝ”๏ฝ‰๏ฝƒ) that has to survive a paste into a field you do not own โ€” a bio, a username, a display name, a chat message. Use when counting characters against a platform limit, truncating a string, validating or sanitising user input, normalising text, or debugging why styled text arrives flattened, cut short, or ending in a black diamond. Covers length units (code points vs UTF-16 vs bytes), NFKC destruction of styled letters, surrogate-pair truncation, per-character vs positional transforms, coverage holes, and how styled digits parse differently in JavaScript and Python. license: CC0-1.0 --- # Unicode text in fields you do not own Published by [Fontius](https://fontius.app), which maps ASCII onto Unicode codepoints so people can paste styled text into a bio. Every number below was **measured** against a shipping 22-style catalog on 2026-08-10, not recalled. Where we do not know something, this document says so instead of guessing. Reproduce anything here with the standard library โ€” Python's `unicodedata` and JavaScript's `String.prototype.normalize`. No dependencies. --- ## 1. "Length" is not one number, and the platform will not tell you which one it means A styled letter is usually one code point in the Supplementary Plane, which means **1 code point = 2 UTF-16 units = 4 UTF-8 bytes**. Three defensible answers to "how long is this?" that disagree by 3ร—: ```js const s = "๐š๐ž๐ฌ๐ญ๐ก๐ž๐ญ๐ข๐œ"; [...s].length // 9 code points โ€” what a person counts s.length // 18 UTF-16 units โ€” what String.length counts new TextEncoder().encode(s).length // 36 UTF-8 bytes โ€” what storage counts ``` Measured on one real 76-code-point bio line, styled: | style | code points | UTF-16 | UTF-8 bytes | |---|---:|---:|---:| | plain | 76 | 76 | 76 | | Bold `๐€` | 76 | 134 | 250 | | Script `๐’œ` | 76 | 117 | 225 | | Small Caps `แด€` | 76 | 76 | 155 | **The failure mode:** a counter that measures code points calls a 76-character bio "76" and marks a 150-character limit as safe, while the field may be budgeting 134 units or 250 bytes. If you display a count next to somebody else's limit, you are making a claim about a unit you do not control. **What to do:** measure all three, and if the destination has not published its unit, say so in the UI rather than picking one and calling it green. The honest three-state verdict: ```js const measure = (s) => ({ codePoints: [...s].length, utf16: s.length, bytes: new TextEncoder().encode(s).length, }); // ok โ€” fits in every plausible unit // risk โ€” fits by code points, not by UTF-16: the field decides, and it did not say // over โ€” too long even by the most generous count const state = (m, limit) => m.codePoints > limit ? "over" : m.utf16 > limit ? "risk" : "ok"; ``` Do **not** fold bytes into that verdict. At 4 bytes per Supplementary glyph a byte-counted rule marks every styled string over every limit โ€” an unfalsifiable warning, not information. ## 2. Truncation splits surrogate pairs, and the user sees a black diamond `.slice()`, `.substring()`, and any `[0..n]` on a JavaScript string cut at UTF-16 boundaries. Cut at an odd index inside a Supplementary character and you keep half of a surrogate pair, which serialises to U+FFFD: ```js "๐š๐ž๐ฌ๐ญ๐ก๐ž๐ญ๐ข๐œ".slice(0, 7) // "๐š๐ž๐ฌ\uD835" โ†’ renders ๐š๐ž๐ฌ๏ฟฝ ``` **Truncate on code points, not units** โ€” and if you care about combining marks (`aฬถ` is two code points), truncate on grapheme clusters with `Intl.Segmenter`: ```js const cutCodePoints = (s, n) => [...s].slice(0, n).join(""); const cutGraphemes = (s, n) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)] .slice(0, n).map((g) => g.segment).join(""); ``` ## 3. NFKC deletes styled text โ€” and this is not a bug, it is what the characters ARE Measured over the whole catalog: **17 of 22 styles do not survive an NFKC round-trip.** They flatten back to plain ASCII. ```js "๐€๐ฎ๐ซ๐จ๐ซ๐š๐Ÿ•".normalize("NFKC") // "Aurora7" "๐’œ๐“Š๐“‡โ„ด๐“‡๐’ถ".normalize("NFKC") // "Aurora" "โ’ถโ“คโ“กโ“žโ“กโ“โ‘ฆ".normalize("NFKC") // "Aurora7" "๏ผก๏ฝ•๏ฝ’๏ฝ๏ฝ’๏ฝ๏ผ—".normalize("NFKC") // "Aurora7" ``` All 22 are stable under **NFC** and **NFD**. The damage is specific to the *compatibility* normalisations, and there is a rule behind it: > A family is a **Unicode-recognised styled variant of ASCII** if and only if it > carries a compatibility decomposition โ€” which is exactly the property that > flattens it under NFKC. Measured: exactly **1033 code points** are a 1:1 compatibility form of an ASCII letter or digit, in 7 decomposition tags โ€” `` 779, `` 64, `` 62, `` 60, `` 27, `` 26, `` 15. The corollary is the useful half: **any styled family that IS NFKC-stable is necessarily a lookalike with no formal relationship to the letter it imitates** โ€” small caps `แด€`, turned letters `ษ`, letters carrying combining marks `aฬถ`. There is no third category. Check before testing, no round-trip needed: ```python import unicodedata bool(unicodedata.decomposition("๐€")) # True โ†’ will flatten under NFKC bool(unicodedata.decomposition("แด€")) # False โ†’ will survive ``` **Consequences to design around:** - If your search index normalises with NFKC, styled text is findable by its plain form โ€” usually what you want. - If a *storage* path normalises, the user's styling silently disappears between save and reload, and they will report it as data loss. - Anyone recommending a style "because it is robust" is recommending a lookalike, which is a different trade: it survives normalisation and fails coverage (see ยง5). ## 4. You are shipping codepoints; the reader's device supplies the font Styled Unicode text is not a font. You hand over characters and the **reader's** device decides which face draws them โ€” a different device from the one that composed the string. Measured with `fc-list :charset=` over 149 distinct non-ASCII code points on one Linux box with 87 Latin-capable font families: **no single font covers every style.** Script letters (U+1D49Cโ€ฆ) were served by 4 of 87 families; enclosed and subscript forms by 24โ€“42. Two consequences people miss: 1. **A glyph can be present and still be wrong.** If `๐š` resolves through the UI font and `๐’ถ` through a math fallback, one word renders in two unrelated typefaces. This gets worse if you "fix" a coverage hole by borrowing a character from a different family โ€” measured, fullwidth digits share *zero* fonts with Script letters. 2. **The author's screen proves nothing about the reader's.** Browser device emulation cannot test this: it changes viewport, user agent and DPR, never the font stack. Only a real device answers it. ## 5. Is your transform per-character? There is a mechanical test A per-character map is a monoid homomorphism: `f(xy) == f(x)f(y)` for every pair of characters. Anything that reverses, joins, inserts separators or reacts to position fails it โ€” and you want to know that before you build on top of it. ```js const isPerCharacter = (f, alphabet = "abcXYZ019 .!") => { for (const a of alphabet) for (const b of alphabet) if (f(a + b) !== f(a) + f(b)) return false; return true; }; ``` Run against our own catalog it returned: 20 of 22 styles pass with **zero** counterexamples; `upside-down` fails with 4158 (it reverses โ€” output at `i` depends on input at `n-1-i`) and `spaced` fails with 4096 (it inserts separators). Worth noting, because the boundary is usually drawn in the wrong place: appending a **fixed** combining mark per character (`c => c + "ฬถ"`, strikethrough) **passes** โ€” a one-to-many map is still per-character. What leaves the class is *nondeterminism* or *position-dependence*, not the number of code points emitted. ## 6. Coverage holes are the norm, and there is no substitution that isn't a lie Unicode never designed these blocks as complete typefaces. Measured: | family | Aโ€“Z | aโ€“z | 0โ€“9 | note | |---|---|---|---|---| | Mathematical Bold | 26 | 26 | 10 | complete | | Mathematical Italic / Script / Fraktur | 26* | 26* | **0** | *letters patched from Letterlike Symbols; **no italic, script or fraktur digits exist at all** | | Small capitals | 25 | โ€” | 0 | `X` has no small-capital form in Unicode | | Superscript | 22 | 26 | 10 | no superscript `S X Y Z` | | Subscript | **0** | 17 | 10 | missing `b c d f g q w y z` โ€” cannot spell "fancy" or "baby" | So a styled word can carry an unstyled digit (`๐’œ๐“Š๐“‡โ„ด๐“‡๐’ถ7`), and there is no fix that is not a worse trade: substituting a plain ASCII letter is a silent lie, substituting a similar-looking letter from another family changes the word (an `วซ` is not a `q`), and borrowing a glyph from a different block maximises the font-divergence in ยง4. Recently-added code points are their own trap โ€” the only superscript `q` in Unicode (U+107A5, added 2021) was in 3 of 87 local fonts, so "fixing" the hole trades a wrong-size glyph for a tofu box. **The honest move is to disclose the hole, not to paper over it.** ## 7. Machines parse styled digits, and they do not all parse them the same way Styled digits from the math and fullwidth blocks are `General_Category=Nd` with a real decimal value. Enclosed forms (`โ‘ `) are `No` and are not. ```python int("๐Ÿ๐ŸŽ๐Ÿ๐Ÿ”") # 2026 re.findall(r"\d+", "๏ผ‹๏ผ‘ ๏ผ•๏ผ•๏ผ• ๏ผ๏ผ‘๏ผ’๏ผ“") # matches โ€” Python \d is Unicode-aware ``` ```js /\d/.test("๐Ÿ๐ŸŽ๐Ÿ๐Ÿ”") // false โ€” JavaScript \d is ASCII-only /\p{Nd}/u.test("๐Ÿ๐ŸŽ๐Ÿ๐Ÿ”") // true ``` If your client validates with `/\d/` and your server parses with Python, styled digits pass one layer and are read as numbers by the next. Treat that asymmetry as a security property, not a curiosity: normalise **before** you validate, and validate on the normalised form. ## 8. Two specific footguns - **Regional indicators collapse into flags.** `๐Ÿ‡ฆ`โ€“`๐Ÿ‡ฟ` (U+1F1E6โ€“U+1F1FF) look like a boxed-letter alphabet, but any two adjacent indicators forming a valid ISO country code are rendered as that country's flag by an emoji-aware renderer. `US` styled this way is not two letters, it is ๐Ÿ‡บ๐Ÿ‡ธ. - **Fullwidth punctuation is not decoration.** U+FF0E FULLWIDTH FULL STOP is treated as a label separator by UTS #46, so `mystore๏ผŽcom` inside a bio is a candidate *domain*, not a styled sentence โ€” which puts it in front of link-policy enforcement. ## Checklist ```text [ ] Count in all three units; never show one number as if it were the limit [ ] Truncate on code points or graphemes, never on UTF-16 indices [ ] Normalise before validating; know whether your storage path normalises [ ] Test rendering on a device you did not compose the text on [ ] Check coverage per family before offering it; disclose the holes [ ] Verify a transform is per-character with f(xy) == f(x)f(y) before assuming it [ ] Remember the reader chooses the font, and may not have one ``` ## What we do not know Stated so nobody builds on an assumption we did not test: - **Which unit any given platform counts.** We measured what the units *are*, not what Instagram, TikTok, X or Discord budget in. The only way to learn it is to paste a known-length string into the field and read where it truncates. - **Whether native mobile app text renderers honour colour fonts** (COLRv1, SVG-in-OpenType) for pasted text. Desktop-browser support numbers do not transfer to a native text stack, and we found no authoritative answer. - **Android rendering variance.** Our coverage sweep ran on one Linux box; iOS was checked by hand and showed no tofu. Android OEM variance is unmeasured. - **Whether any platform treats styled digits as moderation evasion.** ยง7 is a measured parsing fact; the policy half is not ours to assert. --- Corrections welcome: hello@fontius.app. If you use this, the reproducible parts are the point โ€” run them against your own catalog before trusting ours.