You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

586 lines
19 KiB

"""Utility functions for SVG processing and path manipulation.
This module provides low-level utility functions used throughout the svglib
package for processing SVG content. It includes functions for parsing SVG path
data, converting between different curve representations, and handling
geometric transformations.
The module includes:
- SVG path parsing and normalization
- Bezier curve conversion utilities
- Elliptical arc processing functions
- Vector mathematics helpers
- String parsing utilities for SVG attributes
"""
import re
from math import acos, ceil, copysign, cos, degrees, fabs, hypot, radians, sin, sqrt
from typing import List, Tuple, Union, cast
from reportlab.graphics.shapes import mmult, rotate, transformPoint, translate
def split_floats(op: str, min_num: int, value: str) -> List[Union[str, List[float]]]:
"""Parse SVG coordinate string into alternating operators and coordinate lists.
Splits a string of numeric values into groups and pairs each group with the
appropriate SVG path operation. Automatically converts 'M' to 'L' for subsequent
coordinate pairs to handle move-to followed by line-to sequences.
Args:
op: SVG path operation character (e.g., 'M', 'L', 'm', 'l').
min_num: Minimum number of coordinates expected per operation.
value: String containing comma/whitespace-separated numeric values.
Returns:
List alternating between operation strings and coordinate lists.
Example: ['M', [10.0, 20.0], 'L', [30.0, 40.0]]
Examples:
>>> split_floats('M', 2, '10,20 30,40')
['M', [10.0, 20.0], 'L', [30.0, 40.0]]
>>> split_floats('L', 2, '100 200')
['L', [100.0, 200.0]]
Note:
Supports scientific notation (e.g., '1.23e-4') and handles various
whitespace and comma separators automatically.
"""
floats = [
float(seq)
for seq in re.findall(r"(-?\d*\.?\d*(?:[eE][+-]?\d+)?)", value)
if seq
]
res: List[Union[str, List[float]]] = []
for i in range(0, len(floats), min_num):
if i > 0 and op in {"m", "M"}:
op = "l" if op == "m" else "L"
res.extend([op, cast(List[float], list(floats[i : i + min_num]))])
return res
def split_arc_values(op: str, value: str) -> List[Union[str, List[float]]]:
"""Parse SVG elliptical arc parameters into structured format.
Parses SVG arc command parameters which consist of: rx, ry, x-axis-rotation,
large-arc-flag, sweep-flag, x, y. Each complete parameter set is paired with
the operation character.
Args:
op: SVG arc operation character ('A' or 'a').
value: String containing arc parameters in the format:
"rx,ry x-axis-rotation large-arc-flag,sweep-flag x,y"
Returns:
List alternating between operation strings and parameter lists.
Each parameter list contains [rx, ry, x_axis_rotation, large_arc_flag,
sweep_flag, x, y] as floats.
Examples:
>>> split_arc_values('A', '50,50 0 1,0 100,100')
['A', [50.0, 50.0, 0.0, 1.0, 0.0, 100.0, 100.0]]
>>> split_arc_values('a', '25 25 30 0 1 50 75')
['a', [25.0, 25.0, 30.0, 0.0, 1.0, 50.0, 75.0]]
Note:
The large-arc-flag and sweep-flag are converted to float but should
be treated as boolean values (0 or 1).
"""
float_re = r"(-?\d*\.?\d*(?:[eE][+-]?\d+)?)"
flag_re = r"([1|0])"
# 3 numb, 2 flags, 1 coord pair
a_seq_re = (
r"[\s,]*".join(
[float_re, float_re, float_re, flag_re, flag_re, float_re, float_re]
)
+ r"[\s,]*"
)
res: List[Union[str, List[float]]] = []
for seq in re.finditer(a_seq_re, value.strip()):
res.extend([op, cast(List[float], list(float(num) for num in seq.groups()))])
return res
def normalise_svg_path(attr: str) -> List[Union[str, List[float]]]:
"""Normalize SVG path data into structured format.
Parses raw SVG path string and converts it into a standardized list format
where each path command is paired with its coordinate parameters. Automatically
handles command sequences and converts implicit line commands after move commands.
Args:
attr: Raw SVG path string (e.g., "M 10 20 L 30 40 Z").
Returns:
Normalized list alternating between command strings and coordinate lists.
Close path commands ('Z', 'z') are paired with empty lists for consistency.
Examples:
>>> normalise_svg_path("M 10 20 L 30 40 Z")
['M', [10.0, 20.0], 'L', [30.0, 40.0], 'Z', []]
>>> normalise_svg_path("M 0 0 L 10 0 10 10 Z")
['M', [0.0, 0.0], 'L', [10.0, 0.0], 'L', [10.0, 10.0], 'Z', []]
>>> normalise_svg_path("m 100,200 300,400")
['m', [100.0, 200.0], 'l', [300.0, 400.0]]
Note:
- Converts sequences of M/m commands to M/m followed by L/l commands
- Handles all SVG path commands: M, L, H, V, C, c, S, s, Q, q, T, t, A, a, Z, z
- Supports various whitespace and comma separators
- All coordinates are converted to float values
"""
# operator codes mapped to the minimum number of expected arguments
ops = {
"A": 7,
"a": 7,
"Q": 4,
"q": 4,
"T": 2,
"t": 2,
"S": 4,
"s": 4,
"M": 2,
"L": 2,
"m": 2,
"l": 2,
"H": 1,
"V": 1,
"h": 1,
"v": 1,
"C": 6,
"c": 6,
"Z": 0,
"z": 0,
}
op_keys = ops.keys()
# do some preprocessing
result: List[Union[str, List[float]]] = []
groups = re.split("([achlmqstvz])", attr.strip(), flags=re.I)
op = ""
for item in groups:
if item.strip() == "":
continue
if item in op_keys:
# fix sequences of M to one M plus a sequence of L operators,
# same for m and l.
if item == "M" and op == "M":
op = "L"
elif item == "m" and op == "m":
op = "l"
else:
op = item
if ops[op] == 0: # Z, z
result.extend([op, []])
else:
if op.lower() == "a":
result.extend(split_arc_values(op, item))
else:
result.extend(split_floats(op, ops[op], item))
op = cast(str, result[-2]) # Remember last op
return result
def convert_quadratic_to_cubic_path(
q0: Tuple[float, float], q1: Tuple[float, float], q2: Tuple[float, float]
) -> Tuple[
Tuple[float, float], Tuple[float, float], Tuple[float, float], Tuple[float, float]
]:
"""Convert quadratic Bezier curve to cubic Bezier curve.
Converts a quadratic Bezier curve defined by control points q0, q1, q2
into an equivalent cubic Bezier curve. This is useful for SVG processing
since cubic curves are more commonly supported than quadratic curves.
Args:
q0: Starting point as (x, y) tuple.
q1: Control point as (x, y) tuple.
q2: End point as (x, y) tuple.
Returns:
Tuple of four (x, y) points defining the equivalent cubic Bezier curve:
(start_point, control_point1, control_point2, end_point).
Examples:
>>> convert_quadratic_to_cubic_path((0, 0), (5, 10), (10, 0))
((0, 0), (3.3333333333, 6.6666666666), (6.6666666666, 6.6666666666), (10, 0))
>>> # Simple case: straight line becomes straight line
>>> convert_quadratic_to_cubic_path((0, 0), (5, 5), (10, 10))
((0, 0), (3.3333333333, 3.3333333333), (6.6666666666, 6.6666666666), (10, 10))
Note:
The conversion uses the standard formula where the cubic control points
are calculated as: c1 = q0 + (2/3)*(q1 - q0), c2 = c1 + (1/3)*(q2 - q0).
"""
c0 = q0
c1 = (q0[0] + 2 / 3 * (q1[0] - q0[0]), q0[1] + 2 / 3 * (q1[1] - q0[1]))
c2 = (c1[0] + 1 / 3 * (q2[0] - q0[0]), c1[1] + 1 / 3 * (q2[1] - q0[1]))
c3 = q2
return c0, c1, c2, c3
# ***********************************************
# Helper functions for elliptical arc conversion.
# ***********************************************
def vector_angle(u: Tuple[float, float], v: Tuple[float, float]) -> float:
"""Calculate the signed angle between two 2D vectors.
Computes the angle between vectors u and v using the atan2 method to
determine the correct quadrant and sign. Returns angle in degrees.
Args:
u: First vector as (x, y) tuple.
v: Second vector as (x, y) tuple.
Returns:
Signed angle in degrees between the vectors, ranging from -180 to 180.
Examples:
>>> vector_angle((1, 0), (0, 1)) # 90 degrees counterclockwise
90.0
>>> vector_angle((1, 0), (0, -1)) # 90 degrees clockwise
-90.0
>>> vector_angle((1, 0), (1, 0)) # Same direction
0.0
>>> vector_angle((1, 0), (-1, 0)) # Opposite direction
180.0
Note:
- Handles zero-length vectors by returning 0
- Uses numerical stability checks to avoid domain errors in acos
- Result is always in the range [-180, 180] degrees
"""
d = hypot(*u) * hypot(*v)
if d == 0:
return 0
c = (u[0] * v[0] + u[1] * v[1]) / d
if c < -1:
c = -1
elif c > 1:
c = 1
s = u[0] * v[1] - u[1] * v[0]
return degrees(copysign(acos(c), s))
def end_point_to_center_parameters(
x1: float,
y1: float,
x2: float,
y2: float,
fA: int,
fS: int,
rx: float,
ry: float,
phi: float = 0,
) -> Tuple[float, float, float, float, float, float]:
"""Convert SVG arc endpoint parameters to center-based representation.
Implements the algorithm from W3C SVG specification for converting
elliptical arc parameters from endpoint format to center format.
This is needed for proper arc rendering in ReportLab.
Args:
x1: X-coordinate of arc start point.
y1: Y-coordinate of arc start point.
x2: X-coordinate of arc end point.
y2: Y-coordinate of arc end point.
fA: Large arc flag (0 or 1).
fS: Sweep flag (0 or 1).
rx: Arc radius in X direction.
ry: Arc radius in Y direction.
phi: Rotation angle of the arc in degrees (default 0).
Returns:
Tuple of (cx, cy, rx, ry, start_angle, sweep_angle):
- cx, cy: Center point coordinates
- rx, ry: Adjusted radii (may be scaled up if too small)
- start_angle: Starting angle in degrees
- sweep_angle: Sweep angle in degrees
Raises:
This function handles all edge cases internally and doesn't raise exceptions.
Examples:
>>> end_point_to_center_parameters(0, 0, 10, 0, 0, 1, 5, 5)
(5.0, 0.0, 5.0, 5.0, 180.0, 180.0)
>>> # Degenerate case - identical points
>>> end_point_to_center_parameters(5, 5, 5, 5, 0, 0, 10, 10)
(5.0, 5.0, 10.0, 10.0, 0.0, 0.0)
See Also:
W3C SVG 1.1 Implementation Notes, Section F.6.5:
http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
Note:
The rotation angle phi is reduced to zero by coordinate transformation
outside this function for simplicity.
"""
rx = fabs(rx)
ry = fabs(ry)
# step 1
if phi:
phi_rad = radians(phi)
sin_phi = sin(phi_rad)
cos_phi = cos(phi_rad)
tx = 0.5 * (x1 - x2)
ty = 0.5 * (y1 - y2)
x1d = cos_phi * tx - sin_phi * ty
y1d = sin_phi * tx + cos_phi * ty
else:
x1d = 0.5 * (x1 - x2)
y1d = 0.5 * (y1 - y2)
# step 2
# we need to calculate
# (rx*rx*ry*ry-rx*rx*y1d*y1d-ry*ry*x1d*x1d)
# -----------------------------------------
# (rx*rx*y1d*y1d+ry*ry*x1d*x1d)
#
# that is equivalent to
#
# rx*rx*ry*ry
# = ----------------------------- - 1
# (rx*rx*y1d*y1d+ry*ry*x1d*x1d)
#
# 1
# = -------------------------------- - 1
# x1d*x1d/(rx*rx) + y1d*y1d/(ry*ry)
#
# = 1/r - 1
#
# it turns out r is what they recommend checking
# for the negative radicand case
r = x1d * x1d / (rx * rx) + y1d * y1d / (ry * ry)
if r > 1:
rr = sqrt(r)
rx *= rr
ry *= rr
r = x1d * x1d / (rx * rx) + y1d * y1d / (ry * ry)
r = 1 / r - 1
elif r != 0:
r = 1 / r - 1
if -1e-10 < r < 0:
r = 0
r = sqrt(r)
if fA == fS:
r = -r
cxd = (r * rx * y1d) / ry
cyd = -(r * ry * x1d) / rx
# step 3
if phi:
cx = cos_phi * cxd - sin_phi * cyd + 0.5 * (x1 + x2)
cy = sin_phi * cxd + cos_phi * cyd + 0.5 * (y1 + y2)
else:
cx = cxd + 0.5 * (x1 + x2)
cy = cyd + 0.5 * (y1 + y2)
# step 4
theta1 = vector_angle((1, 0), ((x1d - cxd) / rx, (y1d - cyd) / ry))
dtheta = (
vector_angle(
((x1d - cxd) / rx, (y1d - cyd) / ry), ((-x1d - cxd) / rx, (-y1d - cyd) / ry)
)
% 360
)
if fS == 0 and dtheta > 0:
dtheta -= 360
elif fS == 1 and dtheta < 0:
dtheta += 360
return cx, cy, rx, ry, -theta1, -dtheta
def bezier_arc_from_centre(
cx: float, cy: float, rx: float, ry: float, start_ang: float = 0, extent: float = 90
) -> List[Tuple[float, float, float, float, float, float, float, float]]:
"""Convert elliptical arc to cubic Bezier curve segments.
Approximates an elliptical arc with cubic Bezier curves using the kappa
constant method. The arc is divided into segments of at most 90 degrees
each for accurate approximation.
Args:
cx: X-coordinate of ellipse center.
cy: Y-coordinate of ellipse center.
rx: Radius in X direction.
ry: Radius in Y direction.
start_ang: Starting angle in degrees (default 0).
extent: Angular extent in degrees (default 90).
Returns:
List of Bezier curve segments, each as an 8-tuple:
(x1, y1, x2, y2, x3, y3, x4, y4) where:
- (x1, y1): Start point
- (x2, y2): First control point
- (x3, y3): Second control point
- (x4, y4): End point
Examples:
>>> # Quarter circle (90 degrees)
>>> curves = bezier_arc_from_centre(0, 0, 10, 10, 0, 90)
>>> len(curves) # One segment for 90 degrees
1
>>> # Half circle (180 degrees) - split into two 90-degree segments
>>> curves = bezier_arc_from_centre(0, 0, 10, 10, 0, 180)
>>> len(curves)
2
>>> # Full circle (360 degrees) - split into four 90-degree segments
>>> curves = bezier_arc_from_centre(0, 0, 10, 10, 0, 360)
>>> len(curves)
4
Note:
- Arcs are automatically subdivided into segments ≤ 90° for accuracy
- Uses the standard kappa = 4/3 * (1 - cos(θ/2)) / sin(θ/2) formula
- Handles both clockwise and counterclockwise arcs
- Returns empty list for zero-extent arcs
"""
if abs(extent) <= 90:
nfrag = 1
frag_angle = extent
else:
nfrag = ceil(abs(extent) / 90)
frag_angle = extent / nfrag
if frag_angle == 0:
return []
frag_rad = radians(frag_angle)
half_rad = frag_rad * 0.5
kappa = abs(4 / 3 * (1 - cos(half_rad)) / sin(half_rad))
if frag_angle < 0:
kappa = -kappa
point_list = []
theta1 = radians(start_ang)
start_rad = theta1 + frag_rad
c1 = cos(theta1)
s1 = sin(theta1)
for i in range(nfrag):
c0 = c1
s0 = s1
theta1 = start_rad + i * frag_rad
c1 = cos(theta1)
s1 = sin(theta1)
point_list.append(
(
cx + rx * c0,
cy - ry * s0,
cx + rx * (c0 - kappa * s0),
cy - ry * (s0 + kappa * c0),
cx + rx * (c1 + kappa * s1),
cy - ry * (s1 - kappa * c1),
cx + rx * c1,
cy - ry * s1,
)
)
return point_list
def bezier_arc_from_end_points(
x1: float,
y1: float,
rx: float,
ry: float,
phi: float,
fA: int,
fS: int,
x2: float,
y2: float,
) -> List[Tuple[float, float, float, float, float, float, float, float]]:
"""Convert SVG elliptical arc to cubic Bezier curve segments.
High-level function that converts SVG elliptical arc parameters (endpoint format)
to a series of cubic Bezier curves. Handles rotation, scaling, and all SVG arc
flags. This is the main entry point for arc-to-Bezier conversion in SVG processing.
Args:
x1: X-coordinate of arc start point.
y1: Y-coordinate of arc start point.
rx: Arc radius in X direction.
ry: Arc radius in Y direction.
phi: Rotation angle of the arc in degrees.
fA: Large arc flag (0 or 1) - chooses larger or smaller arc.
fS: Sweep flag (0 or 1) - chooses clockwise or counterclockwise.
x2: X-coordinate of arc end point.
y2: Y-coordinate of arc end point.
Returns:
List of Bezier curve segments, each as an 8-tuple:
(x1, y1, x2, y2, x3, y3, x4, y4) representing:
- (x1, y1): Start point of segment
- (x2, y2): First control point
- (x3, y3): Second control point
- (x4, y4): End point of segment
Examples:
>>> # Simple 180-degree arc
>>> curves = bezier_arc_from_end_points(0, 0, 10, 10, 0, 0, 1, 20, 0)
>>> len(curves) # Split into segments
2
>>> # Degenerate case - identical points (returns empty list)
>>> curves = bezier_arc_from_end_points(10, 10, 5, 5, 0, 0, 0, 10, 10)
>>> len(curves)
0
>>> # Rotated ellipse arc
>>> curves = bezier_arc_from_end_points(0, 0, 10, 5, 45, 1, 0, 7, 7)
>>> len(curves) # Will be split into multiple segments
2
Note:
- Returns empty list if start and end points are identical
- Automatically handles coordinate transformations for rotation
- Splits arcs into ≤90° segments for accurate Bezier approximation
- Follows W3C SVG 1.1 specification for arc parameter interpretation
"""
if x1 == x2 and y1 == y2:
# From https://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes:
# If the endpoints (x1, y1) and (x2, y2) are identical, then this is
# equivalent to omitting the elliptical arc segment entirely.
return []
if phi:
# Our box bezier arcs can't handle rotations directly
# move to a well known point, eliminate phi and transform the other point
mx = mmult(rotate(-phi), translate(-x1, -y1))
tx2, ty2 = transformPoint(mx, (x2, y2))
# Convert to box form in unrotated coords
cx, cy, rx, ry, start_ang, extent = end_point_to_center_parameters(
0, 0, tx2, ty2, fA, fS, rx, ry
)
bp = bezier_arc_from_centre(cx, cy, rx, ry, start_ang, extent)
# Re-rotate by the desired angle and add back the translation
mx = mmult(translate(x1, y1), rotate(phi))
res = []
for x1, y1, x2, y2, x3, y3, x4, y4 in bp:
res.append(
transformPoint(mx, (x1, y1))
+ transformPoint(mx, (x2, y2))
+ transformPoint(mx, (x3, y3))
+ transformPoint(mx, (x4, y4))
)
return res
else:
cx, cy, rx, ry, start_ang, extent = end_point_to_center_parameters(
x1, y1, x2, y2, fA, fS, rx, ry
)
return bezier_arc_from_centre(cx, cy, rx, ry, start_ang, extent)