Skip to main content

Open Graph images with Pillow: a 1200×630 card in about 100 ms, no headless browser

Variable fonts, a gradient mask built from a 1-pixel column, text that shrinks to fit, and lazy baking on first request

Updated 9 min readMarkdown
Open Graph images with Pillow: a 1200×630 card in about 100 ms, no headless browser

Every event page on our site has a nice header: a city photo under a dark gradient, the dates in a bright accent colour, a big title, the venue underneath. It is plain HTML and CSS, and it looks good in a browser.

Then someone shares the link in a messenger, and the preview is a grey box with a cropped logo. Link previews are drawn by crawlers. They do not run your CSS, they fetch one image from og:image and that is all the reader sees.

So we needed a real 1200x630 picture for every page, generated from the same data, without a designer in the loop. This post is how we draw those cards with Pillow and why we did not reach for a headless browser.

Why not screenshot the HTML

The obvious move is to render the existing header in Chromium and take a screenshot. We already have the markup, so the card would always match the page.

We decided against it for boring operational reasons. A headless browser on a small web server is a few hundred megabytes of RAM per instance, zombie processes when something times out, and a font stack that differs from the developer laptop. We had already fought stuck Chrome processes in another project and did not want a second one.

Pillow is already a dependency, starts instantly, and draws exactly the pixels you tell it to. The trade-off is that you lay out text by hand. For one fixed template, that turned out to be about 120 lines.

The layout, top to bottom

The card has a fixed structure, and fixing it is what makes hand layout manageable:

  • background: a city photo, cropped to cover, or a branded gradient if there is no photo;
  • a darkening mask, light at the top and dense at the bottom;
  • a thin accent stripe along the top edge, the wordmark on the left, a small category label on the right;
  • at the bottom, built upwards: venue, city, title (one or two lines), dates.

Building the bottom block from the bottom edge up is the key trick. The title can be one or two lines, the venue is optional, and if you lay things out top-down every combination moves the baseline. Bottom-up, the last line always sits 64 px above the edge and everything else stacks on top of it.

Variable fonts in Pillow

Our site uses two variable fonts, one for headings and one for body text. Shipping six static weights just for the card felt wrong, and Pillow can set variation axes directly:

from PIL import ImageFont

def font(path: str, size: int, weight: int):
    f = ImageFont.truetype(path, size)
    try:
        values = []
        for axis in f.get_variation_axes():
            name = axis["name"]
            name = name.decode() if isinstance(name, bytes) else str(name)
            if "weight" in name.lower():
                values.append(max(axis["minimum"], min(axis["maximum"], weight)))
            elif "optical" in name.lower():
                values.append(min(axis["maximum"], max(axis["minimum"], size)))
            else:
                values.append(axis["default"])
        f.set_variation_by_axes(values)
    except Exception:
        pass  # static font, or FreeType built without variations
    return f

Two details matter here. Axis names come back as bytes on some builds and as strings on others, so we normalise them. And the optical size axis, if the font has one, should follow the pixel size: a 74 px title drawn with the 12 px optical master looks clumsy and too wide.

The bare except is deliberate. If the server's FreeType lacks variation support, we still get a usable card in the default weight instead of a 500 on the page.

Covering the frame with a photo

City photos come in every aspect ratio. We want CSS object-fit: cover behaviour: scale until both sides fill the frame, then crop the centre.

W, H = 1200, 630

def cover(im):
    scale = max(W / im.width, H / im.height)
    im = im.resize((max(W, round(im.width * scale)),
                    max(H, round(im.height * scale))), Image.LANCZOS)
    left, top = (im.width - W) // 2, (im.height - H) // 2
    return im.crop((left, top, left + W, top + H))

The max(W, ...) guards against rounding leaving the image one pixel short of the frame, so the crop never has to pad.

When there is no photo, we draw two large ellipses in muted brand colours on a near-black canvas and blur them with a 120 px Gaussian. That blur is the slowest step in the whole pipeline, more on that below.

The gradient mask from a one-pixel column

Text has to be readable on any photo: bright sky, white buildings, snow. A uniform dark overlay kills the photo, so we want a gradient that is almost transparent at the top and nearly opaque behind the text.

The naive way is a nested loop over 756,000 pixels in Python. Instead, we compute one column of 630 values and let Pillow stretch it:

shade = Image.new("L", (1, H))
for y in range(H):
    t = y / (H - 1)
    shade.putpixel((0, y), int(55 + 180 * t ** 1.35))
shade = shade.resize((W, H))

dark = Image.new("RGB", (W, H), (8, 12, 11))
im = Image.composite(dark, im, shade)

Image.composite uses the mask as per-pixel alpha: 55 out of 255 at the top, 235 at the bottom. The exponent of 1.35 keeps the upper half of the photo visible longer and makes the darkening ramp up where the text starts. Building the mask this way takes about 2 ms.

Text that fits

Titles are the hard part. Event names range from four letters to something like "Annual Summit and Awards Night of the Northern Region". We allow at most two lines, and we want the largest size that fits.

Word wrapping is a greedy loop over draw.textlength, which measures the real rendered width with kerning:

def wrap(draw, text, font, max_w, max_lines):
    lines, cur = [], ""
    for word in text.split():
        probe = f"{cur} {word}".strip()
        if draw.textlength(probe, font=font) <= max_w or not cur:
            cur = probe
        else:
            lines.append(cur)
            cur = word
    if cur:
        lines.append(cur)
    if len(lines) > max_lines:
        lines = lines[:max_lines]
        last = lines[-1]
        while draw.textlength(last + "...", font=font) > max_w and " " in last:
            last = last.rsplit(" ", 1)[0]
        lines[-1] = last + "..."
    return lines

The or not cur branch handles a single word wider than the frame: it goes on its own line rather than looping forever. The ellipsis step removes whole words until the last line plus "..." fits, so we never cut a word in half.

On top of wrapping, the title shrinks. We start at 74 px and step down by 4 px, to a floor of 52, while any line is still too wide. Short names stay big and punchy, long ones get smaller before they get truncated.

Things Pillow will not draw

Our event data stores cities with a flag emoji in front. Pillow renders text with a single font file and no fallback chain, so the flag became an empty box at the start of every city name.

The fix is one regex that strips leading non-word characters before drawing. It is a small thing, but it is the kind of bug you only see when you open the image, because the HTML page looks fine: the browser happily falls back to an emoji font.

The same applies to any glyph your chosen font lacks. If your data is multilingual, check that the font covers every script you store, or you will ship boxes in exactly the languages you test least.

When the image gets made

We do not generate cards in a batch job. The page handler checks whether the stored image path exists on disk, and if not, bakes it on the spot:

if not ev.og_image or not os.path.exists(disk_path(ev.og_image)):
    try:
        ev.og_image = make_og_for_event(ev) or ""
        db.commit()
    except Exception:
        db.rollback()  # the page matters more than the preview

This gives us a few properties for free. New events get a card on their first view. Deleting the image folder regenerates everything lazily. And if Pillow is not installed at all, make_og returns an empty string, the page renders without og:image, and nothing breaks.

There is also an admin action that replaces the background photo and re-bakes the card immediately, because the crawler caches previews and you want the right image there before the first share.

Numbers

On a laptop, one card with a photo background takes about 75 ms and produces a JPEG of about 100 KB at quality 86 with progressive encoding. Without a photo it is about 107 ms, slower because of the 120 px blur on the full canvas.

Our event folder currently holds a little over two hundred cards, 20 MB in total, averaging just under 100 KB each. That is well within what link preview crawlers accept, and small enough that we never thought about a CDN for them.

What we would do differently

Blur a smaller canvas. The gradient background could be drawn at a quarter of the size, blurred with a 30 px radius, and upscaled: the result is visually identical and several times faster.

Version the URL. Crawlers cache previews aggressively, and when we re-bake a card at the same path, some platforms keep the old one for days. Appending a short hash of the inputs to the filename would make every change a new URL.

Keep the template frozen. The biggest win was refusing to make the layout flexible. One structure, built bottom-up, with exactly one element that adapts its size. Every extra degree of freedom in hand layout multiplies the edge cases you have to look at.

We use this at SpinHire, a job board for the iGaming industry, for event pages and their link previews.

About the author

  • SpinHire (spinhire.io) is a job board built only for the iGaming industry: casino, sports betting, game studios, affiliates, payments and compliance. We index 6,600+ live roles from 430+ companies every 6 hours and keep the data open: a public jobs API without a key (CC BY 4.0), XML and RSS feeds, and an MCP server for AI assistants. Here we write about building that index, iGaming tech careers and developer salaries.

No comments yet. Be the first to comment.