"""Compute name information for a given location in user-space coordinates using STAT data. This can be used to fill-in automatically the names of an instance: .. code:: python instance = doc.instances[0] names = getStatNames(doc, instance.getFullUserLocation(doc)) print(names.styleNames) """ from __future__ import annotations from dataclasses import dataclass from typing import Dict, Optional, Tuple, Union import logging from fontTools.designspaceLib import ( AxisDescriptor, AxisLabelDescriptor, DesignSpaceDocument, DesignSpaceDocumentError, DiscreteAxisDescriptor, SimpleLocationDict, SourceDescriptor, ) LOGGER = logging.getLogger(__name__) # TODO(Python 3.8): use Literal # RibbiStyleName = Union[Literal["regular"], Literal["bold"], Literal["italic"], Literal["bold italic"]] RibbiStyle = str BOLD_ITALIC_TO_RIBBI_STYLE = { (False, False): "regular", (False, True): "italic", (True, False): "bold", (True, True): "bold italic", } @dataclass class StatNames: """Name data generated from the STAT table information.""" familyNames: Dict[str, str] styleNames: Dict[str, str] postScriptFontName: Optional[str] styleMapFamilyNames: Dict[str, str] styleMapStyleName: Optional[RibbiStyle] def getStatNames( doc: DesignSpaceDocument, userLocation: SimpleLocationDict ) -> StatNames: """Compute the family, style, PostScript names of the given ``userLocation`` using the document's STAT information. Also computes localizations. If not enough STAT data is available for a given name, either its dict of localized names will be empty (family and style names), or the name will be None (PostScript name). .. versionadded:: 5.0 """ familyNames: Dict[str, str] = {} defaultSource: Optional[SourceDescriptor] = doc.findDefault() if defaultSource is None: LOGGER.warning("Cannot determine default source to look up family name.") elif defaultSource.familyName is None: LOGGER.warning( "Cannot look up family name, assign the 'familyname' attribute to the default source." ) else: familyNames = { "en": defaultSource.familyName, **defaultSource.localisedFamilyName, } styleNames: Dict[str, str] = {} # If a free-standing label matches the location, use it for name generation. label = doc.labelForUserLocation(userLocation) if label is not None: styleNames = {"en": label.name, **label.labelNames} # Otherwise, scour the axis labels for matches. else: # Gather all languages in which at least one translation is provided # Then build names for all these languages, but fallback to English # whenever a translation is missing. labels = _getAxisLabelsForUserLocation(doc.axes, userLocation) if labels: languages = set(language for label in labels for language in label.labelNames) languages.add("en") for language in languages: styleName = " ".join( label.labelNames.get(language, label.defaultName) for label in labels if not label.elidable ) if not styleName and doc.elidedFallbackName is not None: styleName = doc.elidedFallbackName styleNames[language] = styleName if "en" not in familyNames or "en" not in styleNames: # Not enough information to compute PS names of styleMap names return StatNames( familyNames=familyNames, styleNames=styleNames, postScriptFontName=None, styleMapFamilyNames={}, styleMapStyleName=None, ) postScriptFontName = f"{familyNames['en']}-{styleNames['en']}".replace(" ", "") styleMapStyleName, regularUserLocation = _getRibbiStyle(doc, userLocation) styleNamesForStyleMap = styleNames if regularUserLocation != userLocation: regularStatNames = getStatNames(doc, regularUserLocation) styleNamesForStyleMap = regularStatNames.styleNames styleMapFamilyNames = {} for language in set(familyNames).union(styleNames.keys()): familyName = familyNames.get(language, familyNames["en"]) styleName = styleNamesForStyleMap.get(language, styleNamesForStyleMap["en"]) styleMapFamilyNames[language] = (familyName + " " + styleName).strip() return StatNames( familyNames=familyNames, styleNames=styleNames, postScriptFontName=postScriptFontName, styleMapFamilyNames=styleMapFamilyNames, styleMapStyleName=styleMapStyleName, ) def _getSortedAxisLabels( axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]], ) -> Dict[str, list[AxisLabelDescriptor]]: """Returns axis labels sorted by their ordering, with unordered ones appended as they are listed.""" # First, get the axis labels with explicit ordering... sortedAxes = sorted( (axis for axis in axes if axis.axisOrdering is not None), key=lambda a: a.axisOrdering, ) sortedLabels: Dict[str, list[AxisLabelDescriptor]] = { axis.name: axis.axisLabels for axis in sortedAxes } # ... then append the others in the order they appear. # NOTE: This relies on Python 3.7+ dict's preserved insertion order. for axis in axes: if axis.axisOrdering is None: sortedLabels[axis.name] = axis.axisLabels return sortedLabels def _getAxisLabelsForUserLocation( axes: list[Union[AxisDescriptor, DiscreteAxisDescriptor]], userLocation: SimpleLocationDict, ) -> list[AxisLabelDescriptor]: labels: list[AxisLabelDescriptor] = [] allAxisLabels = _getSortedAxisLabels(axes) if allAxisLabels.keys() != userLocation.keys(): LOGGER.warning( f"Mismatch between user location '{userLocation.keys()}' and available " f"labels for '{allAxisLabels.keys()}'." ) for axisName, axisLabels in allAxisLabels.items(): userValue = userLocation[axisName] label: Optional[AxisLabelDescriptor] = next( ( l for l in axisLabels if l.userValue == userValue or ( l.userMinimum is not None and l.userMaximum is not None and l.userMinimum <= userValue <= l.userMaximum ) ), None, ) if label is None: LOGGER.debug( f"Document needs a label for axis '{axisName}', user value '{userValue}'." ) else: labels.append(label) return labels def _getRibbiStyle( self: DesignSpaceDocument, userLocation: SimpleLocationDict ) -> Tuple[RibbiStyle, SimpleLocationDict]: """Compute the RIBBI style name of the given user location, return the location of the matching Regular in the RIBBI group. .. versionadded:: 5.0 """ regularUserLocation = {} axes_by_tag = {axis.tag: axis for axis in self.axes} bold: bool = False italic: bool = False axis = axes_by_tag.get("wght") if axis is not None: for regular_label in axis.axisLabels: if regular_label.linkedUserValue == userLocation[axis.name]: regularUserLocation[axis.name] = regular_label.userValue bold = True break axis = axes_by_tag.get("ital") or axes_by_tag.get("slnt") if axis is not None: for urpright_label in axis.axisLabels: if urpright_label.linkedUserValue == userLocation[axis.name]: regularUserLocation[axis.name] = urpright_label.userValue italic = True break return BOLD_ITALIC_TO_RIBBI_STYLE[bold, italic], { **userLocation, **regularUserLocation, }