diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/pptx/shapes/freeform.py')
-rw-r--r-- | .venv/lib/python3.12/site-packages/pptx/shapes/freeform.py | 337 |
1 files changed, 337 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/pptx/shapes/freeform.py b/.venv/lib/python3.12/site-packages/pptx/shapes/freeform.py new file mode 100644 index 00000000..afe87385 --- /dev/null +++ b/.venv/lib/python3.12/site-packages/pptx/shapes/freeform.py @@ -0,0 +1,337 @@ +"""Objects related to construction of freeform shapes.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Iterable, Iterator, Sequence + +from pptx.util import Emu, lazyproperty + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + from pptx.oxml.shapes.autoshape import ( + CT_Path2D, + CT_Path2DClose, + CT_Path2DLineTo, + CT_Path2DMoveTo, + CT_Shape, + ) + from pptx.shapes.shapetree import _BaseGroupShapes # pyright: ignore[reportPrivateUsage] + from pptx.util import Length + +CT_DrawingOperation: TypeAlias = "CT_Path2DClose | CT_Path2DLineTo | CT_Path2DMoveTo" +DrawingOperation: TypeAlias = "_LineSegment | _MoveTo | _Close" + + +class FreeformBuilder(Sequence[DrawingOperation]): + """Allows a freeform shape to be specified and created. + + The initial pen position is provided on construction. From there, drawing proceeds using + successive calls to draw line segments. The freeform shape may be closed by calling the + :meth:`close` method. + + A shape may have more than one contour, in which case overlapping areas are "subtracted". A + contour is a sequence of line segments beginning with a "move-to" operation. A move-to + operation is automatically inserted in each new freeform; additional move-to ops can be + inserted with the `.move_to()` method. + """ + + def __init__( + self, + shapes: _BaseGroupShapes, + start_x: Length, + start_y: Length, + x_scale: float, + y_scale: float, + ): + super(FreeformBuilder, self).__init__() + self._shapes = shapes + self._start_x = start_x + self._start_y = start_y + self._x_scale = x_scale + self._y_scale = y_scale + + def __getitem__( # pyright: ignore[reportIncompatibleMethodOverride] + self, idx: int + ) -> DrawingOperation: + return self._drawing_operations.__getitem__(idx) + + def __iter__(self) -> Iterator[DrawingOperation]: + return self._drawing_operations.__iter__() + + def __len__(self): + return self._drawing_operations.__len__() + + @classmethod + def new( + cls, + shapes: _BaseGroupShapes, + start_x: float, + start_y: float, + x_scale: float, + y_scale: float, + ): + """Return a new |FreeformBuilder| object. + + The initial pen location is specified (in local coordinates) by + (`start_x`, `start_y`). + """ + return cls(shapes, Emu(int(round(start_x))), Emu(int(round(start_y))), x_scale, y_scale) + + def add_line_segments(self, vertices: Iterable[tuple[float, float]], close: bool = True): + """Add a straight line segment to each point in `vertices`. + + `vertices` must be an iterable of (x, y) pairs (2-tuples). Each x and y value is rounded + to the nearest integer before use. The optional `close` parameter determines whether the + resulting contour is `closed` or left `open`. + + Returns this |FreeformBuilder| object so it can be used in chained calls. + """ + for x, y in vertices: + self._add_line_segment(x, y) + if close: + self._add_close() + return self + + def convert_to_shape(self, origin_x: Length = Emu(0), origin_y: Length = Emu(0)): + """Return new freeform shape positioned relative to specified offset. + + `origin_x` and `origin_y` locate the origin of the local coordinate system in slide + coordinates (EMU), perhaps most conveniently by use of a |Length| object. + + Note that this method may be called more than once to add multiple shapes of the same + geometry in different locations on the slide. + """ + sp = self._add_freeform_sp(origin_x, origin_y) + path = self._start_path(sp) + for drawing_operation in self: + drawing_operation.apply_operation_to(path) + return self._shapes._shape_factory(sp) # pyright: ignore[reportPrivateUsage] + + def move_to(self, x: float, y: float): + """Move pen to (x, y) (local coordinates) without drawing line. + + Returns this |FreeformBuilder| object so it can be used in chained calls. + """ + self._drawing_operations.append(_MoveTo.new(self, x, y)) + return self + + @property + def shape_offset_x(self) -> Length: + """Return x distance of shape origin from local coordinate origin. + + The returned integer represents the leftmost extent of the freeform shape, in local + coordinates. Note that the bounding box of the shape need not start at the local origin. + """ + min_x = self._start_x + for drawing_operation in self: + if isinstance(drawing_operation, _Close): + continue + min_x = min(min_x, drawing_operation.x) + return Emu(min_x) + + @property + def shape_offset_y(self) -> Length: + """Return y distance of shape origin from local coordinate origin. + + The returned integer represents the topmost extent of the freeform shape, in local + coordinates. Note that the bounding box of the shape need not start at the local origin. + """ + min_y = self._start_y + for drawing_operation in self: + if isinstance(drawing_operation, _Close): + continue + min_y = min(min_y, drawing_operation.y) + return Emu(min_y) + + def _add_close(self): + """Add a close |_Close| operation to the drawing sequence.""" + self._drawing_operations.append(_Close.new()) + + def _add_freeform_sp(self, origin_x: Length, origin_y: Length): + """Add a freeform `p:sp` element having no drawing elements. + + `origin_x` and `origin_y` are specified in slide coordinates, and represent the location + of the local coordinates origin on the slide. + """ + spTree = self._shapes._spTree # pyright: ignore[reportPrivateUsage] + return spTree.add_freeform_sp( + origin_x + self._left, origin_y + self._top, self._width, self._height + ) + + def _add_line_segment(self, x: float, y: float) -> None: + """Add a |_LineSegment| operation to the drawing sequence.""" + self._drawing_operations.append(_LineSegment.new(self, x, y)) + + @lazyproperty + def _drawing_operations(self) -> list[DrawingOperation]: + """Return the sequence of drawing operation objects for freeform.""" + return [] + + @property + def _dx(self) -> Length: + """Return width of this shape's path in local units.""" + min_x = max_x = self._start_x + for drawing_operation in self: + if isinstance(drawing_operation, _Close): + continue + min_x = min(min_x, drawing_operation.x) + max_x = max(max_x, drawing_operation.x) + return Emu(max_x - min_x) + + @property + def _dy(self) -> Length: + """Return integer height of this shape's path in local units.""" + min_y = max_y = self._start_y + for drawing_operation in self: + if isinstance(drawing_operation, _Close): + continue + min_y = min(min_y, drawing_operation.y) + max_y = max(max_y, drawing_operation.y) + return Emu(max_y - min_y) + + @property + def _height(self): + """Return vertical size of this shape's path in slide coordinates. + + This value is based on the actual extents of the shape and does not include any + positioning offset. + """ + return int(round(self._dy * self._y_scale)) + + @property + def _left(self): + """Return leftmost extent of this shape's path in slide coordinates. + + Note that this value does not include any positioning offset; it assumes the drawing + (local) coordinate origin is at (0, 0) on the slide. + """ + return int(round(self.shape_offset_x * self._x_scale)) + + def _local_to_shape(self, local_x: Length, local_y: Length) -> tuple[Length, Length]: + """Translate local coordinates point to shape coordinates. + + Shape coordinates have the same unit as local coordinates, but are offset such that the + origin of the shape coordinate system (0, 0) is located at the top-left corner of the + shape bounding box. + """ + return Emu(local_x - self.shape_offset_x), Emu(local_y - self.shape_offset_y) + + def _start_path(self, sp: CT_Shape) -> CT_Path2D: + """Return a newly created `a:path` element added to `sp`. + + The returned `a:path` element has an `a:moveTo` element representing the shape starting + point as its only child. + """ + path = sp.add_path(w=self._dx, h=self._dy) + path.add_moveTo(*self._local_to_shape(self._start_x, self._start_y)) + return path + + @property + def _top(self): + """Return topmost extent of this shape's path in slide coordinates. + + Note that this value does not include any positioning offset; it assumes the drawing + (local) coordinate origin is located at slide coordinates (0, 0) (top-left corner of + slide). + """ + return int(round(self.shape_offset_y * self._y_scale)) + + @property + def _width(self): + """Return width of this shape's path in slide coordinates. + + This value is based on the actual extents of the shape path and does not include any + positioning offset. + """ + return int(round(self._dx * self._x_scale)) + + +class _BaseDrawingOperation(object): + """Base class for freeform drawing operations. + + A drawing operation has at least one location (x, y) in local coordinates. + """ + + def __init__(self, freeform_builder: FreeformBuilder, x: Length, y: Length): + super(_BaseDrawingOperation, self).__init__() + self._freeform_builder = freeform_builder + self._x = x + self._y = y + + def apply_operation_to(self, path: CT_Path2D) -> CT_DrawingOperation: + """Add the XML element(s) implementing this operation to `path`. + + Must be implemented by each subclass. + """ + raise NotImplementedError("must be implemented by each subclass") + + @property + def x(self) -> Length: + """Return the horizontal (x) target location of this operation. + + The returned value is an integer in local coordinates. + """ + return self._x + + @property + def y(self) -> Length: + """Return the vertical (y) target location of this operation. + + The returned value is an integer in local coordinates. + """ + return self._y + + +class _Close(object): + """Specifies adding a `<a:close/>` element to the current contour.""" + + @classmethod + def new(cls) -> _Close: + """Return a new _Close object.""" + return cls() + + def apply_operation_to(self, path: CT_Path2D) -> CT_Path2DClose: + """Add `a:close` element to `path`.""" + return path.add_close() + + +class _LineSegment(_BaseDrawingOperation): + """Specifies a straight line segment ending at the specified point.""" + + @classmethod + def new(cls, freeform_builder: FreeformBuilder, x: float, y: float) -> _LineSegment: + """Return a new _LineSegment object ending at point *(x, y)*. + + Both `x` and `y` are rounded to the nearest integer before use. + """ + return cls(freeform_builder, Emu(int(round(x))), Emu(int(round(y)))) + + def apply_operation_to(self, path: CT_Path2D) -> CT_Path2DLineTo: + """Add `a:lnTo` element to `path` for this line segment. + + Returns the `a:lnTo` element newly added to the path. + """ + return path.add_lnTo( + Emu(self._x - self._freeform_builder.shape_offset_x), + Emu(self._y - self._freeform_builder.shape_offset_y), + ) + + +class _MoveTo(_BaseDrawingOperation): + """Specifies a new pen position.""" + + @classmethod + def new(cls, freeform_builder: FreeformBuilder, x: float, y: float) -> _MoveTo: + """Return a new _MoveTo object for move to point `(x, y)`. + + Both `x` and `y` are rounded to the nearest integer before use. + """ + return cls(freeform_builder, Emu(int(round(x))), Emu(int(round(y)))) + + def apply_operation_to(self, path: CT_Path2D) -> CT_Path2DMoveTo: + """Add `a:moveTo` element to `path` for this line segment.""" + return path.add_moveTo( + Emu(self._x - self._freeform_builder.shape_offset_x), + Emu(self._y - self._freeform_builder.shape_offset_y), + ) |