commit 57f7c0de2230e618f03124bcc734e688513530da Author: Nico Verbruggen Date: Thu Aug 21 12:39:12 2025 +0200 Initial commit diff --git a/kobofix.py b/kobofix.py new file mode 100755 index 0000000..49137cb --- /dev/null +++ b/kobofix.py @@ -0,0 +1,284 @@ +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()