Refactor CLI flags into modal options and add presets

This is a big commit that fixes a variety of issues and prevents fonts
from growing far too large without any benefits. Previously, converted
fonts would use multiple subtables for the `kern` table, but sadly those
are usually not read by renderers, so we now don't save al kern pairs,
but we prioritize.

The full list of changes can be found below.

---

Replace individual boolean flags with modal --kern and --hint options:

- --hint {skip,additive,overwrite,strip}: controls hinting behavior,
  including new ttfautohint support (additive/overwrite modes)
- --kern {add-legacy-kern,legacy-kern-only,skip}: replaces the old
  --skip-kobo-kern and --remove-gpos flags
- --preset {nv,kf}: bundled configurations for common workflows

Add upfront dependency checking (`ttfautohint`, `font-line`) so missing
tools are caught before any processing begins.

Fix GPOS Extension lookup (type 9) support: kern pairs were silently
missed in fonts that wrap PairPos subtables in Extension lookups.

Rework legacy kern table writing to respect format 0 size constraints.
The subtable length field is uint16, limiting a single subtable to
10,920 pairs. Since most renderers (including Kobo) only read the first
subtable, we write exactly one and prioritize pairs by Unicode range.

=> Basic Latin > Latin-1 Supplement > Latin Extended > rest

This way, the most commonly encountered kerning pairs are preserved
when truncation is needed (and it usually is, for quality fonts).
This commit is contained in:
2026-03-14 16:28:25 +01:00
parent 04bded25cb
commit 6cd3e2b20b
3 changed files with 225 additions and 200 deletions

View File

@@ -18,12 +18,17 @@ Python 3, FontTools, `font-line`.
You can install them like so:
```bash
pip3 install fonttools
pip3 install font-line
```
If you want to use the `--hint additive` or `--hint overwrite` options, you also need `ttfautohint`:
```bash
brew install ttfautohint # macOS
```
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
@@ -69,90 +74,45 @@ You can customize what the script does. For more information, consult:
```
Given the right arguments, you can:
- Skip the `kern` step
- Control kerning behavior (`--kern`): add a legacy kern table (default), remove GPOS after extraction, or skip entirely
- Control hinting (`--hint`): strip hints, apply ttfautohint to unhinted fonts, apply ttfautohint to all fonts, or skip (default)
- Use a custom name for a font
- Use a custom name for the prefix
- Remove the `GPOS` table entirely
- Adjust the percentage of the `font-line` setting
- Skip running `font-line` altogether
For debugging purposes, you can run the script with the `--verbose` flag.
## Examples
## Presets
### Generating KF fonts
The script includes presets for common workflows. If no preset or flags are provided, you will be prompted to choose one.
This applies the KF prefix, applies 20 percent line spacing and adds a Kobo `kern` table. Ideal if you have an existing TrueType font and you want it on your Kobo device.
### NV preset
The `--name` parameter is used to change the name of the font family.
Prepares fonts for the [ebook-fonts](https://github.com/nicoverbruggen/ebook-fonts) repository. Applies the NV prefix and 20% line spacing. Does not modify kerning or hinting.
```bash
./kobofix.py --prefix KF --name="Fonty" --line-percent 20 --remove-hints *.ttf
./kobofix.py --preset nv *.ttf
```
To process fonts from my [ebook-fonts](https://github.com/nicoverbruggen/ebook-fonts) collection which are prefixed with "NV", you can replace the prefix and make adjustments in bulk.
To process all fonts with the "Kobo Fix" preset, simply run:
You can override individual settings, for example to use relaxed spacing:
```bash
./kobofix.py --prefix KF --remove-prefix="NV" --line-percent 0 *.ttf
./kobofix.py --preset nv --line-percent 50 *.ttf
```
(In this case, we'll set --line-percent to 0 so the line height changes aren't made, because the fonts in the NV Collection should already have those changes applied.)
### KF preset
The expected output is then:
```
nico@m1ni kobo-font-fix % ./kobofix.py --prefix KF --remove-prefix NV *.ttf --line-percent 0
Processing: NV-Elstob-Bold.ttf
--remove-prefix enabled: using 'Elstob' as the new family name.
Renaming the font to: KF Elstob Bold
PANOSE corrected: bWeight 8->8, bLetterForm 2->2
Kerning: extracted 342467 pairs; wrote 342467 to legacy 'kern' table.
Saved: KF_Elstob-Bold.ttf
Skipping line adjustment step.
Processing: NV-Elstob-BoldItalic.ttf
--remove-prefix enabled: using 'Elstob' as the new family name.
Renaming the font to: KF Elstob Bold Italic
PANOSE corrected: bWeight 8->8, bLetterForm 3->3
Kerning: extracted 300746 pairs; wrote 300746 to legacy 'kern' table.
Saved: KF_Elstob-BoldItalic.ttf
Skipping line adjustment step.
Processing: NV-Elstob-Italic.ttf
--remove-prefix enabled: using 'Elstob' as the new family name.
Renaming the font to: KF Elstob Italic
PANOSE corrected: bWeight 5->5, bLetterForm 3->3
Kerning: extracted 286857 pairs; wrote 286856 to legacy 'kern' table.
Saved: KF_Elstob-Italic.ttf
Skipping line adjustment step.
Processing: NV-Elstob-Regular.ttf
--remove-prefix enabled: using 'Elstob' as the new family name.
Renaming the font to: KF Elstob
PANOSE corrected: bWeight 5->5, bLetterForm 2->2
Kerning: extracted 313998 pairs; wrote 313998 to legacy 'kern' table.
Saved: KF_Elstob-Regular.ttf
Skipping line adjustment step.
==================================================
Processed 4/4 fonts successfully.
```
### Generating NV fonts
Tight spacing, with a custom font family name:
Prepares KF fonts from NV fonts for use on Kobo devices. Applies the KF prefix, replaces the NV prefix, and adds a legacy kern table. No line spacing changes are made (since NV fonts already have those applied).
```bash
./kobofix.py --prefix NV --name="Fonty" --line-percent 20 --skip-kobo-kern *.ttf
./kobofix.py --preset kf *.ttf
```
Relaxed spacing, with a custom font family name:
### Custom processing
You can also specify all flags manually:
```bash
./kobofix.py --prefix NV --name="Fonty" --line-percent 50 --skip-kobo-kern *.ttf
./kobofix.py --prefix KF --name="Fonty" --line-percent 20 --kern add-legacy-kern *.ttf
```
You can play around with `--line-percent` to see what works for you.

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
# ttfconv.py
# This script converts OTF fonts to TTF fonts using the font-tools library.
# It processes a list of font files provided as command-line arguments.
# The font-tools library must be installed: `pip install fonttools`.
from fontTools.ttLib import TTFont
import os
import sys
def convert_font(input_file_path):
"""
Converts a font file to a TTF font file.
This function currently assumes the input is OTF and the output is TTF.
Args:
input_file_path (str): The path to the input font file.
"""
if not os.path.exists(input_file_path):
print(f"❌ Error: The file '{input_file_path}' was not found.")
return
try:
output_file_path = os.path.splitext(input_file_path)[0] + ".ttf"
font = TTFont(input_file_path)
font.save(output_file_path)
print(f"✅ Converted: {os.path.basename(input_file_path)} -> {os.path.basename(output_file_path)}")
except Exception as e:
print(f"❌ An error occurred during conversion of '{os.path.basename(input_file_path)}': {e}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python font_converter.py <font_file1> <font_file2> ...")
print("Example: python font_converter.py MyFont.otf AnotherFont.otf")
print("You can also use a wildcard: python font_converter.py *.otf")
else:
for file_path in sys.argv[1:]:
if file_path.lower().endswith(".otf"):
convert_font(file_path)
else:
print(f"⚠️ Skipping '{file_path}': This script only converts OTF to TTF.")
print(f"To convert other formats, please provide the correct extension.")
print("\nProcessing complete.")

View File

@@ -30,11 +30,24 @@ from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib.tables._k_e_r_n import KernTable_format_0
# -------------
# DEFAULTS
# PRESETS
# -------------
#
DEFAULT_PREFIX = "KF"
DEFAULT_LINE_PERCENT = 20
PRESETS = {
"nv": {
"prefix": "NV",
"line_percent": 20,
"kern": "skip",
"hint": "skip",
},
"kf": {
"prefix": "KF",
"line_percent": 0,
"kern": "add-legacy-kern",
"hint": "skip",
"remove_prefix": "NV",
},
}
# -------------
# STYLE MAPPING
@@ -73,8 +86,8 @@ class FontProcessor:
"""
def __init__(self,
prefix: str = DEFAULT_PREFIX,
line_percent: int = DEFAULT_LINE_PERCENT
prefix: str,
line_percent: int,
):
"""
Initialize the font processor with configurable values.
@@ -334,50 +347,118 @@ class FontProcessor:
lookup_list = getattr(gpos, "LookupList", None)
if lookup_list and lookup_list.Lookup:
for lookup in lookup_list.Lookup:
# Only process Pair Adjustment lookups (type 2)
if getattr(lookup, "LookupType", None) == 2:
for subtable in getattr(lookup, "SubTable", []):
lookup_type = getattr(lookup, "LookupType", None)
subtables = getattr(lookup, "SubTable", [])
# Unwrap Extension lookups (type 9) to get the inner subtables
if lookup_type == 9:
unwrapped = []
for ext_subtable in subtables:
ext_type = getattr(ext_subtable, "ExtensionLookupType", None)
inner = getattr(ext_subtable, "ExtSubTable", None)
if ext_type == 2 and inner is not None:
unwrapped.append(inner)
subtables = unwrapped
lookup_type = 2 if unwrapped else None
if lookup_type != 2:
continue
for subtable in subtables:
fmt = getattr(subtable, "Format", None)
if fmt == 1:
format1_pairs = self._extract_format1_pairs(subtable)
for key, value in format1_pairs.items():
# Only add if not already present (first value wins)
if key not in pairs:
pairs[key] = value
extracted = self._extract_format1_pairs(subtable)
elif fmt == 2:
format2_pairs = self._extract_format2_pairs(subtable)
for key, value in format2_pairs.items():
# Only add if not already present (first value wins)
extracted = self._extract_format2_pairs(subtable)
else:
continue
for key, value in extracted.items():
if key not in pairs:
pairs[key] = value
return pairs
@staticmethod
def _glyph_priority(glyph_name: str, cmap_reverse: Dict[str, int]) -> int:
"""
Assign a priority to a glyph for kern pair sorting.
Lower values = higher priority. Pairs involving common glyphs
are prioritized so they fit within the subtable size limit.
"""
cp = cmap_reverse.get(glyph_name)
if cp is None:
return 4 # unmapped glyphs (ligatures, alternates, etc.)
if cp <= 0x007F:
return 0 # Basic Latin (A-Z, a-z, digits, punctuation)
if cp <= 0x00FF:
return 1 # Latin-1 Supplement (accented chars, common symbols)
if cp <= 0x024F:
return 2 # Latin Extended-A and B
return 3 # everything else
@staticmethod
def add_legacy_kern(font: TTFont, kern_pairs: Dict[Tuple[str, str], int]) -> int:
"""
Create or replace a legacy 'kern' table with the supplied pairs.
Splits into multiple subtables if there are more than 10,000 pairs.
The legacy kern table format has strict size constraints:
- Most renderers (including Kobo's WebKit-based engine) only read the
first subtable, so we write exactly one.
- Format 0 subtables have a uint16 length field (max 65,535 bytes).
With a 14-byte header and 6 bytes per pair, this allows at most
(65,535 - 14) / 6 = 10,920 pairs before the length overflows.
When a font has more pairs than this (common with class-based GPOS
kerning, which can expand to 100k+ individual pairs), we prioritize
by Unicode range so the most commonly encountered pairs are kept:
- Basic Latin (U+0000-007F): English, digits, punctuation
- Latin-1 Supplement (U+0080-00FF): Western European accented chars
- Latin Extended-A/B (U+0100-024F): Central/Eastern European chars
- Everything else and unmapped glyphs (ligatures, alternates)
This means all English kerning is preserved, most Western European
kerning (French, German, Spanish, etc.) is preserved, and only less
common extended Latin pairings are dropped when truncation is needed.
"""
if not kern_pairs:
return 0
MAX_PAIRS = 10920
items = [(tuple(k), int(v)) for k, v in kern_pairs.items() if v]
if len(items) > MAX_PAIRS:
# Build reverse cmap (glyph name -> codepoint) for prioritization
cmap_reverse = {}
if "cmap" in font:
for table in font["cmap"].tables:
if hasattr(table, "cmap"):
for cp, glyph_name in table.cmap.items():
if glyph_name not in cmap_reverse:
cmap_reverse[glyph_name] = cp
# Sort by priority of both glyphs (lower = more common)
items.sort(key=lambda pair: (
FontProcessor._glyph_priority(pair[0][0], cmap_reverse) +
FontProcessor._glyph_priority(pair[0][1], cmap_reverse)
))
logger.warning(f" Kerning: {len(items)} pairs exceed the subtable limit of {MAX_PAIRS}. "
f"Keeping the {MAX_PAIRS} most common pairs.")
items = items[:MAX_PAIRS]
kern_table = newTable("kern")
kern_table.version = 0
kern_table.kernTables = []
# Max pairs per subtable
MAX_PAIRS = 10000
items = [(tuple(k), int(v)) for k, v in kern_pairs.items() if v]
for i in range(0, len(items), MAX_PAIRS):
chunk = dict(items[i:i + MAX_PAIRS])
subtable = KernTable_format_0()
subtable.version = 0
subtable.length = None
subtable.coverage = 1
subtable.kernTable = chunk
subtable.kernTable = dict(items)
kern_table.kernTables.append(subtable)
# Additional subtables are not created because most renderers
# (including Kobo's WebKit-based engine) only read the first one.
font["kern"] = kern_table
return len(items)
@@ -645,8 +726,7 @@ class FontProcessor:
# ============================================================
def process_font(self,
kern: bool,
remove_gpos: bool,
kern_mode: str,
font_path: str,
new_name: Optional[str] = None,
remove_prefix: Optional[str] = None,
@@ -693,7 +773,11 @@ class FontProcessor:
self.check_and_fix_panose(font, font_path)
self.update_weight_metadata(font, font_path)
if kern:
# Note: As of firmware 4.45, Kobo reads GPOS kerning data correctly,
# but only when webkitTextRendering=optimizeLegibility is enabled.
# Since this setting is disabled by default, a legacy kern table is
# still needed for most users.
if kern_mode in ("add-legacy-kern", "legacy-kern-only"):
had_kern = "kern" in font
had_gpos = "GPOS" in font
@@ -711,14 +795,12 @@ class FontProcessor:
logger.info(" Kerning: GPOS table found but contained no kern pairs, no 'kern' table created.")
else:
logger.info(" Kerning: no kerning data found (no GPOS or 'kern' table), no pairs written.")
else:
logger.info(" Skipping `kern` step.")
# The GPOS table is removed after the kerning data has been extracted
# and written to the `kern` table. This ensures the information is not lost.
if remove_gpos and kern and "GPOS" in font:
if kern_mode == "legacy-kern-only" and "GPOS" in font:
del font["GPOS"]
logger.info(" Removed GPOS table from the font.")
else:
logger.info(" Skipping `kern` step.")
if hint_mode == "strip":
self.strip_hints(font)
@@ -809,62 +891,95 @@ def validate_font_files(font_paths: List[str]) -> Tuple[List[str], List[str]]:
def main():
"""Main entry point."""
preset_names = ", ".join(PRESETS.keys())
parser = argparse.ArgumentParser(
description="Process fonts for Kobo e-readers: add prefix, kern table, "
"PANOSE validation, and line adjustments.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
epilog=f"""
Presets:
nv Prepare fonts for the ebook-fonts repository. Applies NV prefix,
20%% line spacing. Does not modify kerning or hinting.
kf Prepare KF fonts from NV fonts. Applies KF prefix, replaces NV
prefix, adds legacy kern table. No line spacing changes.
Examples:
For a default experience, which will prefix the font with KF, add `kern` table and adjust line-height:
%(prog)s *.ttf
Using a preset:
%(prog)s --preset nv *.ttf
%(prog)s --preset kf *.ttf
If you want to rename the font:
%(prog)s --prefix KF --name="Fonty" --line-percent 20 *.ttf
Custom processing:
%(prog)s --prefix KF --name="Fonty" --line-percent 20 --kern add-legacy-kern *.ttf
If you want to keep the line-height because for a given font the default was fine:
%(prog)s --line-percent 0 *.ttf
For improved legacy support, you can remove the GPOS table (not recommended):
%(prog)s --prefix KF --name="Fonty" --line-percent 20 --remove-gpos *.ttf
To remove a specific prefix, like "NV", before applying a new one:
%(prog)s --prefix KF --remove-prefix="NV" *.ttf
If no preset or flags are provided, you will be prompted to choose a preset.
"""
)
parser.add_argument("fonts", nargs="+",
help="Font files to process (*.ttf). You can use a wildcard (glob).")
parser.add_argument("--preset", type=str, choices=PRESETS.keys(),
help=f"Use a preset configuration ({preset_names}).")
parser.add_argument("--name", type=str,
help="Optional new family name for all fonts. Other font metadata like copyright info is unaffected.")
parser.add_argument("--prefix", type=str, default=DEFAULT_PREFIX,
help=f"Prefix to add to font names. Set to empty string to omit prefix. (Default: {DEFAULT_PREFIX})")
parser.add_argument("--line-percent", type=int, default=DEFAULT_LINE_PERCENT,
help=f"Line spacing adjustment percentage. Set to 0 to make no changes to line spacing. (Default: {DEFAULT_LINE_PERCENT})")
parser.add_argument("--skip-kobo-kern", action="store_true",
help="Skip the creation of the legacy 'kern' table from GPOS data.")
parser.add_argument("--remove-gpos", action="store_true",
help="Remove the GPOS table after converting kerning to a 'kern' table. Does not work if `--skip-kobo-kern` is set.")
parser.add_argument("--prefix", type=str,
help="Prefix to add to font names. Set to empty string to omit prefix.")
parser.add_argument("--line-percent", type=int,
help="Line spacing adjustment percentage. Set to 0 to make no changes to line spacing.")
parser.add_argument("--kern", type=str,
choices=["add-legacy-kern", "legacy-kern-only", "skip"],
help="Kerning mode: 'add-legacy-kern' extracts GPOS pairs into a legacy kern table, "
"'legacy-kern-only' does the same but removes the GPOS table afterwards, "
"'skip' leaves kerning untouched.")
parser.add_argument("--verbose", action="store_true",
help="Enable verbose output.")
parser.add_argument("--remove-prefix", type=str,
help="Remove a leading prefix from font names before applying the new prefix. Only works if `--name` is not used. (e.g., --remove-prefix=\"NV\")")
parser.add_argument("--hint", type=str, default="skip",
parser.add_argument("--hint", type=str,
choices=["skip", "additive", "overwrite", "strip"],
help="Hinting mode: 'skip' does nothing (default), 'additive' runs ttfautohint on fonts lacking hints, "
help="Hinting mode: 'skip' does nothing, 'additive' runs ttfautohint on fonts lacking hints, "
"'overwrite' runs ttfautohint on all fonts, 'strip' removes all TrueType hints.")
args = parser.parse_args()
if args.remove_gpos and args.skip_kobo_kern:
parser.error("--remove-gpos and --skip-kobo-kern cannot be used together.")
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
# Determine which flags were explicitly set by the user
manual_flags = {k for k in ("prefix", "line_percent", "kern", "hint", "remove_prefix", "name")
if getattr(args, k) is not None}
# If no preset and no manual flags, prompt the user to choose a preset
if args.preset is None and not manual_flags:
logger.info("No preset or flags specified. Available presets:")
for name, values in PRESETS.items():
logger.info(f" {name}")
choice = input("\nChoose a preset: ").strip().lower()
if choice not in PRESETS:
logger.error(f"Unknown preset '{choice}'. Available: {preset_names}")
sys.exit(1)
args.preset = choice
# Apply preset values as defaults, then let explicit flags override
if args.preset:
preset = PRESETS[args.preset]
for key, value in preset.items():
if key not in manual_flags:
setattr(args, key, value)
# Fill in remaining defaults for any unset flags
if args.prefix is None:
parser.error("--prefix is required when not using a preset.")
if args.line_percent is None:
parser.error("--line-percent is required when not using a preset.")
if args.kern is None:
args.kern = "skip"
if args.hint is None:
args.hint = "skip"
if args.name and args.remove_prefix:
parser.error("--name and --remove-prefix cannot be used together. Use --name to set the font name directly, or --remove-prefix to strip an existing prefix.")
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
check_dependencies(args.hint, args.line_percent)
valid_files, invalid_files = validate_font_files(args.fonts)
@@ -894,8 +1009,7 @@ Examples:
success_count = 0
for font_path in valid_files:
if processor.process_font(
not args.skip_kobo_kern,
args.remove_gpos,
args.kern,
font_path,
args.name,
args.remove_prefix,