This is part 4 of my beginner journey.
In part 1 I did not know what to learn first. In part 2 I stopped overthinking and picked a project: Fonti, a free copy-paste font generator. In part 3 I chose my tools (Astro, Git, Cloudflare Pages) and promised the next post would be about the actual build.
NOTE: I renamed the project from Fontly to Fonti since part 3. Same tool, better name.
This is that post. My first real code of this series.
And it starts with me being completely wrong about how these tools work.
What I thought I was building
My idea was simple. The user types text. I apply a font. They copy it and paste it into their Instagram bio.
I spent a few days confused about one part of that plan. How does a font survive a copy-paste? If I load a font on my website, that font lives on my website. Instagram does not have it. So how does the style travel?
The answer is that it does not. There is no font. There never was.
The real trick
When you paste ππ¨π₯π text somewhere, you are not sending style information. You are sending different characters that are shaped like bold letters.
The b in that bold word is not the letter b. It is a math symbol that looks like a bold b.
Unicode has a block called Mathematical Alphanumeric Symbols. It lives at U+1D400 to U+1D7FF. It has 1024 slots, 996 of them are used, and 28 are empty on purpose. It was added in Unicode 3.1 in the year 2001.
Why does it exist? Because mathematicians need π΄ and πΈ and π to mean three different things in the same paper. In math, the style is part of the meaning. So Unicode gave each style its own real character.
Social media users then found these characters and started using them for stylish bios. That is the whole industry of "font generators".
So my tool is not a font tool. It is a lookup table. That is it.
This also connects back to something from part 3. I chose Astro because it sends very little JavaScript by default, and only sends it where the page really needs it. Now I know exactly what that JavaScript is: one small function that swaps characters. Everything else on the site can stay as plain HTML.
My first version
If the styled letters are stored in alphabetical order, then converting text is just simple math.
Normal A is at U+0041. Bold A is at U+1D400. So I take the letter's number, subtract the start of the normal alphabet, and add the start of the bold alphabet.
const OFFSETS = {
bold: { upper: 0x1D400, lower: 0x1D41A },
italic: { upper: 0x1D434, lower: 0x1D44E },
script: { upper: 0x1D49C, lower: 0x1D4B6 },
fraktur: { upper: 0x1D504, lower: 0x1D51E },
doubleStruck: { upper: 0x1D538, lower: 0x1D552 },
sansSerif: { upper: 0x1D5A0, lower: 0x1D5BA },
};
const convert = (text, style) => {
const { upper, lower } = OFFSETS[style];
return [...text].map((ch) => {
const code = ch.charCodeAt(0);
if (ch >= "A" && ch <= "Z") return String.fromCodePoint(upper + code - 65);
if (ch >= "a" && ch <= "z") return String.fromCodePoint(lower + code - 97);
return ch;
}).join("");
};
I was happy. It felt clean. No font files, no library, no dependency. Just math.
Bold worked. Italic mostly worked. Sans-serif worked.
Then I tested script style and got this:
π for A. Then an empty box for B.
Why it broke
The block has holes in it. And the holes are there on purpose.
Before this big math block existed, Unicode had already added a few of these styled letters in a different block called Letterlike Symbols at U+2100 to U+214F. They were added because people already used them as single math symbols. Things like β for real numbers and β for a Hilbert space.
So when the big block was designed later, those letters were left out. Unicode did not want to encode the same character twice. The official Unicode chart says this directly about the double-struck letters: they are "omitted here to avoid duplicate encoding."
The result for me is simple. My math formula was pointing at empty slots. Empty slot means empty box on screen.
I had to build an exception list. Here is every hole that affects normal A to Z text, and where the real character actually lives:
const EXCEPTIONS = {
italic: {
h: "\u210E", // β (Planck constant)
},
script: {
B: "\u212C", E: "\u2130", F: "\u2131", H: "\u210B",
I: "\u2110", L: "\u2112", M: "\u2133", R: "\u211B",
e: "\u212F", g: "\u210A", o: "\u2134",
},
fraktur: {
C: "\u212D", H: "\u210C", I: "\u2111",
R: "\u211C", Z: "\u2128",
},
doubleStruck: {
C: "\u2102", H: "\u210D", N: "\u2115", P: "\u2119",
Q: "\u211A", R: "\u211D", Z: "\u2124",
},
};
That is 24 exceptions in four styles.
Now look at which styles are not in that list. Bold, bold italic, bold script, bold fraktur, sans-serif and sans-serif bold are all fine. Their letters sit in one clean run with no gaps. Pure math works perfectly for them.
Only the four styles that overlap with old math notation are broken. That makes sense once you know the history, and it is impossible to guess before you know it.
My favourite one is italic. The whole italic alphabet is fine except lowercase h. Just one letter. Why? Because β at U+210E was already there as the Planck constant symbol from physics.
Here is the fixed function. The change is small. Check the exception list first, then fall back to math.
const convert = (text, style) => {
const { upper, lower } = OFFSETS[style];
const gaps = EXCEPTIONS[style] ?? {};
return [...text].map((ch) => {
if (gaps[ch]) return gaps[ch];
const code = ch.charCodeAt(0);
if (ch >= "A" && ch <= "Z") return String.fromCodePoint(upper + code - 65);
if (ch >= "a" && ch <= "z") return String.fromCodePoint(lower + code - 97);
return ch;
}).join("");
};
One thing I still cannot explain
Script lowercase e, g and o were all skipped, because β―, β and β΄ already existed in the older block.
But script lowercase l was added to the new block at U+1D4C1, even though β already existed at U+2113. Exactly the same situation, opposite decision.
If someone knows the reason behind that, please tell me in the comments. I looked and I could not find it.
The second thing that bit me
These characters live above U+FFFF. In JavaScript that means one visible character takes two slots inside the string.
This is why my code uses [...text] and not text.split(""). If you use split(""), it cuts these characters in half and you get broken output.
Before you write any code here, open your browser console and run this:
"π".length
Whatever number you see there will tell you why string length cannot be trusted in this project.
The honest part
I have to say this, because I found it while researching and it changed how I think about my own project.
Unicode tells people not to do what I am doing. The Unicode Standard, chapter 22, says: "Characters from the Mathematical Alphanumeric symbols block should not be used to represent styling of nonmathematical text."
These characters were made to be different from normal letters on purpose. Using them as decoration is a misuse of the system.
And there is a real cost. Spammers use this exact trick to get past keyword filters, because a filter looking for a word will not match the same word written in math symbols. People have documented it in spam emails and even inside URLs.
So Fonti will ship with a warning next to the output. It will say clearly that this text is not real formatting, that it can break search and filters, and that it is a bad choice anywhere the text needs to be read by a machine.
One more thing I do not know yet: how screen readers announce these characters. I am not going to guess. I will test it properly and write about what I actually find.
Looking back at part 1
In part 1, I was stuck choosing between HTML, CSS, JavaScript, Python and React. I felt like I had to pick the right one before I could start.
This whole feature is plain JavaScript. A map, a loop, and some numbers. No framework, no library, no React.
The hard part was never the language. It was understanding the problem. I spent one hour writing the code and three days figuring out that fonts were not involved at all.
That is the thing I wish someone had told me in part 1.
Two questions
First: the script lowercase l inconsistency above. Does anyone know the story?
Second: are there other Unicode ranges with reserved holes like this, where offset math quietly produces invalid characters? Letterlike Symbols is the only overlap I found, but I doubt it is the only one that exists.
Next post: building the actual UI and the copy button, which turned out to have its own surprise.
Top comments (1)
I like this writeup