Source code for pytamaro.graphic

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

import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import cached_property

from skia import (Canvas, Font, FontMgr, Matrix, Paint, Path, Point, Rect,
                  Size, Typeface)

from pytamaro.color import Color
from pytamaro.localization import translate
from pytamaro.point import Point as PyTamaroPoint
from pytamaro.point_names import (bottom_center, center, center_left,
                                  center_right, top_center)


[docs]@dataclass(frozen=True, eq=False) 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). """ pin_position: Point path: Path def size(self) -> Size: """ Computes the size of this graphic (x and y axes spanning), using the bounds computed by bounds(). :returns: graphic's size """ return Size(self.bounds.width(), self.bounds.height()) @cached_property def bounds(self) -> Rect: """ Computes the (tight) bounds for the path (outline) of this graphic. :returns: a rectangle that indicates the bounds of the graphic in the 2D space """ return self.path.computeTightBounds() @abstractmethod def draw(self, canvas: Canvas): """ Draws the current graphic onto the provided canvas. :param canvas: canvas onto which to draw """ def zero_pixels(self) -> bool: """ Returns whether this graphic has no pixels to render, because its (rounded) area is 0. :returns: True if the graphic has no pixels, False otherwise """ return self.size().toRound().isEmpty() def __eq__(self, other: object) -> bool: if isinstance(other, Graphic): return self.__hash__() == other.__hash__() return False def __hash__(self) -> int: return hash(self._key()) def _key(self): return ((self.pin_position.fX, self.pin_position.fY), self.path.serialize().bytes())
@dataclass(frozen=True, eq=False) class Primitive(Graphic): """ Represents a primitive graphic, which has a uniform color. Geometric shapes and text are primitive graphics. """ color: Color def __init__(self, path: Path, color: Color, pin_position: Point = None): object.__setattr__(self, "color", color) if pin_position is None: bounds = path.computeTightBounds() pin_position = Point(bounds.width() / 2, bounds.height() / 2) super().__init__(pin_position, path) @cached_property def fill_paint(self) -> Paint: """ Paint used to fill the graphic. """ return Paint(Color=self.color.skia_color, AntiAlias=False, Style=Paint.kFill_Style) @cached_property def stroke_paint(self) -> Paint: """ Paint used to draw the outline of the graphic. """ return Paint(Color=self.color.skia_color, AntiAlias=True, Style=Paint.kStroke_Style) def draw(self, canvas: Canvas): canvas.drawPath(self.path, self.fill_paint) canvas.drawPath(self.path, self.stroke_paint) def _key(self): return super()._key(), self.color @dataclass(frozen=True, eq=False) class Empty(Graphic): """ An empty graphic. """ def __init__(self): super().__init__(Point(0, 0), Path()) def draw(self, canvas: Canvas): pass def __repr__(self) -> str: return f"{translate('empty_graphic')}()" @dataclass(frozen=True, eq=False) class Rectangle(Primitive): """ A rectangle. """ width: float height: float def __init__(self, width: float, height: float, color: Color): object.__setattr__(self, "width", width) object.__setattr__(self, "height", height) path = Path().addRect(Rect.MakeWH(width, height)) super().__init__(path, color) def __repr__(self) -> str: return f"{translate('rectangle')}({self.width}, {self.height}, {self.color})" @dataclass(frozen=True, eq=False) class Ellipse(Primitive): """ An ellipse. """ width: float height: float def __init__(self, width: float, height: float, color: Color): object.__setattr__(self, "width", width) object.__setattr__(self, "height", height) path = Path().addOval(Rect.MakeWH(width, height)) super().__init__(path, color) def __repr__(self) -> str: return f"{translate('ellipse')}({self.width}, {self.height}, {self.color})" @dataclass(frozen=True, eq=False) class CircularSector(Primitive): """ 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 def __init__(self, radius: float, angle: float, color: Color): object.__setattr__(self, "radius", radius) object.__setattr__(self, "angle", angle) if angle == 360: path = Path.Circle(radius, radius, radius) else: diameter = 2 * radius path = Path() path.moveTo(radius, radius) path.arcTo(Rect.MakeWH(diameter, diameter), 0, -angle, False) path.close() super().__init__(path, color, Point(radius, radius)) def __repr__(self) -> str: return f"{translate('circular_sector')}({self.radius}, {self.angle}, {self.color})" @dataclass(frozen=True, eq=False) class Triangle(Primitive): """ 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 def __init__(self, side1: float, side2: float, angle: float, color: Color): object.__setattr__(self, "side1", side1) object.__setattr__(self, "side2", side2) object.__setattr__(self, "angle", angle) third_point = Matrix.RotateDeg(-angle).mapXY(side2, 0) path = Path.Polygon([Point(0, 0), Point(side1, 0), third_point], isClosed=True) # The centroid is the average of the three vertices centroid = Point((side1 + third_point.x()) / 3, third_point.y() / 3) super().__init__(path, color, centroid) def __repr__(self) -> str: return f"{translate('triangle')}({self.side1}, {self.side2}, {self.angle}, {self.color})" @dataclass(frozen=True, eq=False) class Text(Primitive): """ 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 def __init__(self, text: str, font_name: str, text_size: float, color: Color): object.__setattr__(self, "text", text) object.__setattr__(self, "font_name", font_name) object.__setattr__(self, "text_size", text_size) if FontMgr().matchFamily(font_name).count() == 0: print(translate("FONT_NOT_FOUND", font_name), file=sys.stderr) glyphs = self.font.textToGlyphs(text) offsets = self.font.getXPos(glyphs) text_path = Path() for glyph, x_offset in zip(glyphs, offsets): path = self.font.getPath(glyph) if path is not None: # some glyphs (e.g., a space) have no outline path.offset(x_offset, 0) text_path.addPath(path) # The pinning position is on the left (0) on the baseline (0). super().__init__(text_path, color, Point(0, 0)) @cached_property def font(self) -> Font: """ The Skia Font used to render the text. """ return Font(Typeface(self.font_name), self.text_size) @cached_property def bounds(self) -> Rect: """ Computes the bounding box of the text, whose width is determined by Font.measureText() to account for leading and trailing glyphs with no outline. """ path_bounds = super().bounds text_length = self.font.measureText(self.text) return Rect.MakeLTRB(0, path_bounds.top(), text_length, path_bounds.bottom()) def __repr__(self) -> str: return f"{translate('text')}({self.text!r}, {self.font_name!r}, {self.text_size}, {self.color})" # pylint: disable=line-too-long @dataclass(frozen=True, eq=False) 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 __init__(self, foreground: Graphic, background: Graphic): object.__setattr__(self, "foreground", foreground) object.__setattr__(self, "background", background) fg_pin = self.foreground.pin_position bg_pin = self.background.pin_position pin = Point(bg_pin.x(), bg_pin.y()) path = Path(self.background.path) path.addPath(self.foreground.path, bg_pin.x() - fg_pin.x(), bg_pin.y() - fg_pin.y()) super().__init__(pin, path) def draw(self, canvas: Canvas): canvas.save() self.background.draw(canvas) canvas.translate(self.background.pin_position.x() - self.foreground.pin_position.x(), self.background.pin_position.y() - self.foreground.pin_position.y()) self.foreground.draw(canvas) canvas.restore() def __repr__(self) -> str: return f"{translate('compose')}({self.foreground}, {self.background})" @dataclass(frozen=True, eq=False) class Pin(Graphic): """ Represents the pinning of a graphic in a certain position on its bounds. """ graphic: Graphic pinning_point: PyTamaroPoint def __init__(self, graphic: Graphic, pinning_point: PyTamaroPoint): object.__setattr__(self, "graphic", graphic) object.__setattr__(self, "pinning_point", pinning_point) bounds = graphic.bounds h_mapping = { -1.0: bounds.left(), 0.0: bounds.centerX(), 1.0: bounds.right() } v_mapping = { 1.0: bounds.top(), 0.0: bounds.centerY(), -1.0: bounds.bottom() } pin = Point(h_mapping[pinning_point.x], v_mapping[pinning_point.y]) super().__init__(pin, graphic.path) def draw(self, canvas: Canvas): self.graphic.draw(canvas) def __repr__(self) -> str: return f"{translate('pin')}({self.pinning_point}, {self.graphic})" @dataclass(frozen=True, eq=False) 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 __init__(self, graphic: Graphic, angle: float): object.__setattr__(self, "graphic", graphic) object.__setattr__(self, "angle", angle) # Negated angle because RotateDeg works clockwise. object.__setattr__(self, "rot_matrix", Matrix.RotateDeg(-angle, graphic.pin_position)) path = Path() # transform() mutates the path provided as the second argument graphic.path.transform(self.rot_matrix, path) # type: ignore # pylint: disable=no-member super().__init__(graphic.pin_position, path) def draw(self, canvas: Canvas): canvas.save() canvas.concat(self.rot_matrix) # type: ignore # pylint: disable=no-member self.graphic.draw(canvas) canvas.restore() def __repr__(self) -> str: return f"{translate('rotate')}({self.angle}, {self.graphic})" @dataclass(frozen=True, eq=False) class SimpleCompose(Graphic): """ Represents a simple composition operation between two graphics (i.e., beside, above, or overlay). These simple compositions pin the two graphics appropriately, compose them normally, and then pin the result on its center. """ def __init__(self, graphic1: Graphic, graphic2: Graphic, point1: PyTamaroPoint, point2: PyTamaroPoint): composed_graphic = Pin(Compose(Pin(graphic1, point1), Pin(graphic2, point2)), center) object.__setattr__(self, "composed_graphic", composed_graphic) super().__init__(composed_graphic.pin_position, composed_graphic.path) def draw(self, canvas: Canvas): self.composed_graphic.draw(canvas) # type: ignore # pylint: disable=no-member @dataclass(frozen=True, eq=False) class Beside(SimpleCompose): """ Represents the composition of two graphics one beside the other, vertically centered. """ left_graphic: Graphic right_graphic: Graphic def __init__(self, left_graphic: Graphic, right_graphic: Graphic): object.__setattr__(self, "left_graphic", left_graphic) object.__setattr__(self, "right_graphic", right_graphic) super().__init__(left_graphic, right_graphic, center_right, center_left) def __repr__(self) -> str: return f"{translate('beside')}({self.left_graphic}, {self.right_graphic})" @dataclass(frozen=True, eq=False) class Above(SimpleCompose): """ Represents the composition of two graphics one above the other, horizontally centered. """ top_graphic: Graphic bottom_graphic: Graphic def __init__(self, top_graphic: Graphic, bottom_graphic: Graphic): object.__setattr__(self, "top_graphic", top_graphic) object.__setattr__(self, "bottom_graphic", bottom_graphic) super().__init__(top_graphic, bottom_graphic, bottom_center, top_center) def __repr__(self) -> str: return f"{translate('above')}({self.top_graphic}, {self.bottom_graphic})" @dataclass(frozen=True, eq=False) class Overlay(SimpleCompose): """ 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 __init__(self, front_graphic: Graphic, back_graphic: Graphic): object.__setattr__(self, "front_graphic", front_graphic) object.__setattr__(self, "back_graphic", back_graphic) super().__init__(front_graphic, back_graphic, center, center) def __repr__(self) -> str: return f"{translate('overlay')}({self.front_graphic}, {self.back_graphic})"