Technology · · 10 min read

Share Cards Drawn by a Script

A Python script draws the preview picture for every post on this site, here's how it's done, and the testing to make sure I stay compliant with my own rules!

Photograph © Ken Reid

Share Cards Drawn by a Script

30 October 2026 · technology

It took me a long time to understand how sharing to social media sites worked. I thought it was largely driven by them, and that I had no control over what they sourced for the image, because I'd share a blog about data science and a picture of Kwee would appear in the socials (???).

Kwee is a very fluffy grey cat and has nothing to do with data science portfolios (as far as I'm aware, I don't keep an eye on him all day).

This post covers the script I wrote to replace all of that, generate_og_images.py, which draws a card for every post with Pillow, and the manifest that lets CI tell when a card is out of date without drawing. Part of the series on how this site is built.

Quick jargon guide

  • Share card: the picture a chat app or social site shows under a link you paste.
  • Open Graph: the meta tags (og:title, og:image and friends) that tell those sites what to show. Facebook created it.
  • Pillow: the Python library for opening, drawing on and saving pictures.
  • Variable font: one font file that holds a whole range of weights, picked with a number. Lora runs from 400 to 700.
  • woff2: the compressed font format browsers download.
  • Hash: a short fingerprint of some input. Change one letter of the input and the fingerprint changes completely.
  • CI: the checks GitHub runs on every push, which turn red when something is wrong.

What goes on a card

I've since learned a lot about this topic. The Open Graph protocol asks for four things on every page, and one of them is og:image: "An image URL which should represent your object within the graph."[1] Facebook's own guidance is at least 1200 by 630 pixels, as close to 1.91:1 as you can get so nothing is cropped in the feed[2]. So every post now gets img/og/<slug>.jpg at 1200 by 630. Each card copies the post's opener, the big photograph at the top of every post. It's the same photo cropped to fill the frame, under a dark scrim that's heavier at the bottom and on the left where the text sits. Then, from the bottom up, come the site name in the brand gradient, a short gradient rule, the title in Lora, and the kicker (category, date, read time) in capitals.

A share card: a Ferris wheel lit up at night under a dark scrim, the post title in large white serif type, and kenreid.co.uk in a pink-to-gold gradient at the bottom left
© Ken Reid. All rights reserved. The card for the post about hosting the gallery on GitHub, as the script draws it.
  1. Behind the type
    1. Photograph cropped to fill 1200 by 630 The opener's photograph, scaled until it covers the whole card and cut from the middle. A post with no photograph gets the plain dark base colour.
    2. Scrim darkest at the bottom left The dark base colour laid over the photo, 30% opaque at the top right and 96% at the bottom left, where the type goes. It's the same for every card, so it's worked out once a run.
  2. The type, from the bottom up
    1. Site name Poppins 600, 26 px kenreid.co.uk in the brand gradient, pink to gold, 72 pixels in from the left edge and up from the bottom.
    2. Rule 72 by 4 px A short bar in the same gradient, 26 pixels above the site name.
    3. Title Lora 600, 64 px Wrapped to the width of the card, three lines at most. When a title needs a fourth, the third ends in an ellipsis.
    4. Kicker Poppins 600, 22 px Category, date and read time in capitals, in the site's stone grey, 22 pixels above the title.
The order a card is drawn in. Hover over, tap or tab to a box for more.

They average about 77 KB each, which is important because these could clog up the repo over time. The script also rewrites og:image, twitter:image and the JSON-LD image in each post's head so all three point at the card. The head is the part of a post that link previews read. The thirteen top-level pages, from the homepage to the contact page, get cards in the same design, with a kicker I wrote into the script for each. The script never edits a top-level page, though, so I point those at their cards by hand.

Share card for Ant Colony, Live: coloured paths joining dots on a dark background Share card for Book Ratings Are Broken: a bar chart of star ratings behind the title Share card for Putting My Photos on a Map: a dark world map with red markers over Europe Share card for the Photography page: a white tower narrowing into a deep blue sky
© Ken Reid. All rights reserved. Three posts that open on a demo, a chart and a map, and the card for the photography page.

A font Pillow can't open

Pillow does the drawing; its text functions want a TrueType font file[3], and I only ship the site's fonts as woff2. So the script opens the woff2 with fontTools, sets the font's flavor to nothing (plain TrueType, no compression)[4], saves it into a buffer in memory and hands Pillow the buffer. The repo keeps no TrueType copy of Lora, only the woff2 files the site already serves. Here's the loader, trimmed a little:

f = TTFont(FONTS / name)
f.flavor = None                # woff2 in, plain TrueType out
buf = io.BytesIO()
f.save(buf)
buf.seek(0)
font = ImageFont.truetype(buf, size)
if weight is not None:
    font.set_variation_by_axes([weight])

Lora is a variable font with one axis, weight, from 400 to 700, and the opener sets post titles at 600. Without the fix, Pillow drew Lora at its default weight of 400. Every card the first version made had a lighter title than the page it was copied from. set_variation_by_axes takes one value per axis[3], and Lora only has the one, so [600] does it. At the same time I bumped a number in the script called RENDERER from 1 to 2. That number goes into each card's fingerprint, so every card went stale at once and got redrawn.

The title Share Cards Drawn by a Script in a light serif, over a dark photo of light trails
© Ken Reid. All rights reserved. RENDERER 1: Lora at its default weight, 400.
The same title in a heavier weight of the same serif
© Ken Reid. All rights reserved. RENDERER 2: Lora at 600, as the opener sets it.

The gradient on the site name is the same trick as CSS background-clip: text, done by hand: draw the text in white on a black mask, then paste a gradient strip through the mask. The scrim is worked out pixel by pixel in plain Python loops. It takes about a fifth of a second, once per run, because it's the same for every card. The finished card is saved as a progressive JPEG at quality 82.

The words kenreid.co.uk in white on black The words kenreid.co.uk filled with a gradient from pink to gold on a dark background A rectangle shading from light grey at the top to near black at the bottom, darker on the left A night photo of light trails on a road, darkened towards the bottom and the left
© Ken Reid. All rights reserved. Left, the site name as a mask, white where the letters are, and the gradient pasted through it, at three times the size it is on a card. Right, the scrim on its own over white, then over this post's photograph.

Knowing a card is stale

CI has to go red when a card is out of date: a retitled post, a new photo, an edit that changes the read time in the kicker. I gave each card a fingerprint of what went into it, run through SHA-1 and cut to 16 characters. img/og/manifest.json keeps all 81 of them. --check works the fingerprints out again from posts.json and the files on disk and compares them with the manifest. It also checks that every post's head points at its own card, and it needs nothing but Python's standard library. On my machine, drawing all 81 cards from scratch takes a few seconds, and fingerprinting them takes a few hundredths of a second.

  1. What goes into a fingerprint
    1. Title from posts.json Retitle a post and its fingerprint changes.
    2. Kicker category, date, read time The same line that's drawn on the card, so a read time that moves to another minute changes it.
    3. Photo path and size in bytes Where the opener's photo lives and how many bytes it weighs. The pixels themselves never go in.
    4. Fonts both files' sizes Lora and Poppins. A new copy of either font makes every card stale without any code changing.
    5. RENDERER 2 since the weight fix A number in the script that I bump by hand whenever the drawing changes.
  2. On my machine
    1. SHA-1 cut to 16 characters The hosting post's card comes out as 2010dbb24fd4731f.
    2. Draw only the stale cards A card is drawn again when its fingerprint isn't the one in the manifest, or its file is missing.
    3. Manifest img/og/manifest.json Every card's fingerprint, written after each run and committed with the cards.
  3. In CI
    1. Fingerprint again standard library only --check makes every fingerprint again from posts.json and the files on disk, with no Pillow and no drawing.
    2. Compare with the manifest It also checks that every post's head points at the post's own card.
    3. Red or green one stale card is enough Every card that doesn't match is listed by name, and the check fails.
A card's fingerprint, from what goes into it to the check in CI. Hover over, tap or tab to a box for more.
h = hashlib.sha1()
h.update(card.title.encode())
h.update(card.kicker.encode())
h.update(card.hero.relative_to(ROOT).as_posix().encode())
h.update(str(card.hero.stat().st_size).encode())

That as_posix() is the other fix: the first version hashed the hero's path the way the operating system spelled it with backslashes on my Windows machine, forward slashes on the Linux runner. So every manifest I wrote locally told CI that every single card was stale. I fixed it with forward slashes everywhere, and not one card changed.

The hosting post's card same title, kicker, photo and fonts Everything that goes into its fingerprint is the same on both machines, apart from how the photo's path is spelled.
  1. My Windows machine, before the fix
    1. Path img\photography\hero\387.webp
    2. Fingerprint 9c92e092ab178253 What the manifest I wrote locally said.
  2. The Linux runner
    1. Path img/photography/hero/387.webp
    2. Fingerprint 2010dbb24fd4731f What CI worked out. It didn't match the manifest, so CI called the card stale, and every other card went the same way.
  3. Both, after the fix
    1. Path img/photography/hero/387.webp
    2. Fingerprint 2010dbb24fd4731f Forward slashes on both machines, so Windows writes the fingerprint the runner expects. Only the manifest changed, so no card had to be drawn again.
One card fingerprinted both ways, worked out with today's title, kicker and photo. Hover over, tap or tab to a box for more.

Holes in it

  • File size, not contents. The fingerprint takes the hero's size in bytes, so a photo replaced by a different one that happens to weigh the same to the byte gets past the check.
  • The URL never changes. A redrawn card keeps the same address, and Facebook's documentation says to give an updated image a new URL if you want the new one shown[2]. A post whose card I redraw can go on showing the old one there for a while.
  • Long titles get cut. A card has room for three lines of 64 pixel Lora. One title runs to four: the Rothfuss post, whose card stops at "and the Architecture of..." and drops "Creative Friction" altogether.
Share card for the Rothfuss post: temple columns under a cloudy sky, the title stopping after three lines at and the Architecture of...
© Ken Reid. All rights reserved. The Rothfuss post's card. The title's last two words don't fit.

Every card lives at /img/og/<slug>.jpg, so you can look at one without pasting a link anywhere. This post's will be at /img/og/share-cards-drawn-by-a-script.jpg once it's published.

Share card for this post: a night photo of light trails on a road, the title Share Cards Drawn by a Script in white serif type, and kenreid.co.uk in a pink-to-gold gradient
© Ken Reid. All rights reserved. This post's own card.

Common questions

Does a post need a photograph for this to work?

No. Without one the card is the dark base colour with the same type on it, but every post has a picture anyway, and 20 of the 68 use something other than one of my photographs, most of them a demo, a chart or a map made for the post.

Does the card change when a post is edited?

Only when something on it changes: the title, the category, the date, the photo, or the read time. An edit that tips the read time over a minute boundary redraws the card with the new kicker.

References

  1. The Open Graph protocol. Basic Metadata. https://ogp.me/
  2. Meta for Developers. Images in Link Shares. https://developers.facebook.com/docs/sharing/webmasters/images/
  3. Pillow. ImageFont module. https://pillow.readthedocs.io/en/stable/reference/ImageFont.html
  4. fontTools. TTFont. https://fonttools.readthedocs.io/en/latest/ttLib/ttFont.html