1
0

8 Commits
v1.3 ... v1.4

Author SHA1 Message Date
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
4 changed files with 211 additions and 87 deletions

View File

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

View File

@@ -1 +1 @@
1.3 1.4

250
build.py
View File

@@ -8,7 +8,7 @@ Orchestrates the full font build pipeline:
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 TTF → ./out/ttf/ 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, autohinting
Uses FontForge (detected automatically). Uses FontForge (detected automatically).
Run with: python3 build.py Run with: python3 build.py
@@ -25,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
@@ -36,12 +36,14 @@ import textwrap
# - LINE_HEIGHT: Typo line height (default line spacing) # - LINE_HEIGHT: Typo line height (default line spacing)
# - SELECTION_HEIGHT: Win/hhea selection box height and clipping # - SELECTION_HEIGHT: Win/hhea selection box height and clipping
# - ASCENDER_RATIO: ascender share of total height # - ASCENDER_RATIO: ascender share of total height
# - KERN_PAIRS: explicit GPOS kern pairs (for devices without ligatures)
# - STYLE_MAP: naming/weight metadata per style # - STYLE_MAP: naming/weight metadata per style
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_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs 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") 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") ITALIC_VF = os.path.join(SRC_DIR, "Newsreader-Italic-VariableFont_opsz,wght.ttf")
@@ -94,17 +96,19 @@ ASCENDER_RATIO = 0.8
# - Other options are left at ttfautohint defaults; uncomment to override. # - Other options are left at ttfautohint defaults; uncomment to override.
AUTOHINT_OPTS = [ AUTOHINT_OPTS = [
"--no-info", "--no-info",
"--stem-width-mode=nss", "--stem-width-mode=qss",
# "--hinting-range-min=8", # "--hinting-range-min=8",
# "--hinting-range-max=50", # "--hinting-range-max=50",
# "--hinting-limit=200", # "--hinting-limit=200",
"--increase-x-height=0", "--increase-x-height=14",
] ]
# Glyphs whose x-height overshoot is an outlier (+12 vs the standard +22). # Explicit kern pairs: (left_glyph, right_glyph, kern_value_in_units).
# The inconsistent overshoot lands between the hinter's snap zones, causing # Negative values tighten spacing. These are added on top of any existing
# these glyphs to render taller than their neighbors on low-res e-ink. # kerning from the source variable font.
CLAMP_XHEIGHT_GLYPHS = ["u", "uogonek"] KERN_PAIRS = [
("f", "i", -100), # this emulates the `fi` ligature
]
# 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 = {
@@ -524,19 +528,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})
@@ -606,61 +606,6 @@ 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."""
@@ -696,6 +641,117 @@ 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 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 autohint_ttf(ttf_path): def autohint_ttf(ttf_path):
"""Run ttfautohint to add proper TrueType hinting. """Run ttfautohint to add proper TrueType hinting.
@@ -752,6 +808,42 @@ def check_ttfautohint():
sys.exit(1) 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."""
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(): def main():
print("=" * 60) print("=" * 60)
print(" Readerly Build") print(" Readerly Build")
@@ -763,20 +855,16 @@ def main():
print(f" ttfautohint: {shutil.which('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()
@@ -786,12 +874,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]
@@ -883,18 +971,26 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
style_suffix = name.split("-")[-1] if "-" in name else "Regular" style_suffix = name.split("-")[-1] if "-" in name else "Regular"
# 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)
add_kern_pairs(ttf_path)
autohint_ttf(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("\n" + "=" * 60)
print(" Build complete!") print(" Build complete!")
print(f" TTF fonts are in: {OUT_TTF_DIR}/") print(f" TTF fonts are in: {OUT_TTF_DIR}/")
print(f" KF fonts are in: {OUT_KF_DIR}/")
print("=" * 60) print("=" * 60)