initial commit
This commit is contained in:
56
deps/inkex/elements/__init__.py
vendored
Normal file
56
deps/inkex/elements/__init__.py
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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
|
||||
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,
|
||||
Mask,
|
||||
Gradient,
|
||||
LinearGradient,
|
||||
RadialGradient,
|
||||
PathEffect,
|
||||
Stop,
|
||||
MeshGradient,
|
||||
MeshRow,
|
||||
MeshPatch,
|
||||
)
|
||||
from ._image import Image
|
||||
BIN
deps/inkex/elements/__pycache__/__init__.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/__init__.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/__init__.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/__init__.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_base.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_base.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_base.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_base.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_filters.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_filters.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_filters.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_filters.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_groups.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_groups.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_groups.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_groups.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_image.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_image.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_image.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_image.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_meta.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_meta.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_meta.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_meta.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_parser.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_parser.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_parser.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_parser.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_polygons.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_polygons.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_polygons.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_polygons.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_selected.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_selected.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_selected.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_selected.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_svg.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_svg.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_svg.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_svg.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_text.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_text.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_text.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_text.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_use.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_use.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_use.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_use.cpython-313.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_utils.cpython-312.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_utils.cpython-312.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/inkex/elements/__pycache__/_utils.cpython-313.pyc
vendored
Normal file
BIN
deps/inkex/elements/__pycache__/_utils.cpython-313.pyc
vendored
Normal file
Binary file not shown.
939
deps/inkex/elements/_base.py
vendored
Normal file
939
deps/inkex/elements/_base.py
vendored
Normal file
@@ -0,0 +1,939 @@
|
||||
# -*- 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,
|
||||
Callable,
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
from lxml import etree
|
||||
import re
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._svg import SvgDocumentElement
|
||||
|
||||
from ..base import SvgOutputMixin
|
||||
from ..paths import Path
|
||||
from ..styles import Style, Classes, StyleValue
|
||||
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 (
|
||||
_ShorthandValueConverter,
|
||||
_get_tokens_from_value,
|
||||
all_properties,
|
||||
)
|
||||
from ._selected import ElementList
|
||||
from ._parser import NodeBasedLookup, SVG_PARSER
|
||||
|
||||
T = TypeVar("T", bound="BaseElement") # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class BaseElement(etree.ElementBase):
|
||||
"""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"""
|
||||
|
||||
_root: Optional["SvgDocumentElement"] = None
|
||||
|
||||
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, 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
|
||||
return ret
|
||||
return super().get(addNS(attr), default)
|
||||
|
||||
def set(self, attr, value):
|
||||
"""Set element attribute named, with addNS support"""
|
||||
if attr == "id" and value is not None:
|
||||
try:
|
||||
oldid = self.get("id", None)
|
||||
if oldid is not None and oldid in self.root.ids:
|
||||
self.root.ids.pop(oldid)
|
||||
if value in self.root.ids:
|
||||
raise ValueError(f"ID {value} already exists in this document")
|
||||
self.root.ids[value] = self
|
||||
except FragmentError: # element is unrooted
|
||||
pass
|
||||
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) -> "SvgDocumentElement":
|
||||
"""Get the root document element from any element descendent"""
|
||||
if self._root is not None:
|
||||
return self._root
|
||||
|
||||
root, parent = self, self
|
||||
while parent is not None:
|
||||
root, parent = parent, parent.getparent()
|
||||
|
||||
from ._svg import SvgDocumentElement
|
||||
|
||||
if not isinstance(root, SvgDocumentElement):
|
||||
raise FragmentError("Element fragment does not have a document root!")
|
||||
|
||||
self._root = 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
|
||||
"""
|
||||
|
||||
try:
|
||||
return ElementList(self.root, self._ancestors(elem=elem, stop_at=stop_at))
|
||||
except FragmentError:
|
||||
return ElementList(self, 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.getparent().replace(self, 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
|
||||
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("href") or 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()
|
||||
if self.get("href"):
|
||||
self.set("href", "#" + elem)
|
||||
else:
|
||||
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 get_computed_style(self, key):
|
||||
"""Returns the computed style value with respect to
|
||||
n element, i.e. the cascaded style +
|
||||
inheritance, see https://www.w3.org/TR/CSS22/cascade.html#computed-value.
|
||||
|
||||
This is more efficient if only few style values per element are queried. If
|
||||
many attributes are queried, use :func:`specified_style`.
|
||||
|
||||
.. versionadded:: 1.4
|
||||
"""
|
||||
return Style._get_style(key, 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
|
||||
# Shorthands cannot be set by presentation attributes
|
||||
and not isinstance(
|
||||
all_properties[key].converter, _ShorthandValueConverter
|
||||
)
|
||||
and all_properties[key].presentation
|
||||
):
|
||||
style[key] = StyleValue(_get_tokens_from_value(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 other and isinstance(parent, BaseElement):
|
||||
return parent.composed_transform(other) @ self.transform
|
||||
return self.transform
|
||||
|
||||
def _add_to_tree_callback(self, element):
|
||||
try:
|
||||
element._root = self._root
|
||||
self.root.add_to_tree_callback(element)
|
||||
except (FragmentError, AttributeError):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _remove_from_tree_callback(oldtree, element):
|
||||
try:
|
||||
oldtree.root.remove_from_tree_callback(element)
|
||||
except (FragmentError, AttributeError):
|
||||
pass
|
||||
|
||||
def __element_adder(
|
||||
self, element: BaseElement, add_func: Callable[[BaseElement], None]
|
||||
):
|
||||
BaseElement._remove_from_tree_callback(element, element)
|
||||
# Make sure that we have an ID cache before adding the element,
|
||||
# otherwise we will try to add this element twice to the cache
|
||||
try:
|
||||
self.root.ids
|
||||
except FragmentError:
|
||||
pass
|
||||
try:
|
||||
add_func(element)
|
||||
except Exception as err:
|
||||
BaseElement._add_to_tree_callback(element, element)
|
||||
raise err
|
||||
self._add_to_tree_callback(element)
|
||||
|
||||
# Overrides to keep track of styles and IDs
|
||||
def addnext(self, element):
|
||||
self.__element_adder(element, super(etree.ElementBase, self).addnext)
|
||||
|
||||
def addprevious(self, element):
|
||||
self.__element_adder(element, super(etree.ElementBase, self).addprevious)
|
||||
|
||||
def append(self, element):
|
||||
self.__element_adder(element, super(etree.ElementBase, self).append)
|
||||
|
||||
def clear(self, keep_tail=False):
|
||||
subelements = iter(self)
|
||||
old_id = self.get("id", None)
|
||||
super().clear(keep_tail)
|
||||
for element in subelements:
|
||||
BaseElement._remove_from_tree_callback(self, element)
|
||||
if old_id is not None and old_id in self.root.ids:
|
||||
self.root.ids.pop(old_id)
|
||||
|
||||
def extend(self, elements):
|
||||
if not isinstance(elements, (list, tuple)):
|
||||
elements = list(elements)
|
||||
for element in elements:
|
||||
BaseElement._remove_from_tree_callback(element, element)
|
||||
try:
|
||||
self.root.ids
|
||||
except FragmentError:
|
||||
pass
|
||||
try:
|
||||
super().extend(elements)
|
||||
except Exception as err:
|
||||
for element in elements:
|
||||
BaseElement._add_to_tree_callback(element, element)
|
||||
raise err
|
||||
for element in elements:
|
||||
self._add_to_tree_callback(element)
|
||||
|
||||
def insert(self, index, element):
|
||||
self.__element_adder(
|
||||
element,
|
||||
lambda element: super(etree.ElementBase, self).insert(index, element),
|
||||
)
|
||||
|
||||
def remove(self, element):
|
||||
super().remove(element)
|
||||
BaseElement._remove_from_tree_callback(self, element)
|
||||
|
||||
def replace(self, old_element, new_element):
|
||||
def replacer(new_element):
|
||||
super(etree.ElementBase, self).replace(old_element, new_element)
|
||||
BaseElement._remove_from_tree_callback(self, old_element)
|
||||
|
||||
self.__element_adder(new_element, replacer)
|
||||
|
||||
|
||||
NodeBasedLookup.default = BaseElement
|
||||
|
||||
|
||||
class ShapeElement(BaseElement):
|
||||
"""Elements which have a visible representation on the canvas"""
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Gets the outline or path of the element, this may be a simple bounding box"""
|
||||
return self.get_path()
|
||||
|
||||
@path.setter
|
||||
def path(self, path):
|
||||
self.set_path(path)
|
||||
|
||||
@property
|
||||
def clip(self):
|
||||
"""Gets the clip path element (if any). May be set through CSS.
|
||||
|
||||
.. versionadded:: 1.1"""
|
||||
return self.get_computed_style("clip-path")
|
||||
|
||||
@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 this object is invisible
|
||||
|
||||
.. versionchanged:: 1.3
|
||||
rely on cascaded_style() to include CSS and presentation attributes
|
||||
include `visibility` attribute with check for inherit
|
||||
include ancestors
|
||||
|
||||
.. versionadded:: 1.1"""
|
||||
return self._is_visible()
|
||||
|
||||
def _is_visible(self, inherit_visibility=True):
|
||||
# iterate over self and ancestors
|
||||
# This does not use :func:`get_computed_style` but its own iteration
|
||||
# logic to avoid duplicate evaluation of styles: a child is also invisible
|
||||
# if the parent has opacity:0, but opacity is not inherited - so we need
|
||||
# to check the specified style of all parents and ignore inheritance
|
||||
# altogether
|
||||
for element in [self] + list(self.ancestors()):
|
||||
get_style = element.cascaded_style().get
|
||||
# case display:none
|
||||
if get_style("display", "inline") == "none":
|
||||
return False
|
||||
# case opacity:0
|
||||
if not float(get_style("opacity", 1.0)):
|
||||
return False
|
||||
# only check if childs visibility is inherited
|
||||
if inherit_visibility:
|
||||
# case visibility:hidden
|
||||
if get_style("visibility", "inherit") in (
|
||||
"hidden",
|
||||
"collapse",
|
||||
):
|
||||
return False
|
||||
# case visibility: not inherit
|
||||
elif get_style("visibility", "inherit") != "inherit":
|
||||
inherit_visibility = 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)
|
||||
|
||||
|
||||
class ViewboxMixin:
|
||||
"""Mixin for elements with viewboxes, such as <svg>, <marker>"""
|
||||
|
||||
def parse_viewbox(self, vbox: Optional[str]) -> Optional[List[float]]:
|
||||
"""Parses a viewbox. If an error occurs during parsing,
|
||||
(0, 0, 0, 0) is returned. If the viewbox is None, None is returned.
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
if vbox is not None and isinstance(vbox, str):
|
||||
try:
|
||||
result = [float(unit) for unit in re.split(r",\s*|\s+", vbox)]
|
||||
except ValueError:
|
||||
result = []
|
||||
if len(result) != 4:
|
||||
result = [0, 0, 0, 0]
|
||||
return result
|
||||
return None
|
||||
581
deps/inkex/elements/_filters.py
vendored
Normal file
581
deps/inkex/elements/_filters.py
vendored
Normal file
@@ -0,0 +1,581 @@
|
||||
# -*- 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 ..utils import parse_percent
|
||||
|
||||
from ..transforms import Transform
|
||||
|
||||
from ..styles import Style
|
||||
|
||||
from ._utils import addNS
|
||||
from ._base import BaseElement, ViewboxMixin
|
||||
from ._groups import GroupBase
|
||||
from ..units import convert_unit
|
||||
|
||||
|
||||
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 DistantLight(Primitive):
|
||||
"""DistanceLight Filter element
|
||||
defines a light source for a DiffuseLighting or SpecularLighting Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feDistantLight"
|
||||
|
||||
class Flood(Primitive):
|
||||
"""DiffuseLightning Filter element"""
|
||||
|
||||
tag_name = "feFlood"
|
||||
|
||||
class FuncA(Primitive):
|
||||
"""FuncR Filter element
|
||||
defines the alpha channel transfer for a ComponentTransfer Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feFuncA"
|
||||
|
||||
class FuncB(Primitive):
|
||||
"""FuncR Filter element
|
||||
defines the blue channel transfer for a ComponentTransfer Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feFuncB"
|
||||
|
||||
class FuncG(Primitive):
|
||||
"""FuncR Filter element
|
||||
defines the green channel transfer for a ComponentTransfer Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feFuncG"
|
||||
|
||||
class FuncR(Primitive):
|
||||
"""FuncR Filter element
|
||||
defines the red channel transfer for a ComponentTransfer Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feFuncR"
|
||||
|
||||
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 MergeNode(Primitive):
|
||||
"""MergeNode Filter element
|
||||
defines an input for a Merge Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feMergeNode"
|
||||
|
||||
class Morphology(Primitive):
|
||||
"""Morphology Filter element"""
|
||||
|
||||
tag_name = "feMorphology"
|
||||
|
||||
class Offset(Primitive):
|
||||
"""Offset Filter element"""
|
||||
|
||||
tag_name = "feOffset"
|
||||
|
||||
class PointLight(Primitive):
|
||||
"""PointLight Filter elements
|
||||
defines a light source for a DiffuseLighting or SpecularLighting Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "fePointLight"
|
||||
|
||||
class SpecularLighting(Primitive):
|
||||
"""SpecularLighting Filter element"""
|
||||
|
||||
tag_name = "feSpecularLighting"
|
||||
|
||||
class SpotLight(Primitive):
|
||||
"""SpotLight Filter element
|
||||
defines a light source for a DiffuseLighting or SpecularLighting Filter element
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
|
||||
tag_name = "feSpotLight"
|
||||
|
||||
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"""
|
||||
value = self.get("offset", default="0")
|
||||
return parse_percent(value)
|
||||
|
||||
@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, ViewboxMixin):
|
||||
"""Pattern element which is used in the def to control repeating fills"""
|
||||
|
||||
tag_name = "pattern"
|
||||
WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (("patternTransform", Transform),)
|
||||
|
||||
def get_fallback(self, prop, default="0"):
|
||||
val = self.get(prop, None)
|
||||
if val is None:
|
||||
if isinstance(self.href, Pattern):
|
||||
return getattr(self.href, prop)
|
||||
val = default
|
||||
return val
|
||||
|
||||
x = property(lambda self: self.get_fallback("x"))
|
||||
y = property(lambda self: self.get_fallback("y"))
|
||||
width = property(lambda self: self.get_fallback("width"))
|
||||
height = property(lambda self: self.get_fallback("height"))
|
||||
patternUnits = property(
|
||||
lambda self: self.get_fallback("patternUnits", "objectBoundingBox")
|
||||
)
|
||||
|
||||
def get_viewbox(self) -> Optional[List[float]]:
|
||||
"""Get the viewbox of the pattern, falling back to the href's viewbox
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
vbox = self.get("viewBox", None)
|
||||
if vbox is None:
|
||||
if isinstance(self.href, Pattern):
|
||||
return self.href.get_viewbox()
|
||||
return self.parse_viewbox(vbox)
|
||||
|
||||
def get_effective_parent(self, depth=0, maxDepth=10):
|
||||
"""If a pattern has no children, but a href, it uses the children from the href.
|
||||
Avoids infinite recursion.
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
if (
|
||||
len(self) == 0
|
||||
and self.href is not None
|
||||
and isinstance(self.href, Pattern)
|
||||
and depth < maxDepth
|
||||
):
|
||||
return self.href.get_effective_parent(depth + 1, maxDepth)
|
||||
return self
|
||||
|
||||
|
||||
class Mask(GroupBase):
|
||||
"""A structural object that serves as opacity mask
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
|
||||
tag_name = "mask"
|
||||
|
||||
def get_fallback(self, prop, default="0"):
|
||||
return self.to_dimensionless(self.get(prop, default))
|
||||
|
||||
x = property(lambda self: self.get_fallback("x"))
|
||||
y = property(lambda self: self.get_fallback("y"))
|
||||
width = property(lambda self: self.get_fallback("width"))
|
||||
height = property(lambda self: self.get_fallback("height"))
|
||||
maskUnits = property(lambda self: self.get("maskUnits", "objectBoundingBox"))
|
||||
|
||||
|
||||
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 [child for child in gradcolor if isinstance(child, Stop)]
|
||||
|
||||
@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
|
||||
|
||||
def get_percentage_parsed_unit(self, attribute, value, svg=None):
|
||||
"""Parses an attribute of a gradient, respecting percentage values of
|
||||
"userSpaceOnUse" as percentages of document size. See
|
||||
https://www.w3.org/TR/SVG2/pservers.html#LinearGradientAttributes for details
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
if isinstance(value, (float, int)):
|
||||
return value
|
||||
value = value.strip()
|
||||
if len(value) > 0 and value[-1] == "%":
|
||||
try:
|
||||
value = float(value.strip()[0:-1]) / 100.0
|
||||
gradientunits = self.get("gradientUnits", "objectBoundingBox")
|
||||
if gradientunits == "userSpaceOnUse":
|
||||
if svg is None:
|
||||
raise ValueError("Need root SVG to determine percentage value")
|
||||
bbox = svg.get_page_bbox()
|
||||
if attribute in ("cx", "fx", "x1", "x2"):
|
||||
return bbox.width * value
|
||||
if attribute in ("cy", "fy", "y1", "y2"):
|
||||
return bbox.height * value
|
||||
if attribute in ("r"):
|
||||
return bbox.diagonal_length * value
|
||||
if gradientunits == "objectBoundingBox":
|
||||
return value
|
||||
except ValueError:
|
||||
value = None
|
||||
return convert_unit(value, "px")
|
||||
|
||||
def _get_or_href(self, attr, default, svg=None):
|
||||
val = self.get(attr)
|
||||
if val is None:
|
||||
if type(self.href) is type(self):
|
||||
return getattr(self.href, attr)()
|
||||
val = default
|
||||
return self.get_percentage_parsed_unit(attr, val, svg)
|
||||
|
||||
|
||||
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]),
|
||||
)
|
||||
|
||||
def x1(self, svg=None):
|
||||
"""Get the x1 attribute
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("x1", "0%", svg)
|
||||
|
||||
def x2(self, svg=None):
|
||||
"""Get the x2 attribute
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("x2", "100%", svg)
|
||||
|
||||
def y1(self, svg=None):
|
||||
"""Get the y1 attribute
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("y1", "0%", svg)
|
||||
|
||||
def y2(self, svg=None):
|
||||
"""Get the y2 attribute
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("y2", "0%", svg)
|
||||
|
||||
|
||||
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]),
|
||||
)
|
||||
|
||||
def cx(self, svg=None):
|
||||
"""Get the effective cx (horizontal center) attribute in user units
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("cx", "50%", svg)
|
||||
|
||||
def cy(self, svg=None):
|
||||
"""Get the effective cy (vertical center) attribute in user units
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("cy", "50%", svg)
|
||||
|
||||
def fx(self, svg=None):
|
||||
"""Get the effective fx (horizontal focal point) attribute in user units
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("fx", self.cx(svg), svg)
|
||||
|
||||
def fy(self, svg=None):
|
||||
"""Get the effective fx (vertical focal point) attribute in user units
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("fy", self.cy(svg), svg)
|
||||
|
||||
def r(self, svg=None):
|
||||
"""Get the effective r (gradient radius) attribute in user units
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return self._get_or_href("r", "50%", svg)
|
||||
|
||||
|
||||
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])
|
||||
196
deps/inkex/elements/_groups.py
vendored
Normal file
196
deps/inkex/elements/_groups.py
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
# -*- 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 BoundingBox, Transform, Vector2d
|
||||
|
||||
from ._utils import addNS
|
||||
from ._base import ShapeElement, ViewboxMixin
|
||||
from ._polygons import PathElement
|
||||
|
||||
try:
|
||||
from typing import Optional, List # 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):
|
||||
child_path = child.path.transform(child.transform)
|
||||
if child_path and child_path[0].is_relative:
|
||||
child_path[0] = child_path[0].to_absolute(Vector2d(0, 0))
|
||||
ret += child_path
|
||||
return ret
|
||||
|
||||
def bounding_box(self, transform=None):
|
||||
# type: (Optional[Transform]) -> Optional[BoundingBox]
|
||||
"""BoundingBox of the shape
|
||||
|
||||
.. versionchanged:: 1.4
|
||||
Exclude invisible child objects from bounding box computation
|
||||
|
||||
.. versionchanged:: 1.1
|
||||
result adjusted for element's clip path if applicable.
|
||||
"""
|
||||
bbox = None
|
||||
effective_transform = Transform(transform) @ self.transform
|
||||
for child in self:
|
||||
if isinstance(child, ShapeElement) and child.is_visible():
|
||||
child_bbox = child.bounding_box(transform=effective_transform)
|
||||
if child_bbox is not None:
|
||||
bbox += child_bbox
|
||||
clip = self.clip
|
||||
if clip is None or bbox is None:
|
||||
return bbox
|
||||
return bbox & clip.bounding_box(Transform(transform) @ self.transform)
|
||||
|
||||
def shape_box(self, transform=None):
|
||||
# type: (Optional[Transform]) -> Optional[BoundingBox]
|
||||
"""BoundingBox of the unclipped shape
|
||||
|
||||
.. versionchanged:: 1.4
|
||||
returns the bounding box without possible clip effects of child objects
|
||||
|
||||
.. versionadded:: 1.1
|
||||
Previous :func:`bounding_box` function, returning the bounding box
|
||||
without computing the effect of a possible clip."""
|
||||
bbox = None
|
||||
effective_transform = Transform(transform) @ self.transform
|
||||
for child in self:
|
||||
if isinstance(child, ShapeElement):
|
||||
child_bbox = child.shape_box(transform=effective_transform)
|
||||
if child_bbox is not None:
|
||||
bbox += child_bbox
|
||||
return bbox
|
||||
|
||||
def bake_transforms_recursively(self, apply_to_paths=True):
|
||||
"""Bake transforms, i.e. each leaf node has the effective transform (starting
|
||||
from this group) set, and parent transforms are removed.
|
||||
|
||||
.. versionadded:: 1.4
|
||||
|
||||
Args:
|
||||
apply_to_paths (bool, optional): For path elements, the
|
||||
path data is transformed with its effective transform. Nodes and handles
|
||||
will have the same position as before, but visual appearance of the
|
||||
stroke may change (stroke-width is not touched). Defaults to True.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.transform: Transform
|
||||
for element in self:
|
||||
if isinstance(element, PathElement) and apply_to_paths:
|
||||
element.path = element.path.transform(self.transform)
|
||||
else:
|
||||
element.transform = self.transform @ element.transform
|
||||
if isinstance(element, GroupBase):
|
||||
element.bake_transforms_recursively(apply_to_paths)
|
||||
self.transform = None
|
||||
|
||||
|
||||
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.get("{http://www.inkscape.org/namespaces/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, ViewboxMixin):
|
||||
"""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"
|
||||
|
||||
def get_viewbox(self) -> List[float]:
|
||||
"""Returns the viewbox of the Marker, falling back to
|
||||
[0 0 markerWidth markerHeight]
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
vbox = self.get("viewBox", None)
|
||||
result = self.parse_viewbox(vbox)
|
||||
if result is None:
|
||||
# use viewport, https://www.w3.org/TR/SVG11/painting.html#MarkerElement
|
||||
return [
|
||||
0,
|
||||
0,
|
||||
self.to_dimensionless(self.get("markerWidth")),
|
||||
self.to_dimensionless(self.get("markerHeight")),
|
||||
]
|
||||
return result
|
||||
123
deps/inkex/elements/_image.py
vendored
Normal file
123
deps/inkex/elements/_image.py
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
# -*- 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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import urllib.request as urllib
|
||||
import urllib.parse as urlparse
|
||||
from base64 import encodebytes
|
||||
|
||||
|
||||
from ..base import InkscapeExtension
|
||||
from ..localization import inkex_gettext as _
|
||||
from ._polygons import RectangleBase
|
||||
|
||||
|
||||
class Image(RectangleBase):
|
||||
"""Provide a useful extension for image elements"""
|
||||
|
||||
tag_name = "image"
|
||||
|
||||
@staticmethod
|
||||
def _get_type(path, header):
|
||||
"""Basic magic header checker, returns mime type"""
|
||||
for head, mime in (
|
||||
(b"\x89PNG", "image/png"),
|
||||
(b"\xff\xd8", "image/jpeg"),
|
||||
(b"BM", "image/bmp"),
|
||||
(b"GIF87a", "image/gif"),
|
||||
(b"GIF89a", "image/gif"),
|
||||
(b"MM\x00\x2a", "image/tiff"),
|
||||
(b"II\x2a\x00", "image/tiff"),
|
||||
):
|
||||
if header.startswith(head):
|
||||
return mime
|
||||
|
||||
# ico files lack any magic... therefore we check the filename instead
|
||||
for ext, mime in (
|
||||
# official IANA registered MIME is 'image/vnd.microsoft.icon' tho
|
||||
(".ico", "image/x-icon"),
|
||||
(".svg", "image/svg+xml"),
|
||||
):
|
||||
if path.endswith(ext):
|
||||
return mime
|
||||
return None
|
||||
|
||||
def embed_image(self, file_path: str):
|
||||
""" "Embed the data of the selected Image Tag element.
|
||||
Relative image paths are interpreted relative to file_path.
|
||||
|
||||
.. versionadded:: 1.5
|
||||
|
||||
Args:
|
||||
file_path (str): Relative image paths are interpreted relative to file_path.
|
||||
"""
|
||||
xlink = self.get("xlink:href")
|
||||
if xlink is not None and xlink[:5] == "data:":
|
||||
# No need, data already embedded
|
||||
return
|
||||
if xlink is None:
|
||||
raise AttributeError(
|
||||
_('Attribute "xlink:href" not set on node {}.'.format(self.get_id()))
|
||||
)
|
||||
|
||||
url = urlparse.urlparse(xlink)
|
||||
href = urllib.url2pathname(url.path)
|
||||
|
||||
# Look relative to the *temporary* filename instead of the original filename.
|
||||
try:
|
||||
cwd = os.path.dirname(file_path)
|
||||
except TypeError:
|
||||
# input_file was actually stdin, fall back.
|
||||
cwd = None
|
||||
path = InkscapeExtension.absolute_href(href or "", cwd=cwd)
|
||||
|
||||
# Backup directory where we can find the image
|
||||
if not os.path.isfile(path):
|
||||
path = self.get("sodipodi:absref", path)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
raise ValueError(
|
||||
_('File not found "{}". Unable to embed image.').format(path)
|
||||
)
|
||||
return
|
||||
|
||||
with open(path, "rb") as handle:
|
||||
# Don't read the whole file to check the header
|
||||
file_type = self._get_type(path, handle.read(10))
|
||||
handle.seek(0)
|
||||
|
||||
if file_type:
|
||||
self.set(
|
||||
"xlink:href",
|
||||
"data:{};base64,{}".format(
|
||||
file_type, encodebytes(handle.read()).decode("ascii")
|
||||
),
|
||||
)
|
||||
self.pop("sodipodi:absref")
|
||||
else:
|
||||
raise ValueError(
|
||||
_(
|
||||
"%s is not of type image/png, image/jpeg, "
|
||||
"image/bmp, image/gif, image/tiff, or image/x-icon"
|
||||
)
|
||||
% path
|
||||
)
|
||||
499
deps/inkex/elements/_meta.py
vendored
Normal file
499
deps/inkex/elements/_meta.py
vendored
Normal file
@@ -0,0 +1,499 @@
|
||||
# -*- 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 List, Optional
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from inkex.deprecated.meta import deprecate
|
||||
|
||||
from ..styles import StyleSheet
|
||||
from ..transforms import BoundingBox, 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 add_guide(self, position, orient=True, name=None) -> Guide:
|
||||
"""Creates a new guide in this namedview
|
||||
|
||||
.. versionadded:: 1.3
|
||||
|
||||
Args:
|
||||
position: a float containing the y position for ``orient is True``, or
|
||||
the x position for ``orient is False``. The position is specified in the
|
||||
post-1.0 coordinate system, i.e. y=0 is at the top left of the viewbox,
|
||||
positive y axis pointing down.
|
||||
|
||||
Alternatively, the position may be given as Tuple (or VectorLike)
|
||||
orient: True for horizontal, False for Vertical
|
||||
|
||||
alternatively: Tuple / Vector specifying x and y coordinates of the
|
||||
normal vector of the guide, or the (clockwise) angle between the
|
||||
horizontal axis and the guide. Defaults to True (horizontal)
|
||||
name: label of the guide
|
||||
|
||||
Returns:
|
||||
the created guide"""
|
||||
elem = self.add(Guide())
|
||||
|
||||
if orient is True:
|
||||
elem.set_position(0, position, (0, -1))
|
||||
elif orient is False:
|
||||
elem.set_position(position, self.root.viewbox_height, (1, 0))
|
||||
else:
|
||||
pos = Vector2d(position)
|
||||
elem.set_position(pos.x, pos.y, orient)
|
||||
if name:
|
||||
elem.set("inkscape:label", str(name))
|
||||
return elem
|
||||
|
||||
@deprecate
|
||||
def new_guide(self, position, orient=True, name=None):
|
||||
"""
|
||||
.. deprecated:: 1.3
|
||||
Use :func:`add_guide` instead.
|
||||
|
||||
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)
|
||||
|
||||
@deprecate
|
||||
def new_unique_guide(
|
||||
self, position: Vector2d, orientation: Vector2d
|
||||
) -> Optional[Guide]:
|
||||
"""
|
||||
.. deprecated:: 1.3
|
||||
Use :func:`add_unique_guide` instead.
|
||||
|
||||
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 add_unique_guide(
|
||||
self, position: Vector2d, orientation: Vector2d
|
||||
) -> Optional[Guide]:
|
||||
"""Add a guide iif there is no guide that looks the same.
|
||||
|
||||
.. versionadded:: 1.3
|
||||
|
||||
Args:
|
||||
position: Position as Tuple / Vector
|
||||
orientation: Tuple / Vector specifying x and y coordinates of the normal
|
||||
vector of the guide.
|
||||
name: label of the guide
|
||||
"""
|
||||
elem = self.add(Guide()).set_position(position[0], position[1], orientation)
|
||||
if self.get_similar_guide(elem) is not None:
|
||||
self.remove(elem)
|
||||
return None
|
||||
return elem
|
||||
|
||||
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) and is not identity (same element) as the
|
||||
first one. 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) and guide != other:
|
||||
return guide
|
||||
return None
|
||||
|
||||
def _get_pages(self) -> List[Page]:
|
||||
"""Returns all page elements"""
|
||||
return self.findall("inkscape:page")
|
||||
|
||||
def _equivalent_page(self) -> Page:
|
||||
"""Returns an unrooted page based on the viewbox dimensions"""
|
||||
return Page.new(self.root.viewbox_width, self.root.viewbox_height, 0, 0)
|
||||
|
||||
def get_pages(self) -> List[Page]:
|
||||
"""Returns a list of pages within the document. For single page documents,
|
||||
a detached page element with dimensions according to the viewbox will be
|
||||
returned.
|
||||
|
||||
.. versionadded:: 1.2
|
||||
|
||||
.. versionchanged:: 1.3
|
||||
For single-page documents, this function now returns the viewbox
|
||||
dimensions.
|
||||
"""
|
||||
pages = self._get_pages()
|
||||
if len(pages) < 2:
|
||||
return [self._equivalent_page()]
|
||||
return pages
|
||||
|
||||
def new_page(self, x, y, width, height, label=None):
|
||||
"""Creates a new page in this namedview. Always add pages through this
|
||||
function to ensure that single-page documents are treated correctly.
|
||||
|
||||
.. versionadded:: 1.2
|
||||
|
||||
.. versionchanged:: 1.3
|
||||
If none exists, a page element with the viewbox dimensions will be
|
||||
inserted before the new page."""
|
||||
if len(self._get_pages()) == 0:
|
||||
self.add(self._equivalent_page())
|
||||
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, in the pre-1.0 coordinate system (y axis upwards)
|
||||
|
||||
.. versionadded:: 1.2"""
|
||||
return Vector2d(self.get("orientation"), fallback=(1, 0))
|
||||
|
||||
@property
|
||||
def angle(self) -> float:
|
||||
"""(Clockwise) angle between the guide and the horizontal axis in degrees
|
||||
(i.e. what Inkscape 1.2+ shows as "Angle" in the guide properties)
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
return math.degrees(math.atan2(*self.orientation))
|
||||
|
||||
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 raw_position(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))
|
||||
|
||||
def point(self):
|
||||
"""Use raw_position or position instead"""
|
||||
return self.raw_position
|
||||
|
||||
point = property(deprecate(point)) # type: ignore
|
||||
|
||||
@property
|
||||
def position(self) -> Vector2d:
|
||||
"""Position of the guide handle in normal coordinates, i.e. (0,0) is at
|
||||
the top left corner of the viewbox, positive y axis pointing downwards.
|
||||
|
||||
This function can only be used for guides which are attached to a root
|
||||
svg element."""
|
||||
pos = self.raw_position
|
||||
return Vector2d(pos.x, self.root.viewbox_height - pos.y)
|
||||
|
||||
@classmethod
|
||||
def new(cls, pos_x, pos_y, angle, **attrs):
|
||||
guide = super().new(**attrs)
|
||||
guide.set_position(pos_x, pos_y, angle=angle)
|
||||
return guide
|
||||
|
||||
def set_position(self, pos_x, pos_y, angle=None):
|
||||
"""
|
||||
Move this guide to the given x,y position and optionally set its orientation.
|
||||
The coordinate system used is the post-1.0 coordinate system (origin in the
|
||||
top left corner, y axis pointing down), which also defines the sense of
|
||||
rotation.
|
||||
|
||||
The guide must be rooted for this function to be used. Preferably, use
|
||||
:func:`inkex.elements._meta.add_guide` to create a new guide.
|
||||
|
||||
.. versionadded:: 1.3
|
||||
|
||||
Args:
|
||||
pos_x (Union[str, int, float]): x position of the guide's reference point
|
||||
pos_y (Union[str, int, float]): y position of the guide's reference point
|
||||
angle (Union[str, float, int, tuple, list], optional): Angle may be a
|
||||
string, float or integer, which will set the clockwise angle between the
|
||||
horizontal axis and the guide.
|
||||
|
||||
Alternatively, it may be a pair of numbers (tuple) which will be set
|
||||
as normal vector.
|
||||
|
||||
If not given at all, the orientation remains unchanged.
|
||||
Defaults to None.
|
||||
|
||||
Returns:
|
||||
Guide: the modified guide
|
||||
"""
|
||||
pos_y = self.root.viewbox_height - float(pos_y)
|
||||
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[0], -angle[1]])
|
||||
|
||||
if angle is not None:
|
||||
self.set("orientation", angle)
|
||||
return self
|
||||
|
||||
@deprecate
|
||||
def move_to(self, pos_x, pos_y, angle=None):
|
||||
"""
|
||||
.. deprecated:: 1.3
|
||||
Use :func:`set_position` instead.
|
||||
|
||||
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.raw_position
|
||||
opos = guide2.raw_position
|
||||
|
||||
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):
|
||||
"""Resource Description Framework (RDF) metadata"""
|
||||
|
||||
tag_name = "metadata"
|
||||
|
||||
doc_title = property(lambda self: self._first_text("dc:title"))
|
||||
description = property(lambda self: self._first_text("dc:description"))
|
||||
|
||||
rights = property(lambda self: self._first_text("dc:rights/cc:Agent/dc:title"))
|
||||
creator = property(lambda self: self._first_text("dc:creator/cc:Agent/dc:title"))
|
||||
publisher = property(
|
||||
lambda self: self._first_text("dc:publisher/cc:Agent/dc:title")
|
||||
)
|
||||
contributor = property(
|
||||
lambda self: self._first_text("dc:contributor/cc:Agent/dc:title")
|
||||
)
|
||||
|
||||
date = property(lambda self: self._first_text("dc:date"))
|
||||
source = property(lambda self: self._first_text("dc:source"))
|
||||
language = property(lambda self: self._first_text("dc:language"))
|
||||
relation = property(lambda self: self._first_text("dc:relation"))
|
||||
coverage = property(lambda self: self._first_text("dc:coverage"))
|
||||
identifier = property(lambda self: self._first_text("dc:identifier"))
|
||||
|
||||
def _first_text(self, loc):
|
||||
"""Get the work title"""
|
||||
elem = self.findone(f"rdf:RDF/cc:Work/{loc}")
|
||||
if elem:
|
||||
return elem.text
|
||||
return None
|
||||
|
||||
@property
|
||||
def tags(self):
|
||||
return [
|
||||
elem.text
|
||||
for elem in self.findall("rdf:RDF/cc:Work/dc:subject/rdf:Bag/rdf:li")
|
||||
]
|
||||
|
||||
|
||||
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("x", f"{float(x):g}")
|
||||
self.set("y", f"{float(y):g}")
|
||||
return self
|
||||
|
||||
@property
|
||||
def bounding_box(self) -> BoundingBox:
|
||||
"""Returns the bounding box of the page."""
|
||||
return BoundingBox(
|
||||
(self.x, self.x + self.width), (self.y, self.y + self.height)
|
||||
)
|
||||
130
deps/inkex/elements/_parser.py
vendored
Normal file
130
deps/inkex/elements/_parser.py
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
# -*- 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, TYPE_CHECKING
|
||||
|
||||
from lxml import etree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..elements._base import BaseElement
|
||||
|
||||
from ._utils import splitNS, addNS
|
||||
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["BaseElement"]
|
||||
|
||||
# (ns,tag) -> list(cls) ; ascending priority
|
||||
lookup_table: DefaultDict[str, List[Any]] = defaultdict()
|
||||
|
||||
@classmethod
|
||||
def register_class(cls, klass):
|
||||
"""Register the given class using it's attached tag name"""
|
||||
key = addNS(*splitNS(klass.tag_name)[::-1])
|
||||
old = cls.lookup_table.get(key, [])
|
||||
old.append(klass)
|
||||
cls.lookup_table[key] = old
|
||||
|
||||
@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[addNS(*splitNS(xpath.split("/")[-1])[::-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:
|
||||
try:
|
||||
options = self.lookup_table[element.tag]
|
||||
except KeyError:
|
||||
if not element.tag.startswith("{"):
|
||||
tag = addNS(*splitNS(element.tag)[::-1])
|
||||
options = self.lookup_table[tag]
|
||||
else:
|
||||
return self.default
|
||||
for kls in reversed(options):
|
||||
if kls.is_class_element(element): # pylint: disable=protected-access
|
||||
return kls
|
||||
|
||||
except AttributeError:
|
||||
# Handle <!-- Comment -->
|
||||
return None
|
||||
return self.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 occurred, 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(
|
||||
_(
|
||||
"{error_message}. Line {line_number}, column {column_number}",
|
||||
).format(
|
||||
error_message=element.message,
|
||||
line_number=element.line,
|
||||
column_number=element.column,
|
||||
)
|
||||
)
|
||||
errormsg(
|
||||
_(
|
||||
"\nProcessing will continue; however we encourage you to fix your"
|
||||
" file manually."
|
||||
)
|
||||
)
|
||||
return parsed
|
||||
587
deps/inkex/elements/_polygons.py
vendored
Normal file
587
deps/inkex/elements/_polygons.py
vendored
Normal file
@@ -0,0 +1,587 @@
|
||||
# -*- 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 __future__ import annotations
|
||||
|
||||
from math import cos, pi, sin
|
||||
import math
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
from ..paths.interfaces import PathCommand
|
||||
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"""
|
||||
|
||||
def get_path(self) -> Path:
|
||||
"""Gets the path of the element, which can also be used in a context manager"""
|
||||
p = Path(self.get("d"))
|
||||
p.callback = self.set_path
|
||||
return p
|
||||
|
||||
@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"
|
||||
|
||||
MAX_ARC_SUBDIVISIONS = 4
|
||||
|
||||
@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), PathElement.MAX_ARC_SUBDIVISIONS)
|
||||
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,
|
||||
incr > pi,
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def arc_from_3_points(
|
||||
cls,
|
||||
x: complex,
|
||||
y: complex,
|
||||
z: complex,
|
||||
arctype="slice",
|
||||
) -> PathElement:
|
||||
"""
|
||||
Create an arc through the points x, y, z.
|
||||
If those points are specified clockwise, the order is not preserved.
|
||||
This is indicated with the second return value (=clockwise)
|
||||
|
||||
Returns a PathElement. May be a line if x,y,z are collinear.
|
||||
|
||||
|
||||
Idea: http://www.math.okstate.edu/~wrightd/INDRA/MobiusonCircles/node4.html
|
||||
|
||||
.. versionadded:: 1.4
|
||||
"""
|
||||
w = (z - x) / (y - x)
|
||||
if abs(w.imag) > 1e-12:
|
||||
c = -((x - y) * (w - abs(w) ** 2) / (2j * w.imag) - x)
|
||||
r = abs(c - x)
|
||||
|
||||
# Now determine the arc flags by checking the angles
|
||||
deltas = [x - c, y - c, z - c]
|
||||
ang = [math.atan2(i.imag, i.real) for i in deltas]
|
||||
# Check if the angles are "in order"
|
||||
cw = int(any(ang[0 + i] < ang[-2 + i] < ang[-1 + i] for i in range(3)))
|
||||
if not cw:
|
||||
# Flip start and end angle
|
||||
ang = ang[::-1]
|
||||
|
||||
return cls.arc(Vector2d(c), r, r, start=ang[0], end=ang[2], arctype=arctype)
|
||||
else:
|
||||
# Points lie on a line
|
||||
# y between x and z -> draw a line, otherwise skip
|
||||
if x.real <= y.real <= z.real or x.real >= y.real >= z.real:
|
||||
return cls.new(Path([Move(x), PathLine(z)]))
|
||||
else:
|
||||
return cls.new(Path([Move(x), Move(z)]))
|
||||
|
||||
@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) -> Path:
|
||||
p = Path("M" + self.get("points"))
|
||||
p.callback = self.set_path
|
||||
return p
|
||||
|
||||
def set_path(self, path):
|
||||
if type(path) != Path:
|
||||
path = Path("M" + str(path))
|
||||
points = [f"{x:g},{y:g}" for x, y in path.end_points]
|
||||
self.set("points", " ".join(points))
|
||||
|
||||
@classmethod
|
||||
def new(cls, points=None, **attrs):
|
||||
p = super().new(**attrs)
|
||||
p.path = points
|
||||
return p
|
||||
|
||||
|
||||
class Polygon(Polyline):
|
||||
"""A closed polyline"""
|
||||
|
||||
tag_name = "polygon"
|
||||
|
||||
def get_path(self) -> Path:
|
||||
p = Path("M" + self.get("points") + " Z")
|
||||
p.callback = self.set_path
|
||||
return p
|
||||
|
||||
|
||||
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) -> Path:
|
||||
"""Calculate the path as the box around the rect"""
|
||||
if self.rx or self.ry:
|
||||
# pylint: disable=invalid-name
|
||||
rx = min(self.rx if self.rx > 0 else self.ry, self.width / 2)
|
||||
ry = min(self.ry if self.ry > 0 else self.rx, self.height / 2)
|
||||
cpts = [self.left + rx, self.right - rx, self.top + ry, self.bottom - ry]
|
||||
return Path(
|
||||
f"M {cpts[0]},{self.top}"
|
||||
f"L {cpts[1]},{self.top} "
|
||||
f"A {rx},{ry} 0 0 1 {self.right},{cpts[2]}"
|
||||
f"L {self.right},{cpts[3]} "
|
||||
f"A {rx},{ry} 0 0 1 {cpts[1]},{self.bottom}"
|
||||
f"L {cpts[0]},{self.bottom} "
|
||||
f"A {rx},{ry} 0 0 1 {self.left},{cpts[3]}"
|
||||
f"L {self.left},{cpts[2]} "
|
||||
f"A {rx},{ry} 0 0 1 {cpts[0]},{self.top} z"
|
||||
)
|
||||
|
||||
return Path(
|
||||
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) -> Path:
|
||||
"""Calculate the arc path of this circle"""
|
||||
rx, ry = self.rxry()
|
||||
cx, y = self.center.x, self.center.y - ry
|
||||
return Path(
|
||||
(
|
||||
"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
|
||||
237
deps/inkex/elements/_selected.py
vendored
Normal file
237
deps/inkex/elements/_selected.py
vendored
Normal file
@@ -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, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..elements._base import BaseElement
|
||||
|
||||
from ..elements import _base
|
||||
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, _base.BaseElement):
|
||||
# Selection is a list of elements to select
|
||||
key = elem.xml_path
|
||||
element_id = elem.get("id")
|
||||
if element_id is not None:
|
||||
self.ids[element_id] = key
|
||||
super().__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, "BaseElement", 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, _base.BaseElement):
|
||||
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, (_base.BaseElement, 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
|
||||
479
deps/inkex/elements/_svg.py
vendored
Normal file
479
deps/inkex/elements/_svg.py
vendored
Normal file
@@ -0,0 +1,479 @@
|
||||
# -*- 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
|
||||
from functools import cached_property
|
||||
|
||||
from lxml import etree
|
||||
|
||||
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, ConditionalStyle
|
||||
|
||||
from ._base import BaseElement, ViewboxMixin
|
||||
from ._meta import StyleElement, NamedView
|
||||
from ._utils import registerNS, addNS, splitNS
|
||||
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
if False: # pylint: disable=using-constant-test
|
||||
import typing # pylint: disable=unused-import
|
||||
|
||||
|
||||
class SvgDocumentElement(DeprecatedSvgMixin, BaseElement, ViewboxMixin):
|
||||
"""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)
|
||||
|
||||
@cached_property
|
||||
def ids(self):
|
||||
result = {}
|
||||
for el in self.iter():
|
||||
try:
|
||||
id = super(etree.ElementBase, el).get("id", None)
|
||||
if id is not None:
|
||||
result[id] = el
|
||||
|
||||
el._root = self
|
||||
except TypeError:
|
||||
pass # Comments
|
||||
return result
|
||||
|
||||
@cached_property
|
||||
def stylesheet_cache(self):
|
||||
return {node: node.stylesheet() for node in self.xpath("//svg:style")}
|
||||
|
||||
def tostring(self):
|
||||
"""Convert document to string"""
|
||||
return etree.tostring(etree.ElementTree(self))
|
||||
|
||||
def get_ids(self):
|
||||
"""Returns a set of unique document ids"""
|
||||
return self.ids.keys()
|
||||
|
||||
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)
|
||||
new_id = None
|
||||
_from = 10**size - 1
|
||||
_to = 10**size
|
||||
while (
|
||||
new_id is None
|
||||
or new_id in ids
|
||||
or (blacklist is not None and new_id in blacklist)
|
||||
):
|
||||
# Do not use randint because py2/3 incompatibility
|
||||
new_id = prefix + str(int(random.random() * _from - _to) + _to)
|
||||
return new_id
|
||||
|
||||
def get_page_bbox(self, page=None) -> BoundingBox:
|
||||
"""Gets the page dimensions as a bbox. For single-page documents, the viewbox
|
||||
dimensions are returned.
|
||||
|
||||
Args:
|
||||
page (int, optional): Page number. Defaults to the first page.
|
||||
|
||||
.. versionadded:: 1.3
|
||||
|
||||
Raises:
|
||||
IndexError: if the page number provided does not exist in the document.
|
||||
|
||||
Returns:
|
||||
BoundingBox: the bounding box of the page
|
||||
"""
|
||||
if page is None:
|
||||
page = 0
|
||||
pages = self.namedview.get_pages()
|
||||
if 0 <= page < len(pages):
|
||||
return pages[page].bounding_box
|
||||
raise IndexError("Invalid page number")
|
||||
|
||||
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 add_namespace(self, prefix, url):
|
||||
"""Adds an xml namespace to the xml parser with the desired prefix.
|
||||
|
||||
If the prefix or url are already in use with different values, this
|
||||
function will raise an error. Remove any attributes or elements using
|
||||
this namespace before calling this function in order to rename it.
|
||||
|
||||
.. versionadded:: 1.3
|
||||
"""
|
||||
if self.nsmap.get(prefix, None) == url:
|
||||
registerNS(prefix, url)
|
||||
return
|
||||
|
||||
# Attempt to clean any existing namespaces
|
||||
if prefix in self.nsmap or url in self.nsmap.values():
|
||||
nskeep = [k for k, v in self.nsmap.items() if k != prefix and v != url]
|
||||
etree.cleanup_namespaces(self, keep_ns_prefixes=nskeep)
|
||||
if prefix in self.nsmap:
|
||||
raise KeyError("ns prefix already used with a different url")
|
||||
if url in self.nsmap.values():
|
||||
raise ValueError("ns url already used with a different prefix")
|
||||
|
||||
# These are globals, but both will overwrite previous uses.
|
||||
registerNS(prefix, url)
|
||||
etree.register_namespace(prefix, url)
|
||||
|
||||
# Set and unset an attribute to add the namespace to this root element.
|
||||
self.set(f"{prefix}:temp", "1")
|
||||
self.set(f"{prefix}:temp", None)
|
||||
|
||||
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("#")
|
||||
|
||||
result = self.ids.get(eid, None)
|
||||
if result is not None:
|
||||
if elm != "*":
|
||||
elm_with_ns = addNS(*splitNS(elm)[::-1])
|
||||
if not super(etree.ElementBase, result).tag == elm_with_ns:
|
||||
return None
|
||||
return result
|
||||
return None
|
||||
|
||||
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 ConditionalStyle(f".{class_name}").all_matches(self)
|
||||
|
||||
def getElementsByHref(self, eid: str, attribute="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: "href", "xlink:href", "mask", "clip-path".
|
||||
Defaults to "href".
|
||||
|
||||
.. versionadded:: 1.2
|
||||
|
||||
attribute set to "href" or "xlink:href" handles both cases.
|
||||
.. versionchanged:: 1.3
|
||||
|
||||
Returns:
|
||||
Any: list of elements
|
||||
"""
|
||||
if attribute == "href" or attribute == "xlink:href":
|
||||
return self.xpath(f'//*[@href|@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) -> NamedView:
|
||||
"""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) -> List[float]:
|
||||
"""Parse and return the document's viewBox attribute"""
|
||||
return self.parse_viewbox(self.get("viewBox", "0")) or [0, 0, 0, 0]
|
||||
|
||||
@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()
|
||||
for value in self.stylesheet_cache.values():
|
||||
sheets.append(value)
|
||||
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 add_to_tree_callback(self, element):
|
||||
"""Callback called automatically when adding an element to the tree.
|
||||
Updates the list of stylesheets and the ID tracker with the subtree of element.
|
||||
|
||||
.. versionadded:: 1.4
|
||||
|
||||
Args:
|
||||
element (BaseElement): element added to the tree.
|
||||
"""
|
||||
|
||||
for el in element.iter():
|
||||
self._add_individual_to_tree(el)
|
||||
|
||||
def _add_individual_to_tree(self, element: BaseElement):
|
||||
if isinstance(element, etree._Comment):
|
||||
return
|
||||
element._root = self
|
||||
if element.TAG == "style":
|
||||
self.stylesheet_cache[element] = element.stylesheet()
|
||||
new_id = element.get("id", None)
|
||||
if new_id is not None:
|
||||
if new_id in self.ids:
|
||||
while new_id in self.ids:
|
||||
new_id += "-1"
|
||||
super(etree.ElementBase, element).set("id", new_id) # type: ignore
|
||||
self.ids[new_id] = element
|
||||
|
||||
def remove_from_tree_callback(self, element):
|
||||
""" "Callback called automatically when removing an element from the tree.
|
||||
Remove elements in the subtree of element from the the list of stylesheets
|
||||
and the ID tracker.
|
||||
|
||||
.. versionadded:: 1.4
|
||||
|
||||
Args:
|
||||
element (BaseElement): element added to the tree.
|
||||
"""
|
||||
for el in element.iter():
|
||||
self._remove_individual_from_tree(el)
|
||||
|
||||
def _remove_individual_from_tree(self, element):
|
||||
if isinstance(element, etree._Comment):
|
||||
return
|
||||
element._root = None
|
||||
if element.TAG == "style" and element in self.stylesheet_cache:
|
||||
self.stylesheet_cache.remove(element)
|
||||
old_id = element.get("id", None)
|
||||
if old_id is not None and old_id in self.ids:
|
||||
self.ids.pop(old_id)
|
||||
|
||||
|
||||
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"))
|
||||
249
deps/inkex/elements/_text.py
vendored
Normal file
249
deps/inkex/elements/_text.py
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
# -*- 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 and their tail"""
|
||||
# Stack of node and depth
|
||||
stack = [(self, 0)]
|
||||
result = []
|
||||
|
||||
def poptail():
|
||||
"""Pop the tail from the tail stack and add it if necessary to results"""
|
||||
tail = tail_stack.pop()
|
||||
if tail is not None:
|
||||
result.append(tail)
|
||||
|
||||
# Stack of the tail of nodes
|
||||
tail_stack = []
|
||||
previous_depth = -1
|
||||
while stack:
|
||||
# Get current node and depth
|
||||
node, depth = stack.pop()
|
||||
|
||||
# Pop the previous tail if the depth do not increase
|
||||
if previous_depth >= depth:
|
||||
poptail()
|
||||
|
||||
# Pop as many times as the depth is reduced
|
||||
for _ in range(previous_depth - depth):
|
||||
poptail()
|
||||
|
||||
# Add a node text to the result, if any if node is text or tspan
|
||||
if node.text and node.TAG in ["text", "tspan"]:
|
||||
result.append(node.text)
|
||||
|
||||
# Add child elements
|
||||
stack.extend(
|
||||
map(
|
||||
lambda tspan: (tspan, depth + 1),
|
||||
node.iterchildren(reversed=True),
|
||||
)
|
||||
)
|
||||
|
||||
# Add the tail from node to the stack
|
||||
tail_stack.append(node.tail)
|
||||
|
||||
previous_depth = depth
|
||||
|
||||
# Pop remaining tail elements
|
||||
# Tail of the main text element should not be included
|
||||
while len(tail_stack) > 1:
|
||||
poptail()
|
||||
|
||||
return sep.join(result)
|
||||
|
||||
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"
|
||||
89
deps/inkex/elements/_use.py
vendored
Normal file
89
deps/inkex/elements/_use.py
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- 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
|
||||
|
||||
.. versionchanged:: 1.3
|
||||
include transform of the referenced element
|
||||
"""
|
||||
path = self.href.path
|
||||
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
|
||||
# Preserve the id of the clone to not break links that link the <use>
|
||||
# As we replace exactly one element by exactly one, this should be safe.
|
||||
old_id = self.get_id()
|
||||
self.replace_with(copy)
|
||||
copy.set_random_ids()
|
||||
copy.set_id(old_id)
|
||||
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)
|
||||
150
deps/inkex/elements/_utils.py
vendored
Normal file
150
deps/inkex/elements/_utils.py
vendored
Normal file
@@ -0,0 +1,150 @@
|
||||
# -*- 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 registerNS(prefix, url):
|
||||
"""Register the given prefix as a namespace url."""
|
||||
NSS[prefix] = url
|
||||
SSN[url] = prefix
|
||||
|
||||
|
||||
def addNS(tag, ns=None, namespaces=NSS): # 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 = namespaces.get(ns, None) or ns
|
||||
if ns is not None:
|
||||
return f"{{{ns}}}{tag}"
|
||||
return tag
|
||||
|
||||
|
||||
def removeNS(name, reverse_namespaces=SSN, default="svg"): # 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 reverse_namespaces.get(url, default), tag
|
||||
if ":" in name:
|
||||
return name.rsplit(":", 1)
|
||||
return default, name
|
||||
|
||||
|
||||
def splitNS(name, namespaces=NSS): # pylint: disable=invalid-name
|
||||
"""Like removeNS, but returns a url instead of a prefix"""
|
||||
(prefix, tag) = removeNS(name)
|
||||
return (namespaces[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)
|
||||
Reference in New Issue
Block a user