1
0

12 Commits
v1.2 ... main

Author SHA1 Message Date
f43edba38f Publish a release w/ notes 2026-03-14 21:43:15 +01:00
a736526056 Provide two versions (regular and KF) 2026-03-14 21:38:15 +01:00
d4cce21701 Ensure zips only have correct files 2026-03-14 21:22:01 +01:00
b96bb96a88 Automatically generate Kobo optimized versions 2026-03-14 21:11:23 +01:00
2a27486aca Remove old-style kerning setting
(Handling old-style kerning and other various changes will be handled via kobo-font-fix.)
2026-03-14 17:02:51 +01:00
784a1e4f40 Update screenshot 2026-03-14 12:59:29 +01:00
4797071ede Artefacts should not be double zipped 2026-03-14 12:53:01 +01:00
5fc4d9b8d8 Build fonts with fntbld-oci 2026-03-14 12:40:06 +01:00
f5aac0701f Fix x-height overshoot for hinting 2026-03-14 12:39:49 +01:00
139b14dbf6 Tweak autohinting 2026-03-14 12:10:08 +01:00
bd9e4d99d5 Add ttfautohint dependency check 2026-03-14 10:54:47 +01:00
defd728985 Autohint for improved rendering on Kobo 2026-03-14 01:27:42 +01:00
5 changed files with 256 additions and 26 deletions

82
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
name: Build fonts
on:
push:
branches: [main]
tags: ["*"]
jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/nicoverbruggen/fntbld-oci:latest
steps:
- uses: actions/checkout@v4
- name: Build fonts
run: python3 build.py
- name: Download kobofix.py
run: curl -sL https://raw.githubusercontent.com/nicoverbruggen/kobo-font-fix/main/kobofix.py -o kobofix.py
- name: Generate Kobo (KF) fonts
run: |
python3 kobofix.py --preset kf out/ttf/*.ttf
mkdir -p out/kf
mv out/ttf/KF_*.ttf out/kf/
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: Readerly
path: out/ttf/*.ttf
- name: Upload Kobo artifact
uses: actions/upload-artifact@v4
with:
name: KF_Readerly
path: out/kf/*.ttf
- name: Zip TTFs for release
if: startsWith(github.ref, 'refs/tags/')
run: |
cd out/ttf && zip -j ../../Readerly.zip *.ttf
cd ../../out/kf && zip -j ../../KF_Readerly.zip *.ttf
- name: Upload release zips
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v4
with:
name: Readerly-release
path: |
Readerly.zip
KF_Readerly.zip
release:
needs: build
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
name: Readerly-release
- name: Create release
uses: softprops/action-gh-release@v2
with:
draft: false
name: ${{ github.ref_name }}
body: |
> [!TIP]
> **If you are using a Kobo device and reading books purchased from the Kobo Store or reading `kepub` files converted via Calibre**, you should download KF_Readerly.zip, which has fonts slightly altered for optimal kerning for the `kepub` renderer.
### Learn more
Readerly is part of the `ebook-fonts` collection. For more information about those fonts, screenshots and how to install them, please consult the [README](https://github.com/nicoverbruggen/ebook-fonts/blob/main/README.md). The FAQ also includes an entry on how to enable ligatures on Kobo devices, which is highly recommended.
files: |
Readerly.zip
KF_Readerly.zip

View File

@@ -12,6 +12,13 @@ The goal was to get a metrically/visually similar font, without actually copying
To get to the final result, I decided to use the variable font and work on it. The original is located in `src` and is available under the same OFL as the end result, which is included in `LICENSE`.
## Downloads
Two versions are generated via the pipeline of the [latest release](../../releases/latest):
- **KF_Readerly.zip** — Kobo-optimized TrueType fonts with a legacy kern table and `KF` prefix. Use this if you have a Kobo e-reader, this version contains optimizations made with [Kobo Font Fix](https://github.com/nicoverbruggen/kobo-font-fix).
- **Readerly.zip** — The standard, unmodified fonts, as TrueType files. Useful for other e-readers and use on your desktop computer or smartphone.
## Project structure
- `src`: Newsreader variable font TTFs
@@ -22,7 +29,6 @@ To get to the final result, I decided to use the variable font and work on it. T
After running `build.py`, you should get:
- `out/sfd`: FontForge source files (generated)
- `out/ttf`: final TTF fonts (generated)
## Prerequisites
@@ -30,28 +36,53 @@ After running `build.py`, you should get:
- **Python 3**
- **[fontTools](https://github.com/fonttools/fonttools)** — install with `pip install fonttools`
- **[FontForge](https://fontforge.org)** — the build script auto-detects FontForge from PATH, Flatpak, or the macOS app bundle
- **[ttfautohint](https://freetype.org/ttfautohint/)** — required for proper rendering on Kobo e-readers
### Linux preparation
```
sudo apt install ttfautohint # Debian/Ubuntu
sudo dnf install ttfautohint # Fedora
brew install ttfautohint # Bazzite (immutable Fedora)
pip install fonttools
flatpak install flathub org.fontforge.FontForge
```
### macOS preparation
#### System Python
On macOS, if you're using the built-in version of Python (via Xcode), you may need to first add a folder to your `PATH` to make `font-line` available, like:
```bash
echo 'export PATH="$HOME/Library/Python/3.9/bin:$PATH"' >> ~/.zshrc
brew install fontforge
brew install fontforge ttfautohint
brew unlink python3 # ensure that python3 isn't linked via Homebrew
pip3 install fonttools font-line
source ~/.zshrc
```
#### Homebrew Python
If you're using `brew install python`, pip requires a virtual environment:
```bash
brew install fontforge ttfautohint
python3 -m venv .venv
source .venv/bin/activate
pip install fonttools
```
## Building
**Note**: If you're using `venv`, you will need to activate it first:
```
source .venv/bin/activate
```
If you are just using the system Python, you can skip that step and simply run:
```
python3 build.py
```
@@ -62,4 +93,4 @@ To customize the font family name, disable old-style kerning, or skip outline fi
python3 build.py --customize
```
The build script (`build.py`) uses `fontTools` and FontForge to transform the Newsreader variable fonts into Readerly. Configuration and step-by-step details live in the header comments of `build.py`.
The build script (`build.py`) uses `fontTools` and FontForge to transform the Newsreader variable fonts into Readerly. After export, it post-processes the TTFs: clamping x-height overshoots that cause uneven rendering on e-ink, normalizing style flags, and autohinting with `ttfautohint` for Kobo's FreeType renderer. Configuration and step-by-step details live in the header comments of `build.py`.

View File

@@ -1 +1 @@
1.2
1.3

161
build.py
View File

@@ -7,7 +7,8 @@ Orchestrates the full font build pipeline:
1. Instances variable fonts into static TTFs (fontTools.instancer)
2. Applies vertical scale (scale.py) via FontForge
3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py)
4. Exports to SFD and TTF → ./out/sfd/ and ./out/ttf/
4. Exports to TTF → ./out/ttf/
5. Post-processes TTFs: x-height overshoot clamping, style flags, autohinting
Uses FontForge (detected automatically).
Run with: python3 build.py
@@ -24,7 +25,7 @@ import textwrap
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Most of these values are safe to tweak. The --customize flag only toggles
# a small subset at runtime (family name, old-style kerning, outline fixes).
# a small subset at runtime (family name, outline fixes).
#
# Quick reference (what each knob does):
# - REGULAR_VF / ITALIC_VF: input variable fonts from ./src
@@ -40,7 +41,6 @@ import textwrap
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.join(ROOT_DIR, "src")
OUT_DIR = os.path.join(ROOT_DIR, "out")
OUT_SFD_DIR = os.path.join(OUT_DIR, "sfd") # generated FontForge sources
OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs
REGULAR_VF = os.path.join(SRC_DIR, "Newsreader-VariableFont_opsz,wght.ttf")
@@ -87,6 +87,25 @@ LINE_HEIGHT = 1.0
SELECTION_HEIGHT = 1.3
ASCENDER_RATIO = 0.8
# Step 4: ttfautohint options (hinting for Kobo's FreeType renderer)
# - Kobo uses FreeType grayscale, so the 1st char of --stem-width-mode
# (gray) is the one that matters. n=natural, q=quantized, s=strong.
# - Remaining two chars are for GDI and DirectWrite (not used on Kobo).
# - Other options are left at ttfautohint defaults; uncomment to override.
AUTOHINT_OPTS = [
"--no-info",
"--stem-width-mode=nss",
# "--hinting-range-min=8",
# "--hinting-range-max=50",
# "--hinting-limit=200",
"--increase-x-height=0",
]
# Glyphs whose x-height overshoot is an outlier (+12 vs the standard +22).
# The inconsistent overshoot lands between the hinter's snap zones, causing
# these glyphs to render taller than their neighbors on low-res e-ink.
CLAMP_XHEIGHT_GLYPHS = ["u", "uogonek"]
# Step 3: Naming and style metadata (used by the rename step)
STYLE_MAP = {
"Regular": ("Regular", "Book", 400),
@@ -505,19 +524,15 @@ def ff_license_script():
""")
def build_export_script(sfd_path, ttf_path, old_kern=True):
def build_export_script(sfd_path, ttf_path):
"""Build a FontForge script that opens an .sfd and exports to TTF."""
if old_kern:
flags_line = 'flags = ("opentype", "old-kern", "no-FFTM-table", "winkern")'
else:
flags_line = 'flags = ("opentype", "no-FFTM-table")'
return textwrap.dedent(f"""\
import fontforge
f = fontforge.open({sfd_path!r})
print("Exporting: " + f.fontname)
{flags_line}
flags = ("opentype", "no-FFTM-table")
f.generate({ttf_path!r}, flags=flags)
print(" -> " + {ttf_path!r})
@@ -587,6 +602,62 @@ def clean_ttf_degenerate_contours(ttf_path):
font.close()
def clamp_xheight_overshoot(ttf_path):
"""Clamp outlier x-height overshoots in a TTF in-place.
Some glyphs (e.g. 'u') have a smaller overshoot than the standard
round overshoot, landing between the hinter's snap zones. This
flattens them to the true x-height measured from flat-topped glyphs.
"""
try:
from fontTools.ttLib import TTFont
except Exception:
print(" [warn] Skipping x-height clamp: fontTools not available", file=sys.stderr)
return
font = TTFont(ttf_path)
glyf = font["glyf"]
# Measure x-height from flat-topped reference glyphs.
xheight = 0
for ref in ("x", "v"):
if ref not in glyf:
continue
coords = glyf[ref].coordinates
if coords:
ymax = max(c[1] for c in coords)
if ymax > xheight:
xheight = ymax
if xheight == 0:
font.close()
return
clamped = []
for name in CLAMP_XHEIGHT_GLYPHS:
if name not in glyf:
continue
glyph = glyf[name]
coords = glyph.coordinates
if not coords:
continue
ymax = max(c[1] for c in coords)
if ymax <= xheight:
continue
glyph.coordinates = type(coords)(
[(x, min(y, xheight)) for x, y in coords]
)
glyph_set = font.getGlyphSet()
if hasattr(glyph, "recalcBounds"):
glyph.recalcBounds(glyph_set)
clamped.append(name)
if clamped:
font.save(ttf_path)
print(f" Clamped x-height overshoot for: {', '.join(clamped)} (xh={xheight})")
font.close()
def fix_ttf_style_flags(ttf_path, style_suffix):
"""Normalize OS/2 fsSelection and head.macStyle for style linking."""
try:
@@ -621,10 +692,62 @@ def fix_ttf_style_flags(ttf_path, style_suffix):
print(f" Normalized style flags for {style_suffix}")
def autohint_ttf(ttf_path):
"""Run ttfautohint to add proper TrueType hinting.
Kobo uses FreeType for font rasterization. Without embedded hints,
FreeType's auto-hinter computes "blue zones" from the outlines.
When a glyph (e.g. italic 't') has a curved tail that dips just
below the baseline, the auto-hinter snaps that edge up to y=0 —
shifting the entire glyph upward relative to its neighbors. This
is most visible at small sizes.
ttfautohint replaces FreeType's built-in auto-hinter with its own
hinting, which may handle sub-baseline overshoots more gracefully.
The resulting bytecode is baked into the font, so FreeType uses
the TrueType interpreter instead of falling back to auto-hinting.
"""
if not shutil.which("ttfautohint"):
print(" [warn] ttfautohint not found, skipping", file=sys.stderr)
return
tmp_path = ttf_path + ".autohint.tmp"
result = subprocess.run(
["ttfautohint"] + AUTOHINT_OPTS + [ttf_path, tmp_path],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr)
if os.path.exists(tmp_path):
os.remove(tmp_path)
return
os.replace(tmp_path, ttf_path)
print(f" Autohinted with ttfautohint")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# MAIN
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def check_ttfautohint():
"""Verify ttfautohint is installed before starting the build."""
if shutil.which("ttfautohint"):
return
print(
"ERROR: ttfautohint not found.\n"
"\n"
"ttfautohint is required for proper rendering on Kobo e-readers.\n"
"Install it with:\n"
" macOS/Bazzite: brew install ttfautohint\n"
" Debian/Ubuntu: sudo apt install ttfautohint\n"
" Fedora: sudo dnf install ttfautohint\n"
" Arch: sudo pacman -S ttfautohint\n",
file=sys.stderr,
)
sys.exit(1)
def main():
print("=" * 60)
print(" Readerly Build")
@@ -632,22 +755,20 @@ def main():
ff_cmd = find_fontforge()
print(f" FontForge: {' '.join(ff_cmd)}")
check_ttfautohint()
print(f" ttfautohint: {shutil.which('ttfautohint')}")
family = DEFAULT_FAMILY
old_kern = False
outline_fix = True
if "--customize" in sys.argv:
print()
family = input(f" Font family name [{DEFAULT_FAMILY}]: ").strip() or DEFAULT_FAMILY
old_kern_input = input(" Export with old-style kerning? [y/N]: ").strip().lower()
old_kern = old_kern_input in ("y", "yes")
outline_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower()
outline_fix = outline_input not in ("n", "no")
print()
print(f" Family: {family}")
print(f" Old kern: {'yes' if old_kern else 'no'}")
print(f" Outline fix: {'yes' if outline_fix else 'no'}")
print()
@@ -657,12 +778,12 @@ def main():
os.makedirs(tmp_dir)
try:
_build(tmp_dir, family=family, old_kern=old_kern, outline_fix=outline_fix)
_build(tmp_dir, family=family, outline_fix=outline_fix)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True):
variants = [(f"{family}-{style}", vf, wght, opsz)
for style, vf, wght, opsz in VARIANT_STYLES]
variant_names = [name for name, _, _, _ in variants]
@@ -746,7 +867,6 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
# Step 4: Export to out/sfd and out/ttf
print("\n── Step 4: Export ──\n")
os.makedirs(OUT_SFD_DIR, exist_ok=True)
os.makedirs(OUT_TTF_DIR, exist_ok=True)
for name in variant_names:
@@ -754,21 +874,18 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
ttf_path = os.path.join(OUT_TTF_DIR, f"{name}.ttf")
style_suffix = name.split("-")[-1] if "-" in name else "Regular"
# Copy final SFD to out/sfd/
shutil.copy2(sfd_path, os.path.join(OUT_SFD_DIR, f"{name}.sfd"))
print(f" -> {OUT_SFD_DIR}/{name}.sfd")
# Export TTF
script = build_export_script(sfd_path, ttf_path, old_kern=old_kern)
script = build_export_script(sfd_path, ttf_path)
run_fontforge_script(script)
if outline_fix:
clean_ttf_degenerate_contours(ttf_path)
clamp_xheight_overshoot(ttf_path)
fix_ttf_style_flags(ttf_path, style_suffix)
autohint_ttf(ttf_path)
print("\n" + "=" * 60)
print(" Build complete!")
print(f" SFD fonts are in: {OUT_SFD_DIR}/")
print(f" TTF fonts are in: {OUT_TTF_DIR}/")
print("=" * 60)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 80 KiB