Source code for svg_path_editor.path_operations

# 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 typing import Final

from .sub_path_bounds import get_sub_path_bounds
from .svg import Point, SvgItem, SvgPath

__all__ = ["reverse_path", "optimize_relative_absolute", "optimize_path"]


def _to_str(pt: Point) -> tuple[str, str]:
    """Return a point’s coordinates as a pair of strings."""
    return str(pt.x), str(pt.y)


[docs] def reverse_path(svg: SvgPath, subpath_of_item: int | None = None) -> SvgPath: """ Reverse the drawing direction of a path or sub-path. :param svg: Input path. :param subpath_of_item: Index of an item within the sub-path to reverse, or ``None`` to reverse the entire path. :return: A new path with the selected segment reversed. Geometry is preserved, but command types and relative/absolute representation may change. """ start, end = get_sub_path_bounds(svg, subpath_of_item) # Nothing to reverse if fewer than two items in the subpath. if end - start <= 1: return svg.clone() new_svg = svg.clone() path = new_svg.path # If the item following the subpath is relative, temporarily switch it # to absolute so we can rewrite the sub-path safely. is_before_relative = end < len(path) and path[end].relative if is_before_relative: path[end].relative = False sub_path = path[start:end] output_path: list[SvgItem] = [] reversed_path = list(reversed(sub_path))[:-1] start_point = reversed_path[0].target_location output_path.append(SvgItem.make(["M", *_to_str(start_point)])) previous_type = "" is_closed = False for component in reversed_path: pt = _to_str(component.previous_point) ctrl = [_to_str(p) for p in component.absolute_points] component_type = component.get_type(True) match component_type: case "M" | "Z": if is_closed: output_path.append(SvgItem.make(["Z"])) is_closed = component_type == "Z" if output_path[-1].get_type(True) == "M": output_path[-1] = SvgItem.make(["M", *pt]) else: output_path.append(SvgItem.make(["M", *pt])) case "L": output_path.append(SvgItem.make(["L", *pt])) case "H": output_path.append(SvgItem.make(["H", pt[0]])) case "V": output_path.append(SvgItem.make(["V", pt[1]])) case "C": # Swap control points when reversing cubic Bézier. output_path.append(SvgItem.make(["C", *ctrl[1], *ctrl[0], *pt])) case "S": # For smooth cubic, we may need to expand to C depending # on the previous command. a = _to_str(component.control_locations[0]) if previous_type != "S": output_path.append(SvgItem.make(["C", *ctrl[0], *a, *pt])) else: output_path.append(SvgItem.make(["S", *a, *pt])) case "Q": output_path.append(SvgItem.make(["Q", *ctrl[0], *pt])) case "T": # For smooth quadratic, we may need to expand to Q. if previous_type != "T": a = _to_str(component.control_locations[0]) output_path.append(SvgItem.make(["Q", *a, *pt])) else: output_path.append(SvgItem.make(["T", *pt])) case "A": # Reverse arc: keep radii/angle/large-arc, flip sweep, swap endpoints. vals = [str(v) for v in component.values[:4]] sweep = str(1 - component.values[4]) output_path.append(SvgItem.make(["A", *vals, sweep, *pt])) case _: # Unsupported/unknown types result in an error being thrown. raise ValueError(f"Invalid command type: {component_type}") previous_type = component_type if is_closed: output_path.append(SvgItem.make(["Z"])) new_svg.path = [*path[:start], *output_path, *path[end:]] new_svg.refresh_absolute_positions() # Restore the following item’s relativity if needed. if is_before_relative: new_svg.path[start + len(output_path)].relative = True # Optimize the new path to keep representation compact. return optimize_path( new_svg, remove_useless_commands=True, use_shorthands=True, )
[docs] def optimize_relative_absolute(svg: SvgPath) -> SvgPath: """ Optimize the relative/absolute representation of a path. Each command is toggled between relative and absolute form, and the representation that yields a shorter minified path string is kept. :param svg: Input path. :return: A new path with possibly changed relative/absolute commands. Geometry is preserved; only representation changes. """ new_svg = svg.clone() length = len(new_svg.as_string(minify=True)) origin: Final[Point] = Point(0, 0) for i, comp in enumerate(new_svg.path): previous = new_svg.path[i - 1] if i > 0 else None if comp.get_type(True) == "Z": continue # Toggle relativity and test string length. comp.relative = not comp.relative new_length = len(new_svg.as_string(minify=True)) if new_length < length: length = new_length comp.refresh(origin, previous) else: comp.relative = not comp.relative return new_svg
[docs] def optimize_path( svg: SvgPath, *, remove_useless_commands: bool = False, remove_orphan_dots: bool = False, use_shorthands: bool = False, use_horizontal_and_vertical_lines: bool = False, use_relative_absolute: bool = False, use_reverse: bool = False, use_close_path: bool = False, ) -> SvgPath: """ Optimize the representation of an SVG path. The function can apply several optional passes that can be enabled using the parameters. :param svg: Input path. :param remove_useless_commands: Remove redundant ``M``/``Z`` commands and degenerate ``L``/``H``/``V`` segments. :param remove_orphan_dots: Remove empty closed subpaths (``M`` immediately followed by ``Z``). :param use_shorthands: Convert eligible ``C``/``Q`` segments to ``S``/``T`` where possible. :param use_horizontal_and_vertical_lines: Replace ``L`` with ``H`` or ``V`` where possible. :param use_relative_absolute: Choose between relative and absolute commands per segment to minimize size. :param use_reverse: Reverse the path direction if that yields a shorter minified representation. This can affect stroked paths. :param use_close_path: Convert final line segments that return to start into ``Z``. This can affect stroked paths. :return: A new, possibly shorter, but geometrically equivalent path. """ new_svg = svg.clone() path = new_svg.path origin: Final[Point] = Point(0, 0) initial_pt = Point(0, 0) i = 1 while i < len(path): c0 = path[i - 1] c1 = path[i] c0type = c0.get_type(True) c1type = c1.get_type(True) if c0type == "M": initial_pt = c0.target_location if remove_useless_commands: if c0type == "M" and c1type == "M": c1.relative = False del path[i - 1] continue if c0type == "Z" and c1type == "Z": del path[i] continue if c0type == "Z" and c1type == "M": tg = c0.target_location if tg.x == c1.absolute_points[0].x and tg.y == c1.absolute_points[0].y: del path[i] continue if ( c0type in ("L", "V", "H") and c1type == "Z" and c0.target_location == c1.target_location ): del path[i - 1] continue if c1type in ("L", "V", "H"): tg = c1.target_location if tg.x == c1.previous_point.x and tg.y == c1.previous_point.y: del path[i] continue if remove_orphan_dots: if c0type == "M" and c1type == "Z": del path[i] continue if use_horizontal_and_vertical_lines: if c1type == "L": tg = c1.target_location if tg.x == c1.previous_point.x: path[i] = SvgItem.make_from(c1, c0, "V") i += 1 continue if tg.y == c1.previous_point.y: path[i] = SvgItem.make_from(c1, c0, "H") i += 1 continue if use_shorthands: if c0type in ("Q", "T") and c1type == "Q": pt = _to_str(path[i].target_location) candidate = SvgItem.make(["T", *pt]) candidate.refresh(origin, c0) ctrl = candidate.control_locations if ( ctrl[0].x == c1.absolute_points[0].x and ctrl[0].y == c1.absolute_points[0].y ): path[i] = candidate if c0type in ("C", "S") and c1type == "C": pt = _to_str(path[i].target_location) ctrl = _to_str(path[i].absolute_points[1]) candidate = SvgItem.make(["S", *ctrl, *pt]) candidate.refresh(origin, c0) ctrl2 = candidate.control_locations if ( ctrl2[0].x == c1.absolute_points[0].x and ctrl2[0].y == c1.absolute_points[0].y ): path[i] = candidate if c0type not in ("C", "S") and c1type == "C": if ( c1.previous_point.x == c1.absolute_points[0].x and c1.previous_point.y == c1.absolute_points[0].y ): pt = _to_str(c1.target_location) ctrl = _to_str(c1.absolute_points[1]) path[i] = SvgItem.make(["S", *ctrl, *pt]) path[i].refresh(origin, c0) if use_close_path: if c1type in ("L", "H", "V"): target = c1.target_location if initial_pt.x == target.x and initial_pt.y == target.y: path[i] = SvgItem.make(["Z"]) path[i].refresh(initial_pt, c0) i += 1 if remove_useless_commands or remove_orphan_dots: if path and path[-1].get_type(True) == "M": del path[-1] # With remove_useless_commands, links to previous items may become dirty: new_svg.refresh_absolute_positions() if use_relative_absolute: new_svg = optimize_relative_absolute(new_svg) if use_reverse: length = len(new_svg.as_string(minify=True)) non_reversed = new_svg.clone() new_svg = reverse_path(new_svg) if use_relative_absolute: new_svg = optimize_relative_absolute(new_svg) after_length = len(new_svg.as_string(minify=True)) if after_length >= length: new_svg = non_reversed return new_svg