#!/usr/bin/env python3 """Add a compact ordinary CHARIC PairPos lookup. It accumulates with the serialized TFM kern lookup while preserving hmtx and MATH ItalicsCorrection. Exclude prime and combining-accent paths. """ from __future__ import annotations import argparse, json, unicodedata from fontTools.ttLib import TTFont from fontTools.ttLib.tables import otTables VALUE_XADVANCE = 0x0004 EXCLUDED_CODEPOINTS = { # Source fixed/wide accent glyphs exposed in PUA; these are accent commands, # not ordinary right-neighbour characters. 0xE24A, 0xE24B, 0xE24C, 0xE24E, 0xE24F, 0xE250, 0xE251, 0xE254, 0xE255, 0xE286, 0xE287, } ALLOWED_GSUB_FEATURES = {'ssty', 'salt', 'dtls'} | {f'cv{i:02d}' for i in range(1, 13)} def _is_excluded_cp(cp: int) -> bool: if cp in EXCLUDED_CODEPOINTS or cp == 0x20: return True if 0x2032 <= cp <= 0x2037: # prime family return True try: return unicodedata.category(chr(cp)).startswith('M') except ValueError: return False def _feature_lookup_indices(font, tags): if 'GSUB' not in font or not font['GSUB'].table.FeatureList: return set() out = set() for fr in font['GSUB'].table.FeatureList.FeatureRecord: if fr.FeatureTag in tags: out.update(fr.Feature.LookupListIndex) return out def _gsub_outputs(font, lookup_indices): out = set() if 'GSUB' not in font or not font['GSUB'].table.LookupList: return out lookups = font['GSUB'].table.LookupList.Lookup for idx in lookup_indices: if idx >= len(lookups): continue lk = lookups[idx] subtables = [] if lk.LookupType in (1, 3): subtables = lk.SubTable elif lk.LookupType == 7: # ExtensionSubst subtables = [st.ExtSubTable for st in lk.SubTable if getattr(st, 'ExtensionLookupType', None) in (1, 3)] for st in subtables: if hasattr(st, 'mapping'): out.update(st.mapping.values()) if hasattr(st, 'alternates'): for vals in st.alternates.values(): out.update(vals) return out def _right_domain(font, left_glyphs): cmap = font.getBestCmap() by_glyph = {} for cp, g in cmap.items(): by_glyph.setdefault(g, []).append(cp) rights = set() for g, cps in by_glyph.items(): # Keep a glyph if it has at least one ordinary/non-mark encoded identity. if any(not _is_excluded_cp(int(cp)) for cp in cps): rights.add(g) rights.update(_gsub_outputs(font, _feature_lookup_indices(font, ALLOWED_GSUB_FEATURES))) # ssty/cv outputs can be unencoded. Left glyphs themselves must also be # valid right glyphs for adjacency within the same optical/alternate run. rights.update(left_glyphs) # GDEF class 3 = mark. This catches unencoded ssty/cv accent outputs that # cannot be filtered by Unicode General_Category alone. TeX accent noads # and combining-mark shaping are not ordinary adjacency semantics. mark_glyphs = set() if 'GDEF' in font and font['GDEF'].table.GlyphClassDef: mark_glyphs = {g for g, c in font['GDEF'].table.GlyphClassDef.classDefs.items() if int(c) == 3} # Never allow prime contextual products or mark glyphs into the ordinary # right class. Prime/script/accent paths have their own TeX semantics. rights = {g for g in rights if g not in mark_glyphs and 'prime' not in g.lower() and not g.endswith('.icK')} return rights def _value_record(xadvance): v = otTables.ValueRecord() v.XAdvance = int(xadvance) return v def add_bridge(font, contract): if 'GPOS' not in font or not font['GPOS'].table.LookupList: raise SystemExit('font has no GPOS LookupList') glyph_order = set(font.getGlyphOrder()) left_ic = {} for rec in contract.get('ordinary_ic_lefts', []): g = rec['glyph'] ic = int(rec['italic_correction']) if not ic or g not in glyph_order: continue prev = left_ic.get(g) if prev is not None and prev != ic: raise SystemExit(f'conflicting ordinary IC for {g}: {prev} vs {ic}') left_ic[g] = ic if not left_ic: raise SystemExit('contract has no usable ordinary_ic_lefts') rights = _right_domain(font, set(left_ic)) & glyph_order if not rights: raise SystemExit('ordinary right class is empty') # Group left glyphs by IC so PairPos Format 2 stays compact. values = sorted(set(left_ic.values())) class_for_value = {v: i + 1 for i, v in enumerate(values)} classdef1 = {g: class_for_value[v] for g, v in left_ic.items()} classdef2 = {g: 1 for g in rights} sub = otTables.PairPos() sub.Format = 2 sub.Coverage = otTables.Coverage() order_index = {g: i for i, g in enumerate(font.getGlyphOrder())} sub.Coverage.glyphs = sorted(left_ic, key=lambda g: order_index[g]) sub.ValueFormat1 = VALUE_XADVANCE sub.ValueFormat2 = 0 sub.ClassDef1 = otTables.ClassDef() sub.ClassDef1.classDefs = classdef1 sub.ClassDef2 = otTables.ClassDef() sub.ClassDef2.classDefs = classdef2 sub.Class1Count = len(values) + 1 sub.Class2Count = 2 sub.Class1Record = [] value_for_class = {class_for_value[v]: v for v in values} for c1 in range(sub.Class1Count): r1 = otTables.Class1Record() r1.Class2Record = [] for c2 in range(sub.Class2Count): r2 = otTables.Class2Record() x = value_for_class.get(c1, 0) if c2 == 1 else 0 r2.Value1 = _value_record(x) r2.Value2 = None r1.Class2Record.append(r2) sub.Class1Record.append(r1) lookup = otTables.Lookup() lookup.LookupType = 2 lookup.LookupFlag = 0 lookup.SubTable = [sub] lookup.SubTableCount = 1 lookup.MarkFilteringSet = None ll = font['GPOS'].table.LookupList idx = len(ll.Lookup) ll.Lookup.append(lookup) ll.LookupCount = len(ll.Lookup) features = [] fl = font['GPOS'].table.FeatureList if fl: for fr in fl.FeatureRecord: if fr.FeatureTag == 'kern': if idx not in fr.Feature.LookupListIndex: fr.Feature.LookupListIndex.append(idx) fr.Feature.LookupCount = len(fr.Feature.LookupListIndex) features.append(fr) if not features: raise SystemExit('font has no existing kern feature; refusing to invent script wiring') return len(left_ic), len(values), len(rights), idx def main(): ap = argparse.ArgumentParser() ap.add_argument('font') ap.add_argument('--contract', required=True) ap.add_argument('--output') ns = ap.parse_args() f = TTFont(ns.font) con = json.load(open(ns.contract, encoding='utf-8')) add_bridge(f, con) out = ns.output or ns.font f.save(out, reorderTables=False) if __name__ == '__main__': main()