#!/usr/bin/env python3 """Validate generated font structure, metadata, and math layout contracts.""" import sys from fontTools.ttLib import TTFont err, warn = [], [] def E(msg): err.append(msg) def W(msg): warn.append(msg) def check(path): del err[:], warn[:] f = TTFont(path) names = set(f.getGlyphOrder()) cmap = f.getBestCmap() # Required tables. for t in ('MATH', 'GSUB', 'GPOS', 'cmap', 'name', 'OS/2', 'head', 'hhea'): if t not in f: E(f'{t}: missing table') if 'CFF ' not in f and 'glyf' not in f: E('Missing CFF/glyf outlines') # cmap if any(g == '.notdef' for g in cmap.values()): E('cmap contains mappings to .notdef') missing = [c for c, g in cmap.items() if g not in names] if missing: E(f'Missing cmap targets: {len(missing)} entries') for cp, what in ((0x2061, 'function application'), (0x2062, 'invisible multiplication'), (0x2063, 'invisible separator'), (0x0020, 'space')): if cp not in cmap: W(f'U+{cp:04X} ({what}) missing; Word may show a missing-glyph box') # Keep alias expectations independent of the builder's mapping registry. from audit_greek_alias_contract import audit as audit_greek err.extend(audit_greek(f)) # Reject digit-leading glyph names for Word subsetting. import re as _re _dig = [g for g in names if g and g[0].isdigit()] if _dig: E(f'Digit-leading glyph names: {len(_dig)} ({", ".join(sorted(_dig)[:3])} …) — ' f'Invalid PostScript names may cause Word to rasterize text') _badch = [g for g in names if not _re.fullmatch(r'[A-Za-z0-9._]+', g or '')] if _badch: E(f'Invalid glyph-name characters: {_badch[:3]}') _long = [g for g in names if len(g) > 63] if _long: W(f'Glyph names longer than 63 characters: {len(_long)} entries') # OS/2 and naming. os2 = f['OS/2'] if os2.fsType == 2: W('fsType is 2: restricted embedding; Word cannot embed the font') elif os2.fsType == 4: W('fsType is 4: preview/print embedding; Word PDF export ' 'may rasterize text') # Windows clipping bounds must cover the outlines. from fontTools.pens.boundsPen import BoundsPen as _BP gs = f.getGlyphSet() ymin = ymax = 0 for g in names: b = _BP(gs) try: gs[g].draw(b) except Exception: continue if b.bounds: ymin = min(ymin, b.bounds[1]) ymax = max(ymax, b.bounds[3]) if os2.usWinAscent < ymax or os2.usWinDescent < -ymin: E(f'usWinAscent/Descent ({os2.usWinAscent}/{os2.usWinDescent}) - ' f'glyph bounds ({ymax}/{-ymin}) are not covered; ' f'Windows may clip extended delimiters or integrals') nm = {r.nameID: str(r) for r in f['name'].names} for i, what in ((1, 'family name'), (2, 'style name'), (6, 'PostScript name'), (7, 'trademark')): if i not in nm: W(f'name ID {i} ({what}) missing') # MATH m = f['MATH'].table mv, gi = m.MathVariants, m.MathGlyphInfo ic = gi.MathItalicsCorrectionInfo for g in ic.Coverage.glyphs: if g not in names: E(f'Italic-correction target {g} does not exist') ta = gi.MathTopAccentAttachment _topaccent = { g: int(v.Value) for g, v in zip(ta.TopAccentCoverage.glyphs, ta.TopAccentAttachment) } for g in ta.TopAccentCoverage.glyphs: if g not in names: E(f'TopAccent target {g} does not exist') ovl = mv.MinConnectorOverlap for label, cov, cons in (('vertical', mv.VertGlyphCoverage, mv.VertGlyphConstruction), ('horizontal', mv.HorizGlyphCoverage, mv.HorizGlyphConstruction)): if cov is None: continue if len(cov.glyphs) != len(cons): E(f'{label} constructions: coverage {len(cov.glyphs)} differs from construction count {len(cons)}') for base, rec in zip(cov.glyphs, cons): if base not in names: E(f'{label} construction base {base} does not exist') prev = None for v in (rec.MathGlyphVariantRecord or []): if v.VariantGlyph not in names: E(f'{base}: variant {v.VariantGlyph} does not exist') if prev is not None and v.AdvanceMeasurement < prev: W(f'{base}: variant sizes are not monotonic ' f'({prev} → {v.AdvanceMeasurement})') prev = v.AdvanceMeasurement asm = rec.GlyphAssembly if asm: has_ext = False for p in asm.PartRecords: if p.glyph not in names: E(f'{base}: assembly part {p.glyph} does not exist') if p.PartFlags & 1: has_ext = True for attr in ('StartConnectorLength', 'EndConnectorLength'): v = getattr(p, attr) if v and v < ovl: E(f'{base}/{p.glyph}: {attr} {v} - ' f'MinConnectorOverlap {ovl}') parts = asm.PartRecords pairs = list(zip(parts, parts[1:])) pairs.extend((p, p) for p in parts if p.PartFlags & 1) fixed = [(i, p) for i, p in enumerate(parts) if not p.PartFlags & 1] pairs.extend((a, b) for (i, a), (j, b) in zip(fixed, fixed[1:]) if j > i + 1) for a, b in pairs: capacity = min(a.EndConnectorLength, b.StartConnectorLength) if capacity < ovl: E(f'{base}: join {a.glyph} to {b.glyph} supports ' f'{capacity}, below MinConnectorOverlap {ovl}') if not has_ext: E(f'{base}: assembly has no extender (PartFlags=1)') # Check connectors and ink continuity at the active minimum overlap. from fontTools.pens.boundsPen import BoundsPen as _HBP _hgs = f.getGlyphSet() def _hb(gname): pen = _HBP(_hgs) _hgs[gname].draw(pen) return pen.bounds or (0, 0, 0, 0) hcons = {} if mv.HorizGlyphCoverage: hcons = dict(zip(mv.HorizGlyphCoverage.glyphs, mv.HorizGlyphConstruction)) def _hcon(cp): gn = cmap.get(cp) return gn, (hcons.get(gn) if gn else None) # Relation arrows stretch; public bar and right-vector accents remain fixed. for cp, label in ((0x0304, 'bar/macron'), (0x0305, 'bar/overline alias'), (0x20D7, 'right vector accent')): gn, con = _hcon(cp) if not gn: E(f'U+{cp:04X}: {label}: missing glyph') elif con is not None: E(f'U+{cp:04X}: {label}: unexpected horizontal MATH construction') # U+E286 keeps the finite source-bar ladder, positive advance, and center attachment. wg, wc = _hcon(0xE286) if not wg or not wc: E('Missing U+E286 wideoverbar compatibility construction') elif wc.GlyphAssembly: E('U+E286 wideoverbar must use fixed source variants, without an assembly') else: _wrecs = list(wc.MathGlyphVariantRecord or []) _wadv = [r.AdvanceMeasurement for r in _wrecs] if len(_wadv) != 3 or any(b <= a for a, b in zip(_wadv, _wadv[1:])): E('U+E286 wideoverbar ladder must contain three strictly increasing finite rungs') _wbar = cmap.get(0xE24E) _wwbar = cmap.get(0xE24A) _want_glyphs = [wg, _wbar, _wwbar] _got_glyphs = [r.VariantGlyph for r in _wrecs] if None in _want_glyphs: E('U+E286 ladder: missing fixed source-accent donors E24E/E24A in cmap') elif _got_glyphs != _want_glyphs: E(f'U+E286 ladder glyphs {_got_glyphs} != {_want_glyphs}') # Check advance-center attachment separately from ink-center proximity. for _ag in _want_glyphs: if not _ag or _ag not in names: continue _aw = f['hmtx'][_ag][0] if _aw <= 0: E(f'{_ag}: U+E286 finite-rung advance {_aw} must be positive') continue _want_ta = _aw // 2 _have_ta = _topaccent.get(_ag) if _have_ta is None: E(f'{_ag}: missing U+E286 fixed-variant TopAccentAttachment') continue if _have_ta != _want_ta: E(f'{_ag}: TopAccent {_have_ta} != advance-centre {_want_ta}') _abb = _hb(_ag) _ink_center = (_abb[0] + _abb[2]) / 2 if abs(_have_ta - _ink_center) > 2: E(f'{_ag}: TopAccent {_have_ta} differs from ink center {_ink_center:.1f} by more than 2u') # U+E287 uses an accent-height assembly, separate from the relation arrow. vg, vc = _hcon(0xE287) if not vg or not vc or not vc.GlyphAssembly: E('Missing U+E287 widevector compatibility assembly') else: _vpr = vc.GlyphAssembly.PartRecords if len(_vpr) != 2 or not (_vpr[0].PartFlags & 1) or (_vpr[1].PartFlags & 1): E('U+E287 widevector assembly must contain an extender and a fixed endpoint') if f['hmtx'][vg][0] != 0: E(f'U+E287 widevector base advance {f["hmtx"][vg][0]} != 0') _public_vec = cmap.get(0x20D7) if _public_vec: _vta = _topaccent.get(vg) _pta = _topaccent.get(_public_vec) if _vta is None or _pta is None: E('Missing U+E287/U+20D7 vector TopAccentAttachment') elif _vta != _pta: E(f'U+E287 TopAccent {_vta} != normalized U+20D7 TopAccent {_pta}') for cp in (0x2190, 0x2192, 0x2194, 0x21D0, 0x21D2, 0x21D4): gn, con = _hcon(cp) if not gn or not con or not con.GlyphAssembly: E(f'U+{cp:04X}: missing horizontal assembly with valid connectors') continue prs = con.GlyphAssembly.PartRecords if len(prs) != 3 or not (prs[1].PartFlags & 1): E(f'U+{cp:04X}: relation-arrow assembly must use fixed/extender/fixed parts') for a, b in zip(prs, prs[1:]): ba, bb = _hb(a.glyph), _hb(b.glyph) gap = a.FullAdvance - ovl + bb[0] - ba[2] if gap > 0: E(f'U+{cp:04X}: {a.glyph}→{b.glyph}: gap at minimum overlap: {gap:.1f}u') # U+20D6 uses its designated left-vector assembly. for cp, endpoint_cp, extender_index in ((0x20D6, 0x2190, 1),): gn, con = _hcon(cp) if not gn or not con or not con.GlyphAssembly: E(f'U+{cp:04X}: missing horizontal vector assembly') continue prs = con.GlyphAssembly.PartRecords if len(prs) != 2 or not (prs[extender_index].PartFlags & 1): E(f'U+{cp:04X}: vector assembly must use a complete endpoint and optional extender') fixed = [r for r in prs if not (r.PartFlags & 1)] ep = cmap.get(endpoint_cp) epw = f['hmtx'].metrics[ep][0] if ep else 0 if len(fixed) != 1 or abs(fixed[0].FullAdvance - epw) > 1: got = fixed[0].FullAdvance if fixed else None E(f'U+{cp:04X}: shortest vector width {got} != completed arrow width {epw}') for a, b in zip(prs, prs[1:]): ba, bb = _hb(a.glyph), _hb(b.glyph) gap = a.FullAdvance - ovl + bb[0] - ba[2] if gap > 0: E(f'U+{cp:04X}: {a.glyph}→{b.glyph}: gap at minimum overlap: {gap:.1f}u') # Mapsto has dedicated finite source glyphs, not an arbitrary-width assembly. mg, mc = _hcon(0x21A6) if mg and mc: if mc.GlyphAssembly: E('U+21A6: unexpected mapsto assembly; only short/long variants are allowed') if len(mc.MathGlyphVariantRecord or []) < 2: E('U+21A6: missing short/long MTPro2 mapsto variants') # Keep overparen, standard corner-and-rule braces, and overcbrace designs distinct. pg, pc = _hcon(0x23DC) og, oc = _hcon(0x23DE) ug, uc = _hcon(0x23DF) def _construction_glyphs(con): if not con: return set() outg = {r.VariantGlyph for r in (con.MathGlyphVariantRecord or [])} if con.GlyphAssembly: outg.update(p.glyph for p in con.GlyphAssembly.PartRecords) return outg if pc: pglyphs = _construction_glyphs(pc) # Only the designated source continuation belongs to the overparen ladder. bad = [g for g in pglyphs if '.exe' in g or '.exg' in g or ('.exf' in g and not g.startswith(f'{pg}.exf'))] if bad: E(f'U+23DC: unrelated extension glyph in TOP PARENTHESIS: {bad[:4]}') vals = [r.AdvanceMeasurement for r in (pc.MathGlyphVariantRecord or [])] if not vals or max(vals) < 12000: E(f'U+23DC: overparen ladder does not reach 12em (max={max(vals) if vals else 0})') # Bridge fixed overparen ladders without early saturation. if not pc.GlyphAssembly: E('U+23DC: missing assembly between fixed variant ladders') else: prs = pc.GlyphAssembly.PartRecords if len(prs) != 3 or not (prs[1].PartFlags & 1): E(f'U+23DC: overparen assembly requires fixed/extender/fixed parts ({len(prs)} parts)') for a, b in zip(prs, prs[1:]): ba, bb = _hb(a.glyph), _hb(b.glyph) gap = a.FullAdvance - ovl + bb[0] - ba[2] if gap > 0: E(f'U+23DC: {a.glyph}→{b.glyph}: gap at minimum overlap: {gap:.1f}u') for cp, base_g, con in ((0x23DE, og, oc), (0x23DF, ug, uc)): if base_g and any(f'.{tag}' in base_g for tag in ('exe', 'exf', 'exg')): E(f'U+{cp:04X}: base glyph belongs to the separate overcbrace/undercbrace family ({base_g})') if not con or not con.GlyphAssembly: E(f'U+{cp:04X}: missing standard brace assembly') continue prs = con.GlyphAssembly.PartRecords if len(prs) != 5: E(f'U+{cp:04X}: standard brace assembly requires 5 parts; found {len(prs)} parts') elif not ((prs[1].PartFlags & 1) and (prs[3].PartFlags & 1)): E(f'U+{cp:04X}: rule extenders are not in their two expected positions') bad = [g for g in _construction_glyphs(con) if any(f'.{tag}' in g for tag in ('exe', 'exf', 'exg'))] if bad: E(f'U+{cp:04X}: separate overcbrace/undercbrace glyph in standard brace: {bad[:4]}') if pc and oc: cross = _construction_glyphs(pc) & _construction_glyphs(oc) if cross: E(f'U+23DC/U+23DE: overlapping variant/assembly glyph sets: {sorted(cross)[:4]}') # audit_source_contract.py checks source metrics; keep this validator structural. fitalic = cmap.get(0x1D453) if fitalic and fitalic not in dict(zip(ic.Coverage.glyphs, [v.Value for v in ic.ItalicsCorrection])): E('U+1D453 math italic f: missing MATH ItalicsCorrection') # GSUB g = f['GSUB'].table feat = {} feat_lookup_count = {} for fr in g.FeatureList.FeatureRecord: cnt = 0 feat_lookup_count[fr.FeatureTag] = len(fr.Feature.LookupListIndex) for li in fr.Feature.LookupListIndex: lk = g.LookupList.Lookup[li] for st in lk.SubTable: mp = getattr(st, 'mapping', None) or {} al = getattr(st, 'alternates', None) or {} lig = getattr(st, 'ligatures', None) or {} for b, v in list(mp.items()): cnt += 1 if b not in names or v not in names: E(f'{fr.FeatureTag}: {b} → {v}: missing glyph') for b, vs in list(al.items()): cnt += len(vs) if b not in names or any(v not in names for v in vs): E(f'{fr.FeatureTag}: {b}: missing alternate glyph') for first, recs in list(lig.items()): cnt += len(recs) if first not in names: E(f'{fr.FeatureTag}: ligature first {first} does not exist') for rec in recs: if rec.LigGlyph not in names or any(x not in names for x in rec.Component): E(f'{fr.FeatureTag}: missing ligature glyph') feat[fr.FeatureTag] = cnt for tag in ('ssty', 'dtls', 'ccmp'): if tag not in feat: W(f'{tag}: missing feature') # Infer raw-prime classes and padding from the generated glyphs. import math as _math import unicodedata as _ud primes = {cmap.get(x) for x in (0x2032, 0x2033, 0x2034, 0x2057)} - {None} icvals = dict(zip(ic.Coverage.glyphs, [v.Value for v in ic.ItalicsCorrection])) variant_deltas = set() actual = set() for fr in g.FeatureList.FeatureRecord: if fr.FeatureTag != 'ccmp': continue for li in fr.Feature.LookupListIndex: lk = g.LookupList.Lookup[li] if lk.LookupType != 6: continue for st in lk.SubTable: if getattr(st, 'Format', None) != 3 or not getattr(st, 'InputCoverage', None): continue inp = set(st.InputCoverage[0].glyphs) if not (inp & primes): continue for cov in (getattr(st, 'BacktrackCoverage', None) or []): actual.update(cov.glyphs) # Use substitution advances rather than names, which TTF post format 3 drops. for rec in (getattr(st, 'SubstLookupRecord', None) or []): if rec.SequenceIndex != 0: continue sub_lookup = g.LookupList.Lookup[rec.LookupListIndex] subtables = [] if sub_lookup.LookupType == 1: subtables = list(sub_lookup.SubTable) elif sub_lookup.LookupType == 7: subtables = [x.ExtSubTable for x in sub_lookup.SubTable if getattr(x, 'ExtensionLookupType', None) == 1] for sub in subtables: for src, dst in (getattr(sub, 'mapping', None) or {}).items(): if src in primes: variant_deltas.add( f['hmtx'][dst][0] - f['hmtx'][src][0]) if variant_deltas: ordered_delta = sorted(variant_deltas) diffs = [b - a for a, b in zip(ordered_delta, ordered_delta[1:]) if b > a] step = 0 for d in diffs: step = _math.gcd(step, d) if step <= 0: W('prime contextual class step could not be inferred') else: # The smallest advance delta identifies class zero and the source-derived padding. base_shift = -min(ordered_delta) class_values = {d + base_shift for d in ordered_delta} cap = max(class_values) expected = set() for u, gn in cmap.items(): if u < 0x370: continue try: if not _ud.category(chr(u)).startswith('L'): continue except ValueError: continue kval = max(0, min(cap, int(round(icvals.get(gn, 0) / step)) * step)) if abs(kval - base_shift) >= step / 2: expected.add(gn) missing_prime = expected - actual extra_prime = actual - expected if missing_prime: E(f'prime ccmp backtrack coverage missing {len(missing_prime)} eligible glyphs') if extra_prime: W(f'prime ccmp backtrack coverage has {len(extra_prime)} extra glyphs') elif actual: E('prime ccmp coverage exists but no contextual prime variants were found') # Verify that the font can be serialized. import io try: buf = io.BytesIO() f.save(buf) TTFont(io.BytesIO(buf.getvalue()))['MATH'] except Exception as e: E(f'Save/reload failed: {e}') for e in err: print('error:', e, file=sys.stderr) for w in warn: print('warning:', w, file=sys.stderr) return len(err) if __name__ == '__main__': bad = 0 for p in sys.argv[1:]: bad += check(p) sys.exit(1 if bad else 0)