January 14, 2023

Shrinking Font Thumbnails

You can use over 7,600 fonts in Photopea.com, which are 2.75 GB in size. Our users should be able to see how each font looks like, without loading them all from our server. How should we do that?

A list of names of all fonts (7,600 phrases) is already a part of Photopea. The list goes like: first font is ..., second font is ... etc. We decided to create a thumbnail of 120x20 pixels for each font. So we could have a folder with files "1.png", "2.png", ... and load a thumbnail for a specific font, when it is needed.

If the user opens the font menu and quickly scrolls through it, it could initiate hundreds of downloads of PNG files. Each HTTP request requires a certain data overhead, and is returned with a delay. Images would start to pop up with a delay, which can ruin the experience.

We could put all PNG files into one large ZIP file and make Photopea download that one file from the server (and process it e.g. with our open-source UZIP.js library). But we can do better.

Making one big image

We decided to put all images into one large image (one PNG file, downloaded once), into a regular grid, and display them with background-size and background-postion CSS properies. Also, a PNG image has a header and other data (which are not pixel data), and we would store these data only once instead of 7,600 times (so it is better than a solution with a ZIP file).

Our thumbnails are kept in a regular grid. The first column contains thumbnails for fonts 1 to 500, the second column has fonts 501 to 1000, etc., we have 16 columns in total. Our image is 1,920 pixels wide (16 x 120) and 10,000 pixels tall (500 x 20).

How large is that image? A PNG pixel can store four 8-bit components (4 Bytes per pixel). A thumbnail has 2400 pixels (120x20), so we get 9600 Bytes per thumbnail. An image with all thumbnails would be 72.96 MB.

PNG Compression

Our thumbnails are specific. All pixels are black, and only the transparency changes. So we have only one byte of useful information per pixel. Also, pixels are not random, there are large parts of consecutive transparent pixels, or consecutive black pixels, which can be nicely compressed.

PNG files use the Deflate compression. Thanks to this compression, we converted 72.96 MB of raw pixel data into a 4.54 MB PNG file (without any loss), which is 597 Bytes per thumbnail.

We can improve it a bit. PNG files can have a palette, so we make a palette of 256 colors (all black, 256 values of transparency), and store only an index into the palette (1 Byte) per each pixel (4x less raw data: 18.24 MB). These indices are given to a compressor, and we get a 3.37 MB PNG file. That is 443 Bytes per thumbnail!

Lossy Compression

A Lossy Compression is very useful for storing images, and can be done in JPG, WEBP, PNG, and all other formats. Instead of storing the original image, we store a different image, which is very similar to the original, but can be (losslessly) compressed much better!

A trivial lossy compression could be, that you go through all pixels and calculate the average color. Then, you set all pixels to that color. This new image is similar to the original (especially if you look from very far away), and can be compressed into less than 1 kB, even for millions of pixels.

In our case, we decided to have only four levels of transparency, four palette colors: 0, 85, 170, 255, and set the transparency of each pixel to the closest value. Now, we can have only 2 bits per each index into a palette (4x less raw data: 4.56 MB). A PNG compressor turns it into a 1.85 MB PNG file: 243 Bytes per thumbnail.

All this was done using our open-source library UPNG.js, which is also the main PNG encoder in Photopea, and allows you to set the quality (lossy compression) when exporting a PNG.

Are we done?

We came up with all these ideas pretty quickly. But there is one last, tricky idea, which took us years to come up with.

Fonts in our list are not random. They are orderd by the name, and fonts of the same family (e.g. Noto-Sans-Regular and Noto-Sans-Italic) are next to each other in the list. It puts them one below another in our big PNG file. Some different fonts have identical glyphs for Latin, so we end up with identical thumbnails next to each other.

When compressing a PNG image, the compressor reads pixels by lines (at the end of a line, it jumps to a next line). The compression works best when there was a similar sequence of Bytes not far away from the current sequence of bytes. In our case with two identical thumbnails (one below another), when we start compressing the second thumbnail, we need to look for similarities 20 lines above (38400 pixels back).

The improvement is to reorder thumbnails horizontally. The first row will contain fonts 1 to 16, the second row with fonts 17 to 32, etc. Now, when we reach a line of a specific thumbnail, we can find similarities right on the left side (120 pixels back), which improves the compression. We made a 1.52 MB PNG file - 200 Bytes per thumbnail - just with this simple trick, without any loss in precision.

This PNG file - a database of font thumbnails - has existed in Photopea for years, always at the same address Photopea.com/rsrc/fonts/fonts.png.

Further improvements

Is it possible to compress our thumbnails even more? I have several ideas:

  • compress the pixel stream (2 bits per pixel) with Zstandard, rather than Deflate
  • render fonts at a double resolution, black and white, compress with JBIG2, and scale down in Photopea to get back the anti-aliasing.
  • try to switch to vector graphics?