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

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 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
@@ -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 = {
@@ -602,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."""
@@ -692,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.
@@ -748,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")
@@ -879,14 +975,22 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, outline_fix=True):
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)