xref: /aosp_15_r20/external/fonttools/Lib/fontTools/designspaceLib/split.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""Allows building all the variable fonts of a DesignSpace version 5 by
2splitting the document into interpolable sub-space, then into each VF.
3"""
4
5from __future__ import annotations
6
7import itertools
8import logging
9import math
10from typing import Any, Callable, Dict, Iterator, List, Tuple, cast
11
12from fontTools.designspaceLib import (
13    AxisDescriptor,
14    AxisMappingDescriptor,
15    DesignSpaceDocument,
16    DiscreteAxisDescriptor,
17    InstanceDescriptor,
18    RuleDescriptor,
19    SimpleLocationDict,
20    SourceDescriptor,
21    VariableFontDescriptor,
22)
23from fontTools.designspaceLib.statNames import StatNames, getStatNames
24from fontTools.designspaceLib.types import (
25    ConditionSet,
26    Range,
27    Region,
28    getVFUserRegion,
29    locationInRegion,
30    regionInRegion,
31    userRegionToDesignRegion,
32)
33
34LOGGER = logging.getLogger(__name__)
35
36MakeInstanceFilenameCallable = Callable[
37    [DesignSpaceDocument, InstanceDescriptor, StatNames], str
38]
39
40
41def defaultMakeInstanceFilename(
42    doc: DesignSpaceDocument, instance: InstanceDescriptor, statNames: StatNames
43) -> str:
44    """Default callable to synthesize an instance filename
45    when makeNames=True, for instances that don't specify an instance name
46    in the designspace. This part of the name generation can be overriden
47    because it's not specified by the STAT table.
48    """
49    familyName = instance.familyName or statNames.familyNames.get("en")
50    styleName = instance.styleName or statNames.styleNames.get("en")
51    return f"{familyName}-{styleName}.ttf"
52
53
54def splitInterpolable(
55    doc: DesignSpaceDocument,
56    makeNames: bool = True,
57    expandLocations: bool = True,
58    makeInstanceFilename: MakeInstanceFilenameCallable = defaultMakeInstanceFilename,
59) -> Iterator[Tuple[SimpleLocationDict, DesignSpaceDocument]]:
60    """Split the given DS5 into several interpolable sub-designspaces.
61    There are as many interpolable sub-spaces as there are combinations of
62    discrete axis values.
63
64    E.g. with axes:
65        - italic (discrete) Upright or Italic
66        - style (discrete) Sans or Serif
67        - weight (continuous) 100 to 900
68
69    There are 4 sub-spaces in which the Weight axis should interpolate:
70    (Upright, Sans), (Upright, Serif), (Italic, Sans) and (Italic, Serif).
71
72    The sub-designspaces still include the full axis definitions and STAT data,
73    but the rules, sources, variable fonts, instances are trimmed down to only
74    keep what falls within the interpolable sub-space.
75
76    Args:
77      - ``makeNames``: Whether to compute the instance family and style
78        names using the STAT data.
79      - ``expandLocations``: Whether to turn all locations into "full"
80        locations, including implicit default axis values where missing.
81      - ``makeInstanceFilename``: Callable to synthesize an instance filename
82        when makeNames=True, for instances that don't specify an instance name
83        in the designspace. This part of the name generation can be overridden
84        because it's not specified by the STAT table.
85
86    .. versionadded:: 5.0
87    """
88    discreteAxes = []
89    interpolableUserRegion: Region = {}
90    for axis in doc.axes:
91        if hasattr(axis, "values"):
92            # Mypy doesn't support narrowing union types via hasattr()
93            # TODO(Python 3.10): use TypeGuard
94            # https://mypy.readthedocs.io/en/stable/type_narrowing.html
95            axis = cast(DiscreteAxisDescriptor, axis)
96            discreteAxes.append(axis)
97        else:
98            axis = cast(AxisDescriptor, axis)
99            interpolableUserRegion[axis.name] = Range(
100                axis.minimum,
101                axis.maximum,
102                axis.default,
103            )
104    valueCombinations = itertools.product(*[axis.values for axis in discreteAxes])
105    for values in valueCombinations:
106        discreteUserLocation = {
107            discreteAxis.name: value
108            for discreteAxis, value in zip(discreteAxes, values)
109        }
110        subDoc = _extractSubSpace(
111            doc,
112            {**interpolableUserRegion, **discreteUserLocation},
113            keepVFs=True,
114            makeNames=makeNames,
115            expandLocations=expandLocations,
116            makeInstanceFilename=makeInstanceFilename,
117        )
118        yield discreteUserLocation, subDoc
119
120
121def splitVariableFonts(
122    doc: DesignSpaceDocument,
123    makeNames: bool = False,
124    expandLocations: bool = False,
125    makeInstanceFilename: MakeInstanceFilenameCallable = defaultMakeInstanceFilename,
126) -> Iterator[Tuple[str, DesignSpaceDocument]]:
127    """Convert each variable font listed in this document into a standalone
128    designspace. This can be used to compile all the variable fonts from a
129    format 5 designspace using tools that can only deal with 1 VF at a time.
130
131    Args:
132      - ``makeNames``: Whether to compute the instance family and style
133        names using the STAT data.
134      - ``expandLocations``: Whether to turn all locations into "full"
135        locations, including implicit default axis values where missing.
136      - ``makeInstanceFilename``: Callable to synthesize an instance filename
137        when makeNames=True, for instances that don't specify an instance name
138        in the designspace. This part of the name generation can be overridden
139        because it's not specified by the STAT table.
140
141    .. versionadded:: 5.0
142    """
143    # Make one DesignspaceDoc v5 for each variable font
144    for vf in doc.getVariableFonts():
145        vfUserRegion = getVFUserRegion(doc, vf)
146        vfDoc = _extractSubSpace(
147            doc,
148            vfUserRegion,
149            keepVFs=False,
150            makeNames=makeNames,
151            expandLocations=expandLocations,
152            makeInstanceFilename=makeInstanceFilename,
153        )
154        vfDoc.lib = {**vfDoc.lib, **vf.lib}
155        yield vf.name, vfDoc
156
157
158def convert5to4(
159    doc: DesignSpaceDocument,
160) -> Dict[str, DesignSpaceDocument]:
161    """Convert each variable font listed in this document into a standalone
162    format 4 designspace. This can be used to compile all the variable fonts
163    from a format 5 designspace using tools that only know about format 4.
164
165    .. versionadded:: 5.0
166    """
167    vfs = {}
168    for _location, subDoc in splitInterpolable(doc):
169        for vfName, vfDoc in splitVariableFonts(subDoc):
170            vfDoc.formatVersion = "4.1"
171            vfs[vfName] = vfDoc
172    return vfs
173
174
175def _extractSubSpace(
176    doc: DesignSpaceDocument,
177    userRegion: Region,
178    *,
179    keepVFs: bool,
180    makeNames: bool,
181    expandLocations: bool,
182    makeInstanceFilename: MakeInstanceFilenameCallable,
183) -> DesignSpaceDocument:
184    subDoc = DesignSpaceDocument()
185    # Don't include STAT info
186    # FIXME: (Jany) let's think about it. Not include = OK because the point of
187    # the splitting is to build VFs and we'll use the STAT data of the full
188    # document to generate the STAT of the VFs, so "no need" to have STAT data
189    # in sub-docs. Counterpoint: what if someone wants to split this DS for
190    # other purposes?  Maybe for that it would be useful to also subset the STAT
191    # data?
192    # subDoc.elidedFallbackName = doc.elidedFallbackName
193
194    def maybeExpandDesignLocation(object):
195        if expandLocations:
196            return object.getFullDesignLocation(doc)
197        else:
198            return object.designLocation
199
200    for axis in doc.axes:
201        range = userRegion[axis.name]
202        if isinstance(range, Range) and hasattr(axis, "minimum"):
203            # Mypy doesn't support narrowing union types via hasattr()
204            # TODO(Python 3.10): use TypeGuard
205            # https://mypy.readthedocs.io/en/stable/type_narrowing.html
206            axis = cast(AxisDescriptor, axis)
207            subDoc.addAxis(
208                AxisDescriptor(
209                    # Same info
210                    tag=axis.tag,
211                    name=axis.name,
212                    labelNames=axis.labelNames,
213                    hidden=axis.hidden,
214                    # Subset range
215                    minimum=max(range.minimum, axis.minimum),
216                    default=range.default or axis.default,
217                    maximum=min(range.maximum, axis.maximum),
218                    map=[
219                        (user, design)
220                        for user, design in axis.map
221                        if range.minimum <= user <= range.maximum
222                    ],
223                    # Don't include STAT info
224                    axisOrdering=None,
225                    axisLabels=None,
226                )
227            )
228
229    subDoc.axisMappings = mappings = []
230    subDocAxes = {axis.name for axis in subDoc.axes}
231    for mapping in doc.axisMappings:
232        if not all(axis in subDocAxes for axis in mapping.inputLocation.keys()):
233            continue
234        if not all(axis in subDocAxes for axis in mapping.outputLocation.keys()):
235            LOGGER.error(
236                "In axis mapping from input %s, some output axes are not in the variable-font: %s",
237                mapping.inputLocation,
238                mapping.outputLocation,
239            )
240            continue
241
242        mappingAxes = set()
243        mappingAxes.update(mapping.inputLocation.keys())
244        mappingAxes.update(mapping.outputLocation.keys())
245        for axis in doc.axes:
246            if axis.name not in mappingAxes:
247                continue
248            range = userRegion[axis.name]
249            if (
250                range.minimum != axis.minimum
251                or (range.default is not None and range.default != axis.default)
252                or range.maximum != axis.maximum
253            ):
254                LOGGER.error(
255                    "Limiting axis ranges used in <mapping> elements not supported: %s",
256                    axis.name,
257                )
258                continue
259
260        mappings.append(
261            AxisMappingDescriptor(
262                inputLocation=mapping.inputLocation,
263                outputLocation=mapping.outputLocation,
264            )
265        )
266
267    # Don't include STAT info
268    # subDoc.locationLabels = doc.locationLabels
269
270    # Rules: subset them based on conditions
271    designRegion = userRegionToDesignRegion(doc, userRegion)
272    subDoc.rules = _subsetRulesBasedOnConditions(doc.rules, designRegion)
273    subDoc.rulesProcessingLast = doc.rulesProcessingLast
274
275    # Sources: keep only the ones that fall within the kept axis ranges
276    for source in doc.sources:
277        if not locationInRegion(doc.map_backward(source.designLocation), userRegion):
278            continue
279
280        subDoc.addSource(
281            SourceDescriptor(
282                filename=source.filename,
283                path=source.path,
284                font=source.font,
285                name=source.name,
286                designLocation=_filterLocation(
287                    userRegion, maybeExpandDesignLocation(source)
288                ),
289                layerName=source.layerName,
290                familyName=source.familyName,
291                styleName=source.styleName,
292                muteKerning=source.muteKerning,
293                muteInfo=source.muteInfo,
294                mutedGlyphNames=source.mutedGlyphNames,
295            )
296        )
297
298    # Copy family name translations from the old default source to the new default
299    vfDefault = subDoc.findDefault()
300    oldDefault = doc.findDefault()
301    if vfDefault is not None and oldDefault is not None:
302        vfDefault.localisedFamilyName = oldDefault.localisedFamilyName
303
304    # Variable fonts: keep only the ones that fall within the kept axis ranges
305    if keepVFs:
306        # Note: call getVariableFont() to make the implicit VFs explicit
307        for vf in doc.getVariableFonts():
308            vfUserRegion = getVFUserRegion(doc, vf)
309            if regionInRegion(vfUserRegion, userRegion):
310                subDoc.addVariableFont(
311                    VariableFontDescriptor(
312                        name=vf.name,
313                        filename=vf.filename,
314                        axisSubsets=[
315                            axisSubset
316                            for axisSubset in vf.axisSubsets
317                            if isinstance(userRegion[axisSubset.name], Range)
318                        ],
319                        lib=vf.lib,
320                    )
321                )
322
323    # Instances: same as Sources + compute missing names
324    for instance in doc.instances:
325        if not locationInRegion(instance.getFullUserLocation(doc), userRegion):
326            continue
327
328        if makeNames:
329            statNames = getStatNames(doc, instance.getFullUserLocation(doc))
330            familyName = instance.familyName or statNames.familyNames.get("en")
331            styleName = instance.styleName or statNames.styleNames.get("en")
332            subDoc.addInstance(
333                InstanceDescriptor(
334                    filename=instance.filename
335                    or makeInstanceFilename(doc, instance, statNames),
336                    path=instance.path,
337                    font=instance.font,
338                    name=instance.name or f"{familyName} {styleName}",
339                    userLocation={} if expandLocations else instance.userLocation,
340                    designLocation=_filterLocation(
341                        userRegion, maybeExpandDesignLocation(instance)
342                    ),
343                    familyName=familyName,
344                    styleName=styleName,
345                    postScriptFontName=instance.postScriptFontName
346                    or statNames.postScriptFontName,
347                    styleMapFamilyName=instance.styleMapFamilyName
348                    or statNames.styleMapFamilyNames.get("en"),
349                    styleMapStyleName=instance.styleMapStyleName
350                    or statNames.styleMapStyleName,
351                    localisedFamilyName=instance.localisedFamilyName
352                    or statNames.familyNames,
353                    localisedStyleName=instance.localisedStyleName
354                    or statNames.styleNames,
355                    localisedStyleMapFamilyName=instance.localisedStyleMapFamilyName
356                    or statNames.styleMapFamilyNames,
357                    localisedStyleMapStyleName=instance.localisedStyleMapStyleName
358                    or {},
359                    lib=instance.lib,
360                )
361            )
362        else:
363            subDoc.addInstance(
364                InstanceDescriptor(
365                    filename=instance.filename,
366                    path=instance.path,
367                    font=instance.font,
368                    name=instance.name,
369                    userLocation={} if expandLocations else instance.userLocation,
370                    designLocation=_filterLocation(
371                        userRegion, maybeExpandDesignLocation(instance)
372                    ),
373                    familyName=instance.familyName,
374                    styleName=instance.styleName,
375                    postScriptFontName=instance.postScriptFontName,
376                    styleMapFamilyName=instance.styleMapFamilyName,
377                    styleMapStyleName=instance.styleMapStyleName,
378                    localisedFamilyName=instance.localisedFamilyName,
379                    localisedStyleName=instance.localisedStyleName,
380                    localisedStyleMapFamilyName=instance.localisedStyleMapFamilyName,
381                    localisedStyleMapStyleName=instance.localisedStyleMapStyleName,
382                    lib=instance.lib,
383                )
384            )
385
386    subDoc.lib = doc.lib
387
388    return subDoc
389
390
391def _conditionSetFrom(conditionSet: List[Dict[str, Any]]) -> ConditionSet:
392    c: Dict[str, Range] = {}
393    for condition in conditionSet:
394        minimum, maximum = condition.get("minimum"), condition.get("maximum")
395        c[condition["name"]] = Range(
396            minimum if minimum is not None else -math.inf,
397            maximum if maximum is not None else math.inf,
398        )
399    return c
400
401
402def _subsetRulesBasedOnConditions(
403    rules: List[RuleDescriptor], designRegion: Region
404) -> List[RuleDescriptor]:
405    # What rules to keep:
406    #  - Keep the rule if any conditionset is relevant.
407    #  - A conditionset is relevant if all conditions are relevant or it is empty.
408    #  - A condition is relevant if
409    #    - axis is point (C-AP),
410    #       - and point in condition's range (C-AP-in)
411    #            (in this case remove the condition because it's always true)
412    #       - else (C-AP-out) whole conditionset can be discarded (condition false
413    #         => conditionset false)
414    #    - axis is range (C-AR),
415    #       - (C-AR-all) and axis range fully contained in condition range: we can
416    #         scrap the condition because it's always true
417    #       - (C-AR-inter) and intersection(axis range, condition range) not empty:
418    #         keep the condition with the smaller range (= intersection)
419    #       - (C-AR-none) else, whole conditionset can be discarded
420    newRules: List[RuleDescriptor] = []
421    for rule in rules:
422        newRule: RuleDescriptor = RuleDescriptor(
423            name=rule.name, conditionSets=[], subs=rule.subs
424        )
425        for conditionset in rule.conditionSets:
426            cs = _conditionSetFrom(conditionset)
427            newConditionset: List[Dict[str, Any]] = []
428            discardConditionset = False
429            for selectionName, selectionValue in designRegion.items():
430                # TODO: Ensure that all(key in conditionset for key in region.keys())?
431                if selectionName not in cs:
432                    # raise Exception("Selection has different axes than the rules")
433                    continue
434                if isinstance(selectionValue, (float, int)):  # is point
435                    # Case C-AP-in
436                    if selectionValue in cs[selectionName]:
437                        pass  # always matches, conditionset can stay empty for this one.
438                    # Case C-AP-out
439                    else:
440                        discardConditionset = True
441                else:  # is range
442                    # Case C-AR-all
443                    if selectionValue in cs[selectionName]:
444                        pass  # always matches, conditionset can stay empty for this one.
445                    else:
446                        intersection = cs[selectionName].intersection(selectionValue)
447                        # Case C-AR-inter
448                        if intersection is not None:
449                            newConditionset.append(
450                                {
451                                    "name": selectionName,
452                                    "minimum": intersection.minimum,
453                                    "maximum": intersection.maximum,
454                                }
455                            )
456                        # Case C-AR-none
457                        else:
458                            discardConditionset = True
459            if not discardConditionset:
460                newRule.conditionSets.append(newConditionset)
461        if newRule.conditionSets:
462            newRules.append(newRule)
463
464    return newRules
465
466
467def _filterLocation(
468    userRegion: Region,
469    location: Dict[str, float],
470) -> Dict[str, float]:
471    return {
472        name: value
473        for name, value in location.items()
474        if name in userRegion and isinstance(userRegion[name], Range)
475    }
476