Compare commits

...

3 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
2 changed files with 144 additions and 57 deletions

View File

@@ -87,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.

View File

@@ -211,7 +211,12 @@ class FontProcessor:
if style_name != "Regular":
full_name += f" {style_name}"
# 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,8 +237,14 @@ 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:
@@ -241,17 +252,11 @@ class FontProcessor:
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", [])
@@ -266,12 +271,15 @@ class FontProcessor:
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)
@@ -307,17 +315,29 @@ class FontProcessor:
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,29 +404,39 @@ 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"
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:
@@ -417,9 +451,16 @@ class FontProcessor:
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():
@@ -566,6 +607,7 @@ class FontProcessor:
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(" Kerning: no GPOS kerning found.")
logger.info(f" Kerning: created 'kern' table from GPOS data with {written} pairs.")
else:
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}")
@@ -654,7 +735,10 @@ class FontProcessor:
style_part = f"-{style_suffix}" if style_suffix else ""
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()}")
@@ -714,7 +798,7 @@ Examples:
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})")
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,6 +809,8 @@ 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()
@@ -764,6 +850,7 @@ Examples:
font_path,
args.name,
args.remove_prefix,
args.remove_hints,
):
success_count += 1