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`. 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 ## Project structure
- `src`: Newsreader variable font TTFs - `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: After running `build.py`, you should get:
- `out/sfd`: FontForge source files (generated)
- `out/ttf`: final TTF fonts (generated) - `out/ttf`: final TTF fonts (generated)
## Prerequisites ## Prerequisites
@@ -30,28 +36,53 @@ After running `build.py`, you should get:
- **Python 3** - **Python 3**
- **[fontTools](https://github.com/fonttools/fonttools)** — install with `pip install fonttools` - **[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 - **[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 ### Linux preparation
``` ```
sudo apt install ttfautohint # Debian/Ubuntu
sudo dnf install ttfautohint # Fedora
brew install ttfautohint # Bazzite (immutable Fedora)
pip install fonttools pip install fonttools
flatpak install flathub org.fontforge.FontForge flatpak install flathub org.fontforge.FontForge
``` ```
### macOS preparation ### 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: 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 ```bash
echo 'export PATH="$HOME/Library/Python/3.9/bin:$PATH"' >> ~/.zshrc 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 brew unlink python3 # ensure that python3 isn't linked via Homebrew
pip3 install fonttools font-line pip3 install fonttools font-line
source ~/.zshrc 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 ## 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 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 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) 1. Instances variable fonts into static TTFs (fontTools.instancer)
2. Applies vertical scale (scale.py) via FontForge 2. Applies vertical scale (scale.py) via FontForge
3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py) 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). Uses FontForge (detected automatically).
Run with: python3 build.py Run with: python3 build.py
@@ -24,7 +25,7 @@ import textwrap
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# #
# Most of these values are safe to tweak. The --customize flag only toggles # 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): # Quick reference (what each knob does):
# - REGULAR_VF / ITALIC_VF: input variable fonts from ./src # - REGULAR_VF / ITALIC_VF: input variable fonts from ./src
@@ -40,7 +41,6 @@ import textwrap
ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.join(ROOT_DIR, "src") SRC_DIR = os.path.join(ROOT_DIR, "src")
OUT_DIR = os.path.join(ROOT_DIR, "out") 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 OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs
REGULAR_VF = os.path.join(SRC_DIR, "Newsreader-VariableFont_opsz,wght.ttf") REGULAR_VF = os.path.join(SRC_DIR, "Newsreader-VariableFont_opsz,wght.ttf")
@@ -87,6 +87,25 @@ LINE_HEIGHT = 1.0
SELECTION_HEIGHT = 1.3 SELECTION_HEIGHT = 1.3
ASCENDER_RATIO = 0.8 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) # Step 3: Naming and style metadata (used by the rename step)
STYLE_MAP = { STYLE_MAP = {
"Regular": ("Regular", "Book", 400), "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.""" """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"""\ return textwrap.dedent(f"""\
import fontforge import fontforge
f = fontforge.open({sfd_path!r}) f = fontforge.open({sfd_path!r})
print("Exporting: " + f.fontname) print("Exporting: " + f.fontname)
{flags_line} flags = ("opentype", "no-FFTM-table")
f.generate({ttf_path!r}, flags=flags) f.generate({ttf_path!r}, flags=flags)
print(" -> " + {ttf_path!r}) print(" -> " + {ttf_path!r})
@@ -587,6 +602,62 @@ def clean_ttf_degenerate_contours(ttf_path):
font.close() 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): def fix_ttf_style_flags(ttf_path, style_suffix):
"""Normalize OS/2 fsSelection and head.macStyle for style linking.""" """Normalize OS/2 fsSelection and head.macStyle for style linking."""
try: try:
@@ -621,10 +692,62 @@ def fix_ttf_style_flags(ttf_path, style_suffix):
print(f" Normalized style flags for {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 # 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(): def main():
print("=" * 60) print("=" * 60)
print(" Readerly Build") print(" Readerly Build")
@@ -632,22 +755,20 @@ def main():
ff_cmd = find_fontforge() ff_cmd = find_fontforge()
print(f" FontForge: {' '.join(ff_cmd)}") print(f" FontForge: {' '.join(ff_cmd)}")
check_ttfautohint()
print(f" ttfautohint: {shutil.which('ttfautohint')}")
family = DEFAULT_FAMILY family = DEFAULT_FAMILY
old_kern = False
outline_fix = True outline_fix = True
if "--customize" in sys.argv: if "--customize" in sys.argv:
print() print()
family = input(f" Font family name [{DEFAULT_FAMILY}]: ").strip() or DEFAULT_FAMILY 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_input = input(" Apply outline fixes (remove overlaps + zero-area cleanup)? [Y/n]: ").strip().lower()
outline_fix = outline_input not in ("n", "no") outline_fix = outline_input not in ("n", "no")
print() print()
print(f" Family: {family}") 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(f" Outline fix: {'yes' if outline_fix else 'no'}")
print() print()
@@ -657,12 +778,12 @@ def main():
os.makedirs(tmp_dir) os.makedirs(tmp_dir)
try: try:
_build(tmp_dir, family=family, old_kern=old_kern, outline_fix=outline_fix) _build(tmp_dir, family=family, outline_fix=outline_fix)
finally: finally:
shutil.rmtree(tmp_dir, ignore_errors=True) 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) variants = [(f"{family}-{style}", vf, wght, opsz)
for style, vf, wght, opsz in VARIANT_STYLES] for style, vf, wght, opsz in VARIANT_STYLES]
variant_names = [name for name, _, _, _ in variants] 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 # Step 4: Export to out/sfd and out/ttf
print("\n── Step 4: Export ──\n") print("\n── Step 4: Export ──\n")
os.makedirs(OUT_SFD_DIR, exist_ok=True)
os.makedirs(OUT_TTF_DIR, exist_ok=True) os.makedirs(OUT_TTF_DIR, exist_ok=True)
for name in variant_names: 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") ttf_path = os.path.join(OUT_TTF_DIR, f"{name}.ttf")
style_suffix = name.split("-")[-1] if "-" in name else "Regular" 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 # 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) run_fontforge_script(script)
if outline_fix: if outline_fix:
clean_ttf_degenerate_contours(ttf_path) clean_ttf_degenerate_contours(ttf_path)
clamp_xheight_overshoot(ttf_path)
fix_ttf_style_flags(ttf_path, style_suffix) fix_ttf_style_flags(ttf_path, style_suffix)
autohint_ttf(ttf_path)
print("\n" + "=" * 60) print("\n" + "=" * 60)
print(" Build complete!") print(" Build complete!")
print(f" SFD fonts are in: {OUT_SFD_DIR}/")
print(f" TTF fonts are in: {OUT_TTF_DIR}/") print(f" TTF fonts are in: {OUT_TTF_DIR}/")
print("=" * 60) print("=" * 60)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 80 KiB