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
This commit is contained in:
53
README.md
53
README.md
@@ -15,7 +15,6 @@ To get to the final result, I decided to use the variable font and work on it. T
|
||||
## 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
|
||||
@@ -40,9 +39,15 @@ flatpak install flathub org.fontforge.FontForge
|
||||
```
|
||||
|
||||
### macOS preparation
|
||||
```
|
||||
|
||||
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
|
||||
python3 -m pip install --user -U fonttools
|
||||
brew unlink python3 # ensure that python3 isn't linked via Homebrew
|
||||
pip3 install fonttools font-line
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
## Building
|
||||
@@ -57,44 +62,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. Configuration and step-by-step details live in the header comments of `build.py`.
|
||||
|
||||
391
build.py
391
build.py
@@ -22,13 +22,26 @@ import textwrap
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# CONFIGURATION
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
#
|
||||
# Most of these values are safe to tweak. The --customize flag only toggles
|
||||
# a small subset at runtime (family name, old-style kerning, outline fixes).
|
||||
#
|
||||
# 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_SFD_DIR = os.path.join(OUT_DIR, "sfd") # generated FontForge sources
|
||||
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,49 @@ 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 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,16 +192,317 @@ 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 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:
|
||||
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, old_kern=True):
|
||||
@@ -349,9 +696,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 +717,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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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