| 1 | """SVG path parsing, normalization, and DrawingML path command generation. |
| 2 | |
| 3 | See references/svg-effects.md §6.9 for the project freeform grammar. |
| 4 | """ |
| 5 | |
| 6 | from __future__ import annotations |
| 7 | |
| 8 | import math |
| 9 | import re |
| 10 | from collections import deque |
| 11 | from collections.abc import Iterator |
| 12 | from dataclasses import dataclass, field |
| 13 | from xml.etree import ElementTree as ET |
| 14 | |
| 15 | from .context import AffineMatrix |
| 16 | from .utils import ( |
| 17 | SVG_NS, |
| 18 | parse_inline_style, |
| 19 | parse_project_geometry_length, |
| 20 | project_definition_index, |
| 21 | px_to_emu, |
| 22 | resolve_url_id, |
| 23 | transform_point, |
| 24 | ) |
| 25 | |
| 26 | |
| 27 | @dataclass |
| 28 | class PathCommand: |
| 29 | """A single SVG path command with its arguments.""" |
| 30 | cmd: str # M, L, C, Z, etc. (uppercase = absolute) |
| 31 | args: list[float] = field(default_factory=list) |
| 32 | |
| 33 | |
| 34 | # Argument counts per SVG path command |
| 35 | _ARG_COUNTS = { |
| 36 | 'M': 2, 'm': 2, 'L': 2, 'l': 2, |
| 37 | 'H': 1, 'h': 1, 'V': 1, 'v': 1, |
| 38 | 'C': 6, 'c': 6, 'S': 4, 's': 4, |
| 39 | 'Q': 4, 'q': 4, 'T': 2, 't': 2, |
| 40 | 'A': 7, 'a': 7, 'Z': 0, 'z': 0, |
| 41 | } |
| 42 | |
| 43 | _PATH_COMMAND_CHARS = 'MmLlHhVvCcSsQqTtAaZz' |
| 44 | _PATH_NUMBER_PATTERN = ( |
| 45 | r'[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?' |
| 46 | ) |
| 47 | _PATH_TOKEN_RE = re.compile( |
| 48 | rf'(?P<command>[{_PATH_COMMAND_CHARS}])|(?P<number>{_PATH_NUMBER_PATTERN})' |
| 49 | ) |
| 50 | _POINT_TOKEN_RE = re.compile(_PATH_NUMBER_PATTERN) |
| 51 | _TOKEN_SEPARATOR_RE = re.compile(r'[ \t\r\n]*(?:,[ \t\r\n]*)?') |
| 52 | _TRAILING_WHITESPACE_RE = re.compile(r'[ \t\r\n]*') |
| 53 | _CANONICAL_FREEFORM_NUMBER_RE = re.compile( |
| 54 | r'-?(?:\d+(?:\.\d+)?|\.\d+)$' |
| 55 | ) |
| 56 | |
| 57 | |
| 58 | @dataclass(frozen=True) |
| 59 | class _GeometryToken: |
| 60 | kind: str |
| 61 | raw: str |
| 62 | offset: int |
| 63 | |
| 64 | |
| 65 | def _tokenize_path_data(d: str) -> list[_GeometryToken]: |
| 66 | """Tokenize one complete path-data value without skipping input.""" |
| 67 | if not d or not d.strip(): |
| 68 | raise ValueError('path d must not be empty') |
| 69 | |
| 70 | tokens: list[_GeometryToken] = [] |
| 71 | cursor = 0 |
| 72 | previous_kind: str | None = None |
| 73 | for match in _PATH_TOKEN_RE.finditer(d): |
| 74 | kind = 'command' if match.lastgroup == 'command' else 'number' |
| 75 | gap = d[cursor:match.start()] |
| 76 | if _TOKEN_SEPARATOR_RE.fullmatch(gap) is None: |
| 77 | raise ValueError( |
| 78 | f'path d contains unsupported syntax at offset {cursor}: {gap!r}' |
| 79 | ) |
| 80 | if ',' in gap and ( |
| 81 | previous_kind is None |
| 82 | or previous_kind == 'command' |
| 83 | or kind == 'command' |
| 84 | ): |
| 85 | raise ValueError( |
| 86 | f'path d has a misplaced comma at offset {cursor}' |
| 87 | ) |
| 88 | tokens.append(_GeometryToken(kind, match.group(), match.start())) |
| 89 | previous_kind = kind |
| 90 | cursor = match.end() |
| 91 | |
| 92 | trailing = d[cursor:] |
| 93 | if _TRAILING_WHITESPACE_RE.fullmatch(trailing) is None: |
| 94 | raise ValueError( |
| 95 | f'path d contains unsupported trailing syntax at offset ' |
| 96 | f'{cursor}: {trailing!r}' |
| 97 | ) |
| 98 | if not tokens: |
| 99 | raise ValueError('path d must contain a supported command') |
| 100 | return tokens |
| 101 | |
| 102 | |
| 103 | def _finite_token_value(token: _GeometryToken, context: str) -> float: |
| 104 | value = float(token.raw) |
| 105 | if not math.isfinite(value): |
| 106 | raise ValueError( |
| 107 | f'{context} contains a non-finite number {token.raw!r} at ' |
| 108 | f'offset {token.offset}' |
| 109 | ) |
| 110 | return value |
| 111 | |
| 112 | |
| 113 | def _expand_arc_argument_tokens( |
| 114 | argument_tokens: list[_GeometryToken], |
| 115 | command_token: _GeometryToken, |
| 116 | ) -> list[_GeometryToken]: |
| 117 | """Split compact SVG arc flags without weakening the numeric grammar.""" |
| 118 | pending = deque(argument_tokens) |
| 119 | expanded: list[_GeometryToken] = [] |
| 120 | argument_index = 0 |
| 121 | while pending: |
| 122 | token = pending.popleft() |
| 123 | position = argument_index % _ARG_COUNTS[command_token.raw] |
| 124 | if position in {3, 4}: |
| 125 | if not token.raw or token.raw[0] not in {'0', '1'}: |
| 126 | raise ValueError( |
| 127 | f'path arc flag at offset {token.offset} must be exactly ' |
| 128 | f'0 or 1; got {token.raw!r}' |
| 129 | ) |
| 130 | expanded.append( |
| 131 | _GeometryToken('number', token.raw[0], token.offset) |
| 132 | ) |
| 133 | remainder = token.raw[1:] |
| 134 | if remainder: |
| 135 | next_position = (position + 1) % _ARG_COUNTS[command_token.raw] |
| 136 | if ( |
| 137 | next_position in {3, 4} |
| 138 | and remainder[0] not in {'0', '1'} |
| 139 | ) or ( |
| 140 | next_position not in {3, 4} |
| 141 | and re.fullmatch(_PATH_NUMBER_PATTERN, remainder) is None |
| 142 | ): |
| 143 | raise ValueError( |
| 144 | f'path arc flag at offset {token.offset} must be ' |
| 145 | f'exactly 0 or 1; got {token.raw!r}' |
| 146 | ) |
| 147 | pending.appendleft( |
| 148 | _GeometryToken('number', remainder, token.offset + 1) |
| 149 | ) |
| 150 | else: |
| 151 | expanded.append(token) |
| 152 | argument_index += 1 |
| 153 | |
| 154 | return expanded |
| 155 | |
| 156 | |
| 157 | def _tokenize_points(points: str) -> list[_GeometryToken]: |
| 158 | """Tokenize one complete polygon/polyline points value.""" |
| 159 | if not points or not points.strip(): |
| 160 | raise ValueError('points must not be empty') |
| 161 | |
| 162 | tokens: list[_GeometryToken] = [] |
| 163 | cursor = 0 |
| 164 | for match in _POINT_TOKEN_RE.finditer(points): |
| 165 | gap = points[cursor:match.start()] |
| 166 | if _TOKEN_SEPARATOR_RE.fullmatch(gap) is None: |
| 167 | raise ValueError( |
| 168 | f'points contains unsupported syntax at offset {cursor}: {gap!r}' |
| 169 | ) |
| 170 | if not tokens and ',' in gap: |
| 171 | raise ValueError('points cannot start with a comma') |
| 172 | tokens.append(_GeometryToken('number', match.group(), match.start())) |
| 173 | cursor = match.end() |
| 174 | |
| 175 | trailing = points[cursor:] |
| 176 | if _TRAILING_WHITESPACE_RE.fullmatch(trailing) is None: |
| 177 | raise ValueError( |
| 178 | f'points contains unsupported trailing syntax at offset ' |
| 179 | f'{cursor}: {trailing!r}' |
| 180 | ) |
| 181 | if not tokens: |
| 182 | raise ValueError('points must contain coordinate pairs') |
| 183 | return tokens |
| 184 | |
| 185 | |
| 186 | def _parse_svg_path_tokens( |
| 187 | d: str, |
| 188 | ) -> tuple[list[PathCommand], list[_GeometryToken]]: |
| 189 | """Parse path data and retain its semantic numeric tokens.""" |
| 190 | tokens = _tokenize_path_data(d) |
| 191 | if tokens[0].kind != 'command' or tokens[0].raw not in {'M', 'm'}: |
| 192 | raise ValueError('path d must begin with M or m') |
| 193 | |
| 194 | commands: list[PathCommand] = [] |
| 195 | number_tokens: list[_GeometryToken] = [] |
| 196 | token_index = 0 |
| 197 | while token_index < len(tokens): |
| 198 | command_token = tokens[token_index] |
| 199 | if command_token.kind != 'command': |
| 200 | raise ValueError( |
| 201 | f'path d requires a command at offset {command_token.offset}' |
| 202 | ) |
| 203 | command = command_token.raw |
| 204 | token_index += 1 |
| 205 | if command in {'Z', 'z'}: |
| 206 | commands.append(PathCommand(command, [])) |
| 207 | continue |
| 208 | |
| 209 | argument_tokens: list[_GeometryToken] = [] |
| 210 | while token_index < len(tokens) and tokens[token_index].kind == 'number': |
| 211 | argument_tokens.append(tokens[token_index]) |
| 212 | token_index += 1 |
| 213 | |
| 214 | argument_count = _ARG_COUNTS[command] |
| 215 | if command in {'A', 'a'}: |
| 216 | argument_tokens = _expand_arc_argument_tokens( |
| 217 | argument_tokens, |
| 218 | command_token, |
| 219 | ) |
| 220 | if not argument_tokens: |
| 221 | raise ValueError( |
| 222 | f'path command {command!r} at offset {command_token.offset} ' |
| 223 | f'requires {argument_count} argument(s)' |
| 224 | ) |
| 225 | if len(argument_tokens) % argument_count: |
| 226 | raise ValueError( |
| 227 | f'path command {command!r} at offset {command_token.offset} ' |
| 228 | f'has {len(argument_tokens)} argument(s); expected a multiple ' |
| 229 | f'of {argument_count}' |
| 230 | ) |
| 231 | |
| 232 | for group_index in range(0, len(argument_tokens), argument_count): |
| 233 | group = argument_tokens[group_index:group_index + argument_count] |
| 234 | values = [_finite_token_value(token, 'path d') for token in group] |
| 235 | if command in {'A', 'a'}: |
| 236 | if values[0] < 0 or values[1] < 0: |
| 237 | raise ValueError('path arc radii must be non-negative') |
| 238 | |
| 239 | emitted_command = command |
| 240 | if command == 'M' and group_index > 0: |
| 241 | emitted_command = 'L' |
| 242 | elif command == 'm' and group_index > 0: |
| 243 | emitted_command = 'l' |
| 244 | commands.append(PathCommand(emitted_command, values)) |
| 245 | number_tokens.extend(group) |
| 246 | return commands, number_tokens |
| 247 | |
| 248 | |
| 249 | def parse_svg_path(d: str) -> list[PathCommand]: |
| 250 | """Parse one complete supported SVG path value or fail closed.""" |
| 251 | commands, _ = _parse_svg_path_tokens(d) |
| 252 | return commands |
| 253 | |
| 254 | |
| 255 | def parse_svg_points( |
| 256 | points: str, |
| 257 | *, |
| 258 | min_points: int = 2, |
| 259 | ) -> list[tuple[float, float]]: |
| 260 | """Parse complete polygon/polyline points into finite coordinate pairs.""" |
| 261 | tokens = _tokenize_points(points) |
| 262 | if len(tokens) % 2: |
| 263 | raise ValueError( |
| 264 | f'points has {len(tokens)} numeric value(s); expected coordinate pairs' |
| 265 | ) |
| 266 | point_count = len(tokens) // 2 |
| 267 | if point_count < min_points: |
| 268 | raise ValueError( |
| 269 | f'points requires at least {min_points} coordinate pair(s); ' |
| 270 | f'found {point_count}' |
| 271 | ) |
| 272 | values = [_finite_token_value(token, 'points') for token in tokens] |
| 273 | return [ |
| 274 | (values[index], values[index + 1]) |
| 275 | for index in range(0, len(values), 2) |
| 276 | ] |
| 277 | |
| 278 | |
| 279 | def noncanonical_path_numbers(d: str) -> tuple[str, ...]: |
| 280 | """Return compatible path numbers that generated SVG should normalize.""" |
| 281 | _, number_tokens = _parse_svg_path_tokens(d) |
| 282 | return tuple( |
| 283 | token.raw |
| 284 | for token in number_tokens |
| 285 | if _CANONICAL_FREEFORM_NUMBER_RE.fullmatch(token.raw) is None |
| 286 | ) |
| 287 | |
| 288 | |
| 289 | def noncanonical_points_numbers(points: str, *, min_points: int) -> tuple[str, ...]: |
| 290 | """Return compatible point numbers that generated SVG should normalize.""" |
| 291 | parse_svg_points(points, min_points=min_points) |
| 292 | return tuple( |
| 293 | token.raw |
| 294 | for token in _tokenize_points(points) |
| 295 | if _CANONICAL_FREEFORM_NUMBER_RE.fullmatch(token.raw) is None |
| 296 | ) |
| 297 | |
| 298 | |
| 299 | def iter_project_freeform_geometry( |
| 300 | root: ET.Element, |
| 301 | ) -> Iterator[tuple[ET.Element, str, str | None, int | None]]: |
| 302 | """Yield path/points values and their minimum point-count contract.""" |
| 303 | for elem in root.iter(): |
| 304 | raw_tag = str(elem.tag) |
| 305 | if raw_tag.startswith('{'): |
| 306 | namespace, tag = raw_tag[1:].split('}', 1) |
| 307 | if namespace != SVG_NS: |
| 308 | continue |
| 309 | else: |
| 310 | tag = raw_tag |
| 311 | if tag == 'path': |
| 312 | yield elem, 'd', elem.get('d'), None |
| 313 | elif tag == 'polygon': |
| 314 | yield elem, 'points', elem.get('points'), 3 |
| 315 | elif tag == 'polyline': |
| 316 | yield elem, 'points', elem.get('points'), 2 |
| 317 | |
| 318 | |
| 319 | def project_freeform_geometry_errors(root: ET.Element) -> list[str]: |
| 320 | """Return blocking path/points grammar errors for converter preflight.""" |
| 321 | errors: list[str] = [] |
| 322 | for elem, attribute, raw, min_points in iter_project_freeform_geometry(root): |
| 323 | tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) |
| 324 | elem_id = elem.get('id') |
| 325 | label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>' |
| 326 | try: |
| 327 | if raw is None: |
| 328 | raise ValueError(f'<{tag}> requires {attribute}') |
| 329 | if attribute == 'd': |
| 330 | parse_svg_path(raw) |
| 331 | else: |
| 332 | parse_svg_points(raw, min_points=min_points or 2) |
| 333 | except ValueError as exc: |
| 334 | errors.append(f'{label} {attribute}: {exc}') |
| 335 | return errors |
| 336 | |
| 337 | |
| 338 | def _project_path_like_bounds( |
| 339 | elem: ET.Element, |
| 340 | ) -> tuple[float, float, float, float] | None: |
| 341 | """Return intrinsic bounds for line-like SVG geometry.""" |
| 342 | tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) |
| 343 | points: list[tuple[float, float]] = [] |
| 344 | |
| 345 | if tag == 'line': |
| 346 | points = [ |
| 347 | ( |
| 348 | parse_project_geometry_length(elem.get('x1', '0'), 'x1'), |
| 349 | parse_project_geometry_length(elem.get('y1', '0'), 'y1'), |
| 350 | ), |
| 351 | ( |
| 352 | parse_project_geometry_length(elem.get('x2', '0'), 'x2'), |
| 353 | parse_project_geometry_length(elem.get('y2', '0'), 'y2'), |
| 354 | ), |
| 355 | ] |
| 356 | elif tag in {'polygon', 'polyline'}: |
| 357 | min_points = 3 if tag == 'polygon' else 2 |
| 358 | points = parse_svg_points(elem.get('points', ''), min_points=min_points) |
| 359 | elif tag == 'path': |
| 360 | commands = normalize_path_commands( |
| 361 | svg_path_to_absolute(parse_svg_path(elem.get('d', ''))) |
| 362 | ) |
| 363 | current_point: tuple[float, float] | None = None |
| 364 | subpath_start: tuple[float, float] | None = None |
| 365 | for command in commands: |
| 366 | if command.cmd == 'M': |
| 367 | current_point = (command.args[0], command.args[1]) |
| 368 | subpath_start = current_point |
| 369 | elif command.cmd == 'L': |
| 370 | end_point = (command.args[0], command.args[1]) |
| 371 | if current_point is not None: |
| 372 | points.extend((current_point, end_point)) |
| 373 | current_point = end_point |
| 374 | elif command.cmd == 'C': |
| 375 | if current_point is not None: |
| 376 | points.append(current_point) |
| 377 | points.extend( |
| 378 | (command.args[index], command.args[index + 1]) |
| 379 | for index in range(0, 6, 2) |
| 380 | ) |
| 381 | current_point = (command.args[4], command.args[5]) |
| 382 | elif command.cmd == 'Z' and current_point is not None: |
| 383 | if subpath_start is not None: |
| 384 | points.extend((current_point, subpath_start)) |
| 385 | current_point = subpath_start |
| 386 | |
| 387 | if not points: |
| 388 | return None |
| 389 | xs = [point[0] for point in points] |
| 390 | ys = [point[1] for point in points] |
| 391 | return min(xs), min(ys), max(xs), max(ys) |
| 392 | |
| 393 | |
| 394 | def project_gradient_geometry_errors(root: ET.Element) -> list[str]: |
| 395 | """Reject object-bounding-box gradient strokes on degenerate geometry.""" |
| 396 | definitions, _duplicates = project_definition_index(root) |
| 397 | parent_by_id = { |
| 398 | id(child): parent |
| 399 | for parent in root.iter() |
| 400 | for child in list(parent) |
| 401 | } |
| 402 | errors: set[str] = set() |
| 403 | |
| 404 | for elem in root.iter(): |
| 405 | tag = elem.tag.rsplit('}', 1)[-1] if '}' in str(elem.tag) else str(elem.tag) |
| 406 | if tag not in {'line', 'path', 'polygon', 'polyline'}: |
| 407 | continue |
| 408 | |
| 409 | current: ET.Element | None = elem |
| 410 | stroke: str | None = None |
| 411 | while current is not None: |
| 412 | style_values = parse_inline_style(current.get('style')) |
| 413 | if 'stroke' in style_values: |
| 414 | stroke = style_values['stroke'] |
| 415 | break |
| 416 | if current.get('stroke') is not None: |
| 417 | stroke = current.get('stroke') |
| 418 | break |
| 419 | current = parent_by_id.get(id(current)) |
| 420 | |
| 421 | gradient_id = resolve_url_id(stroke) |
| 422 | gradient = definitions.get(gradient_id) if gradient_id else None |
| 423 | if gradient is None: |
| 424 | continue |
| 425 | gradient_tag = ( |
| 426 | gradient.tag.rsplit('}', 1)[-1] |
| 427 | if '}' in str(gradient.tag) |
| 428 | else str(gradient.tag) |
| 429 | ) |
| 430 | if gradient_tag not in {'linearGradient', 'radialGradient'}: |
| 431 | continue |
| 432 | if gradient.get('gradientUnits') not in {None, 'objectBoundingBox'}: |
| 433 | continue |
| 434 | |
| 435 | try: |
| 436 | bounds = _project_path_like_bounds(elem) |
| 437 | except ValueError: |
| 438 | # The existing geometry preflight owns malformed geometry errors. |
| 439 | continue |
| 440 | if bounds is None: |
| 441 | continue |
| 442 | min_x, min_y, max_x, max_y = bounds |
| 443 | zero_width = math.isclose( |
| 444 | min_x, max_x, rel_tol=0.0, abs_tol=1e-9 |
| 445 | ) |
| 446 | zero_height = math.isclose( |
| 447 | min_y, max_y, rel_tol=0.0, abs_tol=1e-9 |
| 448 | ) |
| 449 | if not zero_width and not zero_height: |
| 450 | continue |
| 451 | |
| 452 | dimension = 'width and height' if zero_width and zero_height else ( |
| 453 | 'width' if zero_width else 'height' |
| 454 | ) |
| 455 | elem_id = elem.get('id') |
| 456 | label = f'<{tag} id={elem_id!r}>' if elem_id else f'<{tag}>' |
| 457 | errors.add( |
| 458 | f'{label} stroke=url(#{gradient_id}) has zero intrinsic {dimension}; ' |
| 459 | 'objectBoundingBox gradients do not include stroke width and will ' |
| 460 | 'not render. Use a non-degenerate path or a closed filled shape' |
| 461 | ) |
| 462 | |
| 463 | return sorted(errors) |
| 464 | |
| 465 | |
| 466 | def svg_path_to_absolute(commands: list[PathCommand]) -> list[PathCommand]: |
| 467 | """Convert all relative path commands to absolute.""" |
| 468 | result: list[PathCommand] = [] |
| 469 | cx, cy = 0.0, 0.0 # current point |
| 470 | sx, sy = 0.0, 0.0 # subpath start |
| 471 | |
| 472 | for cmd in commands: |
| 473 | a = cmd.args |
| 474 | if cmd.cmd == 'M': |
| 475 | cx, cy = a[0], a[1] |
| 476 | sx, sy = cx, cy |
| 477 | result.append(PathCommand('M', [cx, cy])) |
| 478 | elif cmd.cmd == 'm': |
| 479 | cx += a[0]; cy += a[1] |
| 480 | sx, sy = cx, cy |
| 481 | result.append(PathCommand('M', [cx, cy])) |
| 482 | elif cmd.cmd == 'L': |
| 483 | cx, cy = a[0], a[1] |
| 484 | result.append(PathCommand('L', [cx, cy])) |
| 485 | elif cmd.cmd == 'l': |
| 486 | cx += a[0]; cy += a[1] |
| 487 | result.append(PathCommand('L', [cx, cy])) |
| 488 | elif cmd.cmd == 'H': |
| 489 | cx = a[0] |
| 490 | result.append(PathCommand('L', [cx, cy])) |
| 491 | elif cmd.cmd == 'h': |
| 492 | cx += a[0] |
| 493 | result.append(PathCommand('L', [cx, cy])) |
| 494 | elif cmd.cmd == 'V': |
| 495 | cy = a[0] |
| 496 | result.append(PathCommand('L', [cx, cy])) |
| 497 | elif cmd.cmd == 'v': |
| 498 | cy += a[0] |
| 499 | result.append(PathCommand('L', [cx, cy])) |
| 500 | elif cmd.cmd == 'C': |
| 501 | result.append(PathCommand('C', list(a))) |
| 502 | cx, cy = a[4], a[5] |
| 503 | elif cmd.cmd == 'c': |
| 504 | abs_args = [ |
| 505 | cx + a[0], cy + a[1], |
| 506 | cx + a[2], cy + a[3], |
| 507 | cx + a[4], cy + a[5], |
| 508 | ] |
| 509 | result.append(PathCommand('C', abs_args)) |
| 510 | cx, cy = abs_args[4], abs_args[5] |
| 511 | elif cmd.cmd == 'S': |
| 512 | result.append(PathCommand('S', list(a))) |
| 513 | cx, cy = a[2], a[3] |
| 514 | elif cmd.cmd == 's': |
| 515 | abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]] |
| 516 | result.append(PathCommand('S', abs_args)) |
| 517 | cx, cy = abs_args[2], abs_args[3] |
| 518 | elif cmd.cmd == 'Q': |
| 519 | result.append(PathCommand('Q', list(a))) |
| 520 | cx, cy = a[2], a[3] |
| 521 | elif cmd.cmd == 'q': |
| 522 | abs_args = [cx + a[0], cy + a[1], cx + a[2], cy + a[3]] |
| 523 | result.append(PathCommand('Q', abs_args)) |
| 524 | cx, cy = abs_args[2], abs_args[3] |
| 525 | elif cmd.cmd == 'T': |
| 526 | result.append(PathCommand('T', list(a))) |
| 527 | cx, cy = a[0], a[1] |
| 528 | elif cmd.cmd == 't': |
| 529 | abs_args = [cx + a[0], cy + a[1]] |
| 530 | result.append(PathCommand('T', abs_args)) |
| 531 | cx, cy = abs_args[0], abs_args[1] |
| 532 | elif cmd.cmd == 'A': |
| 533 | result.append(PathCommand('A', list(a))) |
| 534 | cx, cy = a[5], a[6] |
| 535 | elif cmd.cmd == 'a': |
| 536 | abs_args = [a[0], a[1], a[2], a[3], a[4], cx + a[5], cy + a[6]] |
| 537 | result.append(PathCommand('A', abs_args)) |
| 538 | cx, cy = abs_args[5], abs_args[6] |
| 539 | elif cmd.cmd in ('Z', 'z'): |
| 540 | result.append(PathCommand('Z', [])) |
| 541 | cx, cy = sx, sy |
| 542 | |
| 543 | return result |
| 544 | |
| 545 | |
| 546 | def _reflect_control_point( |
| 547 | cp_x: float, cp_y: float, |
| 548 | cx: float, cy: float, |
| 549 | ) -> tuple[float, float]: |
| 550 | """Reflect a control point through the current point.""" |
| 551 | return 2 * cx - cp_x, 2 * cy - cp_y |
| 552 | |
| 553 | |
| 554 | def _quad_to_cubic( |
| 555 | qp_x: float, qp_y: float, |
| 556 | p0_x: float, p0_y: float, |
| 557 | p3_x: float, p3_y: float, |
| 558 | ) -> list[float]: |
| 559 | """Convert quadratic bezier control point to cubic bezier control points.""" |
| 560 | cp1_x = p0_x + 2.0 / 3.0 * (qp_x - p0_x) |
| 561 | cp1_y = p0_y + 2.0 / 3.0 * (qp_y - p0_y) |
| 562 | cp2_x = p3_x + 2.0 / 3.0 * (qp_x - p3_x) |
| 563 | cp2_y = p3_y + 2.0 / 3.0 * (qp_y - p3_y) |
| 564 | return [cp1_x, cp1_y, cp2_x, cp2_y, p3_x, p3_y] |
| 565 | |
| 566 | |
| 567 | def _arc_to_cubic_beziers( |
| 568 | cx_: float, cy_: float, |
| 569 | rx: float, ry: float, |
| 570 | phi: float, |
| 571 | large_arc: int, sweep: int, |
| 572 | x2: float, y2: float, |
| 573 | ) -> list[PathCommand]: |
| 574 | """Convert SVG arc (endpoint parameterization) to cubic bezier curves. |
| 575 | |
| 576 | Uses the algorithm from the SVG spec (F.6.5) to convert endpoint to center |
| 577 | parameterization, then approximates each arc segment with cubic beziers. |
| 578 | """ |
| 579 | x1, y1 = cx_, cy_ |
| 580 | |
| 581 | if abs(x1 - x2) < 1e-10 and abs(y1 - y2) < 1e-10: |
| 582 | return [] |
| 583 | |
| 584 | rx = abs(rx) |
| 585 | ry = abs(ry) |
| 586 | if rx < 1e-10 or ry < 1e-10: |
| 587 | return [PathCommand('L', [x2, y2])] |
| 588 | |
| 589 | phi_rad = math.radians(phi) |
| 590 | cos_phi = math.cos(phi_rad) |
| 591 | sin_phi = math.sin(phi_rad) |
| 592 | |
| 593 | # Step 1: Compute (x1', y1') |
| 594 | dx = (x1 - x2) / 2.0 |
| 595 | dy = (y1 - y2) / 2.0 |
| 596 | x1p = cos_phi * dx + sin_phi * dy |
| 597 | y1p = -sin_phi * dx + cos_phi * dy |
| 598 | |
| 599 | # Step 2: Compute (cx', cy') |
| 600 | x1p2 = x1p * x1p |
| 601 | y1p2 = y1p * y1p |
| 602 | rx2 = rx * rx |
| 603 | ry2 = ry * ry |
| 604 | |
| 605 | # Ensure radii are large enough |
| 606 | lam = x1p2 / rx2 + y1p2 / ry2 |
| 607 | if lam > 1: |
| 608 | lam_sqrt = math.sqrt(lam) |
| 609 | rx *= lam_sqrt |
| 610 | ry *= lam_sqrt |
| 611 | rx2 = rx * rx |
| 612 | ry2 = ry * ry |
| 613 | |
| 614 | num = max(rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2, 0) |
| 615 | den = rx2 * y1p2 + ry2 * x1p2 |
| 616 | sq = math.sqrt(num / den) if den > 1e-10 else 0.0 |
| 617 | |
| 618 | if large_arc == sweep: |
| 619 | sq = -sq |
| 620 | |
| 621 | cxp = sq * rx * y1p / ry |
| 622 | cyp = -sq * ry * x1p / rx |
| 623 | |
| 624 | # Step 3: Compute (cx, cy) |
| 625 | arc_cx = cos_phi * cxp - sin_phi * cyp + (x1 + x2) / 2.0 |
| 626 | arc_cy = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2.0 |
| 627 | |
| 628 | # Step 4: Compute theta1 and dtheta |
| 629 | def angle_between(ux: float, uy: float, vx: float, vy: float) -> float: |
| 630 | n = math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)) |
| 631 | if n < 1e-10: |
| 632 | return 0 |
| 633 | c = max(-1, min(1, (ux * vx + uy * vy) / n)) |
| 634 | a = math.acos(c) |
| 635 | if ux * vy - uy * vx < 0: |
| 636 | a = -a |
| 637 | return a |
| 638 | |
| 639 | theta1 = angle_between(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry) |
| 640 | dtheta = angle_between( |
| 641 | (x1p - cxp) / rx, (y1p - cyp) / ry, |
| 642 | (-x1p - cxp) / rx, (-y1p - cyp) / ry, |
| 643 | ) |
| 644 | |
| 645 | if sweep == 0 and dtheta > 0: |
| 646 | dtheta -= 2 * math.pi |
| 647 | elif sweep == 1 and dtheta < 0: |
| 648 | dtheta += 2 * math.pi |
| 649 | |
| 650 | # Split arc into segments of at most 90 degrees |
| 651 | n_segs = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2)))) |
| 652 | d_per_seg = dtheta / n_segs |
| 653 | |
| 654 | result: list[PathCommand] = [] |
| 655 | alpha = 4.0 / 3.0 * math.tan(d_per_seg / 4.0) |
| 656 | |
| 657 | for i in range(n_segs): |
| 658 | t1 = theta1 + i * d_per_seg |
| 659 | t2 = theta1 + (i + 1) * d_per_seg |
| 660 | |
| 661 | cos_t1 = math.cos(t1) |
| 662 | sin_t1 = math.sin(t1) |
| 663 | cos_t2 = math.cos(t2) |
| 664 | sin_t2 = math.sin(t2) |
| 665 | |
| 666 | ep1_x = cos_t1 - alpha * sin_t1 |
| 667 | ep1_y = sin_t1 + alpha * cos_t1 |
| 668 | ep2_x = cos_t2 + alpha * sin_t2 |
| 669 | ep2_y = sin_t2 - alpha * cos_t2 |
| 670 | ep_x = cos_t2 |
| 671 | ep_y = sin_t2 |
| 672 | |
| 673 | def transform_pt(px: float, py: float) -> tuple[float, float]: |
| 674 | x = rx * px |
| 675 | y = ry * py |
| 676 | xr = cos_phi * x - sin_phi * y + arc_cx |
| 677 | yr = sin_phi * x + cos_phi * y + arc_cy |
| 678 | return xr, yr |
| 679 | |
| 680 | cp1 = transform_pt(ep1_x, ep1_y) |
| 681 | cp2 = transform_pt(ep2_x, ep2_y) |
| 682 | ep = transform_pt(ep_x, ep_y) |
| 683 | |
| 684 | result.append(PathCommand('C', [cp1[0], cp1[1], cp2[0], cp2[1], ep[0], ep[1]])) |
| 685 | |
| 686 | return result |
| 687 | |
| 688 | |
| 689 | def normalize_path_commands(commands: list[PathCommand]) -> list[PathCommand]: |
| 690 | """Normalize path commands to M/L/C/Z only. |
| 691 | |
| 692 | Converts S -> C, Q -> C, T -> C, A -> C sequences. |
| 693 | """ |
| 694 | result: list[PathCommand] = [] |
| 695 | cx, cy = 0.0, 0.0 |
| 696 | last_cp_x, last_cp_y = 0.0, 0.0 |
| 697 | last_cmd = '' |
| 698 | |
| 699 | for cmd in commands: |
| 700 | a = cmd.args |
| 701 | |
| 702 | if cmd.cmd == 'M': |
| 703 | cx, cy = a[0], a[1] |
| 704 | last_cp_x, last_cp_y = cx, cy |
| 705 | result.append(cmd) |
| 706 | elif cmd.cmd == 'L': |
| 707 | cx, cy = a[0], a[1] |
| 708 | last_cp_x, last_cp_y = cx, cy |
| 709 | result.append(cmd) |
| 710 | elif cmd.cmd == 'C': |
| 711 | last_cp_x, last_cp_y = a[2], a[3] |
| 712 | cx, cy = a[4], a[5] |
| 713 | result.append(cmd) |
| 714 | elif cmd.cmd == 'S': |
| 715 | if last_cmd in ('C', 'S'): |
| 716 | rcp_x, rcp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy) |
| 717 | else: |
| 718 | rcp_x, rcp_y = cx, cy |
| 719 | last_cp_x, last_cp_y = a[0], a[1] |
| 720 | new_cx, new_cy = a[2], a[3] |
| 721 | result.append(PathCommand('C', [rcp_x, rcp_y, a[0], a[1], new_cx, new_cy])) |
| 722 | cx, cy = new_cx, new_cy |
| 723 | elif cmd.cmd == 'Q': |
| 724 | cubic = _quad_to_cubic(a[0], a[1], cx, cy, a[2], a[3]) |
| 725 | last_cp_x, last_cp_y = a[0], a[1] |
| 726 | result.append(PathCommand('C', cubic)) |
| 727 | cx, cy = a[2], a[3] |
| 728 | elif cmd.cmd == 'T': |
| 729 | if last_cmd in ('Q', 'T'): |
| 730 | qp_x, qp_y = _reflect_control_point(last_cp_x, last_cp_y, cx, cy) |
| 731 | else: |
| 732 | qp_x, qp_y = cx, cy |
| 733 | last_cp_x, last_cp_y = qp_x, qp_y |
| 734 | cubic = _quad_to_cubic(qp_x, qp_y, cx, cy, a[0], a[1]) |
| 735 | result.append(PathCommand('C', cubic)) |
| 736 | cx, cy = a[0], a[1] |
| 737 | elif cmd.cmd == 'A': |
| 738 | arc_beziers = _arc_to_cubic_beziers( |
| 739 | cx, cy, a[0], a[1], a[2], int(a[3]), int(a[4]), a[5], a[6], |
| 740 | ) |
| 741 | for bc in arc_beziers: |
| 742 | result.append(bc) |
| 743 | cx, cy = a[5], a[6] |
| 744 | last_cp_x, last_cp_y = cx, cy |
| 745 | elif cmd.cmd == 'Z': |
| 746 | result.append(cmd) |
| 747 | else: |
| 748 | result.append(cmd) |
| 749 | |
| 750 | last_cmd = cmd.cmd |
| 751 | |
| 752 | return result |
| 753 | |
| 754 | |
| 755 | def transform_path_commands( |
| 756 | commands: list[PathCommand], |
| 757 | matrix: AffineMatrix, |
| 758 | ) -> list[PathCommand]: |
| 759 | """Apply an affine transform to normalized M/L/C/Z path commands.""" |
| 760 | transformed: list[PathCommand] = [] |
| 761 | for command in commands: |
| 762 | if command.cmd in {'M', 'L'}: |
| 763 | x, y = transform_point( |
| 764 | matrix, |
| 765 | command.args[0], |
| 766 | command.args[1], |
| 767 | ) |
| 768 | transformed.append(PathCommand(command.cmd, [x, y])) |
| 769 | elif command.cmd == 'C': |
| 770 | args: list[float] = [] |
| 771 | for index in range(0, 6, 2): |
| 772 | x, y = transform_point( |
| 773 | matrix, |
| 774 | command.args[index], |
| 775 | command.args[index + 1], |
| 776 | ) |
| 777 | args.extend([x, y]) |
| 778 | transformed.append(PathCommand(command.cmd, args)) |
| 779 | else: |
| 780 | transformed.append(command) |
| 781 | return transformed |
| 782 | |
| 783 | |
| 784 | def path_commands_to_drawingml( |
| 785 | commands: list[PathCommand], |
| 786 | offset_x: float = 0, |
| 787 | offset_y: float = 0, |
| 788 | scale_x: float = 1.0, |
| 789 | scale_y: float = 1.0, |
| 790 | ) -> tuple[str, float, float, float, float]: |
| 791 | """Convert normalized path commands to DrawingML <a:path> inner XML. |
| 792 | |
| 793 | Returns: |
| 794 | (path_xml, min_x, min_y, width, height) in scaled+offset coordinates. |
| 795 | """ |
| 796 | if not commands: |
| 797 | return '', 0, 0, 0, 0 |
| 798 | |
| 799 | # First pass: calculate bounding box |
| 800 | points: list[tuple[float, float]] = [] |
| 801 | for cmd in commands: |
| 802 | if cmd.cmd in ('M', 'L'): |
| 803 | points.append(( |
| 804 | cmd.args[0] * scale_x + offset_x, |
| 805 | cmd.args[1] * scale_y + offset_y, |
| 806 | )) |
| 807 | elif cmd.cmd == 'C': |
| 808 | for i in range(0, 6, 2): |
| 809 | points.append(( |
| 810 | cmd.args[i] * scale_x + offset_x, |
| 811 | cmd.args[i + 1] * scale_y + offset_y, |
| 812 | )) |
| 813 | |
| 814 | if not points: |
| 815 | return '', 0, 0, 0, 0 |
| 816 | |
| 817 | min_x = min(p[0] for p in points) |
| 818 | min_y = min(p[1] for p in points) |
| 819 | max_x = max(p[0] for p in points) |
| 820 | max_y = max(p[1] for p in points) |
| 821 | |
| 822 | width = max(max_x - min_x, 1) |
| 823 | height = max(max_y - min_y, 1) |
| 824 | |
| 825 | # Second pass: generate DrawingML path commands (EMU, relative to shape) |
| 826 | parts: list[str] = [] |
| 827 | for cmd in commands: |
| 828 | if cmd.cmd == 'M': |
| 829 | x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x) |
| 830 | y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y) |
| 831 | parts.append(f'<a:moveTo><a:pt x="{x_emu}" y="{y_emu}"/></a:moveTo>') |
| 832 | elif cmd.cmd == 'L': |
| 833 | x_emu = px_to_emu(cmd.args[0] * scale_x + offset_x - min_x) |
| 834 | y_emu = px_to_emu(cmd.args[1] * scale_y + offset_y - min_y) |
| 835 | parts.append(f'<a:lnTo><a:pt x="{x_emu}" y="{y_emu}"/></a:lnTo>') |
| 836 | elif cmd.cmd == 'C': |
| 837 | pts = [] |
| 838 | for i in range(0, 6, 2): |
| 839 | x_emu = px_to_emu(cmd.args[i] * scale_x + offset_x - min_x) |
| 840 | y_emu = px_to_emu(cmd.args[i + 1] * scale_y + offset_y - min_y) |
| 841 | pts.append(f'<a:pt x="{x_emu}" y="{y_emu}"/>') |
| 842 | parts.append(f'<a:cubicBezTo>{"".join(pts)}</a:cubicBezTo>') |
| 843 | elif cmd.cmd == 'Z': |
| 844 | parts.append('<a:close/>') |
| 845 | |
| 846 | path_inner = '\n'.join(parts) |
| 847 | return path_inner, min_x, min_y, width, height |
| 848 |