Source code for inkex.elements

# -*- coding: utf-8 -*-
#
# Copyright (c) 2018 - 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.
#
"""
Provide extra utility to each svg element type specific to its type.

This is useful for having a common interface for each element which can
give path, transform, and property access easily.
"""

import math

from lxml import etree

from .paths import Path
from .styles import Style
from .transforms import BoundingBox, Transform
from .utils import NSS, addNS, removeNS
from .units import convert_unit

__all__ = ('Group', 'PathElement', 'ShapeElement')

class SvgClassLookup(etree.CustomElementClassLookup):
    """
    We choose what kind of Elements we should return for each element, providing useful
    SVG based API to our extensions system.
    """
    _lookups = {}

    def lookup(self, node_type, document, namespace, name):  # pylint: disable=unused-argument
        """Choose what kind of functionality our element will have"""
        if node_type != "element":
            return None

        if namespace is None:
            namespace = NSS['svg']

        return self.get_lookups().get((namespace, name), BaseElement)

    def get_lookups(self):
        """Scan for and cache a list of available classes"""
        # This import is needed prior to generating the lookup table
        from .svg import SvgDocumentElement # pylint: disable=unused-variable
        if not self._lookups:
            for cls in BaseElement.get_subclasses():
                for name in (cls.tag_name,) if cls.tag_name else cls.tag_names:
                    self._lookups[removeNS(name, url=True)] = cls

        return self._lookups

SVG_PARSER = etree.XMLParser(huge_tree=True)
SVG_PARSER.set_element_class_lookup(SvgClassLookup())

def load_svg(stream):
    """Load SVG file using the SVG_PARSER"""
    if (isinstance(stream, str) and stream.startswith('<'))\
      or (isinstance(stream, bytes) and stream.startswith(b'<')):
        return etree.ElementTree(etree.fromstring(stream, parser=SVG_PARSER))
    return etree.parse(stream, parser=SVG_PARSER)

class BaseElement(etree.ElementBase):
    """Provide automatic namespaces to all calls"""
    tag_name = ''
    tag_names = ()

    @property
    def TAG(self):
        assert self.tag_name
        return removeNS(self.tag_name)[-1]

    NAMESPACE = property(lambda self: removeNS(self.tag_name, url=True)[0])
    PARSER = SVG_PARSER
    WRAPPED_ATTRS = (
        ('transform', Transform),
        ('style', Style),
    )

    # 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.
    wrapped_attrs = property(lambda self: dict(self.WRAPPED_ATTRS))

    @classmethod
    def get_subclasses(cls):
        """Get subclasses, recursively
        @rtype generator
        """
        for subcls in cls.__subclasses__():
            yield subcls
            for subsubcls in subcls.get_subclasses():
                yield subsubcls

    def __getattr__(self, name):
        """Get the attribute, but load it if it is not available yet"""
        if name in self.wrapped_attrs:
            cls = self.wrapped_attrs[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(name, str(new_item))
                else:
                    self.attrib.pop(name, None) # pylint: disable=no-member

            # pylint: disable=no-member
            value = cls(self.attrib.get(name, None), callback=_set_attr)
            setattr(self, name, value)
            return value
        raise AttributeError("Can't find attribute {}.{}"
                             .format(type(self).__name__, name))

    def __setattr__(self, name, value):
        """Set the attribute, update it if needed"""
        if name in self.wrapped_attrs:
            cls = self.wrapped_attrs[name]
            # Don't call self.set or self.get (infinate loop)
            if value:
                if not isinstance(value, cls):
                    value = cls(value)
                self.attrib[name] = str(value)
            else:
                self.attrib.pop(name, None) # pylint: disable=no-member
        else:
            super(BaseElement, self).__setattr__(name, value)

    def get(self, name, default=None):
        """Get element attribute named, with addNS support."""
        if name in self.wrapped_attrs:
            value = getattr(self, name, None)
            # We check the boolean nature of the value, because empty
            # transformations and style attributes are equiv to not-existing
            ret = str(value) if value else (default or None)
            return ret
        return super(BaseElement, self).get(addNS(name), default)

    def set(self, name, value):
        """Set element attribute named, with addNS support"""
        if name in self.wrapped_attrs:
            # Always keep the local wrapped class up to date.
            setattr(self, name, self.wrapped_attrs[name](value))
            value = getattr(self, name)
            if not value:
                return
        if value is None:
            self.attrib.pop(addNS(name), None) # pylint: disable=no-member
        else:
            super(BaseElement, self).set(addNS(name), str(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, name, default=None):
        """Delete/remove the element attribute named, with addNS support."""
        if name in self.wrapped_attrs:
            # Always keep the local wrapped class up to date.
            value = getattr(self, name)
            setattr(self, name, self.wrapped_attrs[name](None))
            return value
        return self.attrib.pop(addNS(name), default) # pylint: disable=no-member

    def add(self, *children):
        """
        Like append, but will do multiple children and will return
        children or only child
        """
        for child in children:
            self.append(child)
        return children if len(children) > 1 else children[0]

    def set_random_id(self, suffix=None, size=4):
        """Sets the id attribute if it is not already set"""
        root = self.getroottree().getroot()
        self.set('id', root.get_unique_id(suffix, size=size))

    def get_id(self):
        """Get the id for the element, will set a new unique id if not set"""
        if 'id' not in self.attrib:
            self.set_random_id(self.TAG)
        return self.get('id')

    @property
    def root(self):
        """Get the root document element from any element descendent"""
        if self.getparent() is not None:
            return self.getparent().root
        return self

    def descendants(self):
        """Walks the element tree and yields all elements, parent first"""
        yield self
        for child in self:
            if hasattr(child, 'descendants'):
                for descendant in child.descendants():
                    yield descendant

    def ancestors(self):
        """Walk the parents and yield all the ancestor elements, parent first"""
        parent = self.getparent()
        if parent is not None:
            yield parent
            for child in parent.ancestors():
                yield child

    def xpath(self, pattern, namespaces=NSS):  # pylint: disable=dangerous-default-value
        """Wrap xpath call and add svg namespaces"""
        return super(BaseElement, self).xpath(pattern, namespaces=namespaces)

    def findall(self, pattern, namespaces=NSS):  # pylint: disable=dangerous-default-value
        """Wrap findall call and add svg namespaces"""
        return super(BaseElement, self).findall(pattern, namespaces=namespaces)

    def findone(self, xpath):
        """Gets a single element from the given xpath or returns None"""
        el_list = self.xpath(xpath)
        return el_list[0] if el_list else None

    def delete(self):
        """Delete this node from it's parent node"""
        if self.getparent():
            self.getparent().remove(self)

    def __str__(self):
        # We would do more here, but lxml is VERY unpleseant when it comes to
        # namespaces, basically over printing details and providing no
        # supression mechanisms to turn off xml's over engineering.
        return str(self.tag).split('}')[-1]


[docs]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)
[docs] def get_path(self): """Generate a path for this object which can inform the bounding box""" raise NotImplementedError("Path should be provided by svg element {}." .format(type(self).__name__))
[docs] def set_path(self, path): """Set the path for this object (if possible)""" raise AttributeError("Path can not be set on this type of element: {} <- {}." .format(type(self).__name__, path))
[docs] def composed_transform(self): """Calculate every transform down to the root document node""" parent = self.getparent() if parent is not None and isinstance(parent, ShapeElement): return self.transform * parent.composed_transform() return self.transform
[docs] def composed_style(self): """Calculate the final styles applied to this element""" # FUTURE: We could compose styles from class/css too. parent = self.getparent() if parent is not None and isinstance(parent, ShapeElement): return parent.composed_style() + self.style return self.style
[docs] def bounding_box(self, transform=None): # type: () -> BoundingBox """BoundingBox calculation based on the ShapeElement rendered to a path.""" path = self.path.to_absolute().transform(self.transform) if transform: # apply extra transformation path = path.transform(transform) return path.bounding_box()
[docs] def get_center_position(self): """Returns object's center in terms of document units""" x, y = self.bounding_box().center() return x or 0, y or 0
@property def label(self): """Returns the inkscape label""" return self.get('inkscape:label', None) @property def href(self): """Returns the referred-to element if available""" from inkex.svg import SvgDocumentElement if not isinstance(self.root, SvgDocumentElement): raise KeyError("XML Fragment can not use xlinks") ref = self.get('xlink:href') if not ref: return None return self.root.getElementById(ref.strip('#'))
class FlowRegion(ShapeElement): """SVG Flow Region (SVG 2.0)""" tag_name = 'flowRegion' def get_path(self): # This ignores flowRegionExcludes return sum([child.path for child in self], Path()) class FlowRoot(ShapeElement): """SVG Flow Root (SVG 2.0)""" tag_name = 'flowRoot' @property def region(self): """Return the first flowRegion in this flowRoot""" return self.findone('svg:flowRegion') def get_path(self): region = self.region return region.get_path() if region is not None else Path() class FlowPara(ShapeElement): """SVG Flow Paragraph (SVG 2.0)""" tag_name = 'flowPara' def get_path(self): # XXX: These empty paths mean the bbox for text elements will be nothing. return Path() class 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 FilterPrimitive(BaseElement): """A bunch of different filter primitives""" tag_names = [ 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feFlood', 'feGaussianBlur', 'feImage', 'feMerge', 'feMorphology', 'feOffset', 'feSpecularLighting', 'feTile', 'feTurbulence' ] 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
[docs]class Group(ShapeElement): """Any group element (layer or regular group)""" tag_name = 'g' is_layer = lambda self: self.groupmode == 'layer'
[docs] @classmethod def create(cls, label, layer=False): """Create a group, set the inkscape label and groupmode if needed""" elem = cls() elem.set('inkscape:label', label) if layer is True: elem.set('inkscape:groupmode', 'layer') return elem
[docs] def get_path(self): return Path()
[docs] def bounding_box(self, transform=None): bbox = BoundingBox(None) transform = Transform(transform) * self.transform if not transform: transform = None for child in self: if isinstance(child, ShapeElement): bbox += child.bounding_box(transform=transform) return bbox
@property def groupmode(self): """Return the type of group this is""" return self.get('inkscape:groupmode', 'group')
class Anchor(Group): """An anchor or link tag""" tag_name = 'a'
[docs]class PathElement(ShapeElement): """Provide a useful extension for path elements""" tag_name = 'path' get_path = lambda self: self.get('d')
[docs] def set_path(self, path): """Set the given data as a path as the 'd' attribute""" self.set('d', str(Path(path)))
[docs] def apply_transform(self): """Apply the internal transformation to this node and delete""" if 'transform' in self.attrib: self.path.transform(self.transform) self.set('d', str(self.path)) self.set('transform', Transform())
@property def original_path(self): """Returns the original path if this is an 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 Polyline(ShapeElement): """Like a path, but made up of straight lines only""" tag_name = 'polyline' def get_path(self): return Path('M' + self.get('points')) def set_path(self, path): points = ['{:g},{:g}'.format(x, y) for x, y in Path(path).end_points] self.set('points', ' '.join(points)) class 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""" tag_names = ('linearGradient', 'radialGradient') WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (('gradientTransform', Transform),) class Polygon(ShapeElement): """A closed polyline""" tag_name = 'polygon' get_path = lambda self: 'M' + self.get('points') + ' Z' class Line(ShapeElement): """A line connecting two points""" tag_name = 'line' get_path = lambda self: 'M{0[x1]},{0[y1]} L{0[x2]},{0[y2]}'.format(self.attrib) class Rectangle(ShapeElement): """Provide a useful extension for rectangle elements""" tag_name = 'rect' left = property(lambda self: float(self.get('x', '0'))) top = property(lambda self: float(self.get('y', '0'))) width = property(lambda self: float(self.get('width', '0'))) height = property(lambda self: float(self.get('height', '0'))) def get_path(self): """Calculate the path as the box around the rect""" return 'M {0.left},{0.top} h{0.width}v{0.height}h{1} z'.format(self, -self.width) class Image(Rectangle): """Provide a useful extension for image elements""" tag_name = 'image' class Circle(ShapeElement): """Provide a useful extension for circle elements""" tag_name = 'circle' radius = property(lambda self: self.get('r', '0')) radius_x = property(lambda self: float(self.get('rx', self.radius))) radius_y = property(lambda self: float(self.get('ry', self.radius))) center_x = property(lambda self: float(self.get('cx', '0'))) center_y = property(lambda self: float(self.get('cy', '0'))) top = property(lambda self: self.center_y - self.radius_y) bottom = property(lambda self: self.center_y + self.radius_y) left = property(lambda self: self.center_x - self.radius_x) right = property(lambda self: self.center_x + self.radius_x) def get_path(self): """Calculate the arc path of this circle""" return ('M {0.center_x},{0.top} ' 'a {0.radius_x},{0.radius_y} 0 1 0 {0.radius_x}, {0.radius_y} ' 'a {0.radius_x},{0.radius_y} 0 0 0 -{0.radius_x}, -{0.radius_y} z' ).format(self) class Ellipse(Circle): """Provide a similar extension to the Circle interface""" tag_name = 'ellipse' class Use(ShapeElement): """A 'use' element that links to another in the document""" tag_name = 'use' get_path = lambda self: self.href.get_path() class ClipPath(Group): """A path used to clip objects""" tag_name = 'clipPath' class Defs(BaseElement): """An header defs element, one per document""" tag_name = 'defs' class NamedView(BaseElement): """The NamedView element is Inkscape specific metadata about the file""" tag_name = 'sodipodi:namedview' center_x = property(lambda self: self.get('inkscape:cx')) center_y = property(lambda self: self.get('inkscape:cy')) current_layer = property(lambda self: self.get('inkscape:current-layer')) def get_guides(self): """Returns a list of guides""" return self.findall('sodipodi:guide') class Guide(BaseElement): """An inkscape guide""" tag_name = 'sodipodi:guide' is_horizontal = property(lambda self: self.get('orientation') in ('0,1', '0,-1')) is_vertical = property(lambda self: self.get('orientation') == '1,0') point = property(lambda self: self.get('position').split(',')) def move_to(self, pos_x, pos_y, angle=None): """ Move this guide to the given x,y position, Angle may either be a float or integer, which will change the orientation. Or a pair of numbers (tuple) which will be set as the orientation directly. """ self.set('position', "{:g},{:g}".format(float(pos_x), float(pos_y))) if isinstance(angle, str): if ',' not in angle: angle = float(angle) if isinstance(angle, (float, int)): # Generate orientation from angle angle = (math.sin(math.radians(angle)), -math.cos(math.radians(angle))) if isinstance(angle, (tuple, list)) and len(angle) == 2: angle = "{:g},{:g}".format(*angle) self.set('orientation', angle) return self class Metadata(BaseElement): """Inkscape Metadata element""" tag_name = 'metadata' class ForeignObject(BaseElement): """SVG foreignObject element""" tag_name = 'foreignObject' class TextElement(ShapeElement): """A Text element""" tag_name = 'text' x = property(lambda self: float(self.get('x', 0))) y = property(lambda self: float(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 bounding_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) """ transform = self.transform * transform x, y = transform.apply_to_point((self.x, self.y)) bbox = BoundingBox(x, y) for tspan in self.tspans(): bbox += tspan.bounding_box(transform) return bbox class TextPath(ShapeElement): """A textPath element""" tag_name = 'textPath' def get_path(self): return Path() class Tspan(ShapeElement): """A tspan text element""" tag_name = 'tspan' x = property(lambda self: float(self.get('x', 0))) y = property(lambda self: float(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 bounding_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) """ transform = self.transform * transform x1, y1 = transform.apply_to_point((self.x, self.y)) fontsize = convert_unit(self.style.get('font-size', '1em'), 'px') y2 = y1 + float(fontsize) x2 = x1 + 0 # XXX This is impossible to calculate! return BoundingBox((x1, x2), (y1, y2)) class Marker(Group): """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 Switch(BaseElement): """A switch element""" tag_name = 'switch' class Grid(BaseElement): """A namedview grid child""" tag_name = 'inkscape:grid' class Script(BaseElement): """A javascript tag in SVG""" tag_name = 'script' 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(PathElement): """An svg font glyph element""" tag_name = 'glyph' class MissingGlyph(BaseElement): """An svg font missing-glyph element""" tag_name = 'missing-glyph' class Symbol(BaseElement): """SVG symbol element""" tag_name = 'symbol' class PathEffect(BaseElement): """Inkscape LPE element""" tag_name = 'inkscape:path-effect'