Installing and patching fonts with Home Manager

Published:
Updated:

Recently, I was reading some source code on my computer, and suddenly it occurred to me to stop reading the source code and start messing with my computer's font setup instead.

My preferred font for reading source code is Fantasque Sans Mono. In version 1.8.0, Fantasque Sans Mono received a number of programming ligatures, which is a problem for me, because I do not like programming ligatures. Some people find them useful in making code easier to read, but I am not one of those people.

Examples of Fantasque ligatures for two character tokens like '==', and also what they look like without ligatures
This is what Fantasque Sans Mono ligatures look like

Previously, I dealt with this problem by simply using an older version of Fantasque. I would drop it in my ~/.local/share/fonts, where it would be picked up by Fontconfig

This is, of course, not very reproducible—if I wanted to use these fonts on another computer, I would have to (probably manually) copy the files into that computer's ~/.local/share/fonts. Avoiding these sorts of things is one of the reasons I use Home Manager—I can keep a set of configuration files in one central Git repository, and not have to worry about keeping track of a bunch of files, while remembering the where, what, and how of them.

Fantasque Sans Mono can be found in Nixpkgs (under fantasque-sans-mono), and thanks to the diligence of Nixpkgs contributors and maintainers, it is at the latest version: 1.8.0. This means that I can easily install the font by simply adding it to home.packages in my Home Manager configuration, but if I do that the font will come with ligatures, which I do not want. I thus set out in search of more complex solutions, and perhaps some yaks to shave along the way.

Fontconfig and OpenType features

Within an OpenType font, glyph substitutions are organized into categories called features. The idea is that software doing typesetting can decide which features are desired, and only perform glyph substitutions listed under those features.

Fantasque lists its programming ligatures under the calt feature, also known by its friendly name Contextual Alternates. If software can be convinced to ignore the Contextual Alternates feature, it should not apply the ligature substitutions when displaying text using the font. This is, in fact, how the example image was created in Inkscape: SVG supports styling text with CSS, and CSS supports font-feature-settings to control font features, which means we can do font-feature-setting: "calt" off to tell the rendering engine to skip the Contextual Alternate substitutions.

Fontconfig can be used to set the default font features for a given font, and the Arch Wiki provides a helpful example of how to do this. In order to apply this solution via Home Manager, we need to instruct it to deploy the configuration file to where Fontconfig will expect it (i.e. ~/.config/fontconfig/conf.d/).

{ pkgs, config, lib, ... }:
{
  home.packages = [
    pkgs.fantasque-sans-mono
  ];

  # required to autoload fonts from packages installed via Home Manager
  fonts.fontconfig.enable = true; 

  xdg.configFile = {
    # Variant to use if the XML is in a separate file 
    # "fontconfig/conf.d/75-disable-fantasque-calt.conf".source = ./75-disable-fantasque-calt.conf;

    "fontconfig/conf.d/75-disable-fantasque-calt.conf".text = ''
      <?xml version="1.0" encoding="UTF-8"?>
      <!DOCTYPE fontconfig SYSTEM "fonts.dtd">
      <fontconfig>
        <match target="font">
          <test name="family" compare="contains" ignore-blanks="true">
            <string>Fantasque Sans Mono</string>
          </test>
          <edit name="fontfeatures" mode="append">
            <string>calt off</string>
          </edit>
        </match>
      </fontconfig>
    '';
  };
}
Home Manager configuration setting up Fontconfig to ignore Contextual Alternates in the case of Fantasque Sans Mono.

Problem solved, right? Not quite. As the Arch Wiki article points out, not all software respects this setting. Software using Pango (so GTK apps) in general will follow the setting and disable the ligatures, but other software—such as Firefox, or programs using Qt—will fail to do so.

Using a different version of the font

A way to ensure our software will not render ligatures is to give it a font file without the ligatures in it. One option is to use an older version of Fantasque, as previously; another is to strip the ligatures out of the latest version. Fortunately, I am not the only one who prefers fonts without programming ligatures, and so someone has already provided the latest version, but without ligatures (I would like to note my appreciation for the "sans ligatures" name here).

The Sans Ligatures version of the font should be relatively easy to package, since it is very similar to the upstream Fantasque. This means we can take inspiration from fantasque-sans-mono as it is packaged in Nixpkgs. Unfortunately, since the derivation uses fetchzip we cannot simply use fantasque-sans-mono.overrideAttrs to change its url, sha256, and name. What we can do is take more than just inspiration, grab the entire file, and modify it for our use. If you do something like this, and also publish your NixOS or Home Manager configurations on the Internet, keep in mind the Nixpkgs license (MIT).

{ lib, fetchzip }:

let
  version = "1.8.0";
in

fetchzip rec {
  name = "fantasque-sans-ligatures-${version}";

  url = "https://github.com/spinda/fantasque-sans-ligatures/releases/download/v${version}/FantasqueSansMono-Normal.zip";

  postFetch = ''
    mkdir -p $out/share/{doc,fonts}
    unzip -j $downloadedFile \*.otf    -d $out/share/fonts/opentype
    unzip -j $downloadedFile README.md -d $out/share/doc/${name}
  '';

  sha256 = "sha256-XwUqM1BC80J74P0qk6EOnNiqkX61cgnXBq8S8slGpps=";

  meta = with lib; {
    homepage = "https://github.com/spinda/fantasque-sans-ligatures";
    description = "A font family with a great monospaced variant for programmers, version without ligatures";
    license = licenses.ofl;
    platforms = platforms.all;
  };
}
Packaging Fantasque Sans Mono, sans ligatures, based on fantasque-sans-mono from Nixpkgs at b2c910f5 (MIT license).

Normally, when using fetchzip from Nixpkgs, its postFetch extracts the ZIP we asked it to download, preserving the tree. This is useful for getting the source tree of something that we are building, but it is not what we want in our case. What we do instead is extract all the .otf files from the archive and put them under share/fonts/opentype, which is the standard location OpenType font files are expected to be. We also need to populate the sha256 hash of the output; an easy way to figure that out is to set sha256 = lib.fakeHash;, and try to build the derivation, which will cause Nix to error out and report what hash it got.

What we can do now is simply callPackage on the file we wrote (well, the file we modified).

{ pkgs, config, lib, ... }:
let
  fantasqueMonoSansLigatures = pkgs.callPackage ./fantasque-sans-ligatures.nix { };
in {
  home.packages = [
    fantasqueMonoSansLigatures
  ];

  fonts.fontconfig.enable = true; # required to autoload fonts from packages
}
Previous configuration, now modified to use a custom font instead.

Patching fonts with icons

I use exa as my ls replacement. exa has a feature which allows it to display icons next to listed files. For this feature to work, exa has to be run inside a terminal emulator using a font that supports those icons; they are codepoints in the Private Use Area, and thus not standardized in Unicode, which means they are not covered by most fonts.

I had previously been using the Nerd Fonts version of Fantasque Sans Mono for this purpose. Nerd Fonts is a project that takes existing fonts and embeds a whole bunch of icons from various icon sets in them. They distribute a number of pre-patched fonts, and, in fact, you can get those pre-patched fonts via Nixpkgs: pkgs.nerdfonts.override { fonts = [ "FantasqueSansMono" ]; }; will get you Fantasque Sans Mono with icons.

Of course, because the Fantasque patched by Nerd Fonts is the latest Fantasque, it will have ligatures. Fortunately, Nerd Fonts also provides a patcher script which can be used to embed all the various Nerd Fonts icon sets into any arbitrary OpenType font. This means we could have a derivation that patches a font—like the Fantasque Sans Ligatures from above—with the Nerd Fonts icons.

One problem, though: in order to run the patcher, we also need a bunch of files from the Nerd Fonts repository, but the repository also contains a bunch of stuff we do not need, and is huge. The project commits all their pre-patched fonts, and there is a lot of patched fonts: over 3,000 files, totaling over 4 GB. This is a problem in two ways: one, it means downloading the whole repo takes a lot network traffic—over 2 GB if compressed; two, hashing the downloaded repo is a long and resource-intensive process for Nix.

One thing we can do is take inspiration from having already messed with fetchzip before, and override the unzipping script to exclude the big directories from the downloaded snapshot of the repository. This does not solve the downloading problem, but it solves the Nix hashing problem.

{ lib
, stdenvNoCC
, fetchzip
, fantasqueMonoSansLigatures
, python3Packages
}:

let
  version = "2.1.0";
  src = fetchzip {
    url = "https://github.com/ryanoasis/nerd-fonts/archive/refs/tags/v${version}.zip";

    # here we fetch the source, unpack it excluding the big directories, and 
    # strip the top level directory, lifting everything one level up
    postFetch = ''
      unpackDir=$TMPDIR/unpack
      mkdir $unpackDir

      unzip "$downloadedFile" -x '*/patched-fonts/*' '*/src/unpatched-fonts/*' -d $unpackDir
      mv $unpackDir/* $out
    '';

    sha256 = "sha256-gPjnlsz9BM9s0Z8Ph379Uk+2TiY8Suxn56vyny6H89s=";
  };
in stdenvNoCC.mkDerivation rec {
  inherit version src;
  pname = "fantasque-sans-ligatures-nerd";

  nativeBuildInputs = with python3Packages; [
    python
    fontforge
  ];

  # here we actually patch the fonts
  buildPhase = ''
    mkdir -p $out/share/fonts/opentype
    for f in ${fantasqueMonoSansLigatures}/share/fonts/opentype/*; do
      python font-patcher $f --complete --no-progressbars --outputdir $out/share/fonts/opentype
    done
  '';

  dontInstall = true;
  dontFixup = true;

  meta = with lib; {
    homepage = "https://github.com/ryanoasis/nerd-fonts";
    description = "Ligature-less Fantasque Sans Mono patched with Nerd Fonts icons";
    license = licenses.ofl;
    platforms = platforms.all;
  };
}
A derivation that patches a font for us with the Nerd Fonts patcher.

This derivation is a bit more involved. Our src is the ZIP file containing the repository source snapshot that Github offers as a download for the given release. We extract this, omitting the fonts files that we do not need (this is what the -x argument of unzip is for).

The derivation takes Python and python3Packages.fontforge as a inputs. We can find out we need the latter by reading the readme for the Nerd Fonts project, and we obviously need to have Python available if we want to run a Python script.

The actual build script goes through all the fonts in the Fantasque Sans Ligatures package, and calls the font-patcher script on them, asking the script to output patched fonts to this derivation's output directory. The --complete argument asks the patcher to add all the icons it knows; we could replace that with some subset of icons, instead. We also skip drawing progress bars, because such things do not play well with line-based Nix build logs.

Sparse checkouts with Git

At this point, the having to download a Zip file that is gigabytes in size still remains a problem. Fortunately, modern Git is actually pretty decent at not pulling more data than is needed.

Traditionally, cloning with Git downloads the whole tree for the HEAD commit, or the selected branch or tag, as well as the whole history. The latter can be truncated at some point with --depth, including at the commit itself, as is the case with --depth 1. This is a shallow clone. Such clones still involve pulling the whole tree, and with Nerd Fonts the problem is not just that the history is deep and contains a lot of data, but that one commit is already a lot of data.

git clone also takes another useful argument: --filter. Using --filter=blob:none, we can tell Git that we do not want to pull the blobs—that is, all the files that end up in a checked-out directory tree. In this situation, Git will lazily pull the blobs from the remote as needed, such as when we tell it to check out a commit, and it notices that it does not already have the blobs needed for that checkout. This is a partial clone.

The third useful feature is sparse checkout. Git allows us to specify filters for paths, that get applied when performing a checkout. This involves essentially telling Git that when you are checking out a branch, you are interested only in a specific list of paths, and it should not check out any other paths, even if they are in the commit. We can ask Git something like "Please switch to this branch, but only give me src/ and README.md, and nothing else".

We can combine these three features to minimize the data we need to download to get a working patcher. The history can be skipped with --depth 1, as we will not need it. The blobs can be skipped at first with --filter=blob:none, and by telling Git to not initially check out anything at all. Then, we can set a filter to only check out the file we will need, omitting the directories with thousands of fonts in them. The result of this is Git only pulling the small files we need, and not bothering to pull the huge directory. This helps us keep both the size of the repo down, as well as reduce the amount of network traffic.

$ git clone --depth 1 --filter=blob:none  --no-checkout --branch v2.1.0 https://github.com/ryanoasis/nerd-fonts
Cloning into 'nerd-fonts'...
remote: Enumerating objects: 976, done.
remote: Counting objects: 100% (976/976), done.
remote: Compressing objects: 100% (863/863), done.
remote: Total 976 (delta 25), reused 690 (delta 19), pack-reused 0
Receiving objects: 100% (976/976), 190.68 KiB | 1.93 MiB/s, done.
Resolving deltas: 100% (25/25), done.
$ cd nerd-fonts
$ git sparse-checkout set 'font-patcher' 'src/' '!src/unpatched-fonts'
$ git checkout HEAD
remote: Enumerating objects: 75, done.
remote: Counting objects: 100% (75/75), done.
remote: Compressing objects: 100% (70/70), done.
remote: Total 75 (delta 5), reused 73 (delta 5), pack-reused 0
Receiving objects: 100% (75/75), 1.53 MiB | 7.20 MiB/s, done.
Resolving deltas: 100% (5/5), done
Manually cloning the big Nerd Fonts repo, and picking out only the needed parts.

More good news is that we do not actually need to write a fixed output derivation with a script that does this whole Git dance. In current Nixpkgs master, fetchGit and various fetchers that use it (like fetchFromGitHub) now support a sparseCheckout argument, which makes the fetcher internally do something very similar to the manual example here. The author of the recently merged PR which adds it even mentions Nerd Fonts!

{ lib
, stdenvNoCC
, fetchFromGitHub
, fantasqueMonoSansLigatures
, python3Packages
}:
stdenvNoCC.mkDerivation rec {
  pname = "fantasque-sans-ligatures-nerd";
  version = "2.1.0";

  src = fetchFromGitHub {
    owner = "ryanoasis";
    repo = "nerd-fonts";
    rev = "v${version}";
    sparseCheckout = ''
      font-patcher
      src/
      !src/unpatched-fonts/
    '';
    sha256 = "sha256-RkaZ8IV51Eimxk5Q8CijwEHL5yqQJeJMuEqLh/CfC3k=";
  };

  nativeBuildInputs = with python3Packages; [
    python
    fontforge
  ];

  buildPhase = ''
    mkdir -p $out/share/fonts/opentype
    for f in ${fantasqueMonoSansLigatures}/share/fonts/opentype/*; do
      python font-patcher $f --complete --no-progressbars --outputdir $out/share/fonts/opentype
    done
  '';

  dontInstall = true;
  dontFixup = true;

  meta = with lib; {
    homepage = "https://github.com/ryanoasis/nerd-fonts";
    description = "Ligature-less Fantasque Sans Mono patched with Nerd Fonts icons";
    license = licenses.ofl;
    platforms = platforms.all;
  };
}
The font patching derivation, updated to make use of the recently added sparseCheckout feature.

The sparseCheckout syntax is essentially the same as the one used in .gitignore, except reversed: anything that matches will be checked out, and anything that does not match will be ignored. This is how we can add the src/ directory, but exclude its unpatched-fonts/ subdirectory (we do not need the unpatched fonts, we bring our own).

{ pkgs, config, lib, ... }:
let
  fantasqueMonoSansLigatures = pkgs.callPackage ./fantasque.nix { };
  fantasqueMonoSansLigNerd = pkgs.callPackage ./fantasque-nerd.nix {
    inherit fantasqueMonoSansLigatures;
  };
in {
  home.packages = [
    fantasqueMonoSansLigatures
    fantasqueMonoSansLigNerd
  ];

  fonts.fontconfig.enable = true; # required to autoload fonts from packages
}
The Home Manager config file, now calling callPackage on both the original font, and our patching derivation.

With some minor modifications, the Nerd Fonts patching derivation could be made even more fancy, allowing for patching of arbitrary fonts.

And now it works

It is thus, after meandering around various topics, that I have arrived at working fonts—at least some of them, as I did not discuss the boring ones.

The conclusion here is that Nix is enjoyable if you are the kind of person who finds appeal in the idea of thinking "I should mess with my font setup" and then spending several hours diving into several different topics.

Addendum

After publishing this article, I found out that the Nerd Fonts patcher is, in fact, already packaged in Nixpkgs. It is available under nerd-font-patcher (note that this is "font" not "fonts"). I now feel silly.

Further reading