123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209 |
- """
- Tool to find wrong contour order between different masters, and
- other interpolatability (or lack thereof) issues.
- Call as:
- $ fonttools varLib.interpolatable font1 font2 ...
- """
- from .interpolatableHelpers import *
- from .interpolatableTestContourOrder import test_contour_order
- from .interpolatableTestStartingPoint import test_starting_point
- from fontTools.pens.recordingPen import (
- RecordingPen,
- DecomposingRecordingPen,
- lerpRecordings,
- )
- from fontTools.pens.transformPen import TransformPen
- from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen
- from fontTools.pens.momentsPen import OpenContourError
- from fontTools.varLib.models import piecewiseLinearMap, normalizeLocation
- from fontTools.misc.fixedTools import floatToFixedToStr
- from fontTools.misc.transform import Transform
- from collections import defaultdict
- from types import SimpleNamespace
- from functools import wraps
- from pprint import pformat
- from math import sqrt, atan2, pi
- import logging
- import os
- log = logging.getLogger("fontTools.varLib.interpolatable")
- DEFAULT_TOLERANCE = 0.95
- DEFAULT_KINKINESS = 0.5
- DEFAULT_KINKINESS_LENGTH = 0.002 # ratio of UPEM
- DEFAULT_UPEM = 1000
- class Glyph:
- ITEMS = (
- "recordings",
- "greenStats",
- "controlStats",
- "greenVectors",
- "controlVectors",
- "nodeTypes",
- "isomorphisms",
- "points",
- "openContours",
- )
- def __init__(self, glyphname, glyphset):
- self.name = glyphname
- for item in self.ITEMS:
- setattr(self, item, [])
- self._populate(glyphset)
- def _fill_in(self, ix):
- for item in self.ITEMS:
- if len(getattr(self, item)) == ix:
- getattr(self, item).append(None)
- def _populate(self, glyphset):
- glyph = glyphset[self.name]
- self.doesnt_exist = glyph is None
- if self.doesnt_exist:
- return
- perContourPen = PerContourOrComponentPen(RecordingPen, glyphset=glyphset)
- try:
- glyph.draw(perContourPen, outputImpliedClosingLine=True)
- except TypeError:
- glyph.draw(perContourPen)
- self.recordings = perContourPen.value
- del perContourPen
- for ix, contour in enumerate(self.recordings):
- nodeTypes = [op for op, arg in contour.value]
- self.nodeTypes.append(nodeTypes)
- greenStats = StatisticsPen(glyphset=glyphset)
- controlStats = StatisticsControlPen(glyphset=glyphset)
- try:
- contour.replay(greenStats)
- contour.replay(controlStats)
- self.openContours.append(False)
- except OpenContourError as e:
- self.openContours.append(True)
- self._fill_in(ix)
- continue
- self.greenStats.append(greenStats)
- self.controlStats.append(controlStats)
- self.greenVectors.append(contour_vector_from_stats(greenStats))
- self.controlVectors.append(contour_vector_from_stats(controlStats))
- # Check starting point
- if nodeTypes[0] == "addComponent":
- self._fill_in(ix)
- continue
- assert nodeTypes[0] == "moveTo"
- assert nodeTypes[-1] in ("closePath", "endPath")
- points = SimpleRecordingPointPen()
- converter = SegmentToPointPen(points, False)
- contour.replay(converter)
- # points.value is a list of pt,bool where bool is true if on-curve and false if off-curve;
- # now check all rotations and mirror-rotations of the contour and build list of isomorphic
- # possible starting points.
- self.points.append(points.value)
- isomorphisms = []
- self.isomorphisms.append(isomorphisms)
- # Add rotations
- add_isomorphisms(points.value, isomorphisms, False)
- # Add mirrored rotations
- add_isomorphisms(points.value, isomorphisms, True)
- def draw(self, pen, countor_idx=None):
- if countor_idx is None:
- for contour in self.recordings:
- contour.draw(pen)
- else:
- self.recordings[countor_idx].draw(pen)
- def test_gen(
- glyphsets,
- glyphs=None,
- names=None,
- ignore_missing=False,
- *,
- locations=None,
- tolerance=DEFAULT_TOLERANCE,
- kinkiness=DEFAULT_KINKINESS,
- upem=DEFAULT_UPEM,
- show_all=False,
- discrete_axes=[],
- ):
- if tolerance >= 10:
- tolerance *= 0.01
- assert 0 <= tolerance <= 1
- if kinkiness >= 10:
- kinkiness *= 0.01
- assert 0 <= kinkiness
- names = names or [repr(g) for g in glyphsets]
- if glyphs is None:
- # `glyphs = glyphsets[0].keys()` is faster, certainly, but doesn't allow for sparse TTFs/OTFs given out of order
- # ... risks the sparse master being the first one, and only processing a subset of the glyphs
- glyphs = {g for glyphset in glyphsets for g in glyphset.keys()}
- parents, order = find_parents_and_order(
- glyphsets, locations, discrete_axes=discrete_axes
- )
- def grand_parent(i, glyphname):
- if i is None:
- return None
- i = parents[i]
- if i is None:
- return None
- while parents[i] is not None and glyphsets[i][glyphname] is None:
- i = parents[i]
- return i
- for glyph_name in glyphs:
- log.info("Testing glyph %s", glyph_name)
- allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets]
- if len([1 for glyph in allGlyphs if glyph is not None]) <= 1:
- continue
- for master_idx, (glyph, glyphset, name) in enumerate(
- zip(allGlyphs, glyphsets, names)
- ):
- if glyph.doesnt_exist:
- if not ignore_missing:
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.MISSING,
- "master": name,
- "master_idx": master_idx,
- },
- )
- continue
- has_open = False
- for ix, open in enumerate(glyph.openContours):
- if not open:
- continue
- has_open = True
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.OPEN_PATH,
- "master": name,
- "master_idx": master_idx,
- "contour": ix,
- },
- )
- if has_open:
- continue
- matchings = [None] * len(glyphsets)
- for m1idx in order:
- glyph1 = allGlyphs[m1idx]
- if glyph1 is None or not glyph1.nodeTypes:
- continue
- m0idx = grand_parent(m1idx, glyph_name)
- if m0idx is None:
- continue
- glyph0 = allGlyphs[m0idx]
- if glyph0 is None or not glyph0.nodeTypes:
- continue
- #
- # Basic compatibility checks
- #
- m1 = glyph0.nodeTypes
- m0 = glyph1.nodeTypes
- if len(m0) != len(m1):
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.PATH_COUNT,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value_1": len(m0),
- "value_2": len(m1),
- },
- )
- continue
- if m0 != m1:
- for pathIx, (nodes1, nodes2) in enumerate(zip(m0, m1)):
- if nodes1 == nodes2:
- continue
- if len(nodes1) != len(nodes2):
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.NODE_COUNT,
- "path": pathIx,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value_1": len(nodes1),
- "value_2": len(nodes2),
- },
- )
- continue
- for nodeIx, (n1, n2) in enumerate(zip(nodes1, nodes2)):
- if n1 != n2:
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.NODE_INCOMPATIBILITY,
- "path": pathIx,
- "node": nodeIx,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value_1": n1,
- "value_2": n2,
- },
- )
- continue
- #
- # InterpolatableProblem.CONTOUR_ORDER check
- #
- this_tolerance, matching = test_contour_order(glyph0, glyph1)
- if this_tolerance < tolerance:
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.CONTOUR_ORDER,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value_1": list(range(len(matching))),
- "value_2": matching,
- "tolerance": this_tolerance,
- },
- )
- matchings[m1idx] = matching
- #
- # wrong-start-point / weight check
- #
- m0Isomorphisms = glyph0.isomorphisms
- m1Isomorphisms = glyph1.isomorphisms
- m0Vectors = glyph0.greenVectors
- m1Vectors = glyph1.greenVectors
- recording0 = glyph0.recordings
- recording1 = glyph1.recordings
- # If contour-order is wrong, adjust it
- matching = matchings[m1idx]
- if (
- matching is not None and m1Isomorphisms
- ): # m1 is empty for composite glyphs
- m1Isomorphisms = [m1Isomorphisms[i] for i in matching]
- m1Vectors = [m1Vectors[i] for i in matching]
- recording1 = [recording1[i] for i in matching]
- midRecording = []
- for c0, c1 in zip(recording0, recording1):
- try:
- r = RecordingPen()
- r.value = list(lerpRecordings(c0.value, c1.value))
- midRecording.append(r)
- except ValueError:
- # Mismatch because of the reordering above
- midRecording.append(None)
- for ix, (contour0, contour1) in enumerate(
- zip(m0Isomorphisms, m1Isomorphisms)
- ):
- if (
- contour0 is None
- or contour1 is None
- or len(contour0) == 0
- or len(contour0) != len(contour1)
- ):
- # We already reported this; or nothing to do; or not compatible
- # after reordering above.
- continue
- this_tolerance, proposed_point, reverse = test_starting_point(
- glyph0, glyph1, ix, tolerance, matching
- )
- if this_tolerance < tolerance:
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.WRONG_START_POINT,
- "contour": ix,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value_1": 0,
- "value_2": proposed_point,
- "reversed": reverse,
- "tolerance": this_tolerance,
- },
- )
- # Weight check.
- #
- # If contour could be mid-interpolated, and the two
- # contours have the same area sign, proceeed.
- #
- # The sign difference can happen if it's a weirdo
- # self-intersecting contour; ignore it.
- contour = midRecording[ix]
- if contour and (m0Vectors[ix][0] < 0) == (m1Vectors[ix][0] < 0):
- midStats = StatisticsPen(glyphset=None)
- contour.replay(midStats)
- midVector = contour_vector_from_stats(midStats)
- m0Vec = m0Vectors[ix]
- m1Vec = m1Vectors[ix]
- size0 = m0Vec[0] * m0Vec[0]
- size1 = m1Vec[0] * m1Vec[0]
- midSize = midVector[0] * midVector[0]
- for overweight, problem_type in enumerate(
- (
- InterpolatableProblem.UNDERWEIGHT,
- InterpolatableProblem.OVERWEIGHT,
- )
- ):
- if overweight:
- expectedSize = max(size0, size1)
- continue
- else:
- expectedSize = sqrt(size0 * size1)
- log.debug(
- "%s: actual size %g; threshold size %g, master sizes: %g, %g",
- problem_type,
- midSize,
- expectedSize,
- size0,
- size1,
- )
- if (
- not overweight and expectedSize * tolerance > midSize + 1e-5
- ) or (overweight and 1e-5 + expectedSize / tolerance < midSize):
- try:
- if overweight:
- this_tolerance = expectedSize / midSize
- else:
- this_tolerance = midSize / expectedSize
- except ZeroDivisionError:
- this_tolerance = 0
- log.debug("tolerance %g", this_tolerance)
- yield (
- glyph_name,
- {
- "type": problem_type,
- "contour": ix,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "tolerance": this_tolerance,
- },
- )
- #
- # "kink" detector
- #
- m0 = glyph0.points
- m1 = glyph1.points
- # If contour-order is wrong, adjust it
- if matchings[m1idx] is not None and m1: # m1 is empty for composite glyphs
- m1 = [m1[i] for i in matchings[m1idx]]
- t = 0.1 # ~sin(radian(6)) for tolerance 0.95
- deviation_threshold = (
- upem * DEFAULT_KINKINESS_LENGTH * DEFAULT_KINKINESS / kinkiness
- )
- for ix, (contour0, contour1) in enumerate(zip(m0, m1)):
- if (
- contour0 is None
- or contour1 is None
- or len(contour0) == 0
- or len(contour0) != len(contour1)
- ):
- # We already reported this; or nothing to do; or not compatible
- # after reordering above.
- continue
- # Walk the contour, keeping track of three consecutive points, with
- # middle one being an on-curve. If the three are co-linear then
- # check for kinky-ness.
- for i in range(len(contour0)):
- pt0 = contour0[i]
- pt1 = contour1[i]
- if not pt0[1] or not pt1[1]:
- # Skip off-curves
- continue
- pt0_prev = contour0[i - 1]
- pt1_prev = contour1[i - 1]
- pt0_next = contour0[(i + 1) % len(contour0)]
- pt1_next = contour1[(i + 1) % len(contour1)]
- if pt0_prev[1] and pt1_prev[1]:
- # At least one off-curve is required
- continue
- if pt0_prev[1] and pt1_prev[1]:
- # At least one off-curve is required
- continue
- pt0 = complex(*pt0[0])
- pt1 = complex(*pt1[0])
- pt0_prev = complex(*pt0_prev[0])
- pt1_prev = complex(*pt1_prev[0])
- pt0_next = complex(*pt0_next[0])
- pt1_next = complex(*pt1_next[0])
- # We have three consecutive points. Check whether
- # they are colinear.
- d0_prev = pt0 - pt0_prev
- d0_next = pt0_next - pt0
- d1_prev = pt1 - pt1_prev
- d1_next = pt1_next - pt1
- sin0 = d0_prev.real * d0_next.imag - d0_prev.imag * d0_next.real
- sin1 = d1_prev.real * d1_next.imag - d1_prev.imag * d1_next.real
- try:
- sin0 /= abs(d0_prev) * abs(d0_next)
- sin1 /= abs(d1_prev) * abs(d1_next)
- except ZeroDivisionError:
- continue
- if abs(sin0) > t or abs(sin1) > t:
- # Not colinear / not smooth.
- continue
- # Check the mid-point is actually, well, in the middle.
- dot0 = d0_prev.real * d0_next.real + d0_prev.imag * d0_next.imag
- dot1 = d1_prev.real * d1_next.real + d1_prev.imag * d1_next.imag
- if dot0 < 0 or dot1 < 0:
- # Sharp corner.
- continue
- # Fine, if handle ratios are similar...
- r0 = abs(d0_prev) / (abs(d0_prev) + abs(d0_next))
- r1 = abs(d1_prev) / (abs(d1_prev) + abs(d1_next))
- r_diff = abs(r0 - r1)
- if abs(r_diff) < t:
- # Smooth enough.
- continue
- mid = (pt0 + pt1) / 2
- mid_prev = (pt0_prev + pt1_prev) / 2
- mid_next = (pt0_next + pt1_next) / 2
- mid_d0 = mid - mid_prev
- mid_d1 = mid_next - mid
- sin_mid = mid_d0.real * mid_d1.imag - mid_d0.imag * mid_d1.real
- try:
- sin_mid /= abs(mid_d0) * abs(mid_d1)
- except ZeroDivisionError:
- continue
- # ...or if the angles are similar.
- if abs(sin_mid) * (tolerance * kinkiness) <= t:
- # Smooth enough.
- continue
- # How visible is the kink?
- cross = sin_mid * abs(mid_d0) * abs(mid_d1)
- arc_len = abs(mid_d0 + mid_d1)
- deviation = abs(cross / arc_len)
- if deviation < deviation_threshold:
- continue
- deviation_ratio = deviation / arc_len
- if deviation_ratio > t:
- continue
- this_tolerance = t / (abs(sin_mid) * kinkiness)
- log.debug(
- "kink: deviation %g; deviation_ratio %g; sin_mid %g; r_diff %g",
- deviation,
- deviation_ratio,
- sin_mid,
- r_diff,
- )
- log.debug("tolerance %g", this_tolerance)
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.KINK,
- "contour": ix,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- "value": i,
- "tolerance": this_tolerance,
- },
- )
- #
- # --show-all
- #
- if show_all:
- yield (
- glyph_name,
- {
- "type": InterpolatableProblem.NOTHING,
- "master_1": names[m0idx],
- "master_2": names[m1idx],
- "master_1_idx": m0idx,
- "master_2_idx": m1idx,
- },
- )
- @wraps(test_gen)
- def test(*args, **kwargs):
- problems = defaultdict(list)
- for glyphname, problem in test_gen(*args, **kwargs):
- problems[glyphname].append(problem)
- return problems
- def recursivelyAddGlyph(glyphname, glyphset, ttGlyphSet, glyf):
- if glyphname in glyphset:
- return
- glyphset[glyphname] = ttGlyphSet[glyphname]
- for component in getattr(glyf[glyphname], "components", []):
- recursivelyAddGlyph(component.glyphName, glyphset, ttGlyphSet, glyf)
- def ensure_parent_dir(path):
- dirname = os.path.dirname(path)
- if dirname:
- os.makedirs(dirname, exist_ok=True)
- return path
- def main(args=None):
- """Test for interpolatability issues between fonts"""
- import argparse
- import sys
- parser = argparse.ArgumentParser(
- "fonttools varLib.interpolatable",
- description=main.__doc__,
- )
- parser.add_argument(
- "--glyphs",
- action="store",
- help="Space-separate name of glyphs to check",
- )
- parser.add_argument(
- "--show-all",
- action="store_true",
- help="Show all glyph pairs, even if no problems are found",
- )
- parser.add_argument(
- "--tolerance",
- action="store",
- type=float,
- help="Error tolerance. Between 0 and 1. Default %s" % DEFAULT_TOLERANCE,
- )
- parser.add_argument(
- "--kinkiness",
- action="store",
- type=float,
- help="How aggressively report kinks. Default %s" % DEFAULT_KINKINESS,
- )
- parser.add_argument(
- "--json",
- action="store_true",
- help="Output report in JSON format",
- )
- parser.add_argument(
- "--pdf",
- action="store",
- help="Output report in PDF format",
- )
- parser.add_argument(
- "--ps",
- action="store",
- help="Output report in PostScript format",
- )
- parser.add_argument(
- "--html",
- action="store",
- help="Output report in HTML format",
- )
- parser.add_argument(
- "--quiet",
- action="store_true",
- help="Only exit with code 1 or 0, no output",
- )
- parser.add_argument(
- "--output",
- action="store",
- help="Output file for the problem report; Default: stdout",
- )
- parser.add_argument(
- "--ignore-missing",
- action="store_true",
- help="Will not report glyphs missing from sparse masters as errors",
- )
- parser.add_argument(
- "inputs",
- metavar="FILE",
- type=str,
- nargs="+",
- help="Input a single variable font / DesignSpace / Glyphs file, or multiple TTF/UFO files",
- )
- parser.add_argument(
- "--name",
- metavar="NAME",
- type=str,
- action="append",
- help="Name of the master to use in the report. If not provided, all are used.",
- )
- parser.add_argument("-v", "--verbose", action="store_true", help="Run verbosely.")
- parser.add_argument("--debug", action="store_true", help="Run with debug output.")
- args = parser.parse_args(args)
- from fontTools import configLogger
- configLogger(level=("INFO" if args.verbose else "WARNING"))
- if args.debug:
- configLogger(level="DEBUG")
- glyphs = args.glyphs.split() if args.glyphs else None
- from os.path import basename
- fonts = []
- names = []
- locations = []
- discrete_axes = set()
- upem = DEFAULT_UPEM
- original_args_inputs = tuple(args.inputs)
- if len(args.inputs) == 1:
- designspace = None
- if args.inputs[0].endswith(".designspace"):
- from fontTools.designspaceLib import DesignSpaceDocument
- designspace = DesignSpaceDocument.fromfile(args.inputs[0])
- args.inputs = [master.path for master in designspace.sources]
- locations = [master.location for master in designspace.sources]
- discrete_axes = {
- a.name for a in designspace.axes if not hasattr(a, "minimum")
- }
- axis_triples = {
- a.name: (a.minimum, a.default, a.maximum)
- for a in designspace.axes
- if a.name not in discrete_axes
- }
- axis_mappings = {a.name: a.map for a in designspace.axes}
- axis_triples = {
- k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
- for k, vv in axis_triples.items()
- }
- elif args.inputs[0].endswith((".glyphs", ".glyphspackage")):
- from glyphsLib import GSFont, to_designspace
- gsfont = GSFont(args.inputs[0])
- upem = gsfont.upm
- designspace = to_designspace(gsfont)
- fonts = [source.font for source in designspace.sources]
- names = ["%s-%s" % (f.info.familyName, f.info.styleName) for f in fonts]
- args.inputs = []
- locations = [master.location for master in designspace.sources]
- axis_triples = {
- a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes
- }
- axis_mappings = {a.name: a.map for a in designspace.axes}
- axis_triples = {
- k: tuple(piecewiseLinearMap(v, dict(axis_mappings[k])) for v in vv)
- for k, vv in axis_triples.items()
- }
- elif args.inputs[0].endswith(".ttf") or args.inputs[0].endswith(".otf"):
- from fontTools.ttLib import TTFont
- # Is variable font?
- font = TTFont(args.inputs[0])
- upem = font["head"].unitsPerEm
- fvar = font["fvar"]
- axisMapping = {}
- for axis in fvar.axes:
- axisMapping[axis.axisTag] = {
- -1: axis.minValue,
- 0: axis.defaultValue,
- 1: axis.maxValue,
- }
- normalized = False
- if "avar" in font:
- avar = font["avar"]
- if getattr(avar.table, "VarStore", None):
- axisMapping = {tag: {-1: -1, 0: 0, 1: 1} for tag in axisMapping}
- normalized = True
- else:
- for axisTag, segments in avar.segments.items():
- fvarMapping = axisMapping[axisTag].copy()
- for location, value in segments.items():
- axisMapping[axisTag][value] = piecewiseLinearMap(
- location, fvarMapping
- )
- # Gather all glyphs at their "master" locations
- ttGlyphSets = {}
- glyphsets = defaultdict(dict)
- if "gvar" in font:
- gvar = font["gvar"]
- glyf = font["glyf"]
- if glyphs is None:
- glyphs = sorted(gvar.variations.keys())
- for glyphname in glyphs:
- for var in gvar.variations[glyphname]:
- locDict = {}
- loc = []
- for tag, val in sorted(var.axes.items()):
- locDict[tag] = val[1]
- loc.append((tag, val[1]))
- locTuple = tuple(loc)
- if locTuple not in ttGlyphSets:
- ttGlyphSets[locTuple] = font.getGlyphSet(
- location=locDict, normalized=True, recalcBounds=False
- )
- recursivelyAddGlyph(
- glyphname, glyphsets[locTuple], ttGlyphSets[locTuple], glyf
- )
- elif "CFF2" in font:
- fvarAxes = font["fvar"].axes
- cff2 = font["CFF2"].cff.topDictIndex[0]
- charstrings = cff2.CharStrings
- if glyphs is None:
- glyphs = sorted(charstrings.keys())
- for glyphname in glyphs:
- cs = charstrings[glyphname]
- private = cs.private
- # Extract vsindex for the glyph
- vsindices = {getattr(private, "vsindex", 0)}
- vsindex = getattr(private, "vsindex", 0)
- last_op = 0
- # The spec says vsindex can only appear once and must be the first
- # operator in the charstring, but we support multiple.
- # https://github.com/harfbuzz/boring-expansion-spec/issues/158
- for op in enumerate(cs.program):
- if op == "blend":
- vsindices.add(vsindex)
- elif op == "vsindex":
- assert isinstance(last_op, int)
- vsindex = last_op
- last_op = op
- if not hasattr(private, "vstore"):
- continue
- varStore = private.vstore.otVarStore
- for vsindex in vsindices:
- varData = varStore.VarData[vsindex]
- for regionIndex in varData.VarRegionIndex:
- region = varStore.VarRegionList.Region[regionIndex]
- locDict = {}
- loc = []
- for axisIndex, axis in enumerate(region.VarRegionAxis):
- tag = fvarAxes[axisIndex].axisTag
- val = axis.PeakCoord
- locDict[tag] = val
- loc.append((tag, val))
- locTuple = tuple(loc)
- if locTuple not in ttGlyphSets:
- ttGlyphSets[locTuple] = font.getGlyphSet(
- location=locDict,
- normalized=True,
- recalcBounds=False,
- )
- glyphset = glyphsets[locTuple]
- glyphset[glyphname] = ttGlyphSets[locTuple][glyphname]
- names = ["''"]
- fonts = [font.getGlyphSet()]
- locations = [{}]
- axis_triples = {a: (-1, 0, +1) for a in sorted(axisMapping.keys())}
- for locTuple in sorted(glyphsets.keys(), key=lambda v: (len(v), v)):
- name = (
- "'"
- + " ".join(
- "%s=%s"
- % (
- k,
- floatToFixedToStr(
- piecewiseLinearMap(v, axisMapping[k]), 14
- ),
- )
- for k, v in locTuple
- )
- + "'"
- )
- if normalized:
- name += " (normalized)"
- names.append(name)
- fonts.append(glyphsets[locTuple])
- locations.append(dict(locTuple))
- args.ignore_missing = True
- args.inputs = []
- if not locations:
- locations = [{} for _ in fonts]
- for filename in args.inputs:
- if filename.endswith(".ufo"):
- from fontTools.ufoLib import UFOReader
- font = UFOReader(filename)
- info = SimpleNamespace()
- font.readInfo(info)
- upem = info.unitsPerEm
- fonts.append(font)
- else:
- from fontTools.ttLib import TTFont
- font = TTFont(filename)
- upem = font["head"].unitsPerEm
- fonts.append(font)
- names.append(basename(filename).rsplit(".", 1)[0])
- if len(fonts) < 2:
- log.warning("Font file does not seem to be variable. Nothing to check.")
- return
- glyphsets = []
- for font in fonts:
- if hasattr(font, "getGlyphSet"):
- glyphset = font.getGlyphSet()
- else:
- glyphset = font
- glyphsets.append({k: glyphset[k] for k in glyphset.keys()})
- if args.name:
- accepted_names = set(args.name)
- glyphsets = [
- glyphset
- for name, glyphset in zip(names, glyphsets)
- if name in accepted_names
- ]
- locations = [
- location
- for name, location in zip(names, locations)
- if name in accepted_names
- ]
- names = [name for name in names if name in accepted_names]
- if not glyphs:
- glyphs = sorted(set([gn for glyphset in glyphsets for gn in glyphset.keys()]))
- glyphsSet = set(glyphs)
- for glyphset in glyphsets:
- glyphSetGlyphNames = set(glyphset.keys())
- diff = glyphsSet - glyphSetGlyphNames
- if diff:
- for gn in diff:
- glyphset[gn] = None
- # Normalize locations
- locations = [
- {
- **normalizeLocation(loc, axis_triples),
- **{k: v for k, v in loc.items() if k in discrete_axes},
- }
- for loc in locations
- ]
- tolerance = args.tolerance or DEFAULT_TOLERANCE
- kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS
- try:
- log.info("Running on %d glyphsets", len(glyphsets))
- log.info("Locations: %s", pformat(locations))
- problems_gen = test_gen(
- glyphsets,
- glyphs=glyphs,
- names=names,
- locations=locations,
- upem=upem,
- ignore_missing=args.ignore_missing,
- tolerance=tolerance,
- kinkiness=kinkiness,
- show_all=args.show_all,
- discrete_axes=discrete_axes,
- )
- problems = defaultdict(list)
- f = (
- sys.stdout
- if args.output is None
- else open(ensure_parent_dir(args.output), "w")
- )
- if not args.quiet:
- if args.json:
- import json
- for glyphname, problem in problems_gen:
- problems[glyphname].append(problem)
- print(json.dumps(problems), file=f)
- else:
- last_glyphname = None
- for glyphname, p in problems_gen:
- problems[glyphname].append(p)
- if glyphname != last_glyphname:
- print(f"Glyph {glyphname} was not compatible:", file=f)
- last_glyphname = glyphname
- last_master_idxs = None
- master_idxs = (
- (p["master_idx"],)
- if "master_idx" in p
- else (p["master_1_idx"], p["master_2_idx"])
- )
- if master_idxs != last_master_idxs:
- master_names = (
- (p["master"],)
- if "master" in p
- else (p["master_1"], p["master_2"])
- )
- print(f" Masters: %s:" % ", ".join(master_names), file=f)
- last_master_idxs = master_idxs
- if p["type"] == InterpolatableProblem.MISSING:
- print(
- " Glyph was missing in master %s" % p["master"], file=f
- )
- elif p["type"] == InterpolatableProblem.OPEN_PATH:
- print(
- " Glyph has an open path in master %s" % p["master"],
- file=f,
- )
- elif p["type"] == InterpolatableProblem.PATH_COUNT:
- print(
- " Path count differs: %i in %s, %i in %s"
- % (
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.NODE_COUNT:
- print(
- " Node count differs in path %i: %i in %s, %i in %s"
- % (
- p["path"],
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY:
- print(
- " Node %o incompatible in path %i: %s in %s, %s in %s"
- % (
- p["node"],
- p["path"],
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.CONTOUR_ORDER:
- print(
- " Contour order differs: %s in %s, %s in %s"
- % (
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.WRONG_START_POINT:
- print(
- " Contour %d start point differs: %s in %s, %s in %s; reversed: %s"
- % (
- p["contour"],
- p["value_1"],
- p["master_1"],
- p["value_2"],
- p["master_2"],
- p["reversed"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.UNDERWEIGHT:
- print(
- " Contour %d interpolation is underweight: %s, %s"
- % (
- p["contour"],
- p["master_1"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.OVERWEIGHT:
- print(
- " Contour %d interpolation is overweight: %s, %s"
- % (
- p["contour"],
- p["master_1"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.KINK:
- print(
- " Contour %d has a kink at %s: %s, %s"
- % (
- p["contour"],
- p["value"],
- p["master_1"],
- p["master_2"],
- ),
- file=f,
- )
- elif p["type"] == InterpolatableProblem.NOTHING:
- print(
- " Showing %s and %s"
- % (
- p["master_1"],
- p["master_2"],
- ),
- file=f,
- )
- else:
- for glyphname, problem in problems_gen:
- problems[glyphname].append(problem)
- problems = sort_problems(problems)
- for p in "ps", "pdf":
- arg = getattr(args, p)
- if arg is None:
- continue
- log.info("Writing %s to %s", p.upper(), arg)
- from .interpolatablePlot import InterpolatablePS, InterpolatablePDF
- PlotterClass = InterpolatablePS if p == "ps" else InterpolatablePDF
- with PlotterClass(
- ensure_parent_dir(arg), glyphsets=glyphsets, names=names
- ) as doc:
- doc.add_title_page(
- original_args_inputs, tolerance=tolerance, kinkiness=kinkiness
- )
- if problems:
- doc.add_summary(problems)
- doc.add_problems(problems)
- if not problems and not args.quiet:
- doc.draw_cupcake()
- if problems:
- doc.add_index()
- doc.add_table_of_contents()
- if args.html:
- log.info("Writing HTML to %s", args.html)
- from .interpolatablePlot import InterpolatableSVG
- svgs = []
- glyph_starts = {}
- with InterpolatableSVG(svgs, glyphsets=glyphsets, names=names) as svg:
- svg.add_title_page(
- original_args_inputs,
- show_tolerance=False,
- tolerance=tolerance,
- kinkiness=kinkiness,
- )
- for glyph, glyph_problems in problems.items():
- glyph_starts[len(svgs)] = glyph
- svg.add_problems(
- {glyph: glyph_problems},
- show_tolerance=False,
- show_page_number=False,
- )
- if not problems and not args.quiet:
- svg.draw_cupcake()
- import base64
- with open(ensure_parent_dir(args.html), "wb") as f:
- f.write(b"<!DOCTYPE html>\n")
- f.write(
- b'<html><body align="center" style="font-family: sans-serif; text-color: #222">\n'
- )
- f.write(b"<title>fonttools varLib.interpolatable report</title>\n")
- for i, svg in enumerate(svgs):
- if i in glyph_starts:
- f.write(f"<h1>Glyph {glyph_starts[i]}</h1>\n".encode("utf-8"))
- f.write("<img src='data:image/svg+xml;base64,".encode("utf-8"))
- f.write(base64.b64encode(svg))
- f.write(b"' />\n")
- f.write(b"<hr>\n")
- f.write(b"</body></html>\n")
- except Exception as e:
- e.args += original_args_inputs
- log.error(e)
- raise
- if problems:
- return problems
- if __name__ == "__main__":
- import sys
- problems = main()
- sys.exit(int(bool(problems)))
|