Source code for ufoLib2.objects.misc

import collections.abc
import uuid
from abc import abstractmethod
from collections.abc import Mapping, MutableMapping
from copy import deepcopy
from typing import (
    Any,
    Dict,
    Iterator,
    List,
    NamedTuple,
    Optional,
    Sequence,
    Set,
    Type,
    TypeVar,
    Union,
    cast,
)

import attr
from attr import define, field
from fontTools.misc.arrayTools import unionRect
from fontTools.misc.transform import Transform
from fontTools.pens.boundsPen import BoundsPen, ControlBoundsPen
from fontTools.ufoLib import UFOReader, UFOWriter

from ufoLib2.constants import OBJECT_LIBS_KEY
from ufoLib2.typing import Drawable, GlyphSet, HasIdentifier


[docs]class BoundingBox(NamedTuple): """Represents a bounding box as a tuple of (xMin, yMin, xMax, yMax).""" xMin: float yMin: float xMax: float yMax: float
def getBounds(drawable: Drawable, layer: Optional[GlyphSet]) -> Optional[BoundingBox]: pen = BoundsPen(layer) # raise 'KeyError' when a referenced component is missing from glyph set pen.skipMissingComponents = False drawable.draw(pen) return None if pen.bounds is None else BoundingBox(*pen.bounds) def getControlBounds( drawable: Drawable, layer: Optional[GlyphSet] ) -> Optional[BoundingBox]: pen = ControlBoundsPen(layer) # raise 'KeyError' when a referenced component is missing from glyph set pen.skipMissingComponents = False drawable.draw(pen) return None if pen.bounds is None else BoundingBox(*pen.bounds) def unionBounds( bounds1: Optional[BoundingBox], bounds2: Optional[BoundingBox] ) -> Optional[BoundingBox]: if bounds1 is None: return bounds2 if bounds2 is None: return bounds1 return BoundingBox(*unionRect(bounds1, bounds2)) def _deepcopy_unlazify_attrs(self: Any, memo: Any) -> Any: if getattr(self, "_lazy", True) and hasattr(self, "unlazify"): self.unlazify() return self.__class__( **{ (a.name if a.name[0] != "_" else a.name[1:]): deepcopy( getattr(self, a.name), memo ) for a in attr.fields(self.__class__) if a.init and a.metadata.get("copyable", True) }, ) def _object_lib(parent_lib: Dict[str, Any], object: HasIdentifier) -> Dict[str, Any]: if object.identifier is None: # Use UUID4 because it allows us to set a new identifier without # checking if it's already used anywhere else and be right most # of the time. object.identifier = str(uuid.uuid4()) object_libs: Dict[str, Any] if "public.objectLibs" not in parent_lib: object_libs = parent_lib["public.objectLibs"] = {} else: object_libs = parent_lib["public.objectLibs"] assert isinstance(object_libs, collections.abc.MutableMapping) if object.identifier in object_libs: return object_libs[object.identifier] lib = object_libs[object.identifier] = {} return lib def _prune_object_libs(parent_lib: Dict[str, Any], identifiers: Set[str]) -> None: """Prune non-existing objects and empty libs from a lib's public.objectLibs. Empty object libs are pruned, but object identifiers stay. """ if OBJECT_LIBS_KEY not in parent_lib: return object_libs = parent_lib[OBJECT_LIBS_KEY] parent_lib[OBJECT_LIBS_KEY] = { k: v for k, v in object_libs.items() if k in identifiers and v } class Placeholder: """Represents a sentinel value to signal a "lazy" object hasn't been loaded yet.""" _NOT_LOADED = Placeholder() # Create a generic variable for mypy that can be 'DataStore' or any subclass. Tds = TypeVar("Tds", bound="DataStore") @define class DataStore(MutableMapping): """Represents the base class for ImageSet and DataSet. Both behave like a dictionary that loads its "values" lazily by default and only differ in which reader and writer methods they call. """ _data: Dict[str, Union[bytes, Placeholder]] = field(factory=dict) _lazy: Optional[bool] = field(default=False, kw_only=True, eq=False, init=False) _reader: Optional[UFOReader] = field(default=None, init=False, repr=False, eq=False) _scheduledForDeletion: Set[str] = field( factory=set, init=False, repr=False, eq=False ) def __eq__(self, other: object) -> bool: # same as attrs-defined __eq__ method, only that it un-lazifies DataStores # if needed. # NOTE: Avoid isinstance check that mypy recognizes because we don't want to # test possible Font subclasses for equality. if other.__class__ is not self.__class__: return NotImplemented other = cast(DataStore, other) for data_store in (self, other): if data_store._lazy: data_store.unlazify() return self._data == other._data def __ne__(self, other: object) -> bool: result = self.__eq__(other) if result is NotImplemented: return NotImplemented return not result @classmethod def read(cls: Type[Tds], reader: UFOReader, lazy: bool = True) -> Tds: """Instantiate the data store from a :class:`fontTools.ufoLib.UFOReader`.""" self = cls() for fileName in cls.list_contents(reader): if lazy: self._data[fileName] = _NOT_LOADED else: self._data[fileName] = cls.read_data(reader, fileName) self._lazy = lazy if lazy: self._reader = reader return self @staticmethod @abstractmethod def list_contents(reader: UFOReader) -> List[str]: """Returns a list of POSIX filename strings in the data store.""" ... @staticmethod @abstractmethod def read_data(reader: UFOReader, filename: str) -> bytes: """Returns the data at filename within the store.""" ... @staticmethod @abstractmethod def write_data(writer: UFOWriter, filename: str, data: bytes) -> None: """Writes the data to filename within the store.""" ... @staticmethod @abstractmethod def remove_data(writer: UFOWriter, filename: str) -> None: """Remove the data at filename within the store.""" ... def unlazify(self) -> None: """Load all data into memory.""" if self._lazy: assert self._reader is not None for _ in self.items(): pass self._lazy = False __deepcopy__ = _deepcopy_unlazify_attrs # MutableMapping methods def __len__(self) -> int: return len(self._data) def __iter__(self) -> Iterator[str]: return iter(self._data) def __getitem__(self, fileName: str) -> bytes: data_object = self._data[fileName] if isinstance(data_object, Placeholder): data_object = self._data[fileName] = self.read_data(self._reader, fileName) return data_object def __setitem__(self, fileName: str, data: bytes) -> None: # should we forbid overwrite? self._data[fileName] = data if fileName in self._scheduledForDeletion: self._scheduledForDeletion.remove(fileName) def __delitem__(self, fileName: str) -> None: del self._data[fileName] self._scheduledForDeletion.add(fileName) def __repr__(self) -> str: n = len(self._data) return "<{}.{} ({}) at {}>".format( self.__class__.__module__, self.__class__.__name__, "empty" if n == 0 else "{} file{}".format(n, "s" if n > 1 else ""), hex(id(self)), ) def write(self, writer: UFOWriter, saveAs: Optional[bool] = None) -> None: """Write the data store to a :class:`fontTools.ufoLib.UFOWriter`.""" if saveAs is None: saveAs = self._reader is not writer # if in-place, remove deleted data if not saveAs: for fileName in self._scheduledForDeletion: self.remove_data(writer, fileName) # Write data. Iterating over _data.items() prevents automatic loading. for fileName, data in self._data.items(): # Two paths: # 1) We are saving in-place. Only write to disk what is loaded, it # might be modified. # 2) We save elsewhere. Load all data files to write them back out. # XXX: Move write_data into `if saveAs` branch to simplify code? if isinstance(data, Placeholder): if saveAs: data = self.read_data(self._reader, fileName) self._data[fileName] = data else: continue self.write_data(writer, fileName, data) self._scheduledForDeletion = set() if saveAs: # all data was read by now, ref to reader no longer needed self._reader = None @property def fileNames(self) -> List[str]: """Returns a list of filenames in the data store.""" return list(self._data.keys()) class AttrDictMixin(Mapping): """Read attribute values using mapping interface. For use with Anchors and Guidelines classes, where client code expects them to behave as dict. """ # XXX: Use generics? def __getitem__(self, key: str) -> Any: try: return getattr(self, key) except AttributeError: raise KeyError(key) def __iter__(self) -> Iterator[Any]: for key in attr.fields_dict(self.__class__): if getattr(self, key) is not None: yield key def __len__(self) -> int: return sum(1 for _ in self) def _convert_transform(t: Union[Transform, Sequence[float]]) -> Transform: """Return a passed-in Transform as is, otherwise convert a sequence of numbers to a Transform if need be.""" return t if isinstance(t, Transform) else Transform(*t)