Source code for svg_path_editor.svg

# 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 __format__(self, format_spec: str) -> str: """ Format this item using :meth:`as_string`. The ``format_spec`` can be used to control decimal places and minification: * ``""`` (empty): use :meth:`as_string` defaults * ``".3"``: ``decimals=3`` * ``"m"``: ``minify=True`` * ``".3m"`` or ``"m.3"``: ``decimals=3``, ``minify=True`` Any other characters are currently ignored. :param format_spec: Format specification string (e.g. ``".3m"``). """ decimals, minify = _parse_format_spec(format_spec) return self.as_string(decimals=decimals, minify=minify)
[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 __format__(self, format_spec: str) -> str: """ Format this path using :meth:`as_string`. The ``format_spec`` can be used to control decimal places and minification, following the same rules as :meth:`SvgItem.__format__`: * ``""`` (empty): use :meth:`as_string` defaults * ``".3"``: ``decimals=3`` * ``"m"``: ``minify=True`` * ``".3m"`` or ``"m.3"``: ``decimals=3``, ``minify=True`` Any other characters are currently ignored. :param format_spec: Format specification string (e.g. ``".3m"``). """ decimals, minify = _parse_format_spec(format_spec) return self.as_string(decimals=decimals, minify=minify)
[docs] @override def __str__(self) -> str: """Return :meth:`as_string` with default options.""" return self.as_string()