Coverage for /usr/lib/python3/dist-packages/fontTools/designspaceLib/statNames.py: 21%

89 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1"""Compute name information for a given location in user-space coordinates 

2using STAT data. This can be used to fill-in automatically the names of an 

3instance: 

4 

5.. code:: python 

6 

7 instance = doc.instances[0] 

8 names = getStatNames(doc, instance.getFullUserLocation(doc)) 

9 print(names.styleNames) 

10""" 

11from __future__ import annotations 

12 

13from dataclasses import dataclass 

14from typing import Dict, Optional, Tuple, Union 

15import logging 

16 

17from fontTools.designspaceLib import ( 

18 AxisDescriptor, 

19 AxisLabelDescriptor, 

20 DesignSpaceDocument, 

21 DesignSpaceDocumentError, 

22 DiscreteAxisDescriptor, 

23 SimpleLocationDict, 

24 SourceDescriptor, 

25) 

26 

27LOGGER = logging.getLogger(__name__) 

28 

29# TODO(Python 3.8): use Literal 

30# RibbiStyleName = Union[Literal["regular"], Literal["bold"], Literal["italic"], Literal["bold italic"]] 

31RibbiStyle = str 

32BOLD_ITALIC_TO_RIBBI_STYLE = { 

33 (False, False): "regular", 

34 (False, True): "italic", 

35 (True, False): "bold", 

36 (True, True): "bold italic", 

37} 

38 

39 

40@dataclass 

41class StatNames: 

42 """Name data generated from the STAT table information.""" 

43 

44 familyNames: Dict[str, str] 

45 styleNames: Dict[str, str] 

46 postScriptFontName: Optional[str] 

47 styleMapFamilyNames: Dict[str, str] 

48 styleMapStyleName: Optional[RibbiStyle] 

49 

50 

51def getStatNames( 

52 doc: DesignSpaceDocument, userLocation: SimpleLocationDict 

53) -> StatNames: 

54 """Compute the family, style, PostScript names of the given ``userLocation`` 

55 using the document's STAT information. 

56 

57 Also computes localizations. 

58 

59 If not enough STAT data is available for a given name, either its dict of 

60 localized names will be empty (family and style names), or the name will be 

61 None (PostScript name). 

62 

63 .. versionadded:: 5.0 

64 """ 

65 familyNames: Dict[str, str] = {} 

66 defaultSource: Optional[SourceDescriptor] = doc.findDefault() 

67 if defaultSource is None: 

68 LOGGER.warning("Cannot determine default source to look up family name.") 

69 elif defaultSource.familyName is None: 

70 LOGGER.warning( 

71 "Cannot look up family name, assign the 'familyname' attribute to the default source." 

72 ) 

73 else: 

74 familyNames = { 

75 "en": defaultSource.familyName, 

76 **defaultSource.localisedFamilyName, 

77 } 

78 

79 styleNames: Dict[str, str] = {} 

80 # If a free-standing label matches the location, use it for name generation. 

81 label = doc.labelForUserLocation(userLocation) 

82 if label is not None: 

83 styleNames = {"en": label.name, **label.labelNames} 

84 # Otherwise, scour the axis labels for matches. 

85 else: 

86 # Gather all languages in which at least one translation is provided 

87 # Then build names for all these languages, but fallback to English 

88 # whenever a translation is missing. 

89 labels = _getAxisLabelsForUserLocation(doc.axes, userLocation) 

90 if labels: 

91 languages = set( 

92 language for label in labels for language in label.labelNames 

93 ) 

94 languages.add("en") 

95 for language in languages: 

96 styleName = " ".join( 

97 label.labelNames.get(language, label.defaultName) 

98 for label in labels 

99 if not label.elidable 

100 ) 

101 if not styleName and doc.elidedFallbackName is not None: 

102 styleName = doc.elidedFallbackName 

103 styleNames[language] = styleName 

104 

105 if "en" not in familyNames or "en" not in styleNames: 

106 # Not enough information to compute PS names of styleMap names 

107 return StatNames( 

108 familyNames=familyNames, 

109 styleNames=styleNames, 

110 postScriptFontName=None, 

111 styleMapFamilyNames={}, 

112 styleMapStyleName=None, 

113 ) 

114 

115 postScriptFontName = f"{familyNames['en']}-{styleNames['en']}".replace(" ", "") 

116 

117 styleMapStyleName, regularUserLocation = _getRibbiStyle(doc, userLocation) 

118 

119 styleNamesForStyleMap = styleNames 

120 if regularUserLocation != userLocation: 

121 regularStatNames = getStatNames(doc, regularUserLocation) 

122 styleNamesForStyleMap = regularStatNames.styleNames 

123 

124 styleMapFamilyNames = {} 

125 for language in set(familyNames).union(styleNames.keys()): 

126 familyName = familyNames.get(language, familyNames["en"]) 

127 styleName = styleNamesForStyleMap.get(language, styleNamesForStyleMap["en"]) 

128 styleMapFamilyNames[language] = (familyName + " " + styleName).strip() 

129 

130 return StatNames( 

131 familyNames=familyNames, 

132 styleNames=styleNames, 

133 postScriptFontName=postScriptFontName, 

134 styleMapFamilyNames=styleMapFamilyNames, 

135 styleMapStyleName=styleMapStyleName, 

136 ) 

137 

138 

139def _getSortedAxisLabels( 

140 axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]], 

141) -> Dict[str, list[AxisLabelDescriptor]]: 

142 """Returns axis labels sorted by their ordering, with unordered ones appended as 

143 they are listed.""" 

144 

145 # First, get the axis labels with explicit ordering... 

146 sortedAxes = sorted( 

147 (axis for axis in axes if axis.axisOrdering is not None), 

148 key=lambda a: a.axisOrdering, 

149 ) 

150 sortedLabels: Dict[str, list[AxisLabelDescriptor]] = { 

151 axis.name: axis.axisLabels for axis in sortedAxes 

152 } 

153 

154 # ... then append the others in the order they appear. 

155 # NOTE: This relies on Python 3.7+ dict's preserved insertion order. 

156 for axis in axes: 

157 if axis.axisOrdering is None: 

158 sortedLabels[axis.name] = axis.axisLabels 

159 

160 return sortedLabels 

161 

162 

163def _getAxisLabelsForUserLocation( 

164 axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]], 

165 userLocation: SimpleLocationDict, 

166) -> list[AxisLabelDescriptor]: 

167 labels: list[AxisLabelDescriptor] = [] 

168 

169 allAxisLabels = _getSortedAxisLabels(axes) 

170 if allAxisLabels.keys() != userLocation.keys(): 

171 LOGGER.warning( 

172 f"Mismatch between user location '{userLocation.keys()}' and available " 

173 f"labels for '{allAxisLabels.keys()}'." 

174 ) 

175 

176 for axisName, axisLabels in allAxisLabels.items(): 

177 userValue = userLocation[axisName] 

178 label: Optional[AxisLabelDescriptor] = next( 

179 ( 

180 l 

181 for l in axisLabels 

182 if l.userValue == userValue 

183 or ( 

184 l.userMinimum is not None 

185 and l.userMaximum is not None 

186 and l.userMinimum <= userValue <= l.userMaximum 

187 ) 

188 ), 

189 None, 

190 ) 

191 if label is None: 

192 LOGGER.debug( 

193 f"Document needs a label for axis '{axisName}', user value '{userValue}'." 

194 ) 

195 else: 

196 labels.append(label) 

197 

198 return labels 

199 

200 

201def _getRibbiStyle( 

202 self: DesignSpaceDocument, userLocation: SimpleLocationDict 

203) -> Tuple[RibbiStyle, SimpleLocationDict]: 

204 """Compute the RIBBI style name of the given user location, 

205 return the location of the matching Regular in the RIBBI group. 

206 

207 .. versionadded:: 5.0 

208 """ 

209 regularUserLocation = {} 

210 axes_by_tag = {axis.tag: axis for axis in self.axes} 

211 

212 bold: bool = False 

213 italic: bool = False 

214 

215 axis = axes_by_tag.get("wght") 

216 if axis is not None: 

217 for regular_label in axis.axisLabels: 

218 if ( 

219 regular_label.linkedUserValue == userLocation[axis.name] 

220 # In the "recursive" case where both the Regular has 

221 # linkedUserValue pointing the Bold, and the Bold has 

222 # linkedUserValue pointing to the Regular, only consider the 

223 # first case: Regular (e.g. 400) has linkedUserValue pointing to 

224 # Bold (e.g. 700, higher than Regular) 

225 and regular_label.userValue < regular_label.linkedUserValue 

226 ): 

227 regularUserLocation[axis.name] = regular_label.userValue 

228 bold = True 

229 break 

230 

231 axis = axes_by_tag.get("ital") or axes_by_tag.get("slnt") 

232 if axis is not None: 

233 for upright_label in axis.axisLabels: 

234 if ( 

235 upright_label.linkedUserValue == userLocation[axis.name] 

236 # In the "recursive" case where both the Upright has 

237 # linkedUserValue pointing the Italic, and the Italic has 

238 # linkedUserValue pointing to the Upright, only consider the 

239 # first case: Upright (e.g. ital=0, slant=0) has 

240 # linkedUserValue pointing to Italic (e.g ital=1, slant=-12 or 

241 # slant=12 for backwards italics, in any case higher than 

242 # Upright in absolute value, hence the abs() below. 

243 and abs(upright_label.userValue) < abs(upright_label.linkedUserValue) 

244 ): 

245 regularUserLocation[axis.name] = upright_label.userValue 

246 italic = True 

247 break 

248 

249 return BOLD_ITALIC_TO_RIBBI_STYLE[bold, italic], { 

250 **userLocation, 

251 **regularUserLocation, 

252 }