├── tests ├── __init__.py ├── Ultra-Regular.ttf ├── Amarante-Regular.ttf ├── test_preview.py ├── test_set_default.py ├── test_utils.py ├── test_ssl_error.py ├── test_cache.py ├── test_google.py ├── test_bunny.py ├── test_is_valid.py └── test_load_font.py ├── .coverage ├── img ├── quickstart.png └── change_weight.png ├── docs ├── img │ ├── quickstart.png │ └── quickstart-2.png ├── reference │ ├── preview_font.md │ ├── cache.md │ ├── set_default_font.md │ ├── load_google_font.md │ ├── load_bunny_font.md │ └── load_font.md ├── changelog.md ├── contributing.md ├── stylesheets │ └── style.css ├── examples.md └── index.md ├── pyfonts ├── decompress.py ├── __init__.py ├── preview_font.py ├── is_valid.py ├── google.py ├── bunny.py ├── cache.py ├── utils.py └── main.py ├── .gitignore ├── Makefile ├── .github └── workflows │ ├── type.yaml │ ├── lint.yaml │ ├── test.yaml │ ├── site.yaml │ └── pypi.yaml ├── .pre-commit-config.yaml ├── coverage-badge.svg ├── LICENSE ├── pyproject.toml ├── mkdocs.yml ├── README.md └── overrides └── partials └── footer.html /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coverage: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/.coverage -------------------------------------------------------------------------------- /img/quickstart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/img/quickstart.png -------------------------------------------------------------------------------- /img/change_weight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/img/change_weight.png -------------------------------------------------------------------------------- /docs/img/quickstart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/docs/img/quickstart.png -------------------------------------------------------------------------------- /tests/Ultra-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/tests/Ultra-Regular.ttf -------------------------------------------------------------------------------- /docs/img/quickstart-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/docs/img/quickstart-2.png -------------------------------------------------------------------------------- /tests/Amarante-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/y-sunflower/pyfonts/HEAD/tests/Amarante-Regular.ttf -------------------------------------------------------------------------------- /docs/reference/preview_font.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | # Preview font 9 | 10 | ::: pyfonts.preview_font 11 | 12 |
13 | -------------------------------------------------------------------------------- /pyfonts/decompress.py: -------------------------------------------------------------------------------- 1 | import os 2 | from fontTools.ttLib import woff2 3 | 4 | 5 | def _decompress_woff_to_ttf(font_url: str) -> str: 6 | output_path: str = os.path.splitext(font_url)[0] + ".ttf" 7 | woff2.decompress(font_url, output_path) 8 | return output_path 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | dist/ 3 | __pycache__/ 4 | .DS_Store 5 | pyfonts.egg-info/ 6 | sandbox.ipynb 7 | .ruff_cache/ 8 | .pytest_cache/ 9 | *cache* 10 | !cache.py 11 | !test_cache.py 12 | !cache.md 13 | sandbox.* 14 | uv.lock 15 | scripts/release.sh 16 | sandbox/ 17 | .venv 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | 3 | coverage: 4 | uv run coverage run --source=pyfonts -m pytest 5 | uv run coverage report -m 6 | uv run coverage xml 7 | uv run genbadge coverage -i coverage.xml 8 | rm coverage.xml 9 | 10 | preview: 11 | uv run mkdocs serve 12 | 13 | test: 14 | uv run pytest 15 | -------------------------------------------------------------------------------- /tests/test_preview.py: -------------------------------------------------------------------------------- 1 | from pyfonts import preview_font 2 | from matplotlib.figure import Figure 3 | 4 | 5 | def test_preview_font(): 6 | fig: Figure = preview_font( 7 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true" 8 | ) 9 | 10 | assert isinstance(fig, Figure) 11 | -------------------------------------------------------------------------------- /docs/reference/cache.md: -------------------------------------------------------------------------------- 1 | # Cache 2 | 3 | By default, `pyfonts` caches font files to improve performance. But you can disable this behaviour by setting `use_cache=False` in the [`load_font()`](load_font.md) and [`load_google_font()`](load_google_font.md) functions, and manually clearing the cache with `clear_pyfonts_cache()`. 4 | 5 |
6 | 7 | ::: pyfonts.clear_pyfonts_cache 8 | 9 |
10 | -------------------------------------------------------------------------------- /tests/test_set_default.py: -------------------------------------------------------------------------------- 1 | from pyfonts import set_default_font, load_google_font 2 | import matplotlib.pyplot as plt 3 | 4 | 5 | def test_set_default(): 6 | # check that the default font is set correctly 7 | set_default_font(load_google_font("Barrio")) 8 | assert plt.rcParams["font.family"] == ["Barrio"] 9 | 10 | # and that one can re-override 11 | set_default_font(load_google_font("Lato", weight="thin")) 12 | assert plt.rcParams["font.family"] == ["Lato Hairline"] 13 | -------------------------------------------------------------------------------- /pyfonts/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import load_font, set_default_font 2 | from .google import load_google_font 3 | from .bunny import load_bunny_font 4 | from .cache import clear_pyfonts_cache 5 | from .preview_font import preview_font 6 | 7 | from typing import Literal 8 | 9 | __version__: Literal["1.2.0"] = "1.2.0" 10 | __all__: list[str] = [ 11 | "load_font", 12 | "load_google_font", 13 | "load_bunny_font", 14 | "set_default_font", 15 | "preview_font", 16 | "clear_pyfonts_cache", 17 | ] 18 | -------------------------------------------------------------------------------- /.github/workflows/type.yaml: -------------------------------------------------------------------------------- 1 | name: Check Static Typing 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check-formatting: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v5 18 | with: 19 | enable-cache: true 20 | 21 | - name: Install the project 22 | run: uv sync --all-groups 23 | 24 | - name: Run tests 25 | run: uv run ty check 26 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ## 1.2.0 (stable) 2 | 3 | - **NEW**: Add `load_bunny_font()` function to use fonts from Bunny Fonts ([issue #32](https://github.com/y-sunflower/pyfonts/issues/32), [PR #37](https://github.com/y-sunflower/pyfonts/pull/37)) 4 | - **DOC**: New landing page ([PR #36](https://github.com/y-sunflower/pyfonts/pull/36)) 5 | - **FIX**: Fix path VS url detection ([issue #34](https://github.com/y-sunflower/pyfonts/issues/34), [PR #35](https://github.com/y-sunflower/pyfonts/pull/35)) 6 | 7 | ## 1.1.3 8 | 9 | This changelog page has been created when `pyfonts` 1.1.3 was released. 10 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Check Code Formatting 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check-formatting: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.13" 20 | 21 | - name: Install Ruff 22 | run: pip install ruff==0.9.10 23 | 24 | - name: Run Ruff format check 25 | run: ruff format --check . 26 | 27 | - name: Run Ruff linting and auto-fix 28 | run: ruff check --fix . 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.7 4 | hooks: 5 | - id: ruff 6 | types_or: [python, pyi] 7 | args: [--fix] 8 | - id: ruff-format 9 | types_or: [python, pyi] 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: check-yaml 14 | - id: trailing-whitespace 15 | - id: end-of-file-fixer 16 | - id: check-added-large-files 17 | - repo: https://github.com/python-jsonschema/check-jsonschema 18 | rev: 0.28.2 19 | hooks: 20 | - id: check-github-workflows 21 | args: ["--verbose"] 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest, macos-latest] 13 | python-version: ["3.9", "3.14"] 14 | 15 | env: 16 | UV_PYTHON: ${{ matrix.python-version }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v5 22 | 23 | - name: Enable caching 24 | uses: astral-sh/setup-uv@v5 25 | with: 26 | enable-cache: true 27 | 28 | - name: Install the project 29 | run: uv sync --all-extras --dev 30 | 31 | - name: Run tests 32 | run: uv run pytest 33 | -------------------------------------------------------------------------------- /docs/reference/set_default_font.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | # Set default font 9 | 10 | ::: pyfonts.set_default_font 11 | 12 |
13 | 14 | ## Example 15 | 16 | ```python hl_lines="4 5" 17 | # mkdocs: render 18 | from pyfonts import set_default_font, load_google_font 19 | 20 | font = load_google_font("Bitcount") 21 | set_default_font(font) # Sets font for all text 22 | 23 | fig, ax = plt.subplots() 24 | 25 | ax.plot([0, 1, 2, 3, 4], label='hello') 26 | ax.set_title('Simple Line Chart') 27 | ax.text(x=0, y=3.5, s="Using new default font", size=20) 28 | ax.legend() 29 | 30 | font = load_google_font("Roboto") 31 | ax.text(x=0, y=2.5, s="Using a specific font", size=20, font=font) 32 | ``` 33 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from pyfonts.utils import _map_weight_to_numeric 2 | import pytest 3 | 4 | 5 | def test_map_weight_to_numeric(): 6 | assert _map_weight_to_numeric("thin") == 100 7 | assert _map_weight_to_numeric("extra-light") == 200 8 | assert _map_weight_to_numeric("light") == 300 9 | assert _map_weight_to_numeric("regular") == 400 10 | assert _map_weight_to_numeric("medium") == 500 11 | assert _map_weight_to_numeric("semi-bold") == 600 12 | assert _map_weight_to_numeric("bold") == 700 13 | assert _map_weight_to_numeric("extra-bold") == 800 14 | assert _map_weight_to_numeric("black") == 900 15 | 16 | assert _map_weight_to_numeric(200) == 200 17 | assert _map_weight_to_numeric(700) == 700 18 | 19 | with pytest.raises(ValueError, match="Invalid weight descriptor: "): 20 | _map_weight_to_numeric("invalid-weight") 21 | -------------------------------------------------------------------------------- /pyfonts/preview_font.py: -------------------------------------------------------------------------------- 1 | from pyfonts.main import load_font 2 | import matplotlib.pyplot as plt 3 | from matplotlib.figure import Figure 4 | 5 | 6 | def preview_font( 7 | font_url: str, 8 | ) -> Figure: 9 | """ 10 | Preview a font. `font_url` is passed to [`load_font()`](load_font.md) 11 | """ 12 | font = load_font(font_url) 13 | 14 | fig = plt.figure(figsize=(10, 5)) 15 | plt.text( 16 | 0.5, 17 | 0.5, 18 | "Hello, World From PyFonts!", 19 | fontsize=30, 20 | ha="center", 21 | va="center", 22 | font=font, 23 | ) 24 | plt.text( 25 | 0.5, 26 | 0.35, 27 | "This is a test.", 28 | fontsize=25, 29 | ha="center", 30 | va="center", 31 | font=font, 32 | ) 33 | plt.text( 34 | 0.5, 35 | 0.65, 36 | "How about this?", 37 | fontsize=20, 38 | ha="center", 39 | va="center", 40 | font=font, 41 | ) 42 | 43 | plt.axis("off") 44 | 45 | return fig 46 | -------------------------------------------------------------------------------- /coverage-badge.svg: -------------------------------------------------------------------------------- 1 | coverage: 95.65%coverage95.65% 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Joseph Barbier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/site.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Configure Git Credentials 16 | run: | 17 | git config user.name github-actions[bot] 18 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.x 22 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 23 | - uses: actions/cache@v4 24 | with: 25 | key: mkdocs-material-${{ env.cache_id }} 26 | path: .cache 27 | restore-keys: | 28 | mkdocs-material- 29 | 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v5 32 | 33 | - name: Enable caching 34 | uses: astral-sh/setup-uv@v5 35 | with: 36 | enable-cache: true 37 | 38 | - name: Install the project 39 | run: uv sync --all-extras --dev 40 | 41 | - name: Deploy MkDocs 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | run: PYTHONPATH=$(pwd) uv run mkdocs gh-deploy --force 45 | -------------------------------------------------------------------------------- /tests/test_ssl_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import ssl 3 | from urllib.error import URLError 4 | from pyfonts.main import load_font 5 | 6 | 7 | def make_ssl_error_urlopen(*args, **kwargs): 8 | raise URLError(ssl.SSLCertVerificationError("certificate verify failed")) 9 | 10 | 11 | def test_ssl_error_raises(monkeypatch): 12 | monkeypatch.setattr("pyfonts.main.urlopen", make_ssl_error_urlopen) 13 | 14 | with pytest.raises( 15 | Exception, match="SSL certificate verification failed." 16 | ) as excinfo: 17 | load_font("https://example.com/font.ttf") 18 | 19 | msg = str(excinfo.value) 20 | assert "SSL certificate verification failed" in msg 21 | assert "danger_not_verify_ssl=True" in msg 22 | 23 | 24 | def test_ssl_error_warning(monkeypatch): 25 | class DummyResponse: 26 | def read(self): 27 | return b"dummy font data" 28 | 29 | calls = {"count": 0} 30 | 31 | def fake_urlopen(*args, **kwargs): 32 | if calls["count"] == 0: 33 | calls["count"] += 1 34 | raise URLError(ssl.SSLCertVerificationError("certificate verify failed")) 35 | return DummyResponse() 36 | 37 | monkeypatch.setattr("pyfonts.main.urlopen", fake_urlopen) 38 | 39 | with pytest.warns(UserWarning, match="SSL certificate verification disabled"): 40 | font = load_font( 41 | "https://example.com/font.ttf", 42 | danger_not_verify_ssl=True, 43 | use_cache=False, 44 | ) 45 | assert font is not None 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pyfonts" 3 | description = "A simple and reproducible way of using fonts in matplotlib" 4 | version = "1.2.0" 5 | license = "MIT" 6 | license-files = ["LICENSE"] 7 | keywords = ["matplotlib", "font", "reproducibility", "google", "bunny"] 8 | authors = [ 9 | { name="Joseph Barbier", email="joseph.barbierdarnal@gmail.com" }, 10 | ] 11 | readme = "README.md" 12 | requires-python = ">=3.9" 13 | classifiers = [ 14 | "Programming Language :: Python :: 3", 15 | "Operating System :: OS Independent", 16 | "Framework :: Matplotlib", 17 | "Development Status :: 5 - Production/Stable" 18 | ] 19 | dependencies = [ 20 | "matplotlib", 21 | "requests", 22 | ] 23 | 24 | [build-system] 25 | requires = [ 26 | "setuptools", 27 | "setuptools-scm", 28 | ] 29 | build-backend = "setuptools.build_meta" 30 | 31 | [tool.setuptools] 32 | packages = ["pyfonts"] 33 | 34 | [tool.uv.sources] 35 | pyfonts = { workspace = true } 36 | 37 | [dependency-groups] 38 | dev = [ 39 | "pre-commit>=4.2.0", 40 | "pytest>=8.3.5", 41 | "mkdocs-material>=9.6.9", 42 | "mkdocs-matplotlib>=0.10.1", 43 | "mkdocstrings-python>=1.16.5", 44 | "coverage>=7.9.1", 45 | "genbadge[coverage]>=1.1.2", 46 | "ty>=0.0.1a23", 47 | "mkdocs-redirects>=1.2.2", 48 | ] 49 | 50 | [project.urls] 51 | Homepage = "https://y-sunflower.github.io/pyfonts/" 52 | Issues = "https://github.com/y-sunflower/pyfonts/issues" 53 | Documentation = "https://y-sunflower.github.io/pyfonts/" 54 | Repository = "https://github.com/y-sunflower/pyfonts" 55 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pyfonts 2 | site_url: https://y-sunflower.github.io/pyfonts/ 3 | repo_url: https://github.com/y-sunflower/pyfonts 4 | 5 | theme: 6 | name: material 7 | custom_dir: overrides 8 | features: 9 | - content.code.copy 10 | - navigation.path 11 | - navigation.tabs 12 | icon: 13 | repo: fontawesome/brands/github 14 | favicon: https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/pyfonts/image.png?raw=true 15 | logo: https://github.com/JosephBARBIERDARNAL/static/blob/main/python-libs/pyfonts/image.png?raw=true 16 | 17 | plugins: 18 | - mkdocs_matplotlib 19 | - search 20 | - redirects: 21 | redirect_maps: 22 | "examples.md": "index.md#quick-start" 23 | - mkdocstrings: 24 | default_handler: python 25 | handlers: 26 | python: 27 | options: 28 | show_source: false 29 | show_root_heading: true 30 | heading_level: 3 31 | 32 | nav: 33 | - Home: index.md 34 | - API Reference: 35 | - reference/load_font.md 36 | - reference/load_google_font.md 37 | - reference/load_bunny_font.md 38 | - reference/set_default_font.md 39 | - reference/preview_font.md 40 | - reference/cache.md 41 | - Contributing: contributing.md 42 | - Changelog: changelog.md 43 | 44 | extra_css: 45 | - stylesheets/style.css 46 | 47 | markdown_extensions: 48 | - pymdownx.tabbed: 49 | alternate_style: true 50 | - pymdownx.highlight: 51 | anchor_linenums: true 52 | line_spans: __span 53 | pygments_lang_class: true 54 | - pymdownx.inlinehilite 55 | - pymdownx.snippets 56 | - pymdownx.superfences 57 | - attr_list 58 | - md_in_html 59 | - pymdownx.blocks.caption 60 | - admonition 61 | - pymdownx.details 62 | - pymdownx.critic 63 | - pymdownx.caret 64 | - pymdownx.keys 65 | - pymdownx.mark 66 | - pymdownx.tilde 67 | -------------------------------------------------------------------------------- /pyfonts/is_valid.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urlparse, parse_qs 3 | 4 | 5 | def _is_url(s: str) -> bool: 6 | """ 7 | Tests whether a string is an url. 8 | 9 | Args: 10 | s: a string. 11 | 12 | Returns: 13 | a boolean indicating whether the string is an url or not. 14 | """ 15 | parseResult = urlparse(s) 16 | is_an_url: bool = ( 17 | parseResult.scheme in ["http", "https"] and parseResult.netloc != "" 18 | ) 19 | return is_an_url 20 | 21 | 22 | def _is_valid_raw_url(url: str) -> bool: 23 | """ 24 | Tests whether a given URL points to a raw font file by checking: 25 | - If the extension is a common font format (.ttf, .otf, .woff, .woff2) 26 | - If it's a GitHub URL, whether it's in a valid raw format 27 | 28 | Args: 29 | url: the url of the font file. 30 | 31 | Returns: 32 | a boolean indicating whether the url likely corresponds to a raw font file. 33 | """ 34 | # Check file extension 35 | font_pattern = r".+\.(ttf|otf|woff2?|eot)(\?.*)?$" 36 | if not re.match(font_pattern, url, re.IGNORECASE): 37 | return False 38 | 39 | parsed = urlparse(url) 40 | 41 | # Non-GitHub URLs are accepted if the extension is valid 42 | if ( 43 | "github.com" not in parsed.netloc 44 | and "raw.githubusercontent.com" not in parsed.netloc 45 | ): 46 | return True 47 | 48 | # GitHub raw subdomain 49 | if "raw.githubusercontent.com" in parsed.netloc: 50 | return True 51 | 52 | # Check for /raw/ in path (e.g., /user/repo/raw/branch/file) 53 | if "/raw/" in parsed.path: 54 | return True 55 | 56 | # Check for ?raw=true in query string, even with extra params 57 | query_params: dict[str, list[str]] = parse_qs(parsed.query) 58 | if "raw" in query_params and query_params["raw"] == ["true"]: 59 | return True 60 | 61 | return False 62 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import json 3 | from pyfonts import clear_pyfonts_cache, load_font, load_google_font 4 | from pyfonts.cache import _load_cache_from_disk 5 | import sys 6 | 7 | pytestmark = pytest.mark.skipif( 8 | sys.platform.startswith("win"), 9 | reason="Windows is just too weird", 10 | ) 11 | 12 | 13 | def test_load_cache_when_file_missing(tmp_path, monkeypatch): 14 | monkeypatch.setattr("pyfonts.cache._CACHE_FILE", tmp_path / "missing.json") 15 | assert _load_cache_from_disk() == {} 16 | 17 | 18 | def test_load_cache_valid_file(tmp_path, monkeypatch): 19 | data = {"a": 1} 20 | fp = tmp_path / "cache.json" 21 | fp.write_text(json.dumps(data)) 22 | monkeypatch.setattr("pyfonts.cache._CACHE_FILE", fp) 23 | assert _load_cache_from_disk() == data 24 | 25 | 26 | def test_load_cache_invalid_json(tmp_path, monkeypatch): 27 | fp = tmp_path / "cache.json" 28 | fp.write_text("{invalid json") 29 | monkeypatch.setattr("pyfonts.cache._CACHE_FILE", fp) 30 | assert _load_cache_from_disk() == {} 31 | 32 | 33 | @pytest.mark.parametrize("use_cache", [True, False]) 34 | @pytest.mark.parametrize("verbose", [True, False]) 35 | def test_pyfonts_cache(verbose, use_cache, capsys): 36 | clear_pyfonts_cache(verbose=False) 37 | 38 | _ = load_font( 39 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true", 40 | use_cache=use_cache, 41 | ) 42 | _ = load_google_font("Roboto", use_cache=use_cache) 43 | 44 | clear_pyfonts_cache(verbose=verbose) 45 | 46 | captured = capsys.readouterr() 47 | 48 | if verbose: 49 | all(s in captured.out for s in ["Google Fonts", "Font cache cleaned"]) 50 | else: 51 | assert captured.out == "" 52 | 53 | clear_pyfonts_cache(verbose=True) 54 | captured = capsys.readouterr() 55 | if verbose: 56 | assert "No font cache directory found" in captured.out 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # pyfonts 4 | 5 | [![PyPI Downloads](https://static.pepy.tech/badge/pyfonts)](https://pepy.tech/projects/pyfonts) 6 | ![Coverage](https://raw.githubusercontent.com/y-sunflower/pyfonts/refs/heads/main/coverage-badge.svg) 7 | ![Python Versions](https://img.shields.io/badge/Python-3.9–3.14-blue) 8 | 9 | Pyfonts logo 10 | 11 |
12 | 13 | A **simple** and **reproducible** way of using fonts in matplotlib. In short, `pyfonts`: 14 | 15 | - allows you to use all fonts from [**Google Font**](https://fonts.google.com/) 16 | - allows you to use all fonts from [**Bunny Font**](https://fonts.bunny.net/) (GDPR-compliant alternative to Google Fonts) 17 | - allows you to use any font from an **arbitrary URL** 18 | - is **efficient** (thanks to its cache system) 19 | 20 |
21 | 22 | ## Quick start 23 | 24 | - Google Fonts 25 | 26 | ```python 27 | import matplotlib.pyplot as plt 28 | from pyfonts import load_google_font 29 | 30 | font = load_google_font("Fascinate Inline") 31 | 32 | fig, ax = plt.subplots() 33 | ax.text(x=0.2, y=0.5, s="Hey there!", size=30, font=font) 34 | ``` 35 | 36 | ![](https://raw.githubusercontent.com/y-sunflower/pyfonts/refs/heads/main/docs/img/quickstart.png) 37 | 38 | - Bunny Fonts 39 | 40 | ```python 41 | import matplotlib.pyplot as plt 42 | from pyfonts import load_bunny_font 43 | 44 | font = load_bunny_font("Barrio") 45 | 46 | fig, ax = plt.subplots() 47 | ax.text(x=0.2, y=0.5, s="Hey there!", size=30, font=font) 48 | ``` 49 | 50 | ![](https://raw.githubusercontent.com/y-sunflower/pyfonts/refs/heads/main/docs/img/quickstart-2.png) 51 | 52 | [**See more examples**](https://y-sunflower.github.io/pyfonts/#quick-start) 53 | 54 |
55 | 56 | ## Installation 57 | 58 | ```bash 59 | pip install pyfonts 60 | ``` 61 | -------------------------------------------------------------------------------- /tests/test_google.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from matplotlib.font_manager import FontProperties 3 | from pyfonts import load_google_font 4 | from pyfonts.utils import _get_fonturl 5 | from pyfonts.is_valid import _is_url, _is_valid_raw_url 6 | 7 | 8 | def test_errors(): 9 | with pytest.raises(ValueError, match="`weight` must be between 100 and 900"): 10 | _get_fonturl( 11 | endpoint="https://fonts.googleapis.com/css2", 12 | family="Roboto", 13 | weight=90, 14 | italic=False, 15 | allowed_formats=["woff2", "woff", "ttf", "otf"], 16 | use_cache=False, 17 | ) 18 | 19 | with pytest.raises(RuntimeError, match="No font files found in formats"): 20 | _get_fonturl( 21 | endpoint="https://fonts.googleapis.com/css2", 22 | family="Roboto", 23 | weight=400, 24 | italic=False, 25 | allowed_formats=["aaa"], 26 | use_cache=False, 27 | ) 28 | 29 | 30 | @pytest.mark.parametrize("family", ["Roboto", "Open Sans"]) 31 | @pytest.mark.parametrize("weight", [300, 500, 800]) 32 | @pytest.mark.parametrize("italic", [True, False]) 33 | def test_get_fonturl(family, weight, italic): 34 | url = _get_fonturl( 35 | endpoint="https://fonts.googleapis.com/css2", 36 | family=family, 37 | weight=weight, 38 | italic=italic, 39 | allowed_formats=["woff2", "woff", "ttf", "otf"], 40 | use_cache=False, 41 | ) 42 | 43 | assert isinstance(url, str) 44 | assert _is_url(url) 45 | assert _is_valid_raw_url(url) 46 | 47 | 48 | @pytest.mark.parametrize("family", ["Roboto", "Open Sans"]) 49 | @pytest.mark.parametrize("weight", [300, 500, 800, "bold", "light", "regular"]) 50 | @pytest.mark.parametrize("italic", [True, False]) 51 | @pytest.mark.parametrize("use_cache", [True, False]) 52 | def test_load_google_font(family, weight, italic, use_cache): 53 | font = load_google_font(family, weight=weight, italic=italic, use_cache=use_cache) 54 | 55 | assert isinstance(font, FontProperties) 56 | assert font.get_name() == family 57 | -------------------------------------------------------------------------------- /docs/reference/load_google_font.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | # Load Google font 9 | 10 |
11 | 12 | ::: pyfonts.load_google_font 13 | 14 |
15 | 16 | ## Examples 17 | 18 | #### Basic usage 19 | 20 | ```python hl_lines="5 13" 21 | # mkdocs: render 22 | import matplotlib.pyplot as plt 23 | from pyfonts import load_google_font 24 | 25 | font = load_google_font("Roboto") # default Roboto font 26 | 27 | fig, ax = plt.subplots() 28 | ax.text( 29 | x=0.2, 30 | y=0.3, 31 | s="Hey there!", 32 | size=30, 33 | font=font 34 | ) 35 | ``` 36 | 37 | #### Custom font 38 | 39 | ```python hl_lines="5 13" 40 | # mkdocs: render 41 | import matplotlib.pyplot as plt 42 | from pyfonts import load_google_font 43 | 44 | font = load_google_font("Roboto", weight="bold", italic=True) # italic and bold 45 | 46 | fig, ax = plt.subplots() 47 | ax.text( 48 | x=0.2, 49 | y=0.3, 50 | s="Hey there!", 51 | size=30, 52 | font=font 53 | ) 54 | ``` 55 | 56 | #### Use multiple fonts 57 | 58 | ```python hl_lines="5 6 15 23" 59 | # mkdocs: render 60 | import matplotlib.pyplot as plt 61 | from pyfonts import load_google_font 62 | 63 | font_bold = load_google_font("Roboto", weight="bold") 64 | font_italic = load_google_font("Roboto", italic=True) 65 | 66 | fig, ax = plt.subplots() 67 | 68 | ax.text( 69 | x=0.2, 70 | y=0.3, 71 | s="Hey bold!", 72 | size=30, 73 | font=font_bold 74 | ) 75 | 76 | ax.text( 77 | x=0.4, 78 | y=0.6, 79 | s="Hey italic!", 80 | size=30, 81 | font=font_italic 82 | ) 83 | ``` 84 | 85 | #### Fancy font 86 | 87 | All fonts from [Google font](https://fonts.google.com/) can be used: 88 | 89 | ```python hl_lines="5 13" 90 | # mkdocs: render 91 | import matplotlib.pyplot as plt 92 | from pyfonts import load_google_font 93 | 94 | font = load_google_font("Barrio") 95 | 96 | fig, ax = plt.subplots() 97 | ax.text( 98 | x=0.1, 99 | y=0.3, 100 | s="What a weird font!", 101 | size=30, 102 | font=font 103 | ) 104 | ``` 105 | -------------------------------------------------------------------------------- /docs/reference/load_bunny_font.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | # Load Bunny font 9 | 10 |
11 | 12 | ::: pyfonts.load_bunny_font 13 | 14 |
15 | 16 | ## Examples 17 | 18 | #### Basic usage 19 | 20 | ```python hl_lines="5 13" 21 | # mkdocs: render 22 | import matplotlib.pyplot as plt 23 | from pyfonts import load_bunny_font 24 | 25 | font = load_bunny_font("Alumni Sans") # default Alice font 26 | 27 | fig, ax = plt.subplots() 28 | ax.text( 29 | x=0.2, 30 | y=0.3, 31 | s="Hey there!", 32 | size=30, 33 | font=font 34 | ) 35 | ``` 36 | 37 | #### Custom font 38 | 39 | ```python hl_lines="5 13" 40 | # mkdocs: render 41 | import matplotlib.pyplot as plt 42 | from pyfonts import load_bunny_font 43 | 44 | font = load_bunny_font("Alumni Sans", weight="bold", italic=True) # italic and bold 45 | 46 | fig, ax = plt.subplots() 47 | ax.text( 48 | x=0.2, 49 | y=0.3, 50 | s="Hey there!", 51 | size=30, 52 | font=font 53 | ) 54 | ``` 55 | 56 | #### Use multiple fonts 57 | 58 | ```python hl_lines="5 6 15 23" 59 | # mkdocs: render 60 | import matplotlib.pyplot as plt 61 | from pyfonts import load_bunny_font 62 | 63 | font_bold = load_bunny_font("Alumni Sans", weight="bold") 64 | font_italic = load_bunny_font("Alumni Sans", italic=True) 65 | 66 | fig, ax = plt.subplots() 67 | 68 | ax.text( 69 | x=0.2, 70 | y=0.3, 71 | s="Hey bold!", 72 | size=30, 73 | font=font_bold 74 | ) 75 | 76 | ax.text( 77 | x=0.4, 78 | y=0.6, 79 | s="Hey italic!", 80 | size=30, 81 | font=font_italic 82 | ) 83 | ``` 84 | 85 | #### Fancy font 86 | 87 | All fonts from [Bunny font](https://fonts.bunny.net/) can be used: 88 | 89 | ```python hl_lines="5 13" 90 | # mkdocs: render 91 | import matplotlib.pyplot as plt 92 | from pyfonts import load_bunny_font 93 | 94 | font = load_bunny_font("Barrio") 95 | 96 | fig, ax = plt.subplots() 97 | ax.text( 98 | x=0.1, 99 | y=0.3, 100 | s="What a weird font!", 101 | size=30, 102 | font=font 103 | ) 104 | ``` 105 | -------------------------------------------------------------------------------- /tests/test_bunny.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from matplotlib.font_manager import FontProperties 3 | from pyfonts import load_bunny_font 4 | from pyfonts.utils import _get_fonturl 5 | from pyfonts.is_valid import _is_url, _is_valid_raw_url 6 | 7 | 8 | def test_errors(): 9 | with pytest.raises(ValueError, match="`weight` must be between 100 and 900"): 10 | _get_fonturl( 11 | endpoint="https://fonts.bunny.net/css", 12 | family="Roboto", 13 | weight=90, 14 | italic=False, 15 | allowed_formats=["woff", "ttf", "otf"], 16 | use_cache=False, 17 | ) 18 | 19 | with pytest.raises(RuntimeError, match="No font files found in formats"): 20 | _get_fonturl( 21 | endpoint="https://fonts.bunny.net/css", 22 | family="Roboto", 23 | weight=400, 24 | italic=False, 25 | allowed_formats=["aaa"], 26 | use_cache=False, 27 | ) 28 | 29 | 30 | @pytest.mark.parametrize("family", ["Alumni Sans", "Roboto"]) 31 | @pytest.mark.parametrize("weight", [None, 300, 800]) 32 | @pytest.mark.parametrize("italic", [None, True, False]) 33 | def test_get_fonturl(family, weight, italic): 34 | url = _get_fonturl( 35 | endpoint="https://fonts.bunny.net/css", 36 | family=family, 37 | weight=weight, 38 | italic=italic, 39 | allowed_formats=["woff", "ttf", "otf"], 40 | use_cache=False, 41 | ) 42 | 43 | assert isinstance(url, str) 44 | assert _is_url(url) 45 | assert _is_valid_raw_url(url) 46 | 47 | 48 | @pytest.mark.parametrize("family", ["Roboto", "Open Sans"]) 49 | @pytest.mark.parametrize("weight", [None, 300, 500, 800, "bold", "light", "regular"]) 50 | @pytest.mark.parametrize("italic", [None, True, False]) 51 | @pytest.mark.parametrize("use_cache", [True, False]) 52 | def test_load_bunny_font(family, weight, italic, use_cache): 53 | font = load_bunny_font(family, weight=weight, italic=italic, use_cache=use_cache) 54 | 55 | assert isinstance(font, FontProperties) 56 | assert font.get_name() == family 57 | 58 | 59 | def test_weird_api_error(): 60 | with pytest.raises(ValueError, match="No font available for the request at URL*"): 61 | load_bunny_font("Alice", italic=True) 62 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | Any kind of contribution is more than welcomed! There are several ways you can contribute: 2 | 3 | - Opening [GitHub issues](https://github.com/y-sunflower/pyfonts/issues) to list the bugs you've found 4 | - Implementation of new features or resolution of existing bugs 5 | - Enhancing the documentation 6 | 7 | ## How `pyfonts` works 8 | 9 | Under the bonnet, `pyfonts` does several things, but it can be summarised as follows: 10 | 11 | - Take the user's data (font name, weight, italics) and create a url that will be passed to Google's Font API. 12 | - Parse the response to obtain the url of the actual font file 13 | - Retrieve the font file from a temporary file 14 | - Use this temporary file to create a matplotlib font object (which is [`FontProperties`](https://matplotlib.org/stable/api/font_manager_api.html#matplotlib.font_manager.FontProperties){target=‘ \_blank’}) 15 | - Return this object 16 | 17 | By default, the font file url is cached to reduce the number of requests required and improve performance. The cache can be cleared with `clear_pyfonts_cache()`. 18 | 19 | ## Setting up your environment 20 | 21 | ### Install for development 22 | 23 | - Fork the repository to your own GitHub account. 24 | 25 | - Clone your forked repository to your local machine (ensure you have [Git installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)): 26 | 27 | ```bash 28 | git clone https://github.com/YOURUSERNAME/pyfonts.git 29 | cd pyfonts 30 | ``` 31 | 32 | - Create a new branch: 33 | 34 | ```bash 35 | git checkout -b my-feature 36 | ``` 37 | 38 | - Set up your Python environment (ensure you have [uv installed](https://docs.astral.sh/uv/getting-started/installation/)): 39 | 40 | ```bash 41 | uv sync --all-extras --dev 42 | uv pip install -e . 43 | ``` 44 | 45 | ### Code! 46 | 47 | You can now make changes to the package and start coding! 48 | 49 | ### Run the test 50 | 51 | - Test that everything works correctly by running: 52 | 53 | ```bash 54 | uv run pytest 55 | ``` 56 | 57 | ### Preview documentation locally 58 | 59 | ```bash 60 | uv run mkdocs serve 61 | ``` 62 | 63 | ### Push changes 64 | 65 | - Commit and push your changes: 66 | 67 | ```bash 68 | git add -A 69 | git commit -m "description of what you did" 70 | git push 71 | ``` 72 | 73 | - Go back to your fork and click on the "Open a PR" popup 74 | 75 | Congrats! Once your PR is merged, it will be part of `pyfonts`. 76 | 77 |
78 | -------------------------------------------------------------------------------- /tests/test_is_valid.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyfonts.is_valid import _is_valid_raw_url, _is_url 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "input_string, expected_result", 7 | [ 8 | ("https://www.example.com", True), 9 | ("http://example.com", True), 10 | ("ftp://ftp.example.com", False), 11 | ("www.example.com", False), 12 | ("example.com", False), 13 | ("just a string", False), 14 | ("", False), 15 | ("file:///C:/Users/username/Documents/file.txt", False), 16 | ("C:\\Windows\\Fonts\\arial.ttf", False), 17 | ("file:\\C:\\Windows\\Fonts\\arial.ttf", False), 18 | ("mailto:user@example.com", False), 19 | ], 20 | ) 21 | def test_is_url(input_string, expected_result): 22 | assert _is_url(input_string) == expected_result 23 | 24 | 25 | @pytest.mark.parametrize( 26 | "url,expected", 27 | [ 28 | ("https://github.com/user/repo/blob/master/font.ttf?raw=true", True), 29 | ("https://github.com/user/repo/raw/master/font.otf", True), 30 | ("https://raw.githubusercontent.com/user/repo/master/font.woff", True), 31 | ( 32 | "https://raw.githubusercontent.com/user/repo/branch-name/subfolder/font.woff2", 33 | True, 34 | ), 35 | ("https://github.com/user/repo/blob/master/font.ttf", False), 36 | ("https://github.com/user/repo/raw/master/font.txt", False), 37 | ("https://raw.githubusercontent.com/user/repo/master/font.exe", False), 38 | ("https://example.com/font.ttf", True), 39 | ("https://github.com/user/repo/tree/master/fonts/font.ttf", False), 40 | ( 41 | "https://github.com/user/repo/blob/master/font.ttf?raw=true¶m=value", 42 | True, 43 | ), 44 | ("https://github.com/user/repo/raw/master/font.woff", True), 45 | ("https://raw.githubusercontent.com/user/repo/master/font.ttf?raw=true", True), 46 | ], 47 | ) 48 | def test_is_valid_raw_url(url, expected): 49 | assert _is_valid_raw_url(url) == expected, f"{url}" 50 | 51 | 52 | def test_is_valid_raw_url_with_empty_string(): 53 | assert not _is_valid_raw_url("") 54 | 55 | 56 | def test_is_valid_raw_url_with_none(): 57 | with pytest.raises(TypeError): 58 | _is_valid_raw_url(None) # ty: ignore 59 | 60 | 61 | def test_is_valid_raw_url_with_non_string(): 62 | with pytest.raises(TypeError): 63 | _is_valid_raw_url(123) # ty: ignore 64 | -------------------------------------------------------------------------------- /docs/reference/load_font.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | # Load font 9 | 10 |
11 | 12 | ::: pyfonts.load_font 13 | 14 |
15 | 16 | ## Examples 17 | 18 | Most font files are stored on Github, but to pass a valid font url, you need to add `?raw=true` to the end of it. 19 | 20 | So the url goes from: 21 | 22 | ``` 23 | https://github.com/y-sunflower/pyfonts/blob/main/tests/Amarante-Regular.ttf 24 | ``` 25 | 26 | To: 27 | 28 | ``` 29 | https://github.com/y-sunflower/pyfonts/blob/main/tests/Amarante-Regular.ttf?raw=true 30 | ``` 31 | 32 | What's more, if you find a font on the Google font repo (for example, here: `https://github.com/google/fonts/`), it will probably be easier to use the [`load_google_font()`](load_google_font.md) function. 33 | 34 | #### Basic usage 35 | 36 | ```python 37 | # mkdocs: render 38 | import matplotlib.pyplot as plt 39 | from pyfonts import load_font 40 | 41 | font = load_font( 42 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true" 43 | ) 44 | 45 | fig, ax = plt.subplots() 46 | ax.text( 47 | x=0.2, 48 | y=0.3, 49 | s="Hey there!", 50 | size=30, 51 | font=font 52 | ) 53 | ``` 54 | 55 | #### Custom font 56 | 57 | ```python 58 | # mkdocs: render 59 | import matplotlib.pyplot as plt 60 | from pyfonts import load_font 61 | 62 | font = load_font( 63 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Amarante-Regular.ttf?raw=true" 64 | ) 65 | 66 | fig, ax = plt.subplots() 67 | ax.text( 68 | x=0.2, 69 | y=0.3, 70 | s="Hey there!", 71 | size=30, 72 | font=font 73 | ) 74 | ``` 75 | 76 | #### Use multiple fonts 77 | 78 | ```python 79 | # mkdocs: render 80 | import matplotlib.pyplot as plt 81 | from pyfonts import load_font 82 | 83 | font_1 = load_font( 84 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true" 85 | ) 86 | font_2 = load_font( 87 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Amarante-Regular.ttf?raw=true" 88 | ) 89 | 90 | fig, ax = plt.subplots() 91 | 92 | ax.text( 93 | x=0.2, 94 | y=0.3, 95 | s="Hey there!", 96 | size=30, 97 | font=font_1 98 | ) 99 | 100 | ax.text( 101 | x=0.4, 102 | y=0.6, 103 | s="Hello world", 104 | size=30, 105 | font=font_2 106 | ) 107 | ``` 108 | -------------------------------------------------------------------------------- /tests/test_load_font.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import re 3 | 4 | from matplotlib.font_manager import FontProperties 5 | 6 | import pyfonts 7 | from pyfonts import load_font 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "font_url", 12 | [ 13 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true", 14 | "tests/Ultra-Regular.ttf", 15 | ], 16 | ) 17 | @pytest.mark.parametrize( 18 | "use_cache", 19 | [ 20 | True, 21 | False, 22 | ], 23 | ) 24 | def test_load_font(font_url, use_cache): 25 | font = load_font(font_url, use_cache=use_cache) 26 | assert isinstance(font, FontProperties) 27 | assert font.get_family() == ["sans-serif"] 28 | assert font.get_name() == "Ultra" 29 | assert font.get_style() == "normal" 30 | 31 | 32 | def test_load_font_invalid_input(): 33 | font_url = "/path/to/font.ttf" 34 | with pytest.raises(FileNotFoundError, match=f"Font file not found: '{font_url}'."): 35 | load_font(font_url) 36 | 37 | with pytest.warns(UserWarning): 38 | font_url = "/path/to/font.ttf" 39 | with pytest.raises( 40 | FileNotFoundError, match=f"Font file not found: '{font_url}'." 41 | ): 42 | load_font(font_path=font_url) 43 | 44 | font_url = ( 45 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf" 46 | ) 47 | with pytest.raises( 48 | ValueError, 49 | match=rf"^{re.escape(f'The URL provided ({font_url}) does not appear to be valid.')}", 50 | ): 51 | load_font(font_url) 52 | 53 | font_url = "https://github.com/y-sunflower/pyfonts/blob/main/tests/UltraRegular.ttf?raw=true" 54 | with pytest.raises( 55 | Exception, 56 | match="404 error. The url passed does not exist: font file not found.", 57 | ): 58 | load_font(font_url) 59 | 60 | 61 | def test_load_font_warning(): 62 | font_path = "tests/Ultra-Regular.ttf" 63 | match = ( 64 | "`font_path` argument is deprecated and will be removed in a future version." 65 | ) 66 | f" Please replace `load_font(font_path='{font_path}')` by `load_font('{font_path}')`." 67 | with pytest.warns(UserWarning, match=match): 68 | load_font(font_path=font_path) 69 | 70 | 71 | def test_load_font_no_input(): 72 | with pytest.raises(ValueError, match="You must provide a `font_url`."): 73 | load_font() 74 | 75 | 76 | def test_pyfonts_version(): 77 | assert pyfonts.__version__ == "1.2.0" 78 | -------------------------------------------------------------------------------- /pyfonts/google.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, List 2 | from matplotlib.font_manager import FontProperties 3 | 4 | from pyfonts import load_font 5 | from pyfonts.utils import _get_fonturl 6 | 7 | 8 | def load_google_font( 9 | family: str, 10 | weight: Optional[Union[int, str]] = None, 11 | italic: Optional[bool] = None, 12 | allowed_formats: List[str] = ["woff2", "woff", "ttf", "otf"], 13 | use_cache: bool = True, 14 | danger_not_verify_ssl: bool = False, 15 | ) -> FontProperties: 16 | """ 17 | Load a font from Google Fonts with specified styling options and return a font property 18 | object that you can then use in your matplotlib charts. 19 | 20 | The easiest way to find the font you want is to browse [Google font](https://fonts.google.com/) 21 | and then pass the font name to the `family` argument. 22 | 23 | Args: 24 | family: Font family name (e.g., "Open Sans", "Roboto", etc). 25 | weight: Desired font weight (e.g., 400, 700) or one of 'thin', 'extra-light', 'light', 26 | 'regular', 'medium', 'semi-bold', 'bold', 'extra-bold', 'black'. Default is `None`. 27 | italic: Whether to use the italic variant. Default is `None`. 28 | allowed_formats: List of acceptable font file formats. Defaults to ["woff2", "woff", "ttf", "otf"]. 29 | use_cache: Whether or not to cache fonts (to make pyfonts faster). Default to `True`. 30 | danger_not_verify_ssl: Whether or not to to skip SSL certificate on 31 | `ssl.SSLCertVerificationError`. If `True`, it's a **security risk** (such as data breaches or 32 | man-in-the-middle attacks), but can be convenient in some cases, like local 33 | development when behind a firewall. 34 | 35 | Returns: 36 | matplotlib.font_manager.FontProperties: A `FontProperties` object containing the loaded font. 37 | 38 | Examples: 39 | 40 | ```python 41 | from pyfonts import load_google_font 42 | 43 | font = load_google_font("Roboto") # default Roboto font 44 | font = load_google_font("Roboto", weight="bold") # bold font 45 | font = load_google_font("Roboto", italic=True) # italic font 46 | font = load_google_font("Roboto", weight="bold", italic=True) # italic and bold 47 | ``` 48 | """ 49 | font_url = _get_fonturl( 50 | endpoint="https://fonts.googleapis.com/css2", 51 | family=family, 52 | italic=italic, 53 | weight=weight, 54 | allowed_formats=allowed_formats, 55 | use_cache=use_cache, 56 | ) 57 | 58 | return load_font( 59 | font_url, 60 | use_cache=use_cache, 61 | danger_not_verify_ssl=danger_not_verify_ssl, 62 | ) 63 | -------------------------------------------------------------------------------- /pyfonts/bunny.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union, List 2 | from matplotlib.font_manager import FontProperties 3 | 4 | from pyfonts import load_font 5 | from pyfonts.utils import _get_fonturl 6 | 7 | 8 | def load_bunny_font( 9 | family: str, 10 | weight: Optional[Union[int, str]] = None, 11 | italic: Optional[bool] = None, 12 | allowed_formats: List[str] = ["woff", "ttf", "otf"], 13 | use_cache: bool = True, 14 | danger_not_verify_ssl: bool = False, 15 | ) -> FontProperties: 16 | """ 17 | Load a font from bunny Fonts with specified styling options and return a font property 18 | object that you can then use in your matplotlib charts. 19 | 20 | The easiest way to find the font you want is to browse [bunny font](https://fonts.bunny.net/) 21 | and then pass the font name to the `family` argument. 22 | 23 | Args: 24 | family: Font family name (e.g., "Open Sans", "Roboto", etc). 25 | weight: Desired font weight (e.g., 400, 700) or one of 'thin', 'extra-light', 'light', 26 | 'regular', 'medium', 'semi-bold', 'bold', 'extra-bold', 'black'. Default is `None`. 27 | italic: Whether to use the italic variant. Default is `None`. 28 | allowed_formats: List of acceptable font file formats. Defaults to ["woff", "ttf", "otf"]. 29 | Note that for `woff2` fonts to work, you must have [brotli](https://github.com/google/brotli) 30 | installed. 31 | use_cache: Whether or not to cache fonts (to make pyfonts faster). Default to `True`. 32 | danger_not_verify_ssl: Whether or not to to skip SSL certificate on 33 | `ssl.SSLCertVerificationError`. If `True`, it's a **security risk** (such as data breaches or 34 | man-in-the-middle attacks), but can be convenient in some cases, like local 35 | development when behind a firewall. 36 | 37 | Returns: 38 | matplotlib.font_manager.FontProperties: A `FontProperties` object containing the loaded font. 39 | 40 | Examples: 41 | 42 | ```python 43 | from pyfonts import load_bunny_font 44 | 45 | font = load_bunny_font("Roboto") # default Roboto font 46 | font = load_bunny_font("Roboto", weight="bold") # bold font 47 | font = load_bunny_font("Roboto", italic=True) # italic font 48 | font = load_bunny_font("Roboto", weight="bold", italic=True) # italic and bold 49 | ``` 50 | """ 51 | font_url = _get_fonturl( 52 | endpoint="https://fonts.bunny.net/css", 53 | family=family, 54 | weight=weight, 55 | italic=italic, 56 | allowed_formats=allowed_formats, 57 | use_cache=use_cache, 58 | ) 59 | 60 | return load_font( 61 | font_url, 62 | use_cache=use_cache, 63 | danger_not_verify_ssl=danger_not_verify_ssl, 64 | ) 65 | -------------------------------------------------------------------------------- /overrides/partials/footer.html: -------------------------------------------------------------------------------- 1 | 85 | 86 | 110 | -------------------------------------------------------------------------------- /docs/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Barriecito&family=Rubik+Distressed&family=Bangers&family=Jolly+Lodger&display=swap"); 2 | 3 | h2, 4 | h3, 5 | h4 { 6 | margin-top: 3em !important; 7 | } 8 | 9 | .md-header { 10 | background-color: #cb4e4f; 11 | } 12 | 13 | .md-tabs { 14 | background-color: #cb4e4f; 15 | } 16 | 17 | [data-md-component="logo"] > img { 18 | height: 3rem !important; 19 | border: 1px solid white; 20 | border-radius: 50%; 21 | } 22 | 23 | .hero { 24 | font-size: 1.2em; 25 | height: 75vh; 26 | display: flex; 27 | align-items: center; 28 | text-align: center; 29 | } 30 | 31 | .w2 { 32 | font-family: "Bangers", system-ui; 33 | } 34 | 35 | .w4 { 36 | font-family: "Jolly Lodger", system-ui; 37 | } 38 | 39 | .w9 { 40 | font-family: "Barriecito", system-ui; 41 | } 42 | 43 | .w8 { 44 | font-family: "Rubik Distressed", system-ui; 45 | } 46 | 47 | .pyfonts-name { 48 | display: inline-block; 49 | font-weight: 800; 50 | font-size: 1.2em; 51 | line-height: 1; 52 | letter-spacing: 0.02em; 53 | transform-origin: center; 54 | color: #cb4e4f; 55 | position: relative; 56 | text-decoration: underline; 57 | } 58 | 59 | /* trigger-class added on load */ 60 | .pyfonts-name.animated { 61 | animation: popWiggle 1s cubic-bezier(0.2, 0.9, 0.3, 1) both, 62 | gradientShift 3s linear infinite; 63 | } 64 | 65 | /* quick glossy sweep */ 66 | .pyfonts-name.animated::after { 67 | content: ""; 68 | position: absolute; 69 | left: -40%; 70 | top: 0; 71 | width: 60%; 72 | height: 100%; 73 | transform: skewX(-20deg); 74 | background: linear-gradient( 75 | 90deg, 76 | rgba(255, 255, 255, 0) 0%, 77 | rgba(255, 255, 255, 0.9) 50%, 78 | rgba(255, 255, 255, 0) 100% 79 | ); 80 | mix-blend-mode: screen; 81 | animation: shine 1s ease 0.15s both; 82 | pointer-events: none; 83 | } 84 | 85 | /* keyframes */ 86 | @keyframes popWiggle { 87 | 0% { 88 | transform: scale(0.6) rotate(-6deg); 89 | opacity: 0; 90 | filter: blur(6px); 91 | } 92 | 50% { 93 | transform: scale(1.08) rotate(6deg); 94 | filter: blur(0); 95 | } 96 | 70% { 97 | transform: scale(0.98) rotate(-3deg); 98 | } 99 | 100% { 100 | transform: scale(1) rotate(0deg); 101 | opacity: 1; 102 | } 103 | } 104 | 105 | @keyframes gradientShift { 106 | 0% { 107 | background-position: 0% 50%; 108 | } 109 | 50% { 110 | background-position: 100% 50%; 111 | } 112 | 100% { 113 | background-position: 0% 50%; 114 | } 115 | } 116 | 117 | @keyframes shine { 118 | 0% { 119 | transform: translateX(-100%) skewX(-20deg); 120 | opacity: 0; 121 | } 122 | 40% { 123 | opacity: 1; 124 | } 125 | 100% { 126 | transform: translateX(200%) skewX(-20deg); 127 | opacity: 0; 128 | } 129 | } 130 | 131 | @media (max-width: 768px) { 132 | .md-nav--primary .md-nav__title[for="__drawer"] { 133 | background-color: #cb4e4f; 134 | } 135 | 136 | .hero { 137 | height: 80vh; 138 | font-size: 1em; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | ## Quick start 9 | 10 | The easiest (and recommended) way of using `pyfonts` is to find the name of a font you like on [Google font](https://fonts.google.com/){target="\_blank"} and pass it to `load_google_font()`: 11 | 12 | ```python 13 | # mkdocs: render 14 | import matplotlib.pyplot as plt 15 | from pyfonts import load_google_font 16 | 17 | font = load_google_font("Fascinate Inline") 18 | 19 | fig, ax = plt.subplots() 20 | ax.text( 21 | x=0.2, 22 | y=0.5, 23 | s="Hey there!", 24 | size=30, 25 | font=font # We pass it to the `font` argument 26 | ) 27 | ``` 28 | 29 | ## Bold/light fonts 30 | 31 | In order to have a **bold** font, you can use the `weight` argument that accepts either one of: "thin", "extra-light", "light", "regular","medium", "semi-bold", "bold", "extra-bold", "black", or any number between 100 and 900 (the higher the bolder). 32 | 33 | ```python 34 | # mkdocs: render 35 | import matplotlib.pyplot as plt 36 | from pyfonts import load_google_font 37 | 38 | font_bold = load_google_font("Roboto", weight="bold") 39 | font_regular = load_google_font("Roboto", weight="regular") # Default 40 | font_light = load_google_font("Roboto", weight="thin") 41 | 42 | fig, ax = plt.subplots() 43 | text_params = dict(x=0.2,size=30,) 44 | ax.text( 45 | y=0.7, 46 | s="Bold font", 47 | font=font_bold, 48 | **text_params 49 | ) 50 | ax.text( 51 | y=0.5, 52 | s="Regular font", 53 | font=font_regular, 54 | **text_params 55 | ) 56 | ax.text( 57 | y=0.3, 58 | s="Light font", 59 | font=font_light, 60 | **text_params 61 | ) 62 | ``` 63 | 64 | > Note that **not all fonts** have different weight and can be set to bold/light. 65 | 66 | ## Italic font 67 | 68 | `load_google_font()` has an `italic` argument, that can either be `True` or `False` (default to `False`). 69 | 70 | ```python 71 | # mkdocs: render 72 | import matplotlib.pyplot as plt 73 | from pyfonts import load_google_font 74 | 75 | font = load_google_font("Roboto", italic=True) 76 | 77 | fig, ax = plt.subplots() 78 | ax.text( 79 | x=0.2, 80 | y=0.5, 81 | s="This text is in italic", 82 | size=30, 83 | font=font 84 | ) 85 | ``` 86 | 87 | > Note that **not all fonts** can be set to italic. 88 | 89 | ## Set font globally 90 | 91 | If you also want to change the default font used for e.g. the axis labels, legend entries, titles, etc., you can use `set_default_font()`: 92 | 93 | ```python hl_lines="4 5" 94 | # mkdocs: render 95 | from pyfonts import set_default_font, load_google_font 96 | 97 | font = load_google_font("Fascinate Inline") 98 | set_default_font(font) # Sets font for all text 99 | 100 | fig, ax = plt.subplots() 101 | 102 | x = [0, 1, 2, 3] 103 | y = [x**2 for x in x] 104 | 105 | # x+y tick labels, legend entries, title etc. 106 | # will all be in Fascinate Inline 107 | ax.plot(x, y, "-o", label='y = x²') 108 | ax.set_title('Simple Line Chart') 109 | ax.text(x=0, y=5, s="Hello world", size=20) 110 | ax.legend() 111 | 112 | # change the font for a specific element as usual 113 | ax.set_xlabel("x values", font=load_google_font("Roboto"), size=15) 114 | ``` 115 | -------------------------------------------------------------------------------- /pyfonts/cache.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import hashlib 3 | import os 4 | import json 5 | from urllib.parse import urlparse 6 | 7 | _CACHE_FILE: str = os.path.join( 8 | os.path.expanduser("~"), 9 | ".cache", 10 | ".pyfonts_google_cache.json", 11 | ) 12 | _MEMORY_CACHE: dict = {} 13 | 14 | 15 | def _cache_key(family: str, weight, italic, allowed_formats: list[str]) -> str: 16 | key_str: str = json.dumps( 17 | { 18 | "family": family, 19 | "weight": weight, 20 | "italic": italic, 21 | "allowed_formats": allowed_formats, 22 | }, 23 | sort_keys=True, 24 | ) 25 | return hashlib.sha256(key_str.encode()).hexdigest() 26 | 27 | 28 | def _load_cache_from_disk() -> dict: 29 | if not os.path.exists(_CACHE_FILE): 30 | return {} 31 | try: 32 | with open(_CACHE_FILE, "r") as f: 33 | return json.load(f) 34 | except Exception: 35 | return {} 36 | 37 | 38 | def _save_cache_to_disk() -> None: 39 | try: 40 | with open(_CACHE_FILE, "w") as f: 41 | json.dump(_MEMORY_CACHE, f) 42 | except Exception: 43 | pass 44 | 45 | 46 | def clear_pyfonts_cache(verbose: bool = True) -> None: 47 | """ 48 | Cleans both: 49 | 1. The font cache directory 50 | 2. The Google Fonts URL cache 51 | 52 | Args: 53 | `verbose`: Whether or not to print a cache cleanup message. 54 | The default value is `True`. 55 | 56 | Examples: 57 | 58 | ```python 59 | from pyfonts import clear_pyfonts_cache 60 | 61 | clear_pyfonts_cache() 62 | ``` 63 | """ 64 | cache_dir: str = _get_cache_dir() 65 | 66 | # clear the local font file cache 67 | if os.path.exists(cache_dir): 68 | shutil.rmtree(cache_dir) 69 | if verbose: 70 | print(f"Font cache cleaned: {cache_dir}") 71 | else: 72 | if verbose: 73 | print("No font cache directory found. Nothing to clean.") 74 | 75 | # clear the Google Fonts URL cache 76 | global _MEMORY_CACHE 77 | _MEMORY_CACHE.clear() 78 | 79 | if os.path.exists(_CACHE_FILE): 80 | try: 81 | os.remove(_CACHE_FILE) 82 | if verbose: 83 | print(f"Google Fonts URL cache cleared: {_CACHE_FILE}") 84 | except Exception as e: 85 | if verbose: 86 | print(f"Failed to remove Google Fonts cache file: {e}") 87 | else: 88 | if verbose: 89 | print("No Google Fonts cache file found. Nothing to clean.") 90 | 91 | 92 | def _create_cache_from_fontfile(font_url): 93 | parsed_url = urlparse(font_url) 94 | url_path = parsed_url.path 95 | filename = os.path.basename(url_path) 96 | _, ext = os.path.splitext(filename) 97 | url_hash: str = hashlib.sha256(font_url.encode()).hexdigest() 98 | cache_filename: str = f"{url_hash}{ext}" 99 | cache_dir: str = _get_cache_dir() 100 | os.makedirs(cache_dir, exist_ok=True) 101 | cached_fontfile: str = os.path.join(cache_dir, cache_filename) 102 | return cached_fontfile, cache_dir 103 | 104 | 105 | def _get_cache_dir() -> str: 106 | return os.path.join(os.path.expanduser("~"), ".cache", "pyfontsloader") 107 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | # This is taken from 2 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#the-whole-ci-cd-workflow 3 | # but with the following differences 4 | # - removed the TestPyPI part 5 | # - instead of `on: push`, we have `tags` in there too 6 | 7 | name: Publish Python 🐍 distribution 📦 to PyPI 8 | 9 | on: 10 | push: 11 | tags: 12 | - "v[0-9]+.[0-9]+.[0-9]+*" 13 | 14 | jobs: 15 | build: 16 | name: Build distribution 📦 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | persist-credentials: false 23 | - name: Set up Python 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: "3.x" 27 | - name: Install pypa/build 28 | run: python3 -m pip install build --user 29 | - name: Build a binary wheel and a source tarball 30 | run: python3 -m build 31 | - name: Store the distribution packages 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: python-package-distributions 35 | path: dist/ 36 | 37 | publish-to-pypi: 38 | name: >- 39 | Publish Python 🐍 distribution 📦 to PyPI 40 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 41 | needs: 42 | - build 43 | runs-on: ubuntu-latest 44 | environment: 45 | name: pypi 46 | url: https://pypi.org/p/pytest-cov 47 | permissions: 48 | id-token: write # IMPORTANT: mandatory for trusted publishing 49 | 50 | steps: 51 | - name: Download all the dists 52 | uses: actions/download-artifact@v4 53 | with: 54 | name: python-package-distributions 55 | path: dist/ 56 | - name: Publish distribution 📦 to PyPI 57 | uses: pypa/gh-action-pypi-publish@release/v1 58 | 59 | github-release: 60 | name: >- 61 | Sign the Python 🐍 distribution 📦 with Sigstore 62 | and upload them to GitHub Release 63 | needs: 64 | - publish-to-pypi 65 | runs-on: ubuntu-latest 66 | permissions: 67 | contents: write # IMPORTANT: mandatory for making GitHub Releases 68 | id-token: write # IMPORTANT: mandatory for sigstore 69 | steps: 70 | - name: Download all the dists 71 | uses: actions/download-artifact@v4 72 | with: 73 | name: python-package-distributions 74 | path: dist/ 75 | - name: Sign the dists with Sigstore 76 | uses: sigstore/gh-action-sigstore-python@v3.0.0 77 | with: 78 | inputs: >- 79 | ./dist/*.tar.gz 80 | ./dist/*.whl 81 | - name: Create GitHub Release 82 | env: 83 | GITHUB_TOKEN: ${{ github.token }} 84 | run: >- 85 | gh release create 86 | "$GITHUB_REF_NAME" 87 | --repo "$GITHUB_REPOSITORY" 88 | --notes "" 89 | - name: Upload artifact signatures to GitHub Release 90 | env: 91 | GITHUB_TOKEN: ${{ github.token }} 92 | # Upload to GitHub Release using the `gh` CLI. 93 | # `dist/` contains the built packages, and the 94 | # sigstore-produced signatures and certificates. 95 | run: >- 96 | gh release upload 97 | "$GITHUB_REF_NAME" dist/** 98 | --repo "$GITHUB_REPOSITORY" 99 | -------------------------------------------------------------------------------- /pyfonts/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os 3 | from typing import Optional, Union 4 | import requests 5 | 6 | from pyfonts.cache import ( 7 | _cache_key, 8 | _load_cache_from_disk, 9 | _save_cache_to_disk, 10 | _MEMORY_CACHE, 11 | _CACHE_FILE, 12 | ) 13 | 14 | 15 | def _get_fonturl( 16 | endpoint: str, 17 | family: str, 18 | weight: Optional[Union[int, str]], 19 | italic: Optional[bool], 20 | allowed_formats: list, 21 | use_cache: bool, 22 | ): 23 | """ 24 | Construct the URL for a given endpoint, font family and style parameters, 25 | fetch the associated CSS, and extract the URL of the font file. 26 | 27 | Args: 28 | enpoint: URL of the font provider. 29 | family: Name of the font family (e.g., "Roboto"). 30 | italic: Whether the font should be italic. If None, no italic axis is set. 31 | weight: Numeric font weight (e.g., 400, 700). If None, no weight axis is set. 32 | allowed_formats: List of acceptable font file extensions (e.g., ["woff2", "ttf"]). 33 | use_cache: Whether or not to cache fonts (to make pyfonts faster). 34 | 35 | Returns: 36 | Direct URL to the font file matching the requested style and format. 37 | """ 38 | if isinstance(weight, str): 39 | weight: int = _map_weight_to_numeric(weight) 40 | 41 | cache_key: str = _cache_key(family, weight, italic, allowed_formats) 42 | if use_cache: 43 | if not _MEMORY_CACHE and os.path.exists(_CACHE_FILE): 44 | _MEMORY_CACHE.update(_load_cache_from_disk()) 45 | if cache_key in _MEMORY_CACHE: 46 | return _MEMORY_CACHE[cache_key] 47 | 48 | url: str = f"{endpoint}?family={family.replace(' ', '+')}" 49 | settings: dict = {} 50 | 51 | if italic: 52 | settings["ital"] = str(int(italic)) 53 | if weight is not None: 54 | if not (100 <= weight <= 900): 55 | raise ValueError(f"`weight` must be between 100 and 900, not {weight}.") 56 | settings["wght"] = str(int(weight)) 57 | if settings: 58 | axes = ",".join(settings.keys()) 59 | values = ",".join(settings.values()) 60 | url += f":{axes}@{values}" 61 | 62 | response = requests.get(url) 63 | response.raise_for_status() 64 | css_text = response.text 65 | 66 | # for some reason, Bunny fonts sends this text response instead of an 67 | # actual error message, so we handle it ourselves manually. 68 | if "Error: API Error" in css_text and "No families available" in css_text: 69 | raise ValueError( 70 | f"No font available for the request at URL: {url}. " 71 | "Maybe the font variant (italic, bold, etc) you're looking for" 72 | " does not exist." 73 | ) 74 | 75 | formats_pattern = "|".join(map(re.escape, allowed_formats)) 76 | font_urls: list = re.findall( 77 | rf"url\((https://[^)]+\.({formats_pattern}))\)", css_text 78 | ) 79 | if not font_urls: 80 | raise RuntimeError( 81 | f"No font files found in formats {allowed_formats} for '{family}'" 82 | ) 83 | 84 | for fmt in allowed_formats: 85 | for font_url, ext in font_urls: 86 | if ext == fmt: 87 | if use_cache: 88 | _MEMORY_CACHE[cache_key] = font_url 89 | _save_cache_to_disk() 90 | return font_url 91 | 92 | 93 | def _map_weight_to_numeric(weight_str: Union[str, int, float]) -> int: 94 | weight_mapping: dict = { 95 | "thin": 100, 96 | "extra-light": 200, 97 | "light": 300, 98 | "regular": 400, 99 | "medium": 500, 100 | "semi-bold": 600, 101 | "bold": 700, 102 | "extra-bold": 800, 103 | "black": 900, 104 | } 105 | if isinstance(weight_str, int) or isinstance(weight_str, float): 106 | return int(weight_str) 107 | 108 | weight_str: str = weight_str.lower() 109 | if weight_str in weight_mapping: 110 | return weight_mapping[weight_str] 111 | 112 | raise ValueError( 113 | f"Invalid weight descriptor: {weight_str}. Valid options are: " 114 | "thin, extra-light, light, regular, medium, semi-bold, bold, extra-bold, black." 115 | ) 116 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```python 2 | # mkdocs: render 3 | # mkdocs: hidecode 4 | import matplotlib 5 | matplotlib.rcParams.update(matplotlib.rcParamsDefault) 6 | ``` 7 | 8 | 9 | 10 |
11 |

12 | pyfonts 13 |

14 | a 15 | simple 16 | and 17 | reproducible 18 | way 19 | of 20 | using 21 | fonts 22 | in matplotlib 23 |

24 |
25 | 26 | Pyfonts logo 27 | 28 | In short, `pyfonts`: 29 | 30 | - allows you to use all fonts from [**Google Font**](https://fonts.google.com/) 31 | - allows you to use all fonts from [**Bunny Font**](https://fonts.bunny.net/) (GDPR-compliant alternative to Google Fonts) 32 | - allows you to use any font from an **arbitrary URL** 33 | - is **efficient** (thanks to its cache system) 34 | 35 | [![PyPI Downloads](https://static.pepy.tech/badge/pyfonts)](https://pepy.tech/projects/pyfonts) 36 | ![Coverage](https://raw.githubusercontent.com/y-sunflower/pyfonts/refs/heads/main/coverage-badge.svg) 37 | ![Python Versions](https://img.shields.io/badge/Python-3.9–3.14-blue) 38 | 39 | ```bash 40 | pip install pyfonts 41 | ``` 42 | 43 |
44 | 45 | ## Quick start 46 | 47 | The easiest (and recommended) way of using `pyfonts` is to **find the name** of a font you like on [Google Fonts](https://fonts.google.com/){target="\_blank"}/[Bunny Fonts](https://fonts.bunny.net/){target="\_blank"} and pass it to `load_google_font()`/`load_bunny_font()`: 48 | 49 | === "Google Fonts" 50 | 51 | ```python 52 | # mkdocs: render 53 | import matplotlib.pyplot as plt 54 | from pyfonts import load_google_font 55 | 56 | font = load_google_font("Fascinate Inline") 57 | 58 | fig, ax = plt.subplots() 59 | ax.text( 60 | x=0.2, 61 | y=0.5, 62 | s="Hey there!", 63 | size=30, 64 | font=font # We pass it to the `font` argument 65 | ) 66 | ``` 67 | 68 | === "Bunny Fonts" 69 | 70 | ```python 71 | # mkdocs: render 72 | import matplotlib.pyplot as plt 73 | from pyfonts import load_bunny_font 74 | 75 | font = load_bunny_font("Barrio") 76 | 77 | fig, ax = plt.subplots() 78 | ax.text( 79 | x=0.2, 80 | y=0.5, 81 | s="Hey there!", 82 | size=30, 83 | font=font # We pass it to the `font` argument 84 | ) 85 | ``` 86 | 87 | === "Other" 88 | 89 | ```python 90 | # mkdocs: render 91 | import matplotlib.pyplot as plt 92 | from pyfonts import load_font 93 | 94 | font = load_font("https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true") 95 | 96 | fig, ax = plt.subplots() 97 | ax.text( 98 | x=0.2, 99 | y=0.5, 100 | s="Hey there!", 101 | size=30, 102 | font=font # We pass it to the `font` argument 103 | ) 104 | ``` 105 | 106 | ## Bold/light fonts 107 | 108 | In order to have a **bold** font, you can use the `weight` argument that accepts either one of: "thin", "extra-light", "light", "regular","medium", "semi-bold", "bold", "extra-bold", "black", or any number between 100 and 900 (the higher the bolder). 109 | 110 | ```python 111 | # mkdocs: render 112 | import matplotlib.pyplot as plt 113 | from pyfonts import load_google_font 114 | 115 | font_bold = load_google_font("Roboto", weight="bold") 116 | font_regular = load_google_font("Roboto", weight="regular") # Default 117 | font_light = load_google_font("Roboto", weight="thin") 118 | 119 | fig, ax = plt.subplots() 120 | text_params = dict(x=0.2,size=30,) 121 | ax.text( 122 | y=0.7, 123 | s="Bold font", 124 | font=font_bold, 125 | **text_params 126 | ) 127 | ax.text( 128 | y=0.5, 129 | s="Regular font", 130 | font=font_regular, 131 | **text_params 132 | ) 133 | ax.text( 134 | y=0.3, 135 | s="Light font", 136 | font=font_light, 137 | **text_params 138 | ) 139 | ``` 140 | 141 | > Note that **not all fonts** have different weight and can be set to bold/light. 142 | 143 | ## Italic font 144 | 145 | `load_google_font()` has an `italic` argument, that can either be `True` or `False` (default to `False`). 146 | 147 | ```python 148 | # mkdocs: render 149 | import matplotlib.pyplot as plt 150 | from pyfonts import load_google_font 151 | 152 | font = load_google_font("Roboto", italic=True) 153 | 154 | fig, ax = plt.subplots() 155 | ax.text( 156 | x=0.2, 157 | y=0.5, 158 | s="This text is in italic", 159 | size=30, 160 | font=font 161 | ) 162 | ``` 163 | 164 | > Note that **not all fonts** can be set to italic. 165 | 166 | ## Set font globally 167 | 168 | If you also want to change the default font used for e.g. the axis labels, legend entries, titles, etc., you can use `set_default_font()`: 169 | 170 | ```python hl_lines="4 5" 171 | # mkdocs: render 172 | from pyfonts import set_default_font, load_google_font 173 | 174 | font = load_google_font("Fascinate Inline") 175 | set_default_font(font) # Sets font for all text 176 | 177 | fig, ax = plt.subplots() 178 | 179 | x = [0, 1, 2, 3] 180 | y = [x**2 for x in x] 181 | 182 | # x+y tick labels, legend entries, title etc. 183 | # will all be in Fascinate Inline 184 | ax.plot(x, y, "-o", label='y = x²') 185 | ax.set_title('Simple Line Chart') 186 | ax.text(x=0, y=5, s="Hello world", size=20) 187 | ax.legend() 188 | 189 | # change the font for a specific element as usual 190 | ax.set_xlabel("x values", font=load_google_font("Roboto"), size=15) 191 | ``` 192 | 193 |

194 | 195 | 198 | -------------------------------------------------------------------------------- /pyfonts/main.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import os 3 | import ssl 4 | import warnings 5 | 6 | from urllib.request import urlopen 7 | from urllib.error import URLError, HTTPError 8 | from matplotlib.font_manager import FontProperties, fontManager 9 | from matplotlib import rcParams 10 | 11 | from pyfonts.is_valid import _is_url, _is_valid_raw_url 12 | from pyfonts.cache import _create_cache_from_fontfile 13 | from pyfonts.decompress import _decompress_woff_to_ttf 14 | 15 | 16 | def load_font( 17 | font_url: Optional[str] = None, 18 | use_cache: bool = True, 19 | danger_not_verify_ssl: bool = False, 20 | font_path: Optional[str] = None, 21 | ) -> FontProperties: 22 | """ 23 | Loads a matplotlib `FontProperties` object from a remote url or a local file, 24 | that you can then use in your matplotlib charts. 25 | 26 | This function is most useful when the font you are looking for is stored locally 27 | or is not available in Google Fonts. Otherwise, it's easier to use the 28 | [`load_google_font()`](load_google_font.md) function instead. 29 | 30 | If the url points to a font file on Github, add `?raw=true` at the end of the 31 | url (see examples below). 32 | 33 | Args: 34 | font_url: It may be one of the following: 35 | - A URL pointing to a binary font file. 36 | - The local file path of the font. 37 | use_cache: Whether or not to cache fonts (to make pyfonts faster). Default to `True`. 38 | danger_not_verify_ssl: Whether or not to to skip SSL certificate on 39 | `ssl.SSLCertVerificationError`. If `True`, it's a **security risk** (such as data breaches or 40 | man-in-the-middle attacks), but can be convenient in some cases, like local 41 | development when behind a firewall. 42 | font_path: (deprecated) The local file path of the font. Use `font_url` instead. 43 | 44 | Returns: 45 | matplotlib.font_manager.FontProperties: A `FontProperties` object containing the loaded font. 46 | 47 | Examples: 48 | 49 | ```python 50 | from pyfonts import load_font 51 | 52 | font = load_font( 53 | "https://github.com/y-sunflower/pyfonts/blob/main/tests/Ultra-Regular.ttf?raw=true" 54 | ) 55 | ``` 56 | """ 57 | if font_path is not None: 58 | warnings.warn( 59 | "`font_path` argument is deprecated and will be removed in a future version." 60 | f" Please replace `load_font(font_path='{font_path}')` by `load_font('{font_path}')`." 61 | ) 62 | font_prop: FontProperties = FontProperties(fname=font_path) 63 | try: 64 | font_prop.get_name() 65 | except FileNotFoundError: 66 | raise FileNotFoundError(f"Font file not found: '{font_path}'.") 67 | return font_prop 68 | 69 | if font_url is not None: 70 | if not _is_url(font_url): 71 | # if it's not an url, it should be a path 72 | font_prop: FontProperties = FontProperties(fname=font_url) 73 | try: 74 | font_prop.get_name() 75 | except FileNotFoundError: 76 | raise FileNotFoundError(f"Font file not found: '{font_url}'.") 77 | return font_prop 78 | if not _is_valid_raw_url(font_url): 79 | raise ValueError( 80 | f"""The URL provided ({font_url}) does not appear to be valid. 81 | It must point to a binary font file from Github. 82 | Have you forgotten to append `?raw=true` to the end of the URL? 83 | """ 84 | ) 85 | 86 | cached_fontfile, cache_dir = _create_cache_from_fontfile(font_url) 87 | 88 | if use_cache: 89 | # check if file is in cache 90 | 91 | if os.path.exists(cached_fontfile): 92 | try: 93 | # woff/woff2 are not supported by matplotlib, so we convert them 94 | # to ttf. This is mostly useful to work with Bunny fonts API. 95 | if cached_fontfile.endswith(("woff", "woff2")): 96 | cached_fontfile: str = _decompress_woff_to_ttf(cached_fontfile) 97 | 98 | font_prop: FontProperties = FontProperties(fname=cached_fontfile) 99 | font_prop.get_name() # triggers an error if invalid 100 | return font_prop 101 | except Exception: 102 | # cached file is invalid, remove and proceed to download 103 | os.remove(cached_fontfile) 104 | 105 | try: 106 | response = urlopen(font_url) 107 | except HTTPError as e: 108 | if e.code == 404: 109 | raise Exception( 110 | "404 error. The url passed does not exist: font file not found." 111 | ) 112 | else: 113 | raise ValueError(f"An HTTPError has occurred. Code: {e.code}") 114 | except URLError as e: 115 | if isinstance(e.reason, ssl.SSLCertVerificationError): 116 | if danger_not_verify_ssl: 117 | warnings.warn( 118 | "SSL certificate verification disabled. This is insecure and vulnerable " 119 | "to man-in-the-middle attacks. Use only in trusted environments.", 120 | UserWarning, 121 | ) 122 | response = urlopen( 123 | font_url, context=ssl._create_unverified_context() 124 | ) 125 | else: 126 | raise Exception( 127 | "SSL certificate verification failed. " 128 | "If you are behind a firewall or using a proxy, " 129 | "try setting `danger_not_verify_ssl=True` to bypass verification." 130 | "verification." 131 | ) 132 | else: 133 | raise Exception( 134 | "Failed to load font. This may be due to a lack of internet connection " 135 | "or an environment where local files are not accessible (Pyodide, etc)." 136 | ) 137 | 138 | content = response.read() 139 | with open(cached_fontfile, "wb") as f: 140 | f.write(content) 141 | 142 | if cached_fontfile.endswith(("woff", "woff2")): 143 | cached_fontfile: str = _decompress_woff_to_ttf(cached_fontfile) 144 | 145 | return FontProperties(fname=cached_fontfile) 146 | else: 147 | raise ValueError("You must provide a `font_url`.") 148 | 149 | 150 | def set_default_font(font: FontProperties) -> None: 151 | """ 152 | Set the default font for all text elements generated by matplotlib, 153 | including axis labels, tick labels, legend entries, titles, etc. 154 | 155 | Under the hood it updates all the relevant matplotlib rcParams. 156 | 157 | Args: 158 | font: A `FontProperties` object containing the font to set as default. 159 | 160 | Examples: 161 | 162 | ```python 163 | from pyfonts import set_default_font, load_google_font 164 | 165 | set_default_font(load_google_font("Fascinate Inline")) 166 | plt.title("Title") # will be in Fascinate Inline 167 | plt.plot([1, 2, 3], label="Plot") 168 | # ^ axis labels, ticks, legend entries all also in Fascinate Inline 169 | ``` 170 | """ 171 | fontManager.addfont(str(font.get_file())) 172 | rcParams.update( 173 | { 174 | "font.family": font.get_name(), 175 | "font.style": font.get_style(), 176 | "font.weight": font.get_weight(), 177 | "font.size": font.get_size(), 178 | "font.stretch": font.get_stretch(), 179 | "font.variant": font.get_variant(), 180 | } 181 | ) 182 | --------------------------------------------------------------------------------