Files
felhom.eu/marketing/facebook/build.py
T
admin 796a9fdf40
gates / gates (push) Successful in 4m28s
marketing/facebook: covers rebuilt to the MEASURED phone view (R-919 -> VERIFY)
build.py believed a phone shows 640x360 of the cover. It shows 412x274 =
1.504:1 -- the full height and only the centre 938 px of 1640, 351 px off each
side. PHONE_HDR = (412, 274) now drives CW_PHONE, so SAFE is (391,40)-(1249,584),
858 px wide with a 40 px margin inside each crop edge.

QUIET is no longer one formula for both circles: the computer circle is
left-anchored, the phone circle is CENTRED and hides only its measured box
(637,369,393). The old shared formula, applied to a centred circle, would have
blanked everything from x 0 to x 1054 below y 345.

Every cover draws in a derived BAND (408,48)-(1249,340), and a cap check holds
each headline's capital at >= 4% of the cover height; A/B/C come out at 45, 45
and 37 px against a 25 px floor.

RED-PROOF, and it is permanent: control_old_window() runs on every build and
draws the headline where the old assumption put it (x 328); the check must
reject it. The phone's crop edge is x 351, so 23 px were cut; the measured safe
edge is x 391. Against the covers as committed at a76207945e the new check
convicted 3 of 3 -- A 23/22 px past the left/right edges, B 63/58 px plus 1017
content pixels under the phone circle, C 63/82 px plus 1778.

Covers redrawn inside the band: A one centred line; B the home motif scaled
with two tiles moved into the lower-right strip, the one area below the band a
phone still shows beside the profile circle; C the dashboard screen 470 -> 370
px so frame and base fit. preview.html shows the phone panel at the measured
938x624 with the centred circle and no longer claims 640x360.

Profile pictures untouched -- sha256 identical before and after, and not in the
diff. Built on DooPlex: the PNGs are byte-reproducible there (Pillow 11.1.0) and
not across Pillow versions; the README now says so.

R-919 is VERIFY, not closed: the geometry is measured on ONE emulated device in
the mobile website, so the operator's check in the Facebook app is the
acceptance. Nothing uploaded, no Graph API call, FACEBOOK_API never read.
2026-10-09 06:59:53 +02:00

505 lines
25 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. Shown failing
first on the covers as committed at a76207945e — all three were cut on phones (R-919).
cap a cover's headline is set at a capital-letter height of at least CAP_MIN, so it stays readable
once the phone has shrunk the cover to 412 px wide.
size each PNG's size is read back from the file, never taken from the constants here.
Sizes. The COMPUTER figures are graded READ, second-hand (820 x 312; built at 2 x = 1640 x 624; the
profile circle 16 px from the left, 176 px from the top, 176 px across). The PHONE figures are graded
MEASURED — see the constants block: one device (Pixel 9 emulated, 412 x 924, the mobile WEBSITE), NOT the
Facebook app and no other width. Meta's own help page claims 2,4:1 for the phone cover and is wrong; that
figure is what cut „saját szabályaid" down to „aját szabályaid" on every phone (R-919). The profile picture
is shown 176 px (computer) / 196 px (phone) and is built at the size where logo.png fits unscaled.
"""
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
# --- the phone view: MEASURED, never read off Meta's help page (R-919) -------------------------------
# Measured 2026-10-08 on the live Page in Chrome DevTools device mode, Pixel 9 (412 x 924, mobile user
# agent, AFTER a reload — without one Facebook keeps serving the desktop bundle). The Page's mobile
# header renders 412 x 274 CSS px = 1,504 : 1, so Facebook keeps the cover's FULL HEIGHT and shows only
# the centre CW_PHONE pixels of its width. Evidence and the recipe to re-measure:
# documentation/audits/facebook-page-setup-2026-10-08/ (README.md, C2-phone-headline-cut-closeup.png).
# GRADE: measured on ONE device, in the mobile WEBSITE. NOT measured in the Facebook app and not at any
# other width — which is why MARGIN below is deliberate and why the operator's app check is the
# acceptance step, not this build.
PHONE_HDR = (412.0, 274.0)
CW_PHONE = CH * PHONE_HDR[0] / PHONE_HDR[1] # 938.3 of 1640 — about 351 px lost from EACH side
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
# x, y, diameter in cover pixels. The COMPUTER circle is UNCHANGED (second-hand, and left-anchored: it
# sits against the cover's left edge, so everything to its left is under it too). On 2026-10-08 the
# computer circle was measured to sit 16 px BELOW the cover and to overlap nothing — this stays as a
# deliberate conservatism, not because it is still true.
DESK_CIRCLE = (16 * SCALE, 176 * SCALE, 176 * SCALE)
# The PHONE circle is MEASURED and CENTRED: 172 display px over the bottom middle of the header, which
# maps to source x 637..1030, y 369..bottom. A centred circle hides its own box only — never the whole
# width to its left, which is why QUIET can no longer be one formula for both.
PHONE_CIRCLE = (637.0, 369.0, 393.0)
QUIET = [(0, DESK_CIRCLE[1] - PAD, DESK_CIRCLE[0] + DESK_CIRCLE[2] + PAD, CH),
(PHONE_CIRCLE[0] - PAD, PHONE_CIRCLE[1] - PAD, PHONE_CIRCLE[0] + PHONE_CIRCLE[2] + PAD, CH)]
# The band every cover draws in: inside SAFE, clear of the computer circle on the left, above the phone
# circle. Derived, so a re-measurement moves the designs with it.
BAND = (max(SAFE[0], int(math.ceil(DESK_CIRCLE[0] + DESK_CIRCLE[2] + PAD))), SAFE[1] + 8,
SAFE[2], int(PHONE_CIRCLE[1] - PAD) - 5)
CAP_MIN = int(round(0.04 * CH)) # a headline capital is at least 4 % of the cover height (25 px at 624)
# What this file believed before R-919: that a phone shows 640 x 360 of the cover. Kept ONLY so the
# control below can convict it - a check that has never been seen failing is a wish, not a check.
OLD_SAFE_X0 = (CW - CH * 640.0 / 360.0) / 2.0 + MARGIN
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():
"""Calm: the one line, centred in the band, with the domain under it."""
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
mid = (BAND[0] + BAND[2]) / 2.0
size = fit_size(SLOGAN, "ExtraBold", (BAND[2] - BAND[0]) - 20, 86)
w1 = text_width(L1 + " ", size, "ExtraBold")
w = text_width(SLOGAN, size, "ExtraBold")
x0 = mid - w / 2.0
draw_text(c, x0, 222, L1 + " ", size, "ExtraBold", TEXT1)
draw_text(c, x0 + w1, 222, L2, size, "ExtraBold", BLUE_BRIGHT)
draw_text(c, mid, 284, DOMAIN, 34, "SemiBold", TEXT2, align="center")
return c, size
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():
"""The idea: the headline on the left, a home with its server and its apps on the right. Both
columns live inside BAND -- the phone hides the cover's bottom middle behind the profile circle,
so nothing is drawn down there any more (R-919)."""
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(c)
tx = BAND[0] + 12
size = fit_size(L2, "ExtraBold", 456, 64) # the text column; the motif gets the rest
draw_text(c, tx, 170, L1, size, "ExtraBold", TEXT1)
draw_text(c, tx, 170 + size + 14, L2, size, "ExtraBold", BLUE_BRIGHT)
draw_text(c, tx, 170 + size + 14 + 46, DOMAIN, 30, "SemiBold", TEXT2)
# the home with its server, on the right, scaled to the band's height
hx, hy, hw = 1000, 190, 150 # house body: left, top, width
tiles = [(935, 95, "photo"), (1065, 84, "doc"), (1195, 140, "play"), (1200, 278, "lock"),
(928, 262, "folder"), (1155, 425, "photo"), (1110, 540, "doc")]
for x, y, _ in tiles:
d.line((hx + hw / 2, hy + 70, x, y), fill=(46, 168, 245, 110), width=4)
d.rectangle((hx, hy, hx + hw, hy + 130), fill=BG1, outline=BLUE_BRIGHT, width=6)
d.polygon([(hx - 30, hy + 4), (hx + hw / 2, hy - 85), (hx + hw + 30, hy + 4)], fill=BG1, outline=BLUE_BRIGHT)
d.line([(hx - 30, hy + 4), (hx + hw / 2, hy - 85), (hx + hw + 30, hy + 4)], fill=BLUE_BRIGHT, width=6, joint="curve")
rack(d, hx + 28, hy + 28, hw - 56, 75, BLUE, TEXT1)
for x, y, kind in tiles:
tile(d, x, y, 62, kind)
return c, size
def content_c():
"""The product: the headline on the left, the demo box's own dashboard on the right. Both inside
BAND -- the screen used to run past the phone's right crop edge and into the phone circle."""
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(c)
tx = BAND[0] + 12
size = fit_size(L2, "ExtraBold", 366, 56)
draw_text(c, tx, 180, L1, size, "ExtraBold", TEXT1)
draw_text(c, tx, 180 + size + 12, L2, size, "ExtraBold", BLUE_BRIGHT)
draw_text(c, tx, 180 + size + 12 + 42, 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 = 370
sh = int(round(sw * shot.height / shot.width))
shot = shot.resize((sw, sh), Image.LANCZOS)
fx, fy, b = 814, 56, 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, size
def cap_height(size):
"""The height of a capital letter at this size, read from the font rather than assumed."""
bb = font(size, "ExtraBold", 0).getbbox("S")
return bb[3] - bb[1]
def control_old_window():
"""Draw the headline where the OLD 640 x 360 assumption put it - which is where every shipped cover
had it - and return whether the measured check rejects that. It must. On the live Page the phone cut
18 px off exactly this layout: „saj\u00e1t szab\u00e1lyaid" read „aj\u00e1t szab\u00e1lyaid" (R-919)."""
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
draw_text(c, OLD_SAFE_X0 + 24, 180, L1, 52, "ExtraBold", TEXT1)
x0 = min(p[0] * 4 for p in alpha_points(c.resize((CW // 4, CH // 4)), thr=8))
return x0 < SAFE[0], x0
def check_safe(name, content, headline):
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 = []
for q in QUIET:
hits.append(sum(1 for x, y in zip(xs, ys) if q[0] <= x <= q[2] and q[1] <= y <= q[3]))
check(sum(hits) == 0, "%s: %d content pixels under the computer circle, %d under the phone circle"
% (name, hits[0], hits[1]))
cap = cap_height(headline)
check(cap >= CAP_MIN, "%s: headline size %d, capital height %d px (at least %d)"
% (name, headline, cap, CAP_MIN))
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" style="aspect-ratio:%.2f/%d"><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>'
% (CW_PHONE, CH, 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 \u2014 MEASURED (R-919): the centre %d px of %d at full height, and the '
'profile circle %d cover-pixels across - %d px on a %d px phone screen - centred. '
'NOT 640 x 360, and NOT Meta\u2019s 2,4:1.</p>%s</section>'
% (label, desk, round(CW_PHONE), CW, round(ph[2]),
round(ph[2] * PHONE_HDR[0] / CW_PHONE), round(PHONE_HDR[0]), 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(412px,100%%);aspect-ratio:938/624;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">The phone view below is MEASURED on the live Page (Pixel 9 emulated, mobile website, 2026-10-08,
R-919): Facebook keeps the full height and shows only the centre %d px of the %d-pixel width, with a profile
circle over the bottom middle %d cover-pixels across. It is NOT measured in the Facebook app \u2014 check there too.</p>
<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>
""" % (round(CW_PHONE), CW, round(PHONE_CIRCLE[2]), 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 %d x %d; the phone shows the centre %d px of %d - MEASURED, R-919)"
% (CW, CH, round(CW_PHONE), CW))
print(" safe %s, band %s, both profile circles kept clear" % (SAFE, BAND))
convicted, old_x0 = control_old_window()
print(" the pre-R-919 layout put the headline at x %d. The phone's crop edge is x %d, so %d px of it"
% (old_x0, round(PHONE_X0), round(PHONE_X0) - old_x0))
print(" were CUT on every phone; the measured safe edge is x %d, %d px further in (MARGIN %d)."
% (SAFE[0], SAFE[0] - old_x0, MARGIN))
check(convicted, "the safe check convicts the pre-R-919 layout (control)")
covers = [("cover-a.png", content_a(), (0.50, 0.78)), ("cover-b.png", content_b(), (0.68, 0.70)),
("cover-c.png", content_c(), (0.30, 0.74))]
cpaths = []
for name, (content, headline), glow in covers:
check_safe(name, content, headline)
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())