Coverage for src/svglib/svglib.py: 93%

1353 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-06-16 15:14 +0200

1"""A library for reading and converting SVG files. 

2 

3This module provides a converter from SVG to ReportLab Graphics (RLG) drawings. 

4It handles basic shapes, paths, and simple text elements. 

5 

6The intended usage is either as a module within other projects or from the 

7command-line for converting SVG files to PDF. It also supports gzip-compressed 

8SVG files with the .svgz extension. 

9 

10Example: 

11 To convert an SVG file to a ReportLab Drawing object:: 

12 

13 from svglib.svglib import svg2rlg 

14 drawing = svg2rlg("foo.svg") 

15 

16 To convert an SVG file to a PDF from the command-line:: 

17 

18 $ python -m svglib foo.svg 

19""" 

20 

21import base64 

22import copy 

23import gzip 

24import itertools 

25import locale 

26import logging 

27import os 

28import pathlib 

29import re 

30import shlex 

31import shutil 

32import sys 

33from collections import defaultdict, namedtuple 

34from io import BytesIO 

35from typing import Any, BinaryIO, Dict, List, Optional, TextIO, Tuple, Union 

36 

37from PIL import Image as PILImage 

38from reportlab.graphics.shapes import ( 

39 _CLOSEPATH, 

40 Circle, 

41 DirectDraw, 

42 Drawing, 

43 Ellipse, 

44 Group, 

45 Image, 

46 Line, 

47 Path, 

48 Polygon, 

49 PolyLine, 

50 Rect, 

51 SolidShape, 

52 String, 

53 _renderPath, 

54) 

55from reportlab.lib import colors 

56from reportlab.lib.units import toLength 

57from reportlab.pdfbase.pdfmetrics import stringWidth 

58from reportlab.pdfgen.canvas import FILL_EVEN_ODD, FILL_NON_ZERO 

59from reportlab.pdfgen.pdfimages import PDFImage 

60 

61try: 

62 from reportlab.graphics.transform import mmult 

63except ImportError: 

64 # Before Reportlab 3.5.61 

65 from reportlab.graphics.shapes import mmult 

66 

67import cssselect2 

68import tinycss2 

69from lxml import etree 

70 

71from .fonts import ( 

72 DEFAULT_FONT_NAME, 

73 DEFAULT_FONT_SIZE, 

74 DEFAULT_FONT_STYLE, 

75 DEFAULT_FONT_WEIGHT, 

76 get_global_font_map, 

77) 

78from .fonts import ( 

79 find_font as _fonts_find_font, 

80) 

81 

82# To keep backward compatibility, since those functions where previously part of 

83# the svglib module 

84from .fonts import ( 

85 register_font as _fonts_register_font, 

86) 

87from .utils import ( 

88 bezier_arc_from_end_points, 

89 convert_quadratic_to_cubic_path, 

90 normalise_svg_path, 

91) 

92 

93# SVG user units are px; ReportLab works in points. 1 px = 0.75 pt (96 dpi / 72 dpi). 

94PX_TO_PT = 0.75 

95 

96 

97def _convert_palette_to_rgba(image: PILImage.Image) -> PILImage.Image: 

98 """Convert a palette-based image with transparency to RGBA format. 

99 

100 This function checks if a PIL Image is in palette mode ('P') and has 

101 transparency information. If so, it converts the image to RGBA to prevent 

102 potential warnings or errors during processing. 

103 

104 Args: 

105 image: The input PIL Image object. 

106 

107 Returns: 

108 The converted RGBA PIL Image object if changes were made, otherwise 

109 the original image. 

110 """ 

111 if image.mode == "P" and "transparency" in image.info: 

112 # Convert palette image with transparency to RGBA 

113 return image.convert("RGBA") 

114 return image 

115 

116 

117def register_font( 

118 font_name: str, 

119 font_path: Optional[str] = None, 

120 weight: str = "normal", 

121 style: str = "normal", 

122 rlgFontName: Optional[str] = None, 

123) -> Tuple[Optional[str], bool]: 

124 """Register a font for use in SVG processing. 

125 

126 This function serves as a backward-compatible wrapper for the font 

127 registration logic defined in the `svglib.fonts` module. 

128 

129 Args: 

130 font_name: The name of the font to register. 

131 font_path: The file path to the font file (optional). 

132 weight: The font weight (e.g., 'normal', 'bold'). 

133 style: The font style (e.g., 'normal', 'italic'). 

134 rlgFontName: The ReportLab-specific font name (optional). 

135 

136 Returns: 

137 A tuple containing the registered font name and a boolean indicating 

138 if the registration was successful. 

139 """ 

140 return _fonts_register_font(font_name, font_path, weight, style, rlgFontName) 

141 

142 

143def find_font( 

144 font_name: str, weight: str = "normal", style: str = "normal" 

145) -> Tuple[str, bool]: 

146 """Find a registered font by its properties. 

147 

148 This function serves as a backward-compatible wrapper for the font 

149 finding logic defined in the `svglib.fonts` module. 

150 

151 Args: 

152 font_name: The name of the font to find. 

153 weight: The font weight to match. 

154 style: The font style to match. 

155 

156 Returns: 

157 A tuple containing the matched font name and a boolean indicating 

158 if an exact match was found. 

159 """ 

160 return _fonts_find_font(font_name, weight, style) 

161 

162 

163XML_NS = "http://www.w3.org/XML/1998/namespace" 

164INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape" 

165 

166# A sentinel to identify a situation where a node reference a fragment not yet defined. 

167DELAYED = object() 

168 

169logger = logging.getLogger(__name__) 

170 

171Box = namedtuple("Box", ["x", "y", "width", "height"]) 

172 

173# SVG feature strings supported by svglib, used for <switch> evaluation. 

174# Stored in lowercase for case-insensitive matching. 

175# SVG 1.1: https://www.w3.org/TR/SVG11/feature.html 

176# SVG 1.2 Tiny: https://www.w3.org/TR/SVGTiny12/feature.html 

177SUPPORTED_SVG_FEATURES = frozenset( 

178 f.lower() 

179 for f in [ 

180 # SVG 1.1 features 

181 "http://www.w3.org/TR/SVG11/feature#SVG", 

182 "http://www.w3.org/TR/SVG11/feature#SVGDOM", 

183 "http://www.w3.org/TR/SVG11/feature#SVG-static", 

184 "http://www.w3.org/TR/SVG11/feature#SVGDOM-static", 

185 "http://www.w3.org/TR/SVG11/feature#CoreAttribute", 

186 "http://www.w3.org/TR/SVG11/feature#Structure", 

187 "http://www.w3.org/TR/SVG11/feature#BasicStructure", 

188 "http://www.w3.org/TR/SVG11/feature#ConditionalProcessing", 

189 "http://www.w3.org/TR/SVG11/feature#Style", 

190 "http://www.w3.org/TR/SVG11/feature#ViewportAttribute", 

191 "http://www.w3.org/TR/SVG11/feature#Shape", 

192 "http://www.w3.org/TR/SVG11/feature#BasicText", 

193 "http://www.w3.org/TR/SVG11/feature#Image", 

194 "http://www.w3.org/TR/SVG11/feature#BasicPaintAttribute", 

195 "http://www.w3.org/TR/SVG11/feature#OpacityAttribute", 

196 "http://www.w3.org/TR/SVG11/feature#BasicGraphicsAttribute", 

197 "http://www.w3.org/TR/SVG11/feature#Clip", 

198 "http://www.w3.org/TR/SVG11/feature#BasicClip", 

199 "http://www.w3.org/TR/SVG11/feature#ColorProfile", 

200 "http://www.w3.org/TR/SVG11/feature#Gradient", 

201 "http://www.w3.org/TR/SVG11/feature#Pattern", 

202 "http://www.w3.org/TR/SVG11/feature#XlinkAttribute", 

203 "http://www.w3.org/TR/SVG11/feature#Font", 

204 "http://www.w3.org/TR/SVG11/feature#BasicFont", 

205 "http://www.w3.org/TR/SVG11/feature#PaintAttribute", 

206 "http://www.w3.org/TR/SVG11/feature#GraphicsAttribute", 

207 # SVG 1.2 Tiny features 

208 "http://www.w3.org/Graphics/SVG/feature/1.2/#SVG-static", 

209 "http://www.w3.org/Graphics/SVG/feature/1.2/#CoreAttribute", 

210 "http://www.w3.org/Graphics/SVG/feature/1.2/#Structure", 

211 "http://www.w3.org/Graphics/SVG/feature/1.2/#ConditionalProcessing", 

212 "http://www.w3.org/Graphics/SVG/feature/1.2/#ConditionalProcessingAttribute", 

213 "http://www.w3.org/Graphics/SVG/feature/1.2/#Image", 

214 "http://www.w3.org/Graphics/SVG/feature/1.2/#Shape", 

215 "http://www.w3.org/Graphics/SVG/feature/1.2/#Text", 

216 "http://www.w3.org/Graphics/SVG/feature/1.2/#PaintAttribute", 

217 "http://www.w3.org/Graphics/SVG/feature/1.2/#OpacityAttribute", 

218 "http://www.w3.org/Graphics/SVG/feature/1.2/#GraphicsAttribute", 

219 "http://www.w3.org/Graphics/SVG/feature/1.2/#Gradient", 

220 "http://www.w3.org/Graphics/SVG/feature/1.2/#XlinkAttribute", 

221 "http://www.w3.org/Graphics/SVG/feature/1.2/#Font", 

222 "http://www.w3.org/Graphics/SVG/feature/1.2/#Hyperlinking", 

223 "http://www.w3.org/Graphics/SVG/feature/1.2/#ExternalResourcesRequired", 

224 "http://www.w3.org/Graphics/SVG/feature/1.2/#NavigationAttribute", 

225 ] 

226) 

227 

228split_whitespace = re.compile(r"[^ \t\r\n\f]+").findall 

229 

230 

231class NoStrokePath(Path): 

232 """A Path object that never has a stroke width. 

233 

234 This class is used to create filled shapes from unclosed paths, where 

235 only the fill should be rendered and the stroke should be ignored. 

236 """ 

237 

238 def __init__(self, *args: Any, **kwargs: Any) -> None: 

239 copy_from = kwargs.pop("copy_from", None) 

240 super().__init__(*args, **kwargs) 

241 if copy_from: 

242 self.__dict__.update(copy.deepcopy(copy_from.__dict__)) 

243 

244 def getProperties(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: 

245 """Return the properties of the path, ensuring no stroke is applied.""" 

246 props = super().getProperties(*args, **kwargs) 

247 if "strokeWidth" in props: 

248 props["strokeWidth"] = 0 

249 if "strokeColor" in props: 

250 props["strokeColor"] = None 

251 return props 

252 

253 

254class ClippingPath(Path): 

255 """A Path object used for defining a clipping region. 

256 

257 This path will not be rendered with a fill or stroke but will be used 

258 as a clipping mask for other shapes. 

259 """ 

260 

261 def __init__(self, *args: Any, **kwargs: Any) -> None: 

262 copy_from = kwargs.pop("copy_from", None) 

263 Path.__init__(self, *args, **kwargs) 

264 if copy_from: 

265 self.__dict__.update(copy.deepcopy(copy_from.__dict__)) 

266 self.isClipPath = 1 

267 

268 def getProperties(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: 

269 """Return the properties of the path, ensuring no fill or stroke.""" 

270 props = Path.getProperties(self, *args, **kwargs) 

271 if "fillColor" in props: 

272 props["fillColor"] = None 

273 if "strokeColor" in props: 

274 props["strokeColor"] = None 

275 return props 

276 

277 

278class CSSMatcher(cssselect2.Matcher): 

279 """A CSS matcher to handle styles defined in SVG <style> elements.""" 

280 

281 def add_styles(self, style_content: str) -> None: 

282 """Parse a string of CSS rules and add them to the matcher. 

283 

284 Args: 

285 style_content: A string containing CSS rules. 

286 """ 

287 rules = tinycss2.parse_stylesheet( 

288 style_content, skip_comments=True, skip_whitespace=True 

289 ) 

290 

291 for rule in rules: 

292 if not rule.prelude or rule.type == "at-rule": 

293 continue 

294 selectors = cssselect2.compile_selector_list(rule.prelude) 

295 selector_string = tinycss2.serialize(rule.prelude) 

296 content_dict = { 

297 attr.split(":")[0].strip(): attr.split(":")[1].strip() 

298 for attr in tinycss2.serialize(rule.content).split(";") 

299 if ":" in attr 

300 } 

301 payload = (selector_string, content_dict) 

302 for selector in selectors: 

303 self.add_selector(selector, payload) 

304 

305 

306# Attribute converters (from SVG to RLG) 

307 

308 

309class AttributeConverter: 

310 """An abstract class for converting SVG attributes to ReportLab properties.""" 

311 

312 def __init__(self) -> None: 

313 self.css_rules: Optional[CSSMatcher] = None 

314 self.main_box: Optional[Box] = None 

315 # Resolved once at render time from the root <svg> font-size; used by rem. 

316 self.root_font_size: float = DEFAULT_FONT_SIZE / PX_TO_PT # 16 px default 

317 

318 def set_box(self, main_box: Optional[Box]) -> None: 

319 """Set the main viewbox for resolving percentage-based units. 

320 

321 Args: 

322 main_box: A Box tuple representing the main viewbox. 

323 """ 

324 self.main_box = main_box 

325 

326 def parseMultiAttributes(self, line: str) -> Dict[str, str]: 

327 """Parse a compound attribute string into a dictionary. 

328 

329 Args: 

330 line: A string of semicolon-separated style attributes. 

331 

332 Returns: 

333 A dictionary of attribute key-value pairs. 

334 """ 

335 attrs = line.split(";") 

336 attrs = [a.strip() for a in attrs] 

337 attrs = [a for a in attrs if len(a) > 0] 

338 

339 new_attrs = {} 

340 for a in attrs: 

341 k, v = a.split(":") 

342 k, v = (s.strip() for s in (k, v)) 

343 new_attrs[k] = v 

344 

345 return new_attrs 

346 

347 def findAttr(self, svgNode: Any, name: str) -> str: 

348 """Find an attribute value, searching the node and its ancestors. 

349 

350 The search order is: 

351 1. The node's own attributes. 

352 2. The node's 'style' attribute. 

353 3. The node's parent, recursively. 

354 

355 Args: 

356 svgNode: The lxml node to start the search from. 

357 name: The name of the attribute to find. 

358 

359 Returns: 

360 The attribute value, or an empty string if not found. 

361 """ 

362 if not svgNode.attrib.get("__rules_applied", False): 

363 # Apply global styles... 

364 if self.css_rules is not None: 

365 svgNode.apply_rules(self.css_rules) 

366 # ...and locally defined 

367 if svgNode.attrib.get("style"): 

368 attrs = self.parseMultiAttributes(svgNode.attrib.get("style")) 

369 for key, val in attrs.items(): 

370 # lxml nodes cannot accept attributes starting with '-' 

371 if not key.startswith("-"): 

372 svgNode.attrib[key] = val 

373 svgNode.attrib["__rules_applied"] = "1" 

374 

375 attr_value = svgNode.attrib.get(name, "").strip() 

376 

377 if attr_value and attr_value != "inherit": 

378 return attr_value 

379 if svgNode.parent is not None: 

380 return self.findAttr(svgNode.parent, name) 

381 return "" 

382 

383 def getAllAttributes(self, svgNode: Any) -> Dict[str, str]: 

384 """Return a dictionary of all attributes of a node and its ancestors. 

385 

386 Args: 

387 svgNode: The lxml node to get attributes from. 

388 

389 Returns: 

390 A dictionary of all applicable attributes. 

391 """ 

392 dict = {} 

393 

394 if node_name(svgNode.getparent()) == "g": 

395 dict.update(self.getAllAttributes(svgNode.getparent())) 

396 

397 style = svgNode.attrib.get("style") 

398 if style: 

399 d = self.parseMultiAttributes(style) 

400 dict.update(d) 

401 

402 for key, value in svgNode.attrib.items(): 

403 if key != "style": 

404 dict[key] = value 

405 

406 return dict 

407 

408 def id(self, svgAttr: str) -> str: 

409 """Return the attribute value as is.""" 

410 return svgAttr 

411 

412 def convertTransform( 

413 self, 

414 svgAttr: str, 

415 ) -> List[Tuple[str, Union[float, Tuple[float, ...]]]]: 

416 """Parse a transform attribute string into a list of operations. 

417 

418 Args: 

419 svgAttr: The SVG transform attribute string. 

420 

421 Returns: 

422 A list of tuples, where each tuple contains the transform 

423 operation and its arguments. 

424 

425 Example: 

426 >>> converter = AttributeConverter() 

427 >>> converter.convertTransform("scale(2) translate(10,20)") 

428 [('scale', 2.0), ('translate', (10.0, 20.0))] 

429 """ 

430 line = svgAttr.strip() 

431 

432 ops: str = line[:] 

433 brackets: List[int] = [] 

434 indices: List[Union[float, Tuple[float, ...]]] = [] 

435 for i, lin in enumerate(line): 

436 if lin in "()": 

437 brackets.append(i) 

438 for i in range(0, len(brackets), 2): 

439 bi, bj = brackets[i], brackets[i + 1] 

440 subline = line[bi + 1 : bj] 

441 subline = subline.strip() 

442 subline = subline.replace(",", " ") 

443 subline = re.sub("[ ]+", ",", subline) 

444 try: 

445 if "," in subline: 

446 indices.append(tuple(float(num) for num in subline.split(","))) 

447 else: 

448 indices.append(float(subline)) 

449 except ValueError: 

450 continue 

451 ops = ops[:bi] + " " * (bj - bi + 1) + ops[bj + 1 :] 

452 ops_list: List[str] = ops.replace(",", " ").split() 

453 

454 if len(ops_list) != len(indices): 

455 logger.warning("Unable to parse transform expression %r", svgAttr) 

456 return [] 

457 

458 result = [] 

459 for i, op in enumerate(ops_list): 

460 result.append((op, indices[i])) 

461 

462 return result 

463 

464 

465class Svg2RlgAttributeConverter(AttributeConverter): 

466 """A concrete attribute converter for SVG to ReportLab Graphics.""" 

467 

468 def __init__( 

469 self, 

470 color_converter: Optional[Any] = None, 

471 font_map: Optional[Any] = None, 

472 ) -> None: 

473 super().__init__() 

474 self.color_converter = color_converter or self.identity_color_converter 

475 self._font_map = font_map or get_global_font_map() 

476 

477 @staticmethod 

478 def identity_color_converter(c: Any) -> Any: 

479 """A default color converter that returns the color as is.""" 

480 return c 

481 

482 @staticmethod 

483 def split_attr_list(attr: str) -> List[str]: 

484 """Split a string of attributes into a list.""" 

485 return shlex.split(attr.strip().replace(",", " ")) 

486 

487 def convertLength( 

488 self, 

489 svgAttr: str, 

490 em_base: float = DEFAULT_FONT_SIZE / PX_TO_PT, 

491 attr_name: Optional[str] = None, 

492 default: float = 0.0, 

493 ) -> Union[float, List[float]]: 

494 """Convert an SVG length string to user units (px). 

495 

496 Args: 

497 svgAttr: The SVG length string (e.g., "10px", "5em"). 

498 em_base: The base font size for 'em' units. 

499 attr_name: The name of the attribute being converted. 

500 default: The default value to return if the string is empty. 

501 

502 Returns: 

503 The length in points as a float, or a list of floats for 

504 space-separated values. 

505 """ 

506 text = svgAttr.replace(",", " ").strip() 

507 if not text: 

508 return default 

509 if " " in text: 

510 # Multiple length values, returning a list 

511 items = [ 

512 self.convertLength( 

513 val, em_base=em_base, attr_name=attr_name, default=default 

514 ) 

515 for val in self.split_attr_list(text) 

516 ] 

517 result: List[float] = [] 

518 for item in items: 

519 if isinstance(item, list): 

520 result.extend(item) 

521 else: 

522 result.append(item) 

523 return result 

524 

525 if text.endswith("%"): 

526 if self.main_box is None: 

527 logger.error("Unable to resolve percentage unit without a main box") 

528 return float(text[:-1]) 

529 if attr_name is None: 

530 logger.error( 

531 "Unable to resolve percentage unit without knowing the node name" 

532 ) 

533 return float(text[:-1]) 

534 if attr_name in ("x", "cx", "x1", "x2", "width"): 

535 full = self.main_box.width 

536 elif attr_name in ("y", "cy", "y1", "y2", "height"): 

537 full = self.main_box.height 

538 else: 

539 logger.error( 

540 "Unable to detect if node %r is width or height", attr_name 

541 ) 

542 return float(text[:-1]) 

543 return float(text[:-1]) / 100 * full 

544 elif text.endswith("pc"): 

545 # 1 pc = 12 pt = 16 px user units 

546 return float(text[:-2]) * 16 

547 elif text.endswith("pt"): 

548 # 1 pt = 1/72 in = 96/72 px user units 

549 return float(text[:-2]) * (96 / 72) 

550 elif text.endswith("rem"): 

551 return float(text[:-3]) * self.root_font_size 

552 elif text.endswith("em"): 

553 return float(text[:-2]) * em_base 

554 elif text.endswith("vmin"): 

555 if self.main_box is None: 

556 logger.error("Unable to resolve vmin unit without a main box") 

557 return default 

558 return ( 

559 float(text[:-4]) / 100 * min(self.main_box.width, self.main_box.height) 

560 ) 

561 elif text.endswith("vmax"): 

562 if self.main_box is None: 

563 logger.error("Unable to resolve vmax unit without a main box") 

564 return default 

565 return ( 

566 float(text[:-4]) / 100 * max(self.main_box.width, self.main_box.height) 

567 ) 

568 elif text.endswith("vw"): 

569 if self.main_box is None: 

570 logger.error("Unable to resolve vw unit without a main box") 

571 return default 

572 return float(text[:-2]) / 100 * self.main_box.width 

573 elif text.endswith("vh"): 

574 if self.main_box is None: 

575 logger.error("Unable to resolve vh unit without a main box") 

576 return default 

577 return float(text[:-2]) / 100 * self.main_box.height 

578 elif text.endswith("q"): 

579 # 1q = 0.25 mm 

580 return float(text[:-1]) * toLength("1mm") / (4 * PX_TO_PT) 

581 elif text.endswith("px"): 

582 # px are user units (1:1) 

583 return float(text[:-2]) 

584 elif text.endswith("ex"): 

585 # The x-height of the text must be assumed to be 0.5em tall when the 

586 # text cannot be measured. 

587 return float(text[:-2]) * em_base / 2 

588 elif text.endswith("ch"): 

589 # The advance measure of the "0" glyph must be assumed to be 0.5em 

590 # wide when the text cannot be measured. 

591 return float(text[:-2]) * em_base / 2 

592 

593 text = text.strip() 

594 try: 

595 # Bare numbers are user units (= px, SVG spec §5.9.2) — return as-is. 

596 return float(text) 

597 except ValueError: 

598 pass 

599 # toLength handles mm, cm, in, etc. and returns points; convert to user units. 

600 return toLength(text) / PX_TO_PT 

601 

602 def convertLengthList(self, svgAttr: str) -> List[Union[float, List[float]]]: 

603 """Convert a space-separated list of lengths into a list of floats.""" 

604 return [self.convertLength(a) for a in self.split_attr_list(svgAttr)] 

605 

606 def convertLengthToPt( 

607 self, 

608 svgAttr: str, 

609 em_base: float = DEFAULT_FONT_SIZE / PX_TO_PT, 

610 attr_name: Optional[str] = None, 

611 default: float = 0.0, 

612 ) -> Union[float, List[float]]: 

613 """Convert an SVG length string to points (user units × PX_TO_PT).""" 

614 result = self.convertLength( 

615 svgAttr, em_base=em_base, attr_name=attr_name, default=default 

616 ) 

617 if isinstance(result, list): 

618 return [v * PX_TO_PT for v in result] 

619 return result * PX_TO_PT 

620 

621 def convertOpacity(self, svgAttr: str) -> float: 

622 """Convert an opacity string to a float.""" 

623 return float(svgAttr) 

624 

625 def convertFillRule(self, svgAttr: str) -> Union[int, str]: 

626 """Convert an SVG fill-rule string to a ReportLab fill rule.""" 

627 return { 

628 "nonzero": FILL_NON_ZERO, 

629 "evenodd": FILL_EVEN_ODD, 

630 }.get(svgAttr, "") 

631 

632 def convertColor(self, svgAttr: str) -> Any: 

633 """Convert an SVG color string to a ReportLab color object. 

634 

635 Args: 

636 svgAttr: The SVG color string (e.g., "#FF0000", "blue"). 

637 

638 Returns: 

639 A ReportLab color object, or None if the color is invalid. 

640 """ 

641 text = svgAttr 

642 if not text or text == "none": 

643 return None 

644 

645 # Gradient/pattern references are handled at the renderer level. 

646 if text.startswith("url("): 

647 return None 

648 

649 if text == "currentColor": 

650 return "currentColor" 

651 if len(text) in (7, 9) and text[0] == "#": 

652 color = colors.HexColor(text, hasAlpha=len(text) == 9) 

653 elif len(text) == 4 and text[0] == "#": 

654 color = colors.HexColor("#" + 2 * text[1] + 2 * text[2] + 2 * text[3]) 

655 elif len(text) == 5 and text[0] == "#": 

656 color = colors.HexColor( 

657 "#" + 2 * text[1] + 2 * text[2] + 2 * text[3] + 2 * text[4], 

658 hasAlpha=True, 

659 ) 

660 else: 

661 # Should handle pcmyk|cmyk|rgb|hsl values (including 'a' for alpha) 

662 color = colors.cssParse(text) 

663 if color is None: 

664 # Test if text is a predefined color constant 

665 try: 

666 color = getattr(colors, text).clone() 

667 except AttributeError: 

668 pass 

669 if color is None: 

670 logger.warning("Can't handle color: %s", text) 

671 else: 

672 return self.color_converter(color) 

673 return None 

674 

675 def convertLineJoin(self, svgAttr: str) -> int: 

676 """Convert an SVG stroke-linejoin string to a ReportLab line join.""" 

677 return {"miter": 0, "round": 1, "bevel": 2}[svgAttr] 

678 

679 def convertLineCap(self, svgAttr: str) -> int: 

680 """Convert an SVG stroke-linecap string to a ReportLab line cap.""" 

681 return {"butt": 0, "round": 1, "square": 2}[svgAttr] 

682 

683 def convertDashArray(self, svgAttr: str) -> List[Union[float, List[float]]]: 

684 """Convert an SVG stroke-dasharray string to a list of lengths.""" 

685 strokeDashArray = self.convertLengthList(svgAttr) 

686 return strokeDashArray 

687 

688 def convertDashOffset(self, svgAttr: str) -> Union[float, List[float]]: 

689 """Convert an SVG stroke-dashoffset string to a length.""" 

690 strokeDashOffset = self.convertLength(svgAttr) 

691 return strokeDashOffset 

692 

693 def convertFontFamily( 

694 self, 

695 fontAttr: Optional[str], 

696 weightAttr: str = "normal", 

697 styleAttr: str = "normal", 

698 ) -> str: 

699 """Convert an SVG font-family string to a registered font name. 

700 

701 Args: 

702 fontAttr: The SVG font-family attribute string. 

703 weightAttr: The font-weight attribute string. 

704 styleAttr: The font-style attribute string. 

705 

706 Returns: 

707 The best-matched registered font name. 

708 """ 

709 if not fontAttr: 

710 return "" 

711 # split the fontAttr in actual font family names 

712 font_names = self.split_attr_list(fontAttr) 

713 

714 non_exact_matches = [] 

715 for font_name in font_names: 

716 font_name, exact = self._font_map.find_font( 

717 font_name, weightAttr, styleAttr 

718 ) 

719 if exact: 

720 return font_name 

721 elif font_name: 

722 non_exact_matches.append(font_name) 

723 if non_exact_matches: 

724 return non_exact_matches[0] 

725 else: 

726 logger.warning( 

727 f"Unable to find a suitable font for 'font-family:{fontAttr}', " 

728 f"weight:{weightAttr}, style:{styleAttr}; " 

729 "try registering it with register_font()" 

730 ) 

731 return DEFAULT_FONT_NAME 

732 

733 

734class NodeTracker(cssselect2.ElementWrapper): 

735 """A wrapper for lxml nodes to track attribute usage. 

736 

737 This class wraps an lxml node and keeps a record of which attributes 

738 have been accessed, which is useful for debugging unused attributes. 

739 """ 

740 

741 def __init__(self, *args: Any, **kwargs: Any) -> None: 

742 super().__init__(*args, **kwargs) 

743 self.usedAttrs: List[str] = [] 

744 

745 def __repr__(self) -> str: 

746 return f"<NodeTracker for node {self.etree_element}>" 

747 

748 def getAttribute(self, name: str) -> str: 

749 """Get an attribute value and record that it has been used.""" 

750 if name not in self.usedAttrs: 

751 self.usedAttrs.append(name) 

752 return self.etree_element.attrib.get(name, "") 

753 

754 def __getattr__(self, name: str) -> Any: 

755 """Forward attribute access to the wrapped lxml node.""" 

756 return getattr(self.etree_element, name) 

757 

758 def apply_rules(self, rules: Any) -> None: 

759 """Apply CSS rules to the wrapped node. 

760 

761 Args: 

762 rules: A CSSMatcher object containing the styles to apply. 

763 """ 

764 matches = rules.match(self) 

765 for match in matches: 

766 attr_dict = match[3][1] 

767 for attr, val in attr_dict.items(): 

768 try: 

769 self.etree_element.attrib[attr] = val 

770 except ValueError: 

771 pass 

772 # Set marker on the node to not apply rules more than once 

773 self.etree_element.set("__rules_applied", "1") 

774 

775 

776class CircularRefError(Exception): 

777 """Exception raised for circular references in SVG files.""" 

778 

779 pass 

780 

781 

782class ExternalSVG: 

783 """A class to handle external SVG files referenced via xlink:href.""" 

784 

785 def __init__( 

786 self, 

787 path: Union[str, os.PathLike[str]], 

788 renderer: "SvgRenderer", 

789 ) -> None: 

790 self.root_node = load_svg_file(path) 

791 self.renderer = SvgRenderer( 

792 path, parent_svgs=renderer._parent_chain + [str(renderer.source_path)] 

793 ) 

794 self.rendered = False 

795 

796 def get_fragment(self, fragment: str) -> Any: 

797 """Get a defined fragment from the external SVG file. 

798 

799 Args: 

800 fragment: The ID of the fragment to retrieve. 

801 

802 Returns: 

803 The rendered fragment, or None if not found. 

804 """ 

805 if not self.rendered: 

806 self.renderer.render(self.root_node) 

807 self.rendered = True 

808 return self.renderer.definitions.get(fragment) 

809 

810 

811_BEZIER_KAPPA = 0.5523 # cubic bezier constant for circle approximation 

812 

813_GRADIENT_URL_RE = re.compile(r"url\(#([^)]+)\)") 

814 

815 

816def _shape_to_pdf_path(canvas, shape): 

817 """Convert a ReportLab shape to a PDFPathObject for use as a clip path. 

818 

819 Handles Path, Rect, Circle, Ellipse, and Polygon; falls back to the 

820 shape's bounding box for other types. 

821 """ 

822 pdfPath = canvas.beginPath() 

823 if isinstance(shape, Path): 

824 draw_funcs = (pdfPath.moveTo, pdfPath.lineTo, pdfPath.curveTo, pdfPath.close) 

825 _renderPath(shape, draw_funcs) 

826 elif isinstance(shape, Rect): 

827 x, y, x2, y2 = shape.getBounds() 

828 w, h = x2 - x, y2 - y 

829 rx = min(getattr(shape, "rx", 0) or 0, w / 2) 

830 ry = min(getattr(shape, "ry", 0) or 0, h / 2) 

831 k = _BEZIER_KAPPA 

832 if rx or ry: 

833 pdfPath.moveTo(x + rx, y) 

834 pdfPath.lineTo(x + w - rx, y) 

835 pdfPath.curveTo( 

836 x + w - rx + rx * k, y, x + w, y + ry - ry * k, x + w, y + ry 

837 ) 

838 pdfPath.lineTo(x + w, y + h - ry) 

839 pdfPath.curveTo( 

840 x + w, 

841 y + h - ry + ry * k, 

842 x + w - rx + rx * k, 

843 y + h, 

844 x + w - rx, 

845 y + h, 

846 ) 

847 pdfPath.lineTo(x + rx, y + h) 

848 pdfPath.curveTo( 

849 x + rx - rx * k, y + h, x, y + h - ry + ry * k, x, y + h - ry 

850 ) 

851 pdfPath.lineTo(x, y + ry) 

852 pdfPath.curveTo(x, y + ry - ry * k, x + rx - rx * k, y, x + rx, y) 

853 pdfPath.close() 

854 else: 

855 pdfPath.moveTo(x, y) 

856 pdfPath.lineTo(x + w, y) 

857 pdfPath.lineTo(x + w, y + h) 

858 pdfPath.lineTo(x, y + h) 

859 pdfPath.close() 

860 elif isinstance(shape, Circle): 

861 cx, cy, r = shape.cx, shape.cy, shape.r 

862 k = _BEZIER_KAPPA 

863 pdfPath.moveTo(cx + r, cy) 

864 pdfPath.curveTo(cx + r, cy + r * k, cx + r * k, cy + r, cx, cy + r) 

865 pdfPath.curveTo(cx - r * k, cy + r, cx - r, cy + r * k, cx - r, cy) 

866 pdfPath.curveTo(cx - r, cy - r * k, cx - r * k, cy - r, cx, cy - r) 

867 pdfPath.curveTo(cx + r * k, cy - r, cx + r, cy - r * k, cx + r, cy) 

868 pdfPath.close() 

869 elif isinstance(shape, Ellipse): 

870 cx, cy = shape.cx, shape.cy 

871 rx, ry = shape.rx, shape.ry 

872 k = _BEZIER_KAPPA 

873 pdfPath.moveTo(cx + rx, cy) 

874 pdfPath.curveTo(cx + rx, cy + ry * k, cx + rx * k, cy + ry, cx, cy + ry) 

875 pdfPath.curveTo(cx - rx * k, cy + ry, cx - rx, cy + ry * k, cx - rx, cy) 

876 pdfPath.curveTo(cx - rx, cy - ry * k, cx - rx * k, cy - ry, cx, cy - ry) 

877 pdfPath.curveTo(cx + rx * k, cy - ry, cx + rx, cy - ry * k, cx + rx, cy) 

878 pdfPath.close() 

879 elif isinstance(shape, Polygon): 

880 pts = shape.points 

881 if len(pts) >= 2: 

882 pdfPath.moveTo(pts[0], pts[1]) 

883 for i in range(2, len(pts) - 1, 2): 

884 pdfPath.lineTo(pts[i], pts[i + 1]) 

885 pdfPath.close() 

886 else: 

887 try: 

888 x, y, x2, y2 = shape.getBounds() 

889 pdfPath.moveTo(x, y) 

890 pdfPath.lineTo(x2, y) 

891 pdfPath.lineTo(x2, y2) 

892 pdfPath.lineTo(x, y2) 

893 pdfPath.close() 

894 except Exception: 

895 logger.debug( 

896 "Cannot compute bounding box for clip path fallback", exc_info=True 

897 ) 

898 return pdfPath 

899 

900 

901def _find_clip_shape(item): 

902 """Return the first Path/Rect/Circle/Ellipse/Polygon found in item or its group.""" 

903 if isinstance(item, (Path, Rect, Circle, Ellipse, Polygon)): 

904 return item 

905 if isinstance(item, Group): 

906 for child in item.contents: 

907 found = _find_clip_shape(child) 

908 if found is not None: 

909 return found 

910 return None 

911 

912 

913class _LinearGradientShape(DirectDraw): 

914 """Fills a clipped region with a linear gradient via PDF shading.""" 

915 

916 def __init__(self, clip_shape, x0, y0, x1, y1, rl_colors, positions, extend=True): 

917 self._clip_shape = clip_shape 

918 self._x0, self._y0 = x0, y0 

919 self._x1, self._y1 = x1, y1 

920 self._rl_colors = rl_colors 

921 self._positions = positions 

922 self._extend = extend 

923 

924 def drawDirectly(self, renderer): 

925 canvas = renderer._canvas 

926 canvas.saveState() 

927 pdfPath = _shape_to_pdf_path(canvas, self._clip_shape) 

928 canvas.clipPath(pdfPath, fill=1, stroke=0) 

929 canvas.linearGradient( 

930 self._x0, 

931 self._y0, 

932 self._x1, 

933 self._y1, 

934 self._rl_colors, 

935 self._positions, 

936 self._extend, 

937 ) 

938 canvas.restoreState() 

939 

940 

941class _RadialGradientShape(DirectDraw): 

942 """Fills a clipped region with a radial gradient via PDF shading.""" 

943 

944 def __init__(self, clip_shape, cx, cy, r, rl_colors, positions, extend=True): 

945 self._clip_shape = clip_shape 

946 self._cx, self._cy, self._r = cx, cy, r 

947 self._rl_colors = rl_colors 

948 self._positions = positions 

949 self._extend = extend 

950 

951 def drawDirectly(self, renderer): 

952 canvas = renderer._canvas 

953 canvas.saveState() 

954 pdfPath = _shape_to_pdf_path(canvas, self._clip_shape) 

955 canvas.clipPath(pdfPath, fill=1, stroke=0) 

956 canvas.radialGradient( 

957 self._cx, 

958 self._cy, 

959 self._r, 

960 self._rl_colors, 

961 self._positions, 

962 self._extend, 

963 ) 

964 canvas.restoreState() 

965 

966 

967# ## the main meat ### 

968 

969 

970class SvgRenderer: 

971 """A class to render an SVG file into a ReportLab Drawing. 

972 

973 This class walks the SVG DOM and converts SVG elements into their 

974 corresponding ReportLab Graphics objects. 

975 """ 

976 

977 def __init__( 

978 self, 

979 path: Union[str, os.PathLike[str]], 

980 color_converter: Optional[Any] = None, 

981 parent_svgs: Optional[List[str]] = None, 

982 font_map: Optional[Any] = None, 

983 ) -> None: 

984 self.source_path: Union[str, os.PathLike[str]] = path 

985 self._parent_chain: List[str] = parent_svgs or [] # To detect circular refs. 

986 self.attrConverter = Svg2RlgAttributeConverter( 

987 color_converter=color_converter, font_map=font_map 

988 ) 

989 self.shape_converter = Svg2RlgShapeConverter(path, self.attrConverter) 

990 self.handled_shapes = self.shape_converter.get_handled_shapes() 

991 self.definitions: Dict[str, Any] = {} 

992 self.gradient_defs: Dict[str, dict] = {} 

993 self.waiting_use_nodes: Dict[str, List[Tuple[NodeTracker, Optional[Any]]]] = ( 

994 defaultdict(list) 

995 ) 

996 self._external_svgs: Dict[str, ExternalSVG] = {} 

997 self.attrConverter.css_rules = CSSMatcher() 

998 

999 def _set_root_font_size(self, root_node: Any) -> None: 

1000 fs_str = self.attrConverter.findAttr(root_node, "font-size") 

1001 if fs_str: 

1002 fs_px = self.attrConverter.convertLength(fs_str) 

1003 if fs_px: 

1004 self.attrConverter.root_font_size = float(fs_px) # type: ignore[arg-type] 

1005 

1006 def _warn_old_inkscape(self, svg_node: Any) -> None: 

1007 inkscape_version = svg_node.get(f"{{{INKSCAPE_NS}}}version", "") 

1008 if not inkscape_version: 

1009 return 

1010 version_str = inkscape_version.split()[0] 

1011 try: 

1012 parts = tuple(int(x) for x in version_str.split(".")[:2]) 

1013 except ValueError: 

1014 return 

1015 if parts < (0, 92): 

1016 logger.warning( 

1017 "This SVG was created with Inkscape %s (pre-0.92, 90 dpi). " 

1018 "Coordinates assume 90 dpi instead of the standard 96 dpi, " 

1019 "so the output may be ~6.7%% too large. " 

1020 "Re-save with Inkscape ≥0.92 to fix.", 

1021 version_str, 

1022 ) 

1023 

1024 def render(self, svg_node: Any) -> Drawing: 

1025 """Render an SVG node into a ReportLab Drawing. 

1026 

1027 Args: 

1028 svg_node: The root lxml node of the SVG document. 

1029 

1030 Returns: 

1031 A ReportLab Drawing object representing the SVG. 

1032 """ 

1033 node = NodeTracker.from_xml_root(svg_node) 

1034 self._warn_old_inkscape(svg_node) 

1035 self._set_root_font_size(node) 

1036 view_box = self.get_box(node, default_box=True) 

1037 # Knowing the main box is useful for percentage units 

1038 self.attrConverter.set_box(view_box) 

1039 

1040 main_group = self.renderSvg(node, outermost=True) 

1041 for xlink in self.waiting_use_nodes.keys(): 

1042 logger.debug("Ignoring unavailable object width ID %r.", xlink) 

1043 

1044 main_group.translate(0 - view_box.x, -view_box.height - view_box.y) 

1045 

1046 width, height = self.shape_converter.convert_length_attrs( 

1047 svg_node, "width", "height", defaults=(view_box.width, view_box.height) 

1048 ) 

1049 drawing = Drawing(width * PX_TO_PT, height * PX_TO_PT) 

1050 drawing.add(main_group) 

1051 return drawing 

1052 

1053 def renderNode(self, node: NodeTracker, parent: Optional[Any] = None) -> None: 

1054 """Render a single SVG node and add it to a parent group. 

1055 

1056 Args: 

1057 node: The NodeTracker object for the SVG node. 

1058 parent: The parent ReportLab Group to add the rendered object to. 

1059 """ 

1060 if parent is None: 

1061 return 

1062 nid = node.getAttribute("id") 

1063 ignored = False 

1064 item = None 

1065 name = node_name(node) 

1066 

1067 clipping = self.get_clippath(node) 

1068 if name == "svg": 

1069 item = self.renderSvg(node) 

1070 parent.add(item) 

1071 elif name == "defs": 

1072 ignored = True # defs are handled in the initial rendering phase. 

1073 elif name == "a": 

1074 item = self.renderA(node) 

1075 parent.add(item) 

1076 elif name == "g": 

1077 display = node.getAttribute("display") 

1078 item = self.renderG(node, clipping=clipping) 

1079 if display != "none": 

1080 parent.add(item) 

1081 elif name == "style": 

1082 self.renderStyle(node) 

1083 elif name == "symbol": 

1084 item = self.renderSymbol(node) 

1085 # First time the symbol node is rendered, it should not be part of a group. 

1086 # It is only rendered to be part of definitions. 

1087 if node.attrib.get("_rendered"): 

1088 parent.add(item) 

1089 else: 

1090 node.set("_rendered", "1") 

1091 elif name == "use": 

1092 item = self.renderUse(node, clipping=clipping) 

1093 parent.add(item) 

1094 elif name == "switch": 

1095 item = self.renderSwitch(node, clipping=clipping) 

1096 if item is not None: 

1097 parent.add(item) 

1098 elif name == "clipPath": 

1099 item = self.renderG(node) 

1100 elif name in ("linearGradient", "radialGradient"): 

1101 self.renderGradient(node) 

1102 ignored = True 

1103 elif name in self.handled_shapes: 

1104 if name == "image": 

1105 # We resolve the image target at renderer level because it can point 

1106 # to another SVG file or node which has to be rendered too. 

1107 target = self.xlink_href_target(node) 

1108 if target is None: 

1109 return 

1110 elif isinstance(target, tuple): 

1111 # This is SVG content needed to be rendered 

1112 gr = Group() 

1113 renderer, img_node = target 

1114 renderer.renderNode(img_node, parent=gr) 

1115 self.apply_node_attr_to_group(node, gr) 

1116 parent.add(gr) 

1117 return 

1118 else: 

1119 # Attaching target to node, so we can get it back in convertImage 

1120 node._resolved_target = target 

1121 

1122 item = self.shape_converter.convertShape(name, node, clipping) 

1123 display = node.getAttribute("display") 

1124 if item and display != "none": 

1125 fill_val = self.attrConverter.findAttr(node, "fill") 

1126 m = _GRADIENT_URL_RE.fullmatch(fill_val.strip()) if fill_val else None 

1127 if m: 

1128 grad_id = m.group(1) 

1129 grad_def = self._resolve_gradient(grad_id) 

1130 if grad_def is not None: 

1131 item = self._apply_gradient_fill(item, grad_def) 

1132 parent.add(item) 

1133 else: 

1134 ignored = True 

1135 logger.debug("Ignoring node: %s", name) 

1136 

1137 if not ignored: 

1138 if nid and item: 

1139 self.definitions[nid] = node 

1140 # preserve id to keep track of svg objects 

1141 # and simplify further analyses of generated document 

1142 item.setProperties({"svgid": nid}) 

1143 # labels are used in inkscape to name specific groups as layers 

1144 # preserving them simplify extraction of feature from the generated 

1145 # document 

1146 label_attrs = [v for k, v in node.attrib.items() if "label" in k] 

1147 if len(label_attrs) == 1: 

1148 (label,) = label_attrs 

1149 item.setProperties({"label": label}) 

1150 if nid in self.waiting_use_nodes.keys(): 

1151 to_render = self.waiting_use_nodes.pop(nid) 

1152 for use_node, group in to_render: 

1153 self.renderUse(use_node, group=group) 

1154 self.print_unused_attributes(node) 

1155 

1156 def renderGradient(self, node: NodeTracker) -> None: 

1157 """Parse a <linearGradient> or <radialGradient> element and store it.""" 

1158 grad_id = node.attrib.get("id") 

1159 if not grad_id: 

1160 return 

1161 grad_type = node_name(node) # "linearGradient" or "radialGradient" 

1162 

1163 def _float_attr(attr, default): 

1164 raw = node.attrib.get(attr, "").strip() 

1165 if raw.endswith("%"): 

1166 try: 

1167 return float(raw[:-1]) / 100.0 

1168 except ValueError: 

1169 return default 

1170 try: 

1171 return float(raw) if raw else default 

1172 except ValueError: 

1173 return default 

1174 

1175 href = node.attrib.get("{http://www.w3.org/1999/xlink}href") or node.attrib.get( 

1176 "href", "" 

1177 ) 

1178 href_id = href.lstrip("#") if href.startswith("#") else None 

1179 

1180 grad_units = node.attrib.get("gradientUnits", "objectBoundingBox") 

1181 spread = node.attrib.get("spreadMethod", "pad") 

1182 

1183 # Collect stop elements (direct children with tag "stop") 

1184 stops = [] 

1185 for child in node: 

1186 child_name = node_name(child) 

1187 if child_name != "stop": 

1188 continue 

1189 raw_offset = child.attrib.get("offset", "0").strip() 

1190 if raw_offset.endswith("%"): 

1191 try: 

1192 offset = float(raw_offset[:-1]) / 100.0 

1193 except ValueError: 

1194 offset = 0.0 

1195 else: 

1196 try: 

1197 offset = float(raw_offset) 

1198 except ValueError: 

1199 offset = 0.0 

1200 

1201 # stop-color and stop-opacity can be in style or as direct attrs 

1202 style_str = child.attrib.get("style", "") 

1203 style_attrs: dict = {} 

1204 if style_str: 

1205 style_attrs = self.attrConverter.parseMultiAttributes(style_str) 

1206 

1207 stop_color_str = style_attrs.get( 

1208 "stop-color", child.attrib.get("stop-color", "black") 

1209 ) 

1210 stop_opacity_str = style_attrs.get( 

1211 "stop-opacity", child.attrib.get("stop-opacity", "1") 

1212 ) 

1213 try: 

1214 opacity = float(stop_opacity_str) 

1215 except (ValueError, TypeError): 

1216 opacity = 1.0 

1217 

1218 rl_color = self.attrConverter.convertColor(stop_color_str) 

1219 if rl_color is None: 

1220 rl_color = colors.black 

1221 rl_color = rl_color.clone() 

1222 rl_color.alpha = opacity 

1223 stops.append((offset, rl_color)) 

1224 

1225 grad_def: dict = { 

1226 "type": "linear" if grad_type == "linearGradient" else "radial", 

1227 "gradientUnits": grad_units, 

1228 "spreadMethod": spread, 

1229 "stops": stops, 

1230 "href": href_id, 

1231 } 

1232 

1233 if grad_type == "linearGradient": 

1234 grad_def["x1"] = _float_attr("x1", 0.0) 

1235 grad_def["y1"] = _float_attr("y1", 0.0) 

1236 grad_def["x2"] = _float_attr("x2", 1.0) 

1237 grad_def["y2"] = _float_attr("y2", 0.0) 

1238 else: 

1239 grad_def["cx"] = _float_attr("cx", 0.5) 

1240 grad_def["cy"] = _float_attr("cy", 0.5) 

1241 grad_def["r"] = _float_attr("r", 0.5) 

1242 grad_def["fx"] = _float_attr("fx", grad_def["cx"]) 

1243 grad_def["fy"] = _float_attr("fy", grad_def["cy"]) 

1244 

1245 self.gradient_defs[grad_id] = grad_def 

1246 

1247 def _resolve_gradient(self, grad_id: str) -> Optional[dict]: 

1248 """Return a fully resolved gradient dict, following xlink:href chains.""" 

1249 visited = set() 

1250 result = self.gradient_defs.get(grad_id) 

1251 while result is not None: 

1252 href = result.get("href") 

1253 if not href or href in visited: 

1254 break 

1255 parent = self.gradient_defs.get(href) 

1256 if parent is None: 

1257 break 

1258 visited.add(href) 

1259 # Merge: current overrides parent for all keys except missing stops 

1260 merged = dict(parent) 

1261 for k, v in result.items(): 

1262 if k == "stops" and not v: 

1263 continue # inherit parent's stops 

1264 merged[k] = v 

1265 result = merged 

1266 return result 

1267 

1268 def _apply_gradient_fill(self, item: Any, grad_def: dict) -> Any: 

1269 """Wrap a shape in a Group that paints the gradient fill then the stroke.""" 

1270 clip_shape = _find_clip_shape(item) 

1271 if clip_shape is None: 

1272 return item 

1273 

1274 stops = grad_def.get("stops", []) 

1275 if not stops: 

1276 return item 

1277 

1278 positions = [s[0] for s in stops] 

1279 rl_colors = [s[1] for s in stops] 

1280 extend = grad_def.get("spreadMethod", "pad") == "pad" 

1281 grad_units = grad_def.get("gradientUnits", "objectBoundingBox") 

1282 

1283 if grad_units == "objectBoundingBox": 

1284 try: 

1285 bx0, by0, bx1, by1 = clip_shape.getBounds() 

1286 except Exception: 

1287 logger.debug("Cannot compute bounding box for gradient", exc_info=True) 

1288 return item 

1289 bbox_w = bx1 - bx0 

1290 bbox_h = by1 - by0 

1291 else: 

1292 bx0 = by0 = bbox_w = bbox_h = 0.0 # unused for userSpaceOnUse 

1293 

1294 if grad_def["type"] == "linear": 

1295 x1, y1 = grad_def.get("x1", 0.0), grad_def.get("y1", 0.0) 

1296 x2, y2 = grad_def.get("x2", 1.0), grad_def.get("y2", 0.0) 

1297 if grad_units == "objectBoundingBox": 

1298 x1 = bx0 + x1 * bbox_w 

1299 y1 = by0 + y1 * bbox_h 

1300 x2 = bx0 + x2 * bbox_w 

1301 y2 = by0 + y2 * bbox_h 

1302 grad_shape: DirectDraw = _LinearGradientShape( 

1303 clip_shape, x1, y1, x2, y2, rl_colors, positions, extend 

1304 ) 

1305 else: 

1306 cx = grad_def.get("cx", 0.5) 

1307 cy = grad_def.get("cy", 0.5) 

1308 r = grad_def.get("r", 0.5) 

1309 if grad_units == "objectBoundingBox": 

1310 cx = bx0 + cx * bbox_w 

1311 cy = by0 + cy * bbox_h 

1312 r = r * (bbox_w + bbox_h) / 2.0 

1313 grad_shape = _RadialGradientShape( 

1314 clip_shape, cx, cy, r, rl_colors, positions, extend 

1315 ) 

1316 

1317 group = Group() 

1318 group.add(grad_shape) 

1319 group.add(item) 

1320 return group 

1321 

1322 def get_clippath(self, node: NodeTracker) -> Optional[Any]: 

1323 """Get the clipping path object referenced by a node's 'clip-path' attribute. 

1324 

1325 Args: 

1326 node: The NodeTracker object for the SVG node. 

1327 

1328 Returns: 

1329 A ClippingPath object, or None if no valid clipping path is found. 

1330 """ 

1331 

1332 def get_shape_from_group(group: Any) -> Optional[Any]: 

1333 for elem in group.contents: 

1334 if isinstance(elem, Group): 

1335 return get_shape_from_group(elem) 

1336 elif isinstance(elem, SolidShape): 

1337 return elem 

1338 return None 

1339 

1340 def get_shape_from_node(node: Any) -> Optional[Any]: 

1341 for child in node.iter_children(): 

1342 if node_name(child) == "path": 

1343 group = self.shape_converter.convertShape("path", child) 

1344 return group.contents[-1] 

1345 elif node_name(child) == "use": 

1346 grp = self.renderUse(child) 

1347 return get_shape_from_group(grp) 

1348 elif node_name(child) == "rect": 

1349 return self.shape_converter.convertRect(child) 

1350 else: 

1351 return get_shape_from_node(child) 

1352 return None 

1353 

1354 clip_path = node.getAttribute("clip-path") 

1355 if not clip_path: 

1356 return None 

1357 m = re.match(r"url\(#([^)]*)\)", clip_path) 

1358 if not m: 

1359 return None 

1360 ref = m.groups()[0] 

1361 if ref not in self.definitions: 

1362 logger.warning("Unable to find a clipping path with id %s", ref) 

1363 return None 

1364 

1365 shape = get_shape_from_node(self.definitions[ref]) 

1366 if isinstance(shape, Rect): 

1367 # It is possible to use a rect as a clipping path in an svg, so we 

1368 # need to convert it to a path for rlg. 

1369 x1, y1, x2, y2 = shape.getBounds() 

1370 cp = ClippingPath() 

1371 cp.moveTo(x1, y1) 

1372 cp.lineTo(x2, y1) 

1373 cp.lineTo(x2, y2) 

1374 cp.lineTo(x1, y2) 

1375 cp.closePath() 

1376 # Copy the styles from the rect to the clipping path. 

1377 copy_shape_properties(shape, cp) 

1378 return cp 

1379 elif isinstance(shape, Path): 

1380 return ClippingPath(copy_from=shape) 

1381 elif shape: 

1382 logger.error( 

1383 "Unsupported shape type %s for clipping", shape.__class__.__name__ 

1384 ) 

1385 return None 

1386 

1387 def print_unused_attributes(self, node: NodeTracker) -> None: 

1388 """Print any attributes that were not used during rendering. 

1389 

1390 This is a debugging helper to identify unsupported SVG attributes. 

1391 

1392 Args: 

1393 node: The NodeTracker object for the SVG node. 

1394 """ 

1395 if logger.level > logging.DEBUG: 

1396 return 

1397 all_attrs = self.attrConverter.getAllAttributes(node.etree_element).keys() 

1398 unused_attrs = [attr for attr in all_attrs if attr not in node.usedAttrs] 

1399 if unused_attrs: 

1400 logger.debug("Unused attrs: %s %s", node_name(node), unused_attrs) 

1401 

1402 def apply_node_attr_to_group(self, node: NodeTracker, group: Any) -> None: 

1403 """Apply common attributes (transform, x, y) from a node to a group. 

1404 

1405 Args: 

1406 node: The NodeTracker object for the SVG node. 

1407 group: The ReportLab Group to apply the attributes to. 

1408 """ 

1409 getAttr = node.getAttribute 

1410 transform, x, y = map(getAttr, ("transform", "x", "y")) 

1411 if x or y: 

1412 transform += f" translate({x or 0}, {y or 0})" 

1413 if transform: 

1414 self.shape_converter.applyTransformOnGroup(transform, group) 

1415 

1416 def xlink_href_target(self, node: NodeTracker, group: Optional[Any] = None) -> Any: 

1417 """Resolve an xlink:href attribute to its target. 

1418 

1419 The target can be an internal fragment, an external SVG file, or a 

1420 raster image. 

1421 

1422 Args: 

1423 node: The NodeTracker object for the SVG node with the href attribute. 

1424 group: The parent group, used for delayed rendering. 

1425 

1426 Returns: 

1427 - A tuple (renderer, node) for vector targets. 

1428 - A PIL Image object for raster images. 

1429 - None if the target cannot be resolved. 

1430 """ 

1431 # Bare 'href' was introduced in SVG 2. 

1432 xlink_href = node.attrib.get( 

1433 "{http://www.w3.org/1999/xlink}href" 

1434 ) or node.attrib.get("href") 

1435 if not xlink_href: 

1436 return None 

1437 

1438 # First handle any raster embedded image data 

1439 match = re.match(r"^data:image/(jpe?g|png);base64", xlink_href) 

1440 if match: 

1441 image_data = base64.decodebytes( 

1442 xlink_href[(match.span(0)[1] + 1) :].encode("ascii") 

1443 ) 

1444 bytes_stream = BytesIO(image_data) 

1445 

1446 return _convert_palette_to_rgba(PILImage.open(bytes_stream)) 

1447 

1448 # From here, we can assume this is a path. 

1449 if "#" in xlink_href: 

1450 iri, fragment = xlink_href.split("#", 1) 

1451 else: 

1452 iri, fragment = xlink_href, None 

1453 

1454 if iri: 

1455 # Only local relative paths are supported yet 

1456 if not isinstance(self.source_path, str): 

1457 logger.error( 

1458 "Unable to resolve image path %r as the SVG source is not " 

1459 "a file system path.", 

1460 iri, 

1461 ) 

1462 return None 

1463 path = os.path.normpath( 

1464 os.path.join(os.path.dirname(self.source_path), iri) 

1465 ) 

1466 if not os.access(path, os.R_OK): 

1467 return None 

1468 if path == self.source_path: 

1469 # Self-referencing, ignore the IRI part 

1470 iri = None 

1471 

1472 if iri: 

1473 if path.endswith(".svg"): 

1474 if path in self._parent_chain: 

1475 logger.error("Circular reference detected in file.") 

1476 raise CircularRefError() 

1477 if path not in self._external_svgs: 

1478 self._external_svgs[path] = ExternalSVG(path, self) 

1479 ext_svg = self._external_svgs[path] 

1480 if ext_svg.root_node is not None: 

1481 if fragment: 

1482 ext_frag = ext_svg.get_fragment(fragment) 

1483 if ext_frag is not None: 

1484 return ext_svg.renderer, ext_frag 

1485 else: 

1486 return ext_svg.renderer, NodeTracker.from_xml_root( 

1487 ext_svg.root_node 

1488 ) 

1489 else: 

1490 # A raster image path 

1491 try: 

1492 # This will catch invalid images 

1493 PDFImage(path, 0, 0) 

1494 except OSError: 

1495 logger.error("Unable to read the image %s. Skipping...", path) 

1496 return None 

1497 return path 

1498 

1499 elif fragment: 

1500 # A pointer to an internal definition 

1501 if fragment in self.definitions: 

1502 return self, self.definitions[fragment] 

1503 else: 

1504 # The missing definition should appear later in the file 

1505 self.waiting_use_nodes[fragment].append((node, group)) 

1506 return DELAYED 

1507 return None 

1508 

1509 def renderTitle_(self, node: NodeTracker) -> None: 

1510 """Handle the <title> element (currently a no-op).""" 

1511 pass 

1512 

1513 def renderDesc_(self, node: NodeTracker) -> None: 

1514 """Handle the <desc> element (currently a no-op).""" 

1515 pass 

1516 

1517 def get_box(self, svg_node: NodeTracker, default_box: bool = False) -> Box: 

1518 """Get the viewBox or dimensions of an SVG node. 

1519 

1520 Args: 

1521 svg_node: The NodeTracker for the SVG node. 

1522 default_box: If True, use width/height as a fallback. 

1523 

1524 Returns: 

1525 A Box tuple representing the dimensions. 

1526 """ 

1527 view_box = svg_node.getAttribute("viewBox") 

1528 if view_box: 

1529 # viewBox defines a unitless user-coordinate space (SVG spec §8.1). 

1530 # Parse as raw floats — never apply unit conversion here. 

1531 values = [float(v) for v in view_box.replace(",", " ").split()] 

1532 return Box(*values) 

1533 if default_box: 

1534 width, height = map(svg_node.getAttribute, ("width", "height")) 

1535 width, height = map(self.attrConverter.convertLength, (width, height)) # type: ignore 

1536 return Box(0, 0, width, height) 

1537 return Box(0, 0, 0, 0) # fallback 

1538 

1539 def renderSvg(self, node: NodeTracker, outermost: bool = False) -> Any: 

1540 """Render an <svg> element into a ReportLab Group. 

1541 

1542 Args: 

1543 node: The NodeTracker for the <svg> element. 

1544 outermost: True if this is the root <svg> element. 

1545 

1546 Returns: 

1547 A ReportLab Group containing the rendered content. 

1548 """ 

1549 _saved_preserve_space = self.shape_converter.preserve_space 

1550 self.shape_converter.preserve_space = ( 

1551 node.getAttribute(f"{{{XML_NS}}}space") == "preserve" 

1552 ) 

1553 view_box = self.get_box(node, default_box=True) 

1554 _saved_box = self.attrConverter.main_box 

1555 if view_box: 

1556 self.attrConverter.set_box(view_box) 

1557 

1558 # Rendering all definition nodes first. 

1559 svg_ns = node.nsmap.get(None) 

1560 for def_node in node.iter_subtree(): 

1561 if def_node.tag == (f"{{{svg_ns}}}defs" if svg_ns else "defs"): 

1562 self.renderG(def_node) 

1563 

1564 group = Group() 

1565 for child in node.iter_children(): 

1566 self.renderNode(child, group) 

1567 self.shape_converter.preserve_space = _saved_preserve_space 

1568 self.attrConverter.set_box(_saved_box) 

1569 

1570 # Translating 

1571 if not outermost: 

1572 x, y = self.shape_converter.convert_length_attrs(node, "x", "y") 

1573 if x or y: 

1574 group.translate(x or 0, y or 0) 

1575 

1576 # Scaling 

1577 if not view_box and outermost: 

1578 # Apply only the 'reverse' y-scaling (PDF 0,0 is bottom left) 

1579 group.scale(1, -1) 

1580 elif view_box: 

1581 width, height = self.shape_converter.convert_length_attrs( 

1582 node, "width", "height", defaults=(None,) * 2 

1583 ) 

1584 # Per SVG 1.1 §5.1.2, a nested <svg> with omitted width/height 

1585 # defaults them to "100%" of the parent viewport. (SVG 2 §8.2 

1586 # technically changed this so that omitting either resolves to 0 

1587 # and disables rendering, but no browser implements that and most 

1588 # real-world SVGs still rely on the 1.1 behavior.) Without this 

1589 # fallback, an inner <svg viewBox="0 0 N N"> with no explicit 

1590 # width/height renders content at viewBox-unit size instead of 

1591 # filling the parent slot. 

1592 if not outermost and _saved_box is not None: 

1593 if width is None: 

1594 width = _saved_box.width 

1595 if height is None: 

1596 height = _saved_box.height 

1597 # Fall back to viewBox dimensions (user units) when no explicit size. 

1598 # render() does the same, so Drawing size = viewBox × PX_TO_PT. 

1599 if width is None: 

1600 width = view_box.width 

1601 if height is None: 

1602 height = view_box.height 

1603 # canvas is in pts; view_box is in user units — scale converts user→pt 

1604 x_scale = (width * PX_TO_PT) / view_box.width if view_box.width else 1 

1605 y_scale = (height * PX_TO_PT) / view_box.height if view_box.height else 1 

1606 

1607 # Apply preserveAspectRatio (default: xMidYMid meet) 

1608 par = (node.getAttribute("preserveAspectRatio") or "xMidYMid meet").strip() 

1609 par_tokens = par.split() 

1610 align = par_tokens[0] if par_tokens else "xMidYMid" 

1611 meet_or_slice = par_tokens[1] if len(par_tokens) > 1 else "meet" 

1612 

1613 if align != "none": 

1614 if meet_or_slice == "slice": 

1615 uniform_scale = max(x_scale, y_scale) 

1616 else: # meet (default) 

1617 uniform_scale = min(x_scale, y_scale) 

1618 

1619 # Compute translation to align content within canvas (in pts) 

1620 width_pt = width * PX_TO_PT 

1621 height_pt = height * PX_TO_PT 

1622 scaled_vb_w = view_box.width * uniform_scale 

1623 scaled_vb_h = view_box.height * uniform_scale 

1624 # Horizontal alignment 

1625 if align.startswith("xMin"): 

1626 tx = 0 

1627 elif align.startswith("xMax"): 

1628 tx = width_pt - scaled_vb_w 

1629 else: # xMid (default) 

1630 tx = (width_pt - scaled_vb_w) / 2 

1631 # Vertical alignment (SVG y increases downward, PDF upward) 

1632 if "YMin" in align: 

1633 ty = height_pt - scaled_vb_h 

1634 elif "YMax" in align: 

1635 ty = 0 

1636 else: # YMid (default) 

1637 ty = (height_pt - scaled_vb_h) / 2 

1638 if tx or ty: 

1639 group.translate(tx, ty) 

1640 

1641 x_scale = y_scale = uniform_scale 

1642 group.scale(x_scale, y_scale * (-1 if outermost else 1)) 

1643 

1644 return group 

1645 

1646 def renderG(self, node: NodeTracker, clipping: Optional[Any] = None) -> Any: 

1647 """Render a <g> element into a ReportLab Group. 

1648 

1649 Args: 

1650 node: The NodeTracker for the <g> element. 

1651 clipping: An optional clipping path to apply. 

1652 

1653 Returns: 

1654 A ReportLab Group containing the rendered content. 

1655 """ 

1656 getAttr = node.getAttribute 

1657 id, transform = map(getAttr, ("id", "transform")) 

1658 gr = Group() 

1659 if clipping: 

1660 gr.add(clipping) 

1661 for child in node.iter_children(): 

1662 self.renderNode(child, parent=gr) 

1663 

1664 if transform: 

1665 self.shape_converter.applyTransformOnGroup(transform, gr) 

1666 

1667 return gr 

1668 

1669 def renderSwitch( 

1670 self, node: NodeTracker, clipping: Optional[Any] = None 

1671 ) -> Optional[Any]: 

1672 """Render a <switch> element by evaluating each child's conditions. 

1673 

1674 Per the SVG spec, the first child whose requiredFeatures, 

1675 requiredExtensions, and systemLanguage conditions are all satisfied 

1676 is rendered; remaining children are skipped. 

1677 """ 

1678 sys_lang = locale.getdefaultlocale()[0] or "" 

1679 sys_lang_prefix = sys_lang.split("_")[0] 

1680 

1681 for child in node: 

1682 # requiredExtensions: svglib supports no external extensions 

1683 if child.attrib.get("requiredExtensions"): 

1684 continue 

1685 # requiredFeatures: all listed features must be supported 

1686 required = child.attrib.get("requiredFeatures", "") 

1687 if required: 

1688 features = required.split() 

1689 if not all(f.lower() in SUPPORTED_SVG_FEATURES for f in features): 

1690 continue 

1691 # systemLanguage: at least one tag must match the system locale 

1692 sys_language = child.attrib.get("systemLanguage", "") 

1693 if sys_language: 

1694 tags = [t.strip() for t in sys_language.split(",")] 

1695 if not any(t == sys_lang or t == sys_lang_prefix for t in tags): 

1696 continue 

1697 # This child's conditions are satisfied — render it 

1698 gr = Group() 

1699 self.renderNode(child, parent=gr) 

1700 if clipping is not None: 

1701 gr.add(clipping) 

1702 return gr 

1703 

1704 return None 

1705 

1706 def renderStyle(self, node: NodeTracker) -> None: 

1707 """Render a <style> element by adding its content to the CSS matcher.""" 

1708 if self.attrConverter.css_rules is not None: 

1709 self.attrConverter.css_rules.add_styles(node.text or "") 

1710 

1711 def renderSymbol(self, node: NodeTracker) -> Any: 

1712 """Render a <symbol> element as a ReportLab Group.""" 

1713 return self.renderG(node) 

1714 

1715 def renderA(self, node: NodeTracker) -> Any: 

1716 """Render an <a> element as a ReportLab Group (no linking support).""" 

1717 return self.renderG(node) 

1718 

1719 def renderUse( 

1720 self, 

1721 node: NodeTracker, 

1722 group: Optional[Any] = None, 

1723 clipping: Optional[Any] = None, 

1724 ) -> Any: 

1725 """Render a <use> element by cloning a defined element. 

1726 

1727 Args: 

1728 node: The NodeTracker for the <use> element. 

1729 group: The parent group to render into. 

1730 clipping: An optional clipping path to apply. 

1731 

1732 Returns: 

1733 A ReportLab Group containing the rendered content. 

1734 """ 

1735 if group is None: 

1736 group = Group() 

1737 

1738 try: 

1739 item = self.xlink_href_target(node, group=group) 

1740 except CircularRefError: 

1741 node.parent.etree_element.remove(node.etree_element) 

1742 return group 

1743 if item is None: 

1744 return 

1745 elif isinstance(item, str): 

1746 logger.error("<use> nodes cannot reference bitmap image files") 

1747 return 

1748 elif item is DELAYED: 

1749 return group 

1750 else: 

1751 item = item[1] # [0] is the renderer, not used here. 

1752 

1753 if clipping: 

1754 group.add(clipping) 

1755 if len(node.getchildren()) == 0: 

1756 # Append a copy of the referenced node as the <use> child (if not 

1757 # already done) 

1758 node.append(copy.deepcopy(item)) 

1759 self.renderNode(list(node.iter_children())[-1], parent=group) 

1760 self.apply_node_attr_to_group(node, group) 

1761 return group 

1762 return None 

1763 

1764 

1765class SvgShapeConverter: 

1766 """An abstract class for converting SVG shapes to another format. 

1767 

1768 Subclasses should implement `convertX` methods for each SVG shape `X` 

1769 (e.g., `convertRect`, `convertCircle`). 

1770 """ 

1771 

1772 def __init__( 

1773 self, 

1774 path: Union[str, os.PathLike[str]], 

1775 attrConverter: Optional[Svg2RlgAttributeConverter] = None, 

1776 ) -> None: 

1777 self.attrConverter = attrConverter or Svg2RlgAttributeConverter() 

1778 self.svg_source_file = path 

1779 self.preserve_space = False 

1780 

1781 @classmethod 

1782 def get_handled_shapes(cls) -> List[str]: 

1783 """Return a list of SVG shape names that this converter can handle.""" 

1784 return [key[7:].lower() for key in dir(cls) if key.startswith("convert")] 

1785 

1786 

1787class Svg2RlgShapeConverter(SvgShapeConverter): 

1788 """A class for converting SVG shapes to ReportLab Graphics shapes.""" 

1789 

1790 def convertShape(self, name: str, node: Any, clipping: Optional[Any] = None) -> Any: 

1791 """Convert an SVG shape by calling the appropriate `convertX` method. 

1792 

1793 Args: 

1794 name: The name of the SVG shape (e.g., "rect", "circle"). 

1795 node: The lxml node for the shape. 

1796 clipping: An optional clipping path to apply. 

1797 

1798 Returns: 

1799 A ReportLab shape object, or a Group if transforms or clipping 

1800 are applied. 

1801 """ 

1802 method_name = f"convert{name.capitalize()}" 

1803 shape = getattr(self, method_name)(node) 

1804 if not shape: 

1805 return 

1806 if name not in ("path", "polyline", "text"): 

1807 # Only apply style where the convert method did not apply it. 

1808 self.applyStyleOnShape(shape, node) 

1809 transform = node.getAttribute("transform") 

1810 if not (transform or clipping): 

1811 return shape 

1812 else: 

1813 group = Group() 

1814 if transform: 

1815 self.applyTransformOnGroup(transform, group) 

1816 if clipping: 

1817 group.add(clipping) 

1818 group.add(shape) 

1819 return group 

1820 

1821 def convert_length_attrs( 

1822 self, 

1823 node: Any, 

1824 *attrs: str, 

1825 em_base: float = DEFAULT_FONT_SIZE / PX_TO_PT, 

1826 **kwargs: Any, 

1827 ) -> List[float]: 

1828 """Convert a list of length attributes from a node. 

1829 

1830 Args: 

1831 node: The lxml node. 

1832 *attrs: The names of the attributes to convert. 

1833 em_base: The base font size for 'em' units. 

1834 **kwargs: Can include 'defaults' for fallback values. 

1835 

1836 Returns: 

1837 A list of converted lengths in points. 

1838 """ 

1839 getAttr = ( 

1840 node.getAttribute 

1841 if hasattr(node, "getAttribute") 

1842 else lambda attr: node.attrib.get(attr, "") 

1843 ) 

1844 convLength = self.attrConverter.convertLength 

1845 defaults = kwargs.get("defaults", (0.0,) * len(attrs)) 

1846 return [ 

1847 convLength(getAttr(attr), attr_name=attr, em_base=em_base, default=default) # type: ignore 

1848 for attr, default in zip(attrs, defaults) 

1849 ] 

1850 

1851 def convertLine(self, node: Any) -> Line: 

1852 """Convert an SVG <line> element to a ReportLab Line.""" 

1853 points = self.convert_length_attrs(node, "x1", "y1", "x2", "y2") 

1854 nudge_points(points) 

1855 return Line(*points) 

1856 

1857 def convertRect(self, node: Any) -> Optional[Rect]: 

1858 """Convert an SVG <rect> element to a ReportLab Rect.""" 

1859 x, y, width, height, rx, ry = self.convert_length_attrs( 

1860 node, "x", "y", "width", "height", "rx", "ry" 

1861 ) 

1862 if rx > (width / 2): 

1863 rx = width / 2 

1864 if ry > (height / 2): 

1865 ry = height / 2 

1866 if rx and not ry: 

1867 ry = rx 

1868 elif ry and not rx: 

1869 rx = ry 

1870 return Rect(x, y, width, height, rx=rx, ry=ry) 

1871 

1872 def convertCircle(self, node: Any) -> Circle: 

1873 """Convert an SVG <circle> element to a ReportLab Circle.""" 

1874 cx, cy, r = self.convert_length_attrs(node, "cx", "cy", "r") 

1875 return Circle(cx, cy, r) 

1876 

1877 def convertEllipse(self, node: Any) -> Ellipse: 

1878 """Convert an SVG <ellipse> element to a ReportLab Ellipse.""" 

1879 cx, cy, rx, ry = self.convert_length_attrs(node, "cx", "cy", "rx", "ry") 

1880 width, height = rx, ry 

1881 return Ellipse(cx, cy, width, height) 

1882 

1883 def convertPolyline(self, node: Any) -> Optional[Any]: 

1884 """Convert an SVG <polyline> element to a ReportLab PolyLine.""" 

1885 points = node.getAttribute("points") 

1886 points = points.replace(",", " ") 

1887 points = points.split() 

1888 points = list(map(self.attrConverter.convertLength, points)) 

1889 if len(points) % 2 != 0 or len(points) == 0: 

1890 # Odd number of coordinates or no coordinates, invalid polyline 

1891 return None 

1892 

1893 nudge_points(points) 

1894 polyline = PolyLine(points) 

1895 self.applyStyleOnShape(polyline, node) 

1896 has_fill = self.attrConverter.findAttr(node, "fill") not in ("", "none") 

1897 

1898 if has_fill: 

1899 # ReportLab doesn't fill polylines, so we are creating a polygon 

1900 # polygon copy of the polyline, but without stroke. 

1901 group = Group() 

1902 polygon = Polygon(points) 

1903 self.applyStyleOnShape(polygon, node) 

1904 polygon.strokeColor = None 

1905 group.add(polygon) 

1906 group.add(polyline) 

1907 return group 

1908 

1909 return polyline 

1910 

1911 def convertPolygon(self, node: Any) -> Optional[Polygon]: 

1912 """Convert an SVG <polygon> element to a ReportLab Polygon.""" 

1913 points = node.getAttribute("points") 

1914 points = points.replace(",", " ") 

1915 points = points.split() 

1916 points = list(map(self.attrConverter.convertLength, points)) 

1917 if len(points) % 2 != 0 or len(points) == 0: 

1918 # Odd number of coordinates or no coordinates, invalid polygon 

1919 return None 

1920 nudge_points(points) 

1921 shape = Polygon(points) 

1922 

1923 return shape 

1924 

1925 def convertText(self, node: Any) -> Any: 

1926 """Convert an SVG <text> element to a ReportLab Group of Strings.""" 

1927 attrConv = self.attrConverter 

1928 xml_space = node.getAttribute(f"{{{XML_NS}}}space") 

1929 if xml_space: 

1930 preserve_space = xml_space == "preserve" 

1931 else: 

1932 preserve_space = self.preserve_space 

1933 

1934 gr = Group() 

1935 

1936 frag_lengths: List[float] = [] 

1937 

1938 dx0: float = 0 

1939 dy0: float = 0 

1940 x1: Union[float, List[float]] = 0 

1941 y1: Union[float, List[float]] = 0 

1942 ff = attrConv.findAttr(node, "font-family") or DEFAULT_FONT_NAME 

1943 fw = attrConv.findAttr(node, "font-weight") or DEFAULT_FONT_WEIGHT 

1944 fstyle = attrConv.findAttr(node, "font-style") or DEFAULT_FONT_STYLE 

1945 ff = attrConv.convertFontFamily(ff, fw, fstyle) 

1946 fs = attrConv.findAttr(node, "font-size") or f"{DEFAULT_FONT_SIZE}pt" 

1947 fs = attrConv.convertLength(fs) # type: ignore (user units, used as em_base) 

1948 fs_pt = fs * PX_TO_PT # absolute points for ReportLab font metrics 

1949 x: List[float] 

1950 y: List[float] 

1951 x, y = self.convert_length_attrs(node, "x", "y", em_base=fs) # type: ignore 

1952 for subnode, text, is_tail in iter_text_node(node, preserve_space): 

1953 if not text: 

1954 continue 

1955 has_x, has_y = False, False 

1956 dx: Union[float, List[float]] = 0 

1957 dy: Union[float, List[float]] = 0 

1958 baseLineShift: Union[float, int] = 0 

1959 if not is_tail: 

1960 x1, y1, dx, dy = self.convert_length_attrs( # type: ignore 

1961 subnode, 

1962 "x", 

1963 "y", 

1964 "dx", 

1965 "dy", 

1966 em_base=fs, # type: ignore 

1967 ) 

1968 has_x, has_y = ( 

1969 subnode.attrib.get("x", "") != "", 

1970 subnode.attrib.get("y", "") != "", 

1971 ) 

1972 dx0 = dx0 + (dx[0] if isinstance(dx, list) else dx) # type: ignore 

1973 dy0 = dy0 + (dy[0] if isinstance(dy, list) else dy) # type: ignore 

1974 baseLineShift = subnode.attrib.get("baseline-shift", "0") 

1975 if baseLineShift in ("sub", "super", "baseline"): 

1976 baseLineShift = {"sub": -fs / 2, "super": fs / 2, "baseline": 0}[ # type: ignore 

1977 baseLineShift # type: ignore 

1978 ] 

1979 else: 

1980 baseLineShift = attrConv.convertLength(baseLineShift, em_base=fs) # type: ignore 

1981 

1982 frag_lengths.append(stringWidth(text, ff, fs_pt)) # type: ignore 

1983 

1984 # When x, y, dx, or dy is a list, we calculate position for each char of 

1985 # text. 

1986 if any(isinstance(val, list) for val in (x1, y1, dx, dy)): 

1987 if has_x: 

1988 xlist = x1 if isinstance(x1, list) else [x1] 

1989 else: 

1990 xlist = [x + dx0 + sum(frag_lengths[:-1])] # type: ignore 

1991 if has_y: 

1992 ylist = y1 if isinstance(y1, list) else [y1] 

1993 else: 

1994 ylist = [y + dy0] # type: ignore 

1995 dxlist = dx if isinstance(dx, list) else [dx] 

1996 dylist = dy if isinstance(dy, list) else [dy] 

1997 last_x, last_y, last_char = xlist[0], ylist[0], "" 

1998 for char_x, char_y, char_dx, char_dy, char in itertools.zip_longest( 

1999 xlist, ylist, dxlist, dylist, text 

2000 ): 

2001 if char is None: 

2002 break 

2003 if char_dx is None: 

2004 char_dx = 0 

2005 if char_dy is None: 

2006 char_dy = 0 

2007 new_x = char_dx + ( 

2008 last_x + stringWidth(last_char, ff, fs_pt) 

2009 if char_x is None 

2010 else char_x 

2011 ) 

2012 new_y = char_dy + (last_y if char_y is None else char_y) 

2013 shape = String(new_x, -(new_y - baseLineShift), char) 

2014 self.applyStyleOnShape(shape, node) 

2015 if node_name(subnode) == "tspan": 

2016 self.applyStyleOnShape(shape, subnode) 

2017 gr.add(shape) 

2018 last_x = new_x 

2019 last_y = new_y 

2020 last_char = char 

2021 else: 

2022 new_x = (x1 + dx) if has_x else (x + dx0 + sum(frag_lengths[:-1])) # type: ignore 

2023 new_y = (y1 + dy) if has_y else (y + dy0) # type: ignore 

2024 shape = String(new_x, -(new_y - baseLineShift), text) # type: ignore 

2025 self.applyStyleOnShape(shape, node) 

2026 if node_name(subnode) == "tspan": 

2027 self.applyStyleOnShape(shape, subnode) 

2028 gr.add(shape) 

2029 

2030 gr.scale(1, -1) 

2031 

2032 return gr 

2033 

2034 def convertPath(self, node: Any) -> Optional[Any]: 

2035 """Convert an SVG <path> element to a ReportLab Path.""" 

2036 d = node.get("d") 

2037 if not d: 

2038 return None 

2039 normPath = normalise_svg_path(d) 

2040 path = Path() 

2041 points = path.points 

2042 # Track subpaths needing to be closed later 

2043 unclosed_subpath_pointers: List[int] = [] 

2044 subpath_start: List[float] = [] 

2045 lastop = "" 

2046 last_quadratic_cp: Optional[Tuple[float, float]] = None 

2047 

2048 for i in range(0, len(normPath), 2): 

2049 op: str 

2050 nums: List[float] 

2051 op, nums = normPath[i : i + 2] # type: ignore 

2052 

2053 if op in ("m", "M") and i > 0 and path.operators[-1] != _CLOSEPATH: 

2054 unclosed_subpath_pointers.append(len(path.operators)) 

2055 

2056 # moveto absolute 

2057 if op == "M": 

2058 path.moveTo(*nums) 

2059 subpath_start = points[-2:] 

2060 # lineto absolute 

2061 elif op == "L": 

2062 path.lineTo(*nums) 

2063 

2064 # moveto relative 

2065 elif op == "m": 

2066 if len(points) >= 2: 

2067 if lastop in ("Z", "z"): 

2068 starting_point = subpath_start 

2069 else: 

2070 starting_point = points[-2:] 

2071 xn, yn = starting_point[0] + nums[0], starting_point[1] + nums[1] 

2072 path.moveTo(xn, yn) 

2073 else: 

2074 path.moveTo(*nums) 

2075 subpath_start = points[-2:] 

2076 # lineto relative 

2077 elif op == "l": 

2078 xn, yn = points[-2] + nums[0], points[-1] + nums[1] 

2079 path.lineTo(xn, yn) 

2080 

2081 # horizontal/vertical line absolute 

2082 elif op == "H": 

2083 path.lineTo(nums[0], points[-1]) 

2084 elif op == "V": 

2085 path.lineTo(points[-2], nums[0]) 

2086 

2087 # horizontal/vertical line relative 

2088 elif op == "h": 

2089 path.lineTo(points[-2] + nums[0], points[-1]) 

2090 elif op == "v": 

2091 path.lineTo(points[-2], points[-1] + nums[0]) 

2092 

2093 # cubic bezier, absolute 

2094 elif op == "C": 

2095 path.curveTo(*nums) 

2096 elif op == "S": 

2097 x2, y2, xn, yn = nums 

2098 if len(points) < 4 or lastop not in {"c", "C", "s", "S"}: 

2099 xp, yp, x0, y0 = points[-2:] * 2 

2100 else: 

2101 xp, yp, x0, y0 = points[-4:] 

2102 xi, yi = x0 + (x0 - xp), y0 + (y0 - yp) 

2103 path.curveTo(xi, yi, x2, y2, xn, yn) 

2104 

2105 # cubic bezier, relative 

2106 elif op == "c": 

2107 xp, yp = points[-2:] 

2108 x1, y1, x2, y2, xn, yn = nums 

2109 path.curveTo(xp + x1, yp + y1, xp + x2, yp + y2, xp + xn, yp + yn) 

2110 elif op == "s": 

2111 x2, y2, xn, yn = nums 

2112 if len(points) < 4 or lastop not in {"c", "C", "s", "S"}: 

2113 xp, yp, x0, y0 = points[-2:] * 2 

2114 else: 

2115 xp, yp, x0, y0 = points[-4:] 

2116 xi, yi = x0 + (x0 - xp), y0 + (y0 - yp) 

2117 path.curveTo(xi, yi, x0 + x2, y0 + y2, x0 + xn, y0 + yn) 

2118 

2119 # quadratic bezier, absolute 

2120 elif op == "Q": 

2121 x0, y0 = points[-2:] 

2122 x1, y1, xn, yn = nums 

2123 last_quadratic_cp = (x1, y1) 

2124 (_, _), (x1, y1), (x2, y2), (_, _) = convert_quadratic_to_cubic_path( 

2125 (x0, y0), (x1, y1), (xn, yn) 

2126 ) 

2127 path.curveTo(x1, y1, x2, y2, xn, yn) 

2128 elif op == "T": 

2129 if last_quadratic_cp is not None: 

2130 xp, yp = last_quadratic_cp 

2131 else: 

2132 xp, yp = points[-2:] 

2133 x0, y0 = points[-2:] 

2134 xi, yi = x0 + (x0 - xp), y0 + (y0 - yp) 

2135 last_quadratic_cp = (xi, yi) 

2136 xn, yn = nums 

2137 ( 

2138 (_, _), 

2139 (x1, y1), 

2140 (x2, y2), 

2141 (_, _), 

2142 ) = convert_quadratic_to_cubic_path((x0, y0), (xi, yi), (xn, yn)) 

2143 path.curveTo(x1, y1, x2, y2, xn, yn) 

2144 

2145 # quadratic bezier, relative 

2146 elif op == "q": 

2147 x0, y0 = points[-2:] 

2148 x1, y1, xn, yn = nums 

2149 x1, y1, xn, yn = x0 + x1, y0 + y1, x0 + xn, y0 + yn 

2150 last_quadratic_cp = (x1, y1) 

2151 (_, _), (x1, y1), (x2, y2), (_, _) = convert_quadratic_to_cubic_path( 

2152 (x0, y0), (x1, y1), (xn, yn) 

2153 ) 

2154 path.curveTo(x1, y1, x2, y2, xn, yn) 

2155 elif op == "t": 

2156 if last_quadratic_cp is not None: 

2157 xp, yp = last_quadratic_cp 

2158 else: 

2159 xp, yp = points[-2:] 

2160 x0, y0 = points[-2:] 

2161 xn, yn = nums 

2162 xn, yn = x0 + xn, y0 + yn 

2163 xi, yi = x0 + (x0 - xp), y0 + (y0 - yp) 

2164 last_quadratic_cp = (xi, yi) 

2165 ( 

2166 (_, _), 

2167 (x1, y1), 

2168 (x2, y2), 

2169 (_, _), 

2170 ) = convert_quadratic_to_cubic_path((x0, y0), (xi, yi), (xn, yn)) 

2171 path.curveTo(x1, y1, x2, y2, xn, yn) 

2172 

2173 # elliptical arc 

2174 elif op in ("A", "a"): 

2175 rx, ry, phi, fA, fS, x2, y2 = nums 

2176 x1, y1 = points[-2:] 

2177 if op == "a": 

2178 x2 += x1 

2179 y2 += y1 

2180 if abs(rx) <= 1e-10 or abs(ry) <= 1e-10: 

2181 path.lineTo(x2, y2) 

2182 else: 

2183 bp = bezier_arc_from_end_points( 

2184 x1, y1, rx, ry, phi, int(fA), int(fS), x2, y2 

2185 ) 

2186 for _, _, x1, y1, x2, y2, xn, yn in bp: 

2187 path.curveTo(x1, y1, x2, y2, xn, yn) 

2188 

2189 # close path 

2190 elif op in ("Z", "z"): 

2191 path.closePath() 

2192 

2193 else: 

2194 logger.debug("Suspicious path operator: %s", op) 

2195 

2196 if op not in ("Q", "q", "T", "t"): 

2197 last_quadratic_cp = None 

2198 lastop = op 

2199 

2200 gr = Group() 

2201 self.applyStyleOnShape(path, node) 

2202 

2203 if path.operators[-1] != _CLOSEPATH: 

2204 unclosed_subpath_pointers.append(len(path.operators)) 

2205 

2206 if unclosed_subpath_pointers and path.fillColor is not None: 

2207 # ReportLab doesn't fill unclosed paths, so we are creating a copy 

2208 # of the path with all subpaths closed, but without stroke. 

2209 # https://bitbucket.org/rptlab/reportlab/issues/99/ 

2210 closed_path = NoStrokePath(copy_from=path) 

2211 for pointer in reversed(unclosed_subpath_pointers): 

2212 closed_path.operators.insert(pointer, _CLOSEPATH) 

2213 gr.add(closed_path) 

2214 path.fillColor = None 

2215 

2216 gr.add(path) 

2217 return gr 

2218 

2219 def convertImage(self, node: Any) -> Any: 

2220 """Convert an SVG <image> element to a ReportLab Image.""" 

2221 x, y, width, height = self.convert_length_attrs( 

2222 node, "x", "y", "width", "height" 

2223 ) 

2224 image = node._resolved_target 

2225 image = Image(int(x), int(y + height), int(width), int(height), image) 

2226 

2227 group = Group(image) 

2228 group.translate(0, (y + height) * 2) 

2229 group.scale(1, -1) 

2230 return group 

2231 

2232 def applyTransformOnGroup(self, transform: str, group: Any) -> None: 

2233 """Apply an SVG transformation to a ReportLab Group. 

2234 

2235 Args: 

2236 transform: The SVG transform attribute string. 

2237 group: The ReportLab Group to apply the transform to. 

2238 """ 

2239 tr = self.attrConverter.convertTransform(transform) 

2240 for op, values in tr: 

2241 if op == "scale": 

2242 if not isinstance(values, tuple): 

2243 values = (values, values) 

2244 group.scale(*values) 

2245 elif op == "translate": 

2246 if isinstance(values, (int, float)): 

2247 # From the SVG spec: If <ty> is not provided, it is assumed to 

2248 # be zero. 

2249 values = values, 0 

2250 group.translate(*values) 

2251 elif op == "rotate": 

2252 if not isinstance(values, tuple) or len(values) == 1: # type: ignore 

2253 group.rotate(values) 

2254 elif len(values) == 3: 

2255 angle, cx, cy = values 

2256 group.translate(cx, cy) 

2257 group.rotate(angle) 

2258 group.translate(-cx, -cy) 

2259 elif op == "skewX": 

2260 group.skew(values, 0) 

2261 elif op == "skewY": 

2262 group.skew(0, values) 

2263 elif op == "matrix" and len(values) == 6: # type: ignore 

2264 group.transform = mmult(group.transform, values) 

2265 else: 

2266 logger.debug("Ignoring transform: %s %s", op, values) 

2267 

2268 def applyStyleOnShape( 

2269 self, 

2270 shape: Any, 

2271 node: Any, 

2272 only_explicit: bool = False, 

2273 ) -> None: 

2274 """Apply styles from an SVG node to a ReportLab shape. 

2275 

2276 Args: 

2277 shape: The ReportLab shape to apply styles to. 

2278 node: The lxml node to get style attributes from. 

2279 only_explicit: If True, only apply explicitly defined attributes. 

2280 """ 

2281 # RLG-specific: all RLG shapes 

2282 """Apply style attributes of a sequence of nodes to an RL shape.""" 

2283 

2284 # tuple format: (svgAttributes, rlgAttr, converter, default) 

2285 mappingN = ( 

2286 (["fill"], "fillColor", "convertColor", ["black"]), 

2287 (["fill-opacity"], "fillOpacity", "convertOpacity", [1]), 

2288 (["fill-rule"], "fillMode", "convertFillRule", ["nonzero"]), 

2289 (["stroke"], "strokeColor", "convertColor", ["none"]), 

2290 (["stroke-width"], "strokeWidth", "convertLength", ["1"]), 

2291 (["stroke-opacity"], "strokeOpacity", "convertOpacity", [1]), 

2292 (["stroke-linejoin"], "strokeLineJoin", "convertLineJoin", ["0"]), 

2293 (["stroke-linecap"], "strokeLineCap", "convertLineCap", ["0"]), 

2294 (["stroke-dasharray"], "strokeDashArray", "convertDashArray", ["none"]), 

2295 ) 

2296 mappingF = ( 

2297 ( 

2298 ["font-family", "font-weight", "font-style"], 

2299 "fontName", 

2300 "convertFontFamily", 

2301 [DEFAULT_FONT_NAME, DEFAULT_FONT_WEIGHT, DEFAULT_FONT_STYLE], 

2302 ), 

2303 ( 

2304 ["font-size"], 

2305 "fontSize", 

2306 "convertLengthToPt", 

2307 [f"{DEFAULT_FONT_SIZE}pt"], 

2308 ), 

2309 (["text-anchor"], "textAnchor", "id", ["start"]), 

2310 ) 

2311 

2312 if shape.__class__ == Group: 

2313 # Recursively apply style on Group subelements 

2314 for subshape in shape.contents: 

2315 self.applyStyleOnShape(subshape, node, only_explicit=only_explicit) 

2316 return 

2317 

2318 ac = self.attrConverter 

2319 for mapping in (mappingN, mappingF): 

2320 if shape.__class__ != String and mapping == mappingF: 

2321 continue 

2322 for svgAttrNames, rlgAttr, func, defaults in mapping: 

2323 svgAttrValues = [] 

2324 for index, svgAttrName in enumerate(svgAttrNames): 

2325 svgAttrValue = ac.findAttr(node, svgAttrName) 

2326 if svgAttrValue == "": 

2327 if only_explicit: 

2328 continue 

2329 if ( 

2330 svgAttrName == "fill-opacity" 

2331 and getattr(shape, "fillColor", None) is not None 

2332 and getattr(shape.fillColor, "alpha", 1) != 1 # type: ignore 

2333 ): 

2334 svgAttrValue = shape.fillColor.alpha # type: ignore 

2335 elif ( 

2336 svgAttrName == "stroke-opacity" 

2337 and getattr(shape, "strokeColor", None) is not None 

2338 and getattr(shape.strokeColor, "alpha", 1) != 1 # type: ignore 

2339 ): 

2340 svgAttrValue = shape.strokeColor.alpha # type: ignore 

2341 else: 

2342 svgAttrValue = defaults[index] # type: ignore 

2343 if svgAttrValue == "currentColor": 

2344 svgAttrValue = ( 

2345 ac.findAttr(node.parent, "color") or defaults[index] # type: ignore 

2346 ) 

2347 if isinstance(svgAttrValue, str): 

2348 svgAttrValue = svgAttrValue.replace("!important", "").strip() 

2349 svgAttrValues.append(svgAttrValue) 

2350 try: 

2351 meth = getattr(ac, func) 

2352 setattr(shape, rlgAttr, meth(*svgAttrValues)) 

2353 except (AttributeError, KeyError, ValueError): 

2354 exc_type = sys.exc_info()[0].__name__ 

2355 logger.debug( 

2356 "applyStyleOnShape setattr({},{!r},{}(*{!r}))" 

2357 " caused {} exception".format( 

2358 shape.__class__.__name__, 

2359 rlgAttr, 

2360 meth.__name__, 

2361 svgAttrValues, 

2362 exc_type, 

2363 ) 

2364 ) 

2365 if getattr(shape, "fillOpacity", None) is not None and shape.fillColor: 

2366 shape.fillColor.alpha = shape.fillOpacity 

2367 if getattr(shape, "strokeWidth", None) == 0: 

2368 # Quoting from the PDF 1.7 spec: 

2369 # A line width of 0 denotes the thinnest line that can be rendered at 

2370 # device resolution: 1 device pixel wide. However, some devices cannot 

2371 # reproduce 1-pixel lines, and on high-resolution devices, they are 

2372 # nearly invisible. Since the results of rendering such zero-width 

2373 # lines are device-dependent, their use is not recommended. 

2374 shape.strokeColor = None 

2375 

2376 

2377def svg2rlg( 

2378 path: Union[str, os.PathLike[str], BinaryIO, TextIO], 

2379 resolve_entities: bool = False, 

2380 **kwargs: Any, 

2381) -> Optional[Drawing]: 

2382 """Convert an SVG file to a ReportLab Drawing object. 

2383 

2384 Args: 

2385 path: A file path, file-like object, or pathlib.Path to the SVG file. 

2386 resolve_entities: Whether to resolve XML entities (default False). 

2387 **kwargs: Additional keyword arguments for the SvgRenderer. 

2388 

2389 Returns: 

2390 A ReportLab Drawing object, or None if the file cannot be processed. 

2391 """ 

2392 if isinstance(path, pathlib.Path): 

2393 path = str(path) 

2394 

2395 # unzip .svgz file into .svg 

2396 unzipped = False 

2397 if isinstance(path, str) and os.path.splitext(path)[1].lower() == ".svgz": 

2398 with gzip.open(path, "rb") as f_in, open(path[:-1], "wb") as f_out: 

2399 shutil.copyfileobj(f_in, f_out) 

2400 path = path[:-1] 

2401 unzipped = True 

2402 

2403 svg_root = load_svg_file(path, resolve_entities=resolve_entities) 

2404 if svg_root is None: 

2405 return None 

2406 

2407 # convert to a RLG drawing 

2408 svgRenderer = SvgRenderer(path, **kwargs) 

2409 drawing = svgRenderer.render(svg_root) 

2410 

2411 # remove unzipped .svgz file (.svg) 

2412 if unzipped: 

2413 os.remove(path) 

2414 

2415 return drawing 

2416 

2417 

2418def nudge_points(points: List[float]) -> None: 

2419 """Nudge the first coordinate if all coordinate pairs are identical. 

2420 

2421 This is a workaround for a ReportLab issue where shapes of size zero 

2422 are not rendered, even if they have a visible stroke. 

2423 

2424 Args: 

2425 points: A list of coordinates [x1, y1, x2, y2, ...]. 

2426 """ 

2427 if not points: 

2428 return 

2429 if len(points) < 4: 

2430 return 

2431 x = points[0] 

2432 y = points[1] 

2433 for i in range(2, len(points) - 1, 2): 

2434 if x != points[i] or y != points[i + 1]: 

2435 break 

2436 else: 

2437 # All points were identical, so we nudge. 

2438 points[0] *= 1.0000001 

2439 

2440 

2441def load_svg_file( 

2442 path: Union[str, os.PathLike[str]], resolve_entities: bool = False 

2443) -> Optional[Any]: 

2444 """Load an SVG file and return the root lxml node. 

2445 

2446 Args: 

2447 path: A file path or file-like object for the SVG file. 

2448 resolve_entities: Whether to resolve XML entities. 

2449 

2450 Returns: 

2451 The root lxml node of the SVG document, or None on failure. 

2452 """ 

2453 parser = etree.XMLParser( 

2454 remove_comments=True, recover=True, resolve_entities=resolve_entities 

2455 ) 

2456 try: 

2457 doc = etree.parse(path, parser=parser) 

2458 svg_root = doc.getroot() 

2459 except Exception as exc: 

2460 logger.error("Failed to load input file! (%s)", exc) 

2461 return None 

2462 else: 

2463 return svg_root 

2464 

2465 

2466def node_name(node: Any) -> Optional[str]: 

2467 """Return the name of an lxml node without the namespace prefix. 

2468 

2469 Args: 

2470 node: The lxml node. 

2471 

2472 Returns: 

2473 The node name as a string, or None if the node is invalid. 

2474 """ 

2475 try: 

2476 return node.tag.split("}")[-1] 

2477 except AttributeError: 

2478 return None 

2479 

2480 

2481def iter_text_node(node: Any, preserve_space: bool, level: int = 0) -> Any: 

2482 """Recursively iterate through a text node and its children. 

2483 

2484 This generator yields the node, its text, and its tail text, handling 

2485 whitespace according to the 'xml:space' attribute. 

2486 

2487 Args: 

2488 node: The lxml node to start iteration from. 

2489 preserve_space: Whether to preserve whitespace. 

2490 level: The current recursion level. 

2491 

2492 Yields: 

2493 A tuple of (node, text, is_tail). 

2494 """ 

2495 level0 = level == 0 

2496 text = ( 

2497 clean_text( 

2498 node.text, 

2499 preserve_space, 

2500 strip_start=level0, 

2501 strip_end=(level0 and len(node.getchildren()) == 0), 

2502 ) 

2503 if node.text 

2504 else None 

2505 ) 

2506 

2507 yield node, text, False 

2508 

2509 for child in node.iter_children(): 

2510 yield from iter_text_node(child, preserve_space, level=level + 1) 

2511 

2512 if level > 0: # We are not interested by tail of main node. 

2513 strip_end = level <= 1 and node.getnext() is None 

2514 tail = ( 

2515 clean_text(node.tail, preserve_space, strip_end=strip_end) 

2516 if node.tail 

2517 else None 

2518 ) 

2519 if tail not in (None, ""): 

2520 yield node.parent, tail, True 

2521 

2522 

2523def clean_text( 

2524 text: Optional[str], 

2525 preserve_space: bool, 

2526 strip_start: bool = False, 

2527 strip_end: bool = False, 

2528) -> Optional[str]: 

2529 """Clean text content according to SVG whitespace handling rules. 

2530 

2531 Args: 

2532 text: The text content to clean. 

2533 preserve_space: Whether to preserve whitespace. 

2534 strip_start: Whether to strip leading whitespace. 

2535 strip_end: Whether to strip trailing whitespace. 

2536 

2537 Returns: 

2538 The cleaned text, or None if the input was None. 

2539 """ 

2540 if text is None: 

2541 return None 

2542 text = text.replace("\r\n", " ").replace("\n", " ").replace("\t", " ") 

2543 if not preserve_space: 

2544 if strip_start: 

2545 text = text.lstrip() 

2546 if strip_end: 

2547 text = text.rstrip() 

2548 while " " in text: 

2549 text = text.replace(" ", " ") 

2550 return text 

2551 

2552 

2553def copy_shape_properties(source_shape: Any, dest_shape: Any) -> None: 

2554 """Copy properties from one ReportLab shape to another. 

2555 

2556 Args: 

2557 source_shape: The shape to copy properties from. 

2558 dest_shape: The shape to copy properties to. 

2559 """ 

2560 for prop, val in source_shape.getProperties().items(): 

2561 try: 

2562 setattr(dest_shape, prop, val) 

2563 except AttributeError: 

2564 pass