"""Fixed functional donor selection and validation of user donor inputs. """ from __future__ import annotations import hashlib import json import math from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING import mapping as U if TYPE_CHECKING: from fontTools.ttLib import TTFont MODES = ("minimal", "extended") PUBLIC_MODES = MODES INTERNAL_MODE = "MTP2_PIPELINE_DONOR_COVERAGE" PRIVATE_KEYS = ( "MTP2_DONOR_PROFILE_JSON", "MTP2_DONOR_HINT_CONTRACT_JSON", "MTP2_DONOR_HINT_REFERENCE_ROOT", "MTP2_DONOR_HINT_MODE", ) SPEC_PATH = Path(__file__).with_name("donor_scope.json") SPEC_SHA256 = ( "4a3e444b7aebdbaa968d51d8185ad934256a4494a524697f947cdb6508f28e50" ) def sha(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() def public_mode(value): """Accept only the public coverage spellings.""" if value not in PUBLIC_MODES: raise ValueError("expected minimal or extended") return value def resolve_mode(mode, environ): if mode is not None and mode not in MODES: raise ValueError("unknown donor coverage mode") conflicts = [k for k in PRIVATE_KEYS if k in environ] if mode is not None and conflicts: raise ValueError( "unsupported retired private donor settings: " + ", ".join(conflicts) ) return mode def child_environment(mode, environ): resolve_mode(mode, environ) child = dict(environ) for key in tuple(child): if key.startswith("MTP2_"): child.pop(key) if mode is not None: child[INTERNAL_MODE] = mode return child def load_spec(path=SPEC_PATH): if sha(path) != SPEC_SHA256: raise ValueError("unknown public donor spec or modified fixed scope") s = json.loads(Path(path).read_text(encoding="utf-8")) if ( s["schema"] != "mtp2otf7-public-donor-scope/1" or s["id"] != "BI-minimal-317-r01" ): raise ValueError("unknown public donor spec schema/id") roots = s["roots"] codes = [r["output_unicode"] for r in roots] if ( len(codes) != 317 or len(set(codes)) != 317 or set(codes) != {u for g in s["groups"].values() for u in g} ): raise ValueError("public donor scope roots/groups differ") if ( sum(r["source_role"] == "regular" for r in roots) != 255 or sum(r["source_role"] == "bold" for r in roots) != 62 ): raise ValueError("public donor scope role mismatch") return s def select_remaining(candidates, mode, spec=None, runtime_dependencies=()): if mode not in (None, *MODES): raise ValueError("unknown donor coverage mode") if mode != "minimal": return candidates spec = spec if spec is not None else load_spec() codes = [r[1] for r in candidates] if len(set(codes)) != len(codes): raise ValueError("duplicate remaining donor output") required = {r["output_unicode"] for r in spec["roots"]} removed = {r[2] for r in candidates if r[1] not in required} if removed & set(runtime_dependencies): raise ValueError( "retained runtime dependency requires excluded donor glyph" ) return [r for r in candidates if r[1] in required] def validate_outputs(spec, observed, internal, dependencies=(), names=()): missing = {r["output_unicode"] for r in spec["roots"]} - set(observed) if missing: raise ValueError( "minimal required output missing: " + ", ".join("U+%04X" % u for u in sorted(missing)) ) for u, row in observed.items(): if not row.get("visible") or not math.isfinite(row["advance"]): raise ValueError( "minimal required visible outline/advance invalid: U+%04X" % u ) if set(spec["internal_outputs"]) - set(internal) or not all( internal.values() ): raise ValueError("minimal internal dotless-i supply unavailable") missing_dependencies = set(dependencies) - set(names) if missing_dependencies: raise ValueError( "minimal glyph dependency missing: " + ", ".join(sorted(missing_dependencies)) ) REGULAR_CODEPOINTS = tuple(range(0x41, 0x5B)) + tuple(range(0x61, 0x7B)) BOLD_CODEPOINTS = REGULAR_CODEPOINTS + tuple(range(0x30, 0x3A)) @dataclass(frozen=True) class DonorInfo: path: Path family: str upm: int weight: int outline: str def _name_values(font: TTFont, name_id: int) -> set[str]: values: set[str] = set() for record in font["name"].names: if record.nameID != name_id: continue try: value = record.toUnicode().strip() except Exception: continue if value: values.add(value) return values def _family(font: TTFont) -> str: values = _name_values(font, 16) or _name_values(font, 1) if not values: raise ValueError("missing family name (name ID 16/1)") return sorted(values, key=lambda value: (len(value), value.casefold()))[0] def _outline_format(font: TTFont) -> str: if "CFF " in font: return "CFF" if "glyf" in font: return "TrueType" raise ValueError( "unsupported outline format (expected static CFF or TrueType)" ) def _inspect(path: str | Path, kind: str) -> DonorInfo: from fontTools.ttLib import TTFont p = Path(path) try: font = TTFont(p) except Exception as exc: raise ValueError(f"cannot open {kind} donor {p}: {exc}") from exc with font: for tag in ("head", "hmtx", "cmap", "name", "OS/2", "post"): if tag not in font: raise ValueError( f"{kind} donor {p} is missing required {tag} table" ) if "fvar" in font: raise ValueError( f"{kind} donor {p} is variable; select a static instance" ) outline = _outline_format(font) upm = int(font["head"].unitsPerEm) if not 16 <= upm <= 16384: raise ValueError(f"{kind} donor {p} has invalid unitsPerEm={upm}") os2 = font["OS/2"] post = font["post"] weight = int(os2.usWeightClass) italic = bool(os2.fsSelection & 0x01) oblique = bool(os2.fsSelection & 0x200) if abs(float(post.italicAngle)) > 0.01 or italic or oblique: raise ValueError( f"{kind} donor must be upright: {p} " f"(italicAngle={post.italicAngle}, " f"fsSelection={os2.fsSelection:#x})" ) if kind == "Regular" and not 300 <= weight < 600: raise ValueError( f"Regular donor has non-Regular weight {weight}: {p}" ) if kind == "Bold" and not 600 <= weight <= 900: raise ValueError(f"Bold donor has non-Bold weight {weight}: {p}") if int(getattr(post, "isFixedPitch", 0) or 0): raise ValueError( f"{kind} donor is fixed-pitch, not a proportional Roman: {p}" ) cmap = font.getBestCmap() or {} required = REGULAR_CODEPOINTS if kind == "Regular" else BOLD_CODEPOINTS missing = [cp for cp in required if cp not in cmap] if missing: sample = ", ".join(f"U+{cp:04X}" for cp in missing[:12]) more = ( "" if len(missing) <= 12 else f" (+{len(missing) - 12} more)" ) raise ValueError( f"{kind} donor lacks required characters: {sample}{more}" ) result = DonorInfo(p, _family(font), upm, weight, outline) return result def validate_inputs( regular: str | Path, bold: str | Path ) -> tuple[DonorInfo, DonorInfo]: reg = _inspect(regular, "Regular") bld = _inspect(bold, "Bold") if reg.family.casefold() != bld.family.casefold(): raise ValueError( f"Regular/Bold donor families do not match: {reg.family!r} " f"!= {bld.family!r}" ) return reg, bld def unicode_map(ffont): d = {} for g in ffont.glyphs(): u = g.unicode if u is not None and u >= 0: d[u] = g.glyphname return d def read_supply(path): """Read Unicode/name/advance/bounds without constructing an output font. """ from fontTools.pens.boundsPen import BoundsPen from fontTools.ttLib import TTFont with TTFont(path) as font: cmap = font.getBestCmap() or {} gs = font.getGlyphSet() glyphs = {} for name in font.getGlyphOrder(): pen = BoundsPen(gs) gs[name].draw(pen) glyphs[name] = dict( advance=font["hmtx"][name][0], bounds=pen.bounds ) return dict(path=Path(path), cmap=cmap, glyphs=glyphs) def check_supply(supply, coverage): """Fail required donor gaps before native output; retain native checks too. """ requirements = [ dict(source_role=r, source_unicode=cp) for r, cs in [ ("regular", REGULAR_CODEPOINTS), ("bold", BOLD_CODEPOINTS), ] for cp in cs ] if coverage == "minimal": requirements += load_spec()["roots"] for row in requirements: role, cp = row["source_role"], row["source_unicode"] face = supply[role] if ( row.get("supply_rule") == "encoded_dotless_j_or_existing_validated_j_derivation" ): cp = 0x237 if 0x237 in face["cmap"] else 0x6A name = face["cmap"].get(cp) g = face["glyphs"].get(name, {}) b = g.get("bounds") if ( not name or not b or b[2] <= b[0] or b[3] <= b[1] or not math.isfinite(g.get("advance", float("nan"))) ): raise ValueError( f"preflight donor {face['path'].name} role={role} " f"U+{cp:04X}: required visible Unicode supply missing" ) regular = supply["regular"] if 0x237 not in regular["cmap"] and 0x6A not in regular["cmap"]: raise ValueError("preflight regular donor: neither U+0237 nor U+006A") if ( coverage == "minimal" and 0x131 not in regular["cmap"] and "dotlessi" not in regular["glyphs"] ): raise ValueError( "preflight minimal internal dotless-i supply unavailable" ) # Reserve math and non-Roman ranges against text-donor substitution. TEXT_ONLY_SKIP = ( (0x0370, 0x03FF), # Greek (0x0400, 0x052F), # Cyrillic (0x2100, 0x214F), # Letterlike symbols (0x2190, 0x21FF), # Arrows (0x2200, 0x22FF), # Math operators (0x2300, 0x23FF), # Technical symbols (0x25A0, 0x25FF), # Geometric shapes (0x27C0, 0x27EF), # Supplemental math A (0x2980, 0x2AFF), # Supplemental math B (0x1D400, 0x1D7FF), # Math alphanumerics ) # Allow text-only letterlike symbols, but # reserve math-active primes. TEXT_ONLY_PRIMES = set(range(0x2032, 0x2038)) | {0x2057} TEXT_ONLY_ALLOW = { 0x2105, 0x2116, 0x211E, 0x2120, 0x2121, 0x2122, 0x212E, } def _is_math_range(u): if u in TEXT_ONLY_PRIMES: return True if u in TEXT_ONLY_ALLOW: return False return any(a <= u <= b for a, b in TEXT_ONLY_SKIP) # Limit donor additions to Latin/Common text; math # ownership takes precedence. ROMAN_TEXT_EXTRA_RANGES = ( (0x0020, 0x036F), (0x1E00, 0x1EFF), (0x2000, 0x218F), (0x2500, 0x266F), (0x301A, 0x301B), (0xFB00, 0xFB06), ) def _is_roman_text_extra(u): return any(first <= u <= last for first, last in ROMAN_TEXT_EXTRA_RANGES) # Reserve Full-only source codepoints in Lite, including AMSa eth. CANONICAL_FULL_ONLY_SOURCE_UNICODE = set() for _u, _name in U.AMSA.values(): if _u is not None: CANONICAL_FULL_ONLY_SOURCE_UNICODE.add(_u) for _kind in ("bb", "script", "frak", "curly", "bscript", "bfrak"): for _slot, (_u, _name) in U.alpha_map(_kind).items(): if _u is not None: CANONICAL_FULL_ONLY_SOURCE_UNICODE.add(_u) # Text-donor symbols used directly by Word math. ROMAN_EXTRA = { "percent": 0x0025, "ampersand": 0x0026, "at": 0x0040, "underscore": 0x005F, "asciitilde": 0x007E, "numbersign": 0x0023, "dollar": 0x0024, "degree": 0x00B0, "fraction": 0x2044, "question": 0x003F, "exclam": 0x0021, } def text_candidates( font, owned_unicode, names, edition, coverage, spec, dependencies ): """Select eligible remaining text without changing source iteration order. """ todo = [] for g in font.glyphs(): u = g.unicode if u is None or u < 0x20 or u in owned_unicode: continue if not _is_roman_text_extra(u): continue if edition == "lite" and u in CANONICAL_FULL_ONLY_SOURCE_UNICODE: continue if _is_math_range(u): continue if 0xE000 <= u <= 0xF8FF or u in (0xFEFF, 0xFFFD): continue gn = "uni%04X" % u if u < 0x10000 else "u%05X" % u if gn in names: continue todo.append((g.glyphname, u, gn, g.width)) return select_remaining(todo, coverage, spec, dependencies)