Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user