b9073e8fb6
gates / gates (push) Failing after 11m54s
You saw the cover uncropped signed out, after R-919 said the sides are cut. Both are true; my first measurement was one view generalised to "the phone". SIGNED IN, confirmed on your real phone (Chrome/Android, 1080 px): the crop is real. The cover band is 708 px tall across 1080 = 1.525:1 against the file's 2.628:1, and solving the laptop frame's left edge against that scale puts the window at x 351..1298 where the emulated Pixel 9 said 351..1289 -- the left edge to the pixel. R-919 is confirmed on hardware, not replaced. SIGNED OUT, measured from your screenshot: the box is 412x132 = 3.121:1, WIDER than the file, so the height is cut, not the width -- and the file's blue top rule is still visible at the top edge, which puts the cut at the BOTTOM: the top 525 px of 624 survives. The circle is far bigger and higher: x 486..1150 from y 232, 41% of the width. So the sides are cut for one visitor and the bottom for the other. build.py now carries both views; SAFE is their intersection, (391,40)-(1249,485). Two changes made that workable rather than merely safe: - the circles are modelled as DISCS, not rectangles running to the bottom. Near its top a disc is a few pixels wide; the rectangle was discarding most of the lower cover for nothing, which is much of why the frame looked empty. - TWO LAYERS. The READ layer (catchphrase, wordmark) must survive every view and the check fails on it. The DECOR layer may be cropped or covered, and the build REPORTS the cost instead of forbidding it (B 39%, C 62%). Before the split one check governed both, so no laptop big enough to read could ever pass. Red-proof again: the two-view geometry convicted all three covers as they stood -- A 908 content px under a circle (its wordmark sat inside the signed-out circle), B 1715 plus content past the cut bottom, C 1962. control_old_window still convicts the pre-R-919 layout, so there are two controls now. Covers redrawn: C is your laptop idea -- catchphrase and wordmark left, the dashboard at 640 px (was 370) running off the right edge, readable at last. B's home motif grew the same way. A lifted into the tighter band. Capitals 48/45/43 against the 25 px floor. Profile pictures untouched, not in the diff. Still VERIFY: three views measured, all disagreeing with each other and with Meta's help page, and the Facebook APP is still the one nobody has measured.
648 lines
32 KiB
Python
648 lines
32 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
|
||
|
||
The brand lockup. The headline is set the way the WEBSITE sets its own h1 - `site.css`
|
||
`.page-index .hero-text h1` is `font-weight: 700; letter-spacing: -0.03em` - so HEADLINE_WEIGHT is Bold, not
|
||
ExtraBold, and HEADLINE_TRACK is -0.03 em. „felhom.eu" is NOT typed in Plus Jakarta Sans: it is the logo's own
|
||
lettering, cropped out of logo.png, because that lettering is set in „M+ 2c" / „Vremena Grotesk" (fonts this
|
||
machine does not have, R-916) and typing it in another face is a different wordmark. The website hero shows the
|
||
same logo.png on the same dark background, so the cover matches the page. The PROFILE picture carries the MARK
|
||
only - at 176 px the mark plus the wordmark left both too small to read (operator, 2026-10-09).
|
||
|
||
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
|
||
# No DOMAIN constant: „felhom.eu" is never typed. wordmark() draws the logo's own lettering, so the
|
||
# brand name on a cover is the same artwork as on the website, not a look-alike in another face.
|
||
|
||
# The website's own h1, read from website/assets/site.css (.page-index .hero-text h1).
|
||
HEADLINE_WEIGHT = "Bold" # font-weight: 700 - NOT ExtraBold, which is what this file used to use
|
||
HEADLINE_TRACK = -0.03 # letter-spacing: -0.03em
|
||
# logo.png is one image holding two things: the mark, then a blank band, then the „felhom.eu" lettering.
|
||
# MEASURED on the file 2026-10-09: ink runs y 5..289, the band is y 290..295, the wordmark y 296..403.
|
||
LOGO_SPLIT_Y = 293
|
||
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 views: MEASURED, and there are TWO of them (R-919) ------------------------------------
|
||
# A phone does NOT show the cover one way. Both of these are measured on the live Page:
|
||
#
|
||
# SIGNED IN 2026-10-08 in Chrome DevTools device mode (Pixel 9, mobile UA, after a reload - without
|
||
# one Facebook keeps serving the desktop bundle), and CONFIRMED 2026-10-09 on the
|
||
# operator's own phone (Chrome on Android, 1080 px). Facebook keeps the cover's FULL
|
||
# HEIGHT and shows only the centre ~948 px of its width. Solving the real-phone shot
|
||
# against the laptop frame put the window at x 351..1298 where the emulator said
|
||
# 351..1289 - the left edge to the pixel. Profile circle: x 637..1030 from y 369
|
||
# (emulated), x 649..1028 from y 450 (real phone).
|
||
# SIGNED OUT 2026-10-09, measured from the operator's screenshot. The FULL width is shown - nothing
|
||
# is cut at the sides - but the cover is top-anchored and only the TOP 525 px survives;
|
||
# the bottom 99 px is cut. The circle is much bigger and much higher: x 486..1150 from
|
||
# y 232.
|
||
#
|
||
# So the SIDES are cut for a signed-in visitor and the BOTTOM for a signed-out one, and a cover has to
|
||
# survive both. Meta's own help page describes neither. Evidence and the recipe to re-measure:
|
||
# documentation/audits/facebook-page-setup-2026-10-08/.
|
||
# STILL NOT MEASURED: the Facebook app. That is why MARGIN is deliberate and why the operator's check
|
||
# in the app remains the acceptance step rather than this build.
|
||
PHONE_IN_X = (351.0, 1289.0) # signed in: the visible width, at full height
|
||
PHONE_OUT_Y = 525.0 # signed out: the visible height, at full width
|
||
MARGIN = 40
|
||
SAFE = (int(math.ceil(PHONE_IN_X[0] + MARGIN)), MARGIN,
|
||
int(PHONE_IN_X[1] - MARGIN), int(PHONE_OUT_Y - 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 two phone circles, as MEASURED. They are discs, and modelling them as rectangles that reach the
|
||
# bottom threw away most of the cover for nothing: near its top a disc is only a few pixels wide.
|
||
PHONE_CIRCLES = [(637.0, 369.0, 393.0), # signed in
|
||
(486.0, 232.0, 669.0)] # signed out - bigger and higher, so it is the binding one
|
||
DISCS = [(c[0] + c[2] / 2.0, c[1] + c[2] / 2.0, c[2] / 2.0 + PAD) for c in PHONE_CIRCLES]
|
||
QUIET = [(0, DESK_CIRCLE[1] - PAD, DESK_CIRCLE[0] + DESK_CIRCLE[2] + PAD, CH)]
|
||
# The band a CENTRED block may use: inside SAFE, clear of the computer circle, above the higher of the
|
||
# two phone circles. A block that stays to one side may go lower - the disc test is the real arbiter.
|
||
BAND = (max(SAFE[0], int(math.ceil(DESK_CIRCLE[0] + DESK_CIRCLE[2] + PAD))), SAFE[1] + 8,
|
||
SAFE[2], int(min(d[1] - d[2] for d in DISCS)))
|
||
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 text_width_tracked(text, size, weight, track=0.0):
|
||
"""Width with letter-spacing, the way the site puts -0.03em on its h1. `track` is in pixels."""
|
||
if not track:
|
||
return text_width(text, size, weight)
|
||
w = 0.0
|
||
for e, s in runs(text):
|
||
f = font(size, weight, e)
|
||
for ch in s:
|
||
w += f.getlength(ch) + track
|
||
return w - track
|
||
|
||
|
||
def draw_text(img, x, y, text, size, weight, fill, align="left", track=0.0):
|
||
"""Draw at baseline y; x is the left edge, or the centre with align='center'. Returns the width.
|
||
With `track` the glyphs are placed one at a time, so letter-spacing is real and not faked by scaling."""
|
||
w = text_width_tracked(text, size, weight, track)
|
||
if align == "center":
|
||
x -= w / 2.0
|
||
d = ImageDraw.Draw(img)
|
||
for e, s in runs(text):
|
||
f = font(size, weight, e)
|
||
if track:
|
||
for ch in s:
|
||
d.text((x, y), ch, font=f, fill=fill, anchor="ls")
|
||
x += f.getlength(ch) + track
|
||
else:
|
||
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, track_em=0.0):
|
||
"""The largest size at or below `start` whose tracked width fits max_w."""
|
||
size = start
|
||
while text_width_tracked(text, size, weight, track_em * size) > max_w:
|
||
size -= 1
|
||
return size
|
||
|
||
|
||
def headline(c, x, y, text, size, fill, align="left"):
|
||
"""One run of headline text in the website's weight and tracking. Returns the PEN x after it, so the
|
||
second, blue half of the line starts exactly where the first ended - tracking makes `x + width` wrong
|
||
by one letter-space."""
|
||
tr = HEADLINE_TRACK * size
|
||
w = draw_text(c, x, y, text, size, HEADLINE_WEIGHT, fill, align=align, track=tr)
|
||
if align == "center":
|
||
x -= w / 2.0
|
||
return x + w + tr
|
||
|
||
|
||
def headline_fit(text, max_w, start):
|
||
return fit_size(text, HEADLINE_WEIGHT, max_w, start, HEADLINE_TRACK)
|
||
|
||
|
||
def headline_width(text, size):
|
||
return text_width_tracked(text, size, HEADLINE_WEIGHT, HEADLINE_TRACK * 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
|
||
|
||
|
||
_logo_parts = []
|
||
|
||
|
||
def logo_parts():
|
||
"""logo.png split into (mark, wordmark), each tightened to its own ink. The split row is MEASURED."""
|
||
if _logo_parts:
|
||
return _logo_parts[0]
|
||
src = Image.open(LOGO_PNG).convert("RGBA")
|
||
out = []
|
||
for box in ((0, 0, src.width, LOGO_SPLIT_Y), (0, LOGO_SPLIT_Y, src.width, src.height)):
|
||
part = src.crop(box)
|
||
out.append(part.crop(part.getchannel("A").point(lambda v: 255 if v > 16 else 0).getbbox()))
|
||
_logo_parts.append((out[0], out[1]))
|
||
return _logo_parts[0]
|
||
|
||
|
||
def wordmark(c, x, y, height, align="left"):
|
||
"""Draw „felhom.eu" as the logo's own lettering, scaled to `height`. Returns its width."""
|
||
art = logo_parts()[1]
|
||
w = int(round(art.width * float(height) / art.height))
|
||
art = art.resize((w, int(height)), Image.LANCZOS)
|
||
c.alpha_composite(art, (int(round(x - w / 2.0)) if align == "center" else int(round(x)), int(round(y))))
|
||
return w
|
||
|
||
|
||
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 the MARK fits radius LOGO_R x width UNSCALED (never upsampled).
|
||
Floor 640 = twice the 320 x 320 Meta recommends as a source, so there is resolution to spare at the
|
||
176 / 196 px Facebook actually shows. It used to be 720, which was above what the mark alone needs and
|
||
would have shrunk the mark inside the circle for no gain."""
|
||
src = logo_parts()[0]
|
||
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(640, int(math.ceil(dmax / LOGO_R / 2.0)) * 2), (cx, cy)
|
||
|
||
|
||
def build_profile(bg, name):
|
||
w, (cx, cy) = profile_width()
|
||
logo = logo_parts()[0]
|
||
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 wordmark under it. No decor by design."""
|
||
c = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
|
||
mid = (BAND[0] + BAND[2]) / 2.0
|
||
size = headline_fit(SLOGAN, (BAND[2] - BAND[0]) - 20, 86)
|
||
x = mid - headline_width(SLOGAN, size) / 2.0
|
||
x = headline(c, x, 118, L1 + " ", size, TEXT1)
|
||
headline(c, x, 118, L2, size, BLUE_BRIGHT)
|
||
wordmark(c, mid, 134, round(size * 0.95), align="center")
|
||
return c, None, 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 = headline_fit(L2, 430, 58) # the READ column; the motif gets the rest
|
||
headline(c, tx, 100, L1, size, TEXT1)
|
||
headline(c, tx, 100 + size + 12, L2, size, BLUE_BRIGHT)
|
||
wordmark(c, tx, 100 + size + 26, round(size * 0.72))
|
||
# DECOR: the home with its server and its apps, free to run past the crop and under the circles
|
||
dec = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(dec)
|
||
hx, hy, hw = 1080, 210, 230 # house body: left, top, width
|
||
tiles = [(960, 96, "photo"), (1150, 70, "doc"), (1400, 150, "play"), (1430, 360, "lock"),
|
||
(905, 300, "folder"), (1230, 520, "photo"), (980, 505, "doc")]
|
||
for x, y, _ in tiles:
|
||
d.line((hx + hw / 2, hy + 110, x, y), fill=(46, 168, 245, 110), width=5)
|
||
d.rectangle((hx, hy, hx + hw, hy + 200), fill=BG1, outline=BLUE_BRIGHT, width=7)
|
||
d.polygon([(hx - 46, hy + 5), (hx + hw / 2, hy - 130), (hx + hw + 46, hy + 5)], fill=BG1, outline=BLUE_BRIGHT)
|
||
d.line([(hx - 46, hy + 5), (hx + hw / 2, hy - 130), (hx + hw + 46, hy + 5)], fill=BLUE_BRIGHT, width=7, joint="curve")
|
||
rack(d, hx + 42, hy + 42, hw - 84, 116, BLUE, TEXT1)
|
||
for x, y, kind in tiles:
|
||
tile(d, x, y, 92, kind)
|
||
return c, dec, 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 = headline_fit(L2, 400, 58)
|
||
headline(c, tx, 104, L1, size, TEXT1)
|
||
headline(c, tx, 104 + size + 12, L2, size, BLUE_BRIGHT)
|
||
wordmark(c, tx, 104 + size + 26, round(size * 0.80))
|
||
# DECOR: the dashboard, now big enough to read, running off the right edge and under the circles
|
||
dec = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
|
||
d = ImageDraw.Draw(dec)
|
||
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 = 640
|
||
sh = int(round(sw * shot.height / shot.width))
|
||
shot = shot.resize((sw, sh), Image.LANCZOS)
|
||
fx, fy, b = 880, 46, 24 # frame left/top, bezel
|
||
d.rectangle((fx, fy, fx + sw + 2 * b, fy + sh + 2 * b), fill=BG2, outline=LINE, width=3)
|
||
dec.alpha_composite(shot, (fx + b, fy + b))
|
||
base_y = fy + sh + 2 * b
|
||
d.polygon([(fx - 52, base_y + 38), (fx + sw + 2 * b + 52, base_y + 38), (fx + sw + 2 * b, base_y),
|
||
(fx, base_y)], fill=LINE)
|
||
return c, dec, size
|
||
|
||
|
||
def cap_height(size):
|
||
"""The height of a capital letter at this size, read from the font rather than assumed."""
|
||
bb = font(size, HEADLINE_WEIGHT, 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 decor_cost(dec):
|
||
"""How much of the decor layer a visitor never sees: cut by the signed-in crop, cut by the
|
||
signed-out bottom, or behind a profile circle. Reported, never failed - decor is allowed to go
|
||
there, and that is the whole point of having a second layer."""
|
||
if dec is None:
|
||
return 0, 0
|
||
pts = alpha_points(dec.resize((CW // 4, CH // 4)), thr=8)
|
||
if not pts:
|
||
return 0, 0
|
||
tot = len(pts)
|
||
lost = 0
|
||
for q in pts:
|
||
x, y = q[0] * 4, q[1] * 4
|
||
if x < PHONE_IN_X[0] or x > PHONE_IN_X[1] or y > PHONE_OUT_Y:
|
||
lost += 1
|
||
continue
|
||
if any((x - cx) ** 2 + (y - cy) ** 2 <= r * r for cx, cy, r in DISCS):
|
||
lost += 1
|
||
return tot, lost
|
||
|
||
|
||
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))
|
||
q = QUIET[0]
|
||
desk = sum(1 for x, y in zip(xs, ys) if q[0] <= x <= q[2] and q[1] <= y <= q[3])
|
||
phone = 0
|
||
for cx, cy, r in DISCS:
|
||
phone += sum(1 for x, y in zip(xs, ys) if (x - cx) ** 2 + (y - cy) ** 2 <= r * r)
|
||
check(desk + phone == 0, "%s: %d content pixels under the computer circle, %d under a phone circle"
|
||
% (name, desk, phone))
|
||
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, dec, glow):
|
||
img = background(glow)
|
||
if dec is not None:
|
||
img.alpha_composite(dec)
|
||
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 = DESK_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 \u2014 176 px, 96 px, 40 px (the mark only)</figcaption></figure>'
|
||
% (b64(p), b64(p), b64(p), label) for label, p in profiles)
|
||
cov = []
|
||
rec = profiles[0][1]
|
||
|
||
def phone_panel(src, vx0, vx1, vy1, circ):
|
||
"""One phone view: the window (vx0..vx1 of the width, 0..vy1 of the height) with its circle."""
|
||
vw, vh = vx1 - vx0, vy1
|
||
return ('<div class="phone" style="aspect-ratio:%.2f/%.2f"><div class="crop">'
|
||
'<img src="%s" alt="" style="width:%.3f%%;left:-%.3f%%;height:%.3f%%">'
|
||
'</div><img class="ov" src="%s" alt="" style="left:%s;top:%s;width:%s"></div>'
|
||
% (vw, vh, src, CW / vw * 100.0, vx0 / vw * 100.0, CH / vh * 100.0, b64(rec),
|
||
pct(circ[0] - vx0, vw), pct(circ[1], vh), pct(circ[2], vw)))
|
||
|
||
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)))
|
||
pin = phone_panel(src, PHONE_IN_X[0], PHONE_IN_X[1], CH, PHONE_CIRCLES[0])
|
||
pout = phone_panel(src, 0.0, float(CW), PHONE_OUT_Y, PHONE_CIRCLES[1])
|
||
cov.append('<section><h2>%s</h2>'
|
||
'<p class="k">Computer \u2014 the whole picture, and the profile circle sits BELOW it. '
|
||
'Dashed = the safe area (preview only).</p>%s'
|
||
'<div class="pair">'
|
||
'<figure><figcaption>Phone, <b>signed in</b> \u2014 the centre %d px of %d, full height'
|
||
'</figcaption>%s</figure>'
|
||
'<figure><figcaption>Phone, <b>signed out</b> \u2014 the full width, but only the top %d px'
|
||
'</figcaption>%s</figure></div></section>'
|
||
% (label, desk, round(PHONE_IN_X[1] - PHONE_IN_X[0]), CW, pin, round(PHONE_OUT_Y), pout))
|
||
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:980px;margin:0 auto}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin:34px 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:24px}.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}
|
||
.pair{display:flex;gap:24px;flex-wrap:wrap;margin-bottom:40px}
|
||
.phone{position:relative;width:min(412px,100%%);overflow:hidden}
|
||
.crop{position:absolute;inset:0;overflow:hidden}.crop img{position:absolute;top:0;left:0}
|
||
b{color:#2EA8F5}
|
||
</style></head><body><main>
|
||
<h1>Felhom.eu - Facebook pictures</h1>
|
||
<p class="k">A phone does NOT show the cover one way, so each cover is shown twice below. Both are MEASURED
|
||
on the live Page (R-919). <b>Signed in</b>: the centre %d px of the %d-pixel width, at full height - confirmed
|
||
on a real phone, where the window solved to x 351..1298 against the emulator's 351..1289. <b>Signed out</b>:
|
||
the full width, but top-anchored and only the top %d px - the bottom %d px is cut - with a bigger, higher
|
||
circle. So the sides are cut for one visitor and the bottom for the other. The Facebook APP is still not
|
||
measured - please check there.</p>
|
||
<p class="k">Profile picture: the logo MARK only \u2014 no \u201efelhom.eu\u201d lettering. At the 176 px Facebook shows (and
|
||
40 px in a comment) the mark plus the lettering left both too small to read, so the lettering was dropped and the
|
||
mark grew to %d %% of the circle. BOTH backgrounds now read at 40 px, which was not true while the lettering was
|
||
there: dark matches the covers and the Page as it stands, white separates the circle from the dark cover. The
|
||
covers below show the dark one.</p>
|
||
<h2>Profile picture</h2>%s
|
||
%s
|
||
</main></body></html>
|
||
""" % (round(PHONE_IN_X[1] - PHONE_IN_X[0]), CW, round(PHONE_OUT_Y), CH - round(PHONE_OUT_Y),
|
||
round(100.0 * logo_parts()[0].width / profile_width()[0]), 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; signed in a phone shows x %d..%d at full height, signed"
|
||
% (CW, CH, round(PHONE_IN_X[0]), round(PHONE_IN_X[1])))
|
||
print(" out the full width but only the top %d px - all MEASURED, R-919)" % round(PHONE_OUT_Y))
|
||
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_IN_X[0]), round(PHONE_IN_X[0]) - 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, dec, headline), glow in covers:
|
||
check_safe(name, content, headline)
|
||
tot, lost = decor_cost(dec)
|
||
if tot:
|
||
print(" %s: decor %d px, %d (%d %%) cropped or behind a circle in some view - allowed"
|
||
% (name, tot, lost, round(100.0 * lost / tot)))
|
||
cpaths.append(build_cover(name, content, dec, 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())
|