summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex
diff options
context:
space:
mode:
Diffstat (limited to 'share/extensions/inkex')
-rw-r--r--share/extensions/inkex/__init__.py29
-rw-r--r--share/extensions/inkex/__pycache__/__init__.cpython-39.pycbin0 -> 786 bytes
-rw-r--r--share/extensions/inkex/__pycache__/base.cpython-39.pycbin0 -> 11943 bytes
-rw-r--r--share/extensions/inkex/__pycache__/bezier.cpython-39.pycbin0 -> 11855 bytes
-rw-r--r--share/extensions/inkex/__pycache__/colors.cpython-39.pycbin0 -> 13508 bytes
-rw-r--r--share/extensions/inkex/__pycache__/command.cpython-39.pycbin0 -> 6499 bytes
-rw-r--r--share/extensions/inkex/__pycache__/deprecated.cpython-39.pycbin0 -> 15884 bytes
-rw-r--r--share/extensions/inkex/__pycache__/extensions.cpython-39.pycbin0 -> 12533 bytes
-rw-r--r--share/extensions/inkex/__pycache__/inx.cpython-39.pycbin0 -> 6299 bytes
-rw-r--r--share/extensions/inkex/__pycache__/localization.cpython-39.pycbin0 -> 1141 bytes
-rw-r--r--share/extensions/inkex/__pycache__/paths.cpython-39.pycbin0 -> 48895 bytes
-rw-r--r--share/extensions/inkex/__pycache__/ports.cpython-39.pycbin0 -> 4246 bytes
-rw-r--r--share/extensions/inkex/__pycache__/styles.cpython-39.pycbin0 -> 14008 bytes
-rw-r--r--share/extensions/inkex/__pycache__/transforms.cpython-39.pycbin0 -> 34865 bytes
-rw-r--r--share/extensions/inkex/__pycache__/turtle.cpython-39.pycbin0 -> 3808 bytes
-rw-r--r--share/extensions/inkex/__pycache__/tween.cpython-39.pycbin0 -> 1686 bytes
-rw-r--r--share/extensions/inkex/__pycache__/units.cpython-39.pycbin0 -> 2432 bytes
-rw-r--r--share/extensions/inkex/__pycache__/utils.cpython-39.pycbin0 -> 10503 bytes
-rw-r--r--share/extensions/inkex/base.py355
-rw-r--r--share/extensions/inkex/bezier.py425
-rw-r--r--share/extensions/inkex/colors.py478
-rw-r--r--share/extensions/inkex/command.py222
-rw-r--r--share/extensions/inkex/deprecated-simple/README.rst4
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/bezmisc.cpython-39.pycbin0 -> 1138 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pycbin0 -> 348 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pycbin0 -> 1118 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pycbin0 -> 3171 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pycbin0 -> 1808 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pycbin0 -> 1555 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pycbin0 -> 4003 bytes
-rw-r--r--share/extensions/inkex/deprecated-simple/bezmisc.py44
-rw-r--r--share/extensions/inkex/deprecated-simple/cspsubdiv.py25
-rw-r--r--share/extensions/inkex/deprecated-simple/cubicsuperpath.py46
-rw-r--r--share/extensions/inkex/deprecated-simple/ffgeom.py87
-rwxr-xr-xshare/extensions/inkex/deprecated-simple/run_command.py74
-rw-r--r--share/extensions/inkex/deprecated-simple/simplepath.py51
-rw-r--r--share/extensions/inkex/deprecated-simple/simplestyle.py47
-rw-r--r--share/extensions/inkex/deprecated-simple/simpletransform.py106
-rw-r--r--share/extensions/inkex/deprecated.py388
-rw-r--r--share/extensions/inkex/elements/__init__.py19
-rw-r--r--share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pycbin0 -> 1541 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_base.cpython-39.pycbin0 -> 18505 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pycbin0 -> 10723 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pycbin0 -> 3488 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_image.cpython-39.pycbin0 -> 468 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pycbin0 -> 5469 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pycbin0 -> 9772 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pycbin0 -> 6660 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pycbin0 -> 8222 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_text.cpython-39.pycbin0 -> 6256 bytes
-rw-r--r--share/extensions/inkex/elements/__pycache__/_use.cpython-39.pycbin0 -> 2048 bytes
-rw-r--r--share/extensions/inkex/elements/_base.py514
-rw-r--r--share/extensions/inkex/elements/_filters.py276
-rw-r--r--share/extensions/inkex/elements/_groups.py112
-rw-r--r--share/extensions/inkex/elements/_image.py27
-rw-r--r--share/extensions/inkex/elements/_meta.py147
-rw-r--r--share/extensions/inkex/elements/_polygons.py231
-rw-r--r--share/extensions/inkex/elements/_selected.py159
-rw-r--r--share/extensions/inkex/elements/_svg.py211
-rw-r--r--share/extensions/inkex/elements/_text.py159
-rw-r--r--share/extensions/inkex/elements/_use.py70
-rw-r--r--share/extensions/inkex/extensions.py344
-rw-r--r--share/extensions/inkex/inx.py166
-rw-r--r--share/extensions/inkex/localization.py66
-rw-r--r--share/extensions/inkex/paths.py1547
-rw-r--r--share/extensions/inkex/ports.py100
-rw-r--r--share/extensions/inkex/styles.py380
-rw-r--r--share/extensions/inkex/tester/__init__.py384
-rw-r--r--share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pycbin0 -> 13791 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pycbin0 -> 388 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/filters.cpython-39.pycbin0 -> 6337 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/inx.cpython-39.pycbin0 -> 3340 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/mock.cpython-39.pycbin0 -> 13699 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/svg.cpython-39.pycbin0 -> 1279 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/word.cpython-39.pycbin0 -> 841 bytes
-rw-r--r--share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pycbin0 -> 3619 bytes
-rw-r--r--share/extensions/inkex/tester/decorators.py8
-rw-r--r--share/extensions/inkex/tester/filters.py139
-rw-r--r--share/extensions/inkex/tester/inx.py95
-rw-r--r--share/extensions/inkex/tester/mock.py414
-rw-r--r--share/extensions/inkex/tester/svg.py49
-rw-r--r--share/extensions/inkex/tester/word.py38
-rw-r--r--share/extensions/inkex/tester/xmldiff.py113
-rw-r--r--share/extensions/inkex/transforms.py1084
-rw-r--r--share/extensions/inkex/turtle.py120
-rw-r--r--share/extensions/inkex/tween.py79
-rw-r--r--share/extensions/inkex/units.py107
-rw-r--r--share/extensions/inkex/utils.py290
88 files changed, 9829 insertions, 0 deletions
diff --git a/share/extensions/inkex/__init__.py b/share/extensions/inkex/__init__.py
new file mode 100644
index 0000000..36884fc
--- /dev/null
+++ b/share/extensions/inkex/__init__.py
@@ -0,0 +1,29 @@
+# coding=utf-8
+"""
+This describes the core API for the inkex core modules.
+
+This provides the basis from which you can develop your inkscape extension.
+"""
+
+# pylint: disable=wildcard-import
+from __future__ import print_function
+
+from .extensions import *
+from .utils import *
+from .styles import *
+from .paths import Path, CubicSuperPath # Path commands are not exported
+from .colors import *
+from .transforms import *
+from .elements import *
+
+# legacy proxies
+from .deprecated import Effect
+from .deprecated import optparse
+from .deprecated import InkOption
+from .deprecated import etree
+from .deprecated import localize
+from .deprecated import debug
+
+# legacy functions
+from .deprecated import are_near_relative
+from .deprecated import unittouu
diff --git a/share/extensions/inkex/__pycache__/__init__.cpython-39.pyc b/share/extensions/inkex/__pycache__/__init__.cpython-39.pyc
new file mode 100644
index 0000000..3e3e90f
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/__init__.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/base.cpython-39.pyc b/share/extensions/inkex/__pycache__/base.cpython-39.pyc
new file mode 100644
index 0000000..0fad73c
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/base.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/bezier.cpython-39.pyc b/share/extensions/inkex/__pycache__/bezier.cpython-39.pyc
new file mode 100644
index 0000000..c29a13e
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/bezier.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/colors.cpython-39.pyc b/share/extensions/inkex/__pycache__/colors.cpython-39.pyc
new file mode 100644
index 0000000..54725f1
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/colors.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/command.cpython-39.pyc b/share/extensions/inkex/__pycache__/command.cpython-39.pyc
new file mode 100644
index 0000000..544fd56
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/command.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/deprecated.cpython-39.pyc b/share/extensions/inkex/__pycache__/deprecated.cpython-39.pyc
new file mode 100644
index 0000000..778ef50
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/deprecated.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/extensions.cpython-39.pyc b/share/extensions/inkex/__pycache__/extensions.cpython-39.pyc
new file mode 100644
index 0000000..07ba857
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/extensions.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/inx.cpython-39.pyc b/share/extensions/inkex/__pycache__/inx.cpython-39.pyc
new file mode 100644
index 0000000..3ffedb9
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/inx.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/localization.cpython-39.pyc b/share/extensions/inkex/__pycache__/localization.cpython-39.pyc
new file mode 100644
index 0000000..6bfaac4
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/localization.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/paths.cpython-39.pyc b/share/extensions/inkex/__pycache__/paths.cpython-39.pyc
new file mode 100644
index 0000000..c47438d
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/paths.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/ports.cpython-39.pyc b/share/extensions/inkex/__pycache__/ports.cpython-39.pyc
new file mode 100644
index 0000000..039e6d9
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/ports.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/styles.cpython-39.pyc b/share/extensions/inkex/__pycache__/styles.cpython-39.pyc
new file mode 100644
index 0000000..d99135a
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/styles.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/transforms.cpython-39.pyc b/share/extensions/inkex/__pycache__/transforms.cpython-39.pyc
new file mode 100644
index 0000000..3be6188
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/transforms.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/turtle.cpython-39.pyc b/share/extensions/inkex/__pycache__/turtle.cpython-39.pyc
new file mode 100644
index 0000000..c2f622d
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/turtle.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/tween.cpython-39.pyc b/share/extensions/inkex/__pycache__/tween.cpython-39.pyc
new file mode 100644
index 0000000..0199ec1
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/tween.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/units.cpython-39.pyc b/share/extensions/inkex/__pycache__/units.cpython-39.pyc
new file mode 100644
index 0000000..2619f10
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/units.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/__pycache__/utils.cpython-39.pyc b/share/extensions/inkex/__pycache__/utils.cpython-39.pyc
new file mode 100644
index 0000000..a095603
--- /dev/null
+++ b/share/extensions/inkex/__pycache__/utils.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/base.py b/share/extensions/inkex/base.py
new file mode 100644
index 0000000..09d51c1
--- /dev/null
+++ b/share/extensions/inkex/base.py
@@ -0,0 +1,355 @@
+# coding=utf-8
+#
+# Copyright (c) 2018 - Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+The ultimate base functionality for every Inkscape extension.
+"""
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import sys
+import copy
+import shutil
+
+from argparse import ArgumentParser, Namespace
+from lxml import etree
+
+from .utils import PY3, filename_arg, AbortExtension, ABORT_STATUS, errormsg, do_nothing
+from .elements._base import load_svg, BaseElement # pylint: disable=unused-import
+from .localization import localize
+
+stdout = sys.stdout
+
+try:
+ from typing import (List, Tuple, Type, Optional, Callable, Any, Union, IO,
+ TYPE_CHECKING, cast)
+except ImportError:
+ cast = lambda x, y: y
+ TYPE_CHECKING = False
+
+if PY3:
+ unicode = str # pylint: disable=redefined-builtin,invalid-name
+ basestring = str # pylint: disable=redefined-builtin,invalid-name
+ stdout = sys.stdout.buffer # type: ignore
+
+
+class InkscapeExtension(object):
+ """
+ The base class extension, provides argument parsing and basic
+ variable handling features.
+ """
+ multi_inx = False # Set to true if this class is used by multiple inx files.
+
+ def __init__(self):
+ # type: () -> None
+ self.file_io = None # type: Optional[IO]
+ self.options = Namespace()
+ self.document = None # type: Union[None, bytes, str, unicode, etree]
+ self.arg_parser = ArgumentParser(description=self.__doc__)
+
+ self.arg_parser.add_argument(
+ "input_file", nargs="?", metavar="INPUT_FILE", type=filename_arg,
+ help="Filename of the input file (default is stdin)", default=None)
+
+ self.arg_parser.add_argument(
+ "--output", type=str, default=None,
+ help="Optional output filename for saving the result (default is stdout).")
+
+ self.add_arguments(self.arg_parser)
+
+ localize()
+
+ def add_arguments(self, pars):
+ # type: (ArgumentParser) -> None
+ """Add any extra arguments to your extension handle, use:
+
+ def add_arguments(self, pars):
+ pars.add_argument("--num-cool-things", type=int, default=3)
+ pars.add_argument("--pos-in-doc", type=str, default="doobry")
+ """
+ pass # No extra arguments by default so super is not required
+
+ def parse_arguments(self, args):
+ # type: (List[str]) -> None
+ """Parse the given arguments and set 'self.options'"""
+ self.options = self.arg_parser.parse_args(args)
+
+ def arg_method(self, prefix='method'):
+ # type: (str) -> Callable[[str], Callable[[Any], Any]]
+ """Used by add_argument to match a tab selection with an object method
+
+ pars.add_argument("--tab", type=self.arg_method(), default="foo")
+ ...
+ self.options.tab(arguments)
+ ...
+ def method_foo(self, arguments):
+ # do something
+ """
+ def _inner(value):
+ name = '{}_{}'.format(prefix, value.strip('"').lower()).replace('-', '_')
+ try:
+ return getattr(self, name)
+ except AttributeError:
+ if name.startswith('_'):
+ return do_nothing
+ raise AbortExtension("Can not find method {}".format(name))
+ return _inner
+
+ def debug(self, msg):
+ # type: (str) -> None
+ """Write a debug message"""
+ errormsg("DEBUG<{}> {}\n".format(type(self).__name__, msg))
+
+ @staticmethod
+ def msg(msg):
+ # type: (str) -> None
+ """Write a non-error message"""
+ errormsg(msg)
+
+ def run(self, args=None, output=stdout):
+ # type: (Optional[List[str]], Union[str, IO]) -> None
+ """Main entrypoint for any Inkscape Extension"""
+ try:
+ if args is None:
+ args = sys.argv[1:]
+
+ self.parse_arguments(args)
+ if self.options.input_file is None:
+ self.options.input_file = sys.stdin
+
+ if self.options.output is None:
+ # assert output
+ self.options.output = output
+
+ self.load_raw()
+ self.save_raw(self.effect())
+ except AbortExtension as err:
+ err.write()
+ sys.exit(ABORT_STATUS)
+ finally:
+ self.clean_up()
+
+ def load_raw(self):
+ # type: () -> None
+ """Load the input stream or filename, save everything to self"""
+ if isinstance(self.options.input_file, (str, unicode)):
+ self.file_io = open(self.options.input_file, 'rb')
+ document = self.load(self.file_io)
+ else:
+ document = self.load(self.options.input_file)
+ self.document = document
+
+ def save_raw(self, ret):
+ # type: (Any) -> None
+ """Save to the output stream, use everything from self"""
+ if self.has_changed(ret):
+ if isinstance(self.options.output, (str, unicode)):
+ with open(self.options.output, 'wb') as stream:
+ self.save(stream)
+ else:
+ self.save(self.options.output)
+
+ def load(self, stream):
+ # type: (IO) -> str
+ """Takes the input stream and creates a document for parsing"""
+ raise NotImplementedError("No input handle for {}".format(self.name))
+
+ def save(self, stream):
+ # type: (IO) -> None
+ """Save the given document to the output file"""
+ raise NotImplementedError("No output handle for {}".format(self.name))
+
+ def effect(self):
+ # type: () -> Any
+ """Apply some effects on the document or local context"""
+ raise NotImplementedError("No effect handle for {}".format(self.name))
+
+ def has_changed(self, ret): # pylint: disable=no-self-use
+ # type: (Any) -> bool
+ """Return true if the output should be saved"""
+ return ret is not False
+
+ def clean_up(self):
+ # type: () -> None
+ """Clean up any open handles and other items"""
+ if self.file_io is not None:
+ self.file_io.close()
+
+ def svg_path(self):
+ # type: () -> Optional[str]
+ """
+ Return the folder the svg is contained in.
+ Returns None if there is no file.
+ """
+ if self.options.input_file:
+ return os.path.dirname(self.options.input_file)
+ return None
+
+ @classmethod
+ def ext_path(cls):
+ # type: () -> str
+ """Return the folder the extension script is in"""
+ return os.path.dirname(sys.modules[cls.__module__].__file__)
+
+ @classmethod
+ def get_resource(cls, name, abort_on_fail=True):
+ # type: (str, bool) -> str
+ """Return the full filename of the resource in the extension's dir"""
+ filename = os.path.join(cls.ext_path(), name)
+ if abort_on_fail and not os.path.isfile(filename):
+ raise AbortExtension("Could not find resource file: {}".format(filename))
+ return filename
+
+ def absolute_href(self, filename, default='~/'):
+ # type: (str, str) -> str
+ """
+ Process the filename such that it's turned into an absolute filename
+ with the working directory being the directory of the loaded svg.
+
+ User's home folder is also resolved. So '~/a.png` will be `/home/bob/a.png`
+
+ Default is a fallback directory to use if the svg's filename is not available.
+ """
+ filename = os.path.expanduser(filename)
+ if not os.path.isabs(filename):
+ path = self.svg_path() or default
+ filename = os.path.join(path, filename)
+ return os.path.realpath(os.path.expanduser(filename))
+
+ @property
+ def name(self):
+ # type: () -> str
+ """Return a fixed name for this extension"""
+ return type(self).__name__
+
+
+if TYPE_CHECKING:
+ _Base = InkscapeExtension
+else:
+ _Base = object
+
+
+class TempDirMixin(_Base):
+ """
+ Provide a temporary directory for extensions to stash files.
+ """
+ dir_suffix = ''
+ dir_prefix = 'inktmp'
+
+ def __init__(self, *args, **kwargs):
+ self.tempdir = None
+ super(TempDirMixin, self).__init__(*args, **kwargs)
+
+ def load_raw(self):
+ # type: () -> None
+ """Create the temporary directory"""
+ from tempfile import mkdtemp
+ self.tempdir = mkdtemp(self.dir_suffix, self.dir_prefix, None)
+ super(TempDirMixin, self).load_raw()
+
+ def clean_up(self):
+ # type: () -> None
+ """Delete the temporary directory"""
+ if self.tempdir and os.path.isdir(self.tempdir):
+ shutil.rmtree(self.tempdir)
+ super(TempDirMixin, self).clean_up()
+
+
+class SvgInputMixin(_Base): # pylint: disable=too-few-public-methods
+ """
+ Expects the file input to be an svg document and will parse it.
+ """
+ # Select all objects if none are selected
+ select_all = () # type: Tuple[Type[BaseElement], ...]
+
+ def __init__(self):
+ super(SvgInputMixin, self).__init__()
+
+ self.arg_parser.add_argument(
+ "--id", action="append", type=str, dest="ids", default=[],
+ help="id attribute of object to manipulate")
+
+ self.arg_parser.add_argument(
+ "--selected-nodes", action="append", type=str, dest="selected_nodes", default=[],
+ help="id:subpath:position of selected nodes, if any")
+
+ def load(self, stream):
+ # type: (IO) -> etree
+ """Load the stream as an svg xml etree and make a backup"""
+ document = load_svg(stream)
+ self.original_document = copy.deepcopy(document)
+ self.svg = document.getroot()
+ self.svg.selection.set(*self.options.ids)
+ if not self.svg.selection and self.select_all:
+ self.svg.selection = self.svg.descendants().filter(*self.select_all)
+ return document
+
+
+class SvgOutputMixin(_Base): # pylint: disable=too-few-public-methods
+ """
+ Expects the output document to be an svg document and will write an etree xml.
+
+ A template can be specified to kick off the svg document building process.
+ """
+ template = """<svg viewBox="0 0 {width} {height}" width="{width}{unit}" height="{height}{unit}"
+ xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">
+ </svg>"""
+
+ @classmethod
+ def get_template(cls, **kwargs):
+ """
+ Opens a template svg document for building, the kwargs
+ MUST include all the replacement values in the template, the
+ default template has 'width' and 'height' of the document.
+ """
+ kwargs.setdefault('unit', '')
+ return load_svg(str(cls.template.format(**kwargs)))
+
+ def save(self, stream):
+ # type: (IO) -> None
+ """Save the svg document to the given stream"""
+ if isinstance(self.document, (bytes, str, unicode)):
+ document = self.document
+ elif 'Element' in type(self.document).__name__:
+ # isinstance can't be used here because etree is broken
+ doc = cast(etree, self.document)
+ document = doc.getroot().tostring()
+ else:
+ raise ValueError("Unknown type of document: {} can not save."\
+ .format(type(self.document).__name__))
+
+ try:
+ stream.write(document)
+ except TypeError:
+ # we hope that this happens only when document needs to be encoded
+ stream.write(document.encode('utf-8')) # type: ignore
+
+class SvgThroughMixin(SvgInputMixin, SvgOutputMixin):
+ """
+ Combine the input and output svg document handling (usually for effects).
+ """
+
+ def has_changed(self, ret): # pylint: disable=unused-argument
+ # type: (Any) -> bool
+ """Return true if the svg document has changed"""
+ original = etree.tostring(self.original_document)
+ result = etree.tostring(self.document)
+ return original != result
diff --git a/share/extensions/inkex/bezier.py b/share/extensions/inkex/bezier.py
new file mode 100644
index 0000000..a95b56c
--- /dev/null
+++ b/share/extensions/inkex/bezier.py
@@ -0,0 +1,425 @@
+# coding=utf-8
+#
+# Copyright (C) 2010 Nick Drobchenko, nick@cnc-club.ru
+# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
+#
+# 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=invalid-name,too-many-locals
+#
+"""
+Bezier calculations
+"""
+
+import cmath
+import math
+
+import numpy
+
+from .transforms import DirectedLineSegment
+from .localization import inkex_gettext as _
+
+# bez = ((bx0,by0),(bx1,by1),(bx2,by2),(bx3,by3))
+
+def pointdistance(point_a, point_b):
+ """The straight line distance between two points"""
+ return math.sqrt(((point_b[0] - point_a[0]) ** 2) + ((point_b[1] - point_a[1]) ** 2))
+
+
+def between_point(point_a, point_b, time=0.5):
+ """Returns the point between point a and point b"""
+ return point_a[0] + time * (point_b[0] - point_a[0]),\
+ point_a[1] + time * (point_b[1] - point_a[1])
+
+
+def percent_point(point_a, point_b, percent=50.0):
+ """Returns between_point but takes percent instead of 0.0-1.0"""
+ return between_point(point_a, point_b, percent / 100.0)
+
+
+def root_wrapper(root_a, root_b, root_c, root_d):
+ """Get the Cubic function, moic formular of roots, simple root"""
+ if root_a:
+ # Monics formula see http://en.wikipedia.org/wiki/Cubic_function#Monic_formula_of_roots
+ mono_a, mono_b, mono_c = (root_b / root_a, root_c / root_a, root_d / root_a)
+ m = 2.0 * mono_a ** 3 - 9.0 * mono_a * mono_b + 27.0 * mono_c
+ k = mono_a ** 2 - 3.0 * mono_b
+ n = m ** 2 - 4.0 * k ** 3
+ w1 = -.5 + .5 * cmath.sqrt(-3.0)
+ w2 = -.5 - .5 * cmath.sqrt(-3.0)
+ if n < 0:
+ m1 = pow(complex((m + cmath.sqrt(n)) / 2), 1. / 3)
+ n1 = pow(complex((m - cmath.sqrt(n)) / 2), 1. / 3)
+ else:
+ if m + math.sqrt(n) < 0:
+ m1 = -pow(-(m + math.sqrt(n)) / 2, 1. / 3)
+ else:
+ m1 = pow((m + math.sqrt(n)) / 2, 1. / 3)
+ if m - math.sqrt(n) < 0:
+ n1 = -pow(-(m - math.sqrt(n)) / 2, 1. / 3)
+ else:
+ n1 = pow((m - math.sqrt(n)) / 2, 1. / 3)
+ return (-1. / 3 * (mono_a + m1 + n1),
+ -1. / 3 * (mono_a + w1 * m1 + w2 * n1),
+ -1. / 3 * (mono_a + w2 * m1 + w1 * n1))
+ elif root_b:
+ det = root_c ** 2.0 - 4.0 * root_b * root_d
+ if det:
+ return (
+ (-root_c + cmath.sqrt(det)) / (2.0 * root_b),
+ (-root_c - cmath.sqrt(det)) / (2.0 * root_b))
+ return (-root_c / (2.0 * root_b),)
+ elif root_c:
+ return (1.0 * (-root_d / root_c),)
+ return ()
+
+
+def bezlenapprx(sp1, sp2):
+ """Return the aproximate length between two beziers"""
+ return pointdistance(sp1[1], sp1[2]) \
+ + pointdistance(sp1[2], sp2[0]) \
+ + pointdistance(sp2[0], sp2[1])
+
+
+def cspbezsplit(sp1, sp2, time=0.5):
+ """Split a cubic bezier at the time period"""
+ m1 = tpoint(sp1[1], sp1[2], time)
+ m2 = tpoint(sp1[2], sp2[0], time)
+ m3 = tpoint(sp2[0], sp2[1], time)
+ m4 = tpoint(m1, m2, time)
+ m5 = tpoint(m2, m3, time)
+ m = tpoint(m4, m5, time)
+ return [[sp1[0][:], sp1[1][:], m1], [m4, m, m5], [m3, sp2[1][:], sp2[2][:]]]
+
+
+def cspbezsplitatlength(sp1, sp2, length=0.5, tolerance=0.001):
+ """Split a cubic bezier at length"""
+ bez = (sp1[1][:], sp1[2][:], sp2[0][:], sp2[1][:])
+ time = beziertatlength(bez, length, tolerance)
+ return cspbezsplit(sp1, sp2, time)
+
+
+def cspseglength(sp1, sp2, tolerance=0.001):
+ """Get cubic bezier segment length"""
+ bez = (sp1[1][:], sp1[2][:], sp2[0][:], sp2[1][:])
+ return bezierlength(bez, tolerance)
+
+
+def csplength(csp):
+ """Get cubic bezier length"""
+ total = 0
+ lengths = []
+ for sp in csp:
+ lengths.append([])
+ for i in range(1, len(sp)):
+ l = cspseglength(sp[i - 1], sp[i])
+ lengths[-1].append(l)
+ total += l
+ return lengths, total
+
+
+def bezierparameterize(bez):
+ """Return the bezier parameter size"""
+ ((bx0, by0), (bx1, by1), (bx2, by2), (bx3, by3)) = bez
+ # parametric bezier
+ x0 = bx0
+ y0 = by0
+ cx = 3 * (bx1 - x0)
+ bx = 3 * (bx2 - bx1) - cx
+ ax = bx3 - x0 - cx - bx
+ cy = 3 * (by1 - y0)
+ by = 3 * (by2 - by1) - cy
+ ay = by3 - y0 - cy - by
+
+ return ax, ay, bx, by, cx, cy, x0, y0
+
+
+def linebezierintersect(arg_a, bez):
+ """Where a line and bezier intersect"""
+ ((lx1, ly1), (lx2, ly2)) = arg_a
+ # parametric line
+ dd = lx1
+ cc = lx2 - lx1
+ bb = ly1
+ aa = ly2 - ly1
+
+ if aa:
+ coef1 = cc / aa
+ coef2 = 1
+ else:
+ coef1 = 1
+ coef2 = aa / cc
+
+ ax, ay, bx, by, cx, cy, x0, y0 = bezierparameterize(bez)
+ # cubic intersection coefficients
+ a = coef1 * ay - coef2 * ax
+ b = coef1 * by - coef2 * bx
+ c = coef1 * cy - coef2 * cx
+ d = coef1 * (y0 - bb) - coef2 * (x0 - dd)
+
+ roots = root_wrapper(a, b, c, d)
+ retval = []
+ for i in roots:
+ if isinstance(i, complex) and i.imag == 0:
+ i = i.real
+ if not isinstance(i, complex) and 0 <= i <= 1:
+ retval.append(bezierpointatt(bez, i))
+ return retval
+
+
+def bezierpointatt(bez, t):
+ """Get coords at the given time point along a bezier curve"""
+ ax, ay, bx, by, cx, cy, x0, y0 = bezierparameterize(bez)
+ x = ax * (t ** 3) + bx * (t ** 2) + cx * t + x0
+ y = ay * (t ** 3) + by * (t ** 2) + cy * t + y0
+ return x, y
+
+
+def bezierslopeatt(bez, t):
+ """Get slope at the given time point along a bezier curve"""
+ ax, ay, bx, by, cx, cy, _, _ = bezierparameterize(bez)
+ dx = 3 * ax * (t ** 2) + 2 * bx * t + cx
+ dy = 3 * ay * (t ** 2) + 2 * by * t + cy
+ return dx, dy
+
+
+def beziertatslope(bez, d):
+ """Reverse; get time from slope along a bezier curve"""
+ ax, ay, bx, by, cx, cy, _, _ = bezierparameterize(bez)
+ (dy, dx) = d
+ # quadratic coefficients of slope formula
+ if dx:
+ slope = 1.0 * (dy / dx)
+ a = 3 * ay - 3 * ax * slope
+ b = 2 * by - 2 * bx * slope
+ c = cy - cx * slope
+ elif dy:
+ slope = 1.0 * (dx / dy)
+ a = 3 * ax - 3 * ay * slope
+ b = 2 * bx - 2 * by * slope
+ c = cx - cy * slope
+ else:
+ return []
+
+ roots = root_wrapper(0, a, b, c)
+ retval = []
+ for i in roots:
+ if isinstance(i, complex) and i.imag == 0:
+ i = i.real
+ if not isinstance(i, complex) and 0 <= i <= 1:
+ retval.append(i)
+ return retval
+
+
+def tpoint(p1, p2, t):
+ """Linearly interpolate between p1 and p2.
+
+ t = 0.0 returns p1, t = 1.0 returns p2.
+
+ :return: Interpolated point
+ :rtype: tuple
+
+ :param p1: First point as sequence of two floats
+ :param p2: Second point as sequence of two floats
+ :param t: Number between 0.0 and 1.0
+ :type t: float
+ """
+ x1, y1 = p1
+ x2, y2 = p2
+ return x1 + t * (x2 - x1), y1 + t * (y2 - y1)
+
+
+def beziersplitatt(bez, t):
+ """Split bezier at given time"""
+ ((bx0, by0), (bx1, by1), (bx2, by2), (bx3, by3)) = bez
+ m1 = tpoint((bx0, by0), (bx1, by1), t)
+ m2 = tpoint((bx1, by1), (bx2, by2), t)
+ m3 = tpoint((bx2, by2), (bx3, by3), t)
+ m4 = tpoint(m1, m2, t)
+ m5 = tpoint(m2, m3, t)
+ m = tpoint(m4, m5, t)
+
+ return ((bx0, by0), m1, m4, m), (m, m5, m3, (bx3, by3))
+
+
+def addifclose(bez, l, error=0.001):
+ """Gravesen, Add if the line is closed, in-place addition to array l"""
+ box = 0
+ for i in range(1, 4):
+ box += pointdistance(bez[i - 1], bez[i])
+ chord = pointdistance(bez[0], bez[3])
+ if (box - chord) > error:
+ first, second = beziersplitatt(bez, 0.5)
+ addifclose(first, l, error)
+ addifclose(second, l, error)
+ else:
+ l[0] += (box / 2.0) + (chord / 2.0)
+
+
+# balfax, balfbx, balfcx, balfay, balfby, balfcy = 0, 0, 0, 0, 0, 0
+
+
+def balf(t, args):
+ """Bezier Arc Length Function"""
+ ax, bx, cx, ay, by, cy = args
+ retval = (ax * (t ** 2) + bx * t + cx) ** 2 + (ay * (t ** 2) + by * t + cy) ** 2
+ return math.sqrt(retval)
+
+
+def simpson(a, b, n_limit, tolerance, balarg):
+ """It's not known what this function does..."""
+ n = 2
+ multiplier = (b - a) / 6.0
+ endsum = balf(a, balarg) + balf(b, balarg)
+ interval = (b - a) / 2.0
+ asum = 0.0
+ bsum = balf(a + interval, balarg)
+ est1 = multiplier * (endsum + (2.0 * asum) + (4.0 * bsum))
+ est0 = 2.0 * est1
+ # print(multiplier, endsum, interval, asum, bsum, est1, est0)
+ while n < n_limit and abs(est1 - est0) > tolerance:
+ n *= 2
+ multiplier /= 2.0
+ interval /= 2.0
+ asum += bsum
+ bsum = 0.0
+ est0 = est1
+ for i in range(1, n, 2):
+ bsum += balf(a + (i * interval), balarg)
+ est1 = multiplier * (endsum + (2.0 * asum) + (4.0 * bsum))
+ # print(multiplier, endsum, interval, asum, bsum, est1, est0)
+ return est1
+
+
+def bezierlength(bez, tolerance=0.001, time=1.0):
+ """Get length of bezier curve"""
+ ax, ay, bx, by, cx, cy, _, _ = bezierparameterize(bez)
+ return simpson(0.0, time, 4096, tolerance, [3 * ax, 2 * bx, cx, 3 * ay, 2 * by, cy])
+
+
+def beziertatlength(bez, l=0.5, tolerance=0.001):
+ """Get bezier curve time at the length specified"""
+ curlen = bezierlength(bez, tolerance, 1.0)
+ time = 1.0
+ tdiv = time
+ targetlen = l * curlen
+ diff = curlen - targetlen
+ while abs(diff) > tolerance:
+ tdiv /= 2.0
+ if diff < 0:
+ time += tdiv
+ else:
+ time -= tdiv
+ curlen = bezierlength(bez, tolerance, time)
+ diff = curlen - targetlen
+ return time
+
+def maxdist(bez):
+ """Get maximum distance within bezier curve"""
+ seg = DirectedLineSegment(bez[0], bez[3])
+ return max(seg.distance_to_point(*bez[1]), seg.distance_to_point(*bez[2]))
+
+def cspsubdiv(csp, flat):
+ """Sub-divide cubic sub-paths"""
+ for sp in csp:
+ subdiv(sp, flat)
+
+
+def subdiv(sp, flat, i=1):
+ """sub divide bezier curve"""
+ while i < len(sp):
+ p0 = sp[i - 1][1]
+ p1 = sp[i - 1][2]
+ p2 = sp[i][0]
+ p3 = sp[i][1]
+
+ bez = (p0, p1, p2, p3)
+ mdist = maxdist(bez)
+ if mdist <= flat:
+ i += 1
+ else:
+ one, two = beziersplitatt(bez, 0.5)
+ sp[i - 1][2] = one[1]
+ sp[i][0] = two[2]
+ p = [one[2], one[3], two[1]]
+ sp[i:1] = [p]
+
+
+def csparea(csp):
+ """Get area in cubic sub-path"""
+ MAT_AREA = numpy.array([[0, 2, 1, -3],
+ [-2, 0, 1, 1],
+ [-1, -1, 0, 2],
+ [3, -1, -2, 0]])
+ area = 0.0
+ for sp in csp:
+ if len(sp) < 2:
+ continue
+ for x, coord in enumerate(sp): # calculate polygon area
+ area += 0.5 * sp[x - 1][1][0] * (coord[1][1] - sp[x - 2][1][1])
+ for i in range(1, len(sp)): # add contribution from cubic Bezier
+ vec_x = numpy.array([sp[i - 1][1][0], sp[i - 1][2][0], sp[i][0][0], sp[i][1][0]])
+ vec_y = numpy.array([sp[i - 1][1][1], sp[i - 1][2][1], sp[i][0][1], sp[i][1][1]])
+ vex = numpy.matmul(vec_x, MAT_AREA)
+ area += 0.15 * numpy.matmul(vex, vec_y.T)
+ return -area
+
+
+def cspcofm(csp):
+ """Get cubic sub-path coefficient"""
+ MAT_COFM_0 = numpy.array([[0, 35, 10, -45],
+ [-35, 0, 12, 23],
+ [-10, -12, 0, 22],
+ [45, -23, -22, 0]])
+
+ MAT_COFM_1 = numpy.array([[0, 15, 3, -18],
+ [-15, 0, 9, 6],
+ [-3, -9, 0, 12],
+ [18, -6, -12, 0]])
+
+ MAT_COFM_2 = numpy.array([[0, 12, 6, -18],
+ [-12, 0, 9, 3],
+ [-6, -9, 0, 15],
+ [18, -3, -15, 0]])
+
+ MAT_COFM_3 = numpy.array([[0, 22, 23, -45],
+ [-22, 0, 12, 10],
+ [-23, -12, 0, 35],
+ [45, -10, -35, 0]])
+ area = csparea(csp)
+ xc = 0.0
+ yc = 0.0
+ if abs(area) < 1.e-8:
+ raise ValueError(_("Area is zero, cannot calculate Center of Mass"))
+ for sp in csp:
+ for x, coord in enumerate(sp): # calculate polygon moment
+ xc += sp[x - 1][1][1] * (sp[x - 2][1][0] - coord[1][0]) \
+ * (sp[x - 2][1][0] + sp[x - 1][1][0] + coord[1][0]) / 6
+ yc += sp[x - 1][1][0] * (coord[1][1] - sp[x - 2][1][1]) \
+ * (sp[x - 2][1][1] + sp[x - 1][1][1] + coord[1][1]) / 6
+ for i in range(1, len(sp)): # add contribution from cubic Bezier
+ vec_x = numpy.array([sp[i - 1][1][0], sp[i - 1][2][0], sp[i][0][0], sp[i][1][0]])
+ vec_y = numpy.array([sp[i - 1][1][1], sp[i - 1][2][1], sp[i][0][1], sp[i][1][1]])
+ def _mul(MAT):
+ return numpy.matmul(numpy.matmul(vec_x, MAT), vec_y.T)
+ vec_t = numpy.array([
+ _mul(MAT_COFM_0),
+ _mul(MAT_COFM_1),
+ _mul(MAT_COFM_2),
+ _mul(MAT_COFM_3)
+ ])
+ xc += numpy.matmul(vec_x, vec_t.T) / 280
+ yc += numpy.matmul(vec_y, vec_t.T) / 280
+ return -xc / area, -yc / area
diff --git a/share/extensions/inkex/colors.py b/share/extensions/inkex/colors.py
new file mode 100644
index 0000000..5ae9403
--- /dev/null
+++ b/share/extensions/inkex/colors.py
@@ -0,0 +1,478 @@
+# coding=utf-8
+#
+# Copyright (C) 2006 Jos Hirth, kaioa.com
+# Copyright (C) 2007 Aaron C. Spike
+# Copyright (C) 2009 Monash University
+#
+# 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.
+#
+"""
+Basic color controls
+"""
+
+from .utils import PY3
+from .tween import interpcoord
+
+# All the names that get added to the inkex API itself.
+__all__ = ('Color', 'ColorError', 'ColorIdError')
+
+if PY3:
+ unicode = str # pylint: disable=redefined-builtin,invalid-name
+
+SVG_COLOR = {
+ 'aliceblue': '#f0f8ff',
+ 'antiquewhite': '#faebd7',
+ 'aqua': '#00ffff',
+ 'aquamarine': '#7fffd4',
+ 'azure': '#f0ffff',
+ 'beige': '#f5f5dc',
+ 'bisque': '#ffe4c4',
+ 'black': '#000000',
+ 'blanchedalmond': '#ffebcd',
+ 'blue': '#0000ff',
+ 'blueviolet': '#8a2be2',
+ 'brown': '#a52a2a',
+ 'burlywood': '#deb887',
+ 'cadetblue': '#5f9ea0',
+ 'chartreuse': '#7fff00',
+ 'chocolate': '#d2691e',
+ 'coral': '#ff7f50',
+ 'cornflowerblue': '#6495ed',
+ 'cornsilk': '#fff8dc',
+ 'crimson': '#dc143c',
+ 'cyan': '#00ffff',
+ 'darkblue': '#00008b',
+ 'darkcyan': '#008b8b',
+ 'darkgoldenrod': '#b8860b',
+ 'darkgray': '#a9a9a9',
+ 'darkgreen': '#006400',
+ 'darkgrey': '#a9a9a9',
+ 'darkkhaki': '#bdb76b',
+ 'darkmagenta': '#8b008b',
+ 'darkolivegreen': '#556b2f',
+ 'darkorange': '#ff8c00',
+ 'darkorchid': '#9932cc',
+ 'darkred': '#8b0000',
+ 'darksalmon': '#e9967a',
+ 'darkseagreen': '#8fbc8f',
+ 'darkslateblue': '#483d8b',
+ 'darkslategray': '#2f4f4f',
+ 'darkslategrey': '#2f4f4f',
+ 'darkturquoise': '#00ced1',
+ 'darkviolet': '#9400d3',
+ 'deeppink': '#ff1493',
+ 'deepskyblue': '#00bfff',
+ 'dimgray': '#696969',
+ 'dimgrey': '#696969',
+ 'dodgerblue': '#1e90ff',
+ 'firebrick': '#b22222',
+ 'floralwhite': '#fffaf0',
+ 'forestgreen': '#228b22',
+ 'fuchsia': '#ff00ff',
+ 'gainsboro': '#dcdcdc',
+ 'ghostwhite': '#f8f8ff',
+ 'gold': '#ffd700',
+ 'goldenrod': '#daa520',
+ 'gray': '#808080',
+ 'grey': '#808080',
+ 'green': '#008000',
+ 'greenyellow': '#adff2f',
+ 'honeydew': '#f0fff0',
+ 'hotpink': '#ff69b4',
+ 'indianred': '#cd5c5c',
+ 'indigo': '#4b0082',
+ 'ivory': '#fffff0',
+ 'khaki': '#f0e68c',
+ 'lavender': '#e6e6fa',
+ 'lavenderblush': '#fff0f5',
+ 'lawngreen': '#7cfc00',
+ 'lemonchiffon': '#fffacd',
+ 'lightblue': '#add8e6',
+ 'lightcoral': '#f08080',
+ 'lightcyan': '#e0ffff',
+ 'lightgoldenrodyellow': '#fafad2',
+ 'lightgray': '#d3d3d3',
+ 'lightgreen': '#90ee90',
+ 'lightgrey': '#d3d3d3',
+ 'lightpink': '#ffb6c1',
+ 'lightsalmon': '#ffa07a',
+ 'lightseagreen': '#20b2aa',
+ 'lightskyblue': '#87cefa',
+ 'lightslategray': '#778899',
+ 'lightslategrey': '#778899',
+ 'lightsteelblue': '#b0c4de',
+ 'lightyellow': '#ffffe0',
+ 'lime': '#00ff00',
+ 'limegreen': '#32cd32',
+ 'linen': '#faf0e6',
+ 'magenta': '#ff00ff',
+ 'maroon': '#800000',
+ 'mediumaquamarine': '#66cdaa',
+ 'mediumblue': '#0000cd',
+ 'mediumorchid': '#ba55d3',
+ 'mediumpurple': '#9370db',
+ 'mediumseagreen': '#3cb371',
+ 'mediumslateblue': '#7b68ee',
+ 'mediumspringgreen': '#00fa9a',
+ 'mediumturquoise': '#48d1cc',
+ 'mediumvioletred': '#c71585',
+ 'midnightblue': '#191970',
+ 'mintcream': '#f5fffa',
+ 'mistyrose': '#ffe4e1',
+ 'moccasin': '#ffe4b5',
+ 'navajowhite': '#ffdead',
+ 'navy': '#000080',
+ 'oldlace': '#fdf5e6',
+ 'olive': '#808000',
+ 'olivedrab': '#6b8e23',
+ 'orange': '#ffa500',
+ 'orangered': '#ff4500',
+ 'orchid': '#da70d6',
+ 'palegoldenrod': '#eee8aa',
+ 'palegreen': '#98fb98',
+ 'paleturquoise': '#afeeee',
+ 'palevioletred': '#db7093',
+ 'papayawhip': '#ffefd5',
+ 'peachpuff': '#ffdab9',
+ 'peru': '#cd853f',
+ 'pink': '#ffc0cb',
+ 'plum': '#dda0dd',
+ 'powderblue': '#b0e0e6',
+ 'purple': '#800080',
+ 'rebeccapurple': '#663399',
+ 'red': '#ff0000',
+ 'rosybrown': '#bc8f8f',
+ 'royalblue': '#4169e1',
+ 'saddlebrown': '#8b4513',
+ 'salmon': '#fa8072',
+ 'sandybrown': '#f4a460',
+ 'seagreen': '#2e8b57',
+ 'seashell': '#fff5ee',
+ 'sienna': '#a0522d',
+ 'silver': '#c0c0c0',
+ 'skyblue': '#87ceeb',
+ 'slateblue': '#6a5acd',
+ 'slategray': '#708090',
+ 'slategrey': '#708090',
+ 'snow': '#fffafa',
+ 'springgreen': '#00ff7f',
+ 'steelblue': '#4682b4',
+ 'tan': '#d2b48c',
+ 'teal': '#008080',
+ 'thistle': '#d8bfd8',
+ 'tomato': '#ff6347',
+ 'turquoise': '#40e0d0',
+ 'violet': '#ee82ee',
+ 'wheat': '#f5deb3',
+ 'white': '#ffffff',
+ 'whitesmoke': '#f5f5f5',
+ 'yellow': '#ffff00',
+ 'yellowgreen': '#9acd32',
+ 'none': None,
+}
+COLOR_SVG = dict([(value, name) for name, value in SVG_COLOR.items()])
+
+def is_color(color):
+ """Determine if it is a color that we can use. If not, leave it unchanged."""
+ try:
+ return bool(Color(color))
+ except ColorError:
+ return False
+
+def constrain(minim, value, maxim, channel):
+ """Returns the value so long as it is between min and max values"""
+ if channel == 'h': # Hue
+ return value % maxim # Wrap around hue value
+ return min([maxim, max([minim, value])])
+
+class ColorError(KeyError):
+ """Specific color parsing error"""
+
+class ColorIdError(ColorError):
+ """Special color error for gradient and color stop ids"""
+
+class Color(list):
+ """An RGB array for the color"""
+ red = property(lambda self: self.to_rgb()[0])
+ red = red.setter(lambda self, value: self._set(0, value))
+ green = property(lambda self: self.to_rgb()[1])
+ green = green.setter(lambda self, value: self._set(1, value))
+ blue = property(lambda self: self.to_rgb()[2])
+ blue = blue.setter(lambda self, value: self._set(2, value))
+ alpha = property(lambda self: self.to_rgba()[3])
+ alpha = alpha.setter(lambda self, value: self._set(3, value, ('rgba',)))
+ hue = property(lambda self: self.to_hsl()[0])
+ hue = hue.setter(lambda self, value: self._set(0, value, ('hsl',)))
+ saturation = property(lambda self: self.to_hsl()[1])
+ saturation = saturation.setter(lambda self, value: self._set(1, value, ('hsl',)))
+ lightness = property(lambda self: self.to_hsl()[2])
+ lightness = lightness.setter(lambda self, value: self._set(2, value, ('hsl',)))
+
+ def __init__(self, color=None, space='rgb'):
+ super(Color, self).__init__()
+ if isinstance(color, Color):
+ space, color = color.space, list(color)
+
+ if isinstance(color, (str, unicode)):
+ # String from xml or css attributes
+ space, color = self.parse_str(color.strip())
+
+ if isinstance(color, int):
+ # Number from arg parser colour value
+ space, color = self.parse_int(color)
+
+ # Empty list means 'none', or no color
+ if color is None:
+ color = []
+
+ if not isinstance(color, (list, tuple)):
+ raise ColorError("Not a known a color value")
+
+ self.space = space
+ try:
+ for val in color:
+ self.append(val)
+ except ValueError:
+ raise ColorError("Bad color list")
+
+ def __hash__(self):
+ """Allow colors to be hashable"""
+ return tuple(self.to_rgba()).__hash__()
+
+ def _set(self, index, value, spaces=('rgb', 'rgba')):
+ """Set the color value in place, limits setter to specific color space"""
+ # Named colors are just rgb, so dump name memory
+ if self.space == 'named':
+ self.space = 'rgb'
+ if not self.space in spaces:
+ if index == 3 and self.space == 'rgb':
+ # Special, add alpha, don't convert back to rgb
+ self.space = 'rgba'
+ self.append(constrain(0.0, float(value), 1.0, 'a'))
+ return
+ # Set in other colour space and convert back and forth
+ target = self.to(spaces[0])
+ target[index] = constrain(0, int(value), 255, spaces[0][index])
+ self[:] = target.to(self.space)
+ return
+ self[index] = constrain(0, int(value), 255, spaces[0][index])
+
+ def append(self, val):
+ """Append a value to the local list"""
+ if len(self) == len(self.space):
+ raise ValueError("Can't add any more values to color.")
+
+ if isinstance(val, (unicode, str)):
+ val = val.strip()
+ if val.endswith('%'):
+ val = float(val.strip('%')) / 100
+ else:
+ val = float(val)
+
+ end_type = int
+ if len(self) == 3: # Alpha value
+ val = min([1.0, val])
+ end_type = float
+ elif isinstance(val, float) and val <= 1.0:
+ val *= 255
+
+ if isinstance(val, (int, float)):
+ super(Color, self).append(max(end_type(val), 0))
+
+ @staticmethod
+ def parse_str(color):
+ """Creates a rgb int array"""
+ # Handle pre-defined svg color values
+ if color and color.lower() in SVG_COLOR:
+ return 'named', Color.parse_str(SVG_COLOR[color.lower()])[1]
+
+ if color is None:
+ return 'rgb', None
+
+ if color.startswith('url('):
+ raise ColorIdError("Color references other element id, e.g. a gradient")
+
+ # Next handle short colors (css: #abc -> #aabbcc)
+ if color.startswith('#'):
+ # Remove any icc or ilab directives
+ # FUTURE: We could use icc or ilab information
+ col = color.split(' ')[0]
+ if len(col) == 4:
+ col = '#{1}{1}{2}{2}{3}{3}'.format(*col)
+
+ # Convert hex to integers
+ try:
+ return 'rgb', (int(col[1:3], 16), int(col[3:5], 16), int(col[5:], 16))
+ except ValueError:
+ raise ColorError("Bad RGB hex color value {}".format(col))
+
+ # Handle other css color values
+ elif '(' in color and ')' in color:
+ space, values = color.lower().strip().strip(')').split('(')
+ return space, values.split(',')
+
+ try:
+ return Color.parse_int(int(color))
+ except ValueError:
+ pass
+
+ raise ColorError("Unknown color format: {}".format(color))
+
+ @staticmethod
+ def parse_int(color):
+ """Creates an rgb or rgba from a long int"""
+ space = 'rgb'
+ color = [
+ ((color >> 24) & 255), # red
+ ((color >> 16) & 255), # green
+ ((color >> 8) & 255), # blue
+ ((color & 255) / 255.), # opacity
+ ]
+ if color[-1] == 1.0:
+ color.pop()
+ else:
+ space = 'rgba'
+ return space, color
+
+ def __str__(self):
+ """int array to #rrggbb"""
+ if not self:
+ return 'none'
+ if self.space == 'named':
+ rgbhex = '#{0:02x}{1:02x}{2:02x}'.format(*self)
+ if rgbhex in COLOR_SVG:
+ return COLOR_SVG[rgbhex]
+ self.space = 'rgb'
+ if self.space == 'rgb':
+ return '#{0:02x}{1:02x}{2:02x}'.format(*self)
+ if self.space == 'rgba':
+ if self[3] == 1.0:
+ return 'rgb({:g}, {:g}, {:g})'.format(*self[:3])
+ return 'rgba({:g}, {:g}, {:g}, {:g})'.format(*self)
+ elif self.space == 'hsl':
+ return 'hsl({0:g}, {1:g}, {2:g})'.format(*self)
+ raise ColorError("Can't print colour space '{}'".format(self.space))
+
+ def __int__(self):
+ """int array to large integer"""
+ if not self:
+ return -1
+ color = self.to_rgba()
+ return (color[0] << 24) + (color[1] << 16) + (color[2] << 8) + (int(color[3] * 255))
+
+ def to(self, space):
+ """Dynamic caller for to_hsl, to_rgb, etc"""
+ return getattr(self, 'to_' + space)()
+
+ def to_hsl(self):
+ """Turn this color into a Hue/Saturation/Lightness colour space"""
+ if not self and self.space in ('rgb', 'named'):
+ return self.to_rgb().to_hsl()
+ if self.space == 'hsl':
+ return self
+ elif self.space == 'rgb':
+ return Color(rgb_to_hsl(*self.to_floats()), space='hsl')
+ raise ColorError("Unknown color conversion {}->hsl".format(self.space))
+
+ def to_rgb(self):
+ """Turn this color into a Red/Green/Blue colour space"""
+ if not self and self.space in ('rgb', 'named'):
+ return Color([0, 0, 0])
+ if self.space == 'rgb':
+ return self
+ if self.space in ('rgba', 'named'):
+ return Color(self[:3], space='rgb')
+ elif self.space == 'hsl':
+ return Color(hsl_to_rgb(*self.to_floats()), space='rgb')
+ raise ColorError("Unknown color conversion {}->rgb".format(self.space))
+
+ def to_rgba(self, alpha=1.0):
+ """Turn this color isn't an RGB with Alpha colour space"""
+ if self.space == 'rgba':
+ return self
+ return Color(self.to_rgb() + [alpha], 'rgba')
+
+ def to_floats(self):
+ """Returns the colour values as percentage floats (0.0 - 1.0)"""
+ return [val / 255.0 for val in self]
+
+ def to_named(self):
+ """Convert this color to a named color if possible"""
+ if not self:
+ return Color()
+ return Color(COLOR_SVG.get(str(self), str(self)))
+
+ def interpolate(self, other, fraction):
+ """Iterpolate two colours by the given fraction"""
+ return Color(
+ [interpcoord(c1, c2, fraction)
+ for (c1, c2) in zip(self.to_floats(), other.to_floats())]
+ )
+
+
+def rgb_to_hsl(red, green, blue):
+ """RGB to HSL colour conversion"""
+ rgb_max = max(red, green, blue)
+ rgb_min = min(red, green, blue)
+ delta = rgb_max - rgb_min
+ hsl = [0.0, 0.0, (rgb_max + rgb_min) / 2.0]
+ if delta != 0:
+ if hsl[2] <= 0.5:
+ hsl[1] = delta / (rgb_max + rgb_min)
+ else:
+ hsl[1] = delta / (2 - rgb_max - rgb_min)
+
+ if red == rgb_max:
+ hsl[0] = (green - blue) / delta
+ elif green == rgb_max:
+ hsl[0] = 2.0 + (blue - red) / delta
+ elif blue == rgb_max:
+ hsl[0] = 4.0 + (red - green) / delta
+
+ hsl[0] /= 6.0
+ if hsl[0] < 0:
+ hsl[0] += 1
+ if hsl[0] > 1:
+ hsl[0] -= 1
+ return hsl
+
+
+def hsl_to_rgb(hue, sat, light):
+ """HSL to RGB Color Conversion"""
+ if sat == 0:
+ return [light, light, light] # Gray
+
+ if light < 0.5:
+ val2 = light * (1 + sat)
+ else:
+ val2 = light + sat - light * sat
+ val1 = 2 * light - val2
+ return [_hue_to_rgb(val1, val2, hue * 6 + 2.0),
+ _hue_to_rgb(val1, val2, hue * 6),
+ _hue_to_rgb(val1, val2, hue * 6 - 2.0)]
+
+
+def _hue_to_rgb(val1, val2, hue):
+ if hue < 0:
+ hue += 6.0
+ if hue > 6:
+ hue -= 6.0
+ if hue < 1:
+ return val1 + (val2 - val1) * hue
+ if hue < 3:
+ return val2
+ if hue < 4:
+ return val1 + (val2 - val1) * (4 - hue)
+ return val1
diff --git a/share/extensions/inkex/command.py b/share/extensions/inkex/command.py
new file mode 100644
index 0000000..0377002
--- /dev/null
+++ b/share/extensions/inkex/command.py
@@ -0,0 +1,222 @@
+# coding=utf-8
+#
+# Copyright (C) 2019 Martin Owens
+#
+# 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, USA.
+#
+"""
+This API provides methods for calling Inkscape to execute a given
+Inkscape command. This may be needed for various compiling options
+(e.g., png), running other extensions or performing other options only
+available via the shell API.
+
+Best practice is to avoid using this API except when absolutely necessary,
+since it is resource-intensive to invoke a new Inkscape instance.
+
+However, in any circumstance when it is necessary to call Inkscape, it
+is strongly recommended that you do so through this API, rather than calling
+it yourself, to take advantage of the security settings and testing functions.
+
+"""
+
+import os
+from subprocess import Popen, PIPE
+from lxml.etree import ElementTree
+
+from .utils import TemporaryDirectory, PY3
+from .elements import SvgDocumentElement
+
+INKSCAPE_EXECUTABLE_NAME = os.environ.get('INKSCAPE_COMMAND', 'inkscape')
+
+class CommandNotFound(IOError):
+ """Command is not found"""
+ pass
+
+class ProgramRunError(ValueError):
+ """Command returned non-zero output"""
+ pass
+
+def which(program):
+ """
+ Attempt different methods of trying to find if the program exists.
+ """
+ if os.path.isabs(program) and os.path.isfile(program):
+ return program
+ try:
+ # Python2 and python3, but must have distutils and may not always
+ # work on windows versions (depending on the version)
+ from distutils.spawn import find_executable
+ prog = find_executable(program)
+ if prog:
+ return prog
+ except ImportError:
+ pass
+
+ try:
+ # Python3 only version of which
+ from shutil import which as warlock
+ prog = warlock(program)
+ if prog:
+ return prog
+ except ImportError:
+ pass # python2
+
+ # There may be other methods for doing a `which` command for other
+ # operating systems; These should go here as they are discovered.
+
+ raise CommandNotFound("Can not find the command: '{}'".format(program))
+
+def write_svg(svg, *filename):
+ """Writes an svg to the given filename"""
+ filename = os.path.join(*filename)
+ if os.path.isfile(filename):
+ return filename
+ with open(filename, 'wb') as fhl:
+ if isinstance(svg, SvgDocumentElement):
+ svg = ElementTree(svg)
+ if hasattr(svg, 'write'):
+ # XML document
+ svg.write(fhl)
+ elif isinstance(svg, bytes):
+ fhl.write(svg)
+ else:
+ raise ValueError("Not sure what type of SVG data this is.")
+ return filename
+
+
+def to_arg(arg, oldie=False):
+ """Convert a python argument to a command line argument"""
+ if isinstance(arg, (tuple, list)):
+ (arg, val) = arg
+ arg = '-' + arg
+ if len(arg) > 2 and not oldie:
+ arg = '-' + arg
+ if val is True:
+ return arg
+ if val is False:
+ return None
+ return '{}={}'.format(arg, str(val))
+ return str(arg)
+
+def to_args(prog, *positionals, **arguments):
+ """
+ Convert positional arguments and key word arguments
+ into a list of strings which Popen will understand.
+
+ Values can be:
+
+ args = *[
+ 'strait_up_string',
+ '--or_manual_kwarg=1',
+ ('ordered list', 'version of kwargs (as below)'),
+ ...
+ ]
+ kwargs = **{
+ 'name': 'val', # --name="val"'
+ 'name': ['foo', 'bar'], # --name=foo --name=bar
+ 'name': True, # --name
+ 'n': 'v', # -n=v
+ 'n': True, # -n
+ }
+
+ All args appear after the kwargs, so if you need args before,
+ use the ordered list tuple and don't use kwargs.
+ """
+ args = [prog]
+ oldie = arguments.pop('oldie', False)
+ for arg, value in arguments.items():
+ arg = arg.replace('_', '-').strip()
+
+ if isinstance(value, tuple):
+ value = list(value)
+ elif not isinstance(value, list):
+ value = [value]
+
+ for val in value:
+ args.append(to_arg((arg, val), oldie))
+
+ args += [to_arg(pos, oldie) for pos in positionals if pos is not None]
+ # Filter out empty non-arguments
+ return [arg for arg in args if arg is not None]
+
+def _call(program, *args, **kwargs):
+ stdin = kwargs.pop('stdin', None)
+ if PY3 and isinstance(stdin, str):
+ stdin = stdin.encode('utf-8')
+ inpipe = PIPE if stdin else None
+
+ args = to_args(which(program), *args, **kwargs)
+ process = Popen(
+ args,
+ shell=False, # Never have shell=True
+ stdin=inpipe, # StdIn not used (yet)
+ stdout=PIPE, # Grab any output (return it)
+ stderr=PIPE, # Take all errors, just incase
+ )
+ (stdout, stderr) = process.communicate(input=stdin)
+ if process.returncode == 0:
+ return stdout
+ raise ProgramRunError("Return Code: {}: {}\n{}\nargs: {}".format(
+ process.returncode, stderr, stdout, args))
+
+def call(program, *args, **kwargs):
+ """
+ Generic caller to open any program and return its stdout.
+
+ stdout = call('executable', arg1, arg2, dash_dash_arg='foo', d=True, ...)
+
+ Will raise ProgramRunError() if return code is not 0.
+ """
+ return _call(program, *args, **kwargs)
+
+def inkscape(svg_file, *args, **kwargs):
+ """
+ Call Inkscape with the given svg_file and the given arguments
+ """
+ return call(INKSCAPE_EXECUTABLE_NAME, svg_file, *args, **kwargs)
+
+def inkscape_command(svg, select=None, verbs=()):
+ """
+ Executes a list of commands, a mixture of verbs, selects etc.
+
+ inkscape_command('<svg...>', ('verb', 'VerbName'), ...)
+ """
+ with TemporaryDirectory(prefix='inkscape-command') as dirname:
+ svg_file = write_svg(svg, dirname, 'input.svg')
+ select = ('select', select) if select else None
+ verbs += ('FileSave', 'FileQuit')
+ inkscape(svg_file, select, batch_process=True, verb=';'.join(verbs))
+ with open(svg_file, 'rb') as fhl:
+ return fhl.read()
+
+def take_snapshot(svg, dirname, name='snapshot', ext='png', dpi=96, **kwargs):
+ """
+ Take a snapshot of the given svg file.
+
+ Resulting filename is yielded back, after generator finishes, the
+ file is deleted so you must deal with the file inside the for loop.
+ """
+ svg_file = write_svg(svg, dirname, name + '.svg')
+ ext_file = os.path.join(dirname, name + '.' + str(ext).lower())
+ inkscape(svg_file, export_dpi=dpi, export_filename=ext_file, export_type=ext, **kwargs)
+ return ext_file
+
+
+def is_inkscape_available():
+ """Return true if the Inkscape executable is available."""
+ try:
+ return bool(which(INKSCAPE_EXECUTABLE_NAME))
+ except CommandNotFound:
+ return False
diff --git a/share/extensions/inkex/deprecated-simple/README.rst b/share/extensions/inkex/deprecated-simple/README.rst
new file mode 100644
index 0000000..df3e2dc
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/README.rst
@@ -0,0 +1,4 @@
+# coding=utf-8This directory contains compatibility layers for all the `simple` modules, such as `simplepath` and `simplestyle`
+
+This directory IS NOT a module path, to denote this we are using a dash in the name and there is no '__init__.py'
+
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/bezmisc.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/bezmisc.cpython-39.pyc
new file mode 100644
index 0000000..db8842a
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/bezmisc.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pyc
new file mode 100644
index 0000000..26ec318
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pyc
new file mode 100644
index 0000000..0e063ba
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pyc
new file mode 100644
index 0000000..b00b896
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pyc
new file mode 100644
index 0000000..d85fe9d
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pyc
new file mode 100644
index 0000000..17bbfa5
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pyc
new file mode 100644
index 0000000..f28b4e5
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/deprecated-simple/bezmisc.py b/share/extensions/inkex/deprecated-simple/bezmisc.py
new file mode 100644
index 0000000..1992440
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/bezmisc.py
@@ -0,0 +1,44 @@
+# coding=utf-8
+#
+# 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=invalid-name,unused-argument
+"""Deprecated bezmisc API"""
+
+from inkex.deprecated import deprecate
+from inkex import bezier
+
+bezierparameterize = deprecate(bezier.bezierparameterize)
+linebezierintersect = deprecate(bezier.linebezierintersect)
+bezierpointatt = deprecate(bezier.bezierpointatt)
+bezierslopeatt = deprecate(bezier.bezierslopeatt)
+beziertatslope = deprecate(bezier.beziertatslope)
+tpoint = deprecate(bezier.tpoint)
+beziersplitatt = deprecate(bezier.beziersplitatt)
+pointdistance = deprecate(bezier.pointdistance)
+Gravesen_addifclose = deprecate(bezier.addifclose)
+balf = deprecate(bezier.balf)
+bezierlengthSimpson = deprecate(bezier.bezierlength)
+beziertatlength = deprecate(bezier.beziertatlength)
+bezierlength = bezierlengthSimpson
+
+@deprecate
+def Simpson(func, a, b, n_limit, tolerance):
+ """bezier.simpson(a, b, n_limit, tolerance, balf_arguments)"""
+ raise AttributeError(
+ """Because bezmisc.Simpson used global variables, it's not possible to
+ call the replacement code automatically. In fact it's unlikely you were
+ using the code or functionality you think you were since it's a highly
+ broken way of writing python.""")
diff --git a/share/extensions/inkex/deprecated-simple/cspsubdiv.py b/share/extensions/inkex/deprecated-simple/cspsubdiv.py
new file mode 100644
index 0000000..91b2237
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/cspsubdiv.py
@@ -0,0 +1,25 @@
+# coding=utf-8
+#
+# 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=invalid-name
+"""Deprecated cspsubdiv API"""
+
+from inkex.deprecated import deprecate
+from inkex import bezier
+
+maxdist = deprecate(bezier.maxdist)
+cspsubdiv = deprecate(bezier.cspsubdiv)
+subdiv = deprecate(bezier.subdiv)
diff --git a/share/extensions/inkex/deprecated-simple/cubicsuperpath.py b/share/extensions/inkex/deprecated-simple/cubicsuperpath.py
new file mode 100644
index 0000000..990da31
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/cubicsuperpath.py
@@ -0,0 +1,46 @@
+# coding=utf-8
+#
+# 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=invalid-name
+"""Deprecated cubic super path API"""
+
+from inkex.deprecated import deprecate
+from inkex import paths
+
+@deprecate
+def ArcToPath(p1, params):
+ return paths.arc_to_path(p1, params)
+
+@deprecate
+def CubicSuperPath(simplepath):
+ return paths.Path(simplepath).to_superpath()
+
+@deprecate
+def unCubicSuperPath(csp):
+ return paths.CubicSuperPath(csp).to_path().to_arrays()
+
+@deprecate
+def parsePath(d):
+ return paths.CubicSuperPath(paths.Path(d))
+
+@deprecate
+def formatPath(p):
+ return str(paths.Path(unCubicSuperPath(p)))
+
+matprod = deprecate(paths.matprod)
+rotmat = deprecate(paths.rotmat)
+applymat = deprecate(paths.applymat)
+norm = deprecate(paths.norm)
diff --git a/share/extensions/inkex/deprecated-simple/ffgeom.py b/share/extensions/inkex/deprecated-simple/ffgeom.py
new file mode 100644
index 0000000..bef3ba4
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/ffgeom.py
@@ -0,0 +1,87 @@
+# coding=utf-8
+#
+# 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=invalid-name,missing-docstring
+"""Deprecated ffgeom API"""
+
+from collections import namedtuple
+
+from inkex.deprecated import deprecate
+from inkex.transforms import DirectedLineSegment as NewSeg
+
+try:
+ NaN = float('NaN')
+except ValueError:
+ PosInf = 1e300000
+ NaN = PosInf/PosInf
+
+class Point(namedtuple('Point', 'x y')):
+ __slots__ = ()
+ def __getitem__(self, key):
+ if isinstance(key, str):
+ key = 'xy'.index(key)
+ return super(Point, self).__getitem__(key)
+
+class Segment(NewSeg):
+ @deprecate
+ def __init__(self, e0, e1):
+ """inkex.transforms.Segment(((x1, y1), (x2, y2)))"""
+ if isinstance(e0, dict):
+ e0 = (e0['x'], e0['y'])
+ if isinstance(e1, dict):
+ e1 = (e1['x'], e1['y'])
+ super(Segment, self).__init__((e0, e1))
+
+ def __getitem__(self, key):
+ if key:
+ return {'x': self.x.maximum, 'y': self.y.maximum}
+ return {'x': self.x.minimum, 'y': self.y.minimum}
+
+ delta_x = lambda self: self.width
+ delta_y = lambda self: self.height
+ run = delta_x
+ rise = delta_y
+
+ def distanceToPoint(self, p):
+ return self.distance_to_point(p['x'], p['y'])
+
+ def perpDistanceToPoint(self, p):
+ return self.perp_distance(p['x'], p['y'])
+
+ def angle(self):
+ return super(Segment, self).angle
+
+ def length(self):
+ return super(Segment, self).length
+
+ def pointAtLength(self, length):
+ return self.point_at_length(length)
+
+ def pointAtRatio(self, ratio):
+ return self.point_at_ratio(ratio)
+
+ def createParallel(self, p):
+ self.parallel(p['x'], p['y'])
+
+@deprecate
+def intersectSegments(s1, s2):
+ """transforms.Segment(s1).intersect(s2)"""
+ return Point(*s1.intersect(s2))
+
+@deprecate
+def dot(s1, s2):
+ """transforms.Segment(s1).dot(s2)"""
+ return s1.dot(s2)
diff --git a/share/extensions/inkex/deprecated-simple/run_command.py b/share/extensions/inkex/deprecated-simple/run_command.py
new file mode 100755
index 0000000..7190750
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/run_command.py
@@ -0,0 +1,74 @@
+# coding=utf-8
+#
+# Copyright (C) 2008 Stephen Silver
+#
+# 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
+#
+"""
+Deprecated module for running SVG-generating commands in Inkscape extensions
+"""
+import os
+import sys
+import tempfile
+from subprocess import Popen, PIPE
+
+from inkex.deprecated import deprecate
+
+def run(command_format, prog_name):
+ """inkex.commands.call(...)"""
+ svgfile = tempfile.mktemp(".svg")
+ command = command_format % svgfile
+ msg = None
+ # ps2pdf may attempt to write to the current directory, which may not
+ # be writeable, so we switch to the temp directory first.
+ try:
+ os.chdir(tempfile.gettempdir())
+ except IOError:
+ pass
+
+ try:
+ proc = Popen(command, shell=True, stdout=PIPE, stderr=PIPE)
+ return_code = proc.wait()
+ out = proc.stdout.read()
+ err = proc.stderr.read()
+
+ if msg is None:
+ if return_code:
+ msg = "{} failed:\n{}\n{}\n".format(prog_name, out, err)
+ elif err:
+ sys.stderr.write("{} executed but logged the following error:\n{}\n{}\n".format(prog_name, out, err))
+ except Exception as inst:
+ msg = "Error attempting to run {}: {}".format(prog_name, str(inst))
+
+ # If successful, copy the output file to stdout.
+ if msg is None:
+ if os.name == 'nt': # make stdout work in binary on Windows
+ import msvcrt
+ msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
+ try:
+ with open(svgfile, "rb") as fhl:
+ sys.stdout.write(fhl.read().decode(sys.stdout.encoding))
+ except IOError as inst:
+ msg = "Error reading temporary file: {}".format(str(inst))
+
+ try:
+ # Clean up.
+ os.remove(svgfile)
+ except (IOError, OSError):
+ pass
+
+ # Output error message (if any) and exit.
+ return msg
+
diff --git a/share/extensions/inkex/deprecated-simple/simplepath.py b/share/extensions/inkex/deprecated-simple/simplepath.py
new file mode 100644
index 0000000..97748f7
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/simplepath.py
@@ -0,0 +1,51 @@
+# coding=utf-8
+# COPYRIGHT
+#
+# pylint: disable=invalid-name
+#
+"""
+Depreicated simplepath replacements with documentation
+"""
+
+import math
+from inkex.deprecated import deprecate, DeprecatedDict
+from inkex.transforms import Transform
+from inkex.paths import Path
+
+pathdefs = DeprecatedDict({
+ 'M':['L', 2, [float, float], ['x', 'y']],
+ 'L':['L', 2, [float, float], ['x', 'y']],
+ 'H':['H', 1, [float], ['x']],
+ 'V':['V', 1, [float], ['y']],
+ 'C':['C', 6, [float, float, float, float, float, float], ['x', 'y', 'x', 'y', 'x', 'y']],
+ 'S':['S', 4, [float, float, float, float], ['x', 'y', 'x', 'y']],
+ 'Q':['Q', 4, [float, float, float, float], ['x', 'y', 'x', 'y']],
+ 'T':['T', 2, [float, float], ['x', 'y']],
+ 'A':['A', 7, [float, float, float, int, int, float, float], ['r', 'r', 'a', 0, 's', 'x', 'y']],
+ 'Z':['L', 0, [], []]
+})
+
+@deprecate
+def parsePath(d):
+ """element.path.to_arrays()"""
+ return Path(d).to_arrays()
+
+@deprecate
+def formatPath(a):
+ """str(element.path) or str(Path(array))"""
+ return str(Path(a))
+
+@deprecate
+def translatePath(p, x, y):
+ """Path(array).translate(x, y)"""
+ p[:] = Path(p).translate(x, y).to_arrays()
+
+@deprecate
+def scalePath(p, x, y):
+ """Path(array).scale(x, y)"""
+ p[:] = Path(p).scale(x, y).to_arrays()
+
+@deprecate
+def rotatePath(p, a, cx=0, cy=0):
+ """Path(array).rotate(angle_degrees, (center_x, center_y))"""
+ p[:] = Path(p).rotate(math.degrees(a), (cx, cy)).to_arrays()
diff --git a/share/extensions/inkex/deprecated-simple/simplestyle.py b/share/extensions/inkex/deprecated-simple/simplestyle.py
new file mode 100644
index 0000000..3312143
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/simplestyle.py
@@ -0,0 +1,47 @@
+# coding=utf-8
+# COPYRIGHT
+"""DOCSTRING"""
+
+import inkex
+from inkex.colors import SVG_COLOR as svgcolors
+from inkex.deprecated import deprecate
+
+@deprecate
+def parseStyle(s):
+ """dict(inkex.Style.parse_str(s))"""
+ return dict(inkex.Style.parse_str(s))
+
+@deprecate
+def formatStyle(a):
+ """str(inkex.Style(a))"""
+ return str(inkex.Style(a))
+
+@deprecate
+def isColor(c):
+ """inkex.colors.is_color(c)"""
+ return inkex.colors.is_color(c)
+
+@deprecate
+def parseColor(c):
+ """inkex.Color(c).to_rgb()"""
+ return tuple(inkex.Color(c).to_rgb())
+
+@deprecate
+def formatColoria(a):
+ """str(inkex.Color(a))"""
+ return str(inkex.Color(a))
+
+@deprecate
+def formatColorfa(a):
+ """str(inkex.Color(a))"""
+ return str(inkex.Color(a))
+
+@deprecate
+def formatColor3i(r,g,b):
+ """str(inkex.Color((r, g, b)))"""
+ return str(inkex.Color((r, g, b)))
+
+@deprecate
+def formatColor3f(r,g,b):
+ """str(inkex.Color((r, g, b)))"""
+ return str(inkex.Color((r, g, b)))
diff --git a/share/extensions/inkex/deprecated-simple/simpletransform.py b/share/extensions/inkex/deprecated-simple/simpletransform.py
new file mode 100644
index 0000000..1130c30
--- /dev/null
+++ b/share/extensions/inkex/deprecated-simple/simpletransform.py
@@ -0,0 +1,106 @@
+# coding=utf-8
+#
+# pylint: disable=invalid-name
+#
+"""
+Depreicated simpletransform replacements with documentation
+"""
+
+import warnings
+
+from inkex.deprecated import deprecate
+from inkex.transforms import Transform, BoundingBox, cubic_extrema
+from inkex.paths import Path
+
+import inkex, cubicsuperpath
+
+def _lists(mat):
+ return [list(row) for row in mat]
+
+@deprecate
+def parseTransform(transf, mat=None):
+ """Transform(str).matrix"""
+ t = Transform(transf)
+ if mat is not None:
+ t = Transform(mat) * t
+ return _lists(t.matrix)
+
+@deprecate
+def formatTransform(mat):
+ """str(Transform(mat))"""
+ if len(mat) == 3:
+ warnings.warn("3x3 matrices not suported")
+ mat = mat[:2]
+ return str(Transform(mat))
+
+@deprecate
+def invertTransform(mat):
+ """-Transform(mat)"""
+ return _lists((-Transform(mat)).matrix)
+
+@deprecate
+def composeTransform(mat1, mat2):
+ """Transform(M1) * Transform(M2)"""
+ return _lists((Transform(mat1) * Transform(mat2)).matrix)
+
+@deprecate
+def composeParents(node, mat):
+ """elem.composed_transform() or elem.transform * Transform(mat)"""
+ return (node.transform * Transform(mat)).matrix
+
+@deprecate
+def applyTransformToNode(mat, node):
+ """elem.transform = Transform(mat) * elem.transform """
+ node.transform = Transform(mat) * node.transform
+
+@deprecate
+def applyTransformToPoint(mat, pt):
+ """Transform(mat).apply_to_point(pt)"""
+ pt2 = Transform(mat).apply_to_point(pt)
+ # Apply in place as original method was modifying arrays in place.
+ # but don't do this in your code! This is not good code design.
+ pt[0] = pt2[0]
+ pt[1] = pt2[1]
+
+@deprecate
+def applyTransformToPath(mat, path):
+ """Path(path).transform(mat)"""
+ return Path(path).transform(Transform(mat)).to_arrays()
+
+@deprecate
+def fuseTransform(node):
+ """node.apply_transform()"""
+ return node.apply_transform()
+
+@deprecate
+def boxunion(b1, b2):
+ """list(BoundingBox(b1) + BoundingBox(b2))"""
+ bbox = BoundingBox(b1[:2], b1[2:]) + BoundingBox(b2[:2], b2[2:])
+ return bbox.x.minimum, bbox.x.maximum, bbox.y.minimum, bbox.y.maximum
+
+@deprecate
+def roughBBox(path):
+ """list(Path(path)).bounding_box())"""
+ bbox = Path(path).bounding_box()
+ return bbox.x.minimum, bbox.x.maximum, bbox.y.minimum, bbox.y.maximum
+
+@deprecate
+def refinedBBox(path):
+ """list(Path(path)).bounding_box())"""
+ bbox = Path(path).bounding_box()
+ return bbox.x.minimum, bbox.x.maximum, bbox.y.minimum, bbox.y.maximum
+
+@deprecate
+def cubicExtrema(y0, y1, y2, y3):
+ """from inkex.transforms import cubic_extrema"""
+ return cubic_extrema(y0, y1, y2, y3)
+
+@deprecate
+def computeBBox(aList, mat=[[1,0,0],[0,1,0]]):
+ """sum([node.bounding_box() for node in aList])"""
+ return sum([node.bounding_box() for node in aList], None)
+
+@deprecate
+def computePointInNode(pt, node, mat=[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]):
+ """(-Transform(node.transform * mat)).apply_to_point(pt)"""
+ return (-Transform(node.transform * mat)).apply_to_point(pt)
diff --git a/share/extensions/inkex/deprecated.py b/share/extensions/inkex/deprecated.py
new file mode 100644
index 0000000..b7c73ef
--- /dev/null
+++ b/share/extensions/inkex/deprecated.py
@@ -0,0 +1,388 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 - Martin Owens <doctormo@mgail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+Provide some documentation to existing extensions about why they're failing.
+"""
+#
+# We ignore a lot of pylint warnings here:
+#
+# pylint: disable=invalid-name,unused-argument,missing-docstring,too-many-public-methods
+#
+
+import os
+import sys
+import traceback
+import warnings
+import argparse
+from argparse import ArgumentParser
+
+import inkex
+import inkex.utils
+import inkex.units
+from inkex.base import SvgThroughMixin, InkscapeExtension
+from inkex.localization import inkex_gettext as _
+
+warnings.simplefilter("default")
+# To load each of the deprecated sub-modules (the ones without a namespace)
+# we will add the directory to our pythonpath so older scripts can find them
+
+INKEX_DIR = os.path.abspath(os.path.dirname(__file__))
+SIMPLE_DIR = os.path.join(INKEX_DIR, 'deprecated-simple')
+
+if os.path.isdir(SIMPLE_DIR):
+ sys.path.append(SIMPLE_DIR)
+
+try:
+ DEPRECATION_LEVEL = int(os.environ.get('INKEX_DEPRECATION_LEVEL', 1))
+except ValueError:
+ DEPRECATION_LEVEL = 1
+
+def _deprecated(msg, stack=2, level=DEPRECATION_LEVEL):
+ """Internal method for raising a deprecation warning"""
+ if level > 1:
+ msg += ' ; '.join(traceback.format_stack())
+ if level:
+ warnings.warn(msg, category=DeprecationWarning, stacklevel=stack + 1)
+
+class DeprecatedEffect(object):
+ """An Inkscape effect, takes SVG in and outputs SVG, providing a deprecated layer"""
+
+ def __init__(self):
+ super(DeprecatedEffect, self).__init__()
+
+ self._doc_ids = None
+
+ # These are things we reference in the deprecated code, they are provided
+ # by the new effects code, but we want to keep this as a Mixin so these
+ # items will keep pylint happy and let use check our code as we write.
+ if not hasattr(self, 'svg'):
+ from .elements import SvgDocumentElement
+ self.svg = SvgDocumentElement()
+ if not hasattr(self, 'arg_parser'):
+ self.arg_parser = ArgumentParser()
+ if not hasattr(self, 'run'):
+ self.run = self.affect
+
+ @classmethod
+ def _deprecated(cls, name, msg=_('{} is deprecated and should be removed'), stack=3):
+ """Give the user a warning about their extension using a deprecated API"""
+ _deprecated(
+ msg.format('Effect.' + name, cls=cls.__module__ + '.' + cls.__name__),
+ stack=stack)
+
+ @property
+ def OptionParser(self):
+ self._deprecated(
+ 'OptionParser',
+ _('{} or `optparse` has been deprecated and replaced with `argparser`.'
+ 'You must change `self.OptionParser.add_option` to '
+ '`self.arg_parser.add_argument`; the arguments are similar.'))
+ return self
+
+ def add_option(self, *args, **kw):
+ # Convert type string into type method as needed
+ if 'type' in kw:
+ kw['type'] = {
+ 'string': str,
+ 'int': int,
+ 'float': float,
+ 'inkbool': inkex.utils.Boolean,
+ }.get(kw['type'])
+ if kw.get('action', None) == 'store':
+ # Default store action not required, removed.
+ kw.pop('action')
+ args = [arg for arg in args if arg != ""]
+ self.arg_parser.add_argument(*args, **kw)
+
+ def effect(self):
+ self._deprecated('effect', _('{} method is now a required method. It should '
+ 'be created on {cls}, even if it does nothing.'))
+
+ @property
+ def current_layer(self):
+ self._deprecated('current_layer',\
+ _('{} is now a method in the svg. Use `self.svg.get_current_layer()` instead.'))
+ return self.svg.get_current_layer()
+
+ @property
+ def view_center(self):
+ self._deprecated('view_center',\
+ _('{} is now a method in the svg. Use `self.svg.namedview.center` instead.'))
+ return self.svg.namedview.center
+
+ @property
+ def selected(self):
+ self._deprecated('selected', _('{} is now a dict in the svg. Use `self.svg.selected`.'))
+ return dict([(elem.get('id'), elem) for elem in self.svg.selected.values()])
+
+ @property
+ def doc_ids(self):
+ self._deprecated('doc_ids', _('{} is now a method in the svg '
+ 'document. Use `self.svg.get_ids()` instead.'))
+ if self._doc_ids is None:
+ self._doc_ids = dict.fromkeys(self.svg.get_ids())
+ return self._doc_ids
+
+ def getdocids(self):
+ self._deprecated('getdocids', _('Use `self.svg.get_ids()` instead of {} and `doc_ids`.'))
+ self._doc_ids = None
+ self.svg.ids.clear()
+
+ def getselected(self):
+ self._deprecated('getselected', _('{} has been removed'))
+
+ def getElementById(self, eid):
+ self._deprecated('getElementById',\
+ _('{} is now a method in the svg. Use `self.svg.getElementById(eid)` instead.'))
+ return self.svg.getElementById(eid)
+
+ def xpathSingle(self, xpath):
+ self._deprecated('xpathSingle', _('{} is now a new method in the svg '
+ 'document. Use `self.svg.getElement(path)` instead.`'))
+ return self.svg.getElement(xpath)
+
+ def getParentNode(self, node):
+ self._deprecated('getParentNode',\
+ _('{} is no longer in use. Use the lxml .getparent() method instead.'))
+ return node.getparent()
+
+ def getNamedView(self):
+ self._deprecated('getNamedView',\
+ _('{} is now a property of the svg. Use `self.svg.namedview` to access this element'))
+ return self.svg.namedview
+
+ def createGuide(self, posX, posY, angle):
+ from .elements import Guide
+ self._deprecated('createGuide',\
+ _('{} is now a method of the namedview element object. '
+ 'Use `self.svg.namedview.add(Guide().move_to(x, y, a))` instead'))
+ return self.svg.namedview.add(Guide().move_to(posX, posY, angle))
+
+ def affect(self, args=sys.argv[1:], output=True): # pylint: disable=dangerous-default-value
+ # We need a list as the default value to preserve backwards compatibility
+ self._deprecated('affect', _('{} is now `Effect.run()`. The `output` argument has changed.'))
+ self._args = args[-1:]
+ return self.run(args=args)
+
+ @property
+ def args(self):
+ self._deprecated('args', _('self.args[-1] is now self.options.input_file'))
+ return self._args
+
+ @property
+ def svg_file(self):
+ self._deprecated('svg_file', _('self.svg_file is now self.options.input_file'))
+ return self.options.input_file
+
+ def save_raw(self, ret):
+ # Derived class may implement "output()"
+ # Attention: 'cubify.py' implements __getattr__ -> hasattr(self, 'output') returns True
+ if hasattr(self.__class__, 'output'):
+ self._deprecated('output', 'Use `save()` or `save_raw()` instead.', stack=5)
+ return getattr(self, 'output')()
+ return inkex.base.InkscapeExtension.save_raw(self, ret)
+
+ def uniqueId(self, old_id, make_new_id=True):
+ self._deprecated('uniqueId', _('{} is now a method in the svg document. '
+ ' Use `self.svg.get_unique_id(old_id)` instead.'))
+ return self.svg.get_unique_id(old_id)
+
+ def getDocumentWidth(self):
+ self._deprecated('getDocumentWidth', _('{} is now a property of the svg '
+ 'document. Use `self.svg.width` instead.'))
+ return self.svg.get('width')
+
+ def getDocumentHeight(self):
+ self._deprecated('getDocumentHeight', _('{} is now a property of the svg '
+ 'document. Use `self.svg.height` instead.'))
+ return self.svg.get('height')
+
+ def getDocumentUnit(self):
+ self._deprecated('getDocumentUnit', _('{} is now a property of the svg '
+ 'document. Use `self.svg.unit` instead.'))
+ return self.svg.unit
+
+ def unittouu(self, string):
+ self._deprecated('unittouu', _('{} is now a method in the svg '
+ 'document. Use `self.svg.unittouu(str)` instead.'))
+ return self.svg.unittouu(string)
+
+ def uutounit(self, val, unit):
+ self._deprecated('uutounit', _('{} is now a method in the svg '
+ 'document. Use `self.svg.uutounit(value, unit)` instead.'))
+ return self.svg.uutounit(val, unit)
+
+ def addDocumentUnit(self, value):
+ self._deprecated('addDocumentUnit', _('{} is now a method in the svg '
+ 'document. Use `self.svg.add_unit(value)` instead.'))
+ return self.svg.add_unit(value)
+
+class Effect(SvgThroughMixin, DeprecatedEffect, InkscapeExtension):
+ """An Inkscape effect, takes SVG in and outputs SVG"""
+ pass
+
+def deprecate(func):
+ """Function decorator for deprecation functions which have a one-liner
+ equivalent in the new API. The one-liner has to passed as a string
+ to the decorator.
+
+ >>> @deprecate
+ >>> def someOldFunction(*args):
+ >>> '''Example replacement code someNewFunction('foo', ...)'''
+ >>> someNewFunction('foo', *args)
+
+ Or if the args API is the same:
+
+ >>> someOldFunction = deprecate(someNewFunction)
+
+ """
+
+ def _inner(*args, **kwargs):
+ _deprecated('{0.__module__}.{0.__name__} -> {0.__doc__}'.format(func), stack=2)
+ return func(*args, **kwargs)
+ _inner.__name__ = func.__name__
+ if func.__doc__:
+ _inner.__doc__ = "Deprecated -> " + func.__doc__
+ return _inner
+
+class DeprecatedDict(dict):
+ @deprecate
+ def __getitem__(self, key):
+ return super(DeprecatedDict, self).__getitem__(key)
+
+ @deprecate
+ def __iter__(self):
+ return super(DeprecatedDict, self).__iter__()
+
+# legacy inkex members
+
+class lazyproxy(object):
+ """Proxy, use as decorator on a function with provides the wrapped object.
+ The decorated function is called when a member is accessed on the proxy.
+ """
+ def __init__(self, getwrapped):
+ '''
+ :param getwrapped: Callable which returns the wrapped object
+ '''
+ self._getwrapped = getwrapped
+
+ def __getattr__(self, name):
+ return getattr(self._getwrapped(), name)
+
+ def __call__(self, *args, **kwargs):
+ return self._getwrapped()(*args, **kwargs)
+
+@lazyproxy
+def optparse():
+ _deprecated('inkex.optparse was removed, use "import optparse"', stack=3)
+ import optparse as wrapped
+ return wrapped
+
+@lazyproxy
+def etree():
+ _deprecated('inkex.etree was removed, use "from lxml import etree"', stack=3)
+ from lxml import etree as wrapped
+ return wrapped
+
+@lazyproxy
+def InkOption():
+ import optparse
+ class wrapped(optparse.Option):
+ TYPES = optparse.Option.TYPES + ("inkbool", )
+ TYPE_CHECKER = dict(optparse.Option.TYPE_CHECKER)
+ TYPE_CHECKER["inkbool"] = lambda _1, _2, v: str(v).capitalize() == 'True'
+ return wrapped
+
+@lazyproxy
+def localize():
+ _deprecated('inkex.localize was moved to inkex.localization.localize', stack=3)
+ from .localization import localize as wrapped
+ return wrapped
+
+def are_near_relative(a, b, eps):
+ _deprecated('inkex.are_near_relative was moved to '
+ 'inkex.units.are_near_relative', stack=2)
+ return inkex.units.are_near_relative(a, b, eps)
+
+def debug(what):
+ _deprecated('inkex.debug was moved to inkex.utils.debug', stack=2)
+ return inkex.utils.debug(what)
+
+# legacy inkex members <= 0.48.x
+
+def unittouu(string):
+ _deprecated('inkex.unittouu is now a method in the svg '
+ 'document. Use `self.svg.unittouu(str)` instead.', stack=2)
+ return inkex.units.convert_unit(string, 'px')
+
+# optparse.Values.ensure_value
+
+def ensure_value(self, attr, value):
+ _deprecated('Effect().options.ensure_value was removed', stack=2)
+ if getattr(self, attr, None) is None:
+ setattr(self, attr, value)
+ return getattr(self, attr)
+
+argparse.Namespace.ensure_value = ensure_value # type: ignore
+
+@deprecate
+def zSort(inNode, idList):
+ """self.svg.get_z_selected()"""
+ sortedList = []
+ theid = inNode.get("id")
+ if theid in idList:
+ sortedList.append(theid)
+ for child in inNode:
+ if len(sortedList) == len(idList):
+ break
+ sortedList += zSort(child, idList)
+ return sortedList
+
+class DeprecatedSvgMixin(object):
+ """Mixin which adds deprecated API elements to the SvgDocumentElement"""
+ @property
+ def selected(self):
+ """svg.selection"""
+ return self.selection
+
+ def set_selected(self, *ids):
+ """svg.selection.set(*ids)"""
+ return self.selection.set(*ids)
+
+ def get_z_selected(self):
+ """svg.selection.paint_order()"""
+ return self.selection.paint_order()
+
+ def get_selected(self, *types):
+ """svg.selection.filter(*types).values()"""
+ return self.selection.filter(*types).values()
+
+ def get_selected_or_all(self, *types):
+ """Set select_all = True in extension class"""
+ if not self.selection:
+ self.selection.set_all()
+ return self.selection.filter(*types)
+
+ def get_selected_bbox(self):
+ """selection.bounding_box()"""
+ return self.selection.bounding_box()
+
+ def get_first_selected(self, *types):
+ """selection.filter(*types).first() or [0] if you'd like an error"""
+ return self.selection.filter(*types).first()
diff --git a/share/extensions/inkex/elements/__init__.py b/share/extensions/inkex/elements/__init__.py
new file mode 100644
index 0000000..f5f0d65
--- /dev/null
+++ b/share/extensions/inkex/elements/__init__.py
@@ -0,0 +1,19 @@
+"""
+Element based interface provides the bulk of features that allow you to
+interact directly with the SVG xml interface.
+
+See the documentation for each of the elements for details on how it works.
+"""
+
+from ._base import SVG_PARSER, load_svg, ShapeElement, BaseElement
+from ._svg import SvgDocumentElement
+from ._groups import Group, Layer, Anchor, Marker, ClipPath
+from ._polygons import PathElement, Polyline, Polygon, Line, Rectangle, Circle, Ellipse
+from ._text import FlowRegion, FlowRoot, FlowPara, FlowDiv, FlowSpan, TextElement, \
+ TextPath, Tspan, SVGfont, FontFace, Glyph, MissingGlyph
+from ._use import Symbol, Use
+from ._meta import Defs, StyleElement, Script, Desc, Title, NamedView, Guide, \
+ Metadata, ForeignObject, Switch, Grid
+from ._filters import Filter, Pattern, Gradient, LinearGradient, RadialGradient, \
+ PathEffect, Stop, MeshGradient, MeshRow, MeshPatch
+from ._image import Image
diff --git a/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc
new file mode 100644
index 0000000..718ece7
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc
new file mode 100644
index 0000000..06dd762
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc
new file mode 100644
index 0000000..a1d3a70
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc
new file mode 100644
index 0000000..35305d8
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc
new file mode 100644
index 0000000..1c2849c
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc
new file mode 100644
index 0000000..807de7b
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc
new file mode 100644
index 0000000..270e082
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc
new file mode 100644
index 0000000..6272318
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc
new file mode 100644
index 0000000..800e778
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc
new file mode 100644
index 0000000..1e898f2
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc
new file mode 100644
index 0000000..7fb1511
--- /dev/null
+++ b/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/elements/_base.py b/share/extensions/inkex/elements/_base.py
new file mode 100644
index 0000000..e922908
--- /dev/null
+++ b/share/extensions/inkex/elements/_base.py
@@ -0,0 +1,514 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=arguments-differ
+"""
+Provide extra utility to each svg element type specific to its type.
+
+This is useful for having a common interface for each element which can
+give path, transform, and property access easily.
+"""
+
+from collections import defaultdict
+from copy import deepcopy
+from lxml import etree
+
+from ..paths import Path
+from ..styles import Style, AttrFallbackStyle, Classes
+from ..transforms import Transform, BoundingBox
+from ..utils import PY3, NSS, addNS, removeNS, splitNS, FragmentError
+from ..utils import InitSubClassPy3
+
+try:
+ from typing import overload, DefaultDict, Type, Any, List, Tuple, Union, Optional # pylint: disable=unused-import
+except ImportError:
+ overload = lambda x: x
+
+class NodeBasedLookup(etree.PythonElementClassLookup):
+ """
+ We choose what kind of Elements we should return for each element, providing useful
+ SVG based API to our extensions system.
+ """
+ # (ns,tag) -> list(cls) ; ascending priority
+ lookup_table = defaultdict(list) # type: DefaultDict[str, List[Any]]
+
+ @classmethod
+ def register_class(cls, klass):
+ """Register the given class using it's attached tag name"""
+ cls.lookup_table[splitNS(klass.tag_name)].append(klass)
+
+ def lookup(self, doc, element): # pylint: disable=unused-argument
+ """Lookup called by lxml when assigning elements their object class"""
+ try:
+ for cls in reversed(self.lookup_table[splitNS(element.tag)]):
+ if cls._is_class_element(element): # pylint: disable=protected-access
+ return cls
+ except TypeError:
+ # Handle non-element proxies case
+ # The documentation implies that it's not possible
+ # Didn't found a reliable way to check whether proxy corresponds to element or not
+ # Look like lxml issue to me.
+ # The troubling element is "<!--Comment-->"
+ return None
+ return BaseElement
+
+
+SVG_PARSER = etree.XMLParser(huge_tree=True, strip_cdata=False)
+SVG_PARSER.set_element_class_lookup(NodeBasedLookup())
+
+def load_svg(stream):
+ """Load SVG file using the SVG_PARSER"""
+ if (isinstance(stream, str) and stream.lstrip().startswith('<'))\
+ or (isinstance(stream, bytes) and stream.lstrip().startswith(b'<')):
+ return etree.ElementTree(etree.fromstring(stream, parser=SVG_PARSER))
+ return etree.parse(stream, parser=SVG_PARSER)
+
+class BaseElement(etree.ElementBase):
+ """Provide automatic namespaces to all calls"""
+ # Included for python2 support (this branch is for 1.0.x only)
+ __metaclass__ = InitSubClassPy3
+ @classmethod
+ def __init_subclass__(cls):
+ if cls.tag_name:
+ NodeBasedLookup.register_class(cls)
+
+ @classmethod
+ def _is_class_element(cls, el): # type: (etree.Element) -> bool
+ """Hook to do more restrictive check in addition to (ns,tag) match"""
+ return True
+
+ tag_name = ''
+
+ @property
+ def TAG(self): # pylint: disable=invalid-name
+ """Return the tag_name without NS"""
+ if not self.tag_name:
+ return removeNS(super(etree.ElementBase, self).tag)[-1]
+ return removeNS(self.tag_name)[-1]
+
+ @classmethod
+ def new(cls, *children, **attrs):
+ """Create a new element, converting attrs values to strings."""
+ obj = cls(*children)
+ obj.update(**attrs)
+ return obj
+
+ NAMESPACE = property(lambda self: splitNS(self.tag_name)[0])
+ PARSER = SVG_PARSER
+ WRAPPED_ATTRS = (
+ # (prop_name, [optional: attr_name], cls)
+ ('transform', Transform),
+ ('style', Style),
+ ('classes', 'class', Classes),
+ ) # type: Tuple[Tuple[Any, ...], ...]
+
+ # We do this because python2 and python3 have different ways
+ # of combining two dictionaries that are incompatible.
+ # This allows us to update these with inheritance.
+ @property
+ def wrapped_attrs(self):
+ """Map attributes to property name and wrapper class"""
+ return dict([(row[-2], (row[0], row[-1])) for row in self.WRAPPED_ATTRS])
+
+ @property
+ def wrapped_props(self):
+ """Map properties to attribute name and wrapper class"""
+ return dict([(row[0], (row[-2], row[-1])) for row in self.WRAPPED_ATTRS])
+
+ typename = property(lambda self: type(self).__name__)
+ xml_path = property(lambda self: self.getroottree().getpath(self))
+
+ def __getattr__(self, name):
+ """Get the attribute, but load it if it is not available yet"""
+ if name in self.wrapped_props:
+ (attr, cls) = self.wrapped_props[name]
+ # The reason we do this here and not in _init is because lxml
+ # is inconsistant about when elements are initialised.
+ # So we make this a lazy property.
+ def _set_attr(new_item):
+ if new_item:
+ self.set(attr, str(new_item))
+ else:
+ self.attrib.pop(attr, None) # pylint: disable=no-member
+
+ # pylint: disable=no-member
+ value = cls(self.attrib.get(attr, None), callback=_set_attr)
+ setattr(self, name, value)
+ return value
+ raise AttributeError("Can't find attribute {}.{}".format(self.typename, name))
+
+ def __setattr__(self, name, value):
+ """Set the attribute, update it if needed"""
+ if name in self.wrapped_props:
+ (attr, cls) = self.wrapped_props[name]
+ # Don't call self.set or self.get (infinate loop)
+ if value:
+ if not isinstance(value, cls):
+ value = cls(value)
+ self.attrib[attr] = str(value)
+ else:
+ self.attrib.pop(attr, None) # pylint: disable=no-member
+ else:
+ super(BaseElement, self).__setattr__(name, value)
+
+ def get(self, attr, default=None):
+ """Get element attribute named, with addNS support."""
+ if attr in self.wrapped_attrs:
+ (prop, _) = self.wrapped_attrs[attr]
+ value = getattr(self, prop, None)
+ # We check the boolean nature of the value, because empty
+ # transformations and style attributes are equiv to not-existing
+ ret = str(value) if value else (default or None)
+ return ret
+ return super(BaseElement, self).get(addNS(attr), default)
+
+ def set(self, attr, value):
+ """Set element attribute named, with addNS support"""
+ if attr in self.wrapped_attrs:
+ # Always keep the local wrapped class up to date.
+ (prop, cls) = self.wrapped_attrs[attr]
+ setattr(self, prop, cls(value))
+ value = getattr(self, prop)
+ if not value:
+ return
+ if value is None:
+ self.attrib.pop(addNS(attr), None) # pylint: disable=no-member
+ else:
+ value = str(value) if PY3 else unicode(value) # pylint: disable=undefined-variable
+ super(BaseElement, self).set(addNS(attr), value)
+
+ def update(self, **kwargs):
+ """
+ Update element attributes using keyword arguments
+
+ Note: double underscore is used as namespace separator,
+ i.e. "namespace__attr" argument name will be treated as "namespace:attr"
+
+ :param kwargs: dict with name=value pairs
+ :return: self
+ """
+ for name, value in kwargs.items():
+ self.set(name, value)
+ return self
+
+ def pop(self, attr, default=None):
+ """Delete/remove the element attribute named, with addNS support."""
+ if attr in self.wrapped_attrs:
+ # Always keep the local wrapped class up to date.
+ (prop, cls) = self.wrapped_attrs[attr]
+ value = getattr(self, prop)
+ setattr(self, prop, cls(None))
+ return value
+ return self.attrib.pop(addNS(attr), default) # pylint: disable=no-member
+
+ def add(self, *children):
+ """
+ Like append, but will do multiple children and will return
+ children or only child
+ """
+ for child in children:
+ self.append(child)
+ return children if len(children) != 1 else children[0]
+
+ def tostring(self):
+ """Return this element as it would appear in an svg document"""
+ # This kind of hack is pure maddness, but etree provides very little
+ # in the way of fragment printing, prefering to always output valid xml
+ from ..base import SvgOutputMixin
+ svg = SvgOutputMixin.get_template(width=0, height=0).getroot()
+ svg.append(self.copy())
+ return svg.tostring().split(b'>\n ', 1)[-1][:-6]
+
+ def description(self, text):
+ """Set the desc element with text"""
+ from ._meta import Desc
+ desc = self.add(Desc())
+ desc.text = text
+
+ def set_random_id(self, prefix=None, size=4, backlinks=False):
+ """Sets the id attribute if it is not already set."""
+ prefix = str(self) if prefix is None else prefix
+ self.set_id(self.root.get_unique_id(prefix, size=size), backlinks=backlinks)
+
+ def set_random_ids(self, prefix=None, levels=-1, backlinks=False):
+ """Same as set_random_id, but will apply also to children"""
+ self.set_random_id(prefix=prefix, backlinks=backlinks)
+ if levels != 0:
+ for child in self:
+ if hasattr(child, 'set_random_ids'):
+ child.set_random_ids(prefix=prefix, levels=levels-1, backlinks=backlinks)
+
+ def get_id(self):
+ """Get the id for the element, will set a new unique id if not set"""
+ if 'id' not in self.attrib:
+ self.set_random_id(self.TAG)
+ return self.get('id')
+
+ def set_id(self, new_id, backlinks=False):
+ """Set the id and update backlinks to xlink and style urls if needed"""
+ old_id = self.get('id', None)
+ self.set('id', new_id)
+ if backlinks and old_id:
+ for elem in self.root.getElementsByHref(old_id):
+ elem.href = self
+ for elem in self.root.getElementsByStyleUrl(old_id):
+ elem.style.update_urls(old_id, new_id)
+
+ @property
+ def root(self):
+ """Get the root document element from any element descendent"""
+ if self.getparent() is not None:
+ return self.getparent().root
+ from ._svg import SvgDocumentElement
+ if not isinstance(self, SvgDocumentElement):
+ raise FragmentError("Element fragment does not have a document root!")
+ return self
+
+ def get_or_create(self, xpath, nodeclass, prepend=False):
+ """Get or create the given xpath, pre/append new node if not found."""
+ node = self.findone(xpath)
+ if node is None:
+ node = nodeclass()
+ if prepend:
+ self.insert(0, node)
+ else:
+ self.append(node)
+ return node
+
+ def descendants(self):
+ """Walks the element tree and yields all elements, parent first"""
+ from ._selected import ElementList
+ return ElementList(self.root, self._descendants())
+
+ def _descendants(self):
+ yield self
+ for child in self:
+ if hasattr(child, '_descendants'):
+ for item in child._descendants(): # pylint: disable=protected-access
+ yield item
+
+ def ancestors(self, elem=None, stop_at=()):
+ """
+ Walk the parents and yield all the ancestor elements, parent first
+
+ If elem is provided, it will stop at the last common ancestor.
+ If stop_at is provided, it will stop at the first parent that is in this list.
+ """
+ from ._selected import ElementList
+ return ElementList(self.root, self._ancestors(elem=elem, stop_at=stop_at))
+
+ def _ancestors(self, elem, stop_at):
+ if isinstance(elem, BaseElement):
+ stop_at = list(elem.ancestors().values())
+ parent = self.getparent()
+ if parent is not None:
+ yield parent
+ if parent not in stop_at:
+ for item in parent._ancestors(elem=elem, stop_at=stop_at): # pylint: disable=protected-access
+ yield item
+
+ def backlinks(self, *types):
+ """Get elements which link back to this element, like ancestors but via xlinks"""
+ if not types or isinstance(self, types):
+ yield self
+ my_id = self.get('id')
+ if my_id is not None:
+ elems = list(self.root.getElementsByHref(my_id)) \
+ + list(self.root.getElementsByStyleUrl(my_id))
+ for elem in elems:
+ if hasattr(elem, 'backlinks'):
+ for child in elem.backlinks(*types):
+ yield child
+
+ def xpath(self, pattern, namespaces=NSS): # pylint: disable=dangerous-default-value
+ """Wrap xpath call and add svg namespaces"""
+ return super(BaseElement, self).xpath(pattern, namespaces=namespaces)
+
+ def findall(self, pattern, namespaces=NSS): # pylint: disable=dangerous-default-value
+ """Wrap findall call and add svg namespaces"""
+ return super(BaseElement, self).findall(pattern, namespaces=namespaces)
+
+ def findone(self, xpath):
+ """Gets a single element from the given xpath or returns None"""
+ el_list = self.xpath(xpath)
+ return el_list[0] if el_list else None
+
+ def delete(self):
+ """Delete this node from it's parent node"""
+ if self.getparent() is not None:
+ self.getparent().remove(self)
+
+ def remove_all(self, *types):
+ """Remove all children or child types"""
+ for child in self:
+ if not types or isinstance(child, types):
+ self.remove(child)
+
+ def replace_with(self, elem):
+ """Replace this element with the given element"""
+ self.addnext(elem)
+ if not elem.get('id') and self.get('id'):
+ elem.set('id', self.get('id'))
+ if not elem.label and self.label:
+ elem.label = self.label
+ self.delete()
+ return elem
+
+ def copy(self):
+ """Make a copy of the element and return it"""
+ elem = deepcopy(self)
+ elem.set('id', None)
+ return elem
+
+ def duplicate(self):
+ """Like copy(), but the copy stays in the tree and sets a random id"""
+ elem = self.copy()
+ self.addnext(elem)
+ elem.set_random_id()
+ return elem
+
+ def __str__(self):
+ # We would do more here, but lxml is VERY unpleseant when it comes to
+ # namespaces, basically over printing details and providing no
+ # supression mechanisms to turn off xml's over engineering.
+ return str(self.tag).split('}')[-1]
+
+ @property
+ def href(self):
+ """Returns the referred-to element if available"""
+ ref = self.get('xlink:href')
+ if not ref:
+ return None
+ return self.root.getElementById(ref.strip('#'))
+
+ @href.setter
+ def href(self, elem):
+ """Set the href object"""
+ if isinstance(elem, BaseElement):
+ elem = elem.get_id()
+ self.set('xlink:href', '#' + elem)
+
+ def fallback_style(self, move=False):
+ """Get styles falling back to element attributes"""
+ return AttrFallbackStyle(self, move=move)
+
+ @property
+ def label(self):
+ """Returns the inkscape label"""
+ return self.get('inkscape:label', None)
+
+ label = label.setter(lambda self, value: self.set('inkscape:label', str(value))) # type: ignore
+
+
+class ShapeElement(BaseElement):
+ """Elements which have a visible representation on the canvas"""
+ @property
+ def path(self):
+ """Gets the outline or path of the element, this may be a simple bounding box"""
+ return Path(self.get_path())
+
+ @path.setter
+ def path(self, path):
+ self.set_path(path)
+
+ @property
+ def clip(self):
+ """Gets the clip path element (if any)"""
+ ref = self.get('clip-path')
+ if not ref:
+ return None
+ return self.root.getElementById(ref)
+
+ @clip.setter
+ def clip(self, elem):
+ self.set('clip-path', 'url(#' + elem.get_id() + ')')
+
+ def get_path(self):
+ """Generate a path for this object which can inform the bounding box"""
+ raise NotImplementedError("Path should be provided by svg elem {}.".format(self.typename))
+
+ def set_path(self, path):
+ """Set the path for this object (if possible)"""
+ raise AttributeError(
+ "Path can not be set on this element: {} <- {}.".format(self.typename, path))
+
+ def to_path_element(self):
+ """Replace this element with a path element"""
+ from ._polygons import PathElement
+ elem = PathElement()
+ elem.path = self.path
+ elem.style = self.effective_style()
+ elem.transform = self.transform
+ return elem
+
+ def composed_transform(self, other=None):
+ """Calculate every transform down to the other element
+ if none specified the transform is to the root document element"""
+ parent = self.getparent()
+ if parent is not None and isinstance(parent, ShapeElement):
+ return parent.composed_transform() * self.transform
+ return self.transform
+
+ def composed_style(self):
+ """Calculate the final styles applied to this element"""
+ parent = self.getparent()
+ if parent is not None and isinstance(parent, ShapeElement):
+ return parent.composed_style() + self.style
+ return self.style
+
+ def cascaded_style(self):
+ """Add all cascaded styles, do not write to this Style object"""
+ ret = Style()
+ for style in self.root.stylesheets.lookup(self.get('id')):
+ ret += style
+ return ret + self.style
+
+ def effective_style(self):
+ """Without parent styles, what is the effective style is"""
+ return self.style
+
+ def bounding_box(self, transform=None):
+ # type: (Optional[Transform]) -> Optional[BoundingBox]
+ """BoundingBox of the shape (adjusted for its clip path if applicable)"""
+ shape_box = self.shape_box(transform)
+ clip = self.clip
+ if clip is None or shape_box is None:
+ return shape_box
+ return shape_box & clip.bounding_box(Transform(transform) * self.transform)
+
+ def shape_box(self, transform=None):
+ # type: (Optional[Transform]) -> Optional[BoundingBox]
+ """BoundingBox of the unclipped shape"""
+ path = self.path.to_absolute()
+ if transform is True:
+ path = path.transform(self.composed_transform())
+ else:
+ path = path.transform(self.transform)
+ if transform: # apply extra transformation
+ path = path.transform(transform)
+ return path.bounding_box()
+
+ def is_visible(self):
+ """Returns false if the css says this object is invisible"""
+ if self.style.get('display', '') == 'none':
+ return False
+ if not float(self.style.get('opacity', 1.0)):
+ return False
+ return True
diff --git a/share/extensions/inkex/elements/_filters.py b/share/extensions/inkex/elements/_filters.py
new file mode 100644
index 0000000..ff6883f
--- /dev/null
+++ b/share/extensions/inkex/elements/_filters.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=arguments-differ
+"""
+Element interface for patterns, filters, gradients and path effects.
+"""
+
+from lxml import etree
+from copy import deepcopy
+
+from ..utils import addNS
+from ..transforms import Transform
+from ..tween import interpcoord, interp
+from ..units import convert_unit
+
+from ..styles import Style
+from ._base import BaseElement
+
+
+try:
+ from typing import overload, Iterable, List, Tuple, Union, Optional # pylint: disable=unused-import
+except ImportError:
+ overload = lambda x: x
+
+
+class Filter(BaseElement):
+ """A filter (usually in defs)"""
+ tag_name = 'filter'
+
+ def add_primitive(self, fe_type, **args):
+ """Create a filter primitive with the given arguments"""
+ elem = etree.SubElement(self, addNS(fe_type, 'svg'))
+ elem.update(**args)
+ return elem
+
+ class Primitive(BaseElement):
+ pass
+
+ class Blend(Primitive):
+ tag_name = 'feBlend'
+
+ class ColorMatrix(Primitive):
+ tag_name = 'feColorMatrix'
+
+ class ComponentTransfer(Primitive):
+ tag_name = 'feComponentTransfer'
+
+ class Composite(Primitive):
+ tag_name = 'feComposite'
+
+ class ConvolveMatrix(Primitive):
+ tag_name = 'feConvolveMatrix'
+
+ class DiffuseLighting(Primitive):
+ tag_name = 'feDiffuseLighting'
+
+ class DisplacementMap(Primitive):
+ tag_name = 'feDisplacementMap'
+
+ class Flood(Primitive):
+ tag_name = 'feFlood'
+
+ class GaussianBlur(Primitive):
+ tag_name = 'feGaussianBlur'
+
+ class Image(Primitive):
+ tag_name = 'feImage'
+
+ class Merge(Primitive):
+ tag_name = 'feMerge'
+
+ class Morphology(Primitive):
+ tag_name = 'feMorphology'
+
+ class Offset(Primitive):
+ tag_name = 'feOffset'
+
+ class SpecularLighting(Primitive):
+ tag_name = 'feSpecularLighting'
+
+ class Tile(Primitive):
+ tag_name = 'feTile'
+
+ class Turbulence(Primitive):
+ tag_name = 'feTurbulence'
+
+
+class Stop(BaseElement):
+ tag_name = 'stop'
+
+ @property
+ def offset(self):
+ # type: () -> float
+ return self.get('offset')
+
+ @offset.setter
+ def offset(self, number):
+ self.set('offset', number)
+
+ def interpolate(self, other, fraction):
+ newstop = Stop()
+ newstop.style = self.style.interpolate(other.style, fraction)
+ newstop.offset = interpcoord(float(self.offset), float(other.offset), fraction)
+ return newstop
+
+
+class Pattern(BaseElement):
+ """Pattern element which is used in the def to control repeating fills"""
+ tag_name = 'pattern'
+ WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (('patternTransform', Transform),)
+
+
+class Gradient(BaseElement):
+ """A gradient instruction usually in the defs"""
+ WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (('gradientTransform', Transform),)
+
+ orientation_attributes = () # type: Tuple[str, ...]
+
+ @property
+ def stops(self):
+ """Return an ordered list of own or linked stop nodes"""
+ gradcolor = self.href if isinstance(self.href, LinearGradient) else self
+ return sorted([child for child in gradcolor if isinstance(child, Stop)]
+ , key=lambda x: float(x.offset))
+
+ @property
+ def stop_offsets(self):
+ # type: () -> List[float]
+ """Return a list of own or linked stop offsets"""
+ return [child.offset for child in self.stops]
+
+ @property
+ def stop_styles(self): # type: () -> List[Style]
+ """Return a list of own or linked offset styles"""
+ return [child.style for child in self.stops]
+
+ def remove_orientation(self):
+ """Remove all orientation attributes from this element"""
+ for attr in self.orientation_attributes:
+ self.pop(attr)
+
+ def interpolate(self, other, fraction): # type: (LinearGradient, float) -> LinearGradient
+ """Interpolate with another gradient."""
+ if self.tag_name != other.tag_name:
+ return self
+ newgrad = self.copy()
+
+ # interpolate transforms
+ newtransform = self.gradientTransform.interpolate(other.gradientTransform, fraction)
+ newgrad.gradientTransform = newtransform
+
+ # interpolate orientation
+ for attr in self.orientation_attributes:
+ newattr = interpcoord(convert_unit(self.get(attr), 'px'), convert_unit(other.get(attr), 'px'), fraction)
+ newgrad.set(attr, newattr)
+
+ # interpolate stops
+ if self.href is not None and self.href is other.href:
+ # both gradients link to the same stops
+ pass
+ else:
+ # gradients might have different stops
+ newoffsets = sorted(self.stop_offsets + other.stop_offsets[1:-1])
+ func = lambda x,y,f: x.interpolate(y, f)
+ sstops = interp(self.stop_offsets, list(self.stops), newoffsets, func)
+ ostops = interp(other.stop_offsets, list(other.stops), newoffsets, func)
+ newstops = [s1.interpolate(s2, fraction) for s1, s2 in zip(sstops, ostops)]
+ newgrad.remove_all(Stop)
+ newgrad.add(*newstops)
+ return newgrad
+
+ def stops_and_orientation(self):
+ """Return a copy of all the stops in this gradient"""
+ stops = self.copy()
+ stops.remove_orientation()
+ orientation = self.copy()
+ orientation.remove_all(Stop)
+ return stops, orientation
+
+
+class LinearGradient(Gradient):
+ tag_name = 'linearGradient'
+ orientation_attributes = ('x1', 'y1', 'x2', 'y2')
+
+ def apply_transform(self): # type: () -> None
+ """Apply transform to orientation points and set it to identity."""
+ trans = self.pop('gradientTransform')
+ p1 = (convert_unit(self.get('x1'), 'px'), convert_unit(self.get('y1'), 'px'))
+ p2 = (convert_unit(self.get('x2'), 'px'), convert_unit(self.get('y2'), 'px'))
+ p1t = trans.apply_to_point(p1)
+ p2t = trans.apply_to_point(p2)
+ self.update(x1=p1t[0], y1=p1t[1], x2=p2t[0], y2=p2t[1])
+
+
+class RadialGradient(Gradient):
+ tag_name = 'radialGradient'
+ orientation_attributes = ('cx', 'cy', 'fx', 'fy', 'r')
+
+ def apply_transform(self): # type: () -> None
+ """Apply transform to orientation points and set it to identity."""
+ trans = self.pop('gradientTransform')
+ p1 = (convert_unit(self.get('cx'), 'px'), convert_unit(self.get('cy'), 'px'))
+ p2 = (convert_unit(self.get('fx'), 'px'), convert_unit(self.get('fy'), 'px'))
+ p1t = trans.apply_to_point(p1)
+ p2t = trans.apply_to_point(p2)
+ self.update(cx=p1t[0], cy=p1t[1], fx=p2t[0], fy=p2t[1])
+
+class PathEffect(BaseElement):
+ """Inkscape LPE element"""
+ tag_name = 'inkscape:path-effect'
+
+
+class MeshGradient(Gradient):
+ """Usable MeshGradient XML base class"""
+ tag_name = 'meshgradient'
+
+ @classmethod
+ def new_mesh(cls, pos=None, rows=1, cols=1, autocollect=True):
+ """Return skeleton of 1x1 meshgradient definition."""
+ # initial point
+ if pos is None or len(pos) != 2:
+ pos = [0.0, 0.0]
+ # create nested elements for rows x cols mesh
+ meshgradient = cls()
+ for _ in range(rows):
+ meshrow = meshgradient.add(MeshRow())
+ for _ in range(cols):
+ meshrow.append(MeshPatch())
+ # set meshgradient attributes
+ meshgradient.set('gradientUnits', 'userSpaceOnUse')
+ meshgradient.set('x', pos[0])
+ meshgradient.set('y', pos[1])
+ if autocollect:
+ meshgradient.set('inkscape:collect', 'always')
+ return meshgradient
+
+
+class MeshRow(BaseElement):
+ """Each row of a mesh gradient"""
+ tag_name = 'meshrow'
+
+class MeshPatch(BaseElement):
+ """Each column or 'patch' in a mesh gradient"""
+ tag_name = 'meshpatch'
+
+ def stops(self, edges, colors):
+ """Add or edit meshpatch stops with path and stop-color."""
+ # iterate stops based on number of edges (path data)
+ for i, edge in enumerate(edges):
+ if i < len(self):
+ stop = self[i]
+ else:
+ stop = self.add(Stop())
+
+ # set edge path data
+ stop.set('path', str(edge))
+ # set stop color
+ stop.style['stop-color'] = str(colors[i % 2])
diff --git a/share/extensions/inkex/elements/_groups.py b/share/extensions/inkex/elements/_groups.py
new file mode 100644
index 0000000..52bd073
--- /dev/null
+++ b/share/extensions/inkex/elements/_groups.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Ryan Jarvis <ryan@shopboxretail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=arguments-differ
+"""
+Interface for all group based elements such as Groups, Use, Markers etc.
+"""
+
+from lxml import etree # pylint: disable=unused-import
+
+from ..paths import Path
+from ..utils import addNS
+from ..transforms import Transform
+
+from ._base import ShapeElement
+
+try:
+ from typing import Optional # pylint: disable=unused-import
+except ImportError:
+ pass
+
+class GroupBase(ShapeElement):
+ """Base Group element"""
+ def get_path(self):
+ ret = Path()
+ for child in self:
+ if isinstance(child, ShapeElement):
+ ret += child.path.transform(child.transform)
+ return ret
+
+ def shape_box(self, transform=None):
+ bbox = None
+ effective_transform = Transform(transform) * self.transform
+ for child in self:
+ if isinstance(child, ShapeElement):
+ child_bbox = child.bounding_box(transform=effective_transform)
+ if child_bbox is not None:
+ bbox += child_bbox
+ return bbox
+
+
+class Group(GroupBase):
+ """Any group element (layer or regular group)"""
+ tag_name = 'g'
+
+ @classmethod
+ def new(cls, label, *children, **attrs):
+ attrs['inkscape:label'] = label
+ return super(Group, cls).new(*children, **attrs)
+
+
+ def effective_style(self):
+ """A blend of each child's style mixed together (last child wins)"""
+ style = self.style
+ for child in self:
+ style.update(child.effective_style())
+ return style
+
+ @property
+ def groupmode(self):
+ """Return the type of group this is"""
+ return self.get('inkscape:groupmode', 'group')
+
+
+class Layer(Group):
+ """Inkscape extension of svg:g"""
+
+ def _init(self):
+ self.set('inkscape:groupmode', 'layer')
+
+ @classmethod
+ def _is_class_element(cls, el):
+ # type: (etree.Element) -> bool
+ return el.attrib.get(addNS('inkscape:groupmode'), None) == "layer"
+
+
+class Anchor(GroupBase):
+ """An anchor or link tag"""
+ tag_name = 'a'
+
+ @classmethod
+ def new(cls, href, *children, **attrs):
+ attrs['xlink:href'] = href
+ return super(Anchor, cls).new(*children, **attrs)
+
+
+class ClipPath(GroupBase):
+ """A path used to clip objects"""
+ tag_name = 'clipPath'
+
+
+class Marker(GroupBase):
+ """The <marker> element defines the graphic that is to be used for drawing arrowheads
+ or polymarkers on a given <path>, <line>, <polyline> or <polygon> element."""
+ tag_name = 'marker'
diff --git a/share/extensions/inkex/elements/_image.py b/share/extensions/inkex/elements/_image.py
new file mode 100644
index 0000000..efd00d3
--- /dev/null
+++ b/share/extensions/inkex/elements/_image.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 - Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+Image element interface.
+"""
+
+from ._polygons import RectangleBase
+
+class Image(RectangleBase):
+ """Provide a useful extension for image elements"""
+ tag_name = 'image'
diff --git a/share/extensions/inkex/elements/_meta.py b/share/extensions/inkex/elements/_meta.py
new file mode 100644
index 0000000..f8107ad
--- /dev/null
+++ b/share/extensions/inkex/elements/_meta.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Maren Hachmann <moini>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=arguments-differ
+"""
+Provide extra utility to each svg element type specific to its type.
+
+This is useful for having a common interface for each element which can
+give path, transform, and property access easily.
+"""
+
+import math
+
+from lxml import etree
+
+from ..styles import StyleSheet
+from ..transforms import Vector2d
+
+from ._base import BaseElement
+
+class Defs(BaseElement):
+ """A header defs element, one per document"""
+ tag_name = 'defs'
+
+class StyleElement(BaseElement):
+ """A CSS style element containing multiple style definitions"""
+ tag_name = 'style'
+
+ def set_text(self, content):
+ """Sets the style content text as a CDATA section"""
+ self.text = etree.CDATA(str(content))
+
+ def stylesheet(self):
+ """Return the StyleSheet() object for the style tag"""
+ return StyleSheet(self.text, callback=self.set_text)
+
+class Script(BaseElement):
+ """A javascript tag in SVG"""
+ tag_name = 'script'
+
+ def set_text(self, content):
+ """Sets the style content text as a CDATA section"""
+ self.text = etree.CDATA(str(content))
+
+class Desc(BaseElement):
+ """Description element"""
+ tag_name = 'desc'
+
+class Title(BaseElement):
+ """Title element"""
+ tag_name = 'title'
+
+class NamedView(BaseElement):
+ """The NamedView element is Inkscape specific metadata about the file"""
+ tag_name = 'sodipodi:namedview'
+
+ current_layer = property(lambda self: self.get('inkscape:current-layer'))
+
+ @property
+ def center(self):
+ """Returns view_center in terms of document units"""
+ return Vector2d(self.root.unittouu(self.get('inkscape:cx') or 0),
+ self.root.unittouu(self.get('inkscape:cy') or 0))
+
+ def get_guides(self):
+ """Returns a list of guides"""
+ return self.findall('sodipodi:guide')
+
+ def new_guide(self, position, orient=True, name=None):
+ """Creates a new guide in this namedview"""
+ if orient is True:
+ elem = Guide().move_to(0, position, (0, 1))
+ elif orient is False:
+ elem = Guide().move_to(position, 0, (1, 0))
+ if name:
+ elem.set('inkscape:label', str(name))
+ return self.add(elem)
+
+
+class Guide(BaseElement):
+ """An inkscape guide"""
+ tag_name = 'sodipodi:guide'
+
+ is_horizontal = property(lambda self: self.get('orientation').startswith('0,') and not
+ self.get('orientation') == '0,0')
+ is_vertical = property(lambda self: self.get('orientation').endswith(',0'))
+ point = property(lambda self: Vector2d(self.get('position')))
+
+ @classmethod
+ def new(cls, pos_x, pos_y, angle, **attrs):
+ guide = super(Guide, cls).new(**attrs)
+ guide.move_to(pos_x, pos_y, angle=angle)
+ return guide
+
+ def move_to(self, pos_x, pos_y, angle=None):
+ """
+ Move this guide to the given x,y position,
+
+ Angle may be a float or integer, which will change the orientation. Alternately,
+ it may be a pair of numbers (tuple) which will set the orientation directly.
+ """
+ self.set('position', "{:g},{:g}".format(float(pos_x), float(pos_y)))
+ if isinstance(angle, str):
+ if ',' not in angle:
+ angle = float(angle)
+
+ if isinstance(angle, (float, int)):
+ # Generate orientation from angle
+ angle = (math.sin(math.radians(angle)), -math.cos(math.radians(angle)))
+
+ if isinstance(angle, (tuple, list)) and len(angle) == 2:
+ angle = "{:g},{:g}".format(*angle)
+
+ self.set('orientation', angle)
+ return self
+
+class Metadata(BaseElement):
+ """Inkscape Metadata element"""
+ tag_name = 'metadata'
+
+class ForeignObject(BaseElement):
+ """SVG foreignObject element"""
+ tag_name = 'foreignObject'
+
+class Switch(BaseElement):
+ """A switch element"""
+ tag_name = 'switch'
+
+class Grid(BaseElement):
+ """A namedview grid child"""
+ tag_name = 'inkscape:grid'
diff --git a/share/extensions/inkex/elements/_polygons.py b/share/extensions/inkex/elements/_polygons.py
new file mode 100644
index 0000000..0bcbc38
--- /dev/null
+++ b/share/extensions/inkex/elements/_polygons.py
@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=arguments-differ
+"""
+Interface for all shapes/polygons such as lines, paths, rectangles, circles etc.
+"""
+
+from ..paths import Path
+from ..transforms import Transform, ImmutableVector2d, Vector2d
+from ..utils import addNS
+from ..units import convert_unit
+
+from ._base import ShapeElement
+
+class PathElementBase(ShapeElement):
+ """Base element for path based shapes"""
+ get_path = lambda self: self.get('d')
+
+ @classmethod
+ def new(cls, path, **attrs):
+ return super(PathElementBase, cls).new(d=Path(path), **attrs)
+
+ def set_path(self, path):
+ """Set the given data as a path as the 'd' attribute"""
+ self.set('d', str(Path(path)))
+
+ def apply_transform(self):
+ """Apply the internal transformation to this node and delete"""
+ if 'transform' in self.attrib:
+ self.path = self.path.transform(self.transform)
+ self.set('transform', Transform())
+
+ @property
+ def original_path(self):
+ """Returns the original path if this is a LPE, or the path if not"""
+ return Path(self.get('inkscape:original-d', self.path))
+
+ @original_path.setter
+ def original_path(self, path):
+ if addNS('inkscape:original-d') in self.attrib:
+ self.set('inkscape:original-d', str(Path(path)))
+ else:
+ self.path = path
+
+
+class PathElement(PathElementBase):
+ """Provide a useful extension for path elements"""
+ tag_name = 'path'
+
+ @classmethod
+ def arc(cls, center, rx, ry=None, **kw): # pylint: disable=invalid-name
+ """Generate a sodipodi arc (special type)"""
+ others = [(name, kw.pop(name, None)) for name in ('start', 'end', 'open')]
+ elem = cls(**kw)
+ elem.set('sodipodi:cx', center[0])
+ elem.set('sodipodi:cy', center[1])
+ elem.set('sodipodi:rx', rx)
+ elem.set('sodipodi:ry', ry or rx)
+ elem.set('sodipodi:type', 'arc')
+ for name, value in others:
+ if value is not None:
+ elem.set('sodipodi:'+name, str(value).lower())
+ return elem
+
+ @classmethod
+ def star(cls, center, radi, sides, rounded=None):
+ """Generate a sodipodi start (special type)"""
+ elem = cls()
+ elem.set('sodipodi:cx', center[0])
+ elem.set('sodipodi:cy', center[1])
+ elem.set('sodipodi:r1', radi[0])
+ elem.set('sodipodi:r2', radi[1])
+ elem.set('sodipodi:arg1', 0.85)
+ elem.set('sodipodi:arg2', 1.3)
+ elem.set('sodipodi:sides', sides)
+ elem.set('inkscape:rounded', rounded)
+ elem.set('sodipodi:type', 'star')
+ return elem
+
+
+class Polyline(ShapeElement):
+ """Like a path, but made up of straight line segments only"""
+ tag_name = 'polyline'
+
+ def get_path(self):
+ return Path('M' + self.get('points'))
+
+ def set_path(self, path):
+ points = ['{:g},{:g}'.format(x, y) for x, y in Path(path).end_points]
+ self.set('points', ' '.join(points))
+
+
+class Polygon(ShapeElement):
+ """A closed polyline"""
+ tag_name = 'polygon'
+ get_path = lambda self: 'M' + self.get('points') + ' Z'
+
+
+class Line(ShapeElement):
+ """A line segment connecting two points"""
+ tag_name = 'line'
+ get_path = lambda self: 'M{0[x1]},{0[y1]} L{0[x2]},{0[y2]} Z'.format(self.attrib)
+
+ @classmethod
+ def new(cls, start, end, **attrs):
+ start = Vector2d(start)
+ end = Vector2d(end)
+ return super(Line, cls).new(x1=start.x, y1=start.y,
+ x2=end.x, y2=end.y, **attrs)
+
+
+class RectangleBase(ShapeElement):
+ """Provide a useful extension for rectangle elements"""
+ left = property(lambda self: convert_unit(self.get('x', '0'), 'px'))
+ top = property(lambda self: convert_unit(self.get('y', '0'), 'px'))
+ right = property(lambda self: self.left + self.width)
+ bottom = property(lambda self: self.top + self.height)
+ width = property(lambda self: convert_unit(self.get('width', '0'), 'px'))
+ height = property(lambda self: convert_unit(self.get('height', '0'), 'px'))
+ rx = property(lambda self: convert_unit(self.get('rx', self.get('ry', 0.0)), 'px'))
+ ry = property(lambda self: convert_unit(self.get('ry', self.get('rx', 0.0)), 'px')) # pylint: disable=invalid-name
+
+ def get_path(self):
+ """Calculate the path as the box around the rect"""
+ if self.rx:
+ rx, ry = self.rx, self.ry # pylint: disable=invalid-name
+ return 'M {1},{0.top}'\
+ 'L {2},{0.top} A {0.rx},{0.ry} 0 0 1 {0.right},{3}'\
+ 'L {0.right},{4} A {0.rx},{0.ry} 0 0 1 {2},{0.bottom}'\
+ 'L {1},{0.bottom} A {0.rx},{0.ry} 0 0 1 {0.left},{4}'\
+ 'L {0.left},{3} A {0.rx},{0.ry} 0 0 1 {1},{0.top} z'\
+ .format(self, self.left + rx, self.right - rx, self.top + ry, self.bottom - ry)
+
+ return 'M {0.left},{0.top} h{0.width}v{0.height}h{1} z'.format(self, -self.width)
+
+
+class Rectangle(RectangleBase):
+ """Provide a useful extension for rectangle elements"""
+ tag_name = 'rect'
+
+ @classmethod
+ def new(cls, left, top, width, height, **attrs):
+ return super(Rectangle, cls).new(x=left, y=top, width=width, height=height, **attrs)
+
+
+class EllipseBase(ShapeElement):
+ """Absorbs common part of Circle and Ellipse classes"""
+
+ def get_path(self):
+ """Calculate the arc path of this circle"""
+ rx, ry = self._rxry()
+ cx, y = self.center.x, self.center.y - ry
+ return ('M {cx},{y} '
+ 'a {rx},{ry} 0 1 0 {rx}, {ry} '
+ 'a {rx},{ry} 0 0 0 -{rx}, -{ry} z'
+ ).format(cx=cx, y=y, rx=rx, ry=ry)
+
+ @property
+ def center(self):
+ return ImmutableVector2d(convert_unit(self.get('cx', '0'), 'px'), convert_unit(self.get('cy', '0'), 'px'))
+
+ @center.setter
+ def center(self, value):
+ value = Vector2d(value)
+ self.set("cx", value.x)
+ self.set("cy", value.y)
+
+ def _rxry(self):
+ # type: () -> Vector2d
+ """Helper function """
+ raise NotImplementedError()
+
+ @classmethod
+ def new(cls, center, radius, **attrs):
+ circle = super(EllipseBase, cls).new(**attrs)
+ circle.center = center
+ circle.radius = radius
+ return circle
+
+
+class Circle(EllipseBase):
+ """Provide a useful extension for circle elements"""
+ tag_name = 'circle'
+
+ @property
+ def radius(self):
+ return convert_unit(self.get('r', '0'), 'px')
+
+ @radius.setter
+ def radius(self, value):
+ self.set("r", value)
+
+ def _rxry(self):
+ r = self.radius
+ return Vector2d(r, r)
+
+
+class Ellipse(EllipseBase):
+ """Provide a similar extension to the Circle interface"""
+ tag_name = 'ellipse'
+
+ @property
+ def radius(self):
+ return ImmutableVector2d(convert_unit(self.get('rx', '0'), 'px'), convert_unit(self.get('ry', '0'), 'px'))
+
+ @radius.setter
+ def radius(self, value):
+ value = Vector2d(value)
+ self.set("rx", str(value.x))
+ self.set("ry", str(value.y))
+
+ def _rxry(self):
+ return self.radius
diff --git a/share/extensions/inkex/elements/_selected.py b/share/extensions/inkex/elements/_selected.py
new file mode 100644
index 0000000..c09c526
--- /dev/null
+++ b/share/extensions/inkex/elements/_selected.py
@@ -0,0 +1,159 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc.,Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+When elements are selected, these structures provide an advanced API.
+"""
+
+from collections import OrderedDict
+
+class ElementList(OrderedDict):
+ """
+ A list of elements, selected by id, iterator or xpath
+
+ This may look like a dictionary, but it is really a list of elements.
+ The default iterator is the element objects themselves (not keys) and it is
+ possible to key elements by their numerical index.
+
+ It is also possible to look up items by their id and the element object itself.
+ """
+ def __init__(self, svg, _iter=None):
+ self.svg = svg
+ self.ids = OrderedDict()
+ super(ElementList, self).__init__()
+ if _iter:
+ self.set(*list(_iter))
+
+ def __getitem__(self, key):
+ return super(ElementList, self).__getitem__(self._to_key(key))
+
+ def __contains__(self, key):
+ return super(ElementList, self).__contains__(self._to_key(key))
+
+ def __setitem__(self, orig_key, elem):
+ from ._base import BaseElement
+ if orig_key != elem and orig_key != elem.get('id'):
+ raise ValueError("Refusing to set bad key in ElementList {}".format(orig_key))
+ if isinstance(elem, str):
+ key = elem
+ elem = self.svg.getElementById(elem)
+ if elem is None:
+ return
+ if isinstance(elem, BaseElement):
+ # Selection is a list of elements to select
+ key = elem.xml_path
+ element_id = elem.get('id')
+ if element_id is not None:
+ self.ids[element_id] = key
+ super(ElementList, self).__setitem__(key, elem)
+ else:
+ kind = type(elem).__name__
+ raise ValueError("Unknown element type: {}".format(kind))
+
+ def _to_key(self, key, default=None):
+ """Takes a key (id, element, etc) and returns an xml_path key"""
+ from ._base import BaseElement
+ if self and key is None:
+ key = default
+ if isinstance(key, int):
+ return list(self.keys())[key]
+ elif isinstance(key, BaseElement):
+ return key.xml_path
+ elif isinstance(key, str) and key[0] != '/':
+ return self.ids.get(key, key)
+ return key
+
+ def clear(self):
+ """Also clear ids"""
+ self.ids.clear()
+ super(ElementList, self).clear()
+
+ def set(self, *ids):
+ """
+ Sets the currently selected elements to these ids, any existing
+ selection is cleared.
+
+ Arguments a list of element ids, element objects or
+ a single xpath expression starting with "//".
+
+ All element objects must have an id to be correctly set.
+
+ >>> selection.set("rect123", "path456", "text789")
+ >>> selection.set(elem1, elem2, elem3)
+ >>> selection.set("//rect")
+ """
+ self.clear()
+ self.add(*ids)
+
+ def pop(self, key=None):
+ """Remove the key item or remove the last item selected"""
+ item = super(ElementList, self).pop(self._to_key(key, default=-1))
+ self.ids.pop(item.get('id'))
+ return item
+
+ def add(self, *ids):
+ """Like set() but does not clear first"""
+ # Allow selecting of xpath elements directly
+ if len(ids) == 1 and isinstance(ids[0], str) and ids[0].startswith('//'):
+ ids = self.svg.xpath(ids[0])
+
+ for elem in ids:
+ self[elem] = elem # This doesn't matter
+
+ def paint_order(self):
+ """Get the selected elements, but ordered by their appearance in the document"""
+ new_list = ElementList(self.svg)
+ new_list.set(*[elem for _, elem in sorted(self.items(), key=lambda x: x[0])])
+ return new_list
+
+ def filter(self, *types):
+ """Filter selected elements of the given type, returns a new SelectedElements object"""
+ return ElementList(self.svg, [e for e in self.values() if not types or isinstance(e, types)])
+
+ def get(self, *types):
+ """Like filter, but will enter each element searching for any child of the given types"""
+ def _recurse(elem):
+ if not types or isinstance(elem, types):
+ yield elem
+ for child in elem:
+ for item in _recurse(child):
+ yield item
+ return ElementList(self.svg, [r for e in self.values() for r in _recurse(e)])
+
+ def id_dict(self):
+ """For compatibility, return regular dictionary of id -> element pairs"""
+ return OrderedDict([(eid, self[xid]) for eid, xid in self.ids.items()])
+
+ def bounding_box(self):
+ """
+ Gets a :class:`inkex.transforms.BoundingBox` object for the selected items.
+
+ Text objects have a bounding box without width or height that only
+ reflects the coordinate of their anchor. If a text object is a part of
+ the selection's boundary, the bounding box may be inaccurate.
+
+ When no object is selected or when the object's location cannot be
+ determined (e.g. empty group or layer), all coordinates will be None.
+ """
+ return sum([elem.bounding_box() for elem in self.values()], None)
+
+ def first(self):
+ """Returns the first item in the selected list"""
+ for elem in self.values():
+ return elem
+ return None
diff --git a/share/extensions/inkex/elements/_svg.py b/share/extensions/inkex/elements/_svg.py
new file mode 100644
index 0000000..3e1af09
--- /dev/null
+++ b/share/extensions/inkex/elements/_svg.py
@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+# Sergei Izmailov <sergei.a.izmailov@gmail.com>
+# Windell Oskay <windell@oskay.net>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=attribute-defined-outside-init
+#
+"""
+Provide a way to load lxml attributes with an svg API on top.
+"""
+
+import random
+from lxml import etree
+
+from ..deprecated import DeprecatedSvgMixin
+from ..units import discover_unit, convert_unit, render_unit
+from ._selected import ElementList
+from ..transforms import BoundingBox
+from ..styles import StyleSheets
+
+from ._base import BaseElement
+from ._meta import NamedView, Defs, StyleElement, Metadata
+
+if False: # pylint: disable=using-constant-test
+ import typing # pylint: disable=unused-import
+
+
+class SvgDocumentElement(DeprecatedSvgMixin, BaseElement):
+ """Provide access to the document level svg functionality"""
+ tag_name = 'svg'
+
+ def _init(self):
+ self.current_layer = None
+ self.view_center = (0.0, 0.0)
+ self.selection = ElementList(self)
+ self.ids = {}
+
+ def tostring(self):
+ """Convert document to string"""
+ return etree.tostring(etree.ElementTree(self))
+
+ def get_ids(self):
+ """Returns a set of unique document ids"""
+ if not self.ids:
+ self.ids = set(self.xpath('//@id'))
+ return self.ids
+
+ def get_unique_id(self, prefix, size=4):
+ """Generate a new id from an existing old_id"""
+ ids = self.get_ids()
+ new_id = None
+ _from = 10 ** size - 1
+ _to = 10 ** size
+ while new_id is None or new_id in ids:
+ # Do not use randint because py2/3 incompatibility
+ new_id = prefix + str(int(random.random() * _from - _to) + _to)
+ self.ids.add(new_id)
+ return new_id
+
+ def get_page_bbox(self):
+ """Gets the page dimensions as a bbox"""
+ return BoundingBox((0, float(self.width)), (0, float(self.height)))
+
+ def get_current_layer(self):
+ """Returns the currently selected layer"""
+ layer = self.getElementById(self.namedview.current_layer, 'svg:g')
+ if layer is None:
+ return self
+ return layer
+
+ def getElement(self, xpath): # pylint: disable=invalid-name
+ """Gets a single element from the given xpath or returns None"""
+ return self.findone(xpath)
+
+ def getElementById(self, eid, elm='*'): # pylint: disable=invalid-name
+ """Get an element in this svg document by it's ID attribute"""
+ if eid is not None:
+ eid = eid.strip()[4:-1] if eid.startswith('url(') else eid
+ eid = eid.lstrip('#')
+ return self.getElement('//{elm}[@id="{eid}"]'.format(elm=elm, eid=eid))
+
+ def getElementByName(self, name, elm='*'): # pylint: disable=invalid-name
+ """Get an element by it's inkscape:label (aka name)"""
+ return self.getElement('//{elm}[@inkscape:label="{name}"]'.format(elm=elm, name=name))
+
+ def getElementsByClass(self, class_name): # pylint: disable=invalid-name
+ """Get elements by it's class name"""
+ from inkex.styles import ConditionalRule
+ return self.xpath(ConditionalRule(".{}".format(class_name)).to_xpath())
+
+ def getElementsByHref(self, eid): # pylint: disable=invalid-name
+ """Get elements by their href xlink attribute"""
+ return self.xpath('//*[@xlink:href="#{}"]'.format(eid))
+
+ def getElementsByStyleUrl(self, eid, style=None): # pylint: disable=invalid-name
+ """Get elements by a style attribute url"""
+ url = "url(#{})".format(eid)
+ if style is not None:
+ url = style + ":" + url
+ return self.xpath('//*[contains(@style,"{}")]'.format(url))
+
+ @property
+ def name(self):
+ """Returns the Document Name"""
+ return self.get('sodipodi:docname', '')
+
+ @property
+ def namedview(self):
+ """Return the sp namedview meta information element"""
+ return self.get_or_create('//sodipodi:namedview', NamedView, True)
+
+ @property
+ def metadata(self):
+ """Return the svg metadata meta element container"""
+ return self.get_or_create('//svg:metadata', Metadata, True)
+
+ @property
+ def defs(self):
+ """Return the svg defs meta element container"""
+ return self.get_or_create('//svg:defs', Defs, True)
+
+ def get_viewbox(self):
+ """Parse and return the document's viewBox attribute"""
+ try:
+ ret = [float(unit) for unit in self.get('viewBox', '0').split()]
+ except ValueError:
+ ret = ''
+ if len(ret) != 4:
+ return [0, 0, 0, 0]
+ return ret
+
+ @property
+ def width(self): # getDocumentWidth(self):
+ """Fault tolerance for lazily defined SVG"""
+ return self.unittouu(self.get('width')) or self.get_viewbox()[2]
+
+ @property
+ def height(self): # getDocumentHeight(self):
+ """Returns a string corresponding to the height of the document, as
+ defined in the SVG file. If it is not defined, returns the height
+ as defined by the viewBox attribute. If viewBox is not defined,
+ returns the string '0'."""
+ return self.unittouu(self.get('height')) or self.get_viewbox()[3]
+
+ @property
+ def scale(self):
+ """Return the ratio between the page width and the viewBox width"""
+ try:
+ scale_x = float(self.width) / float(self.get_viewbox()[2])
+ scale_y = float(self.height) / float(self.get_viewbox()[3])
+ return max([scale_x, scale_y])
+ except (ValueError, ZeroDivisionError):
+ return 1.0
+
+ @property
+ def unit(self):
+ """Returns the unit used for in the SVG document.
+ In the case the SVG document lacks an attribute that explicitly
+ defines what units are used for SVG coordinates, it tries to calculate
+ the unit from the SVG width and viewBox attributes.
+ Defaults to 'px' units."""
+ viewbox = self.get_viewbox()
+ if viewbox and set(viewbox) != {0}:
+ return discover_unit(self.get('width'), viewbox[2], default='px')
+ return 'px' # Default is px
+
+ def unittouu(self, value):
+ """Convert a unit value into the document's units"""
+ return convert_unit(value, self.unit)
+
+ def uutounit(self, value, to_unit):
+ """Convert from the document's units to the given unit"""
+ return convert_unit(render_unit(value, self.unit), to_unit)
+
+ def add_unit(self, value):
+ """Add document unit when no unit is specified in the string """
+ return render_unit(value, self.unit)
+
+ @property
+ def stylesheets(self):
+ """Get all the stylesheets, bound together to one, (for reading)"""
+ sheets = StyleSheets(self)
+ for node in self.xpath('//svg:style'):
+ sheets.append(node.stylesheet())
+ return sheets
+
+ @property
+ def stylesheet(self):
+ """Return the first stylesheet or create one if needed (for writing)"""
+ for sheet in self.stylesheets:
+ return sheet
+
+ style_node = StyleElement()
+ self.defs.append(style_node)
+ return style_node.stylesheet()
diff --git a/share/extensions/inkex/elements/_text.py b/share/extensions/inkex/elements/_text.py
new file mode 100644
index 0000000..766344c
--- /dev/null
+++ b/share/extensions/inkex/elements/_text.py
@@ -0,0 +1,159 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# pylint: disable=arguments-differ
+"""
+Provide text based element classes interface.
+
+Because text is not rendered at all, no information about a text's path
+size or actual location can be generated yet.
+"""
+
+from ..paths import Path
+from ..transforms import Transform, BoundingBox
+from ..units import convert_unit
+
+from ._base import BaseElement, ShapeElement
+from ._polygons import PathElementBase
+
+class FlowRegion(ShapeElement):
+ """SVG Flow Region (SVG 2.0)"""
+ tag_name = 'flowRegion'
+
+ def get_path(self):
+ # This ignores flowRegionExcludes
+ return sum([child.path for child in self], Path())
+
+class FlowRoot(ShapeElement):
+ """SVG Flow Root (SVG 2.0)"""
+ tag_name = 'flowRoot'
+
+ @property
+ def region(self):
+ """Return the first flowRegion in this flowRoot"""
+ return self.findone('svg:flowRegion')
+
+ def get_path(self):
+ region = self.region
+ return region.get_path() if region is not None else Path()
+
+class FlowPara(ShapeElement):
+ """SVG Flow Paragraph (SVG 2.0)"""
+ tag_name = 'flowPara'
+
+ def get_path(self):
+ # XXX: These empty paths mean the bbox for text elements will be nothing.
+ return Path()
+
+class FlowDiv(ShapeElement):
+ """SVG Flow Div (SVG 2.0)"""
+ tag_name = 'flowDiv'
+
+ def get_path(self):
+ # XXX: These empty paths mean the bbox for text elements will be nothing.
+ return Path()
+
+class FlowSpan(ShapeElement):
+ """SVG Flow Span (SVG 2.0)"""
+ tag_name = 'flowSpan'
+
+ def get_path(self):
+ # XXX: These empty paths mean the bbox for text elements will be nothing.
+ return Path()
+
+class TextElement(ShapeElement):
+ """A Text element"""
+ tag_name = 'text'
+ x = property(lambda self: convert_unit(self.get('x', 0), 'px'))
+ y = property(lambda self: convert_unit(self.get('y', 0), 'px'))
+
+ def get_path(self):
+ return Path()
+
+ def tspans(self):
+ """Returns all children that are tspan elements"""
+ return self.findall('svg:tspan')
+
+ def get_text(self, sep="\n"):
+ """Return the text content including tspans"""
+ nodes = [self] + list(self.tspans())
+ return sep.join([elem.text for elem in nodes if elem.text is not None])
+
+ def shape_box(self, transform=None):
+ """
+ Returns a horrible bounding box that just contains the coord points
+ of the text without width or height (which is impossible to calculate)
+ """
+ effective_transform = Transform(transform) * self.transform
+ x, y = effective_transform.apply_to_point((self.x, self.y))
+ bbox = BoundingBox(x, y)
+ for tspan in self.tspans():
+ bbox += tspan.bounding_box(effective_transform)
+ return bbox
+
+class TextPath(ShapeElement):
+ """A textPath element"""
+ tag_name = 'textPath'
+
+ def get_path(self):
+ return Path()
+
+class Tspan(ShapeElement):
+ """A tspan text element"""
+ tag_name = 'tspan'
+ x = property(lambda self: convert_unit(self.get('x', 0), 'px'))
+ y = property(lambda self: convert_unit(self.get('y', 0), 'px'))
+
+ @classmethod
+ def superscript(cls, text):
+ """Adds a superscript tspan element"""
+ return cls(text, style="font-size:65%;baseline-shift:super")
+
+ def get_path(self):
+ return Path()
+
+ def shape_box(self, transform=None):
+ """
+ Returns a horrible bounding box that just contains the coord points
+ of the text without width or height (which is impossible to calculate)
+ """
+ effective_transform = Transform(transform) * self.transform
+ x1, y1 = effective_transform.apply_to_point((self.x, self.y))
+ fontsize = convert_unit(self.style.get('font-size', '1em'), 'px')
+ x2 = self.x + 0 # XXX This is impossible to calculate!
+ y2 = self.y + float(fontsize)
+ x2, y2 = effective_transform.apply_to_point((x2, y2))
+ return BoundingBox((x1, x2), (y1, y2))
+
+
+class SVGfont(BaseElement):
+ """An svg font element"""
+ tag_name = 'font'
+
+class FontFace(BaseElement):
+ """An svg font font-face element"""
+ tag_name = 'font-face'
+
+class Glyph(PathElementBase):
+ """An svg font glyph element"""
+ tag_name = 'glyph'
+
+class MissingGlyph(BaseElement):
+ """An svg font missing-glyph element"""
+ tag_name = 'missing-glyph'
diff --git a/share/extensions/inkex/elements/_use.py b/share/extensions/inkex/elements/_use.py
new file mode 100644
index 0000000..b5a38c5
--- /dev/null
+++ b/share/extensions/inkex/elements/_use.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) 2020 Martin Owens <doctormo@gmail.com>
+# Thomas Holder <thomas.holder@schrodinger.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+Interface for the Use and Symbol elements
+"""
+
+from ..transforms import Transform
+
+from ._groups import Group, GroupBase
+from ._base import ShapeElement
+
+class Symbol(GroupBase):
+ """SVG symbol element"""
+ tag_name = 'symbol'
+
+class Use(ShapeElement):
+ """A 'use' element that links to another in the document"""
+ tag_name = 'use'
+
+ @classmethod
+ def new(cls, elem, x, y, **attrs): # pylint: disable=arguments-differ
+ ret = super(Use, cls).new(x=x, y=y, **attrs)
+ ret.href = elem
+ return ret
+
+ def get_path(self):
+ """Returns the path of the cloned href plus any transformation"""
+ path = self.href.path
+ path.transform(self.href.transform)
+ return path
+
+ def effective_style(self):
+ """Href's style plus this object's own styles"""
+ style = self.href.effective_style()
+ style.update(self.style)
+ return style
+
+ def unlink(self):
+ """Unlink this clone, replacing it with a copy of the original"""
+ copy = self.href.copy()
+ if isinstance(copy, Symbol):
+ group = Group(**copy.attrib)
+ group.extend(copy)
+ copy = group
+ copy.transform *= self.transform
+ copy.style = self.style + copy.style
+ self.replace_with(copy)
+ copy.set_random_ids()
+ return copy
+
+ def shape_box(self, transform=None):
+ effective_transform = Transform(transform) * self.transform
+ return self.href.bounding_box(effective_transform)
diff --git a/share/extensions/inkex/extensions.py b/share/extensions/inkex/extensions.py
new file mode 100644
index 0000000..10f30a2
--- /dev/null
+++ b/share/extensions/inkex/extensions.py
@@ -0,0 +1,344 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+A helper module for creating Inkscape effect extensions
+
+This provides the basic generic types of extensions which most writers should
+use in their code. See below for the different types.
+"""
+
+import os
+import re
+import sys
+import types
+
+from .utils import errormsg, Boolean, CloningVat, PY3
+from .colors import Color, ColorIdError, ColorError
+from .elements import load_svg, BaseElement, ShapeElement, Group, Layer, Grid, \
+ TextElement, FlowPara, FlowDiv
+from .base import InkscapeExtension, SvgThroughMixin, SvgInputMixin, SvgOutputMixin, TempDirMixin
+from .transforms import Transform
+
+# All the names that get added to the inkex API itself.
+__all__ = ('EffectExtension', 'GenerateExtension', 'InputExtension', 'OutputExtension',
+ 'CallExtension', 'TemplateExtension', 'ColorExtension', 'TextExtension')
+
+stdout = sys.stdout
+if PY3:
+ unicode = str # pylint: disable=redefined-builtin,invalid-name
+
+class EffectExtension(SvgThroughMixin, InkscapeExtension):
+ """
+ Takes the SVG from Inkscape, modifies the selection or the document
+ and returns an SVG to Inkscape.
+ """
+ pass
+
+class OutputExtension(SvgInputMixin, InkscapeExtension):
+ """
+ Takes the SVG from Inkscape and outputs it to something that's not an SVG.
+
+ Used in functions for `Save As`
+ """
+ def effect(self):
+ """Effect isn't needed for a lot of Output extensions"""
+ pass
+
+ def save(self, stream):
+ """But save certainly is, we give a more exact message here"""
+ raise NotImplementedError("Output extensions require a save(stream) method!")
+
+class InputExtension(SvgOutputMixin, InkscapeExtension):
+ """
+ Takes any type of file as input and outputs SVG which Inkscape can read.
+
+ Used in functions for `Open`
+ """
+ def effect(self):
+ """Effect isn't needed for a lot of Input extensions"""
+ pass
+
+ def load(self, stream):
+ """But load certainly is, we give a more exact message here"""
+ raise NotImplementedError("Input extensions require a load(stream) method!")
+
+class CallExtension(TempDirMixin, InputExtension):
+ """Call an external program to get the output"""
+ input_ext = 'svg'
+ output_ext = 'svg'
+
+ def load(self, stream):
+ pass # Not called (load_raw instead)
+
+ def load_raw(self):
+ # Don't call InputExtension.load_raw
+ TempDirMixin.load_raw(self)
+ input_file = self.options.input_file
+
+ if not isinstance(input_file, (unicode, str)):
+ data = input_file.read()
+ input_file = os.path.join(self.tempdir, 'input.' + self.input_ext)
+ with open(input_file, 'wb') as fhl:
+ fhl.write(data)
+
+ output_file = os.path.join(self.tempdir, 'output.' + self.output_ext)
+ document = self.call(input_file, output_file) or output_file
+ if isinstance(document, (str, unicode)):
+ if not os.path.isfile(document):
+ raise IOError("Can't find generated document: {}".format(document))
+
+ if self.output_ext == 'svg':
+ with open(document, 'r') as fhl:
+ document = fhl.read()
+ if '<' in document:
+ document = load_svg(document)
+ else:
+ with open(document, 'rb') as fhl:
+ document = fhl.read()
+
+ self.document = document
+
+ def call(self, input_file, output_file):
+ """Call whatever programs are needed to get the desired result."""
+ raise NotImplementedError("Call extensions require a call(in, out) method!")
+
+class GenerateExtension(EffectExtension):
+ """
+ Does not need any SVG, but instead just outputs an SVG fragment which is
+ inserted into Inkscape, centered on the selection.
+ """
+ container_label = ''
+ container_layer = False
+
+ def generate(self):
+ """
+ Return an SVG fragment to be inserted into the selected layer of the document
+ OR yield multiple elements which will be grouped into a container group
+ element which will be given an automatic label and transformation.
+ """
+ raise NotImplementedError("Generate extensions must provide generate()")
+
+ def container_transform(self):
+ """
+ Generate the transformation for the container group, the default is
+ to return the center position of the svg document or view port.
+ """
+ (pos_x, pos_y) = self.svg.namedview.center
+ if pos_x is None:
+ pos_x = 0
+ if pos_y is None:
+ pos_y = 0
+ return Transform(translate=(pos_x, pos_y))
+
+ def effect(self):
+ layer = self.svg.get_current_layer()
+ fragment = self.generate()
+ if isinstance(fragment, types.GeneratorType):
+ container = (Layer if self.container_layer else Group).new(self.container_label)
+ if self.container_layer:
+ self.svg.append(container)
+ else:
+ container.transform = self.container_transform()
+ layer.append(container)
+ for child in fragment:
+ if isinstance(child, BaseElement):
+ container.append(child)
+ elif isinstance(fragment, BaseElement):
+ layer.append(fragment)
+ else:
+ errormsg("Nothing was generated\n")
+
+
+class TemplateExtension(EffectExtension):
+ """
+ Provide a standard way of creating templates.
+ """
+ size_rex = re.compile(r'([\d.]*)(\w\w)?x([\d.]*)(\w\w)?')
+ template_id = "SVGRoot"
+
+ def __init__(self):
+ super(TemplateExtension, self).__init__()
+ # Arguments added on after add_arguments so it can be overloaded cleanly.
+ self.arg_parser.add_argument("--size", type=self.arg_size(), dest="size")
+ self.arg_parser.add_argument("--width", type=int, default=800)
+ self.arg_parser.add_argument("--height", type=int, default=600)
+ self.arg_parser.add_argument("--orientation", default=None)
+ self.arg_parser.add_argument("--unit", default="px")
+ self.arg_parser.add_argument("--grid", type=Boolean)
+
+ def get_template(self):
+ """Can be over-ridden with custom svg loading here"""
+ return self.document
+
+ def arg_size(self, unit='px'):
+ """Argument is a string of the form X[unit]xY[unit], default units apply when missing"""
+ def _inner(value):
+ try:
+ value = float(value)
+ return (value, unit, value, unit)
+ except ValueError:
+ pass
+ match = self.size_rex.match(str(value))
+ if match is not None:
+ size = match.groups()
+ return (float(size[0]), size[1] or unit, float(size[2]), size[3] or unit)
+ return None
+ return _inner
+
+ def get_size(self):
+ """Get the size of the new template (defaults to size options)"""
+ size = self.options.size
+ if self.options.size is None:
+ size = (self.options.width, self.options.unit,
+ self.options.height, self.options.unit)
+ if self.options.orientation == "horizontal" and size[0] < size[2] \
+ or self.options.orientation == "vertical" and size[0] > size[2]:
+ size = size[2:4] + size[0:2]
+ return size
+
+ def effect(self):
+ """Creates a template, do not over-ride"""
+ (width, width_unit, height, height_unit) = self.get_size()
+ width_px = int(self.svg.uutounit(width, 'px'))
+ height_px = int(self.svg.uutounit(height, 'px'))
+
+ self.document = self.get_template()
+ self.svg = self.document.getroot()
+ self.svg.set("id", self.template_id)
+ self.svg.set("width", str(width) + width_unit)
+ self.svg.set("height", str(height) + height_unit)
+ self.svg.set("viewBox", "0 0 {} {}".format(width, height))
+ self.set_namedview(width_px, height_px, width_unit)
+
+ def set_namedview(self, width, height, unit):
+ """Setup the document namedview"""
+ self.svg.namedview.set('inkscape:document-units', unit)
+ self.svg.namedview.set('inkscape:zoom', '0.25')
+ self.svg.namedview.set('inkscape:cx', str(width / 2.0))
+ self.svg.namedview.set('inkscape:cy', str(height / 2.0))
+ if self.options.grid:
+ self.svg.namedview.set('showgrid', "true")
+ self.svg.namedview.add(Grid(type="xygrid"))
+
+
+class ColorExtension(EffectExtension):
+ """
+ A standard way to modify colours in an svg document.
+ """
+ process_none = False # should we call modify_color for the "none" color.
+ select_all = (ShapeElement,)
+
+ def effect(self):
+ # Limiting to shapes ignores Gradients (and other things) from the select_all
+ # this prevents defs from being processed twice.
+ self._renamed = {}
+ gradients = CloningVat(self.svg)
+ for elem in self.svg.selection.get(ShapeElement).values():
+ self.process_element(elem, gradients)
+ gradients.process(self.process_elements, types=(ShapeElement,))
+
+ def process_elements(self, elem):
+ """Process multiple elements (gradients)"""
+ for child in elem.descendants().values():
+ self.process_element(child)
+
+ def process_element(self, elem, gradients=None):
+ """Process one of the selected elements"""
+ style = elem.fallback_style(move=False)
+ # Colours first
+ for name in elem.style.color_props:
+ value = style.get(name)
+ if value is not None:
+ try:
+ style[name] = self._modify_color(name, Color(value))
+ except ColorIdError:
+ gradient = self.svg.getElementById(value)
+ gradients.track(gradient, elem, self._ref_cloned, style=style, name=name)
+ if gradient.href is not None:
+ gradients.track(gradient.href, elem, self._xlink_cloned, linker=gradient)
+ except ColorError:
+ pass # bad color value, don't touch.
+ # Then opacities (usually does nothing)
+ for name in elem.style.opacity_props:
+ value = style.get(name)
+ if value is not None:
+ style[name] = self.modify_opacity(name, value)
+
+ def _ref_cloned(self, old_id, new_id, style, name):
+ self._renamed[old_id] = new_id
+ style[name] = "url(#{})".format(new_id)
+
+ def _xlink_cloned(self, old_id, new_id, linker):
+ lid = linker.get('id')
+ linker = self.svg.getElementById(self._renamed.get(lid, lid))
+ linker.set('xlink:href', '#' + new_id)
+
+ def _modify_color(self, name, color):
+ """Pre-process color value to filter out bad colors"""
+ if color or self.process_none:
+ return self.modify_color(name, color)
+ return color
+
+ def modify_color(self, name, color):
+ """Replace this method with your colour modifier method"""
+ raise NotImplementedError("Provide a modify_color method.")
+
+ def modify_opacity(self, name, opacity):
+ """Optional opacity modification"""
+ return opacity
+
+class TextExtension(EffectExtension):
+ """
+ A base effect for changing text in a document.
+ """
+ newline = True
+ newpar = True
+
+ def effect(self):
+ nodes = self.svg.selected.values() or {None: self.document.getroot()}
+ for elem in nodes.values():
+ self.process_element(elem)
+
+ def process_element(self, node):
+ """Reverse the node text"""
+ if node.get('sodipodi:role') == 'line':
+ self.newline = True
+ elif isinstance(node, (TextElement, FlowPara, FlowDiv)):
+ self.newline = True
+ self.newpar = True
+
+ if node.text is not None:
+ node.text = self.process_chardata(node.text)
+ self.newline = False
+ self.newpar = False
+
+ for child in node:
+ self.process_element(child)
+
+ if node.tail is not None:
+ node.tail = self.process_chardata(node.tail)
+
+ def process_chardata(self, text):
+ """Replaceable chardata method for processing the text"""
+ return ''.join(map(self.map_char, text))
+
+ @staticmethod
+ def map_char(char):
+ """Replaceable map_char method for processing each letter"""
+ raise NotImplementedError("Please provide a process_chardata or map_char static method.")
diff --git a/share/extensions/inkex/inx.py b/share/extensions/inkex/inx.py
new file mode 100644
index 0000000..76d2d9c
--- /dev/null
+++ b/share/extensions/inkex/inx.py
@@ -0,0 +1,166 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+Parsing inx files for checking and generating.
+"""
+
+import os
+from lxml import etree
+
+try:
+ from inspect import isclass
+ from importlib import util
+except ImportError:
+ util = None # type: ignore
+
+from .base import InkscapeExtension
+from .utils import Boolean
+
+NSS = {
+ 'inx': 'http://www.inkscape.org/namespace/inkscape/extension',
+}
+
+class InxLookup(etree.CustomElementClassLookup):
+ """Custom inx xml file lookup"""
+ def lookup(self, node_type, document, namespace, name): # pylint: disable=unused-argument
+ if name == 'param':
+ return ParamElement
+ return None
+
+INX_PARSER = etree.XMLParser()
+INX_PARSER.set_element_class_lookup(InxLookup())
+
+class InxFile(object):
+ """Open an INX file and provide useful functions"""
+ name = property(lambda self: self._text('inx:name'))
+ ident = property(lambda self: self._text('inx:id'))
+ slug = property(lambda self: self.ident.split('.')[-1].title().replace('_', ''))
+ kind = property(lambda self: self.metadata['type'])
+
+ def __init__(self, filename):
+ self.filename = os.path.basename(filename)
+ self.doc = etree.parse(filename, parser=INX_PARSER)
+ self.root = self.doc.getroot()
+
+ def __repr__(self):
+ return "<inx '{0.filename}' '{0.name}'>".format(self)
+
+ def xpath(self, xpath):
+ """Namespace specific xpath searches"""
+ return self.root.xpath(xpath, namespaces=NSS)
+
+ def find_one(self, name):
+ """Return the first element matching the given name"""
+ for elem in self.xpath(name):
+ return elem
+ return None
+
+ def _text(self, name, default=None):
+ elem = self.find_one(name)
+ if elem is not None and elem.text:
+ return elem.text
+ return default
+
+ @property
+ def script(self):
+ """Returns information about the called script"""
+ command = self.find_one('inx:script/inx:command')
+ if command is None:
+ return {}
+ return {
+ 'interpreter': command.get('interpreter', None),
+ 'location': command.get('location', None),
+ 'script': command.text,
+ }
+
+ @property
+ def extension_class(self):
+ """Attempt to get the extension class"""
+ script = self.script.get('script', None)
+ if script is not None and util is not None:
+ name = script[:-3].replace('/', '.')
+ spec = util.spec_from_file_location(name, script)
+ mod = util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ for value in mod.__dict__.values():
+ if 'Base' not in name and isclass(value) and value.__module__ == name \
+ and issubclass(value, InkscapeExtension):
+ return value
+ return None
+
+ @property
+ def metadata(self):
+ """Returns information about what type of extension this is"""
+ effect = self.find_one('inx:effect')
+ output = self.find_one('inx:output')
+ data = {}
+ if effect is not None:
+ data['type'] = 'effect'
+ data['preview'] = Boolean(effect.get('needs-live-preview', 'true'))
+ data['objects'] = self._text('inx:effect/inx:object-type', 'all')
+ elif self.find_one('inx:input') is not None:
+ data['type'] = 'input'
+ data['extension'] = self._text('inx:input/inx:extension')
+ data['mimetype'] = self._text('inx:input/inx:mimetype')
+ data['name'] = self._text('inx:input/inx:filetypename')
+ data['tooltip'] = self._text('inx:input/inx:filetypetooltip')
+ elif output is not None:
+ data['type'] = 'output'
+ data['dataloss'] = Boolean(self._text('inx:output/inx:dataloss', 'false'))
+ data['extension'] = self._text('inx:output/inx:extension')
+ data['mimetype'] = self._text('inx:output/inx:mimetype')
+ data['name'] = self._text('inx:output/inx:filetypename')
+ data['tooltip'] = self._text('inx:output/inx:filetypetooltip')
+ return data
+
+ @property
+ def menu(self):
+ """Return the menu this effect ends up in"""
+ def _recurse_menu(parent):
+ for child in parent.xpath('inx:submenu', namespaces=NSS):
+ yield child.get('name')
+ for subchild in _recurse_menu(child):
+ yield subchild
+ break # Not more than one menu chain?
+ menu = self.find_one('inx:effect/inx:effects-menu')
+ return list(_recurse_menu(menu)) + [self.name]
+
+ @property
+ def params(self):
+ """Get all params at all levels"""
+ # Returns any params at any levels
+ return list(self.xpath('//inx:param'))
+
+
+class ParamElement(etree.ElementBase):
+ """
+ A param in an inx file.
+ """
+ name = property(lambda self: self.get('name'))
+ param_type = property(lambda self: self.get('type', 'string'))
+
+ @property
+ def options(self):
+ """Return a list of option values"""
+ if self.param_type == 'notebook':
+ return [option.get('name') for option in self.xpath('inx:page', namespaces=NSS)]
+ return [option.get('value') for option in self.xpath('inx:option', namespaces=NSS)]
+
+ def __repr__(self):
+ return "<param name='{0.name}' type='{0.param_type}'>".format(self)
diff --git a/share/extensions/inkex/localization.py b/share/extensions/inkex/localization.py
new file mode 100644
index 0000000..7695b69
--- /dev/null
+++ b/share/extensions/inkex/localization.py
@@ -0,0 +1,66 @@
+# coding=utf-8
+#
+# Copyright (C) 2010 Nick Drobchenko, nick@cnc-club.ru
+# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
+#
+# 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.
+#
+"""
+Allow extensions to translate messages.
+"""
+
+import gettext
+import os
+
+# Get gettext domain and matching locale directory for translation of extensions strings
+# (both environment variables are set by Inkscape)
+GETTEXT_DOMAIN = os.environ.get('INKEX_GETTEXT_DOMAIN')
+GETTEXT_DIRECTORY = os.environ.get('INKEX_GETTEXT_DIRECTORY')
+
+# INKSCAPE_LOCALEDIR can be used to override the default locale directory Inkscape uses
+INKSCAPE_LOCALEDIR = os.environ.get('INKSCAPE_LOCALEDIR')
+
+def localize(domain=GETTEXT_DOMAIN, localedir=GETTEXT_DIRECTORY):
+ """Configure gettext and install _() function into builtins namespace for easy access"""
+
+ # Do not enable translation if GETTEXT_DOMAIN is unset.
+ # This is the case when translationdomain="none", but also when no catalog was found.
+ # Install a NullTranslation just to be sure (so we do not get errors about undefined '_')
+ if domain is None:
+ gettext.NullTranslations().install()
+ return
+
+ # Use the default system locale by default,
+ # but prefer LANGUAGE environment variable (which is set by Inkscape according to UI language)
+ languages = None
+
+ trans = gettext.translation(domain, localedir, languages, fallback=True)
+ trans.install()
+
+
+
+def inkex_localize():
+ """
+ Return internal Translations instance for translation of the inkex module itself
+ Those will always use the 'inkscape' domain and attempt to lookup the same catalog Inkscape uses
+ """
+
+ domain = 'inkscape'
+ localedir = INKSCAPE_LOCALEDIR
+ languages = None
+
+ return gettext.translation(domain, localedir, languages, fallback=True)
+
+inkex_gettext = inkex_localize().gettext # pylint: disable=invalid-name
diff --git a/share/extensions/inkex/paths.py b/share/extensions/inkex/paths.py
new file mode 100644
index 0000000..b3620e8
--- /dev/null
+++ b/share/extensions/inkex/paths.py
@@ -0,0 +1,1547 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+functions for digesting paths into a simple list structure
+"""
+
+import re
+import copy
+
+from math import atan2, cos, pi, sin, sqrt, acos, tan
+
+from .transforms import Transform, BoundingBox, Vector2d
+from .utils import classproperty, strargs
+
+try: # pylint: disable=using-constant-test
+ from typing import overload, Any, Type, Dict, Optional, Union, Tuple, List, Iterator, Generator # pylint: disable=unused-import
+ from typing import TypeVar
+ Pathlike = TypeVar('Pathlike', bound="PathCommand")
+ AbsolutePathlike = TypeVar('AbsolutePathlike', bound="AbsolutePathCommand")
+except ImportError:
+ overload = lambda x: x
+
+# All the names that get added to the inkex API itself.
+__all__ = (
+ 'Path', 'CubicSuperPath',
+ # Path commands:
+ 'Line', 'line',
+ 'Move', 'move',
+ 'ZoneClose', 'zoneClose',
+ 'Horz', 'horz',
+ 'Vert', 'vert',
+ 'Curve', 'curve',
+ 'Smooth', 'smooth',
+ 'Quadratic', 'quadratic',
+ 'TepidQuadratic', 'tepidQuadratic',
+ 'Arc', 'arc',
+ # errors
+ 'InvalidPath'
+)
+
+LEX_REX = re.compile(r'([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)')
+NONE = lambda obj: obj is not None
+
+
+class InvalidPath(ValueError):
+ """Raised when given an invalid path string"""
+
+
+class PathCommand(object):
+ """
+ Base class of all path commands
+ """
+
+ # Number of arguments that follow this path commands letter
+ nargs = -1
+
+ # The full name of the segment (i.e. Line, Arc, etc)
+ name = classproperty(lambda cls: cls.__name__)
+
+ # The single letter representation of this command (i.e. L, A, etc)
+ letter = classproperty(lambda cls: cls.name[0])
+
+ # The implicit next command. This is for automatic chains where the next command
+ # isn't given, just a bunch on numbers which we automatically parse.
+ @classproperty
+ def next_command(self):
+ return self
+
+ @property
+ def is_relative(self): # type: () -> bool
+ raise NotImplementedError
+
+ @property
+ def is_absolute(self): # type: () -> bool
+ raise NotImplementedError
+
+ def to_relative(self, prev): # type: (Vector2d) -> RelativePathCommand
+ """Return absolute counterpart for absolute commands or copy for relative"""
+ raise NotImplementedError
+
+ def to_absolute(self, prev): # type: (Vector2d) -> AbsolutePathCommand
+ """Return relative counterpart for relative commands or copy for absolute"""
+ raise NotImplementedError
+
+ # The precision of the numbers when converting to string
+ number_template = "{:.6g}"
+
+ # Maps single letter path command to corresponding class
+ # (filled at the bottom of file, when all classes already defined)
+ _letter_to_class = {} # type: Dict[str, Type[Any]]
+
+ @staticmethod
+ def letter_to_class(letter):
+ """Returns class for given path command letter"""
+ return PathCommand._letter_to_class[letter]
+
+ @property
+ def args(self): # type: () -> List[float]
+ """Returns path command arguments as tuple of floats"""
+ raise NotImplementedError()
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Union[List[Vector2d], Generator[Vector2d, None, None]]
+ """Returns list of path command control points"""
+ raise NotImplementedError
+
+ @classmethod
+ def _argt(cls, sep):
+ return sep.join([cls.number_template] * cls.nargs)
+
+ def __str__(self):
+ return "{} {}".format(self.letter, self._argt(" ").format(*self.args)).strip()
+
+ def __repr__(self):
+ return "{{}}({})".format(self._argt(", ")).format(self.name, *self.args)
+
+ def __eq__(self, other):
+ previous = Vector2d()
+ if type(self) == type(other): # pylint: disable=unidiomatic-typecheck
+ return self.args == other.args
+ if isinstance(other, tuple):
+ return self.args == other
+ if not isinstance(other, PathCommand):
+ raise ValueError("Can't compare types")
+ try:
+ if self.is_relative == other.is_relative:
+ return self.to_curve(previous) == other.to_curve(previous)
+ except ValueError:
+ pass
+ return False
+
+ def end_point(self, first, prev): # type: (Vector2d, Vector2d) -> Vector2d
+ """Returns last control point of path command"""
+ raise NotImplementedError()
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ # type: (Vector2d, List[Vector2d], BoundingBox) -> None
+ # pylint: disable=unused-argument
+ """
+ Enlarges given bbox to contain path element.
+
+ :param (tuple of float) first: first point of path. Required to calculate Z segment
+ :param (list of tuple) last_two_points: list with last two control points in abs coords.
+ :param (BoundingBox) bbox: bounding box to update
+ """
+ raise NotImplementedError("Bounding box is not implemented for {}".format(self.name))
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ """Convert command to :py:class:`Curve`
+ Curve().to_curve() returns a copy
+ """
+ raise NotImplementedError("To curve not supported for {}".format(self.name))
+
+ def to_curves(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> List[Curve]
+ """Convert command to list of :py:class:`Curve` commands """
+ return [self.to_curve(prev, prev_prev)]
+
+ def to_line(self, prev):
+ # type: (Vector2d) -> Line
+ """Converts this segment to a line (copies if already a line)"""
+ return Line(*self.end_point(Vector2d(), prev))
+
+
+class RelativePathCommand(PathCommand):
+ """
+ Abstract base class for relative path commands.
+
+ Implements most of methods of :py:class:`PathCommand` through
+ conversion to :py:class:`AbsolutePathCommand`
+ """
+
+ @property
+ def is_relative(self):
+ return True
+
+ @property
+ def is_absolute(self):
+ return False
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Union[List[Vector2d], Generator[Vector2d, None, None]]
+ return self.to_absolute(prev).control_points(first, prev, prev_prev)
+
+ def to_relative(self, prev):
+ # type: (Pathlike, Vector2d) -> Pathlike
+ return self.__class__(*self.args)
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ self.to_absolute(last_two_points[-1]).update_bounding_box(first, last_two_points, bbox)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return self.to_absolute(prev).end_point(first, prev)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ return self.to_absolute(prev).to_curve(prev, prev_prev)
+
+ def to_curves(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> List[Curve]
+ return self.to_absolute(prev).to_curves(prev, prev_prev)
+
+
+class AbsolutePathCommand(PathCommand):
+ """
+ Absolute path command. Unlike :py:class:`RelativePathCommand` can be transformed directly.
+ """
+
+ @property
+ def is_relative(self):
+ return False
+
+ @property
+ def is_absolute(self):
+ return True
+
+ def to_absolute(self, previous): # type: (AbsolutePathlike, Vector2d) -> AbsolutePathlike
+ return self.__class__(*self.args)
+
+ def transform(self, transform): # type: (AbsolutePathlike, Transform) -> AbsolutePathlike
+ """Returns new transformed segment
+
+ :param transform: a transformation to apply
+ """
+ raise NotImplementedError()
+
+ def rotate(self, degrees, center): # type: (AbsolutePathlike, float, Vector2d) -> AbsolutePathlike
+ """
+ Returns new transformed segment
+
+ :param degrees: rotation angle in degrees
+ :param center: invariant point of rotation
+ """
+ return self.transform(Transform(rotate=(degrees, center[0], center[1])))
+
+ def translate(self, dr): # type: (AbsolutePathlike, Vector2d) -> AbsolutePathlike
+ """Translate or scale this path command by dr"""
+ return self.transform(Transform(translate=dr))
+
+ def scale(self, factor): # type: (AbsolutePathlike, Union[float, Tuple[float,float]]) -> AbsolutePathlike
+ """Returns new transformed segment
+
+ :param factor: scale or (scale_x, scale_y)
+ """
+ return self.transform(Transform(scale=factor))
+
+
+class Line(AbsolutePathCommand):
+ """Line segment"""
+
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.x, self.y
+
+ def __init__(self, x, y):
+ self.x = x
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox((last_two_points[-1].x, self.x), (last_two_points[-1].y, self.y))
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, self.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> line
+ return line(self.x - prev.x, self.y - prev.y)
+
+ def transform(self, transform):
+ # type: (Line, Transform) -> Line
+ return Line(*transform.apply_to_point((self.x, self.y)))
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ return Curve(prev.x, prev.y, self.x, self.y, self.x, self.y)
+
+
+
+class line(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative line segment"""
+
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.dx, self.dy
+
+ def __init__(self, dx, dy):
+ self.dx = dx
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Line
+ return Line(prev.x + self.dx, prev.y + self.dy)
+
+
+class Move(AbsolutePathCommand):
+ """Move pen segment without a line"""
+
+ nargs = 2
+ next_command = Line
+
+ @property
+ def args(self):
+ return self.x, self.y
+
+ def __init__(self, x, y):
+ self.x = x
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox(self.x, self.y)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, self.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> move
+ return move(self.x - prev.x, self.y - prev.y)
+
+ def transform(self, transform):
+ # type: (Transform) -> Move
+ return Move(*transform.apply_to_point((self.x, self.y)))
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ raise ValueError("Move segments can not be changed into curves.")
+
+
+class move(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative move segment"""
+
+ nargs = 2
+ next_command = line
+
+ @property
+ def args(self):
+ return self.dx, self.dy
+
+ def __init__(self, dx, dy):
+ self.dx = dx
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Move
+ return Move(prev.x + self.dx, prev.y + self.dy)
+
+
+class ZoneClose(AbsolutePathCommand):
+ """Close segment to finish a path"""
+ nargs = 0
+ next_command = Move
+
+ @property
+ def args(self):
+ return ()
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ pass
+
+ def transform(self, transform):
+ # type: (Transform) -> ZoneClose
+ return ZoneClose()
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield first
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> zoneClose
+ return zoneClose()
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return first
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ raise ValueError("ZoneClose segments can not be changed into curves.")
+
+
+class zoneClose(RelativePathCommand): # pylint: disable=invalid-name
+ """Same as above (svg says no difference)"""
+
+ nargs = 0
+ next_command = Move
+
+ @property
+ def args(self):
+ return ()
+
+ def to_absolute(self, prev):
+ return ZoneClose()
+
+
+class Horz(AbsolutePathCommand):
+ """Horizontal Line segment"""
+ nargs = 1
+
+ @property
+ def args(self):
+ return self.x,
+
+ def __init__(self, x):
+ self.x = x
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox((last_two_points[-1].x, self.x), last_two_points[-1].y)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, prev.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> horz
+ return horz(self.x - prev.x)
+
+ def transform(self, transformation):
+ # type: (Pathlike, Transform) -> Pathlike
+ raise ValueError("Horizontal lines can't be transformed directly.")
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, prev.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ """Convert a horizontal line into a curve"""
+ return self.to_line(prev).to_curve(prev)
+
+ def to_line(self, prev):
+ # type: (Vector2d) -> Line
+ """Return this path command as a Line instead"""
+ return Line(self.x, prev.y)
+
+
+class horz(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative horz line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return self.dx,
+
+ def __init__(self, dx):
+ self.dx = dx
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Horz
+ return Horz(prev.x + self.dx)
+
+ def to_line(self, prev): # type: (Vector2d) -> Line
+ """Return this path command as a Line instead"""
+ return Line(prev.x + self.dx, prev.y)
+
+
+class Vert(AbsolutePathCommand):
+ """Vertical Line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return self.y,
+
+ def __init__(self, y):
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox(last_two_points[-1].x, (last_two_points[-1].y, self.y))
+
+ def transform(self, transform): # type: (Pathlike, Transform) -> Pathlike
+ raise ValueError("Vertical lines can't be transformed directly.")
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(prev.x, self.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> vert
+ return vert(self.y - prev.y)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(prev.x, self.y)
+
+ def to_line(self, prev):
+ # type: (Vector2d) -> Line
+ """Return this path command as a line instead"""
+ return Line(prev.x, self.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()): # type: (Vector2d, Optional[Vector2d]) -> Curve
+ """Convert a horizontal line into a curve"""
+ return self.to_line(prev).to_curve(prev)
+
+
+class vert(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative vertical line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return self.dy,
+
+ def __init__(self, dy):
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Vert
+ return Vert(prev.y + self.dy)
+
+ def to_line(self, prev): # type: (Vector2d) -> Line
+ """Return this path command as a line instead"""
+ return Line(prev.x, prev.y + self.dy)
+
+
+class Curve(AbsolutePathCommand):
+ """Absolute Curved Line segment"""
+ nargs = 6
+
+ @property
+ def args(self):
+ return self.x2, self.y2, self.x3, self.y3, self.x4, self.y4
+
+ def __init__(self, x2, y2, x3, y3, x4, y4):
+ self.x2 = x2
+ self.y2 = y2
+
+ self.x3 = x3
+ self.y3 = y3
+
+ self.x4 = x4
+ self.y4 = y4
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ from .transforms import cubic_extrema
+
+ x1, x2, x3, x4 = last_two_points[-1].x, self.x2, self.x3, self.x4
+ y1, y2, y3, y4 = last_two_points[-1].y, self.y2, self.y3, self.y4
+
+ if not (x1 in bbox.x and
+ x2 in bbox.x and
+ x3 in bbox.x and
+ x4 in bbox.x):
+ bbox.x += cubic_extrema(x1, x2, x3, x4)
+
+ if not (y1 in bbox.y and
+ y2 in bbox.y and
+ y3 in bbox.y and
+ y4 in bbox.y):
+ bbox.y += cubic_extrema(y1, y2, y3, y4)
+
+ def transform(self, transform):
+ # type: (Transform) -> Curve
+ x2, y2 = transform.apply_to_point((self.x2, self.y2))
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ x4, y4 = transform.apply_to_point((self.x4, self.y4))
+ return Curve(x2, y2, x3, y3, x4, y4)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x2, self.y2)
+ yield Vector2d(self.x3, self.y3)
+ yield Vector2d(self.x4, self.y4)
+
+ def to_relative(self, prev): # type: (Vector2d) -> curve
+ return curve(
+ self.x2 - prev.x, self.y2 - prev.y,
+ self.x3 - prev.x, self.y3 - prev.y,
+ self.x4 - prev.x, self.y4 - prev.y
+ )
+
+ def end_point(self, first, prev):
+ return Vector2d(self.x4, self.y4)
+
+ def to_curve(self, prev, prev_prev=Vector2d()): # type: (Vector2d, Optional[Vector2d]) -> Curve
+ """No conversion needed, pass-through, returns self"""
+ return Curve(*self.args)
+
+ def to_bez(self):
+ """Returns the list of coords for SuperPath"""
+ return [list(self.args[:2]), list(self.args[2:4]), list(self.args[4:6])]
+
+class curve(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative curved line segment"""
+ nargs = 6
+
+ @property
+ def args(self):
+ return self.dx2, self.dy2, self.dx3, self.dy3, self.dx4, self.dy4
+
+ def __init__(self, dx2, dy2, dx3, dy3, dx4, dy4):
+ self.dx2 = dx2
+ self.dy2 = dy2
+
+ self.dx3 = dx3
+ self.dy3 = dy3
+
+ self.dx4 = dx4
+ self.dy4 = dy4
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Curve
+ return Curve(
+ self.dx2 + prev.x, self.dy2 + prev.y,
+ self.dx3 + prev.x, self.dy3 + prev.y,
+ self.dx4 + prev.x, self.dy4 + prev.y
+ )
+
+
+class Smooth(AbsolutePathCommand):
+ """Absolute Smoothed Curved Line segment"""
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.x3, self.y3, self.x4, self.y4
+
+ def __init__(self, x3, y3, x4, y4):
+
+ self.x3 = x3
+ self.y3 = y3
+
+ self.x4 = x4
+ self.y4 = y4
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ self.to_curve(last_two_points[-1], last_two_points[-2]).update_bounding_box(first, last_two_points, bbox)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+
+ x1, x2, x3, x4 = prev_prev.x, prev.x, self.x3, self.x4
+ y1, y2, y3, y4 = prev_prev.y, prev.y, self.y3, self.y4
+
+ # infer reflected point
+ x2 = 2 * x2 - x1
+ y2 = 2 * y2 - y1
+
+ yield Vector2d(x2, y2)
+ yield Vector2d(x3, y3)
+ yield Vector2d(x4, y4)
+
+ def to_relative(self, prev): # type: (Vector2d) -> smooth
+ return smooth(
+ self.x3 - prev.x, self.y3 - prev.y,
+ self.x4 - prev.x, self.y4 - prev.y
+ )
+
+ def transform(self, transform):
+ # type: (Transform) -> Smooth
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ x4, y4 = transform.apply_to_point((self.x4, self.y4))
+ return Smooth(x3, y3, x4, y4)
+
+ def end_point(self, first, prev):
+ return Vector2d(self.x4, self.y4)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ """
+ Convert this Smooth curve to a regular curve by creating a mirror
+ set of nodes based on the previous node. Previous should be a curve.
+ """
+ (x2, y2), (x3, y3), (x4, y4) = self.control_points(prev, prev, prev_prev)
+ return Curve(x2, y2, x3, y3, x4, y4)
+
+
+class smooth(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative smoothed curved line segment"""
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.dx3, self.dy3, self.dx4, self.dy4
+
+ def __init__(self, dx3, dy3, dx4, dy4):
+ self.dx3 = dx3
+ self.dy3 = dy3
+
+ self.dx4 = dx4
+ self.dy4 = dy4
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Smooth
+ return Smooth(
+ self.dx3 + prev.x, self.dy3 + prev.y,
+ self.dx4 + prev.x, self.dy4 + prev.y
+ )
+
+
+class Quadratic(AbsolutePathCommand):
+ """Absolute Quadratic Curved Line segment"""
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.x2, self.y2, self.x3, self.y3
+
+ def __init__(self, x2, y2, x3, y3):
+
+ self.x2 = x2
+ self.y2 = y2
+
+ self.x3 = x3
+ self.y3 = y3
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ from .transforms import quadratic_extrema
+
+ x1, x2, x3 = last_two_points[-1].x, self.x2, self.x3
+ y1, y2, y3 = last_two_points[-1].y, self.y2, self.y3
+
+ if not (x1 in bbox.x and
+ x2 in bbox.x and
+ x3 in bbox.x):
+ bbox.x += quadratic_extrema(x1, x2, x3)
+
+ if not (y1 in bbox.y and
+ y2 in bbox.y and
+ y3 in bbox.y):
+ bbox.y += quadratic_extrema(y1, y2, y3)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x2, self.y2)
+ yield Vector2d(self.x3, self.y3)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> quadratic
+ return quadratic(
+ self.x2 - prev.x, self.y2 - prev.y,
+ self.x3 - prev.x, self.y3 - prev.y
+ )
+
+ def transform(self, transform):
+ # type: (Transform) -> Quadratic
+ x2, y2 = transform.apply_to_point((self.x2, self.y2))
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ return Quadratic(x2, y2, x3, y3)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x3, self.y3)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ """Attempt to convert a quadratic to a curve"""
+ prev = Vector2d(prev)
+ x1 = 1. / 3 * prev.x + 2. / 3 * self.x2
+ x2 = 2. / 3 * self.x2 + 1. / 3 * self.x3
+ y1 = 1. / 3 * prev.y + 2. / 3 * self.y2
+ y2 = 2. / 3 * self.y2 + 1. / 3 * self.y3
+ return Curve(x1, y1, x2, y2, self.x3, self.y3)
+
+
+class quadratic(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative quadratic line segment"""
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.dx2, self.dy2, self.dx3, self.dy3
+
+ def __init__(self, dx2, dy2, dx3, dy3):
+ self.dx2 = dx2
+ self.dx3 = dx3
+ self.dy2 = dy2
+ self.dy3 = dy3
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Quadratic
+ return Quadratic(
+ self.dx2 + prev.x, self.dy2 + prev.y,
+ self.dx3 + prev.x, self.dy3 + prev.y
+ )
+
+
+class TepidQuadratic(AbsolutePathCommand):
+ """Continued Quadratic Line segment"""
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.x3, self.y3
+
+ def __init__(self, x3, y3):
+ self.x3 = x3
+ self.y3 = y3
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ self.to_quadratic(last_two_points[-1], last_two_points[-2]).update_bounding_box(first, last_two_points, bbox)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+
+ x1, x2, x3 = prev_prev.x, prev.x, self.x3
+ y1, y2, y3 = prev_prev.y, prev.y, self.y3
+
+ # infer reflected point
+ x2 = 2 * x2 - x1
+ y2 = 2 * y2 - y1
+
+ yield Vector2d(x2, y2)
+ yield Vector2d(x3, y3)
+
+ def to_relative(self, prev): # type: (Vector2d) -> tepidQuadratic
+ return tepidQuadratic(
+ self.x3 - prev.x, self.y3 - prev.y
+ )
+
+ def transform(self, transform):
+ # type: (Transform) -> TepidQuadratic
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ return TepidQuadratic(x3, y3)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x3, self.y3)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ return self.to_quadratic(prev, prev_prev).to_curve(prev)
+
+ def to_quadratic(self, prev, prev_prev):
+ # type: (Vector2d, Vector2d) -> Quadratic
+ """
+ Convert this continued quadratic into a full quadratic
+ """
+ (x2, y2), (x3, y3) = self.control_points(prev, prev, prev_prev)
+ return Quadratic(x2, y2, x3, y3)
+
+
+class tepidQuadratic(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative continued quadratic line segment"""
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.dx3, self.dy3
+
+ def __init__(self, dx3, dy3):
+ self.dx3 = dx3
+ self.dy3 = dy3
+
+ def to_absolute(self, prev):
+ # type: (Vector2d) -> TepidQuadratic
+ return TepidQuadratic(
+ self.dx3 + prev.x, self.dy3 + prev.y
+ )
+
+
+class Arc(AbsolutePathCommand):
+ """Special Arc segment"""
+ nargs = 7
+
+ @property
+ def args(self):
+ return self.rx, self.ry, self.x_axis_rotation, self.large_arc, self.sweep, self.x, self.y
+
+ def __init__(self, rx, ry, x_axis_rotation, large_arc, sweep, x, y):
+ self.rx = rx
+ self.ry = ry
+ self.x_axis_rotation = x_axis_rotation
+ self.large_arc = large_arc
+ self.sweep = sweep
+ self.x = x
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ prev = last_two_points[-1]
+ for seg in self.to_curves(prev=prev):
+ seg.update_bounding_box(first, [None, prev], bbox)
+ prev = seg.end_point(first, prev)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, self.y)
+
+ def to_curves(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> List[Curve]
+ """Convert this arc into bezier curves"""
+ path = CubicSuperPath([arc_to_path(list(prev), self.args)]).to_path(curves_only=True)
+ # Ignore the first move command from to_path()
+ return list(path)[1:]
+
+ def transform(self, transform):
+ # type: (Transform) -> Arc
+ x_, y_ = transform.apply_to_point((self.x, self.y))
+
+ T = transform # type: Transform
+ if self.x_axis_rotation != 0:
+ T = T * Transform(rotate=self.x_axis_rotation)
+ a, c, b, d, _, _ = list(T.to_hexad())
+ # T = | a b |
+ # | c d |
+
+ detT = a * d - b * c
+ detT2 = detT ** 2
+
+ rx = float(self.rx)
+ ry = float(self.ry)
+
+ if rx == 0.0 or ry == 0.0 or detT2 == 0.0:
+ # invalid Arc parameters
+ # transform only last point
+ return Arc(self.rx, self.ry, self.x_axis_rotation, self.large_arc, self.sweep, x_, y_)
+
+ A = (d ** 2 / rx ** 2 + c ** 2 / ry ** 2) / detT2
+ B = - (d * b / rx ** 2 + c * a / ry ** 2) / detT2
+ D = (b ** 2 / rx ** 2 + a ** 2 / ry ** 2) / detT2
+
+ theta = atan2(-2 * B, D - A) / 2
+ theta_deg = theta * 180.0 / pi
+ DA = (D - A)
+ l2 = 4 * B ** 2 + DA ** 2
+
+ if l2 == 0:
+ delta = 0.0
+ else:
+ delta = 0.5 * (-DA ** 2 - 4 * B ** 2) / sqrt(l2)
+
+ half = (A + D) / 2
+
+ rx_ = 1.0 / sqrt(half + delta)
+ ry_ = 1.0 / sqrt(half - delta)
+
+ x_, y_ = transform.apply_to_point((self.x, self.y))
+
+ if detT > 0:
+ sweep = self.sweep
+ else:
+ sweep = 0 if self.sweep>0 else 1
+
+ return Arc(rx_, ry_, theta_deg, self.large_arc, sweep, x_, y_)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> arc
+ return arc(self.rx, self.ry, self.x_axis_rotation, self.large_arc, self.sweep, self.x - prev.x, self.y - prev.y)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, self.y)
+
+
+class arc(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative Arc line segment"""
+
+ nargs = 7
+
+ @property
+ def args(self):
+ return self.rx, self.ry, self.x_axis_rotation, self.large_arc, self.sweep, self.dx, self.dy
+
+ def __init__(self, rx, ry, x_axis_rotation, large_arc, sweep, dx, dy):
+ self.rx = rx
+ self.ry = ry
+ self.x_axis_rotation = x_axis_rotation
+ self.large_arc = large_arc
+ self.sweep = sweep
+ self.dx = dx
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> "Arc"
+ x1, y1 = prev
+ return Arc(self.rx, self.ry, self.x_axis_rotation, self.large_arc, self.sweep, self.dx + x1, self.dy + y1)
+
+
+PathCommand._letter_to_class = {
+ "M": Move,
+ "L": Line,
+ "V": Vert,
+ "H": Horz,
+ "A": Arc,
+ "C": Curve,
+ "S": Smooth,
+ "Z": ZoneClose,
+ "Q": Quadratic,
+ "T": TepidQuadratic,
+ "m": move,
+ "l": line,
+ "v": vert,
+ "h": horz,
+ "a": arc,
+ "c": curve,
+ "s": smooth,
+ "z": zoneClose,
+ "q": quadratic,
+ "t": tepidQuadratic
+}
+
+
+class Path(list):
+ """A list of segment commands which combine to draw a shape"""
+
+ class PathCommandProxy(object):
+ """
+ A handy class for Path traverse and coordinate access
+
+ Reduces number of arguments in user code compared to bare :py:class:`PathCommand` methods
+ """
+
+ def __init__(self, command, first_point, previous_end_point, prev2_control_point):
+ self.command = command # type: PathCommand
+ self.first_point = first_point # type: Vector2d
+ self.previous_end_point = previous_end_point # type: Vector2d
+ self.prev2_control_point = prev2_control_point # type: Vector2d
+
+ @property
+ def name(self):
+ return self.command.name
+
+ @property
+ def letter(self):
+ return self.command.letter
+
+ @property
+ def next_command(self):
+ return self.command.next_command
+
+ @property
+ def is_relative(self):
+ return self.command.is_relative
+
+ @property
+ def is_absolute(self):
+ return self.command.is_absolute
+
+ @property
+ def args(self):
+ return self.command.args
+
+ @property
+ def control_points(self):
+ return self.command.control_points(self.first_point, self.previous_end_point, self.prev2_control_point)
+
+ @property
+ def end_point(self):
+ return self.command.end_point(self.first_point, self.previous_end_point)
+
+ def to_curve(self):
+ return self.command.to_curve(self.previous_end_point, self.prev2_control_point)
+
+ def to_curves(self):
+ return self.command.to_curves(self.previous_end_point, self.prev2_control_point)
+
+ def __str__(self):
+ return str(self.command)
+
+ def __repr__(self):
+ return "<" + self.__class__.__name__ + ">" + repr(self.command)
+
+ def __init__(self, path_d=None):
+ super(Path, self).__init__()
+ if isinstance(path_d, str):
+ # Returns a generator returning PathCommand objects
+ path_d = self.parse_string(path_d)
+ elif isinstance(path_d, CubicSuperPath):
+ path_d = path_d.to_path()
+
+ for item in (path_d or ()):
+ if isinstance(item, PathCommand):
+ self.append(item)
+ elif isinstance(item, (list, tuple)) and len(item) == 2:
+ if isinstance(item[1], (list, tuple)):
+ self.append(PathCommand.letter_to_class(item[0])(*item[1]))
+ else:
+ self.append(Line(*item))
+ else:
+ raise TypeError("Bad path type: {}({}, ...): {}".format(
+ type(path_d).__name__, type(item).__name__, item))
+
+ @classmethod
+ def parse_string(cls, path_d):
+ """Parse a path string and generate segment objects"""
+ for cmd, numbers in LEX_REX.findall(path_d):
+ args = list(strargs(numbers))
+ cmd = PathCommand.letter_to_class(cmd)
+ i = 0
+ while i < len(args) or cmd.nargs == 0:
+ seg = cmd(*args[i:i + cmd.nargs])
+ i += cmd.nargs
+ cmd = seg.next_command
+ yield seg
+
+ def bounding_box(self):
+ # type: () -> Optional[BoundingBox]
+ """Return bounding box of the Path"""
+ if not self:
+ return None
+ iterator = self.proxy_iterator()
+ proxy = next(iterator)
+ bbox = BoundingBox(proxy.first_point.x, proxy.first_point.y)
+ try:
+ while True:
+ proxy = next(iterator)
+ proxy.command.update_bounding_box(proxy.first_point, [
+ proxy.prev2_control_point,
+ proxy.previous_end_point,
+ ], bbox)
+ except StopIteration:
+ return bbox
+
+ def append(self, cmd):
+ """Append a command to this path including any chained commands"""
+ if isinstance(cmd, list):
+ self.extend(cmd)
+ elif isinstance(cmd, PathCommand):
+ super(Path, self).append(cmd)
+
+ def translate(self, x, y, inplace=False): # pylint: disable=invalid-name
+ """Move all coords in this path by the given amount"""
+ return self.transform(Transform(translate=(x, y)), inplace=inplace)
+
+ def scale(self, x, y, inplace=False): # pylint: disable=invalid-name
+ """Scale all coords in this path by the given amounts"""
+ return self.transform(Transform(scale=(x, y)), inplace=inplace)
+
+ def rotate(self, deg, center=None, inplace=False):
+ """Rotate the path around the given point"""
+ if center is None:
+ # Default center is center of bbox
+ bbox = self.bounding_box()
+ if bbox:
+ center = bbox.center
+ else:
+ center = Vector2d()
+ center = Vector2d(center)
+ return self.transform(Transform(rotate=(deg, center.x, center.y)), inplace=inplace)
+
+ @property
+ def control_points(self):
+
+ prev = Vector2d()
+ prev_prev = Vector2d()
+ first = Vector2d()
+
+ for i, seg in enumerate(self): # type: PathCommand
+ if i == 0:
+ first = seg.end_point(first, prev)
+ for cp in seg.control_points(first, prev, prev_prev):
+ prev_prev = prev
+ prev = cp
+ yield cp
+
+ @property
+ def end_points(self):
+ prev = Vector2d()
+ first = Vector2d()
+
+ for i, seg in enumerate(self): # type: PathCommand
+ if i == 0:
+ first = seg.end_point(first, prev)
+ end_point = seg.end_point(first, prev)
+ prev = end_point
+ yield end_point
+
+ def transform(self, transform, inplace=False):
+ """Convert to new path"""
+ result = Path()
+ previous = Vector2d()
+ previous_new = Vector2d()
+ first = Vector2d()
+ first_new = Vector2d()
+
+ for i, seg in enumerate(self): # type: PathCommand
+ if i == 0:
+ first = seg.end_point(first, previous)
+
+ if isinstance(seg, (horz, Horz, Vert, vert)):
+ seg = seg.to_line(previous)
+
+ if seg.is_relative:
+ new_seg = seg.to_absolute(previous).transform(transform).to_relative(previous_new)
+ else:
+ new_seg = seg.transform(transform)
+
+ if i == 0:
+ first_new = new_seg.end_point(first_new, previous_new)
+
+ if inplace:
+ self[i] = new_seg
+ else:
+ result.append(new_seg)
+ previous = seg.end_point(first, previous)
+ previous_new = new_seg.end_point(first_new, previous_new)
+ if inplace:
+ return self
+ return result
+
+ def reverse(self):
+ """Returns a reversed path"""
+ pass
+
+ def close(self):
+ """Attempt to close the last path segment"""
+ if self and not isinstance(self[-1], (zoneClose, ZoneClose)):
+ self.append(ZoneClose())
+
+ def proxy_iterator(self):
+ """
+ Yields :py:class:`AugmentedPathIterator`
+
+ :rtype: Iterator[ Path.PathCommandProxy ]
+ """
+
+ previous = Vector2d()
+ prev_prev = Vector2d()
+ first = Vector2d()
+
+ for i, seg in enumerate(self): # type: PathCommand
+ if i == 0:
+ prev_prev = previous = first = seg.end_point(first, previous)
+ yield Path.PathCommandProxy(seg, first, previous, prev_prev)
+ if isinstance(seg, (curve, tepidQuadratic, quadratic, smooth,
+ Curve, TepidQuadratic, Quadratic, Smooth)):
+ prev_prev = list(seg.control_points(first, previous, prev_prev))[-2]
+ previous = seg.end_point(first, previous)
+
+ def to_absolute(self):
+ """Convert this path to use only absolute coordinates"""
+ abspath = Path()
+
+ previous = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand
+ if isinstance(seg, (move, Move)):
+ first = seg.end_point(first, previous)
+
+ abspath.append(seg.to_absolute(previous))
+ previous = seg.end_point(first, previous)
+
+ return abspath
+
+ def to_relative(self):
+ """Convert this path to use only relative coordinates"""
+ abspath = Path()
+
+ previous = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand
+ if isinstance(seg, (move, Move)):
+ first = seg.end_point(first, previous)
+
+ abspath.append(seg.to_relative(previous))
+ previous = seg.end_point(first, previous)
+
+ return abspath
+
+ def __str__(self):
+ return " ".join([str(seg) for seg in self])
+
+ def __add__(self, other):
+ acopy = copy.deepcopy(self)
+ if isinstance(other, str):
+ other = Path(other)
+ if isinstance(other, list):
+ acopy.extend(other)
+ return acopy
+
+ def to_arrays(self):
+ """Returns path in format of parsePath output, returning arrays of absolute command data
+
+ .. deprecated:: 1.0
+ This is compatibility function for older API. Should not be used in new code
+
+ """
+ return [[seg.letter, list(seg.args)] for seg in self.to_absolute()]
+
+ def to_superpath(self):
+ """Convert this path into a cubic super path"""
+ return CubicSuperPath(self)
+
+ def copy(self):
+ """Make a copy"""
+ return copy.deepcopy(self)
+
+
+class CubicSuperPath(list):
+ """
+ A conversion of a path into a predictable list of cubic curves which
+ can be operated on as a list of simplified instructions.
+
+ When converting back into a path, all lines, arcs etc will be converted
+ to curve instructions.
+
+ Structure is held as [SubPath[(point_a, bezier, point_b), ...]], ...]
+ """
+
+ def __init__(self, items):
+ super(CubicSuperPath, self).__init__()
+ self._closed = True
+ self._prev = Vector2d()
+ self._prev_prev = Vector2d()
+
+
+ if isinstance(items, str):
+ items = Path(items)
+
+ if isinstance(items, Path):
+ items = items.to_absolute()
+
+ for item in items:
+ self.append(item)
+
+ def __str__(self):
+ return str(self.to_path())
+
+ def append(self, item):
+ """Accept multiple different formats for the data"""
+ if isinstance(item, list) and len(item) == 2 and isinstance(item[0], str):
+ item = PathCommand.letter_to_class(item[0])(*item[1])
+ is_quadratic = False
+ if isinstance(item, PathCommand):
+ if isinstance(item, Move):
+ if self._closed is False:
+ super(CubicSuperPath, self).append([])
+ item = [list(item.args), list(item.args), list(item.args)]
+ elif isinstance(item, ZoneClose) and self and self[-1]:
+ # This duplicates the first segment to 'close' the path, it's appended directly
+ # because we don't want to last coord to change for the final segment.
+ self[-1].append([self[-1][0][0][:], self[-1][0][1][:], self[-1][0][2][:]])
+ # Then adds a new subpath for the next shape (if any)
+ self._closed = True
+ self._prev.assign(self._first)
+ return
+ elif isinstance(item, Arc):
+ # Arcs are made up of three curves (approximated)
+ for arc_curve in item.to_curves(self._prev, self._prev_prev):
+ x2, y2, x3, y3, x4, y4 = arc_curve.args
+ self.append([[x2, y2], [x3, y3], [x4, y4]])
+ self._prev_prev.assign(x3, y3)
+ return
+ else:
+ is_quadratic = isinstance(item, (Quadratic, TepidQuadratic, quadratic, tepidQuadratic))
+ if isinstance(item, (Horz, Vert)):
+ item = item.to_line(self._prev)
+ pp = self._prev_prev
+ if is_quadratic:
+ self._prev_prev = list(item.control_points(self._first, self._prev, pp))[-2:-1][0]
+ item = item.to_curve(self._prev, pp)
+
+ if isinstance(item, Curve):
+ # Curves are cut into three tuples for the super path.
+ item = item.to_bez()
+
+ if not isinstance(item, list):
+ raise ValueError("Unknown super curve item type: {}".format(item))
+
+ if len(item) != 3 or not all([len(bit) == 2 for bit in item]):
+ # The item is already a subpath (usually from some other process)
+ if len(item[0]) == 3 and all([len(bit) == 2 for bit in item[0]]):
+ super(CubicSuperPath, self).append(self._clean(item))
+ self._prev_prev = Vector2d(self[-1][-1][0])
+ self._prev = Vector2d(self[-1][-1][1])
+ return
+ raise ValueError("Unknown super curve list format: {}".format(item))
+
+ if self._closed:
+ # Closed means that the previous segment is closed so we need a new one
+ # We always append to the last open segment. CSP starts out closed.
+ self._closed = False
+ super(CubicSuperPath, self).append([])
+
+ if self[-1]:
+ # The last tuple is replaced, it's the coords of where the next segment will land.
+ self[-1][-1][-1] = item[0][:]
+ # The last coord is duplicated, but is expected to be replaced
+ self[-1].append(item[1:] + copy.deepcopy(item)[-1:])
+
+ self._prev = Vector2d(self[-1][-1][1])
+ if not is_quadratic:
+ self._prev_prev = Vector2d(self[-1][-1][0])
+
+ def _clean(self, lst):
+ """Recursively clean lists so they have the same type"""
+ if isinstance(lst, (tuple, list)):
+ return [self._clean(child) for child in lst]
+ return lst
+
+ @property
+ def _first(self):
+ try:
+ return Vector2d(self[-1][0][0])
+ except IndexError:
+ return Vector2d()
+
+ def to_path(self, curves_only=False):
+ """Convert the super path back to an svg path"""
+ return Path(list(self.to_segments(curves_only)))
+
+ def to_segments(self, curves_only=False):
+ """Generate a set of segments for this cubic super path"""
+ for subpath in self:
+ previous = []
+ for segment in subpath:
+ if not previous:
+ yield Move(*segment[1][:])
+ elif self.is_line(previous, segment) and not curves_only:
+ if segment is subpath[-1] and Vector2d(segment[1]).is_close(subpath[0][1]):
+ yield ZoneClose()
+ else:
+ yield Line(*segment[1][:])
+ else:
+ yield Curve(*(previous[2][:] + segment[0][:] + segment[1][:]))
+ previous = segment
+
+ def transform(self, transform):
+ """Apply a transformation matrix to this super path"""
+ return self.to_path().transform(transform).to_superpath()
+
+ @staticmethod
+ def is_line(previous, segment):
+ """Check whether csp segment (two points) has retracted handles."""
+ return Vector2d(previous[1]).is_close(previous[2]) and \
+ Vector2d(segment[0]).is_close(segment[1])
+
+def arc_to_path(point, params):
+ """Approximates an arc with cubic bezier segments.
+
+ Arguments:
+ point: Starting point (absolute coords)
+ params: Arcs parameters as per
+ https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
+
+ Returns a list of triplets of points : [control_point_before, node, control_point_after]
+ (first and last returned triplets are [p1, p1, *] and [*, p2, p2])
+ """
+ A = point[:]
+ rx, ry, teta, longflag, sweepflag, x2, y2 = params[:]
+ teta = teta * pi / 180.0
+ B = [x2, y2]
+ # Degenerate ellipse
+ if rx == 0 or ry == 0 or A == B:
+ return [[A[:], A[:], A[:]], [B[:], B[:], B[:]]]
+
+ # turn coordinates so that the ellipse morph into a *unit circle* (not 0-centered)
+ mat = matprod((rotmat(teta), [[1.0 / rx, 0.0], [0.0, 1.0 / ry]], rotmat(-teta)))
+ applymat(mat, A)
+ applymat(mat, B)
+
+ k = [-(B[1] - A[1]), B[0] - A[0]]
+ d = k[0] * k[0] + k[1] * k[1]
+ k[0] /= sqrt(d)
+ k[1] /= sqrt(d)
+ d = sqrt(max(0, 1 - d / 4.0))
+ # k is the unit normal to AB vector, pointing to center O
+ # d is distance from center to AB segment (distance from O to the midpoint of AB)
+ # for the last line, remember this is a unit circle, and kd vector is ortogonal to AB (Pythagorean thm)
+
+ if longflag == sweepflag: # top-right ellipse in SVG example https://www.w3.org/TR/SVG/images/paths/arcs02.svg
+ d *= -1
+
+ O = [(B[0] + A[0]) / 2.0 + d * k[0], (B[1] + A[1]) / 2.0 + d * k[1]]
+ OA = [A[0] - O[0], A[1] - O[1]]
+ OB = [B[0] - O[0], B[1] - O[1]]
+ start = acos(OA[0] / norm(OA))
+ if OA[1] < 0:
+ start *= -1
+ end = acos(OB[0] / norm(OB))
+ if OB[1] < 0:
+ end *= -1
+ # start and end are the angles from center of the circle to A and to B respectively
+
+ if sweepflag and start > end:
+ end += 2 * pi
+ if (not sweepflag) and start < end:
+ end -= 2 * pi
+
+ NbSectors = int(abs(start - end) * 2 / pi) + 1
+ dTeta = (end - start) / NbSectors
+ v = 4 * tan(dTeta / 4.) / 3.
+ # I would use v = tan(dTeta/2)*4*(sqrt(2)-1)/3 ?
+ p = []
+ for i in range(0, NbSectors + 1, 1):
+ angle = start + i * dTeta
+ v1 = [O[0] + cos(angle) - (-v) * sin(angle), O[1] + sin(angle) + (-v) * cos(angle)]
+ pt = [O[0] + cos(angle), O[1] + sin(angle)]
+ v2 = [O[0] + cos(angle) - v * sin(angle), O[1] + sin(angle) + v * cos(angle)]
+ p.append([v1, pt, v2])
+ p[0][0] = p[0][1][:]
+ p[-1][2] = p[-1][1][:]
+
+ # go back to the original coordinate system
+ mat = matprod((rotmat(teta), [[rx, 0], [0, ry]], rotmat(-teta)))
+ for pts in p:
+ applymat(mat, pts[0])
+ applymat(mat, pts[1])
+ applymat(mat, pts[2])
+ return p
+
+
+def matprod(mlist):
+ """Get the product of the mat"""
+ prod = mlist[0]
+ for mat in mlist[1:]:
+ a00 = prod[0][0] * mat[0][0] + prod[0][1] * mat[1][0]
+ a01 = prod[0][0] * mat[0][1] + prod[0][1] * mat[1][1]
+ a10 = prod[1][0] * mat[0][0] + prod[1][1] * mat[1][0]
+ a11 = prod[1][0] * mat[0][1] + prod[1][1] * mat[1][1]
+ prod = [[a00, a01], [a10, a11]]
+ return prod
+
+
+def rotmat(teta):
+ """Rotate the mat"""
+ return [[cos(teta), -sin(teta)], [sin(teta), cos(teta)]]
+
+
+def applymat(mat, point):
+ """Apply the given mat"""
+ x = mat[0][0] * point[0] + mat[0][1] * point[1]
+ y = mat[1][0] * point[0] + mat[1][1] * point[1]
+ point[0] = x
+ point[1] = y
+
+
+def norm(point):
+ """Normalise"""
+ return sqrt(point[0] * point[0] + point[1] * point[1])
diff --git a/share/extensions/inkex/ports.py b/share/extensions/inkex/ports.py
new file mode 100644
index 0000000..f7c5f5d
--- /dev/null
+++ b/share/extensions/inkex/ports.py
@@ -0,0 +1,100 @@
+# coding=utf-8
+#
+# Copyright (C) 2019 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.
+#
+"""
+Common access to serial and other computer ports.
+"""
+
+import os
+import sys
+import time
+from .utils import DependencyError, AbortExtension
+
+try:
+ import serial
+ from serial.tools import list_ports
+except ImportError:
+ serial = None
+
+class Serial(object):
+ """
+ Attempt to get access to the computer's serial port.
+
+ with Serial(port_name, ...) as com:
+ com.write(...)
+
+ Provides access to the debug/testing ports which are pretend ports
+ able to accept the same input but allow for debugging.
+ """
+ def __init__(self, port, baud=9600, timeout=0.1, **options):
+ self.test = port == '[test]'
+ if self.test:
+ import pty # This does not work on windows
+ self.controller, self.peripheral = pty.openpty()
+ port = os.ttyname(self.peripheral)
+
+ self.has_serial()
+ self.com = serial.Serial()
+ self.com.port = port
+ self.com.baudrate = int(baud)
+ self.com.timeout = timeout
+ self.set_options(**options)
+
+ def set_options(self, stop=1, size=8, flow=None, parity=None):
+ """Set further options on the serial port"""
+ size = {5: 'five', 6: 'six', 7: 'seven', 8: 'eight'}.get(size, size)
+ stop = {'onepointfive': 1.5}.get(stop.lower(), stop)
+ stop = {1: 'one', 1.5: 'one_point_five', 2: 'two'}.get(stop, stop)
+ self.com.bytesize = getattr(serial, str(str(size).upper()) + 'BITS')
+ self.com.stopbits = getattr(serial, 'STOPBITS_' + str(stop).upper())
+ self.com.parity = getattr(serial, 'PARITY_' + str(parity).upper())
+ # set flow control
+ self.com.xonxoff = flow == 'xonxoff'
+ self.com.rtscts = flow in ('rtscts', 'dsrdtrrtscts')
+ self.com.dsrdtr = flow == 'dsrdtrrtscts'
+
+ def __enter__(self):
+ try:
+ # try to establish connection
+ self.com.open()
+ except serial.SerialException:
+ raise AbortExtension("Could not open serial port. Please check your device"\
+ " is running, connected and the settings are correct")
+ return self.com
+
+ def __exit__(self, exc, value, traceback):
+ if not traceback and self.test:
+ output = ' ' * 1024
+ while len(output) == 1024:
+ time.sleep(0.01)
+ output = os.read(self.controller, 1024)
+ sys.stderr.write(output.decode('utf8'))
+ #self.com.read(2)
+ self.com.close()
+
+ @staticmethod
+ def has_serial():
+ """Late importing of pySerial module"""
+ if serial is None:
+ raise DependencyError("pySerial is required to open serial ports.")
+
+ @staticmethod
+ def list_ports():
+ """Return a list of available serial ports"""
+ Serial.has_serial() # Cause DependencyError error
+ return [hw.name for hw in list_ports.comports(True)]
diff --git a/share/extensions/inkex/styles.py b/share/extensions/inkex/styles.py
new file mode 100644
index 0000000..4d9e2f7
--- /dev/null
+++ b/share/extensions/inkex/styles.py
@@ -0,0 +1,380 @@
+# coding=utf-8
+#
+# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
+#
+# 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.
+#
+"""
+Two simple functions for working with inline css
+and some color handling on top.
+"""
+
+import re
+from collections import OrderedDict
+
+from .utils import PY3
+from .colors import Color, ColorIdError
+from .tween import interpcoord, interpunit
+
+if PY3:
+ unicode = str # pylint: disable=redefined-builtin,invalid-name
+
+class Classes(list):
+ """A list of classes applied to an element (used in css and js)"""
+ def __init__(self, classes=None, callback=None):
+ self.callback = None
+ if isinstance(classes, (str, unicode)):
+ classes = classes.split()
+ super(Classes, self).__init__(classes or ())
+ self.callback = callback
+
+ def __str__(self):
+ return " ".join(self)
+
+ def _callback(self):
+ if self.callback is not None:
+ self.callback(self)
+
+ def __setitem__(self, index, value):
+ super(Classes, self).__setitem__(index, value)
+ self._callback()
+
+ def append(self, value):
+ value = str(value)
+ if value not in self:
+ super(Classes, self).append(value)
+ self._callback()
+
+ def remove(self, value):
+ value = str(value)
+ if value in self:
+ super(Classes, self).remove(value)
+ self._callback()
+
+ def toggle(self, value):
+ """If exists, remove it, if not, add it"""
+ value = str(value)
+ if value in self:
+ return self.remove(value)
+ return self.append(value)
+
+class Style(OrderedDict):
+ """A list of style directives"""
+ color_props = ('stroke', 'fill', 'stop-color', 'flood-color', 'lighting-color')
+ opacity_props = ('stroke-opacity', 'fill-opacity', 'opacity', 'stop-opacity')
+ unit_props = ('stroke-width')
+
+ def __init__(self, style=None, callback=None, **kw):
+ # This callback is set twice because this is 'pre-initial' data (no callback)
+ self.callback = None
+ # Either a string style or kwargs (with dashes as underscores).
+ style = style or [(k.replace('_', '-'), v) for k, v in kw.items()]
+ if isinstance(style, (str, unicode)):
+ style = self.parse_str(style)
+ # Order raw dictionaries so tests can be made reliable
+ if isinstance(style, dict) and not isinstance(style, OrderedDict):
+ style = [(name, style[name]) for name in sorted(style)]
+ # Should accept dict, Style, parsed string, list etc.
+ super(Style, self).__init__(style)
+ # Now after the initial data, the callback makes sense.
+ self.callback = callback
+
+ @staticmethod
+ def parse_str(style):
+ """Create a dictionary from the value of an inline style attribute"""
+ if style is None:
+ style = ""
+ for directive in style.split(';'):
+ if ':' in directive:
+ (name, value) = directive.split(':', 1)
+ # FUTURE: Parse value here for extra functionality
+ yield (name.strip().lower(), value.strip())
+
+ def __str__(self):
+ """Format an inline style attribute from a dictionary"""
+ return self.to_str()
+
+ def to_str(self, sep=";"):
+ """Convert to string using a custom delimiter"""
+ return sep.join(["{0}:{1}".format(*seg) for seg in self.items()])
+
+ def __add__(self, other):
+ """Add two styles together to get a third, composing them"""
+ ret = self.copy()
+ ret.update(Style(other))
+ return ret
+
+ def __iadd__(self, other):
+ """Add style to this style, the same as style.update(dict)"""
+ self.update(other)
+ return self
+
+ def __sub__(self, other):
+ """Remove keys and return copy"""
+ ret = self.copy()
+ ret.__isub__(other)
+ return ret
+
+ def __isub__(self, other):
+ """Remove keys from this style, list of keys or other style dictionary"""
+ for key in other:
+ self.pop(key, None)
+ return self
+
+ def __eq__(self, other):
+ """Not equals, prefer to overload 'in' but that doesn't seem possible"""
+ if not isinstance(other, Style):
+ other = Style(other)
+ for arg in set(self) | set(other):
+ if self.get(arg, None) != other.get(arg, None):
+ return False
+ return True
+ __ne__ = lambda self, other: not self.__eq__(other)
+
+ def update(self, other):
+ """Make sure callback is called when updating"""
+ super(Style, self).update(Style(other))
+ if self.callback is not None:
+ self.callback(self)
+
+ def __setitem__(self, key, value):
+ super(Style, self).__setitem__(key, value)
+ if self.callback is not None:
+ self.callback(self)
+
+ def get_color(self, name='fill'):
+ """Get the color AND opacity as one Color object"""
+ color = Color(self.get(name, 'none'))
+ return color.to_rgba(self.get(name + '-opacity', 1.0))
+
+ def set_color(self, color, name='fill'):
+ """Sets the given color AND opacity as rgba to the fill or stroke style properties."""
+ color = Color(color)
+ if color.space == 'rgba':
+ self[name + '-opacity'] = color.alpha
+ self[name] = str(color.to_rgb())
+
+ def update_urls(self, old_id, new_id):
+ """Find urls in this style and replace them with the new id"""
+ for (name, value) in self.items():
+ if value == 'url(#{})'.format(old_id):
+ self[name] = 'url(#{})'.format(new_id)
+
+ def interpolate_prop(self, other, fraction, prop, svg=None):
+ """Interpolate specific property."""
+ a1 = self[prop]
+ a2 = other.get(prop, None)
+ if a2 is None:
+ val = a1
+ else:
+ if prop in self.color_props:
+ if isinstance(a1, Color):
+ val = a1.interpolate(Color(a2), fraction)
+ elif a1.startswith('url(') or a2.startswith('url('):
+ # gradient requires changes to the whole svg
+ # and needs to be handled externally
+ val = a1
+ else:
+ val = Color(a1).interpolate(Color(a2), fraction)
+ elif prop in self.opacity_props:
+ val = interpcoord(float(a1), float(a2), fraction)
+ elif prop in self.unit_props:
+ val = interpunit(a1, a2, fraction)
+ else:
+ val = a1
+ return val
+
+ def interpolate(self, other, fraction):
+ # type: (Style, float) -> Style
+ """Interpolate all properties."""
+ style = Style()
+ for prop, value in self.items():
+ style[prop] = self.interpolate_prop(other, fraction, prop)
+ return style
+
+
+class AttrFallbackStyle(object):
+ """
+ A container for a style and an element that may have competing styles
+
+ If move is set to true, any new values are set to the style attribute
+ and removed from the element attributes list.
+ """
+ # TODO: This doesn't cover iterating over styles, because we don't
+ # have a list of known styles to check attribs for.
+ def __init__(self, elem, move=False):
+ self.elem = elem
+ self.styles = [elem.style]
+ self.styles.extend(elem.root.stylesheets.lookup(elem.get('id')))
+ self.move = move
+
+ def __getitem__(self, name):
+ # Style is more improtant, followed by the element
+ for style in self.styles:
+ if name in style:
+ return style[name]
+ return self.elem.attrib.get(name, None)
+
+ def __setitem__(self, name, value):
+ # Set the item back into the attribs, or move it if requested.
+ if name in self.elem.attrib:
+ # The other reason to unset the attrib is if it's already in
+ # the style dictionary so isn't needed here anyway.
+ if not self.move and name not in self.styles[0]:
+ self.elem.set(name, value)
+ return
+ self.elem.set(name, None)
+ for style in self.styles:
+ if name in style:
+ style[name] = value
+ return
+ # Not set before (anywhere), so set to element style
+ self.styles[0][name] = value
+
+ def get(self, name, default=None):
+ """Get with default"""
+ try:
+ return self[name]
+ except KeyError:
+ return default
+
+ def set(self, name, value):
+ """Set, nothing fancy"""
+ self[name] = value
+
+class StyleSheets(list):
+ """
+ Special mechanism which contains all the stylesheets for an svg document
+ while also caching lookups for specific elements.
+
+ This caching is needed because data can't be attached to elements as they are
+ re-created on the fly by lxml so lookups have to be centralised.
+ """
+ def __init__(self, svg=None):
+ super(StyleSheets, self).__init__()
+ self.svg = svg
+
+ def lookup(self, element_id, svg=None):
+ """
+ Find all styles for this element.
+ """
+ # This is aweful, but required because we can't know for sure
+ # what might have changed in the xml tree.
+ if svg is None:
+ svg = self.svg
+ for sheet in self:
+ for style in sheet.lookup(element_id, svg=svg):
+ yield style
+
+class StyleSheet(list):
+ """
+ A style sheet, usually the CDATA contents of a style tag, but also
+ a css file used with a css. Will yield multiple Style() classes.
+ """
+ comment_strip = re.compile(r"//.*?\n")
+
+ def __init__(self, content=None, callback=None):
+ super(StyleSheet, self).__init__()
+ self.callback = None
+ # Remove comments
+ content = self.comment_strip.sub('', (content or ''))
+ # Parse rules
+ for block in content.split('}'):
+ if block:
+ self.append(block)
+ self.callback = callback
+
+ def __str__(self):
+ return '\n' + '\n'.join([str(style) for style in self]) + '\n'
+
+ def _callback(self, style=None): # pylint: disable=unused-argument
+ if self.callback is not None:
+ self.callback(self)
+
+ def add(self, rule, style):
+ """Append a rule and style combo to this stylesheet"""
+ self.append(ConditionalStyle(rules=rule, style=str(style), callback=self._callback))
+
+ def append(self, other):
+ """Make sure callback is called when updating"""
+ if isinstance(other, str):
+ if '{' not in other:
+ return # Warning?
+ rules, style = other.strip('}').split('{', 1)
+ other = ConditionalStyle(rules=rules, style=style.strip(), callback=self._callback)
+ super(StyleSheet, self).append(other)
+ self._callback()
+
+ def lookup(self, element_id, svg):
+ """Lookup the element_id against all the styles in this sheet"""
+ for style in self:
+ for elem in svg.xpath(style.to_xpath()):
+ if elem.get('id', None) == element_id:
+ yield style
+
+class ConditionalStyle(Style):
+ """
+ Just like a Style object, but includes one or more
+ conditional rules which places this style in a stylesheet
+ rather than being an attribute style.
+ """
+ def __init__(self, rules='*', style=None, callback=None, **kwargs):
+ super(ConditionalStyle, self).__init__(style=style, callback=callback, **kwargs)
+ self.rules = [ConditionalRule(rule) for rule in rules.split(',')]
+
+ def __str__(self):
+ """Return this style as a css entry with class"""
+ content = self.to_str(";\n ")
+ rules = ",\n".join(str(rule) for rule in self.rules)
+ if content:
+ return "{0} {{\n {1};\n}}".format(rules, content)
+ return "{0} {{}}".format(rules)
+
+ def to_xpath(self):
+ """Convert all rules to an xpath"""
+ # This can be converted to cssselect.CSSSelector (lxml.cssselect) later if we have
+ # coverage problems. The main reason we're not is that cssselect is doing exactly
+ # this xpath transform and provides no extra functionality for reverse lookups.
+ return '|'.join([rule.to_xpath() for rule in self.rules])
+
+class ConditionalRule(object):
+ """A single css rule"""
+ step_to_xpath = [
+ (re.compile(r'\[(\w+)\^=([^\]]+)\]'), r'[starts-with(@\1,\2)]'), # Starts With
+ (re.compile(r'\[(\w+)\$=([^\]]+)\]'), r'[ends-with(@\1,\2)]'), # Ends With
+ (re.compile(r'\[(\w+)\*=([^\]]+)\]'), r'[contains(@\1,\2)]'), # Contains
+ (re.compile(r'\[([^@\(\)\]]+)\]'), r'[@\1]'), # Attribute (start)
+ (re.compile(r'#(\w+)'), r"[@id='\1']"), # Id Match
+ (re.compile(r'\s*>\s*([^\s>~\+]+)'), r'/\1'), # Direct child match
+ #(re.compile(r'\s*~\s*([^\s>~\+]+)'), r'/following-sibling::\1'),
+ #(re.compile(r'\s*\+\s*([^\s>~\+]+)'), r'/following-sibling::\1[1]'),
+ (re.compile(r'\s*([^\s>~\+]+)'), r'//\1'), # Decendant match
+ (re.compile(r'\.(\w+)'), r"[contains(concat(' ', normalize-space(@class), ' '), ' \1 ')]"),
+ (re.compile(r'//\['), r'//*['), # Attribute only match
+ (re.compile(r'//(\w+)'), r'//svg:\1'), # SVG namespace addition
+ ]
+
+ def __init__(self, rule):
+ self.rule = rule.strip()
+
+ def __str__(self):
+ return self.rule
+
+ def to_xpath(self):
+ """Attempt to convert the rule into a simplified xpath"""
+ ret = self.rule
+ for matcher, replacer in self.step_to_xpath:
+ ret = matcher.sub(replacer, ret)
+ return ret
diff --git a/share/extensions/inkex/tester/__init__.py b/share/extensions/inkex/tester/__init__.py
new file mode 100644
index 0000000..f33f695
--- /dev/null
+++ b/share/extensions/inkex/tester/__init__.py
@@ -0,0 +1,384 @@
+# coding=utf-8
+#
+# Copyright (C) 2018-2019 Martin Owens
+# 2019 Thomas Holder
+#
+# 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, USA.
+#
+"""
+All Inkscape extensions should come with tests. This package provides you with
+the tools needed to create tests and thus ensure that your extension continues
+to work with future versions of Inkscape, the "inkex" python modules, and other
+python and non-python tools you may use.
+
+Make sure your extension is a python extension and is using the `inkex.generic`
+base classes. These provide the greatest amount of functionality for testing.
+
+You should start by creating a folder in your repository called `tests` with
+an empty file inside called `__init__.py` to turn it into a module folder.
+
+For each of your extensions, you should create a file called
+`test_{extension_name}.py` where the name reflects the name of your extension.
+
+There are two types of tests:
+
+ 1. Full-process Comparison tests - These are tests which invoke your
+ extension with various arguments and attempt to compare the
+ output to a known good reference. These are useful for testing
+ that your extension would work if it was used in Inkscape.
+
+ Good example of writing comparison tests can be found in the
+ Inkscape core repository, each test which inherits from
+ the ComparisonMixin class is running comparison tests.
+
+ 2. Unit tests - These are individual test functions which call out to
+ specific functions within your extension. These are typical
+ python unit tests and many good python documents exist
+ to describe how to write them well. For examples here you
+ can find the tests that test the inkex modules themselves
+ to be the most instructive.
+
+When running a test, it will cause a certain fraction of the code within the
+extension to execute. This fraction called it's **coverage** and a higher
+coverage score indicates that your test is better at exercising the various
+options, features, and branches within your code.
+
+Generating comparison output can be done using the EXPORT_COMPARE environment
+variable when calling pytest. For example:
+
+ EXPORT_COMPARE=1 pytest tests/test_my_specific_test.py
+
+This will create files in `tests/data/refs/*.out.export` and these files should
+be manually checked to make sure they are correct before being renamed and stripped
+of the `.export` suffix. pytest should then be re-run to confirm before
+committing to the repository.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+import re
+import sys
+import shutil
+import tempfile
+import hashlib
+import random
+import uuid
+
+from io import BytesIO, StringIO
+import xml.etree.ElementTree as xml
+
+from unittest import TestCase as BaseCase
+from inkex.base import InkscapeExtension
+
+from ..utils import PY3, to_bytes
+from .xmldiff import xmldiff
+from .mock import MockCommandMixin, Capture
+
+if False: # pylint: disable=using-constant-test
+ from typing import Type, List
+ from .filters import Compare
+
+
+class NoExtension(InkscapeExtension): # pylint: disable=too-few-public-methods
+ """Test case must specify 'self.effect_class' to assertEffect."""
+
+ def __init__(self, *args, **kwargs): # pylint: disable=super-init-not-called
+ raise NotImplementedError(self.__doc__)
+
+ def run(self, args=None, output=None):
+ """Fake run"""
+ pass
+
+
+class TestCase(MockCommandMixin, BaseCase):
+ """
+ Base class for all effects tests, provides access to data_files and test_without_parameters
+ """
+ effect_class = NoExtension # type: Type[InkscapeExtension]
+ effect_name = property(lambda self: self.effect_class.__module__)
+
+ # If set to true, the output is not expected to be the stdout SVG document, but rather
+ # text or a message sent to the stderr, this is highly weird. But sometimes happens.
+ stderr_output = False
+ stdout_protect = True
+ stderr_protect = True
+ python3_only = False
+
+ def __init__(self, *args, **kw):
+ super(TestCase, self).__init__(*args, **kw)
+ self._temp_dir = None
+ self._effect = None
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Make sure every test is seeded the same way"""
+ self._effect = None
+ super(TestCase, self).setUp()
+ if self.python3_only and not PY3:
+ self.skipTest("No available in python2")
+ try:
+ # python3, with version 1 to get the same numbers
+ # as in python2 during tests.
+ random.seed(0x35f, version=1)
+ except TypeError:
+ # But of course this kwarg doesn't exist in python2
+ random.seed(0x35f)
+
+ def tearDown(self):
+ super(TestCase, self).tearDown()
+ if self._temp_dir and os.path.isdir(self._temp_dir):
+ shutil.rmtree(self._temp_dir)
+
+ @classmethod
+ def __file__(cls):
+ """Create a __file__ property which acts much like the module version"""
+ return os.path.abspath(sys.modules[cls.__module__].__file__)
+
+ @classmethod
+ def _testdir(cls):
+ """Get's the folder where the test exists (so data can be found)"""
+ return os.path.dirname(cls.__file__())
+
+ @classmethod
+ def rootdir(cls):
+ """Return the full path to the extensions directory"""
+ return os.path.dirname(cls._testdir())
+
+ @classmethod
+ def datadir(cls):
+ """Get the data directory (can be over-ridden if needed)"""
+ return os.path.join(cls._testdir(), 'data')
+
+ @property
+ def tempdir(self):
+ """Generate a temporary location to store files"""
+ if self._temp_dir is None:
+ self._temp_dir = tempfile.mkdtemp(prefix='inkex-tests-')
+ if not os.path.isdir(self._temp_dir):
+ raise IOError("The temporary directory has disappeared!")
+ return self._temp_dir
+
+ def temp_file(self, prefix='file-', template='{prefix}{name}{suffix}', suffix='.tmp'):
+ """Generate the filename of a temporary file"""
+ filename = template.format(prefix=prefix, suffix=suffix, name=uuid.uuid4().hex)
+ return os.path.join(self.tempdir, filename)
+
+ @classmethod
+ def data_file(cls, filename, *parts):
+ """Provide a data file from a filename, can accept directories as arguments."""
+ if os.path.isabs(filename):
+ # Absolute root was passed in, so we trust that (it might be a tempdir)
+ full_path = os.path.join(filename, *parts)
+ else:
+ # Otherwise we assume it's relative to the test data dir.
+ full_path = os.path.join(cls.datadir(), filename, *parts)
+
+ if not os.path.isfile(full_path):
+ raise IOError("Can't find test data file: {}".format(full_path))
+ return full_path
+
+ @property
+ def empty_svg(self):
+ """Returns a common minimal svg file"""
+ return self.data_file('svg', 'default-inkscape-SVG.svg')
+
+ def assertAlmostTuple(self, found, expected, precision=8): # pylint: disable=invalid-name
+ """
+ Floating point results may vary with computer architecture; use
+ assertAlmostEqual to allow a tolerance in the result.
+ """
+ self.assertEqual(len(found), len(expected))
+ for fon, exp in zip(found, expected):
+ self.assertAlmostEqual(fon, exp, precision)
+
+ def assertEffectEmpty(self, effect, **kwargs): # pylint: disable=invalid-name
+ """Assert calling effect without any arguments"""
+ self.assertEffect(effect=effect, **kwargs)
+
+ def assertEffect(self, *filename, **kwargs): # pylint: disable=invalid-name
+ """Assert an effect, capturing the output to stdout.
+
+ filename should point to a starting svg document, default is empty_svg
+ """
+ effect = kwargs.pop('effect', self.effect_class)()
+
+ args = [self.data_file(*filename)] if filename else [self.empty_svg] # pylint: disable=no-value-for-parameter
+ args += kwargs.pop('args', [])
+ args += ['--{}={}'.format(*kw) for kw in kwargs.items()]
+
+ # Output is redirected to this string io buffer
+ if self.stderr_output:
+ with Capture('stderr') as stderr:
+ effect.run(args, output=BytesIO())
+ effect.test_output = stderr
+ else:
+ output = BytesIO()
+ with Capture('stdout', kwargs.get('stdout_protect', self.stdout_protect)) as stdout:
+ with Capture('stderr', kwargs.get('stderr_protect', self.stderr_protect)) as stderr:
+ effect.run(args, output=output)
+ self.assertEqual('', stdout.getvalue(), "Extra print statements detected")
+ self.assertEqual('', stderr.getvalue(), "Extra error or warnings detected")
+ effect.test_output = output
+
+ if os.environ.get('FAIL_ON_DEPRECATION', False):
+ warnings = getattr(effect, 'warned_about', set())
+ effect.warned_about = set() # reset for next test
+ self.assertFalse(warnings, "Deprecated API is still being used!")
+
+ return effect
+
+ def assertDeepAlmostEqual(self, first, second, places=None, msg=None, delta=None):
+ if delta is None and places is None:
+ places = 7
+ if isinstance(first, (list, tuple)):
+ assert len(first) == len(second)
+ for (f, s) in zip(first, second):
+ self.assertDeepAlmostEqual(f, s, places, msg, delta)
+ else:
+ self.assertAlmostEqual(first, second, places, msg, delta)
+
+ @property
+ def effect(self):
+ """Generate an effect object"""
+ if self._effect is None:
+ self._effect = self.effect_class()
+ return self._effect
+
+class InkscapeExtensionTestMixin(object):
+ """Automatically setup self.effect for each test and test with an empty svg"""
+ def setUp(self): # pylint: disable=invalid-name
+ """Check if there is an effect_class set and create self.effect if it is"""
+ super(InkscapeExtensionTestMixin, self).setUp()
+ if self.effect_class is None:
+ self.skipTest('self.effect_class is not defined for this this test')
+
+ def test_default_settings(self):
+ """Extension works with empty svg file"""
+ self.effect.run([self.empty_svg])
+
+class ComparisonMixin(object):
+ """
+ Add comparison tests to any existing test suite.
+ """
+ # This input svg file sent to the extension (if any)
+ compare_file = 'svg/shapes.svg'
+ # The ways in which the output is filtered for comparision (see filters.py)
+ compare_filters = [] # type: List[Compare]
+ # If true, the filtered output will be saved and only applied to the
+ # extension output (and not to the reference file)
+ compare_filter_save = False
+ # A list of comparison runs, each entry will cause the extension to be run.
+ comparisons = [
+ (),
+ ('--id=p1', '--id=r3'),
+ ]
+
+ def test_all_comparisons(self):
+ """Testing all comparisons"""
+ if not isinstance(self.compare_file, (list, tuple)):
+ self._test_comparisons(self.compare_file)
+ else:
+ for compare_file in self.compare_file:
+ self._test_comparisons(
+ compare_file,
+ addout=os.path.basename(compare_file)
+ )
+
+ def _test_comparisons(self, compare_file, addout=None):
+ for args in self.comparisons:
+ self.assertCompare(
+ compare_file,
+ self.get_compare_outfile(args, addout),
+ args,
+ )
+
+ def assertCompare(self, infile, outfile, args): #pylint: disable=invalid-name
+ """
+ Compare the output of a previous run against this one.
+
+ - infile: The filename of the pre-processed svg (or other type of file)
+ - outfile: The filename of the data we expect to get, if not set
+ the filename will be generated from the effect name and kwargs.
+ - args: All the arguments to be passed to the effect run
+
+ """
+ effect = self.assertEffect(infile, args=args)
+
+ if outfile is None:
+ outfile = self.get_compare_outfile(args)
+
+ if not os.path.isfile(outfile):
+ raise IOError("Comparison file {} not found".format(outfile))
+
+ data_a = effect.test_output.getvalue()
+ if os.environ.get('EXPORT_COMPARE', False):
+ with open(outfile + '.export', 'wb') as fhl:
+ if sys.version_info[0] == 3 and isinstance(data_a, str):
+ data_a = data_a.encode('utf-8')
+ fhl.write(self._apply_compare_filters(data_a, True))
+ print("Written output: {}.export".format(outfile))
+
+ data_a = self._apply_compare_filters(data_a)
+
+ with open(outfile, 'rb') as fhl:
+ data_b = self._apply_compare_filters(fhl.read(), False)
+
+ if isinstance(data_a, bytes) and isinstance(data_b, bytes) \
+ and data_a.startswith(b'<') and data_b.startswith(b'<'):
+ # Late importing
+ diff_xml, delta = xmldiff(data_a, data_b)
+ if not delta and not os.environ.get('EXPORT_COMPARE', False):
+ print('The XML is different, you can save the output using the EXPORT_COMPARE=1'\
+ ' envionment variable. This will save the compared file as a ".output" file'\
+ ' next to the reference file used in the test.\n')
+ diff = 'SVG Differences: {}\n\n'.format(outfile)
+ if os.environ.get('XML_DIFF', False):
+ diff = '<- ' + diff_xml
+ else:
+ for x, (value_a, value_b) in enumerate(delta):
+ try:
+ # Take advantage of better text diff in testcase's own asserts.
+ self.assertEqual(value_a, value_b)
+ except AssertionError as err:
+ diff += " {}. {}\n".format(x, str(err))
+ self.assertTrue(delta, diff)
+ else:
+ # compare any content (non svg)
+ self.assertEqual(data_a, data_b)
+
+ def _apply_compare_filters(self, data, is_saving=None):
+ data = to_bytes(data)
+ # Applying filters flips depending if we are saving the filtered content
+ # to disk, or filtering during the test run. This is because some filters
+ # are destructive others are useful for diagnostics.
+ if is_saving is self.compare_filter_save or is_saving is None:
+ for cfilter in self.compare_filters:
+ data = cfilter(data)
+ return data
+
+ def get_compare_outfile(self, args, addout=None):
+ """Generate an output file for the arguments given"""
+ if addout is not None:
+ args = list(args) + [str(addout)]
+ opstr = '__'.join(args)\
+ .replace(self.tempdir, 'TMP_DIR')\
+ .replace(self.datadir(), 'DAT_DIR')
+ opstr = re.sub(r'[^\w-]', '__', opstr)
+ if opstr:
+ if len(opstr) > 127:
+ # avoid filename-too-long error
+ opstr = hashlib.md5(opstr.encode('latin1')).hexdigest()
+ opstr = '__' + opstr
+ return self.data_file("refs", "{}{}.out".format(self.effect_name, opstr))
diff --git a/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc
new file mode 100644
index 0000000..7d6722e
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc
new file mode 100644
index 0000000..6c98af5
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc
new file mode 100644
index 0000000..b460938
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc
new file mode 100644
index 0000000..7f8204a
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc
new file mode 100644
index 0000000..82c6234
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc
new file mode 100644
index 0000000..439198f
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc
new file mode 100644
index 0000000..cd16936
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc
new file mode 100644
index 0000000..5e61017
--- /dev/null
+++ b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc
Binary files differ
diff --git a/share/extensions/inkex/tester/decorators.py b/share/extensions/inkex/tester/decorators.py
new file mode 100644
index 0000000..92d6b4d
--- /dev/null
+++ b/share/extensions/inkex/tester/decorators.py
@@ -0,0 +1,8 @@
+"""
+Useful decorators for tests.
+"""
+import pytest
+from inkex.command import is_inkscape_available
+
+requires_inkscape = pytest.mark.skipif( # pylint: disable=invalid-name
+ not is_inkscape_available(), reason="Test requires inkscape, but it's not available")
diff --git a/share/extensions/inkex/tester/filters.py b/share/extensions/inkex/tester/filters.py
new file mode 100644
index 0000000..f23d124
--- /dev/null
+++ b/share/extensions/inkex/tester/filters.py
@@ -0,0 +1,139 @@
+#
+# Copyright (C) 2019 Thomas Holder
+#
+# 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=too-few-public-methods
+#
+"""
+Comparison filters for use with the ComparisonMixin.
+
+Each filter should be initialised in the list of
+filters that are being used.
+
+compare_filters = [
+ CompareNumericFuzzy(),
+ CompareOrderIndependentLines(option=yes),
+]
+"""
+
+import re
+from ..utils import to_bytes
+
+class Compare(object):
+ """
+ Comparison base class, this acts as a passthrough unless
+ the filter staticmethod is overwritten.
+ """
+ def __init__(self, **options):
+ self.options = options
+
+ def __call__(self, content):
+ return self.filter(content)
+
+ @staticmethod
+ def filter(contents):
+ """Replace this filter method with your own filtering"""
+ return contents
+
+class CompareNumericFuzzy(Compare):
+ """
+ Turn all numbers into shorter standard formats
+
+ 1.2345678 -> 1.2346
+ 1.2300 -> 1.23, 50.0000 -> 50.0
+ 50.0 -> 50
+ """
+ @staticmethod
+ def filter(contents):
+ func = lambda m: b'%.3f' % (float(m.group(0)))
+ contents = re.sub(br'\d+\.\d+', func, contents)
+ contents = re.sub(br'(\d\.\d+?)0+\b', br'\1', contents)
+ contents = re.sub(br'(\d)\.0+(?=\D|\b)', br'\1', contents)
+ return contents
+
+class CompareWithoutIds(Compare):
+ """Remove all ids from the svg"""
+ @staticmethod
+ def filter(contents):
+ return re.sub(br' id="([^"]*)"', b'', contents)
+
+class CompareWithPathSpace(Compare):
+ """Make sure that path segment commands have spaces around them"""
+ @staticmethod
+ def filter(contents):
+ def func(match):
+ """We've found a path command, process it"""
+ new = re.sub(br'\s*([LZMHVCSQTAatqscvhmzl])\s*', br' \1 ', match.group(1))
+ return b' d="' + new.replace(b',', b' ') + b'"'
+ return re.sub(br' d="([^"]*)"', func, contents)
+
+class CompareSize(Compare):
+ """Compare the length of the contents instead of the contents"""
+ @staticmethod
+ def filter(contents):
+ return len(contents)
+
+class CompareOrderIndependentBytes(Compare):
+ """Take all the bytes and sort them"""
+ @staticmethod
+ def filter(contents):
+ return b"".join([bytes(i) for i in sorted(contents)])
+
+class CompareOrderIndependentLines(Compare):
+ """Take all the lines and sort them"""
+ @staticmethod
+ def filter(contents):
+ return b"\n".join(sorted(contents.splitlines()))
+
+class CompareOrderIndependentStyle(Compare):
+ """Take all styles and sort the results"""
+ @staticmethod
+ def filter(contents):
+ contents = CompareNumericFuzzy.filter(contents)
+ def func(match):
+ """Search and replace function for sorting"""
+ sty = b';'.join(sorted(match.group(1).split(b';')))
+ return b'style="%s"' % (sty,)
+ return re.sub(br'style="([^"]*)"', func, contents)
+
+class CompareOrderIndependentStyleAndPath(Compare):
+ """Take all styles and paths and sort them both"""
+ @staticmethod
+ def filter(contents):
+ contents = CompareOrderIndependentStyle.filter(contents)
+ def func(match):
+ """Search and replace function for sorting"""
+ path = b'X'.join(sorted(re.split(br'[A-Z]', match.group(1))))
+ return b'd="%s"' % (path,)
+ return re.sub(br'\bd="([^"]*)"', func, contents)
+
+class CompareOrderIndependentTags(Compare):
+ """Sorts all the XML tags"""
+ @staticmethod
+ def filter(contents):
+ return b"\n".join(sorted(re.split(br'>\s*<', contents)))
+
+class CompareReplacement(Compare):
+ """Replace pieces to make output more comparable"""
+ def __init__(self, *replacements):
+ self.deltas = replacements
+ super(CompareReplacement, self).__init__()
+
+ def filter(self, contents):
+ contents = to_bytes(contents)
+ for _from, _to in self.deltas:
+ contents = contents.replace(to_bytes(_from), to_bytes(_to))
+ return contents
diff --git a/share/extensions/inkex/tester/inx.py b/share/extensions/inkex/tester/inx.py
new file mode 100644
index 0000000..a30eb7e
--- /dev/null
+++ b/share/extensions/inkex/tester/inx.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python
+# coding=utf-8
+"""
+Test elements extra logic from svg xml lxml custom classes.
+"""
+
+from ..utils import PY3
+from ..inx import InxFile
+
+INTERNAL_ARGS = ('help', 'output', 'id', 'selected-nodes')
+ARG_TYPES = {
+ 'Boolean': 'bool',
+ 'Color': 'color',
+ 'str': 'string',
+ 'int': 'int',
+ 'float': 'float',
+}
+
+class InxMixin(object):
+ """Tools for Testing INX files, use as a mixin class:
+
+ class MyTests(InxMixin, TestCase):
+ def test_inx_file(self):
+ self.assertInxIsGood("some_inx_file.inx")
+ """
+ def assertInxIsGood(self, inx_file): # pylint: disable=invalid-name
+ """Test the inx file for consistancy and correctness"""
+ self.assertTrue(PY3, "INX files can only be tested in python3")
+
+ inx = InxFile(inx_file)
+ if 'help' in inx.ident or inx.script.get('interpreter', None) != 'python':
+ return
+ cls = inx.extension_class
+ # Check class can be matched in python file
+ self.assertTrue(cls, 'Can not find class for {}'.format(inx.filename))
+ # Check name is reasonable for the class
+ if not cls.multi_inx:
+ self.assertEqual(
+ cls.__name__, inx.slug,
+ "Name of extension class {}.{} is different from ident {}".format(
+ cls.__module__, cls.__name__, inx.slug))
+ self.assertParams(inx, cls)
+
+ def assertParams(self, inx, cls): # pylint: disable=invalid-name
+ """Confirm the params in the inx match the python script"""
+ params = dict([(param.name, self.parse_param(param)) for param in inx.params])
+ args = dict(self.introspect_arg_parser(cls().arg_parser))
+ mismatch_a = list(set(params) ^ set(args) & set(params))
+ mismatch_b = list(set(args) ^ set(params) & set(args))
+ self.assertFalse(mismatch_a, "{}: Inx params missing from arg parser".format(inx.filename))
+ self.assertFalse(mismatch_b, "{}: Script args missing from inx xml".format(inx.filename))
+
+ for param in args:
+ if params[param]['type'] and args[param]['type']:
+ self.assertEqual(
+ params[param]['type'],
+ args[param]['type'],
+ "Type is not the same for {}:param:{}".format(inx.filename, param))
+
+ def introspect_arg_parser(self, arg_parser):
+ """Pull apart the arg parser to find out what we have in it"""
+ for action in arg_parser._optionals._actions: # pylint: disable=protected-access
+ for opt in action.option_strings:
+ # Ignore params internal to inkscape (thus not in the inx)
+ if opt.startswith('--') and opt[2:] not in INTERNAL_ARGS:
+ yield (opt[2:], self.introspect_action(action))
+
+ @staticmethod
+ def introspect_action(action):
+ """Pull apart a single action to get at the juicy insides"""
+ return {
+ 'type': ARG_TYPES.get((action.type or str).__name__, 'string'),
+ 'default': action.default,
+ 'choices': action.choices,
+ 'help': action.help,
+ }
+
+ @staticmethod
+ def parse_param(param):
+ """Pull apart the param element in the inx file"""
+ if param.param_type in ('optiongroup', 'notebook'):
+ options = param.options
+ return {
+ 'type': None,
+ 'choices': options,
+ 'default': options and options[0] or None,
+ }
+ param_type = param.param_type
+ if param.param_type in ('path',):
+ param_type = 'string'
+ return {
+ 'type': param_type,
+ 'default': param.text,
+ 'choices': None,
+ }
diff --git a/share/extensions/inkex/tester/mock.py b/share/extensions/inkex/tester/mock.py
new file mode 100644
index 0000000..2df8791
--- /dev/null
+++ b/share/extensions/inkex/tester/mock.py
@@ -0,0 +1,414 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens
+#
+# 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, USA.
+#
+# pylint: disable=protected-access,too-few-public-methods
+"""
+Any mocking utilities required by testing. Mocking is when you need the test
+to exercise a piece of code, but that code may or does call on something
+outside of the target code that either takes too long to run, isn't available
+during the test running process or simply shouldn't be running at all.
+"""
+
+import io
+import os
+import sys
+import logging
+import hashlib
+import tempfile
+
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.parser import Parser as EmailParser
+
+import inkex.command
+
+if False: # pylint: disable=using-constant-test
+ from typing import List, Tuple, Callable, Any # pylint: disable=unused-import
+
+FIXED_BOUNDARY = '--CALLDATA--//--CALLDATA--'
+
+class Capture(object):
+ """Capture stdout or stderr. Used as `with Capture('stdout') as stream:`"""
+ def __init__(self, io_name='stdout', swap=True):
+ self.io_name = io_name
+ self.original = getattr(sys, io_name)
+ self.stream = io.StringIO()
+ self.swap = swap
+
+ def __enter__(self):
+ # We can't control python2 correctly (unicode vs. bytes-like) but
+ # we don't need it, so we're ignore python2 as if it doesn't exist.
+ if self.swap:
+ setattr(sys, self.io_name, self.stream)
+ return self.stream
+
+ def __exit__(self, exc, value, traceback):
+ if exc is not None and self.swap:
+ # Dump content back to original if there was an error.
+ self.original.write(self.stream.getvalue())
+ setattr(sys, self.io_name, self.original)
+
+class ManualVerbosity(object):
+ """Change the verbosity of the test suite manually"""
+ result = property(lambda self: self.test._current_result)
+
+ def __init__(self, test, okay=True, dots=False):
+ self.test = test
+ self.okay = okay
+ self.dots = dots
+
+ def flip(self, exc_type=None, exc_val=None, exc_tb=None): # pylint: disable=unused-argument
+ """Swap the stored verbosity with the original"""
+ self.okay, self.result.showAll = self.result.showAll, self.okay
+ self.dots, self.result.dots = self.result.dots, self.okay
+
+ __enter__ = flip
+ __exit__ = flip
+
+
+class MockMixin(object):
+ """
+ Add mocking ability to any test base class, will set up mock on setUp
+ and remove it on tearDown.
+
+ Mocks are stored in an array attached to the test class (not instance!) which
+ ensures that mocks can only ever be setUp once and can never be reset over
+ themselves. (just in case this looks weird at first glance)
+
+ class SomeTest(MockingMixin, TestBase):
+ mocks = [(sys, 'exit', NoSystemExit("Nope!")]
+ """
+ mocks = [] # type: List[Tuple[Any, str, Any]]
+
+ def setUpMock(self, owner, name, new): # pylint: disable=invalid-name
+ """Setup the mock here, taking name and function and returning (name, old)"""
+ old = getattr(owner, name)
+ if isinstance(new, str):
+ if hasattr(self, new):
+ new = getattr(self, new)
+ if isinstance(new, Exception):
+ def _error_function(*args2, **kw2): # pylint: disable=unused-argument
+ raise type(new)(str(new))
+ setattr(owner, name, _error_function)
+ elif new is None or isinstance(new, (str, int, float, list, tuple)):
+ def _value_function(*args, **kw): # pylint: disable=unused-argument
+ return new
+ setattr(owner, name, _value_function)
+ else:
+ setattr(owner, name, new)
+ # When we start, mocks contains length 3 tuples, when we're finished, it contains
+ # length 4, this stops remocking and reunmocking from taking place.
+ return (owner, name, old, False)
+
+ def setUp(self): # pylint: disable=invalid-name
+ """For each mock instruction, set it up and store the return"""
+ super(MockMixin, self).setUp()
+ for x, mock in enumerate(self.mocks):
+ if len(mock) == 4:
+ logging.error("Mock was already set up, so it wasn't cleared previously!")
+ continue
+ self.mocks[x] = self.setUpMock(*mock)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """For each returned stored, tear it down and restore mock instruction"""
+ super(MockMixin, self).tearDown()
+ try:
+ for x, (owner, name, old, _) in enumerate(self.mocks):
+ self.mocks[x] = (owner, name, getattr(owner, name))
+ setattr(owner, name, old)
+ except ValueError:
+ logging.warning("Was never mocked, did something go wrong?")
+
+ def old_call(self, name):
+ """Get the original caller"""
+ for arg in self.mocks:
+ if arg[1] == name:
+ return arg[2]
+ return lambda: None
+
+class MockCommandMixin(MockMixin):
+ """
+ Replace all the command functions with testable replacements.
+
+ This stops the pipeline and people without the programs, running into problems.
+ """
+ mocks = [
+ (inkex.command, '_call', 'mock_call'),
+ (tempfile, 'mkdtemp', 'record_tempdir'),
+ ]
+ recorded_tempdirs = [] # type:List[str]
+
+ def setUp(self): # pylint: disable=invalid-name
+ super(MockCommandMixin, self).setUp()
+ # This is a the daftest thing I've ever seen, when in the middle
+ # of a mock, the 'self' variable magically turns from a FooTest
+ # into a TestCase, this makes it impossible to find the datadir.
+ from . import TestCase
+ TestCase._mockdatadir = self.datadir()
+
+ @classmethod
+ def cmddir(cls):
+ """Returns the location of all the mocked command results"""
+ from . import TestCase
+ return os.path.join(TestCase._mockdatadir, 'cmd')
+
+ def record_tempdir(self, *args, **kwargs):
+ """Record any attempts to make tempdirs"""
+ newdir = self.old_call('mkdtemp')(*args, **kwargs)
+ self.recorded_tempdirs.append(newdir)
+ return newdir
+
+ def clean_paths(self, data, files):
+ """Clean a string of any files or tempdirs"""
+ try:
+ for fdir in self.recorded_tempdirs:
+ data = data.replace(fdir, '.')
+ files = [fname.replace(fdir, '.') for fname in files]
+ for fname in files:
+ data = data.replace(fname, os.path.basename(fname))
+ except (UnicodeDecodeError, TypeError):
+ pass
+ return data
+
+ def get_all_tempfiles(self):
+ """Returns a set() of all files currently in any of the tempdirs"""
+ ret = set([])
+ for fdir in self.recorded_tempdirs:
+ if not os.path.isdir(fdir):
+ continue
+ for fname in os.listdir(fdir):
+ if fname in ('.', '..'):
+ continue
+ path = os.path.join(fdir, fname)
+ # We store the modified time so if a program modifies
+ # the input file in-place, it will look different.
+ ret.add(path + ';{}'.format(os.path.getmtime(path)))
+
+ return ret
+
+ def ignore_command_mock(self, program, arglst):
+ """Return true if the mock is ignored"""
+ if self and program and arglst:
+ return os.environ.get('NO_MOCK_COMMANDS')
+ return False
+
+ def mock_call(self, program, *args, **kwargs):
+ """
+ Replacement for the inkex.command.call() function, instead of calling
+ an external program, will compile all arguments into a hash and use the
+ hash to find a command result.
+ """
+ # Remove stdin first because it needs to NOT be in the Arguments list.
+ stdin = kwargs.pop('stdin', None)
+ args = list(args)
+
+ # We use email
+ msg = MIMEMultipart(boundary=FIXED_BOUNDARY)
+ msg['Program'] = self.get_program_name(program)
+
+ # Gather any output files and add any input files to msg, args and kwargs
+ # may be modified to strip out filename directories (which change)
+ inputs, outputs = self.add_call_files(msg, args, kwargs)
+
+ arglst = inkex.command.to_args(program, *args, **kwargs)[1:]
+ arglst.sort()
+ argstr = ' '.join(arglst)
+ argstr = self.clean_paths(argstr, inputs + outputs)
+ msg['Arguments'] = argstr.strip()
+
+ if stdin is not None:
+ # The stdin is counted as the msg body
+ cleanin = self.clean_paths(stdin, inputs + outputs)
+ msg.attach(MIMEText(cleanin, 'plain', 'utf-8'))
+
+ keystr = msg.as_string()
+ # There is a difference between python2 and python3 output
+ keystr = keystr.replace('\n\n', '\n')
+ keystr = keystr.replace('\n ', ' ')
+ if 'verb' in keystr:
+ # Verbs seperated by colons cause diff in py2/3
+ keystr = keystr.replace('; ', ';')
+ # Generate a unique key for this call based on _all_ it's inputs
+ key = hashlib.md5(keystr.encode('utf-8')).hexdigest()
+
+ if self.ignore_command_mock(program, arglst):
+ # Call original code. This is so programmers can run the test suite
+ # against the external programs too, to see how their fair.
+ if stdin is not None:
+ kwargs['stdin'] = stdin
+
+ before = self.get_all_tempfiles()
+ stdout = self.old_call('_call')(program, *args, **kwargs)
+ outputs += list(self.get_all_tempfiles() - before)
+ # Remove the modified time from the call
+ outputs = [out.rsplit(';', 1)[0] for out in outputs]
+
+ # After the program has run, we collect any file outputs and store
+ # them, then store any stdout or stderr created during the run.
+ # A developer can then use this to build new test cases.
+ reply = MIMEMultipart(boundary=FIXED_BOUNDARY)
+ reply['Program'] = self.get_program_name(program)
+ reply['Arguments'] = argstr
+ self.save_call(program, key, stdout, outputs, reply)
+ self.save_key(program, key, keystr, 'key')
+ return stdout
+
+ try:
+ return self.load_call(program, key, outputs)
+ except IOError:
+ self.save_key(program, key, keystr, 'bad-key')
+ raise IOError("Problem loading call: {}/{} use the environment variable "\
+ "NO_MOCK_COMMANDS=1 to call out to the external program and generate "\
+ "the mock call file.".format(program, key))
+
+ def add_call_files(self, msg, args, kwargs):
+ """
+ Gather all files, adding input files to the msg (for hashing) and
+ output files to the returned files list (for outputting in debug)
+ """
+ # Gather all possible string arguments together.
+ loargs = sorted(kwargs.items(), key=lambda i: i[0])
+ values = []
+ for arg in args:
+ if isinstance(arg, (tuple, list)):
+ loargs.append(arg)
+ else:
+ values.append(str(arg))
+
+ for (_, value) in loargs:
+ if isinstance(value, (tuple, list)):
+ for val in value:
+ if val is not True:
+ values.append(str(val))
+ elif value is not True:
+ values.append(str(value))
+
+ # See if any of the strings could be filenames, either going to be
+ # or are existing files on the disk.
+ files = [[], []]
+ for value in values:
+ if os.path.isfile(value): # Input file
+ files[0].append(value)
+ self.add_call_file(msg, value)
+ elif os.path.isdir(os.path.dirname(value)): # Output file
+ files[1].append(value)
+ return files
+
+ def add_call_file(self, msg, filename):
+ """Add a single file to the given mime message"""
+ fname = os.path.basename(filename)
+ with open(filename, "rb") as fhl:
+ if filename.endswith('.svg'):
+ value = self.clean_paths(fhl.read().decode('utf8'), [])
+ else:
+ value = fhl.read()
+ part = MIMEApplication(value, Name=fname)
+ # After the file is closed
+ part['Content-Disposition'] = 'attachment'
+ part['Filename'] = fname
+ msg.attach(part)
+
+ def get_call_filename(self, program, key, create=False):
+ """
+ Get the filename for the call testing information.
+ """
+ path = self.get_call_path(program, create=create)
+ fname = os.path.join(path, key + '.msg')
+ if not create and not os.path.isfile(fname):
+ raise IOError("Attempted to find call test data {}".format(key))
+ return fname
+
+ def get_program_name(self, program):
+ """Takes a program and returns a program name"""
+ if program == inkex.command.INKSCAPE_EXECUTABLE_NAME:
+ return 'inkscape'
+ return program
+
+ def get_call_path(self, program, create=True):
+ """Get where this program would store it's test data"""
+ command_dir = os.path.join(self.cmddir(), self.get_program_name(program))
+ if not os.path.isdir(command_dir):
+ if create:
+ os.makedirs(command_dir)
+ else:
+ raise IOError("A test is attempting to use an external program in a test:"\
+ " {}; but there is not a command data directory which should"\
+ " contain the results of the command here: {}"\
+ .format(program, command_dir))
+ return command_dir
+
+ def load_call(self, program, key, files):
+ """
+ Load the given call
+ """
+ fname = self.get_call_filename(program, key, create=False)
+ with open(fname, 'rb') as fhl:
+ msg = EmailParser().parsestr(fhl.read().decode('utf-8'))
+
+ stdout = None
+ for part in msg.walk():
+ if 'attachment' in part.get("Content-Disposition", ''):
+ base_name = part['Filename']
+ for out_file in files:
+ if out_file.endswith(base_name):
+ with open(out_file, 'wb') as fhl:
+ fhl.write(part.get_payload(decode=True))
+ part = None
+ if part is not None:
+ # Was not caught by any normal outputs, so we will
+ # save the file to EVERY tempdir in the hopes of
+ # hitting on of them.
+ for fdir in self.recorded_tempdirs:
+ if os.path.isdir(fdir):
+ with open(os.path.join(fdir, base_name), 'wb') as fhl:
+ fhl.write(part.get_payload(decode=True))
+ elif part.get_content_type() == "text/plain":
+ stdout = part.get_payload(decode=True)
+
+ return stdout
+
+ def save_call(self, program, key, stdout, files, msg, ext='output'): # pylint: disable=too-many-arguments
+ """
+ Saves the results from the call into a debug output file, the resulting files
+ should be a Mime msg file format with each attachment being one of the input
+ files as well as any stdin and arguments used in the call.
+ """
+ if stdout is not None and stdout.strip():
+ # The stdout is counted as the msg body here
+ msg.attach(MIMEText(stdout.decode('utf-8'), 'plain', 'utf-8'))
+
+ for fname in set(files):
+ if os.path.isfile(fname):
+ #print("SAVING FILE INTO MSG: {}".format(fname))
+ self.add_call_file(msg, fname)
+ else:
+ part = MIMEText("Missing File", 'plain', 'utf-8')
+ part.add_header('Filename', os.path.basename(fname))
+ msg.attach(part)
+
+ fname = self.get_call_filename(program, key, create=True) + '.' + ext
+ with open(fname, 'wb') as fhl:
+ fhl.write(msg.as_string().encode('utf-8'))
+
+ def save_key(self, program, key, keystr, ext='key'):
+ """Save the key file if we are debugging the key data"""
+ if os.environ.get('DEBUG_KEY'):
+ fname = self.get_call_filename(program, key, create=True) + '.' + ext
+ with open(fname, 'wb') as fhl:
+ fhl.write(keystr.encode('utf-8'))
diff --git a/share/extensions/inkex/tester/svg.py b/share/extensions/inkex/tester/svg.py
new file mode 100644
index 0000000..d7ef569
--- /dev/null
+++ b/share/extensions/inkex/tester/svg.py
@@ -0,0 +1,49 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens
+#
+# 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, USA.
+#
+"""
+SVG specific utilities for tests.
+"""
+
+from lxml import etree
+
+from inkex import SVG_PARSER
+
+def svg(svg_attrs=''):
+ """Returns xml etree based on a simple SVG element.
+
+ svg_attrs: A string containing attributes to add to the
+ root <svg> element of a minimal SVG document.
+ """
+ return etree.fromstring(str.encode(
+ '<?xml version="1.0" encoding="UTF-8" standalone="no"?>'
+ '<svg {}></svg>'.format(svg_attrs)), parser=SVG_PARSER)
+
+
+def uu_svg(user_unit):
+ """Same as svg, but takes a user unit for the new document.
+
+ It's based on the ratio between the SVG width and the viewBox width.
+ """
+ return svg('width="1{}" viewBox="0 0 1 1"'.format(user_unit))
+
+def svg_file(filename):
+ """Parse an svg file and return it's document root"""
+ with open(filename, 'r') as fhl:
+ doc = etree.parse(fhl, parser=SVG_PARSER)
+ return doc.getroot()
diff --git a/share/extensions/inkex/tester/word.py b/share/extensions/inkex/tester/word.py
new file mode 100644
index 0000000..cab7d04
--- /dev/null
+++ b/share/extensions/inkex/tester/word.py
@@ -0,0 +1,38 @@
+# coding=utf-8
+#
+# Unknown author
+#
+"""
+Generate words for testing.
+"""
+
+import string
+import random
+
+def word_generator(text_length):
+ """
+ Generate a word of text_length size
+ """
+ word = ""
+
+ for _ in range(0, text_length):
+ word += random.choice(string.ascii_lowercase + \
+ string.ascii_uppercase + \
+ string.digits + \
+ string.punctuation)
+
+ return word
+
+def sentencecase(word):
+ """Make a word standace case"""
+ word_new = ""
+ lower_letters = list(string.ascii_lowercase)
+ first = True
+ for letter in word:
+ if letter in lower_letters and first is True:
+ word_new += letter.upper()
+ first = False
+ else:
+ word_new += letter
+
+ return word_new
diff --git a/share/extensions/inkex/tester/xmldiff.py b/share/extensions/inkex/tester/xmldiff.py
new file mode 100644
index 0000000..17f43a4
--- /dev/null
+++ b/share/extensions/inkex/tester/xmldiff.py
@@ -0,0 +1,113 @@
+#
+# Copyright 2011 (c) Ian Bicking <ianb@colorstudy.com>
+# 2019 (c) Martin Owens <doctormo@gmail.com>
+#
+# Taken from http://formencode.org under the GPL compatible PSF License.
+# Modified to produce more output as a diff.
+#
+"""
+Allow two xml files/lxml etrees to be compared, returning their differences.
+"""
+import xml.etree.ElementTree as xml
+from io import BytesIO
+
+def text_compare(test1, test2):
+ """
+ Compare two text strings while allowing for '*' to match
+ anything on either lhs or rhs.
+ """
+ if not test1 and not test2:
+ return True
+ if test1 == '*' or test2 == '*':
+ return True
+ return (test1 or '').strip() == (test2 or '').strip()
+
+class DeltaLogger(list):
+ """A record keeper of the delta between two svg files"""
+ def append_tag(self, tag_a, tag_b):
+ """Record a tag difference"""
+ if tag_a:
+ tag_a = "<{}.../>".format(tag_a)
+ if tag_b:
+ tag_b = "<{}.../>".format(tag_b)
+ self.append((tag_a, tag_b))
+
+ def append_attr(self, attr, value_a, value_b):
+ """Record an attribute difference"""
+ def _prep(val):
+ if val:
+ if attr == 'd':
+ from inkex.paths import Path
+ return [attr] + Path(val).to_arrays()
+ return (attr, val)
+ return val
+ self.append((_prep(value_a), _prep(value_b)))
+
+ def append_text(self, text_a, text_b):
+ """Record a text difference"""
+ self.append((text_a, text_b))
+
+ def __bool__(self):
+ """Returns True if there's no log, i.e. the delta is clean"""
+ return not self.__len__()
+ __nonzero__ = __bool__
+
+ def __repr__(self):
+ if self:
+ return "No differences detected"
+ return "{} xml differences".format(len(self))
+
+def to_xml(data):
+ """Convert string or bytes to xml parsed root node"""
+ if isinstance(data, str):
+ data = data.encode('utf8')
+ if isinstance(data, bytes):
+ return xml.parse(BytesIO(data)).getroot()
+ return data
+
+def xmldiff(data1, data2):
+ """Create an xml difference, will modify the first xml structure with a diff"""
+ xml1, xml2 = to_xml(data1), to_xml(data2)
+ delta = DeltaLogger()
+ _xmldiff(xml1, xml2, delta)
+ return xml.tostring(xml1).decode('utf-8'), delta
+
+def _xmldiff(xml1, xml2, delta):
+ if xml1.tag != xml2.tag:
+ xml1.tag = '{}XXX{}'.format(xml1.tag, xml2.tag)
+ delta.append_tag(xml1.tag, xml2.tag)
+ for name, value in xml1.attrib.items():
+ if name not in xml2.attrib:
+ delta.append_attr(name, xml1.attrib[name], None)
+ xml1.attrib[name] += "XXX"
+ elif xml2.attrib.get(name) != value:
+ delta.append_attr(name, xml1.attrib.get(name), xml2.attrib.get(name))
+ xml1.attrib[name] = "{}XXX{}".format(xml1.attrib.get(name), xml2.attrib.get(name))
+ for name, value in xml2.attrib.items():
+ if name not in xml1.attrib:
+ delta.append_attr(name, None, value)
+ xml1.attrib[name] = "XXX" + value
+ if not text_compare(xml1.text, xml2.text):
+ delta.append_text(xml1.text, xml2.text)
+ xml1.text = "{}XXX{}".format(xml1.text, xml2.text)
+ if not text_compare(xml1.tail, xml2.tail):
+ delta.append_text(xml1.tail, xml2.tail)
+ xml1.tail = "{}XXX{}".format(xml1.tail, xml2.tail)
+
+ # Get children and pad with nulls
+ children_a = list(xml1)
+ children_b = list(xml2)
+ children_a += [None] * (len(children_a) - len(children_b))
+ children_b += [None] * (len(children_b) - len(children_a))
+
+ for child_a, child_b in zip(children_a, children_b):
+ if child_a is None: # child_b exists
+ child_c = child_b.clone()
+ delta.append_tag(child_c.tag, None)
+ child_c.tag = 'XXX' + child_c.tag
+ xml1.append(child_c)
+ elif child_b is None: # child_a exists
+ delta.append_tag(None, child_a.tag)
+ child_a.tag += 'XXX'
+ else:
+ _xmldiff(child_a, child_b, delta)
diff --git a/share/extensions/inkex/transforms.py b/share/extensions/inkex/transforms.py
new file mode 100644
index 0000000..89c8563
--- /dev/null
+++ b/share/extensions/inkex/transforms.py
@@ -0,0 +1,1084 @@
+# coding=utf-8
+#
+# Copyright (C) 2006 Jean-Francois Barraud, barraud@math.univ-lille1.fr
+# Copyright (C) 2010 Alvin Penner, penner@vaxxine.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.
+# barraud@math.univ-lille1.fr
+#
+# This code defines several functions to make handling of transform
+# attribute easier.
+#
+"""
+Provide transformation parsing to extensions
+"""
+
+import re
+import sys
+from decimal import Decimal
+from math import cos, radians, sin, sqrt, tan, fabs, atan2, hypot, pi
+
+try:
+ from math import isfinite
+except ImportError:
+ isfinite = lambda n: isinstance(n, (int, float)) and n not in (float('+inf'), float('-inf'))
+
+from .tween import interpcoord
+from .utils import strargs, KeyDict, PY3
+
+try:
+ from typing import overload, cast, List, Any, Callable, Generator, Iterator, Tuple, Union, Optional, Sequence # pylint: disable=unused-import
+
+ VectorLike = Union["ImmutableVector2d", Tuple[float, float]] # pylint: disable=invalid-name
+ MatrixLike = Union[str, Tuple[Tuple[float,float,float], Tuple[float,float,float]], Tuple[float,float,float,float,float,float], "Transform"]
+ BoundingIntervalArgs = Union['BoundingInterval', Tuple[float, float], float] # pylint: disable=invalid-name
+except ImportError:
+ overload = lambda x: x
+ cast = lambda x, y: y
+
+# All the names that get added to the inkex API itself.
+__all__ = (
+ 'BoundingBox',
+ 'DirectedLineSegment',
+ 'ImmutableVector2d',
+ 'Transform',
+ 'Vector2d',
+)
+
+if PY3:
+ unicode = str # pylint: disable=redefined-builtin,invalid-name
+
+# Old settings, supported because users click 'ok' without looking.
+XAN = KeyDict({'l': 'left', 'r': 'right', 'm': 'center_x'})
+YAN = KeyDict({'t': 'top', 'b': 'bottom', 'm': 'center_y'})
+# Anchoring objects with given directions (see inx options)
+CUSTOM_DIRECTION = {270: 'tb', 90: 'bt', 0: 'lr', 360: 'lr', 180: 'rl'}
+DIRECTION = ['tb', 'bt', 'lr', 'rl', 'ro', 'ri']
+
+
+class ImmutableVector2d(object):
+ _x = 0.0
+ _y = 0.0
+
+ x = property(lambda self: self._x) # type: property
+ y = property(lambda self: self._y) # type: property
+
+ @overload
+ def __init__(self):
+ # type: () -> None
+ pass
+
+ @overload
+ def __init__(self, v):
+ # type: (Union[VectorLike, str]) -> None
+ pass
+
+ @overload
+ def __init__(self, x, y):
+ # type: (float, float) -> None
+ pass
+
+ def __init__(self, *args):
+ if len(args) == 0:
+ x, y = 0.0, 0.0
+ elif len(args) == 1:
+ x, y = self._parse(args[0])
+ elif len(args) == 2:
+ x, y = map(float, args)
+ else:
+ raise ValueError("too many arguments")
+ self._x, self._y = float(x), float(y)
+
+ @staticmethod
+ def _parse(point):
+ # type: (Union[VectorLike, str]) -> Tuple[float, float]
+ if isinstance(point, ImmutableVector2d):
+ x, y = point.x, point.y
+ elif isinstance(point, (tuple, list)) and len(point) == 2:
+ x, y = map(float, point)
+ elif isinstance(point, str) and point.count(',') == 1:
+ x, y = map(float, point.split(','))
+ else:
+ raise ValueError("Can't parse {}".format(repr(point)))
+ return x, y
+
+ def __add__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(self.x + other.x, self.y + other.y)
+
+ def __radd__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(self.x + other.x, self.y + other.y)
+
+ def __sub__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(self.x - other.x, self.y - other.y)
+
+ def __rsub__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(-self.x + other.x, -self.y + other.y)
+
+ def __neg__(self):
+ # type: () -> Vector2d
+ return Vector2d(-self.x, -self.y)
+
+ def __pos__(self):
+ # type: () -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def __floordiv__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x / float(factor), self.y / float(factor))
+
+ def __truediv__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x / float(factor), self.y / float(factor))
+
+ def __div__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x / float(factor), self.y / float(factor))
+
+ def __mul__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x * factor, self.y * factor)
+
+ def __abs__(self):
+ # type: () -> float
+ return self.length
+
+ def __rmul__(self, factor):
+ # type: (float) -> VectorLike
+ return Vector2d(self.x * factor, self.y * factor)
+
+ def __repr__(self):
+ # type: () -> str
+ return "Vector2d({:.6g}, {:.6g})".format(self.x, self.y)
+
+ def __str__(self):
+ # type: () -> str
+ return "{:.6g}, {:.6g}".format(self.x, self.y)
+
+ def __iter__(self):
+ # type: () -> Generator[float, None, None]
+ yield self.x
+ yield self.y
+
+ def __len__(self):
+ # type: () -> int
+ return 2
+
+ def __getitem__(self, item):
+ # type: (int) -> float
+ return (self.x, self.y)[item]
+
+ def to_tuple(self):
+ # type : () -> Tuple[float, float]
+ return self.x, self.y
+
+ def dot(self, other):
+ # type: (VectorLike) -> float
+ other = Vector2d(other)
+ return self.x * other.x + self.y * other.y
+
+ def is_close(self, other, rtol=1e-5, atol=1e-8):
+ # type: (Union[VectorLike, str, Tuple[float,float]], float, float) -> float
+ other = Vector2d(other)
+ delta = (self - other).length
+ return delta < (atol + rtol * other.length)
+
+ @property
+ def length(self):
+ # type: () -> float
+ return sqrt(fabs(self.dot(self)))
+
+
+class Vector2d(ImmutableVector2d):
+ """
+ Represents an element of 2-dimensional Euclidean space
+ """
+
+ @ImmutableVector2d.x.setter
+ def x(self, value):
+ # type: (Union[float, int, str]) -> None
+ self._x = float(value)
+
+ @ImmutableVector2d.y.setter
+ def y(self, value):
+ # type: (Union[float, int, str]) -> None
+ self._y = float(value)
+
+ def __iadd__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ self.x += other.x
+ self.y += other.y
+ return self
+
+ def __isub__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ self.x -= other.x
+ self.y -= other.y
+ return self
+
+ def __imul__(self, factor):
+ # type: (float) -> Vector2d
+ self.x *= factor
+ self.y *= factor
+ return self
+
+ def __idiv__(self, factor):
+ # type: (float) -> Vector2d
+ self.x /= factor
+ self.y /= factor
+ return self
+
+ def __itruediv__(self, factor):
+ # type: (float) -> Vector2d
+ self.x /= factor
+ self.y /= factor
+ return self
+
+ def __ifloordiv__(self, factor):
+ # type: (float) -> Vector2d
+ self.x /= factor
+ self.y /= factor
+ return self
+
+ @overload
+ def assign(self, x, y):
+ # type: (float, float) -> None
+ pass
+
+ @overload
+ def assign(self, other):
+ # type: (VectorLike, str) -> None
+ pass
+
+ def assign(self, *args):
+ self.x, self.y = Vector2d(*args)
+
+
+
+class Transform(object):
+ """A transformation object which will always reduce to a matrix and can
+ then be used in combination with other transformations for reducing
+ finding a point and printing svg ready output.
+
+ Use with svg transform attribute input:
+
+ tr = Transform("scale(45, 32)")
+
+ Use with triad matrix input (internal representation):
+
+ tr = Transform(((1.0, 0.0, 0.0), (0.0, 1.0, 0.0)))
+
+ Use with hexad matrix input (i.e. svg matrix(...)):
+
+ tr = Transform((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
+
+ Once you have a transformation you can operate tr * tr to compose,
+ any of the above inputs are also valid operators for composing.
+ """
+ TRM = re.compile(r'(translate|scale|rotate|skewX|skewY|matrix)\s*\(([^)]*)\)\s*,?')
+ absolute_tolerance = 1e-5 # type: float
+
+
+ def __init__(
+ self,
+ matrix=None, # type: Optional[MatrixLike]
+ callback=None, # type: Optional[Callable[[Transform], Transform]]
+ **extra):
+ # type: (...) -> None
+ self.callback = None
+ self.matrix = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0))
+ if matrix is not None:
+ self._set_matrix(matrix)
+
+ self.add_kwargs(**extra)
+ # Set callback last, so it doesn't kick off just setting up the internal value
+ self.callback = callback
+
+ def _set_matrix(self, matrix):
+ # type: (MatrixLike) -> None
+ """Parse a given string as an svg transformation instruction."""
+ if isinstance(matrix, (str, unicode)):
+ for func, values in self.TRM.findall(matrix.strip()):
+ getattr(self, 'add_' + func.lower())(*strargs(values))
+ elif isinstance(matrix, Transform):
+ self.matrix = matrix.matrix
+ elif isinstance(matrix, (tuple, list)) and len(matrix) == 2:
+ row1 = matrix[0]
+ row2 = matrix[1]
+ if isinstance(row1, (tuple, list)) and isinstance(row2, (tuple, list)):
+ if len(row1) == 3 and len(row2) == 3:
+ row1 = cast("Tuple[float, float, float]", tuple(map(float, row1)))
+ row2 = cast("Tuple[float, float, float]", tuple(map(float, row2)))
+ self.matrix = row1, row2
+ else:
+ raise ValueError("Matrix '{}' is not a valid transformation matrix".format(matrix))
+ else:
+ raise ValueError("Matrix '{}' is not a valid transformation matrix".format(matrix))
+ elif isinstance(matrix, (list, tuple)) and len(matrix) == 6:
+ tmatrix = cast("Union[List[float], Tuple[float,float,float,float,float,float]]", matrix)
+ row1 = (float(tmatrix[0]), float(tmatrix[2]), float(tmatrix[4]))
+ row2 = (float(tmatrix[1]), float(tmatrix[3]), float(tmatrix[5]))
+ self.matrix = row1, row2
+ elif not isinstance(matrix, (list, tuple)):
+ raise ValueError("Invalid transform type: {}".format(type(matrix).__name__))
+ else:
+ raise ValueError("Matrix '{}' is not a valid transformation matrix".format(matrix))
+
+
+ # These provide quick access to the svg matrix:
+ #
+ # [ a, c, e ]
+ # [ b, d, f ]
+ #
+ a = property(lambda self: self.matrix[0][0]) # pylint: disable=invalid-name
+ b = property(lambda self: self.matrix[1][0]) # pylint: disable=invalid-name
+ c = property(lambda self: self.matrix[0][1]) # pylint: disable=invalid-name
+ d = property(lambda self: self.matrix[1][1]) # pylint: disable=invalid-name
+ e = property(lambda self: self.matrix[0][2]) # pylint: disable=invalid-name
+ f = property(lambda self: self.matrix[1][2]) # pylint: disable=invalid-name
+
+ def __bool__(self):
+ # type: () -> bool
+ return not self.__eq__(Transform())
+
+ __nonzero__ = __bool__
+
+ @overload
+ def add_matrix(self, a):
+ # type: (MatrixLike) -> None
+ pass
+
+ @overload
+ def add_matrix(self, a, b, c, d, e, f):
+ # type: (float, float, float, float, float, float) -> None
+ pass
+
+ @overload
+ def add_matrix(self, a, b):
+ # type: (Tuple[float, float, float], Tuple[float, float, float]) -> None
+ pass
+
+ def add_matrix(self, *args):
+ """Add matrix in order they appear in the svg hexad"""
+ if len(args) == 1:
+ self.__imul__(Transform(args[0]))
+ elif len(args) == 2 or len(args) == 6:
+ self.__imul__(Transform(args))
+ else:
+ raise ValueError("Invalid number of arguments {}".format(args))
+
+ def add_kwargs(self, **kwargs):
+ """Add translations, scales, rotations etc using key word arguments"""
+ for key, value in reversed(list(kwargs.items())):
+ func = getattr(self, 'add_' + key)
+ if isinstance(value, tuple):
+ func(*value)
+ elif value is not None:
+ func(value)
+
+ @overload
+ def add_translate(self, dr):
+ # type: (VectorLike) -> None
+ pass
+
+ @overload
+ def add_translate(self, tr_x, tr_y=0.0):
+ # type: (float, Optional[float]) -> None
+ pass
+
+ def add_translate(self, *args):
+ if len(args) == 1 and isinstance(args[0], (int, float)):
+ tr_x, tr_y = args[0], 0.0
+ else:
+ tr_x, tr_y = Vector2d(*args)
+ self.__imul__(((1.0, 0.0, tr_x), (0.0, 1.0, tr_y)))
+
+ def add_scale(self, sc_x, sc_y=None):
+ """Add scale to this transformation"""
+ sc_y = sc_x if sc_y is None else sc_y
+ self.__imul__(((sc_x, 0.0, 0.0), (0.0, sc_y, 0.0)))
+
+ @overload
+ def add_rotate(self, deg, center):
+ # type: (float, VectorLike) -> None
+ pass
+
+ @overload
+ def add_rotate(self, deg, center_x, center_y):
+ # type: (float, float, float) -> None
+ pass
+
+ @overload
+ def add_rotate(self, deg):
+ # type: (float) -> None
+ pass
+
+ @overload
+ def add_rotate(self, deg, a):
+ # type: (float, Union[VectorLike, str]) -> None
+ pass
+
+ @overload
+ def add_rotate(self, deg, a, b):
+ # type: (float, float, float) -> None
+ pass
+
+ def add_rotate(self, deg, *args):
+ """Add rotation to this transformation"""
+ center_x, center_y = Vector2d(*args)
+ _cos, _sin = cos(radians(deg)), sin(radians(deg))
+ self.__imul__(((_cos, -_sin, center_x), (_sin, _cos, center_y)))
+ self.__imul__(((1.0, 0.0, -center_x), (0.0, 1.0, -center_y)))
+
+ def add_skewx(self, deg):
+ # type: (float) -> None
+ """Add skew x to this transformation"""
+ self.__imul__(((1.0, tan(radians(deg)), 0.0), (0.0, 1.0, 0.0)))
+
+ def add_skewy(self, deg):
+ # type: (float) -> None
+ """Add skew y to this transformation"""
+ self.__imul__(((1.0, 0.0, 0.0), (tan(radians(deg)), 1.0, 0.0)))
+
+ def to_hexad(self):
+ # type: () -> Iterator[float]
+ """Returns the transform as a hexad matrix (used in svg)"""
+ return (val for lst in zip(*self.matrix) for val in lst)
+
+ def is_translate(self, exactly=False):
+ # type: (bool) -> bool
+ """Returns True if this transformation is ONLY translate"""
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return fabs(self.a - 1) <= tol and abs(self.d - 1) <= tol and fabs(self.b) <= tol and fabs(self.c) <= tol
+
+ def is_scale(self, exactly=False):
+ # type: (bool) -> bool
+ """Returns True if this transformation is ONLY scale"""
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return (fabs(self.e) <= tol and fabs(self.f) <= tol and
+ fabs(self.b) <= tol and fabs(self.c) <= tol)
+
+ def is_rotate(self, exactly=False):
+ # type: (bool) -> bool
+ """Returns True if this transformation is ONLY rotate"""
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return self._is_URT(exactly=exactly) and \
+ fabs(self.e) <= tol and fabs(self.f) <= tol and fabs(self.a ** 2 + self.b ** 2 - 1) <= tol
+
+ def rotation_degrees(self):
+ # type: () -> float
+ """Return the amount of rotation in this transform"""
+ if not self._is_URT(exactly=False):
+ raise ValueError("Rotation angle is undefined for non-uniformly scaled or skewed matrices")
+ return atan2(self.b, self.a) * 180 / pi
+
+ def __str__(self):
+ # type: () -> str
+ """Format the given matrix into a string representation for svg"""
+ hexad = tuple(self.to_hexad())
+ if self.is_translate():
+ if not self:
+ return ""
+ return "translate({:.6g}, {:.6g})".format(self.e, self.f)
+ elif self.is_scale():
+ return "scale({:.6g}, {:.6g})".format(self.a, self.d)
+ elif self.is_rotate():
+ return "rotate({:.6g})".format(self.rotation_degrees())
+ return "matrix({})".format(" ".join(format(var, '.6g') for var in hexad))
+
+ def __repr__(self):
+ # type: () -> str
+ """String representation of this object"""
+ return "{}((({}), ({})))".format(
+ type(self).__name__,
+ ', '.join(format(var, '.6g') for var in self.matrix[0]),
+ ', '.join(format(var, '.6g') for var in self.matrix[1]))
+
+ def __eq__(self, matrix):
+ # typing this requires writing a proof for mypy that matrix is really
+ # MatrixLike
+ """Test if this transformation is equal to the given matrix"""
+ if isinstance(matrix, (str, tuple, list, Transform)):
+ val = all(fabs(l - r) <= self.absolute_tolerance
+ for l, r in zip(self.to_hexad(), Transform(matrix).to_hexad()))
+ else:
+ val = False
+ return val
+
+ def __mul__(self, matrix):
+ # type: (MatrixLike) -> Transform
+ """Combine this transform's internal matrix with the given matrix"""
+ # Conform the input to a known quantity (and convert if needed)
+ other = Transform(matrix)
+ # Return a transformation as the combined result
+ return Transform((
+ self.a * other.a + self.c * other.b,
+ self.b * other.a + self.d * other.b,
+ self.a * other.c + self.c * other.d,
+ self.b * other.c + self.d * other.d,
+ self.a * other.e + self.c * other.f + self.e,
+ self.b * other.e + self.d * other.f + self.f))
+
+ def __imul__(self, matrix):
+ # type: (MatrixLike) -> Transform
+ """In place multiplication of transform matrices"""
+ self.matrix = (self * matrix).matrix
+ if self.callback is not None:
+ self.callback(self)
+ return self
+
+ def __neg__(self):
+ # type: () -> Transform
+ """Returns an inverted transformation"""
+ det = (self.a * self.d) - (self.c * self.b)
+ # invert the rotation/scaling part
+ new_a = self.d / det
+ new_d = self.a / det
+ new_c = -self.c / det
+ new_b = -self.b / det
+ # invert the translational part
+ new_e = -(new_a * self.e + new_c * self.f)
+ new_f = -(new_b * self.e + new_d * self.f)
+ return Transform((new_a, new_b, new_c, new_d, new_e, new_f))
+
+ def apply_to_point(self, point):
+ # type: (VectorLike) -> Vector2d
+ """Transform a tuple (X, Y)"""
+ if isinstance(point, str):
+ raise ValueError("Will not transform string '{}'".format(point))
+ point = Vector2d(point)
+ return Vector2d(self.a * point.x + self.c * point.y + self.e,
+ self.b * point.x + self.d * point.y + self.f)
+
+ def _is_URT(self, exactly=False):
+ # type: (bool) -> bool
+ """
+ Checks that transformation can be decomposed into product of
+ Uniform scale (U), Rotation around origin (R) and translation (T)
+
+ :return: decomposition as U*R*T is possible
+ """
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return (fabs(self.a - self.d) <= tol) and (fabs(self.b + self.c) <= tol)
+
+ def interpolate(self, other, fraction):
+ # type: (Transform, float) -> Transform
+ """Interpolate with another Transform."""
+ return Transform((
+ interpcoord(self.a, other.a, fraction),
+ interpcoord(self.b, other.b, fraction),
+ interpcoord(self.c, other.c, fraction),
+ interpcoord(self.d, other.d, fraction),
+ interpcoord(self.e, other.e, fraction),
+ interpcoord(self.f, other.f, fraction)))
+
+
+class BoundingInterval(object): # pylint: disable=too-few-public-methods
+ """A pair of numbers that represent the minimum and maximum values."""
+
+ @overload
+ def __init__(self, other=None):
+ # type: (Optional[BoundingInterval]) -> None
+ pass
+
+ @overload
+ def __init__(self, pair):
+ # type: (Tuple[float, float]) -> None
+ pass
+
+ @overload
+ def __init__(self, value):
+ # type: (float) -> None
+ pass
+
+ @overload
+ def __init__(self, x, y):
+ # type: (float, float) -> None
+ pass
+
+ def __init__(self, x=None, y=None):
+ if y is not None:
+ if isinstance(x, (int, float, Decimal)) and isinstance(y, (int, float, Decimal)):
+ self.minimum = x
+ self.maximum = y
+ else:
+ raise ValueError("Not a number for scaling: {} ({},{})"
+ .format(str((x, y)), type(x).__name__, type(y).__name__))
+
+ else:
+ value = x
+ if value is None:
+ # identity for addition, zero for intersection
+ self.minimum, self.maximum = float('+inf'), float('-inf')
+ elif isinstance(value, BoundingInterval):
+ self.minimum = value.minimum
+ self.maximum = value.maximum
+ elif isinstance(value, (tuple, list)) and len(value) == 2:
+ self.minimum, self.maximum = min(value), max(value)
+ elif isinstance(value, (int, float, Decimal)):
+ self.minimum = self.maximum = value
+ else:
+ raise ValueError("Not a number for scaling: {} ({})"
+ .format(str(value), type(value).__name__))
+
+ def __bool__(self):
+ # type: () -> bool
+ return (isfinite(self.minimum) and isfinite(self.maximum))
+
+ __nonzero__ = __bool__
+
+ def __neg__(self):
+ # type: () -> BoundingInterval
+ return BoundingInterval((-self.maximum, -self.minimum))
+
+ def __add__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ """Calculate the bounding interval that covers both given bounding intervals"""
+ new = BoundingInterval(self)
+ if other is not None:
+ new += other
+ return new
+
+ def __iadd__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ other = BoundingInterval(other)
+ self.minimum = min((self.minimum, other.minimum))
+ self.maximum = max((self.maximum, other.maximum))
+ return self
+
+ def __radd__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ if other is None:
+ return BoundingInterval(self)
+ return self + other
+
+ def __and__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ """Calculate the bounding interval where both given bounding intervals overlap"""
+ new = BoundingInterval(self)
+ if other is not None:
+ new &= other
+ return new
+
+ def __iand__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ other = BoundingInterval(other)
+ self.minimum = max((self.minimum, other.minimum))
+ self.maximum = min((self.maximum, other.maximum))
+ if self.minimum > self.maximum:
+ self.minimum, self.maximum = float('+inf'), float('-inf')
+ return self
+
+ def __rand__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ if other is None:
+ return BoundingInterval(self)
+ return self & other
+
+ def __mul__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ new = BoundingInterval(self)
+ if other is not None:
+ new *= other
+ return new
+
+ def __imul__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ self.minimum *= other
+ self.maximum *= other
+ return self
+
+ def __iter__(self):
+ # type: () -> Generator[BoundingInterval, None, None]
+ yield self.minimum
+ yield self.maximum
+
+ def __eq__(self, other):
+ # type (object) -> bool
+ return tuple(self) == tuple(BoundingInterval(other))
+
+ def __contains__(self, value):
+ # type: (float) -> bool
+ return self.minimum <= value <= self.maximum
+
+ def __repr__(self):
+ # type: () -> str
+ return "BoundingInterval({}, {})".format(self.minimum, self.maximum)
+
+ @property
+ def center(self):
+ # type: () -> float
+ """Pick the middle of the line"""
+ return self.minimum + ((self.maximum - self.minimum) / 2)
+
+ @property
+ def size(self):
+ # type: () -> float
+ """Return the size difference minimum and maximum"""
+ return self.maximum - self.minimum
+
+
+class BoundingBox(object): # pylint: disable=too-few-public-methods
+ """
+ Some functions to compute a rough bbox of a given list of objects.
+
+ BoundingBox(other)
+ BoundingBox(x, y)
+ BoundingBox((x1, x2), (y1, y2))
+ """
+
+ width = property(lambda self: self.x.size)
+ height = property(lambda self: self.y.size)
+ top = property(lambda self: self.y.minimum)
+ left = property(lambda self: self.x.minimum)
+ bottom = property(lambda self: self.y.maximum)
+ right = property(lambda self: self.x.maximum)
+ center_x = property(lambda self: self.x.center)
+ center_y = property(lambda self: self.y.center)
+
+ @overload
+ def __init__(self, other=None):
+ # type: (Optional[BoundingBox]) -> None
+ pass
+
+ @overload
+ def __init__(self, x, y):
+ # type: (BoundingIntervalArgs, BoundingIntervalArgs) -> None
+ pass
+
+ def __init__(self, x=None, y=None):
+ if y is None:
+ if x is None:
+ # identity for addition, zero for intersection
+ pass
+ elif isinstance(x, BoundingBox):
+ x, y = x.x, x.y
+ else:
+ raise ValueError("Not a number for scaling: {} ({})"
+ .format(str(x), type(x).__name__))
+ self.x = BoundingInterval(x)
+ self.y = BoundingInterval(y)
+
+ def __bool__(self):
+ # type: () -> bool
+ return bool(self.x) and bool(self.y)
+
+ __nonzero__ = __bool__
+
+ def __neg__(self):
+ # type: () -> BoundingBox
+ return BoundingBox(-self.x, -self.y)
+
+ def __add__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ """Calculate the bounding box that covers both given bounding boxes"""
+ new = BoundingBox(self)
+ new += BoundingBox(other)
+ return new
+
+ def __iadd__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ other = BoundingBox(other)
+ self.x += other.x
+ self.y += other.y
+ return self
+
+ def __radd__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ return self + other
+
+ def __and__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ """Calculate the bounding box where both given bounding boxes overlap"""
+ new = BoundingBox(self)
+ new &= BoundingBox(other)
+ return new
+
+ def __iand__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ new = BoundingBox(self)
+ other = BoundingBox(other)
+ self.x = self.x & other.x
+ self.y = self.y & other.y
+ if not self.x or not self.y:
+ self.x, self.y = BoundingInterval(), BoundingInterval()
+ return self
+
+ def __rand__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ return self & other
+
+ def __mul__(self, factor):
+ # type: (float) -> BoundingBox
+ new = BoundingBox(self)
+ new *= factor
+ return new
+
+ def __imul__(self, factor):
+ # type: (float) -> BoundingBox
+ self.x *= factor
+ self.y *= factor
+ return self
+
+ def __eq__(self, other):
+ # type (object) -> bool
+ if isinstance(other, BoundingBox):
+ return tuple(self) == tuple(other)
+ return False
+
+ def __iter__(self):
+ # type: () -> Generator[BoundingBox, None, None]
+ yield self.x
+ yield self.y
+
+ @property
+ def minimum(self):
+ # type: () -> Vector2d
+ """Return the minimum x,y coords"""
+ return Vector2d(self.x.minimum, self.y.minimum)
+
+ @property
+ def maximum(self):
+ # type: () -> Vector2d
+ """Return the maximum x,y coords"""
+ return Vector2d(self.x.maximum, self.y.maximum)
+
+ def __repr__(self):
+ # type: () -> str
+ return "BoundingBox({},{})".format(tuple(self.x), tuple(self.y))
+
+ @property
+ def center(self):
+ # type: () -> Vector2d
+ """Returns the middle of the bounding box"""
+ return Vector2d(self.x.center, self.y.center)
+
+ def get_anchor(self, xanchor, yanchor, direction=0, selbox=None):
+ # type: (str, str, Union[int, str], Optional[BoundingBox]) -> float
+ """Calls get_distance with the given anchor options"""
+ return self.anchor_distance(
+ getattr(self, XAN[xanchor]),
+ getattr(self, YAN[yanchor]),
+ direction=direction,
+ selbox=selbox)
+
+ @staticmethod
+ def anchor_distance(x, y, direction=0, selbox=None):
+ # type: (float, float, Union[int, str], Optional[BoundingBox]) -> float
+ """Using the x,y returns a single sortable value based on direction and angle
+
+ direction - int (custom angle), tb/bt (top/bottom), lr/rl (left/right), ri/ro (radial)
+ selbox - The bounding box of the whole selection for radial anchors
+ """
+ rot = 0.0
+ if isinstance(direction, int): # Angle
+ if direction not in CUSTOM_DIRECTION:
+ return hypot(x, y) * (cos(radians(-direction) - atan2(y, x)))
+ direction = CUSTOM_DIRECTION[direction]
+
+ if direction in ('ro', 'ri'):
+ if selbox is None:
+ raise ValueError("Radial distance not available without selection bounding box")
+ rot = hypot(selbox.x.center - x, selbox.y.center - y)
+
+ return [y, -y, x, -x, rot, -rot][DIRECTION.index(direction)]
+
+
+class DirectedLineSegment(object):
+ """
+ A directed line segment
+
+ DirectedLineSegment(((x0, y0), (x1, y1)))
+ """
+
+ start = Vector2d() # start point of segment
+ end = Vector2d() # end point of segment
+
+ x0 = property(lambda self: self.start.x) # pylint: disable=invalid-name
+ y0 = property(lambda self: self.start.y) # pylint: disable=invalid-name
+ x1 = property(lambda self: self.end.x)
+ y1 = property(lambda self: self.end.y)
+ dx = property(lambda self: self.x1 - self.x0) # pylint: disable=invalid-name
+ dy = property(lambda self: self.y1 - self.y0) # pylint: disable=invalid-name
+
+ @overload
+ def __init__(self):
+ # type: () -> None
+ pass
+
+ @overload
+ def __init__(self, other):
+ # type: (DirectedLineSegment) -> None
+ pass
+
+ @overload
+ def __init__(self, start, end):
+ # type: (VectorLike, VectorLike) -> None
+ pass
+
+ def __init__(self, *args):
+ if not args: # overload 0
+ start, end = Vector2d(), Vector2d()
+ elif len(args) == 1: # overload 1
+ other, = args
+ start, end = other.start, other.end
+ elif len(args) == 2: # overload 2
+ start, end = args
+ else:
+ raise ValueError("DirectedLineSegment() can't be constructed from {}".format(args))
+
+ self.start = Vector2d(start)
+ self.end = Vector2d(end)
+
+ def __eq__(self, other):
+ # type: (object) -> bool
+ if isinstance(other, (tuple, DirectedLineSegment)):
+ return tuple(self) == tuple(other)
+ return False
+
+ def __iter__(self):
+ # type: () -> Generator[DirectedLineSegment, None, None]
+ yield self.x0
+ yield self.x1
+ yield self.y0
+ yield self.y1
+
+ @property
+ def length(self):
+ # type: () -> float
+ """Get the length from the top left to the bottom right of the line"""
+ return sqrt((self.dx ** 2) + (self.dy ** 2))
+
+ @property
+ def angle(self):
+ # type: () -> float
+ """Get the angle of the line created by this segment"""
+ return pi * (atan2(self.dy, self.dx)) / 180
+
+ def distance_to_point(self, x, y):
+ # type: (float, float) -> Union[DirectedLineSegment, Optional[float]]
+ """Get the distance to the given point (x, y)"""
+ segment2 = DirectedLineSegment(self.start, (x, y))
+ dot2 = segment2.dot(self)
+ if dot2 <= 0:
+ return DirectedLineSegment((x, y), self.start).length
+ if self.dot(self) <= dot2:
+ return DirectedLineSegment((x, y), self.end).length
+ return self.perp_distance(x, y)
+
+ def perp_distance(self, x, y):
+ # type: (float, float) -> Optional[float]
+ """Perpendicular distance to the given point"""
+ if self.length == 0:
+ return None
+ return fabs((self.dx * (self.y0 - y)) - ((self.x0 - x) * self.dy)) / self.length
+
+ def dot(self, other):
+ # type: (DirectedLineSegment) -> float
+ """Get the dot product with the segment with another"""
+ return self.dx * other.dx + self.dy * other.dy
+
+ def point_at_ratio(self, ratio):
+ # type: (float) -> Tuple[float, float]
+ """Get the point at the given ratio along the line"""
+ return self.x0 + ratio * self.dx, self.y0 + ratio * self.dy
+
+ def point_at_length(self, length):
+ # type: (float) -> Tuple[float, float]
+ """Get the point as the length along the line"""
+ return self.point_at_ratio(length / self.length)
+
+ def parallel(self, x, y):
+ # type: (float, float) -> DirectedLineSegment
+ """Create parallel Segment"""
+ return DirectedLineSegment((x + self.dx, y + self.dy), (x, y))
+
+ def intersect(self, other):
+ # type: (DirectedLineSegment) -> Optional[Vector2d]
+ """Get the intersection between two segments"""
+ other = DirectedLineSegment(other)
+ denom = (other.dy * self.dx) - (other.dx * self.dy)
+ num = (other.dx * (self.y0 - other.y0)) - (other.dy * (self.x0 - other.x0))
+ # num2 = (self.width * (self.top - other.top)) - (self.height * (self.left - other.left))
+
+ if denom != 0:
+ return Vector2d(
+ self.x0 + ((num / denom) * self.dx),
+ self.y0 + ((num / denom) * self.dy)
+ )
+ return None
+
+ def __repr__(self):
+ # type: () -> str
+ return "DirectedLineSegment(({0.start}), ({0.end}))".format(self)
+
+
+def cubic_extrema(py0, py1, py2, py3):
+ # type: (float, float, float, float) -> Tuple[float, float]
+ """Returns the extreme value, given a set of bezier coordinates"""
+
+ atol = 1e-9
+ cmin, cmax = min(py0, py3), max(py0, py3)
+ pd1 = py1 - py0
+ pd2 = py2 - py1
+ pd3 = py3 - py2
+
+ def _is_bigger(point):
+ if (point > 0) and (point < 1):
+ pyx = py0 * (1 - point) * (1 - point) * (1 - point) + \
+ 3 * py1 * point * (1 - point) * (1 - point) + \
+ 3 * py2 * point * point * (1 - point) + \
+ py3 * point * point * point
+ return min(cmin, pyx), max(cmax, pyx)
+ return cmin, cmax
+
+ if fabs(pd1 - 2 * pd2 + pd3) > atol:
+ if pd2 * pd2 > pd1 * pd3:
+ pds = sqrt(pd2 * pd2 - pd1 * pd3)
+ cmin, cmax = _is_bigger((pd1 - pd2 + pds) / (pd1 - 2 * pd2 + pd3))
+ cmin, cmax = _is_bigger((pd1 - pd2 - pds) / (pd1 - 2 * pd2 + pd3))
+
+ elif fabs(pd2 - pd1) > atol:
+ cmin, cmax = _is_bigger(-pd1 / (2 * (pd2 - pd1)))
+
+ return cmin, cmax
+
+
+def quadratic_extrema(py0, py1, py2):
+ # type: (float, float, float) -> Tuple[float, float]
+ atol = 1e-9
+ cmin, cmax = min(py0, py2), max(py0, py2)
+
+ def _is_bigger(point):
+ if (point > 0) and (point < 1):
+ pyx = py0 * (1 - point) * (1 - point) + \
+ 2 * py1 * point * (1 - point) + \
+ py2 * point * point
+ return min(cmin, pyx), max(cmax, pyx)
+ return cmin, cmax
+
+ if fabs(py0 + py2 - 2 * py1) > atol:
+ cmin, cmax = _is_bigger((py0 - py1) / (py0 + py2 - 2 * py1))
+
+ return cmin, cmax
diff --git a/share/extensions/inkex/turtle.py b/share/extensions/inkex/turtle.py
new file mode 100644
index 0000000..cee7dad
--- /dev/null
+++ b/share/extensions/inkex/turtle.py
@@ -0,0 +1,120 @@
+# coding=utf-8
+#
+# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
+#
+# 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.
+#
+
+import math
+import random
+
+
+class pTurtle(object):
+ """A Python path turtle"""
+
+ def __init__(self, home=(0, 0)):
+ self.__home = [home[0], home[1]]
+ self.__pos = self.__home[:]
+ self.__heading = -90
+ self.__path = ""
+ self.__draw = True
+ self.__new = True
+
+ def forward(self, mag):
+ self.setpos((self.__pos[0] + math.cos(math.radians(self.__heading)) * mag,
+ self.__pos[1] + math.sin(math.radians(self.__heading)) * mag))
+
+ def backward(self, mag):
+ self.setpos((self.__pos[0] - math.cos(math.radians(self.__heading)) * mag,
+ self.__pos[1] - math.sin(math.radians(self.__heading)) * mag))
+
+ def right(self, deg):
+ self.__heading -= deg
+
+ def left(self, deg):
+ self.__heading += deg
+
+ def penup(self):
+ self.__draw = False
+ self.__new = False
+
+ def pendown(self):
+ if not self.__draw:
+ self.__new = True
+ self.__draw = True
+
+ def pentoggle(self):
+ if self.__draw:
+ self.penup()
+ else:
+ self.pendown()
+
+ def home(self):
+ self.setpos(self.__home)
+
+ def clean(self):
+ self.__path = ''
+
+ def clear(self):
+ self.clean()
+ self.home()
+
+ def setpos(self, arg):
+ if self.__new:
+ self.__path += "M" + ",".join([str(i) for i in self.__pos])
+ self.__new = False
+ self.__pos = arg
+ if self.__draw:
+ self.__path += "L" + ",".join([str(i) for i in self.__pos])
+
+ def getpos(self):
+ return self.__pos[:]
+
+ def setheading(self, deg):
+ self.__heading = deg
+
+ def getheading(self):
+ return self.__heading
+
+ def sethome(self, arg):
+ self.__home = list(arg)
+
+ def getPath(self):
+ return self.__path
+
+ def rtree(self, size, minimum, pt=False):
+ if size < minimum:
+ return
+ self.fd(size)
+ turn = random.uniform(20, 40)
+ self.lt(turn)
+ self.rtree(size * random.uniform(0.5, 0.9), minimum, pt)
+ self.rt(turn)
+ turn = random.uniform(20, 40)
+ self.rt(turn)
+ self.rtree(size * random.uniform(0.5, 0.9), minimum, pt)
+ self.lt(turn)
+ if pt:
+ self.pu()
+ self.bk(size)
+ if pt:
+ self.pd()
+
+ fd = forward
+ bk = backward
+ rt = right
+ lt = left
+ pu = penup
+ pd = pendown
diff --git a/share/extensions/inkex/tween.py b/share/extensions/inkex/tween.py
new file mode 100644
index 0000000..31c165b
--- /dev/null
+++ b/share/extensions/inkex/tween.py
@@ -0,0 +1,79 @@
+# coding=utf-8
+#
+# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
+#
+# 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.
+#
+
+import math
+
+from bisect import bisect_left
+from .utils import X, Y
+from .units import convert_unit, parse_unit, render_unit
+
+try:
+ from typing import Union, Tuple, List, TypeVar, Callable, overload
+ hasTypes = True
+ Value = TypeVar('Value')
+ Number = TypeVar('Number', int, float)
+except ImportError:
+ pass
+
+
+def interpcoord(
+ coord_a, # type: Number
+ coord_b, # type: Number
+ time # type: float
+):
+ # type: (...) -> float
+ """Interpolate single coordinate by the amount of time"""
+ return coord_a + ((coord_b - coord_a) * time)
+
+
+def interp(
+ positions, # type: List[float]
+ values, # type: List[Value]
+ newpositions, # type: List[float]
+ func # type: (Callable[[Value, Value, float], Value])
+):
+ # type: (...) -> List[Value]
+ """Interpolate list with arbitrary interpolation function."""
+ newvalues = []
+ positions = list(map(float, positions))
+ newpositions = list(map(float, newpositions))
+ for pos in newpositions:
+ idxl = max(0, bisect_left(positions, pos) - 1)
+ idxr = min(len(positions)-1, idxl + 1)
+ fraction = (pos - positions[idxl]) / (positions[idxr] - positions[idxl])
+ vall = values[idxl]
+ valr = values[idxr]
+ newval = func(vall, valr, fraction)
+ newvalues.append(newval)
+ return newvalues
+
+
+def interppoints(point1, point2, time):
+ # type: (Tuple[float, float], Tuple[float, float], float) -> Tuple[float, float]
+ """Interpolate coordinate points by amount of time"""
+ return (interpcoord(point1[X], point2[X], time), interpcoord(point1[Y], point2[Y], time))
+
+
+def interpunit(start, end, fraction):
+ # type: (str, str, float) -> str
+ """Interpolate float attributes with unit."""
+ # moved here so we can call 'unittouu'
+ sp, unit = parse_unit(start)
+ ep = convert_unit(end, unit)
+ return render_unit(interpcoord(sp, ep, fraction), unit)
diff --git a/share/extensions/inkex/units.py b/share/extensions/inkex/units.py
new file mode 100644
index 0000000..cd69a39
--- /dev/null
+++ b/share/extensions/inkex/units.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (c) Aaron Spike <aaron@ekips.org>
+# Aurélio A. Heckert <aurium(a)gmail.com>
+# Bulia Byak <buliabyak@users.sf.net>
+# Nicolas Dufour, nicoduf@yahoo.fr
+# Peter J. R. Moulder <pjrm@users.sourceforge.net>
+# 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.
+#
+"""
+Convert to and from various units and find the closest matching unit.
+"""
+
+import re
+
+# a dictionary of unit to user unit conversion factors
+CONVERSIONS = {
+ 'in': 96.0,
+ 'pt': 1.3333333333333333,
+ 'px': 1.0,
+ 'mm': 3.779527559055118,
+ 'cm': 37.79527559055118,
+ 'm': 3779.527559055118,
+ 'km': 3779527.559055118,
+ 'Q': 0.94488188976378,
+ 'pc': 16.0,
+ 'yd': 3456.0,
+ 'ft': 1152.0,
+ '': 1.0, # Default px
+}
+
+# allowed unit types, including percentages, relative units, and others
+# that are not suitable for direct conversion to a length.
+# Note that this is _not_ an exhaustive list of allowed unit types.
+UNITS = ['in', 'pt', 'px', 'mm', 'cm', 'm', 'km', 'Q', 'pc', 'yd', 'ft', '',\
+ '%', 'em', 'ex', 'ch', 'rem', 'vw', 'vh', 'vmin', 'vmax',\
+ 'deg', 'grad', 'rad', 'turn', 's', 'ms', 'Hz', 'kHz',\
+ 'dpi', 'dpcm', 'dppx']
+
+UNIT_MATCH = re.compile(r'({})'.format('|'.join(UNITS)))
+NUMBER_MATCH = re.compile(r'(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)')
+BOTH_MATCH = re.compile(r'^\s*{}\s*{}\s*$'.format(NUMBER_MATCH.pattern, UNIT_MATCH.pattern))
+
+
+def parse_unit(value, default_unit='px', default_value=None):
+ """
+ Takes a value such as 55.32px and returns (55.32, 'px')
+ Returns default (None) if no match can be found
+ """
+ ret = BOTH_MATCH.match(str(value))
+ if ret:
+ return float(ret.groups()[0]), ret.groups()[-1] or default_unit
+ return (default_value, default_unit) if default_value is not None else None
+
+
+def are_near_relative(point_a, point_b, eps=0.01):
+ """Return true if the points are near to eps"""
+ return (point_a - point_b <= point_a * eps) and (point_a - point_b >= -point_a * eps)
+
+
+def discover_unit(value, viewbox, default='px'):
+ """Attempt to detect the unit being used based on the viewbox"""
+ # Default 100px when width can't be parsed
+ (value, unit) = parse_unit(value, default_value=100.0)
+ if unit not in CONVERSIONS:
+ return default
+ this_factor = CONVERSIONS[unit] * value / viewbox
+
+ # try to find the svgunitfactor in the list of units known. If we don't find something, ...
+ for unit, unit_factor in CONVERSIONS.items():
+ if unit != '':
+ # allow 1% error in factor
+ if are_near_relative(this_factor, unit_factor, eps=0.01):
+ return unit
+ return default
+
+
+def convert_unit(value, to_unit):
+ """Returns userunits given a string representation of units in another system"""
+ value, from_unit = parse_unit(value, default_value=0.0)
+ if from_unit in CONVERSIONS and to_unit in CONVERSIONS:
+ return value * CONVERSIONS[from_unit] / CONVERSIONS.get(to_unit, CONVERSIONS['px'])
+ return 0.0
+
+
+def render_unit(value, unit):
+ """Checks and then renders a number with its unit"""
+ try:
+ if isinstance(value, str):
+ (value, unit) = parse_unit(value, default_unit=unit)
+ return "{:.6g}{:s}".format(value, unit)
+ except TypeError:
+ return ''
diff --git a/share/extensions/inkex/utils.py b/share/extensions/inkex/utils.py
new file mode 100644
index 0000000..dea5308
--- /dev/null
+++ b/share/extensions/inkex/utils.py
@@ -0,0 +1,290 @@
+# coding=utf-8
+#
+# Copyright (C) 2010 Nick Drobchenko, nick@cnc-club.ru
+# Copyright (C) 2005 Aaron Spike, aaron@ekips.org
+#
+# 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.
+#
+"""
+Basic common utility functions for calculated things
+"""
+from __future__ import absolute_import, print_function, unicode_literals
+
+import re
+import os
+import sys
+import shutil
+
+from itertools import tee
+from collections import defaultdict
+from argparse import ArgumentTypeError
+
+# When python2 support is gone, enable tempfile's version
+# from tempfile import TemporaryDirectory
+
+# All the names that get added to the inkex API itself.
+__all__ = ('AbortExtension', 'DependencyError', 'Boolean', 'errormsg', 'addNS', 'NSS')
+
+ABORT_STATUS = -5
+
+(X, Y) = range(2)
+PY3 = sys.version_info[0] == 3
+
+if PY3:
+ unicode = str # pylint: disable=redefined-builtin,invalid-name
+
+# 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())
+
+class KeyDict(dict):
+ """
+ A normal dictionary, except asking for anything not in the dictionary
+ always returns the key itself. This is used for translation dictionaries.
+ """
+ def __getitem__(self, key):
+ try:
+ return super(KeyDict, self).__getitem__(key)
+ except KeyError:
+ return key
+
+class TemporaryDirectory(object): # pylint: disable=too-few-public-methods
+ """Tiny replacement for python3's version."""
+ def __init__(self, suffix="", prefix="tmp"):
+ self.suffix = suffix
+ self.prefix = prefix
+ self.path = None
+ def __enter__(self):
+ from tempfile import mkdtemp
+ self.path = mkdtemp(self.suffix, self.prefix, None)
+ return self.path
+ def __exit__(self, exc, value, traceback):
+ if os.path.isdir(self.path):
+ shutil.rmtree(self.path)
+
+def Boolean(value):
+ """ArgParser function to turn a boolean string into a python boolean"""
+ if value.upper() == 'TRUE':
+ return True
+ elif value.upper() == 'FALSE':
+ return False
+ return None
+
+def to_bytes(content):
+ """Ensures the content is bytes"""
+ if isinstance(content, bytes):
+ return content
+ return str(content).encode("utf8")
+
+def debug(what):
+ """Print debug message if debugging is switched on"""
+ errormsg(what)
+ return what
+
+def do_nothing(*args, **kwargs): # pylint: disable=unused-argument
+ """A blank function to do nothing"""
+ pass
+
+def errormsg(msg):
+ """Intended for end-user-visible error messages.
+
+ (Currently just writes to stderr with an appended newline, but could do
+ something better in future: e.g. could add markup to distinguish error
+ messages from status messages or debugging output.)
+
+ Note that this should always be combined with translation:
+
+ import inkex
+ ...
+ inkex.errormsg(_("This extension requires two selected paths."))
+ """
+ try:
+ sys.stderr.write(msg)
+ except TypeError:
+ sys.stderr.write(unicode(msg))
+ except UnicodeEncodeError:
+ # Python 2:
+ # Fallback for cases where sys.stderr.encoding is not Unicode.
+ # Python 3:
+ # This will not work as write() does not accept byte strings, but AFAIK
+ # we should never reach this point as the default error handler is
+ # 'backslashreplace'.
+
+ # This will be None by default if stderr is piped, so use ASCII as a
+ # last resort.
+ encoding = sys.stderr.encoding or 'ascii'
+ sys.stderr.write(msg.encode(encoding, 'backslashreplace'))
+
+ # Write '\n' separately to avoid dealing with different string types.
+ sys.stderr.write('\n')
+
+
+class AbortExtension(Exception):
+ """Raised to print a message to the user without backtrace"""
+
+ def __init__(self, message=""):
+ self.message = message
+
+ def write(self):
+ """write the error message out to the user"""
+ errormsg(self.message)
+
+
+class DependencyError(NotImplementedError):
+ """Raised when we need an external python module that isn't available"""
+
+class FragmentError(Exception):
+ """Raised when trying to do rooty things on an xml fragment"""
+
+class InitSubClassPy3(type):
+ """Provide a poly-fill for python3 __init_subclass__()"""
+ def __init__(cls, name, bases, dct):
+ if '__metaclass__' not in cls.__dict__:
+ if hasattr(cls, '__init_subclass__'):
+ cls.__init_subclass__()
+ super(InitSubClassPy3, cls).__init__(name, bases, dct)
+
+def to(kind): # pylint: disable=invalid-name
+ """
+ Decorator which will turn a generator into a list, tuple or other object type.
+ """
+
+ def _inner(call):
+ def _outer(*args, **kw):
+ return kind(call(*args, **kw))
+
+ return _outer
+
+ return _inner
+
+
+def strargs(string, kind=float):
+ """Returns a list of floats from a string with commas or space separators,
+ also splits at -(minus) signs by adding a space in front of the - sign
+ """
+ return [kind(val) for val in string.replace(',', ' ').replace('-', ' -').replace('e ', 'e').split()]
+
+
+def addNS(tag, ns=None): # pylint: disable=invalid-name
+ """Add a known namespace to a name for use with lxml"""
+ if tag.startswith('{') and ns:
+ _, tag = removeNS(tag)
+ if not tag.startswith('{'):
+ tag = tag.replace('__', ':')
+ if ':' in tag:
+ (ns, tag) = tag.rsplit(':', 1)
+ if ns in NSS:
+ ns = NSS[ns]
+ if ns is not None:
+ return "{%s}%s" % (ns, tag)
+ return tag
+
+
+def removeNS(name): # pylint: disable=invalid-name
+ """The reverse of addNS, finds any namespace and returns tuple (ns, tag)"""
+ if name[0] == '{':
+ (url, tag) = name[1:].split('}', 1)
+ return SSN.get(url, 'svg'), tag
+ if ':' in name:
+ return name.rsplit(':', 1)
+ return 'svg', name
+
+def splitNS(name): # pylint: disable=invalid-name
+ """Like removeNS, but returns a url instead of a prefix"""
+ (prefix, tag) = removeNS(name)
+ return (NSS[prefix], tag)
+
+class classproperty(object): # pylint: disable=invalid-name, too-few-public-methods
+ """Combine classmethod and property decorators"""
+
+ def __init__(self, func):
+ self.func = func
+
+ def __get__(self, obj, owner):
+ return self.func(owner)
+
+
+def filename_arg(name):
+ """Existing file to read or option used in script arguments"""
+ filename = os.path.abspath(os.path.expanduser(name))
+ if not os.path.isfile(filename):
+ raise ArgumentTypeError("File not found: {}".format(name))
+ return filename
+
+def pairwise(iterable, start=True):
+ "Iterate over a list with overlapping pairs (see itertools recipes)"
+ first, then = tee(iterable)
+ starter = [(None, next(then, None))]
+ if not start:
+ starter = []
+ return starter + list(zip(first, then))
+
+class CloningVat(object):
+ """
+ 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 = set([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)
+
+def fullmatch(regex, string, flags=0):
+ """Emulate python-3.4 re.fullmatch()."""
+ if hasattr(regex, 'fullmatch'):
+ return regex.fullmatch(string, flags)
+ if hasattr(regex, 'pattern'):
+ regex = regex.pattern
+ return re.match("(?:" + regex + r")\Z", string, flags=flags)