#!/usr/bin/env python3 # -*- coding: utf-8 -*- """build.py — the Felhom.eu Facebook Page pictures: two profile pictures, three covers, one preview page. Run: python3 marketing/facebook/build.py Exit: 0 every image built and every check passed · 1 a check failed · 2 a render failed Renderer: Pillow only (shapes, text, compositing). Text is set in the website's own Plus Jakarta Sans (website/assets/fonts/*.woff2), which Pillow reads through FreeType. NOT librsvg: tried 2026-10-08 — pango fell back to DejaVu for the woff2 fonts, and logo.svg's own lettering is live in 'M+ 2c' / 'Vremena Grotesk', fonts DooPlex does not have, so the SVG renders with the wrong letters. The logo master is therefore website/assets/logo.png (645 x 408, the file the site itself shows), placed unscaled. Checks (each prints its numbers; a failure exits 1): circle every logo pixel of a profile picture lies inside radius 0.42 x width of the centre (8 % margin). Shown failing first on the shape the Page has today (logo.png, full size, on a square). safe every pixel of a cover's CONTENT layer (text, logo, drawings — not the background) lies inside the area both desktop and phone show, and outside the places the profile circle covers. size each PNG's size is read back from the file, never taken from the constants here. Sizes (graded READ, second-hand — Meta's own help page needs a browser; see README.md for the URLs): cover shown 820 x 312 on computers, 640 x 360 on phones; built at 2 x = 1640 x 624. profile shown 176 px (computer) / 196 px (phone, older layouts) — built at the size where logo.png fits unscaled. profile circle over the cover: computer 16 px from the left, 176 px from the top (176 px circle); phone 24 px from the left, 196 px from the top (140 px circle). """ import base64 import math import os import sys from PIL import Image, ImageDraw, ImageFilter, ImageFont HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) sys.path.insert(0, HERE) ASSETS = os.path.join(ROOT, "website", "assets") OUT = os.path.join(HERE, "out") LOGO_PNG = os.path.join(ASSETS, "logo.png") SHOT = os.path.join(ASSETS, "dashboard-backups-hu.webp") PJS = os.path.join(ASSETS, "fonts", "pjs-latin.woff2") PJS_EXT = os.path.join(ASSETS, "fonts", "pjs-latin-ext.woff2") def hexc(h, a=255): h = h.lstrip("#") return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), a) # website/assets/site.css :root — design system v2 tokens, verbatim (the site is dark-only). BG0, BG1, BG2 = hexc("#0A1220"), hexc("#0F1B2E"), hexc("#16263F") LINE = hexc("#22344F") TEXT1, TEXT2, TEXT3 = hexc("#EDF2F9"), hexc("#94A6BF"), hexc("#5E7392") BLUE, BLUE_BRIGHT = hexc("#0083D8"), hexc("#2EA8F5") WHITE = hexc("#FFFFFF") # The site's headline (index.html hero), from escapes (brief section 9.7). L1 = "Saj\u00e1t felh\u0151d," L2 = "saj\u00e1t szab\u00e1lyaid" SLOGAN = L1 + " " + L2 DOMAIN = "felhom.eu" CIRCLE_R = 0.42 # brief §7 A: the check radius, as a fraction of the width LOGO_R = 0.40 # where the logo's farthest pixel is placed (inside the check radius) CW, CH = 1640, 624 # cover, 2 x the 820 x 312 computer display SCALE = CW / 820.0 # Phone shows the full height at 640:360 → the centre CW_PHONE pixels of the width. CW_PHONE = CH * 640.0 / 360.0 PHONE_X0 = (CW - CW_PHONE) / 2.0 MARGIN = 40 SAFE = (int(math.ceil(PHONE_X0 + MARGIN)), MARGIN, int(CW - PHONE_X0 - MARGIN), CH - MARGIN) PAD = 24 DESK_CIRCLE = (16 * SCALE, 176 * SCALE, 176 * SCALE) # x, y, diameter in cover pixels PHONE_K = CH / 360.0 PHONE_CIRCLE = (PHONE_X0 + 24 * PHONE_K, 196 * PHONE_K, 140 * PHONE_K) QUIET = [(0, c[1] - PAD, c[0] + c[2] + PAD, CH) for c in (DESK_CIRCLE, PHONE_CIRCLE)] FAILS = [] def check(ok, msg): print((" ok " if ok else " FAIL ") + msg) if not ok: FAILS.append(msg) # ------------------------------------------------------------------ text in the site font _fonts = {} def font(size, weight, ext): key = (size, weight, ext) if key not in _fonts: f = ImageFont.truetype(PJS_EXT if ext else PJS, size) f.set_variation_by_name(weight) _fonts[key] = f return _fonts[key] def runs(text): """Split into runs by the CSS unicode-range of the two woff2 files (latin-ext = U+0100–02BA).""" out = [] for ch in text: ext = 0x100 <= ord(ch) <= 0x2BA if out and out[-1][0] == ext: out[-1][1] += ch else: out.append([ext, ch]) return out def text_width(text, size, weight): return sum(font(size, weight, e).getlength(s) for e, s in runs(text)) def draw_text(img, x, y, text, size, weight, fill, align="left"): """Draw at baseline y; x is the left edge, or the centre with align='center'. Returns the width.""" w = text_width(text, size, weight) if align == "center": x -= w / 2.0 d = ImageDraw.Draw(img) for e, s in runs(text): f = font(size, weight, e) d.text((x, y), s, font=f, fill=fill, anchor="ls") x += f.getlength(s) return w def fit_size(text, weight, max_w, start): size = start while text_width(text, size, weight) > max_w: size -= 1 return size # ------------------------------------------------------------------ the logo def alpha_points(img, thr=16): a = img.getchannel("A") w, h = img.size px = a.load() return [(x, y) for y in range(h) for x in range(w) if px[x, y] > thr] def outside_circle(img, bg, r_frac): """Count pixels that differ from the background and lie outside radius r_frac x width of the centre.""" w, h = img.size cx, cy, r = (w - 1) / 2.0, (h - 1) / 2.0, r_frac * w rgb = img.convert("RGB").load() n = 0 for y in range(h): dy2 = (y - cy) ** 2 for x in range(w): p = rgb[x, y] if max(abs(p[0] - bg[0]), abs(p[1] - bg[1]), abs(p[2] - bg[2])) > 24 and (x - cx) ** 2 + dy2 > r * r: n += 1 return n def today_shape(): """The Page today: logo.png at full size, unscaled, centred on a white square of its own width.""" src = Image.open(LOGO_PNG).convert("RGBA") w, h = src.size sq = Image.new("RGBA", (w, w), WHITE) sq.alpha_composite(src, (0, (w - h) // 2)) return sq def profile_width(): """The square size at which logo.png fits radius LOGO_R x width UNSCALED (never upsampled), at least 720.""" src = Image.open(LOGO_PNG).convert("RGBA") pts = alpha_points(src) xs, ys = [p[0] for p in pts], [p[1] for p in pts] cx, cy = (min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0 dmax = max(math.hypot(x - cx, y - cy) for x, y in pts) + 1 return max(720, int(math.ceil(dmax / LOGO_R / 2.0)) * 2), (cx, cy) def build_profile(bg, name): w, (cx, cy) = profile_width() logo = Image.open(LOGO_PNG).convert("RGBA") img = Image.new("RGBA", (w, w), bg) img.alpha_composite(logo, (int(round(w / 2.0 - cx)), int(round(w / 2.0 - cy)))) path = os.path.join(OUT, name) img.convert("RGB").save(path, optimize=True) return path # ------------------------------------------------------------------ covers def background(glow_at=(0.78, 0.25)): """bg-0 with the site's 50 px grid (3 % blue, index.html) and the og-image's soft blue glow; top blue rule.""" img = Image.new("RGBA", (CW, CH), BG0) glow = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) gd = ImageDraw.Draw(glow) gx, gy, gr = glow_at[0] * CW, glow_at[1] * CH, 520 gd.ellipse((gx - gr, gy - gr, gx + gr, gy + gr), fill=(0, 131, 216, 46)) img.alpha_composite(glow.filter(ImageFilter.GaussianBlur(180))) grid = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) d = ImageDraw.Draw(grid) step = int(50 * SCALE) for x in range(0, CW, step): d.line((x, 0, x, CH), fill=(0, 131, 216, 9), width=2) for y in range(0, CH, step): d.line((0, y, CW, y), fill=(0, 131, 216, 9), width=2) img.alpha_composite(grid) ImageDraw.Draw(img).rectangle((0, 0, CW, 8), fill=BLUE) return img def content_a(): c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) mid = (SAFE[0] + SAFE[2]) / 2.0 size = fit_size(SLOGAN, "ExtraBold", (SAFE[2] - SAFE[0]) - 120, 66) w1 = text_width(L1 + " ", size, "ExtraBold") w = text_width(SLOGAN, size, "ExtraBold") x0 = mid - w / 2.0 draw_text(c, x0, 238, L1 + " ", size, "ExtraBold", TEXT1) draw_text(c, x0 + w1, 238, L2, size, "ExtraBold", BLUE_BRIGHT) draw_text(c, mid, 300, DOMAIN, 34, "SemiBold", TEXT2, align="center") return c def rack(d, x, y, w, h, fill, dot): """Three stacked server units with status dots — the logo's own motif, drawn as plain shapes.""" uh = (h - 2 * 8) / 3.0 for i in range(3): ty = y + i * (uh + 8) d.rectangle((x, ty, x + w, ty + uh), fill=fill) for j in range(3): cx = x + 20 + j * 22 d.ellipse((cx - 5, ty + uh / 2 - 5, cx + 5, ty + uh / 2 + 5), fill=dot) def tile(d, cx, cy, s, kind): """A plain app tile (no third-party logo): a square with a simple generic glyph.""" x0, y0, x1, y1 = cx - s / 2, cy - s / 2, cx + s / 2, cy + s / 2 d.rectangle((x0, y0, x1, y1), fill=BG2, outline=LINE, width=3) g = s * 0.28 if kind == "photo": d.polygon([(cx - g, cy + g * 0.7), (cx - g * 0.2, cy - g * 0.5), (cx + g * 0.3, cy + g * 0.2), (cx + g * 0.6, cy - g * 0.1), (cx + g, cy + g * 0.7)], fill=BLUE_BRIGHT) d.ellipse((cx + g * 0.35, cy - g, cx + g * 0.85, cy - g * 0.5), fill=TEXT2) elif kind == "doc": d.rectangle((cx - g * 0.75, cy - g, cx + g * 0.75, cy + g), outline=TEXT1, width=5) for i in range(3): yy = cy - g * 0.45 + i * g * 0.45 d.line((cx - g * 0.45, yy, cx + g * 0.45, yy), fill=BLUE_BRIGHT, width=5) elif kind == "play": d.polygon([(cx - g * 0.6, cy - g), (cx + g, cy), (cx - g * 0.6, cy + g)], fill=BLUE_BRIGHT) elif kind == "lock": d.arc((cx - g * 0.6, cy - g * 1.2, cx + g * 0.6, cy), 180, 360, fill=TEXT1, width=6) d.rectangle((cx - g * 0.85, cy - g * 0.6, cx + g * 0.85, cy + g * 0.9), fill=TEXT1) d.ellipse((cx - g * 0.18, cy - g * 0.1, cx + g * 0.18, cy + g * 0.26), fill=BG2) elif kind == "folder": d.polygon([(cx - g, cy - g * 0.7), (cx - g * 0.2, cy - g * 0.7), (cx, cy - g * 0.4), (cx + g, cy - g * 0.4), (cx + g, cy + g * 0.8), (cx - g, cy + g * 0.8)], fill=BLUE_BRIGHT) def content_b(): c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) d = ImageDraw.Draw(c) tx = SAFE[0] + 24 # text stays above the profile circles (y < 316) draw_text(c, tx, 170, L1, 64, "ExtraBold", TEXT1) draw_text(c, tx, 244, L2, 64, "ExtraBold", BLUE_BRIGHT) draw_text(c, tx, 290, DOMAIN, 32, "SemiBold", TEXT2) # the home with its server, on the right hx, hy, hw = 1010, 300, 220 # house body: left, top, width tiles = [(975, 110, "photo"), (1140, 92, "doc"), (1260, 210, "play"), (1260, 400, "lock"), (970, 520, "folder")] for x, y, _ in tiles: d.line((hx + hw / 2, hy + 90, x, y), fill=(46, 168, 245, 110), width=4) d.rectangle((hx, hy, hx + hw, hy + 190), fill=BG1, outline=BLUE_BRIGHT, width=6) d.polygon([(hx - 30, hy + 4), (hx + hw / 2, hy - 120), (hx + hw + 30, hy + 4)], fill=BG1, outline=BLUE_BRIGHT) d.line([(hx - 30, hy + 4), (hx + hw / 2, hy - 120), (hx + hw + 30, hy + 4)], fill=BLUE_BRIGHT, width=6, joint="curve") rack(d, hx + 40, hy + 40, hw - 80, 110, BLUE, TEXT1) for x, y, kind in tiles: tile(d, x, y, 92, kind) return c def content_c(): c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0)) d = ImageDraw.Draw(c) tx = SAFE[0] + 24 draw_text(c, tx, 180, L1, 52, "ExtraBold", TEXT1) draw_text(c, tx, 246, L2, 52, "ExtraBold", BLUE_BRIGHT) draw_text(c, tx, 292, DOMAIN, 30, "SemiBold", TEXT2) shot = Image.open(SHOT).convert("RGBA") # The domain chip next to the title names the demo box; covered with the page colour (copy only, source unchanged). ImageDraw.Draw(shot).rectangle((684, 32, 826, 70), fill=hexc("#0B1322")) sw = 470 sh = int(round(sw * shot.height / shot.width)) shot = shot.resize((sw, sh), Image.LANCZOS) fx, fy, b = 800, 110, 14 # frame left/top, bezel d.rectangle((fx, fy, fx + sw + 2 * b, fy + sh + 2 * b), fill=BG2, outline=LINE, width=3) c.alpha_composite(shot, (fx + b, fy + b)) base_y = fy + sh + 2 * b d.polygon([(fx - 30, base_y + 22), (fx + sw + 2 * b + 30, base_y + 22), (fx + sw + 2 * b, base_y), (fx, base_y)], fill=LINE) return c def check_safe(name, content): pts = alpha_points(content.resize((CW // 4, CH // 4)), thr=8) xs = [p[0] * 4 for p in pts] ys = [p[1] * 4 for p in pts] bbox = (min(xs), min(ys), max(xs) + 3, max(ys) + 3) inside = bbox[0] >= SAFE[0] and bbox[1] >= SAFE[1] and bbox[2] <= SAFE[2] and bbox[3] <= SAFE[3] check(inside, "%s: content box %s inside the safe area %s" % (name, bbox, SAFE)) hits = 0 for q in QUIET: hits += sum(1 for x, y in zip(xs, ys) if q[0] <= x <= q[2] and q[1] <= y <= q[3]) check(hits == 0, "%s: %d content pixels where the profile circle sits (computer or phone)" % (name, hits)) def build_cover(name, content, glow): img = background(glow) img.alpha_composite(content) path = os.path.join(OUT, name) img.convert("RGB").save(path, optimize=True) return path # ------------------------------------------------------------------ preview def b64(path): with open(path, "rb") as fh: return "data:image/png;base64," + base64.b64encode(fh.read()).decode() def preview(profiles, covers): dk, ph = DESK_CIRCLE, PHONE_CIRCLE sx0, sy0, sx1, sy1 = SAFE pct = lambda v, tot: "%.3f%%" % (100.0 * v / tot) # noqa: E731 prof_html = "".join( '
' '
%s — 176 px, 96 px, 40 px
' % (b64(p), b64(p), b64(p), label) for label, p in profiles) cov = [] rec = profiles[0][1] for label, p in covers: src = b64(p) safe = ('
' % (pct(sx0, CW), pct(sy0, CH), pct(sx1 - sx0, CW), pct(sy1 - sy0, CH))) desk = ('
%s
' % (src, safe, b64(rec), pct(dk[0], CW), pct(dk[1], CH), pct(dk[2], CW))) crop_l = PHONE_X0 / CW_PHONE * 100 phone = ('
' '
' % (src, CW / CW_PHONE * 100, crop_l, b64(rec), pct(ph[0] - PHONE_X0, CW_PHONE), pct(ph[1], CH), pct(ph[2], CW_PHONE))) cov.append('

%s

Computer (820 x 312), dashed = safe area (preview only)

%s' '

Phone (640 x 360 crop)

%s
' % (label, desk, phone)) html = """ Facebook Page pictures

Felhom.eu - Facebook pictures

Profile picture: the DARK background is recommended. The logo's lettering is white with a thin navy edge, so on white it fades at 40 px; on dark it reads solid, as on the website. The covers below show the dark one.

Profile picture

%s %s
""" % (prof_html, "".join(cov)) path = os.path.join(OUT, "preview.html") with open(path, "w", encoding="utf-8") as fh: fh.write(html) return path # ------------------------------------------------------------------ main def main(): os.makedirs(OUT, exist_ok=True) print("circle check (radius %.2f x width)" % CIRCLE_R) try: before = today_shape() n0 = outside_circle(before, WHITE, CIRCLE_R) print(" today's shape (logo.png %dx%d on a %d square): %d logo pixels outside the circle — expected > 0" % (Image.open(LOGO_PNG).width, Image.open(LOGO_PNG).height, before.width, n0)) check(n0 > 0, "the check convicts today's shape (control)") prof = [("profile-white.png", WHITE), ("profile-dark.png", BG0)] built = [] for name, bg in prof: p = build_profile(bg, name) n = outside_circle(Image.open(p), bg, CIRCLE_R) check(n == 0, "%s: %d logo pixels outside the circle" % (name, n)) built.append(p) print("safe-area check (computer 820x312 + phone 640x360; profile circles kept clear)") covers = [("cover-a.png", content_a(), (0.78, 0.25)), ("cover-b.png", content_b(), (0.70, 0.45)), ("cover-c.png", content_c(), (0.66, 0.40))] cpaths = [] for name, content, glow in covers: check_safe(name, content) cpaths.append(build_cover(name, content, glow)) except RuntimeError as e: print("RENDER FAILED: %s" % e) return 2 print("sizes read back from the files") for p in built + cpaths: w, h = Image.open(p).size want = (profile_width()[0],) * 2 if "profile" in p else (CW, CH) check((w, h) == want, "%s: %d x %d" % (os.path.basename(p), w, h)) pv = preview([("Dark background (recommended)", built[1]), ("White background", built[0])], [("A - calm", cpaths[0]), ("B - the idea", cpaths[1]), ("C - the product", cpaths[2])]) print("preview: %s (%d KB)" % (os.path.relpath(pv, ROOT), os.path.getsize(pv) // 1024)) if FAILS: print("FAILED: %d check(s)" % len(FAILS)) return 1 print("all checks passed") return 0 if __name__ == "__main__": sys.exit(main())