Skip to content
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

- **NEW** and **FIX**: Add `subset` parameter to `load_google_font()` and `load_bunny_font()` (defaults to `"latin"`). Multi-subset fonts (e.g., Chakra Petch) now return the correct character set instead of whichever subset appears first in the provider CSS.

## 1.3.0 (stable)

- **NEW** and **FIX**: `set_default_font()` function now fetches and adds all font variants (weight, style) ([issue #23](https://github.com/y-sunflower/pyfonts/issues/23), [PR #39](https://github.com/y-sunflower/pyfonts/pull/39))
Expand Down
4 changes: 4 additions & 0 deletions pyfonts/bunny.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def load_bunny_font(
weight: Optional[Union[int, str]] = None,
italic: Optional[bool] = None,
allowed_formats: List[str] = ["woff", "ttf", "otf"],
subset: str = "latin",
use_cache: bool = True,
danger_not_verify_ssl: bool = False,
) -> FontProperties:
Expand All @@ -30,6 +31,7 @@ def load_bunny_font(
allowed_formats: List of acceptable font file formats. Defaults to ["woff", "ttf", "otf"].
Note that for `woff2` fonts to work, you must have [brotli](https://github.com/google/brotli)
installed.
subset: Unicode subset to select (e.g., "latin", "thai"). Defaults to "latin".
use_cache: Whether or not to cache fonts (to make pyfonts faster). Default to `True`.
danger_not_verify_ssl: Whether or not to to skip SSL certificate on
`ssl.SSLCertVerificationError`. If `True`, it's a **security risk** (such as data breaches or
Expand Down Expand Up @@ -57,6 +59,7 @@ def load_bunny_font(
italic=italic,
allowed_formats=allowed_formats,
use_cache=use_cache,
subset=subset,
)

font = load_font(
Expand All @@ -70,6 +73,7 @@ def load_bunny_font(
endpoint=_BUNNY_ENDPOINT,
family=family,
allowed_formats=allowed_formats,
subset=subset,
use_cache=use_cache,
danger_not_verify_ssl=danger_not_verify_ssl,
)
5 changes: 4 additions & 1 deletion pyfonts/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
_MEMORY_CACHE: dict = {}


def _cache_key(family: str, weight, italic, allowed_formats: list[str]) -> str:
def _cache_key(
family: str, weight, italic, allowed_formats: list[str], subset: str
) -> str:
key_str: str = json.dumps(
{
"family": family,
"weight": weight,
"italic": italic,
"allowed_formats": allowed_formats,
"subset": subset,
},
sort_keys=True,
)
Expand Down
4 changes: 4 additions & 0 deletions pyfonts/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def load_google_font(
weight: Optional[Union[int, str]] = None,
italic: Optional[bool] = None,
allowed_formats: List[str] = ["woff2", "woff", "ttf", "otf"],
subset: str = "latin",
use_cache: bool = True,
danger_not_verify_ssl: bool = False,
) -> FontProperties:
Expand All @@ -29,6 +30,7 @@ def load_google_font(
'regular', 'medium', 'semi-bold', 'bold', 'extra-bold', 'black'. Default is `None`.
italic: Whether to use the italic variant. Default is `None`.
allowed_formats: List of acceptable font file formats. Defaults to ["woff2", "woff", "ttf", "otf"].
subset: Unicode subset to select (e.g., "latin", "thai"). Defaults to "latin".
use_cache: Whether or not to cache fonts (to make pyfonts faster). Default to `True`.
danger_not_verify_ssl: Whether or not to to skip SSL certificate on
`ssl.SSLCertVerificationError`. If `True`, it's a **security risk** (such as data breaches or
Expand Down Expand Up @@ -56,6 +58,7 @@ def load_google_font(
weight=weight,
allowed_formats=allowed_formats,
use_cache=use_cache,
subset=subset,
)

font = load_font(
Expand All @@ -69,6 +72,7 @@ def load_google_font(
endpoint=_GOOGLE_ENDPOINT,
family=family,
allowed_formats=allowed_formats,
subset=subset,
use_cache=use_cache,
danger_not_verify_ssl=danger_not_verify_ssl,
)
1 change: 1 addition & 0 deletions pyfonts/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def _get_font_variant_files(font: FontProperties) -> set[str]:
italic=italic,
allowed_formats=provider_metadata["allowed_formats"],
use_cache=provider_metadata["use_cache"],
subset=provider_metadata["subset"],
)
variant_font = load_font(
font_url=font_url,
Expand Down
29 changes: 27 additions & 2 deletions pyfonts/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,26 @@
_FONT_PROVIDER_METADATA_ATTR = "_pyfonts_provider_metadata"


def _parse_css_subsets(css_text: str) -> dict[str, str]:
parts = re.split(r"/\*\s*(\S+)\s*\*/", css_text)
if len(parts) < 3:
return {"": css_text}
subsets: dict[str, str] = {}
for i in range(1, len(parts), 2):
name = parts[i]
block = parts[i + 1] if i + 1 < len(parts) else ""
subsets[name] = block
return subsets


def _get_fonturl(
endpoint: str,
family: str,
weight: Optional[Union[int, str]],
italic: Optional[bool],
allowed_formats: list,
use_cache: bool,
subset: str = "latin",
) -> Optional[str]:
"""
Construct the URL for a given endpoint, font family and style parameters,
Expand All @@ -35,14 +48,15 @@ def _get_fonturl(
weight: Numeric font weight (e.g., 400, 700). If None, no weight axis is set.
allowed_formats: List of acceptable font file extensions (e.g., ["woff2", "ttf"]).
use_cache: Whether or not to cache fonts (to make pyfonts faster).
subset: Unicode subset to select from the CSS (e.g., "latin", "thai"). Defaults to "latin".

Returns:
Direct URL to the font file matching the requested style and format.
"""
if isinstance(weight, str):
weight: int = _map_weight_to_numeric(weight)

cache_key: str = _cache_key(family, weight, italic, allowed_formats)
cache_key: str = _cache_key(family, weight, italic, allowed_formats, subset)
if use_cache:
if not _MEMORY_CACHE and os.path.exists(_CACHE_FILE):
_MEMORY_CACHE.update(_load_cache_from_disk())
Expand Down Expand Up @@ -76,10 +90,19 @@ def _get_fonturl(
" does not exist."
)

subsets = _parse_css_subsets(css_text)
search_text = subsets.get(subset, css_text)

formats_pattern = "|".join(map(re.escape, allowed_formats))
font_urls: list = re.findall(
rf"url\((https://[^)]+\.({formats_pattern}))\)", css_text
rf"url\((https://[^)]+\.({formats_pattern}))\)", search_text
)

if not font_urls:
font_urls = re.findall(
rf"url\((https://[^)]+\.({formats_pattern}))\)", css_text
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if not font_urls:
raise RuntimeError(
f"No font files found in formats {allowed_formats} for '{family}'"
Expand Down Expand Up @@ -125,6 +148,7 @@ def _attach_font_provider_metadata(
endpoint: str,
family: str,
allowed_formats: list[str],
subset: str,
use_cache: bool,
danger_not_verify_ssl: bool,
) -> FontProperties:
Expand All @@ -135,6 +159,7 @@ def _attach_font_provider_metadata(
"endpoint": endpoint,
"family": family,
"allowed_formats": list(allowed_formats),
"subset": subset,
"use_cache": use_cache,
"danger_not_verify_ssl": danger_not_verify_ssl,
},
Expand Down
15 changes: 15 additions & 0 deletions tests/test_bunny.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ def test_load_bunny_font(family, weight, italic, use_cache):
assert font.get_name() == family


@pytest.mark.parametrize("subset", ["latin", "thai"])
def test_get_fonturl_subset(subset):
url = _get_fonturl(
endpoint="https://fonts.bunny.net/css",
family="Chakra Petch",
weight=400,
italic=False,
allowed_formats=["woff", "ttf", "otf"],
use_cache=False,
subset=subset,
)
assert isinstance(url, str)
assert subset in url


def test_weird_api_error():
with pytest.raises(ValueError, match="No font available for the request at URL*"):
load_bunny_font("Alice", italic=True)
8 changes: 7 additions & 1 deletion tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
import json
from pyfonts import clear_pyfonts_cache, load_font, load_google_font
from pyfonts.cache import _load_cache_from_disk
from pyfonts.cache import _cache_key, _load_cache_from_disk
import sys

pytestmark = pytest.mark.skipif(
Expand All @@ -10,6 +10,12 @@
)


def test_cache_key_differs_by_subset():
key_latin = _cache_key("Roboto", 400, False, ["woff"], "latin")
key_thai = _cache_key("Roboto", 400, False, ["woff"], "thai")
assert key_latin != key_thai


def test_load_cache_when_file_missing(tmp_path, monkeypatch):
monkeypatch.setattr("pyfonts.cache._CACHE_FILE", tmp_path / "missing.json")
assert _load_cache_from_disk() == {}
Expand Down
26 changes: 25 additions & 1 deletion tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
from pyfonts.utils import _map_weight_to_numeric
from pyfonts.utils import _map_weight_to_numeric, _parse_css_subsets
import pytest


def test_parse_css_subsets_multi():
css = (
"/* thai */\n"
"@font-face { src: url(https://example.com/font-thai.woff); }\n"
"/* latin */\n"
"@font-face { src: url(https://example.com/font-latin.woff); }\n"
"/* latin-ext */\n"
"@font-face { src: url(https://example.com/font-latin-ext.woff); }\n"
)
result = _parse_css_subsets(css)
assert "thai" in result
assert "latin" in result
assert "latin-ext" in result
assert "font-thai" in result["thai"]
assert "font-latin.woff" in result["latin"]
assert "font-latin-ext" in result["latin-ext"]


def test_parse_css_subsets_no_comments():
css = "@font-face { src: url(https://example.com/font.woff); }"
result = _parse_css_subsets(css)
assert result == {"": css}


def test_map_weight_to_numeric():
assert _map_weight_to_numeric("thin") == 100
assert _map_weight_to_numeric("extra-light") == 200
Expand Down
Loading