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

1"""Utility functions for SVG processing and path manipulation. 

2 

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. 

7 

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""" 

15 

16import re 

17from math import acos, ceil, copysign, cos, degrees, fabs, hypot, radians, sin, sqrt 

18from typing import List, Tuple, Union, cast 

19 

20from reportlab.graphics.shapes import mmult, rotate, transformPoint, translate 

21 

22 

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. 

25 

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. 

29 

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. 

34 

35 Returns: 

36 List alternating between operation strings and coordinate lists. 

37 Example: ['M', [10.0, 20.0], 'L', [30.0, 40.0]] 

38 

39 Examples: 

40 >>> split_floats('M', 2, '10,20 30,40') 

41 ['M', [10.0, 20.0], 'L', [30.0, 40.0]] 

42 

43 >>> split_floats('L', 2, '100 200') 

44 ['L', [100.0, 200.0]] 

45 

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 

61 

62 

63def split_arc_values(op: str, value: str) -> List[Union[str, List[float]]]: 

64 """Parse SVG elliptical arc parameters into structured format. 

65 

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. 

69 

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" 

74 

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. 

79 

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]] 

83 

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]] 

86 

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 

104 

105 

106def normalise_svg_path(attr: str) -> List[Union[str, List[float]]]: 

107 """Normalize SVG path data into structured format. 

108 

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. 

112 

113 Args: 

114 attr: Raw SVG path string (e.g., "M 10 20 L 30 40 Z"). 

115 

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. 

119 

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', []] 

123 

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', []] 

126 

127 >>> normalise_svg_path("m 100,200 300,400") 

128 ['m', [100.0, 200.0], 'l', [300.0, 400.0]] 

129 

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 """ 

135 

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() 

160 

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 

177 

178 return result 

179 

180 

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. 

187 

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. 

191 

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. 

196 

197 Returns: 

198 Tuple of four (x, y) points defining the equivalent cubic Bezier curve: 

199 (start_point, control_point1, control_point2, end_point). 

200 

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)) 

204 

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)) 

208 

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 

218 

219 

220# *********************************************** 

221# Helper functions for elliptical arc conversion. 

222# *********************************************** 

223 

224 

225def vector_angle(u: Tuple[float, float], v: Tuple[float, float]) -> float: 

226 """Calculate the signed angle between two 2D vectors. 

227 

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. 

230 

231 Args: 

232 u: First vector as (x, y) tuple. 

233 v: Second vector as (x, y) tuple. 

234 

235 Returns: 

236 Signed angle in degrees between the vectors, ranging from -180 to 180. 

237 

238 Examples: 

239 >>> vector_angle((1, 0), (0, 1)) # 90 degrees counterclockwise 

240 90.0 

241 

242 >>> vector_angle((1, 0), (0, -1)) # 90 degrees clockwise 

243 -90.0 

244 

245 >>> vector_angle((1, 0), (1, 0)) # Same direction 

246 0.0 

247 

248 >>> vector_angle((1, 0), (-1, 0)) # Opposite direction 

249 180.0 

250 

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)) 

266 

267 

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. 

280 

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. 

284 

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). 

295 

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 

302 

303 Raises: 

304 This function handles all edge cases internally and doesn't raise exceptions. 

305 

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) 

309 

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) 

313 

314 See Also: 

315 W3C SVG 1.1 Implementation Notes, Section F.6.5: 

316 http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes 

317 

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) 

324 

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) 

337 

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 

374 

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) 

382 

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 

396 

397 

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. 

402 

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. 

406 

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). 

414 

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 

422 

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 

428 

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 

433 

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 

438 

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 [] 

453 

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)) 

457 

458 if frag_angle < 0: 

459 kappa = -kappa 

460 

461 point_list = [] 

462 theta1 = radians(start_ang) 

463 start_rad = theta1 + frag_rad 

464 

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 

486 

487 

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. 

500 

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. 

504 

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. 

515 

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 

523 

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 

529 

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 

534 

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 

539 

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)