# This file is part of https://github.com/KurtBoehm/svg_path_editor.
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
from __future__ import annotations
from abc import ABC
import re
from collections.abc import Iterable
from decimal import Decimal, getcontext
from typing import TYPE_CHECKING, Final, Self, TypedDict, final, override
from .path_parser import PathParser
if TYPE_CHECKING:
import sympy as sp
__all__ = [
"Point",
"SvgPoint",
"SvgControlPoint",
"SvgItem",
"DummySvgItem",
"MoveTo",
"LineTo",
"CurveTo",
"SmoothCurveTo",
"QuadraticBezierCurveTo",
"SmoothQuadraticBezierCurveTo",
"ClosePath",
"HorizontalLineTo",
"VerticalLineTo",
"EllipticalArcTo",
"SvgPath",
]
Number = Decimal | int | float | str
_number_strip_trailing_zeros: Final = re.compile(r"^(-?[0-9]*\.([0-9]*[1-9])?)0*$")
_number_strip_dot: Final = re.compile(r"\.$")
_number_leading_zero: Final = re.compile(r"^(-?)0\.")
_minify_cmd_space: Final = re.compile(r"^([a-zA-Z]) ")
_minify_dot_gap: Final = re.compile(r"(\.[0-9]+) (?=\.)")
def _dec_to_rat(x: Decimal) -> "sp.Expr":
"""
Convert a ``Decimal`` to a SymPy ``Rational``
using the exact decimal representation.
"""
import sympy as sp
return sp.Rational(str(x))
def _rat_to_dec(x: "sp.Expr") -> Decimal:
"""
Convert a SymPy expression to ``Decimal`` with current precision.
The result is evaluated to a decimal string using the current
``Decimal`` context precision and then converted to ``Decimal``.
"""
return Decimal(str(x.evalf(n=getcontext().prec)))
def _format_number(v: Decimal, d: int | None, minify: bool = False) -> str:
"""
Format a ``Decimal`` with optional fixed decimals and SVG number minification.
:param v: Value to format.
:param d: Number of decimal places, or ``None`` for default string conversion.
:param minify: Apply SVG-oriented minification (strip trailing zeros,
leading zero before decimal, etc.).
"""
s = f"{v:.{d}f}" if d is not None else f"{v:f}"
s = _number_strip_trailing_zeros.sub(r"\1", s)
s = _number_strip_dot.sub("", s)
if minify:
s = _number_leading_zero.sub(r"\1.", s)
return s
def _parse_format_spec(spec: str) -> tuple[int | None, bool]:
"""
Parse a format specification for path/string formatting.
The accepted pattern is a combination of:
* an optional ``.N`` for decimal places
* an optional ``m`` flag to enable minification
Order and whitespace are ignored, e.g. ``"m.3"``, ``".3m"`` and
``" .3 m "`` all mean the same.
:param spec: Raw format spec passed to :meth:`__format__`.
:return: ``(decimals, minify)`` where ``decimals`` is ``None`` if not set.
"""
decimals: int | None = None
minify = False
spec = spec.strip()
if spec:
# allow "m", ".3", ".3m", "m.3", " m .2 " etc.
if "m" in spec:
minify = True
spec = spec.replace("m", "")
spec = spec.strip()
if spec.startswith("."):
try:
decimals = int(spec[1:])
except ValueError:
pass
return decimals, minify
[docs]
class Point:
"""Simple 2D point."""
def __init__(self, x: Number, y: Number) -> None:
"""
:param x: x coordinate.
:param y: y coordinate.
"""
self.x: Decimal = Decimal(x)
self.y: Decimal = Decimal(y)
[docs]
class SvgPoint(Point):
"""
Point used as target or vertex in an SVG path.
Instances hold a back-reference to the :class:`SvgItem` that owns them.
"""
def __init__(self, x: Number, y: Number) -> None:
"""
:param x: x coordinate.
:param y: y coordinate.
"""
super().__init__(x, y)
self.item_reference: SvgItem = DummySvgItem()
[docs]
class SvgControlPoint(SvgPoint):
"""
Control point for Bézier segments with optional relation hints.
The :attr:`relations` list can be used to store points that geometrically
constrain this control point (e.g. endpoints of the segment).
"""
def __init__(self, point: Point, relations: list[Point]) -> None:
"""
:param point: Base point for the control point.
:param relations: Related points, e.g. endpoints of the curve segment.
"""
super().__init__(point.x, point.y)
self.sub_index: int = 0
self.relations: list[Point] = relations
[docs]
class SvgItem(ABC):
"""Base class for a single SVG path command and its numeric values."""
def __init__[T: Number](self, values: list[T], relative: bool) -> None:
"""
:param values: Command parameters as a flat list of numbers.
:param relative: Whether values are stored in relative coordinates.
"""
self._relative: bool = relative
self.values: list[Decimal] = [Decimal(v) for v in values]
self.previous_point: Point = Point(0, 0)
self.absolute_points: list[SvgPoint] = []
self.absolute_control_points: list[SvgControlPoint] = []
[docs]
@staticmethod
def make(raw_item: list[str]) -> SvgItem:
"""
Construct the appropriate subclass of :class:`SvgItem` from a parsed command
and its parameter strings.
:param raw_item: List starting with the command letter followed by numeric
parameters as strings (e.g. ``["M", "0", "0"]``).
:raises ValueError: If the item is empty or the command is invalid.
"""
if not raw_item:
raise ValueError("Empty SVG item")
cmd = raw_item[0]
relative = cmd.islower()
values = [Decimal(it) for it in raw_item[1:]]
mapping: dict[str, type[SvgItem]] = {
MoveTo.key: MoveTo,
LineTo.key: LineTo,
HorizontalLineTo.key: HorizontalLineTo,
VerticalLineTo.key: VerticalLineTo,
ClosePath.key: ClosePath,
CurveTo.key: CurveTo,
SmoothCurveTo.key: SmoothCurveTo,
QuadraticBezierCurveTo.key: QuadraticBezierCurveTo,
SmoothQuadraticBezierCurveTo.key: SmoothQuadraticBezierCurveTo,
EllipticalArcTo.key: EllipticalArcTo,
}
cls = mapping.get(cmd.upper())
if not cls:
raise ValueError(f"Invalid SVG command type: {cmd!r}")
return cls(values, relative)
[docs]
@staticmethod
def make_from(origin: SvgItem, previous: SvgItem, new_type: str) -> SvgItem:
"""
Create a new :class:`SvgItem` of type ``new_type`` from an existing item.
The new item preserves the current target location and, where possible,
the original control point geometry.
:param origin: Existing item whose geometry should be preserved.
:param previous: Previous item in the path, used for control point defaults.
:param new_type: New SVG command letter (e.g. ``"L"`` or ``"c"``).
:raises ValueError: If ``new_type`` is not supported.
"""
target = origin.target_location()
x, y = str(target.x), str(target.y)
absolute_type = new_type.upper()
match absolute_type:
case MoveTo.key:
parts = [MoveTo.key, x, y]
case LineTo.key:
parts = [LineTo.key, x, y]
case HorizontalLineTo.key:
parts = [HorizontalLineTo.key, x]
case VerticalLineTo.key:
parts = [VerticalLineTo.key, y]
case ClosePath.key:
parts = [ClosePath.key]
case CurveTo.key:
parts = [CurveTo.key, "0", "0", "0", "0", x, y]
case SmoothCurveTo.key:
parts = [SmoothCurveTo.key, "0", "0", x, y]
case QuadraticBezierCurveTo.key:
parts = [QuadraticBezierCurveTo.key, "0", "0", x, y]
case SmoothQuadraticBezierCurveTo.key:
parts = [SmoothQuadraticBezierCurveTo.key, x, y]
case EllipticalArcTo.key:
parts = [EllipticalArcTo.key, "1", "1", "0", "0", "0", x, y]
case _:
raise ValueError(f"Unsupported SVG item type: {new_type!r}")
result = SvgItem.make(parts)
result.previous_point = previous.target_location()
result.absolute_points = [target]
result.reset_control_points(previous)
control_points = origin.absolute_control_points
if isinstance(origin, (CurveTo, SmoothCurveTo)) and isinstance(
result, (CurveTo, SmoothCurveTo)
):
if isinstance(result, CurveTo):
result.values[0] = control_points[0].x
result.values[1] = control_points[0].y
result.values[2] = control_points[1].x
result.values[3] = control_points[1].y
else:
result.values[0] = control_points[1].x
result.values[1] = control_points[1].y
if isinstance(
origin, (QuadraticBezierCurveTo, SmoothQuadraticBezierCurveTo)
) and isinstance(result, QuadraticBezierCurveTo):
result.values[0] = control_points[0].x
result.values[1] = control_points[0].y
if new_type != absolute_type:
result.relative = True
return result
[docs]
def refresh_absolute_points(self, origin: Point, previous: SvgItem | None) -> None:
"""
Recalculate absolute points from stored values and the previous item.
:param origin: Current subpath origin (last ``M``/``m`` or ``Z``).
:param previous: Previous item in the path, or ``None`` for the first item.
"""
self.previous_point = previous.target_location() if previous else Point(0, 0)
self.absolute_points = []
current = self.previous_point if self.relative else Point(0, 0)
for i in range(0, len(self.values) - 1, 2):
self.absolute_points.append(
SvgPoint(current.x + self.values[i], current.y + self.values[i + 1])
)
@property
def relative(self) -> bool:
"""Whether this command is stored in relative coordinates."""
return self._relative
@relative.setter
def relative(self, new_relative: bool) -> None:
"""
Switch between relative and absolute representation.
The underlying numeric values are rewritten based on the last known
:attr:`previous_point`.
:param new_relative: Target representation (``True`` for relative).
"""
if self._relative == new_relative:
return
self._relative = False
dx = -self.previous_point.x if new_relative else self.previous_point.x
dy = -self.previous_point.y if new_relative else self.previous_point.y
self.translate(dx, dy)
self._relative = new_relative
[docs]
def refresh_absolute_control_points(
self, origin: Point, previous_target: SvgItem | None
) -> None:
"""
Recalculate absolute control points.
The default implementation assumes there are no control points.
:param origin: Current subpath origin.
:param previous_target: Previous item in the path, if any.
"""
self.absolute_control_points = []
[docs]
def reset_control_points(self, previous_target: SvgItem) -> None:
"""
Reset control points to a default geometry between previous and target.
Subclasses for curve commands override this to compute reasonable defaults.
:param previous_target: Previous item in the path.
"""
pass
[docs]
def refresh(self, origin: Point, previous: SvgItem | None) -> None:
"""
Recompute all absolute points and re-bind back-references.
:param origin: Current subpath origin.
:param previous: Previous item in the path, or ``None`` for the first item.
"""
self.refresh_absolute_points(origin, previous)
self.refresh_absolute_control_points(origin, previous)
for point in self.absolute_points:
point.item_reference = self
for ctrl in self.absolute_control_points:
ctrl.item_reference = self
[docs]
def clone(self) -> Self:
"""
Return a shallow clone of this item, retaining its subclass.
Values, relativity and :attr:`previous_point` are copied. Absolute points
and control points need to be recomputed via :meth:`refresh`,
as is done in :meth:`SvgPath.clone`.
"""
clone = self.__class__(self.values.copy(), self._relative)
clone.previous_point = Point(self.previous_point.x, self.previous_point.y)
return clone
[docs]
def translate(self, x: Number, y: Number, force: bool = False) -> None:
"""
Translate in place.
Relative items are translated only if ``force`` is true; otherwise their
stored deltas are left unchanged.
:param x: Translation in x direction.
:param y: Translation in y direction.
:param force: Also adjust relative coordinates.
"""
x, y = Decimal(x), Decimal(y)
if not self.relative or force:
for idx in range(len(self.values)):
self.values[idx] += x if idx % 2 == 0 else y
[docs]
def translated(self, x: Number, y: Number, force: bool = False) -> SvgItem:
"""
Return a translated copy. See :meth:`translate` for details.
:param x: Translation in x direction.
:param y: Translation in y direction.
:param force: Also adjust relative coordinates.
"""
item = self.clone()
item.translate(x, y, force=force)
return item
[docs]
def scale(self, kx: Number, ky: Number) -> None:
"""
Scale in place.
:param kx: Scale factor for x coordinates.
:param ky: Scale factor for y coordinates.
"""
kx, ky = Decimal(kx), Decimal(ky)
for idx in range(len(self.values)):
self.values[idx] *= kx if idx % 2 == 0 else ky
[docs]
def scaled(self, kx: Number, ky: Number) -> SvgItem:
"""
Return a scaled copy.
:param kx: Scale factor for x coordinates.
:param ky: Scale factor for y coordinates.
"""
item = self.clone()
item.scale(kx, ky)
return item
[docs]
def rotate(
self, ox: Number, oy: Number, degrees: Number, force: bool = False
) -> None:
"""
Rotate the item in place around ``(ox, oy)``.
For relative items, rotation is performed around ``(0, 0)`` unless
``force`` is true.
:param ox: Rotation origin x coordinate.
:param oy: Rotation origin y coordinate.
:param degrees: Rotation angle in degrees.
:param force: Rotate relative coordinates around ``(ox, oy)``.
"""
import sympy as sp
ox, oy, degrees = Decimal(ox), Decimal(oy), Decimal(degrees)
angle = sp.rad(_dec_to_rat(degrees))
cosv, sinv = _rat_to_dec(sp.cos(angle)), _rat_to_dec(sp.sin(angle))
for i in range(0, len(self.values), 2):
px, py = self.values[i], self.values[i + 1]
cx, cy = (0, 0) if self._relative and not force else (ox, oy)
dx, dy = px - cx, py - cy
qx = cx + dx * cosv - dy * sinv
qy = cy + dx * sinv + dy * cosv
self.values[i] = qx
self.values[i + 1] = qy
[docs]
def rotated(
self, ox: Number, oy: Number, degrees: Number, force: bool = False
) -> SvgItem:
"""
Return a rotated copy around ``(ox, oy)``. See :meth:`rotate` for details.
:param ox: Rotation origin x coordinate.
:param oy: Rotation origin y coordinate.
:param degrees: Rotation angle in degrees.
:param force: Rotate relative coordinates around ``(ox, oy)``.
"""
item = self.clone()
item.rotate(ox, oy, degrees, force=force)
return item
[docs]
def target_location(self) -> SvgPoint:
"""Final absolute point reached by this item."""
return self.absolute_points[-1]
[docs]
def set_target_location(self, pt: Point) -> None:
"""
Move the geometric target of this command to ``pt``.
:param pt: New target location in absolute coordinates.
"""
loc = self.target_location()
dx, dy = pt.x - loc.x, pt.y - loc.y
self.values[-2] += dx
self.values[-1] += dy
[docs]
def set_control_location(self, idx: int, pt: Point) -> None:
"""
Move control point ``idx`` to ``pt``.
Only meaningful for commands storing Bézier handles.
:param idx: Index of the control point to move.
:param pt: New control point location in absolute coordinates.
"""
loc = self.absolute_points[idx]
dx, dy = pt.x - loc.x, pt.y - loc.y
self.values[2 * idx] += dx
self.values[2 * idx + 1] += dy
@property
def control_locations(self) -> list[SvgControlPoint]:
"""Absolute control points associated with this item."""
return self.absolute_control_points
[docs]
def get_type(self, ignore_is_relative: bool = False) -> str:
"""
Return the SVG command letter for this item (e.g. ``"M"`` or ``"l"``).
:param ignore_is_relative:
Always return the uppercase key regardless of :attr:`relative`.
"""
type_key = getattr(self.__class__, "key")
assert isinstance(type_key, str)
if self.relative and not ignore_is_relative:
return type_key.lower()
return type_key
[docs]
def as_standalone_string(self) -> str:
"""
Return a standalone path string for this command.
The result starts with an ``M`` to this command’s :attr:`previous_point`
followed by the command itself.
"""
return " ".join(
[
"M",
str(self.previous_point.x),
str(self.previous_point.y),
self.get_type(),
*[str(v) for v in self.values],
]
)
[docs]
def as_string(
self,
decimals: int | None = None,
minify: bool = False,
trailing_items: Iterable["SvgItem"] = (),
) -> str:
"""
Serialize this command into an SVG path fragment.
Optionally additional same-typed ``trailing_items`` can be appended
in a compact form.
:param decimals: Number of decimal places, or ``None`` for default.
:param minify: Use a more compact numeric representation.
:param trailing_items: Additional items of the same type to serialize
in the same command group.
"""
flattened = self.values + [v for it in trailing_items for v in it.values]
str_values = [_format_number(it, decimals, minify) for it in flattened]
return " ".join([self.get_type(), *str_values])
[docs]
@final
class DummySvgItem(SvgItem):
"""Placeholder item used as a default reference owner for points."""
def __init__(self) -> None:
"""Create a dummy item with no values, always absolute."""
super().__init__([], False)
[docs]
@final
class MoveTo(SvgItem):
"""SVG ``M``/``m`` command (move current point)."""
key = "M"
[docs]
@final
class LineTo(SvgItem):
"""SVG ``L``/``l`` command (line to point)."""
key = "L"
[docs]
@final
class CurveTo(SvgItem):
"""SVG ``C``/``c`` command (cubic Bézier curve)."""
key = "C"
[docs]
@override
def refresh_absolute_control_points(
self, origin: Point, previous_target: SvgItem | None
) -> None:
"""
Recompute absolute control points for a cubic Bézier segment.
:param origin: Current subpath origin.
:param previous_target: Previous item in the path.
:raises ValueError: If there is no previous item.
"""
if not previous_target:
raise ValueError("Invalid path: CurveTo without previous item")
self.absolute_control_points = [
SvgControlPoint(
self.absolute_points[0], [previous_target.target_location()]
),
SvgControlPoint(self.absolute_points[1], [self.target_location()]),
]
[docs]
@override
def reset_control_points(self, previous_target: SvgItem) -> None:
"""
Reset control points to a smooth cubic curve between previous and target.
:param previous_target: Previous item in the path.
"""
a, b = previous_target.target_location(), self.target_location()
d = a if self.relative else Point(0, 0)
self.values[0] = 2 * a.x / 3 + b.x / 3 - d.x
self.values[1] = 2 * a.y / 3 + b.y / 3 - d.y
self.values[2] = a.x / 3 + 2 * b.x / 3 - d.x
self.values[3] = a.y / 3 + 2 * b.y / 3 - d.y
[docs]
@final
class SmoothCurveTo(SvgItem):
"""SVG ``S``/``s`` command (smooth cubic Bézier curve)."""
key = "S"
[docs]
@override
def refresh_absolute_control_points(
self, origin: Point, previous_target: SvgItem | None
) -> None:
"""
Recompute absolute control points for a smooth cubic Bézier segment.
:param origin: Current subpath origin.
:param previous_target: Previous item in the path, used for reflection.
"""
self.absolute_control_points = []
if isinstance(previous_target, (CurveTo, SmoothCurveTo)):
prev_loc = previous_target.target_location()
prev_control = previous_target.absolute_control_points[1]
pt = Point(2 * prev_loc.x - prev_control.x, 2 * prev_loc.y - prev_control.y)
self.absolute_control_points.append(SvgControlPoint(pt, [prev_loc]))
else:
current = (
previous_target.target_location() if previous_target else Point(0, 0)
)
pt = Point(current.x, current.y)
self.absolute_control_points.append(SvgControlPoint(pt, []))
self.absolute_control_points.append(
SvgControlPoint(self.absolute_points[0], [self.target_location()])
)
[docs]
@override
def as_standalone_string(self) -> str:
"""Standalone SVG path fragment using ``M`` and an explicit ``C``."""
ctrl0, ctrl1 = self.absolute_control_points
target = self.absolute_points[1]
return " ".join(
[
"M",
str(self.previous_point.x),
str(self.previous_point.y),
"C",
str(ctrl0.x),
str(ctrl0.y),
str(ctrl1.x),
str(ctrl1.y),
str(target.x),
str(target.y),
]
)
[docs]
@override
def reset_control_points(self, previous_target: SvgItem) -> None:
"""
Reset the trailing control point for a smooth cubic curve.
:param previous_target: Previous item in the path.
"""
a = previous_target.target_location()
b = self.target_location()
d = a if self.relative else Point(0, 0)
self.values[0] = a.x / 3 + 2 * b.x / 3 - d.x
self.values[1] = a.y / 3 + 2 * b.y / 3 - d.y
[docs]
@override
def set_control_location(self, idx: int, pt: Point) -> None:
"""
Move the effective control point of this smooth cubic to ``pt``.
:param idx: Ignored index, the smooth command has a single free control.
:param pt: New control point location in absolute coordinates.
"""
loc = self.absolute_control_points[1]
dx = pt.x - loc.x
dy = pt.y - loc.y
self.values[0] += dx
self.values[1] += dy
[docs]
@final
class QuadraticBezierCurveTo(SvgItem):
"""SVG ``Q``/``q`` command (quadratic Bézier curve)."""
key = "Q"
[docs]
@override
def refresh_absolute_control_points(
self, origin: Point, previous_target: SvgItem | None
) -> None:
"""
Recompute absolute control point for a quadratic Bézier segment.
:param origin: Current subpath origin.
:param previous_target: Previous item in the path.
:raises ValueError: If there is no previous item.
"""
if not previous_target:
raise ValueError("Invalid path: QuadraticBezierCurveTo without previous")
ctrl = SvgControlPoint(
self.absolute_points[0],
[previous_target.target_location(), self.target_location()],
)
self.absolute_control_points = [ctrl]
[docs]
@override
def reset_control_points(self, previous_target: SvgItem) -> None:
"""
Reset the control point to the midpoint of previous and target.
:param previous_target: Previous item in the path.
"""
a = previous_target.target_location()
b = self.target_location()
d = a if self.relative else Point(0, 0)
self.values[0] = (a.x + b.x) / 2 - d.x
self.values[1] = (a.y + b.y) / 2 - d.y
[docs]
@final
class SmoothQuadraticBezierCurveTo(SvgItem):
"""SVG ``T``/``t`` command (smooth quadratic Bézier curve)."""
key = "T"
[docs]
@override
def refresh_absolute_control_points(
self, origin: Point, previous_target: SvgItem | None
) -> None:
"""
Recompute absolute control point for a smooth quadratic Bézier segment.
:param origin: Current subpath origin.
:param previous_target: Previous item in the path, used for reflection.
"""
if not isinstance(
previous_target, (QuadraticBezierCurveTo, SmoothQuadraticBezierCurveTo)
):
previous = (
previous_target.target_location() if previous_target else Point(0, 0)
)
pt = Point(previous.x, previous.y)
self.absolute_control_points = [SvgControlPoint(pt, [])]
return
prev_loc = previous_target.target_location()
prev_control = previous_target.absolute_control_points[0]
pt = Point(2 * prev_loc.x - prev_control.x, 2 * prev_loc.y - prev_control.y)
ctrl = SvgControlPoint(pt, [prev_loc, self.target_location()])
self.absolute_control_points = [ctrl]
[docs]
@override
def as_standalone_string(self) -> str:
"""Standalone SVG path fragment using ``M`` and an explicit ``Q``."""
ctrl = self.absolute_control_points[0]
target = self.absolute_points[0]
return " ".join(
[
"M",
str(self.previous_point.x),
str(self.previous_point.y),
"Q",
str(ctrl.x),
str(ctrl.y),
str(target.x),
str(target.y),
]
)
[docs]
@final
class ClosePath(SvgItem):
"""SVG ``Z``/``z`` command (close current subpath)."""
key = "Z"
[docs]
@override
def refresh_absolute_points(self, origin: Point, previous: SvgItem | None) -> None:
"""
Set the target to the current subpath origin.
:param origin: Subpath origin point.
:param previous: Previous item in the path, if any.
"""
self.previous_point = previous.target_location() if previous else Point(0, 0)
self.absolute_points = [SvgPoint(origin.x, origin.y)]
[docs]
@final
class HorizontalLineTo(SvgItem):
"""SVG ``H``/``h`` command (horizontal line)."""
key = "H"
[docs]
@override
def rotate(
self, ox: Number, oy: Number, degrees: Number, force: bool = False
) -> None:
"""
Rotate in place.
Only a rotation by 180 degrees affects pure horizontal segments. Other
angles are handled at the path level by type changes.
:param ox: Rotation origin x coordinate (ignored here).
:param oy: Rotation origin y coordinate (ignored here).
:param degrees: Rotation angle in degrees.
:param force: Unused for this subclass.
"""
if Decimal(degrees) == Decimal(180):
self.values[0] = -self.values[0]
[docs]
@override
def refresh_absolute_points(self, origin: Point, previous: SvgItem | None) -> None:
"""
Recompute absolute point for a horizontal line.
:param origin: Current subpath origin.
:param previous: Previous item in the path.
"""
self.previous_point = previous.target_location() if previous else Point(0, 0)
x = self.values[0] + self.previous_point.x if self.relative else self.values[0]
self.absolute_points = [SvgPoint(x, self.previous_point.y)]
[docs]
@override
def set_target_location(self, pt: Point) -> None:
"""
Move the target x coordinate to ``pt.x`` (y stays unchanged).
:param pt: New target location.
"""
loc = self.target_location()
dx = pt.x - loc.x
self.values[0] += dx
[docs]
@final
class VerticalLineTo(SvgItem):
"""SVG ``V``/``v`` command (vertical line)."""
key = "V"
[docs]
@override
def rotate(
self, ox: Number, oy: Number, degrees: Number, force: bool = False
) -> None:
"""
Rotate in place.
Only a rotation by 180 degrees affects pure vertical segments. Other
angles are handled at the path level by type changes.
:param ox: Rotation origin x coordinate (ignored here).
:param oy: Rotation origin y coordinate (ignored here).
:param degrees: Rotation angle in degrees.
:param force: Unused for this subclass.
"""
if Decimal(degrees) == Decimal(180):
self.values[0] = -self.values[0]
[docs]
@override
def translate(self, x: Number, y: Number, force: bool = False) -> None:
"""
Translate in place.
For absolute vertical lines, only the y coordinate is translated.
:param x: Translation in x direction (ignored).
:param y: Translation in y direction.
:param force: Unused for this subclass.
"""
if not self.relative:
self.values[0] += Decimal(y)
[docs]
@override
def scale(self, kx: Number, ky: Number) -> None:
"""
Scale in place.
For vertical lines only y scaling applies.
:param kx: Scale factor for x coordinates (ignored).
:param ky: Scale factor for y coordinates.
"""
self.values[0] *= Decimal(ky)
[docs]
@override
def refresh_absolute_points(self, origin: Point, previous: SvgItem | None) -> None:
"""
Recompute absolute point for a vertical line.
:param origin: Current subpath origin.
:param previous: Previous item in the path.
"""
self.previous_point = previous.target_location() if previous else Point(0, 0)
y = self.values[0] + self.previous_point.y if self.relative else self.values[0]
self.absolute_points = [SvgPoint(self.previous_point.x, y)]
[docs]
@override
def set_target_location(self, pt: Point) -> None:
"""
Move the target y coordinate to ``pt.y`` (x stays unchanged).
:param pt: New target location.
"""
loc = self.target_location()
dy = pt.y - loc.y
self.values[0] += dy
[docs]
@final
class EllipticalArcTo(SvgItem):
"""SVG ``A``/``a`` command (elliptical arc)."""
key = "A"
[docs]
@override
def translate(self, x: Number, y: Number, force: bool = False) -> None:
"""
Translate in place.
For absolute arcs, only the arc target coordinates are translated.
:param x: Translation in x direction.
:param y: Translation in y direction.
:param force: Unused for this subclass.
"""
if not self.relative:
self.values[5] += Decimal(x)
self.values[6] += Decimal(y)
[docs]
@override
def rotate(
self, ox: Number, oy: Number, degrees: Number, force: bool = False
) -> None:
"""
Rotate in place.
The arc’s rotation angle and target coordinates are updated accordingly.
:param ox: Rotation origin x coordinate.
:param oy: Rotation origin y coordinate.
:param degrees: Rotation angle in degrees.
:param force: Rotate relative coordinates around ``(ox, oy)``.
"""
import sympy as sp
ox, oy, degrees = Decimal(ox), Decimal(oy), Decimal(degrees)
self.values[2] = (self.values[2] + degrees) % 360
angle = sp.rad(_dec_to_rat(degrees))
cosv, sinv = _rat_to_dec(sp.cos(angle)), _rat_to_dec(sp.sin(angle))
px, py = self.values[5], self.values[6]
x, y = (0, 0) if self.relative and not force else (ox, oy)
dx, dy = px - x, py - y
qx = dx * cosv - dy * sinv + x
qy = dx * sinv + dy * cosv + y
self.values[5] = qx
self.values[6] = qy
[docs]
@override
def scale(self, kx: Number, ky: Number) -> None:
"""
Scale in place.
Radii, rotation angle, target and sweep flag are updated to reflect the
scaling factors.
:param kx: Scale factor for x coordinates.
:param ky: Scale factor for y coordinates.
"""
import sympy as sp
kx, ky = Decimal(kx), Decimal(ky)
a, b = _dec_to_rat(self.values[0]), _dec_to_rat(self.values[1])
degrees = _dec_to_rat(self.values[2])
rkx, rky = _dec_to_rat(kx), _dec_to_rat(ky)
angle = sp.rad(degrees)
cosv, sinv = sp.cos(angle), sp.sin(angle)
ca = b * b * rky * rky * cosv * cosv + a * a * rky * rky * sinv * sinv
cb = 2 * rkx * rky * cosv * sinv * (b * b - a * a)
cc = a * a * rkx * rkx * cosv * cosv + b * b * rkx * rkx * sinv * sinv
cf = -(a * a * b * b * rkx * rkx * rky * rky)
det = cb * cb - 4 * ca * cc
val1 = sp.sqrt((ca - cc) * (ca - cc) + cb * cb)
# New rotation
if not cb.equals(0):
# atan2-style expression in degrees, using SymPy
self.values[2] = _rat_to_dec(sp.deg(sp.atan2(cc - ca - val1, cb)))
else:
# Fall back to axis-aligned orientation
self.values[2] = Decimal(0) if ca < cc else Decimal(90)
# New radii
if not det.equals(0):
# Use SymPy throughout and convert back at the end
f = 2 * det * cf
self.values[0] = _rat_to_dec(-sp.sqrt(f * ((ca + cc) + val1)) / det)
self.values[1] = _rat_to_dec(-sp.sqrt(f * ((ca + cc) - val1)) / det)
# New target
self.values[5] *= kx
self.values[6] *= ky
# New sweep flag
self.values[4] = self.values[4] if kx * ky >= 0 else 1 - self.values[4]
[docs]
@override
def refresh_absolute_points(self, origin: Point, previous: SvgItem | None) -> None:
"""
Recompute the absolute target point for the arc.
:param origin: Current subpath origin.
:param previous: Previous item in the path.
"""
self.previous_point = previous.target_location() if previous else Point(0, 0)
if self.relative:
x = self.values[5] + self.previous_point.x
y = self.values[6] + self.previous_point.y
self.absolute_points = [SvgPoint(x, y)]
else:
self.absolute_points = [SvgPoint(self.values[5], self.values[6])]
[docs]
@override
def as_string(
self,
decimals: int | None = None,
minify: bool = False,
trailing_items: Iterable[SvgItem] = (),
) -> str:
"""
Serialize this arc (and optionally trailing arcs) to an SVG path fragment.
:param decimals: Number of decimal places, or ``None`` for default.
:param minify: Use a compact group representation.
:param trailing_items: Additional arc items to serialize together.
"""
if not minify:
return super().as_string(decimals, minify, trailing_items)
vals_groups = [self.values, *[it.values for it in trailing_items]]
formatted_groups = [
[_format_number(v, decimals, minify) for v in vals] for vals in vals_groups
]
compact = [
f"{v[0]} {v[1]} {v[2]} {v[3]}{v[4]}{v[5]} {v[6]}" for v in formatted_groups
]
return " ".join([self.get_type(), *compact])
class _Grouped(TypedDict):
"""Internal helper structure for grouping path items by command type."""
type: str
item: SvgItem
trailing: list[SvgItem]
[docs]
class SvgPath:
"""An SVG path as a sequence of :class:`SvgItem`."""
def __init__(self, path: str) -> None:
"""
:param path: SVG path data string (e.g. ``"M0 0L10 0Z"``).
"""
raw_path = PathParser.parse(path)
self.path: list[SvgItem] = [SvgItem.make(it) for it in raw_path]
self.refresh_absolute_positions()
[docs]
def clone(self) -> SvgPath:
"""
Return a deep clone of this path.
All contained items are cloned as well, and absolute positions are recomputed.
"""
clone = object.__new__(SvgPath)
clone.path = [it.clone() for it in self.path]
clone.refresh_absolute_positions()
return clone
[docs]
def translate(self, dx: Number, dy: Number) -> None:
"""
Translate in place.
:param dx: Translation in x direction.
:param dy: Translation in y direction.
"""
for idx, it in enumerate(self.path):
it.translate(dx, dy, idx == 0)
self.refresh_absolute_positions()
[docs]
def translated(self, dx: Number, dy: Number) -> SvgPath:
"""
Return a translated copy of this path.
:param dx: Translation in x direction.
:param dy: Translation in y direction.
"""
new_path = self.clone()
new_path.translate(dx, dy)
return new_path
[docs]
def scale(self, kx: Number, ky: Number) -> None:
"""
Scale in place.
:param kx: Scale factor for x coordinates.
:param ky: Scale factor for y coordinates.
"""
for it in self.path:
it.scale(kx, ky)
self.refresh_absolute_positions()
[docs]
def scaled(self, kx: Number, ky: Number) -> SvgPath:
"""
Return a scaled copy of this path.
:param kx: Scale factor for x coordinates.
:param ky: Scale factor for y coordinates.
"""
new_path = self.clone()
new_path.scale(kx, ky)
return new_path
[docs]
def rotate(self, ox: Number, oy: Number, degrees: Number) -> None:
"""
Rotate in place around ``(ox, oy)``.
May also normalize horizontal/vertical segments after rotation.
:param ox: Rotation origin x coordinate.
:param oy: Rotation origin y coordinate.
:param degrees: Rotation angle in degrees.
"""
degrees = Decimal(degrees) % 360
if degrees == 0:
return
for idx, it in enumerate(self.path):
last_instance_of = type(it)
if degrees != Decimal(180) and isinstance(
it, (HorizontalLineTo, VerticalLineTo)
):
new_type = LineTo.key.lower() if it.relative else LineTo.key
changed = self.change_type(idx, new_type)
if changed is not None:
it = changed
it.rotate(ox, oy, degrees, idx == 0)
if degrees in (Decimal(90), Decimal(270)):
if last_instance_of is HorizontalLineTo:
self.refresh_absolute_positions()
new_type = (
VerticalLineTo.key.lower()
if it.relative
else VerticalLineTo.key
)
self.change_type(idx, new_type)
elif last_instance_of is VerticalLineTo:
self.refresh_absolute_positions()
new_type = (
HorizontalLineTo.key.lower()
if it.relative
else HorizontalLineTo.key
)
self.change_type(idx, new_type)
self.refresh_absolute_positions()
[docs]
def rotated(self, ox: Number, oy: Number, degrees: Number) -> SvgPath:
"""
Return a rotated copy of this path. See :meth:`rotate` for details.
:param ox: Rotation origin x coordinate.
:param oy: Rotation origin y coordinate.
:param degrees: Rotation angle in degrees.
"""
new_path = self.clone()
new_path.rotate(ox, oy, degrees)
return new_path
@property
def relative(self) -> bool:
"""
Indicate whether all items are stored as relative commands.
Mixed paths (some absolute, some relative) return ``False``.
"""
return all(it.relative for it in self.path)
@relative.setter
def relative(self, new_relative: bool) -> None:
"""
Convert all items to relative or absolute coordinates in place.
:param new_relative: Target representation (``True`` for relative).
"""
for it in self.path:
it.relative = new_relative
self.refresh_absolute_positions()
[docs]
def with_relative(self, new_relative: bool) -> SvgPath:
"""
Return a new path with all items converted to the requested representation.
:param new_relative: Target representation (``True`` for relative).
"""
new_path = self.clone()
new_path.relative = new_relative
return new_path
[docs]
def remove(self, item: SvgItem) -> None:
"""
Remove the given item.
:param item: Item to remove.
:raises ValueError: If the item is not present.
"""
self.path.remove(item)
self.refresh_absolute_positions()
[docs]
def insert(self, index: int, item: SvgItem) -> None:
"""
Insert ``item`` before ``index``.
:param index: Index before which to insert.
:param item: Item to insert.
"""
self.path.insert(index, item)
self.refresh_absolute_positions()
[docs]
def change_type(self, index: int, new_type: str) -> SvgItem | None:
"""
Change the command type of the item at ``index`` in place.
:param index: The index of the item whose type should be changed.
:param new_type: New SVG command letter (e.g. ``"L"`` or ``"c"``).
:return: Newly created :class:`SvgItem` replacing the item at ``index``,
or ``None`` if ``index`` is not in the path or is the first item.
"""
if index not in range(1, len(self.path)):
return None
previous = self.path[index - 1]
self.path[index] = SvgItem.make_from(self.path[index], previous, new_type)
self.refresh_absolute_positions()
return self.path[index]
[docs]
def as_string(self, decimals: int | None = None, minify: bool = False) -> str:
"""
Serialize the entire path to an SVG path data string.
:param decimals: Number of decimal places, or ``None`` for default.
:param minify: Use a compact representation.
"""
grouped: list[_Grouped] = []
for it in self.path:
t = it.get_type()
if minify and grouped and (last := grouped[-1])["type"] == t:
last["trailing"].append(it)
continue
gtype = "l" if t == "m" else ("L" if t == "M" else t)
grouped.append({"type": gtype, "item": it, "trailing": []})
out_parts: list[str] = []
for g in grouped:
s = g["item"].as_string(decimals, minify, g["trailing"])
if minify:
s = _minify_cmd_space.sub(r"\1", s)
s = s.replace(" -", "-")
s = _minify_dot_gap.sub(r"\1", s)
out_parts.append(s)
return "".join(out_parts) if minify else " ".join(out_parts)
@property
def target_locations(self) -> list[SvgPoint]:
"""Final absolute points for each item in the path."""
return [it.target_location() for it in self.path]
@property
def control_locations(self) -> list[SvgControlPoint]:
"""Flattened list of all absolute control points for the path."""
result: list[SvgControlPoint] = []
for item in self.path[1:]:
controls = item.control_locations
for idx, ctrl in enumerate(controls):
ctrl.sub_index = idx
result.extend(controls)
return result
[docs]
def set_location(self, pt_reference: SvgPoint, to: Point) -> None:
"""
Move the given point to ``to``.
The reference must come from a previously queried point list
(e.g. :attr:`target_locations` or :attr:`control_locations`).
:param pt_reference: Point (target or control) to be moved.
:param to: New absolute location for the point.
"""
if isinstance(pt_reference, SvgControlPoint):
pt_reference.item_reference.set_control_location(pt_reference.sub_index, to)
else:
pt_reference.item_reference.set_target_location(to)
self.refresh_absolute_positions()
[docs]
def refresh_absolute_positions(self) -> None:
"""
Recompute absolute positions for all items in the path.
This should be called after structural or coordinate changes.
"""
previous: SvgItem | None = None
origin = Point(0, 0)
for item in self.path:
item.refresh(origin, previous)
if isinstance(item, (MoveTo, ClosePath)):
origin = item.target_location()
previous = item
[docs]
@override
def __str__(self) -> str:
"""Return :meth:`as_string` with default options."""
return self.as_string()