About Builds AI Portfolio Lab Tools Blog Contact
All Posts

My Font Matcher Couldn't Tell Fonts Apart

I went in to fix the camera button on my handwriting font matcher. While I was there, I found out the comparison had been identical for every font since I shipped it.

2 min read
toolsweb-devside-projectsdebugging

I built a handwriting font matcher for the tools section on my site. Take a photo of someone’s handwriting, it runs against a set of reference fonts, returns the closest one. Browser-native, client-side inference, no upload to a server needed.

For a few days I left it alone. It ran. Results came back. Nobody said anything.

Then I went in to fix the camera button.

The button said “Take a photo.” On mobile, it actually opened the camera. On desktop, the capture attribute is silently ignored by every major browser and you get the standard file picker instead. Same interface, different behavior, no indication anything was wrong. I’d shipped it that way and not noticed because I’d mostly tested it on my phone.

Replacing it with a getUserMedia preview was about twenty minutes of work. Now it opens a live camera feed with Capture and Cancel buttons. Actual camera capture on desktop for the first time.

I was closing the file when I looked at the font loading.

The matcher pulls reference fonts from Google Fonts, renders each one to a canvas, and generates an embedding for comparison. The code was parsing the CSS, finding the @font-face blocks, and loading the first woff2 URL it found. Google serves the latin-ext block first. Latin-ext covers extended characters: é, à, ñ. That woff2 only contains those code points. When the canvas tried to render plain ASCII with that font face, the browser couldn’t find those characters in the loaded file and fell back to the default serif.

Every reference font embedded identically.

The comparison math was fine. The architecture was fine. But the embeddings it was comparing were thirty copies of the same default serif render. Whatever handwriting you uploaded, the “closest match” was whichever font happened to sit highest in a comparison between identical vectors. It returned a result every time. The result meant nothing.


Fixing it was the same kind of work as the camera: find the right block, load that one. Parse all the @font-face declarations, pick the one covering basic Latin specifically. Render with A-Z, a-z, the characters you’re actually comparing against. Embeddings diverge. Results become real.

I added dual-scale rendering while I was in there. Fit and fill, averaged. Fit-only favors print fonts, fill-only favors cursive, averaging across both covers the range. Cache key bumped to v6.


What I keep thinking about: the tool looked right for days. The spinner ran, the percentage scores appeared, a font name rendered at the top. No errors. No failures. Just confident wrong answers.

A function that runs without errors and returns plausible output is not the same as a function that’s correct.

Learn that difference before you’re months into a tool telling people their handwriting looks like Lora when it just didn’t know any better.