Coverage for /usr/lib/python3/dist-packages/fontTools/designspaceLib/split.py: 14%
132 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
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"""
5from __future__ import annotations
7import itertools
8import logging
9import math
10from typing import Any, Callable, Dict, Iterator, List, Tuple, cast
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)
34LOGGER = logging.getLogger(__name__)
36MakeInstanceFilenameCallable = Callable[
37 [DesignSpaceDocument, InstanceDescriptor, StatNames], str
38]
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"
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.
64 E.g. with axes:
65 - italic (discrete) Upright or Italic
66 - style (discrete) Sans or Serif
67 - weight (continuous) 100 to 900
69 There are 4 sub-spaces in which the Weight axis should interpolate:
70 (Upright, Sans), (Upright, Serif), (Italic, Sans) and (Italic, Serif).
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.
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.
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
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.
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.
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
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.
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
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
194 def maybeExpandDesignLocation(object):
195 if expandLocations:
196 return object.getFullDesignLocation(doc)
197 else:
198 return object.designLocation
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 )
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
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
260 mappings.append(
261 AxisMappingDescriptor(
262 inputLocation=mapping.inputLocation,
263 outputLocation=mapping.outputLocation,
264 )
265 )
267 # Don't include STAT info
268 # subDoc.locationLabels = doc.locationLabels
270 # Rules: subset them based on conditions
271 designRegion = userRegionToDesignRegion(doc, userRegion)
272 subDoc.rules = _subsetRulesBasedOnConditions(doc.rules, designRegion)
273 subDoc.rulesProcessingLast = doc.rulesProcessingLast
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
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 )
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
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 )
323 # Instances: same as Sources + compute missing names
324 for instance in doc.instances:
325 if not locationInRegion(instance.getFullUserLocation(doc), userRegion):
326 continue
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 )
386 subDoc.lib = doc.lib
388 return subDoc
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
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)
464 return newRules
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 }