# pylint: disable=C0103, C0114, R0904, W0212, W0221
from __future__ import annotations
from typing import TYPE_CHECKING, cast, Any
from collections.abc import Iterator
import re
import warnings
from fontParts.base.base import BaseObject
from smufolib import config
from smufolib.objects.range import Range, METADATA, RANGES_LIB_KEY
from smufolib.objects.engravingDefaults import EngravingDefaults
from smufolib.objects import _lib
from smufolib.request import Request
from smufolib.utils import converters, error, normalizers
if TYPE_CHECKING: # pragma: no cover
from smufolib.objects.layer import Layer
from smufolib.objects.font import Font
from smufolib.objects.glyph import Glyph
CONFIG = config.load()
EDITABLE_RANGES = CONFIG["ranges"]["editable"]
STRICT_CLASSES = CONFIG["classes"]["strict"]
CLASSES_LIB_KEY = "com.smufolib.classes"
DESCRIPTION_LIB_KEY = "com.smufolib.description"
DESIGN_SIZE_LIB_KEY = "com.smufolib.designSize"
NAMES_LIB_KEY = "com.smufolib.names"
NAME_LIB_KEY = "com.smufolib.name"
SIZE_RANGE_LIB_KEY = "com.smufolib.sizeRange"
SPACES_LIB_KEY = "com.smufolib.spaces"
GLYPHNAMES_DATA = Request.glyphnames()
#: Names of glyph anchors specified by the SMuFL standard.
ANCHOR_NAMES: set[str] = {
"splitStemUpSE",
"splitStemUpSW",
"splitStemDownNE",
"splitStemDownNW",
"stemUpSE",
"stemDownNW",
"stemUpNW",
"stemDownSW",
"nominalWidth",
"numeralTop",
"numeralBottom",
"cutOutNE",
"cutOutSE",
"cutOutSW",
"cutOutNW",
"graceNoteSlashSW",
"graceNoteSlashNE",
"graceNoteSlashNW",
"graceNoteSlashSE",
"repeatOffset",
"noteheadOrigin",
"opticalCenter",
}
#: Names of glyph classes included in the SMuFL specification.
CLASS_NAMES: set[str] = {
"accidentals",
"accidentals24EDOArrows",
"accidentals53EDOTurkish",
"accidentals72EDOWyschnegradsky",
"accidentalsAEU",
"accidentalsArabic",
"accidentalsHelmholtzEllis",
"accidentalsJohnston",
"accidentalsPersian",
"accidentalsSagittalAthenian",
"accidentalsSagittalDiacritics",
"accidentalsSagittalMixed",
"accidentalsSagittalPromethean",
"accidentalsSagittalPure",
"accidentalsSagittalTrojan",
"accidentalsSims",
"accidentalsStandard",
"accidentalsSteinZimmermann",
"accidentalsStockhausen",
"articulations",
"articulationsAbove",
"articulationsBelow",
"combiningStaffPositions",
"clefs",
"clefsC",
"clefsF",
"clefsG",
"dynamics",
"forTextBasedApplications",
"multiGlyphForms",
"noteheads",
"noteheadSetCircled",
"noteheadSetCircleX",
"noteheadSetDefault",
"noteheadSetDiamond",
"noteheadSetDiamondOld",
"noteheadSetHeavyX",
"noteheadSetLargeArrowDown",
"noteheadSetLargeArrowUp",
"noteheadSetNamesPitch",
"noteheadSetNamesSolfege",
"noteheadSetPlus",
"noteheadSetRoundLarge",
"noteheadSetRoundSmall",
"noteheadSetSacredHarp",
"noteheadSetSlashed1",
"noteheadSetSlashed2",
"noteheadSetSlashHorizontalEnds",
"noteheadSetSlashVerticalEnds",
"noteheadSetSquare",
"noteheadSetTriangleDown",
"noteheadSetTriangleLeft",
"noteheadSetTriangleRight",
"noteheadSetTriangleUp",
"noteheadSetWithX",
"noteheadSetX",
"parenthesesNotehead",
"octaves",
"ornaments",
"pauses",
"pausesAbove",
"pausesBelow",
"rests",
"stemDecorations",
"wigglesArpeggiato",
"wigglesArpeggiatoDown",
"wigglesArpeggiatoUp",
"wigglesCircularMotion",
"wigglesQuasiRandom",
"wigglesTrill",
"wigglesVibrato",
"wigglesVibratoVariable",
}
#: Names of font-specific attributes of the :class:`Smufl` class.
FONT_ATTRIBUTES: set[str] = {"designSize", "engravingDefaults", "sizeRange", "spaces"}
#: Names of glyph-specific attributes of the :class:`Smufl` class.
GLYPH_ATTRIBUTES: set[str] = {"classes", "description", "name"}
[docs]
class Smufl(BaseObject):
"""SMuFL metadata interface for fonts and glyphs.
This class provides structured access to SMuFL-related metadata and utility methods
for interacting with both font-level and glyph-level data. It may be accessed from
either a :class:`.Font` or a :class:`.Glyph`. Font-level attributes are available
in both cases, thanks to consistent parent access patterns in FontParts.
.. _about-glyph-naming:
.. admonition:: About Glyph Naming
Attributes dealing with ligatures (:attr:`isLigature`, :attr:`componentGlyphs`,
:attr:`componentNames`) and stylistic alternates (:attr:`isSalt`, :attr:`isSet`,
:attr:`alternates`, :attr:`base`, and :attr:`suffix`) depend on strict adherence
to the descriptive naming schemes stipulated in the `Adobe Glyph List
Specification <https://github.com/adobe-type-tools/agl-specification#readme>`_,
and followed by the SMuFL standard (see `Section 6
<https://github.com/adobe-type-tools/agl-specification#6-assigning-glyph-names-
in-new-fonts>`_ for more information).
.. tip::
To avoid having to set all the glyph identification attributes manually, it is
advisable to run the script :mod:`.importID` prior to using this class with an
existing font for the first time.
:keyword font: Parent :class:`.Font` object.
:keyword glyph: Parent :class:`.Glyph` object.
This object is typically accessed through a :class:`.Font` or :class:`.Glyph`:
>>> smufl = font.smufl
>>> glyph = font["uniE050"]
>>> smufl = glyph.smufl
It may also be instantiated independently and assigned to a font or glyph later:
>>> smufl = Smufl() # doctest: +SKIP
"""
def _init(self, font: Font | None = None, glyph: Glyph | None = None) -> None:
self._font = font
self._glyph = glyph
def _reprContents(self) -> list[str]:
contents = []
if self._glyph is not None:
contents.append("in glyph")
contents += self._glyph._reprContents()
if self._font is not None:
contents.append("in font")
contents += self._font._reprContents()
return contents
[docs]
def naked(self):
"""Return the wrapped defcon object.
This method is useful if you need to bypass the wrapper and interact directly
with the underlying `defcon <https://defcon.robotools.dev/en/stable/>`_ object
(e.g., for compatibility with other libraries).
Example:
>>> smufl.glyph.naked() # doctest: +ELLIPSIS
<defcon.objects.glyph.Glyph object at 0x...>
"""
return self
def _requireGlyphAccess(self, attribute: str) -> Glyph | None:
attribute = f"{self.__class__.__name__}.{attribute}"
if self._font is not None and self._glyph is None:
raise AttributeError(
error.generateErrorMessage(
"contextualAttributeError",
attribute=attribute,
context="accessed from 'Font'",
)
)
return self._glyph
# -----------------
# Glyph Interaction
# -----------------
def __contains__(self, name: str) -> bool:
"""Check if a SMuFL glyph exists in the font by its canonical name.
:param name: The :attr:`name` of the glyph to check.
"""
return name in self._names
def __getitem__(self, name: str) -> Glyph:
"""Get a SMuFL glyph by its canonical name from the font.
:param name: The :attr:`name` of the glyph to retrieve.
:raises TypeError: If `name` or `glyph` is not of the expected type.
:raises ValueError: If `name` is not a valid SMuFL name.
:raises KeyError: If the glyph is not found.
Example:
>>> glyph = font.smufl["accidentalFlat"] # doctest: +ELLIPSIS
<Glyph 'uniE260' ['accidentalFlat'] ('public.default') at ...>
"""
if self.font is None or name not in self._names:
raise KeyError(error.generateErrorMessage("missingGlyph", name=name))
glyphName = self._names[name]
return self.font[glyphName]
def __setitem__(self, name: str, glyph: Glyph) -> None:
"""Insert or replace a SMuFL glyph in the font.
If `glyph` is considered recommended (i.e., listed in the
:smufl:`glyphnames.json <specification/glyphnames.html>` metadata file), it will
be assigned a corresponding :attr:`~fontParts.base.BaseGlyph.name` and
:attr:`~fontParts.base.BaseGlyph.unicode`.
If `glyph` is optional, `name` will be used if ``glyph.name`` is :obj:`None`.
:param name: The :attr:`name` of the glyph to insert or replace.
:param glyph: The :class:`.Glyph` object to insert.
:raises TypeError: If `name` or `glyph` is not of the expected type.
:raises ValueError:
- If `name` is not a valid SMuFL name.
- If ``glyph`` is not a SMuFL glyph (i.e., ``glyph.unicode`` is outside the
Unicode range U+E000-U+F8FF).
Example:
>>> font.smufl["accidentalFlat"] = glyph
"""
if self.font is None:
return
from smufolib.objects.glyph import Glyph
error.validateType(name, str, objectName="name")
error.validateType(glyph, Glyph, objectName="glyph")
normalizedName = cast(str, normalizers.normalizeSmuflName(name, "name"))
if isinstance(GLYPHNAMES_DATA, dict):
glyphData = GLYPHNAMES_DATA.get(normalizedName)
else:
glyphData = None
if glyphData:
glyphName = converters.toUniName(glyphData["codepoint"])
codepoint = converters.toDecimal(glyphData["codepoint"])
else:
glyphName = glyph.name or name
codepoint = glyph.unicode
start, end = 0xE000, 0xF8FF
if codepoint is not None and not start <= codepoint <= end:
raise ValueError(
error.generateErrorMessage(
"unicodeOutOfRange",
objectName=name,
start=converters.toUniHex(start),
end=converters.toUniHex(end),
)
)
insert = self.font._insertGlyph(glyph, name=glyphName, clear=False)
insert.unicode = codepoint
insert.smufl.name = name
def __delitem__(self, name: str) -> None:
"""Delete a SMuFL glyph from the font.
:param name: The :attr:`name` of the glyph to delete.
:raises TypeError: If `name` is not a :class:`str`.
:raises ValueError: If `name` is not a valid SMuFL name.
Example:
>>> del font.smufl["accidentalFlat"]
"""
if self.font is None or self._names is None:
return
normalizedName = normalizers.normalizeSmuflName(name, "Smufl.name")
if normalizedName in self._names:
del self.font[self._names[normalizedName]]
_lib.updateLibSubdictValue(self.font, NAMES_LIB_KEY, normalizedName, None)
def __len__(self) -> int:
"""Return the number of SMuFL glyphs in the font."""
if self.font is None or self._names is None:
return 0
return len(self._names)
def __iter__(self) -> Iterator[Glyph]:
"""Iterate over SMuFL glyphs in the font."""
for smuflName in self._names:
yield self[smuflName]
[docs]
def keys(self):
"""Return a view of the canonical SMuFL glyph names in the font."""
return self._names.keys()
[docs]
def newGlyph(self, name: str, clear: bool = True) -> Glyph | None:
"""Create a new glyph in the font and return it.
This method is a SMuFL-specific implementation of :meth:`Font.newGlyph
<fontParts.base.BaseFont.newGlyph>`.
If `name` represents a recommended glyph (i.e., listed in the
:smufl:`glyphnames.json <specification/glyphnames.html>` metadata file), it will
be assigned a corresponding :attr:`~fontParts.base.BaseGlyph.name` and
:attr:`~fontParts.base.BaseGlyph.unicode`. Otherwise, `name` will be used if
``glyph.name`` is :obj:`None`.
:param name: The name of the glyph to create.
:param clear: Whether to clear any preexisting glyph with the specified `name`
before creation. Defaults to :obj:`True`
:returns: The newly created :class:`.Glyph` instance.
Example::
>>> glyph = font.smufl.newGlyph("brace")
"""
if self.font is None:
return None
normalizedName = cast(str, normalizers.normalizeSmuflName(name, "name"))
if isinstance(GLYPHNAMES_DATA, dict):
glyphData = GLYPHNAMES_DATA.get(normalizedName)
else:
glyphData = None
tempName = "com.smufolib.temp"
if name in self:
if not clear:
return self[name]
del self[name]
glyph = self.font._newGlyph(tempName)
if glyphData:
glyphName = converters.toUniName(glyphData["codepoint"])
codepoint = converters.toDecimal(glyphData["codepoint"])
else:
glyphName = name
codepoint = None
glyph.name = glyphName
glyph.smufl.name = name
glyph.unicode = codepoint
return glyph
# TODO: Remove in v0.8.0
[docs]
def findGlyph(self, name: str) -> Glyph | None:
"""Find :class:`.Glyph` object from :attr:`name`.
.. deprecated 0.7.0
Use ``font.smufl["name"]`` instead.
:param name: SMuFL-specific canonical glyph name.
Example:
>>> font.smufl.findGlyph("accidentalFlat") # doctest: +ELLIPSIS
<Glyph 'uniE260' ['accidentalFlat'] ('public.default') at ...>
"""
warnings.warn(
error.generateErrorMessage(
"deprecated",
"deprecatedReplacement",
objectName="findGlyph",
replacement="__getitem__",
version="0.7.0",
),
DeprecationWarning,
stacklevel=2,
)
if name is None:
return None
normalizedName = normalizers.normalizeSmuflName(name, "Smufl.name")
if (
self.font is None
or self._names is None
or normalizedName not in self._names
):
return None
return self.font[self._names[normalizedName]]
# -------
# Parents
# -------
@property
def font(self) -> Font | None:
"""The parent :class:`.Font` object.
Example:
>>> smufl.font # doctest: +ELLIPSIS
<Font 'MyFont Regular' path='/path/to/MyFont.ufo' at ...>
"""
if self._font is not None:
return self._font
if self._glyph is not None:
return self._glyph.font
return None
@font.setter
def font(self, value: Font) -> None:
if self._font is not None and self._font != value:
raise AssertionError(
"Font for Smufl object is already set and is not same as value."
)
if self._glyph is not None:
raise AssertionError("Glyph for Smufl object is already set.")
self._font = normalizers.normalizeFont(value)
@property
def glyph(self) -> Glyph | None:
"""The parent :class:`.Glyph` object.
Example:
>>> smufl.glyph # doctest: +ELLIPSIS
<Glyph 'uniE050' ['gClef'] ('public.default') at ...>
"""
if self._glyph is None:
return None
return self._glyph
@glyph.setter
def glyph(self, value: Glyph) -> None:
if self._font is not None:
raise AssertionError("Font for Smufl object is already set.")
if self._glyph is not None and self._glyph != value:
raise AssertionError(
"Glyph for Smufl object is already set and is not same as value."
)
self._glyph = normalizers.normalizeGlyph(value)
@property
def layer(self) -> Layer | None:
"""The parent :class:`.Layer` object.
This property is read-only.
Example:
>>> smufl.layer # doctest: +ELLIPSIS
<Layer 'public.default' at ...>
"""
if self._glyph is None:
return None
return self._glyph.layer
# -------------
# Font Metadata
# -------------
@property
def designSize(self) -> int | None:
"""The optimum point size in integral decipoints.
Example:
>>> font.smufl.designSize = 20
>>> font.smufl.designSize
20
"""
if self.font is None:
return None
return self.font.lib.naked().get(DESIGN_SIZE_LIB_KEY, None)
@designSize.setter
def designSize(self, value: int | None) -> None:
_lib.updateLibSubdict(
self.font, DESIGN_SIZE_LIB_KEY, normalizers.normalizeDesignSize(value)
)
@property
def engravingDefaults(self) -> EngravingDefaults:
"""The font's :class:`.EngravingDefaults` object.
:raises AttributeError: If attempting to access attribute from glyph.
Example:
>>> font.smufl.engravingDefaults # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
<EngravingDefaults in font 'MyFont Regular' path='/path/to/MyFont.ufo'
auto=True at ...>
"""
if self.font is None:
raise AttributeError(
error.generateErrorMessage(
"contextualAttributeError",
attribute="Smufl.engravingDefaults",
context="'Smufl.font' is None",
)
)
return EngravingDefaults(self)
@engravingDefaults.setter
def engravingDefaults(self, value: EngravingDefaults) -> None:
self.engravingDefaults.update(normalizers.normalizeEngravingDefaults(value))
@property
def sizeRange(self) -> tuple[int, int] | None:
"""The optimum size range in integral decipoints.
Example:
>>> font.smufl.sizeRange = (16, 24)
>>> font.smufl.sizeRange
(16, 24)
"""
if self.font is None:
return None
return self.font.lib.naked().get(SIZE_RANGE_LIB_KEY, None)
@sizeRange.setter
def sizeRange(self, value: tuple[int, int] | None) -> None:
_lib.updateLibSubdict(
self.font, SIZE_RANGE_LIB_KEY, normalizers.normalizeSizeRange(value)
)
# --------------
# Glyph metadata
# --------------
# Alternates
@property
def alternates(self) -> tuple[dict[str, str], ...] | None:
"""Alternates of the current glyph as metadata stubs.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"] # gClef
>>> glyph.smufl.alternates
({'codepoint': 'U+F472', 'name': 'gClefSmall'},)
"""
if self.font is None:
return None
results = self._findAlternates("Smufl.alternates")
alternates = []
for name in results:
glyph = self.font[name]
alternates.append(
{"codepoint": glyph.smufl.codepoint, "name": glyph.smufl.name}
)
return tuple(alternates)
@property
def alternateGlyphs(self) -> tuple[Glyph, ...] | None:
"""Alternates of the current glyph as :class:`.Glyph` objects.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.alternateGlyphs # doctest: +ELLIPSIS
(<Glyph 'uniE050.ss01' ['gClefSmall'] ('public.default') at ...>,)
"""
if self.font is None:
return None
alternates = self._findAlternates("Smufl.alternateGlyphs")
return tuple(self.font[a] for a in alternates)
@property
def alternateNames(self) -> tuple[str, ...] | None:
"""Alternates of the current glyph by :attr:`name`.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.alternateNames
('gClefSmall',)
"""
if self.font is None:
return None
alternates = self._findAlternates("Smufl.alternateNames")
return tuple(self.font[a].smufl.name for a in alternates)
def _findAlternates(self, attribute: str) -> list[str]:
# find alt names among string of glyph names
glyph = self._requireGlyphAccess(attribute)
if glyph is None or self.font is None:
return []
string = " ".join(sorted(self.font.keys()))
pattern = rf"\b{glyph.name}\.(?:s?alt|ss)[0-9]{{2}}\b"
return re.findall(pattern, string)
@property
def base(self) -> Glyph | None:
"""Base glyph of the current glyph.
If the current glyph is not an alternate (i.e., a stylistic variant),
the glyph itself is returned.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050.ss01"] # doctest: +ELLIPSIS
>>> glyph.smufl.base
<Glyph 'uniE050' ['gClef'] ('public.default') at ...>
"""
if self.font is None:
return None
baseName = self._getBasename()
if self.font and baseName:
return self.font[baseName]
return None
def _getBasename(self) -> str | None:
# Get name of base glyph.
glyph = self._requireGlyphAccess("base")
if self.font is None or glyph is None or glyph.name is None:
return None
basename = glyph.name.split(".")[0]
return basename if basename in self.font else None
@property
def suffix(self) -> str | None:
"""Suffix of the current glyph.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050.ss01"]
>>> glyph.smufl.suffix
'ss01'
"""
if self.font is None:
return None
glyph = self._requireGlyphAccess("suffix")
if glyph is not None and (self.isSalt or self.isSet):
return glyph.name.split(".")[1]
return None
# Components
@property
def componentGlyphs(self) -> tuple[Glyph, ...] | None:
"""Ligature components by :class:`.Glyph` object.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> ligature = font["uniE26A_uniE260_uniE26B"]
>>> ligature.smufl.componentGlyphs # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
(<Glyph 'uniE26A' ['accidentalParensLeft'] ('public.default') at ...>,
<Glyph 'uniE260' ['accidentalFlat'] ('public.default') at ...>,
<Glyph 'uniE26B' ['accidentalParensRight'] ('public.default') at ...>)
"""
glyph = self._requireGlyphAccess("componentGlyphs")
if glyph is None or self.font is None:
return None
if not self.isLigature:
return ()
components = [self.font[n] for n in glyph.name.split("_") if n in self.font]
return tuple(components)
@property
def componentNames(self) -> tuple[str | None, ...] | None:
"""Ligature components by :attr:`name`.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> ligature = font["uniE26A_uniE260_uniE26B"]
>>> ligature.smufl.componentNames
('accidentalParensLeft', 'accidentalFlat', 'accidentalParensRight')
"""
glyph = self._requireGlyphAccess("componentGlyphs")
if glyph is None or self.font is None:
return None
if not self.isLigature:
return ()
components = [
self.font[n].smufl.name for n in glyph.name.split("_") if n in self.font
]
return tuple(components)
# Ranges
[docs]
def newRange(
self,
name: str,
start: int,
end: int,
description: str,
overrideExisting: bool = False,
) -> None:
"""Add SMuFL range to font.
This method defines a SMuFL range in the font's metadata using a `start` and
`end` decimal codepoint.
The `glyphs` key in the resulting metadata is computed dynamically and reflects
the current glyphs in the font that fall within the specified range. It will
update automatically as glyphs are added (and assigned a :attr:`name`) or
removed.
:param name: A unique identifier for the range.
:param start: The starting unicode codepoint of the range.
:param end: The ending unicode codepoint of the range.
:param description: A human-readable description of the range.
:param overrideExisting: Whether to replace an existing range if any part of the
new range overlap with it. Defaults to :obj:`False`.
:raises PermissionError: If :confval:`ranges.editable` is disabled.
:raises ValueError: If `start` or `end` partially or completely overlap with an
existing range when `overrideExisting` is :obj:`False`.
Example:
>>> font.smufl.newRange( # doctest: +SKIP
... "myRange", 0xF500, 0xF50F, "A Range of custom glyphs."
... )
"""
if self.font is None:
return
if not EDITABLE_RANGES:
raise PermissionError(
error.generateErrorMessage(
"permissionError",
context="Editing range data is disallowed in configuration",
)
)
normalizedName = normalizers.normalizeSmuflName(name, "Range.name")
if normalizedName is None:
raise TypeError(
error.generateTypeError(validTypes=str, objectName="name", value=name)
)
normalizedDescription = normalizers.normalizeDescription(
description, "Range.description"
)
range_: dict[str, dict[str, str | int | list[str] | None]] = {
normalizedName: {
"description": normalizedDescription,
"range_start": start,
"range_end": end,
}
}
if self.ranges and (
any(
self._hasOverlap((start, end), (r.start, r.end))
for r in self.ranges
if r.start and r.end
)
and overrideExisting is False
):
raise ValueError(
error.generateErrorMessage(
"overlappingRange",
string="Set overrideExisting=True to replace",
name=name,
start=converters.toUniHex(start),
end=converters.toUniHex(end),
)
)
glyphs: list[str] = [
g.smufl.name
for g in self.font
if g.smufl.name and start <= g.unicode <= end
]
range_[normalizedName]["glyphs"] = glyphs
_lib.updateLibSubdict(self.font, RANGES_LIB_KEY, range_)
@property
def ranges(self) -> tuple[Range, ...] | None:
"""SMuFL ranges covered by font or glyph.
This property behaves differently depending on the context in which the
:class:`Smufl` object is used:
- When accessed from a :class:`.Font`, (e.g., ``font.smufl.ranges``) it returns
a :class:`tuple` of all :class:`.Range` objects covered by the font.
- When accessed from a :class:`.Glyph` (e.g., ``glyph.smufl.ranges``), it
returns a singleton :class:`tuple` containing the glyph's corresponding
:class:`.Range` object.
This property is read-only.
Examples:
>>> font.smufl.ranges # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
(<Range 'clefs' (U+E050-U+E07F) editable=False at ...>,
...
<Range 'multiSegmentLines' (U+EAA0-U+EB0F) editable=False at ...>)
>>> glyph = font["uniE050"]
>>> glyph.smufl.ranges
(<Range 'clefs' (U+E050-U+E07F) editable=False at ...>,)
"""
if self.font is None:
return None
if self._glyph is not None:
internalData = _lib.getLibSubdict(self.font, RANGES_LIB_KEY)
if internalData:
for data in internalData.values():
if self._glyph.unicode is not None and (
data.get("range_start")
<= self._glyph.unicode
<= data.get("range_end")
):
return (Range(self, _internal=True),)
range_ = Range(self, _internal=False)
return (range_,) if range_ else ()
return self._collectAllRanges()
def _getRangesFromMetadata(self, metadata, _internal=False) -> list[Range]:
if (
self.font is None
or self._names is None
or metadata is None
or isinstance(metadata, str)
):
return []
ranges = []
for data in metadata.values():
match = next(
(
self._names[smuflName]
for smuflName in data["glyphs"]
if smuflName in self._names
),
None,
)
if match:
ranges.append(Range(self.font[match].smufl, _internal=_internal))
return ranges
def _collectAllRanges(self) -> tuple[Range, ...]:
internalRanges = (
self._getRangesFromMetadata(
_lib.getLibSubdict(self.font, RANGES_LIB_KEY), _internal=True
)
or []
)
externalRanges = self._getRangesFromMetadata(METADATA) or []
internalSpans = [
(r.start, r.end)
for r in internalRanges
if r.start is not None and r.end is not None
]
nonConflictingExternal = [
r
for r in externalRanges
if r.start is not None
and r.end is not None
and not any(
self._hasOverlap((r.start, r.end), (iStart, iEnd))
for iStart, iEnd in internalSpans
)
]
return tuple(
sorted(
internalRanges + nonConflictingExternal,
key=lambda r: (r.start is None, r.start),
)
)
@staticmethod
def _hasOverlap(range1: tuple[int, int], range2: tuple[int, int]) -> bool:
start1, end1 = range1
start2, end2 = range2
return not (end1 < start2 or start1 > end2)
# Anchors
@property
def anchors(self) -> dict[str, tuple[int | float, int | float]] | None:
"""SMuFL-specific glyph anchors as Cartesian coordinates.
This property is read-only. Use the :attr:`Glyph.anchors
<fontParts.base.BaseGlyph.anchors>` attribute to set glyph anchors.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE240"]
>>> glyph.smufl.anchors # doctest: +NORMALIZE_WHITESPACE
{'graceNoteSlashNE': (321, -199),
'graceNoteSlashSW': (-161, -614),
'stemUpNW': (0, -10)}
"""
glyph = self._requireGlyphAccess("anchors")
if glyph is None:
return None
anchors = {}
for a in glyph.naked().anchors:
if a.name in ANCHOR_NAMES:
x = self.toSpaces(a.x) if self.spaces else a.x
y = self.toSpaces(a.y) if self.spaces else a.y
if x is None or y is None:
return None
anchors[a.name] = (x, y)
return anchors
# Codepoint
@property
def codepoint(self) -> str | None:
"""Unicode codepoint as formatted string.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.codepoint
'U+E050'
"""
glyph = self._requireGlyphAccess("codepoint")
if glyph is None:
return None
if not glyph.unicode:
return None
return converters.toUniHex(glyph.unicode)
@codepoint.setter
def codepoint(self, value: str | None) -> None:
glyph = self._requireGlyphAccess("codepoint")
if glyph is not None:
if value is None:
glyph.unicode = None
else:
glyph.unicode = converters.toDecimal(value)
# Metrics and Dimensions
@property
def bBox(self) -> dict[str, tuple[int | float, int | float]] | None:
"""Glyph bounding box as Cartesian coordinates.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.bBox
{'bBoxSW': (0, -634), 'bBoxNE': (648, 1167)}
"""
glyph = self._requireGlyphAccess("bBox")
if glyph is None:
return None
if not glyph.bounds:
return None
xMin, yMin, xMax, yMax = glyph.bounds
if self.spaces:
xMin, yMin, xMax, yMax = [self.toSpaces(b) for b in glyph.bounds]
return {"bBoxSW": (xMin, yMin), "bBoxNE": (xMax, yMax)}
@property
def advanceWidth(self) -> int | float | None:
"""Glyph advance width.
This property is equivalent to :attr:`Glyph.width
<fontParts.base.BaseGlyph.width>`.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.advanceWidth = 230.5
>>> glyph.smufl.advanceWidth
230.5
"""
glyph = self._requireGlyphAccess("advanceWidth")
if glyph is None:
return None
if self.spaces:
return self.toSpaces(glyph.width)
return glyph.width
@advanceWidth.setter
def advanceWidth(self, value: int | float) -> None:
glyph = self._requireGlyphAccess("advanceWidth")
if glyph is not None:
if self.spaces:
normalizedValue = self.toUnits(value)
if normalizedValue is None:
return
else:
normalizedValue = value
glyph.width = normalizedValue
# --------------
# Identification
# --------------
# Font
# ----
# Font family name is accessible through font.smufl.name.
[docs]
def classMembers(self, className: str) -> tuple[Glyph, ...]:
"""Return all glyphs in the font that belong to the given SMuFL class.
.. versionadded:: 0.6.0
:param className: The name of the SMuFL glyph class to search for.
Example:
>>> glyph.smufl.classMembers("accidentalsStandard") # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
(<Glyph 'uniE260' ['accidentalFlat'] ('public.default') at ...>,
<Glyph 'uniE266' ['accidentalTripleFlat'] ('public.default') at ...>,
<Glyph 'uniE267' ['accidentalNaturalFlat'] ('public.default') at ...>)
"""
if self.font is None:
return ()
return tuple(
sorted(
[g for g in self.font if className in g.smufl.classes],
key=lambda g: g.name,
)
)
@property
def version(self) -> float | None:
"""SMuFL-specific font version number.
Example:
>>> font.smufl.version = 2.2
>>> font.smufl.version
2.2
"""
if self.font is None:
return None
try:
return float(
f"{self.font.info.naked().versionMajor}."
f"{self.font.info.naked().versionMinor}"
)
except ValueError:
return None
@version.setter
def version(self, value: float | None) -> None:
if value is None:
major, minor = None, None
else:
major, minor = [int(n) for n in str(value).split(".")]
if self.font is not None:
self.font.info.naked().versionMajor = major
self.font.info.naked().versionMinor = minor
# Glyph
# -----
@property
def classes(self) -> tuple[str, ...] | None:
"""SMuFL-specific class memberships.
If :confval:`classes.strict` is enabled, only SMuFL-specific class names are
allowed. See :data:`.CLASS_NAMES` for the full :class:`set` of specified names.
.. versionadded:: 0.7.0
Distinction between strict, SMuFL-specific vs. lenient, custom class names.
:raises AttributeError: If attempting to access attribute from font.
:raises ValueError: If attempting to set a name not specified in
:data:`.CLASS_NAMES` when :confval:`classes.strict` is enabled.
Example::
>>> glyph = font["uniE260"]
>>> glyph.smufl.classes
('accidentals', 'accidentalsSagittalMixed',
'accidentalsStandard', 'combiningStaffPositions')
"""
glyph = self._requireGlyphAccess("classes")
if glyph is None:
return None
return tuple(glyph.lib.naked().get(CLASSES_LIB_KEY, ()))
@classes.setter
def classes(self, value: tuple[str, ...] | None) -> None:
self._requireGlyphAccess("classes")
if STRICT_CLASSES and value:
for item in value:
if item not in CLASS_NAMES:
raise ValueError(
error.generateErrorMessage(
"itemsValueError",
objectName="Smufl.classes",
value=item,
string="Non-SMuFL classes are disallowed in configuration",
)
)
_lib.updateLibSubdict(
self.glyph, CLASSES_LIB_KEY, normalizers.normalizeClasses(value)
)
@property
def description(self) -> str | None:
"""SMuFL-specific human-readable glyph description.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE260"]
>>> glyph.smufl.description = "Flat"
>>> glyph.smufl.description
'Flat'
"""
glyph = self._requireGlyphAccess("description")
if glyph is None:
return None
return glyph.lib.naked().get(DESCRIPTION_LIB_KEY, None)
@description.setter
def description(self, value: str | None) -> None:
self._requireGlyphAccess("description")
_lib.updateLibSubdict(
self.glyph,
DESCRIPTION_LIB_KEY,
normalizers.normalizeDescription(value, "Smufl.description"),
)
@property
def name(self) -> str | None:
"""SMuFL-specific canonical font or glyph name.
This property behaves differently depending on the context in which the
:class:`Smufl` object is used:
- When accessed from a :class:`.Font` (``e.g, font.smufl.name``), it returns the
SMuFL font name, equivalent to ``font.info.familyName``.
- When accessed from a :class:`.Glyph` (``e.g, glyph.smufl.name``), it returns
the canonical SMuFL glyph name.
Examples:
>>> font.smufl.name = "MyFont"
>>> font.smufl.name
'MyFont'
>>> glyph = font["uniE050"]
>>> glyph.smufl.name = "gClef"
>>> glyph.smufl.name
'gClef'
"""
if self.font is None:
return None
if self.glyph is None:
return self.font.info.naked().familyName
return self.glyph.lib.naked().get(NAME_LIB_KEY, None)
@name.setter
def name(self, value: str | None) -> None:
# Update com.smufolib.names before ID property
if self.font is not None:
if self._glyph is None:
self.font.info.naked().familyName = value
else:
normalizedName = normalizers.normalizeSmuflName(value, "Smufl.name")
self._updateRange(normalizedName)
self._updateNames(normalizedName)
_lib.updateLibSubdict(
self.glyph,
NAME_LIB_KEY,
normalizedName,
)
# TODO: Remove in v0.8.0
@property
def names(self) -> dict[str, str] | None:
"""Mapping of canonical SMuFL names to corresponding glyph names.
.. deprecated:: 0.7.0
This property is read-only. Its content is updated through the
:attr:`.Smufl.name` and :attr:`Glyph.name <fontParts.base.BaseGlyph.name>`
attributes.
"""
warnings.warn(
error.generateErrorMessage(
"deprecated",
"deprecatedReplacement",
objectName="Smufl.names",
version="0.7",
replacement="keys",
),
DeprecationWarning,
stacklevel=2,
)
return _lib.getLibSubdict(self.font, NAMES_LIB_KEY)
@property
def _names(self) -> dict[str, str]:
return _lib.getLibSubdict(self.font, NAMES_LIB_KEY) or {}
def _clearNames(self) -> None:
if self.font is not None:
if self.name:
self._names.pop(self.name, None)
if not self._names:
_lib.updateLibSubdict(self.font, NAMES_LIB_KEY, None)
def _addNames(self, value: Any) -> None:
if self._glyph is not None:
if value in self._names and self._names[value] != self._glyph.name:
raise ValueError(
error.generateErrorMessage(
"duplicateAttributeValue",
value=value,
attribute="smufl.name",
objectName="Glyph",
conflictingInstance=self._names[value],
)
)
if self._glyph.name in self._names.values():
filtered = {
k: v for k, v in self._names.items() if v != self._glyph.name
}
_lib.updateLibSubdict(self.font, NAMES_LIB_KEY, filtered)
_lib.updateLibSubdictValue(
self.font, NAMES_LIB_KEY, value, self._glyph.name
)
def _updateNames(self, value: str | None) -> None:
# Keep dynamic dict of glyph names in font.lib.
if value is None:
self._clearNames()
else:
self._addNames(value)
def _updateRange(self, value: str | None) -> None:
# Update name in range.glyphs.
if not self.font or not self.ranges:
return
range_ = self.ranges[0]
if not range_._internal:
return
glyphs = self.font.lib[RANGES_LIB_KEY][range_.name]["glyphs"]
if self.name in glyphs:
glyphs.remove(self.name)
if value is not None:
glyphs.append(value)
# ----------
# Predicates
# ----------
@property
def isLigature(self) -> bool:
"""Return :obj:`True` if glyph is ligature.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> ligature = font["uniE26A_uniE260_uniE26B"]
>>> ligature.smufl.isLigature
True
>>> glyph = font["uniE260"]
>>> glyph.smufl.isLigature
False
"""
glyph = self._requireGlyphAccess("isLigature")
if (
glyph is not None
and glyph.name
and glyph.name.count("uni") > 1
and "_" in glyph.name
):
return True
return False
@property
def isMember(self) -> bool:
"""Return :obj:`True` if glyph is either :smufl:`recommended or optional
<about/recommended-chars-optional-glyphs.html>`.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.isMember
True
>>> glyph = font["space"]
>>> glyph.smufl.isMember
False
"""
glyph = self._requireGlyphAccess("isMember")
if glyph is not None and glyph.unicode and 0xE000 <= glyph.unicode <= 0xF8FF:
return True
return False
@property
def isOptional(self) -> bool:
"""Return :obj:`True` if glyph is :smufl:`optional
<about/recommended-chars-optional-glyphs.html>`.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050.ss01"]
>>> glyph.smufl.isOptional
True
>>> glyph = font["uniE050"]
>>> glyph.smufl.isOptional
False
"""
glyph = self._requireGlyphAccess("isOptional")
if glyph is not None and glyph.unicode and 0xF400 <= glyph.unicode <= 0xF8FF:
return True
return False
@property
def isRecommended(self) -> bool:
"""Return :obj:`True` if glyph is :smufl:`recommended
<about/recommended-chars-optional-glyphs.html>`.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050"]
>>> glyph.smufl.isRecommended
True
>>> glyph = font["uniE050.ss01"]
>>> glyph.smufl.isRecommended
False
"""
glyph = self._requireGlyphAccess("isRecommended")
if glyph is not None and glyph.unicode and 0xE000 <= glyph.unicode <= 0xF3FF:
return True
return False
@property
def isSalt(self) -> bool:
"""Return :obj:`True` if glyph is stylistic alternate.
Glyph names with either ``".alt"`` and ``".salt"`` suffix are
accepted. See :ref:`Note <about-glyph-naming>` about glyph
naming.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE062.salt01"]
>>> glyph.smufl.isSalt
True
>>> glyph = font["uniE050.ss01"]
>>> glyph.smufl.isSalt
False
"""
glyph = self._requireGlyphAccess("isSalt")
if (
glyph is not None
and glyph.name
and (
glyph.name.endswith(".salt", 7, -2)
or glyph.name.endswith(".alt", 7, -2)
)
):
return True
return False
@property
def isSet(self) -> bool:
"""Return :obj:`True` if glyph is stylistic set member.
See :ref:`Note <about-glyph-naming>` about glyph naming.
This property is read-only.
:raises AttributeError: If attempting to access attribute from font.
Example:
>>> glyph = font["uniE050.ss01"]
>>> glyph.smufl.isSet
True
>>> glyph = font["uniE062.salt01"]
>>> glyph.smufl.isSet
False
"""
glyph = self._requireGlyphAccess("isSet")
if glyph is not None and glyph.name and glyph.name.endswith(".ss", 7, -2):
return True
return False
# -----------------------------
# Normalization and Measurement
# -----------------------------
[docs]
def round(self) -> None:
"""Round font units to integers.
Method applies to the following attributes:
- :attr:`Smufl.engravingDefaults`
- :attr:`Smufl.anchors`
- :attr:`Smufl.advanceWidth`
- :attr:`Glyph.width <fontParts.base.BaseGlyph.width>`
- :attr:`Glyph.height <fontParts.base.BaseGlyph.height>`
- :attr:`Glyph.contours <fontParts.base.BaseGlyph.contours>`
- :attr:`Glyph.components <fontParts.base.BaseGlyph.components>`
- :attr:`Glyph.anchors <fontParts.base.BaseGlyph.anchors>`
- :attr:`Glyph.guidelines <fontParts.base.BaseGlyph.guidelines>`
If :attr:`spaces` is :obj:`True`, values are left unchanged.
Examples:
>>> font.smufl.spaces = True
>>> glyph.smufl.advanceWidth
0.922
>>> glyph.smufl.round()
>>> glyph.smufl.advanceWidth
0.922
>>> font.smufl.spaces = False
>>> glyph = font["uniE050"]
>>> glyph.smufl.advanceWidth
230.5
>>> glyph.smufl.round()
>>> glyph.smufl.advanceWidth
231
"""
if self.spaces:
return
if self.font:
self.engravingDefaults.round()
if self._glyph is not None:
self._glyph.round()
[docs]
def toSpaces(self, value: int | float) -> float | None:
"""Convert font units to staff spaces based on font UPM size.
The inverse of :meth:`toUnits`.
:param value: Value to convert.
Example::
>>> font.info.unitsPerEm = 2048
>>> font.smufl.toSpaces(512)
1.0
"""
if self.font is None:
return None
if not self.font.info.unitsPerEm:
raise AttributeError(
error.generateErrorMessage(
"missingDependency",
objectName="value",
dependency="font.info.unitsPerEm",
)
)
return converters.convertMeasurement(
measurement=value,
targetUnit="spaces",
unitsPerEm=self.font.info.unitsPerEm,
rounded=False,
)
[docs]
def toUnits(self, value: int | float, rounded=True) -> int | float | None:
"""Convert staff spaces to font units based on font UPM size.
The inverse of :meth:`toSpaces`. The result is always rounded.
:param value: Value to convert.
:param rounded: Whether to round result to nearest integer.
Example::
>>> font.info.unitsPerEm = 2048
>>> font.smufl.toSpaces(2)
1024
"""
if self.font is None:
return None
if not self.font.info.unitsPerEm:
raise AttributeError(
error.generateErrorMessage(
"missingDependency",
objectName="value",
dependency="font.info.unitsPerEm",
)
)
return converters.convertMeasurement(
measurement=value,
targetUnit="units",
unitsPerEm=self.font.info.unitsPerEm,
rounded=rounded,
)
@property
def spaces(self) -> bool:
"""Set state of measurement to staff spaces.
Example:
>>> glyph = font["uniE050"]
>>> font.smufl.spaces = True
>>> glyph.smufl.advanceWidth
0.922
>>> font.smufl.spaces = False
>>> glyph.smufl.advanceWidth
230.5
"""
if self.font is None:
return False
return self.font.lib.naked().get(SPACES_LIB_KEY, False)
@spaces.setter
def spaces(self, value):
if self.font is not None:
if not self.font.info.unitsPerEm:
raise AttributeError(
error.generateErrorMessage(
"missingDependency",
objectName="spaces",
dependency="font.info.unitsPerEm",
)
)
value = normalizers.normalizeBoolean(value)
if value:
self.font.lib.naked()[SPACES_LIB_KEY] = True
else:
self.font.lib.naked().pop(SPACES_LIB_KEY, False)
# ------------------------
# Override from BaseObject
# ------------------------
[docs]
def raiseNotImplementedError(self):
"""This exception needs to be raised frequently by
the base classes. So, it's here for convenience.
"""
raise NotImplementedError( # pragma: no cover
error.generateErrorMessage(
"notImplementedError", objectName=self.__class__.__name__
)
)