├── .github └── workflows │ ├── python-app.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── django_google_fonts ├── __init__.py ├── apps.py ├── templatetags │ ├── __init__.py │ └── google_fonts.py └── tests.py └── pyproject.toml /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Python 3.10 18 | uses: actions/setup-python@v3 19 | with: 20 | python-version: "3.10" 21 | - name: Install dependencies 22 | run: | 23 | pip install . 24 | pip install django 25 | - name: Test 26 | run: | 27 | cd django_google_fonts 28 | python -m unittest tests 29 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | dist 3 | django_google_fonts.egg-info -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Andy McKay 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Python application](https://github.com/andymckay/django-google-fonts/actions/workflows/python-app.yml/badge.svg)](https://github.com/andymckay/django-google-fonts/actions/workflows/python-app.yml) 2 | 3 | `django-google-fonts` lets you use Google fonts in Django easily, by downloading, rewriting and caching the font and CSS files locally. 4 | README.md 5 | This means that you can have all the benefits of using Google Fonts, but with added privacy, security and speed for your users, because all the requests for the fonts will be on your domain and not hitting Google servers. 6 | 7 | When the server restarts it will check if any fonts are missing locally and load them if they are. So there is no impact or performance considerations. After that initial download of the fonts, `django-google-fonts` does not need to make any more requests to Google servers, working totally offline from the Google servers. 8 | 9 | ### Installing 10 | 11 | ```bash 12 | pip install django-google-fonts 13 | ``` 14 | 15 | Then add to your Django settings file: 16 | 17 | ```python 18 | INSTALLED_APPS = [ 19 | ... 20 | 'django_google_fonts' 21 | ] 22 | ``` 23 | 24 | ### Using 25 | 26 | Tell Django which fonts you'd like: 27 | 28 | ```python 29 | GOOGLE_FONTS = ["Kablammo", "Roboto"] 30 | ``` 31 | 32 | When Django starts it will grab the fonts from Google and store them in your `STATICFILES_DIRS` directory. It will rewrite the CSS that Google Fonts provides, so all you have to do is load the font like normal. For example: 33 | 34 | ```html 35 | 36 | 41 | ``` 42 | 43 | There is also a `font` tag that will return the raw CSS: 44 | 45 | ```html 46 | {% load google_fonts %} 47 | {% font_css "Pathway Extreme" %} 48 | ``` 49 | 50 | Custom font weights are available by specifying the font weights in the URL. The easy way to do this is visit a font page, for example [Robot](https://fonts.google.com/specimen/Roboto) and then selecting the weights and styles you'd like. Then click on `Selected Families` and copy the font definition in. 51 | 52 | For example Google will suggest embedding the font using this URL: 53 | 54 | ```html 55 | 56 | 57 | 58 | ``` 59 | 60 | Roboto with italic in weights 100, 700, 900. To use this in Django you would specify: 61 | 62 | ```python 63 | GOOGLE_FONTS = ["Roboto:ital,wght@0,100;0,700;1,700"] 64 | ``` 65 | 66 | And you would reference it in a stylesheet: 67 | 68 | ```html 69 | 70 | ``` 71 | 72 | #### Optional settings 73 | 74 | By default `django-google-fonts` will store fonts in the first directory specified in `STATICFILES_DIRS`. That might not be where you want, so you can set a `GOOGLE_FONTS_DIR` setting if you'd like it be somewhere else: 75 | 76 | ```python 77 | GOOGLE_FONTS_DIR = BASE_DIR / "fonts" 78 | STATICFILES_DIRS = [BASE_DIR / "static", BASE_DIR / "fonts"] 79 | ``` 80 | 81 | The CSS file contains the path to the font and `django-google-fonts` tries to calculate what the path to the font should be by using the value of `STATIC_URL`. If that's wrong and you need it be something else, you can set that value: 82 | 83 | ```python 84 | GOOGLE_FONTS_URL = "my-exotic-static/url/to-the-fonts" 85 | ``` 86 | 87 | ### Names 88 | 89 | Google fonts normally have title cased names, with capitalized first names [^1]. For example `Pathway Extreme`. Google normalises this too: `pathwayextreme` and this is used in file names. So in the case of `Pathway Extreme` 90 | 91 | |Where|Name| 92 | |-|-| 93 | |Settings file|`Pathway Extreme`| 94 | |Font tag|`Pathway Extreme`| 95 | |Static tag|`pathwayextreme`| 96 | 97 | [^1]: Font's like `IBM Plex Sans` have more capital letters than just the first letter. 98 | 99 | If you are unsure you can get at the fonts programatically, for example: 100 | 101 | ```python 102 | >>> from django.apps import apps 103 | >>> for font in apps.get_app_config("django_google_fonts").fonts: 104 | ... print(font.name, font.slug, font.dest) 105 | ... 106 | Kablammo kablammo /Users/a/c/examplefonts/static/fonts/kablammo 107 | Roboto roboto /Users/a/c/examplefonts/static/fonts/roboto 108 | ``` 109 | -------------------------------------------------------------------------------- /django_google_fonts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andymckay/django-google-fonts/63c417dbce5aedf7e0edbf9d8c13bc573904f2a8/django_google_fonts/__init__.py -------------------------------------------------------------------------------- /django_google_fonts/apps.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import requests 5 | import tinycss2 6 | from django.apps import AppConfig 7 | from django.conf import settings 8 | 9 | logger = logging.getLogger(__name__) 10 | # pylint: disable=logging-fstring-interpolation invalid-name 11 | # pylint: disable=missing-class-docstring missing-function-docstring 12 | # Google Fonts produces a different CSS based on the user agent, using the Chrome user agent seems to give us a nice CSS compatible with browsers. 13 | # But in case you can override this by setting the GOOGLE_FONTS_USER_AGENT setting. 14 | user_agent = getattr( 15 | settings, 16 | "GOOGLE_FONTS_USER_AGENT", 17 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", 18 | ) 19 | css_url = "https://fonts.googleapis.com/css2" 20 | css_prefix = "https://fonts.gstatic.com/s/" 21 | log_prefix = "django_google_fonts" 22 | # Requests timeout in seconds. 23 | timeout = 10 24 | fonts = [] 25 | 26 | 27 | class Font: 28 | __slots__ = ["name", "dest", "slug", "dest_css"] 29 | 30 | def __init__(self, name, dest): 31 | self.name = name 32 | self.slug = self.name.replace(" ", "").lower() 33 | self.dest = dest 34 | self.dest_css = os.path.join(dest, self.slug + ".css") 35 | 36 | def cached(self): 37 | return os.path.exists(self.dest_css) 38 | 39 | def get(self): 40 | if self.cached(): 41 | return 42 | 43 | res = requests.get( 44 | css_url, 45 | params={"family": self.name}, 46 | headers={"User-Agent": user_agent}, 47 | timeout=timeout, 48 | ) 49 | if res.status_code != 200: 50 | logger.error( 51 | f"{log_prefix}: Failed to get font: {self.name}, got status code: {res.status_code}" 52 | ) 53 | return 54 | 55 | output_css = [] 56 | 57 | input_css = res.content.decode("utf-8") 58 | rules = tinycss2.parse_stylesheet(input_css) 59 | 60 | for rule in rules: 61 | if rule.type == "at-rule": 62 | for line in rule.content: 63 | if line.type == "url": 64 | res = requests.get(line.value, timeout=timeout) 65 | if res.status_code != 200: 66 | logger.error( 67 | f"{log_prefix}: Failed to get font: {self.name}, got status code: {res.status_code}" 68 | ) 69 | return 70 | 71 | # Convert https://fonts.gstatic.com/s/opensans/v35/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVc.ttf 72 | # To: /static/fonts/opensans/v35/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVc.ttf 73 | # And save it to the right place. 74 | target = line.value.split(css_prefix)[1] 75 | dest = os.path.join(self.dest, *target.split("/")) 76 | if not os.path.exists(os.path.dirname(dest)): 77 | os.makedirs(os.path.dirname(dest)) 78 | 79 | with open(dest, "wb") as f: 80 | f.write(res.content) 81 | 82 | # STATIC_URL must have a trailing slash. 83 | path = getattr(settings, "GOOGLE_FONTS_URL", f"{settings.STATIC_URL}fonts/") 84 | line.representation = line.representation.replace( 85 | "https://fonts.gstatic.com/s/", path 86 | ) 87 | output_css.append(rule) 88 | 89 | with open(self.dest_css, "w", encoding="utf-8") as f: 90 | f.write(tinycss2.serialize(output_css)) 91 | 92 | 93 | class DjangoGoogleFontsConfig(AppConfig): 94 | name = log_prefix 95 | 96 | def ready(self): 97 | if not getattr(settings, "GOOGLE_FONTS", None): 98 | logger.error(f"{log_prefix}: Either STATIC_URL or GOOGLE_FONTS_URL must be set.") 99 | return 100 | 101 | if ( 102 | getattr(settings, "STATIC_URL", None) is None 103 | and getattr(settings, "GOOGLE_FONTS_URL", None) is None 104 | ): 105 | logger.error(f"{log_prefix}: Either STATIC_URL or GOOGLE_FONTS_URL must be set.") 106 | return 107 | 108 | dest = getattr(settings, "GOOGLE_FONTS_DIR", None) 109 | if not dest: 110 | if not getattr(settings, "STATICFILES_DIRS", None): 111 | logger.error(f"{log_prefix}: STATICFILES_DIRS is required but not set.") 112 | return 113 | 114 | dest = settings.STATICFILES_DIRS[0] 115 | 116 | dest = os.path.join(dest, "fonts") 117 | if not os.path.exists(dest): 118 | os.makedirs(dest) 119 | 120 | self.fonts = [] 121 | fonts = getattr(settings, "GOOGLE_FONTS", []) 122 | for font in fonts: 123 | if any([word.islower() for word in font.split(" ")]): 124 | logger.warning( 125 | f"{self.name}: Font families usually have capitalized first letters, check the spelling of {font}." 126 | ) 127 | 128 | for name in fonts: 129 | font = Font(name, dest) 130 | font.get() 131 | self.fonts.append(font) 132 | 133 | return True 134 | -------------------------------------------------------------------------------- /django_google_fonts/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andymckay/django-google-fonts/63c417dbce5aedf7e0edbf9d8c13bc573904f2a8/django_google_fonts/templatetags/__init__.py -------------------------------------------------------------------------------- /django_google_fonts/templatetags/google_fonts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django import template 4 | from django.apps import apps 5 | from django.core.cache import cache 6 | 7 | from django_google_fonts.apps import log_prefix 8 | 9 | logger = logging.getLogger(__name__) 10 | register = template.Library() 11 | 12 | 13 | @register.simple_tag 14 | def font_css(name): 15 | fonts = apps.get_app_config("django_google_fonts").fonts 16 | cache_key = f"{log_prefix}:font:{name}" 17 | cached = cache.get(cache_key) 18 | if cached: 19 | return cached 20 | 21 | for font in fonts: 22 | if font.name == name: 23 | try: 24 | data = open(font.dest_css, "r", encoding="utf-8").read() 25 | cache.set(cache_key, data) 26 | return data 27 | except FileNotFoundError: 28 | logger.error(f"{log_prefix}: Failed to get find css for font: {name}") 29 | -------------------------------------------------------------------------------- /django_google_fonts/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import unittest 4 | 5 | from unittest.mock import ANY, patch 6 | 7 | from django.conf import settings 8 | from django.test import TestCase 9 | 10 | class Stub(object): 11 | def __init__(self, **kwargs): 12 | self.__dict__.update(kwargs) 13 | 14 | import django 15 | settings.configure(Stub( 16 | DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:'}}, 17 | DEBUG=False, 18 | FORCE_SCRIPT_NAME=None, 19 | INSTALLED_APPS=['django_google_fonts'], 20 | LOGGING=None, 21 | LOGGING_CONFIG=None, 22 | STATIC_URL='/static/', 23 | )) 24 | 25 | from apps import DjangoGoogleFontsConfig, Font 26 | 27 | 28 | roboto_css = """/* cyrillic-ext */ 29 | @font-face { 30 | font-family: 'Roboto'; 31 | font-style: normal; 32 | font-weight: 400; 33 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format('woff2'); 34 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 35 | } 36 | """ 37 | 38 | get_mock_params = {"params": {"family": "Roboto"}, "headers": {"User-Agent": ANY}, "timeout": 10} 39 | get_mock_url = "https://fonts.googleapis.com/css2" 40 | 41 | 42 | class TestFont(TestCase): 43 | def setUp(self): 44 | self.tempdir = tempfile.TemporaryDirectory() 45 | self.tempdir_path = self.tempdir.name 46 | self.font = Font("Roboto", self.tempdir_path) 47 | 48 | def tearDown(self): 49 | self.tempdir.cleanup() 50 | return super().tearDown() 51 | 52 | @patch("apps.requests") 53 | def test_cache(self, mock_requests): 54 | mock_requests.get.return_value = Stub(status_code=200, content="".encode("utf-8")) 55 | self.font.get() 56 | self.assertEqual(self.font.cached(), True) 57 | self.font.get() 58 | mock_requests.get.assert_called_once_with(get_mock_url, **get_mock_params) 59 | 60 | @patch("apps.requests") 61 | def test_rewrites(self, mock_requests): 62 | mock_requests.get.return_value = Stub(status_code=200, content=roboto_css.encode("utf-8")) 63 | self.font.get() 64 | with open(self.font.dest_css, "r", encoding="utf-8") as f: 65 | data = f.read() 66 | self.assertIn("url(/static/fonts/roboto/", data) 67 | mock_requests.get.assert_called_with( 68 | "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2", timeout=10 69 | ) 70 | 71 | @patch("apps.requests") 72 | def test_uses_google_fonts_url(self, mock_requests): 73 | mock_requests.get.return_value = Stub(status_code=200, content=roboto_css.encode("utf-8")) 74 | with self.settings(GOOGLE_FONTS_URL="blah/"): 75 | self.font.get() 76 | with open(self.font.dest_css, "r", encoding="utf-8") as f: 77 | data = f.read() 78 | self.assertIn("url(blah/roboto/", data) 79 | 80 | 81 | class TestDjangoGoogleFontsConfig(TestCase): 82 | def setUp(self): 83 | self.tempdir = tempfile.TemporaryDirectory() 84 | self.tempdir_path = self.tempdir.name 85 | self.obj = DjangoGoogleFontsConfig( 86 | "django_google_fonts", Stub(__path__=["a"], __file__="b/__init__.py") 87 | ) 88 | super().setUp() 89 | 90 | def tearDown(self): 91 | self.tempdir.cleanup() 92 | return super().tearDown() 93 | 94 | @patch("apps.requests") 95 | def test_good_path(self, mock_requests): 96 | with self.settings( 97 | GOOGLE_FONTS=["Roboto"], 98 | STATIC_URL="static/", 99 | STATICFILES_DIRS=[os.path.join(self.tempdir_path, "static/"), "tmp"], 100 | ): 101 | mock_requests.get.return_value = Stub(status_code=200, content="".encode("utf-8")) 102 | res = self.obj.ready() 103 | self.assertEqual(res, True) 104 | self.assertEqual(len(self.obj.fonts), 1) 105 | self.assertEqual(self.obj.fonts[0].name, "Roboto") 106 | self.assertEqual( 107 | self.obj.fonts[0].dest, os.path.join(self.tempdir_path, "static/fonts") 108 | ) 109 | self.assertEqual( 110 | self.obj.fonts[0].dest_css, 111 | os.path.join(self.tempdir_path, "static/fonts/roboto.css"), 112 | ) 113 | self.assertEqual(self.obj.fonts[0].slug, "roboto") 114 | mock_requests.get.assert_called_once_with(get_mock_url, **get_mock_params) 115 | 116 | def test_no_staticfiles(self): 117 | with self.settings( 118 | GOOGLE_FONTS=["Roboto"], 119 | STATICFILES_DIRS=None, 120 | ): 121 | res = self.obj.ready() 122 | self.assertEqual(res, None) 123 | 124 | def test_no_google_fonts(self): 125 | with self.settings( 126 | GOOGLE_FONTS=None, 127 | ): 128 | res = self.obj.ready() 129 | self.assertEqual(res, None) 130 | 131 | def test_no_static_url(self): 132 | with self.settings( 133 | GOOGLE_FONTS=["Roboto"], 134 | GOOGLE_FONTS_URL=None, 135 | STATIC_URL=None, 136 | ): 137 | res = self.obj.ready() 138 | self.assertEqual(res, None) 139 | 140 | @patch("django_google_fonts.apps.requests") 141 | def test_no_staticfiles_but_google_fonts(self, mock_requests): 142 | with self.settings( 143 | GOOGLE_FONTS=["Roboto"], 144 | STATICFILES_DIRS=None, 145 | GOOGLE_FONTS_DIR=self.tempdir_path, 146 | ): 147 | mock_requests.get.return_value = Stub(status_code=200, content="".encode("utf-8")) 148 | res = self.obj.ready() 149 | self.assertEqual(res, True) 150 | 151 | if __name__ == '__main__': 152 | unittest.main() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.isort] 6 | profile = "black" 7 | 8 | [tool.black] 9 | line-length = 100 10 | 11 | [project] 12 | name = "django_google_fonts" 13 | version = "0.0.3" 14 | authors = [ 15 | { name="Andy McKay", email="andy@mckay.pub" }, 16 | ] 17 | dependencies = [ 18 | 'requests >= 2.31.0', 19 | 'tinycss2 >= 1.2.1', 20 | ] 21 | description = "Easy to install and offline Google fonts in Django projects" 22 | readme = "README.md" 23 | requires-python = ">=3.7" 24 | classifiers = [ 25 | "Programming Language :: Python :: 3", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: OS Independent", 28 | ] 29 | 30 | [project.urls] 31 | "Homepage" = "https://github.com/andymckay/django-google-fonts" --------------------------------------------------------------------------------