1
0

13 Commits
v1.1 ... main

Author SHA1 Message Date
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
784a1e4f40 Update screenshot 2026-03-14 12:59:29 +01:00
4797071ede Artefacts should not be double zipped 2026-03-14 12:53:01 +01:00
5fc4d9b8d8 Build fonts with fntbld-oci 2026-03-14 12:40:06 +01:00
f5aac0701f Fix x-height overshoot for hinting 2026-03-14 12:39:49 +01:00
139b14dbf6 Tweak autohinting 2026-03-14 12:10:08 +01:00
bd9e4d99d5 Add ttfautohint dependency check 2026-03-14 10:54:47 +01:00
defd728985 Autohint for improved rendering on Kobo 2026-03-14 01:27:42 +01:00
641c89bc1a Unify script, fix incorrect x-height
- The script has been unified and documentation has been added
- The x-height was previously incorrectly calculated; caps and ascender
  are now also correct
- Added instructions to set up Mac environment
- Bumped version number
2026-03-05 20:59:49 +01:00
14 changed files with 630 additions and 634 deletions

82
.github/workflows/build.yml vendored Normal file
View 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

View File

@@ -12,10 +12,16 @@ 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
- `scripts`: FontForge Python scripts applied during the build
- `build.py`: The build script to generate Readerly
- `LICENSE`: The OFL license
- `COPYRIGHT`: Copyright information, later embedded in font
@@ -23,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
@@ -31,22 +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 ttfautohint
brew unlink python3 # ensure that python3 isn't linked via Homebrew
pip3 install fonttools font-line
source ~/.zshrc
```
brew install fontforge
python3 -m pip install --user -U fonttools
#### 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
```
@@ -57,44 +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. Each step is described below.
#### Step 1: Instancing
The Newsreader variable font supports two axes: optical size (`opsz`) and weight (`wght`). Using `fontTools.instancer`, the variable fonts are pinned to specific axis values to produce static TTFs. A small optical size (`opsz=9`) is used as the starting point because it produces tighter, more compact letterforms that resemble Bookerly's proportions.
Variant configuration (in `build.py`):
- Regular: wght=450, opsz=9
- Bold: wght=650, opsz=9
- Italic: wght=450, opsz=9
- BoldItalic: wght=650, opsz=9
#### Step 2: Scaling, condensing, and overlap removal
Three transforms are applied in sequence via FontForge:
- **Vertical scaling** (`scale.py`): Lowercase glyphs are scaled up vertically (and slightly horizontally) to increase the x-height, bringing it closer to Bookerly's proportions.
- **Horizontal condensing** (`condense.py`): All glyphs are narrowed slightly to match Bookerly's more compact character widths.
- **Overlap removal** (`overlaps.py`): Overlapping contours are merged into clean, unified outlines and winding direction is corrected. Variable fonts commonly use overlapping paths to aid interpolation between weights. After instancing, these overlaps remain. While desktop renderers handle this fine, e-readers like Kobo apply synthetic font weight scaling that can cause visible artifacts (gaps, blobs, uneven strokes) when contours overlap. Merging the overlaps into single paths prevents these rendering issues.
#### Step 3: Metrics, naming, version, and copyright
Several metadata scripts are applied via FontForge:
- **Vertical metrics** (`metrics.py`): Measures design landmarks (cap height, ascender, x-height, descender) from actual glyph bounding boxes and sets OS/2 Typo metrics to the ink boundaries. Enables `USE_TYPO_METRICS`.
- **Line height** (`lineheight.py`): Overrides Win/hhea metrics to control line spacing and selection box height. Values are expressed as multiples of the font's UPM (units per em) — the coordinate grid that all glyph measurements are defined in (Newsreader uses 2000 UPM). A line height of 1.0x UPM means lines are spaced exactly one em apart, with an 80/20 ascender/descender split. The selection box height (1.32x UPM) controls the highlighted area when selecting text.
- **Renaming** (`rename.py`): Rewrites all SFNT name table entries from Newsreader to Readerly, and sets the correct PS weight string and OS/2 weight class for each variant.
- **Version** (`version.py`): Sets the font version and `head.fontRevision` from `./VERSION`.
- **Copyright** (`license.py`): Sets the copyright notice from `./COPYRIGHT`.
#### Step 4: Export
The final fonts are exported from FontForge as TTF. Outline fixes remove overlaps and zero-area contours that can cause missing glyphs on macOS; you can disable them via `--customize`. The build supports optional old-style kern tables, but this is off by default because it has no effect on device tests. As a final post-export step, `build.py` normalizes the OS/2 style flags and `head.macStyle` with fontTools so Bold/Italic variants link correctly on Kobo.
#### TTF cleanup (manual exports)
Some FontForge exports emit 12 point contours in the `glyf` table. macOS can treat these as invalid and skip the glyph entirely (for example, `m` or italic `u`). The build pipeline removes these zero-area contours automatically. If you manually export a TTF from an SFD and see missing glyphs, run:
```
python3 cleanup_ttf.py out/ttf/Readerly-Regular.ttf
```
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`.

View File

@@ -1 +1 @@
1.1
1.3

548
build.py
View File

@@ -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
@@ -22,13 +23,25 @@ import textwrap
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Most of these values are safe to tweak. The --customize flag only toggles
# a small subset at runtime (family name, outline fixes).
#
# Quick reference (what each knob does):
# - REGULAR_VF / ITALIC_VF: input variable fonts from ./src
# - DEFAULT_FAMILY: default output family name
# - VARIANT_STYLES: (style, source VF, wght, opsz) pins for instancing
# - SCALE_LOWER_X/Y: lowercase-only scale (x-height tuning)
# - CONDENSE_X: horizontal condense for all glyphs
# - LINE_HEIGHT: Typo line height (default line spacing)
# - SELECTION_HEIGHT: Win/hhea selection box height and clipping
# - ASCENDER_RATIO: ascender share of total height
# - 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_SFD_DIR = os.path.join(OUT_DIR, "sfd")
OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf")
SCRIPTS_DIR = os.path.join(ROOT_DIR, "scripts")
OUT_TTF_DIR = os.path.join(OUT_DIR, "ttf") # generated TTFs
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")
@@ -39,16 +52,68 @@ with open(os.path.join(ROOT_DIR, "VERSION")) as _vf:
with open(os.path.join(ROOT_DIR, "COPYRIGHT")) as _cf:
COPYRIGHT_TEXT = _cf.read().strip()
DEFAULT_FAMILY = "Readerly"
DEFAULT_FAMILY = "Readerly" # default if --customize not used
VARIANT_STYLES = [
# (style_suffix, source_vf, wght, opsz)
# opsz=9 is intentionally small to tighten letterforms for e-readers.
("Regular", REGULAR_VF, 450, 9),
("Bold", REGULAR_VF, 650, 9),
("Italic", ITALIC_VF, 450, 9),
("BoldItalic", ITALIC_VF, 650, 9),
]
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# INLINE FONTFORGE SCRIPT CONFIG
# (Migrated from ./scripts for readability and single-file builds.)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#
# Step 2: Scaling + overlap cleanup
# - SCALE_LOWER_* affects lowercase only (x-height tuning).
# - CONDENSE_X narrows all glyphs to match Bookerly-like widths.
# Scale lowercase glyphs vertically (and slightly widen).
SCALE_LOWER_X = 1.03
SCALE_LOWER_Y = 1.08
# Condense all glyphs horizontally.
CONDENSE_X = 0.95
# Step 3: Vertical metrics + line spacing (relative to UPM)
# - LINE_HEIGHT drives OS/2 Typo metrics (default line spacing)
# - SELECTION_HEIGHT drives Win/hhea metrics (selection box + clipping)
# - ASCENDER_RATIO splits the total height between ascender/descender
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),
"Bold": ("Bold", "Bold", 700),
"Italic": ("Italic", "Book", 400),
"BoldItalic": ("Bold Italic", "Bold", 700),
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# HELPERS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -146,31 +211,328 @@ def build_per_font_script(open_path, save_path, steps):
return "\n".join(parts)
def load_script_as_function(script_path):
"""
Read a script file and adapt it from using fontforge.activeFont() to
using a pre-opened font variable `f`.
"""
with open(script_path) as fh:
code = fh.read()
# Replace activeFont() call — the font is already open as `f`
code = code.replace("fontforge.activeFont()", "f")
return code
def ff_scale_lowercase_script():
"""FontForge script: scale lowercase glyphs vertically."""
return textwrap.dedent(f"""\
import psMat
import unicodedata
# Scale lowercase glyphs only, from glyph origin.
SCALE_X = {SCALE_LOWER_X}
SCALE_Y = {SCALE_LOWER_Y}
mat = psMat.scale(SCALE_X, SCALE_Y)
f.selection.none()
count = 0
for g in f.glyphs():
if g.unicode < 0:
continue
try:
cat = unicodedata.category(chr(g.unicode))
except (ValueError, OverflowError):
continue
if cat == "Ll" or g.unicode in (0x00AA, 0x00BA):
f.selection.select(("more",), g.glyphname)
count += 1
f.transform(mat, ("round",))
print(f" Scaled {{count}} lowercase glyphs by X={{SCALE_X:.0%}}, Y={{SCALE_Y:.0%}}")
""")
def build_export_script(sfd_path, ttf_path, old_kern=True):
"""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")'
def ff_condense_script():
"""FontForge script: condense all glyphs horizontally."""
return textwrap.dedent(f"""\
import psMat
SCALE_X = {CONDENSE_X}
mat = psMat.scale(SCALE_X, 1.0)
f.selection.all()
f.transform(mat, ("round",))
count = sum(1 for g in f.glyphs() if g.isWorthOutputting())
print(f" Condensed {{count}} glyphs by X={{SCALE_X:.0%}}")
""")
def ff_remove_overlaps_script():
"""FontForge script: merge overlapping contours and fix direction."""
return textwrap.dedent("""\
f.selection.all()
f.removeOverlap()
f.correctDirection()
count = sum(1 for g in f.glyphs() if g.isWorthOutputting())
print(f" Removed overlaps and corrected direction for {count} glyphs")
""")
def ff_metrics_script():
"""FontForge script: measure landmarks and set OS/2 Typo metrics."""
return textwrap.dedent("""\
def _bbox(name):
# Return bounding box (xmin, ymin, xmax, ymax) or None.
if name in f and f[name].isWorthOutputting():
bb = f[name].boundingBox()
if bb != (0, 0, 0, 0):
return bb
return None
def measure_chars(chars, *, axis="top"):
# Measure a set of reference characters.
# axis="top" -> return the highest yMax
# axis="bottom" -> return the lowest yMin
# Returns (value, display_char) or (None, None).
idx = 3 if axis == "top" else 1
pick = max if axis == "top" else min
hits = []
for ch in chars:
name = fontforge.nameFromUnicode(ord(ch))
bb = _bbox(name)
if bb is not None:
hits.append((bb[idx], ch))
if not hits:
return None, None
return pick(hits, key=lambda t: t[0])
def scan_font_extremes():
# Walk every output glyph; return (yMax, yMin, max_name, min_name).
y_max, y_min = 0, 0
max_nm, min_nm = None, None
for g in f.glyphs():
if not g.isWorthOutputting():
continue
bb = g.boundingBox()
if bb == (0, 0, 0, 0):
continue
if bb[3] > y_max:
y_max, max_nm = bb[3], g.glyphname
if bb[1] < y_min:
y_min, min_nm = bb[1], g.glyphname
return y_max, y_min, max_nm, min_nm
print("─── Design landmarks ───\\n")
cap_h, cap_c = measure_chars("HIOX", axis="top")
asc_h, asc_c = measure_chars("bdfhkl", axis="top")
xht_h, xht_c = measure_chars("xuvw", axis="top")
dsc_h, dsc_c = measure_chars("gpqyj", axis="bottom")
for label, val, ch in [
("Cap height", cap_h, cap_c),
("Ascender", asc_h, asc_c),
("x-height", xht_h, xht_c),
("Descender", dsc_h, dsc_c),
]:
if val is not None:
print(f" {label:12s} {int(val):>6} ('{ch}')")
else:
flags_line = 'flags = ("opentype", "no-FFTM-table")'
print(f" {label:12s} {'N/A':>6}")
print("\\n─── Full font scan ───\\n")
font_ymax, font_ymin, ymax_name, ymin_name = scan_font_extremes()
print(f" Highest glyph: {int(font_ymax):>6} ({ymax_name})")
print(f" Lowest glyph: {int(font_ymin):>6} ({ymin_name})")
upm = f.em
design_top = asc_h if asc_h is not None else cap_h
design_bot = dsc_h # negative value
if design_top is None or design_bot is None:
raise SystemExit(
"ERROR: Could not measure ascender/cap-height or descender.\\n"
" Make sure your font contains basic Latin glyphs (H, b, p, etc.)."
)
typo_ascender = int(round(design_top))
typo_descender = int(round(design_bot))
f.os2_typoascent = typo_ascender
f.os2_typodescent = typo_descender
f.os2_typolinegap = 0
if hasattr(f, "os2_xheight") and xht_h is not None:
f.os2_xheight = int(round(xht_h))
if hasattr(f, "os2_capheight") and cap_h is not None:
f.os2_capheight = int(round(cap_h))
# Win/hhea set to same initial values; lineheight step overrides these.
f.os2_winascent = typo_ascender
f.os2_windescent = abs(typo_descender)
f.hhea_ascent = typo_ascender
f.hhea_descent = typo_descender
f.hhea_linegap = 0
typo_metrics_set = False
if hasattr(f, "os2_use_typo_metrics"):
f.os2_use_typo_metrics = True
typo_metrics_set = True
if not typo_metrics_set and hasattr(f, "os2_fsselection"):
f.os2_fsselection |= (1 << 7)
typo_metrics_set = True
if not typo_metrics_set:
if hasattr(f, "os2_version") and f.os2_version < 4:
f.os2_version = 4
if not typo_metrics_set:
print(" WARNING: Could not set USE_TYPO_METRICS programmatically.")
print(" -> In Font Info -> OS/2 -> Misc, tick 'USE_TYPO_METRICS'.\\n")
typo_line = typo_ascender - typo_descender
print(f"\\n─── Applied metrics ───\\n")
print(f" UPM: {upm}")
print(f" Typo: {typo_ascender} / {typo_descender} (ink span: {typo_line}, {typo_line/upm:.2f}x UPM)")
if cap_h is not None:
print(f" Cap height: {int(cap_h)}")
if xht_h is not None:
print(f" x-height: {int(xht_h)}")
""")
def ff_lineheight_script():
"""FontForge script: set line height and selection box metrics."""
return textwrap.dedent(f"""\
# Line height (Typo) as a multiple of UPM.
LINE_HEIGHT = {LINE_HEIGHT}
# Selection box height (Win/hhea) as a multiple of UPM.
SELECTION_HEIGHT = {SELECTION_HEIGHT}
# Ascender share of the line/selection height.
ASCENDER_RATIO = {ASCENDER_RATIO}
upm = f.em
# OS/2 Typo — controls line spacing
typo_total = int(round(upm * LINE_HEIGHT))
typo_asc = int(round(typo_total * ASCENDER_RATIO))
typo_dsc = typo_asc - typo_total # negative
f.os2_typoascent = typo_asc
f.os2_typodescent = typo_dsc
f.os2_typolinegap = 0
# Win/hhea — controls selection box height and clipping
sel_total = int(round(upm * SELECTION_HEIGHT))
sel_asc = int(round(sel_total * ASCENDER_RATIO))
sel_dsc = sel_total - sel_asc
f.hhea_ascent = sel_asc
f.hhea_descent = -sel_dsc
f.hhea_linegap = 0
f.os2_winascent = sel_asc
f.os2_windescent = sel_dsc
print(f" Typo: {{typo_asc}} / {{typo_dsc}} / gap 0 (line height: {{typo_total}}, {LINE_HEIGHT:.2f}x UPM)")
print(f" hhea: {{sel_asc}} / {{-sel_dsc}} / gap 0 (selection: {{sel_total}}, {SELECTION_HEIGHT:.2f}x UPM)")
print(f" Win: {{sel_asc}} / {{sel_dsc}}")
""")
def ff_rename_script():
"""FontForge script: update font name metadata."""
style_map = repr(STYLE_MAP)
return textwrap.dedent(f"""\
# FAMILY is injected by build.py; default if run standalone.
if "FAMILY" not in dir():
FAMILY = "Readerly"
STYLE_MAP = {style_map}
# Determine style from the current fontname (e.g. "Readerly-BoldItalic")
style_suffix = f.fontname.split("-")[-1] if "-" in f.fontname else "Regular"
style_display, ps_weight, os2_weight = STYLE_MAP.get(
style_suffix, (style_suffix, "Book", 400)
)
f.fontname = f"{{FAMILY}}-{{style_suffix}}"
f.familyname = FAMILY
f.fullname = f"{{FAMILY}} {{style_display}}"
f.weight = ps_weight
f.os2_weight = os2_weight
# Set head.macStyle for style linking if supported by FontForge
if hasattr(f, "macstyle"):
macstyle = f.macstyle
macstyle &= ~((1 << 0) | (1 << 1))
if "Bold" in style_suffix:
macstyle |= (1 << 0)
if "Italic" in style_suffix:
macstyle |= (1 << 1)
f.macstyle = macstyle
lang = "English (US)"
f.appendSFNTName(lang, "Family", FAMILY)
f.appendSFNTName(lang, "SubFamily", style_display)
f.appendSFNTName(lang, "Fullname", f"{{FAMILY}} {{style_display}}")
f.appendSFNTName(lang, "PostScriptName", f"{{FAMILY}}-{{style_suffix}}")
f.appendSFNTName(lang, "Preferred Family", FAMILY)
f.appendSFNTName(lang, "Preferred Styles", style_display)
f.appendSFNTName(lang, "Compatible Full", f"{{FAMILY}} {{style_display}}")
f.appendSFNTName(lang, "UniqueID", f"{{FAMILY}} {{style_display}}")
# Clear Newsreader-specific entries
f.appendSFNTName(lang, "Trademark", "")
f.appendSFNTName(lang, "Manufacturer", "")
f.appendSFNTName(lang, "Designer", "")
f.appendSFNTName(lang, "Vendor URL", "")
f.appendSFNTName(lang, "Designer URL", "")
count = 0
for _name in f.sfnt_names:
count += 1
print(f" Updated {{count}} name entries for {{FAMILY}} {{style_display}}")
print(f" PS weight: {{ps_weight}}, OS/2 usWeightClass: {{os2_weight}}")
""")
def ff_version_script():
"""FontForge script: set font version."""
return textwrap.dedent("""\
# VERSION is injected by build.py before this script runs.
version_str = "Version " + VERSION
f.version = VERSION
f.sfntRevision = float(VERSION)
f.appendSFNTName("English (US)", "Version", version_str)
print(f" Version set to: {version_str}")
print(f" head.fontRevision set to: {float(VERSION)}")
""")
def ff_license_script():
"""FontForge script: set copyright."""
return textwrap.dedent("""\
# COPYRIGHT_TEXT is injected by build.py before this script runs.
lang = "English (US)"
f.copyright = COPYRIGHT_TEXT
f.appendSFNTName(lang, "Copyright", COPYRIGHT_TEXT)
print(f" Copyright: {COPYRIGHT_TEXT.splitlines()[0]}")
""")
def build_export_script(sfd_path, ttf_path):
"""Build a FontForge script that opens an .sfd and exports to TTF."""
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})
@@ -240,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:
@@ -274,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")
@@ -285,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()
@@ -310,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]
@@ -349,9 +817,9 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
# Step 2: Apply vertical scale (opens TTF, saves as SFD)
print("\n── Step 2: Scale lowercase ──\n")
scale_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "scale.py"))
condense_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "condense.py"))
overlap_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "overlaps.py"))
scale_code = ff_scale_lowercase_script()
condense_code = ff_condense_script()
overlap_code = ff_remove_overlaps_script()
for name in variant_names:
ttf_path = os.path.join(tmp_dir, f"{name}.ttf")
@@ -370,11 +838,11 @@ def _build(tmp_dir, family=DEFAULT_FAMILY, old_kern=True, outline_fix=True):
# Step 3: Apply metrics and rename (opens SFD, saves as SFD)
print("\n── Step 3: Apply metrics and rename ──\n")
metrics_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "metrics.py"))
lineheight_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "lineheight.py"))
rename_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "rename.py"))
version_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "version.py"))
license_code = load_script_as_function(os.path.join(SCRIPTS_DIR, "license.py"))
metrics_code = ff_metrics_script()
lineheight_code = ff_lineheight_script()
rename_code = ff_rename_script()
version_code = ff_version_script()
license_code = ff_license_script()
for name in variant_names:
sfd_path = os.path.join(tmp_dir, f"{name}.sfd")
@@ -399,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:
@@ -407,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)

View File

@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""
Remove zero-area contours from a TTF.
Some FontForge exports emit 12 point contours that macOS can treat as
invalid and skip the glyph entirely. This script removes those contours
in-place.
"""
import sys
def clean_ttf_degenerate_contours(ttf_path):
try:
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
except Exception as exc:
raise SystemExit(f"ERROR: fontTools is required ({exc})")
font = TTFont(ttf_path)
glyf = font["glyf"] # type: ignore[index]
removed_total = 0
modified = set()
for name in font.getGlyphOrder():
glyph = glyf[name] # type: ignore[index]
if glyph.isComposite():
continue
end_pts = getattr(glyph, "endPtsOfContours", None)
if not end_pts:
continue
coords = glyph.coordinates
flags = glyph.flags
new_coords = []
new_flags = []
new_end_pts = []
start = 0
removed = 0
for end in end_pts:
count = end - start + 1
if count <= 2:
removed += 1
else:
new_coords.extend(coords[start:end + 1])
new_flags.extend(flags[start:end + 1])
new_end_pts.append(len(new_coords) - 1)
start = end + 1
if removed:
removed_total += removed
modified.add(name)
glyph.coordinates = GlyphCoordinates(new_coords)
glyph.flags = new_flags
glyph.endPtsOfContours = new_end_pts
glyph.numberOfContours = len(new_end_pts)
if removed_total:
glyph_set = font.getGlyphSet()
for name in modified:
glyph = glyf[name] # type: ignore[index]
if hasattr(glyph, "recalcBounds"):
glyph.recalcBounds(glyph_set)
if hasattr(glyf, "recalcBounds"):
glyf.recalcBounds(glyph_set) # type: ignore[attr-defined]
font.save(ttf_path)
font.close()
return removed_total
def main():
if len(sys.argv) != 2:
raise SystemExit("Usage: python3 cleanup_ttf.py path/to/font.ttf")
ttf_path = sys.argv[1]
removed = clean_ttf_degenerate_contours(ttf_path)
print(f"Cleaned {removed} zero-area contour(s): {ttf_path}")
if __name__ == "__main__":
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,31 +0,0 @@
"""
FontForge: Condense all glyphs horizontally
────────────────────────────────────────────
Applies a horizontal scale to all glyphs, reducing set width.
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
import psMat
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCALE_X = 0.95
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mat = psMat.scale(SCALE_X, 1.0)
f.selection.all()
f.transform(mat, ("round",))
count = sum(1 for g in f.glyphs() if g.isWorthOutputting())
print(f" Condensed {count} glyphs by X={SCALE_X:.0%}")
print("Done.")

View File

@@ -1,21 +0,0 @@
"""
FontForge: Set copyright information
─────────────────────────────────────
Sets the copyright notice from COPYRIGHT_TEXT injected by build.py.
Run inside FontForge (or via build.py which sets `f` and `COPYRIGHT_TEXT` before running this).
"""
import fontforge
f = fontforge.activeFont()
# COPYRIGHT_TEXT is injected by build.py before this script runs
lang = "English (US)"
f.copyright = COPYRIGHT_TEXT
f.appendSFNTName(lang, "Copyright", COPYRIGHT_TEXT)
print(f" Copyright: {COPYRIGHT_TEXT.splitlines()[0]}")
print("Done.")

View File

@@ -1,58 +0,0 @@
"""
FontForge: Adjust line height
──────────────────────────────
Sets vertical metrics to control line spacing and selection box height.
- Typo: controls line spacing (via USE_TYPO_METRICS)
- Win/hhea: controls selection box height and clipping
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Line height (Typo) as a multiple of UPM.
LINE_HEIGHT = 1.0
# Selection box height (Win/hhea) as a multiple of UPM.
SELECTION_HEIGHT = 1.32
# Ascender share of the line/selection height.
ASCENDER_RATIO = 0.80
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
upm = f.em
# OS/2 Typo — controls line spacing
typo_total = int(round(upm * LINE_HEIGHT))
typo_asc = int(round(typo_total * ASCENDER_RATIO))
typo_dsc = typo_asc - typo_total # negative
f.os2_typoascent = typo_asc
f.os2_typodescent = typo_dsc
f.os2_typolinegap = 0
# Win/hhea — controls selection box height and clipping
sel_total = int(round(upm * SELECTION_HEIGHT))
sel_asc = int(round(sel_total * ASCENDER_RATIO))
sel_dsc = sel_total - sel_asc
f.hhea_ascent = sel_asc
f.hhea_descent = -sel_dsc
f.hhea_linegap = 0
f.os2_winascent = sel_asc
f.os2_windescent = sel_dsc
print(f" Typo: {typo_asc} / {typo_dsc} / gap 0 (line height: {typo_total}, {LINE_HEIGHT:.2f}x UPM)")
print(f" hhea: {sel_asc} / {-sel_dsc} / gap 0 (selection: {sel_total}, {SELECTION_HEIGHT:.2f}x UPM)")
print(f" Win: {sel_asc} / {sel_dsc}")
print("Done.")

View File

@@ -1,161 +0,0 @@
"""
FontForge: Set Vertical Metrics
───────────────────────────────
Measures design landmarks, sets OS/2 Typo metrics to the ink boundaries,
and enables USE_TYPO_METRICS. Win/hhea are set to initial values here
but will be overridden by lineheight.py.
Run inside FontForge (File → Execute Script → paste, or fontforge -script).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# HELPERS
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
def _bbox(name):
"""Return bounding box (xmin, ymin, xmax, ymax) or None."""
if name in f and f[name].isWorthOutputting():
bb = f[name].boundingBox()
if bb != (0, 0, 0, 0):
return bb
return None
def measure_chars(chars, *, axis="top"):
"""
Measure a set of reference characters.
axis="top" → return the highest yMax
axis="bottom" → return the lowest yMin
Returns (value, display_char) or (None, None).
"""
idx = 3 if axis == "top" else 1
pick = max if axis == "top" else min
hits = []
for ch in chars:
name = fontforge.nameFromUnicode(ord(ch))
bb = _bbox(name)
if bb is not None:
hits.append((bb[idx], ch))
if not hits:
return None, None
return pick(hits, key=lambda t: t[0])
def scan_font_extremes():
"""Walk every output glyph; return (yMax, yMin, max_name, min_name)."""
y_max, y_min = 0, 0
max_nm, min_nm = None, None
for g in f.glyphs():
if not g.isWorthOutputting():
continue
bb = g.boundingBox()
if bb == (0, 0, 0, 0):
continue
if bb[3] > y_max:
y_max, max_nm = bb[3], g.glyphname
if bb[1] < y_min:
y_min, min_nm = bb[1], g.glyphname
return y_max, y_min, max_nm, min_nm
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 1 — Measure design landmarks
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print("─── Design landmarks ───\n")
cap_h, cap_c = measure_chars("HIOXE", axis="top")
asc_h, asc_c = measure_chars("bdfhkl", axis="top")
xht_h, xht_c = measure_chars("xzouv", axis="top")
dsc_h, dsc_c = measure_chars("gpqyj", axis="bottom")
for label, val, ch in [("Cap height", cap_h, cap_c),
("Ascender", asc_h, asc_c),
("x-height", xht_h, xht_c),
("Descender", dsc_h, dsc_c)]:
if val is not None:
print(f" {label:12s} {int(val):>6} ('{ch}')")
else:
print(f" {label:12s} {'N/A':>6}")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 2 — Full-font bounding-box scan
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
print("\n─── Full font scan ───\n")
font_ymax, font_ymin, ymax_name, ymin_name = scan_font_extremes()
print(f" Highest glyph: {int(font_ymax):>6} ({ymax_name})")
print(f" Lowest glyph: {int(font_ymin):>6} ({ymin_name})")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 3 — Set OS/2 Typo to ink boundaries
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
upm = f.em
design_top = asc_h if asc_h is not None else cap_h
design_bot = dsc_h # negative value
if design_top is None or design_bot is None:
raise SystemExit(
"ERROR: Could not measure ascender/cap-height or descender.\n"
" Make sure your font contains basic Latin glyphs (H, b, p, etc.)."
)
typo_ascender = int(round(design_top))
typo_descender = int(round(design_bot))
f.os2_typoascent = typo_ascender
f.os2_typodescent = typo_descender
f.os2_typolinegap = 0
# Win/hhea set to same initial values; lineheight.py overrides these.
f.os2_winascent = typo_ascender
f.os2_windescent = abs(typo_descender)
f.hhea_ascent = typo_ascender
f.hhea_descent = typo_descender
f.hhea_linegap = 0
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 4 — USE_TYPO_METRICS (fsSelection bit 7)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
typo_metrics_set = False
if hasattr(f, "os2_use_typo_metrics"):
f.os2_use_typo_metrics = True
typo_metrics_set = True
if not typo_metrics_set and hasattr(f, "os2_fsselection"):
f.os2_fsselection |= (1 << 7)
typo_metrics_set = True
if not typo_metrics_set:
if hasattr(f, "os2_version") and f.os2_version < 4:
f.os2_version = 4
if not typo_metrics_set:
print(" WARNING: Could not set USE_TYPO_METRICS programmatically.")
print(" -> In Font Info -> OS/2 -> Misc, tick 'USE_TYPO_METRICS'.\n")
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# STEP 5 — Report
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
typo_line = typo_ascender - typo_descender
print(f"\n─── Applied metrics ───\n")
print(f" UPM: {upm}")
print(f" Typo: {typo_ascender} / {typo_descender} (ink span: {typo_line}, {typo_line/upm:.2f}x UPM)")
if cap_h is not None:
print(f" Cap height: {int(cap_h)}")
if xht_h is not None:
print(f" x-height: {int(xht_h)}")
print("\nDone.")

View File

@@ -1,28 +0,0 @@
"""
FontForge: Remove overlapping contours
───────────────────────────────────────
Merges overlapping contours into clean outlines for all glyphs.
Also corrects winding direction, which can get flipped after overlap removal.
This fixes rendering issues on devices (e.g. Kobo) that struggle with
overlapping paths, especially when applying synthetic bold/weight scaling.
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
f.selection.all()
f.removeOverlap()
f.correctDirection()
count = sum(1 for g in f.glyphs() if g.isWorthOutputting())
print(f" Removed overlaps and corrected direction for {count} glyphs")
print("Done.")

View File

@@ -1,87 +0,0 @@
"""
FontForge: Update font name metadata
─────────────────────────────────────
Replaces Newsreader references with the target family name in all name table
entries and font-level properties.
FAMILY is injected by build.py before this script runs (defaults to "Readerly").
Run inside FontForge (or via build.py which sets `f` and `FAMILY` before running this).
"""
import fontforge
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# FAMILY is injected by build.py; default if run standalone
if "FAMILY" not in dir():
FAMILY = "Readerly"
# Map style suffixes to display names, PS weight strings, and OS/2 weight classes
STYLE_MAP = {
"Regular": ("Regular", "Book", 400),
"Bold": ("Bold", "Bold", 700),
"Italic": ("Italic", "Book", 400),
"BoldItalic": ("Bold Italic", "Bold", 700),
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DETECT STYLE
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Determine style from the current fontname (e.g. "Readerly-BoldItalic")
style_suffix = f.fontname.split("-")[-1] if "-" in f.fontname else "Regular"
style_display, ps_weight, os2_weight = STYLE_MAP.get(style_suffix, (style_suffix, "Book", 400))
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# UPDATE FONT PROPERTIES
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
f.fontname = f"{FAMILY}-{style_suffix}"
f.familyname = FAMILY
f.fullname = f"{FAMILY} {style_display}"
f.weight = ps_weight
f.os2_weight = os2_weight
# Set head.macStyle for style linking if supported by FontForge
if hasattr(f, "macstyle"):
macstyle = f.macstyle
macstyle &= ~((1 << 0) | (1 << 1))
if "Bold" in style_suffix:
macstyle |= (1 << 0)
if "Italic" in style_suffix:
macstyle |= (1 << 1)
f.macstyle = macstyle
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# UPDATE SFNT NAME TABLE
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
lang = "English (US)"
f.appendSFNTName(lang, "Family", FAMILY)
f.appendSFNTName(lang, "SubFamily", style_display)
f.appendSFNTName(lang, "Fullname", f"{FAMILY} {style_display}")
f.appendSFNTName(lang, "PostScriptName", f"{FAMILY}-{style_suffix}")
f.appendSFNTName(lang, "Preferred Family", FAMILY)
f.appendSFNTName(lang, "Preferred Styles", style_display)
f.appendSFNTName(lang, "Compatible Full", f"{FAMILY} {style_display}")
f.appendSFNTName(lang, "UniqueID", f"{FAMILY} {style_display}")
# Clear Newsreader-specific entries
f.appendSFNTName(lang, "Trademark", "")
f.appendSFNTName(lang, "Manufacturer", "")
f.appendSFNTName(lang, "Designer", "")
f.appendSFNTName(lang, "Vendor URL", "")
f.appendSFNTName(lang, "Designer URL", "")
count = 0
for name in f.sfnt_names:
count += 1
print(f" Updated {count} name entries for {FAMILY} {style_display}")
print(f" PS weight: {ps_weight}, OS/2 usWeightClass: {os2_weight}")
print("Done.")

View File

@@ -1,53 +0,0 @@
"""
FontForge: Scale lowercase glyphs vertically
─────────────────────────────────────────────
Applies a vertical scale to lowercase glyphs only, from glyph origin,
matching the Transform dialog with all options checked:
- Transform All Layers
- Transform Guide Layer Too
- Transform Width Too
- Transform kerning classes too
- Transform simple positioning features & kern pairs
- Round To Int
Run inside FontForge (or via build.py which sets `f` before running this).
"""
import fontforge
import psMat
import unicodedata
f = fontforge.activeFont()
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CONFIGURATION
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
SCALE_X = 1.03
SCALE_Y = 1.08
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# APPLY
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
mat = psMat.scale(SCALE_X, SCALE_Y)
# Select only lowercase glyphs
f.selection.none()
count = 0
for g in f.glyphs():
if g.unicode < 0:
continue
try:
cat = unicodedata.category(chr(g.unicode))
except (ValueError, OverflowError):
continue
if cat == "Ll" or g.unicode in (0x00AA, 0x00BA):
f.selection.select(("more",), g.glyphname)
count += 1
f.transform(mat, ("round",))
print(f" Scaled {count} lowercase glyphs by X={SCALE_X:.0%}, Y={SCALE_Y:.0%}")
print("Done.")

View File

@@ -1,24 +0,0 @@
"""
FontForge: Set font version
────────────────────────────
Sets the font version from a VERSION variable injected by build.py.
Run inside FontForge (or via build.py which sets `f` and `VERSION` before running this).
"""
import fontforge
f = fontforge.activeFont()
# VERSION is injected by build.py before this script runs
# e.g. VERSION = "1.0"
version_str = "Version " + VERSION
f.version = VERSION
f.sfntRevision = float(VERSION)
f.appendSFNTName("English (US)", "Version", version_str)
print(f" Version set to: {version_str}")
print(f" head.fontRevision set to: {float(VERSION)}")
print("Done.")