Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f43edba38f | |||
| a736526056 | |||
| d4cce21701 | |||
| b96bb96a88 | |||
| 2a27486aca | |||
| 784a1e4f40 | |||
| 4797071ede | |||
| 5fc4d9b8d8 | |||
| f5aac0701f | |||
| 139b14dbf6 | |||
| bd9e4d99d5 | |||
| defd728985 |
82
.github/workflows/build.yml
vendored
Normal file
82
.github/workflows/build.yml
vendored
Normal 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
|
||||
37
README.md
37
README.md
@@ -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`.
|
||||
|
||||
161
build.py
161
build.py
@@ -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)
|
||||
|
||||
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 80 KiB |
Reference in New Issue
Block a user