796a9fdf40
gates / gates (push) Successful in 4m28s
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.
505 lines
25 KiB
Python
505 lines
25 KiB
Python
#!/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())
|