# Source code for fontTools.designspaceLib.split

    """Allows building all the variable fonts of a DesignSpace version 5 by
    splitting the document into interpolable sub-space, then into each VF.
    """

    from __future__ import annotations

    import itertools
    import logging
    import math
    from typing import Any, Callable, Dict, Iterator, List, Tuple, cast

    from fontTools.designspaceLib import (
        AxisDescriptor,
        DesignSpaceDocument,
        DiscreteAxisDescriptor,
        InstanceDescriptor,
        RuleDescriptor,
        SimpleLocationDict,
        SourceDescriptor,
        VariableFontDescriptor,
    )
    from fontTools.designspaceLib.statNames import StatNames, getStatNames
    from fontTools.designspaceLib.types import (
        ConditionSet,
        Range,
        Region,
        getVFUserRegion,
        locationInRegion,
        regionInRegion,
        userRegionToDesignRegion,
    )

    LOGGER = logging.getLogger(__name__)

    MakeInstanceFilenameCallable = Callable[
        [DesignSpaceDocument, InstanceDescriptor, StatNames], str
    ]

    def defaultMakeInstanceFilename(
        doc: DesignSpaceDocument, instance: InstanceDescriptor, statNames: StatNames
    ) -> str:
        """Default callable to synthesize an instance filename
        when makeNames=True, for instances that don't specify an instance name
        in the designspace. This part of the name generation can be overriden
        because it's not specified by the STAT table.
        """
        familyName = instance.familyName or statNames.familyNames.get("en")
        styleName = instance.styleName or statNames.styleNames.get("en")
        return f"{familyName}-{styleName}.ttf"

    def splitInterpolable(
        doc: DesignSpaceDocument,
        makeNames: bool = True,
        expandLocations: bool = True,
        makeInstanceFilename: MakeInstanceFilenameCallable = defaultMakeInstanceFilename,
    ) -> Iterator[Tuple[SimpleLocationDict, DesignSpaceDocument]]:
        """Split the given DS5 into several interpolable sub-designspaces.
        There are as many interpolable sub-spaces as there are combinations of
        discrete axis values.

        E.g. with axes:
            - italic (discrete) Upright or Italic
            - style (discrete) Sans or Serif
            - weight (continuous) 100 to 900

        There are 4 sub-spaces in which the Weight axis should interpolate:
        (Upright, Sans), (Upright, Serif), (Italic, Sans) and (Italic, Serif).

        The sub-designspaces still include the full axis definitions and STAT data,
        but the rules, sources, variable fonts, instances are trimmed down to only
        keep what falls within the interpolable sub-space.

        Args:
          - ``makeNames``: Whether to compute the instance family and style
            names using the STAT data.
          - ``expandLocations``: Whether to turn all locations into "full"
            locations, including implicit default axis values where missing.
          - ``makeInstanceFilename``: Callable to synthesize an instance filename
            when makeNames=True, for instances that don't specify an instance name
            in the designspace. This part of the name generation can be overridden
            because it's not specified by the STAT table.

        .. versionadded:: 5.0
        """
        discreteAxes = []
        interpolableUserRegion: Region = {}
        for axis in doc.axes:
            if hasattr(axis, "values"):
                # Mypy doesn't support narrowing union types via hasattr()
                # TODO(Python 3.10): use TypeGuard
                # https://mypy.readthedocs.io/en/stable/type_narrowing.html
                axis = cast(DiscreteAxisDescriptor, axis)
                discreteAxes.append(axis)
            else:
                axis = cast(AxisDescriptor, axis)
                interpolableUserRegion[axis.name] = Range(
                    axis.minimum,
                    axis.maximum,
                    axis.default,
                )
        valueCombinations = itertools.product(*[axis.values for axis in discreteAxes])
        for values in valueCombinations:
            discreteUserLocation = {
                discreteAxis.name: value
                for discreteAxis, value in zip(discreteAxes, values)
            }
            subDoc = _extractSubSpace(
                doc,
                {**interpolableUserRegion, **discreteUserLocation},
                keepVFs=True,
                makeNames=makeNames,
                expandLocations=expandLocations,
                makeInstanceFilename=makeInstanceFilename,
            )
            yield discreteUserLocation, subDoc

    def splitVariableFonts(
        doc: DesignSpaceDocument,
        makeNames: bool = False,
        expandLocations: bool = False,
        makeInstanceFilename: MakeInstanceFilenameCallable = defaultMakeInstanceFilename,
    ) -> Iterator[Tuple[str, DesignSpaceDocument]]:
        """Convert each variable font listed in this document into a standalone
        designspace. This can be used to compile all the variable fonts from a
        format 5 designspace using tools that can only deal with 1 VF at a time.

        Args:
          - ``makeNames``: Whether to compute the instance family and style
            names using the STAT data.
          - ``expandLocations``: Whether to turn all locations into "full"
            locations, including implicit default axis values where missing.
          - ``makeInstanceFilename``: Callable to synthesize an instance filename
            when makeNames=True, for instances that don't specify an instance name
            in the designspace. This part of the name generation can be overridden
            because it's not specified by the STAT table.

        .. versionadded:: 5.0
        """
        # Make one DesignspaceDoc v5 for each variable font
        for vf in doc.getVariableFonts():
            vfUserRegion = getVFUserRegion(doc, vf)
            vfDoc = _extractSubSpace(
                doc,
                vfUserRegion,
                keepVFs=False,
                makeNames=makeNames,
                expandLocations=expandLocations,
                makeInstanceFilename=makeInstanceFilename,
            )
            vfDoc.lib = {**vfDoc.lib, **vf.lib}
            yield vf.name, vfDoc

    def convert5to4(
        doc: DesignSpaceDocument,
    ) -> Dict[str, DesignSpaceDocument]:
        """Convert each variable font listed in this document into a standalone
        format 4 designspace. This can be used to compile all the variable fonts
        from a format 5 designspace using tools that only know about format 4.

        .. versionadded:: 5.0
        """
        vfs = {}
        for _location, subDoc in splitInterpolable(doc):
            for vfName, vfDoc in splitVariableFonts(subDoc):
                vfDoc.formatVersion = "4.1"
                vfs[vfName] = vfDoc
        return vfs

    def _extractSubSpace(
        doc: DesignSpaceDocument,
        userRegion: Region,
        *,
        keepVFs: bool,
        makeNames: bool,
        expandLocations: bool,
        makeInstanceFilename: MakeInstanceFilenameCallable,
    ) -> DesignSpaceDocument:
        subDoc = DesignSpaceDocument()
        # Don't include STAT info
        # FIXME: (Jany) let's think about it. Not include = OK because the point of
        # the splitting is to build VFs and we'll use the STAT data of the full
        # document to generate the STAT of the VFs, so "no need" to have STAT data
        # in sub-docs. Counterpoint: what if someone wants to split this DS for
        # other purposes?  Maybe for that it would be useful to also subset the STAT
        # data?
        # subDoc.elidedFallbackName = doc.elidedFallbackName

        def maybeExpandDesignLocation(object):
            if expandLocations:
                return object.getFullDesignLocation(doc)
            else:
                return object.designLocation

        for axis in doc.axes:
            range = userRegion[axis.name]
            if isinstance(range, Range) and hasattr(axis, "minimum"):
                # Mypy doesn't support narrowing union types via hasattr()
                # TODO(Python 3.10): use TypeGuard
                # https://mypy.readthedocs.io/en/stable/type_narrowing.html
                axis = cast(AxisDescriptor, axis)
                subDoc.addAxis(
                    AxisDescriptor(
                        # Same info
                        tag=axis.tag,
                        name=axis.name,
                        labelNames=axis.labelNames,
                        hidden=axis.hidden,
                        # Subset range
                        minimum=max(range.minimum, axis.minimum),
                        default=range.default or axis.default,
                        maximum=min(range.maximum, axis.maximum),
                        map=[
                            (user, design)
                            for user, design in axis.map
                            if range.minimum <= user <= range.maximum
                        ],
                        # Don't include STAT info
                        axisOrdering=None,
                        axisLabels=None,
                    )
                )

        # Don't include STAT info
        # subDoc.locationLabels = doc.locationLabels

        # Rules: subset them based on conditions
        designRegion = userRegionToDesignRegion(doc, userRegion)
        subDoc.rules = _subsetRulesBasedOnConditions(doc.rules, designRegion)
        subDoc.rulesProcessingLast = doc.rulesProcessingLast

        # Sources: keep only the ones that fall within the kept axis ranges
        for source in doc.sources:
            if not locationInRegion(doc.map_backward(source.designLocation), userRegion):
                continue

            subDoc.addSource(
                SourceDescriptor(
                    filename=source.filename,
                    path=source.path,
                    font=source.font,
                    name=source.name,
                    designLocation=_filterLocation(
                        userRegion, maybeExpandDesignLocation(source)
                    ),
                    layerName=source.layerName,
                    familyName=source.familyName,
                    styleName=source.styleName,
                    muteKerning=source.muteKerning,
                    muteInfo=source.muteInfo,
                    mutedGlyphNames=source.mutedGlyphNames,
                )
            )

        # Copy family name translations from the old default source to the new default
        vfDefault = subDoc.findDefault()
        oldDefault = doc.findDefault()
        if vfDefault is not None and oldDefault is not None:
            vfDefault.localisedFamilyName = oldDefault.localisedFamilyName

        # Variable fonts: keep only the ones that fall within the kept axis ranges
        if keepVFs:
            # Note: call getVariableFont() to make the implicit VFs explicit
            for vf in doc.getVariableFonts():
                vfUserRegion = getVFUserRegion(doc, vf)
                if regionInRegion(vfUserRegion, userRegion):
                    subDoc.addVariableFont(
                        VariableFontDescriptor(
                            name=vf.name,
                            filename=vf.filename,
                            axisSubsets=[
                                axisSubset
                                for axisSubset in vf.axisSubsets
                                if isinstance(userRegion[axisSubset.name], Range)
                            ],
                            lib=vf.lib,
                        )
                    )

        # Instances: same as Sources + compute missing names
        for instance in doc.instances:
            if not locationInRegion(instance.getFullUserLocation(doc), userRegion):
                continue

            if makeNames:
                statNames = getStatNames(doc, instance.getFullUserLocation(doc))
                familyName = instance.familyName or statNames.familyNames.get("en")
                styleName = instance.styleName or statNames.styleNames.get("en")
                subDoc.addInstance(
                    InstanceDescriptor(
                        filename=instance.filename
                        or makeInstanceFilename(doc, instance, statNames),
                        path=instance.path,
                        font=instance.font,
                        name=instance.name or f"{familyName} {styleName}",
                        userLocation={} if expandLocations else instance.userLocation,
                        designLocation=_filterLocation(
                            userRegion, maybeExpandDesignLocation(instance)
                        ),
                        familyName=familyName,
                        styleName=styleName,
                        postScriptFontName=instance.postScriptFontName
                        or statNames.postScriptFontName,
                        styleMapFamilyName=instance.styleMapFamilyName
                        or statNames.styleMapFamilyNames.get("en"),
                        styleMapStyleName=instance.styleMapStyleName
                        or statNames.styleMapStyleName,
                        localisedFamilyName=instance.localisedFamilyName
                        or statNames.familyNames,
                        localisedStyleName=instance.localisedStyleName
                        or statNames.styleNames,
                        localisedStyleMapFamilyName=instance.localisedStyleMapFamilyName
                        or statNames.styleMapFamilyNames,
                        localisedStyleMapStyleName=instance.localisedStyleMapStyleName
                        or {},
                        lib=instance.lib,
                    )
                )
            else:
                subDoc.addInstance(
                    InstanceDescriptor(
                        filename=instance.filename,
                        path=instance.path,
                        font=instance.font,
                        name=instance.name,
                        userLocation={} if expandLocations else instance.userLocation,
                        designLocation=_filterLocation(
                            userRegion, maybeExpandDesignLocation(instance)
                        ),
                        familyName=instance.familyName,
                        styleName=instance.styleName,
                        postScriptFontName=instance.postScriptFontName,
                        styleMapFamilyName=instance.styleMapFamilyName,
                        styleMapStyleName=instance.styleMapStyleName,
                        localisedFamilyName=instance.localisedFamilyName,
                        localisedStyleName=instance.localisedStyleName,
                        localisedStyleMapFamilyName=instance.localisedStyleMapFamilyName,
                        localisedStyleMapStyleName=instance.localisedStyleMapStyleName,
                        lib=instance.lib,
                    )
                )

        subDoc.lib = doc.lib

        return subDoc

    def _conditionSetFrom(conditionSet: List[Dict[str, Any]]) -> ConditionSet:
        c: Dict[str, Range] = {}
        for condition in conditionSet:
            minimum, maximum = condition.get("minimum"), condition.get("maximum")
            c[condition["name"]] = Range(
                minimum if minimum is not None else -math.inf,
                maximum if maximum is not None else math.inf,
            )
        return c

    def _subsetRulesBasedOnConditions(
        rules: List[RuleDescriptor], designRegion: Region
    ) -> List[RuleDescriptor]:
        # What rules to keep:
        #  - Keep the rule if any conditionset is relevant.
        #  - A conditionset is relevant if all conditions are relevant or it is empty.
        #  - A condition is relevant if
        #    - axis is point (C-AP),
        #       - and point in condition's range (C-AP-in)
        #            (in this case remove the condition because it's always true)
        #       - else (C-AP-out) whole conditionset can be discarded (condition false
        #         => conditionset false)
        #    - axis is range (C-AR),
        #       - (C-AR-all) and axis range fully contained in condition range: we can
        #         scrap the condition because it's always true
        #       - (C-AR-inter) and intersection(axis range, condition range) not empty:
        #         keep the condition with the smaller range (= intersection)
        #       - (C-AR-none) else, whole conditionset can be discarded
        newRules: List[RuleDescriptor] = []
        for rule in rules:
            newRule: RuleDescriptor = RuleDescriptor(
                name=rule.name, conditionSets=[], subs=rule.subs
            )
            for conditionset in rule.conditionSets:
                cs = _conditionSetFrom(conditionset)
                newConditionset: List[Dict[str, Any]] = []
                discardConditionset = False
                for selectionName, selectionValue in designRegion.items():
                    # TODO: Ensure that all(key in conditionset for key in region.keys())?
                    if selectionName not in cs:
                        # raise Exception("Selection has different axes than the rules")
                        continue
                    if isinstance(selectionValue, (float, int)):  # is point
                        # Case C-AP-in
                        if selectionValue in cs[selectionName]:
                            pass  # always matches, conditionset can stay empty for this one.
                        # Case C-AP-out
                        else:
                            discardConditionset = True
                    else:  # is range
                        # Case C-AR-all
                        if selectionValue in cs[selectionName]:
                            pass  # always matches, conditionset can stay empty for this one.
                        else:
                            intersection = cs[selectionName].intersection(selectionValue)
                            # Case C-AR-inter
                            if intersection is not None:
                                newConditionset.append(
                                    {
                                        "name": selectionName,
                                        "minimum": intersection.minimum,
                                        "maximum": intersection.maximum,
                                    }
                                )
                            # Case C-AR-none
                            else:
                                discardConditionset = True
                if not discardConditionset:
                    newRule.conditionSets.append(newConditionset)
            if newRule.conditionSets:
                newRules.append(newRule)

        return newRules

    def _filterLocation(
        userRegion: Region,
        location: Dict[str, float],
    ) -> Dict[str, float]:
        return {
            name: value
            for name, value in location.items()
            if name in userRegion and isinstance(userRegion[name], Range)
        }