DEV Community

Cover image for FontForge brand fonts: why changeWeight failed and offset outlines worked
Christian Anderson
Christian Anderson

Posted on

FontForge brand fonts: why changeWeight failed and offset outlines worked

I run two side projects that look nothing alike on purpose. The tech one (homelab notes, tips, the Server reels) is black and acid green with IBM Plex Sans Condensed Bold for headlines. The poster one is gold on black with Anton. Both fonts are free and brilliant, which is the problem. Half the internet uses them.

On 29 September I wanted something "a little off what we use, sharper lines, more impact". Not a new typeface, I'm not a type designer. Just my existing fonts, a bit heavier with a signature detail, so a headline looks like mine before you read it.

Here's what broke, the licence rules, and the colours, which were less tidy than I thought.

The tools

FontForge has Python bindings (apt install python3-fontforge on Debian/Ubuntu). The whole build is one 82-line script: open the source font, loop over every glyph, change the outline, rename the font, write .otf and .ttf, then render a specimen with Pillow so I can actually see it.

Two fonts come out of it:

  • iC Volt Condensed, from IBM Plex Sans Condensed Bold, for the tech brand
  • C1 Cut, from Anton Regular, for the poster brand

Specimen: Anton vs C1 Cut in gold, then Plex Sans Condensed Bold vs iC Volt Condensed in volt green, each with a headline line and a body line with digits

v1: changeWeight made a mess

FontForge has a built-in for this, glyph.changeWeight(). One call per glyph, give it a stroke amount, job done. So v1 did exactly that: 3% of the em for the Anton one, 3.4% for Plex, then tightened the letter spacing by 1.2% and 1.8%.

It never errored. It was wrapped in a try/except: pass (not proud of that), so I went back and counted: 596 glyphs on Anton, 330 on Plex, zero exceptions.

It just looked rubbish. The heavy Anton one clogged up, the holes in R, G and 8 nearly closed. The R leg grew a weird curled foot. The S on the Plex one got little nicks at its ends. And the tighter spacing made the clogging worse, letters started kissing.

Close-up: v1 changeWeight on the left vs v2 offset with mitre joins on the right, for both fonts. v1 has clogged counters, a curled R foot and nicked S terminals

That left-hand side is a rebuild of v1 from the old script today, so it's real output, not a mock up.

v2: offset the outline yourself

What I actually wanted was "the same letter, a bit fatter, corners still sharp". So v2 does that directly:

  1. removeOverlap() so the glyph is one clean outline
  2. take a copy of the outline and stroke it with a circular pen, butt caps and mitre joins, keeping only the outer band
  3. add that band back onto the original body and merge it with removeOverlap() again

The mitre join is the bit that matters. A round join would soften every corner, which is the opposite of "sharper". A mitre keeps the point.

body = g.foreground.dup()
band = g.foreground.dup()
band.stroke("circular", 2 * weight * em, "butt", "miter", "removeinternal")
body += band
g.foreground = body
g.removeOverlap()
Enter fullscreen mode Exit fullscreen mode

v2 also uses way less weight: 1% of the em on C1 Cut, 2% on iC Volt. A little goes a long way when the shapes aren't getting mangled.

The cut corners

The signature detail is a chamfer, like a machined edge. For every corner point the script checks:

  • the point and both neighbours are on-curve, so it's a straight line meeting a straight line
  • both edges are longer than 3x the chamfer size, so tiny details don't get eaten
  • the angle is between 60° and 120°

If all three pass, the corner point gets swapped for two points a set distance back along each edge. That's 3% of the em on C1 Cut and 2.8% on iC Volt. Curves are never touched, so S, G and 8 keep their shape and only the hard corners on things like M, T and K get cut.

Close-up of MTK: the original fonts on the left, the new ones on the right with small 45 degree cuts on the corners

After that it's housekeeping: correctDirection(), addExtrema(), round(), then spacing. C1 Cut went 0.4% looser, iC Volt 0.6% tighter, split evenly across both side bearings.

The licence bit (do read this)

Both source fonts are under the SIL Open Font License 1.1. It's a generous licence, but it has rules for modified versions:

  • Reserved Font Names. IBM's licence for Plex says "Reserved Font Name 'Plex'". A modified version can't use that name. So no "Plex Volt" or anything like it, hence iC Volt Condensed. Anton doesn't reserve a name, but I renamed it to C1 Cut anyway so nobody mixes it up with the real thing.
  • Rename it properly. The script strips the old Family, Full name, PostScript name, Unique ID, Preferred Family, WWS and Trademark entries from the name table and writes new ones. It appends "Modified 2026 ... (SIL Open Font License 1.1)" to the copyright, it doesn't replace it.
  • Ship the licence with the font. And the modified font stays under the OFL.
  • Don't sell the font files on their own. You can use them in stuff you sell, but the font itself isn't a product.

Where I got it wrong: the first licence file the script wrote for iC Volt was a three-line notice, credit and a link, without the actual licence text. When the font went on my site I shipped the proper file instead: the notice plus the full OFL 1.1 text, served next to the font with a "Font licence" link in the footer.

Where they're used right now

iC Volt went live on 8 October on my tech site, headlines only. It's a Latin-only subset made with fontTools, 12,232 bytes as WOFF2. Body text stays IBM Plex Sans and Plex Mono. The Friday newsletter template uses it too, with Impact as the fallback because loads of email clients won't load web fonts.

C1 Cut isn't used anywhere yet. The poster renderers still use Anton, and the tech Instagram renderer still uses Plex Condensed Bold. Swapping is mostly changing a font constant in each renderer, but I'd rather the owner of each brand (me, wearing a different hat) looks at it on real posts first.

And the colours

I'd been calling these "colour packs" in my head. Being honest, there's no colour pack system. What exists:

Palette swatches rendered from the hex values in the code: the eight IAMC/TECH tokens, the THEC1LAB gold trio, and five product colourways

The tech brand has one palette of eight tokens: ink #0B0C0E, panel #15181C, grid #262A30, paper #F1EFE8, mute #8B9099, volt #D7FF3A, red #FF5A4E, green #39D98A. Volt is the headline and accent colour, red and green are for the do/don't slides and result labels. It lives as a BRAND dict in the Instagram renderer, as RGB tuples in the reel renderer, and as CSS variables on the site.

Three copies, no shared file. And it's already drifted. The newer "Server explains" reel script defines its own red and green, (255, 92, 92) and (92, 220, 120), which are close to the brand ones but not the same, plus an amber the palette doesn't have. Nobody would spot it on a phone, but that's how a brand goes muddy. Next job is one palette file that everything imports.

The poster brand is simpler at the top: gold #D4AF37 on #0B0B0B with #F5F3EE text in the video renderer. The posters themselves use named colourways, which is the closest thing I've got to actual packs. Each one is a dict with a background, a gradient or solid fill for the glyphs, an accent, title, subtitle and fine-print colours, plus a flag for light or dark. There are five base ones (noir-gold, noir-silver, paper, neon, pop), and limited runs add their own with COLOURWAYS.update() so they never reuse a base one.

What I'd tell you

  • No errors isn't the same as working. changeWeight ran clean on 926 glyphs and still made rubbish. Render a specimen every build.
  • Mitre joins if you want weight without losing sharp corners.
  • Check the licence for a Reserved Font Name before picking a name.
  • Colours in one file from day one.

Has anyone made a proper derivative font for their own stuff? I'd like to know how you checked yours at small sizes, because I've only really looked at mine big.


🤖 Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.

Top comments (0)