If you have ever built a "fancy text" generator — the things people paste into
Instagram bios and Discord names — you have written this function:
// Looks right. Is wrong.
const toBold = text => text.replace(/[A-Za-z]/g, ch => {
const c = ch.charCodeAt(0);
return c <= 90
? String.fromCodePoint(0x1D400 + (c - 65)) // A-Z
: String.fromCodePoint(0x1D41A + (c - 97)); // a-z
});
Map A onto U+1D400, add the offset, done. It works. You test it with
"Hello World", get 𝐇𝐞𝐥𝐥𝐨 𝐖𝐨𝐫𝐥𝐝, and ship it.
Then someone types their name in cursive and gets ☐.
The block is not contiguous
The Mathematical Alphanumeric Symbols block, U+1D400–U+1D7FF, holds about a
dozen styled Latin alphabets: bold, italic, bold italic, script, bold script,
fraktur, double-struck, sans-serif, monospace, and the digit sets.
It reads like a clean set of 26-letter runs. It is not. When the block was
encoded, some of those letters already existed in the Letterlike Symbols
block, U+2100–U+214F, because mathematicians had been using them as named
constants for decades — ℋ for the Hamiltonian, ℒ for the Lagrangian,
ℝ for the reals. Unicode does not encode the same character twice. So rather
than duplicate them, it left those slots in the new block permanently
unassigned.
Five alphabets have gaps. Here is the complete set:
| Style | Missing letters | Where Unicode actually put them |
|---|---|---|
| Script | B E F H I L M R, e g o | ℬ ℰ ℱ ℋ ℐ ℒ ℳ ℛ, ℯ ℊ ℴ |
| Fraktur | C H I R Z | ℭ ℌ ℑ ℜ ℨ |
| Double-struck | C H N P Q R Z | ℂ ℍ ℕ ℙ ℚ ℝ ℤ |
| Italic (serif) | h | ℎ (Planck's constant) |
Every one of those is a hole your arithmetic falls into. 𝒜 + 1 is not ℬ, it
is U+1D49D, which is nothing at all. Your users see tofu, and only for certain
letters, which is why this survives testing: "Hello" breaks in script and
fraktur, "World" does not.
The fix is a lookup, not cleverness
There is no formula. The gaps are historical accident, so the only correct
implementation is an explicit exception table consulted before the arithmetic:
type Style = {
upper?: number; lower?: number; digit?: number;
holes?: Record<string, string>;
};
const HOLES_SCRIPT = {
B: 'ℬ', E: 'ℰ', F: 'ℱ', H: 'ℋ', I: 'ℐ', L: 'ℒ', M: 'ℳ', R: 'ℛ',
e: 'ℯ', g: 'ℊ', o: 'ℴ',
};
function styleChar(ch: string, s: Style): string {
if (s.holes?.[ch]) return s.holes[ch]; // check the table FIRST
const c = ch.charCodeAt(0);
if (c >= 65 && c <= 90 && s.upper !== undefined) return String.fromCodePoint(s.upper + c - 65);
if (c >= 97 && c <= 122 && s.lower !== undefined) return String.fromCodePoint(s.lower + c - 97);
if (c >= 48 && c <= 57 && s.digit !== undefined) return String.fromCodePoint(s.digit + c - 48);
return ch; // pass everything else through
}
The test that catches it is a pangram, checked for the reserved codepoints
rather than eyeballed:
const RESERVED = new Set([
0x1D455, 0x1D49D, 0x1D4A0, 0x1D4A1, 0x1D4A3, 0x1D4A4, 0x1D4A7, 0x1D4A8,
0x1D4AD, 0x1D4BA, 0x1D4BC, 0x1D4C4, 0x1D506, 0x1D50B, 0x1D50C, 0x1D515,
0x1D51D, 0x1D53A, 0x1D53F, 0x1D545, 0x1D547, 0x1D548, 0x1D549, 0x1D551,
]);
const out = apply('How vexingly quick daft zebras jump', style);
const bad = [...out].filter(c => RESERVED.has(c.codePointAt(0)));
console.assert(bad.length === 0, style.id, bad);
Eyeballing does not work, because ☐ is what a missing font looks like too.
Checking the codepoints tells you which of the two you have.
Three more traps in the same neighbourhood
Iterate codepoints, not code units. Every character you are producing is
astral-plane — above U+FFFF, so two UTF-16 code units. The moment your input
contains an emoji, or someone pastes your own output back in, for (let i = 0; splits a surrogate pair and you emit garbage.
i < text.length; i++)for…of
and [...text] both iterate by codepoint. Use them.
Seed your zalgo. Glitch text works by stacking combining diacritics —
U+0300–U+036F — on each letter, and there is no limit on how many can stack.
The obvious implementation reaches for Math.random(). Do not: if the output is
rendered reactively, every keystroke re-randomises the whole string, so the
text visibly crawls while the user types and the thing they copy is not the
thing they were looking at when they decided they liked it. Derive the seed from
the input and run a tiny PRNG:
let state = seed | 0 || 1;
const next = () => {
state ^= state << 13; state ^= state >>> 17; state ^= state << 5;
return (state >>> 0) / 4294967296;
};
Make upside-down text an involution. Flipping is two operations: substitute
each character for its turned twin (a → ɐ, U+0250), then reverse the
string, because a genuinely rotated line reads from what was its end. Skip the
reversal and you get mirror writing, which is the most common bug in these
tools. But there is a subtler one: half your users arrive with text that is
already upside down and want it turned back. If your table only maps a → ɐ,
pasting ɐ back in leaves it untouched. Fill the reverse direction from the
forward table and the same function does both jobs:
for (const [k, v] of Object.entries({ ...FLIP })) {
if (!(v in FLIP)) FLIP[v] = k; // only fill gaps; hand-written pairs win
}
The part nobody tells users
Styled Unicode is worse for accessibility and for search, and it is worth saying
so in the UI rather than leaving people to find out.
A screen reader does not read 𝗯𝗼𝗹𝗱 as "bold". It reads it as "mathematical
bold small b, mathematical bold small o, mathematical bold small l,
mathematical bold small d" — because that is what those characters are named.
Search engines do not match 𝘀𝗵𝗼𝗲𝘀 to a search for shoes either.
That makes these styles genuinely good for a decorative username or one accent
word, and genuinely bad for a whole bio, a business name people need to find, or
anything that has to be read aloud. Most generators do not mention it. It costs
one sentence and it is the difference between a tool and a trap.
Working reference
Every finding here came out of building the text tools on
Confileo — 26 styles, the
full holes table, seeded glitch and a two-way flip, all client-side. The
upside-down one is the quickest
way to check the involution claim: paste its output back into its own box and
you should get your original line, character for character.
If you are building your own, the short version is: check the table before you
do the arithmetic, iterate by codepoint, seed anything random, and tell your
users what the output costs them.
Top comments (0)