Coverage for src/svglib/utils.py: 91%
137 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-06-16 15:14 +0200
« prev ^ index » next coverage.py v7.10.6, created at 2026-06-16 15:14 +0200
1"""Utility functions for SVG processing and path manipulation.
3This module provides low-level utility functions used throughout the svglib
4package for processing SVG content. It includes functions for parsing SVG path
5data, converting between different curve representations, and handling
6geometric transformations.
8The module includes:
9- SVG path parsing and normalization
10- Bezier curve conversion utilities
11- Elliptical arc processing functions
12- Vector mathematics helpers
13- String parsing utilities for SVG attributes
14"""
16import re
17from math import acos, ceil, copysign, cos, degrees, fabs, hypot, radians, sin, sqrt
18from typing import List, Tuple, Union, cast
20from reportlab.graphics.shapes import mmult, rotate, transformPoint, translate
23def split_floats(op: str, min_num: int, value: str) -> List[Union[str, List[float]]]:
24 """Parse SVG coordinate string into alternating operators and coordinate lists.
26 Splits a string of numeric values into groups and pairs each group with the
27 appropriate SVG path operation. Automatically converts 'M' to 'L' for subsequent
28 coordinate pairs to handle move-to followed by line-to sequences.
30 Args:
31 op: SVG path operation character (e.g., 'M', 'L', 'm', 'l').
32 min_num: Minimum number of coordinates expected per operation.
33 value: String containing comma/whitespace-separated numeric values.
35 Returns:
36 List alternating between operation strings and coordinate lists.
37 Example: ['M', [10.0, 20.0], 'L', [30.0, 40.0]]
39 Examples:
40 >>> split_floats('M', 2, '10,20 30,40')
41 ['M', [10.0, 20.0], 'L', [30.0, 40.0]]
43 >>> split_floats('L', 2, '100 200')
44 ['L', [100.0, 200.0]]
46 Note:
47 Supports scientific notation (e.g., '1.23e-4') and handles various
48 whitespace and comma separators automatically.
49 """
50 floats = [
51 float(seq)
52 for seq in re.findall(r"(-?\d*\.?\d*(?:[eE][+-]?\d+)?)", value)
53 if seq
54 ]
55 res: List[Union[str, List[float]]] = []
56 for i in range(0, len(floats), min_num):
57 if i > 0 and op in {"m", "M"}:
58 op = "l" if op == "m" else "L"
59 res.extend([op, cast(List[float], list(floats[i : i + min_num]))])
60 return res
63def split_arc_values(op: str, value: str) -> List[Union[str, List[float]]]:
64 """Parse SVG elliptical arc parameters into structured format.
66 Parses SVG arc command parameters which consist of: rx, ry, x-axis-rotation,
67 large-arc-flag, sweep-flag, x, y. Each complete parameter set is paired with
68 the operation character.
70 Args:
71 op: SVG arc operation character ('A' or 'a').
72 value: String containing arc parameters in the format:
73 "rx,ry x-axis-rotation large-arc-flag,sweep-flag x,y"
75 Returns:
76 List alternating between operation strings and parameter lists.
77 Each parameter list contains [rx, ry, x_axis_rotation, large_arc_flag,
78 sweep_flag, x, y] as floats.
80 Examples:
81 >>> split_arc_values('A', '50,50 0 1,0 100,100')
82 ['A', [50.0, 50.0, 0.0, 1.0, 0.0, 100.0, 100.0]]
84 >>> split_arc_values('a', '25 25 30 0 1 50 75')
85 ['a', [25.0, 25.0, 30.0, 0.0, 1.0, 50.0, 75.0]]
87 Note:
88 The large-arc-flag and sweep-flag are converted to float but should
89 be treated as boolean values (0 or 1).
90 """
91 float_re = r"(-?\d*\.?\d*(?:[eE][+-]?\d+)?)"
92 flag_re = r"([1|0])"
93 # 3 numb, 2 flags, 1 coord pair
94 a_seq_re = (
95 r"[\s,]*".join(
96 [float_re, float_re, float_re, flag_re, flag_re, float_re, float_re]
97 )
98 + r"[\s,]*"
99 )
100 res: List[Union[str, List[float]]] = []
101 for seq in re.finditer(a_seq_re, value.strip()):
102 res.extend([op, cast(List[float], list(float(num) for num in seq.groups()))])
103 return res
106def normalise_svg_path(attr: str) -> List[Union[str, List[float]]]:
107 """Normalize SVG path data into structured format.
109 Parses raw SVG path string and converts it into a standardized list format
110 where each path command is paired with its coordinate parameters. Automatically
111 handles command sequences and converts implicit line commands after move commands.
113 Args:
114 attr: Raw SVG path string (e.g., "M 10 20 L 30 40 Z").
116 Returns:
117 Normalized list alternating between command strings and coordinate lists.
118 Close path commands ('Z', 'z') are paired with empty lists for consistency.
120 Examples:
121 >>> normalise_svg_path("M 10 20 L 30 40 Z")
122 ['M', [10.0, 20.0], 'L', [30.0, 40.0], 'Z', []]
124 >>> normalise_svg_path("M 0 0 L 10 0 10 10 Z")
125 ['M', [0.0, 0.0], 'L', [10.0, 0.0], 'L', [10.0, 10.0], 'Z', []]
127 >>> normalise_svg_path("m 100,200 300,400")
128 ['m', [100.0, 200.0], 'l', [300.0, 400.0]]
130 Note:
131 - Handles all SVG path commands: M, L, H, V, C, c, S, s, Q, q, T, t, A, a, Z, z
132 - Supports various whitespace and comma separators
133 - All coordinates are converted to float values
134 """
136 # operator codes mapped to the minimum number of expected arguments
137 ops = {
138 "A": 7,
139 "a": 7,
140 "Q": 4,
141 "q": 4,
142 "T": 2,
143 "t": 2,
144 "S": 4,
145 "s": 4,
146 "M": 2,
147 "L": 2,
148 "m": 2,
149 "l": 2,
150 "H": 1,
151 "V": 1,
152 "h": 1,
153 "v": 1,
154 "C": 6,
155 "c": 6,
156 "Z": 0,
157 "z": 0,
158 }
159 op_keys = ops.keys()
161 result: List[Union[str, List[float]]] = []
162 groups = re.split("([achlmqstvz])", attr.strip(), flags=re.I)
163 op = ""
164 for item in groups:
165 if item.strip() == "":
166 continue
167 if item in op_keys:
168 op = item
169 if ops[op] == 0: # Z, z
170 result.extend([op, []])
171 else:
172 if op.lower() == "a":
173 result.extend(split_arc_values(op, item))
174 else:
175 result.extend(split_floats(op, ops[op], item))
176 op = cast(str, result[-2]) # Remember last op
178 return result
181def convert_quadratic_to_cubic_path(
182 q0: Tuple[float, float], q1: Tuple[float, float], q2: Tuple[float, float]
183) -> Tuple[
184 Tuple[float, float], Tuple[float, float], Tuple[float, float], Tuple[float, float]
185]:
186 """Convert quadratic Bezier curve to cubic Bezier curve.
188 Converts a quadratic Bezier curve defined by control points q0, q1, q2
189 into an equivalent cubic Bezier curve. This is useful for SVG processing
190 since cubic curves are more commonly supported than quadratic curves.
192 Args:
193 q0: Starting point as (x, y) tuple.
194 q1: Control point as (x, y) tuple.
195 q2: End point as (x, y) tuple.
197 Returns:
198 Tuple of four (x, y) points defining the equivalent cubic Bezier curve:
199 (start_point, control_point1, control_point2, end_point).
201 Examples:
202 >>> convert_quadratic_to_cubic_path((0, 0), (5, 10), (10, 0))
203 ((0, 0), (3.3333333333, 6.6666666666), (6.6666666666, 6.6666666666), (10, 0))
205 >>> # Simple case: straight line becomes straight line
206 >>> convert_quadratic_to_cubic_path((0, 0), (5, 5), (10, 10))
207 ((0, 0), (3.3333333333, 3.3333333333), (6.6666666666, 6.6666666666), (10, 10))
209 Note:
210 The conversion uses the standard formula where the cubic control points
211 are calculated as: c1 = q0 + (2/3)*(q1 - q0), c2 = c1 + (1/3)*(q2 - q0).
212 """
213 c0 = q0
214 c1 = (q0[0] + 2 / 3 * (q1[0] - q0[0]), q0[1] + 2 / 3 * (q1[1] - q0[1]))
215 c2 = (c1[0] + 1 / 3 * (q2[0] - q0[0]), c1[1] + 1 / 3 * (q2[1] - q0[1]))
216 c3 = q2
217 return c0, c1, c2, c3
220# ***********************************************
221# Helper functions for elliptical arc conversion.
222# ***********************************************
225def vector_angle(u: Tuple[float, float], v: Tuple[float, float]) -> float:
226 """Calculate the signed angle between two 2D vectors.
228 Computes the angle between vectors u and v using the atan2 method to
229 determine the correct quadrant and sign. Returns angle in degrees.
231 Args:
232 u: First vector as (x, y) tuple.
233 v: Second vector as (x, y) tuple.
235 Returns:
236 Signed angle in degrees between the vectors, ranging from -180 to 180.
238 Examples:
239 >>> vector_angle((1, 0), (0, 1)) # 90 degrees counterclockwise
240 90.0
242 >>> vector_angle((1, 0), (0, -1)) # 90 degrees clockwise
243 -90.0
245 >>> vector_angle((1, 0), (1, 0)) # Same direction
246 0.0
248 >>> vector_angle((1, 0), (-1, 0)) # Opposite direction
249 180.0
251 Note:
252 - Handles zero-length vectors by returning 0
253 - Uses numerical stability checks to avoid domain errors in acos
254 - Result is always in the range [-180, 180] degrees
255 """
256 d = hypot(*u) * hypot(*v)
257 if d == 0:
258 return 0
259 c = (u[0] * v[0] + u[1] * v[1]) / d
260 if c < -1:
261 c = -1
262 elif c > 1:
263 c = 1
264 s = u[0] * v[1] - u[1] * v[0]
265 return degrees(copysign(acos(c), s))
268def end_point_to_center_parameters(
269 x1: float,
270 y1: float,
271 x2: float,
272 y2: float,
273 fA: int,
274 fS: int,
275 rx: float,
276 ry: float,
277 phi: float = 0,
278) -> Tuple[float, float, float, float, float, float]:
279 """Convert SVG arc endpoint parameters to center-based representation.
281 Implements the algorithm from W3C SVG specification for converting
282 elliptical arc parameters from endpoint format to center format.
283 This is needed for proper arc rendering in ReportLab.
285 Args:
286 x1: X-coordinate of arc start point.
287 y1: Y-coordinate of arc start point.
288 x2: X-coordinate of arc end point.
289 y2: Y-coordinate of arc end point.
290 fA: Large arc flag (0 or 1).
291 fS: Sweep flag (0 or 1).
292 rx: Arc radius in X direction.
293 ry: Arc radius in Y direction.
294 phi: Rotation angle of the arc in degrees (default 0).
296 Returns:
297 Tuple of (cx, cy, rx, ry, start_angle, sweep_angle):
298 - cx, cy: Center point coordinates
299 - rx, ry: Adjusted radii (may be scaled up if too small)
300 - start_angle: Starting angle in degrees
301 - sweep_angle: Sweep angle in degrees
303 Raises:
304 This function handles all edge cases internally and doesn't raise exceptions.
306 Examples:
307 >>> end_point_to_center_parameters(0, 0, 10, 0, 0, 1, 5, 5)
308 (5.0, 0.0, 5.0, 5.0, 180.0, 180.0)
310 >>> # Degenerate case - identical points
311 >>> end_point_to_center_parameters(5, 5, 5, 5, 0, 0, 10, 10)
312 (5.0, 5.0, 10.0, 10.0, 0.0, 0.0)
314 See Also:
315 W3C SVG 1.1 Implementation Notes, Section F.6.5:
316 http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
318 Note:
319 The rotation angle phi is reduced to zero by coordinate transformation
320 outside this function for simplicity.
321 """
322 rx = fabs(rx)
323 ry = fabs(ry)
325 # step 1
326 if phi:
327 phi_rad = radians(phi)
328 sin_phi = sin(phi_rad)
329 cos_phi = cos(phi_rad)
330 tx = 0.5 * (x1 - x2)
331 ty = 0.5 * (y1 - y2)
332 x1d = cos_phi * tx - sin_phi * ty
333 y1d = sin_phi * tx + cos_phi * ty
334 else:
335 x1d = 0.5 * (x1 - x2)
336 y1d = 0.5 * (y1 - y2)
338 # step 2
339 # we need to calculate
340 # (rx*rx*ry*ry-rx*rx*y1d*y1d-ry*ry*x1d*x1d)
341 # -----------------------------------------
342 # (rx*rx*y1d*y1d+ry*ry*x1d*x1d)
343 #
344 # that is equivalent to
345 #
346 # rx*rx*ry*ry
347 # = ----------------------------- - 1
348 # (rx*rx*y1d*y1d+ry*ry*x1d*x1d)
349 #
350 # 1
351 # = -------------------------------- - 1
352 # x1d*x1d/(rx*rx) + y1d*y1d/(ry*ry)
353 #
354 # = 1/r - 1
355 #
356 # it turns out r is what they recommend checking
357 # for the negative radicand case
358 r = x1d * x1d / (rx * rx) + y1d * y1d / (ry * ry)
359 if r > 1:
360 rr = sqrt(r)
361 rx *= rr
362 ry *= rr
363 r = x1d * x1d / (rx * rx) + y1d * y1d / (ry * ry)
364 r = 1 / r - 1
365 elif r != 0:
366 r = 1 / r - 1
367 if -1e-10 < r < 0:
368 r = 0
369 r = sqrt(r)
370 if fA == fS:
371 r = -r
372 cxd = (r * rx * y1d) / ry
373 cyd = -(r * ry * x1d) / rx
375 # step 3
376 if phi:
377 cx = cos_phi * cxd - sin_phi * cyd + 0.5 * (x1 + x2)
378 cy = sin_phi * cxd + cos_phi * cyd + 0.5 * (y1 + y2)
379 else:
380 cx = cxd + 0.5 * (x1 + x2)
381 cy = cyd + 0.5 * (y1 + y2)
383 # step 4
384 theta1 = vector_angle((1, 0), ((x1d - cxd) / rx, (y1d - cyd) / ry))
385 dtheta = (
386 vector_angle(
387 ((x1d - cxd) / rx, (y1d - cyd) / ry), ((-x1d - cxd) / rx, (-y1d - cyd) / ry)
388 )
389 % 360
390 )
391 if fS == 0 and dtheta > 0:
392 dtheta -= 360
393 elif fS == 1 and dtheta < 0:
394 dtheta += 360
395 return cx, cy, rx, ry, -theta1, -dtheta
398def bezier_arc_from_centre(
399 cx: float, cy: float, rx: float, ry: float, start_ang: float = 0, extent: float = 90
400) -> List[Tuple[float, float, float, float, float, float, float, float]]:
401 """Convert elliptical arc to cubic Bezier curve segments.
403 Approximates an elliptical arc with cubic Bezier curves using the kappa
404 constant method. The arc is divided into segments of at most 90 degrees
405 each for accurate approximation.
407 Args:
408 cx: X-coordinate of ellipse center.
409 cy: Y-coordinate of ellipse center.
410 rx: Radius in X direction.
411 ry: Radius in Y direction.
412 start_ang: Starting angle in degrees (default 0).
413 extent: Angular extent in degrees (default 90).
415 Returns:
416 List of Bezier curve segments, each as an 8-tuple:
417 (x1, y1, x2, y2, x3, y3, x4, y4) where:
418 - (x1, y1): Start point
419 - (x2, y2): First control point
420 - (x3, y3): Second control point
421 - (x4, y4): End point
423 Examples:
424 >>> # Quarter circle (90 degrees)
425 >>> curves = bezier_arc_from_centre(0, 0, 10, 10, 0, 90)
426 >>> len(curves) # One segment for 90 degrees
427 1
429 >>> # Half circle (180 degrees) - split into two 90-degree segments
430 >>> curves = bezier_arc_from_centre(0, 0, 10, 10, 0, 180)
431 >>> len(curves)
432 2
434 >>> # Full circle (360 degrees) - split into four 90-degree segments
435 >>> curves = bezier_arc_from_centre(0, 0, 10, 10, 0, 360)
436 >>> len(curves)
437 4
439 Note:
440 - Arcs are automatically subdivided into segments ≤ 90° for accuracy
441 - Uses the standard kappa = 4/3 * (1 - cos(θ/2)) / sin(θ/2) formula
442 - Handles both clockwise and counterclockwise arcs
443 - Returns empty list for zero-extent arcs
444 """
445 if abs(extent) <= 90:
446 nfrag = 1
447 frag_angle = extent
448 else:
449 nfrag = ceil(abs(extent) / 90)
450 frag_angle = extent / nfrag
451 if frag_angle == 0:
452 return []
454 frag_rad = radians(frag_angle)
455 half_rad = frag_rad * 0.5
456 kappa = abs(4 / 3 * (1 - cos(half_rad)) / sin(half_rad))
458 if frag_angle < 0:
459 kappa = -kappa
461 point_list = []
462 theta1 = radians(start_ang)
463 start_rad = theta1 + frag_rad
465 c1 = cos(theta1)
466 s1 = sin(theta1)
467 for i in range(nfrag):
468 c0 = c1
469 s0 = s1
470 theta1 = start_rad + i * frag_rad
471 c1 = cos(theta1)
472 s1 = sin(theta1)
473 point_list.append(
474 (
475 cx + rx * c0,
476 cy - ry * s0,
477 cx + rx * (c0 - kappa * s0),
478 cy - ry * (s0 + kappa * c0),
479 cx + rx * (c1 + kappa * s1),
480 cy - ry * (s1 - kappa * c1),
481 cx + rx * c1,
482 cy - ry * s1,
483 )
484 )
485 return point_list
488def bezier_arc_from_end_points(
489 x1: float,
490 y1: float,
491 rx: float,
492 ry: float,
493 phi: float,
494 fA: int,
495 fS: int,
496 x2: float,
497 y2: float,
498) -> List[Tuple[float, float, float, float, float, float, float, float]]:
499 """Convert SVG elliptical arc to cubic Bezier curve segments.
501 High-level function that converts SVG elliptical arc parameters (endpoint format)
502 to a series of cubic Bezier curves. Handles rotation, scaling, and all SVG arc
503 flags. This is the main entry point for arc-to-Bezier conversion in SVG processing.
505 Args:
506 x1: X-coordinate of arc start point.
507 y1: Y-coordinate of arc start point.
508 rx: Arc radius in X direction.
509 ry: Arc radius in Y direction.
510 phi: Rotation angle of the arc in degrees.
511 fA: Large arc flag (0 or 1) - chooses larger or smaller arc.
512 fS: Sweep flag (0 or 1) - chooses clockwise or counterclockwise.
513 x2: X-coordinate of arc end point.
514 y2: Y-coordinate of arc end point.
516 Returns:
517 List of Bezier curve segments, each as an 8-tuple:
518 (x1, y1, x2, y2, x3, y3, x4, y4) representing:
519 - (x1, y1): Start point of segment
520 - (x2, y2): First control point
521 - (x3, y3): Second control point
522 - (x4, y4): End point of segment
524 Examples:
525 >>> # Simple 180-degree arc
526 >>> curves = bezier_arc_from_end_points(0, 0, 10, 10, 0, 0, 1, 20, 0)
527 >>> len(curves) # Split into segments
528 2
530 >>> # Degenerate case - identical points (returns empty list)
531 >>> curves = bezier_arc_from_end_points(10, 10, 5, 5, 0, 0, 0, 10, 10)
532 >>> len(curves)
533 0
535 >>> # Rotated ellipse arc
536 >>> curves = bezier_arc_from_end_points(0, 0, 10, 5, 45, 1, 0, 7, 7)
537 >>> len(curves) # Will be split into multiple segments
538 2
540 Note:
541 - Returns empty list if start and end points are identical
542 - Automatically handles coordinate transformations for rotation
543 - Splits arcs into ≤90° segments for accurate Bezier approximation
544 - Follows W3C SVG 1.1 specification for arc parameter interpretation
545 """
546 if x1 == x2 and y1 == y2:
547 # From https://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes:
548 # If the endpoints (x1, y1) and (x2, y2) are identical, then this is
549 # equivalent to omitting the elliptical arc segment entirely.
550 return []
551 if phi:
552 # Our box bezier arcs can't handle rotations directly
553 # move to a well known point, eliminate phi and transform the other point
554 mx = mmult(rotate(-phi), translate(-x1, -y1))
555 tx2, ty2 = transformPoint(mx, (x2, y2))
556 # Convert to box form in unrotated coords
557 cx, cy, rx, ry, start_ang, extent = end_point_to_center_parameters(
558 0, 0, tx2, ty2, fA, fS, rx, ry
559 )
560 bp = bezier_arc_from_centre(cx, cy, rx, ry, start_ang, extent)
561 # Re-rotate by the desired angle and add back the translation
562 mx = mmult(translate(x1, y1), rotate(phi))
563 res = []
564 for x1, y1, x2, y2, x3, y3, x4, y4 in bp:
565 res.append(
566 transformPoint(mx, (x1, y1))
567 + transformPoint(mx, (x2, y2))
568 + transformPoint(mx, (x3, y3))
569 + transformPoint(mx, (x4, y4))
570 )
571 return res
572 else:
573 cx, cy, rx, ry, start_ang, extent = end_point_to_center_parameters(
574 x1, y1, x2, y2, fA, fS, rx, ry
575 )
576 return bezier_arc_from_centre(cx, cy, rx, ry, start_ang, extent)