How to 10x downsize fonts - Building a font optimizer
Tags: webdev, docker, python, beginners
Published: 2024-07-14 09:41:12


I was using fonts wrong.

I usually got most of my fonts from [Google Fonts](https://fonts.google.com/). 

For a long time, I just downloaded the font files from their website and added them directly to my projects. 

Not realizing, that this was a mistake...

<img src="https://media.giphy.com/media/d1E1msx7Yw5Ne1Fe/giphy.gif?cid=790b7611vajjg0vdwxg81fljpl51zs5i69uzbn8e2vcnodvc&ep=v1_gifs_search&rid=giphy.gif&ct=g" >

I just blindly assumed, that what can be downloaded from Google must be the gold standard, and can be used right away.

**The problem is:**

The fonts that can be downloaded from Google (not talking about using the CDN here), and many other providers, are not optimized for the web. The files can be several hundred KBs large and therefore significantly increase the bundle size and loading times.

My good friend and CSS magician [mrflix](https://github.com/mrflix) recently showed me 2 simple optimizations that drastically decreased the font file sizes, that I want to share with you today. 

With these 2 tips, I was able to bring down a 232 KB font down to only 20 KB, that's more than a 10x improvement!

And going even further: 

I'll also show you, how we can create our own font optimization script, turn it into a webapp and deploy it, so that we end up with something similar to https://fontconverter.com.

You can find all the code from this demo in this repository: [font-optimizer](https://github.com/sliplane-support/font-optimizer)

## What I am going to cover:

1. [How to optimize fonts for the web](#how-to-optimize-fonts-for-the-web)
2. [Create your own font optimization script](#create-your-own-font-optimization-script)
3. [Turn the script into a web app](#turn-the-script-into-a-web-app)
4. [Containerize the app with Docker](#containerize-the-app-with-docker)
4. [Deploy the app with Sliplane](#deploy-the-app-with-sliplane)
5. [Summary](#summary)

## How to optimize fonts for the web
<img src="https://media.giphy.com/media/l396Sok6H728brWlG/giphy.gif?cid=790b7611p1zjs29w5ytc05lgx024bc3ih406vyfsakg5rd1p&ep=v1_gifs_search&rid=giphy.gif&ct=g">

**Step 1:** Convert the font to woff2 (Web Open Font Format 2.0). 

What you can download from Google is usually in ttf format (True Type), an uncompressed font format used by operating systems and printers. 

woff2 on the other hand is designed for the web, the font format is compressed and therefore significantly smaller. It is nowadays [supported in most browsers](https://caniuse.com/woff2) but if you want to be extra safe, you can use woff.

There are some free online font converters out there, e.g. https://fontconverter.com, https://www.fontconverter.io, https://www.fontconverter.org, where you can upload your font and have them convert it to woff2. 

But we can do even better! 

**Step 2:** We can optimize the font even further by excluding glyphs, that we don't need. 

Fonts typically contain a bunch of special characters or glyphs in foreign alphabets (e.g. greek or cyrillic), that you might not need for your project. By excluding them, we can downsize even further. 

So let's start writing our own font optimizer!

Not only is it more efficient, but we also get some additional privacy and security benefits, since we don't need to share any data and we know that what's coming back is virus free.

## Create your own font optimization script

There is a popular Python library called [fonttools](https://pypi.org/project/fonttools/). We can use the `pyftsubset` command to throw out any unused glyphs and convert our ttf fonts to woff2.

I stole this little bash script from [mrflix](https://github.com/mrflix) (slightly modified):

```bash
# path to .ttf file that should be converted
input_file=$1

# get rid of the extension
filename=$(echo "$input_file" | cut -f 1 -d '.')

# settings for pyftsubset
# Define a subset of unicodes and layout features to be included. Here:
# Basic Latin, Latin-1 Supplement, Double Quotation Marks, €, „, “, “, EN Dash, EM Dash, Minus, EM Space, EN Space
unicodes="U+0020-007F,U+0080-00FF,U+201E,U+201C,U+20AC,U+201E,U+201C,U+201D,U+2013,U+2014,U+2212,U+2002,U+2003"
layout_features="tnum,ss01,ss02,ss03,ss04,ss05,ss06,ss07,ss08,ss09,ss10,ss11,ss12,ss13,ss14,ss15"
flavor="woff2"

# run the command
pyftsubset ${input_file} --unicodes=${unicodes} --layout_features=${layout_features} --flavor=${flavor} --output-file=${filename}.woff2

```

> Note: Bash scripts are not natively supported on Windows. Check out the next section to see how you can run this with Python. 

The script defines a subset of unicode characters and layout features that should be included in our optimized font and then executes the `pyftsubset` command. 

Simply add or remove any other unicode characters that you might need or want to discard for your project. You can checkout [this site](https://jrgraphix.net/r/Unicode/), to get the unicodes.

Before we can use the script, we need to make sure [Python](https://www.python.org/) 3.8 or later is installed on our system. Follow the official installation guide from the Python website.

Next, open we open terminal in our project and run

```bash
python3 -m venv myenv
```

This creates a virtual environment named `myenv`, where we can install python dependencies to avoid dependency conflicts with the host. We activate the environment with

```bash
source myenv/bin/activate
```

Our shell should change. All subsequent commands are now executed in this virtual environment. 

> You can exit the virtual environment with the `deactivate` command

Let's install `fonttools` and it's sub dependency `brotli`by running 

```bash
pip install brotli fonttools
```

Next, we create a file named `font-optimizer.sh` and copy the bash code from above in there. 

Make the file executable by running 

```bash
chmod +x font-optimzer.sh
```

We can now call the optimizer by running 

```bash
./font-optimizer.sh font-file.ttf
```

The command should spit out an optimized `font-file.woff2`.

I tested it with the font [Playwrite Cuba](https://fonts.google.com/specimen/Playwrite+CU) from Google fonts and compressed the light font weight down from 232 KB to 20 KB. 

In comparison: By only using fontconverter.com I ended up with 77 KB.


## Turn the script into a web app

To push things even further, let me show you, how we can turn this little script into a web app that we can deploy and use from anywhere.

I will keep it very minimal here, so feel free to extend this example application as you like.

Since we are dealing with Python here, let's use it for our web app, too! 

The only issue is, I don't really know Python very well... 😓

Thanks to GPT, this is shouldn't be a problem. 

<img src="https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExemkyamVlYjNrZ3E3c3BwZjBubXN0bWgyY3BtZDJpYjRvNnZjYjZpciZlcD12MV9naWZzX3NlYXJjaCZjdD1n/waUr4629vk5QhD558r/giphy.gif" >

My initial prompt looked like this:

> Create a Python webapp that converts ttf files to woff2 using the subset command from fonttools

And after around 11 iterations and some manual adjustments I ended up with this `app.py` file:

```python
from flask import Flask, request, jsonify, send_file
import io
from fontTools.subset import Subsetter, Options
from fontTools.ttLib import TTFont

app = Flask(__name__)

@app.route('/')
def index():
    return '''
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>TTF to WOFF2 Converter</title>
    </head>
    <body>
        <h1>TTF to WOFF2 Converter</h1>
        <form action="/convert" method="post" enctype="multipart/form-data">
            <label for="ttfFile">Upload TTF file:</label>
            <input type="file" id="ttfFile" name="ttfFile" accept=".ttf" required>
            <button type="submit">Convert</button>
        </form>
    </body>
    </html>
    '''

@app.route('/convert', methods=['POST'])
def upload_file():
    # some basic validation
    if 'ttfFile' not in request.files:
        return jsonify({'error': 'No file part'}), 400

    file = request.files['ttfFile']

    if file.filename == '':
        return jsonify({'error': 'No selected file'}), 400

    if not file.filename.endswith('.ttf'):
        return jsonify({'error': 'File is not a TTF font'}), 400

    # Read the TTF file into memory
    ttf_data = io.BytesIO(file.read())

    # Convert TTF to WOFF2 in memory
    try:
        # Load the font
        font = TTFont(ttf_data)

        # Subsetting options
        options = Options()
        options.flavor = 'woff2'
        options.layout_features = [
            'tnum', 'ss01', 'ss02', 'ss03', 'ss04', 'ss05', 'ss06', 'ss07', 'ss08', 'ss09', 
            'ss10', 'ss11', 'ss12', 'ss13', 'ss14', 'ss15'
        ]
        options.unicodes = (
            list(range(0x0020, 0x007F + 1)) +
            list(range(0x0080, 0x00FF + 1)) +
            [0x201E, 0x201C, 0x20AC, 0x201E, 0x201C, 0x201D, 0x2013, 0x2014, 0x2212, 0x2002, 0x2003]
        )

        # Subsetting the font
        subsetter = Subsetter(options=options)
        subsetter.populate(unicodes=options.unicodes)
        subsetter.subset(font)

        # Save the subsetted font to WOFF2
        woff2_data = io.BytesIO()
        font.flavor = 'woff2'
        font.save(woff2_data)
        woff2_data.seek(0)

        # Send the WOFF2 file as a response
        return send_file(woff2_data, mimetype='font/woff2', as_attachment=True, download_name=file.filename.replace('.ttf', '.woff2'))

    except Exception as e:
        return jsonify({'error': 'Conversion failed', 'details': str(e)}), 500

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

```

> Note: This is very minimal example and should only serve as a starting point for you to further develop it, make it beatiful and sprinkle in a bit of your favorite JavaScript framework 🪄

The app spins up a simple web server using the [flask](https://flask.palletsprojects.com/en/3.0.x/) framework. 

On GET / it serves HTML code containing a form to upload ttf font files. On submit the files are sent to the `/convert` endpoint. This endpoint does some basic validation first, and converts the font file in memory using the `fonttools` library.

Before we can give it a try, we need to also install `flask` by running

```bash
pip install flask
```

Then execute the script with 

```bash
python app.py
```

The app should now be running on `http://localhost:5000` 🥳


## Containerize the app with Docker

If you have not heard of Docker yet, Docker is a handy tool, that allows you to bundle your application together with all the depenedencies it needs. This bundle is called "Docker Image" and you can run instances of this image which are called "Containers".

Remember that you had to install Python, Flask, brotli and fonttools manually, in order to get the optimzer running?

You can put all of these installation instructions inside a `Dockerfile` like this:

```Dockerfile
# Start from a base image, that already has Python installed
FROM python:3.12.4-slim

# Install any additionally needed packages
RUN pip install flask brotli fonttools

# Copy our app.py file into the container
COPY app.py .

# Run the application
CMD ["python", "app.py"]
```

Now we only need to install Docker once, instead of every single dependendency for every single app that we ever want to run, which can lead to dependency conflicts, if e.g. one app uses Python 3 and another one Python 2... It's similar to a virtual machine, but more lightweight, since Docker images do not include the operating system.

For development purposes use [Docker Desktop](https://docs.docker.com/guides/getting-started/get-docker-desktop/) and follow the installation instructions on their website.

Once Docker is installed we can build the Dockerfile into a Docker image by running

```bash
docker build . -t font-optimizer
```

in our app directory. The image will be tagged "font-optimizer". 

To run it, use

```bash
docker run -p 5000:5000 font-optimizer
```

This command will spin up a container, that runs our app. For security reasons, containers are shielded off from the host machine. Everything that happens in a container, stays in the container. To access the app inside the container, we used the `-p 5000:5000` option, which tells docker to forward everything that happens inside the container on port 5000 to our host port 5000.

So we can now access the app by visiting http://localhost:5000 🥳

There are two more modification we should probably to do.

1. First, we get a warning, that states, we are running a development server and we should use a web server like [gunicorn](https://gunicorn.org/) to run the app in production mode.

2. Second, it makes sense to pin our dependencies to specific versions, so that we don't run into compatibility issues, since running the installation commands without a pinned version, will always fetch the newest.

Let's create a `requirements.txt` file, and move our dependency definitions there:

```txt
flask==3.0.3 
brotli==1.1.0
fonttools==4.53.1
gunicorn==22.0.0
```

Now update the `Dockerfile` to

```Dockerfile
# Use an official Python runtime as a parent image
FROM python:3.12.4-slim

# COPY requirements.txt into the container
COPY requirements.txt .

# Install dependencies defined in requirements.txt
RUN pip install -r requirements.txt

# Copy app.py into the container
COPY app.py .

# Run the application
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:5000", "app:app"]

```

### Deploy the app with Sliplane

To deploy this application I will use [Sliplane](https://sliplane.io?utm_source=font-optimizer). Sliplane is a simple Docker hosting platform, that I co founded. All we have to do is push our code to GitHub and connect the repo to Sliplane.

> To try out the app, you can create free demo servers. Just take note that demo servers are for testing purposes and will be deleted after 48 hours.

Login to GitHub and create a new repository named "font-optimizer".

Open a terminal in your project folder and run

```bash
# initialize a local repository
git init
# select the files, that you want to include in your repo (staging)
# . means everything inside your current directory
git add .
# include your staged files alongside a commit message
git commit -m "initial commit"
# add a remote branch
git remote add origin <INSERT REMOTE GITHUB PATH HERE>
# push your local code to the remote repo
git push -u origin main
```

Next, login to Sliplane with your GitHub account and do the following:

- Create a new Project named "Font Optimizer"
- Navigate to the project and click on "Deploy Service"
- Select the server you want to deploy to, or create a new one if needed
- Select "Repository" as the deploy source.
- Search for "font-optimizer" in the repositories list. 
> If your repository does not show up, you need to configure access to the repository first. Click on "Configure Repository Access" and select the "font-optimizer" repository. Take note, that it might take a minute for the newly connected repo to show up here. You can hit "Refresh list" to see, if the repo is available.
- After selecting the repo, go with the default settings and hit "Deploy" at the bottom of the page

After the deployment finished, you can access your app via on your own sliplane.app domain! 🥳


## Summary

Using raw ttf fonts slows down your website drastically. 

Optimize your fonts by

1. converting ttf fonts to woff2
2. throwing out any unused glyphs

We used the Python library [fonttools](https://pypi.org/project/fonttools/) to create a simple optimization script and turned it into a webapp. 

We then used [Docker](https://docker.com) to containerize the app and [Sliplane](https://sliplane.io?utm_source=font-optimizer) to deploy it.

You can find all the code in this repository: [font-optimizer](https://github.com/sliplane-support/font-optimizer)

Hope you learned something. 

Share, Like, Comment, Subscribe.