848 lines
29 KiB
Python
848 lines
29 KiB
Python
"""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<command>[{_PATH_COMMAND_CHARS}])|(?P<number>{_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 <a:path> 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'<a:moveTo><a:pt x="{x_emu}" y="{y_emu}"/></a:moveTo>')
|
|
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'<a:lnTo><a:pt x="{x_emu}" y="{y_emu}"/></a:lnTo>')
|
|
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'<a:pt x="{x_emu}" y="{y_emu}"/>')
|
|
parts.append(f'<a:cubicBezTo>{"".join(pts)}</a:cubicBezTo>')
|
|
elif cmd.cmd == 'Z':
|
|
parts.append('<a:close/>')
|
|
|
|
path_inner = '\n'.join(parts)
|
|
return path_inner, min_x, min_y, width, height
|