返回 ppt-master
paths.py
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
848 lines PYTHON