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

This module provides a converter from SVG to ReportLab Graphics (RLG) drawings.
It handles basic shapes, paths, and simple text elements.

The intended usage is either as a module within other projects or from the
command-line for converting SVG files to PDF. It also supports gzip-compressed
SVG files with the .svgz extension.

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

        from svglib.svglib import svg2rlg
        drawing = svg2rlg("foo.svg")

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

        $ python -m svglib foo.svg
"""

import base64
import copy
import gzip
import itertools
import logging
import os
import pathlib
import re
import shlex
import shutil
from collections import defaultdict, namedtuple
from io import BytesIO
from typing import (
    Any,
    BinaryIO,
    Optional,
    TextIO,
    TypedDict,
    Union,
    cast,
)

from PIL import Image as PILImage
from reportlab.graphics.renderSVG import SVGCanvas
from reportlab.graphics.shapes import (
    _CLOSEPATH,
    Circle,
    DirectDraw,
    Drawing,
    Ellipse,
    Group,
    Image,
    Line,
    Path,
    Polygon,
    PolyLine,
    Rect,
    Shape,
    SolidShape,
    String,
    _renderPath,
)
from reportlab.lib import colors
from reportlab.lib.units import toLength
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen.canvas import FILL_EVEN_ODD, FILL_NON_ZERO
from reportlab.pdfgen.pdfimages import PDFImage

try:
    from reportlab.graphics.transform import mmult
except ImportError:
    # Before Reportlab 3.5.61
    from reportlab.graphics.shapes import mmult

import cssselect2
import tinycss2
from lxml import etree

from .fonts import (
    DEFAULT_FONT_NAME,
    DEFAULT_FONT_SIZE,
    DEFAULT_FONT_STYLE,
    DEFAULT_FONT_WEIGHT,
    get_global_font_map,
)
from .fonts import (
    find_font as _fonts_find_font,
)

# To keep backward compatibility, since those functions where previously part of
# the svglib module
from .fonts import (
    register_font as _fonts_register_font,
)
from .utils import (
    bezier_arc_from_end_points,
    convert_quadratic_to_cubic_path,
    normalise_svg_path,
)

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

# An SVG source accepted by the public API: a path, a path-like object, or an
# already-open binary/text file object.
SVGSource = Union[str, os.PathLike[str], BinaryIO, TextIO]

# A single gradient stop: (offset, ReportLab color). The color is untyped
# because ReportLab ships no stubs.
GradientStop = tuple[float, Any]


class _GradientDefBase(TypedDict):
    """Keys always present on a parsed gradient definition."""

    type: str  # "linear" or "radial"
    gradientUnits: str
    spreadMethod: str
    stops: list[GradientStop]
    href: Optional[str]


class _GradientDef(_GradientDefBase, total=False):
    """Geometry keys present depending on ``type`` (linear vs radial)."""

    # linearGradient
    x1: float
    y1: float
    x2: float
    y2: float
    # radialGradient
    cx: float
    cy: float
    r: float
    fx: float
    fy: float


def _convert_palette_to_rgba(image: PILImage.Image) -> PILImage.Image:
    """Convert a palette-based image with transparency to RGBA format.

    This function checks if a PIL Image is in palette mode ('P') and has
    transparency information. If so, it converts the image to RGBA to prevent
    potential warnings or errors during processing.

    Args:
        image: The input PIL Image object.

    Returns:
        The converted RGBA PIL Image object if changes were made, otherwise
        the original image.
    """
    if image.mode == "P" and "transparency" in image.info:
        # Convert palette image with transparency to RGBA
        return image.convert("RGBA")
    return image


def register_font(
    font_name: str,
    font_path: Optional[str] = None,
    weight: str = "normal",
    style: str = "normal",
    rlgFontName: Optional[str] = None,
) -> tuple[Optional[str], bool]:
    """Register a font for use in SVG processing.

    This function serves as a backward-compatible wrapper for the font
    registration logic defined in the `svglib.fonts` module.

    Args:
        font_name: The name of the font to register.
        font_path: The file path to the font file (optional).
        weight: The font weight (e.g., 'normal', 'bold').
        style: The font style (e.g., 'normal', 'italic').
        rlgFontName: The ReportLab-specific font name (optional).

    Returns:
        A tuple containing the registered font name and a boolean indicating
        if the registration was successful.
    """
    return _fonts_register_font(font_name, font_path, weight, style, rlgFontName)


def find_font(
    font_name: str, weight: str = "normal", style: str = "normal"
) -> tuple[str, bool]:
    """Find a registered font by its properties.

    This function serves as a backward-compatible wrapper for the font
    finding logic defined in the `svglib.fonts` module.

    Args:
        font_name: The name of the font to find.
        weight: The font weight to match.
        style: The font style to match.

    Returns:
        A tuple containing the matched font name and a boolean indicating
        if an exact match was found.
    """
    return _fonts_find_font(font_name, weight, style)


XML_NS = "http://www.w3.org/XML/1998/namespace"
INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape"

# A sentinel to identify a situation where a node reference a fragment not yet defined.
DELAYED = object()

logger = logging.getLogger(__name__)

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

# SVG feature strings supported by svglib, used for <switch> evaluation.
# Stored in lowercase for case-insensitive matching.
# SVG 1.1: https://www.w3.org/TR/SVG11/feature.html
# SVG 1.2 Tiny: https://www.w3.org/TR/SVGTiny12/feature.html
SUPPORTED_SVG_FEATURES = frozenset(
    f.lower()
    for f in [
        # SVG 1.1 features
        "http://www.w3.org/TR/SVG11/feature#SVG",
        "http://www.w3.org/TR/SVG11/feature#SVGDOM",
        "http://www.w3.org/TR/SVG11/feature#SVG-static",
        "http://www.w3.org/TR/SVG11/feature#SVGDOM-static",
        "http://www.w3.org/TR/SVG11/feature#CoreAttribute",
        "http://www.w3.org/TR/SVG11/feature#Structure",
        "http://www.w3.org/TR/SVG11/feature#BasicStructure",
        "http://www.w3.org/TR/SVG11/feature#ConditionalProcessing",
        "http://www.w3.org/TR/SVG11/feature#Style",
        "http://www.w3.org/TR/SVG11/feature#ViewportAttribute",
        "http://www.w3.org/TR/SVG11/feature#Shape",
        "http://www.w3.org/TR/SVG11/feature#BasicText",
        "http://www.w3.org/TR/SVG11/feature#Image",
        "http://www.w3.org/TR/SVG11/feature#BasicPaintAttribute",
        "http://www.w3.org/TR/SVG11/feature#OpacityAttribute",
        "http://www.w3.org/TR/SVG11/feature#BasicGraphicsAttribute",
        "http://www.w3.org/TR/SVG11/feature#Clip",
        "http://www.w3.org/TR/SVG11/feature#BasicClip",
        "http://www.w3.org/TR/SVG11/feature#ColorProfile",
        "http://www.w3.org/TR/SVG11/feature#Gradient",
        "http://www.w3.org/TR/SVG11/feature#Pattern",
        "http://www.w3.org/TR/SVG11/feature#XlinkAttribute",
        "http://www.w3.org/TR/SVG11/feature#Font",
        "http://www.w3.org/TR/SVG11/feature#BasicFont",
        "http://www.w3.org/TR/SVG11/feature#PaintAttribute",
        "http://www.w3.org/TR/SVG11/feature#GraphicsAttribute",
        # SVG 1.2 Tiny features
        "http://www.w3.org/Graphics/SVG/feature/1.2/#SVG-static",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#CoreAttribute",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Structure",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#ConditionalProcessing",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#ConditionalProcessingAttribute",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Image",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Shape",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Text",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#PaintAttribute",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#OpacityAttribute",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#GraphicsAttribute",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Gradient",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#XlinkAttribute",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Font",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#Hyperlinking",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#ExternalResourcesRequired",
        "http://www.w3.org/Graphics/SVG/feature/1.2/#NavigationAttribute",
    ]
)

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


class NoStrokePath(Path):
    """A Path object that never has a stroke width.

    This class is used to create filled shapes from unclosed paths, where
    only the fill should be rendered and the stroke should be ignored.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the path, optionally copying state from another shape."""
        copy_from = kwargs.pop("copy_from", None)
        super().__init__(*args, **kwargs)
        if copy_from:
            self.__dict__.update(copy.deepcopy(copy_from.__dict__))

    def getProperties(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
        """Return the properties of the path, ensuring no stroke is applied."""
        props: dict[str, Any] = super().getProperties(*args, **kwargs)
        if "strokeWidth" in props:
            props["strokeWidth"] = 0
        if "strokeColor" in props:
            props["strokeColor"] = None
        return props


class ClippingPath(Path):
    """A Path object used for defining a clipping region.

    This path will not be rendered with a fill or stroke but will be used
    as a clipping mask for other shapes.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the clipping path, optionally copying state from another shape."""
        copy_from = kwargs.pop("copy_from", None)
        Path.__init__(self, *args, **kwargs)
        if copy_from:
            self.__dict__.update(copy.deepcopy(copy_from.__dict__))
        self.isClipPath = 1

    def getProperties(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
        """Return the properties of the path, ensuring no fill or stroke."""
        props: dict[str, Any] = Path.getProperties(self, *args, **kwargs)
        if "fillColor" in props:
            props["fillColor"] = None
        if "strokeColor" in props:
            props["strokeColor"] = None
        return props


class CSSMatcher(cssselect2.Matcher):
    """A CSS matcher to handle styles defined in SVG <style> elements."""

    def add_styles(self, style_content: str) -> None:
        """Parse a string of CSS rules and add them to the matcher.

        Args:
            style_content: A string containing CSS rules.
        """
        rules = tinycss2.parse_stylesheet(
            style_content, skip_comments=True, skip_whitespace=True
        )

        for rule in rules:
            if not rule.prelude or rule.type == "at-rule":
                continue
            selectors = cssselect2.compile_selector_list(rule.prelude)
            selector_string = tinycss2.serialize(rule.prelude)
            content_dict = {
                attr.split(":")[0].strip(): attr.split(":")[1].strip()
                for attr in tinycss2.serialize(rule.content).split(";")
                if ":" in attr
            }
            payload = (selector_string, content_dict)
            for selector in selectors:
                self.add_selector(selector, payload)


# Attribute converters (from SVG to RLG)


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

    def __init__(self) -> None:
        """Initialize the converter's per-render state (CSS rules, boxes, caches)."""
        self.css_rules: Optional[CSSMatcher] = None
        self.main_box: Optional[Box] = None
        # Resolved once at render time from the root <svg> font-size; used by rem.
        self.root_font_size: float = DEFAULT_FONT_SIZE / PX_TO_PT  # 16 px default

    def set_box(self, main_box: Optional[Box]) -> None:
        """Set the main viewbox for resolving percentage-based units.

        Args:
            main_box: A Box tuple representing the main viewbox.
        """
        self.main_box = main_box

    def parseMultiAttributes(self, line: str) -> dict[str, str]:
        """Parse a compound attribute string into a dictionary.

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

        Returns:
            A dictionary of attribute key-value pairs.
        """
        attrs = line.split(";")
        attrs = [a.strip() for a in attrs]
        attrs = [a for a in attrs if len(a) > 0]

        new_attrs = {}
        for a in attrs:
            k, v = a.split(":")
            k, v = (s.strip() for s in (k, v))
            new_attrs[k] = v

        return new_attrs

    def findAttr(self, svgNode: Any, name: str) -> str:
        """Find an attribute value, searching the node and its ancestors.

        The search order is:
        1. The node's own attributes.
        2. The node's 'style' attribute.
        3. The node's parent, recursively.

        Args:
            svgNode: The lxml node to start the search from.
            name: The name of the attribute to find.

        Returns:
            The attribute value, or an empty string if not found.
        """
        if not svgNode.attrib.get("__rules_applied", False):
            # Apply global styles...
            if self.css_rules is not None:
                svgNode.apply_rules(self.css_rules)
            # ...and locally defined
            if svgNode.attrib.get("style"):
                attrs = self.parseMultiAttributes(svgNode.attrib.get("style"))
                for key, val in attrs.items():
                    # lxml nodes cannot accept attributes starting with '-'
                    if not key.startswith("-"):
                        svgNode.attrib[key] = val
                svgNode.attrib["__rules_applied"] = "1"

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

        if attr_value and attr_value != "inherit":
            return attr_value
        if svgNode.parent is not None:
            return self.findAttr(svgNode.parent, name)
        return ""

    def getAllAttributes(self, svgNode: Any) -> dict[str, str]:
        """Return a dictionary of all attributes of a node and its ancestors.

        Args:
            svgNode: The lxml node to get attributes from.

        Returns:
            A dictionary of all applicable attributes.
        """
        dict = {}

        if node_name(svgNode.getparent()) == "g":
            dict.update(self.getAllAttributes(svgNode.getparent()))

        style = svgNode.attrib.get("style")
        if style:
            d = self.parseMultiAttributes(style)
            dict.update(d)

        for key, value in svgNode.attrib.items():
            if key != "style":
                dict[key] = value

        return dict

    def id(self, svgAttr: str) -> str:
        """Return the attribute value as is."""
        return svgAttr

    def convertTransform(
        self,
        svgAttr: str,
    ) -> list[tuple[str, Union[float, tuple[float, ...]]]]:
        """Parse a transform attribute string into a list of operations.

        Args:
            svgAttr: The SVG transform attribute string.

        Returns:
            A list of tuples, where each tuple contains the transform
            operation and its arguments.

        Example:
            >>> converter = AttributeConverter()
            >>> converter.convertTransform("scale(2) translate(10,20)")
            [('scale', 2.0), ('translate', (10.0, 20.0))]
        """
        line = svgAttr.strip()

        ops: str = line[:]
        brackets: list[int] = []
        indices: list[Union[float, tuple[float, ...]]] = []
        for i, lin in enumerate(line):
            if lin in "()":
                brackets.append(i)
        for i in range(0, len(brackets), 2):
            bi, bj = brackets[i], brackets[i + 1]
            subline = line[bi + 1 : bj]
            subline = subline.strip()
            subline = subline.replace(",", " ")
            subline = re.sub("[ ]+", ",", subline)
            try:
                if "," in subline:
                    indices.append(tuple(float(num) for num in subline.split(",")))
                else:
                    indices.append(float(subline))
            except ValueError:
                continue
            ops = ops[:bi] + " " * (bj - bi + 1) + ops[bj + 1 :]
        ops_list: list[str] = ops.replace(",", " ").split()

        if len(ops_list) != len(indices):
            logger.warning("Unable to parse transform expression %r", svgAttr)
            return []

        result = []
        for i, op in enumerate(ops_list):
            result.append((op, indices[i]))

        return result


class Svg2RlgAttributeConverter(AttributeConverter):
    """A concrete attribute converter for SVG to ReportLab Graphics."""

    def __init__(
        self,
        color_converter: Optional[Any] = None,
        font_map: Optional[Any] = None,
    ) -> None:
        """Initialize the converter with optional colour- and font-mapping hooks."""
        super().__init__()
        self.color_converter = color_converter or self.identity_color_converter
        self._font_map = font_map or get_global_font_map()

    @staticmethod
    def identity_color_converter(c: Any) -> Any:
        """A default color converter that returns the color as is."""
        return c

    @staticmethod
    def split_attr_list(attr: str) -> list[str]:
        """Split a string of attributes into a list."""
        return shlex.split(attr.strip().replace(",", " "))

    @staticmethod
    def _split_outside_quotes(attr: str) -> list[str]:
        """Split on commas that are outside quoted strings, respecting escapes."""
        segments = []
        current = []
        quote_char = None
        escaped = False
        for char in attr:
            if escaped:
                current.append(char)
                escaped = False
                continue
            if char == "\\":
                current.append(char)
                escaped = True
                continue
            if quote_char is not None:
                current.append(char)
                if char == quote_char:
                    quote_char = None
                continue
            if char in "\"'":
                quote_char = char
                current.append(char)
                continue
            if char == ",":
                segments.append("".join(current))
                current = []
                continue
            current.append(char)
        segments.append("".join(current))
        return segments

    @staticmethod
    def split_font_family_list(attr: str) -> list[str]:
        """Split an SVG/CSS font-family attribute into family names to try.

        Per CSS, only commas separate family names, so an unquoted name
        containing spaces (e.g. ``Cascadia Code``) is yielded intact first.
        Each space-separated word is also yielded afterwards, preserving the
        previous lookup behaviour as a fallback.
        """
        names = []
        for segment in Svg2RlgAttributeConverter._split_outside_quotes(attr.strip()):
            words = shlex.split(segment.strip())
            if not words:
                continue
            full_name = " ".join(words)
            if full_name not in names:
                names.append(full_name)
            for word in words:
                if word not in names:
                    names.append(word)
        return names

    def convertLength(
        self,
        svgAttr: str,
        em_base: float = DEFAULT_FONT_SIZE / PX_TO_PT,
        attr_name: Optional[str] = None,
        default: float = 0.0,
    ) -> Union[float, list[float]]:
        """Convert an SVG length string to user units (px).

        Args:
            svgAttr: The SVG length string (e.g., "10px", "5em").
            em_base: The base font size for 'em' units.
            attr_name: The name of the attribute being converted.
            default: The default value to return if the string is empty.

        Returns:
            The length in points as a float, or a list of floats for
            space-separated values.
        """
        text = svgAttr.replace(",", " ").strip()
        if not text:
            return default
        if " " in text:
            # Multiple length values, returning a list
            items = [
                self.convertLength(
                    val, em_base=em_base, attr_name=attr_name, default=default
                )
                for val in self.split_attr_list(text)
            ]
            result: list[float] = []
            for item in items:
                if isinstance(item, list):
                    result.extend(item)
                else:
                    result.append(item)
            return result

        if text.endswith("%"):
            if self.main_box is None:
                logger.error("Unable to resolve percentage unit without a main box")
                return float(text[:-1])
            if attr_name is None:
                logger.error(
                    "Unable to resolve percentage unit without knowing the node name"
                )
                return float(text[:-1])
            if attr_name in ("x", "cx", "x1", "x2", "width"):
                full = self.main_box.width
            elif attr_name in ("y", "cy", "y1", "y2", "height"):
                full = self.main_box.height
            else:
                logger.error(
                    "Unable to detect if node %r is width or height", attr_name
                )
                return float(text[:-1])
            return cast(float, float(text[:-1]) / 100 * full)
        elif text.endswith("pc"):
            # 1 pc = 12 pt = 16 px user units
            return float(text[:-2]) * 16
        elif text.endswith("pt"):
            # 1 pt = 1/72 in = 96/72 px user units
            return float(text[:-2]) * (96 / 72)
        elif text.endswith("rem"):
            return float(text[:-3]) * self.root_font_size
        elif text.endswith("em"):
            return float(text[:-2]) * em_base
        elif text.endswith("vmin"):
            if self.main_box is None:
                logger.error("Unable to resolve vmin unit without a main box")
                return default
            return cast(
                float,
                float(text[:-4]) / 100 * min(self.main_box.width, self.main_box.height),
            )
        elif text.endswith("vmax"):
            if self.main_box is None:
                logger.error("Unable to resolve vmax unit without a main box")
                return default
            return cast(
                float,
                float(text[:-4]) / 100 * max(self.main_box.width, self.main_box.height),
            )
        elif text.endswith("vw"):
            if self.main_box is None:
                logger.error("Unable to resolve vw unit without a main box")
                return default
            return cast(float, float(text[:-2]) / 100 * self.main_box.width)
        elif text.endswith("vh"):
            if self.main_box is None:
                logger.error("Unable to resolve vh unit without a main box")
                return default
            return cast(float, float(text[:-2]) / 100 * self.main_box.height)
        elif text.endswith("q"):
            # 1q = 0.25 mm
            return cast(float, float(text[:-1]) * toLength("1mm") / (4 * PX_TO_PT))
        elif text.endswith("px"):
            # px are user units (1:1)
            return float(text[:-2])
        elif text.endswith("ex"):
            # The x-height of the text must be assumed to be 0.5em tall when the
            # text cannot be measured.
            return float(text[:-2]) * em_base / 2
        elif text.endswith("ch"):
            # The advance measure of the "0" glyph must be assumed to be 0.5em
            # wide when the text cannot be measured.
            return float(text[:-2]) * em_base / 2

        text = text.strip()
        try:
            # Bare numbers are user units (= px, SVG spec §5.9.2) — return as-is.
            return float(text)
        except ValueError:
            pass
        # toLength handles mm, cm, in, etc. and returns points; convert to user units.
        return cast(float, toLength(text) / PX_TO_PT)

    def convertLengthList(self, svgAttr: str) -> list[Union[float, list[float]]]:
        """Convert a space-separated list of lengths into a list of floats."""
        return [self.convertLength(a) for a in self.split_attr_list(svgAttr)]

    def convertLengthToPt(
        self,
        svgAttr: str,
        em_base: float = DEFAULT_FONT_SIZE / PX_TO_PT,
        attr_name: Optional[str] = None,
        default: float = 0.0,
    ) -> Union[float, list[float]]:
        """Convert an SVG length string to points (user units × PX_TO_PT)."""
        result = self.convertLength(
            svgAttr, em_base=em_base, attr_name=attr_name, default=default
        )
        if isinstance(result, list):
            return [v * PX_TO_PT for v in result]
        return result * PX_TO_PT

    def convertOpacity(self, svgAttr: str) -> float:
        """Convert an opacity string to a float."""
        return float(svgAttr)

    def convertFillRule(self, svgAttr: str) -> Union[int, str]:
        """Convert an SVG fill-rule string to a ReportLab fill rule."""
        return cast(
            Union[int, str],
            {
                "nonzero": FILL_NON_ZERO,
                "evenodd": FILL_EVEN_ODD,
            }.get(svgAttr, ""),
        )

    def convertColor(self, svgAttr: str) -> Any:
        """Convert an SVG color string to a ReportLab color object.

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

        Returns:
            A ReportLab color object, or None if the color is invalid.
        """
        text = svgAttr
        if not text or text == "none":
            return None

        # Gradient/pattern references are handled at the renderer level.
        if text.startswith("url("):
            return None

        if text == "currentColor":
            return "currentColor"
        if len(text) in (7, 9) and text[0] == "#":
            color = colors.HexColor(text, hasAlpha=len(text) == 9)
        elif len(text) == 4 and text[0] == "#":
            color = colors.HexColor("#" + 2 * text[1] + 2 * text[2] + 2 * text[3])
        elif len(text) == 5 and text[0] == "#":
            color = colors.HexColor(
                "#" + 2 * text[1] + 2 * text[2] + 2 * text[3] + 2 * text[4],
                hasAlpha=True,
            )
        else:
            # Should handle pcmyk|cmyk|rgb|hsl values (including 'a' for alpha)
            color = colors.cssParse(text)
            if color is None:
                # Test if text is a predefined color constant
                try:
                    color = getattr(colors, text).clone()
                except AttributeError:
                    pass
        if color is None:
            logger.warning("Can't handle color: %s", text)
        else:
            return self.color_converter(color)
        return None

    def convertLineJoin(self, svgAttr: str) -> int:
        """Convert an SVG stroke-linejoin string to a ReportLab line join."""
        return {"miter": 0, "round": 1, "bevel": 2}[svgAttr]

    def convertLineCap(self, svgAttr: str) -> int:
        """Convert an SVG stroke-linecap string to a ReportLab line cap."""
        return {"butt": 0, "round": 1, "square": 2}[svgAttr]

    def convertDashArray(self, svgAttr: str) -> list[Union[float, list[float]]]:
        """Convert an SVG stroke-dasharray string to a list of lengths."""
        strokeDashArray = self.convertLengthList(svgAttr)
        return strokeDashArray

    def convertDashOffset(self, svgAttr: str) -> Union[float, list[float]]:
        """Convert an SVG stroke-dashoffset string to a length."""
        strokeDashOffset = self.convertLength(svgAttr)
        return strokeDashOffset

    def convertFontFamily(
        self,
        fontAttr: Optional[str],
        weightAttr: str = "normal",
        styleAttr: str = "normal",
    ) -> str:
        """Convert an SVG font-family string to a registered font name.

        Args:
            fontAttr: The SVG font-family attribute string.
            weightAttr: The font-weight attribute string.
            styleAttr: The font-style attribute string.

        Returns:
            The best-matched registered font name.
        """
        if not fontAttr:
            return ""
        # split the fontAttr in actual font family names
        font_names = self.split_font_family_list(fontAttr)

        non_exact_matches = []
        for font_name in font_names:
            font_name, exact = self._font_map.find_font(
                font_name, weightAttr, styleAttr
            )
            if exact:
                return font_name
            elif font_name:
                non_exact_matches.append(font_name)
        if non_exact_matches:
            return non_exact_matches[0]
        else:
            logger.warning(
                f"Unable to find a suitable font for 'font-family:{fontAttr}', "
                f"weight:{weightAttr}, style:{styleAttr}; "
                "try registering it with register_font()"
            )
            return DEFAULT_FONT_NAME


class NodeTracker(cssselect2.ElementWrapper):
    """A wrapper for lxml nodes to track attribute usage.

    This class wraps an lxml node and keeps a record of which attributes
    have been accessed, which is useful for debugging unused attributes.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the wrapper and the list of accessed attribute names."""
        super().__init__(*args, **kwargs)
        self.usedAttrs: list[str] = []

    def __repr__(self) -> str:
        """Return a debug representation naming the wrapped element."""
        return f"<NodeTracker for node {self.etree_element}>"

    def getAttribute(self, name: str) -> str:
        """Get an attribute value and record that it has been used."""
        if name not in self.usedAttrs:
            self.usedAttrs.append(name)
        return cast(str, self.etree_element.attrib.get(name, ""))

    def __getattr__(self, name: str) -> Any:
        """Forward attribute access to the wrapped lxml node."""
        return getattr(self.etree_element, name)

    def apply_rules(self, rules: Any) -> None:
        """Apply CSS rules to the wrapped node.

        Args:
            rules: A CSSMatcher object containing the styles to apply.
        """
        matches = rules.match(self)
        for match in matches:
            attr_dict = match[3][1]
            for attr, val in attr_dict.items():
                try:
                    self.etree_element.attrib[attr] = val
                except ValueError:
                    pass
        # Set marker on the node to not apply rules more than once
        self.etree_element.set("__rules_applied", "1")


class CircularRefError(Exception):
    """Exception raised for circular references in SVG files."""

    pass


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

    def __init__(
        self,
        path: Union[str, os.PathLike[str]],
        renderer: "SvgRenderer",
    ) -> None:
        """Load and parse an external SVG referenced by the given path."""
        self.root_node = load_svg_file(path)
        self.renderer = SvgRenderer(
            path, parent_svgs=renderer._parent_chain + [str(renderer.source_path)]
        )
        self.rendered = False

    def get_fragment(self, fragment: str) -> Any:
        """Get a defined fragment from the external SVG file.

        Args:
            fragment: The ID of the fragment to retrieve.

        Returns:
            The rendered fragment, or None if not found.
        """
        if not self.rendered:
            self.renderer.render(self.root_node)
            self.rendered = True
        return self.renderer.definitions.get(fragment)


_BEZIER_KAPPA = 0.5523  # cubic bezier constant for circle approximation

# Match values: url(#name), url('#name'), url("#name"), url( "#name" )
_GRADIENT_URL_RE = re.compile(r"""url\(\s*(['"]?)#([^'"]+?)\1\s*\)""")


def _shape_to_pdf_path(canvas: Any, shape: Any) -> Any:
    """Convert a ReportLab shape to a PDFPathObject for use as a clip path.

    Handles Path, Rect, Circle, Ellipse, and Polygon; falls back to the
    shape's bounding box for other types.
    """
    if isinstance(canvas, SVGCanvas) and not hasattr(canvas, "beginPath"):
        # SVGCanvas doesn't have function beginPath. It is used here:
        # easy-thumbnails[svg]==2.10.1
        # https://github.com/SmileyChris/easy-thumbnails/blob/2.10.1/easy_thumbnails/VIL/Image.py#L21
        return None
    pdfPath = canvas.beginPath()
    if isinstance(shape, Path):
        draw_funcs = (pdfPath.moveTo, pdfPath.lineTo, pdfPath.curveTo, pdfPath.close)
        _renderPath(shape, draw_funcs)
    elif isinstance(shape, Rect):
        x, y, x2, y2 = shape.getBounds()
        w, h = x2 - x, y2 - y
        rx = min(getattr(shape, "rx", 0) or 0, w / 2)
        ry = min(getattr(shape, "ry", 0) or 0, h / 2)
        k = _BEZIER_KAPPA
        if rx or ry:
            pdfPath.moveTo(x + rx, y)
            pdfPath.lineTo(x + w - rx, y)
            pdfPath.curveTo(
                x + w - rx + rx * k, y, x + w, y + ry - ry * k, x + w, y + ry
            )
            pdfPath.lineTo(x + w, y + h - ry)
            pdfPath.curveTo(
                x + w,
                y + h - ry + ry * k,
                x + w - rx + rx * k,
                y + h,
                x + w - rx,
                y + h,
            )
            pdfPath.lineTo(x + rx, y + h)
            pdfPath.curveTo(
                x + rx - rx * k, y + h, x, y + h - ry + ry * k, x, y + h - ry
            )
            pdfPath.lineTo(x, y + ry)
            pdfPath.curveTo(x, y + ry - ry * k, x + rx - rx * k, y, x + rx, y)
            pdfPath.close()
        else:
            pdfPath.moveTo(x, y)
            pdfPath.lineTo(x + w, y)
            pdfPath.lineTo(x + w, y + h)
            pdfPath.lineTo(x, y + h)
            pdfPath.close()
    elif isinstance(shape, Circle):
        cx, cy, r = shape.cx, shape.cy, shape.r
        k = _BEZIER_KAPPA
        pdfPath.moveTo(cx + r, cy)
        pdfPath.curveTo(cx + r, cy + r * k, cx + r * k, cy + r, cx, cy + r)
        pdfPath.curveTo(cx - r * k, cy + r, cx - r, cy + r * k, cx - r, cy)
        pdfPath.curveTo(cx - r, cy - r * k, cx - r * k, cy - r, cx, cy - r)
        pdfPath.curveTo(cx + r * k, cy - r, cx + r, cy - r * k, cx + r, cy)
        pdfPath.close()
    elif isinstance(shape, Ellipse):
        cx, cy = shape.cx, shape.cy
        rx, ry = shape.rx, shape.ry
        k = _BEZIER_KAPPA
        pdfPath.moveTo(cx + rx, cy)
        pdfPath.curveTo(cx + rx, cy + ry * k, cx + rx * k, cy + ry, cx, cy + ry)
        pdfPath.curveTo(cx - rx * k, cy + ry, cx - rx, cy + ry * k, cx - rx, cy)
        pdfPath.curveTo(cx - rx, cy - ry * k, cx - rx * k, cy - ry, cx, cy - ry)
        pdfPath.curveTo(cx + rx * k, cy - ry, cx + rx, cy - ry * k, cx + rx, cy)
        pdfPath.close()
    elif isinstance(shape, Polygon):
        pts = shape.points
        if len(pts) >= 2:
            pdfPath.moveTo(pts[0], pts[1])
            for i in range(2, len(pts) - 1, 2):
                pdfPath.lineTo(pts[i], pts[i + 1])
            pdfPath.close()
    else:
        try:
            x, y, x2, y2 = shape.getBounds()
            pdfPath.moveTo(x, y)
            pdfPath.lineTo(x2, y)
            pdfPath.lineTo(x2, y2)
            pdfPath.lineTo(x, y2)
            pdfPath.close()
        except Exception:
            logger.debug(
                "Cannot compute bounding box for clip path fallback", exc_info=True
            )
    return pdfPath


def _find_clip_shape(item: Any) -> Optional[Any]:
    """Return the first Path/Rect/Circle/Ellipse/Polygon found in item or its group."""
    if isinstance(item, (Path, Rect, Circle, Ellipse, Polygon)):
        return item
    if isinstance(item, Group):
        for child in item.contents:
            found = _find_clip_shape(child)
            if found is not None:
                return found
    return None


def _default_language() -> str:
    """Return the user's preferred language from the environment.

    Reads the standard locale environment variables and returns a language code
    such as ``en_US`` (or an empty string if none is set). This replaces
    ``locale.getdefaultlocale()``, which is deprecated and removed in Python
    3.15; unlike ``locale.getlocale()`` it reflects the environment without
    requiring a global ``setlocale()`` call.
    """
    for var in ("LC_ALL", "LC_CTYPE", "LANG", "LANGUAGE"):
        value = os.environ.get(var, "")
        if not value or value in ("C", "POSIX"):
            continue
        # LANGUAGE may hold a colon-separated priority list; take the first.
        value = value.split(":")[0]
        # Drop the encoding (".UTF-8") and modifier ("@euro") suffixes.
        return value.split(".")[0].split("@")[0]
    return ""


class LinearGradientShape(DirectDraw):
    """Fills a clipped region with a linear gradient via PDF shading."""

    def __init__(
        self,
        clip_shape: Shape,
        x0: float,
        y0: float,
        x1: float,
        y1: float,
        rl_colors: list[colors.Color],
        positions: list[float],
        extend: bool = True,
    ) -> None:
        """Store the clip shape, gradient axis, colours and stop positions."""
        self._clip_shape = clip_shape
        self._x0, self._y0 = x0, y0
        self._x1, self._y1 = x1, y1
        self._rl_colors = rl_colors
        self._positions = positions
        self._extend = extend

    def drawDirectly(self, renderer: Any) -> None:
        """Paint the linear gradient into the clipped region on the PDF canvas."""
        canvas = renderer._canvas
        canvas.saveState()
        if pdfPath := _shape_to_pdf_path(canvas, self._clip_shape):
            canvas.clipPath(pdfPath, fill=1, stroke=0)
            canvas.linearGradient(
                self._x0,
                self._y0,
                self._x1,
                self._y1,
                self._rl_colors,
                self._positions,
                self._extend,
            )
            canvas.restoreState()

    def getBounds(self) -> tuple[float, float, float, float]:
        """Return the bounds of the clipped region this gradient fills."""
        return cast(tuple[float, float, float, float], self._clip_shape.getBounds())


class RadialGradientShape(DirectDraw):
    """Fills a clipped region with a radial gradient via PDF shading."""

    def __init__(
        self,
        clip_shape: Shape,
        cx: float,
        cy: float,
        r: float,
        rl_colors: list[colors.Color],
        positions: list[float],
        extend: bool = True,
    ) -> None:
        """Store the clip shape, centre, radius, colours and stop positions."""
        self._clip_shape = clip_shape
        self._cx, self._cy, self._r = cx, cy, r
        self._rl_colors = rl_colors
        self._positions = positions
        self._extend = extend

    def drawDirectly(self, renderer: Any) -> None:
        """Paint the radial gradient into the clipped region on the PDF canvas."""
        canvas = renderer._canvas
        canvas.saveState()
        if pdfPath := _shape_to_pdf_path(canvas, self._clip_shape):
            canvas.clipPath(pdfPath, fill=1, stroke=0)
            canvas.radialGradient(
                self._cx,
                self._cy,
                self._r,
                self._rl_colors,
                self._positions,
                self._extend,
            )
            canvas.restoreState()

    def getBounds(self) -> tuple[float, float, float, float]:
        """Return the bounds of the clipped region this gradient fills."""
        return cast(tuple[float, float, float, float], self._clip_shape.getBounds())


# ## the main meat ###


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

    This class walks the SVG DOM and converts SVG elements into their
    corresponding ReportLab Graphics objects.
    """

    def __init__(
        self,
        path: SVGSource,
        color_converter: Optional[Any] = None,
        parent_svgs: Optional[list[str]] = None,
        font_map: Optional[Any] = None,
    ) -> None:
        """Initialize the renderer for the given SVG source and converters."""
        self.source_path: SVGSource = path
        self._parent_chain: list[str] = parent_svgs or []  # To detect circular refs.
        self.attrConverter = Svg2RlgAttributeConverter(
            color_converter=color_converter, font_map=font_map
        )
        self.shape_converter = Svg2RlgShapeConverter(path, self.attrConverter)
        self.handled_shapes = self.shape_converter.get_handled_shapes()
        self.definitions: dict[str, Any] = {}
        self.gradient_defs: dict[str, _GradientDef] = {}
        self.waiting_use_nodes: dict[str, list[tuple[NodeTracker, Optional[Group]]]] = (
            defaultdict(list)
        )
        self._external_svgs: dict[str, ExternalSVG] = {}
        self.attrConverter.css_rules = CSSMatcher()

    def _set_root_font_size(self, root_node: Any) -> None:
        """Resolve and store the root font-size, used to convert rem units."""
        fs_str = self.attrConverter.findAttr(root_node, "font-size")
        if fs_str:
            fs_px = self.attrConverter.convertLength(fs_str)
            if fs_px:
                self.attrConverter.root_font_size = float(fs_px)  # type: ignore[arg-type]

    def _warn_old_inkscape(self, svg_node: Any) -> None:
        """Log a warning if the SVG was produced by an outdated Inkscape."""
        inkscape_version = svg_node.get(f"{{{INKSCAPE_NS}}}version", "")
        if not inkscape_version:
            return
        version_str = inkscape_version.split()[0]
        try:
            parts = tuple(int(x) for x in version_str.split(".")[:2])
        except ValueError:
            return
        if parts < (0, 92):
            logger.warning(
                "This SVG was created with Inkscape %s (pre-0.92, 90 dpi). "
                "Coordinates assume 90 dpi instead of the standard 96 dpi, "
                "so the output may be ~6.7%% too large. "
                "Re-save with Inkscape ≥0.92 to fix.",
                version_str,
            )

    def render(self, svg_node: Any) -> Drawing:
        """Render an SVG node into a ReportLab Drawing.

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

        Returns:
            A ReportLab Drawing object representing the SVG.
        """
        node = NodeTracker.from_xml_root(svg_node)
        self._warn_old_inkscape(svg_node)
        self._set_root_font_size(node)
        view_box = self.get_box(node, default_box=True)
        # Knowing the main box is useful for percentage units
        self.attrConverter.set_box(view_box)

        main_group = self.renderSvg(node, outermost=True)
        for xlink in self.waiting_use_nodes.keys():
            logger.debug("Ignoring unavailable object width ID %r.", xlink)

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

        width, height = self.shape_converter.convert_length_attrs(
            svg_node, "width", "height", defaults=(view_box.width, view_box.height)
        )
        drawing = Drawing(width * PX_TO_PT, height * PX_TO_PT)
        drawing.add(main_group)
        return drawing

    def renderNode(self, node: NodeTracker, parent: Optional[Group] = None) -> None:
        """Render a single SVG node and add it to a parent group.

        Args:
            node: The NodeTracker object for the SVG node.
            parent: The parent ReportLab Group to add the rendered object to.
        """
        if parent is None:
            return
        nid = node.getAttribute("id")
        ignored = False
        item = None
        name = node_name(node)

        clipping = self.get_clippath(node)
        if name == "svg":
            item = self.renderSvg(node)
            parent.add(item)
        elif name == "defs":
            ignored = True  # defs are handled in the initial rendering phase.
        elif name == "a":
            item = self.renderA(node)
            parent.add(item)
        elif name == "g":
            display = self.get_display(node)
            item = self.renderG(node, clipping=clipping)
            if display != "none":
                parent.add(item)
        elif name == "style":
            self.renderStyle(node)
        elif name == "symbol":
            item = self.renderSymbol(node)
            # First time the symbol node is rendered, it should not be part of a group.
            # It is only rendered to be part of definitions.
            if node.attrib.get("_rendered"):
                parent.add(item)
            else:
                node.set("_rendered", "1")
        elif name == "use":
            item = self.renderUse(node, clipping=clipping)
            parent.add(item)
        elif name == "switch":
            item = self.renderSwitch(node, clipping=clipping)
            if item is not None:
                parent.add(item)
        elif name == "clipPath":
            item = self.renderG(node)
        elif name in ("linearGradient", "radialGradient"):
            self.renderGradient(node)
            ignored = True
        elif name in self.handled_shapes:
            if name == "image":
                # We resolve the image target at renderer level because it can point
                # to another SVG file or node which has to be rendered too.
                target = self.xlink_href_target(node)
                if target is None:
                    return
                elif isinstance(target, tuple):
                    # This is SVG content needed to be rendered
                    gr = Group()
                    renderer, img_node = target
                    renderer.renderNode(img_node, parent=gr)
                    self.apply_node_attr_to_group(node, gr)
                    parent.add(gr)
                    return
                else:
                    # Attaching target to node, so we can get it back in convertImage
                    node._resolved_target = target

            item = self.shape_converter.convertShape(name, node, clipping)
            display = self.get_display(node)
            visibility = self.attrConverter.findAttr(node, "visibility")
            if item and display != "none" and visibility not in ("hidden", "collapse"):
                fill_val = self.attrConverter.findAttr(node, "fill")
                m = _GRADIENT_URL_RE.fullmatch(fill_val.strip()) if fill_val else None
                if m:
                    grad_id = m.group(2)
                    grad_def = self._resolve_gradient(grad_id)
                    if grad_def is not None:
                        item = self._apply_gradient_fill(item, grad_def)
                parent.add(item)
        else:
            ignored = True
            logger.debug("Ignoring node: %s", name)

        if not ignored:
            if nid and item:
                self.definitions[nid] = node
                # preserve id to keep track of svg objects
                # and simplify further analyses of generated document
                item.setProperties({"svgid": nid})
                # labels are used in inkscape to name specific groups as layers
                # preserving them simplify extraction of feature from the generated
                # document
                label_attrs = [v for k, v in node.attrib.items() if "label" in k]
                if len(label_attrs) == 1:
                    (label,) = label_attrs
                    item.setProperties({"label": label})
            if nid in self.waiting_use_nodes.keys():
                to_render = self.waiting_use_nodes.pop(nid)
                for use_node, group in to_render:
                    self.renderUse(use_node, group=group)
            self.print_unused_attributes(node)

    def renderGradient(self, node: NodeTracker) -> None:
        """Parse a <linearGradient> or <radialGradient> element and store it."""
        grad_id = node.attrib.get("id")
        if not grad_id:
            return
        grad_type = node_name(node)  # "linearGradient" or "radialGradient"

        def _float_attr(attr: str, default: float) -> float:
            """Return a gradient geometry attribute as a float, resolving percent."""
            raw = node.attrib.get(attr, "").strip()
            if raw.endswith("%"):
                try:
                    return float(raw[:-1]) / 100.0
                except ValueError:
                    return default
            try:
                return float(raw) if raw else default
            except ValueError:
                return default

        href = node.attrib.get("{http://www.w3.org/1999/xlink}href") or node.attrib.get(
            "href", ""
        )
        href_id = href.lstrip("#") if href.startswith("#") else None

        grad_units = node.attrib.get("gradientUnits", "objectBoundingBox")
        spread = node.attrib.get("spreadMethod", "pad")

        # Collect stop elements (direct children with tag "stop")
        stops: list[GradientStop] = []
        for child in node:
            child_name = node_name(child)
            if child_name != "stop":
                continue
            raw_offset = child.attrib.get("offset", "0").strip()
            if raw_offset.endswith("%"):
                try:
                    offset = float(raw_offset[:-1]) / 100.0
                except ValueError:
                    offset = 0.0
            else:
                try:
                    offset = float(raw_offset)
                except ValueError:
                    offset = 0.0

            # stop-color and stop-opacity can be in style or as direct attrs
            style_str = child.attrib.get("style", "")
            style_attrs: dict[str, Any] = {}
            if style_str:
                style_attrs = self.attrConverter.parseMultiAttributes(style_str)

            stop_color_str = style_attrs.get(
                "stop-color", child.attrib.get("stop-color", "black")
            )
            stop_opacity_str = style_attrs.get(
                "stop-opacity", child.attrib.get("stop-opacity", "1")
            )
            try:
                opacity = float(stop_opacity_str)
            except (ValueError, TypeError):
                opacity = 1.0

            rl_color = self.attrConverter.convertColor(stop_color_str)
            if rl_color is None:
                rl_color = colors.black
            rl_color = rl_color.clone()
            rl_color.alpha = opacity
            stops.append((offset, rl_color))

        grad_def: _GradientDef = {
            "type": "linear" if grad_type == "linearGradient" else "radial",
            "gradientUnits": grad_units,
            "spreadMethod": spread,
            "stops": stops,
            "href": href_id,
        }

        if grad_type == "linearGradient":
            grad_def["x1"] = _float_attr("x1", 0.0)
            grad_def["y1"] = _float_attr("y1", 0.0)
            grad_def["x2"] = _float_attr("x2", 1.0)
            grad_def["y2"] = _float_attr("y2", 0.0)
        else:
            grad_def["cx"] = _float_attr("cx", 0.5)
            grad_def["cy"] = _float_attr("cy", 0.5)
            grad_def["r"] = _float_attr("r", 0.5)
            grad_def["fx"] = _float_attr("fx", grad_def["cx"])
            grad_def["fy"] = _float_attr("fy", grad_def["cy"])

        self.gradient_defs[grad_id] = grad_def

    def _resolve_gradient(self, grad_id: str) -> Optional[_GradientDef]:
        """Return a fully resolved gradient dict, following xlink:href chains."""
        visited: set[str] = set()
        result = self.gradient_defs.get(grad_id)
        while result is not None:
            href = result.get("href")
            if not href or href in visited:
                break
            parent = self.gradient_defs.get(href)
            if parent is None:
                break
            visited.add(href)
            # Merge: current overrides parent for all keys except missing stops.
            # The merge is by dynamic key, so build a plain dict and cast back.
            merged: dict[str, Any] = dict(parent)
            for k, v in result.items():
                if k == "stops" and not v:
                    continue  # inherit parent's stops
                merged[k] = v
            result = cast(_GradientDef, merged)
        return result

    def _apply_gradient_fill(self, item: Any, grad_def: _GradientDef) -> Any:
        """Wrap a shape in a Group that paints the gradient fill then the stroke."""
        clip_shape = _find_clip_shape(item)
        if clip_shape is None:
            return item

        stops = grad_def.get("stops", [])
        if not stops:
            return item

        positions = [s[0] for s in stops]
        rl_colors = [s[1] for s in stops]
        extend = grad_def.get("spreadMethod", "pad") == "pad"
        grad_units = grad_def.get("gradientUnits", "objectBoundingBox")

        if grad_units == "objectBoundingBox":
            try:
                bx0, by0, bx1, by1 = clip_shape.getBounds()
            except Exception:
                logger.debug("Cannot compute bounding box for gradient", exc_info=True)
                return item
            bbox_w = bx1 - bx0
            bbox_h = by1 - by0
        else:
            bx0 = by0 = bbox_w = bbox_h = 0.0  # unused for userSpaceOnUse

        if grad_def["type"] == "linear":
            x1, y1 = grad_def.get("x1", 0.0), grad_def.get("y1", 0.0)
            x2, y2 = grad_def.get("x2", 1.0), grad_def.get("y2", 0.0)
            if grad_units == "objectBoundingBox":
                x1 = bx0 + x1 * bbox_w
                y1 = by0 + y1 * bbox_h
                x2 = bx0 + x2 * bbox_w
                y2 = by0 + y2 * bbox_h
            grad_shape: DirectDraw = LinearGradientShape(
                clip_shape, x1, y1, x2, y2, rl_colors, positions, extend
            )
        else:
            cx = grad_def.get("cx", 0.5)
            cy = grad_def.get("cy", 0.5)
            r = grad_def.get("r", 0.5)
            if grad_units == "objectBoundingBox":
                cx = bx0 + cx * bbox_w
                cy = by0 + cy * bbox_h
                r = r * (bbox_w + bbox_h) / 2.0
            grad_shape = RadialGradientShape(
                clip_shape, cx, cy, r, rl_colors, positions, extend
            )

        group = Group()
        group.add(grad_shape)
        group.add(item)
        return group

    def get_display(self, node: NodeTracker) -> str:
        """Return the effective 'display' value of a node.

        The value may be set either as a presentation attribute
        (``display="none"``) or inside the ``style`` attribute
        (``style="display:none"``); both forms are equivalent in SVG.

        Args:
            node: The NodeTracker object for the SVG node.

        Returns:
            The display value, or an empty string if it is not set.
        """
        display = node.getAttribute("display")
        style = node.getAttribute("style")
        if style:
            display = self.attrConverter.parseMultiAttributes(style).get(
                "display", display
            )
        return display.strip()

    def get_clippath(self, node: NodeTracker) -> Optional[Any]:
        """Get the clipping path object referenced by a node's 'clip-path' attribute.

        Args:
            node: The NodeTracker object for the SVG node.

        Returns:
            A ClippingPath object, or None if no valid clipping path is found.
        """

        def find_shapes_from_group(
            group: Any,
            shapes: list[Path],
            clip_rule: int,
        ) -> None:
            """Recursively find drawable shapes found within a group."""
            for elem in group.contents:
                if isinstance(elem, Group):
                    find_shapes_from_group(elem, shapes, clip_rule)
                elif isinstance(elem, SolidShape):
                    result = self.shape_converter.shapeToPath(elem, group.transform)
                    if result:
                        result.fillMode = clip_rule
                        shapes.append(result)
                    else:
                        logger.error(
                            "Unsupported shape type %s for clipping",
                            elem.__class__.__name__,
                        )

        def find_shapes_from_node(node: Any, shapes: list[Path]) -> None:
            """Find the shapes from the node."""
            for child in node.iter_children():
                # Normally the fill-rule is used for the reportlab fillMode,
                # but clip-rule is used instead when clipping.
                clip_rule_value = self.attrConverter.findAttr(child, "clip-rule")
                clip_rule = FILL_NON_ZERO
                if clip_rule_value:
                    clip_rule_value = clip_rule_value.replace("!important", "").strip()
                    if clip_rule_value == "evenodd":
                        clip_rule = FILL_EVEN_ODD

                child_name = node_name(child)
                valid_nodes = ["path", "rect", "circle", "ellipse", "polygon"]
                if child_name in valid_nodes:
                    item = self.shape_converter.convertShape(child_name, child)

                    # The transform will be applied in the get_shape_from_group
                    if isinstance(item, Group):
                        find_shapes_from_group(item, shapes, clip_rule)
                    else:
                        result = self.shape_converter.shapeToPath(item, None)
                        if result:
                            result.fillMode = clip_rule
                            shapes.append(result)
                        else:
                            logger.error(
                                "Unsupported shape type %s for clipping",
                                item.__class__.__name__,
                            )

                elif child_name == "use":
                    # The clip-rule on the referenced element isn't handled yet.
                    grp = self.renderUse(child)
                    find_shapes_from_group(grp, shapes, clip_rule)
                else:
                    find_shapes_from_node(child, shapes)

        clip_path = node.getAttribute("clip-path")
        if not clip_path:
            return None
        m = re.match(r"url\(#([^)]*)\)", clip_path)
        if not m:
            return None
        ref = m.groups()[0]
        if ref not in self.definitions:
            logger.warning("Unable to find a clipping path with id %s", ref)
            return None

        found_shapes: list[Path] = []
        find_shapes_from_node(self.definitions[ref], found_shapes)
        if not found_shapes:
            return None

        combined_shape = found_shapes[0]
        for other in found_shapes[1:]:
            combined_shape.points = [*combined_shape.points, *other.points]
            combined_shape.operators = [*combined_shape.operators, *other.operators]

            if combined_shape.fillMode != other.fillMode:
                logger.warning(
                    "Mixed clip-rule shapes within a single clip path is not supported."
                )

        return ClippingPath(copy_from=combined_shape)

    def print_unused_attributes(self, node: NodeTracker) -> None:
        """Print any attributes that were not used during rendering.

        This is a debugging helper to identify unsupported SVG attributes.

        Args:
            node: The NodeTracker object for the SVG node.
        """
        if logger.level > logging.DEBUG:
            return
        all_attrs = self.attrConverter.getAllAttributes(node.etree_element).keys()
        unused_attrs = [attr for attr in all_attrs if attr not in node.usedAttrs]
        if unused_attrs:
            logger.debug("Unused attrs: %s %s", node_name(node), unused_attrs)

    def apply_node_attr_to_group(self, node: NodeTracker, group: Any) -> None:
        """Apply common attributes (transform, x, y) from a node to a group.

        Args:
            node: The NodeTracker object for the SVG node.
            group: The ReportLab Group to apply the attributes to.
        """
        getAttr = node.getAttribute
        transform, x, y = map(getAttr, ("transform", "x", "y"))
        if x or y:
            transform += f" translate({x or 0}, {y or 0})"
        if transform:
            self.shape_converter.applyTransformOnGroup(transform, group)

    def xlink_href_target(
        self, node: NodeTracker, group: Optional[Group] = None
    ) -> Any:
        """Resolve an xlink:href attribute to its target.

        The target can be an internal fragment, an external SVG file, or a
        raster image.

        Args:
            node: The NodeTracker object for the SVG node with the href attribute.
            group: The parent group, used for delayed rendering.

        Returns:
            - A tuple (renderer, node) for vector targets.
            - A PIL Image object for raster images.
            - None if the target cannot be resolved.
        """
        # Bare 'href' was introduced in SVG 2.
        xlink_href = node.attrib.get(
            "{http://www.w3.org/1999/xlink}href"
        ) or node.attrib.get("href")
        if not xlink_href:
            return None

        # First handle any raster embedded image data
        match = re.match(r"^data:image/(jpe?g|png);base64", xlink_href)
        if match:
            image_data = base64.decodebytes(
                xlink_href[(match.span(0)[1] + 1) :].encode("ascii")
            )
            bytes_stream = BytesIO(image_data)

            return _convert_palette_to_rgba(PILImage.open(bytes_stream))

        # From here, we can assume this is a path.
        if "#" in xlink_href:
            iri, fragment = xlink_href.split("#", 1)
        else:
            iri, fragment = xlink_href, None

        if iri:
            # Only local relative paths are supported yet
            if not isinstance(self.source_path, str):
                logger.error(
                    "Unable to resolve image path %r as the SVG source is not "
                    "a file system path.",
                    iri,
                )
                return None
            path = os.path.normpath(
                os.path.join(os.path.dirname(self.source_path), iri)
            )
            if not os.access(path, os.R_OK):
                return None
            if path == self.source_path:
                # Self-referencing, ignore the IRI part
                iri = None

        if iri:
            if path.endswith(".svg"):
                if path in self._parent_chain:
                    logger.error("Circular reference detected in file.")
                    raise CircularRefError()
                if path not in self._external_svgs:
                    self._external_svgs[path] = ExternalSVG(path, self)
                ext_svg = self._external_svgs[path]
                if ext_svg.root_node is not None:
                    if fragment:
                        ext_frag = ext_svg.get_fragment(fragment)
                        if ext_frag is not None:
                            return ext_svg.renderer, ext_frag
                    else:
                        return ext_svg.renderer, NodeTracker.from_xml_root(
                            ext_svg.root_node
                        )
            else:
                # A raster image path
                try:
                    # This will catch invalid images
                    PDFImage(path, 0, 0)
                except OSError:
                    logger.error("Unable to read the image %s. Skipping...", path)
                    return None
                return path

        elif fragment:
            # A pointer to an internal definition
            if fragment in self.definitions:
                return self, self.definitions[fragment]
            else:
                # The missing definition should appear later in the file
                self.waiting_use_nodes[fragment].append((node, group))
                return DELAYED

        # The target could not be resolved (e.g. an external SVG with no root
        # node, or an unresolved fragment): make the fall-through explicit.
        return None

    def renderTitle_(self, node: NodeTracker) -> None:
        """Handle the <title> element (currently a no-op)."""
        pass

    def renderDesc_(self, node: NodeTracker) -> None:
        """Handle the <desc> element (currently a no-op)."""
        pass

    def get_box(self, svg_node: NodeTracker, default_box: bool = False) -> Box:
        """Get the viewBox or dimensions of an SVG node.

        Args:
            svg_node: The NodeTracker for the SVG node.
            default_box: If True, use width/height as a fallback.

        Returns:
            A Box tuple representing the dimensions.
        """
        view_box = svg_node.getAttribute("viewBox")
        if view_box:
            # viewBox defines a unitless user-coordinate space (SVG spec §8.1).
            # Parse as raw floats — never apply unit conversion here.
            values = [float(v) for v in view_box.replace(",", " ").split()]
            return Box(*values)
        if default_box:
            width, height = map(svg_node.getAttribute, ("width", "height"))
            width, height = map(self.attrConverter.convertLength, (width, height))  # type: ignore
            return Box(0, 0, width, height)
        return Box(0, 0, 0, 0)  # fallback

    def renderSvg(self, node: NodeTracker, outermost: bool = False) -> Any:
        """Render an <svg> element into a ReportLab Group.

        Args:
            node: The NodeTracker for the <svg> element.
            outermost: True if this is the root <svg> element.

        Returns:
            A ReportLab Group containing the rendered content.
        """
        _saved_preserve_space = self.shape_converter.preserve_space
        self.shape_converter.preserve_space = (
            node.getAttribute(f"{{{XML_NS}}}space") == "preserve"
        )
        view_box = self.get_box(node, default_box=True)
        _saved_box = self.attrConverter.main_box
        if view_box:
            self.attrConverter.set_box(view_box)

        # Rendering all definition nodes first.
        svg_ns = node.nsmap.get(None)
        for def_node in node.iter_subtree():
            if def_node.tag == (f"{{{svg_ns}}}defs" if svg_ns else "defs"):
                self.renderG(def_node)

        group = Group()
        for child in node.iter_children():
            self.renderNode(child, group)
        self.shape_converter.preserve_space = _saved_preserve_space
        self.attrConverter.set_box(_saved_box)

        # Translating
        if not outermost:
            x, y = self.shape_converter.convert_length_attrs(node, "x", "y")
            if x or y:
                group.translate(x or 0, y or 0)

        # Scaling
        if not view_box and outermost:
            # Apply only the 'reverse' y-scaling (PDF 0,0 is bottom left)
            group.scale(1, -1)
        elif view_box:
            width, height = self.shape_converter.convert_length_attrs(
                node, "width", "height", defaults=(None,) * 2
            )
            # Per SVG 1.1 §5.1.2, a nested <svg> with omitted width/height
            # defaults them to "100%" of the parent viewport. (SVG 2 §8.2
            # technically changed this so that omitting either resolves to 0
            # and disables rendering, but no browser implements that and most
            # real-world SVGs still rely on the 1.1 behavior.) Without this
            # fallback, an inner <svg viewBox="0 0 N N"> with no explicit
            # width/height renders content at viewBox-unit size instead of
            # filling the parent slot.
            if not outermost and _saved_box is not None:
                if width is None:
                    width = _saved_box.width
                if height is None:
                    height = _saved_box.height
            # Fall back to viewBox dimensions (user units) when no explicit size.
            # render() does the same, so Drawing size = viewBox × PX_TO_PT.
            if width is None:
                width = view_box.width
            if height is None:
                height = view_box.height
            # canvas is in pts; view_box is in user units — scale converts user→pt
            x_scale = (width * PX_TO_PT) / view_box.width if view_box.width else 1
            y_scale = (height * PX_TO_PT) / view_box.height if view_box.height else 1

            # Apply preserveAspectRatio (default: xMidYMid meet)
            par = (node.getAttribute("preserveAspectRatio") or "xMidYMid meet").strip()
            par_tokens = par.split()
            align = par_tokens[0] if par_tokens else "xMidYMid"
            meet_or_slice = par_tokens[1] if len(par_tokens) > 1 else "meet"

            if align != "none":
                if meet_or_slice == "slice":
                    uniform_scale = max(x_scale, y_scale)
                else:  # meet (default)
                    uniform_scale = min(x_scale, y_scale)

                # Compute translation to align content within canvas (in pts)
                width_pt = width * PX_TO_PT
                height_pt = height * PX_TO_PT
                scaled_vb_w = view_box.width * uniform_scale
                scaled_vb_h = view_box.height * uniform_scale
                # Horizontal alignment
                if align.startswith("xMin"):
                    tx = 0
                elif align.startswith("xMax"):
                    tx = width_pt - scaled_vb_w
                else:  # xMid (default)
                    tx = (width_pt - scaled_vb_w) / 2
                # Vertical alignment (SVG y increases downward, PDF upward)
                if "YMin" in align:
                    ty = height_pt - scaled_vb_h
                elif "YMax" in align:
                    ty = 0
                else:  # YMid (default)
                    ty = (height_pt - scaled_vb_h) / 2
                if tx or ty:
                    group.translate(tx, ty)

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

        return group

    def renderG(self, node: NodeTracker, clipping: Optional[Any] = None) -> Any:
        """Render a <g> element into a ReportLab Group.

        Args:
            node: The NodeTracker for the <g> element.
            clipping: An optional clipping path to apply.

        Returns:
            A ReportLab Group containing the rendered content.
        """
        getAttr = node.getAttribute
        id, transform = map(getAttr, ("id", "transform"))
        gr = Group()
        if clipping:
            gr.add(clipping)
        for child in node.iter_children():
            self.renderNode(child, parent=gr)

        if transform:
            self.shape_converter.applyTransformOnGroup(transform, gr)

        return gr

    def renderSwitch(
        self, node: NodeTracker, clipping: Optional[Any] = None
    ) -> Optional[Any]:
        """Render a <switch> element by evaluating each child's conditions.

        Per the SVG spec, the first child whose requiredFeatures,
        requiredExtensions, and systemLanguage conditions are all satisfied
        is rendered; remaining children are skipped.
        """
        sys_lang = _default_language()
        sys_lang_prefix = sys_lang.split("_")[0]

        for child in node:
            # requiredExtensions: svglib supports no external extensions
            if child.attrib.get("requiredExtensions"):
                continue
            # requiredFeatures: all listed features must be supported
            required = child.attrib.get("requiredFeatures", "")
            if required:
                features = required.split()
                if not all(f.lower() in SUPPORTED_SVG_FEATURES for f in features):
                    continue
            # systemLanguage: at least one tag must match the system locale
            sys_language = child.attrib.get("systemLanguage", "")
            if sys_language:
                tags = [t.strip() for t in sys_language.split(",")]
                if not any(t == sys_lang or t == sys_lang_prefix for t in tags):
                    continue
            # This child's conditions are satisfied — render it
            gr = Group()
            self.renderNode(child, parent=gr)
            if clipping is not None:
                gr.add(clipping)
            return gr

        return None

    def renderStyle(self, node: NodeTracker) -> None:
        """Render a <style> element by adding its content to the CSS matcher."""
        if self.attrConverter.css_rules is not None:
            self.attrConverter.css_rules.add_styles(node.text or "")

    def renderSymbol(self, node: NodeTracker) -> Any:
        """Render a <symbol> element as a ReportLab Group."""
        return self.renderG(node)

    def renderA(self, node: NodeTracker) -> Any:
        """Render an <a> element as a ReportLab Group (no linking support)."""
        return self.renderG(node)

    def renderUse(
        self,
        node: NodeTracker,
        group: Optional[Group] = None,
        clipping: Optional[Any] = None,
    ) -> Any:
        """Render a <use> element by cloning a defined element.

        Args:
            node: The NodeTracker for the <use> element.
            group: The parent group to render into.
            clipping: An optional clipping path to apply.

        Returns:
            A ReportLab Group containing the rendered content.
        """
        if group is None:
            group = Group()

        try:
            item = self.xlink_href_target(node, group=group)
        except CircularRefError:
            node.parent.etree_element.remove(node.etree_element)
            return group
        if item is None:
            return
        elif isinstance(item, str):
            logger.error("<use> nodes cannot reference bitmap image files")
            return
        elif item is DELAYED:
            return group
        else:
            item = item[1]  # [0] is the renderer, not used here.

        if clipping:
            group.add(clipping)
        if len(node.getchildren()) == 0:
            # Append a copy of the referenced node as the <use> child (if not
            # already done)
            node.append(copy.deepcopy(item))
        self.renderNode(list(node.iter_children())[-1], parent=group)
        self.apply_node_attr_to_group(node, group)
        return group
        return None


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

    Subclasses should implement `convertX` methods for each SVG shape `X`
    (e.g., `convertRect`, `convertCircle`).
    """

    def __init__(
        self,
        path: SVGSource,
        attrConverter: Optional[Svg2RlgAttributeConverter] = None,
    ) -> None:
        """Initialize the shape converter with its source and attribute converter."""
        self.attrConverter = attrConverter or Svg2RlgAttributeConverter()
        self.svg_source_file = path
        self.preserve_space = False

    @classmethod
    def get_handled_shapes(cls) -> list[str]:
        """Return a list of SVG shape names that this converter can handle."""
        return [key[7:].lower() for key in dir(cls) if key.startswith("convert")]


class Svg2RlgShapeConverter(SvgShapeConverter):
    """A class for converting SVG shapes to ReportLab Graphics shapes."""

    def convertShape(self, name: str, node: Any, clipping: Optional[Any] = None) -> Any:
        """Convert an SVG shape by calling the appropriate `convertX` method.

        Args:
            name: The name of the SVG shape (e.g., "rect", "circle").
            node: The lxml node for the shape.
            clipping: An optional clipping path to apply.

        Returns:
            A ReportLab shape object, or a Group if transforms or clipping
            are applied.
        """
        method_name = f"convert{name.capitalize()}"
        shape = getattr(self, method_name)(node)
        if not shape:
            return
        if name not in ("path", "polyline", "text"):
            # Only apply style where the convert method did not apply it.
            self.applyStyleOnShape(shape, node)
        transform = node.getAttribute("transform")

        if clipping:
            group = Group()
            group.add(clipping)
            group.add(shape)
            shape = group

        if transform:
            if not isinstance(shape, Group):
                group = Group()
                group.add(shape)
                shape = group

            # Matrix multiplication is non-commutative, need to apply in order
            temp_group = Group()
            self.applyTransformOnGroup(transform, temp_group)
            shape.transform = mmult(temp_group.transform, shape.transform)

        return shape

    def convert_length_attrs(
        self,
        node: Any,
        *attrs: str,
        em_base: float = DEFAULT_FONT_SIZE / PX_TO_PT,
        **kwargs: Any,
    ) -> list[float]:
        """Convert a list of length attributes from a node.

        Args:
            node: The lxml node.
            *attrs: The names of the attributes to convert.
            em_base: The base font size for 'em' units.
            **kwargs: Can include 'defaults' for fallback values.

        Returns:
            A list of converted lengths in points.
        """
        getAttr = (
            node.getAttribute
            if hasattr(node, "getAttribute")
            else lambda attr: node.attrib.get(attr, "")
        )
        convLength = self.attrConverter.convertLength
        defaults = kwargs.get("defaults", (0.0,) * len(attrs))
        return [
            convLength(getAttr(attr), attr_name=attr, em_base=em_base, default=default)  # type: ignore
            for attr, default in zip(attrs, defaults)
        ]

    def convertLine(self, node: Any) -> Line:
        """Convert an SVG <line> element to a ReportLab Line."""
        points = self.convert_length_attrs(node, "x1", "y1", "x2", "y2")
        nudge_points(points)
        return Line(*points)

    def convertRect(self, node: Any) -> Optional[Rect]:
        """Convert an SVG <rect> element to a ReportLab Rect."""
        x, y, width, height, rx, ry = self.convert_length_attrs(
            node, "x", "y", "width", "height", "rx", "ry"
        )
        if rx > (width / 2):
            rx = width / 2
        if ry > (height / 2):
            ry = height / 2
        if rx and not ry:
            ry = rx
        elif ry and not rx:
            rx = ry

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

    def convertCircle(self, node: Any) -> Circle:
        """Convert an SVG <circle> element to a ReportLab Circle."""
        cx, cy, r = self.convert_length_attrs(node, "cx", "cy", "r")
        return Circle(cx, cy, r)

    def convertEllipse(self, node: Any) -> Ellipse:
        """Convert an SVG <ellipse> element to a ReportLab Ellipse."""
        cx, cy, rx, ry = self.convert_length_attrs(node, "cx", "cy", "rx", "ry")
        width, height = rx, ry
        return Ellipse(cx, cy, width, height)

    def convertPolyline(self, node: Any) -> Optional[Any]:
        """Convert an SVG <polyline> element to a ReportLab PolyLine."""
        points = node.getAttribute("points")
        points = points.replace(",", " ")
        points = points.split()
        points = list(map(self.attrConverter.convertLength, points))
        if len(points) % 2 != 0 or not points:
            # Odd number of coordinates or no coordinates, invalid polyline
            return None

        nudge_points(points)
        polyline = PolyLine(points)
        self.applyStyleOnShape(polyline, node)
        has_fill = self.attrConverter.findAttr(node, "fill") not in ("", "none")

        if has_fill:
            # ReportLab doesn't fill polylines, so we are creating a polygon
            # polygon copy of the polyline, but without stroke.
            group = Group()
            polygon = Polygon(points)
            self.applyStyleOnShape(polygon, node)
            polygon.strokeColor = None
            group.add(polygon)
            group.add(polyline)
            return group

        return polyline

    def convertPolygon(self, node: Any) -> Optional[Polygon]:
        """Convert an SVG <polygon> element to a ReportLab Polygon."""
        points = node.getAttribute("points")
        points = points.replace(",", " ")
        points = points.split()
        points = list(map(self.attrConverter.convertLength, points))
        if len(points) % 2 != 0 or not points:
            # Odd number of coordinates or no coordinates, invalid polygon
            return None
        nudge_points(points)
        shape = Polygon(points)

        return shape

    def convertText(self, node: Any) -> Any:
        """Convert an SVG <text> element to a ReportLab Group of Strings."""
        attrConv = self.attrConverter
        xml_space = node.getAttribute(f"{{{XML_NS}}}space")
        if xml_space:
            preserve_space = xml_space == "preserve"
        else:
            preserve_space = self.preserve_space

        gr = Group()

        frag_lengths: list[float] = []

        dx0: float = 0
        dy0: float = 0
        x1: Union[float, list[float]] = 0
        y1: Union[float, list[float]] = 0
        ff = attrConv.findAttr(node, "font-family") or DEFAULT_FONT_NAME
        fw = attrConv.findAttr(node, "font-weight") or DEFAULT_FONT_WEIGHT
        fstyle = attrConv.findAttr(node, "font-style") or DEFAULT_FONT_STYLE
        ff = attrConv.convertFontFamily(ff, fw, fstyle)
        fs_attr = attrConv.findAttr(node, "font-size") or f"{DEFAULT_FONT_SIZE}pt"
        # font-size is always a single value, so convertLength returns a float.
        fs = cast(float, attrConv.convertLength(fs_attr))  # user units, em_base
        x: list[float]
        y: list[float]
        x, y = self.convert_length_attrs(node, "x", "y", em_base=fs)  # type: ignore
        for subnode, text, is_tail in iter_text_node(node, preserve_space):
            if not text:
                continue
            has_x, has_y = False, False
            dx: Union[float, list[float]] = 0
            dy: Union[float, list[float]] = 0
            baseLineShift: Union[float, int] = 0
            if not is_tail:
                x1, y1, dx, dy = self.convert_length_attrs(
                    subnode,
                    "x",
                    "y",
                    "dx",
                    "dy",
                    em_base=fs,
                )
                has_x, has_y = (
                    subnode.attrib.get("x", "") != "",
                    subnode.attrib.get("y", "") != "",
                )
                dx0 += dx[0] if isinstance(dx, list) else dx
                dy0 += dy[0] if isinstance(dy, list) else dy
            baseLineShift_raw = subnode.attrib.get("baseline-shift", "0")
            if baseLineShift_raw in ("sub", "super", "baseline"):
                baseLineShift = {"sub": -fs / 2, "super": fs / 2, "baseline": 0}[
                    baseLineShift_raw
                ]
            else:
                baseLineShift = cast(
                    float, attrConv.convertLength(baseLineShift_raw, em_base=fs)
                )

            # User units, like the x coordinates these widths are added to.
            frag_lengths.append(stringWidth(text, ff, fs))

            # When x, y, dx, or dy is a list, we calculate position for each char of
            # text.
            if any(isinstance(val, list) for val in (x1, y1, dx, dy)):
                if has_x:
                    xlist = x1 if isinstance(x1, list) else [x1]
                else:
                    xlist = [x + dx0 + sum(frag_lengths[:-1])]  # type: ignore
                if has_y:
                    ylist = y1 if isinstance(y1, list) else [y1]
                else:
                    ylist = [y + dy0]  # type: ignore
                dxlist = dx if isinstance(dx, list) else [dx]
                dylist = dy if isinstance(dy, list) else [dy]
                last_x, last_y, last_char = xlist[0], ylist[0], ""
                for char_x, char_y, char_dx, char_dy, char in itertools.zip_longest(
                    xlist, ylist, dxlist, dylist, text
                ):
                    if char is None:
                        break
                    if char_dx is None:
                        char_dx = 0
                    if char_dy is None:
                        char_dy = 0
                    new_x = char_dx + (
                        last_x + stringWidth(last_char, ff, fs)
                        if char_x is None
                        else char_x
                    )
                    new_y = char_dy + (last_y if char_y is None else char_y)
                    shape = String(new_x, -(new_y - baseLineShift), char)
                    self.applyStyleOnShape(shape, node)
                    if node_name(subnode) == "tspan":
                        self.applyStyleOnShape(shape, subnode)
                    gr.add(shape)
                    last_x = new_x
                    last_y = new_y
                    last_char = char
            else:
                new_x = (x1 + dx) if has_x else (x + dx0 + sum(frag_lengths[:-1]))  # type: ignore
                new_y = (y1 + dy) if has_y else (y + dy0)  # type: ignore
                shape = String(new_x, -(new_y - baseLineShift), text)  # type: ignore
                self.applyStyleOnShape(shape, node)
                if node_name(subnode) == "tspan":
                    self.applyStyleOnShape(shape, subnode)
                gr.add(shape)

        gr.scale(1, -1)

        return gr

    def convertPath(self, node: Any) -> Optional[Any]:
        """Convert an SVG <path> element to a ReportLab Path."""
        d = node.get("d")
        if not d:
            return None
        normPath = normalise_svg_path(d)
        path = Path()
        points = path.points
        # Track subpaths needing to be closed later
        unclosed_subpath_pointers: list[int] = []
        subpath_start: list[float] = []
        lastop = ""
        last_quadratic_cp: Optional[tuple[float, float]] = None

        for i in range(0, len(normPath), 2):
            op: str
            nums: list[float]
            op, nums = normPath[i : i + 2]  # type: ignore

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

            # moveto absolute
            if op == "M":
                path.moveTo(*nums)
                subpath_start = points[-2:]
            # lineto absolute
            elif op == "L":
                path.lineTo(*nums)

            # moveto relative
            elif op == "m":
                if len(points) >= 2:
                    if lastop in ("Z", "z"):
                        starting_point = subpath_start
                    else:
                        starting_point = points[-2:]
                    xn, yn = starting_point[0] + nums[0], starting_point[1] + nums[1]
                    path.moveTo(xn, yn)
                else:
                    path.moveTo(*nums)
                subpath_start = points[-2:]
            # lineto relative
            elif op == "l":
                xn, yn = points[-2] + nums[0], points[-1] + nums[1]
                path.lineTo(xn, yn)

            # horizontal/vertical line absolute
            elif op == "H":
                path.lineTo(nums[0], points[-1])
            elif op == "V":
                path.lineTo(points[-2], nums[0])

            # horizontal/vertical line relative
            elif op == "h":
                path.lineTo(points[-2] + nums[0], points[-1])
            elif op == "v":
                path.lineTo(points[-2], points[-1] + nums[0])

            # cubic bezier, absolute
            elif op == "C":
                path.curveTo(*nums)
            elif op == "S":
                x2, y2, xn, yn = nums
                if len(points) < 4 or lastop not in {"c", "C", "s", "S"}:
                    xp, yp, x0, y0 = points[-2:] * 2
                else:
                    xp, yp, x0, y0 = points[-4:]
                xi, yi = x0 + (x0 - xp), y0 + (y0 - yp)
                path.curveTo(xi, yi, x2, y2, xn, yn)

            # cubic bezier, relative
            elif op == "c":
                xp, yp = points[-2:]
                x1, y1, x2, y2, xn, yn = nums
                path.curveTo(xp + x1, yp + y1, xp + x2, yp + y2, xp + xn, yp + yn)
            elif op == "s":
                x2, y2, xn, yn = nums
                if len(points) < 4 or lastop not in {"c", "C", "s", "S"}:
                    xp, yp, x0, y0 = points[-2:] * 2
                else:
                    xp, yp, x0, y0 = points[-4:]
                xi, yi = x0 + (x0 - xp), y0 + (y0 - yp)
                path.curveTo(xi, yi, x0 + x2, y0 + y2, x0 + xn, y0 + yn)

            # quadratic bezier, absolute
            elif op == "Q":
                x0, y0 = points[-2:]
                x1, y1, xn, yn = nums
                last_quadratic_cp = (x1, y1)
                (_, _), (x1, y1), (x2, y2), (_, _) = convert_quadratic_to_cubic_path(
                    (x0, y0), (x1, y1), (xn, yn)
                )
                path.curveTo(x1, y1, x2, y2, xn, yn)
            elif op == "T":
                if last_quadratic_cp is not None:
                    xp, yp = last_quadratic_cp
                else:
                    xp, yp = points[-2:]
                x0, y0 = points[-2:]
                xi, yi = x0 + (x0 - xp), y0 + (y0 - yp)
                last_quadratic_cp = (xi, yi)
                xn, yn = nums
                (
                    (_, _),
                    (x1, y1),
                    (x2, y2),
                    (_, _),
                ) = convert_quadratic_to_cubic_path((x0, y0), (xi, yi), (xn, yn))
                path.curveTo(x1, y1, x2, y2, xn, yn)

            # quadratic bezier, relative
            elif op == "q":
                x0, y0 = points[-2:]
                x1, y1, xn, yn = nums
                x1, y1, xn, yn = x0 + x1, y0 + y1, x0 + xn, y0 + yn
                last_quadratic_cp = (x1, y1)
                (_, _), (x1, y1), (x2, y2), (_, _) = convert_quadratic_to_cubic_path(
                    (x0, y0), (x1, y1), (xn, yn)
                )
                path.curveTo(x1, y1, x2, y2, xn, yn)
            elif op == "t":
                if last_quadratic_cp is not None:
                    xp, yp = last_quadratic_cp
                else:
                    xp, yp = points[-2:]
                x0, y0 = points[-2:]
                xn, yn = nums
                xn, yn = x0 + xn, y0 + yn
                xi, yi = x0 + (x0 - xp), y0 + (y0 - yp)
                last_quadratic_cp = (xi, yi)
                (
                    (_, _),
                    (x1, y1),
                    (x2, y2),
                    (_, _),
                ) = convert_quadratic_to_cubic_path((x0, y0), (xi, yi), (xn, yn))
                path.curveTo(x1, y1, x2, y2, xn, yn)

            # elliptical arc
            elif op in ("A", "a"):
                rx, ry, phi, fA, fS, x2, y2 = nums
                x1, y1 = points[-2:]
                if op == "a":
                    x2 += x1
                    y2 += y1
                if abs(rx) <= 1e-10 or abs(ry) <= 1e-10:
                    path.lineTo(x2, y2)
                else:
                    bp = bezier_arc_from_end_points(
                        x1, y1, rx, ry, phi, int(fA), int(fS), x2, y2
                    )
                    for _, _, x1, y1, x2, y2, xn, yn in bp:
                        path.curveTo(x1, y1, x2, y2, xn, yn)

            # close path
            elif op in ("Z", "z"):
                path.closePath()

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

            if op not in ("Q", "q", "T", "t"):
                last_quadratic_cp = None
            lastop = op

        gr = Group()
        self.applyStyleOnShape(path, node)

        if path.operators[-1] != _CLOSEPATH:
            unclosed_subpath_pointers.append(len(path.operators))

        if unclosed_subpath_pointers and path.fillColor is not None:
            # ReportLab doesn't fill unclosed paths, so we are creating a copy
            # of the path with all subpaths closed, but without stroke.
            # https://bitbucket.org/rptlab/reportlab/issues/99/
            closed_path = NoStrokePath(copy_from=path)
            for pointer in reversed(unclosed_subpath_pointers):
                closed_path.operators.insert(pointer, _CLOSEPATH)
            gr.add(closed_path)
            path.fillColor = None

        gr.add(path)
        return gr

    def convertImage(self, node: Any) -> Any:
        """Convert an SVG <image> element to a ReportLab Image."""
        x, y, width, height = self.convert_length_attrs(
            node, "x", "y", "width", "height"
        )
        image = node._resolved_target

        # If the width and/or height were not set then the image size
        # needs to be used as a fallback
        if (not width or not height) and image and image.size:
            image_width, image_height = image.size

            if image_width and image_height:
                if width and not height:
                    height = width * image_height / image_width
                elif height and not width:
                    width = height * image_width / image_height
                else:
                    width = image_width
                    height = image_height

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

        group = Group(image)
        group.translate(0, (y + height) * 2)
        group.scale(1, -1)
        return group

    def applyTransformOnGroup(self, transform: str, group: Any) -> None:
        """Apply an SVG transformation to a ReportLab Group.

        Args:
            transform: The SVG transform attribute string.
            group: The ReportLab Group to apply the transform to.
        """
        tr = self.attrConverter.convertTransform(transform)
        for op, values in tr:
            if op == "scale":
                if not isinstance(values, tuple):
                    values = (values, values)
                group.scale(*values)
            elif op == "translate":
                if isinstance(values, (int, float)):
                    # From the SVG spec: If <ty> is not provided, it is assumed to
                    # be zero.
                    values = values, 0
                group.translate(*values)
            elif op == "rotate":
                if not isinstance(values, tuple) or len(values) == 1:
                    group.rotate(values)
                elif len(values) == 3:
                    angle, cx, cy = values
                    group.translate(cx, cy)
                    group.rotate(angle)
                    group.translate(-cx, -cy)
            elif op == "skewX":
                group.skew(values, 0)
            elif op == "skewY":
                group.skew(0, values)
            elif op == "matrix" and len(values) == 6:  # type: ignore
                group.transform = mmult(group.transform, values)
            else:
                logger.debug("Ignoring transform: %s %s", op, values)

    def applyStyleOnShape(
        self,
        shape: Any,
        node: Any,
        only_explicit: bool = False,
    ) -> None:
        """Apply styles from an SVG node to a ReportLab shape.

        Args:
            shape: The ReportLab shape to apply styles to.
            node: The lxml node to get style attributes from.
            only_explicit: If True, only apply explicitly defined attributes.
        """
        # RLG-specific: all RLG shapes
        """Apply style attributes of a sequence of nodes to an RL shape."""

        # tuple format: (svgAttributes, rlgAttr, converter, default)
        mappingN = (
            (["fill"], "fillColor", "convertColor", ["black"]),
            (["fill-opacity"], "fillOpacity", "convertOpacity", [1]),
            (["fill-rule"], "fillMode", "convertFillRule", ["nonzero"]),
            (["stroke"], "strokeColor", "convertColor", ["none"]),
            (["stroke-width"], "strokeWidth", "convertLength", ["1"]),
            (["stroke-opacity"], "strokeOpacity", "convertOpacity", [1]),
            (["stroke-linejoin"], "strokeLineJoin", "convertLineJoin", ["0"]),
            (["stroke-linecap"], "strokeLineCap", "convertLineCap", ["0"]),
            (["stroke-dasharray"], "strokeDashArray", "convertDashArray", ["none"]),
        )
        mappingF = (
            (
                ["font-family", "font-weight", "font-style"],
                "fontName",
                "convertFontFamily",
                [DEFAULT_FONT_NAME, DEFAULT_FONT_WEIGHT, DEFAULT_FONT_STYLE],
            ),
            # User units: the viewport group already applies PX_TO_PT.
            (
                ["font-size"],
                "fontSize",
                "convertLength",
                [f"{DEFAULT_FONT_SIZE}pt"],
            ),
            (["text-anchor"], "textAnchor", "id", ["start"]),
        )

        if shape.__class__ == Group:
            # Recursively apply style on Group subelements
            for subshape in shape.contents:
                self.applyStyleOnShape(subshape, node, only_explicit=only_explicit)
            return

        ac = self.attrConverter
        for mapping in (mappingN, mappingF):
            if shape.__class__ != String and mapping == mappingF:
                continue
            for svgAttrNames, rlgAttr, func, defaults in mapping:
                svgAttrValues = []
                for index, svgAttrName in enumerate(svgAttrNames):
                    svgAttrValue = ac.findAttr(node, svgAttrName)
                    if svgAttrValue == "":
                        if only_explicit:
                            continue
                        if (
                            svgAttrName == "fill-opacity"
                            and getattr(shape, "fillColor", None) is not None
                            and getattr(shape.fillColor, "alpha", 1) != 1
                        ):
                            svgAttrValue = shape.fillColor.alpha
                        elif (
                            svgAttrName == "stroke-opacity"
                            and getattr(shape, "strokeColor", None) is not None
                            and getattr(shape.strokeColor, "alpha", 1) != 1
                        ):
                            svgAttrValue = shape.strokeColor.alpha
                        else:
                            svgAttrValue = defaults[index]  # type: ignore
                    if svgAttrValue == "currentColor":
                        svgAttrValue = (
                            ac.findAttr(node.parent, "color") or defaults[index]  # type: ignore
                        )
                    if isinstance(svgAttrValue, str):
                        svgAttrValue = svgAttrValue.replace("!important", "").strip()
                    svgAttrValues.append(svgAttrValue)
                try:
                    meth = getattr(ac, func)
                    setattr(shape, rlgAttr, meth(*svgAttrValues))
                except (AttributeError, KeyError, ValueError) as exc:
                    exc_type = type(exc).__name__
                    logger.debug(
                        "applyStyleOnShape setattr({},{!r},{}(*{!r}))"
                        " caused {} exception".format(
                            shape.__class__.__name__,
                            rlgAttr,
                            meth.__name__,
                            svgAttrValues,
                            exc_type,
                        )
                    )
        if getattr(shape, "fillOpacity", None) is not None and shape.fillColor:
            shape.fillColor.alpha = shape.fillOpacity
        if getattr(shape, "strokeWidth", None) == 0:
            # Quoting from the PDF 1.7 spec:
            # A line width of 0 denotes the thinnest line that can be rendered at
            # device resolution: 1 device pixel wide. However, some devices cannot
            # reproduce 1-pixel lines, and on high-resolution devices, they are
            # nearly invisible. Since the results of rendering such zero-width
            # lines are device-dependent, their use is not recommended.
            shape.strokeColor = None

    def shapeToPath(
        self,
        shape: SolidShape,
        transform: Optional[tuple[float, float, float, float, float, float]] = None,
    ) -> Optional[Path]:
        """
        Convert a solid shape into path.

        Not all shapes can be perfectly represented with bézier curves
        so the path may be a close approximation with a margin of error.
        """
        if not shape:
            return None

        path = Path()

        # Approximation constant for circle to cubic bezier = 4/3 * tan(pi/8)
        bc = 0.552284749830793

        if isinstance(shape, Rect):
            x = shape.x
            y = shape.y
            width = shape.width
            height = shape.height
            rx = shape.rx
            ry = shape.ry

            if rx == 0 or ry == 0:
                # Basic rectangle, exact shape
                path.moveTo(x, y)
                path.lineTo(x + width, y)
                path.lineTo(x + width, y + height)
                path.lineTo(x, y + height)
                path.closePath()
            else:
                # Rounded corners, need to approximate the corner curves
                dx = rx * bc
                dy = ry * bc

                # Bottom-left corner arc start
                path.moveTo(x + rx, y)

                # Bottom edge + bottom-right corner
                path.lineTo(x + width - rx, y)
                path.curveTo(
                    x + width - rx + dx, y, x + width, y + ry - dy, x + width, y + ry
                )

                # Right edge + top-right corner
                path.lineTo(x + width, y + height - ry)
                path.curveTo(
                    x + width,
                    y + height - ry + dy,
                    x + width - rx + dx,
                    y + height,
                    x + width - rx,
                    y + height,
                )

                # Top edge + top-left corner
                path.lineTo(x + rx, y + height)
                path.curveTo(
                    x + rx - dx, y + height, x, y + height - ry + dy, x, y + height - ry
                )

                # Left edge + bottom-left corner
                path.lineTo(x, y + ry)
                path.curveTo(x, y + ry - dy, x + rx - dx, y, x + rx, y)

                path.closePath()

        elif isinstance(shape, Circle) or isinstance(shape, Ellipse):
            if isinstance(shape, Circle):
                rx = shape.r
                ry = shape.r
            else:
                rx = shape.rx
                ry = shape.ry

            cx = shape.cx
            cy = shape.cy
            dx = rx * bc
            dy = ry * bc

            path.moveTo(cx + rx, cy)

            # Lower-right
            path.curveTo(
                cx + rx,
                cy - dy,
                cx + dx,
                cy - ry,
                cx,
                cy - ry,
            )

            # Lower-left
            path.curveTo(
                cx - dx,
                cy - ry,
                cx - rx,
                cy - dy,
                cx - rx,
                cy,
            )

            # Upper-left
            path.curveTo(
                cx - rx,
                cy + dy,
                cx - dx,
                cy + ry,
                cx,
                cy + ry,
            )

            # Upper-right
            path.curveTo(
                cx + dx,
                cy + ry,
                cx + rx,
                cy + dy,
                cx + rx,
                cy,
            )

            path.closePath()

        elif isinstance(shape, Polygon):
            if len(shape.points) >= 2:
                path.moveTo(shape.points[0], shape.points[1])

            for i in range(2, len(shape.points), 2):
                path.lineTo(shape.points[i], shape.points[i + 1])

            path.closePath()

        elif isinstance(shape, Path):
            path = shape.copy()

        else:
            # Unsupported shape conversion
            return None

        # Preserve fill rule and other shape properties
        copy_shape_properties(shape, path)

        # Bake in the transform so it can be used with clipping paths
        if transform:
            a, b, c, d, e, f = transform
            for i in range(0, len(path.points), 2):
                x, y = path.points[i], path.points[i + 1]
                path.points[i] = a * x + c * y + e
                path.points[i + 1] = b * x + d * y + f

        return path


def svg2rlg(
    path: Union[str, os.PathLike[str], BinaryIO, TextIO],
    resolve_entities: bool = False,
    **kwargs: Any,
) -> Optional[Drawing]:
    """Convert an SVG file to a ReportLab Drawing object.

    Args:
        path: A file path, file-like object, or pathlib.Path to the SVG file.
        resolve_entities: Whether to resolve XML entities (default False).
        **kwargs: Additional keyword arguments for the SvgRenderer.

    Returns:
        A ReportLab Drawing object, or None if the file cannot be processed.
    """
    if isinstance(path, pathlib.Path):
        path = str(path)

    # unzip .svgz file into .svg
    unzipped_path: Optional[str] = None
    if isinstance(path, str) and os.path.splitext(path)[1].lower() == ".svgz":
        with gzip.open(path, "rb") as f_in, open(path[:-1], "wb") as f_out:
            shutil.copyfileobj(f_in, f_out)
        path = path[:-1]
        unzipped_path = path

    svg_root = load_svg_file(path, resolve_entities=resolve_entities)
    if svg_root is None:
        return None

    # convert to a RLG drawing
    svgRenderer = SvgRenderer(path, **kwargs)
    drawing = svgRenderer.render(svg_root)

    # remove unzipped .svgz file (.svg)
    if unzipped_path is not None:
        os.remove(unzipped_path)

    return drawing


def nudge_points(points: list[float]) -> None:
    """Nudge the first coordinate if all coordinate pairs are identical.

    This is a workaround for a ReportLab issue where shapes of size zero
    are not rendered, even if they have a visible stroke.

    Args:
        points: A list of coordinates [x1, y1, x2, y2, ...].
    """
    if not points:
        return
    if len(points) < 4:
        return
    x = points[0]
    y = points[1]
    for i in range(2, len(points) - 1, 2):
        if x != points[i] or y != points[i + 1]:
            break
    else:
        # All points were identical, so we nudge.
        points[0] *= 1.0000001


def load_svg_file(path: SVGSource, resolve_entities: bool = False) -> Optional[Any]:
    """Load an SVG file and return the root lxml node.

    Args:
        path: A file path or file-like object for the SVG file.
        resolve_entities: Whether to resolve XML entities.

    Returns:
        The root lxml node of the SVG document, or None on failure.
    """
    parser = etree.XMLParser(
        remove_comments=True, recover=True, resolve_entities=resolve_entities
    )
    try:
        doc = etree.parse(path, parser=parser)
        svg_root = doc.getroot()
    except Exception as exc:
        logger.error("Failed to load input file! (%s)", exc)
        return None
    else:
        return svg_root


def node_name(node: Any) -> Optional[str]:
    """Return the name of an lxml node without the namespace prefix.

    Args:
        node: The lxml node.

    Returns:
        The node name as a string, or None if the node is invalid.
    """
    try:
        return cast(str, node.tag.split("}")[-1])
    except AttributeError:
        return None


def iter_text_node(node: Any, preserve_space: bool, level: int = 0) -> Any:
    """Recursively iterate through a text node and its children.

    This generator yields the node, its text, and its tail text, handling
    whitespace according to the 'xml:space' attribute.

    Args:
        node: The lxml node to start iteration from.
        preserve_space: Whether to preserve whitespace.
        level: The current recursion level.

    Yields:
        A tuple of (node, text, is_tail).
    """
    level0 = level == 0
    text = (
        clean_text(
            node.text,
            preserve_space,
            strip_start=level0,
            strip_end=(level0 and len(node.getchildren()) == 0),
        )
        if node.text
        else None
    )

    yield node, text, False

    for child in node.iter_children():
        yield from iter_text_node(child, preserve_space, level=level + 1)

    if level > 0:  # We are not interested by tail of main node.
        strip_end = level <= 1 and node.getnext() is None
        tail = (
            clean_text(node.tail, preserve_space, strip_end=strip_end)
            if node.tail
            else None
        )
        if tail not in (None, ""):
            yield node.parent, tail, True


def clean_text(
    text: Optional[str],
    preserve_space: bool,
    strip_start: bool = False,
    strip_end: bool = False,
) -> Optional[str]:
    """Clean text content according to SVG whitespace handling rules.

    Args:
        text: The text content to clean.
        preserve_space: Whether to preserve whitespace.
        strip_start: Whether to strip leading whitespace.
        strip_end: Whether to strip trailing whitespace.

    Returns:
        The cleaned text, or None if the input was None.
    """
    if text is None:
        return None
    text = text.replace("\r\n", " ").replace("\n", " ").replace("\t", " ")
    if not preserve_space:
        if strip_start:
            text = text.lstrip()
        if strip_end:
            text = text.rstrip()
        while "  " in text:
            text = text.replace("  ", " ")
    return text


def copy_shape_properties(source_shape: Any, dest_shape: Any) -> None:
    """Copy properties from one ReportLab shape to another.

    Args:
        source_shape: The shape to copy properties from.
        dest_shape: The shape to copy properties to.
    """
    for prop, val in source_shape.getProperties().items():
        try:
            setattr(dest_shape, prop, val)
        except AttributeError:
            pass
