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