diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
commit | cca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch) | |
tree | 146f39ded1c938019e1ed42d30923c2ac9e86789 /share/extensions/inkex/elements | |
parent | Initial commit. (diff) | |
download | inkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.tar.xz inkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.zip |
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | share/extensions/inkex/elements/__init__.py | 55 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_base.py | 755 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_filters.py | 367 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_groups.py | 126 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_image.py | 29 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_meta.py | 293 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_parser.py | 118 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_polygons.py | 507 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_selected.py | 237 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_svg.py | 371 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_text.py | 202 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_use.py | 81 | ||||
-rw-r--r-- | share/extensions/inkex/elements/_utils.py | 144 |
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) |