Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 74 additions & 18 deletions docs/_ext/sphinx_fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,57 @@ def _cdn_url(
)


# Unicode range descriptors per subset — tells the browser to only download
# the file when characters from this range appear on the page. Ranges are
# from Fontsource / Google Fonts CSS (CSS unicode-range values).
_UNICODE_RANGES: dict[str, str] = {
"latin": (
"U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,"
" U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,"
" U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,"
" U+FEFF, U+FFFD"
),
"latin-ext": (
"U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,"
" U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF,"
" U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,"
" U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF"
),
"cyrillic": ("U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116"),
"cyrillic-ext": (
"U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F"
),
"greek": (
"U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF"
),
"vietnamese": (
"U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169,"
" U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304,"
" U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB"
),
}


def _unicode_range(subset: str) -> str:
"""Return the CSS ``unicode-range`` descriptor for *subset*.

Falls back to an empty string for unknown subsets (omitting the
descriptor causes the browser to treat the face as covering all
codepoints, which is the correct fallback).

Parameters
----------
subset : str
Fontsource subset name (e.g. ``"latin"``, ``"latin-ext"``).

Returns
-------
str
CSS ``unicode-range`` value, or ``""`` if unknown.
"""
return _UNICODE_RANGES.get(subset, "")


def _download_font(url: str, dest: pathlib.Path) -> bool:
if dest.exists():
logger.debug("font cached: %s", dest.name)
Expand Down Expand Up @@ -89,31 +140,36 @@ def _on_builder_inited(app: Sphinx) -> None:
font_id = font["package"].split("/")[-1]
version = font["version"]
package = font["package"]
subset = font.get("subset", "latin")
for weight in font["weights"]:
for style in font["styles"]:
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
cached = cache / filename
url = _cdn_url(package, version, font_id, subset, weight, style)
if _download_font(url, cached):
shutil.copy2(cached, fonts_dir / filename)
font_faces.append(
{
"family": font["family"],
"style": style,
"weight": str(weight),
"filename": filename,
}
)
# Accept "subsets" (list) or legacy "subset" (str).
subsets: list[str] = font.get("subsets", [font.get("subset", "latin")])
for subset in subsets:
for weight in font["weights"]:
for style in font["styles"]:
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
cached = cache / filename
url = _cdn_url(package, version, font_id, subset, weight, style)
if _download_font(url, cached):
shutil.copy2(cached, fonts_dir / filename)
font_faces.append(
{
"family": font["family"],
"style": style,
"weight": str(weight),
"filename": filename,
"unicode_range": _unicode_range(subset),
}
)

preload_hrefs: list[str] = []
preload_specs: list[tuple[str, int, str]] = app.config.sphinx_font_preload
for family_name, weight, style in preload_specs:
for font in fonts:
if font["family"] == family_name:
font_id = font["package"].split("/")[-1]
subset = font.get("subset", "latin")
filename = f"{font_id}-{subset}-{weight}-{style}.woff2"
# Preload the first (primary) subset only — typically "latin".
subsets = font.get("subsets", [font.get("subset", "latin")])
primary = subsets[0] if subsets else "latin"
filename = f"{font_id}-{primary}-{weight}-{style}.woff2"
preload_hrefs.append(filename)
break

Expand Down
3 changes: 3 additions & 0 deletions docs/_templates/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
font-weight: {{ face.weight }};
font-display: block;
src: url("{{ pathto('_static/fonts/' + face.filename, 1) }}") format("woff2");
{%- if face.unicode_range %}
unicode-range: {{ face.unicode_range }};
{%- endif %}
}
{%- endfor %}
{%- for fb in font_fallbacks|default([]) %}
Expand Down
6 changes: 3 additions & 3 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,15 @@
"version": "5.2.8",
"weights": [400, 500, 600, 700],
"styles": ["normal", "italic"],
"subset": "latin",
"subsets": ["latin", "latin-ext"],
},
{
"family": "IBM Plex Mono",
"package": "@fontsource/ibm-plex-mono",
"version": "5.2.7",
"weights": [400],
"weights": [400, 500, 600, 700],
"styles": ["normal", "italic"],
"subset": "latin",
"subsets": ["latin", "latin-ext"],
},
]

Expand Down
129 changes: 129 additions & 0 deletions tests/docs/_ext/test_sphinx_fonts.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,36 @@ def test_cdn_url_matches_template() -> None:
assert url.endswith(".woff2")


# --- _unicode_range tests ---


def test_unicode_range_latin() -> None:
"""_unicode_range returns a non-empty range for 'latin'."""
result = sphinx_fonts._unicode_range("latin")
assert result.startswith("U+")
assert "U+0000" in result


def test_unicode_range_latin_ext() -> None:
"""_unicode_range returns a non-empty range for 'latin-ext'."""
result = sphinx_fonts._unicode_range("latin-ext")
assert result.startswith("U+")
assert result != sphinx_fonts._unicode_range("latin")


def test_unicode_range_unknown_subset() -> None:
"""_unicode_range returns empty string for unknown subsets."""
result = sphinx_fonts._unicode_range("klingon")
assert result == ""


def test_unicode_range_all_known_subsets_non_empty() -> None:
"""Every subset in _UNICODE_RANGES produces a non-empty range."""
for subset, urange in sphinx_fonts._UNICODE_RANGES.items():
assert urange.startswith("U+"), f"subset {subset!r} has invalid range"
assert sphinx_fonts._unicode_range(subset) == urange


# --- _download_font tests ---


Expand Down Expand Up @@ -337,6 +367,105 @@ def test_on_builder_inited_explicit_subset(
assert app._font_faces[0]["filename"] == "noto-sans-latin-ext-400-normal.woff2"


def test_on_builder_inited_multiple_subsets(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_on_builder_inited downloads files for each subset and includes unicode_range."""
monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache")

fonts = [
{
"package": "@fontsource/ibm-plex-sans",
"version": "5.2.8",
"family": "IBM Plex Sans",
"subsets": ["latin", "latin-ext"],
"weights": [400],
"styles": ["normal"],
},
]
app = _make_app(tmp_path, fonts=fonts)

cache = tmp_path / "cache"
cache.mkdir(parents=True)
(cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data")
(cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data")

sphinx_fonts._on_builder_inited(app)

assert len(app._font_faces) == 2
filenames = [f["filename"] for f in app._font_faces]
assert "ibm-plex-sans-latin-400-normal.woff2" in filenames
assert "ibm-plex-sans-latin-ext-400-normal.woff2" in filenames

# unicode_range should be populated for known subsets
latin_face = next(f for f in app._font_faces if "latin-400" in f["filename"])
assert latin_face["unicode_range"].startswith("U+")
latin_ext_face = next(f for f in app._font_faces if "latin-ext" in f["filename"])
assert latin_ext_face["unicode_range"].startswith("U+")
assert latin_face["unicode_range"] != latin_ext_face["unicode_range"]


def test_on_builder_inited_legacy_subset_gets_unicode_range(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Legacy single 'subset' config still produces unicode_range in font_faces."""
monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache")

fonts = [
{
"package": "@fontsource/noto-sans",
"version": "5.0.0",
"family": "Noto Sans",
"subset": "latin",
"weights": [400],
"styles": ["normal"],
},
]
app = _make_app(tmp_path, fonts=fonts)

cache = tmp_path / "cache"
cache.mkdir(parents=True)
(cache / "noto-sans-latin-400-normal.woff2").write_bytes(b"data")

sphinx_fonts._on_builder_inited(app)

assert len(app._font_faces) == 1
assert app._font_faces[0]["unicode_range"].startswith("U+")


def test_on_builder_inited_preload_uses_primary_subset(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Preload uses the first (primary) subset when multiple are configured."""
monkeypatch.setattr("sphinx_fonts._cache_dir", lambda: tmp_path / "cache")

fonts = [
{
"package": "@fontsource/ibm-plex-sans",
"version": "5.2.8",
"family": "IBM Plex Sans",
"subsets": ["latin", "latin-ext"],
"weights": [400],
"styles": ["normal"],
},
]
preload = [("IBM Plex Sans", 400, "normal")]
app = _make_app(tmp_path, fonts=fonts, preload=preload)

cache = tmp_path / "cache"
cache.mkdir(parents=True)
(cache / "ibm-plex-sans-latin-400-normal.woff2").write_bytes(b"data")
(cache / "ibm-plex-sans-latin-ext-400-normal.woff2").write_bytes(b"data")

sphinx_fonts._on_builder_inited(app)

# Preload should only include the primary (first) subset
assert app._font_preload_hrefs == ["ibm-plex-sans-latin-400-normal.woff2"]


def test_on_builder_inited_preload_match(
tmp_path: pathlib.Path,
monkeypatch: pytest.MonkeyPatch,
Expand Down
Loading