Files
felhom.eu/marketing/facebook/build.py
T
admin f0dc6a9d4e
gates / gates (push) Successful in 4m24s
Facebook Page: profile pictures, three covers, preview page and texts (marketing/facebook); R-916 opened
build.py (Pillow, site tokens + Plus Jakarta Sans) with in-build checks: profile circle (today's shape 6823 pixels
outside, new 0), cover safe area and profile-circle clearance, sizes read back. COPY.md: intro 99/100, long
description, Messenger welcome, three first posts, each claim traced to the website. R-914 gets the listing-based
removal proof and the picture-API tasks; R-915 the footer-link note; R-916 the logo's vector master.
2026-10-08 19:19:50 +02:00

431 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <text> 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(
'<figure><div class="row"><img class="pc" src="%s" alt=""><img class="pc sm" src="%s" alt="">'
'<img class="pc xs" src="%s" alt=""></div><figcaption>%s — 176 px, 96 px, 40 px</figcaption></figure>'
% (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 = ('<div class="safe" style="left:%s;top:%s;width:%s;height:%s"></div>'
% (pct(sx0, CW), pct(sy0, CH), pct(sx1 - sx0, CW), pct(sy1 - sy0, CH)))
desk = ('<div class="desk"><img src="%s" alt="">%s<img class="ov" src="%s" alt="" '
'style="left:%s;top:%s;width:%s"></div>'
% (src, safe, b64(rec), pct(dk[0], CW), pct(dk[1], CH), pct(dk[2], CW)))
crop_l = PHONE_X0 / CW_PHONE * 100
phone = ('<div class="phone"><div class="crop"><img src="%s" alt="" style="width:%.3f%%;left:-%.3f%%">'
'</div><img class="ov" src="%s" alt="" style="left:%s;top:%s;width:%s"></div>'
% (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('<section><h2>%s</h2><p class="k">Computer (820 x 312), dashed = safe area (preview only)</p>%s'
'<p class="k">Phone (640 x 360 crop)</p>%s</section>' % (label, desk, phone))
html = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Facebook Page pictures</title>
<style>
:root{--bg-0:#0A1220;--bg-1:#0F1B2E;--line:#22344F;--text-1:#EDF2F9;--text-2:#94A6BF;--blue:#0083D8}
body{margin:0;background:var(--bg-0);color:var(--text-1);font:15px/1.5 -apple-system,'Segoe UI',sans-serif;padding:24px 16px}
main{max-width:860px;margin:0 auto}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:32px 0 8px}
.k{color:var(--text-2);margin:12px 0 6px;font-size:13px}figure{margin:16px 0}.row{display:flex;gap:16px;align-items:center;flex-wrap:wrap}
.pc{width:176px;height:176px;border-radius:50%%;border:4px solid #fff;display:block}.sm{width:96px;height:96px;border-width:3px}
.xs{width:40px;height:40px;border-width:1px}figcaption{color:var(--text-2);font-size:13px;margin-top:6px}
.desk{position:relative;max-width:820px;margin-bottom:56px}.desk>img:first-child{width:100%%;display:block}
.ov{position:absolute;border-radius:50%%;border:3px solid #fff;box-sizing:border-box}
.safe{position:absolute;border:2px dashed #E0A93E;box-sizing:border-box;pointer-events:none}
.phone{position:relative;width:min(360px,100%%);aspect-ratio:640/360;margin-bottom:48px}
.crop{position:absolute;inset:0;overflow:hidden}.crop img{position:absolute;top:0;height:100%%}
</style></head><body><main>
<h1>Felhom.eu - Facebook pictures</h1>
<p class="k">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.</p>
<h2>Profile picture</h2>%s
%s
</main></body></html>
""" % (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())