"""SVG path parsing, normalization, and DrawingML path command generation.""" from __future__ import annotations import math import re from dataclasses import dataclass, field from .drawingml_utils import px_to_emu @dataclass class PathCommand: """A single SVG path command with its arguments.""" cmd: str # M, L, C, Z, etc. (uppercase = absolute) args: list[float] = field(default_factory=list) # Argument counts per SVG path command _ARG_COUNTS = { 'M': 2, 'm': 2, 'L': 2, 'l': 2, 'H': 1, 'h': 1, 'V': 1, 'v': 1, 'C': 6, 'c': 6, 'S': 4, 's': 4, 'Q': 4, 'q': 4, 'T': 2, 't': 2, 'A': 7, 'a': 7, 'Z': 0, 'z': 0, } def parse_svg_path(d: str) -> list[PathCommand]: """Parse SVG path d attribute into a list of PathCommands.""" if not d: return [] commands: list[PathCommand] = [] tokens = re.findall( r'[MmLlHhVvCcSsQqTtAaZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d ) current_cmd: str | None = None current_args: list[float] = [] def flush() -> None: nonlocal current_cmd, current_args if current_cmd is None: return n = _ARG_COUNTS.get(current_cmd, 0) if n == 0: commands.append(PathCommand(current_cmd, [])) elif n > 0 and len(current_args) >= n: i = 0 while i + n <= len(current_args): commands.append(PathCommand(current_cmd, current_args[i:i + n])) # After first M, implicit commands become L if current_cmd == 'M': current_cmd = 'L' elif current_cmd == 'm': current_cmd = 'l' i += n current_args = [] for token in tokens: if token in 'MmLlHhVvCcSsQqTtAaZz': flush() current_cmd = token current_args = [] else: try: current_args.append(float(token)) except ValueError: pass flush() return commands def svg_path_to_absolute(commands: list[PathCommand]) -> list[PathCommand]: """Convert all relative path commands to absolute.""" result: list[PathCommand] = [] cx, cy = 0.0, 0.0 # current point sx, sy = 0.0, 0.0 # subpath start for cmd in commands: a = cmd.args if cmd.cmd == 'M': cx, cy = a[0], a[1] sx, sy = cx, cy result.append(PathCommand('M', [cx, cy])) elif cmd.cmd == 'm': cx += a[0]; cy += a[1] sx, sy = cx, cy result.append(PathCommand('M', [cx, cy])) elif cmd.cmd == 'L': cx, cy = a[0], a[1] result.append(PathCommand('L', [cx, cy])) elif cmd.cmd == 'l': cx += a[0]; cy += a[1] result.append(PathCommand('L', [cx, cy])) elif cmd.cmd == 'H': cx = a[0] result.append(PathCommand('L', [cx, cy])) elif cmd.cmd == 'h': cx += a[0] result.append(PathCommand('L', [cx, cy])) elif cmd.cmd == 'V': cy = a[0] result.append(PathCommand('L', [cx, cy])) elif cmd.cmd == 'v': cy += a[0] result.append(PathCommand('L', [cx, cy])) elif cmd.cmd == 'C': result.append(PathCommand('C', list(a))) cx, cy = a[4], a[5] elif cmd.cmd == 'c': abs_args = [ cx + a[0], cy + a[1], cx + a[2], cy + a[3], cx + a[4], cy + a[5], ] result.append(PathCommand('C', abs_args)) cx, cy = abs_args[4], abs_args[5] elif cmd.cmd == 'S': result.append(PathCommand('S', list(a))) cx, cy = a[2], a[3] elif cmd.cmd == 's': abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]] result.append(PathCommand('S', abs_args)) cx, cy = abs_args[2], abs_args[3] elif cmd.cmd == 'Q': result.append(PathCommand('Q', list(a))) cx, cy = a[2], a[3] elif cmd.cmd == 'q': abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]] result.append(PathCommand('Q', abs_args)) cx, cy = abs_args[2], abs_args[3] elif cmd.cmd == 'T': result.append(PathCommand('T', list(a))) cx, cy = a[0], a[1] elif cmd.cmd == 't': abs_args = [cx + a[0], cy + a[1]] result.append(PathCommand('T', abs_args)) cx, cy = abs_args[0], abs_args[1] elif cmd.cmd == 'A': result.append(PathCommand('A', list(a))) cx, cy = a[5], a[6] elif cmd.cmd == 'a': abs_args = [a[0], a[1], a[2], a[3], a[4], cx + a[5], cy + a[6]] result.append(PathCommand('A', abs_args)) cx, cy = abs_args[5], abs_args[6] elif cmd.cmd in ('Z', 'z'): result.append(PathCommand('Z', [])) cx, cy = sx, sy return result def _reflect_control_point( cp_x: float, cp_y: float, cx: float, cy: float, ) -> tuple[float, float]: """Reflect a control point through the current point.""" return 2 * cx - cp_x, 2 * cy - cp_y def _quad_to_cubic( qp_x: float, qp_y: float, p0_x: float, p0_y: float, p3_x: float, p3_y: float, ) -> list[float]: """Convert quadratic bezier control point to cubic bezier control points.""" cp1_x = p0_x + 2.0 / 3.0 * (qp_x - p0_x) cp1_y = p0_y + 2.0 / 3.0 * (qp_y - p0_y) cp2_x = p3_x + 2.0 / 3.0 * (qp_x - p3_x) cp2_y = p3_y + 2.0 / 3.0 * (qp_y - p3_y) return [cp1_x, cp1_y, cp2_x, cp2_y, p3_x, p3_y] def _arc_to_cubic_beziers( cx_: float, cy_: float, rx: float, ry: float, phi: float, large_arc: int, sweep: int, x2: float, y2: float, ) -> list[PathCommand]: """Convert SVG arc (endpoint parameterization) to cubic bezier curves. Uses the algorithm from the SVG spec (F.6.5) to convert endpoint to center parameterization, then approximates each arc segment with cubic beziers. """ x1, y1 = cx_, cy_ if abs(x1 - x2) < 1e-10 and abs(y1 - y2) < 1e-10: return [] rx = abs(rx) ry = abs(ry) if rx < 1e-10 or ry < 1e-10: return [PathCommand('L', [x2, y2])] phi_rad = math.radians(phi) cos_phi = math.cos(phi_rad) sin_phi = math.sin(phi_rad) # Step 1: Compute (x1', y1') dx = (x1 - x2) / 2.0 dy = (y1 - y2) / 2.0 x1p = cos_phi * dx + sin_phi * dy y1p = -sin_phi * dx + cos_phi * dy # Step 2: Compute (cx', cy') x1p2 = x1p * x1p y1p2 = y1p * y1p rx2 = rx * rx ry2 = ry * ry # Ensure radii are large enough lam = x1p2 / rx2 + y1p2 / ry2 if lam > 1: lam_sqrt = math.sqrt(lam) rx *= lam_sqrt ry *= lam_sqrt rx2 = rx * rx ry2 = ry * ry num = max(rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0) den = rx2 * y1p2 + ry2 * x1p2 sq = math.sqrt(num / den) if den > 1e-10 else 0.0 if large_arc == sweep: sq = -sq cxp = sq * rx * y1p / ry cyp = -sq * ry * x1p / rx # Step 3: Compute (cx, cy) arc_cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0 arc_cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0 # Step 4: Compute theta1 and dtheta def angle_between(ux: float, uy: float, vx: float, vy: float) -> float: n = math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) if n < 1e-10: return 0 c = max(-1, min(1, (ux * vx + uy * vy) / n)) a = math.acos(c) if ux * vy - uy * vx < 0: a = -a return a theta1 = angle_between(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry) dtheta = angle_between( (x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry, ) if sweep == 0 and dtheta > 0: dtheta -= 2 * math.pi elif sweep == 1 and dtheta < 0: dtheta += 2 * math.pi # Split arc into segments of at most 90 degrees n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2)))) d_per_seg = dtheta / n_segs result: list[PathCommand] = [] alpha = 4.0 / 3.0 * math.tan(d_per_seg / 4.0) for i in range(n_segs): t1 = theta1 + i * d_per_seg t2 = theta1 + (i + 1) * d_per_seg cos_t1 = math.cos(t1) sin_t1 = math.sin(t1) cos_t2 = math.cos(t2) sin_t2 = math.sin(t2) ep1_x = cos_t1 - alpha * sin_t1 ep1_y = sin_t1 + alpha * cos_t1 ep2_x = cos_t2 + alpha * sin_t2 ep2_y = sin_t2 - alpha * cos_t2 ep_x = cos_t2 ep_y = sin_t2 def transform_pt(px: float, py: float) -> tuple[float, float]: x = rx * px y = ry * py xr = cos_phi * x - sin_phi * y + arc_cx yr = sin_phi * x + cos_phi * y + arc_cy return xr, yr cp1 = transform_pt(ep1_x, ep1_y) cp2 = transform_pt(ep2_x, ep2_y) ep = transform_pt(ep_x, ep_y) result.append(PathCommand('C', [cp1[0], cp1[1], cp2[0], cp2[1], ep[0], ep[1]])) return result def normalize_path_commands(commands: list[PathCommand]) -> list[PathCommand]: """Normalize path commands to M/L/C/Z only. Converts S -> C, Q -> C, T -> C, A -> C sequences. """ result: list[PathCommand] = [] cx, cy = 0.0, 0.0 last_cp_x, last_cp_y = 0.0, 0.0 last_cmd = '' for cmd in commands: a = cmd.args if cmd.cmd == 'M': cx, cy = a[0], a[1] last_cp_x, last_cp_y = cx, cy result.append(cmd) elif cmd.cmd == 'L': cx, cy = a[0], a[1] last_cp_x, last_cp_y = cx, cy result.append(cmd) elif cmd.cmd == 'C': last_cp_x, last_cp_y = a[2], a[3] cx, cy = a[4], a[5] result.append(cmd) elif cmd.cmd == 'S': if last_cmd in ('C', 'S'): rcp_x, rcp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy) else: rcp_x, rcp_y = cx, cy last_cp_x, last_cp_y = a[0], a[1] new_cx, new_cy = a[2], a[3] result.append(PathCommand('C', [rcp_x, rcp_y, a[0], a[1], new_cx, new_cy])) cx, cy = new_cx, new_cy elif cmd.cmd == 'Q': cubic = _quad_to_cubic(a[0], a[1], cx, cy, a[2], a[3]) last_cp_x, last_cp_y = a[0], a[1] result.append(PathCommand('C', cubic)) cx, cy = a[2], a[3] elif cmd.cmd == 'T': if last_cmd in ('Q', 'T'): qp_x, qp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy) else: qp_x, qp_y = cx, cy last_cp_x, last_cp_y = qp_x, qp_y cubic = _quad_to_cubic(qp_x, qp_y, cx, cy, a[0], a[1]) result.append(PathCommand('C', cubic)) cx, cy = a[0], a[1] elif cmd.cmd == 'A': arc_beziers = _arc_to_cubic_beziers( cx, cy, a[0], a[1], a[2], int(a[3]), int(a[4]), a[5], a[6], ) for bc in arc_beziers: result.append(bc) cx, cy = a[5], a[6] last_cp_x, last_cp_y = cx, cy elif cmd.cmd == 'Z': result.append(cmd) else: result.append(cmd) last_cmd = cmd.cmd return result def path_commands_to_drawingml( commands: list[PathCommand], offset_x: float = 0, offset_y: float = 0, scale_x: float = 1.0, scale_y: float = 1.0, ) -> tuple[str, float, float, float, float]: """Convert normalized path commands to DrawingML inner XML. Returns: (path_xml, min_x, min_y, width, height) in scaled+offset coordinates. """ if not commands: return '', 0, 0, 0, 0 # First pass: calculate bounding box points: list[tuple[float, float]] = [] for cmd in commands: if cmd.cmd in ('M', 'L'): points.append(( cmd.args[0] * scale_x + offset_x, cmd.args[1] * scale_y + offset_y, )) elif cmd.cmd == 'C': for i in range(0, 6, 2): points.append(( cmd.args[i] * scale_x + offset_x, cmd.args[i + 1] * scale_y + offset_y, )) if not points: return '', 0, 0, 0, 0 min_x = min(p[0] for p in points) min_y = min(p[1] for p in points) max_x = max(p[0] for p in points) max_y = max(p[1] for p in points) width = max(max_x - min_x, 1) height = max(max_y - min_y, 1) # Second pass: generate DrawingML path commands (EMU, relative to shape) parts: list[str] = [] for cmd in commands: if cmd.cmd == 'M': x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x) y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y) parts.append(f'') elif cmd.cmd == 'L': x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x) y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y) parts.append(f'') elif cmd.cmd == 'C': pts = [] for i in range(0, 6, 2): x_emu = px_to_emu(cmd.args[i] * scale_x + offset_x - min_x) y_emu = px_to_emu(cmd.args[i + 1] * scale_y + offset_y - min_y) pts.append(f'') parts.append(f'{"".join(pts)}') elif cmd.cmd == 'Z': parts.append('') path_inner = '\n'.join(parts) return path_inner, min_x, min_y, width, height