Blog
algorithm

How to Score Font Similarity Without AI: A Deterministic Algorithm

Why I built a deterministic, weighted-sum scorer for explainable font matching instead of using AI — classification, design traits, use cases, language support, and variable-font match.

Mladen Ruzicic
Mladen Ruzicic
8 min

Updated July 2026 — this post now describes the scorer that actually ships. The original draft documented a five-dimension x-height/stroke/width design that never made it into the codebase; what follows is the real, deployed algorithm.

“Similar fonts” is subjective. What makes Inter similar to Helvetica? Why is Lato a good alternative to Proxima Nova?

I needed an algorithm that produces consistent, explainable scores. Not AI vibes. Deterministic math.

The problem with AI matching

I considered using embeddings or ML models for font similarity. Problems:

  1. Black box: Why did the model say these fonts are similar? No explanation.
  2. Inconsistent: Run twice, get different results (temperature, random seeds).
  3. Expensive: API calls for every font pair add up.
  4. Overkill: Font similarity isn’t that complex.

Font matching isn’t like image recognition. Our fonts already carry structured metadata — classification, design traits, use cases, language coverage — and I can compare those fields directly. No inference required.

The five factors

The programmatic scorer compares two premium fonts across five factors, every one of them read straight from our structured catalog:

  1. Classification match: Same broad category (sans-serif, serif, display, mono)?
  2. Design-trait overlap: How many canonical traits do they share (geometric, humanist, rounded, neo-grotesque…)?
  3. Use-case overlap: How much do their intended uses overlap (UI, editorial, display, code…)?
  4. Language-support overlap: How closely do their supported scripts line up (Latin, Cyrillic, Greek…)?
  5. Variable-font match: Are they both variable fonts, or both static?

Note what is not in that list: x-height, character width, and stroke contrast. Those are real typographic measurements, but they don’t exist as fields in our font data, so the similarity scorer can’t and doesn’t use them. (They do power a separate system — see “A note on what belongs where” below.)

The scoring algorithm

Each factor contributes a fixed number of points. The final score is their sum, rounded to a 0–100 value:

// Weights (max points per factor):
//   classification match  40
//   design-trait overlap  25  (Jaccard of canonical traits)
//   use-case overlap      20  (Jaccard of useCases)
//   language overlap      10  (Jaccard of languageSupport)
//   variable-font match    5
export function computePremiumSimilarity(
  fontA: PremiumFont,
  fontB: PremiumFont,
): number {
  const classificationMatch =
    fontA.classification === fontB.classification ? 40 : 0;

  const traitOverlap =
    jaccard(canonicalizeTraits(fontA.traits), canonicalizeTraits(fontB.traits)) * 25;

  const useCaseOverlap = jaccard(fontA.useCases, fontB.useCases) * 20;

  const languageOverlap =
    jaccard(fontA.languageSupport, fontB.languageSupport) * 10;

  const variableFontMatch = fontA.variableFont === fontB.variableFont ? 5 : 0;

  return Math.round(
    classificationMatch +
      traitOverlap +
      useCaseOverlap +
      languageOverlap +
      variableFontMatch,
  );
}

That’s the whole thing. No branching heuristics, no learned parameters, no randomness — just an addition of five terms.

The Jaccard overlap helper

Three of the five factors — traits, use cases, and language support — are lists, so I score them by how much the two lists overlap. That’s the Jaccard index: the size of the intersection over the size of the union.

function jaccard(a: string[], b: string[]): number {
  if (a.length === 0 && b.length === 0) return 1;
  const setA = new Set(a);
  const setB = new Set(b);
  let intersection = 0;
  for (const item of setA) {
    if (setB.has(item)) intersection++;
  }
  const union = new Set([...setA, ...setB]).size;
  return union === 0 ? 0 : intersection / union;
}

Two fonts that share every use case score 1.0 on that factor; two that share none score 0. Everything in between is the proportion of tags they have in common. It’s symmetric, it’s bounded to [0, 1], and it never depends on list order.

Traits get one extra step first. Different files might say "grotesque", "neo-grotesque", or "neogrotesque" for what is really the same idea, so canonicalizeTraits() maps each raw string to a canonical slug before the overlap is measured. Without that, two identical fonts described with slightly different words would look less similar than they are.

Why these weights?

Classification is worth more than everything else combined (40 points). A serif can’t stand in for a sans-serif, no matter how many other tags line up. Making it the single heaviest factor — and the reason we only ever recommend within-classification alternatives — keeps the scorer honest about like-for-like replacement.

Design traits (25) and use cases (20) are the next tier. Once two fonts are in the same category, how they’re drawn and what they’re for is what separates a great substitute from a merely acceptable one.

Language support (10) and variable-font match (5) are tie-breakers. They matter — if you need Cyrillic or a weight axis, a font that lacks them is a worse swap — but they shouldn’t outweigh the font actually looking and behaving right.

The frontmatter format

Each premium font lists its alternatives with a similarity score:

alternatives:
  - slug: inter
    similarity: 85
    notes: "Similar proportions, slightly taller x-height"
  - slug: open-sans
    similarity: 72
    notes: "More humanist, different terminals"
  - slug: source-sans-3
    similarity: 68
    notes: "Narrower, more neutral feel"

Scores are pre-computed at build time. No runtime calculation, and no API call in the request path.

One honest caveat. The algorithm above is what powers our programmatic comparison pages — the automatically generated, catalog-wide /compare/ grid. On our in-depth, hand-researched Tier-1 font pages, the headline similarity numbers you see next to each alternative are expert editorial assessments, not this formula’s output. A human weighs the same qualities — classification, traits, intended use, language coverage — plus letterform detail that structured data simply doesn’t capture, and assigns the score. The programmatic algorithm gives us consistency across thousands of pairs; the editorial layer gives the top fonts the benefit of real typographic judgment. Both are documented on the methodology page.

A worked example

Take two geometric sans-serifs from the catalog and run the programmatic scorer:

  • Same classification (both sans-serif) → 40
  • Design traits: they share 3 canonical traits out of 5 total across both → Jaccard 0.6 × 25 = 15
  • Use cases: they share 2 of 4 total (both UI + branding; one adds editorial, the other code) → Jaccard 0.5 × 20 = 10
  • Language support: identical Latin + Latin-ext + Cyrillic coverage → Jaccard 1.0 × 10 = 10
  • Variable-font match: both are variable → 5

Add them up: 40 + 15 + 10 + 10 + 5 = 80. An 80 lands in the high-confidence band — a strong, defensible substitute — and every one of those five terms is a number I can point to.

Deterministic = debuggable

When someone questions a score, I can show the arithmetic:

“These two fonts score 80 because:

  • Same classification (sans-serif): 40 / 40
  • Shared design traits (0.6 overlap): 15 / 25
  • Shared use cases (0.5 overlap): 10 / 20
  • Identical language support (1.0 overlap): 10 / 10
  • Both variable: 5 / 5

Sum: 80 / 100.”

Try explaining why an embedding model thinks two fonts are similar.

A note on what belongs where

The original version of this post described a similarity scorer built on x-height ratios, stroke contrast, and character width. That was wrong for this system — but those measurements aren’t fictional. They live in a separate font-pairing scorer, which answers a different question: not “can Font B replace Font A?” but “do Font A and Font B look good together?”

That pairing algorithm weighs x-height ratio, stroke contrast, width ratio, classification contrast, and mood alignment, because harmony between two different fonts really does come down to those physical proportions. Similarity and pairing are two distinct scorers with two distinct inputs, and it’s worth keeping them straight. The methodology page documents both.

What the algorithm doesn’t capture

Structured tags can only go so far. The programmatic scorer can’t see:

  • Optical adjustments: How the font looks at a specific size
  • Letterform detail: Terminals, apertures, and curve construction that two “geometric sans” fonts can still disagree on
  • Cultural associations: Some fonts read “tech,” others “editorial”
  • Rendering quality: How the font hints and renders across screens

That’s exactly the gap the editorial Tier-1 scores and the human-written notes field are there to fill.

Tradeoffs

What I gained:

  • Consistent, reproducible scores across the whole catalog
  • Explainable recommendations — every point is traceable to a field
  • Fast computation (no API calls, computed at build time)
  • Works offline

What I lost:

  • The nuance of expert judgment (which is why Tier-1 pages layer it back on)
  • Adaptation to emerging preferences
  • Discovery of unexpected similarities

Future improvements:

  • Weight tuning informed by real substitution feedback
  • Richer trait vocabularies so the overlap terms carry more signal
  • A/B testing different weight configurations

The result

Programmatic pages get similarity percentages that are consistent and fully explainable. An 80 is reliably a strong substitute; a 65 works in a pinch. Where a font deserves more than tags can express, an editor’s assessment takes over on the Tier-1 page.

No AI magic. Just a weighted sum of factors I can defend line by line.

See the full methodology for how these scores — programmatic and editorial — are produced and displayed on the site.

Explore on FontAlternatives

#algorithm#typography#ai#scoring

More from the blog