"""
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})"