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