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

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 }