diff options
Diffstat (limited to 'share/extensions/inkex/elements')
22 files changed, 1925 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..f5f0d65 --- /dev/null +++ b/share/extensions/inkex/elements/__init__.py @@ -0,0 +1,19 @@ +""" +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 ._base import SVG_PARSER, load_svg, ShapeElement, BaseElement +from ._svg import SvgDocumentElement +from ._groups import Group, Layer, Anchor, Marker, ClipPath +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 +from ._filters import Filter, Pattern, Gradient, LinearGradient, RadialGradient, \ + PathEffect, Stop, MeshGradient, MeshRow, MeshPatch +from ._image import Image diff --git a/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..718ece7 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..06dd762 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..a1d3a70 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..35305d8 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..1c2849c --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..807de7b --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..270e082 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..6272318 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..800e778 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..1e898f2 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..7fb1511 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc diff --git a/share/extensions/inkex/elements/_base.py b/share/extensions/inkex/elements/_base.py new file mode 100644 index 0000000..e922908 --- /dev/null +++ b/share/extensions/inkex/elements/_base.py @@ -0,0 +1,514 @@ +# -*- 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 collections import defaultdict +from copy import deepcopy +from lxml import etree + +from ..paths import Path +from ..styles import Style, AttrFallbackStyle, Classes +from ..transforms import Transform, BoundingBox +from ..utils import PY3, NSS, addNS, removeNS, splitNS, FragmentError +from ..utils import InitSubClassPy3 + +try: + from typing import overload, DefaultDict, Type, Any, List, Tuple, Union, Optional # pylint: disable=unused-import +except ImportError: + overload = lambda x: x + +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. + """ + # (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) + + def lookup(self, doc, element): # pylint: disable=unused-argument + """Lookup called by lxml when assigning elements their object class""" + try: + for cls in reversed(self.lookup_table[splitNS(element.tag)]): + if cls._is_class_element(element): # pylint: disable=protected-access + return cls + 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 BaseElement + + +SVG_PARSER = etree.XMLParser(huge_tree=True, strip_cdata=False) +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'<')): + return etree.ElementTree(etree.fromstring(stream, parser=SVG_PARSER)) + return etree.parse(stream, parser=SVG_PARSER) + +class BaseElement(etree.ElementBase): + """Provide automatic namespaces to all calls""" + # Included for python2 support (this branch is for 1.0.x only) + __metaclass__ = InitSubClassPy3 + @classmethod + def __init_subclass__(cls): + if cls.tag_name: + NodeBasedLookup.register_class(cls) + + @classmethod + def _is_class_element(cls, el): # type: (etree.Element) -> bool + """Hook to do more restrictive check in addition to (ns,tag) match""" + 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(etree.ElementBase, self).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]) + PARSER = SVG_PARSER + WRAPPED_ATTRS = ( + # (prop_name, [optional: attr_name], cls) + ('transform', Transform), + ('style', Style), + ('classes', 'class', Classes), + ) # type: Tuple[Tuple[Any, ...], ...] + + # 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 dict([(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 dict([(row[0], (row[-2], row[-1])) for row in self.WRAPPED_ATTRS]) + + typename = property(lambda self: type(self).__name__) + xml_path = property(lambda self: self.getroottree().getpath(self)) + + 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) + setattr(self, name, value) + return value + raise AttributeError("Can't find attribute {}.{}".format(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(BaseElement, self).__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(BaseElement, self).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) if PY3 else unicode(value) # pylint: disable=undefined-variable + super(BaseElement, self).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 + + 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 + from ..base import SvgOutputMixin + svg = SvgOutputMixin.get_template(width=0, height=0).getroot() + svg.append(self.copy()) + return svg.tostring().split(b'>\n ', 1)[-1][:-6] + + def description(self, text): + """Set the desc element with text""" + from ._meta import Desc + desc = self.add(Desc()) + desc.text = text + + def set_random_id(self, prefix=None, size=4, backlinks=False): + """Sets the id attribute if it is not already set.""" + prefix = str(self) if prefix is None else prefix + self.set_id(self.root.get_unique_id(prefix, size=size), backlinks=backlinks) + + def set_random_ids(self, prefix=None, levels=-1, backlinks=False): + """Same as set_random_id, but will apply also to children""" + self.set_random_id(prefix=prefix, backlinks=backlinks) + if levels != 0: + for child in self: + if hasattr(child, 'set_random_ids'): + child.set_random_ids(prefix=prefix, levels=levels-1, backlinks=backlinks) + + def get_id(self): + """Get the id for the element, will set a new unique id if not set""" + if 'id' not in self.attrib: + self.set_random_id(self.TAG) + return self.get('id') + + 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 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""" + if self.getparent() is not None: + return self.getparent().root + from ._svg import SvgDocumentElement + if not isinstance(self, SvgDocumentElement): + raise FragmentError("Element fragment does not have a document root!") + return self + + def get_or_create(self, xpath, nodeclass, prepend=False): + """Get or create the given xpath, pre/append new node if not found.""" + node = self.findone(xpath) + if node is None: + 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""" + from ._selected import ElementList + return ElementList(self.root, self._descendants()) + + def _descendants(self): + yield self + for child in self: + if hasattr(child, '_descendants'): + for item in child._descendants(): # pylint: disable=protected-access + yield item + + def ancestors(self, elem=None, stop_at=()): + """ + Walk the parents and yield all the ancestor elements, parent first + + If elem is provided, it will stop at the last common ancestor. + If stop_at is provided, it will stop at the first parent that is in this list. + """ + from ._selected import ElementList + 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().values()) + parent = self.getparent() + if parent is not None: + yield parent + if parent not in stop_at: + for item in parent._ancestors(elem=elem, stop_at=stop_at): # pylint: disable=protected-access + yield item + + 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(BaseElement, self).xpath(pattern, namespaces=namespaces) + + def findall(self, pattern, namespaces=NSS): # pylint: disable=dangerous-default-value + """Wrap findall call and add svg namespaces""" + return super(BaseElement, self).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""" + 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""" + elem = self.copy() + self.addnext(elem) + elem.set_random_id() + 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('}')[-1] + + @property + def href(self): + """Returns the referred-to element if available""" + 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) + + def fallback_style(self, move=False): + """Get styles falling back to element attributes""" + return AttrFallbackStyle(self, move=move) + + @property + def label(self): + """Returns the inkscape label""" + return self.get('inkscape:label', None) + + label = label.setter(lambda self, value: self.set('inkscape:label', str(value))) # type: ignore + + +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)""" + 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', 'url(#' + elem.get_id() + ')') + + def get_path(self): + """Generate a path for this object which can inform the bounding box""" + raise NotImplementedError("Path should be provided by svg elem {}.".format(self.typename)) + + def set_path(self, path): + """Set the path for this object (if possible)""" + raise AttributeError( + "Path can not be set on this element: {} <- {}.".format(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 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, ShapeElement): + return parent.composed_transform() * self.transform + return self.transform + + def composed_style(self): + """Calculate the final styles applied to this element""" + parent = self.getparent() + if parent is not None and isinstance(parent, ShapeElement): + return parent.composed_style() + self.style + return self.style + + def cascaded_style(self): + """Add all cascaded styles, do not write to this Style object""" + ret = Style() + for style in self.root.stylesheets.lookup(self.get('id')): + ret += style + return ret + self.style + + 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 (adjusted for its 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""" + 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""" + if self.style.get('display', '') == 'none': + return False + if not float(self.style.get('opacity', 1.0)): + return False + return True diff --git a/share/extensions/inkex/elements/_filters.py b/share/extensions/inkex/elements/_filters.py new file mode 100644 index 0000000..ff6883f --- /dev/null +++ b/share/extensions/inkex/elements/_filters.py @@ -0,0 +1,276 @@ +# -*- 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 lxml import etree +from copy import deepcopy + +from ..utils import addNS +from ..transforms import Transform +from ..tween import interpcoord, interp +from ..units import convert_unit + +from ..styles import Style +from ._base import BaseElement + + +try: + from typing import overload, Iterable, List, Tuple, Union, Optional # pylint: disable=unused-import +except ImportError: + overload = lambda x: x + + +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): + pass + + class Blend(Primitive): + tag_name = 'feBlend' + + class ColorMatrix(Primitive): + tag_name = 'feColorMatrix' + + class ComponentTransfer(Primitive): + tag_name = 'feComponentTransfer' + + class Composite(Primitive): + tag_name = 'feComposite' + + class ConvolveMatrix(Primitive): + tag_name = 'feConvolveMatrix' + + class DiffuseLighting(Primitive): + tag_name = 'feDiffuseLighting' + + class DisplacementMap(Primitive): + tag_name = 'feDisplacementMap' + + class Flood(Primitive): + tag_name = 'feFlood' + + class GaussianBlur(Primitive): + tag_name = 'feGaussianBlur' + + class Image(Primitive): + tag_name = 'feImage' + + class Merge(Primitive): + tag_name = 'feMerge' + + class Morphology(Primitive): + tag_name = 'feMorphology' + + class Offset(Primitive): + tag_name = 'feOffset' + + class SpecularLighting(Primitive): + tag_name = 'feSpecularLighting' + + class Tile(Primitive): + tag_name = 'feTile' + + class Turbulence(Primitive): + tag_name = 'feTurbulence' + + +class Stop(BaseElement): + tag_name = 'stop' + + @property + def offset(self): + # type: () -> float + return self.get('offset') + + @offset.setter + def offset(self, number): + self.set('offset', number) + + def interpolate(self, other, fraction): + newstop = Stop() + newstop.style = self.style.interpolate(other.style, fraction) + newstop.offset = interpcoord(float(self.offset), float(other.offset), fraction) + return newstop + + +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),) + + orientation_attributes = () # type: Tuple[str, ...] + + @property + def stops(self): + """Return an ordered list of own or linked stop nodes""" + gradcolor = self.href if isinstance(self.href, LinearGradient) else self + return sorted([child for child in gradcolor if isinstance(child, Stop)] + , key=lambda x: float(x.offset)) + + @property + def stop_offsets(self): + # type: () -> List[float] + """Return a list of own or linked stop offsets""" + 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""" + return [child.style for child in self.stops] + + def remove_orientation(self): + """Remove all orientation attributes from this element""" + for attr in self.orientation_attributes: + self.pop(attr) + + def interpolate(self, other, fraction): # type: (LinearGradient, float) -> LinearGradient + """Interpolate with another gradient.""" + if self.tag_name != other.tag_name: + return self + newgrad = self.copy() + + # interpolate transforms + newtransform = self.gradientTransform.interpolate(other.gradientTransform, fraction) + newgrad.gradientTransform = newtransform + + # interpolate orientation + for attr in self.orientation_attributes: + newattr = interpcoord(convert_unit(self.get(attr), 'px'), convert_unit(other.get(attr), 'px'), fraction) + newgrad.set(attr, newattr) + + # interpolate stops + if self.href is not None and self.href is other.href: + # both gradients link to the same stops + pass + else: + # gradients might have different stops + newoffsets = sorted(self.stop_offsets + other.stop_offsets[1:-1]) + func = lambda x,y,f: x.interpolate(y, f) + sstops = interp(self.stop_offsets, list(self.stops), newoffsets, func) + ostops = interp(other.stop_offsets, list(other.stops), newoffsets, func) + newstops = [s1.interpolate(s2, fraction) for s1, s2 in zip(sstops, ostops)] + newgrad.remove_all(Stop) + newgrad.add(*newstops) + return newgrad + + def stops_and_orientation(self): + """Return a copy of all the stops in this gradient""" + stops = self.copy() + stops.remove_orientation() + orientation = self.copy() + orientation.remove_all(Stop) + return stops, orientation + + +class LinearGradient(Gradient): + tag_name = 'linearGradient' + orientation_attributes = ('x1', 'y1', 'x2', 'y2') + + def apply_transform(self): # type: () -> None + """Apply transform to orientation points and set it to identity.""" + trans = self.pop('gradientTransform') + p1 = (convert_unit(self.get('x1'), 'px'), convert_unit(self.get('y1'), 'px')) + p2 = (convert_unit(self.get('x2'), 'px'), convert_unit(self.get('y2'), 'px')) + p1t = trans.apply_to_point(p1) + p2t = trans.apply_to_point(p2) + self.update(x1=p1t[0], y1=p1t[1], x2=p2t[0], y2=p2t[1]) + + +class RadialGradient(Gradient): + tag_name = 'radialGradient' + orientation_attributes = ('cx', 'cy', 'fx', 'fy', 'r') + + def apply_transform(self): # type: () -> None + """Apply transform to orientation points and set it to identity.""" + trans = self.pop('gradientTransform') + p1 = (convert_unit(self.get('cx'), 'px'), convert_unit(self.get('cy'), 'px')) + p2 = (convert_unit(self.get('fx'), 'px'), convert_unit(self.get('fy'), 'px')) + p1t = trans.apply_to_point(p1) + p2t = trans.apply_to_point(p2) + self.update(cx=p1t[0], cy=p1t[1], fx=p2t[0], fy=p2t[1]) + +class PathEffect(BaseElement): + """Inkscape LPE element""" + tag_name = 'inkscape:path-effect' + + +class MeshGradient(Gradient): + """Usable MeshGradient XML base class""" + 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 = 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""" + tag_name = 'meshrow' + +class MeshPatch(BaseElement): + """Each column or 'patch' in a mesh gradient""" + 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..52bd073 --- /dev/null +++ b/share/extensions/inkex/elements/_groups.py @@ -0,0 +1,112 @@ +# -*- 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 ..utils import addNS +from ..transforms import Transform + +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(Group, cls).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, el): + # type: (etree.Element) -> bool + return el.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(Anchor, cls).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' diff --git a/share/extensions/inkex/elements/_image.py b/share/extensions/inkex/elements/_image.py new file mode 100644 index 0000000..efd00d3 --- /dev/null +++ b/share/extensions/inkex/elements/_image.py @@ -0,0 +1,27 @@ +# -*- 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..f8107ad --- /dev/null +++ b/share/extensions/inkex/elements/_meta.py @@ -0,0 +1,147 @@ +# -*- 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. +""" + +import math + +from lxml import etree + +from ..styles import StyleSheet +from ..transforms import Vector2d + +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.unittouu(self.get('inkscape:cx') or 0), + self.root.unittouu(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""" + if orient is True: + elem = Guide().move_to(0, position, (0, 1)) + elif orient is False: + elem = Guide().move_to(position, 0, (1, 0)) + if name: + elem.set('inkscape:label', str(name)) + return self.add(elem) + + +class Guide(BaseElement): + """An inkscape guide""" + tag_name = 'sodipodi:guide' + + is_horizontal = property(lambda self: self.get('orientation').startswith('0,') and not + self.get('orientation') == '0,0') + is_vertical = property(lambda self: self.get('orientation').endswith(',0')) + point = property(lambda self: Vector2d(self.get('position'))) + + @classmethod + def new(cls, pos_x, pos_y, angle, **attrs): + guide = super(Guide, cls).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. + """ + self.set('position', "{:g},{:g}".format(float(pos_x), float(pos_y))) + 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 = "{:g},{:g}".format(*angle) + + self.set('orientation', angle) + return self + +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' diff --git a/share/extensions/inkex/elements/_polygons.py b/share/extensions/inkex/elements/_polygons.py new file mode 100644 index 0000000..0bcbc38 --- /dev/null +++ b/share/extensions/inkex/elements/_polygons.py @@ -0,0 +1,231 @@ +# -*- 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 ..paths import Path +from ..transforms import Transform, ImmutableVector2d, Vector2d +from ..utils import addNS +from ..units import convert_unit + +from ._base import ShapeElement + +class PathElementBase(ShapeElement): + """Base element for path based shapes""" + get_path = lambda self: self.get('d') + + @classmethod + def new(cls, path, **attrs): + return super(PathElementBase, cls).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' + + @classmethod + def arc(cls, center, rx, ry=None, **kw): # pylint: disable=invalid-name + """Generate a sodipodi arc (special type)""" + 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') + for name, value in others: + if value is not None: + elem.set('sodipodi:'+name, str(value).lower()) + return elem + + @classmethod + def star(cls, center, radi, sides, rounded=None): + """Generate a sodipodi start (special type)""" + elem = cls() + elem.set('sodipodi:cx', center[0]) + elem.set('sodipodi:cy', center[1]) + elem.set('sodipodi:r1', radi[0]) + elem.set('sodipodi:r2', radi[1]) + elem.set('sodipodi:arg1', 0.85) + elem.set('sodipodi:arg2', 1.3) + elem.set('sodipodi:sides', sides) + elem.set('inkscape:rounded', rounded) + elem.set('sodipodi:type', 'star') + 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 = ['{:g},{:g}'.format(x, y) 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: 'M' + self.get('points') + ' Z' + + +class Line(ShapeElement): + """A line segment connecting two points""" + tag_name = 'line' + get_path = lambda self: 'M{0[x1]},{0[y1]} L{0[x2]},{0[y2]} Z'.format(self.attrib) + + @classmethod + def new(cls, start, end, **attrs): + start = Vector2d(start) + end = Vector2d(end) + return super(Line, cls).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: convert_unit(self.get('x', '0'), 'px')) + top = property(lambda self: convert_unit(self.get('y', '0'), 'px')) + right = property(lambda self: self.left + self.width) + bottom = property(lambda self: self.top + self.height) + width = property(lambda self: convert_unit(self.get('width', '0'), 'px')) + height = property(lambda self: convert_unit(self.get('height', '0'), 'px')) + rx = property(lambda self: convert_unit(self.get('rx', self.get('ry', 0.0)), 'px')) + ry = property(lambda self: convert_unit(self.get('ry', self.get('rx', 0.0)), 'px')) # 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 + return 'M {1},{0.top}'\ + 'L {2},{0.top} A {0.rx},{0.ry} 0 0 1 {0.right},{3}'\ + 'L {0.right},{4} A {0.rx},{0.ry} 0 0 1 {2},{0.bottom}'\ + 'L {1},{0.bottom} A {0.rx},{0.ry} 0 0 1 {0.left},{4}'\ + 'L {0.left},{3} A {0.rx},{0.ry} 0 0 1 {1},{0.top} z'\ + .format(self, self.left + rx, self.right - rx, self.top + ry, self.bottom - ry) + + return 'M {0.left},{0.top} h{0.width}v{0.height}h{1} z'.format(self, -self.width) + + +class Rectangle(RectangleBase): + """Provide a useful extension for rectangle elements""" + tag_name = 'rect' + + @classmethod + def new(cls, left, top, width, height, **attrs): + return super(Rectangle, cls).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 ImmutableVector2d(convert_unit(self.get('cx', '0'), 'px'), convert_unit(self.get('cy', '0'), 'px')) + + @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(EllipseBase, cls).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): + return convert_unit(self.get('r', '0'), 'px') + + @radius.setter + def radius(self, value): + self.set("r", value) + + def _rxry(self): + r = self.radius + return Vector2d(r, r) + + +class Ellipse(EllipseBase): + """Provide a similar extension to the Circle interface""" + tag_name = 'ellipse' + + @property + def radius(self): + return ImmutableVector2d(convert_unit(self.get('rx', '0'), 'px'), convert_unit(self.get('ry', '0'), 'px')) + + @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..c09c526 --- /dev/null +++ b/share/extensions/inkex/elements/_selected.py @@ -0,0 +1,159 @@ +# -*- 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. +""" + +from collections import OrderedDict + +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(ElementList, self).__init__() + if _iter: + self.set(*list(_iter)) + + def __getitem__(self, key): + return super(ElementList, self).__getitem__(self._to_key(key)) + + def __contains__(self, key): + return super(ElementList, self).__contains__(self._to_key(key)) + + def __setitem__(self, orig_key, elem): + from ._base import BaseElement + if orig_key != elem and orig_key != elem.get('id'): + raise ValueError("Refusing to set bad key in ElementList {}".format(orig_key)) + if isinstance(elem, str): + key = elem + elem = self.svg.getElementById(elem) + if elem is None: + return + if isinstance(elem, BaseElement): + # 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(ElementList, self).__setitem__(key, elem) + else: + kind = type(elem).__name__ + raise ValueError("Unknown element type: {}".format(kind)) + + def _to_key(self, key, default=None): + """Takes a key (id, element, etc) and returns an xml_path key""" + from ._base import BaseElement + if self and key is None: + key = default + if isinstance(key, int): + return list(self.keys())[key] + elif isinstance(key, BaseElement): + return key.xml_path + elif isinstance(key, str) and key[0] != '/': + return self.ids.get(key, key) + return key + + def clear(self): + """Also clear ids""" + self.ids.clear() + super(ElementList, self).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(ElementList, self).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 paint_order(self): + """Get the selected elements, but ordered by their appearance in the document""" + new_list = ElementList(self.svg) + new_list.set(*[elem for _, elem in sorted(self.items(), key=lambda x: 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.values() if not types or isinstance(e, types)]) + + 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: + for item in _recurse(child): + yield item + return ElementList(self.svg, [r for e in self.values() for r in _recurse(e)]) + + def id_dict(self): + """For compatibility, return regular dictionary of id -> element pairs""" + return OrderedDict([(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.values()], None) + + def first(self): + """Returns the first item in the selected list""" + for elem in self.values(): + 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..3e1af09 --- /dev/null +++ b/share/extensions/inkex/elements/_svg.py @@ -0,0 +1,211 @@ +# -*- 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 +from lxml import etree + +from ..deprecated import DeprecatedSvgMixin +from ..units import discover_unit, convert_unit, render_unit +from ._selected import ElementList +from ..transforms import BoundingBox +from ..styles import StyleSheets + +from ._base import BaseElement +from ._meta import NamedView, Defs, StyleElement, Metadata + +if False: # pylint: disable=using-constant-test + import typing # pylint: disable=unused-import + + +class SvgDocumentElement(DeprecatedSvgMixin, BaseElement): + """Provide access to the document level svg functionality""" + tag_name = 'svg' + + 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, size=4): + """Generate a new id from an existing old_id""" + ids = self.get_ids() + 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.width)), (0, float(self.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, elm='*'): # pylint: disable=invalid-name + """Get an element in this svg document by it's ID attribute""" + if eid is not None: + eid = eid.strip()[4:-1] if eid.startswith('url(') else eid + eid = eid.lstrip('#') + return self.getElement('//{elm}[@id="{eid}"]'.format(elm=elm, eid=eid)) + + def getElementByName(self, name, elm='*'): # pylint: disable=invalid-name + """Get an element by it's inkscape:label (aka name)""" + return self.getElement('//{elm}[@inkscape:label="{name}"]'.format(elm=elm, name=name)) + + def getElementsByClass(self, class_name): # pylint: disable=invalid-name + """Get elements by it's class name""" + from inkex.styles import ConditionalRule + return self.xpath(ConditionalRule(".{}".format(class_name)).to_xpath()) + + def getElementsByHref(self, eid): # pylint: disable=invalid-name + """Get elements by their href xlink attribute""" + return self.xpath('//*[@xlink:href="#{}"]'.format(eid)) + + def getElementsByStyleUrl(self, eid, style=None): # pylint: disable=invalid-name + """Get elements by a style attribute url""" + url = "url(#{})".format(eid) + if style is not None: + url = style + ":" + url + return self.xpath('//*[contains(@style,"{}")]'.format(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', NamedView, True) + + @property + def metadata(self): + """Return the svg metadata meta element container""" + return self.get_or_create('//svg:metadata', Metadata, True) + + @property + def defs(self): + """Return the svg defs meta element container""" + return self.get_or_create('//svg:defs', Defs, True) + + def get_viewbox(self): + """Parse and return the document's viewBox attribute""" + try: + ret = [float(unit) for unit in self.get('viewBox', '0').split()] + except ValueError: + ret = '' + if len(ret) != 4: + return [0, 0, 0, 0] + return ret + + @property + def width(self): # getDocumentWidth(self): + """Fault tolerance for lazily defined SVG""" + return self.unittouu(self.get('width')) or self.get_viewbox()[2] + + @property + def height(self): # getDocumentHeight(self): + """Returns a string corresponding to the height of the document, as + defined in the SVG file. If it is not defined, returns the height + as defined by the viewBox attribute. If viewBox is not defined, + returns the string '0'.""" + return self.unittouu(self.get('height')) or self.get_viewbox()[3] + + @property + def scale(self): + """Return the ratio between the page width and the viewBox width""" + try: + scale_x = float(self.width) / float(self.get_viewbox()[2]) + scale_y = float(self.height) / float(self.get_viewbox()[3]) + return max([scale_x, scale_y]) + except (ValueError, ZeroDivisionError): + return 1.0 + + @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.""" + viewbox = self.get_viewbox() + if viewbox and set(viewbox) != {0}: + return discover_unit(self.get('width'), viewbox[2], default='px') + return 'px' # Default is px + + def unittouu(self, value): + """Convert a unit value into the document's units""" + return convert_unit(value, self.unit) + + def uutounit(self, value, to_unit): + """Convert from the document's units to the given unit""" + return convert_unit(render_unit(value, self.unit), to_unit) + + def add_unit(self, value): + """Add document unit when no unit is specified in the string """ + return render_unit(value, self.unit) + + @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() diff --git a/share/extensions/inkex/elements/_text.py b/share/extensions/inkex/elements/_text.py new file mode 100644 index 0000000..766344c --- /dev/null +++ b/share/extensions/inkex/elements/_text.py @@ -0,0 +1,159 @@ +# -*- 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 ..paths import Path +from ..transforms import Transform, BoundingBox +from ..units import convert_unit + +from ._base import BaseElement, ShapeElement +from ._polygons import PathElementBase + +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): + """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): + """A Text element""" + tag_name = 'text' + x = property(lambda self: convert_unit(self.get('x', 0), 'px')) + y = property(lambda self: convert_unit(self.get('y', 0), 'px')) + + 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): + """A textPath element""" + tag_name = 'textPath' + + def get_path(self): + return Path() + +class Tspan(ShapeElement): + """A tspan text element""" + tag_name = 'tspan' + x = property(lambda self: convert_unit(self.get('x', 0), 'px')) + y = property(lambda self: convert_unit(self.get('y', 0), 'px')) + + @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 = convert_unit(self.style.get('font-size', '1em'), 'px') + 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..b5a38c5 --- /dev/null +++ b/share/extensions/inkex/elements/_use.py @@ -0,0 +1,70 @@ +# -*- 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(Use, cls).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.style = self.style + copy.style + self.replace_with(copy) + copy.set_random_ids() + return copy + + def shape_box(self, transform=None): + effective_transform = Transform(transform) * self.transform + return self.href.bounding_box(effective_transform) |