import sys import os from collections import defaultdict from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables._k_e_r_n import KernTable_format_0 # ------------------------------------------------------------ # Kerning extraction # ------------------------------------------------------------ def _pair_value_to_kern(v1, v2): """Compute a legacy kerning value from a GPOS PairValue (Value1/Value2). Prefer XAdvance adjustments; if none, fall back to XPlacement. Returns an int (may be negative). """ val = 0 if v1 is not None: val += getattr(v1, "XAdvance", 0) or 0 if v2 is not None: val += getattr(v2, "XAdvance", 0) or 0 if val == 0: # Some fonts encode kerning via placements only if v1 is not None: val += getattr(v1, "XPlacement", 0) or 0 if v2 is not None: val += getattr(v2, "XPlacement", 0) or 0 return int(val or 0) def extract_kern_pairs(font): """Extract kerning pairs from GPOS PairPos lookups (Format 1 & 2). Returns: dict[(leftGlyphName, rightGlyphName)] -> int kerning value Safe against missing GPOS or unexpected structures. """ pairs = defaultdict(int) if "GPOS" not in font: return {} gpos = font["GPOS"].table lookup_list = getattr(gpos, "LookupList", None) if not lookup_list or not lookup_list.Lookup: return {} for lookup in lookup_list.Lookup: if getattr(lookup, "LookupType", None) != 2: # Pair Adjustment continue for subtable in getattr(lookup, "SubTable", []): fmt = getattr(subtable, "Format", None) # -------- PairPos Format 1: per-glyph PairSets -------- if fmt == 1: coverage = getattr(subtable, "Coverage", None) pair_sets = getattr(subtable, "PairSet", []) if not coverage or not hasattr(coverage, "glyphs"): continue cov_glyphs = coverage.glyphs for i, left in enumerate(cov_glyphs): if i >= len(pair_sets): break for rec in getattr(pair_sets[i], "PairValueRecord", []): right = rec.SecondGlyph k = _pair_value_to_kern(rec.Value1, rec.Value2) if k: pairs[(left, right)] += k # -------- PairPos Format 2: class-based -------- elif fmt == 2: 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"): continue cov_glyphs = coverage.glyphs # Build glyph lists per class for the left side, limited to covered glyphs class1_map = getattr(class_def1, "classDefs", {}) if class_def1 else {} left_by_class = defaultdict(list) for g in cov_glyphs: c = class1_map.get(g, 0) left_by_class[c].append(g) # Build glyph lists per class for the right side from explicit definitions only class2_map = getattr(class_def2, "classDefs", {}) if class_def2 else {} right_by_class = defaultdict(list) for g, c in class2_map.items(): right_by_class[c].append(g) for c1, c1rec in enumerate(class1_records): lefts = left_by_class.get(c1, []) if not lefts: continue for c2, c2rec in enumerate(c1rec.Class2Record): rights = right_by_class.get(c2, []) if not rights: continue k = _pair_value_to_kern(c2rec.Value1, c2rec.Value2) if not k: continue for L in lefts: for R in rights: pairs[(L, R)] += k else: # Other formats not handled continue return dict(pairs) # ------------------------------------------------------------ # Legacy 'kern' table builder # ------------------------------------------------------------ def add_legacy_kern(font, kern_pairs): """Create/replace a legacy 'kern' table with the supplied pairs. """ if not kern_pairs: # Remove existing legacy 'kern' if present? We'll leave as-is. return 0 kern_table = newTable("kern") kern_table.version = 0 kern_table.kernTables = [] subtable = KernTable_format_0() subtable.version = 0 subtable.length = None # recalculated by fontTools subtable.coverage = 1 # horizontal kerning, format 0 # Ensure ints and glyph-name tuple keys subtable.kernTable = {tuple(k): int(v) for k, v in kern_pairs.items() if v} kern_table.kernTables.append(subtable) font["kern"] = kern_table return len(subtable.kernTable) # ------------------------------------------------------------ # Name table updates # ------------------------------------------------------------ def rename_font(font): """Prefix the font's family/full names with 'KC '. Updates name IDs 1 (Family), 4 (Full), and 16 (Typographic Family) when present. """ if "name" not in font: return name_table = font["name"] ids_to_prefix = {1, 4, 16} for record in name_table.names: if record.nameID in ids_to_prefix: try: new_name = "KC " + record.toUnicode() record.string = new_name.encode(record.getEncoding()) except Exception: # Fallback encoding if getEncoding fails try: record.string = ("KC " + record.toUnicode()).encode("utf_16_be") except Exception: pass # ------------------------------------------------------------ # PANOSE check & fix # ------------------------------------------------------------ def check_and_fix_panose(font, filename): """Check and adjust PANOSE based on filename suffix. Expected suffixes: -Regular, -Bold, -Italic, -BoldItalic Adjusts bWeight for Bold/Regular and bLetterForm for Italic/Regular. Prints status and corrections performed. """ # Order matters: test BoldItalic before Bold/Italic expected_styles = ( ("-BoldItalic", {"weight": 8, "letterform": 3}), ("-Bold", {"weight": 8, "letterform": 2}), ("-Italic", {"weight": 5, "letterform": 3}), ("-Regular", {"weight": 5, "letterform": 2}), ) base = os.path.basename(filename) matched = False if "OS/2" not in font: print(" WARNING: No OS/2 table found; cannot check/correct PANOSE.") return panose = font["OS/2"].panose for suffix, expected in expected_styles: if base.endswith(suffix + ".ttf") or base.endswith(suffix + ".otf"): matched = True exp_w = expected["weight"] exp_lf = expected["letterform"] cur_w = getattr(panose, "bWeight", None) cur_lf = getattr(panose, "bLetterForm", None) changes = [] if cur_w != exp_w and exp_w is not None: panose.bWeight = exp_w changes.append(f"bWeight {cur_w}→{exp_w}") if cur_lf != exp_lf and exp_lf is not None: panose.bLetterForm = exp_lf changes.append(f"bLetterForm {cur_lf}→{exp_lf}") if changes: print(f" PANOSE corrected for {suffix}: " + ", ".join(changes)) else: print(f" PANOSE check passed for {suffix}.") break if not matched: print( " WARNING: Filename does not end with expected suffix " "(-Regular, -Bold, -Italic, -BoldItalic). PANOSE check skipped." ) # ------------------------------------------------------------ # Orchestration per font # ------------------------------------------------------------ def process_font(path): """Load, process, and save the font. Steps (each independent): 1) Prefix names with "KC ". 2) Check & fix PANOSE based on filename. 3) Extract kerning from GPOS and write a legacy 'kern' table. 4) Save as KC_. """ print(f"Processing: {path}") try: font = TTFont(path) except Exception as e: print(f" ERROR: Failed to open font: {e}") return # Always run name prefix & PANOSE checks, regardless of kerning outcome rename_font(font) check_and_fix_panose(font, os.path.basename(path)) # Extract kerning (robust against missing/odd structures) try: kern_pairs = extract_kern_pairs(font) pair_count = len(kern_pairs) if pair_count: written = add_legacy_kern(font, kern_pairs) print(f" Kerning: extracted {pair_count} pairs; wrote {written} pairs to legacy 'kern'.") else: print(" Kerning: no GPOS kerning found; skipping legacy 'kern' table.") except Exception as e: print(f" WARNING: Failed to extract/add kerning: {e}") # Save dirname, filename = os.path.split(path) out_path = os.path.join(dirname, f"KC_{filename}") try: font.save(out_path) print(f" Saved: {out_path}\n") except Exception as e: print(f" ERROR: Failed to save font: {e}\n") # ------------------------------------------------------------ # CLI # ------------------------------------------------------------ def main(): if len(sys.argv) < 2: print("Usage: python kobofix.py *.ttf *.otf") sys.exit(1) for path in sys.argv[1:]: if os.path.isfile(path) and path.lower().endswith((".ttf", ".otf")): process_font(path) else: print(f"Skipping non-TTF/OTF file: {path}") if __name__ == "__main__": main()