# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from collections import defaultdict, deque
import copy
import dataclasses
from functools import lru_cache, reduce
import itertools
from lxml import etree  # pytype: disable=import-error
import re
from typing import (
    Any,
    Generator,
    Iterable,
    List,
    Mapping,
    MutableMapping,
    NamedTuple,
    Optional,
    Sequence,
    Tuple,
)
from picosvgx.svg_meta import (
    ATTRIB_DEFAULTS,
    attrib_default,
    number_or_percentage,
    parse_css_length,
    ntos,
    splitns,
    strip_ns,
    svgns,
    xlinkns,
    parse_css_declarations,
    parse_view_box,
    SVG_LENGTH_FIELDS,
    _LinkedDefault,
)
from picosvgx.svg_types import *
from picosvgx.svg_transform import Affine2D
import numbers

_SHAPE_CLASSES = {
    "circle": SVGCircle,
    "ellipse": SVGEllipse,
    "line": SVGLine,
    "path": SVGPath,
    "polygon": SVGPolygon,
    "polyline": SVGPolyline,
    "rect": SVGRect,
}
_GRADIENT_CLASSES = {
    "linearGradient": SVGLinearGradient,
    "radialGradient": SVGRadialGradient,
}
_CLASS_ELEMENTS = {
    v: f"{{{svgns()}}}{k}" for k, v in {**_SHAPE_CLASSES, **_GRADIENT_CLASSES}.items()
}
_SHAPE_CLASSES.update({f"{{{svgns()}}}{k}": v for k, v in _SHAPE_CLASSES.items()})

_SHAPE_FIELDS = {
    tag: tuple(f.name for f in dataclasses.fields(klass))
    for tag, klass in _SHAPE_CLASSES.items()
}
_GRADIENT_FIELDS = {
    tag: tuple(f.name for f in dataclasses.fields(klass))
    for tag, klass in _GRADIENT_CLASSES.items()
}
# Manually add stop, we don't type it
_GRADIENT_FIELDS["stop"] = tuple({"offset", "stop_color", "stop_opacity"})

_GRADIENT_COORDS = {
    "linearGradient": (("x1", "y1"), ("x2", "y2")),
    "radialGradient": (("cx", "cy"), ("fx", "fy")),
}

_VALID_FIELDS = {}
_VALID_FIELDS.update(_SHAPE_FIELDS)
_VALID_FIELDS.update(_GRADIENT_FIELDS)

_XLINK_TEMP = "xlink_"


_ATTRIB_W_CUSTOM_INHERITANCE = frozenset({"clip-path", "opacity", "transform"})


# How much error, as pct of viewbox max(w,h), is allowed on lossy ops
# For example, for a Noto Emoji with a viewBox 0 0 128 128 permit error of 0.128
_MAX_PCT_ERROR = 0.1

# When you have no viewbox, use this. Absolute value in svg units.
_DEFAULT_DEFAULT_TOLERENCE = 0.1


# Rounding for rewritten gradient matrices
_GRADIENT_TRANSFORM_NDIGITS = 6

# Safe SVG elements allowed in defs (top-level)
_DEFS_ALLOWED_TAGS = frozenset({
    "filter", "mask", "pattern", "clipPath", "marker", "symbol",
    "linearGradient", "radialGradient", "stop",
    "rect", "circle", "ellipse", "line", "polyline", "polygon",
    "path", "g", "image", "use",
})

# Safe SVG elements allowed as children inside defs elements
_DEFS_CHILD_ALLOWED_TAGS = frozenset({
    # filter primitives
    "feGaussianBlur", "feOffset", "feMerge", "feMergeNode", "feBlend",
    "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix",
    "feDiffuseLighting", "feDisplacementMap", "feDropShadow", "feFlood",
    "feFuncR", "feFuncG", "feFuncB", "feFuncA",
    "feImage", "feMorphology", "feSpecularLighting", "feTile", "feTurbulence",
    # lighting sources
    "fePointLight", "feSpotLight", "feDistantLight",
    # basic shapes & structural
    "rect", "circle", "ellipse", "line", "polyline", "polygon",
    "path", "text", "use", "g", "image",
    # other
    "stop", "animate", "animateTransform", "set",
})


def _clamp(value: float, minv: float = 0.0, maxv: float = 1.0) -> float:
    return max(min(value, maxv), minv)


def _xlink_href_attr_name() -> str:
    return f"{{{xlinkns()}}}href"


def _copy_new_nsmap(tree, nsm):
    new_tree = etree.Element(tree.tag, nsmap=nsm)
    new_tree.attrib.update(tree.attrib)
    new_tree[:] = tree[:]
    return new_tree


def _fix_xlink_ns(tree):
    """Fix namespace problems for SVG and xlink.

    Ensure SVG has proper default namespace.
    If there are xlink temps, add namespace and fix temps.
    If we declare xlink but don't use it then remove it.
    """
    # Ensure SVG has proper default namespace
    nsm = copy.copy(tree.nsmap)
    if nsm.get(None) != svgns():
        nsm[None] = svgns()
        tree = _copy_new_nsmap(tree, nsm)
    xlink_nsmap = {"xlink": xlinkns()}
    if "xlink" in tree.nsmap and not len(
        tree.xpath("//*[@xlink:href]", namespaces=xlink_nsmap)
    ):
        # no reason to keep xlink
        nsm = copy.copy(tree.nsmap)
        del nsm["xlink"]
        tree = _copy_new_nsmap(tree, nsm)

    elif "xlink" not in tree.nsmap and len(tree.xpath(f"//*[@{_XLINK_TEMP}]")):
        # declare xlink and fix temps
        nsm = copy.copy(tree.nsmap)
        nsm["xlink"] = xlinkns()
        tree = _copy_new_nsmap(tree, nsm)
        for el in tree.xpath(f"//*[@{_XLINK_TEMP}]"):
            # try to retain attrib order, unexpected when they shuffle
            attrs = [(k, v) for k, v in el.attrib.items()]
            el.attrib.clear()
            for name, value in attrs:
                if name == _XLINK_TEMP:
                    name = _xlink_href_attr_name()
                el.attrib[name] = value

    return tree


def _del_attrs(el, *attr_names):
    for name in attr_names:
        if name in el.attrib:
            del el.attrib[name]


def _attr_name(field_name: str) -> str:
    return field_name.replace("_", "-")


def _field_name(attr_name: str) -> str:
    return attr_name.replace("-", "_")


def _is_defs(tag):
    return strip_ns(tag) == "defs"


def _is_shape(tag):
    return strip_ns(tag) in _SHAPE_CLASSES


def _is_gradient(tag):
    return strip_ns(tag) in _GRADIENT_CLASSES


def _is_group(tag):
    return strip_ns(tag) == "g"


def _opacity(el: etree.Element) -> float:
    return _clamp(float(el.attrib.get("opacity", 1.0)))


def _is_redundant(tag):
    return tag is etree.Comment or tag is etree.ProcessingInstruction


def _is_removable_group(el):
    """
    Groups with:

        0 < opacity < 1
        >1 child

    must be retained.

    This over-retains groups; no difference unless children overlap
    """
    if not _is_group(el):
        return False
    # no attributes makes a group meaningless
    if len(el.attrib) == 0:
        return True
    num_children = sum(1 for e in el if not _is_redundant(e.tag))

    return num_children <= 1 or _opacity(el) in {0.0, 1.0}


def _try_remove_group(group_el, push_opacity=True):
    """
    Transfer children of group to their parent if possible.

    Only groups with 0 < opacity < 1 *and* multiple children must be retained.

    This over-retains groups; no difference unless children overlap
    """
    assert _is_group(group_el)

    remove = _is_removable_group(group_el)
    opacity = _opacity(group_el)
    if remove:
        children = list(group_el)
        if group_el.getparent() is not None:
            _replace_el(group_el, list(group_el))
        if push_opacity:
            for child in children:
                if _is_redundant(child.tag):
                    continue
                _inherit_attrib({"opacity": opacity}, child)
    else:
        # We're keeping the group, but we promised groups only have opacity
        group_el.attrib.clear()
        group_el.attrib["opacity"] = ntos(opacity)
        _drop_default_attrib(group_el.attrib)
    return remove


def _element_transform(el, current_transform=Affine2D.identity()):
    attr_name = "transform"
    if _is_gradient(el.tag):
        attr_name = "gradientTransform"

    raw = el.attrib.get(attr_name, None)
    if raw:
        return Affine2D.compose_ltr((Affine2D.fromstring(raw), current_transform))
    return current_transform


def from_element(el, **inherited_attrib):
    if not _is_shape(el.tag):
        raise ValueError(f"Bad tag <{el.tag}>")
    data_type = _SHAPE_CLASSES[el.tag]
    attrs = {**inherited_attrib, **el.attrib}

    args = {}
    for f in dataclasses.fields(data_type):
        attr_name = _attr_name(f.name)
        if attr_name not in attrs or not attrs[attr_name].strip():
            continue

        attr_value = attrs[attr_name]
        if f.type == float and f.name in SVG_LENGTH_FIELDS and isinstance(attr_value, str):
            # For CSS length values (including percentages and units), use parse_css_length
            try:
                if attr_value.endswith('%'):
                    # For percentage values, use number_or_percentage with appropriate scaling
                    args[f.name] = number_or_percentage(attr_value, scale=100)
                else:
                    # For other CSS units (px, pt, em, etc.), use parse_css_length
                    args[f.name] = parse_css_length(attr_value)
            except (ValueError, TypeError):
                # If parsing fails, try the original type conversion as fallback
                args[f.name] = f.type(attr_value)
        else:
            # Use the original type conversion
            args[f.name] = f.type(attr_value)
    
    return data_type(**args)

def to_element(data_obj, **inherited_attrib):
    el = etree.Element(_CLASS_ELEMENTS[type(data_obj)])
    for field in dataclasses.fields(data_obj):
        attr_name = _attr_name(field.name)
        field_value = getattr(data_obj, field.name)
        # omit attributes whose value == the respective default,
        # unless it's != from the attribute value inherited from context
        if isinstance(field.default, _LinkedDefault):
            default_value = field.default(data_obj)
        else:
            default_value = field.default
        attrib_value = field_value
        if isinstance(attrib_value, numbers.Number):
            attrib_value = ntos(attrib_value)
        elif isinstance(attrib_value, Affine2D):
            attrib_value = attrib_value.tostring()
        if attr_name in inherited_attrib:
            if attrib_value == inherited_attrib[attr_name]:
                continue
        elif field_value == default_value:
            continue
        el.attrib[attr_name] = attrib_value
    return el


def _reset_attrs(data_obj, field_pred):
    for field in dataclasses.fields(data_obj):
        if field_pred(field):
            setattr(data_obj, field.name, field.default)


def _safe_remove(el: etree.Element):
    parent = el.getparent()
    if parent is not None:
        parent.remove(el)


def _id_of_target(url):
    match = re.match(r"^url[(]#([\w-]+)[)]$", url)
    if not match:
        raise ValueError(f'Unrecognized url "{url}"')
    return match.group(1)


def _xpath_for_url(url, el_tag):
    return f'//svg:{el_tag}[@id="{_id_of_target(url)}"]'


def _attrib_to_pass_on(current_attrib, el, skips=_ATTRIB_W_CUSTOM_INHERITANCE):
    attr_catcher = etree.Element("dummy")
    _inherit_attrib(el.attrib, attr_catcher, skips=skips, skip_unhandled=True)
    _inherit_attrib(current_attrib, attr_catcher, skips=skips)
    return dict(attr_catcher.attrib)


def _replace_el(el, replacements):
    parent = el.getparent()
    idx = parent.index(el)
    parent.remove(el)
    for child_idx, child in enumerate(replacements):
        parent.insert(idx + child_idx, child)


class SVGTraverseContext(NamedTuple):
    nth_of_type: int
    element: etree.Element
    path: str
    transform: Affine2D
    clips: Tuple[SVGPath, ...]
    attrib: Mapping[str, Any]  # except _ATTRIB_W_CUSTOM_INHERITANCE

    def depth(self) -> int:
        return self.path.count("/") - 1

    def shape(self) -> SVGShape:
        return from_element(self.element, **self.attrib)

    def is_shape(self):
        return _is_shape(self.element)

    def is_group(self):
        return _is_group(self.element)


class SVG:
    svg_root: etree.Element
    elements: List[Tuple[etree.Element, Tuple[SVGShape, ...]]]

    def __init__(self, svg_root):
        self.svg_root = svg_root
        self.elements = []

    def _clone(self) -> "SVG":
        return SVG(svg_root=copy.deepcopy(self.svg_root))

    def _elements(self) -> List[Tuple[etree.Element, Tuple[SVGShape, ...]]]:
        if self.elements:
            return self.elements
        elements = []
        for context in self.depth_first(resolve_clip_paths=False):
            el = context.element
            if el.tag not in _SHAPE_CLASSES:
                continue
            elements.append((el, (context.shape(),)))
        self.elements = elements
        return self.elements

    def _set_element(self, idx: int, el: etree.Element, shapes: Tuple[SVGShape, ...]):
        self.elements[idx] = (el, shapes)

    def _fallback_view_box(self) -> Optional[Rect]:
        """Derive viewBox from width/height when viewBox is missing or empty."""
        w = self.svg_root.attrib.get("width", None)
        h = self.svg_root.attrib.get("height", None)
        if not w or not h:
            return None
        try:
            return Rect(0, 0, parse_css_length(w), parse_css_length(h))
        except (ValueError, TypeError):
            try:
                return Rect(0, 0, float(w), float(h))
            except (ValueError, TypeError):
                return None

    def view_box(self) -> Optional[Rect]:
        if "viewBox" not in self.svg_root.attrib:
            return self._fallback_view_box()

        viewbox_value = self.svg_root.attrib["viewBox"]
        if not viewbox_value or not viewbox_value.strip():
            return self._fallback_view_box()

        return parse_view_box(viewbox_value)

    def _default_tolerance(self):
        vbox = self.view_box()
        # Absence of viewBox is unusual
        if vbox is None:
            return _DEFAULT_DEFAULT_TOLERENCE
        return min(vbox.w, vbox.h) * _MAX_PCT_ERROR / 100

    @property
    def tolerance(self):
        return self._default_tolerance()

    def shapes(self):
        """Returns all shapes in order encountered.

        Use to operate per-shape; if you want to iterate over graph use breadth_first.
        """
        return tuple(shape for (_, shapes) in self._elements() for shape in shapes)

    def bounding_box(self) -> Optional[Rect]:
        """Returns the bounding box of this SVG."""
        shapes = self.shapes()
        if not shapes:
            return None
        return reduce(
            lambda a, b: a.union(b), (shape.bounding_box() for shape in shapes)
        )

    def absolute(self, inplace=False):
        """Converts all basic shapes to their equivalent path."""
        if not inplace:
            svg = self._clone()
            svg.absolute(inplace=True)
            return svg

        swaps = []
        for idx, (el, (shape,)) in enumerate(self._elements()):
            self.elements[idx] = (el, (shape.absolute(),))
        return self

    def shapes_to_paths(self, inplace=False):
        """Converts all basic shapes to their equivalent path."""
        if not inplace:
            svg = self._clone()
            svg.shapes_to_paths(inplace=True)
            return svg

        swaps = []
        for idx, (el, (shape,)) in enumerate(self._elements()):
            self.elements[idx] = (el, (shape.as_path(),))
        return self

    def expand_shorthand(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.expand_shorthand(inplace=True)
            return svg

        for idx, (el, (shape,)) in enumerate(self._elements()):
            if isinstance(shape, SVGPath):
                self.elements[idx] = (
                    el,
                    (shape.explicit_lines().expand_shorthand(inplace=True),),
                )
        return self

    def _apply_styles(self, el: etree.Element):
        parse_css_declarations(el.attrib.pop("style", ""), el.attrib)

    def apply_style_attributes(self, inplace=False):
        """Converts inlined CSS "style" attributes to equivalent SVG attributes."""
        if not inplace:
            svg = self._clone()
            svg.apply_style_attributes(inplace=True)
            return svg

        if self.elements:
            # if we already parsed the SVG shapes, apply style attrs and sync tree
            for shape in self.shapes():
                shape.apply_style_attribute(inplace=True)
            self._update_etree()

        # parse all remaining style attributes (e.g. in gradients or root svg element)
        for el in itertools.chain((self.svg_root,), self.xpath("//svg:*[@style]")):
            self._apply_styles(el)

        return self

    def xpath(self, xpath: str, el: etree.Element = None, expected_result_range=None):
        if el is None:
            el = self.svg_root
        results = el.xpath(xpath, namespaces={"svg": svgns()})
        if expected_result_range and len(results) not in expected_result_range:
            raise ValueError(
                f"Expected {xpath} matches in {expected_result_range}, {len(results)} results"
            )
        return results

    def xpath_one(self, xpath):
        return self.xpath(xpath, expected_result_range=range(1, 2))[0]

    def resolve_url(self, url, el_tag):
        return self.xpath_one(_xpath_for_url(url, el_tag))

    def _resolve_use(self, scope_el):
        attrib_not_copied = {
            "x",
            "y",
            "width",
            "height",
            "transform",
            _xlink_href_attr_name(),
        }

        # capture elements by id so even if we change it they remain stable
        el_by_id = {el.attrib["id"]: el for el in self.xpath(".//svg:*[@id]")}

        while True:
            swaps = []
            use_els = list(self.xpath(".//svg:use", el=scope_el))
            if not use_els:
                break
            for use_el in use_els:
                ref = use_el.attrib.get(_xlink_href_attr_name(), "")
                if not ref.startswith("#"):
                    raise ValueError(f"Only use #fragment supported, reject {ref}")

                target = el_by_id.get(ref[1:], None)
                if target is None:
                    raise ValueError(f"No element has id '{ref[1:]}'")

                new_el = copy.deepcopy(target)
                # leaving id's on <use> instantiated content is a path to duplicate ids
                for el in new_el.getiterator("*"):
                    if "id" in el.attrib:
                        del el.attrib["id"]

                group = etree.Element(f"{{{svgns()}}}g", nsmap=self.svg_root.nsmap)
                affine = Affine2D.identity().translate(
                    float(use_el.attrib.get("x", 0)), float(use_el.attrib.get("y", 0))
                )

                if "transform" in use_el.attrib:
                    affine = Affine2D.compose_ltr(
                        (
                            affine,
                            Affine2D.fromstring(use_el.attrib["transform"]),
                        )
                    )

                if affine != Affine2D.identity():
                    group.attrib["transform"] = affine.tostring()

                for attr_name in use_el.attrib:
                    if attr_name in attrib_not_copied:
                        continue
                    group.attrib[attr_name] = use_el.attrib[attr_name]

                group.append(new_el)

                if _try_remove_group(group, push_opacity=False):
                    _inherit_attrib(group.attrib, new_el)
                    swaps.append((use_el, new_el))
                else:
                    swaps.append((use_el, group))

            for old_el, new_el in swaps:
                old_el.getparent().replace(old_el, new_el)

    def resolve_use(self, inplace=False):
        """Instantiate reused elements.

        https://www.w3.org/TR/SVG11/struct.html#UseElement"""
        if not inplace:
            svg = self._clone()
            svg.resolve_use(inplace=True)
            return svg

        self._update_etree()
        self._resolve_use(self.svg_root)
        return self

    def _resolve_clip_path(
        self, clip_path_url, transform=Affine2D.identity()
    ) -> SVGPath:
        clip_path_el = self.resolve_url(clip_path_url, "clipPath")
        self._resolve_use(clip_path_el)

        transform = _element_transform(clip_path_el, transform)
        clip_paths = [
            from_element(e).apply_transform(_element_transform(e, transform))
            for e in clip_path_el
            if _is_shape(e.tag)
        ]

        # Return empty path if no valid clip paths found
        if not clip_paths:
            return SVGPath()

        clip = SVGPath.from_commands(union(clip_paths))

        if "clip-path" in clip_path_el.attrib:
            # TODO cycle detection
            clip_clop = self._resolve_clip_path(
                clip_path_el.attrib["clip-path"], transform
            )
            clip = SVGPath.from_commands(intersection([clip, clip_clop]))

        return clip

    def append_to(self, xpath, el):
        self._update_etree()
        self.xpath_one(xpath).append(el)
        return el

    def _new_id(self, template):
        for i in range(1 << 16):
            potential_id = template % i
            existing = self.xpath(f'//svg:*[@id="{potential_id}"]')
            if not existing:
                return potential_id
        raise ValueError(f"No free id for {template}")

    def _traverse(self, next_fn, append_fn, resolve_clip_paths=True):
        frontier = [
            SVGTraverseContext(
                0,
                self.svg_root,
                "/svg[0]",
                Affine2D.identity(),
                (),
                _attrib_to_pass_on(_INHERITABLE_ATTRIB_DEFAULTS, self.svg_root),
            )
        ]
        while frontier:
            context = next_fn(frontier)
            yield context

            child_idxs = defaultdict(int)
            new_entries = []
            for child in context.element:
                if _is_redundant(child.tag):
                    continue
                transform = _element_transform(child, context.transform)
                clips = context.clips
                if (
                    resolve_clip_paths
                    and child.attrib.get("clip-path")  # neither None nor ""
                    and child.attrib["clip-path"] != "none"
                ):
                    clips += (
                        self._resolve_clip_path(child.attrib["clip-path"], transform),
                    )

                nth_of_type = child_idxs[strip_ns(child.tag)]
                child_idxs[strip_ns(child.tag)] += 1
                path = f"{context.path}/{strip_ns(child.tag)}[{nth_of_type}]"
                child_context = SVGTraverseContext(
                    nth_of_type,
                    child,
                    path,
                    transform,
                    clips,
                    _attrib_to_pass_on(context.attrib, child),
                )
                new_entries.append(child_context)
            append_fn(frontier, new_entries)

    def depth_first(self, resolve_clip_paths=True):
        # dfs will take from the back
        # reverse so this still yields in order (first child, second child, etc)
        # makes processing feel more intuitive
        yield from self._traverse(
            lambda f: f.pop(),
            lambda f, e: f.extend(reversed(e)),
            resolve_clip_paths=resolve_clip_paths,
        )

    def breadth_first(self, resolve_clip_paths=True):
        yield from self._traverse(
            lambda f: f.pop(0),
            lambda f, e: f.extend(e),
            resolve_clip_paths=resolve_clip_paths,
        )

    def _add_to_defs(self, defs, new_el):
        if "id" not in new_el.attrib:
            return  # idless defs are useless
        new_id = new_el.attrib["id"]
        insert_at = 0
        for i, el in enumerate(defs):
            if new_id < el.attrib["id"]:
                insert_at = i
                break
        defs.insert(insert_at, new_el)

    def _transformed_gradient(self, defs, fill_el, transform, shape_bbox):
        assert _is_gradient(fill_el), f"Not sure how to fill from {fill_el.tag}"

        gradient = (
            _GRADIENT_CLASSES[strip_ns(fill_el.tag)]
            .from_element(fill_el, self.view_box())
            .as_user_space_units(shape_bbox, inplace=True)
        )
        gradient.gradientTransform = Affine2D.compose_ltr(
            (gradient.gradientTransform, transform)
        ).round(_GRADIENT_TRANSFORM_NDIGITS)
        gradient.id = self._new_id(gradient.id + "_%d")

        new_fill = to_element(gradient)
        # TODO normalize stop elements too
        new_fill.extend(copy.deepcopy(stop) for stop in fill_el)

        self._apply_gradient_translation(new_fill)

        self._add_to_defs(defs, new_fill)
        return new_fill

    def _simplify(self, allow_all_defs=False):
        """
        Removes groups where possible, applies transforms, applies clip paths.
        """
        # Reversed: we want leaves first
        to_process = reversed(tuple(c for c in self.breadth_first()))

        defs = etree.Element(f"{{{svgns()}}}defs")
        self.svg_root.insert(0, defs)

        for context in to_process:
            if "clipPath" in context.path:
                _safe_remove(context.element)
                continue

            el = context.element

            # When allow_all_defs is enabled, completely skip non-gradient
            # elements inside defs (filter, mask, pattern, etc.) so that
            # _del_attrs, _inherit_attrib, and shape processing leave them
            # untouched — preserving transform and all other attributes.
            if allow_all_defs and re.search(r"/defs\[\d+\]", context.path):
                tag_name = strip_ns(el.tag)
                if tag_name not in _GRADIENT_CLASSES and tag_name != "defs" and tag_name != "stop":
                    continue

            _del_attrs(el, "clip-path", "transform")  # handled separately
            _inherit_attrib(context.attrib, el)

            # Only some elements change
            if _is_shape(el.tag):
                assert len(el) == 0, "Shapes shouldn't have children"

                # If we are transformed and we use a gradient we may need to
                # emit the transformed gradient
                if context.transform != Affine2D.identity() and "url" in el.attrib.get(
                    "fill", ""
                ):
                    fill_el = self.resolve_url(el.attrib["fill"], "*")
                    # Only apply gradient transform if fill_el is actually a gradient
                    # (not pattern or other paint server)
                    if _is_gradient(fill_el.tag):
                        self._apply_gradient_template(fill_el)
                        fill_el = self._transformed_gradient(
                            defs,
                            fill_el,
                            context.transform,
                            from_element(el).bounding_box(),
                        )
                        fill_id = fill_el.attrib["id"]
                        el.attrib["fill"] = f"url(#{fill_id})"

                paths = [from_element(el).as_path().absolute(inplace=True)]
                initial_path = copy.deepcopy(paths[0])

                # stroke may introduce multiple paths
                assert len(paths) == 1  # oh ye of little faith
                if paths[0].stroke != "none":
                    paths = list(self._stroke(paths[0]))

                # Any remaining stroke attributes don't do anything
                # For example, a stroke-width with no stroke set is removed
                for path in paths:
                    _reset_attrs(path, lambda field: field.name.startswith("stroke"))

                # Apply any transform
                if context.transform != Affine2D.identity():
                    paths = [p.apply_transform(context.transform) for p in paths]

                if context.clips:
                    for p in paths:
                        # When constructing pathops.Path objects for performing the
                        # intersection operation, we need to use the fill-rule attribute
                        # for the shape to be clipped, and clip-rule for the clipping
                        # path itself (clip-rule only applies within clipPath element).
                        p.update_path(
                            intersection(
                                (p, *context.clips),
                                fill_rules=(
                                    p.fill_rule,
                                    *(c.clip_rule for c in context.clips),
                                ),
                            ),
                            inplace=True,
                        )
                        # skia-pathops operations always return nonzero winding paths
                        p.fill_rule = "nonzero"

                if len(paths) != 1 or paths[0] != initial_path:
                    _replace_el(el, [to_element(p) for p in paths])

            elif _is_gradient(el.tag):
                _safe_remove(el)
                self._add_to_defs(defs, el)
                self._apply_gradient_template(el)
                self._apply_gradient_translation(el)

            elif _is_defs(el.tag):
                # any children were already processed
                # now just moved to master defs
                for child_el in el:
                    self._add_to_defs(defs, child_el)
                _safe_remove(el)

            elif _is_group(el.tag):
                _try_remove_group(el)

        # https://github.com/googlefonts/nanoemoji/issues/275
        _del_attrs(self.svg_root, *_INHERITABLE_ATTRIB)

        self._remove_orphaned_gradients()

        # After simplification only gradient defs should be referenced
        # It's illegal for picosvg to leave anything else in defs
        # Unless allow_all_defs is True, in which case we keep all defs elements
        if not allow_all_defs:
            for unused_el in [el for el in defs if not _is_gradient(el)]:
                defs.remove(unused_el)

        self.elements = None  # force elements to reload

    def simplify(self, inplace=False, allow_all_defs=False):
        if not inplace:
            svg = self._clone()
            svg.simplify(inplace=True, allow_all_defs=allow_all_defs)
            return svg

        self._update_etree()
        self._simplify(allow_all_defs=allow_all_defs)
        return self

    def _stroke(self, shape):
        """Convert stroke to path.

        Returns sequence of shapes in draw order. That is, result[1] should be
        drawn on top of result[0], etc."""

        assert shape.stroke != "none"

        # make a new path that is the stroke
        stroke = shape.as_path().update_path(shape.stroke_commands(self.tolerance))

        # skia stroker returns paths with 'nonzero' winding fill rule
        stroke.fill_rule = stroke.clip_rule = "nonzero"

        # a few attributes move in interesting ways
        stroke.opacity *= stroke.stroke_opacity
        stroke.fill = stroke.stroke
        # the fill and stroke are now different (filled) paths, reset 'fill_opacity'
        # to default and only use a combined 'opacity' in each one.
        shape.opacity *= shape.fill_opacity
        shape.fill_opacity = stroke.fill_opacity = 1.0

        # remove all the stroke settings
        for cleanmeup in (shape, stroke):
            _reset_attrs(cleanmeup, lambda field: field.name.startswith("stroke"))

        if not shape.might_paint():
            return (stroke,)

        # The original id doesn't correctly refer to either
        # It would be for the best if any id-based operations happened first
        shape.id = stroke.id = ""

        return (shape, stroke)

    def clip_to_viewbox(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.clip_to_viewbox(inplace=True)
            return svg

        self._update_etree()

        view_box = self.view_box()

        # phase 1: dump shapes that are completely out of bounds
        for el, (shape,) in self._elements():
            if view_box.intersection(shape.bounding_box()) is None:
                _safe_remove(el)

        self.elements = None  # force elements to reload

        # phase 2: clip things that are partially out of bounds
        updates = []
        for idx, (el, (shape,)) in enumerate(self._elements()):
            bbox = shape.bounding_box()
            isct = view_box.intersection(bbox)
            assert isct is not None, f"We should have already dumped {shape}"
            if bbox == isct:
                continue
            clip_path = (
                SVGRect(x=isct.x, y=isct.y, width=isct.w, height=isct.h)
                .as_path()
                .absolute(inplace=True)
            )
            shape = shape.as_path().absolute(inplace=True)
            shape.update_path(
                intersection(
                    (shape, clip_path),
                    fill_rules=(shape.fill_rule, clip_path.clip_rule),
                ),
                inplace=True,
            )
            shape.fill_rule = "nonzero"
            updates.append((idx, el, shape))

        for idx, el, shape in updates:
            self._set_element(idx, el, (shape,))

        # Update the etree
        self._update_etree()

        # We may now have useless groups
        for context in reversed(list(self.depth_first())):
            if _is_group(context.element):
                _try_remove_group(context.element)

        return self

    def evenodd_to_nonzero_winding(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.evenodd_to_nonzero_winding(inplace=True)
            return svg

        for idx, (el, (shape,)) in enumerate(self._elements()):
            if shape.fill_rule == "evenodd":
                path = shape.as_path().remove_overlaps(inplace=True)
                self._set_element(idx, el, (path,))

        return self

    def round_floats(self, ndigits: int, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.round_floats(ndigits, inplace=True)
            return svg

        for shape in self.shapes():
            shape.round_floats(ndigits, inplace=True)
        return self

    def remove_empty_subpaths(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.remove_empty_subpaths(inplace=True)
            return svg

        for shape in self.shapes():
            if isinstance(shape, SVGPath):
                shape.remove_empty_subpaths(inplace=True)

        return self

    def remove_unpainted_shapes(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.remove_unpainted_shapes(inplace=True)
            return svg

        self._update_etree()

        remove = []
        for el, (shape,) in self._elements():
            if not shape.might_paint():
                remove.append(el)

        for el in remove:
            el.getparent().remove(el)

        self.elements = None

        return self

    def remove_nonsvg_content(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.remove_nonsvg_content(inplace=True)
            return svg

        self._update_etree()

        good_ns = {svgns(), xlinkns()}
        # Some SVGs may have no default namespace key in nsmap; avoid KeyError
        default_ns = self.svg_root.nsmap.get(None)
        # Accept un-namespaced elements either when default ns is SVG or missing
        if default_ns == svgns() or default_ns is None:
            good_ns.add(None)

        el_to_rm = []
        for el in self.svg_root.getiterator("*"):
            attr_to_rm = []
            ns, _ = splitns(el.tag)
            if ns not in good_ns:
                el_to_rm.append(el)
                continue
            for attr in el.attrib:
                ns, _ = splitns(attr)
                if ns not in good_ns:
                    attr_to_rm.append(attr)
            for attr in attr_to_rm:
                del el.attrib[attr]

        for el in el_to_rm:
            el.getparent().remove(el)

        # Make svg default; destroy anything unexpected
        good_nsmap = {
            None: svgns(),
            "xlink": xlinkns(),
        }
        if any(good_nsmap.get(k, None) != v for k, v in self.svg_root.nsmap.items()):
            self.svg_root = _copy_new_nsmap(self.svg_root, good_nsmap)

        self.elements = None

        return self

    def remove_processing_instructions(self, inplace=False):
        if not inplace:
            svg = SVG(copy.deepcopy(self.svg_root))
            svg.remove_processing_instructions(inplace=True)
            return svg

        self._update_etree()

        for el in self.xpath("//processing-instruction()"):
            el.getparent().remove(el)

        return self

    def remove_anonymous_symbols(self, inplace=False):
        # No id makes a symbol useless
        # https://github.com/googlefonts/picosvg/issues/46
        if not inplace:
            svg = self._clone()
            svg.remove_anonymous_symbols(inplace=True)
            return svg

        self._update_etree()

        for el in self.xpath("//svg:symbol[not(@id)]"):
            el.getparent().remove(el)

        return self

    def remove_title_meta_desc(self, inplace=False):
        if not inplace:
            svg = self._clone()
            svg.remove_title_meta_desc(inplace=True)
            return svg

        self._update_etree()

        for tag in ("title", "desc", "metadata", "comment"):
            for el in self.xpath(f"//svg:{tag}"):
                el.getparent().remove(el)

        return self

    def set_attributes(self, name_values, xpath="/svg:svg", inplace=False):
        if not inplace:
            svg = self._clone()
            svg.set_attributes(name_values, xpath=xpath, inplace=True)
            return svg

        self._update_etree()

        for el in self.xpath(xpath):
            for name, value in name_values:
                el.attrib[name] = value

        return self

    def remove_attributes(self, names, xpath="/svg:svg", inplace=False):
        """Drop things like viewBox, width, height that set size of overall svg"""
        if not inplace:
            svg = self._clone()
            svg.remove_attributes(names, xpath=xpath, inplace=True)
            return svg

        self._update_etree()

        for el in self.xpath(xpath):
            _del_attrs(el, *names)

        return self

    def normalize_opacity(self, inplace=False):
        """Merge '{fill,stroke}_opacity' with generic 'opacity' when possible."""
        if not inplace:
            svg = self._clone()
            svg.normalize_opacity(inplace=True)
            return svg

        for shape in self.shapes():
            shape.normalize_opacity(inplace=True)

        return self

    def _iter_nested_svgs(
        self, root: etree.Element
    ) -> Generator[etree.Element, None, None]:
        # This is different from Element.iter("svg") in that we don't yield the root
        # svg element itself, only traverse its children and yield any immediate
        # nested SVGs without traversing the latter's children as well.
        frontier = deque(root)
        while frontier:
            el = frontier.popleft()
            if _is_redundant(el.tag):
                continue
            if strip_ns(el.tag) == "svg":
                yield el
            elif len(el) != 0:
                frontier.extend(el)

    def _unnest_svg(
        self, svg: etree.Element, parent_width: float, parent_height: float
    ) -> Tuple[etree.Element, ...]:
        x = float(svg.attrib.get("x", 0))
        y = float(svg.attrib.get("y", 0))
        width = float(svg.attrib.get("width", parent_width))
        height = float(svg.attrib.get("height", parent_height))

        viewport = viewbox = Rect(x, y, width, height)
        if "viewBox" in svg.attrib:
            viewbox = parse_view_box(svg.attrib["viewBox"])

        # first recurse to un-nest any nested nested SVGs
        self._swap_elements(
            (el, self._unnest_svg(el, viewbox.w, viewbox.h))
            for el in self._iter_nested_svgs(svg)
        )

        g = etree.Element(f"{{{svgns()}}}g")
        g.extend(svg)

        if viewport != viewbox:
            preserve_aspect_ratio = svg.attrib.get("preserveAspectRatio", "xMidYMid")
            transform = Affine2D.rect_to_rect(viewbox, viewport, preserve_aspect_ratio)
        else:
            transform = Affine2D.identity().translate(x, y)

        if "transform" in svg.attrib:
            transform = Affine2D.compose_ltr(
                (transform, Affine2D.fromstring(svg.attrib["transform"]))
            )

        if transform != Affine2D.identity():
            g.attrib["transform"] = transform.tostring()

        # non-root svg elements by default have overflow="hidden" which means a clip path
        # the size of the SVG viewport is applied; if overflow="visible" don't clip
        # https://www.w3.org/TR/SVG/render.html#OverflowAndClipProperties
        overflow = svg.attrib.get("overflow", "hidden")
        if overflow == "visible":
            return (g,)

        if overflow != "hidden":
            raise NotImplementedError(f"overflow='{overflow}' is not supported")

        clip_path = etree.Element(
            f"{{{svgns()}}}clipPath", {"id": self._new_id("nested-svg-viewport-%d")}
        )
        clip_path.append(to_element(SVGRect(x=x, y=y, width=width, height=height)))
        clipped_g = etree.Element(f"{{{svgns()}}}g")
        clipped_g.attrib["clip-path"] = f"url(#{clip_path.attrib['id']})"
        clipped_g.append(g)

        return (clip_path, clipped_g)

    def resolve_nested_svgs(self, inplace=False):
        """Replace nested <svg> elements with equivalent <g> with a transform.

        NOTE: currently this is still missing two features:
        1) resolving percentage units in reference to the nearest SVG viewport;
        2) applying a clip to all children of the nested SVG with a rectangle the size
           of the new viewport (inner SVGs have default overflow property set to
           'hidden'). Blocked on https://github.com/googlefonts/picosvg/issues/200
        No error is raised in these cases.

        References:
        - https://www.w3.org/TR/SVG/coords.html
        - https://www.sarasoueidan.com/blog/nesting-svgs/
        """
        if not inplace:
            svg = self._clone()
            svg.resolve_nested_svgs(inplace=True)
            return svg

        self._update_etree()

        nested_svgs = list(self._iter_nested_svgs(self.svg_root))
        if len(nested_svgs) == 0:
            return

        vb = self.view_box()
        if vb is None:
            raise ValueError(
                "Can't determine root SVG width/height, "
                "which is required for resolving nested SVGs"
            )

        self._swap_elements(
            (el, self._unnest_svg(el, vb.w, vb.h)) for el in nested_svgs
        )

        return self

    def _select_gradients(self):
        return self.xpath(" | ".join(f"//svg:{tag}" for tag in _GRADIENT_CLASSES))

    def _apply_gradient_translation(self, el: etree.Element):
        assert _is_gradient(el)
        gradient = _GRADIENT_CLASSES[strip_ns(el.tag)].from_element(el, self.view_box())
        affine = gradient.gradientTransform

        # split translation from rest of the transform and apply to gradient coords
        translate, affine_prime = affine.decompose_translation()
        if translate.round(_GRADIENT_TRANSFORM_NDIGITS) != Affine2D.identity():
            for x_attr, y_attr in _GRADIENT_COORDS[strip_ns(el.tag)]:
                x = getattr(gradient, x_attr)
                y = getattr(gradient, y_attr)
                x_prime, y_prime = translate.map_point((x, y))
                setattr(gradient, x_attr, round(x_prime, _GRADIENT_TRANSFORM_NDIGITS))
                setattr(gradient, y_attr, round(y_prime, _GRADIENT_TRANSFORM_NDIGITS))

        gradient.gradientTransform = affine_prime.round(_GRADIENT_TRANSFORM_NDIGITS)

        el.attrib.clear()
        el.attrib.update(to_element(gradient).attrib)

    def _apply_gradient_template(self, gradient: etree.Element):
        # Gradients can have an 'href' attribute that specifies another gradient as
        # a template, inheriting its attributes and/or stops when not already defined:
        # https://www.w3.org/TR/SVG/pservers.html#PaintServerTemplates

        assert _is_gradient(gradient)

        href_attr = _xlink_href_attr_name()
        if href_attr not in gradient.attrib:
            return  # nop

        ref = gradient.attrib[href_attr]
        if not ref.startswith("#"):
            raise ValueError(f"Only use #fragment supported, reject {ref}")
        ref = ref[1:].strip()

        template = self.xpath_one(f'.//svg:*[@id="{ref}"]')

        template_tag = strip_ns(template.tag)
        if template_tag not in _GRADIENT_CLASSES:
            raise ValueError(
                f"Referenced element with id='{ref}' has unexpected tag: "
                f"expected linear or radialGradient, found '{template_tag}'"
            )

        # recurse if template references another template
        if template.attrib.get(href_attr):
            self._apply_gradient_template(template)

        for attr_name in _GRADIENT_FIELDS[strip_ns(gradient.tag)]:
            if attr_name in template.attrib and attr_name not in gradient.attrib:
                gradient.attrib[attr_name] = template.attrib[attr_name]

        # only copy stops if we don't have our own
        if len(gradient) == 0:
            for stop_el in template:
                new_stop_el = copy.deepcopy(stop_el)
                # strip stop id if present; useless and no longer unique
                _del_attrs(new_stop_el, "id")
                gradient.append(new_stop_el)

        del gradient.attrib[href_attr]

    def _remove_orphaned_gradients(self):
        # remove orphaned templates, only keep gradients directly referenced by shapes
        used_gradient_ids = set()
        for shape in self.shapes():
            if shape.fill.startswith("url("):
                try:
                    el = self.resolve_url(shape.fill, "*")
                except ValueError:  # skip not found
                    continue
                if strip_ns(el.tag) not in _GRADIENT_CLASSES:
                    # unlikely the url target isn't a gradient but I'm not the police
                    continue
                used_gradient_ids.add(el.attrib["id"])
        for grad in self._select_gradients():
            if grad.attrib.get("id") not in used_gradient_ids:
                _safe_remove(grad)

    def checkpicosvg(self, allow_text=False, allow_all_defs=False, drop_unsupported=False):
        """Check for nano violations, return xpaths to bad elements.

        If result sequence empty then this is a valid picosvg.
        """

        self._update_etree()

        errors = []
        bad_paths = set()

        path_allowlist = {
            r"^/svg\[0\]$",
            r"^/svg\[0\]/defs\[0\]$",
            r"^/svg\[0\]/defs\[0\]/(linear|radial)Gradient\[\d+\](/stop\[\d+\])?$",
            r"^/svg\[0\](/(path|g)\[\d+\])+$",
        }
        if allow_text:
            # Allow text elements directly under svg or nested within g elements
            path_allowlist.add(
                r"^/svg\[0\](/(path|g)\[\d+\])*(/(text|textPath)\[\d+\])+(/(text|tspan|textPath)\[\d+\])*$"
            )
        if allow_all_defs:
            _defs_re = "|".join(sorted(_DEFS_ALLOWED_TAGS))
            _child_re = "|".join(sorted(_DEFS_CHILD_ALLOWED_TAGS))
            # Allow whitelisted elements in defs with safe children
            path_allowlist.add(
                r"^/svg\[0\]/defs\[0\]/("
                + _defs_re
                + r")\[\d+\](/("
                + _child_re
                + r")\[\d+\])*$"
            )
            # Allow switch/symbol/use at root level with safe children
            # NOTE: foreignObject excluded — it can embed arbitrary HTML
            path_allowlist.add(
                r"^/svg\[0\](/(switch|symbol|use)\[\d+\])+(/(g|path|rect|circle|ellipse|text|image)\[\d+\])*$"
            )
            # Allow style/pattern/mask/clipPath at root level with safe children
            path_allowlist.add(
                r"^/svg\[0\]/(style|pattern|mask|clipPath)\[\d+\](/(rect|circle|ellipse|path|line|polyline|polygon|g|use|image|text)\[\d+\])*$"
            )
        paths_required = {
            "/svg[0]",
            "/svg[0]/defs[0]",
        }

        # Make a list of xpaths with offsets (/svg/defs[0]/..., etc)
        ids = {}
        for context in self.breadth_first():
            if any(context.path.startswith(bp) for bp in bad_paths):
                continue  # no sense reporting all the children as bad

            if not any((re.match(pat, context.path) for pat in path_allowlist)):
                if drop_unsupported:
                    _safe_remove(context.element)
                else:
                    errors.append(f"BadElement: {context.path}")
                    bad_paths.add(context.path)
                continue

            paths_required.discard(context.path)

            el_id = context.element.attrib.get("id", None)
            if el_id is not None:
                if el_id in ids:
                    errors.append(
                        f'BadElement: {context.path} reuses id="{el_id}", first seen at {ids[el_id]}'
                    )
                ids[el_id] = context.path

        for path in paths_required:
            errors.append(f"MissingElement: {path}")

        # TODO paths, groups, & gradients should only have specific attributes

        return tuple(errors)

    def topicosvg(
        self, *, ndigits=3, inplace=False, allow_text=False, allow_all_defs=False, drop_unsupported=False
    ):
        if not inplace:
            svg = self._clone()
            svg.topicosvg(
                ndigits=ndigits,
                inplace=True,
                allow_text=allow_text,
                allow_all_defs=allow_all_defs,
                drop_unsupported=drop_unsupported,
            )
            return svg

        self._update_etree()

        # Discard useless content
        self.remove_nonsvg_content(inplace=True)
        self.remove_processing_instructions(inplace=True)
        self.remove_anonymous_symbols(inplace=True)
        self.remove_title_meta_desc(inplace=True)

        # Simplify things that simplify in isolation
        self.apply_style_attributes(inplace=True)
        self.resolve_nested_svgs(inplace=True)
        self.shapes_to_paths(inplace=True)
        self.expand_shorthand(inplace=True)
        self.resolve_use(inplace=True)

        # Simplify things that do not simplify in isolation
        self.simplify(inplace=True, allow_all_defs=allow_all_defs)

        # Tidy up
        self.evenodd_to_nonzero_winding(inplace=True)
        self.normalize_opacity(inplace=True)
        self.absolute(inplace=True)
        self.round_floats(ndigits, inplace=True)

        # https://github.com/googlefonts/picosvg/issues/269 remove empty subpaths *after* rounding
        self.remove_empty_subpaths(inplace=True)
        self.remove_unpainted_shapes(inplace=True)

        violations = self.checkpicosvg(
            allow_text=allow_text, allow_all_defs=allow_all_defs, drop_unsupported=drop_unsupported
        )
        if violations:
            raise ValueError("Unable to convert to picosvg: " + ",".join(violations))

        return self

    @staticmethod
    def _swap_elements(swaps: Iterable[Tuple[etree.Element, Sequence[etree.Element]]]):
        for old_el, new_els in swaps:
            for new_el in reversed(new_els):
                old_el.addnext(new_el)
            parent = old_el.getparent()
            if parent is None:
                raise ValueError("Lost parent!")
            parent.remove(old_el)

    @lru_cache(maxsize=None)
    def _inherited_attrib(self, el: etree.Element) -> Mapping[str, str]:
        parents = []
        while el.getparent() is not None:
            el = el.getparent()
            parents.append(el)
        return reduce(
            _attrib_to_pass_on, reversed(parents), _INHERITABLE_ATTRIB_DEFAULTS
        )

    def _update_etree(self):
        if not self.elements:
            return
        self._inherited_attrib.cache_clear()
        self._swap_elements(
            (
                old_el,
                [to_element(s, **self._inherited_attrib(old_el)) for s in shapes],
            )
            for old_el, shapes in self.elements
        )
        self.elements = None

    def toetree(self):
        self._update_etree()
        self.svg_root = _fix_xlink_ns(self.svg_root)
        return copy.deepcopy(self.svg_root)

    def tostring(self, pretty_print=False):
        return etree.tostring(self.toetree(), pretty_print=pretty_print).decode("utf-8")

    @classmethod
    def fromstring(cls, string):
        if isinstance(string, bytes):
            string = string.decode("utf-8")

        # svgs are fond of not declaring xlink
        # based on https://mailman-mail5.webfaction.com/pipermail/lxml/20100323/021184.html
        if "xlink" in string and "xmlns:xlink" not in string:
            string = string.replace("xlink:href", _XLINK_TEMP)

        # encode because fromstring dislikes xml encoding decl if input is str
        parser = etree.XMLParser(
            remove_comments=True,
            remove_blank_text=True,
            # external entities may load local files (e.g. /etc/passwd), so disable
            # safe entities like &gt; are still allowed
            resolve_entities=False,
        )
        tree = etree.fromstring(string.encode("utf-8"), parser)
        tree = _fix_xlink_ns(tree)
        return cls(tree)

    @classmethod
    def parse(cls, file_or_path):
        if hasattr(file_or_path, "read"):
            raw_svg = file_or_path.read()
        else:
            with open(file_or_path) as f:
                raw_svg = f.read()
        return cls.fromstring(raw_svg)


def _inherit_copy(attrib, child, attr_name):
    if attr_name in child.attrib:
        return
    if attr_name in attrib:
        child.attrib[attr_name] = attrib[attr_name]


def _inherit_multiply(attrib, child, attr_name):
    if attr_name not in attrib and attr_name not in child.attrib:
        return
    value = float(attrib.get(attr_name, 1.0))
    value *= float(child.attrib.get(attr_name, 1.0))
    child.attrib[attr_name] = ntos(value)


def _inherit_clip_path(attrib, child, attr_name):
    clips = sorted(
        child.attrib.get("clip-path", "").split(",") + [attrib.get("clip-path", "")]
    )
    child.attrib["clip-path"] = ",".join([c for c in clips if c])


def _inherit_nondefault_overflow(attrib, child, attr_name):
    value = attrib.get(attr_name, "visible")
    if value != "visible":
        _inherit_copy(attrib, child, attr_name)


# https://github.com/googlefonts/picosvg/issues/260
def _inherit_nondefault_display(attrib, child, attr_name):
    value = attrib.get(attr_name, "")
    if value == "none":
        child.attrib[attr_name] = value
    else:
        _inherit_copy(attrib, child, attr_name)


def _inherit_matrix_multiply(attrib, child, attr_name):
    transform = Affine2D.identity()
    if attr_name in attrib:
        transform = Affine2D.fromstring(attrib[attr_name])
    if attr_name in child.attrib:
        transform = Affine2D.compose_ltr(
            (Affine2D.fromstring(child.attrib[attr_name]), transform)
        )
    if transform != Affine2D.identity():
        child.attrib[attr_name] = transform.tostring()
    else:
        del child.attrib[attr_name]


def _do_not_inherit(*_):
    return


_INHERIT_ATTRIB_HANDLERS = {
    "clip-rule": _inherit_copy,
    "color": _inherit_copy,
    "display": _inherit_nondefault_display,
    "fill": _inherit_copy,
    "fill-rule": _inherit_copy,
    "style": _inherit_copy,
    "transform": _inherit_matrix_multiply,
    "stroke": _inherit_copy,
    "stroke-width": _inherit_copy,
    "stroke-linecap": _inherit_copy,
    "stroke-linejoin": _inherit_copy,
    "stroke-miterlimit": _inherit_copy,
    "stroke-dasharray": _inherit_copy,
    "stroke-dashoffset": _inherit_copy,
    "stroke-opacity": _inherit_copy,
    "fill-opacity": _inherit_copy,
    "opacity": _inherit_multiply,
    "clip-path": _inherit_clip_path,
    "id": _do_not_inherit,
    "data-name": _do_not_inherit,
    "enable-background": _do_not_inherit,
    "overflow": _inherit_nondefault_overflow,
}


_INHERITABLE_ATTRIB = frozenset(
    k for k, v in _INHERIT_ATTRIB_HANDLERS.items() if v is not _do_not_inherit
)

_INHERITABLE_ATTRIB_DEFAULTS = {
    k: (
        ntos(ATTRIB_DEFAULTS[k])
        if isinstance(ATTRIB_DEFAULTS[k], numbers.Number)
        else str(ATTRIB_DEFAULTS[k])
    )
    for k, v in _INHERIT_ATTRIB_HANDLERS.items()
    if k in ATTRIB_DEFAULTS and v is _inherit_copy
}


def _attr_supported(el: etree.Element, attr_name: str) -> bool:
    tag = strip_ns(el.tag)
    field_name = _field_name(attr_name)
    if tag in _VALID_FIELDS:
        return field_name in _VALID_FIELDS[tag]
    return True  # we don't know


def _drop_default_attrib(attrib: MutableMapping[str, Any]):
    for attr_name in sorted(attrib.keys()):
        value = attrib[attr_name]
        default_value = attrib_default(attr_name, default=None)
        if default_value is None:
            continue
        if isinstance(default_value, float):
            value = float(value)
        if default_value == value:
            del attrib[attr_name]


def _inherit_attrib(
    attrib: Mapping[str, Any],
    child: etree.Element,
    skip_unhandled: bool = False,
    skips=frozenset(),
):
    attrib: MutableMapping[str, Any] = copy.deepcopy(
        attrib
    )  # pytype: disable=annotation-type-mismatch
    for attr_name in sorted(attrib.keys()):
        if attr_name in skips or not _attr_supported(child, attr_name):
            del attrib[attr_name]
            continue
        if not attr_name in _INHERIT_ATTRIB_HANDLERS:
            continue
        _INHERIT_ATTRIB_HANDLERS[attr_name](attrib, child, attr_name)
        del attrib[attr_name]

    if len(attrib) and not skip_unhandled:
        raise ValueError(f"Unable to process attrib {attrib}")
