mirror of
https://github.com/nicoverbruggen/kobo-font-fix.git
synced 2026-03-25 01:20:07 +01:00
1034 lines
42 KiB
Python
Executable File
1034 lines
42 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Font processing utility for Kobo e-readers.
|
|
|
|
Processes TrueType fonts to improve compatibility with Kobo e-readers:
|
|
- Renaming fonts with a configurable prefix and updating internal metadata
|
|
(name table, CFF, post table, PS name)
|
|
- Validating and correcting PANOSE metadata based on font style
|
|
- Updating font weight metadata (OS/2 usWeightClass)
|
|
- Adjusting line spacing via font-line
|
|
- Kerning: extracting GPOS pairs (Format 1, Format 2, and Extension lookups)
|
|
into a legacy kern table, prioritized by Unicode range to fit within
|
|
format 0 size constraints
|
|
- Hinting: optionally stripping hints or applying ttfautohint
|
|
|
|
Includes NV and KF presets for common workflows, or can be fully
|
|
configured via individual flags. Run with -h for usage details.
|
|
|
|
Requirements:
|
|
- fontTools (pip install fonttools)
|
|
- font-line (pip install font-line)
|
|
- ttfautohint (optional, for --hint additive/overwrite)
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import subprocess
|
|
import argparse
|
|
import logging
|
|
from pathlib import Path
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from typing import Dict, Tuple, Optional, List
|
|
|
|
from fontTools.ttLib import TTFont, newTable
|
|
from fontTools.ttLib.tables._k_e_r_n import KernTable_format_0
|
|
|
|
# -------------
|
|
# PRESETS
|
|
# -------------
|
|
#
|
|
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
|
|
# -------------
|
|
# Style mapping for filenames and internal font data.
|
|
# If a particular style string is found in the font file name, it can be mapped.
|
|
# The values are a tuple of (human-readable_style_name, usWeightClass).
|
|
#
|
|
STYLE_MAP = {
|
|
"BoldItalic": ("Bold Italic", 700),
|
|
"Bold": ("Bold", 700),
|
|
"Italic": ("Italic", 400),
|
|
"Regular": ("Regular", 400),
|
|
}
|
|
|
|
|
|
# Configure logging for clear output
|
|
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class FontMetadata:
|
|
"""
|
|
A simple data class to hold consistent font naming and metadata.
|
|
"""
|
|
family_name: str
|
|
style_name: str
|
|
full_name: str
|
|
ps_name: str
|
|
|
|
|
|
class FontProcessor:
|
|
"""
|
|
Main font processing class.
|
|
"""
|
|
|
|
def __init__(self,
|
|
prefix: str,
|
|
line_percent: int,
|
|
):
|
|
"""
|
|
Initialize the font processor with configurable values.
|
|
If the user has not supplied custom arguments, the default values are used.
|
|
|
|
Args:
|
|
prefix: Prefix to add to font names
|
|
line_percent: Percentage for baseline adjustment
|
|
"""
|
|
self.prefix = prefix
|
|
self.line_percent = line_percent
|
|
|
|
# ============================================================
|
|
# Helper methods
|
|
# ============================================================
|
|
|
|
@staticmethod
|
|
def _get_style_from_filename(filename: str) -> Tuple[str, int]:
|
|
"""
|
|
Determine font style and weight from filename.
|
|
This function centralizes a critical piece of logic that is used in
|
|
multiple places to ensure consistency across the script.
|
|
|
|
Args:
|
|
filename: The font file name.
|
|
|
|
Returns:
|
|
A tuple of (style_name, usWeightClass).
|
|
"""
|
|
base_filename = os.path.basename(filename)
|
|
for key, (style_name, weight) in STYLE_MAP.items():
|
|
if key.lower() in base_filename.lower():
|
|
return style_name, weight
|
|
return "Regular", 400 # Default if no style found
|
|
|
|
@staticmethod
|
|
def _set_name_records(font: TTFont, name_id: int, new_name: str) -> None:
|
|
"""
|
|
Update a font's name table record for all relevant platforms,
|
|
encodings, and languages.
|
|
|
|
This method has been updated to iterate through all existing records
|
|
for the given name_id and update them, ensuring consistent naming
|
|
across different platforms like Windows and Macintosh.
|
|
"""
|
|
name_table = font["name"]
|
|
|
|
# Find all existing records for the given nameID
|
|
names_to_update = [
|
|
n for n in name_table.names if n.nameID == name_id
|
|
]
|
|
|
|
# If no records exist, add new ones for standard platforms.
|
|
if not names_to_update:
|
|
logger.debug(f" Name ID {name_id} not found; adding new records.")
|
|
try:
|
|
name_table.setName(new_name, name_id, 3, 1, 0x0409) # Windows record
|
|
name_table.setName(new_name, name_id, 1, 0, 0) # Macintosh record
|
|
logger.debug(f" Name ID {name_id} added as '{new_name}'.")
|
|
except Exception as e:
|
|
logger.warning(f" Failed to add new name ID {name_id}: {e}")
|
|
return
|
|
|
|
updated_count = 0
|
|
for name_record in names_to_update:
|
|
try:
|
|
# Determine the appropriate encoding for the platform
|
|
if name_record.platformID == 1: # Macintosh
|
|
encoded_name = new_name.encode('mac-roman', 'ignore')
|
|
if name_record.string != encoded_name:
|
|
name_record.string = encoded_name
|
|
updated_count += 1
|
|
elif name_record.platformID == 3: # Windows
|
|
encoded_name = new_name.encode('utf-16-be', 'ignore')
|
|
if name_record.string != encoded_name:
|
|
name_record.string = encoded_name
|
|
updated_count += 1
|
|
else:
|
|
# Fallback for other platforms to ensure consistency
|
|
encoded_name = new_name.encode('utf-8', 'ignore')
|
|
if name_record.string != encoded_name:
|
|
name_record.string = encoded_name
|
|
updated_count += 1
|
|
|
|
except Exception as e:
|
|
logger.warning(f" Failed to update record for Name ID {name_id} "
|
|
f"(Platform {name_record.platformID}, "
|
|
f"Encoding {name_record.platEncID}): {e}")
|
|
|
|
if updated_count > 0:
|
|
logger.debug(f" Name ID {name_id} updated for {updated_count} record(s).")
|
|
else:
|
|
logger.debug(f" Name ID {name_id} is already correct across all records.")
|
|
|
|
# ============================================================
|
|
# Metadata extraction
|
|
# ============================================================
|
|
|
|
def _get_font_metadata(
|
|
self,
|
|
font: TTFont,
|
|
font_path: str,
|
|
new_family_name: Optional[str]
|
|
) -> Optional[FontMetadata]:
|
|
"""
|
|
Extract or infer font metadata from the font and arguments.
|
|
This function acts as a single point of truth for font metadata,
|
|
ensuring consistency throughout the processing pipeline.
|
|
"""
|
|
if "name" in font:
|
|
# Determine family name from user input or best available name from font.
|
|
family_name = new_family_name if new_family_name else font["name"].getBestFamilyName()
|
|
else:
|
|
family_name = new_family_name
|
|
|
|
if not family_name:
|
|
logger.warning(" Could not determine font family name.")
|
|
return None
|
|
|
|
# Centralized logic: Determine style name from filename.
|
|
style_name, _ = self._get_style_from_filename(font_path)
|
|
|
|
# Construct the full name and PS name based on style name logic
|
|
full_name = f"{family_name}"
|
|
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(' ', '')}"
|
|
|
|
logger.debug(f" Constructed metadata: family='{family_name}', style='{style_name}', full='{full_name}', ps='{ps_name}'")
|
|
|
|
return FontMetadata(
|
|
family_name=family_name,
|
|
style_name=style_name,
|
|
full_name=full_name,
|
|
ps_name=ps_name
|
|
)
|
|
|
|
# ============================================================
|
|
# Kerning extraction methods
|
|
# ============================================================
|
|
|
|
@staticmethod
|
|
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
|
|
|
|
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 = {}
|
|
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:
|
|
# 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 = {}
|
|
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:
|
|
# 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 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.
|
|
"""
|
|
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)
|
|
if lookup_list and lookup_list.Lookup:
|
|
for lookup in lookup_list.Lookup:
|
|
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:
|
|
extracted = self._extract_format1_pairs(subtable)
|
|
elif fmt == 2:
|
|
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.
|
|
|
|
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 = []
|
|
|
|
subtable = KernTable_format_0()
|
|
subtable.version = 0
|
|
subtable.length = None
|
|
subtable.coverage = 1
|
|
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)
|
|
|
|
# ============================================================
|
|
# Name table methods
|
|
# ============================================================
|
|
|
|
def rename_font(self, font: TTFont, metadata: FontMetadata) -> None:
|
|
"""
|
|
Update the font's name-related metadata.
|
|
This method uses the centralized `_set_name_records` helper to update
|
|
all relevant name fields.
|
|
"""
|
|
if "name" not in font:
|
|
logger.warning(" No 'name' table found; skipping all name changes")
|
|
return
|
|
|
|
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, adjusted_family_name)
|
|
# Update Subfamily
|
|
self._set_name_records(font, 2, metadata.style_name)
|
|
# Update Full Name
|
|
self._set_name_records(font, 4, adjusted_full_name)
|
|
|
|
# Update Typographic Family
|
|
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, 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:
|
|
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)
|
|
|
|
# Update PostScript data in CFF (if applicable)
|
|
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": cff_full_name,
|
|
"FamilyName": cff_family_name
|
|
}
|
|
|
|
for key, new_value in name_mapping.items():
|
|
if key in cff_topdict.rawDict:
|
|
current_value = cff_topdict.rawDict[key]
|
|
if current_value != new_value:
|
|
cff_topdict.rawDict[key] = new_value
|
|
logger.debug(f" CFF table '{key}' updated to '{new_value}'.")
|
|
else:
|
|
logger.debug(f" CFF table '{key}' is already correct.")
|
|
|
|
logger.warning(" CFF table found. The original font name may persist as part of an indexed `Name INDEX`. (This cannot be easily fixed with this script. If you are encountering issues, I recommend using FontForge.)")
|
|
else:
|
|
logger.debug(" No 'CFF' table in this font.")
|
|
|
|
# Update PostScript data if relevant
|
|
if "post" in font:
|
|
if hasattr(font["post"], "fontName"):
|
|
new_ps_name = metadata.ps_name
|
|
if font["post"].fontName != new_ps_name:
|
|
font["post"].fontName = new_ps_name
|
|
logger.debug(f" 'post' table updated with new fontName '{new_ps_name}'.")
|
|
else:
|
|
logger.debug(" 'post' table fontName is already correct.")
|
|
else:
|
|
logger.debug(" No 'post' table in this font.")
|
|
|
|
# ============================================================
|
|
# Weight metadata methods
|
|
# ============================================================
|
|
|
|
def update_weight_metadata(self, font: TTFont, filename: str) -> None:
|
|
"""
|
|
Update font weight metadata based on filename suffix.
|
|
This function uses the centralized style lookup, which simplifies
|
|
the logic significantly.
|
|
"""
|
|
style_name, os2_weight = self._get_style_from_filename(filename)
|
|
ps_weight = style_name.replace(" ", "")
|
|
|
|
if "OS/2" in font and hasattr(font["OS/2"], "usWeightClass"):
|
|
if font["OS/2"].usWeightClass != os2_weight:
|
|
font["OS/2"].usWeightClass = os2_weight
|
|
logger.debug(f" OS/2 usWeightClass updated to {os2_weight}.")
|
|
else:
|
|
logger.debug(" OS/2 usWeightClass is already correct.")
|
|
|
|
if "CFF " in font and hasattr(font["CFF "].cff.topDictIndex[0], "Weight"):
|
|
if getattr(font["CFF "].cff.topDictIndex[0], "Weight", "") != ps_weight:
|
|
font["CFF "].cff.topDictIndex[0].Weight = ps_weight
|
|
logger.debug(f" PostScript CFF weight updated to '{ps_weight}'.")
|
|
elif "post" in font and hasattr(font["post"], "Weight"):
|
|
if getattr(font["post"], "Weight", "") != ps_weight:
|
|
font["post"].Weight = ps_weight
|
|
logger.debug(f" PostScript 'post' weight updated to '{ps_weight}'.")
|
|
|
|
# ============================================================
|
|
# PANOSE methods
|
|
# ============================================================
|
|
|
|
def check_and_fix_panose(self, font: TTFont, filename: str) -> None:
|
|
"""
|
|
Check and adjust PANOSE values based on filename suffix.
|
|
PANOSE is an older classification system for fonts. Correcting these
|
|
values ensures better compatibility with legacy systems and font menus.
|
|
"""
|
|
style_name, _ = self._get_style_from_filename(filename)
|
|
|
|
# PANOSE expected values for each style
|
|
style_specs = {
|
|
"Bold Italic": {"weight": 8, "letterform": 3},
|
|
"Bold": {"weight": 8, "letterform": 2},
|
|
"Italic": {"weight": 5, "letterform": 3},
|
|
"Regular": {"weight": 5, "letterform": 2},
|
|
}
|
|
|
|
if "OS/2" not in font or not hasattr(font["OS/2"], "panose") or font["OS/2"].panose is None:
|
|
logger.warning(" No OS/2 table or PANOSE information found; skipping check.")
|
|
return
|
|
|
|
panose = font["OS/2"].panose
|
|
expected = style_specs.get(style_name)
|
|
if not expected:
|
|
logger.warning(f" No PANOSE specification for style '{style_name}'; skipping.")
|
|
return
|
|
|
|
changes = []
|
|
if panose.bWeight != expected["weight"]:
|
|
old_weight = panose.bWeight
|
|
panose.bWeight = expected["weight"]
|
|
changes.append(f"bWeight {old_weight}->{expected['weight']}")
|
|
|
|
if panose.bLetterForm != expected["letterform"]:
|
|
old_letterform = panose.bLetterForm
|
|
panose.bLetterForm = expected["letterform"]
|
|
changes.append(f"bLetterForm {old_letterform}->{expected['letterform']}")
|
|
|
|
if changes:
|
|
logger.info(f" PANOSE corrected: {', '.join(changes)}")
|
|
else:
|
|
logger.info(" PANOSE check passed, no modifications required.")
|
|
|
|
# ============================================================
|
|
# Hinting methods
|
|
# ============================================================
|
|
|
|
@staticmethod
|
|
def _font_has_hints(font: TTFont) -> bool:
|
|
"""Check whether a font contains TrueType hinting data."""
|
|
if "fpgm" in font or "prep" in font or "cvt " in font:
|
|
return True
|
|
if "glyf" in font:
|
|
for glyph_name in font.getGlyphOrder():
|
|
glyph = font["glyf"][glyph_name]
|
|
if hasattr(glyph, 'program') and glyph.program and glyph.program.getAssembly():
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def strip_hints(font: TTFont) -> None:
|
|
"""Remove all TrueType hints from the font."""
|
|
hints_removed = False
|
|
for table in ("fpgm", "prep", "cvt "):
|
|
if table in font:
|
|
del font[table]
|
|
hints_removed = True
|
|
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.")
|
|
|
|
def apply_ttfautohint(self, font_path: str) -> bool:
|
|
"""Run ttfautohint on a saved font file, replacing it in-place."""
|
|
try:
|
|
hinted_path = font_path + ".hinted"
|
|
subprocess.run(
|
|
["ttfautohint", font_path, hinted_path],
|
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE
|
|
)
|
|
os.replace(hinted_path, font_path)
|
|
logger.info(" Applied ttfautohint.")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
logger.warning(f" ttfautohint failed: {e}")
|
|
# Clean up temp file if it exists
|
|
hinted_path = font_path + ".hinted"
|
|
if os.path.exists(hinted_path):
|
|
os.remove(hinted_path)
|
|
return False
|
|
|
|
# ============================================================
|
|
# Line adjustment methods
|
|
# ============================================================
|
|
|
|
def apply_line_adjustment(self, font_path: str) -> bool:
|
|
"""
|
|
Apply font-line baseline adjustment to the font.
|
|
This external tool fixes an issue with line spacing on some e-readers.
|
|
The function handles the necessary file operations (renaming and cleanup)
|
|
after the external utility has run.
|
|
"""
|
|
try:
|
|
subprocess.run(["font-line", "percent", str(self.line_percent), font_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
|
|
base, ext = os.path.splitext(font_path)
|
|
linegap_file = f"{base}-linegap{self.line_percent}{ext}"
|
|
|
|
if os.path.exists(linegap_file):
|
|
os.remove(font_path)
|
|
os.rename(linegap_file, font_path)
|
|
logger.info(f" Line spacing adjusted ({self.line_percent}% baseline shift).")
|
|
return True
|
|
else:
|
|
logger.warning(f" Expected font-line output '{linegap_file}' not found.")
|
|
return False
|
|
except subprocess.CalledProcessError as e:
|
|
logger.warning(f" font-line failed: {e}")
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(f" Unexpected error during line adjustment: {e}")
|
|
return False
|
|
|
|
# ============================================================
|
|
# Main processing method
|
|
# ============================================================
|
|
|
|
def process_font(self,
|
|
kern_mode: str,
|
|
font_path: str,
|
|
new_name: Optional[str] = None,
|
|
remove_prefix: Optional[str] = None,
|
|
hint_mode: str = "skip",
|
|
) -> bool:
|
|
"""
|
|
Process a single font file.
|
|
This function orchestrates the entire process, calling the various
|
|
helper methods in the correct order.
|
|
"""
|
|
logger.info(f"\nProcessing: {font_path}")
|
|
|
|
try:
|
|
font = TTFont(font_path)
|
|
except Exception as e:
|
|
logger.error(f" Failed to open font: {e}")
|
|
return False
|
|
|
|
# Remove WWS family names (IDs 21 and 22) to prevent confusion when determining best family name
|
|
if font["name"]:
|
|
old_names_list = font["name"].names
|
|
names_to_remove = [21, 22]
|
|
new_names_list = [n for n in old_names_list if n.nameID not in names_to_remove]
|
|
if len(new_names_list) < len(old_names_list):
|
|
font["name"].names = new_names_list
|
|
logger.info(" Removed WWS Family Name (ID 21) and WWS Subfamily Name (ID 22).")
|
|
|
|
# Determine the effective font name, checking for `--remove-prefix` first
|
|
effective_name = new_name
|
|
if new_name is None:
|
|
# If no --name argument is provided, get the font's best family name
|
|
current_family_name = font["name"].getBestFamilyName()
|
|
# If --remove-prefix is used and the name starts with the specified prefix, remove it
|
|
if remove_prefix and current_family_name.startswith(remove_prefix + " "):
|
|
effective_name = current_family_name[len(remove_prefix + " "):]
|
|
logger.info(f" --remove-prefix enabled: using '{effective_name}' as the new family name.")
|
|
|
|
metadata = self._get_font_metadata(font, font_path, effective_name)
|
|
if not metadata:
|
|
return False
|
|
|
|
try:
|
|
self.rename_font(font, metadata)
|
|
self.check_and_fix_panose(font, font_path)
|
|
self.update_weight_metadata(font, font_path)
|
|
|
|
# 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
|
|
|
|
kern_pairs = self.extract_kern_pairs(font)
|
|
if kern_pairs:
|
|
written = self.add_legacy_kern(font, kern_pairs)
|
|
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:
|
|
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.")
|
|
|
|
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)
|
|
|
|
output_path = self._generate_output_path(font_path, metadata)
|
|
font.save(output_path)
|
|
logger.info(f" Saved: {output_path}")
|
|
|
|
if hint_mode == "overwrite":
|
|
self.apply_ttfautohint(output_path)
|
|
elif hint_mode == "additive" and not self._font_has_hints(font):
|
|
self.apply_ttfautohint(output_path)
|
|
|
|
if self.line_percent != 0:
|
|
self.apply_line_adjustment(output_path)
|
|
else:
|
|
logger.info(" Skipping line adjustment step.")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f" Processing failed: {e}")
|
|
return False
|
|
|
|
def _generate_output_path(self, original_path: str, metadata: FontMetadata) -> str:
|
|
"""
|
|
Generate the output path for the processed font.
|
|
This function now uses the centralized `STYLE_MAP` to ensure filename
|
|
suffixes are consistent with the styles found in the font's internal metadata.
|
|
"""
|
|
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 ""
|
|
|
|
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()}")
|
|
|
|
|
|
def check_dependencies(hint_mode: str, line_percent: int) -> None:
|
|
"""Check that all required external tools are available before processing."""
|
|
missing = []
|
|
if hint_mode in ("additive", "overwrite"):
|
|
if subprocess.run(["which", "ttfautohint"], capture_output=True).returncode != 0:
|
|
missing.append("ttfautohint")
|
|
if line_percent != 0:
|
|
if subprocess.run(["which", "font-line"], capture_output=True).returncode != 0:
|
|
missing.append("font-line")
|
|
if missing:
|
|
logger.error(f"Missing required dependencies: {', '.join(missing)}")
|
|
logger.error("Please install them before running this script.")
|
|
sys.exit(1)
|
|
|
|
|
|
def validate_font_files(font_paths: List[str]) -> Tuple[List[str], List[str]]:
|
|
"""Validate font files for processing."""
|
|
valid_files = []
|
|
invalid_files = []
|
|
|
|
for path in font_paths:
|
|
if not os.path.isfile(path):
|
|
logger.warning(f"File not found: {path}")
|
|
continue
|
|
if not path.lower().endswith(".ttf"):
|
|
logger.error(f"Unsupported file type: {path} (only .ttf files are supported)")
|
|
invalid_files.append(os.path.basename(path))
|
|
continue
|
|
|
|
has_valid_suffix = any(
|
|
key.lower() in os.path.basename(path).lower() for key in STYLE_MAP
|
|
)
|
|
|
|
if has_valid_suffix:
|
|
valid_files.append(path)
|
|
else:
|
|
invalid_files.append(os.path.basename(path))
|
|
|
|
return valid_files, invalid_files
|
|
|
|
|
|
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=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:
|
|
Using a preset:
|
|
%(prog)s --preset nv *.ttf
|
|
%(prog)s --preset kf *.ttf
|
|
|
|
Custom processing:
|
|
%(prog)s --prefix KF --name="Fonty" --line-percent 20 --kern add-legacy-kern *.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,
|
|
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,
|
|
choices=["skip", "additive", "overwrite", "strip"],
|
|
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.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.")
|
|
|
|
check_dependencies(args.hint, args.line_percent)
|
|
|
|
valid_files, invalid_files = validate_font_files(args.fonts)
|
|
|
|
if invalid_files:
|
|
logger.error("\nERROR: The following fonts have invalid filenames:")
|
|
logger.error(f"(Must contain one of the following: {', '.join(STYLE_MAP.keys())})")
|
|
for filename in invalid_files:
|
|
logger.error(f" {filename}")
|
|
|
|
if not valid_files:
|
|
sys.exit(1)
|
|
|
|
response = input("\nContinue with valid files only? [y/N]: ")
|
|
if response.lower() != 'y':
|
|
sys.exit(1)
|
|
|
|
if not valid_files:
|
|
logger.error("No valid font files to process.")
|
|
sys.exit(1)
|
|
|
|
processor = FontProcessor(
|
|
prefix=args.prefix,
|
|
line_percent=args.line_percent,
|
|
)
|
|
|
|
success_count = 0
|
|
for font_path in valid_files:
|
|
if processor.process_font(
|
|
args.kern,
|
|
font_path,
|
|
args.name,
|
|
args.remove_prefix,
|
|
args.hint,
|
|
):
|
|
success_count += 1
|
|
|
|
logger.info(f"\n{'='*50}")
|
|
logger.info(f"Processed {success_count}/{len(valid_files)} fonts successfully.")
|
|
|
|
if success_count < len(valid_files):
|
|
sys.exit(1)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|