# Source code for fontTools.varLib.models

    """Variation fonts interpolation models."""

    __all__ = [
        "normalizeValue",
        "normalizeLocation",
        "supportScalar",
        "VariationModel",
    ]

    from fontTools.misc.roundTools import noRound
    from .errors import VariationModelError

    def nonNone(lst):
        return [l for l in lst if l is not None]

    def allNone(lst):
        return all(l is None for l in lst)

    def allEqualTo(ref, lst, mapper=None):
        if mapper is None:
            return all(ref == item for item in lst)

        mapped = mapper(ref)
        return all(mapped == mapper(item) for item in lst)

    def allEqual(lst, mapper=None):
        if not lst:
            return True
        it = iter(lst)
        try:
            first = next(it)
        except StopIteration:
            return True
        return allEqualTo(first, it, mapper=mapper)

    def subList(truth, lst):
        assert len(truth) == len(lst)
        return [l for l, t in zip(lst, truth) if t]

    def normalizeValue(v, triple):
        """Normalizes value based on a min/default/max triple.

          >>> normalizeValue(400, (100, 400, 900))
          0.0
          >>> normalizeValue(100, (100, 400, 900))
          -1.0
          >>> normalizeValue(650, (100, 400, 900))
          0.5
        """
        lower, default, upper = triple
        if not (lower <= default <= upper):
            raise ValueError(
                f"Invalid axis values, must be minimum, default, maximum: "
                f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
            )
        v = max(min(v, upper), lower)
        if v == default:
            v = 0.0
        elif v < default:
            v = (v - default) / (default - lower)
        else:
            v = (v - default) / (upper - default)
        return v

    def normalizeLocation(location, axes):
        """Normalizes location based on axis min/default/max values from axes.

          >>> axes = {"wght": (100, 400, 900)}
          >>> normalizeLocation({"wght": 400}, axes)
          {'wght': 0.0}
          >>> normalizeLocation({"wght": 100}, axes)
          {'wght': -1.0}
          >>> normalizeLocation({"wght": 900}, axes)
          {'wght': 1.0}
          >>> normalizeLocation({"wght": 650}, axes)
          {'wght': 0.5}
          >>> normalizeLocation({"wght": 1000}, axes)
          {'wght': 1.0}
          >>> normalizeLocation({"wght": 0}, axes)
          {'wght': -1.0}
          >>> axes = {"wght": (0, 0, 1000)}
          >>> normalizeLocation({"wght": 0}, axes)
          {'wght': 0.0}
          >>> normalizeLocation({"wght": -1}, axes)
          {'wght': 0.0}
          >>> normalizeLocation({"wght": 1000}, axes)
          {'wght': 1.0}
          >>> normalizeLocation({"wght": 500}, axes)
          {'wght': 0.5}
          >>> normalizeLocation({"wght": 1001}, axes)
          {'wght': 1.0}
          >>> axes = {"wght": (0, 1000, 1000)}
          >>> normalizeLocation({"wght": 0}, axes)
          {'wght': -1.0}
          >>> normalizeLocation({"wght": -1}, axes)
          {'wght': -1.0}
          >>> normalizeLocation({"wght": 500}, axes)
          {'wght': -0.5}
          >>> normalizeLocation({"wght": 1000}, axes)
          {'wght': 0.0}
          >>> normalizeLocation({"wght": 1001}, axes)
          {'wght': 0.0}
        """
        out = {}
        for tag, triple in axes.items():
            v = location.get(tag, triple[1])
            out[tag] = normalizeValue(v, triple)
        return out

    def supportScalar(location, support, ot=True, extrapolate=False):
        """Returns the scalar multiplier at location, for a master
        with support.  If ot is True, then a peak value of zero
        for support of an axis means "axis does not participate".  That
        is how OpenType Variation Font technology works.

          >>> supportScalar({}, {})
          1.0
          >>> supportScalar({'wght':.2}, {})
          1.0
          >>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
          0.1
          >>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
          0.75
          >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
          0.75
          >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
          0.375
          >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
          0.75
          >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
          0.75
          >>> supportScalar({'wght':4}, {'wght':(0,2,3)}, extrapolate=True)
          2.0
          >>> supportScalar({'wght':4}, {'wght':(0,2,2)}, extrapolate=True)
          2.0
        """
        scalar = 1.0
        for axis, (lower, peak, upper) in support.items():
            if ot:
                # OpenType-specific case handling
                if peak == 0.0:
                    continue
                if lower > peak or peak > upper:
                    continue
                if lower < 0.0 and upper > 0.0:
                    continue
                v = location.get(axis, 0.0)
            else:
                assert axis in location
                v = location[axis]
            if v == peak:
                continue

            if extrapolate:
                if v < -1 and lower <= -1:
                    if peak <= -1 and peak < upper:
                        scalar *= (v - upper) / (peak - upper)
                        continue
                    elif -1 < peak:
                        scalar *= (v - lower) / (peak - lower)
                        continue
                elif +1 < v and +1 <= upper:
                    if +1 <= peak and lower < peak:
                        scalar *= (v - lower) / (peak - lower)
                        continue
                    elif peak < +1:
                        scalar *= (v - upper) / (peak - upper)
                        continue

            if v <= lower or upper <= v:
                scalar = 0.0
                break

            if v < peak:
                scalar *= (v - lower) / (peak - lower)
            else:  # v > peak
                scalar *= (v - upper) / (peak - upper)
        return scalar

    class VariationModel(object):
        """Locations must have the base master at the origin (ie. 0).

        If the extrapolate argument is set to True, then location values are
        interpretted in the normalized space, ie. in the [-1,+1] range, and
        values are extrapolated outside this range.

          >>> from pprint import pprint
          >>> locations = [ \
          {'wght':100}, \
          {'wght':-100}, \
          {'wght':-180}, \
          {'wdth':+.3}, \
          {'wght':+120,'wdth':.3}, \
          {'wght':+120,'wdth':.2}, \
          {}, \
          {'wght':+180,'wdth':.3}, \
          {'wght':+180}, \
          ]
          >>> model = VariationModel(locations, axisOrder=['wght'])
          >>> pprint(model.locations)
          [{},
           {'wght': -100},
           {'wght': -180},
           {'wght': 100},
           {'wght': 180},
           {'wdth': 0.3},
           {'wdth': 0.3, 'wght': 180},
           {'wdth': 0.3, 'wght': 120},
           {'wdth': 0.2, 'wght': 120}]
          >>> pprint(model.deltaWeights)
          [{},
           {0: 1.0},
           {0: 1.0},
           {0: 1.0},
           {0: 1.0},
           {0: 1.0},
           {0: 1.0, 4: 1.0, 5: 1.0},
           {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
           {0: 1.0,
            3: 0.75,
            4: 0.25,
            5: 0.6666666666666667,
            6: 0.4444444444444445,
            7: 0.6666666666666667}]
        """

        def __init__(self, locations, axisOrder=None, extrapolate=False):

            if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
                raise VariationModelError("Locations must be unique.")

            self.origLocations = locations
            self.axisOrder = axisOrder if axisOrder is not None else []
            self.extrapolate = extrapolate

            locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
            keyFunc = self.getMasterLocationsSortKeyFunc(
                locations, axisOrder=self.axisOrder
            )
            self.locations = sorted(locations, key=keyFunc)

            # Mapping from user's master order to our master order
            self.mapping = [self.locations.index(l) for l in locations]
            self.reverseMapping = [locations.index(l) for l in self.locations]

            self._computeMasterSupports()
            self._subModels = {}

        def getSubModel(self, items):
            if None not in items:
                return self, items
            key = tuple(v is not None for v in items)
            subModel = self._subModels.get(key)
            if subModel is None:
                subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
                self._subModels[key] = subModel
            return subModel, subList(key, items)

        @staticmethod
        def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
            if {} not in locations:
                raise VariationModelError("Base master not found.")
            axisPoints = {}
            for loc in locations:
                if len(loc) != 1:
                    continue
                axis = next(iter(loc))
                value = loc[axis]
                if axis not in axisPoints:
                    axisPoints[axis] = {0.0}
                assert (
                    value not in axisPoints[axis]
                ), 'Value "%s" in axisPoints["%s"] -->  %s' % (value, axis, axisPoints)
                axisPoints[axis].add(value)

            def getKey(axisPoints, axisOrder):
                def sign(v):
                    return -1 if v < 0 else +1 if v > 0 else 0

                def key(loc):
                    rank = len(loc)
                    onPointAxes = [
                        axis
                        for axis, value in loc.items()
                        if axis in axisPoints and value in axisPoints[axis]
                    ]
                    orderedAxes = [axis for axis in axisOrder if axis in loc]
                    orderedAxes.extend(
                        [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
                    )
                    return (
                        rank,  # First, order by increasing rank
                        -len(onPointAxes),  # Next, by decreasing number of onPoint axes
                        tuple(
                            axisOrder.index(axis) if axis in axisOrder else 0x10000
                            for axis in orderedAxes
                        ),  # Next, by known axes
                        tuple(orderedAxes),  # Next, by all axes
                        tuple(
                            sign(loc[axis]) for axis in orderedAxes
                        ),  # Next, by signs of axis values
                        tuple(
                            abs(loc[axis]) for axis in orderedAxes
                        ),  # Next, by absolute value of axis values
                    )

                return key

            ret = getKey(axisPoints, axisOrder)
            return ret

        def reorderMasters(self, master_list, mapping):
            # For changing the master data order without
            # recomputing supports and deltaWeights.
            new_list = [master_list[idx] for idx in mapping]
            self.origLocations = [self.origLocations[idx] for idx in mapping]
            locations = [
                {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
            ]
            self.mapping = [self.locations.index(l) for l in locations]
            self.reverseMapping = [locations.index(l) for l in self.locations]
            self._subModels = {}
            return new_list

        def _computeMasterSupports(self):
            self.supports = []
            regions = self._locationsToRegions()
            for i, region in enumerate(regions):
                locAxes = set(region.keys())
                # Walk over previous masters now
                for prev_region in regions[:i]:
                    # Master with extra axes do not participte
                    if not set(prev_region.keys()).issubset(locAxes):
                        continue
                    # If it's NOT in the current box, it does not participate
                    relevant = True
                    for axis, (lower, peak, upper) in region.items():
                        if axis not in prev_region or not (
                            prev_region[axis][1] == peak
                            or lower < prev_region[axis][1] < upper
                        ):
                            relevant = False
                            break
                    if not relevant:
                        continue

                    # Split the box for new master; split in whatever direction
                    # that has largest range ratio.
                    #
                    # For symmetry, we actually cut across multiple axes
                    # if they have the largest, equal, ratio.
                    # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804

                    bestAxes = {}
                    bestRatio = -1
                    for axis in prev_region.keys():
                        val = prev_region[axis][1]
                        assert axis in region
                        lower, locV, upper = region[axis]
                        newLower, newUpper = lower, upper
                        if val < locV:
                            newLower = val
                            ratio = (val - locV) / (lower - locV)
                        elif locV < val:
                            newUpper = val
                            ratio = (val - locV) / (upper - locV)
                        else:  # val == locV
                            # Can't split box in this direction.
                            continue
                        if ratio > bestRatio:
                            bestAxes = {}
                            bestRatio = ratio
                        if ratio == bestRatio:
                            bestAxes[axis] = (newLower, locV, newUpper)

                    for axis, triple in bestAxes.items():
                        region[axis] = triple
                self.supports.append(region)
            self._computeDeltaWeights()

        def _locationsToRegions(self):
            locations = self.locations
            # Compute min/max across each axis, use it as total range.
            # TODO Take this as input from outside?
            minV = {}
            maxV = {}
            for l in locations:
                for k, v in l.items():
                    minV[k] = min(v, minV.get(k, v))
                    maxV[k] = max(v, maxV.get(k, v))

            regions = []
            for loc in locations:
                region = {}
                for axis, locV in loc.items():
                    if locV > 0:
                        region[axis] = (0, locV, maxV[axis])
                    else:
                        region[axis] = (minV[axis], locV, 0)
                regions.append(region)
            return regions

        def _computeDeltaWeights(self):
            self.deltaWeights = []
            for i, loc in enumerate(self.locations):
                deltaWeight = {}
                # Walk over previous masters now, populate deltaWeight
                for j, support in enumerate(self.supports[:i]):
                    scalar = supportScalar(loc, support)
                    if scalar:
                        deltaWeight[j] = scalar
                self.deltaWeights.append(deltaWeight)

        def getDeltas(self, masterValues, *, round=noRound):
            assert len(masterValues) == len(self.deltaWeights)
            mapping = self.reverseMapping
            out = []
            for i, weights in enumerate(self.deltaWeights):
                delta = masterValues[mapping[i]]
                for j, weight in weights.items():
                    if weight == 1:
                        delta -= out[j]
                    else:
                        delta -= out[j] * weight
                out.append(round(delta))
            return out

        def getDeltasAndSupports(self, items, *, round=noRound):
            model, items = self.getSubModel(items)
            return model.getDeltas(items, round=round), model.supports

        def getScalars(self, loc):
            return [supportScalar(loc, support, extrapolate=self.extrapolate)
                    for support in self.supports]

        @staticmethod
        def interpolateFromDeltasAndScalars(deltas, scalars):
            v = None
            assert len(deltas) == len(scalars)
            for delta, scalar in zip(deltas, scalars):
                if not scalar:
                    continue
                contribution = delta * scalar
                if v is None:
                    v = contribution
                else:
                    v += contribution
            return v

        def interpolateFromDeltas(self, loc, deltas):
            scalars = self.getScalars(loc)
            return self.interpolateFromDeltasAndScalars(deltas, scalars)

        def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
            deltas = self.getDeltas(masterValues, round=round)
            return self.interpolateFromDeltas(loc, deltas)

        def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
            deltas = self.getDeltas(masterValues, round=round)
            return self.interpolateFromDeltasAndScalars(deltas, scalars)

    def piecewiseLinearMap(v, mapping):
        keys = mapping.keys()
        if not keys:
            return v
        if v in keys:
            return mapping[v]
        k = min(keys)
        if v < k:
            return v + mapping[k] - k
        k = max(keys)
        if v > k:
            return v + mapping[k] - k
        # Interpolate
        a = max(k for k in keys if k < v)
        b = min(k for k in keys if k > v)
        va = mapping[a]
        vb = mapping[b]
        return va + (vb - va) * (v - a) / (b - a)

    def main(args=None):
        """Normalize locations on a given designspace"""
        from fontTools import configLogger
        import argparse

        parser = argparse.ArgumentParser(
            "fonttools varLib.models",
            description=main.__doc__,
        )
        parser.add_argument(
            "--loglevel",
            metavar="LEVEL",
            default="INFO",
            help="Logging level (defaults to INFO)",
        )

        group = parser.add_mutually_exclusive_group(required=True)
        group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
        group.add_argument(
            "-l",
            "--locations",
            metavar="LOCATION",
            nargs="+",
            help="Master locations as comma-separate coordinates. One must be all zeros.",
        )

        args = parser.parse_args(args)

        configLogger(level=args.loglevel)
        from pprint import pprint

        if args.designspace:
            from fontTools.designspaceLib import DesignSpaceDocument

            doc = DesignSpaceDocument()
            doc.read(args.designspace)
            locs = [s.location for s in doc.sources]
            print("Original locations:")
            pprint(locs)
            doc.normalize()
            print("Normalized locations:")
            locs = [s.location for s in doc.sources]
            pprint(locs)
        else:
            axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
            locs = [
                dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
            ]

        model = VariationModel(locs)
        print("Sorted locations:")
        pprint(model.locations)
        print("Supports:")
        pprint(model.supports)

    if __name__ == "__main__":
        import doctest, sys

        if len(sys.argv) > 1:
            sys.exit(main())

        sys.exit(doctest.testmod().failed)