Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f43edba38f | |||
| a736526056 | |||
| d4cce21701 | |||
| b96bb96a88 | |||
| 2a27486aca | |||
| 784a1e4f40 | |||
| 4797071ede | |||
| 5fc4d9b8d8 | |||
| f5aac0701f | |||
| 139b14dbf6 | |||
| bd9e4d99d5 | |||
| defd728985 | |||
| 641c89bc1a |
82
.github/workflows/build.yml
vendored
Normal file
82
.github/workflows/build.yml
vendored
Normal 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
|
||||
86
README.md
86
README.md
@@ -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 1–2 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`.
|
||||
|
||||
548
build.py
548
build.py
@@ -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)
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Remove zero-area contours from a TTF.
|
||||
|
||||
Some FontForge exports emit 1–2 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()
|
||||
BIN
screenshot.png
BIN
screenshot.png
Binary file not shown.
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 80 KiB |
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
@@ -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.")
|
||||
Reference in New Issue
Block a user