Compare commits

...

5 Commits

Author SHA1 Message Date
1ed3677d8e Add an option to remove TrueType hints
You can invoke it by adding `--remove-hints` as an argument.

This may improve the appearance of certain fonts on Kobo devices with
high resolution displays. Might make a difference for 300 DPI and up,
at 12 pt font sizes and above.

For the upcoming update to the  KF collection of fonts, this flag will
likely be used for all fonts or at the very least for a selection of
fonts that benefit from TrueType hints being absent.
2025-10-07 21:16:24 +02:00
da8b3631ea Allow omitting prefix 2025-10-07 15:45:49 +02:00
1dd9a6ab79 Adjust how kern pairs are copied 2025-10-07 15:38:45 +02:00
27c3aaf522 Updated README 2025-08-22 11:45:47 +02:00
507cb87fe1 Updated README, added LICENSE 2025-08-22 11:37:35 +02:00
3 changed files with 253 additions and 70 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Nico Verbruggen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

103
README.md
View File

@@ -2,12 +2,16 @@
## Overview
`kobofix.py` is a Python script designed to process TTF fonts for Kobo e-readers.
**`kobofix.py` is a Python script designed to process and adjust TTF fonts for Kobo e-readers for a better reading experience with the default `kepub` renderer.**
It generates a renamed font, fixes PANOSE information based on the filename, adjusts the baseline with the `font-line` utility, and adds a legacy `kern` table which allows the `kepub` engine for improved rendering of kerned pairs.
You can use this to modify or fix your own, legally acquired fonts (assuming you are permitted to do so).
## License
Licensed under the [MIT License](/LICENSE).
## Requirements
Python 3, FontTools, `font-line`.
@@ -29,23 +33,53 @@ source ~/.zshrc
## Usage
1. Open a terminal and navigate to the directory containing your font files.
2. Run the script with a glob pattern to include all TTF files:
Open a terminal and navigate to the directory containing your font files. Make sure your font files are named correctly. The script will process files that contain the string:
```bash
python3 kobofix.py *.ttf
```
3. The default script will:
- `Regular`
- `Italic`
- `Bold`
- `BoldItalic`
* Validate the file names (must end with `-Regular`, `-Bold`, `-Italic` or `-BoldItalic` so they're valid for Kobo devices).
* Process each font (e.g. "Lora" becomes "KF Lora").
* Apply kerning, rename, PANOSE adjustments, and baseline shift.
* Save output as `KF_<original_filename>`.
This is the naming convention used on Kobo devices for proper compatibility with both the `epub` and `kepub` renderer.
You can customize what the script does.
You can then run:
```bash
python3 kobofix.py ./src/*.ttf
```
By default, the script will:
1. **Validate all filenames.** If there are any invalid filenames, you will be prompted and can continue with all valid filenames, but it is recommended that you fix the invalid files.
2. **Remove any WWS name metadata from the font.** This is done because the font is renamed afterwards.
3. **Modify the internal name of the font.** Unless a new name was specified, this is merely a prefix that is applied. (By default, this is `KF`.)
4. **PANOSE metadata is checked and fixed.** Sometimes, the PANOSE information does not match the font style. This is often an oversight but it causes issues on Kobo devices, so this fixes that.
5. **Font weight metadata is updated.** There's other metadata that is part of the font that reflects the weight of the font. In case this information needs to be modified, it is adjusted.
6. **Kern pairs from the GPOS table are copied to the legacy `kern` table.** This only applies to fonts that have a GPOS table, which is used for kerning in modern fonts.
7. **The `font-line` helper is used to apply a 20% line-height setting.** This generates a new file which is immediately renamed to the desired output format.
The modified fonts are saved in the directory where the original fonts are located.
## Customization
You can customize what the script does. For more information, consult:
```bash
./kobofix.py -h
```
Given the right arguments, you can:
- Skip the `kern` step
- 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
### Generating KF fonts
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.
@@ -53,7 +87,7 @@ This applies the KF prefix, applies 20 percent line spacing and adds a Kobo `ker
The `--name` parameter is used to change the name of the font family.
```bash
./kobofix.py --prefix KF --name="Fonty" --line-percent 20 *.ttf
./kobofix.py --prefix KF --name="Fonty" --line-percent 20 --remove-hints *.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.
@@ -66,6 +100,47 @@ To process all fonts with the "Kobo Fix" preset, simply run:
(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.)
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:
@@ -80,4 +155,4 @@ Relaxed spacing, with a custom font family name:
./kobofix.py --prefix NV --name="Fonty" --line-percent 50 --skip-kobo-kern *.ttf
```
You can play around with `--line-percent` to see what works for you.
You can play around with `--line-percent` to see what works for you.

View File

@@ -210,8 +210,13 @@ class FontProcessor:
full_name = f"{family_name}"
if style_name != "Regular":
full_name += f" {style_name}"
ps_name = f"{self.prefix}_{family_name.replace(' ', '-')}"
# If prefix is empty, don't add it to the PS name
if self.prefix:
ps_name = f"{self.prefix}_{family_name.replace(' ', '-')}"
else:
ps_name = family_name.replace(' ', '-')
if style_name != "Regular":
ps_name += f"-{style_name.replace(' ', '')}"
@@ -232,92 +237,107 @@ class FontProcessor:
def _pair_value_to_kern(value1, value2) -> int:
"""
Compute a legacy kerning value from GPOS PairValue records.
This logic is specific to converting GPOS (OpenType) kerning to
the older 'kern' (TrueType) table format.
Note: Only XAdvance values are used, as they directly map to kern table semantics
(adjusting inter-character spacing). XPlacement values shift glyphs without
affecting spacing and cannot be represented in the legacy kern table. To avoid
potential issues, XPlacement values are now being ignored.
"""
kern_value = 0
if value1 is not None:
kern_value += getattr(value1, "XAdvance", 0) or 0
if value2 is not None:
kern_value += getattr(value2, "XAdvance", 0) or 0
if kern_value == 0:
if value1 is not None:
kern_value += getattr(value1, "XPlacement", 0) or 0
if value2 is not None:
kern_value += getattr(value2, "XPlacement", 0) or 0
return int(kern_value)
def _extract_format1_pairs(self, subtable) -> Dict[Tuple[str, str], int]:
"""Extract kerning pairs from PairPos Format 1 (per-glyph PairSets)."""
pairs = defaultdict(int)
pairs = {}
coverage = getattr(subtable, "Coverage", None)
pair_sets = getattr(subtable, "PairSet", [])
if not coverage or not hasattr(coverage, "glyphs"):
return pairs
for idx, left_glyph in enumerate(coverage.glyphs):
if idx >= len(pair_sets):
break
for record in getattr(pair_sets[idx], "PairValueRecord", []):
right_glyph = record.SecondGlyph
kern_value = self._pair_value_to_kern(record.Value1, record.Value2)
if kern_value:
pairs[(left_glyph, right_glyph)] += kern_value
# Only set if not already present (first value wins)
key = (left_glyph, right_glyph)
if key not in pairs:
pairs[key] = kern_value
return pairs
def _extract_format2_pairs(self, subtable) -> Dict[Tuple[str, str], int]:
"""Extract kerning pairs from PairPos Format 2 (class-based)."""
pairs = defaultdict(int)
pairs = {}
coverage = getattr(subtable, "Coverage", None)
class_def1 = getattr(subtable, "ClassDef1", None)
class_def2 = getattr(subtable, "ClassDef2", None)
class1_records = getattr(subtable, "Class1Record", [])
if not coverage or not hasattr(coverage, "glyphs"):
return pairs
class1_map = getattr(class_def1, "classDefs", {}) if class_def1 else {}
left_by_class = defaultdict(list)
for glyph in coverage.glyphs:
class_idx = class1_map.get(glyph, 0)
left_by_class[class_idx].append(glyph)
class2_map = getattr(class_def2, "classDefs", {}) if class_def2 else {}
right_by_class = defaultdict(list)
for glyph, class_idx in class2_map.items():
right_by_class[class_idx].append(glyph)
for class1_idx, class1_record in enumerate(class1_records):
left_glyphs = left_by_class.get(class1_idx, [])
if not left_glyphs:
continue
for class2_idx, class2_record in enumerate(class1_record.Class2Record):
right_glyphs = right_by_class.get(class2_idx, [])
if not right_glyphs:
continue
kern_value = self._pair_value_to_kern(class2_record.Value1, class2_record.Value2)
if not kern_value:
continue
for left in left_glyphs:
for right in right_glyphs:
pairs[(left, right)] += kern_value
# Only set if not already present (first value wins)
key = (left, right)
if key not in pairs:
pairs[key] = kern_value
return pairs
def extract_kern_pairs(self, font: TTFont) -> Dict[Tuple[str, str], int]:
"""
Extract all kerning pairs from GPOS PairPos lookups.
Extract kerning pairs from the font.
Prioritizes existing 'kern' table over GPOS data if present.
GPOS (Glyph Positioning) is the modern standard for kerning in OpenType fonts.
This function iterates through the GPOS tables to find all kerning pairs
before we convert them to the legacy 'kern' table format.
"""
pairs = defaultdict(int)
pairs = {}
# If a kern table already exists, use it instead of GPOS
if "kern" in font:
kern_table = font["kern"]
for subtable in getattr(kern_table, "kernTables", []):
if hasattr(subtable, "kernTable"):
pairs.update(subtable.kernTable)
return pairs
# Otherwise, extract from GPOS
if "GPOS" in font:
gpos = font["GPOS"].table
lookup_list = getattr(gpos, "LookupList", None)
@@ -330,12 +350,16 @@ class FontProcessor:
if fmt == 1:
format1_pairs = self._extract_format1_pairs(subtable)
for key, value in format1_pairs.items():
pairs[key] += value
# Only add if not already present (first value wins)
if key not in pairs:
pairs[key] = value
elif fmt == 2:
format2_pairs = self._extract_format2_pairs(subtable)
for key, value in format2_pairs.items():
pairs[key] += value
return dict(pairs)
# Only add if not already present (first value wins)
if key not in pairs:
pairs[key] = value
return pairs
@staticmethod
def add_legacy_kern(font: TTFont, kern_pairs: Dict[Tuple[str, str], int]) -> int:
@@ -380,34 +404,44 @@ class FontProcessor:
if "name" not in font:
logger.warning(" No 'name' table found; skipping all name changes")
return
else:
if self.prefix:
logger.info(" Renaming the font to: " + f"{self.prefix} {metadata.full_name}")
adjusted_family_name = f"{self.prefix} {metadata.family_name}"
adjusted_full_name = f"{self.prefix} {metadata.full_name}"
else:
logger.info(" Updating font metadata (no prefix)")
adjusted_family_name = metadata.family_name
adjusted_full_name = metadata.full_name
# Update Family Name
self._set_name_records(font, 1, f"{self.prefix} {metadata.family_name}")
self._set_name_records(font, 1, adjusted_family_name)
# Update Subfamily
self._set_name_records(font, 2, metadata.style_name)
# Update Full Name
self._set_name_records(font, 4, f"{self.prefix} {metadata.full_name}")
self._set_name_records(font, 4, adjusted_full_name)
# Update Typographic Family
self._set_name_records(font, 16, f"{self.prefix} {metadata.family_name}")
self._set_name_records(font, 16, adjusted_family_name)
# Update Preferred Subfamily
self._set_name_records(font, 17, metadata.style_name)
# Update Preferred Family
self._set_name_records(font, 18, f"{self.prefix} {metadata.family_name}")
self._set_name_records(font, 18, adjusted_family_name)
# Update Unique ID (ID 3)
try:
current_unique = font["name"].getName(3, 3, 1).toUnicode()
parts = current_unique.split("Version")
version_info = f"Version{parts[1]}" if len(parts) == 2 else "Version 1.000"
new_unique_id = f"{self.prefix} {metadata.family_name.strip()}:{version_info}"
if self.prefix:
new_unique_id = f"{self.prefix} {metadata.family_name.strip()}:{version_info}"
else:
new_unique_id = f"{metadata.family_name.strip()}:{version_info}"
if current_unique != new_unique_id:
self._set_name_records(font, 3, new_unique_id)
except Exception as e:
logger.warning(f" Failed to update Unique ID: {e}")
# Update PostScript Name (ID 6)
new_ps_name = metadata.ps_name
self._set_name_records(font, 6, new_ps_name)
@@ -416,10 +450,17 @@ class FontProcessor:
if "CFF " in font:
cff = font["CFF "].cff
cff_topdict = cff.topDictIndex[0]
if self.prefix:
cff_full_name = f"{self.prefix} {metadata.full_name}"
cff_family_name = f"{self.prefix} {metadata.family_name.replace(' ', '_')}"
else:
cff_full_name = metadata.full_name
cff_family_name = metadata.family_name.replace(' ', '_')
name_mapping = {
"FullName": f"{self.prefix} {metadata.full_name}",
"FamilyName": f"{self.prefix} {metadata.family_name.replace(' ', '_')}"
"FullName": cff_full_name,
"FamilyName": cff_family_name
}
for key, new_value in name_mapping.items():
@@ -560,12 +601,13 @@ class FontProcessor:
# Main processing method
# ============================================================
def process_font(self,
kern: bool,
remove_gpos: bool,
font_path: str,
def process_font(self,
kern: bool,
remove_gpos: bool,
font_path: str,
new_name: Optional[str] = None,
remove_prefix: Optional[str] = None,
remove_hints: bool = False,
) -> bool:
"""
Process a single font file.
@@ -609,12 +651,23 @@ class FontProcessor:
self.update_weight_metadata(font, font_path)
if kern:
had_kern = "kern" in font
had_gpos = "GPOS" in font
kern_pairs = self.extract_kern_pairs(font)
if kern_pairs:
written = self.add_legacy_kern(font, kern_pairs)
logger.info(f" Kerning: extracted {len(kern_pairs)} pairs; wrote {written} to legacy 'kern' table.")
if had_kern:
logger.info(f" Kerning: 'kern' table already existed, preserved {written} pairs.")
else:
logger.info(f" Kerning: created 'kern' table from GPOS data with {written} pairs.")
else:
logger.info(" Kerning: no GPOS kerning found.")
if had_kern:
logger.info(" Kerning: 'kern' table existed but was empty, no pairs written.")
elif had_gpos:
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.")
@@ -624,6 +677,34 @@ class FontProcessor:
del font["GPOS"]
logger.info(" Removed GPOS table from the font.")
# Remove TrueType hints if requested
if remove_hints:
hints_removed = False
# Remove fpgm (Font Program) table
if "fpgm" in font:
del font["fpgm"]
hints_removed = True
# Remove prep (Control Value Program) table
if "prep" in font:
del font["prep"]
hints_removed = True
# Remove cvt (Control Value Table)
if "cvt " in font:
del font["cvt "]
hints_removed = True
# Remove hints from glyf table using the built-in removeHinting method
if "glyf" in font:
for glyph_name in font.getGlyphOrder():
glyph = font["glyf"][glyph_name]
if hasattr(glyph, 'removeHinting'):
glyph.removeHinting()
hints_removed = True
if hints_removed:
logger.info(" Removed TrueType hints from the font.")
else:
logger.info(" No TrueType hints found to remove.")
output_path = self._generate_output_path(font_path, metadata)
font.save(output_path)
logger.info(f" Saved: {output_path}")
@@ -645,17 +726,20 @@ class FontProcessor:
"""
dirname = os.path.dirname(original_path)
original_name, ext = os.path.splitext(os.path.basename(original_path))
style_suffix = ""
for key in STYLE_MAP:
if key.lower() in original_name.lower():
style_suffix = key
break
style_part = f"-{style_suffix}" if style_suffix else ""
base_name = f"{self.prefix}_{metadata.family_name.replace(' ', '_')}{style_part}"
if self.prefix:
base_name = f"{self.prefix}_{metadata.family_name.replace(' ', '_')}{style_part}"
else:
base_name = f"{metadata.family_name.replace(' ', '_')}{style_part}"
return os.path.join(dirname, f"{base_name}{ext.lower()}")
@@ -713,8 +797,8 @@ Examples:
help="Font files to process (*.ttf). You can use a wildcard (glob).")
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. Required. (Default: {DEFAULT_PREFIX})")
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",
@@ -725,8 +809,10 @@ Examples:
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("--remove-hints", action="store_true",
help="Remove TrueType hints from the font. This may improve render quality on some devices and reduce file size.")
args = parser.parse_args()
if args.verbose:
@@ -761,9 +847,10 @@ Examples:
if processor.process_font(
not args.skip_kobo_kern,
args.remove_gpos,
font_path,
font_path,
args.name,
args.remove_prefix,
args.remove_hints,
):
success_count += 1