- Create Economist SubmissionTracking package correctly: * mainArticle = full blog post content * coverLetter = 216-word SIR— letter * Links to blog post via blogPostId - Archive 'Letter to The Economist' from blog posts (it's the cover letter) - Fix date display on article cards (use published_at) - Target publication already displaying via blue badge Database changes: - Make blogPostId optional in SubmissionTracking model - Economist package ID: 68fa85ae49d4900e7f2ecd83 - Le Monde package ID: 68fa2abd2e6acd5691932150 Next: Enhanced modal with tabs, validation, export 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""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),
|
|
)
|