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
« 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.
3This module provides a converter from SVG to ReportLab Graphics (RLG) drawings.
4It handles basic shapes, paths, and simple text elements.
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.
10Example:
11 To convert an SVG file to a ReportLab Drawing object::
13 from svglib.svglib import svg2rlg
14 drawing = svg2rlg("foo.svg")
16 To convert an SVG file to a PDF from the command-line::
18 $ python -m svglib foo.svg
19"""
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
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
61try:
62 from reportlab.graphics.transform import mmult
63except ImportError:
64 # Before Reportlab 3.5.61
65 from reportlab.graphics.shapes import mmult
67import cssselect2
68import tinycss2
69from lxml import etree
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)
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)
93# SVG user units are px; ReportLab works in points. 1 px = 0.75 pt (96 dpi / 72 dpi).
94PX_TO_PT = 0.75
97def _convert_palette_to_rgba(image: PILImage.Image) -> PILImage.Image:
98 """Convert a palette-based image with transparency to RGBA format.
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.
104 Args:
105 image: The input PIL Image object.
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
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.
126 This function serves as a backward-compatible wrapper for the font
127 registration logic defined in the `svglib.fonts` module.
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).
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)
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.
148 This function serves as a backward-compatible wrapper for the font
149 finding logic defined in the `svglib.fonts` module.
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.
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)
163XML_NS = "http://www.w3.org/XML/1998/namespace"
164INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape"
166# A sentinel to identify a situation where a node reference a fragment not yet defined.
167DELAYED = object()
169logger = logging.getLogger(__name__)
171Box = namedtuple("Box", ["x", "y", "width", "height"])
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)
228split_whitespace = re.compile(r"[^ \t\r\n\f]+").findall
231class NoStrokePath(Path):
232 """A Path object that never has a stroke width.
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 """
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__))
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
254class ClippingPath(Path):
255 """A Path object used for defining a clipping region.
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 """
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
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
278class CSSMatcher(cssselect2.Matcher):
279 """A CSS matcher to handle styles defined in SVG <style> elements."""
281 def add_styles(self, style_content: str) -> None:
282 """Parse a string of CSS rules and add them to the matcher.
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 )
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)
306# Attribute converters (from SVG to RLG)
309class AttributeConverter:
310 """An abstract class for converting SVG attributes to ReportLab properties."""
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
318 def set_box(self, main_box: Optional[Box]) -> None:
319 """Set the main viewbox for resolving percentage-based units.
321 Args:
322 main_box: A Box tuple representing the main viewbox.
323 """
324 self.main_box = main_box
326 def parseMultiAttributes(self, line: str) -> Dict[str, str]:
327 """Parse a compound attribute string into a dictionary.
329 Args:
330 line: A string of semicolon-separated style attributes.
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]
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
345 return new_attrs
347 def findAttr(self, svgNode: Any, name: str) -> str:
348 """Find an attribute value, searching the node and its ancestors.
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.
355 Args:
356 svgNode: The lxml node to start the search from.
357 name: The name of the attribute to find.
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"
375 attr_value = svgNode.attrib.get(name, "").strip()
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 ""
383 def getAllAttributes(self, svgNode: Any) -> Dict[str, str]:
384 """Return a dictionary of all attributes of a node and its ancestors.
386 Args:
387 svgNode: The lxml node to get attributes from.
389 Returns:
390 A dictionary of all applicable attributes.
391 """
392 dict = {}
394 if node_name(svgNode.getparent()) == "g":
395 dict.update(self.getAllAttributes(svgNode.getparent()))
397 style = svgNode.attrib.get("style")
398 if style:
399 d = self.parseMultiAttributes(style)
400 dict.update(d)
402 for key, value in svgNode.attrib.items():
403 if key != "style":
404 dict[key] = value
406 return dict
408 def id(self, svgAttr: str) -> str:
409 """Return the attribute value as is."""
410 return svgAttr
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.
418 Args:
419 svgAttr: The SVG transform attribute string.
421 Returns:
422 A list of tuples, where each tuple contains the transform
423 operation and its arguments.
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()
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()
454 if len(ops_list) != len(indices):
455 logger.warning("Unable to parse transform expression %r", svgAttr)
456 return []
458 result = []
459 for i, op in enumerate(ops_list):
460 result.append((op, indices[i]))
462 return result
465class Svg2RlgAttributeConverter(AttributeConverter):
466 """A concrete attribute converter for SVG to ReportLab Graphics."""
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()
477 @staticmethod
478 def identity_color_converter(c: Any) -> Any:
479 """A default color converter that returns the color as is."""
480 return c
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(",", " "))
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).
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.
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
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
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
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)]
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
621 def convertOpacity(self, svgAttr: str) -> float:
622 """Convert an opacity string to a float."""
623 return float(svgAttr)
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, "")
632 def convertColor(self, svgAttr: str) -> Any:
633 """Convert an SVG color string to a ReportLab color object.
635 Args:
636 svgAttr: The SVG color string (e.g., "#FF0000", "blue").
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
645 # Gradient/pattern references are handled at the renderer level.
646 if text.startswith("url("):
647 return None
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
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]
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]
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
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
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.
701 Args:
702 fontAttr: The SVG font-family attribute string.
703 weightAttr: The font-weight attribute string.
704 styleAttr: The font-style attribute string.
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)
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
734class NodeTracker(cssselect2.ElementWrapper):
735 """A wrapper for lxml nodes to track attribute usage.
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 """
741 def __init__(self, *args: Any, **kwargs: Any) -> None:
742 super().__init__(*args, **kwargs)
743 self.usedAttrs: List[str] = []
745 def __repr__(self) -> str:
746 return f"<NodeTracker for node {self.etree_element}>"
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, "")
754 def __getattr__(self, name: str) -> Any:
755 """Forward attribute access to the wrapped lxml node."""
756 return getattr(self.etree_element, name)
758 def apply_rules(self, rules: Any) -> None:
759 """Apply CSS rules to the wrapped node.
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")
776class CircularRefError(Exception):
777 """Exception raised for circular references in SVG files."""
779 pass
782class ExternalSVG:
783 """A class to handle external SVG files referenced via xlink:href."""
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
796 def get_fragment(self, fragment: str) -> Any:
797 """Get a defined fragment from the external SVG file.
799 Args:
800 fragment: The ID of the fragment to retrieve.
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)
811_BEZIER_KAPPA = 0.5523 # cubic bezier constant for circle approximation
813_GRADIENT_URL_RE = re.compile(r"url\(#([^)]+)\)")
816def _shape_to_pdf_path(canvas, shape):
817 """Convert a ReportLab shape to a PDFPathObject for use as a clip path.
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
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
913class _LinearGradientShape(DirectDraw):
914 """Fills a clipped region with a linear gradient via PDF shading."""
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
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()
941class _RadialGradientShape(DirectDraw):
942 """Fills a clipped region with a radial gradient via PDF shading."""
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
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()
967# ## the main meat ###
970class SvgRenderer:
971 """A class to render an SVG file into a ReportLab Drawing.
973 This class walks the SVG DOM and converts SVG elements into their
974 corresponding ReportLab Graphics objects.
975 """
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()
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]
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 )
1024 def render(self, svg_node: Any) -> Drawing:
1025 """Render an SVG node into a ReportLab Drawing.
1027 Args:
1028 svg_node: The root lxml node of the SVG document.
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)
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)
1044 main_group.translate(0 - view_box.x, -view_box.height - view_box.y)
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
1053 def renderNode(self, node: NodeTracker, parent: Optional[Any] = None) -> None:
1054 """Render a single SVG node and add it to a parent group.
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)
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
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)
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)
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"
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
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
1180 grad_units = node.attrib.get("gradientUnits", "objectBoundingBox")
1181 spread = node.attrib.get("spreadMethod", "pad")
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
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)
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
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))
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 }
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"])
1245 self.gradient_defs[grad_id] = grad_def
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
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
1274 stops = grad_def.get("stops", [])
1275 if not stops:
1276 return item
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")
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
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 )
1317 group = Group()
1318 group.add(grad_shape)
1319 group.add(item)
1320 return group
1322 def get_clippath(self, node: NodeTracker) -> Optional[Any]:
1323 """Get the clipping path object referenced by a node's 'clip-path' attribute.
1325 Args:
1326 node: The NodeTracker object for the SVG node.
1328 Returns:
1329 A ClippingPath object, or None if no valid clipping path is found.
1330 """
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
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
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
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
1387 def print_unused_attributes(self, node: NodeTracker) -> None:
1388 """Print any attributes that were not used during rendering.
1390 This is a debugging helper to identify unsupported SVG attributes.
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)
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.
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)
1416 def xlink_href_target(self, node: NodeTracker, group: Optional[Any] = None) -> Any:
1417 """Resolve an xlink:href attribute to its target.
1419 The target can be an internal fragment, an external SVG file, or a
1420 raster image.
1422 Args:
1423 node: The NodeTracker object for the SVG node with the href attribute.
1424 group: The parent group, used for delayed rendering.
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
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)
1446 return _convert_palette_to_rgba(PILImage.open(bytes_stream))
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
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
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
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
1509 def renderTitle_(self, node: NodeTracker) -> None:
1510 """Handle the <title> element (currently a no-op)."""
1511 pass
1513 def renderDesc_(self, node: NodeTracker) -> None:
1514 """Handle the <desc> element (currently a no-op)."""
1515 pass
1517 def get_box(self, svg_node: NodeTracker, default_box: bool = False) -> Box:
1518 """Get the viewBox or dimensions of an SVG node.
1520 Args:
1521 svg_node: The NodeTracker for the SVG node.
1522 default_box: If True, use width/height as a fallback.
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
1539 def renderSvg(self, node: NodeTracker, outermost: bool = False) -> Any:
1540 """Render an <svg> element into a ReportLab Group.
1542 Args:
1543 node: The NodeTracker for the <svg> element.
1544 outermost: True if this is the root <svg> element.
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)
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)
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)
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)
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
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"
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)
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)
1641 x_scale = y_scale = uniform_scale
1642 group.scale(x_scale, y_scale * (-1 if outermost else 1))
1644 return group
1646 def renderG(self, node: NodeTracker, clipping: Optional[Any] = None) -> Any:
1647 """Render a <g> element into a ReportLab Group.
1649 Args:
1650 node: The NodeTracker for the <g> element.
1651 clipping: An optional clipping path to apply.
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)
1664 if transform:
1665 self.shape_converter.applyTransformOnGroup(transform, gr)
1667 return gr
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.
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]
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
1704 return None
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 "")
1711 def renderSymbol(self, node: NodeTracker) -> Any:
1712 """Render a <symbol> element as a ReportLab Group."""
1713 return self.renderG(node)
1715 def renderA(self, node: NodeTracker) -> Any:
1716 """Render an <a> element as a ReportLab Group (no linking support)."""
1717 return self.renderG(node)
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.
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.
1732 Returns:
1733 A ReportLab Group containing the rendered content.
1734 """
1735 if group is None:
1736 group = Group()
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.
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
1765class SvgShapeConverter:
1766 """An abstract class for converting SVG shapes to another format.
1768 Subclasses should implement `convertX` methods for each SVG shape `X`
1769 (e.g., `convertRect`, `convertCircle`).
1770 """
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
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")]
1787class Svg2RlgShapeConverter(SvgShapeConverter):
1788 """A class for converting SVG shapes to ReportLab Graphics shapes."""
1790 def convertShape(self, name: str, node: Any, clipping: Optional[Any] = None) -> Any:
1791 """Convert an SVG shape by calling the appropriate `convertX` method.
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.
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
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.
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.
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 ]
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)
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)
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)
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)
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
1893 nudge_points(points)
1894 polyline = PolyLine(points)
1895 self.applyStyleOnShape(polyline, node)
1896 has_fill = self.attrConverter.findAttr(node, "fill") not in ("", "none")
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
1909 return polyline
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)
1923 return shape
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
1934 gr = Group()
1936 frag_lengths: List[float] = []
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
1982 frag_lengths.append(stringWidth(text, ff, fs_pt)) # type: ignore
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)
2030 gr.scale(1, -1)
2032 return gr
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
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
2053 if op in ("m", "M") and i > 0 and path.operators[-1] != _CLOSEPATH:
2054 unclosed_subpath_pointers.append(len(path.operators))
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)
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)
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])
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])
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)
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)
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)
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)
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)
2189 # close path
2190 elif op in ("Z", "z"):
2191 path.closePath()
2193 else:
2194 logger.debug("Suspicious path operator: %s", op)
2196 if op not in ("Q", "q", "T", "t"):
2197 last_quadratic_cp = None
2198 lastop = op
2200 gr = Group()
2201 self.applyStyleOnShape(path, node)
2203 if path.operators[-1] != _CLOSEPATH:
2204 unclosed_subpath_pointers.append(len(path.operators))
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
2216 gr.add(path)
2217 return gr
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)
2227 group = Group(image)
2228 group.translate(0, (y + height) * 2)
2229 group.scale(1, -1)
2230 return group
2232 def applyTransformOnGroup(self, transform: str, group: Any) -> None:
2233 """Apply an SVG transformation to a ReportLab Group.
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)
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.
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."""
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 )
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
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
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.
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.
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)
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
2403 svg_root = load_svg_file(path, resolve_entities=resolve_entities)
2404 if svg_root is None:
2405 return None
2407 # convert to a RLG drawing
2408 svgRenderer = SvgRenderer(path, **kwargs)
2409 drawing = svgRenderer.render(svg_root)
2411 # remove unzipped .svgz file (.svg)
2412 if unzipped:
2413 os.remove(path)
2415 return drawing
2418def nudge_points(points: List[float]) -> None:
2419 """Nudge the first coordinate if all coordinate pairs are identical.
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.
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
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.
2446 Args:
2447 path: A file path or file-like object for the SVG file.
2448 resolve_entities: Whether to resolve XML entities.
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
2466def node_name(node: Any) -> Optional[str]:
2467 """Return the name of an lxml node without the namespace prefix.
2469 Args:
2470 node: The lxml node.
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
2481def iter_text_node(node: Any, preserve_space: bool, level: int = 0) -> Any:
2482 """Recursively iterate through a text node and its children.
2484 This generator yields the node, its text, and its tail text, handling
2485 whitespace according to the 'xml:space' attribute.
2487 Args:
2488 node: The lxml node to start iteration from.
2489 preserve_space: Whether to preserve whitespace.
2490 level: The current recursion level.
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 )
2507 yield node, text, False
2509 for child in node.iter_children():
2510 yield from iter_text_node(child, preserve_space, level=level + 1)
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
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.
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.
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
2553def copy_shape_properties(source_shape: Any, dest_shape: Any) -> None:
2554 """Copy properties from one ReportLab shape to another.
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