Files

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