1
0

3 Commits
v1.3.1 ... v1.4

3 changed files with 174 additions and 79 deletions

View File

@@ -2,7 +2,7 @@ name: Build fonts
on:
push:
branches: [main]
branches: [main, develop]
tags: ["*"]
jobs:
@@ -12,28 +12,19 @@ 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: 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
uses: actions/upload-artifact@v6
with:
name: Readerly
path: out/ttf/*.ttf
- name: Upload Kobo artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: KF_Readerly
path: out/kf/*.ttf
@@ -46,7 +37,7 @@ jobs:
- 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: |
@@ -61,7 +52,7 @@ jobs:
contents: write
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v6
with:
name: Readerly-release

View File

@@ -1 +1 @@
1.3
1.4

230
build.py
View File

@@ -8,7 +8,7 @@ Orchestrates the full font build pipeline:
2. Applies vertical scale (scale.py) via FontForge
3. Applies vertical metrics, line height, rename (metrics.py, lineheight.py, rename.py)
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).
Run with: python3 build.py
@@ -36,12 +36,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")
@@ -94,17 +96,19 @@ ASCENDER_RATIO = 0.8
# - Other options are left at ttfautohint defaults; uncomment to override.
AUTOHINT_OPTS = [
"--no-info",
"--stem-width-mode=nss",
"--stem-width-mode=qss",
# "--hinting-range-min=8",
# "--hinting-range-max=50",
# "--hinting-limit=200",
"--increase-x-height=0",
"--increase-x-height=14",
]
# 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"]
# 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 = {
@@ -602,61 +606,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."""
@@ -692,6 +641,117 @@ 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 autohint_ttf(ttf_path):
"""Run ttfautohint to add proper TrueType hinting.
@@ -748,6 +808,42 @@ 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."""
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")
@@ -879,14 +975,22 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True):
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)
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)