├── .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 | [](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"
--------------------------------------------------------------------------------