#!/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()