1
0

12 Commits

Author SHA1 Message Date
437b102a99 Update documentation 2026-03-30 23:05:15 +02:00
e3b8fea2a5 Update hinting again 2026-03-29 00:21:54 +01:00
83a84b7941 Add fix for "ghosting" serifs 2026-03-28 19:53:32 +01:00
19e915f4e1 Revert to natural hinting w/ control instructions 2026-03-28 18:59:53 +01:00
ef39ce4046 Tweak hinting, generate KF variants with build.py 2026-03-28 18:11:41 +01:00
5cfc4ea36f Use newer versions of checkout and upload-artifact 2026-03-15 00:13:25 +01:00
efc9c37522 Add explicit fi kern pair 2026-03-15 00:03:27 +01:00
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
5 changed files with 342 additions and 96 deletions

View File

@@ -2,7 +2,7 @@ name: Build fonts
on:
push:
branches: [main]
branches: [main, develop]
tags: ["*"]
jobs:
@@ -12,27 +12,37 @@ jobs:
image: ghcr.io/nicoverbruggen/fntbld-oci:latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Build fonts
run: python3 build.py
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Readerly
path: out/ttf/*.ttf
- name: Upload Kobo artifact
uses: actions/upload-artifact@v6
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
run: |
cd out/ttf && zip -j ../../Readerly.zip *.ttf
cd ../../out/kf && zip -j ../../KF_Readerly.zip *.ttf
- name: Upload release zip
- name: Upload release zips
if: startsWith(github.ref, 'refs/tags/')
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: Readerly-release
path: Readerly.zip
path: |
Readerly.zip
KF_Readerly.zip
release:
needs: build
@@ -42,11 +52,22 @@ jobs:
contents: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v6
with:
name: Readerly-release
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: Readerly.zip
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

View File

@@ -1 +1 @@
1.3
1.5

390
build.py
View File

@@ -5,10 +5,11 @@ Readerly Build Script
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)
2. Scales, condenses, and cleans up overlaps via FontForge
3. Applies vertical metrics, line height, rename, version, and license via FontForge
4. Exports to TTF → ./out/ttf/
5. Post-processes TTFs: x-height overshoot clamping, style flags, autohinting
5. Post-processes TTFs: style flags, kern pairs, glyph Y ceilings, autohinting
6. Generates Kobo (KF) variants via kobofix.py → ./out/kf/
Uses FontForge (detected automatically).
Run with: python3 build.py
@@ -25,7 +26,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
@@ -36,12 +37,14 @@ import textwrap
# - LINE_HEIGHT: Typo line height (default line spacing)
# - SELECTION_HEIGHT: Win/hhea selection box height and clipping
# - ASCENDER_RATIO: ascender share of total height
# - KERN_PAIRS: explicit GPOS kern pairs (for devices without ligatures)
# - STYLE_MAP: naming/weight metadata per style
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_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs
OUT_KF_DIR = os.path.join(OUT_DIR, "kf") # Kobo (KF) variants
REGULAR_VF = os.path.join(SRC_DIR, "Newsreader-VariableFont_opsz,wght.ttf")
ITALIC_VF = os.path.join(SRC_DIR, "Newsreader-Italic-VariableFont_opsz,wght.ttf")
@@ -89,22 +92,38 @@ 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.
# matters: n=natural (least distortion), q=quantized, s=strong.
# - x-height snapping is disabled to avoid inconsistent glyph heights.
AUTOHINT_OPTS = [
"--no-info",
"--stem-width-mode=nss",
# "--hinting-range-min=8",
# "--hinting-range-max=50",
# "--hinting-limit=200",
"--increase-x-height=0",
'--x-height-snapping-exceptions=-',
]
# 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"]
# Baseline alignment: deepen the bottom anti-aliasing of non-serifed
# glyphs via hinting-only touch deltas (no outline changes). This
# shifts their bottom points down during rasterization so they produce
# more gray below the baseline, visually matching serifed characters.
# Each entry is (shift_px, ppem_min, ppem_max). Shifts are in pixels
# (multiples of 1/8, max 1.0). Set to empty list to disable.
BASELINE_HINT_SHIFTS = [
(0.125, 6, 53),
]
# Per-glyph Y ceiling: cap the top of specific glyphs to reduce
# oversized or awkward serifs. (glyph_name, max_y)
# Points above max_y are clamped down to max_y.
GLYPH_Y_CEILING = [
("u", 1062), # flatten tiny top serif tips to platform level
]
# Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units).
# Negative values tighten spacing. These are added on top of any existing
# kerning from the source variable font.
KERN_PAIRS = [
("f", "i", -100), # this emulates the `fi` ligature
]
# Step 3: Naming and style metadata (used by the rename step)
STYLE_MAP = {
@@ -524,19 +543,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})
@@ -606,61 +621,6 @@ 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."""
@@ -696,6 +656,190 @@ def fix_ttf_style_flags(ttf_path, style_suffix):
print(f" Normalized style flags for {style_suffix}")
def add_kern_pairs(ttf_path):
"""Prepend explicit kern pairs to the GPOS kern table.
Devices that don't support OpenType ligatures (e.g. some e-readers)
fall back to individual glyphs. Without kern pairs, combinations
like 'fi' render with a visible gap.
Pairs are inserted at the front of the first PairPos subtable so
they take priority even on renderers that truncate the kern list.
"""
if not KERN_PAIRS:
return
try:
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables.otTables import PairValueRecord, ValueRecord
except Exception:
print(" [warn] Skipping kern pairs: fontTools not available", file=sys.stderr)
return
font = TTFont(ttf_path)
gpos = font.get("GPOS")
if gpos is None:
font.close()
print(" [warn] No GPOS table, skipping kern pairs", file=sys.stderr)
return
cmap = font.getBestCmap()
# Map glyph names: try cmap first, fall back to glyph order
glyph_order = set(font.getGlyphOrder())
def resolve(name):
# If it's a single character, look up via cmap
if len(name) == 1:
cp = ord(name)
if cp in cmap:
return cmap[cp]
# Otherwise treat as a glyph name
if name in glyph_order:
return name
return None
# Build resolved pairs
pairs = []
for left, right, value in KERN_PAIRS:
l = resolve(left)
r = resolve(right)
if l and r:
pairs.append((l, r, value))
else:
print(f" [warn] Kern pair {left}+{right}: glyph not found", file=sys.stderr)
if not pairs:
font.close()
return
# Find the first Format 1 (individual pairs) PairPos subtable in kern
pair_pos = None
for lookup in gpos.table.LookupList.Lookup:
if lookup.LookupType == 2: # PairPos
for subtable in lookup.SubTable:
if subtable.Format == 1:
pair_pos = subtable
break
if pair_pos:
break
if pair_pos is None:
font.close()
print(" [warn] No Format 1 PairPos subtable found, skipping kern pairs", file=sys.stderr)
return
count = 0
for left_glyph, right_glyph, value in pairs:
# Find or create the PairSet for the left glyph
try:
idx = pair_pos.Coverage.glyphs.index(left_glyph)
except ValueError:
# Left glyph not in coverage — add it
pair_pos.Coverage.glyphs.append(left_glyph)
from fontTools.ttLib.tables.otTables import PairSet
ps = PairSet()
ps.PairValueRecord = []
ps.PairValueCount = 0
pair_pos.PairSet.append(ps)
pair_pos.PairSetCount = len(pair_pos.PairSet)
idx = len(pair_pos.Coverage.glyphs) - 1
pair_set = pair_pos.PairSet[idx]
# Remove existing pair for same right glyph
pair_set.PairValueRecord = [
pvr for pvr in pair_set.PairValueRecord
if pvr.SecondGlyph != right_glyph
]
# Prepend new pair so it appears first
pvr = PairValueRecord()
pvr.SecondGlyph = right_glyph
vr = ValueRecord()
vr.XAdvance = value
pvr.Value1 = vr
pair_set.PairValueRecord.insert(0, pvr)
pair_set.PairValueCount = len(pair_set.PairValueRecord)
count += 1
font.save(ttf_path)
font.close()
print(f" Added {count} kern pair(s) to GPOS")
def apply_glyph_y_ceiling(ttf_path):
"""Clamp glyph points above a Y ceiling down to the ceiling value."""
if not GLYPH_Y_CEILING:
return
from fontTools.ttLib import TTFont
font = TTFont(ttf_path)
glyf = font["glyf"]
modified = []
for glyph_name, max_y in GLYPH_Y_CEILING:
g = glyf.get(glyph_name)
if not g or not g.numberOfContours or g.numberOfContours <= 0:
continue
coords = g.coordinates
clamped = 0
for j in range(len(coords)):
if coords[j][1] > max_y:
coords[j] = (coords[j][0], max_y)
clamped += 1
if clamped:
modified.append(f"{glyph_name}({clamped}pts)")
if modified:
font.save(ttf_path)
print(f" Clamped Y ceiling: {', '.join(modified)}")
font.close()
def _generate_baseline_shift_ctrl(ttf_path):
"""Generate touch deltas to deepen bottom anti-aliasing of non-serifed glyphs.
For lowercase glyphs without a flat baseline (no serif foot), shifts
the bottom-most points down during rasterization. Uses graduated
shifts from BASELINE_HINT_SHIFTS — stronger at small ppem sizes
where alignment is most noticeable. No outline changes.
"""
if not BASELINE_HINT_SHIFTS:
return ""
from fontTools.ttLib import TTFont
font = TTFont(ttf_path)
glyf = font["glyf"]
cmap = font.getBestCmap()
lines = []
for char in "abcdefghijklmnopqrstuvwxyz":
code = ord(char)
if code not in cmap:
continue
name = cmap[code]
g = glyf[name]
if not g.numberOfContours or g.numberOfContours <= 0:
continue
coords = g.coordinates
ys = set(c[1] for c in coords)
if 0 in ys:
continue # has serif baseline
bottom_pts = [i for i, (x, y) in enumerate(coords) if y <= 0]
if not bottom_pts:
continue
pts_str = ", ".join(str(p) for p in bottom_pts)
for shift_px, ppem_min, ppem_max in BASELINE_HINT_SHIFTS:
shift = -abs(shift_px)
lines.append(
f"{name} touch {pts_str} yshift {shift:.3f} @ {ppem_min}-{ppem_max}"
)
font.close()
return "\n".join(lines)
def autohint_ttf(ttf_path):
"""Run ttfautohint to add proper TrueType hinting.
@@ -710,16 +854,34 @@ def autohint_ttf(ttf_path):
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.
Additionally generates per-font touch deltas to deepen the
baseline anti-aliasing of non-serifed glyphs.
"""
if not shutil.which("ttfautohint"):
print(" [warn] ttfautohint not found, skipping", file=sys.stderr)
return
# Generate control instructions for this specific font's points
ctrl_text = _generate_baseline_shift_ctrl(ttf_path)
ctrl_path = ttf_path + ".ctrl.tmp"
ctrl_count = 0
opts = list(AUTOHINT_OPTS)
if ctrl_text:
with open(ctrl_path, "w") as f:
f.write(ctrl_text)
opts += [f"--control-file={ctrl_path}"]
ctrl_count = ctrl_text.count("\n") + 1
tmp_path = ttf_path + ".autohint.tmp"
result = subprocess.run(
["ttfautohint"] + AUTOHINT_OPTS + [ttf_path, tmp_path],
["ttfautohint"] + opts + [ttf_path, tmp_path],
capture_output=True, text=True,
)
if os.path.exists(ctrl_path):
os.remove(ctrl_path)
if result.returncode != 0:
print(f" [warn] ttfautohint failed: {result.stderr.strip()}", file=sys.stderr)
if os.path.exists(tmp_path):
@@ -727,7 +889,10 @@ def autohint_ttf(ttf_path):
return
os.replace(tmp_path, ttf_path)
print(f" Autohinted with ttfautohint")
hint_msg = "Autohinted with ttfautohint"
if ctrl_count:
hint_msg += f" ({ctrl_count} serif control hints)"
print(f" {hint_msg}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -752,6 +917,45 @@ def check_ttfautohint():
sys.exit(1)
KOBOFIX_URL = (
"https://raw.githubusercontent.com/nicoverbruggen/kobo-font-fix/main/kobofix.py"
)
def _download_kobofix(dest):
"""Download kobofix.py if not already cached."""
if os.path.isfile(dest):
print(f" Using cached kobofix.py")
return
import urllib.request
print(f" Downloading kobofix.py ...")
urllib.request.urlretrieve(KOBOFIX_URL, dest)
print(f" Saved to {dest}")
def _run_kobofix(kobofix_path, variant_names):
"""Run kobofix.py --preset kf on built TTFs, move KF_ files to out/kf/."""
ttf_files = [os.path.join(OUT_TTF_DIR, f"{n}.ttf") for n in variant_names]
cmd = [sys.executable, kobofix_path, "--preset", "kf"] + ttf_files
result = subprocess.run(cmd, capture_output=True, text=True)
if result.stdout:
print(result.stdout, end="")
if result.returncode != 0:
print("\nERROR: kobofix.py failed", file=sys.stderr)
if result.stderr:
print(result.stderr, file=sys.stderr)
sys.exit(1)
os.makedirs(OUT_KF_DIR, exist_ok=True)
import glob
moved = 0
for kf_file in glob.glob(os.path.join(OUT_TTF_DIR, "KF_*.ttf")):
dest = os.path.join(OUT_KF_DIR, os.path.basename(kf_file))
shutil.move(kf_file, dest)
moved += 1
print(f" Moved {moved} KF font(s) to {OUT_KF_DIR}/")
def main():
print("=" * 60)
print(" Readerly Build")
@@ -763,20 +967,25 @@ def main():
print(f" ttfautohint: {shutil.which('ttfautohint')}")
family = DEFAULT_FAMILY
old_kern = False
outline_fix = True
# --name "Foo" sets the family name directly
if "--name" in sys.argv:
idx = sys.argv.index("--name")
if idx + 1 < len(sys.argv):
family = sys.argv[idx + 1]
else:
print("ERROR: --name requires a value", file=sys.stderr)
sys.exit(1)
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")
family = input(f" Font family name [{family}]: ").strip() or family
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()
@@ -786,12 +995,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]
@@ -883,18 +1092,27 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
style_suffix = name.split("-")[-1] if "-" in name else "Regular"
# 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)
add_kern_pairs(ttf_path)
apply_glyph_y_ceiling(ttf_path)
autohint_ttf(ttf_path)
# Step 5: Generate Kobo (KF) variants via kobofix.py
print("\n── Step 5: Generate Kobo (KF) variants ──\n")
kobofix_path = os.path.join(tmp_dir, "kobofix.py")
_download_kobofix(kobofix_path)
_run_kobofix(kobofix_path, variant_names)
print("\n" + "=" * 60)
print(" Build complete!")
print(f" TTF fonts are in: {OUT_TTF_DIR}/")
print(f" KF fonts are in: {OUT_KF_DIR}/")
print("=" * 60)

BIN
screenshot.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 192 KiB