Source code for pytamaro.graphic

"""Type `Graphic`, that includes a graphic with a pinning position."""

from abc import ABC, abstractmethod
from dataclasses import dataclass

from pytamaro.color import Color
from pytamaro.localization import translate
from pytamaro.point import Point
from pytamaro.point_names import bottom_center, center, center_left, center_right, top_center
from pytamaro.utils import Spec


[docs] @dataclass(frozen=True) class Graphic(ABC): """A graphic (image) with a position for pinning. The pinning position is used in the following operations: - rotation (to determine the center of rotation) - graphic composition (two graphics get composed aligning their pinning position). """ @abstractmethod def spec_with_deps(self) -> tuple[Spec, list]: """Returns a tuple that "declaratively specifies" this graphic. The first is a dictionary with this graphic's properties. The second member is a list of Graphics that this graphic depends on. See :file:`impl/ffi/specs.py` for more information. """
@dataclass(frozen=True) class Empty(Graphic): """An empty graphic.""" def __repr__(self) -> str: # noqa: D105 return f"{translate('empty_graphic')}()" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Empty", }, [] @dataclass(frozen=True) class Rectangle(Graphic): """A rectangle.""" width: float height: float color: Color def __repr__(self) -> str: # noqa: D105 return f"{translate('rectangle')}({self.width}, {self.height}, {self.color})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Rectangle", "width": self.width, "height": self.height, "color": self.color.value_for_spec, }, [] @dataclass(frozen=True) class Ellipse(Graphic): """An ellipse.""" width: float height: float color: Color def __repr__(self) -> str: # noqa: D105 return f"{translate('ellipse')}({self.width}, {self.height}, {self.color})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Ellipse", "width": self.width, "height": self.height, "color": self.color.value_for_spec, }, [] @dataclass(frozen=True) class CircularSector(Graphic): """A circular sector (with an angle between 0 and 360). Its pinning position is the center of the circle from which it is taken. """ radius: float angle: float color: Color def __repr__(self) -> str: # noqa: D105 return f"{translate('circular_sector')}({self.radius}, {self.angle}, {self.color})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "CircularSector", "radius": self.radius, "angle": self.angle, "color": self.color.value_for_spec, }, [] @dataclass(frozen=True) class Triangle(Graphic): """A triangle specified using two sides and the angle between them. The first side extends horizontally to the right. The second side is rotated counterclockwise by the specified angle. Its pinning position is the centroid of the triangle. """ side1: float side2: float angle: float color: Color def __repr__(self) -> str: # noqa: D105 return f"{translate('triangle')}({self.side1}, {self.side2}, {self.angle}, {self.color})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Triangle", "side1": self.side1, "side2": self.side2, "angle": self.angle, "color": self.color.value_for_spec, }, [] @dataclass(frozen=True) class Text(Graphic): """Graphic containing text, using a given font with a given typographic size. Its pinning position is horizontally aligned on the left and vertically on the baseline of the text. """ text: str font_name: str text_size: float color: Color def __repr__(self) -> str: # noqa: D105 return f"{translate('text')}({self.text!r}, {self.font_name!r}, {self.text_size}, {self.color})" # noqa: E501 def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Text", "text": self.text, "font_name": self.font_name, "text_size": self.text_size, "color": self.color.value_for_spec, }, [] @dataclass(frozen=True) class Compose(Graphic): """Represents the composition of two graphics, one in the foreground and the other in the background, joined on their pinning positions. """ foreground: Graphic background: Graphic def __repr__(self) -> str: # noqa: D105 return f"{translate('compose')}({self.foreground}, {self.background})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 spec: Spec = {"t": "Compose"} deps: list[Graphic] = [] def optimize_child_pin(child: Graphic, key_prefix: str): """Optimization: If `child` is a `Pin`, directly add the pinning position to the spec of the current graphic, to skip one level in the tree. """ if isinstance(child, Pin): spec[f"{key_prefix}_pin"] = child.pinning_point.value_for_spec deps.append(child.graphic) else: deps.append(child) optimize_child_pin(self.foreground, "fg") optimize_child_pin(self.background, "bg") return spec, deps @dataclass(frozen=True) class Pin(Graphic): """Represents the pinning of a graphic in a certain position on its bounds.""" graphic: Graphic pinning_point: Point def __repr__(self) -> str: # noqa: D105 return f"{translate('pin')}({self.pinning_point}, {self.graphic})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 if isinstance(self.graphic, Compose): # Optimization: if our direct child is a Compose, # we can directly add the pinning position to the spec of the Compose, # to skip one level in the tree. child_spec, child_deps = self.graphic.spec_with_deps() child_spec["pin"] = self.pinning_point.value_for_spec return child_spec, child_deps # Regular case return { "t": "Pin", "pin": self.pinning_point.value_for_spec, }, [self.graphic] @dataclass(frozen=True) class Rotate(Graphic): """Represents the counterclockwise rotation of a graphic by a certain angle around the pinning position. The angle is expressed in degrees. """ graphic: Graphic angle: float def __repr__(self) -> str: # noqa: D105 return f"{translate('rotate')}({self.angle}, {self.graphic})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 # Optimization: skip rotations by multiples of 360 degrees if ( (isinstance(self.angle, float) and self.angle.is_integer()) or isinstance(self.angle, int) ) and int(self.angle) % 360 == 0: return self.graphic.spec_with_deps() return { "t": "Rotate", "angle": self.angle, }, [self.graphic] @dataclass(frozen=True) class Beside(Graphic): """Represents the composition of two graphics one beside the other, vertically centered. """ left_graphic: Graphic right_graphic: Graphic def __repr__(self) -> str: # noqa: D105 return f"{translate('beside')}({self.left_graphic}, {self.right_graphic})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Compose", "fg_pin": center_right.value_for_spec, "bg_pin": center_left.value_for_spec, "pin": center.value_for_spec, }, [self.left_graphic, self.right_graphic] @dataclass(frozen=True) class Above(Graphic): """Represents the composition of two graphics one above the other, horizontally centered. """ top_graphic: Graphic bottom_graphic: Graphic def __repr__(self) -> str: # noqa: D105 return f"{translate('above')}({self.top_graphic}, {self.bottom_graphic})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Compose", "fg_pin": bottom_center.value_for_spec, "bg_pin": top_center.value_for_spec, "pin": center.value_for_spec, }, [self.top_graphic, self.bottom_graphic] @dataclass(frozen=True) class Overlay(Graphic): """Represents the composition of two graphics that one overlay other, the center of the two graphics are at the same position. """ front_graphic: Graphic back_graphic: Graphic def __repr__(self) -> str: # noqa: D105 return f"{translate('overlay')}({self.front_graphic}, {self.back_graphic})" def spec_with_deps(self) -> tuple[Spec, list[Graphic]]: # noqa: D102 return { "t": "Compose", "fg_pin": center.value_for_spec, "bg_pin": center.value_for_spec, # When `pin` is absent, it defaults to `bg_pin`. # We omit it here as an optimization. # 'pin': center.spec, }, [self.front_graphic, self.back_graphic]