summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/elements
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--share/extensions/inkex/elements/__init__.py55
-rw-r--r--share/extensions/inkex/elements/_base.py755
-rw-r--r--share/extensions/inkex/elements/_filters.py367
-rw-r--r--share/extensions/inkex/elements/_groups.py126
-rw-r--r--share/extensions/inkex/elements/_image.py29
-rw-r--r--share/extensions/inkex/elements/_meta.py293
-rw-r--r--share/extensions/inkex/elements/_parser.py118
-rw-r--r--share/extensions/inkex/elements/_polygons.py507
-rw-r--r--share/extensions/inkex/elements/_selected.py237
-rw-r--r--share/extensions/inkex/elements/_svg.py371
-rw-r--r--share/extensions/inkex/elements/_text.py202
-rw-r--r--share/extensions/inkex/elements/_use.py81
-rw-r--r--share/extensions/inkex/elements/_utils.py144
13 files changed, 3285 insertions, 0 deletions
diff --git a/share/extensions/inkex/elements/__init__.py b/share/extensions/inkex/elements/__init__.py
new file mode 100644
index 0000000..021d47b
--- /dev/null
+++ b/share/extensions/inkex/elements/__init__.py
@@ -0,0 +1,55 @@
+"""
+Element based interface provides the bulk of features that allow you to
+interact directly with the SVG xml interface.
+
+See the documentation for each of the elements for details on how it works.
+"""
+
+from ._utils import addNS, NSS
+from ._parser import SVG_PARSER, load_svg
+from ._base import ShapeElement, BaseElement
+from ._svg import SvgDocumentElement
+from ._groups import Group, Layer, Anchor, Marker, ClipPath, Mask
+from ._polygons import PathElement, Polyline, Polygon, Line, Rectangle, Circle, Ellipse
+from ._text import (
+ FlowRegion,
+ FlowRoot,
+ FlowPara,
+ FlowDiv,
+ FlowSpan,
+ TextElement,
+ TextPath,
+ Tspan,
+ SVGfont,
+ FontFace,
+ Glyph,
+ MissingGlyph,
+)
+from ._use import Symbol, Use
+from ._meta import (
+ Defs,
+ StyleElement,
+ Script,
+ Desc,
+ Title,
+ NamedView,
+ Guide,
+ Metadata,
+ ForeignObject,
+ Switch,
+ Grid,
+ Page,
+)
+from ._filters import (
+ Filter,
+ Pattern,
+ Gradient,
+ LinearGradient,
+ RadialGradient,
+ PathEffect,
+ Stop,
+ MeshGradient,
+ MeshRow,
+ MeshPatch,
+)
+from ._image import Image
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)
diff --git a/share/extensions/inkex/elements/_filters.py b/share/extensions/inkex/elements/_filters.py
new file mode 100644
index 0000000..ce86507
--- /dev/null
+++ b/share/extensions/inkex/elements/_filters.py
@@ -0,0 +1,367 @@
+# -*- 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
+"""
+Element interface for patterns, filters, gradients and path effects.
+"""
+from __future__ import annotations
+from typing import List, Tuple, TYPE_CHECKING, Optional
+
+from lxml import etree
+
+from ..transforms import Transform
+from ..utils import parse_percent
+
+from ..styles import Style
+
+from ._utils import addNS
+from ._base import BaseElement
+
+
+if TYPE_CHECKING:
+ from ._svg import SvgDocumentElement
+
+
+class Filter(BaseElement):
+ """A filter (usually in defs)"""
+
+ tag_name = "filter"
+
+ def add_primitive(self, fe_type, **args):
+ """Create a filter primitive with the given arguments"""
+ elem = etree.SubElement(self, addNS(fe_type, "svg"))
+ elem.update(**args)
+ return elem
+
+ class Primitive(BaseElement):
+ """Any filter primitive"""
+
+ class Blend(Primitive):
+ """Blend Filter element"""
+
+ tag_name = "feBlend"
+
+ class ColorMatrix(Primitive):
+ """ColorMatrix Filter element"""
+
+ tag_name = "feColorMatrix"
+
+ class ComponentTransfer(Primitive):
+ """ComponentTransfer Filter element"""
+
+ tag_name = "feComponentTransfer"
+
+ class Composite(Primitive):
+ """Composite Filter element"""
+
+ tag_name = "feComposite"
+
+ class ConvolveMatrix(Primitive):
+ """ConvolveMatrix Filter element"""
+
+ tag_name = "feConvolveMatrix"
+
+ class DiffuseLighting(Primitive):
+ """DiffuseLightning Filter element"""
+
+ tag_name = "feDiffuseLighting"
+
+ class DisplacementMap(Primitive):
+ """Flood Filter element"""
+
+ tag_name = "feDisplacementMap"
+
+ class Flood(Primitive):
+ """DiffuseLightning Filter element"""
+
+ tag_name = "feFlood"
+
+ class GaussianBlur(Primitive):
+ """GaussianBlur Filter element"""
+
+ tag_name = "feGaussianBlur"
+
+ class Image(Primitive):
+ """Image Filter element"""
+
+ tag_name = "feImage"
+
+ class Merge(Primitive):
+ """Merge Filter element"""
+
+ tag_name = "feMerge"
+
+ class Morphology(Primitive):
+ """Morphology Filter element"""
+
+ tag_name = "feMorphology"
+
+ class Offset(Primitive):
+ """Offset Filter element"""
+
+ tag_name = "feOffset"
+
+ class SpecularLighting(Primitive):
+ """SpecularLighting Filter element"""
+
+ tag_name = "feSpecularLighting"
+
+ class Tile(Primitive):
+ """Tile Filter element"""
+
+ tag_name = "feTile"
+
+ class Turbulence(Primitive):
+ """Turbulence Filter element"""
+
+ tag_name = "feTurbulence"
+
+
+class Stop(BaseElement):
+ """Gradient stop
+
+ .. versionadded:: 1.1"""
+
+ tag_name = "stop"
+
+ @property
+ def offset(self) -> float:
+ """The offset of the gradient stop"""
+ return self.get("offset")
+
+ @offset.setter
+ def offset(self, number):
+ self.set("offset", number)
+
+ def interpolate(self, other, fraction):
+ """Interpolate gradient stops"""
+ from ..tween import StopInterpolator
+
+ return StopInterpolator(self, other).interpolate(fraction)
+
+
+class Pattern(BaseElement):
+ """Pattern element which is used in the def to control repeating fills"""
+
+ tag_name = "pattern"
+ WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (("patternTransform", Transform),)
+
+
+class Gradient(BaseElement):
+ """A gradient instruction usually in the defs."""
+
+ WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (("gradientTransform", Transform),)
+ """Additional to the :attr:`~inkex.elements._base.BaseElement.WRAPPED_ATTRS` of
+ :class:`~inkex.elements._base.BaseElement`, ``gradientTransform`` is wrapped."""
+
+ orientation_attributes = () # type: Tuple[str, ...]
+ """
+ .. versionadded:: 1.1
+ """
+
+ @property
+ def stops(self):
+ """Return an ordered list of own or linked stop nodes
+
+ .. versionadded:: 1.1"""
+ gradcolor = (
+ self.href
+ if isinstance(self.href, (LinearGradient, RadialGradient))
+ else self
+ )
+ return sorted(
+ [child for child in gradcolor if isinstance(child, Stop)],
+ key=lambda x: parse_percent(x.offset),
+ )
+
+ @property
+ def stop_offsets(self):
+ # type: () -> List[float]
+ """Return a list of own or linked stop offsets
+
+ .. versionadded:: 1.1"""
+ return [child.offset for child in self.stops]
+
+ @property
+ def stop_styles(self): # type: () -> List[Style]
+ """Return a list of own or linked offset styles
+
+ .. versionadded:: 1.1"""
+ return [child.style for child in self.stops]
+
+ def remove_orientation(self):
+ """Remove all orientation attributes from this element
+
+ .. versionadded:: 1.1"""
+ for attr in self.orientation_attributes:
+ self.pop(attr)
+
+ def interpolate(
+ self,
+ other: LinearGradient,
+ fraction: float,
+ svg: Optional[SvgDocumentElement] = None,
+ ):
+ """Interpolate with another gradient.
+
+ .. versionadded:: 1.1"""
+ from ..tween import GradientInterpolator
+
+ return GradientInterpolator(self, other, svg).interpolate(fraction)
+
+ def stops_and_orientation(self):
+ """Return a copy of all the stops in this gradient
+
+ .. versionadded:: 1.1"""
+ stops = self.copy()
+ stops.remove_orientation()
+ orientation = self.copy()
+ orientation.remove_all(Stop)
+ return stops, orientation
+
+
+class LinearGradient(Gradient):
+ """LinearGradient element"""
+
+ tag_name = "linearGradient"
+ orientation_attributes = ("x1", "y1", "x2", "y2")
+ """
+ .. versionadded:: 1.1
+ """
+
+ def apply_transform(self): # type: () -> None
+ """Apply transform to orientation points and set it to identity.
+ .. versionadded:: 1.1
+ """
+ trans = self.pop("gradientTransform")
+ pt1 = (
+ self.to_dimensionless(self.get("x1")),
+ self.to_dimensionless(self.get("y1")),
+ )
+ pt2 = (
+ self.to_dimensionless(self.get("x2")),
+ self.to_dimensionless(self.get("y2")),
+ )
+ p1t = trans.apply_to_point(pt1)
+ p2t = trans.apply_to_point(pt2)
+ self.update(
+ x1=self.to_dimensionless(p1t[0]),
+ y1=self.to_dimensionless(p1t[1]),
+ x2=self.to_dimensionless(p2t[0]),
+ y2=self.to_dimensionless(p2t[1]),
+ )
+
+
+class RadialGradient(Gradient):
+ """RadialGradient element"""
+
+ tag_name = "radialGradient"
+ orientation_attributes = ("cx", "cy", "fx", "fy", "r")
+ """
+ .. versionadded:: 1.1
+ """
+
+ def apply_transform(self): # type: () -> None
+ """Apply transform to orientation points and set it to identity.
+
+ .. versionadded:: 1.1
+ """
+ trans = self.pop("gradientTransform")
+ pt1 = (
+ self.to_dimensionless(self.get("cx")),
+ self.to_dimensionless(self.get("cy")),
+ )
+ pt2 = (
+ self.to_dimensionless(self.get("fx")),
+ self.to_dimensionless(self.get("fy")),
+ )
+ p1t = trans.apply_to_point(pt1)
+ p2t = trans.apply_to_point(pt2)
+ self.update(
+ cx=self.to_dimensionless(p1t[0]),
+ cy=self.to_dimensionless(p1t[1]),
+ fx=self.to_dimensionless(p2t[0]),
+ fy=self.to_dimensionless(p2t[1]),
+ )
+
+
+class PathEffect(BaseElement):
+ """Inkscape LPE element"""
+
+ tag_name = "inkscape:path-effect"
+
+
+class MeshGradient(Gradient):
+ """Usable MeshGradient XML base class
+
+ .. versionadded:: 1.1"""
+
+ tag_name = "meshgradient"
+
+ @classmethod
+ def new_mesh(cls, pos=None, rows=1, cols=1, autocollect=True):
+ """Return skeleton of 1x1 meshgradient definition."""
+ # initial point
+ if pos is None or len(pos) != 2:
+ pos = [0.0, 0.0]
+ # create nested elements for rows x cols mesh
+ meshgradient = cls()
+ for _ in range(rows):
+ meshrow: BaseElement = meshgradient.add(MeshRow())
+ for _ in range(cols):
+ meshrow.append(MeshPatch())
+ # set meshgradient attributes
+ meshgradient.set("gradientUnits", "userSpaceOnUse")
+ meshgradient.set("x", pos[0])
+ meshgradient.set("y", pos[1])
+ if autocollect:
+ meshgradient.set("inkscape:collect", "always")
+ return meshgradient
+
+
+class MeshRow(BaseElement):
+ """Each row of a mesh gradient
+
+ .. versionadded:: 1.1"""
+
+ tag_name = "meshrow"
+
+
+class MeshPatch(BaseElement):
+ """Each column or 'patch' in a mesh gradient
+
+ .. versionadded:: 1.1"""
+
+ tag_name = "meshpatch"
+
+ def stops(self, edges, colors):
+ """Add or edit meshpatch stops with path and stop-color."""
+ # iterate stops based on number of edges (path data)
+ for i, edge in enumerate(edges):
+ if i < len(self):
+ stop = self[i]
+ else:
+ stop = self.add(Stop())
+
+ # set edge path data
+ stop.set("path", str(edge))
+ # set stop color
+ stop.style["stop-color"] = str(colors[i % 2])
diff --git a/share/extensions/inkex/elements/_groups.py b/share/extensions/inkex/elements/_groups.py
new file mode 100644
index 0000000..c66bfe3
--- /dev/null
+++ b/share/extensions/inkex/elements/_groups.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Ryan Jarvis <ryan@shopboxretail.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
+"""
+Interface for all group based elements such as Groups, Use, Markers etc.
+"""
+
+from lxml import etree # pylint: disable=unused-import
+
+from ..paths import Path
+from ..transforms import Transform
+
+from ._utils import addNS
+from ._base import ShapeElement
+
+try:
+ from typing import Optional # pylint: disable=unused-import
+except ImportError:
+ pass
+
+
+class GroupBase(ShapeElement):
+ """Base Group element"""
+
+ def get_path(self):
+ ret = Path()
+ for child in self:
+ if isinstance(child, ShapeElement):
+ ret += child.path.transform(child.transform)
+ return ret
+
+ def shape_box(self, transform=None):
+ bbox = None
+ effective_transform = Transform(transform) @ self.transform
+ for child in self:
+ if isinstance(child, ShapeElement):
+ child_bbox = child.bounding_box(transform=effective_transform)
+ if child_bbox is not None:
+ bbox += child_bbox
+ return bbox
+
+
+class Group(GroupBase):
+ """Any group element (layer or regular group)"""
+
+ tag_name = "g"
+
+ @classmethod
+ def new(cls, label, *children, **attrs):
+ attrs["inkscape:label"] = label
+ return super().new(*children, **attrs)
+
+ def effective_style(self):
+ """A blend of each child's style mixed together (last child wins)"""
+ style = self.style
+ for child in self:
+ style.update(child.effective_style())
+ return style
+
+ @property
+ def groupmode(self):
+ """Return the type of group this is"""
+ return self.get("inkscape:groupmode", "group")
+
+
+class Layer(Group):
+ """Inkscape extension of svg:g"""
+
+ def _init(self):
+ self.set("inkscape:groupmode", "layer")
+
+ @classmethod
+ def is_class_element(cls, elem):
+ # type: (etree.Element) -> bool
+ return elem.attrib.get(addNS("inkscape:groupmode"), None) == "layer"
+
+
+class Anchor(GroupBase):
+ """An anchor or link tag"""
+
+ tag_name = "a"
+
+ @classmethod
+ def new(cls, href, *children, **attrs):
+ attrs["xlink:href"] = href
+ return super().new(*children, **attrs)
+
+
+class ClipPath(GroupBase):
+ """A path used to clip objects"""
+
+ tag_name = "clipPath"
+
+
+class Marker(GroupBase):
+ """The <marker> element defines the graphic that is to be used for drawing
+ arrowheads or polymarkers on a given <path>, <line>, <polyline> or <polygon>
+ element."""
+
+ tag_name = "marker"
+
+
+class Mask(GroupBase):
+ """An alpha mask for compositing an object into the background
+
+ .. versionadded:: 1.2"""
+
+ tag_name = "mask"
diff --git a/share/extensions/inkex/elements/_image.py b/share/extensions/inkex/elements/_image.py
new file mode 100644
index 0000000..9294d73
--- /dev/null
+++ b/share/extensions/inkex/elements/_image.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 - Martin Owens <doctormo@gmail.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.
+#
+"""
+Image element interface.
+"""
+
+from ._polygons import RectangleBase
+
+
+class Image(RectangleBase):
+ """Provide a useful extension for image elements"""
+
+ tag_name = "image"
diff --git a/share/extensions/inkex/elements/_meta.py b/share/extensions/inkex/elements/_meta.py
new file mode 100644
index 0000000..e9eeb0f
--- /dev/null
+++ b/share/extensions/inkex/elements/_meta.py
@@ -0,0 +1,293 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Maren Hachmann <moini>
+#
+# 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
+import math
+
+from typing import Optional
+
+from lxml import etree
+
+from ..styles import StyleSheet
+from ..transforms import Vector2d, VectorLike, DirectedLineSegment
+
+from ._base import BaseElement
+
+
+class Defs(BaseElement):
+ """A header defs element, one per document"""
+
+ tag_name = "defs"
+
+
+class StyleElement(BaseElement):
+ """A CSS style element containing multiple style definitions"""
+
+ tag_name = "style"
+
+ def set_text(self, content):
+ """Sets the style content text as a CDATA section"""
+ self.text = etree.CDATA(str(content))
+
+ def stylesheet(self):
+ """Return the StyleSheet() object for the style tag"""
+ return StyleSheet(self.text, callback=self.set_text)
+
+
+class Script(BaseElement):
+ """A javascript tag in SVG"""
+
+ tag_name = "script"
+
+ def set_text(self, content):
+ """Sets the style content text as a CDATA section"""
+ self.text = etree.CDATA(str(content))
+
+
+class Desc(BaseElement):
+ """Description element"""
+
+ tag_name = "desc"
+
+
+class Title(BaseElement):
+ """Title element"""
+
+ tag_name = "title"
+
+
+class NamedView(BaseElement):
+ """The NamedView element is Inkscape specific metadata about the file"""
+
+ tag_name = "sodipodi:namedview"
+
+ current_layer = property(lambda self: self.get("inkscape:current-layer"))
+
+ @property
+ def center(self):
+ """Returns view_center in terms of document units"""
+ return Vector2d(
+ self.root.viewport_to_unit(self.get("inkscape:cx") or 0),
+ self.root.viewport_to_unit(self.get("inkscape:cy") or 0),
+ )
+
+ def get_guides(self):
+ """Returns a list of guides"""
+ return self.findall("sodipodi:guide")
+
+ def new_guide(self, position, orient=True, name=None):
+ """Creates a new guide in this namedview
+
+ Args:
+ position: a float containing the y position for ``orient is True``, or
+ the x position for ``orient is False``
+
+ .. versionchanged:: 1.2
+ Alternatively, the position may be given as Tuple (or VectorLike)
+ orient: True for horizontal, False for Vertical
+
+ .. versionchanged:: 1.2
+ Tuple / Vector specifying x and y coordinates of the normal vector
+ of the guide.
+ name: label of the guide
+
+ Returns:
+ the created guide"""
+ if orient is True:
+ elem = Guide().move_to(0, position, (0, 1))
+ elif orient is False:
+ elem = Guide().move_to(position, 0, (1, 0))
+ else:
+ elem = Guide().move_to(*position, orient)
+ if name:
+ elem.set("inkscape:label", str(name))
+ return self.add(elem)
+
+ def new_unique_guide(
+ self, position: VectorLike, orientation: VectorLike
+ ) -> Optional[Guide]:
+ """Add a guide iif there is no guide that looks the same.
+
+ .. versionadded:: 1.2"""
+ elem = Guide().move_to(position[0], position[1], orientation)
+ return self.add(elem) if self.get_similar_guide(elem) is None else None
+
+ def get_similar_guide(self, other: Guide) -> Optional[Guide]:
+ """Check if the namedview contains a guide that looks identical to one
+ defined by (position, orientation). If such a guide exists, return it;
+ otherwise, return None.
+
+ .. versionadded:: 1.2"""
+ for guide in self.get_guides():
+ if Guide.guides_coincident(guide, other):
+ return guide
+ return None
+
+ def get_pages(self):
+ """Returns a list of pages
+
+ .. versionadded:: 1.2"""
+ return self.findall("inkscape:page")
+
+ def new_page(self, x, y, width, height, label=None):
+ """Creates a new page in this namedview
+
+ .. versionadded:: 1.2"""
+ elem = Page(width=width, height=height, x=x, y=y)
+ if label:
+ elem.set("inkscape:label", str(label))
+ return self.add(elem)
+
+
+class Guide(BaseElement):
+ """An inkscape guide"""
+
+ tag_name = "sodipodi:guide"
+
+ @property
+ def orientation(self) -> Vector2d:
+ """Vector normal to the guide
+
+ .. versionadded:: 1.2"""
+ return Vector2d(self.get("orientation"), fallback=(1, 0))
+
+ is_horizontal = property(
+ lambda self: self.orientation[0] == 0 and self.orientation[1] != 0
+ )
+ is_vertical = property(
+ lambda self: self.orientation[0] != 0 and self.orientation[1] == 0
+ )
+
+ @property
+ def point(self) -> Vector2d:
+ """Position of the guide handle. The y coordinate is flipped and relative
+ to the bottom of the viewbox, this is a remnant of the pre-1.0 coordinate system
+ """
+ return Vector2d(self.get("position"), fallback=(0, 0))
+
+ @classmethod
+ def new(cls, pos_x, pos_y, angle, **attrs):
+ guide = super().new(**attrs)
+ guide.move_to(pos_x, pos_y, angle=angle)
+ return guide
+
+ def move_to(self, pos_x, pos_y, angle=None):
+ """
+ Move this guide to the given x,y position,
+
+ Angle may be a float or integer, which will change the orientation. Alternately,
+ it may be a pair of numbers (tuple) which will set the orientation directly.
+ If not given at all, the orientation remains unchanged.
+ """
+ self.set("position", f"{float(pos_x):g},{float(pos_y):g}")
+ if isinstance(angle, str):
+ if "," not in angle:
+ angle = float(angle)
+
+ if isinstance(angle, (float, int)):
+ # Generate orientation from angle
+ angle = (math.sin(math.radians(angle)), -math.cos(math.radians(angle)))
+
+ if isinstance(angle, (tuple, list)) and len(angle) == 2:
+ angle = ",".join(f"{i:g}" for i in angle)
+
+ if angle is not None:
+ self.set("orientation", angle)
+ return self
+
+ @staticmethod
+ def guides_coincident(guide1, guide2):
+ """Check if two guides defined by (position, orientation) and (opos, oor) look
+ identical (i.e. the position lies on the other guide AND the guide is
+ (anti)parallel to the other guide).
+
+ .. versionadded:: 1.2"""
+ # normalize orientations first
+ orientation = guide1.orientation / guide1.orientation.length
+ oor = guide2.orientation / guide2.orientation.length
+
+ position = guide1.point
+ opos = guide2.point
+
+ return (
+ DirectedLineSegment(
+ position, position + Vector2d(orientation[1], -orientation[0])
+ ).perp_distance(*opos)
+ < 1e-6
+ and abs(abs(orientation[1] * oor[0]) - abs(orientation[0] * oor[1])) < 1e-6
+ )
+
+
+class Metadata(BaseElement):
+ """Inkscape Metadata element"""
+
+ tag_name = "metadata"
+
+
+class ForeignObject(BaseElement):
+ """SVG foreignObject element"""
+
+ tag_name = "foreignObject"
+
+
+class Switch(BaseElement):
+ """A switch element"""
+
+ tag_name = "switch"
+
+
+class Grid(BaseElement):
+ """A namedview grid child"""
+
+ tag_name = "inkscape:grid"
+
+
+class Page(BaseElement):
+ """A namedview page child
+
+ .. versionadded:: 1.2"""
+
+ tag_name = "inkscape:page"
+
+ width = property(lambda self: self.to_dimensionless(self.get("width") or 0))
+ height = property(lambda self: self.to_dimensionless(self.get("height") or 0))
+ x = property(lambda self: self.to_dimensionless(self.get("x") or 0))
+ y = property(lambda self: self.to_dimensionless(self.get("y") or 0))
+
+ @classmethod
+ def new(cls, width, height, x, y):
+ """Creates a new page element in the namedview"""
+ page = super().new()
+ page.move_to(x, y)
+ page.set("width", width)
+ page.set("height", height)
+ return page
+
+ def move_to(self, x, y):
+ """Move this page to the given x,y position"""
+ self.set("position", f"{float(x):g},{float(y):g}")
+ return self
diff --git a/share/extensions/inkex/elements/_parser.py b/share/extensions/inkex/elements/_parser.py
new file mode 100644
index 0000000..0357eec
--- /dev/null
+++ b/share/extensions/inkex/elements/_parser.py
@@ -0,0 +1,118 @@
+# -*- 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.
+#
+
+"""Utilities for parsing SVG documents.
+
+.. versionadded:: 1.2
+ Separated out from :py:mod:`inkex.elements._base`"""
+
+from collections import defaultdict
+from typing import DefaultDict, List, Any, Type
+
+from lxml import etree
+
+from ..interfaces.IElement import IBaseElement
+
+from ._utils import splitNS
+from ..utils import errormsg
+from ..localization import inkex_gettext as _
+
+
+class NodeBasedLookup(etree.PythonElementClassLookup):
+ """
+ We choose what kind of Elements we should return for each element, providing useful
+ SVG based API to our extensions system.
+ """
+
+ default: Type[IBaseElement]
+
+ # (ns,tag) -> list(cls) ; ascending priority
+ lookup_table = defaultdict(list) # type: DefaultDict[str, List[Any]]
+
+ @classmethod
+ def register_class(cls, klass):
+ """Register the given class using it's attached tag name"""
+ cls.lookup_table[splitNS(klass.tag_name)].append(klass)
+
+ @classmethod
+ def find_class(cls, xpath):
+ """Find the class for this type of element defined by an xpath
+
+ .. versionadded:: 1.1"""
+ if isinstance(xpath, type):
+ return xpath
+ for kls in cls.lookup_table[splitNS(xpath.split("/")[-1])]:
+ # TODO: We could create a apply the xpath attrs to the test element
+ # to narrow the search, but this does everything we need right now.
+ test_element = kls()
+ if kls.is_class_element(test_element):
+ return kls
+ raise KeyError(f"Could not find svg tag for '{xpath}'")
+
+ def lookup(self, doc, element): # pylint: disable=unused-argument
+ """Lookup called by lxml when assigning elements their object class"""
+ try:
+ for kls in reversed(self.lookup_table[splitNS(element.tag)]):
+ if kls.is_class_element(element): # pylint: disable=protected-access
+ return kls
+ except TypeError:
+ # Handle non-element proxies case
+ # The documentation implies that it's not possible
+ # Didn't found a reliable way to check whether proxy corresponds to element
+ # or not
+ # Look like lxml issue to me.
+ # The troubling element is "<!--Comment-->"
+ return None
+ return NodeBasedLookup.default
+
+
+SVG_PARSER = etree.XMLParser(huge_tree=True, strip_cdata=False, recover=True)
+SVG_PARSER.set_element_class_lookup(NodeBasedLookup())
+
+
+def load_svg(stream):
+ """Load SVG file using the SVG_PARSER"""
+ if (isinstance(stream, str) and stream.lstrip().startswith("<")) or (
+ isinstance(stream, bytes) and stream.lstrip().startswith(b"<")
+ ):
+ parsed = etree.ElementTree(etree.fromstring(stream, parser=SVG_PARSER))
+ else:
+ parsed = etree.parse(stream, parser=SVG_PARSER)
+ if len(SVG_PARSER.error_log) > 0:
+ errormsg(
+ _(
+ "A parsing error occured, which means you are likely working with "
+ "a non-conformant SVG file. The following errors were found:\n"
+ )
+ )
+ for __, element in enumerate(SVG_PARSER.error_log):
+ errormsg(
+ _("{}. Line {}, column {}").format(
+ element.message, element.line, element.column
+ )
+ )
+ errormsg(
+ _(
+ "\nProcessing will continue; however we encourage you to fix your"
+ " file manually."
+ )
+ )
+ return parsed
diff --git a/share/extensions/inkex/elements/_polygons.py b/share/extensions/inkex/elements/_polygons.py
new file mode 100644
index 0000000..4f88a3a
--- /dev/null
+++ b/share/extensions/inkex/elements/_polygons.py
@@ -0,0 +1,507 @@
+# -*- 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
+"""
+Interface for all shapes/polygons such as lines, paths, rectangles, circles etc.
+"""
+
+from math import cos, pi, sin
+from typing import Optional, Tuple
+from ..paths import Arc, Curve, Move, Path, ZoneClose
+from ..paths import Line as PathLine
+from ..transforms import Transform, ImmutableVector2d, Vector2d
+from ..bezier import pointdistance
+
+from ._utils import addNS
+from ._base import ShapeElement
+
+
+class PathElementBase(ShapeElement):
+ """Base element for path based shapes"""
+
+ get_path = lambda self: Path(self.get("d"))
+
+ @classmethod
+ def new(cls, path, **attrs):
+ return super().new(d=Path(path), **attrs)
+
+ def set_path(self, path):
+ """Set the given data as a path as the 'd' attribute"""
+ self.set("d", str(Path(path)))
+
+ def apply_transform(self):
+ """Apply the internal transformation to this node and delete"""
+ if "transform" in self.attrib:
+ self.path = self.path.transform(self.transform)
+ self.set("transform", Transform())
+
+ @property
+ def original_path(self):
+ """Returns the original path if this is a LPE, or the path if not"""
+ return Path(self.get("inkscape:original-d", self.path))
+
+ @original_path.setter
+ def original_path(self, path):
+ if addNS("inkscape:original-d") in self.attrib:
+ self.set("inkscape:original-d", str(Path(path)))
+ else:
+ self.path = path
+
+
+class PathElement(PathElementBase):
+ """Provide a useful extension for path elements"""
+
+ tag_name = "path"
+
+ @staticmethod
+ def _arcpath(
+ cx: float,
+ cy: float,
+ rx: float,
+ ry: float,
+ start: float,
+ end: float,
+ arctype: str,
+ ) -> Optional[Path]:
+ """Compute the path for an arc defined by Inkscape-specific attributes.
+
+ For details on arguments, see :func:`arc`.
+
+ .. versionadded:: 1.2"""
+ if abs(rx) < 1e-8 or abs(ry) < 1e-8:
+ return None
+ incr = end - start
+ if incr < 0:
+ incr += 2 * pi
+ numsegs = min(1 + int(incr * 2.0 / pi), 4)
+ incr = incr / numsegs
+
+ computed = Path()
+ computed.append(Move(cos(start), sin(start)))
+ for seg in range(1, numsegs + 1):
+ computed.append(
+ Arc(1, 1, 0, 0, 1, cos(start + seg * incr), sin(start + seg * incr))
+ )
+ if abs(incr * numsegs - 2 * pi) > 1e-8 and (
+ arctype in ("slice", "")
+ ): # slice is default
+ computed.append(PathLine(0, 0))
+ if arctype != "arc":
+ computed.append(ZoneClose())
+ computed.transform(
+ Transform().add_translate(cx, cy).add_scale(rx, ry), inplace=True
+ )
+ return computed.to_relative()
+
+ @classmethod
+ def arc(
+ cls, center, rx, ry=None, arctype="", pathonly=False, **kw
+ ): # pylint: disable=invalid-name
+ """Generates a sodipodi elliptical arc (special type). Also computes the path
+ that Inkscape uses under the hood.
+ All data may be given as parseable strings or using numeric data types.
+
+ Args:
+ center (tuple-like): Coordinates of the star/polygon center as tuple or
+ Vector2d
+ rx (Union[float, str]): Radius in x direction
+ ry (Union[float, str], optional): Radius in y direction. If not given,
+ ry=rx. Defaults to None.
+ arctype (str, optional): "arc", "chord" or "slice". Defaults to "", i.e.
+ "slice".
+
+ .. versionadded:: 1.2
+ Previously set to "arc" as fixed value
+ pathonly (bool, optional): Whether to create the path without
+ Inkscape-specific attributes. Defaults to False.
+
+ .. versionadded:: 1.2
+ Keyword args:
+ start (Union[float, str]): start angle in radians
+ end (Union[float, str]): end angle in radians
+ open (str): whether the path should be open (true/false). Not used in
+ Inkscape > 1.1
+
+ Returns:
+ PathElement : the created star/polygon
+ """
+ others = [(name, kw.pop(name, None)) for name in ("start", "end", "open")]
+ elem = cls(**kw)
+ elem.set("sodipodi:cx", center[0])
+ elem.set("sodipodi:cy", center[1])
+ elem.set("sodipodi:rx", rx)
+ elem.set("sodipodi:ry", ry or rx)
+ elem.set("sodipodi:type", "arc")
+ if arctype != "":
+ elem.set("sodipodi:arc-type", arctype)
+ for name, value in others:
+ if value is not None:
+ elem.set("sodipodi:" + name, str(value).lower())
+
+ path = cls._arcpath(
+ float(center[0]),
+ float(center[1]),
+ float(rx),
+ float(ry or rx),
+ float(elem.get("sodipodi:start", 0)),
+ float(elem.get("sodipodi:end", 2 * pi)),
+ arctype,
+ )
+ if pathonly:
+ elem = cls(**kw)
+ if path is not None:
+ elem.path = path
+ return elem
+
+ @staticmethod
+ def _starpath(
+ c: Tuple[float, float],
+ sides: int,
+ r: Tuple[float, float], # pylint: disable=invalid-name
+ arg: Tuple[float, float],
+ rounded: float,
+ flatsided: bool,
+ ):
+ """Helper method to generate the path for an Inkscape star/ polygon; randomized
+ is ignored.
+
+ For details on arguments, see :func:`star`.
+
+ .. versionadded:: 1.2"""
+
+ def _star_get_xy(point, index):
+ cur_arg = arg[point] + 2 * pi / sides * (index % sides)
+ return Vector2d(*c) + r[point] * Vector2d(cos(cur_arg), sin(cur_arg))
+
+ def _rot90_rel(origin, other):
+ """Returns a unit length vector at 90 deg from origin to other"""
+ return (
+ 1
+ / pointdistance(other, origin)
+ * Vector2d(other.y - origin.y, other.x - origin.x)
+ )
+
+ def _star_get_curvepoint(point, index, is_prev: bool):
+ index = index % sides
+ orig = _star_get_xy(point, index)
+ previ = (index - 1 + sides) % sides
+ nexti = (index + 1) % sides
+ # neighbors of the current point depend on polygon or star
+ prev = (
+ _star_get_xy(point, previ)
+ if flatsided
+ else _star_get_xy(1 - point, index if point == 1 else previ)
+ )
+ nextp = (
+ _star_get_xy(point, nexti)
+ if flatsided
+ else _star_get_xy(1 - point, index if point == 0 else nexti)
+ )
+ mid = 0.5 * (prev + nextp)
+ # direction of bezier handles
+ rot = _rot90_rel(orig, mid + 100000 * _rot90_rel(mid, nextp))
+ ret = (
+ rounded
+ * rot
+ * (
+ -1 * pointdistance(prev, orig)
+ if is_prev
+ else pointdistance(nextp, orig)
+ )
+ )
+ return orig + ret
+
+ pointy = abs(rounded) < 1e-4
+ result = Path()
+ result.append(Move(*_star_get_xy(0, 0)))
+ for i in range(0, sides):
+ # draw to point type 1 for stars
+ if not flatsided:
+ if pointy:
+ result.append(PathLine(*_star_get_xy(1, i)))
+ else:
+ result.append(
+ Curve(
+ *_star_get_curvepoint(0, i, False),
+ *_star_get_curvepoint(1, i, True),
+ *_star_get_xy(1, i),
+ )
+ )
+ # draw to point type 0 for both stars and rectangles
+ if pointy and i < sides - 1:
+ result.append(PathLine(*_star_get_xy(0, i + 1)))
+ if not pointy:
+ if not flatsided:
+ result.append(
+ Curve(
+ *_star_get_curvepoint(1, i, False),
+ *_star_get_curvepoint(0, i + 1, True),
+ *_star_get_xy(0, i + 1),
+ )
+ )
+ else:
+ result.append(
+ Curve(
+ *_star_get_curvepoint(0, i, False),
+ *_star_get_curvepoint(0, i + 1, True),
+ *_star_get_xy(0, i + 1),
+ )
+ )
+
+ result.append(ZoneClose())
+ return result.to_relative()
+
+ @classmethod
+ def star(
+ cls,
+ center,
+ radii,
+ sides=5,
+ rounded=0,
+ args=(0, 0),
+ flatsided=False,
+ pathonly=False,
+ ):
+ """Generate a sodipodi star / polygon. Also computes the path that Inkscape uses
+ under the hood. The arguments for center, radii, sides, rounded and args can be
+ given as strings or as numeric data.
+
+ .. versionadded:: 1.1
+
+ Args:
+ center (Tuple-like): Coordinates of the star/polygon center as tuple or
+ Vector2d
+ radii (tuple): Radii of the control points, i.e. their distances from the
+ center. The control points are specified in polar coordinates. Only the
+ first control point is used for polygons.
+ sides (int, optional): Number of sides / tips of the polygon / star.
+ Defaults to 5.
+ rounded (int, optional): Controls the rounding radius of the polygon / star.
+ For `rounded=0`, only straight lines are used. Defaults to 0.
+ args (tuple, optional): Angle between horizontal axis and control points.
+ Defaults to (0,0).
+
+ .. versionadded:: 1.2
+ Previously fixed to (0.85, 1.3)
+ flatsided (bool, optional): True for polygons, False for stars.
+ Defaults to False.
+
+ .. versionadded:: 1.2
+ pathonly (bool, optional): Whether to create the path without
+ Inkscape-specific attributes. Defaults to False.
+
+ .. versionadded:: 1.2
+
+ Returns:
+ PathElement : the created star/polygon
+ """
+ elem = cls()
+ elem.set("sodipodi:cx", center[0])
+ elem.set("sodipodi:cy", center[1])
+ elem.set("sodipodi:r1", radii[0])
+ elem.set("sodipodi:r2", radii[1])
+ elem.set("sodipodi:arg1", args[0])
+ elem.set("sodipodi:arg2", args[1])
+ elem.set("sodipodi:sides", max(sides, 3) if flatsided else max(sides, 2))
+ elem.set("inkscape:rounded", rounded)
+ elem.set("inkscape:flatsided", str(flatsided).lower())
+ elem.set("sodipodi:type", "star")
+
+ path = cls._starpath(
+ (float(center[0]), float(center[1])),
+ int(sides),
+ (float(radii[0]), float(radii[1])),
+ (float(args[0]), float(args[1])),
+ float(rounded),
+ flatsided,
+ )
+ if pathonly:
+ elem = cls()
+ # inkex.errormsg(path)
+ if path is not None:
+ elem.path = path
+
+ return elem
+
+
+class Polyline(ShapeElement):
+ """Like a path, but made up of straight line segments only"""
+
+ tag_name = "polyline"
+
+ def get_path(self):
+ return Path("M" + self.get("points"))
+
+ def set_path(self, path):
+ points = [f"{x:g},{y:g}" for x, y in Path(path).end_points]
+ self.set("points", " ".join(points))
+
+
+class Polygon(ShapeElement):
+ """A closed polyline"""
+
+ tag_name = "polygon"
+ get_path = lambda self: Path("M" + self.get("points") + " Z")
+
+
+class Line(ShapeElement):
+ """A line segment connecting two points"""
+
+ tag_name = "line"
+ x1 = property(lambda self: self.to_dimensionless(self.get("x1", 0)))
+ y1 = property(lambda self: self.to_dimensionless(self.get("y1", 0)))
+ x2 = property(lambda self: self.to_dimensionless(self.get("x2", 0)))
+ y2 = property(lambda self: self.to_dimensionless(self.get("y2", 0)))
+ get_path = lambda self: Path(f"M{self.x1},{self.y1} L{self.x2},{self.y2}")
+
+ @classmethod
+ def new(cls, start, end, **attrs):
+ start = Vector2d(start)
+ end = Vector2d(end)
+ return super().new(x1=start.x, y1=start.y, x2=end.x, y2=end.y, **attrs)
+
+
+class RectangleBase(ShapeElement):
+ """Provide a useful extension for rectangle elements"""
+
+ left = property(lambda self: self.to_dimensionless(self.get("x", "0")))
+ top = property(lambda self: self.to_dimensionless(self.get("y", "0")))
+ right = property(lambda self: self.left + self.width)
+ bottom = property(lambda self: self.top + self.height)
+ width = property(lambda self: self.to_dimensionless(self.get("width", "0")))
+ height = property(lambda self: self.to_dimensionless(self.get("height", "0")))
+ rx = property(
+ lambda self: self.to_dimensionless(self.get("rx", self.get("ry", 0.0)))
+ )
+ ry = property(
+ lambda self: self.to_dimensionless(self.get("ry", self.get("rx", 0.0)))
+ ) # pylint: disable=invalid-name
+
+ def get_path(self):
+ """Calculate the path as the box around the rect"""
+ if self.rx:
+ rx, ry = self.rx, self.ry # pylint: disable=invalid-name
+ cpts = [self.left + rx, self.right - rx, self.top + ry, self.bottom - ry]
+ return (
+ f"M {cpts[0]},{self.top}"
+ f"L {cpts[1]},{self.top} "
+ f"A {self.rx},{self.ry} 0 0 1 {self.right},{cpts[2]}"
+ f"L {self.right},{cpts[3]} "
+ f"A {self.rx},{self.ry} 0 0 1 {cpts[1]},{self.bottom}"
+ f"L {cpts[0]},{self.bottom} "
+ f"A {self.rx},{self.ry} 0 0 1 {self.left},{cpts[3]}"
+ f"L {self.left},{cpts[2]} "
+ f"A {self.rx},{self.ry} 0 0 1 {cpts[0]},{self.top} z"
+ )
+
+ return f"M {self.left},{self.top} h{self.width}v{self.height}h{-self.width} z"
+
+
+class Rectangle(RectangleBase):
+ """Provide a useful extension for rectangle elements"""
+
+ tag_name = "rect"
+
+ @classmethod
+ def new(cls, left, top, width, height, **attrs):
+ return super().new(x=left, y=top, width=width, height=height, **attrs)
+
+
+class EllipseBase(ShapeElement):
+ """Absorbs common part of Circle and Ellipse classes"""
+
+ def get_path(self):
+ """Calculate the arc path of this circle"""
+ rx, ry = self._rxry()
+ cx, y = self.center.x, self.center.y - ry
+ return (
+ "M {cx},{y} "
+ "a {rx},{ry} 0 1 0 {rx}, {ry} "
+ "a {rx},{ry} 0 0 0 -{rx}, -{ry} z"
+ ).format(cx=cx, y=y, rx=rx, ry=ry)
+
+ @property
+ def center(self):
+ """Return center of circle/ellipse"""
+ return ImmutableVector2d(
+ self.to_dimensionless(self.get("cx", "0")),
+ self.to_dimensionless(self.get("cy", "0")),
+ )
+
+ @center.setter
+ def center(self, value):
+ value = Vector2d(value)
+ self.set("cx", value.x)
+ self.set("cy", value.y)
+
+ def _rxry(self):
+ # type: () -> Vector2d
+ """Helper function"""
+ raise NotImplementedError()
+
+ @classmethod
+ def new(cls, center, radius, **attrs):
+ circle = super().new(**attrs)
+ circle.center = center
+ circle.radius = radius
+ return circle
+
+
+class Circle(EllipseBase):
+ """Provide a useful extension for circle elements"""
+
+ tag_name = "circle"
+
+ @property
+ def radius(self) -> float:
+ """Return radius of circle"""
+ return self.to_dimensionless(self.get("r", "0"))
+
+ @radius.setter
+ def radius(self, value):
+ self.set("r", self.to_dimensionless(value))
+
+ def _rxry(self):
+ r = self.radius
+ return Vector2d(r, r)
+
+
+class Ellipse(EllipseBase):
+ """Provide a similar extension to the Circle interface for ellipses"""
+
+ tag_name = "ellipse"
+
+ @property
+ def radius(self) -> ImmutableVector2d:
+ """Return radii of ellipse"""
+ return ImmutableVector2d(
+ self.to_dimensionless(self.get("rx", "0")),
+ self.to_dimensionless(self.get("ry", "0")),
+ )
+
+ @radius.setter
+ def radius(self, value):
+ value = Vector2d(value)
+ self.set("rx", str(value.x))
+ self.set("ry", str(value.y))
+
+ def _rxry(self):
+ return self.radius
diff --git a/share/extensions/inkex/elements/_selected.py b/share/extensions/inkex/elements/_selected.py
new file mode 100644
index 0000000..9f23e59
--- /dev/null
+++ b/share/extensions/inkex/elements/_selected.py
@@ -0,0 +1,237 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.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.,Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+When elements are selected, these structures provide an advanced API.
+
+.. versionadded:: 1.1
+"""
+
+from collections import OrderedDict
+from typing import Any, overload, Union, Optional
+
+from ..interfaces.IElement import IBaseElement
+from ._utils import natural_sort_key
+from ..localization import inkex_gettext
+from ..utils import AbortExtension
+
+
+class ElementList(OrderedDict):
+ """
+ A list of elements, selected by id, iterator or xpath
+
+ This may look like a dictionary, but it is really a list of elements.
+ The default iterator is the element objects themselves (not keys) and it is
+ possible to key elements by their numerical index.
+
+ It is also possible to look up items by their id and the element object itself.
+ """
+
+ def __init__(self, svg, _iter=None):
+ self.svg = svg
+ self.ids = OrderedDict()
+ super().__init__()
+ if _iter is not None:
+ self.set(*list(_iter))
+
+ def __iter__(self):
+ return self.values().__iter__()
+
+ def __getitem__(self, key):
+ return super().__getitem__(self._to_key(key))
+
+ def __contains__(self, key):
+ return super().__contains__(self._to_key(key))
+
+ def __setitem__(self, orig_key, elem):
+
+ if orig_key != elem and orig_key != elem.get("id"):
+ raise ValueError(f"Refusing to set bad key in ElementList {orig_key}")
+ if isinstance(elem, str):
+ key = elem
+ elem = self.svg.getElementById(elem, literal=True)
+ if elem is None:
+ return
+ if isinstance(elem, IBaseElement):
+ # Selection is a list of elements to select
+ key = elem.xml_path
+ element_id = elem.get("id")
+ if element_id is not None:
+ self.ids[element_id] = key
+ super().__setitem__(key, elem)
+ else:
+ kind = type(elem).__name__
+ raise ValueError(f"Unknown element type: {kind}")
+
+ @overload
+ def _to_key(self, key: None, default: Any) -> Any:
+ ...
+
+ @overload
+ def _to_key(self, key: Union[int, IBaseElement, str], default: Any) -> str:
+ ...
+
+ def _to_key(self, key, default=None) -> str:
+ """Takes a key (id, element, etc) and returns an xml_path key"""
+
+ if self and key is None:
+ key = default
+ if isinstance(key, int):
+ return list(self.keys())[key]
+ if isinstance(key, IBaseElement):
+ return key.xml_path
+ if isinstance(key, str) and key[0] != "/":
+ return self.ids.get(key, key)
+ return key
+
+ def clear(self):
+ """Also clear ids"""
+ self.ids.clear()
+ super().clear()
+
+ def set(self, *ids):
+ """
+ Sets the currently selected elements to these ids, any existing
+ selection is cleared.
+
+ Arguments a list of element ids, element objects or
+ a single xpath expression starting with ``//``.
+
+ All element objects must have an id to be correctly set.
+
+ >>> selection.set("rect123", "path456", "text789")
+ >>> selection.set(elem1, elem2, elem3)
+ >>> selection.set("//rect")
+ """
+ self.clear()
+ self.add(*ids)
+
+ def pop(self, key=None):
+ """Remove the key item or remove the last item selected"""
+ item = super().pop(self._to_key(key, default=-1))
+ self.ids.pop(item.get("id"))
+ return item
+
+ def add(self, *ids):
+ """Like set() but does not clear first"""
+ # Allow selecting of xpath elements directly
+ if len(ids) == 1 and isinstance(ids[0], str) and ids[0].startswith("//"):
+ ids = self.svg.xpath(ids[0])
+
+ for elem in ids:
+ self[elem] = elem # This doesn't matter
+
+ def rendering_order(self):
+ """Get the selected elements by z-order (stacking order), ordered from bottom to
+ top
+
+ .. versionadded:: 1.2
+ :func:`paint_order` has been renamed to :func:`rendering_order`"""
+ new_list = ElementList(self.svg)
+ # the elements are stored with their xpath index, so a natural sort order
+ # '3' < '20' < '100' has to be applied
+ new_list.set(
+ *[
+ elem
+ for _, elem in sorted(
+ self.items(), key=lambda x: natural_sort_key(x[0])
+ )
+ ]
+ )
+ return new_list
+
+ def filter(self, *types):
+ """Filter selected elements of the given type, returns a new SelectedElements
+ object"""
+ return ElementList(
+ self.svg, [e for e in self if not types or isinstance(e, types)]
+ )
+
+ def filter_nonzero(self, *types, error_msg: Optional[str] = None):
+ """Filter selected elements of the given type, returns a new SelectedElements
+ object. If the selection is empty, abort the extension.
+
+ .. versionadded:: 1.2
+
+ :param error_msg: e
+ :type error_msg: str, optional
+
+ Args:
+ *types (Type) : type(s) to filter the selection by
+ error_msg (str, optional): error message that is displayed if the selection
+ is empty, defaults to
+ ``_("Please select at least one element of type(s) {}")``.
+ Defaults to None.
+
+ Raises:
+ AbortExtension: if the selection is empty
+
+ Returns:
+ ElementList: filtered selection
+ """
+ filtered = self.filter(*types)
+ if not filtered:
+ if error_msg is None:
+ error_msg = inkex_gettext(
+ "Please select at least one element of the following type(s): {}"
+ ).format(", ".join([type.__name__ for type in types]))
+ raise AbortExtension(error_msg)
+ return filtered
+
+ def get(self, *types):
+ """Like filter, but will enter each element searching for any child of the given
+ types"""
+
+ def _recurse(elem):
+ if not types or isinstance(elem, types):
+ yield elem
+ for child in elem:
+ yield from _recurse(child)
+
+ return ElementList(
+ self.svg,
+ [
+ r
+ for e in self
+ for r in _recurse(e)
+ if isinstance(r, (IBaseElement, str))
+ ],
+ )
+
+ def id_dict(self):
+ """For compatibility, return regular dictionary of id -> element pairs"""
+ return {eid: self[xid] for eid, xid in self.ids.items()}
+
+ def bounding_box(self):
+ """
+ Gets a :class:`inkex.transforms.BoundingBox` object for the selected items.
+
+ Text objects have a bounding box without width or height that only
+ reflects the coordinate of their anchor. If a text object is a part of
+ the selection's boundary, the bounding box may be inaccurate.
+
+ When no object is selected or when the object's location cannot be
+ determined (e.g. empty group or layer), all coordinates will be None.
+ """
+ return sum([elem.bounding_box() for elem in self], None)
+
+ def first(self):
+ """Returns the first item in the selected list"""
+ for elem in self:
+ return elem
+ return None
diff --git a/share/extensions/inkex/elements/_svg.py b/share/extensions/inkex/elements/_svg.py
new file mode 100644
index 0000000..00228b1
--- /dev/null
+++ b/share/extensions/inkex/elements/_svg.py
@@ -0,0 +1,371 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Windell Oskay <windell@oskay.net>
+#
+# 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=attribute-defined-outside-init
+#
+"""
+Provide a way to load lxml attributes with an svg API on top.
+"""
+
+import random
+import math
+import re
+
+from lxml import etree
+
+from ..css import ConditionalRule
+from ..interfaces.IElement import ISVGDocumentElement
+
+from ..deprecated.meta import DeprecatedSvgMixin, deprecate
+from ..units import discover_unit, parse_unit
+from ._selected import ElementList
+from ..transforms import BoundingBox
+from ..styles import StyleSheets
+
+from ._base import BaseElement
+from ._meta import StyleElement
+
+from typing import Optional, List
+
+if False: # pylint: disable=using-constant-test
+ import typing # pylint: disable=unused-import
+
+
+class SvgDocumentElement(DeprecatedSvgMixin, ISVGDocumentElement, BaseElement):
+ """Provide access to the document level svg functionality"""
+
+ # pylint: disable=too-many-public-methods
+ tag_name = "svg"
+
+ selection: ElementList
+ """The selection as passed by Inkscape (readonly)"""
+
+ def _init(self):
+ self.current_layer = None
+ self.view_center = (0.0, 0.0)
+ self.selection = ElementList(self)
+ self.ids = {}
+
+ def tostring(self):
+ """Convert document to string"""
+ return etree.tostring(etree.ElementTree(self))
+
+ def get_ids(self):
+ """Returns a set of unique document ids"""
+ if not self.ids:
+ self.ids = set(self.xpath("//@id"))
+ return self.ids
+
+ def get_unique_id(
+ self,
+ prefix: str,
+ size: Optional[int] = None,
+ blacklist: Optional[List[str]] = None,
+ ):
+ """Generate a new id from an existing old_id
+
+ The id consists of a prefix and an appended random integer with size digits.
+
+ If size is not given, it is determined automatically from the length of
+ existing ids, i.e. those in the document plus those in the blacklist.
+
+ Args:
+ prefix (str): the prefix of the new ID.
+ 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.1
+ The default of this parameter has been changed from 4 to None.
+ blacklist (Optional[Iterable[str]], optional): An additional iterable of ids
+ that are not allowed to be used. This is useful when bulk inserting
+ objects.
+ Defaults to None.
+
+ .. versionadded:: 1.2
+
+ Returns:
+ _type_: _description_
+ """
+ ids = self.get_ids()
+ if size is None:
+ size = max(math.ceil(math.log10(len(ids) or 1000)) + 1, 4)
+ if blacklist is not None:
+ ids.update(blacklist)
+ new_id = None
+ _from = 10**size - 1
+ _to = 10**size
+ while new_id is None or new_id in ids:
+ # Do not use randint because py2/3 incompatibility
+ new_id = prefix + str(int(random.random() * _from - _to) + _to)
+ self.ids.add(new_id)
+ return new_id
+
+ def get_page_bbox(self):
+ """Gets the page dimensions as a bbox"""
+ return BoundingBox(
+ (0, float(self.viewbox_width)), (0, float(self.viewbox_height))
+ )
+
+ def get_current_layer(self):
+ """Returns the currently selected layer"""
+ layer = self.getElementById(self.namedview.current_layer, "svg:g")
+ if layer is None:
+ return self
+ return layer
+
+ def getElement(self, xpath): # pylint: disable=invalid-name
+ """Gets a single element from the given xpath or returns None"""
+ return self.findone(xpath)
+
+ def getElementById(
+ self, eid: str, elm="*", literal=False
+ ): # pylint: disable=invalid-name
+ """Get an element in this svg document by it's ID attribute.
+
+ Args:
+ eid (str): element id
+ elm (str, optional): element type, including namespace, e.g. ``svg:path``.
+ Defaults to "*".
+ literal (bool, optional): If ``False``, ``#url()`` is stripped from ``eid``.
+ Defaults to False.
+
+ .. versionadded:: 1.1
+
+ Returns:
+ Union[BaseElement, None]: found element
+ """
+ if eid is not None and not literal:
+ eid = eid.strip()[4:-1] if eid.startswith("url(") else eid
+ eid = eid.lstrip("#")
+ return self.getElement(f'//{elm}[@id="{eid}"]')
+
+ def getElementByName(self, name, elm="*"): # pylint: disable=invalid-name
+ """Get an element by it's inkscape:label (aka name)"""
+ return self.getElement(f'//{elm}[@inkscape:label="{name}"]')
+
+ def getElementsByClass(self, class_name): # pylint: disable=invalid-name
+ """Get elements by it's class name"""
+
+ return self.xpath(ConditionalRule(f".{class_name}").to_xpath())
+
+ def getElementsByHref(
+ self, eid: str, attribute="xlink:href"
+ ): # pylint: disable=invalid-name
+ """Get elements that reference the element with id eid.
+
+ Args:
+ eid (str): _description_
+ attribute (str, optional): Attribute to look for.
+ Valid choices: "xlink:href", "mask", "clip-path".
+ Defaults to "xlink:href".
+
+ .. versionadded:: 1.2
+
+ Returns:
+ Any: list of elements
+ """
+ if attribute == "xlink:href":
+ return self.xpath(f'//*[@xlink:href="#{eid}"]')
+ elif attribute == "mask":
+ return self.xpath(f'//*[@mask="url(#{eid})"]')
+ elif attribute == "clip-path":
+ return self.xpath(f'//*[@clip-path="url(#{eid})"]')
+
+ def getElementsByStyleUrl(self, eid, style=None): # pylint: disable=invalid-name
+ """Get elements by a style attribute url"""
+ url = f"url(#{eid})"
+ if style is not None:
+ url = style + ":" + url
+ return self.xpath(f'//*[contains(@style,"{url}")]')
+
+ @property
+ def name(self):
+ """Returns the Document Name"""
+ return self.get("sodipodi:docname", "")
+
+ @property
+ def namedview(self):
+ """Return the sp namedview meta information element"""
+ return self.get_or_create("//sodipodi:namedview", prepend=True)
+
+ @property
+ def metadata(self):
+ """Return the svg metadata meta element container"""
+ return self.get_or_create("//svg:metadata", prepend=True)
+
+ @property
+ def defs(self):
+ """Return the svg defs meta element container"""
+ return self.get_or_create("//svg:defs", prepend=True)
+
+ def get_viewbox(self):
+ """Parse and return the document's viewBox attribute"""
+ try:
+ ret = [
+ float(unit) for unit in re.split(r",\s*|\s+", self.get("viewBox", "0"))
+ ]
+ except ValueError:
+ ret = ""
+ if len(ret) != 4:
+ return [0, 0, 0, 0]
+ return ret
+
+ @property
+ def viewbox_width(self) -> float: # getDocumentWidth(self):
+ """Returns the width of the `user coordinate system
+ <https://www.w3.org/TR/SVG2/coords.html#Introduction>`_ in user units, i.e.
+ the width of the viewbox, as defined in the SVG file. If no viewbox is defined,
+ the value of the width attribute is returned. If the height is not defined,
+ returns 0.
+
+ .. versionadded:: 1.2"""
+ return self.get_viewbox()[2] or self.viewport_width
+
+ @property
+ def viewport_width(self) -> float:
+ """Returns the width of the `viewport coordinate system
+ <https://www.w3.org/TR/SVG2/coords.html#Introduction>`_ in user units, i.e. the
+ width attribute of the svg element converted to px
+
+ .. versionadded:: 1.2"""
+ return self.to_dimensionless(self.get("width")) or self.get_viewbox()[2]
+
+ @property
+ def viewbox_height(self) -> float: # getDocumentHeight(self):
+ """Returns the height of the `user coordinate system
+ <https://www.w3.org/TR/SVG2/coords.html#Introduction>`_ in user units, i.e. the
+ height of the viewbox, as defined in the SVG file. If no viewbox is defined, the
+ value of the height attribute is returned. If the height is not defined,
+ returns 0.
+
+ .. versionadded:: 1.2"""
+ return self.get_viewbox()[3] or self.viewport_height
+
+ @property
+ def viewport_height(self) -> float:
+ """Returns the width of the `viewport coordinate system
+ <https://www.w3.org/TR/SVG2/coords.html#Introduction>`_ in user units, i.e. the
+ height attribute of the svg element converted to px
+
+ .. versionadded:: 1.2"""
+ return self.to_dimensionless(self.get("height")) or self.get_viewbox()[3]
+
+ @property
+ def scale(self):
+ """Returns the ratio between the viewBox width and the page width.
+
+ .. versionchanged:: 1.2
+ Previously, the scale as shown by the document properties was computed,
+ but the computation of this in core Inkscape changed in Inkscape 1.2, so
+ this was moved to :attr:`inkscape_scale`."""
+ return self._base_scale()
+
+ @property
+ def inkscape_scale(self):
+ """Returns the ratio between the viewBox width (in width/height units) and the
+ page width, which is displayed as "scale" in the Inkscape document
+ properties.
+
+ .. versionadded:: 1.2"""
+
+ viewbox_unit = (
+ parse_unit(self.get("width")) or parse_unit(self.get("height")) or (0, "px")
+ )[1]
+ return self._base_scale(viewbox_unit)
+
+ def _base_scale(self, unit="px"):
+ """Returns what Inkscape shows as "user units per `unit`"
+
+ .. versionadded:: 1.2"""
+ try:
+ scale_x = (
+ self.to_dimensional(self.viewport_width, unit) / self.viewbox_width
+ )
+ scale_y = (
+ self.to_dimensional(self.viewport_height, unit) / self.viewbox_height
+ )
+ value = max([scale_x, scale_y])
+ return 1.0 if value == 0 else value
+ except (ValueError, ZeroDivisionError):
+ return 1.0
+
+ @property
+ def equivalent_transform_scale(self) -> float:
+ """Return the scale of the equivalent transform of the svg tag, as defined by
+ https://www.w3.org/TR/SVG2/coords.html#ComputingAViewportsTransform
+ (highly simplified)
+
+ .. versionadded:: 1.2"""
+ return self.scale
+
+ @property
+ def unit(self):
+ """Returns the unit used for in the SVG document.
+ In the case the SVG document lacks an attribute that explicitly
+ defines what units are used for SVG coordinates, it tries to calculate
+ the unit from the SVG width and viewBox attributes.
+ Defaults to 'px' units."""
+ if not hasattr(self, "_unit"):
+ self._unit = "px" # Default is px
+ viewbox = self.get_viewbox()
+ if viewbox and set(viewbox) != {0}:
+ self._unit = discover_unit(self.get("width"), viewbox[2], default="px")
+ return self._unit
+
+ @property
+ def document_unit(self):
+ """Returns the display unit (Inkscape-specific attribute) of the document
+
+ .. versionadded:: 1.2"""
+ return self.namedview.get("inkscape:document-units", "px")
+
+ @property
+ def stylesheets(self):
+ """Get all the stylesheets, bound together to one, (for reading)"""
+ sheets = StyleSheets(self)
+ for node in self.xpath("//svg:style"):
+ sheets.append(node.stylesheet())
+ return sheets
+
+ @property
+ def stylesheet(self):
+ """Return the first stylesheet or create one if needed (for writing)"""
+ for sheet in self.stylesheets:
+ return sheet
+
+ style_node = StyleElement()
+ self.defs.append(style_node)
+ return style_node.stylesheet()
+
+
+def width(self):
+ """Use :func:`viewport_width` instead"""
+ return self.viewport_width
+
+
+def height(self):
+ """Use :func:`viewport_height` instead"""
+ return self.viewport_height
+
+
+SvgDocumentElement.width = property(deprecate(width, "1.2"))
+SvgDocumentElement.height = property(deprecate(height, "1.2"))
diff --git a/share/extensions/inkex/elements/_text.py b/share/extensions/inkex/elements/_text.py
new file mode 100644
index 0000000..8fc1756
--- /dev/null
+++ b/share/extensions/inkex/elements/_text.py
@@ -0,0 +1,202 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@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 text based element classes interface.
+
+Because text is not rendered at all, no information about a text's path
+size or actual location can be generated yet.
+"""
+from __future__ import annotations
+
+from tempfile import TemporaryDirectory
+
+from ..interfaces.IElement import BaseElementProtocol
+from ..paths import Path
+from ..transforms import Transform, BoundingBox
+from ..command import inkscape, write_svg
+from ._base import BaseElement, ShapeElement
+from ._polygons import PathElementBase
+
+
+class TextBBMixin: # pylint: disable=too-few-public-methods
+ """Mixin to query the bounding box from Inkscape
+
+ .. versionadded:: 1.2"""
+
+ def get_inkscape_bbox(self: BaseElementProtocol) -> BoundingBox:
+ """Query the bbbox of a single object. This calls the Inkscape command,
+ so it is rather slow to use in a loop."""
+ with TemporaryDirectory(prefix="inkscape-command") as tmpdir:
+ svg_file = write_svg(self.root, tmpdir, "input.svg")
+ out = inkscape(svg_file, "-X", "-Y", "-W", "-H", query_id=self.get_id())
+ out = list(map(self.root.viewport_to_unit, out.splitlines()))
+ if len(out) != 4:
+ raise ValueError("Error: Bounding box computation failed")
+ return BoundingBox.new_xywh(*out)
+
+
+class FlowRegion(ShapeElement):
+ """SVG Flow Region (SVG 2.0)"""
+
+ tag_name = "flowRegion"
+
+ def get_path(self):
+ # This ignores flowRegionExcludes
+ return sum([child.path for child in self], Path())
+
+
+class FlowRoot(ShapeElement, TextBBMixin):
+ """SVG Flow Root (SVG 2.0)"""
+
+ tag_name = "flowRoot"
+
+ @property
+ def region(self):
+ """Return the first flowRegion in this flowRoot"""
+ return self.findone("svg:flowRegion")
+
+ def get_path(self):
+ region = self.region
+ return region.get_path() if region is not None else Path()
+
+
+class FlowPara(ShapeElement):
+ """SVG Flow Paragraph (SVG 2.0)"""
+
+ tag_name = "flowPara"
+
+ def get_path(self):
+ # XXX: These empty paths mean the bbox for text elements will be nothing.
+ return Path()
+
+
+class FlowDiv(ShapeElement):
+ """SVG Flow Div (SVG 2.0)"""
+
+ tag_name = "flowDiv"
+
+ def get_path(self):
+ # XXX: These empty paths mean the bbox for text elements will be nothing.
+ return Path()
+
+
+class FlowSpan(ShapeElement):
+ """SVG Flow Span (SVG 2.0)"""
+
+ tag_name = "flowSpan"
+
+ def get_path(self):
+ # XXX: These empty paths mean the bbox for text elements will be nothing.
+ return Path()
+
+
+class TextElement(ShapeElement, TextBBMixin):
+ """A Text element"""
+
+ tag_name = "text"
+ x = property(lambda self: self.to_dimensionless(self.get("x", 0)))
+ y = property(lambda self: self.to_dimensionless(self.get("y", 0)))
+
+ def get_path(self):
+ return Path()
+
+ def tspans(self):
+ """Returns all children that are tspan elements"""
+ return self.findall("svg:tspan")
+
+ def get_text(self, sep="\n"):
+ """Return the text content including tspans"""
+ nodes = [self] + list(self.tspans())
+ return sep.join([elem.text for elem in nodes if elem.text is not None])
+
+ def shape_box(self, transform=None):
+ """
+ Returns a horrible bounding box that just contains the coord points
+ of the text without width or height (which is impossible to calculate)
+ """
+ effective_transform = Transform(transform) @ self.transform
+ x, y = effective_transform.apply_to_point((self.x, self.y))
+ bbox = BoundingBox(x, y)
+ for tspan in self.tspans():
+ bbox += tspan.bounding_box(effective_transform)
+ return bbox
+
+
+class TextPath(ShapeElement, TextBBMixin):
+ """A textPath element"""
+
+ tag_name = "textPath"
+
+ def get_path(self):
+ return Path()
+
+
+class Tspan(ShapeElement, TextBBMixin):
+ """A tspan text element"""
+
+ tag_name = "tspan"
+ x = property(lambda self: self.to_dimensionless(self.get("x", 0)))
+ y = property(lambda self: self.to_dimensionless(self.get("y", 0)))
+
+ @classmethod
+ def superscript(cls, text):
+ """Adds a superscript tspan element"""
+ return cls(text, style="font-size:65%;baseline-shift:super")
+
+ def get_path(self):
+ return Path()
+
+ def shape_box(self, transform=None):
+ """
+ Returns a horrible bounding box that just contains the coord points
+ of the text without width or height (which is impossible to calculate)
+ """
+ effective_transform = Transform(transform) @ self.transform
+ x1, y1 = effective_transform.apply_to_point((self.x, self.y))
+ fontsize = self.to_dimensionless(self.style.get("font-size", "12px"))
+ x2 = self.x + 0 # XXX This is impossible to calculate!
+ y2 = self.y + float(fontsize)
+ x2, y2 = effective_transform.apply_to_point((x2, y2))
+ return BoundingBox((x1, x2), (y1, y2))
+
+
+class SVGfont(BaseElement):
+ """An svg font element"""
+
+ tag_name = "font"
+
+
+class FontFace(BaseElement):
+ """An svg font font-face element"""
+
+ tag_name = "font-face"
+
+
+class Glyph(PathElementBase):
+ """An svg font glyph element"""
+
+ tag_name = "glyph"
+
+
+class MissingGlyph(BaseElement):
+ """An svg font missing-glyph element"""
+
+ tag_name = "missing-glyph"
diff --git a/share/extensions/inkex/elements/_use.py b/share/extensions/inkex/elements/_use.py
new file mode 100644
index 0000000..78acb61
--- /dev/null
+++ b/share/extensions/inkex/elements/_use.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@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.
+#
+"""
+Interface for the Use and Symbol elements
+"""
+
+from ..transforms import Transform
+
+from ._groups import Group, GroupBase
+from ._base import ShapeElement
+
+
+class Symbol(GroupBase):
+ """SVG symbol element"""
+
+ tag_name = "symbol"
+
+
+class Use(ShapeElement):
+ """A 'use' element that links to another in the document"""
+
+ tag_name = "use"
+
+ @classmethod
+ def new(cls, elem, x, y, **attrs): # pylint: disable=arguments-differ
+ ret = super().new(x=x, y=y, **attrs)
+ ret.href = elem
+ return ret
+
+ def get_path(self):
+ """Returns the path of the cloned href plus any transformation"""
+ path = self.href.path
+ path.transform(self.href.transform)
+ return path
+
+ def effective_style(self):
+ """Href's style plus this object's own styles"""
+ style = self.href.effective_style()
+ style.update(self.style)
+ return style
+
+ def unlink(self):
+ """Unlink this clone, replacing it with a copy of the original"""
+ copy = self.href.copy()
+ if isinstance(copy, Symbol):
+ group = Group(**copy.attrib)
+ group.extend(copy)
+ copy = group
+ copy.transform = self.transform @ copy.transform
+ copy.transform.add_translate(
+ self.to_dimensionless(self.get("x", 0)),
+ self.to_dimensionless(self.get("y", 0)),
+ )
+ copy.style = self.style + copy.style
+ self.replace_with(copy)
+ copy.set_random_ids()
+ return copy
+
+ def shape_box(self, transform=None):
+ """BoundingBox of the unclipped shape
+
+ .. versionadded:: 1.1"""
+ effective_transform = Transform(transform) @ self.transform
+ return self.href.bounding_box(effective_transform)
diff --git a/share/extensions/inkex/elements/_utils.py b/share/extensions/inkex/elements/_utils.py
new file mode 100644
index 0000000..56e1e12
--- /dev/null
+++ b/share/extensions/inkex/elements/_utils.py
@@ -0,0 +1,144 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2021 Martin Owens <doctormo@gmail.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.
+#
+"""
+Useful utilities specifically for elements (that aren't base classes)
+
+.. versionadded:: 1.1
+ Most of the methods in this module were moved from inkex.utils.
+"""
+
+from collections import defaultdict
+import re
+
+# a dictionary of all of the xmlns prefixes in a standard inkscape doc
+NSS = {
+ "sodipodi": "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd",
+ "cc": "http://creativecommons.org/ns#",
+ "ccOLD": "http://web.resource.org/cc/",
+ "svg": "http://www.w3.org/2000/svg",
+ "dc": "http://purl.org/dc/elements/1.1/",
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ "inkscape": "http://www.inkscape.org/namespaces/inkscape",
+ "xlink": "http://www.w3.org/1999/xlink",
+ "xml": "http://www.w3.org/XML/1998/namespace",
+}
+SSN = dict((b, a) for (a, b) in NSS.items())
+
+
+def addNS(tag, ns=None): # pylint: disable=invalid-name
+ """Add a known namespace to a name for use with lxml"""
+ if tag.startswith("{") and ns:
+ _, tag = removeNS(tag)
+ if not tag.startswith("{"):
+ tag = tag.replace("__", ":")
+ if ":" in tag:
+ (ns, tag) = tag.rsplit(":", 1)
+ ns = NSS.get(ns, None) or ns
+ if ns is not None:
+ return f"{{{ns}}}{tag}"
+ return tag
+
+
+def removeNS(name): # pylint: disable=invalid-name
+ """The reverse of addNS, finds any namespace and returns tuple (ns, tag)"""
+ if name[0] == "{":
+ (url, tag) = name[1:].split("}", 1)
+ return SSN.get(url, "svg"), tag
+ if ":" in name:
+ return name.rsplit(":", 1)
+ return "svg", name
+
+
+def splitNS(name): # pylint: disable=invalid-name
+ """Like removeNS, but returns a url instead of a prefix"""
+ (prefix, tag) = removeNS(name)
+ return (NSS[prefix], tag)
+
+
+def natural_sort_key(key, _nsre=re.compile("([0-9]+)")):
+ """Helper for a natural sort, see
+ https://stackoverflow.com/a/16090640/3298143"""
+ return [int(text) if text.isdigit() else text.lower() for text in _nsre.split(key)]
+
+
+class ChildToProperty(property):
+ """Use when you have a singleton child element who's text
+ content is the canonical value for the property"""
+
+ def __init__(self, tag, prepend=False):
+ super().__init__()
+ self.tag = tag
+ self.prepend = prepend
+
+ def __get__(self, obj, klass=None):
+ elem = obj.findone(self.tag)
+ return elem.text if elem is not None else None
+
+ def __set__(self, obj, value):
+ elem = obj.get_or_create(self.tag, prepend=self.prepend)
+ elem.text = value
+
+ def __delete__(self, obj):
+ obj.remove_all(self.tag)
+
+ @property
+ def __doc__(self):
+ return f"Get, set or delete the {self.tag} property."
+
+
+class CloningVat:
+ """
+ When modifying defs, sometimes we want to know if every backlink would have
+ needed changing, or it was just some of them.
+
+ This tracks the def elements, their promises and creates clones if needed.
+ """
+
+ def __init__(self, svg):
+ self.svg = svg
+ self.tracks = defaultdict(set)
+ self.set_ids = defaultdict(list)
+
+ def track(self, elem, parent, set_id=None, **kwargs):
+ """Track the element and connected parent"""
+ elem_id = elem.get("id")
+ parent_id = parent.get("id")
+ self.tracks[elem_id].add(parent_id)
+ self.set_ids[elem_id].append((set_id, kwargs))
+
+ def process(self, process, types=(), make_clones=True, **kwargs):
+ """
+ Process each tracked item if the backlinks match the parents
+
+ Optionally make clones, process the clone and set the new id.
+ """
+ for elem_id in list(self.tracks):
+ parents = self.tracks[elem_id]
+ elem = self.svg.getElementById(elem_id)
+ backlinks = {blk.get("id") for blk in elem.backlinks(*types)}
+ if backlinks == parents:
+ # No need to clone, we're processing on-behalf of all parents
+ process(elem, **kwargs)
+ elif make_clones:
+ clone = elem.copy()
+ elem.getparent().append(clone)
+ clone.set_random_id()
+ for update, upkw in self.set_ids.get(elem_id, ()):
+ update(elem.get("id"), clone.get("id"), **upkw)
+ process(clone, **kwargs)