diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
commit | cca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch) | |
tree | 146f39ded1c938019e1ed42d30923c2ac9e86789 /share/extensions/inkex/elements/_base.py | |
parent | Initial commit. (diff) | |
download | inkscape-upstream.tar.xz inkscape-upstream.zip |
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | share/extensions/inkex/elements/_base.py | 755 |
1 files changed, 755 insertions, 0 deletions
diff --git a/share/extensions/inkex/elements/_base.py b/share/extensions/inkex/elements/_base.py new file mode 100644 index 0000000..492278e --- /dev/null +++ b/share/extensions/inkex/elements/_base.py @@ -0,0 +1,755 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Sergei Izmailov <sergei.a.izmailov@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Provide extra utility to each svg element type specific to its type. + +This is useful for having a common interface for each element which can +give path, transform, and property access easily. +""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any, Tuple, Optional, overload, TypeVar, List +from lxml import etree + +from ..interfaces.IElement import IBaseElement, ISVGDocumentElement + +from ..base import SvgOutputMixin +from ..paths import Path +from ..styles import Style, Classes +from ..transforms import Transform, BoundingBox +from ..utils import FragmentError +from ..units import convert_unit, render_unit, parse_unit +from ._utils import ChildToProperty, NSS, addNS, removeNS, splitNS +from ..properties import BaseStyleValue, all_properties +from ._selected import ElementList +from ._parser import NodeBasedLookup, SVG_PARSER + +T = TypeVar("T", bound="BaseElement") # pylint: disable=invalid-name + + +class BaseElement(IBaseElement): + """Provide automatic namespaces to all calls""" + + # pylint: disable=too-many-public-methods + + def __init_subclass__(cls): + if cls.tag_name: + NodeBasedLookup.register_class(cls) + + @classmethod + def is_class_element( # pylint: disable=unused-argument + cls, elem: etree.Element + ) -> bool: + """Hook to do more restrictive check in addition to (ns,tag) match + + .. versionadded:: 1.2 + The function has been made public.""" + return True + + tag_name = "" + + @property + def TAG(self): # pylint: disable=invalid-name + """Return the tag_name without NS""" + if not self.tag_name: + return removeNS(super().tag)[-1] + return removeNS(self.tag_name)[-1] + + @classmethod + def new(cls, *children, **attrs): + """Create a new element, converting attrs values to strings.""" + obj = cls(*children) + obj.update(**attrs) + return obj + + NAMESPACE = property(lambda self: splitNS(self.tag_name)[0]) + """Get namespace of element""" + + PARSER = SVG_PARSER + """A reference to the :attr:`inkex.elements._parser.SVG_PARSER`""" + WRAPPED_ATTRS = ( + # (prop_name, [optional: attr_name], cls) + ("transform", Transform), + ("style", Style), + ("classes", "class", Classes), + ) # type: Tuple[Tuple[Any, ...], ...] + """A list of attributes that are automatically converted to objects.""" + + # We do this because python2 and python3 have different ways + # of combining two dictionaries that are incompatible. + # This allows us to update these with inheritance. + @property + def wrapped_attrs(self): + """Map attributes to property name and wrapper class""" + return {row[-2]: (row[0], row[-1]) for row in self.WRAPPED_ATTRS} + + @property + def wrapped_props(self): + """Map properties to attribute name and wrapper class""" + return {row[0]: (row[-2], row[-1]) for row in self.WRAPPED_ATTRS} + + typename = property(lambda self: type(self).__name__) + """Type name of the element""" + xml_path = property(lambda self: self.getroottree().getpath(self)) + """XPath representation of the element in its tree + + .. versionadded:: 1.1""" + desc = ChildToProperty("svg:desc", prepend=True) + """The element's long-form description (for accessibility purposes) + + .. versionadded:: 1.1""" + title = ChildToProperty("svg:title", prepend=True) + """The element's short-form description (for accessibility purposes) + + .. versionadded:: 1.1""" + + def __getattr__(self, name): + """Get the attribute, but load it if it is not available yet""" + if name in self.wrapped_props: + (attr, cls) = self.wrapped_props[name] + # The reason we do this here and not in _init is because lxml + # is inconsistant about when elements are initialised. + # So we make this a lazy property. + def _set_attr(new_item): + if new_item: + self.set(attr, str(new_item)) + else: + self.attrib.pop(attr, None) # pylint: disable=no-member + + # pylint: disable=no-member + value = cls(self.attrib.get(attr, None), callback=_set_attr) + if name == "style": + value.element = self + setattr(self, name, value) + return value + raise AttributeError(f"Can't find attribute {self.typename}.{name}") + + def __setattr__(self, name, value): + """Set the attribute, update it if needed""" + if name in self.wrapped_props: + (attr, cls) = self.wrapped_props[name] + # Don't call self.set or self.get (infinate loop) + if value: + if not isinstance(value, cls): + value = cls(value) + self.attrib[attr] = str(value) + else: + self.attrib.pop(attr, None) # pylint: disable=no-member + else: + super().__setattr__(name, value) + + def get(self, attr, default=None): + """Get element attribute named, with addNS support.""" + if attr in self.wrapped_attrs: + (prop, _) = self.wrapped_attrs[attr] + value = getattr(self, prop, None) + # We check the boolean nature of the value, because empty + # transformations and style attributes are equiv to not-existing + ret = str(value) if value else (default or None) + return ret + return super().get(addNS(attr), default) + + def set(self, attr, value): + """Set element attribute named, with addNS support""" + if attr in self.wrapped_attrs: + # Always keep the local wrapped class up to date. + (prop, cls) = self.wrapped_attrs[attr] + setattr(self, prop, cls(value)) + value = getattr(self, prop) + if not value: + return + if value is None: + self.attrib.pop(addNS(attr), None) # pylint: disable=no-member + else: + value = str(value) + super().set(addNS(attr), value) + + def update(self, **kwargs): + """ + Update element attributes using keyword arguments + + Note: double underscore is used as namespace separator, + i.e. "namespace__attr" argument name will be treated as "namespace:attr" + + :param kwargs: dict with name=value pairs + :return: self + """ + for name, value in kwargs.items(): + self.set(name, value) + return self + + def pop(self, attr, default=None): + """Delete/remove the element attribute named, with addNS support.""" + if attr in self.wrapped_attrs: + # Always keep the local wrapped class up to date. + (prop, cls) = self.wrapped_attrs[attr] + value = getattr(self, prop) + setattr(self, prop, cls(None)) + return value + return self.attrib.pop(addNS(attr), default) # pylint: disable=no-member + + @overload + def add( + self, child1: BaseElement, child2: BaseElement, *children: BaseElement + ) -> Tuple[BaseElement]: + ... + + @overload + def add(self, child: T) -> T: + ... + + def add(self, *children): + """ + Like append, but will do multiple children and will return + children or only child + """ + for child in children: + self.append(child) + return children if len(children) != 1 else children[0] + + def tostring(self): + """Return this element as it would appear in an svg document""" + # This kind of hack is pure maddness, but etree provides very little + # in the way of fragment printing, prefering to always output valid xml + + svg = SvgOutputMixin.get_template(width=0, height=0).getroot() + svg.append(self.copy()) + return svg.tostring().split(b">\n ", 1)[-1][:-6] + + def set_random_id( + self, + prefix: Optional[str] = None, + size: Optional[int] = None, + backlinks: bool = False, + blacklist: Optional[List[str]] = None, + ): + """Sets the id attribute if it is not already set. + + The id consists of a prefix and an appended random integer of length size. + Args: + prefix (str, optional): the prefix of the new ID. Defaults to the tag name. + size (Optional[int], optional): number of digits of the second part of the + id. If None, the length is chosen based on the amount of existing + objects. Defaults to None. + + .. versionchanged:: 1.2 + The default of this value has been changed from 4 to None. + backlinks (bool, optional): Whether to update the links in existing objects + that reference this element. Defaults to False. + blacklist (List[str], optional): An additional list of ids that are not + allowed to be used. This is useful when bulk inserting objects. + Defaults to None. + + .. versionadded:: 1.2 + """ + prefix = str(self) if prefix is None else prefix + self.set_id( + self.root.get_unique_id(prefix, size=size, blacklist=blacklist), + backlinks=backlinks, + ) + + def set_random_ids( + self, + prefix: Optional[str] = None, + levels: int = -1, + backlinks: bool = False, + blacklist: Optional[List[str]] = None, + ): + """Same as set_random_id, but will apply also to children + + The id consists of a prefix and an appended random integer of length size. + Args: + prefix (str, optional): the prefix of the new ID. Defaults to the tag name. + levels (int, optional): the depth of the tree traversion, if negative, no + limit is imposed. Defaults to -1. + backlinks (bool, optional): Whether to update the links in existing objects + that reference this element. Defaults to False. + blacklist (List[str], optional): An additional list of ids that are not + allowed to be used. This is useful when bulk inserting objects. + Defaults to None. + + .. versionadded:: 1.2 + """ + self.set_random_id(prefix=prefix, backlinks=backlinks, blacklist=blacklist) + if levels != 0: + for child in self: + if hasattr(child, "set_random_ids"): + child.set_random_ids( + prefix=prefix, levels=levels - 1, backlinks=backlinks + ) + + eid = property(lambda self: self.get_id()) + """Property to access the element's id; will set a new unique id if not set.""" + + def get_id(self, as_url=0) -> str: + """Get the id for the element, will set a new unique id if not set. + + as_url - If set to 1, returns #{id} as a string + If set to 2, returns url(#{id}) as a string + + Args: + as_url (int, optional): + - If set to 1, returns #{id} as a string + - If set to 2, returns url(#{id}) as a string. + + Defaults to 0. + + .. versionadded:: 1.1 + + Returns: + str: formatted id + """ + if "id" not in self.attrib: + self.set_random_id(self.TAG) + eid = self.get("id") + if as_url > 0: + eid = "#" + eid + if as_url > 1: + eid = f"url({eid})" + return eid + + def set_id(self, new_id, backlinks=False): + """Set the id and update backlinks to xlink and style urls if needed""" + old_id = self.get("id", None) + self.set("id", new_id) + if backlinks and old_id: + for elem in self.root.getElementsByHref(old_id): + elem.href = self + for attr in ["clip-path", "mask"]: + for elem in self.root.getElementsByHref(old_id, attribute=attr): + elem.set(attr, self.get_id(2)) + for elem in self.root.getElementsByStyleUrl(old_id): + elem.style.update_urls(old_id, new_id) + + @property + def root(self): + """Get the root document element from any element descendent""" + root, parent = self, self + while parent is not None: + root, parent = parent, parent.getparent() + + if not isinstance(root, ISVGDocumentElement): + raise FragmentError("Element fragment does not have a document root!") + return root + + def get_or_create(self, xpath, nodeclass=None, prepend=False): + """Get or create the given xpath, pre/append new node if not found. + + .. versionchanged:: 1.1 + The ``nodeclass`` attribute is optional; if not given, it is looked up + using :func:`~inkex.elements._parser.NodeBasedLookup.find_class`""" + node = self.findone(xpath) + if node is None: + if nodeclass is None: + nodeclass = NodeBasedLookup.find_class(xpath) + node = nodeclass() + if prepend: + self.insert(0, node) + else: + self.append(node) + return node + + def descendants(self): + """Walks the element tree and yields all elements, parent first + + .. versionchanged:: 1.1 + The ``*types`` attribute was removed + + """ + + return ElementList( + self.root, + [ + element + for element in self.iter() + if isinstance(element, (BaseElement, str)) + ], + ) + + def ancestors(self, elem=None, stop_at=()): + """ + Walk the parents and yield all the ancestor elements, parent first + + Args: + elem (BaseElement, optional): If provided, it will stop at the last common + ancestor. Defaults to None. + + .. versionadded:: 1.1 + + stop_at (tuple, optional): If provided, it will stop at the first parent + that is in this list. Defaults to (). + + .. versionadded:: 1.1 + + Returns: + ElementList: list of ancestors + """ + + return ElementList(self.root, self._ancestors(elem=elem, stop_at=stop_at)) + + def _ancestors(self, elem, stop_at): + if isinstance(elem, BaseElement): + stop_at = list(elem.ancestors()) + for parent in self.iterancestors(): + yield parent + if parent in stop_at: + break + + def backlinks(self, *types): + """Get elements which link back to this element, like ancestors but via + xlinks""" + if not types or isinstance(self, types): + yield self + my_id = self.get("id") + if my_id is not None: + elems = list(self.root.getElementsByHref(my_id)) + list( + self.root.getElementsByStyleUrl(my_id) + ) + for elem in elems: + if hasattr(elem, "backlinks"): + for child in elem.backlinks(*types): + yield child + + def xpath(self, pattern, namespaces=NSS): # pylint: disable=dangerous-default-value + """Wrap xpath call and add svg namespaces""" + return super().xpath(pattern, namespaces=namespaces) + + def findall( + self, pattern, namespaces=NSS + ): # pylint: disable=dangerous-default-value + """Wrap findall call and add svg namespaces""" + return super().findall(pattern, namespaces=namespaces) + + def findone(self, xpath): + """Gets a single element from the given xpath or returns None""" + el_list = self.xpath(xpath) + return el_list[0] if el_list else None + + def delete(self): + """Delete this node from it's parent node""" + if self.getparent() is not None: + self.getparent().remove(self) + + def remove_all(self, *types): + """Remove all children or child types + + .. versionadded:: 1.1""" + types = tuple(NodeBasedLookup.find_class(t) for t in types) + for child in self: + if not types or isinstance(child, types): + self.remove(child) + + def replace_with(self, elem): + """Replace this element with the given element""" + self.addnext(elem) + if not elem.get("id") and self.get("id"): + elem.set("id", self.get("id")) + if not elem.label and self.label: + elem.label = self.label + self.delete() + return elem + + def copy(self): + """Make a copy of the element and return it""" + elem = deepcopy(self) + elem.set("id", None) + return elem + + def duplicate(self): + """Like copy(), but the copy stays in the tree and sets a random id on the + duplicate. + + .. versionchanged:: 1.2 + A random id is also set on all the duplicate's descendants""" + elem = self.copy() + self.addnext(elem) + elem.set_random_ids() + return elem + + def __str__(self): + # We would do more here, but lxml is VERY unpleseant when it comes to + # namespaces, basically over printing details and providing no + # supression mechanisms to turn off xml's over engineering. + return str(self.tag).split("}", maxsplit=1)[-1] + + @property + def href(self): + """Returns the referred-to element if available + + .. versionchanged:: 1.1 + A setter for href was added.""" + ref = self.get("xlink:href") + if not ref: + return None + return self.root.getElementById(ref.strip("#")) + + @href.setter + def href(self, elem): + """Set the href object""" + if isinstance(elem, BaseElement): + elem = elem.get_id() + self.set("xlink:href", "#" + elem) + + @property + def label(self): + """Returns the inkscape label""" + return self.get("inkscape:label", None) + + @label.setter + def label(self, value): + """Sets the inkscape label""" + self.set("inkscape:label", str(value)) + + def is_sensitive(self): + """Return true if this element is sensitive in inkscape + + .. versionadded:: 1.1""" + return self.get("sodipodi:insensitive", None) != "true" + + def set_sensitive(self, sensitive=True): + """Set the sensitivity of the element/layer + + .. versionadded:: 1.1""" + # Sensitive requires None instead of 'false' + self.set("sodipodi:insensitive", ["true", None][sensitive]) + + @property + def unit(self): + """Return the unit being used by the owning document, cached + + .. versionadded:: 1.1""" + try: + return self.root.unit + except FragmentError: + return "px" # Don't cache. + + @staticmethod + def to_dimensional(value, to_unit="px"): + """Convert a value given in user units (px) the given unit type + + .. versionadded:: 1.2""" + return convert_unit(value, to_unit) + + @staticmethod + def to_dimensionless(value): + """Convert a length value into user units (px) + + .. versionadded:: 1.2""" + return convert_unit(value, "px") + + def uutounit(self, value, to_unit="px"): + """Convert a unit value to a given unit. If the value does not have a unit, + "Document" units are assumed. "Document units" are an Inkscape-specific concept. + For most use-cases, :func:`to_dimensional` is more appropriate. + + .. versionadded:: 1.1""" + return convert_unit(value, to_unit, default=self.unit) + + def unittouu(self, value): + """Convert a unit value into document units. "Document unit" is an + Inkscape-specific concept. For most use-cases, :func:`viewport_to_unit` (when + the size of an object given in viewport units is needed) or + :func:`to_dimensionless` (when the equivalent value without unit is needed) is + more appropriate. + + .. versionadded:: 1.1""" + return convert_unit(value, self.unit) + + def unit_to_viewport(self, value, unit="px"): + """Converts a length value to viewport units, as defined by the width/height + element on the root (i.e. applies the equivalent transform of the viewport) + + .. versionadded:: 1.2""" + return self.to_dimensional( + self.to_dimensionless(value) * self.root.equivalent_transform_scale, unit + ) + + def viewport_to_unit(self, value, unit="px"): + """Converts a length given on the viewport to the specified unit in the user + coordinate system + + .. versionadded:: 1.2""" + return self.to_dimensional( + self.to_dimensionless(value) / self.root.equivalent_transform_scale, unit + ) + + def add_unit(self, value): + """Add document unit when no unit is specified in the string. + + .. versionadded:: 1.1""" + return render_unit(value, self.unit) + + def cascaded_style(self): + """Returns the cascaded style of an element (all rules that apply the element + itself), based on the stylesheets, the presentation attributes and the inline + style using the respective specificity of the style. + + see https://www.w3.org/TR/CSS22/cascade.html#cascading-order + + .. versionadded:: 1.2 + + Returns: + Style: the cascaded style + + """ + return Style.cascaded_style(self) + + def specified_style(self): + """Returns the specified style of an element, i.e. the cascaded style + + inheritance, see https://www.w3.org/TR/CSS22/cascade.html#specified-value. + + Returns: + Style: the specified style + + .. versionadded:: 1.2 + """ + return Style.specified_style(self) + + def presentation_style(self): + """Return presentation attributes of an element as style + + .. versionadded:: 1.2""" + style = Style() + for key in self.keys(): + if key in all_properties and all_properties[key][2]: + style[key] = BaseStyleValue.factory( + declaration=key + ": " + self.attrib[key] + ) + return style + + def composed_transform(self, other=None): + """Calculate every transform down to the other element + if none specified the transform is to the root document element + """ + parent = self.getparent() + if parent is not None and isinstance(parent, BaseElement): + return parent.composed_transform() @ self.transform + return self.transform + + +NodeBasedLookup.default = BaseElement + + +class ShapeElement(BaseElement): + """Elements which have a visible representation on the canvas""" + + @property + def path(self): + """Gets the outline or path of the element, this may be a simple bounding box""" + return Path(self.get_path()) + + @path.setter + def path(self, path): + self.set_path(path) + + @property + def clip(self): + """Gets the clip path element (if any) + + .. versionadded:: 1.1""" + ref = self.get("clip-path") + if not ref: + return None + return self.root.getElementById(ref) + + @clip.setter + def clip(self, elem): + self.set("clip-path", elem.get_id(as_url=2)) + + def get_path(self) -> Path: + """Generate a path for this object which can inform the bounding box""" + raise NotImplementedError( + f"Path should be provided by svg elem {self.typename}." + ) + + def set_path(self, path): + """Set the path for this object (if possible)""" + raise AttributeError( + f"Path can not be set on this element: {self.typename} <- {path}." + ) + + def to_path_element(self): + """Replace this element with a path element""" + from ._polygons import PathElement + + elem = PathElement() + elem.path = self.path + elem.style = self.effective_style() + elem.transform = self.transform + return elem + + def effective_style(self): + """Without parent styles, what is the effective style is""" + return self.style + + def bounding_box(self, transform=None): + # type: (Optional[Transform]) -> Optional[BoundingBox] + """BoundingBox of the shape + + .. versionchanged:: 1.1 + result adjusted for element's clip path if applicable.""" + shape_box = self.shape_box(transform) + clip = self.clip + if clip is None or shape_box is None: + return shape_box + return shape_box & clip.bounding_box(Transform(transform) @ self.transform) + + def shape_box(self, transform=None): + # type: (Optional[Transform]) -> Optional[BoundingBox] + """BoundingBox of the unclipped shape + + .. versionadded:: 1.1 + Previous :func:`bounding_box` function, returning the bounding box + without computing the effect of a possible clip.""" + path = self.path.to_absolute() + if transform is True: + path = path.transform(self.composed_transform()) + else: + path = path.transform(self.transform) + if transform: # apply extra transformation + path = path.transform(transform) + return path.bounding_box() + + def is_visible(self): + """Returns false if the css says this object is invisible + + .. versionadded:: 1.1""" + if self.style.get("display", "") == "none": + return False + if not float(self.style.get("opacity", 1.0)): + return False + return True + + def get_line_height_uu(self): + """Returns the specified value of line-height, in user units + + .. versionadded:: 1.1""" + style = self.specified_style() + font_size = style("font-size") # already in uu + line_height = style("line-height") + parsed = parse_unit(line_height) + if parsed is None: + return font_size * 1.2 + if parsed[1] == "%": + return font_size * parsed[0] * 0.01 + return self.to_dimensionless(line_height) |