"""brand.py — identité UQO : logo, favicons, icônes tactiles, image de partage (Open Graph) nettes.""" from __future__ import annotations import base64 import os import subprocess from PIL import Image, ImageDraw, ImageFont HERE = os.path.dirname(os.path.abspath(__file__)) SITE = os.path.abspath(os.path.join(HERE, "..")) BRAND = os.path.join(SITE, "assets", "brand") UQO_TEAL = (15, 97, 128) # #0F6180 — bleu UQO du logo NAVY = (9, 26, 51) GOLD = (204, 164, 36) INK = (31, 36, 48) GRIS = (96, 102, 112) def font(weight: str, size: int) -> ImageFont.FreeTypeFont: name = {"bold": "FiraSans-Bold.otf", "semibold": "FiraSans-SemiBold.otf", "medium": "FiraSans-Medium.otf", "regular": "FiraSans-Regular.otf"}[weight] try: path = subprocess.run(["kpsewhich", name], stdout=subprocess.PIPE, text=True, timeout=20).stdout.strip() if path: return ImageFont.truetype(path, size) except Exception: # noqa: BLE001 pass for cand in ["/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf"]: if os.path.exists(cand): return ImageFont.truetype(cand, size, index=1 if weight in ("bold", "semibold") and cand.endswith(".ttc") else 0) return ImageFont.load_default() def logo(color: tuple | None = None) -> Image.Image: """Logo UQO (mot-symbole) recoloré ; conserve l'alpha.""" im = Image.open(os.path.join(BRAND, "uqo-logo-wiki.png")).convert("RGBA") if color is None: return im r, g, b, a = im.split() solid = Image.new("RGBA", im.size, color + (255,)) solid.putalpha(a) return solid def rounded_tile(size: int, color: tuple, radius_ratio: float = .22) -> Image.Image: im = Image.new("RGBA", (size, size), (0, 0, 0, 0)) d = ImageDraw.Draw(im) d.rounded_rectangle((0, 0, size - 1, size - 1), radius=int(size * radius_ratio), fill=color + (255,)) return im def make_icons(out_dir: str): """favicon-32/64/192/512, apple-touch-icon (180), favicon.svg (vectoriel + logo intégré).""" os.makedirs(out_dir, exist_ok=True) white = logo((255, 255, 255)) big = 1024 tile = rounded_tile(big, UQO_TEAL) lw = int(big * .78) lg = white.resize((lw, int(white.height * lw / white.width)), Image.LANCZOS) tile.alpha_composite(lg, ((big - lg.width) // 2, (big - lg.height) // 2 - int(big * .02))) # barre or discrète en bas d = ImageDraw.Draw(tile) d.rounded_rectangle((int(big * .3), int(big * .8), int(big * .7), int(big * .8) + int(big * .035)), radius=20, fill=GOLD + (255,)) for s, name in ((32, "favicon-32.png"), (64, "favicon-64.png"), (180, "apple-touch-icon.png"), (192, "icon-192.png"), (512, "icon-512.png")): tile.resize((s, s), Image.LANCZOS).save(os.path.join(out_dir, name), optimize=True) tile.resize((256, 256), Image.LANCZOS).save(os.path.join(out_dir, "favicon.ico"), sizes=[(16, 16), (32, 32), (48, 48), (64, 64)]) # SVG : tuile vectorielle + logo PNG haute résolution intégré (net sur tous les écrans) buf = os.path.join(out_dir, "_logo_white.png") white.save(buf, optimize=True) b64 = base64.b64encode(open(buf, "rb").read()).decode() os.remove(buf) h = white.height / white.width svg = (f'' f'' f'') open(os.path.join(out_dir, "favicon.svg"), "w", encoding="utf-8").write(svg) # logos pour la page (teal et blanc), 2x logo().save(os.path.join(out_dir, "uqo-logo.png"), optimize=True) white.save(os.path.join(out_dir, "uqo-logo-white.png"), optimize=True) def make_og(course: dict, stats: dict, out_path: str): """Image de partage 1200×630 (rendue en 2400×1260 puis réduite : texte net).""" S = 2 W, H = 1200 * S, 630 * S im = Image.new("RGB", (W, H), (255, 255, 255)) d = ImageDraw.Draw(im) # bandeau gauche UQO et fond doux d.rectangle((0, 0, 16 * S, H), fill=UQO_TEAL) d.rectangle((16 * S, 0, 22 * S, H), fill=(15, 97, 128, 60)) for i in range(60): y = int(H * (1 - i / 60)) shade = 255 - int(6 * (i / 60)) d.line((22 * S, y, W, y), fill=(shade - 8, shade - 3, shade)) # cercles décoratifs for (cx, cy, r, col) in ((W - 120 * S, 40 * S, 200 * S, (15, 97, 128)), (W - 40 * S, 200 * S, 300 * S, (42, 134, 176)), (W - 300 * S, 330 * S, 70 * S, GOLD)): d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=col + (0,), width=2 * S) # logo UQO lg = logo(UQO_TEAL) lw = 300 * S lg = lg.resize((lw, int(lg.height * lw / lg.width)), Image.LANCZOS) im.paste(lg, (70 * S, 56 * S), lg) d.text((70 * S + lw + 30 * S, 62 * S), "Université du Québec\nen Outaouais", font=font("medium", 30 * S), fill=GRIS, spacing=6 * S) # surtitre d.text((70 * S, 236 * S), f"NOTES DE COURS INTERACTIVES · {course['term'].upper()}", font=font("bold", 24 * S), fill=GOLD) d.rounded_rectangle((70 * S, 272 * S, 130 * S, 277 * S), radius=3 * S, fill=GOLD) # code et titre d.text((70 * S, 292 * S), course["code"], font=font("bold", 96 * S), fill=UQO_TEAL) title_font = font("semibold", 50 * S) title = course["title"] # retour à la ligne simple words, lines, cur = title.split(), [], "" for w in words: t = (cur + " " + w).strip() if d.textlength(t, font=title_font) > 1000 * S: lines.append(cur) cur = w else: cur = t lines.append(cur) y = 410 * S for ln in lines[:2]: d.text((70 * S, y), ln, font=title_font, fill=INK) y += 60 * S # stats sf = font("regular", 26 * S) stat = f"14 séances · {stats.get('def', 0)} définitions · {stats.get('form', 0)} formules · {stats.get('exo', 0)} exercices corrigés · {stats.get('quiz', 0)} questions de quiz" d.text((70 * S, 556 * S), stat, font=sf, fill=GRIS) d.text((70 * S, 592 * S), f"Simon-Pierre Boucher · {course['domain']}", font=font("medium", 24 * S), fill=UQO_TEAL) im.resize((1200, 630), Image.LANCZOS).save(out_path, optimize=True) if __name__ == "__main__": make_icons("/tmp/uqo-test/brand") print("ok")