from __future__ import annotations import json import urllib.parse from dataclasses import dataclass from anydi import singleton from cachelib import BaseCache as Cache from email_campaigns.adapters.db import DB from email_campaigns.adapters.google.client import GoogleFontsClient from email_campaigns.adapters.google.models import FontAxis, WebfontItem from email_campaigns.api.schemas import CustomFont from email_campaigns.config import settings from email_campaigns.emails.models import BaseEmail GOOGLE_FONTS_CSS_BASE_URL = "https://fonts.googleapis.com/css2" EXCLUDED_FONT_FAMILIES: frozenset[str] = frozenset( { "Material Icons", "Material Icons Outlined", "Material Icons Round", "Material Icons Sharp", "Material Icons Two Tone", "Material Symbols", "Material Symbols Outlined", "Material Symbols Rounded", "Material Symbols Sharp", } ) CATEGORY_TO_GENERIC_FAMILY: dict[str, str] = { "sans-serif": "sans-serif", "serif": "serif", "display": "sans-serif", "handwriting": "cursive", "monospace": "monospace", } CATEGORY_TO_FALLBACK_FONT: dict[str, str] = { "sans-serif": "Arial", "serif": "Times New Roman", "display": "Arial", "handwriting": "Arial", "monospace": "Courier New", } @dataclass class GoogleFont: name: str fallback_font: str generic_font_family: str css_font_family: str url: str @singleton class GoogleFontsService: DEFAULT_FONT_WEIGHT = 400 CACHE_TTL = 15 * 60 # 15 minutes def __init__( self, google_fonts_client: GoogleFontsClient, cache: Cache, db: DB, ) -> None: self.google_fonts_client = google_fonts_client self.cache = cache self.db = db def get_fonts(self) -> list[GoogleFont]: font_list = self.google_fonts_client.get_webfonts() return [ self._to_google_font(item) for item in font_list.items if item.family not in EXCLUDED_FONT_FAMILIES ] def _to_google_font(self, item: WebfontItem) -> GoogleFont: generic_family = CATEGORY_TO_GENERIC_FAMILY.get(item.category, "") fallback_font = CATEGORY_TO_FALLBACK_FONT.get(item.category, "") css_font_family = ( f"'{item.family}', {fallback_font}, {generic_family}" if fallback_font else f"'{item.family}', {generic_family}" ) normal_weights, is_variable, italic_weights, ital_is_variable = ( self._get_font_capabilities(item.variants, item.axes) ) url = self._build_font_url( item.family, normal_weights, is_variable, italic_weights, ital_is_variable ) return GoogleFont( name=item.family, fallback_font=fallback_font, generic_font_family=generic_family, css_font_family=css_font_family, url=url, ) def _parse_variants(self, variants: list[str]) -> tuple[list[int], list[int]]: """Return (normal_weights, italic_weights) parsed from a variants list.""" normal_weights: list[int] = [] italic_weights: list[int] = [] for variant in variants: if variant == "regular": normal_weights.append(self.DEFAULT_FONT_WEIGHT) elif variant == "italic": italic_weights.append(self.DEFAULT_FONT_WEIGHT) elif variant.endswith("italic"): try: italic_weights.append(int(variant.removesuffix("italic"))) except ValueError: pass else: try: normal_weights.append(int(variant)) except ValueError: pass return normal_weights, italic_weights def _get_font_capabilities( self, variants: list[str], axes: list[FontAxis] ) -> tuple[list[int] | None, bool, list[int] | None, bool]: """Return (normal_weights, is_variable, italic_weights, ital_is_variable). - normal_weights: [min, max] for variable fonts, specific list for static, or None. - is_variable: True if a wght axis is present (use range syntax for normal weights). - italic_weights: [min, max] if an ital axis is present, specific list from italic variants, or None if the font has no italic support. - ital_is_variable: True if italic uses range syntax (ital axis present). """ normal_variants, italic_variants = self._parse_variants(variants) if axes: wght = next((a for a in axes if a.tag == "wght"), None) ital_axis = next((a for a in axes if a.tag == "ital"), None) normal_weights = [int(wght.start), int(wght.end)] if wght else None if ital_axis is not None and ital_axis.end >= 1: # Ital axis present: italic uses the same weight range as normal. return normal_weights, True, normal_weights, True elif italic_variants: # No ital axis but italic variants exist: list specific italic weights. return normal_weights, True, sorted(set(italic_variants)), False else: return normal_weights, True, None, False else: normal_weights = sorted(set(normal_variants)) italic_weights = sorted(set(italic_variants)) return normal_weights or None, False, italic_weights or None, False @staticmethod def _build_font_url( family: str, normal_weights: list[int] | None, is_variable: bool, italic_weights: list[int] | None, ital_is_variable: bool, ) -> str: family_encoded = urllib.parse.quote(family) if not normal_weights and not italic_weights: return f"{GOOGLE_FONTS_CSS_BASE_URL}?family={family_encoded}&display=swap" def _wght_spec(weights: list[int], variable: bool) -> str: if variable: return ( f"{weights[0]}..{weights[1]}" if weights[0] != weights[1] else str(weights[0]) ) return ";".join(str(w) for w in weights) if italic_weights is not None: if normal_weights: if is_variable: normal_part = f"0,{_wght_spec(normal_weights, True)}" else: normal_part = ";".join(f"0,{w}" for w in normal_weights) italic_part = ( f"1,{_wght_spec(italic_weights, True)}" if ital_is_variable else ";".join(f"1,{w}" for w in italic_weights) ) spec = f"ital,wght@{normal_part};{italic_part}" else: # Italic-only font. spec = "ital,wght@" + ";".join(f"1,{w}" for w in italic_weights) else: assert normal_weights is not None spec = f"wght@{_wght_spec(normal_weights, is_variable)}" return ( f"{GOOGLE_FONTS_CSS_BASE_URL}?family={family_encoded}:{spec}&display=swap" ) def get_favorite_fonts_for_email(self, email: BaseEmail) -> list[CustomFont]: fandata_list_id = email.fandata_list_id if fandata_list_id is None: return [] cache_key = ( f"{settings.cache_key_prefix}favorite_fonts:{email.id}:{fandata_list_id.id}" ) cached = self.cache.get(cache_key) if cached is not None: return [CustomFont.model_validate(f) for f in json.loads(cached)] query = self.db.query_from_template( "google_fonts/favorite-fonts-for-email.sql", context={ "vendor_id": email.vendor_id, "list_id": fandata_list_id.id, "is_artist": fandata_list_id.is_artist, }, ) rows = self.db.session.execute(query).all() result = [CustomFont.model_validate_json(row[0]) for row in rows] self.cache.set( cache_key, json.dumps([f.model_dump(by_alias=False) for f in result]), timeout=self.CACHE_TTL, ) return result