# 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
import re
from abc import ABC
from collections.abc import Iterable
from decimal import Decimal
from typing import Final, Self, TypedDict, final, override
from .geometry import Line, ParametricEllipticalArc, Point, Vec2
from .math import Number, Precision, as_bool, dec_to_rat, evalf, is_zero, rat_to_dec
from .path_parser import PathParser
__all__ = [
"Point",
"SvgPoint",
"SvgControlPoint",
"SvgItem",
"DummySvgItem",
"MoveTo",
"LineTo",
"CurveTo",
"SmoothCurveTo",
"QuadraticBezierCurveTo",
"SmoothQuadraticBezierCurveTo",
"ClosePath",
"HorizontalLineTo",
"VerticalLineTo",
"EllipticalArcTo",
"SvgPath",
]
_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_str(x: Decimal) -> str:
"""
Convert a ``Decimal`` to a normalized string in fixed-point notation.
The value is normalized and formatted using fixed-point notation,
removing any exponent part while preserving the exact decimal value.
"""
return f"{x.normalize():f}"
def format_decimal(v: Decimal, *, d: int | None = 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.).
"""
v = v.normalize()
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("."):
decimals = int(spec[1:])
elif len(spec) > 0:
raise ValueError(f"Unsupported format spec: {spec}")
return decimals, minify
[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 = _dec_to_str(target.x), _dec_to_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
@property
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",
_dec_to_str(self.previous_point.x),
_dec_to_str(self.previous_point.y),
self.get_type(),
*[_dec_to_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_decimal(it, d=decimals, minify=minify) for it in flattened]
return " ".join([self.get_type(), *str_values])
[docs]
@override
def __str__(self) -> str:
"""Return :meth:`as_string` with default options."""
return self.as_string()
[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"
def M(x: Number, y: Number) -> MoveTo:
return MoveTo([x, y], relative=False)
def m(x: Number, y: Number) -> MoveTo:
return MoveTo([x, y], relative=True)
[docs]
@final
class LineTo(SvgItem):
"""SVG ``L``/``l`` command (line to point)."""
key = "L"
def L(x: Number, y: Number) -> LineTo:
return LineTo([x, y], relative=False)
def l(x: Number, y: Number) -> LineTo:
return LineTo([x, y], relative=True)
[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",
_dec_to_str(self.previous_point.x),
_dec_to_str(self.previous_point.y),
"C",
_dec_to_str(ctrl0.x),
_dec_to_str(ctrl0.y),
_dec_to_str(ctrl1.x),
_dec_to_str(ctrl1.y),
_dec_to_str(target.x),
_dec_to_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, b = previous_target.target_location, 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, b = previous_target.target_location, 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",
_dec_to_str(self.previous_point.x),
_dec_to_str(self.previous_point.y),
"Q",
_dec_to_str(ctrl.x),
_dec_to_str(ctrl.y),
_dec_to_str(target.x),
_dec_to_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)]
def Z() -> ClosePath:
return ClosePath([], relative=False)
def z() -> ClosePath:
return ClosePath([], relative=True)
[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.
"""
dy = pt.y - self.target_location.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)
# --- Uniform scaling: pure similarity transform ---
# This does not change the ellipse orientation. For circles (rx == ry),
# this is the *only* way to preserve the stored φ, because the conic
# itself has no preferred direction.
if kx == ky:
s = kx
f = abs(s)
# Scale radii
self.values[0] *= f # rx
self.values[1] *= f # ry
# Keep rotation angle self.values[2] unchanged
# Scale target point
self.values[5] *= s
self.values[6] *= s
# Sweep flag: det(scale) = s^2 >= 0 ⇒ orientation unchanged
# so self.values[4] stays the same.
return
# --- General non-uniform scaling: use conic-transform algebra ---
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)
# Coefficients of the transformed conic
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 is_zero(cb):
# atan2-style expression in degrees, using SymPy
self.values[2] = rat_to_dec(sp.deg(sp.atan2(cc - ca - val1, cb))) % 360
else:
# Axis-aligned orientation (ellipse principal axes parallel to axes)
self.values[2] = Decimal(0) if ca < cc else Decimal(90)
# New radii
if not is_zero(det):
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 point (end point of the arc)
self.values[5] *= kx
self.values[6] *= ky
# New sweep flag: flip when orientation is reversed (det < 0)
self.values[4] = self.values[4] if kx * ky >= 0 else 1 - self.values[4]
[docs]
def to_geometry(
self,
*,
n: Precision | None = None,
) -> ParametricEllipticalArc | Line:
"""
Return a parametric representation of this SVG elliptical arc.
The construction follows section B.2.4 of the SVG 2.0 specification
(see https://svgwg.org/svg2-draft/implnote.html#ArcImplementationNotes)
using SymPy for exact computations.
:param n: Optional precision passed to :func:`evalf` for numeric evaluation.
:return: Parametric representation as :class:`ParametricEllipticalArc`
or a straight :class:`Line` if the radii are zero.
"""
import sympy as sp
def ev(expr: sp.Expr) -> sp.Expr:
return evalf(expr, n=n)
def vangle(ux: sp.Expr, uy: sp.Expr, vx: sp.Expr, vy: sp.Expr) -> sp.Expr:
# angle between (ux, uy) and (vx, vy) in degrees, signed by cross product
return sp.deg(sp.atan2(ev(ux * vy - uy * vx), ev(ux * vx + uy * vy)))
x0, y0 = self.previous_point
rx, ry, phi, large_arc, sweep, _, _ = self.values
large_arc, sweep = bool(large_arc), bool(sweep)
[(x1, y1)] = self.absolute_points
# Degenerate case: straight line
if rx == 0 and ry == 0:
return Line(Point(x0, y0).vec2, Point(x1, y1).vec2)
# Convert to exact rationals
x0, y0 = dec_to_rat(x0), dec_to_rat(y0)
x1, y1 = dec_to_rat(x1), dec_to_rat(y1)
rx, ry = dec_to_rat(abs(rx)), dec_to_rat(abs(ry))
phi = dec_to_rat(phi)
angle = sp.rad(phi)
cosv, sinv = sp.cos(angle), sp.sin(angle)
# Step 1
avgx, avgy = (x0 - x1) / 2, (y0 - y1) / 2
px, py = cosv * avgx + sinv * avgy, cosv * avgy - sinv * avgx
# B.2.5 step 3: ensure radii are large enough
rx2, ry2 = rx * rx, ry * ry
px2, py2 = px * px, py * py
λ = px2 / rx2 + py2 / ry2
if as_bool(λ > 1):
scale = sp.sqrt(λ)
rx *= scale
ry *= scale
# Update squares since radii changed
rx2, ry2 = rx * rx, ry * ry
# Step 2
cfn = rx2 * ry2 - rx2 * py2 - ry2 * px2
cfd = rx2 * py2 + ry2 * px2
cf = sp.sqrt(cfn / cfd)
# Center in the transformed coordinate system
pcx, pcy = cf * rx * py / ry, -cf * ry * px / rx
if large_arc == sweep:
pcx, pcy = -pcx, -pcy
# Step 3: center in original coordinates
cx = cosv * pcx - sinv * pcy + (x0 + x1) / 2
cy = sinv * pcx + cosv * pcy + (y0 + y1) / 2
# Step 4: angles
x, y = (px - pcx) / rx, (py - pcy) / ry
theta0 = vangle(sp.S.One, sp.S.Zero, x, y)
dtheta = vangle(x, y, -(px + pcx) / rx, -(py + pcy) / ry)
# vangle returns angles in (-180°, 180°]
if as_bool(dtheta < 0):
dtheta += 360
if not sweep:
dtheta -= 360
return ParametricEllipticalArc(
c=Vec2(ev(cx), ev(cy)),
r=Vec2(ev(rx), ev(ry)),
theta0=ev(theta0),
dtheta=ev(dtheta),
phi=ev(phi),
)
@property
def geometry(self) -> ParametricEllipticalArc | Line:
"""Geometric representation of this arc using :meth:`to_geometry`."""
return self.to_geometry()
[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_decimal(v, d=decimals, minify=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])
def A(
rx: Number,
ry: Number,
angle: Number,
large_arc_flag: bool,
sweep_flag: bool,
x: Number,
y: Number,
) -> EllipticalArcTo:
return EllipticalArcTo(
[rx, ry, angle, int(large_arc_flag), int(sweep_flag), x, y],
relative=False,
)
def a(
rx: Number,
ry: Number,
angle: Number,
large_arc_flag: bool,
sweep_flag: bool,
x: Number,
y: Number,
) -> EllipticalArcTo:
return EllipticalArcTo(
[rx, ry, angle, int(large_arc_flag), int(sweep_flag), x, y],
relative=True,
)
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 | list[SvgItem]) -> None:
"""
:param path: SVG path data string (e.g. ``"M0 0L10 0Z"``) or a list
of :class:`SvgItem` instances.
"""
if isinstance(path, str):
raw_path = PathParser.parse(path)
self.path: list[SvgItem] = [SvgItem.make(it) for it in raw_path]
else:
self.path = 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 == Decimal(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()