split.py 19 KB

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