From 4b5a2f544d6a7836afce0470822b4279c7b4476f Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Wed, 25 Sep 2024 16:59:40 -0400 Subject: [PATCH] feat(typography): Support variable font weights For #13 --- examples/brand-typography-fonts.yml | 2 +- pkg-py/src/brand_yaml/typography.py | 332 +++++++++++++++--- .../tests/__snapshots__/test_typography.ambr | 2 +- .../test_brand_typography_ex_fonts.json | 2 +- pkg-py/tests/test_typography.py | 116 +++--- 5 files changed, 354 insertions(+), 100 deletions(-) diff --git a/examples/brand-typography-fonts.yml b/examples/brand-typography-fonts.yml index de3fdf22..94f998f2 100644 --- a/examples/brand-typography-fonts.yml +++ b/examples/brand-typography-fonts.yml @@ -22,7 +22,7 @@ typography: # Online Font Foundries - family: Roboto Slab source: google - weight: semi-bold + weight: 600..900 style: normal display: block diff --git a/pkg-py/src/brand_yaml/typography.py b/pkg-py/src/brand_yaml/typography.py index edf2c70c..f6000f73 100644 --- a/pkg-py/src/brand_yaml/typography.py +++ b/pkg-py/src/brand_yaml/typography.py @@ -3,7 +3,17 @@ import itertools from abc import ABC, abstractmethod from pathlib import Path -from typing import Annotated, Any, Literal, TypeVar, Union +from re import split as re_split +from textwrap import indent +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Literal, + TypeVar, + Union, + overload, +) from urllib.parse import urlencode, urljoin from pydantic import ( @@ -12,8 +22,12 @@ Discriminator, Field, HttpUrl, + PlainSerializer, PositiveInt, + RootModel, + Tag, field_validator, + model_serializer, model_validator, ) @@ -26,6 +40,7 @@ T = TypeVar("T") SingleOrList = Union[T, list[T]] +SingleOrTuple = Union[T, tuple[T, ...]] BrandTypographyFontStyleType = Literal["normal", "italic"] @@ -44,12 +59,24 @@ "ultra-bold", "black", ] + +BrandTypographyFontWeightInt = Annotated[int, Field(ge=1, le=999)] + BrandTypographyFontWeightAllType = Union[ - float, int, BrandTypographyFontWeightNamedType + BrandTypographyFontWeightInt, BrandTypographyFontWeightNamedType ] BrandTypographyFontWeightSimpleType = Union[ - float, int, Literal["normal", "bold", "auto"] + BrandTypographyFontWeightInt, Literal["normal", "bold"] +] + +BrandTypographyFontWeightSimplePairedType = tuple[ + BrandTypographyFontWeightSimpleType, + BrandTypographyFontWeightSimpleType, +] + +BrandTypographyFontWeightSimpleAutoType = Union[ + BrandTypographyFontWeightInt, Literal["normal", "bold", "auto"] ] BrandTypographyFontWeightRoundIntType = Literal[ @@ -103,34 +130,50 @@ class BrandInvalidFontWeight(ValueError): - def __init__(self, value: Any): + def __init__(self, value: Any, allow_auto: bool = True): + allowed = list(font_weight_map.keys()) + if allow_auto: + allowed = ["auto", *allowed] + super().__init__( f"Invalid font weight {value!r}. Expected a number divisible " + "by 100 and between 100 and 900, or one of " - + f"{', '.join(font_weight_map.keys())}." + + f"{', '.join(allowed)}." ) -# Fonts ------------------------------------------------------------------------ - +# Font Weights ----------------------------------------------------------------- +@overload +def validate_font_weight( + value: Any, + allow_auto: Literal[True] = True, +) -> BrandTypographyFontWeightSimpleAutoType: ... -class BrandUnsupportedFontFileFormat(ValueError): - supported = ("opentype", "truetype", "woff", "woff2") - def __init__(self, value: Any): - super().__init__( - f"Unsupported font file {value!r}. Expected one of {', '.join(self.supported)}." - ) +@overload +def validate_font_weight( + value: Any, + allow_auto: Literal[False], +) -> BrandTypographyFontWeightSimpleType: ... def validate_font_weight( - value: int | str | None, -) -> BrandTypographyFontWeightSimpleType: + value: Any, + allow_auto: bool = True, +) -> ( + BrandTypographyFontWeightSimpleAutoType + | BrandTypographyFontWeightSimpleType +): if value is None: return "auto" + if not isinstance(value, (str, int, float, bool)): + raise BrandInvalidFontWeight(value, allow_auto=allow_auto) + if isinstance(value, str): - if value in ("auto", "normal", "bold"): + if allow_auto and value == "auto": + return value + if value in ("normal", "bold"): return value if value in font_weight_map: return font_weight_map[value] @@ -138,14 +181,95 @@ def validate_font_weight( try: value = int(value) except ValueError: - raise BrandInvalidFontWeight(value) + raise BrandInvalidFontWeight(value, allow_auto=allow_auto) if value < 100 or value > 900 or value % 100 != 0: - raise BrandInvalidFontWeight(value) + raise BrandInvalidFontWeight(value, allow_auto=allow_auto) return value +# Fonts (Files) ---------------------------------------------------------------- + + +class BrandUnsupportedFontFileFormat(ValueError): + supported = ("opentype", "truetype", "woff", "woff2") + + def __init__(self, value: Any): + super().__init__( + f"Unsupported font file {value!r}. Expected one of {', '.join(self.supported)}." + ) + + +class BrandTypographyFontFileWeight(RootModel): + root: ( + BrandTypographyFontWeightSimpleAutoType + | BrandTypographyFontWeightSimplePairedType + ) + + def __str__(self) -> str: + if isinstance(self.root, tuple): + vals = [ + str(font_weight_map[v]) if isinstance(v, str) else str(v) + for v in self.root + ] + return " ".join(vals) + return str(self.root) + + @model_serializer + def to_str_url(self) -> str: + if isinstance(self.root, tuple): + return f"{self.root[0]}..{self.root[1]}" + return str(self.root) + + if TYPE_CHECKING: + # https://docs.pydantic.dev/latest/concepts/serialization/#overriding-the-return-type-when-dumping-a-model + # Ensure type checkers see the correct return type + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: Any = None, + exclude: Any = None, + context: dict[str, Any] | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + serialize_as_any: bool = False, + ) -> str: ... + + @field_validator("root", mode="before") + @classmethod + def validate_root_before(cls, value: Any) -> Any: + if isinstance(value, str) and ".." in value: + value = value.split("..") + return (v for v in value if v) + return value + + @field_validator("root", mode="before") + @classmethod + def validate_root( + cls, value: Any + ) -> ( + BrandTypographyFontWeightSimpleAutoType + | BrandTypographyFontWeightSimplePairedType + ): + if isinstance(value, tuple) or isinstance(value, list): + if len(value) != 2: + raise ValueError( + "Font weight ranges must have exactly 2 elements." + ) + vals = ( + validate_font_weight(value[0], allow_auto=False), + validate_font_weight(value[1], allow_auto=False), + ) + return vals + return validate_font_weight(value, allow_auto=True) + + FontSourceType = Union[Literal["file"], Literal["google"], Literal["bunny"]] @@ -169,12 +293,14 @@ def css_include(self) -> str: return "" return "\n".join( - f"@font-face {{\n" - f" font-family: '{self.family}';\n" - f" font-weight: {font.weight};\n" - f" font-style: {font.style};\n" - f" src: {font.css_font_face_src()};\n" - f"}}" + "\n".join( + [ + "@font-face {", + f" font-family: '{self.family}';", + indent(font.to_css(), 2 * " "), + "}", + ] + ) for font in self.files ) @@ -183,13 +309,23 @@ class BrandTypographyFontFilesPath(BaseModel): model_config = ConfigDict(extra="forbid") path: FileLocation - weight: BrandTypographyFontWeightSimpleType = "auto" + weight: BrandTypographyFontFileWeight = Field( + default_factory=lambda: BrandTypographyFontFileWeight(root="auto"), + validate_default=True, + ) style: BrandTypographyFontStyleType = "normal" - @field_validator("weight", mode="before") - @classmethod - def validate_weight(cls, value: str | int | None): - return validate_font_weight(value) + def to_css(self) -> str: + # TODO: Handle `file://` vs `https://` or move to correct location + weight = self.weight.to_str_url() + src = f"url('{self.path.root}') format('{self.format}')" + return "\n".join( + [ + f"font-weight: {weight};", + f"font-style: {self.style};", + f"src: {src};", + ] + ) @field_validator("path", mode="after") @classmethod @@ -217,43 +353,131 @@ def format(self) -> Literal["opentype", "truetype", "woff", "woff2"]: return fmt - def css_font_face_src(self) -> str: - # TODO: Handle `file://` vs `https://` or move to correct location - return f"url('{self.path.root}') format('{self.format}')" + +# Fonts (Google) --------------------------------------------------------------- + + +class BrandTypographyGoogleFontsWeightRange(RootModel): + model_config = ConfigDict(json_schema_mode_override="serialization") + + root: list[BrandTypographyFontWeightInt] + + def __str__(self) -> str: + return f"{self.root[0]}..{self.root[1]}" + + @model_serializer(mode="plain", when_used="always") + def to_serialized(self) -> str: + return f"{self.root[0]}..{self.root[1]}" + + def to_url_list(self) -> list[str]: + return [str(self)] + + @field_validator("root", mode="before") + @classmethod + def validate_weight(cls, value: Any) -> list[BrandTypographyFontWeightInt]: + if isinstance(value, str) and ".." in value: + start, end = re_split(r"\s*[.]{2,3}\s*", value, maxsplit=1) + value = [start, end] + + if len(value) != 2: + raise ValueError("Font weight ranges must have exactly 2 elements.") + + value = [validate_font_weight(v, allow_auto=False) for v in value] + value = [font_weight_map[v] if isinstance(v, str) else v for v in value] + return value + + if TYPE_CHECKING: + # https://docs.pydantic.dev/latest/concepts/serialization/#overriding-the-return-type-when-dumping-a-model + # Ensure type checkers see the correct return type + def model_dump( + self, + *, + mode: Literal["json", "python"] | str = "python", + include: Any = None, + exclude: Any = None, + context: dict[str, Any] | None = None, + by_alias: bool = False, + exclude_unset: bool = False, + exclude_defaults: bool = False, + exclude_none: bool = False, + round_trip: bool = False, + warnings: bool | Literal["none", "warn", "error"] = True, + serialize_as_any: bool = False, + ) -> str: ... + + +class BrandTypographyGoogleFontsWeight(RootModel): + root: ( + BrandTypographyFontWeightSimpleAutoType + | list[BrandTypographyFontWeightSimpleType] + ) + + def to_url_list(self) -> list[str]: + weights = self.root if isinstance(self.root, list) else [self.root] + vals = [ + str(font_weight_map[w]) if isinstance(w, str) else str(w) + for w in weights + ] + vals.sort() + return vals + + def to_serialized( + self, + ) -> ( + BrandTypographyFontWeightSimpleAutoType + | list[BrandTypographyFontWeightSimpleType] + ): + return self.root + + @field_validator("root", mode="before") + @classmethod + def validate_root( + cls, + value: str | int | list[str | int], + ) -> ( + BrandTypographyFontWeightSimpleAutoType + | list[BrandTypographyFontWeightSimpleType] + ): + if isinstance(value, list): + return [validate_font_weight(v, allow_auto=False) for v in value] + return validate_font_weight(value, allow_auto=True) + + +def google_font_weight_discriminator(value: Any) -> str: + if isinstance(value, str) and ".." in value: + return "range" + else: + return "weights" class BrandTypographyGoogleFontsApi(BrandTypographyFontSource): family: str - weight: SingleOrList[BrandTypographyFontWeightSimpleType] = Field( - default=list(font_weight_round_int) - ) + weight: Annotated[ + Union[ + Annotated[BrandTypographyGoogleFontsWeightRange, Tag("range")], + Annotated[BrandTypographyGoogleFontsWeight, Tag("weights")], + ], + Discriminator(google_font_weight_discriminator), + PlainSerializer( + lambda x: x.to_serialized(), + return_type=Union[str, int, list[int | str]], + ), + ] = Field(default=list(font_weight_round_int), validate_default=True) style: SingleOrList[BrandTypographyFontStyleType] = ["normal", "italic"] display: Literal["auto", "block", "swap", "fallback", "optional"] = "auto" version: PositiveInt = 2 url: HttpUrl = Field("https://fonts.googleapis.com/", validate_default=True) - @field_validator("weight", mode="before") - @classmethod - def validate_weight( - cls, value: SingleOrList[Union[int, str]] - ) -> SingleOrList[BrandTypographyFontWeightSimpleType]: - if isinstance(value, list): - return [validate_font_weight(x) for x in value] - else: - return validate_font_weight(value) - def css_include(self) -> str: - return f"@import url('{self.import_url()}');" + return f"@import url('{self.to_import_url()}');" - def import_url(self) -> str: + def to_import_url(self) -> str: if self.version == 1: return self._import_url_v1() return self._import_url_v2() def _import_url_v1(self) -> str: - weight = sorted( - self.weight if isinstance(self.weight, list) else [self.weight] - ) + weight = self.weight.to_url_list() style_str = sorted( self.style if isinstance(self.style, list) else [self.style] ) @@ -279,9 +503,7 @@ def _import_url_v1(self) -> str: return urljoin(str(self.url), f"css?{params}") def _import_url_v2(self) -> str: - weight = sorted( - self.weight if isinstance(self.weight, list) else [self.weight] - ) + weight = self.weight.to_url_list() style_str = sorted( self.style if isinstance(self.style, list) else [self.style] ) @@ -361,8 +583,8 @@ class BrandTypographyOptionsWeight(BaseModel): @field_validator("weight", mode="before") @classmethod - def validate_weight(cls, value: int | str): - return validate_font_weight(value) + def validate_weight(cls, value: Any) -> BrandTypographyFontWeightSimpleType: + return validate_font_weight(value, allow_auto=False) class BrandTypographyBase( diff --git a/pkg-py/tests/__snapshots__/test_typography.ambr b/pkg-py/tests/__snapshots__/test_typography.ambr index 7ceffa73..791a8107 100644 --- a/pkg-py/tests/__snapshots__/test_typography.ambr +++ b/pkg-py/tests/__snapshots__/test_typography.ambr @@ -25,7 +25,7 @@ font-style: italic; src: url('https://example.com/Closed-Sans-Italic.woff2') format('woff2'); } - @import url('https://fonts.googleapis.com/css2?family=Roboto+Slab%3Aital%2Cwght%400%2C600&display=block'); + @import url('https://fonts.googleapis.com/css2?family=Roboto+Slab%3Aital%2Cwght%400%2C600..900&display=block'); @import url('https://fonts.bunny.net/css?family=Fira+Code%3A100%2C100i%2C200%2C200i%2C300%2C300i%2C400%2C400i%2C500%2C500i%2C600%2C600i%2C700%2C700i%2C800%2C800i%2C900%2C900i&display=auto'); ''' # --- diff --git a/pkg-py/tests/__snapshots__/test_typography/test_brand_typography_ex_fonts.json b/pkg-py/tests/__snapshots__/test_typography/test_brand_typography_ex_fonts.json index 1780800e..cb36355c 100644 --- a/pkg-py/tests/__snapshots__/test_typography/test_brand_typography_ex_fonts.json +++ b/pkg-py/tests/__snapshots__/test_typography/test_brand_typography_ex_fonts.json @@ -43,7 +43,7 @@ "family": "Roboto Slab", "source": "google", "style": "normal", - "weight": 600 + "weight": "600..900" }, { "family": "Fira Code", diff --git a/pkg-py/tests/test_typography.py b/pkg-py/tests/test_typography.py index dab1e82f..d3180a8f 100644 --- a/pkg-py/tests/test_typography.py +++ b/pkg-py/tests/test_typography.py @@ -11,13 +11,16 @@ BrandTypographyFontBunny, BrandTypographyFontFiles, BrandTypographyFontFilesPath, + BrandTypographyFontFileWeight, BrandTypographyFontGoogle, BrandTypographyGoogleFontsApi, + BrandTypographyGoogleFontsWeightRange, BrandTypographyHeadings, BrandTypographyLink, BrandTypographyMonospace, BrandTypographyMonospaceBlock, BrandTypographyMonospaceInline, + validate_font_weight, ) from syrupy.extensions.json import JSONSnapshotExtension from utils import path_examples, pydantic_data_from_json @@ -44,55 +47,58 @@ def test_brand_typography_font_file_format(path, fmt): assert font.format == fmt -def test_brand_typography_font_file_weight(): - args = { - "path": "my-font.otf", - } +def test_validate_font_weight(): + assert validate_font_weight(None) == "auto" + assert validate_font_weight("auto") == "auto" + assert validate_font_weight("normal") == "normal" + assert validate_font_weight("bold") == "bold" + + assert validate_font_weight("thin") == 100 + assert validate_font_weight("semi-bold") == 600 with pytest.raises(ValueError): - BrandTypographyFontFilesPath.model_validate( - {**args, "weight": "invalid"} - ) + validate_font_weight("invalid") with pytest.raises(ValueError): - BrandTypographyFontFilesPath.model_validate({**args, "weight": 999}) + validate_font_weight([100, 200]) with pytest.raises(ValueError): - BrandTypographyFontFilesPath.model_validate({**args, "weight": 150}) + # Auto is only allowed as a single value + validate_font_weight(["auto", "normal"]) + +def test_brand_typography_font_file_weight(): with pytest.raises(ValueError): - BrandTypographyFontFilesPath.model_validate({**args, "weight": 0}) + BrandTypographyFontFileWeight.model_validate("invalid") + with pytest.raises(ValueError): + BrandTypographyFontFileWeight.model_validate(999) + + with pytest.raises(ValueError): + BrandTypographyFontFileWeight.model_validate(150) + + with pytest.raises(ValueError): + BrandTypographyFontFileWeight.model_validate(0) + + assert BrandTypographyFontFileWeight.model_validate(100).root == 100 + assert BrandTypographyFontFileWeight.model_validate("thin").root == 100 + assert BrandTypographyFontFileWeight.model_validate("semi-bold").root == 600 + assert BrandTypographyFontFileWeight.model_validate("bold").root == "bold" assert ( - BrandTypographyFontFilesPath.model_validate( - {**args, "weight": 100} - ).weight - == 100 - ) - assert ( - BrandTypographyFontFilesPath.model_validate( - {**args, "weight": "thin"} - ).weight - == 100 - ) - assert ( - BrandTypographyFontFilesPath.model_validate( - {**args, "weight": "semi-bold"} - ).weight - == 600 - ) - assert ( - BrandTypographyFontFilesPath.model_validate( - {**args, "weight": "bold"} - ).weight - == "bold" + BrandTypographyFontFileWeight.model_validate("normal").root == "normal" ) - assert ( - BrandTypographyFontFilesPath.model_validate( - {**args, "weight": "normal"} - ).weight - == "normal" + assert BrandTypographyFontFileWeight.model_validate("auto").root == "auto" + + assert BrandTypographyFontFileWeight.model_validate([100, 200]).root == ( + 100, + 200, ) + thin_bold = BrandTypographyFontFileWeight.model_validate(["thin", "bold"]) + assert thin_bold.root == (100, "bold") + assert str(thin_bold) == "100 700" + + with pytest.raises(ValueError): + BrandTypographyFontFileWeight.model_validate(["thin", "auto"]) def test_brand_typography_monospace(): @@ -227,11 +233,34 @@ def test_brand_typography_font_google_import_url(): assert len(bg.fonts) == 1 assert isinstance(bg.fonts[0], BrandTypographyFontGoogle) assert ( - unquote(bg.fonts[0].import_url()) + unquote(bg.fonts[0].to_import_url()) == "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&display=auto" ) +def test_brand_typography_font_google_weight_range_import_url(): + bg = BrandTypography.model_validate( + { + "fonts": [ + { + "source": "google", + "family": "Open Sans", + "weight": "400..700", + "style": ["italic", "normal"], + } + ] + } + ) + + assert len(bg.fonts) == 1 + assert isinstance(bg.fonts[0], BrandTypographyFontGoogle) + assert isinstance(bg.fonts[0].weight, BrandTypographyGoogleFontsWeightRange) + assert ( + unquote(bg.fonts[0].to_import_url()) + == "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400..700;1,400..700&display=auto" + ) + + def test_brand_typography_font_bunny_import_url(): bg = BrandTypography.model_validate( { @@ -249,7 +278,7 @@ def test_brand_typography_font_bunny_import_url(): assert len(bg.fonts) == 1 assert isinstance(bg.fonts[0], BrandTypographyFontBunny) assert ( - unquote(bg.fonts[0].import_url()) + unquote(bg.fonts[0].to_import_url()) == "https://fonts.bunny.net/css?family=Open+Sans:400,400i,700,700i&display=auto" ) @@ -304,7 +333,8 @@ def test_brand_typography_ex_fonts(snapshot_json): assert "OpenSans" in str(font.path.root) assert str(font.path.root).endswith(".ttf") assert font.format == "truetype" - assert font.weight == ["auto", "auto"][i] + assert isinstance(font.weight, BrandTypographyFontFileWeight) + assert str(font.weight) == ["auto", "auto"][i] assert font.style == ["normal", "italic"][i] # Online Font Files @@ -317,14 +347,16 @@ def test_brand_typography_ex_fonts(snapshot_json): assert str(font.path.root).startswith("https://") assert str(font.path.root).endswith(".woff2") assert font.format == "woff2" - assert font.weight == ["bold", "auto"][i] + assert str(font.weight) == ["bold", "auto"][i] assert font.style == ["normal", "italic"][i] # Google Fonts google_font = brand.typography.fonts[2] assert isinstance(google_font, BrandTypographyFontGoogle) assert google_font.family == "Roboto Slab" - assert google_font.weight == 600 + assert isinstance(google_font.weight, BrandTypographyGoogleFontsWeightRange) + assert str(google_font.weight) == "600..900" + assert google_font.weight.to_url_list() == ["600..900"] assert google_font.style == "normal" assert google_font.display == "block"