Adding a Font to CrossPoint Reader

CrossPoint Reader is an open-source e-reader firmware for the Xteink X4 (ESP32-C3). Fonts are baked into the firmware as C headers at compile time, so adding a new one means converting it, generating an ID, and wiring it up in a few places. This is how I added Atkinson Hyperlegible.

Atkinson Hyperlegible is a typeface from the Braille Institute, designed for low-vision readers. It has become my go-to font in KOReader as well, so porting it to CrossPoint felt like the obvious first move when I started customising the firmware.

Prerequisites

You need Python with freetype-py and fonttools, plus Ruby for the ID script:

pip install freetype-py fonttools

1. Get the Font Files

You can use any TTF or OTF files. I used the Fast Atkinson variant already installed on my system at ~/.local/share/fonts/. If you want the standard Atkinson Hyperlegible, download the TTFs from the Braille Institute and put them somewhere convenient.

2. Convert to C Headers

The converter is lib/EpdFont/scripts/fontconvert.py. Reader fonts use --2bit --compress --pnum. Run it once per size per style:

cd lib/EpdFont/scripts

FONT_DIR="$HOME/.local/share/fonts"
for size in 12 14 16 18; do
  for style in Regular Bold Italic BoldItalic; do
    name="atkinson_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
    python fontconvert.py \
      "$name" "$size" \
      "${FONT_DIR}/Fast_Atkinson_${style}.otf" \
      --2bit --compress --pnum \
      > "../builtinFonts/${name}.h"
  done
done

Adjust the font path and filenames to match whichever variant you have.

3. Add Headers to all.h

All built-in font headers are included through lib/EpdFont/builtinFonts/all.h. Add your new headers there:

#include <builtinFonts/atkinson_12_bold.h>
#include <builtinFonts/atkinson_12_bolditalic.h>
#include <builtinFonts/atkinson_12_italic.h>
#include <builtinFonts/atkinson_12_regular.h>
// ... repeat for 14, 16, 18

4. Generate Font IDs

Font IDs are SHA-256 hashes of the generated headers, computed by build-font-ids.sh. Add a block for Atkinson to that script:

echo "#define ATKINSON_12_FONT_ID ($(
ruby -rdigest -e 'puts [
  "./atkinson_12_regular.h",
  "./atkinson_12_bold.h",
  "./atkinson_12_bolditalic.h",
  "./atkinson_12_italic.h",
].map{|f| Digest::SHA256.hexdigest(File.read(f)).to_i(16) }.sum % (2 ** 32) - (2 ** 31)'
))"
# ... repeat for 14, 16, 18

Then regenerate src/fontIds.h:

bash lib/EpdFont/scripts/build-font-ids.sh > src/fontIds.h

5. Wire Up the Font Objects

In src/main.cpp, inside the #ifndef OMIT_FONTS block, declare the font objects and families:

EpdFont atkinson12RegularFont(&atkinson_12_regular);
EpdFont atkinson12BoldFont(&atkinson_12_bold);
EpdFont atkinson12ItalicFont(&atkinson_12_italic);
EpdFont atkinson12BoldItalicFont(&atkinson_12_bolditalic);
EpdFontFamily atkinson12FontFamily(&atkinson12RegularFont, &atkinson12BoldFont,
                                   &atkinson12ItalicFont, &atkinson12BoldItalicFont);
// ... repeat for 14, 16, 18

Then register them with the renderer in setupDisplayAndFonts(), still inside #ifndef OMIT_FONTS:

renderer.insertFont(ATKINSON_12_FONT_ID, atkinson12FontFamily);
renderer.insertFont(ATKINSON_14_FONT_ID, atkinson14FontFamily);
renderer.insertFont(ATKINSON_16_FONT_ID, atkinson16FontFamily);
renderer.insertFont(ATKINSON_18_FONT_ID, atkinson18FontFamily);

6. Add to Settings

In src/CrossPointSettings.h, extend the enum:

enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, OPENDYSLEXIC = 2, ATKINSON = 3, FONT_FAMILY_COUNT };

In src/CrossPointSettings.cpp, add an ATKINSON case to both switch statements, getReaderFontId() and getReaderLineCompression():

// getReaderFontId()
case ATKINSON:
  switch (fontSize) {
    case SMALL:        return ATKINSON_12_FONT_ID;
    case MEDIUM:
    default:           return ATKINSON_14_FONT_ID;
    case LARGE:        return ATKINSON_16_FONT_ID;
    case EXTRA_LARGE:  return ATKINSON_18_FONT_ID;
  }

// getReaderLineCompression()
case ATKINSON:
  switch (lineSpacing) {
    case TIGHT:   return 0.90f;
    case NORMAL:
    default:      return 0.95f;
    case WIDE:    return 1.0f;
  }

In src/SettingsList.h, add STR_ATKINSON to the fontFamily options list:

SettingInfo::Enum(StrId::STR_FONT_FAMILY, &CrossPointSettings::fontFamily,
                  {StrId::STR_NOTO_SERIF, StrId::STR_NOTO_SANS, StrId::STR_OPEN_DYSLEXIC, StrId::STR_ATKINSON},
                  "fontFamily", StrId::STR_CAT_READER),

7. Add the Translation String

In lib/I18n/translations/english.yaml:

STR_ATKINSON: "Atkinson Hyperlegible"

Then regenerate the I18n headers:

python scripts/gen_i18n.py lib/I18n/translations lib/I18n/

Build and Flash

pio run --target upload

The font should now appear as "Atkinson Hyperlegible" in the reader's font selection menu. If you ever change any of the generated header files, re-run the ID script and rebuild. Otherwise the cached layout files in .crosspoint/ on the SD card will have mismatched IDs and the cache will need to be cleared.