Files
felhom.eu/scripts/make_og_card.py
T
admin c0a2953bea
gates / gates (push) Successful in 6m15s
website: the share card's wordmark is the brand's own, with the blue .eu
The old card's lettering was plain white and in some other face. The card now
takes assets/logo-wordmark.png - the same file the sidebar shows - scaled to the
old lettering's ink height and alpha-composited in its place, so felhom is white,
.eu is blue, and the drawn outline is there. The cloud mark beside it is still
the old card's pixels, which were always right. og:image ?v=3.
2026-10-12 15:34:55 +02:00

155 lines
7.1 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""make_og_card.py - rebuild the link-preview cards (og:image) around the laptop.
python scripts/make_og_card.py [--lang hu|en|both] [--check]
WHAT IT IS FOR. The card Facebook, Messenger, LinkedIn and Slack show when somebody shares
felhom.eu. The 2026-07-18 cards were a headline and two lines of text on a dark field: they showed
the words and none of the product, and they predate the site's current look (operator, 2026-10-12:
"the image is outdated"). This puts the same laptop the home page shows - the frame with the real
screen recording's first frame in it - beside the headline.
HOW IT KEEPS THE TYPOGRAPHY. The site's font is a woff2 (Plus Jakarta Sans); no font file on this
machine or on DooPlex can be given to a drawing library, and installing one to draw two lines of text
would be a new dependency for a once-a-year picture. So the headline, the logo and the button are
taken as PIXELS from the previous card, which was rendered with the real font: they are cropped,
the headline is scaled to free the right half, and they are composited onto a flat --bg-0 field with
`lighter`, which makes the crop's own background disappear into the new one (both are #0A1220, so
there is no seam to see). Nothing here draws a glyph.
IF THE WORDS EVER CHANGE this script cannot help - a new headline needs the font, so the card goes
back to the design tool, and this script then takes the new card as its source.
INPUT website/assets/og-image.png, og-image-en.png (the previous cards - the typography)
website/assets/hero-laptop-frame.webp (the laptop, screen cut to transparency)
website/assets/hero-poster.webp (what the hero's clip opens on)
OUTPUT the same two card files, 1200x630, overwritten.
--check prints where the ink sits on the current cards and changes nothing.
"""
import argparse
import os
import sys
from PIL import Image, ImageChops
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
A = os.path.join(ROOT, "website", "assets")
W, H = 1200, 630
BG = (10, 18, 32) # --bg-0, which is what sits behind the headline on the old cards
TOPBAR_H = 6
LEFT = 73 # the logo's own left edge on the old cards
HEAD_TOP = 206 # where the headline's ink starts on the new card
HEAD_WIDTH = 498 # how wide the headline is allowed to be, to leave the laptop its half
LAPTOP_W = 548
LAPTOP_X = 616
# The screen hole in hero-laptop-frame.webp, measured off its alpha channel (2026-10-12):
HOLE = (0.11041, 0.12345, 0.79817, 0.64305)
CARDS = {
# lang: (file, headline band, pill band) - bands are y ranges on the OLD card, generous enough
# to hold accents and descenders
"hu": ("og-image.png", (200, 372), (510, 572)),
"en": ("og-image-en.png", (208, 382), (510, 572)),
}
LOGO_BAND = (76, 132)
# The old card's wordmark was plain white; the brand's is "felhom" white and ".eu" blue (operator,
# 2026-10-12). It is not a font we can set - it is drawn lettering with an outline - so the card
# takes assets/logo-wordmark.png, the same file the sidebar shows, instead of the old card's pixels.
MARK_X = (73, 153) # the cloud mark on the old card, which is unchanged
WORD_AT = (176, 89) # where the old card's lettering began, and where the real one goes
WORD_INK_H = 34 # its ink height there
def ink_bbox(im, y0, y1, thresh=300):
"""The bounding box of everything brighter than the background in a horizontal band."""
px = im.convert("RGB").load()
xs, ys = [], []
for y in range(y0, y1 + 1):
for x in range(im.size[0]):
if sum(px[x, y]) > thresh:
xs.append(x)
ys.append(y)
if not xs:
sys.exit("no ink in rows %d..%d - the source card is not the one this script expects" % (y0, y1))
return min(xs), min(ys), max(xs) + 1, max(ys) + 1
def laptop():
"""The frame with the hero's poster frame inside its cut-out screen."""
frame = Image.open(os.path.join(A, "hero-laptop-frame.webp")).convert("RGBA")
shot = Image.open(os.path.join(A, "hero-poster.webp")).convert("RGB")
fw, fh = frame.size
x, y = int(round(HOLE[0] * fw)), int(round(HOLE[1] * fh))
w, h = int(round(HOLE[2] * fw)), int(round(HOLE[3] * fh))
out = Image.new("RGBA", frame.size, (0, 0, 0, 0))
out.paste(shot.resize((w, h), Image.LANCZOS), (x, y))
out.alpha_composite(frame)
return out
def build(lang, check):
src_name, head_band, pill_band = CARDS[lang]
src_path = os.path.join(A, src_name)
src = Image.open(src_path).convert("RGB")
if src.size != (W, H):
sys.exit("%s is %dx%d, expected %dx%d" % (src_name, src.size[0], src.size[1], W, H))
logo = ink_bbox(src, *LOGO_BAND)
head = ink_bbox(src, *head_band)
pill = ink_bbox(src, *pill_band)
if check:
print("%s logo %s headline %s button %s" % (src_name, logo, head, pill))
return
card = Image.new("RGB", (W, H), BG)
card.paste(src.crop((0, 0, W, TOPBAR_H)), (0, 0)) # the blue rule along the top
lap = laptop()
lh = int(round(LAPTOP_W * lap.size[1] / float(lap.size[0])))
card.paste(lap.resize((LAPTOP_W, lh), Image.LANCZOS), (LAPTOP_X, (H - lh) // 2),
lap.resize((LAPTOP_W, lh), Image.LANCZOS))
# the three text blocks, lifted as pixels and laid down with `lighter` so their own
# background (the same #0A1220) vanishes instead of leaving a rectangle
def lay(box, at, scale=1.0):
part = src.crop(box)
if scale != 1.0:
part = part.resize((max(1, int(round(part.size[0] * scale))),
max(1, int(round(part.size[1] * scale)))), Image.LANCZOS)
region = card.crop((at[0], at[1], at[0] + part.size[0], at[1] + part.size[1]))
card.paste(ImageChops.lighter(region, part), at)
lay((MARK_X[0], LOGO_BAND[0], MARK_X[1], LOGO_BAND[1]), (MARK_X[0], LOGO_BAND[0]))
word = Image.open(os.path.join(A, "logo-wordmark.png")).convert("RGBA")
wb = word.getbbox() # the lettering's own edges inside its canvas
word = word.crop(wb)
ws = WORD_INK_H / float(word.size[1])
word = word.resize((int(round(word.size[0] * ws)), WORD_INK_H), Image.LANCZOS)
card.paste(word, WORD_AT, word)
scale = HEAD_WIDTH / float(head[2] - head[0])
lay(head, (LEFT, HEAD_TOP), scale)
lay(pill, (LEFT - 1, pill[1]))
card.save(src_path, "PNG", optimize=True)
print("%-16s %4dx%d %6.1f KB (headline scaled %.2f, laptop %dpx)"
% (src_name, W, H, os.path.getsize(src_path) / 1024.0, scale, LAPTOP_W))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--lang", default="both", choices=["hu", "en", "both"])
ap.add_argument("--check", action="store_true", help="print the measured bands, change nothing")
a = ap.parse_args()
for lang in (["hu", "en"] if a.lang == "both" else [a.lang]):
build(lang, a.check)
if not a.check:
print("Bump the ?v= on the og:image / twitter:image URLs, then re-scrape the page in "
"Facebook's Sharing Debugger - a card already attached to a post does not change.")
if __name__ == "__main__":
main()