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