"""SVG path parsing, normalization, and DrawingML path command generation. See references/svg-effects.md ยง6.9 for the project freeform grammar. """ from __future__ import annotations import math import re from collections import deque from collections.abc import Iterator from dataclasses import dataclass, field from xml.etree import ElementTree as ET from .context import AffineMatrix from .utils import ( SVG_NS, parse_inline_style, parse_project_geometry_length, project_definition_index, px_to_emu, resolve_url_id, transform_point, ) @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, } _PATH_COMMAND_CHARS = 'MmLlHhVvCcSsQqTtAaZz' _PATH_NUMBER_PATTERN = ( r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?' ) _PATH_TOKEN_RE = re.compile( rf'(?P[{_PATH_COMMAND_CHARS}])|(?P{_PATH_NUMBER_PATTERN})' ) _POINT_TOKEN_RE = re.compile(_PATH_NUMBER_PATTERN) _TOKEN_SEPARATOR_RE = re.compile(r'[ \t\r\n]*(?:,[ \t\r\n]*)?') _TRAILING_WHITESPACE_RE = re.compile(r'[ \t\r\n]*') _CANONICAL_FREEFORM_NUMBER_RE = re.compile( r'-?(?:\d+(?:\.\d+)?|\.\d+)$' ) @dataclass(frozen=True) class _GeometryToken: kind: str raw: str offset: int def _tokenize_path_data(d: str) -> list[_GeometryToken]: """Tokenize one complete path-data value without skipping input.""" if not d or not d.strip(): raise ValueError('path d must not be empty') tokens: list[_GeometryToken] = [] cursor = 0 previous_kind: str | None = None for match in _PATH_TOKEN_RE.finditer(d): kind = 'command' if match.lastgroup == 'command' else 'number' gap = d[cursor:match.start()] if _TOKEN_SEPARATOR_RE.fullmatch(gap) is None: raise ValueError( f'path d contains unsupported syntax at offset {cursor}: {gap!r}' ) if ',' in gap and ( previous_kind is None or previous_kind == 'command' or kind == 'command' ): raise ValueError( f'path d has a misplaced comma at offset {cursor}' ) tokens.append(_GeometryToken(kind, match.group(), match.start())) previous_kind = kind cursor = match.end() trailing = d[cursor:] if _TRAILING_WHITESPACE_RE.fullmatch(trailing) is None: raise ValueError( f'path d contains unsupported trailing syntax at offset ' f'{cursor}: {trailing!r}' ) if not tokens: raise ValueError('path d must contain a supported command') return tokens def _finite_token_value(token: _GeometryToken, context: str) -> float: value = float(token.raw) if not math.isfinite(value): raise ValueError( f'{context} contains a non-finite number {token.raw!r} at ' f'offset {token.offset}' ) return value def _expand_arc_argument_tokens( argument_tokens: list[_GeometryToken], command_token: _GeometryToken, ) -> list[_GeometryToken]: """Split compact SVG arc flags without weakening the numeric grammar.""" pending = deque(argument_tokens) expanded: list[_GeometryToken] = [] argument_index = 0 while pending: token = pending.popleft() position = argument_index % _ARG_COUNTS[command_token.raw] if position in {3, 4}: if not token.raw or token.raw[0] not in {'0', '1'}: raise ValueError( f'path arc flag at offset {token.offset} must be exactly ' f'0 or 1; got {token.raw!r}' ) expanded.append( _GeometryToken('number', token.raw[0], token.offset) ) remainder = token.raw[1:] if remainder: next_position = (position + 1) % _ARG_COUNTS[command_token.raw] if ( next_position in {3, 4} and remainder[0] not in {'0', '1'} ) or ( next_position not in {3, 4} and re.fullmatch(_PATH_NUMBER_PATTERN, remainder) is None ): raise ValueError( f'path arc flag at offset {token.offset} must be ' f'exactly 0 or 1; got {token.raw!r}' ) pending.appendleft( _GeometryToken('number', remainder, token.offset + 1) ) else: expanded.append(token) argument_index += 1 return expanded def _tokenize_points(points: str) -> list[_GeometryToken]: """Tokenize one complete polygon/polyline points value.""" if not points or not points.strip(): raise ValueError('points must not be empty') tokens: list[_GeometryToken] = [] cursor = 0 for match in _POINT_TOKEN_RE.finditer(points): gap = points[cursor:match.start()] if _TOKEN_SEPARATOR_RE.fullmatch(gap) is None: raise ValueError( f'points contains unsupported syntax at offset {cursor}: {gap!r}' ) if not tokens and ',' in gap: raise ValueError('points cannot start with a comma') tokens.append(_GeometryToken('number', match.group(), match.start())) cursor = match.end() trailing = points[cursor:] if _TRAILING_WHITESPACE_RE.fullmatch(trailing) is None: raise ValueError( f'points contains unsupported trailing syntax at offset ' f'{cursor}: {trailing!r}' ) if not tokens: raise ValueError('points must contain coordinate pairs') return tokens def _parse_svg_path_tokens( d: str, ) -> tuple[list[PathCommand], list[_GeometryToken]]: """Parse path data and retain its semantic numeric tokens.""" tokens = _tokenize_path_data(d) if tokens[0].kind != 'command' or tokens[0].raw not in {'M', 'm'}: raise ValueError('path d must begin with M or m') commands: list[PathCommand] = [] number_tokens: list[_GeometryToken] = [] token_index = 0 while token_index < len(tokens): command_token = tokens[token_index] if command_token.kind != 'command': raise ValueError( f'path d requires a command at offset {command_token.offset}' ) command = command_token.raw token_index += 1 if command in {'Z', 'z'}: commands.append(PathCommand(command, [])) continue argument_tokens: list[_GeometryToken] = [] while token_index < len(tokens) and tokens[token_index].kind == 'number': argument_tokens.append(tokens[token_index]) token_index += 1 argument_count = _ARG_COUNTS[command] if command in {'A', 'a'}: argument_tokens = _expand_arc_argument_tokens( argument_tokens, command_token, ) if not argument_tokens: raise ValueError( f'path command {command!r} at offset {command_token.offset} ' f'requires {argument_count} argument(s)' ) if len(argument_tokens) % argument_count: raise ValueError( f'path command {command!r} at offset {command_token.offset} ' f'has {len(argument_tokens)} argument(s); expected a multiple ' f'of {argument_count}' ) for group_index in range(0, len(argument_tokens), argument_count): group = argument_tokens[group_index:group_index + argument_count] values = [_finite_token_value(token, 'path d') for token in group] if command in {'A', 'a'}: if values[0] < 0 or values[1] < 0: raise ValueError('path arc radii must be non-negative') emitted_command = command if command == 'M' and group_index > 0: emitted_command = 'L' elif command == 'm' and group_index > 0: emitted_command = 'l' commands.append(PathCommand(emitted_command, values)) number_tokens.extend(group) return commands, number_tokens def parse_svg_path(d: str) -> list[PathCommand]: """Parse one complete supported SVG path value or fail closed.""" commands, _ = _parse_svg_path_tokens(d) return commands def parse_svg_points( points: str, *, min_points: int = 2, ) -> list[tuple[float, float]]: """Parse complete polygon/polyline points into finite coordinate pairs.""" tokens = _tokenize_points(points) if len(tokens) % 2: raise ValueError( f'points has {len(tokens)} numeric value(s); expected coordinate pairs' ) point_count = len(tokens) // 2 if point_count < min_points: raise ValueError( f'points requires at least {min_points} coordinate pair(s); ' f'found {point_count}' ) values = [_finite_token_value(token, 'points') for token in tokens] return [ (values[index], values[index + 1]) for index in range(0, len(values), 2) ] def noncanonical_path_numbers(d: str) -> tuple[str, ...]: """Return compatible path numbers that generated SVG should normalize.""" _, number_tokens = _parse_svg_path_tokens(d) return tuple( token.raw for token in number_tokens if _CANONICAL_FREEFORM_NUMBER_RE.fullmatch(token.raw) is None ) def noncanonical_points_numbers(points: str, *, min_points: int) -> tuple[str, ...]: """Return compatible point numbers that generated SVG should normalize.""" parse_svg_points(points, min_points=min_points) return tuple( token.raw for token in _tokenize_points(points) if _CANONICAL_FREEFORM_NUMBER_RE.fullmatch(token.raw) is None ) def iter_project_freeform_geometry( root: ET.Element, ) -> Iterator[tuple[ET.Element, str, str | None, int | None]]: """Yield path/points values and their minimum point-count contract.""" for elem in root.iter(): raw_tag = str(elem.tag) if raw_tag.startswith('{'): namespace, tag = raw_tag[1:].split('}', 1) if namespace != SVG_NS: continue else: tag = raw_tag if tag == 'path': yield elem, 'd', elem.get('d'), None elif tag == 'polygon': yield elem, 'points', elem.get('points'), 3 elif tag == 'polyline': yield elem, 'points', elem.get('points'), 2 def project_freeform_geometry_errors(root: ET.Element) -> list[str]: """Return blocking path/points grammar errors for converter preflight.""" errors: list[str] = [] for elem, attribute, raw, min_points in iter_project_freeform_geometry(root): tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) elem_id = elem.get('id') label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>' try: if raw is None: raise ValueError(f'<{tag}> requires {attribute}') if attribute == 'd': parse_svg_path(raw) else: parse_svg_points(raw, min_points=min_points or 2) except ValueError as exc: errors.append(f'{label} {attribute}: {exc}') return errors def _project_path_like_bounds( elem: ET.Element, ) -> tuple[float, float, float, float] | None: """Return intrinsic bounds for line-like SVG geometry.""" tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) points: list[tuple[float, float]] = [] if tag == 'line': points = [ ( parse_project_geometry_length(elem.get('x1', '0'), 'x1'), parse_project_geometry_length(elem.get('y1', '0'), 'y1'), ), ( parse_project_geometry_length(elem.get('x2', '0'), 'x2'), parse_project_geometry_length(elem.get('y2', '0'), 'y2'), ), ] elif tag in {'polygon', 'polyline'}: min_points = 3 if tag == 'polygon' else 2 points = parse_svg_points(elem.get('points', ''), min_points=min_points) elif tag == 'path': commands = normalize_path_commands( svg_path_to_absolute(parse_svg_path(elem.get('d', ''))) ) current_point: tuple[float, float] | None = None subpath_start: tuple[float, float] | None = None for command in commands: if command.cmd == 'M': current_point = (command.args[0], command.args[1]) subpath_start = current_point elif command.cmd == 'L': end_point = (command.args[0], command.args[1]) if current_point is not None: points.extend((current_point, end_point)) current_point = end_point elif command.cmd == 'C': if current_point is not None: points.append(current_point) points.extend( (command.args[index], command.args[index + 1]) for index in range(0, 6, 2) ) current_point = (command.args[4], command.args[5]) elif command.cmd == 'Z' and current_point is not None: if subpath_start is not None: points.extend((current_point, subpath_start)) current_point = subpath_start if not points: return None xs = [point[0] for point in points] ys = [point[1] for point in points] return min(xs), min(ys), max(xs), max(ys) def project_gradient_geometry_errors(root: ET.Element) -> list[str]: """Reject object-bounding-box gradient strokes on degenerate geometry.""" definitions, _duplicates = project_definition_index(root) parent_by_id = { id(child): parent for parent in root.iter() for child in list(parent) } errors: set[str] = set() for elem in root.iter(): tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) if tag not in {'line', 'path', 'polygon', 'polyline'}: continue current: ET.Element | None = elem stroke: str | None = None while current is not None: style_values = parse_inline_style(current.get('style')) if 'stroke' in style_values: stroke = style_values['stroke'] break if current.get('stroke') is not None: stroke = current.get('stroke') break current = parent_by_id.get(id(current)) gradient_id = resolve_url_id(stroke) gradient = definitions.get(gradient_id) if gradient_id else None if gradient is None: continue gradient_tag = ( gradient.tag.rsplit('}', 1)[-1] if '}' in str(gradient.tag) else str(gradient.tag) ) if gradient_tag not in {'linearGradient', 'radialGradient'}: continue if gradient.get('gradientUnits') not in {None, 'objectBoundingBox'}: continue try: bounds = _project_path_like_bounds(elem) except ValueError: # The existing geometry preflight owns malformed geometry errors. continue if bounds is None: continue min_x, min_y, max_x, max_y = bounds zero_width = math.isclose( min_x, max_x, rel_tol=0.0, abs_tol=1e-9 ) zero_height = math.isclose( min_y, max_y, rel_tol=0.0, abs_tol=1e-9 ) if not zero_width and not zero_height: continue dimension = 'width and height' if zero_width and zero_height else ( 'width' if zero_width else 'height' ) elem_id = elem.get('id') label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>' errors.add( f'{label} stroke=url(#{gradient_id}) has zero intrinsic {dimension}; ' 'objectBoundingBox gradients do not include stroke width and will ' 'not render. Use a non-degenerate path or a closed filled shape' ) return sorted(errors) 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 transform_path_commands( commands: list[PathCommand], matrix: AffineMatrix, ) -> list[PathCommand]: """Apply an affine transform to normalized M/L/C/Z path commands.""" transformed: list[PathCommand] = [] for command in commands: if command.cmd in {'M', 'L'}: x, y = transform_point( matrix, command.args[0], command.args[1], ) transformed.append(PathCommand(command.cmd, [x, y])) elif command.cmd == 'C': args: list[float] = [] for index in range(0, 6, 2): x, y = transform_point( matrix, command.args[index], command.args[index + 1], ) args.extend([x, y]) transformed.append(PathCommand(command.cmd, args)) else: transformed.append(command) return transformed 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