Source code for ufoLib2.objects.layer

from __future__ import annotations

from typing import (
    TYPE_CHECKING,
    Any,
    Dict,
    Iterator,
    KeysView,
    Optional,
    Sequence,
    Type,
    overload,
)

from attrs import define, field
from fontTools.ufoLib.glifLib import GlyphSet

from ufoLib2.constants import DEFAULT_LAYER_NAME
from ufoLib2.objects.glyph import Glyph
from ufoLib2.objects.lib import (
    Lib,
    _convert_Lib,
    _get_lib,
    _get_tempLib,
    _set_lib,
    _set_tempLib,
)
from ufoLib2.objects.misc import (
    BoundingBox,
    _deepcopy_unlazify_attrs,
    _getstate_unlazify_attrs,
    _prune_object_libs,
    _setstate_attrs,
    unionBounds,
)
from ufoLib2.serde import serde
from ufoLib2.typing import T

if TYPE_CHECKING:
    from cattrs import Converter

_GLYPH_NOT_LOADED = Glyph(name="___UFOLIB2_LAZY_GLYPH___")


def _convert_glyphs(value: dict[str, Glyph] | Sequence[Glyph]) -> dict[str, Glyph]:
    result: dict[str, Glyph] = {}
    if isinstance(value, dict):
        glyph_ids = set()
        for name, glyph in value.items():
            if not isinstance(glyph, Glyph):
                raise TypeError(f"Expected Glyph, found {type(glyph).__name__}")
            if glyph is not _GLYPH_NOT_LOADED:
                glyph_id = id(glyph)
                if glyph_id in glyph_ids:
                    raise KeyError(f"{glyph!r} can't be added twice")
                glyph_ids.add(glyph_id)
                if glyph.name is None:
                    glyph._name = name
                elif glyph.name != name:
                    raise ValueError(
                        "glyph has incorrect name: "
                        f"expected '{name}', found '{glyph.name}'"
                    )
            result[name] = glyph
    else:
        for glyph in value:
            if not isinstance(glyph, Glyph):
                raise TypeError(f"Expected Glyph, found {type(glyph).__name__}")
            if glyph.name is None:
                raise ValueError(f"{glyph!r} has no name; can't add it to Layer")
            if glyph.name in result:
                raise KeyError(f"glyph named '{glyph.name}' already exists")
            result[glyph.name] = glyph
    return result


[docs] @serde @define class Layer: """Represents a Layer that holds Glyph objects. See http://unifiedfontobject.org/versions/ufo3/glyphs/layerinfo.plist/. Note: Various methods that work on Glyph objects take a ``layer`` attribute, because the UFO data model prescribes that Components within a Glyph object refer to glyphs *within the same layer*. Behavior: Layer behaves **partly** like a dictionary of type ``Dict[str, Glyph]``. Unless the font is loaded eagerly (with ``lazy=False``), the Glyph objects by default are only loaded into memory when accessed. To get the number of glyphs in the layer:: glyphCount = len(layer) To iterate over all glyphs:: for glyph in layer: ... To check if a specific glyph exists:: exists = "myGlyphName" in layer To get a specific glyph:: layer["myGlyphName"] To delete a specific glyph:: del layer["myGlyphName"] """ _name: str = field(default=DEFAULT_LAYER_NAME, metadata={"omit_if_default": False}) _glyphs: Dict[str, Glyph] = field(factory=dict, converter=_convert_glyphs) color: Optional[str] = None """The color assigned to the layer.""" _lib: Lib = field(factory=Lib, converter=_convert_Lib) """The layer's lib for mapping string keys to arbitrary data.""" _default: bool = False """Can set to True to mark a layer as default. If layer name is 'public.default' the default attribute is automatically True. Exactly one layer must be marked as default in a font.""" _tempLib: Lib = field(factory=Lib, converter=_convert_Lib) """A temporary map of arbitrary plist values.""" _lazy: Optional[bool] = field(default=None, init=False, eq=False) _glyphSet: Any = field(default=None, init=False, eq=False) def __attrs_post_init__(self) -> None: if self._name == DEFAULT_LAYER_NAME and not self._default: # layer named 'public.default' is default by definition self._default = True
[docs] @classmethod def read( cls, name: str, glyphSet: GlyphSet, lazy: bool = True, default: bool = False ) -> Layer: """Instantiates a Layer object from a :class:`fontTools.ufoLib.glifLib.GlyphSet`. Args: name: The name of the layer. glyphSet: The GlyphSet object to read from. lazy: If True, load glyphs as they are accessed. If False, load everything up front. """ glyphNames = glyphSet.keys() glyphs: dict[str, Glyph] if lazy: glyphs = {gn: _GLYPH_NOT_LOADED for gn in glyphNames} else: glyphs = {} for glyphName in glyphNames: glyph = Glyph(glyphName) glyphSet.readGlyph(glyphName, glyph, glyph.getPointPen()) glyphs[glyphName] = glyph self = cls(name, glyphs, default=default) self._lazy = lazy if lazy: self._glyphSet = glyphSet glyphSet.readLayerInfo(self) return self
[docs] def unlazify(self) -> None: """Load all glyphs into memory.""" if self._lazy: for _ in self: pass self._lazy = False
__deepcopy__ = _deepcopy_unlazify_attrs __getstate__ = _getstate_unlazify_attrs __setstate__ = _setstate_attrs def __contains__(self, name: object) -> bool: return name in self._glyphs def __delitem__(self, name: str) -> None: del self._glyphs[name] def __getitem__(self, name: str) -> Glyph: glyph_object = self._glyphs[name] if glyph_object is _GLYPH_NOT_LOADED: return self.loadGlyph(name) return glyph_object def __setitem__(self, name: str, glyph: Glyph) -> None: if not isinstance(glyph, Glyph): raise TypeError(f"Expected Glyph, found {type(glyph).__name__}") glyph._name = name self._glyphs[name] = glyph def __iter__(self) -> Iterator[Glyph]: for name in self._glyphs: yield self[name] def __len__(self) -> int: return len(self._glyphs) def __repr__(self) -> str: n = len(self._glyphs) return "<{}.{} '{}' ({}{}) at {}>".format( self.__class__.__module__, self.__class__.__name__, self._name, "default, " if self._default else "", "empty" if n == 0 else "{} glyph{}".format(n, "s" if n > 1 else ""), hex(id(self)), )
[docs] def get(self, name: str, default: T | None = None) -> T | Glyph | None: """Return the Glyph object for name if it is present in this layer, otherwise return ``default``.""" try: return self[name] except KeyError: return default
[docs] def keys(self) -> KeysView[str]: """Returns a list of glyph names.""" return self._glyphs.keys()
@overload def pop(self, key: str) -> Glyph: ... @overload def pop(self, key: str, default: Glyph | T = ...) -> Glyph | T: ...
[docs] def pop(self, key: str, default: Glyph | T = KeyError) -> Glyph | T: # type: ignore """Remove and return glyph from layer. Args: key: The name of the glyph. default: What to return if there is no glyph with the given name. """ # NOTE: We can't defer to self._glyphs.pop because we must load glyphs try: glyph = self[key] except KeyError: if default is KeyError: raise glyph = default # type: ignore else: del self[key] return glyph
@property def name(self) -> str: """The name of the layer.""" return self._name lib = property(_get_lib, _set_lib) tempLib = property(_get_tempLib, _set_tempLib) @property def default(self) -> bool: """Read-only property. To change the font's default layer use the LayerSet.defaultLayer property setter.""" return self._default @property def bounds(self) -> BoundingBox | None: """Returns the (xMin, yMin, xMax, yMax) bounding box of the layer, taking the actual contours into account. |defcon_compat| """ bounds = None for glyph in self: bounds = unionBounds(bounds, glyph.getBounds(self)) return bounds @property def controlPointBounds(self) -> BoundingBox | None: """Returns the (xMin, yMin, xMax, yMax) bounding box of the layer, taking only the control points into account. |defcon_compat| """ bounds = None for glyph in self: bounds = unionBounds(bounds, glyph.getControlBounds(self)) return bounds
[docs] def addGlyph(self, glyph: Glyph) -> None: """Appends glyph object to the this layer unless its name is already taken.""" self.insertGlyph(glyph, overwrite=False, copy=False)
[docs] def insertGlyph( self, glyph: Glyph, name: str | None = None, overwrite: bool = True, copy: bool = True, ) -> None: """Inserts Glyph object into this layer. Args: glyph: The Glyph object. name: The name of the glyph. overwrite: If True, overwrites (read: deletes) glyph with the same name if it exists. If False, raises KeyError. copy: If True, copies the Glyph object before insertion. If False, inserts as is. """ if copy: glyph = glyph.copy() if name is not None: glyph._name = name if glyph.name is None: raise ValueError(f"{glyph!r} has no name; can't add it to Layer") if not overwrite and glyph.name in self._glyphs: raise KeyError(f"glyph named '{glyph.name}' already exists") self._glyphs[glyph.name] = glyph
[docs] def loadGlyph(self, name: str) -> Glyph: """Load and return Glyph object.""" # XXX: Remove and let __getitem__ do it? glyph = Glyph(name) self._glyphSet.readGlyph(name, glyph, glyph.getPointPen()) self._glyphs[name] = glyph return glyph
[docs] def newGlyph(self, name: str) -> Glyph: """Creates and returns new Glyph object in this layer with name.""" if name in self._glyphs: raise KeyError(f"glyph named '{name}' already exists") self._glyphs[name] = glyph = Glyph(name) return glyph
[docs] def renameGlyph(self, name: str, newName: str, overwrite: bool = False) -> None: """Renames a Glyph object in this layer. Args: name: The old name. newName: The new name. overwrite: If False, raises exception if newName is already taken. If True, overwrites (read: deletes) the old Glyph object. """ if name == newName: return if not overwrite and newName in self._glyphs: raise KeyError(f"target glyph named '{newName}' already exists") # pop and set name glyph = self.pop(name) glyph._name = newName # add it back self._glyphs[newName] = glyph
[docs] def instantiateGlyphObject(self) -> Glyph: """Returns a new Glyph instance. |defcon_compat| """ return Glyph()
[docs] def write(self, glyphSet: GlyphSet, saveAs: bool = True) -> None: """Write Layer to a :class:`fontTools.ufoLib.glifLib.GlyphSet`. Args: glyphSet: The GlyphSet object to write to. saveAs: If True, tells the writer to save out-of-place. If False, tells the writer to save in-place. This affects how resources are cleaned before writing. """ glyphs = self._glyphs if not saveAs: for name in set(glyphSet.contents).difference(glyphs): glyphSet.deleteGlyph(name) for name, glyph in glyphs.items(): if glyph is _GLYPH_NOT_LOADED: if saveAs: glyph = self.loadGlyph(name) else: continue _prune_object_libs(glyph.lib, _fetch_glyph_identifiers(glyph)) glyphSet.writeGlyph( name, glyphObject=glyph, drawPointsFunc=glyph.drawPoints ) glyphSet.writeContents() glyphSet.writeLayerInfo(self) if saveAs: # all glyphs are loaded by now, no need to keep ref to glyphSet self._glyphSet = None
def _unstructure(self, converter: Converter) -> dict[str, Any]: # omit glyph name attribute, already used as key glyphs: dict[str, dict[str, Any]] = {} for glyph_name in self._glyphs: g = converter.unstructure(self[glyph_name]) assert glyph_name == g.pop("name") glyphs[glyph_name] = g d: dict[str, Any] = { # never omit name even if == 'public.default' as that acts as # the layer's "key" in the layerSet. "name": self._name, } default: Any for key, value, default in [ ("default", self._default, self._name == DEFAULT_LAYER_NAME), ("glyphs", glyphs, {}), ("lib", self._lib, {}), ("tempLib", self._tempLib, {}), ]: if not converter.omit_if_default or value != default: d[key] = value if self.color is not None: d["color"] = self.color return d @staticmethod def _structure( data: dict[str, Any], cls: Type[Layer], converter: Converter ) -> Layer: return cls( name=data.get("name", DEFAULT_LAYER_NAME), glyphs={ k: converter.structure(v, Glyph) for k, v in data.get("glyphs", {}).items() }, color=data.get("color"), lib=converter.structure(data.get("lib", {}), Lib), default=data.get("default", False), tempLib=converter.structure(data.get("tempLib", {}), Lib), )
def _fetch_glyph_identifiers(glyph: Glyph) -> set[str]: """Returns all identifiers in use in a glyph.""" identifiers = set() for anchor in glyph.anchors: if anchor.identifier is not None: identifiers.add(anchor.identifier) for guideline in glyph.guidelines: if guideline.identifier is not None: identifiers.add(guideline.identifier) for contour in glyph.contours: if contour.identifier is not None: identifiers.add(contour.identifier) for point in contour: if point.identifier is not None: identifiers.add(point.identifier) for component in glyph.components: if component.identifier is not None: identifiers.add(component.identifier) return identifiers