diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-27 16:29:01 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-27 16:29:01 +0000 |
commit | 35a96bde514a8897f6f0fcc41c5833bf63df2e2a (patch) | |
tree | 657d15a03cc46bd099fc2c6546a7a4ad43815d9f /share/extensions/inkex | |
parent | Initial commit. (diff) | |
download | inkscape-35a96bde514a8897f6f0fcc41c5833bf63df2e2a.tar.xz inkscape-35a96bde514a8897f6f0fcc41c5833bf63df2e2a.zip |
Adding upstream version 1.0.2.upstream/1.0.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'share/extensions/inkex')
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 Binary files differnew file mode 100644 index 0000000..3e3e90f --- /dev/null +++ b/share/extensions/inkex/__pycache__/__init__.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/base.cpython-39.pyc b/share/extensions/inkex/__pycache__/base.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..0fad73c --- /dev/null +++ b/share/extensions/inkex/__pycache__/base.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/bezier.cpython-39.pyc b/share/extensions/inkex/__pycache__/bezier.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..c29a13e --- /dev/null +++ b/share/extensions/inkex/__pycache__/bezier.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/colors.cpython-39.pyc b/share/extensions/inkex/__pycache__/colors.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..54725f1 --- /dev/null +++ b/share/extensions/inkex/__pycache__/colors.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/command.cpython-39.pyc b/share/extensions/inkex/__pycache__/command.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..544fd56 --- /dev/null +++ b/share/extensions/inkex/__pycache__/command.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/deprecated.cpython-39.pyc b/share/extensions/inkex/__pycache__/deprecated.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..778ef50 --- /dev/null +++ b/share/extensions/inkex/__pycache__/deprecated.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/extensions.cpython-39.pyc b/share/extensions/inkex/__pycache__/extensions.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..07ba857 --- /dev/null +++ b/share/extensions/inkex/__pycache__/extensions.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/inx.cpython-39.pyc b/share/extensions/inkex/__pycache__/inx.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..3ffedb9 --- /dev/null +++ b/share/extensions/inkex/__pycache__/inx.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/localization.cpython-39.pyc b/share/extensions/inkex/__pycache__/localization.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..6bfaac4 --- /dev/null +++ b/share/extensions/inkex/__pycache__/localization.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/paths.cpython-39.pyc b/share/extensions/inkex/__pycache__/paths.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..c47438d --- /dev/null +++ b/share/extensions/inkex/__pycache__/paths.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/ports.cpython-39.pyc b/share/extensions/inkex/__pycache__/ports.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..039e6d9 --- /dev/null +++ b/share/extensions/inkex/__pycache__/ports.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/styles.cpython-39.pyc b/share/extensions/inkex/__pycache__/styles.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..d99135a --- /dev/null +++ b/share/extensions/inkex/__pycache__/styles.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/transforms.cpython-39.pyc b/share/extensions/inkex/__pycache__/transforms.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..3be6188 --- /dev/null +++ b/share/extensions/inkex/__pycache__/transforms.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/turtle.cpython-39.pyc b/share/extensions/inkex/__pycache__/turtle.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..c2f622d --- /dev/null +++ b/share/extensions/inkex/__pycache__/turtle.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/tween.cpython-39.pyc b/share/extensions/inkex/__pycache__/tween.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..0199ec1 --- /dev/null +++ b/share/extensions/inkex/__pycache__/tween.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/units.cpython-39.pyc b/share/extensions/inkex/__pycache__/units.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..2619f10 --- /dev/null +++ b/share/extensions/inkex/__pycache__/units.cpython-39.pyc diff --git a/share/extensions/inkex/__pycache__/utils.cpython-39.pyc b/share/extensions/inkex/__pycache__/utils.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..a095603 --- /dev/null +++ b/share/extensions/inkex/__pycache__/utils.cpython-39.pyc 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 Binary files differnew file mode 100644 index 0000000..db8842a --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/bezmisc.cpython-39.pyc diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..26ec318 --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/cspsubdiv.cpython-39.pyc diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..0e063ba --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/cubicsuperpath.cpython-39.pyc diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..b00b896 --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/ffgeom.cpython-39.pyc diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..d85fe9d --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/simplepath.cpython-39.pyc diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..17bbfa5 --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/simplestyle.cpython-39.pyc diff --git a/share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pyc b/share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..f28b4e5 --- /dev/null +++ b/share/extensions/inkex/deprecated-simple/__pycache__/simpletransform.cpython-39.pyc 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 Binary files differnew file mode 100644 index 0000000..718ece7 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/__init__.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..06dd762 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_base.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..a1d3a70 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_filters.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..35305d8 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_groups.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..1c2849c --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_image.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..807de7b --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_meta.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..270e082 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_polygons.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..6272318 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_selected.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..800e778 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_svg.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..1e898f2 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_text.cpython-39.pyc diff --git a/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc b/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..7fb1511 --- /dev/null +++ b/share/extensions/inkex/elements/__pycache__/_use.cpython-39.pyc diff --git a/share/extensions/inkex/elements/_base.py b/share/extensions/inkex/elements/_base.py new file mode 100644 index 0000000..e922908 --- /dev/null +++ b/share/extensions/inkex/elements/_base.py @@ -0,0 +1,514 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Sergei Izmailov <sergei.a.izmailov@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Provide extra utility to each svg element type specific to its type. + +This is useful for having a common interface for each element which can +give path, transform, and property access easily. +""" + +from collections import defaultdict +from copy import deepcopy +from lxml import etree + +from ..paths import Path +from ..styles import Style, AttrFallbackStyle, Classes +from ..transforms import Transform, BoundingBox +from ..utils import PY3, NSS, addNS, removeNS, splitNS, FragmentError +from ..utils import InitSubClassPy3 + +try: + from typing import overload, DefaultDict, Type, Any, List, Tuple, Union, Optional # pylint: disable=unused-import +except ImportError: + overload = lambda x: x + +class NodeBasedLookup(etree.PythonElementClassLookup): + """ + We choose what kind of Elements we should return for each element, providing useful + SVG based API to our extensions system. + """ + # (ns,tag) -> list(cls) ; ascending priority + lookup_table = defaultdict(list) # type: DefaultDict[str, List[Any]] + + @classmethod + def register_class(cls, klass): + """Register the given class using it's attached tag name""" + cls.lookup_table[splitNS(klass.tag_name)].append(klass) + + def lookup(self, doc, element): # pylint: disable=unused-argument + """Lookup called by lxml when assigning elements their object class""" + try: + for cls in reversed(self.lookup_table[splitNS(element.tag)]): + if cls._is_class_element(element): # pylint: disable=protected-access + return cls + except TypeError: + # Handle non-element proxies case + # The documentation implies that it's not possible + # Didn't found a reliable way to check whether proxy corresponds to element or not + # Look like lxml issue to me. + # The troubling element is "<!--Comment-->" + return None + return BaseElement + + +SVG_PARSER = etree.XMLParser(huge_tree=True, strip_cdata=False) +SVG_PARSER.set_element_class_lookup(NodeBasedLookup()) + +def load_svg(stream): + """Load SVG file using the SVG_PARSER""" + if (isinstance(stream, str) and stream.lstrip().startswith('<'))\ + or (isinstance(stream, bytes) and stream.lstrip().startswith(b'<')): + return etree.ElementTree(etree.fromstring(stream, parser=SVG_PARSER)) + return etree.parse(stream, parser=SVG_PARSER) + +class BaseElement(etree.ElementBase): + """Provide automatic namespaces to all calls""" + # Included for python2 support (this branch is for 1.0.x only) + __metaclass__ = InitSubClassPy3 + @classmethod + def __init_subclass__(cls): + if cls.tag_name: + NodeBasedLookup.register_class(cls) + + @classmethod + def _is_class_element(cls, el): # type: (etree.Element) -> bool + """Hook to do more restrictive check in addition to (ns,tag) match""" + return True + + tag_name = '' + + @property + def TAG(self): # pylint: disable=invalid-name + """Return the tag_name without NS""" + if not self.tag_name: + return removeNS(super(etree.ElementBase, self).tag)[-1] + return removeNS(self.tag_name)[-1] + + @classmethod + def new(cls, *children, **attrs): + """Create a new element, converting attrs values to strings.""" + obj = cls(*children) + obj.update(**attrs) + return obj + + NAMESPACE = property(lambda self: splitNS(self.tag_name)[0]) + PARSER = SVG_PARSER + WRAPPED_ATTRS = ( + # (prop_name, [optional: attr_name], cls) + ('transform', Transform), + ('style', Style), + ('classes', 'class', Classes), + ) # type: Tuple[Tuple[Any, ...], ...] + + # We do this because python2 and python3 have different ways + # of combining two dictionaries that are incompatible. + # This allows us to update these with inheritance. + @property + def wrapped_attrs(self): + """Map attributes to property name and wrapper class""" + return dict([(row[-2], (row[0], row[-1])) for row in self.WRAPPED_ATTRS]) + + @property + def wrapped_props(self): + """Map properties to attribute name and wrapper class""" + return dict([(row[0], (row[-2], row[-1])) for row in self.WRAPPED_ATTRS]) + + typename = property(lambda self: type(self).__name__) + xml_path = property(lambda self: self.getroottree().getpath(self)) + + def __getattr__(self, name): + """Get the attribute, but load it if it is not available yet""" + if name in self.wrapped_props: + (attr, cls) = self.wrapped_props[name] + # The reason we do this here and not in _init is because lxml + # is inconsistant about when elements are initialised. + # So we make this a lazy property. + def _set_attr(new_item): + if new_item: + self.set(attr, str(new_item)) + else: + self.attrib.pop(attr, None) # pylint: disable=no-member + + # pylint: disable=no-member + value = cls(self.attrib.get(attr, None), callback=_set_attr) + setattr(self, name, value) + return value + raise AttributeError("Can't find attribute {}.{}".format(self.typename, name)) + + def __setattr__(self, name, value): + """Set the attribute, update it if needed""" + if name in self.wrapped_props: + (attr, cls) = self.wrapped_props[name] + # Don't call self.set or self.get (infinate loop) + if value: + if not isinstance(value, cls): + value = cls(value) + self.attrib[attr] = str(value) + else: + self.attrib.pop(attr, None) # pylint: disable=no-member + else: + super(BaseElement, self).__setattr__(name, value) + + def get(self, attr, default=None): + """Get element attribute named, with addNS support.""" + if attr in self.wrapped_attrs: + (prop, _) = self.wrapped_attrs[attr] + value = getattr(self, prop, None) + # We check the boolean nature of the value, because empty + # transformations and style attributes are equiv to not-existing + ret = str(value) if value else (default or None) + return ret + return super(BaseElement, self).get(addNS(attr), default) + + def set(self, attr, value): + """Set element attribute named, with addNS support""" + if attr in self.wrapped_attrs: + # Always keep the local wrapped class up to date. + (prop, cls) = self.wrapped_attrs[attr] + setattr(self, prop, cls(value)) + value = getattr(self, prop) + if not value: + return + if value is None: + self.attrib.pop(addNS(attr), None) # pylint: disable=no-member + else: + value = str(value) if PY3 else unicode(value) # pylint: disable=undefined-variable + super(BaseElement, self).set(addNS(attr), value) + + def update(self, **kwargs): + """ + Update element attributes using keyword arguments + + Note: double underscore is used as namespace separator, + i.e. "namespace__attr" argument name will be treated as "namespace:attr" + + :param kwargs: dict with name=value pairs + :return: self + """ + for name, value in kwargs.items(): + self.set(name, value) + return self + + def pop(self, attr, default=None): + """Delete/remove the element attribute named, with addNS support.""" + if attr in self.wrapped_attrs: + # Always keep the local wrapped class up to date. + (prop, cls) = self.wrapped_attrs[attr] + value = getattr(self, prop) + setattr(self, prop, cls(None)) + return value + return self.attrib.pop(addNS(attr), default) # pylint: disable=no-member + + def add(self, *children): + """ + Like append, but will do multiple children and will return + children or only child + """ + for child in children: + self.append(child) + return children if len(children) != 1 else children[0] + + def tostring(self): + """Return this element as it would appear in an svg document""" + # This kind of hack is pure maddness, but etree provides very little + # in the way of fragment printing, prefering to always output valid xml + from ..base import SvgOutputMixin + svg = SvgOutputMixin.get_template(width=0, height=0).getroot() + svg.append(self.copy()) + return svg.tostring().split(b'>\n ', 1)[-1][:-6] + + def description(self, text): + """Set the desc element with text""" + from ._meta import Desc + desc = self.add(Desc()) + desc.text = text + + def set_random_id(self, prefix=None, size=4, backlinks=False): + """Sets the id attribute if it is not already set.""" + prefix = str(self) if prefix is None else prefix + self.set_id(self.root.get_unique_id(prefix, size=size), backlinks=backlinks) + + def set_random_ids(self, prefix=None, levels=-1, backlinks=False): + """Same as set_random_id, but will apply also to children""" + self.set_random_id(prefix=prefix, backlinks=backlinks) + if levels != 0: + for child in self: + if hasattr(child, 'set_random_ids'): + child.set_random_ids(prefix=prefix, levels=levels-1, backlinks=backlinks) + + def get_id(self): + """Get the id for the element, will set a new unique id if not set""" + if 'id' not in self.attrib: + self.set_random_id(self.TAG) + return self.get('id') + + def set_id(self, new_id, backlinks=False): + """Set the id and update backlinks to xlink and style urls if needed""" + old_id = self.get('id', None) + self.set('id', new_id) + if backlinks and old_id: + for elem in self.root.getElementsByHref(old_id): + elem.href = self + for elem in self.root.getElementsByStyleUrl(old_id): + elem.style.update_urls(old_id, new_id) + + @property + def root(self): + """Get the root document element from any element descendent""" + if self.getparent() is not None: + return self.getparent().root + from ._svg import SvgDocumentElement + if not isinstance(self, SvgDocumentElement): + raise FragmentError("Element fragment does not have a document root!") + return self + + def get_or_create(self, xpath, nodeclass, prepend=False): + """Get or create the given xpath, pre/append new node if not found.""" + node = self.findone(xpath) + if node is None: + node = nodeclass() + if prepend: + self.insert(0, node) + else: + self.append(node) + return node + + def descendants(self): + """Walks the element tree and yields all elements, parent first""" + from ._selected import ElementList + return ElementList(self.root, self._descendants()) + + def _descendants(self): + yield self + for child in self: + if hasattr(child, '_descendants'): + for item in child._descendants(): # pylint: disable=protected-access + yield item + + def ancestors(self, elem=None, stop_at=()): + """ + Walk the parents and yield all the ancestor elements, parent first + + If elem is provided, it will stop at the last common ancestor. + If stop_at is provided, it will stop at the first parent that is in this list. + """ + from ._selected import ElementList + return ElementList(self.root, self._ancestors(elem=elem, stop_at=stop_at)) + + def _ancestors(self, elem, stop_at): + if isinstance(elem, BaseElement): + stop_at = list(elem.ancestors().values()) + parent = self.getparent() + if parent is not None: + yield parent + if parent not in stop_at: + for item in parent._ancestors(elem=elem, stop_at=stop_at): # pylint: disable=protected-access + yield item + + def backlinks(self, *types): + """Get elements which link back to this element, like ancestors but via xlinks""" + if not types or isinstance(self, types): + yield self + my_id = self.get('id') + if my_id is not None: + elems = list(self.root.getElementsByHref(my_id)) \ + + list(self.root.getElementsByStyleUrl(my_id)) + for elem in elems: + if hasattr(elem, 'backlinks'): + for child in elem.backlinks(*types): + yield child + + def xpath(self, pattern, namespaces=NSS): # pylint: disable=dangerous-default-value + """Wrap xpath call and add svg namespaces""" + return super(BaseElement, self).xpath(pattern, namespaces=namespaces) + + def findall(self, pattern, namespaces=NSS): # pylint: disable=dangerous-default-value + """Wrap findall call and add svg namespaces""" + return super(BaseElement, self).findall(pattern, namespaces=namespaces) + + def findone(self, xpath): + """Gets a single element from the given xpath or returns None""" + el_list = self.xpath(xpath) + return el_list[0] if el_list else None + + def delete(self): + """Delete this node from it's parent node""" + if self.getparent() is not None: + self.getparent().remove(self) + + def remove_all(self, *types): + """Remove all children or child types""" + for child in self: + if not types or isinstance(child, types): + self.remove(child) + + def replace_with(self, elem): + """Replace this element with the given element""" + self.addnext(elem) + if not elem.get('id') and self.get('id'): + elem.set('id', self.get('id')) + if not elem.label and self.label: + elem.label = self.label + self.delete() + return elem + + def copy(self): + """Make a copy of the element and return it""" + elem = deepcopy(self) + elem.set('id', None) + return elem + + def duplicate(self): + """Like copy(), but the copy stays in the tree and sets a random id""" + elem = self.copy() + self.addnext(elem) + elem.set_random_id() + return elem + + def __str__(self): + # We would do more here, but lxml is VERY unpleseant when it comes to + # namespaces, basically over printing details and providing no + # supression mechanisms to turn off xml's over engineering. + return str(self.tag).split('}')[-1] + + @property + def href(self): + """Returns the referred-to element if available""" + ref = self.get('xlink:href') + if not ref: + return None + return self.root.getElementById(ref.strip('#')) + + @href.setter + def href(self, elem): + """Set the href object""" + if isinstance(elem, BaseElement): + elem = elem.get_id() + self.set('xlink:href', '#' + elem) + + def fallback_style(self, move=False): + """Get styles falling back to element attributes""" + return AttrFallbackStyle(self, move=move) + + @property + def label(self): + """Returns the inkscape label""" + return self.get('inkscape:label', None) + + label = label.setter(lambda self, value: self.set('inkscape:label', str(value))) # type: ignore + + +class ShapeElement(BaseElement): + """Elements which have a visible representation on the canvas""" + @property + def path(self): + """Gets the outline or path of the element, this may be a simple bounding box""" + return Path(self.get_path()) + + @path.setter + def path(self, path): + self.set_path(path) + + @property + def clip(self): + """Gets the clip path element (if any)""" + ref = self.get('clip-path') + if not ref: + return None + return self.root.getElementById(ref) + + @clip.setter + def clip(self, elem): + self.set('clip-path', 'url(#' + elem.get_id() + ')') + + def get_path(self): + """Generate a path for this object which can inform the bounding box""" + raise NotImplementedError("Path should be provided by svg elem {}.".format(self.typename)) + + def set_path(self, path): + """Set the path for this object (if possible)""" + raise AttributeError( + "Path can not be set on this element: {} <- {}.".format(self.typename, path)) + + def to_path_element(self): + """Replace this element with a path element""" + from ._polygons import PathElement + elem = PathElement() + elem.path = self.path + elem.style = self.effective_style() + elem.transform = self.transform + return elem + + def composed_transform(self, other=None): + """Calculate every transform down to the other element + if none specified the transform is to the root document element""" + parent = self.getparent() + if parent is not None and isinstance(parent, ShapeElement): + return parent.composed_transform() * self.transform + return self.transform + + def composed_style(self): + """Calculate the final styles applied to this element""" + parent = self.getparent() + if parent is not None and isinstance(parent, ShapeElement): + return parent.composed_style() + self.style + return self.style + + def cascaded_style(self): + """Add all cascaded styles, do not write to this Style object""" + ret = Style() + for style in self.root.stylesheets.lookup(self.get('id')): + ret += style + return ret + self.style + + def effective_style(self): + """Without parent styles, what is the effective style is""" + return self.style + + def bounding_box(self, transform=None): + # type: (Optional[Transform]) -> Optional[BoundingBox] + """BoundingBox of the shape (adjusted for its clip path if applicable)""" + shape_box = self.shape_box(transform) + clip = self.clip + if clip is None or shape_box is None: + return shape_box + return shape_box & clip.bounding_box(Transform(transform) * self.transform) + + def shape_box(self, transform=None): + # type: (Optional[Transform]) -> Optional[BoundingBox] + """BoundingBox of the unclipped shape""" + path = self.path.to_absolute() + if transform is True: + path = path.transform(self.composed_transform()) + else: + path = path.transform(self.transform) + if transform: # apply extra transformation + path = path.transform(transform) + return path.bounding_box() + + def is_visible(self): + """Returns false if the css says this object is invisible""" + if self.style.get('display', '') == 'none': + return False + if not float(self.style.get('opacity', 1.0)): + return False + return True diff --git a/share/extensions/inkex/elements/_filters.py b/share/extensions/inkex/elements/_filters.py new file mode 100644 index 0000000..ff6883f --- /dev/null +++ b/share/extensions/inkex/elements/_filters.py @@ -0,0 +1,276 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Sergei Izmailov <sergei.a.izmailov@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Element interface for patterns, filters, gradients and path effects. +""" + +from lxml import etree +from copy import deepcopy + +from ..utils import addNS +from ..transforms import Transform +from ..tween import interpcoord, interp +from ..units import convert_unit + +from ..styles import Style +from ._base import BaseElement + + +try: + from typing import overload, Iterable, List, Tuple, Union, Optional # pylint: disable=unused-import +except ImportError: + overload = lambda x: x + + +class Filter(BaseElement): + """A filter (usually in defs)""" + tag_name = 'filter' + + def add_primitive(self, fe_type, **args): + """Create a filter primitive with the given arguments""" + elem = etree.SubElement(self, addNS(fe_type, 'svg')) + elem.update(**args) + return elem + + class Primitive(BaseElement): + pass + + class Blend(Primitive): + tag_name = 'feBlend' + + class ColorMatrix(Primitive): + tag_name = 'feColorMatrix' + + class ComponentTransfer(Primitive): + tag_name = 'feComponentTransfer' + + class Composite(Primitive): + tag_name = 'feComposite' + + class ConvolveMatrix(Primitive): + tag_name = 'feConvolveMatrix' + + class DiffuseLighting(Primitive): + tag_name = 'feDiffuseLighting' + + class DisplacementMap(Primitive): + tag_name = 'feDisplacementMap' + + class Flood(Primitive): + tag_name = 'feFlood' + + class GaussianBlur(Primitive): + tag_name = 'feGaussianBlur' + + class Image(Primitive): + tag_name = 'feImage' + + class Merge(Primitive): + tag_name = 'feMerge' + + class Morphology(Primitive): + tag_name = 'feMorphology' + + class Offset(Primitive): + tag_name = 'feOffset' + + class SpecularLighting(Primitive): + tag_name = 'feSpecularLighting' + + class Tile(Primitive): + tag_name = 'feTile' + + class Turbulence(Primitive): + tag_name = 'feTurbulence' + + +class Stop(BaseElement): + tag_name = 'stop' + + @property + def offset(self): + # type: () -> float + return self.get('offset') + + @offset.setter + def offset(self, number): + self.set('offset', number) + + def interpolate(self, other, fraction): + newstop = Stop() + newstop.style = self.style.interpolate(other.style, fraction) + newstop.offset = interpcoord(float(self.offset), float(other.offset), fraction) + return newstop + + +class Pattern(BaseElement): + """Pattern element which is used in the def to control repeating fills""" + tag_name = 'pattern' + WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (('patternTransform', Transform),) + + +class Gradient(BaseElement): + """A gradient instruction usually in the defs""" + WRAPPED_ATTRS = BaseElement.WRAPPED_ATTRS + (('gradientTransform', Transform),) + + orientation_attributes = () # type: Tuple[str, ...] + + @property + def stops(self): + """Return an ordered list of own or linked stop nodes""" + gradcolor = self.href if isinstance(self.href, LinearGradient) else self + return sorted([child for child in gradcolor if isinstance(child, Stop)] + , key=lambda x: float(x.offset)) + + @property + def stop_offsets(self): + # type: () -> List[float] + """Return a list of own or linked stop offsets""" + return [child.offset for child in self.stops] + + @property + def stop_styles(self): # type: () -> List[Style] + """Return a list of own or linked offset styles""" + return [child.style for child in self.stops] + + def remove_orientation(self): + """Remove all orientation attributes from this element""" + for attr in self.orientation_attributes: + self.pop(attr) + + def interpolate(self, other, fraction): # type: (LinearGradient, float) -> LinearGradient + """Interpolate with another gradient.""" + if self.tag_name != other.tag_name: + return self + newgrad = self.copy() + + # interpolate transforms + newtransform = self.gradientTransform.interpolate(other.gradientTransform, fraction) + newgrad.gradientTransform = newtransform + + # interpolate orientation + for attr in self.orientation_attributes: + newattr = interpcoord(convert_unit(self.get(attr), 'px'), convert_unit(other.get(attr), 'px'), fraction) + newgrad.set(attr, newattr) + + # interpolate stops + if self.href is not None and self.href is other.href: + # both gradients link to the same stops + pass + else: + # gradients might have different stops + newoffsets = sorted(self.stop_offsets + other.stop_offsets[1:-1]) + func = lambda x,y,f: x.interpolate(y, f) + sstops = interp(self.stop_offsets, list(self.stops), newoffsets, func) + ostops = interp(other.stop_offsets, list(other.stops), newoffsets, func) + newstops = [s1.interpolate(s2, fraction) for s1, s2 in zip(sstops, ostops)] + newgrad.remove_all(Stop) + newgrad.add(*newstops) + return newgrad + + def stops_and_orientation(self): + """Return a copy of all the stops in this gradient""" + stops = self.copy() + stops.remove_orientation() + orientation = self.copy() + orientation.remove_all(Stop) + return stops, orientation + + +class LinearGradient(Gradient): + tag_name = 'linearGradient' + orientation_attributes = ('x1', 'y1', 'x2', 'y2') + + def apply_transform(self): # type: () -> None + """Apply transform to orientation points and set it to identity.""" + trans = self.pop('gradientTransform') + p1 = (convert_unit(self.get('x1'), 'px'), convert_unit(self.get('y1'), 'px')) + p2 = (convert_unit(self.get('x2'), 'px'), convert_unit(self.get('y2'), 'px')) + p1t = trans.apply_to_point(p1) + p2t = trans.apply_to_point(p2) + self.update(x1=p1t[0], y1=p1t[1], x2=p2t[0], y2=p2t[1]) + + +class RadialGradient(Gradient): + tag_name = 'radialGradient' + orientation_attributes = ('cx', 'cy', 'fx', 'fy', 'r') + + def apply_transform(self): # type: () -> None + """Apply transform to orientation points and set it to identity.""" + trans = self.pop('gradientTransform') + p1 = (convert_unit(self.get('cx'), 'px'), convert_unit(self.get('cy'), 'px')) + p2 = (convert_unit(self.get('fx'), 'px'), convert_unit(self.get('fy'), 'px')) + p1t = trans.apply_to_point(p1) + p2t = trans.apply_to_point(p2) + self.update(cx=p1t[0], cy=p1t[1], fx=p2t[0], fy=p2t[1]) + +class PathEffect(BaseElement): + """Inkscape LPE element""" + tag_name = 'inkscape:path-effect' + + +class MeshGradient(Gradient): + """Usable MeshGradient XML base class""" + tag_name = 'meshgradient' + + @classmethod + def new_mesh(cls, pos=None, rows=1, cols=1, autocollect=True): + """Return skeleton of 1x1 meshgradient definition.""" + # initial point + if pos is None or len(pos) != 2: + pos = [0.0, 0.0] + # create nested elements for rows x cols mesh + meshgradient = cls() + for _ in range(rows): + meshrow = meshgradient.add(MeshRow()) + for _ in range(cols): + meshrow.append(MeshPatch()) + # set meshgradient attributes + meshgradient.set('gradientUnits', 'userSpaceOnUse') + meshgradient.set('x', pos[0]) + meshgradient.set('y', pos[1]) + if autocollect: + meshgradient.set('inkscape:collect', 'always') + return meshgradient + + +class MeshRow(BaseElement): + """Each row of a mesh gradient""" + tag_name = 'meshrow' + +class MeshPatch(BaseElement): + """Each column or 'patch' in a mesh gradient""" + tag_name = 'meshpatch' + + def stops(self, edges, colors): + """Add or edit meshpatch stops with path and stop-color.""" + # iterate stops based on number of edges (path data) + for i, edge in enumerate(edges): + if i < len(self): + stop = self[i] + else: + stop = self.add(Stop()) + + # set edge path data + stop.set('path', str(edge)) + # set stop color + stop.style['stop-color'] = str(colors[i % 2]) diff --git a/share/extensions/inkex/elements/_groups.py b/share/extensions/inkex/elements/_groups.py new file mode 100644 index 0000000..52bd073 --- /dev/null +++ b/share/extensions/inkex/elements/_groups.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Sergei Izmailov <sergei.a.izmailov@gmail.com> +# Ryan Jarvis <ryan@shopboxretail.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Interface for all group based elements such as Groups, Use, Markers etc. +""" + +from lxml import etree # pylint: disable=unused-import + +from ..paths import Path +from ..utils import addNS +from ..transforms import Transform + +from ._base import ShapeElement + +try: + from typing import Optional # pylint: disable=unused-import +except ImportError: + pass + +class GroupBase(ShapeElement): + """Base Group element""" + def get_path(self): + ret = Path() + for child in self: + if isinstance(child, ShapeElement): + ret += child.path.transform(child.transform) + return ret + + def shape_box(self, transform=None): + bbox = None + effective_transform = Transform(transform) * self.transform + for child in self: + if isinstance(child, ShapeElement): + child_bbox = child.bounding_box(transform=effective_transform) + if child_bbox is not None: + bbox += child_bbox + return bbox + + +class Group(GroupBase): + """Any group element (layer or regular group)""" + tag_name = 'g' + + @classmethod + def new(cls, label, *children, **attrs): + attrs['inkscape:label'] = label + return super(Group, cls).new(*children, **attrs) + + + def effective_style(self): + """A blend of each child's style mixed together (last child wins)""" + style = self.style + for child in self: + style.update(child.effective_style()) + return style + + @property + def groupmode(self): + """Return the type of group this is""" + return self.get('inkscape:groupmode', 'group') + + +class Layer(Group): + """Inkscape extension of svg:g""" + + def _init(self): + self.set('inkscape:groupmode', 'layer') + + @classmethod + def _is_class_element(cls, el): + # type: (etree.Element) -> bool + return el.attrib.get(addNS('inkscape:groupmode'), None) == "layer" + + +class Anchor(GroupBase): + """An anchor or link tag""" + tag_name = 'a' + + @classmethod + def new(cls, href, *children, **attrs): + attrs['xlink:href'] = href + return super(Anchor, cls).new(*children, **attrs) + + +class ClipPath(GroupBase): + """A path used to clip objects""" + tag_name = 'clipPath' + + +class Marker(GroupBase): + """The <marker> element defines the graphic that is to be used for drawing arrowheads + or polymarkers on a given <path>, <line>, <polyline> or <polygon> element.""" + tag_name = 'marker' diff --git a/share/extensions/inkex/elements/_image.py b/share/extensions/inkex/elements/_image.py new file mode 100644 index 0000000..efd00d3 --- /dev/null +++ b/share/extensions/inkex/elements/_image.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 - Martin Owens <doctormo@gmail.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +""" +Image element interface. +""" + +from ._polygons import RectangleBase + +class Image(RectangleBase): + """Provide a useful extension for image elements""" + tag_name = 'image' diff --git a/share/extensions/inkex/elements/_meta.py b/share/extensions/inkex/elements/_meta.py new file mode 100644 index 0000000..f8107ad --- /dev/null +++ b/share/extensions/inkex/elements/_meta.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Maren Hachmann <moini> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Provide extra utility to each svg element type specific to its type. + +This is useful for having a common interface for each element which can +give path, transform, and property access easily. +""" + +import math + +from lxml import etree + +from ..styles import StyleSheet +from ..transforms import Vector2d + +from ._base import BaseElement + +class Defs(BaseElement): + """A header defs element, one per document""" + tag_name = 'defs' + +class StyleElement(BaseElement): + """A CSS style element containing multiple style definitions""" + tag_name = 'style' + + def set_text(self, content): + """Sets the style content text as a CDATA section""" + self.text = etree.CDATA(str(content)) + + def stylesheet(self): + """Return the StyleSheet() object for the style tag""" + return StyleSheet(self.text, callback=self.set_text) + +class Script(BaseElement): + """A javascript tag in SVG""" + tag_name = 'script' + + def set_text(self, content): + """Sets the style content text as a CDATA section""" + self.text = etree.CDATA(str(content)) + +class Desc(BaseElement): + """Description element""" + tag_name = 'desc' + +class Title(BaseElement): + """Title element""" + tag_name = 'title' + +class NamedView(BaseElement): + """The NamedView element is Inkscape specific metadata about the file""" + tag_name = 'sodipodi:namedview' + + current_layer = property(lambda self: self.get('inkscape:current-layer')) + + @property + def center(self): + """Returns view_center in terms of document units""" + return Vector2d(self.root.unittouu(self.get('inkscape:cx') or 0), + self.root.unittouu(self.get('inkscape:cy') or 0)) + + def get_guides(self): + """Returns a list of guides""" + return self.findall('sodipodi:guide') + + def new_guide(self, position, orient=True, name=None): + """Creates a new guide in this namedview""" + if orient is True: + elem = Guide().move_to(0, position, (0, 1)) + elif orient is False: + elem = Guide().move_to(position, 0, (1, 0)) + if name: + elem.set('inkscape:label', str(name)) + return self.add(elem) + + +class Guide(BaseElement): + """An inkscape guide""" + tag_name = 'sodipodi:guide' + + is_horizontal = property(lambda self: self.get('orientation').startswith('0,') and not + self.get('orientation') == '0,0') + is_vertical = property(lambda self: self.get('orientation').endswith(',0')) + point = property(lambda self: Vector2d(self.get('position'))) + + @classmethod + def new(cls, pos_x, pos_y, angle, **attrs): + guide = super(Guide, cls).new(**attrs) + guide.move_to(pos_x, pos_y, angle=angle) + return guide + + def move_to(self, pos_x, pos_y, angle=None): + """ + Move this guide to the given x,y position, + + Angle may be a float or integer, which will change the orientation. Alternately, + it may be a pair of numbers (tuple) which will set the orientation directly. + """ + self.set('position', "{:g},{:g}".format(float(pos_x), float(pos_y))) + if isinstance(angle, str): + if ',' not in angle: + angle = float(angle) + + if isinstance(angle, (float, int)): + # Generate orientation from angle + angle = (math.sin(math.radians(angle)), -math.cos(math.radians(angle))) + + if isinstance(angle, (tuple, list)) and len(angle) == 2: + angle = "{:g},{:g}".format(*angle) + + self.set('orientation', angle) + return self + +class Metadata(BaseElement): + """Inkscape Metadata element""" + tag_name = 'metadata' + +class ForeignObject(BaseElement): + """SVG foreignObject element""" + tag_name = 'foreignObject' + +class Switch(BaseElement): + """A switch element""" + tag_name = 'switch' + +class Grid(BaseElement): + """A namedview grid child""" + tag_name = 'inkscape:grid' diff --git a/share/extensions/inkex/elements/_polygons.py b/share/extensions/inkex/elements/_polygons.py new file mode 100644 index 0000000..0bcbc38 --- /dev/null +++ b/share/extensions/inkex/elements/_polygons.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Sergei Izmailov <sergei.a.izmailov@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Interface for all shapes/polygons such as lines, paths, rectangles, circles etc. +""" + +from ..paths import Path +from ..transforms import Transform, ImmutableVector2d, Vector2d +from ..utils import addNS +from ..units import convert_unit + +from ._base import ShapeElement + +class PathElementBase(ShapeElement): + """Base element for path based shapes""" + get_path = lambda self: self.get('d') + + @classmethod + def new(cls, path, **attrs): + return super(PathElementBase, cls).new(d=Path(path), **attrs) + + def set_path(self, path): + """Set the given data as a path as the 'd' attribute""" + self.set('d', str(Path(path))) + + def apply_transform(self): + """Apply the internal transformation to this node and delete""" + if 'transform' in self.attrib: + self.path = self.path.transform(self.transform) + self.set('transform', Transform()) + + @property + def original_path(self): + """Returns the original path if this is a LPE, or the path if not""" + return Path(self.get('inkscape:original-d', self.path)) + + @original_path.setter + def original_path(self, path): + if addNS('inkscape:original-d') in self.attrib: + self.set('inkscape:original-d', str(Path(path))) + else: + self.path = path + + +class PathElement(PathElementBase): + """Provide a useful extension for path elements""" + tag_name = 'path' + + @classmethod + def arc(cls, center, rx, ry=None, **kw): # pylint: disable=invalid-name + """Generate a sodipodi arc (special type)""" + others = [(name, kw.pop(name, None)) for name in ('start', 'end', 'open')] + elem = cls(**kw) + elem.set('sodipodi:cx', center[0]) + elem.set('sodipodi:cy', center[1]) + elem.set('sodipodi:rx', rx) + elem.set('sodipodi:ry', ry or rx) + elem.set('sodipodi:type', 'arc') + for name, value in others: + if value is not None: + elem.set('sodipodi:'+name, str(value).lower()) + return elem + + @classmethod + def star(cls, center, radi, sides, rounded=None): + """Generate a sodipodi start (special type)""" + elem = cls() + elem.set('sodipodi:cx', center[0]) + elem.set('sodipodi:cy', center[1]) + elem.set('sodipodi:r1', radi[0]) + elem.set('sodipodi:r2', radi[1]) + elem.set('sodipodi:arg1', 0.85) + elem.set('sodipodi:arg2', 1.3) + elem.set('sodipodi:sides', sides) + elem.set('inkscape:rounded', rounded) + elem.set('sodipodi:type', 'star') + return elem + + +class Polyline(ShapeElement): + """Like a path, but made up of straight line segments only""" + tag_name = 'polyline' + + def get_path(self): + return Path('M' + self.get('points')) + + def set_path(self, path): + points = ['{:g},{:g}'.format(x, y) for x, y in Path(path).end_points] + self.set('points', ' '.join(points)) + + +class Polygon(ShapeElement): + """A closed polyline""" + tag_name = 'polygon' + get_path = lambda self: 'M' + self.get('points') + ' Z' + + +class Line(ShapeElement): + """A line segment connecting two points""" + tag_name = 'line' + get_path = lambda self: 'M{0[x1]},{0[y1]} L{0[x2]},{0[y2]} Z'.format(self.attrib) + + @classmethod + def new(cls, start, end, **attrs): + start = Vector2d(start) + end = Vector2d(end) + return super(Line, cls).new(x1=start.x, y1=start.y, + x2=end.x, y2=end.y, **attrs) + + +class RectangleBase(ShapeElement): + """Provide a useful extension for rectangle elements""" + left = property(lambda self: convert_unit(self.get('x', '0'), 'px')) + top = property(lambda self: convert_unit(self.get('y', '0'), 'px')) + right = property(lambda self: self.left + self.width) + bottom = property(lambda self: self.top + self.height) + width = property(lambda self: convert_unit(self.get('width', '0'), 'px')) + height = property(lambda self: convert_unit(self.get('height', '0'), 'px')) + rx = property(lambda self: convert_unit(self.get('rx', self.get('ry', 0.0)), 'px')) + ry = property(lambda self: convert_unit(self.get('ry', self.get('rx', 0.0)), 'px')) # pylint: disable=invalid-name + + def get_path(self): + """Calculate the path as the box around the rect""" + if self.rx: + rx, ry = self.rx, self.ry # pylint: disable=invalid-name + return 'M {1},{0.top}'\ + 'L {2},{0.top} A {0.rx},{0.ry} 0 0 1 {0.right},{3}'\ + 'L {0.right},{4} A {0.rx},{0.ry} 0 0 1 {2},{0.bottom}'\ + 'L {1},{0.bottom} A {0.rx},{0.ry} 0 0 1 {0.left},{4}'\ + 'L {0.left},{3} A {0.rx},{0.ry} 0 0 1 {1},{0.top} z'\ + .format(self, self.left + rx, self.right - rx, self.top + ry, self.bottom - ry) + + return 'M {0.left},{0.top} h{0.width}v{0.height}h{1} z'.format(self, -self.width) + + +class Rectangle(RectangleBase): + """Provide a useful extension for rectangle elements""" + tag_name = 'rect' + + @classmethod + def new(cls, left, top, width, height, **attrs): + return super(Rectangle, cls).new(x=left, y=top, width=width, height=height, **attrs) + + +class EllipseBase(ShapeElement): + """Absorbs common part of Circle and Ellipse classes""" + + def get_path(self): + """Calculate the arc path of this circle""" + rx, ry = self._rxry() + cx, y = self.center.x, self.center.y - ry + return ('M {cx},{y} ' + 'a {rx},{ry} 0 1 0 {rx}, {ry} ' + 'a {rx},{ry} 0 0 0 -{rx}, -{ry} z' + ).format(cx=cx, y=y, rx=rx, ry=ry) + + @property + def center(self): + return ImmutableVector2d(convert_unit(self.get('cx', '0'), 'px'), convert_unit(self.get('cy', '0'), 'px')) + + @center.setter + def center(self, value): + value = Vector2d(value) + self.set("cx", value.x) + self.set("cy", value.y) + + def _rxry(self): + # type: () -> Vector2d + """Helper function """ + raise NotImplementedError() + + @classmethod + def new(cls, center, radius, **attrs): + circle = super(EllipseBase, cls).new(**attrs) + circle.center = center + circle.radius = radius + return circle + + +class Circle(EllipseBase): + """Provide a useful extension for circle elements""" + tag_name = 'circle' + + @property + def radius(self): + return convert_unit(self.get('r', '0'), 'px') + + @radius.setter + def radius(self, value): + self.set("r", value) + + def _rxry(self): + r = self.radius + return Vector2d(r, r) + + +class Ellipse(EllipseBase): + """Provide a similar extension to the Circle interface""" + tag_name = 'ellipse' + + @property + def radius(self): + return ImmutableVector2d(convert_unit(self.get('rx', '0'), 'px'), convert_unit(self.get('ry', '0'), 'px')) + + @radius.setter + def radius(self, value): + value = Vector2d(value) + self.set("rx", str(value.x)) + self.set("ry", str(value.y)) + + def _rxry(self): + return self.radius diff --git a/share/extensions/inkex/elements/_selected.py b/share/extensions/inkex/elements/_selected.py new file mode 100644 index 0000000..c09c526 --- /dev/null +++ b/share/extensions/inkex/elements/_selected.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc.,Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +""" +When elements are selected, these structures provide an advanced API. +""" + +from collections import OrderedDict + +class ElementList(OrderedDict): + """ + A list of elements, selected by id, iterator or xpath + + This may look like a dictionary, but it is really a list of elements. + The default iterator is the element objects themselves (not keys) and it is + possible to key elements by their numerical index. + + It is also possible to look up items by their id and the element object itself. + """ + def __init__(self, svg, _iter=None): + self.svg = svg + self.ids = OrderedDict() + super(ElementList, self).__init__() + if _iter: + self.set(*list(_iter)) + + def __getitem__(self, key): + return super(ElementList, self).__getitem__(self._to_key(key)) + + def __contains__(self, key): + return super(ElementList, self).__contains__(self._to_key(key)) + + def __setitem__(self, orig_key, elem): + from ._base import BaseElement + if orig_key != elem and orig_key != elem.get('id'): + raise ValueError("Refusing to set bad key in ElementList {}".format(orig_key)) + if isinstance(elem, str): + key = elem + elem = self.svg.getElementById(elem) + if elem is None: + return + if isinstance(elem, BaseElement): + # Selection is a list of elements to select + key = elem.xml_path + element_id = elem.get('id') + if element_id is not None: + self.ids[element_id] = key + super(ElementList, self).__setitem__(key, elem) + else: + kind = type(elem).__name__ + raise ValueError("Unknown element type: {}".format(kind)) + + def _to_key(self, key, default=None): + """Takes a key (id, element, etc) and returns an xml_path key""" + from ._base import BaseElement + if self and key is None: + key = default + if isinstance(key, int): + return list(self.keys())[key] + elif isinstance(key, BaseElement): + return key.xml_path + elif isinstance(key, str) and key[0] != '/': + return self.ids.get(key, key) + return key + + def clear(self): + """Also clear ids""" + self.ids.clear() + super(ElementList, self).clear() + + def set(self, *ids): + """ + Sets the currently selected elements to these ids, any existing + selection is cleared. + + Arguments a list of element ids, element objects or + a single xpath expression starting with "//". + + All element objects must have an id to be correctly set. + + >>> selection.set("rect123", "path456", "text789") + >>> selection.set(elem1, elem2, elem3) + >>> selection.set("//rect") + """ + self.clear() + self.add(*ids) + + def pop(self, key=None): + """Remove the key item or remove the last item selected""" + item = super(ElementList, self).pop(self._to_key(key, default=-1)) + self.ids.pop(item.get('id')) + return item + + def add(self, *ids): + """Like set() but does not clear first""" + # Allow selecting of xpath elements directly + if len(ids) == 1 and isinstance(ids[0], str) and ids[0].startswith('//'): + ids = self.svg.xpath(ids[0]) + + for elem in ids: + self[elem] = elem # This doesn't matter + + def paint_order(self): + """Get the selected elements, but ordered by their appearance in the document""" + new_list = ElementList(self.svg) + new_list.set(*[elem for _, elem in sorted(self.items(), key=lambda x: x[0])]) + return new_list + + def filter(self, *types): + """Filter selected elements of the given type, returns a new SelectedElements object""" + return ElementList(self.svg, [e for e in self.values() if not types or isinstance(e, types)]) + + def get(self, *types): + """Like filter, but will enter each element searching for any child of the given types""" + def _recurse(elem): + if not types or isinstance(elem, types): + yield elem + for child in elem: + for item in _recurse(child): + yield item + return ElementList(self.svg, [r for e in self.values() for r in _recurse(e)]) + + def id_dict(self): + """For compatibility, return regular dictionary of id -> element pairs""" + return OrderedDict([(eid, self[xid]) for eid, xid in self.ids.items()]) + + def bounding_box(self): + """ + Gets a :class:`inkex.transforms.BoundingBox` object for the selected items. + + Text objects have a bounding box without width or height that only + reflects the coordinate of their anchor. If a text object is a part of + the selection's boundary, the bounding box may be inaccurate. + + When no object is selected or when the object's location cannot be + determined (e.g. empty group or layer), all coordinates will be None. + """ + return sum([elem.bounding_box() for elem in self.values()], None) + + def first(self): + """Returns the first item in the selected list""" + for elem in self.values(): + return elem + return None diff --git a/share/extensions/inkex/elements/_svg.py b/share/extensions/inkex/elements/_svg.py new file mode 100644 index 0000000..3e1af09 --- /dev/null +++ b/share/extensions/inkex/elements/_svg.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# Sergei Izmailov <sergei.a.izmailov@gmail.com> +# Windell Oskay <windell@oskay.net> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=attribute-defined-outside-init +# +""" +Provide a way to load lxml attributes with an svg API on top. +""" + +import random +from lxml import etree + +from ..deprecated import DeprecatedSvgMixin +from ..units import discover_unit, convert_unit, render_unit +from ._selected import ElementList +from ..transforms import BoundingBox +from ..styles import StyleSheets + +from ._base import BaseElement +from ._meta import NamedView, Defs, StyleElement, Metadata + +if False: # pylint: disable=using-constant-test + import typing # pylint: disable=unused-import + + +class SvgDocumentElement(DeprecatedSvgMixin, BaseElement): + """Provide access to the document level svg functionality""" + tag_name = 'svg' + + def _init(self): + self.current_layer = None + self.view_center = (0.0, 0.0) + self.selection = ElementList(self) + self.ids = {} + + def tostring(self): + """Convert document to string""" + return etree.tostring(etree.ElementTree(self)) + + def get_ids(self): + """Returns a set of unique document ids""" + if not self.ids: + self.ids = set(self.xpath('//@id')) + return self.ids + + def get_unique_id(self, prefix, size=4): + """Generate a new id from an existing old_id""" + ids = self.get_ids() + new_id = None + _from = 10 ** size - 1 + _to = 10 ** size + while new_id is None or new_id in ids: + # Do not use randint because py2/3 incompatibility + new_id = prefix + str(int(random.random() * _from - _to) + _to) + self.ids.add(new_id) + return new_id + + def get_page_bbox(self): + """Gets the page dimensions as a bbox""" + return BoundingBox((0, float(self.width)), (0, float(self.height))) + + def get_current_layer(self): + """Returns the currently selected layer""" + layer = self.getElementById(self.namedview.current_layer, 'svg:g') + if layer is None: + return self + return layer + + def getElement(self, xpath): # pylint: disable=invalid-name + """Gets a single element from the given xpath or returns None""" + return self.findone(xpath) + + def getElementById(self, eid, elm='*'): # pylint: disable=invalid-name + """Get an element in this svg document by it's ID attribute""" + if eid is not None: + eid = eid.strip()[4:-1] if eid.startswith('url(') else eid + eid = eid.lstrip('#') + return self.getElement('//{elm}[@id="{eid}"]'.format(elm=elm, eid=eid)) + + def getElementByName(self, name, elm='*'): # pylint: disable=invalid-name + """Get an element by it's inkscape:label (aka name)""" + return self.getElement('//{elm}[@inkscape:label="{name}"]'.format(elm=elm, name=name)) + + def getElementsByClass(self, class_name): # pylint: disable=invalid-name + """Get elements by it's class name""" + from inkex.styles import ConditionalRule + return self.xpath(ConditionalRule(".{}".format(class_name)).to_xpath()) + + def getElementsByHref(self, eid): # pylint: disable=invalid-name + """Get elements by their href xlink attribute""" + return self.xpath('//*[@xlink:href="#{}"]'.format(eid)) + + def getElementsByStyleUrl(self, eid, style=None): # pylint: disable=invalid-name + """Get elements by a style attribute url""" + url = "url(#{})".format(eid) + if style is not None: + url = style + ":" + url + return self.xpath('//*[contains(@style,"{}")]'.format(url)) + + @property + def name(self): + """Returns the Document Name""" + return self.get('sodipodi:docname', '') + + @property + def namedview(self): + """Return the sp namedview meta information element""" + return self.get_or_create('//sodipodi:namedview', NamedView, True) + + @property + def metadata(self): + """Return the svg metadata meta element container""" + return self.get_or_create('//svg:metadata', Metadata, True) + + @property + def defs(self): + """Return the svg defs meta element container""" + return self.get_or_create('//svg:defs', Defs, True) + + def get_viewbox(self): + """Parse and return the document's viewBox attribute""" + try: + ret = [float(unit) for unit in self.get('viewBox', '0').split()] + except ValueError: + ret = '' + if len(ret) != 4: + return [0, 0, 0, 0] + return ret + + @property + def width(self): # getDocumentWidth(self): + """Fault tolerance for lazily defined SVG""" + return self.unittouu(self.get('width')) or self.get_viewbox()[2] + + @property + def height(self): # getDocumentHeight(self): + """Returns a string corresponding to the height of the document, as + defined in the SVG file. If it is not defined, returns the height + as defined by the viewBox attribute. If viewBox is not defined, + returns the string '0'.""" + return self.unittouu(self.get('height')) or self.get_viewbox()[3] + + @property + def scale(self): + """Return the ratio between the page width and the viewBox width""" + try: + scale_x = float(self.width) / float(self.get_viewbox()[2]) + scale_y = float(self.height) / float(self.get_viewbox()[3]) + return max([scale_x, scale_y]) + except (ValueError, ZeroDivisionError): + return 1.0 + + @property + def unit(self): + """Returns the unit used for in the SVG document. + In the case the SVG document lacks an attribute that explicitly + defines what units are used for SVG coordinates, it tries to calculate + the unit from the SVG width and viewBox attributes. + Defaults to 'px' units.""" + viewbox = self.get_viewbox() + if viewbox and set(viewbox) != {0}: + return discover_unit(self.get('width'), viewbox[2], default='px') + return 'px' # Default is px + + def unittouu(self, value): + """Convert a unit value into the document's units""" + return convert_unit(value, self.unit) + + def uutounit(self, value, to_unit): + """Convert from the document's units to the given unit""" + return convert_unit(render_unit(value, self.unit), to_unit) + + def add_unit(self, value): + """Add document unit when no unit is specified in the string """ + return render_unit(value, self.unit) + + @property + def stylesheets(self): + """Get all the stylesheets, bound together to one, (for reading)""" + sheets = StyleSheets(self) + for node in self.xpath('//svg:style'): + sheets.append(node.stylesheet()) + return sheets + + @property + def stylesheet(self): + """Return the first stylesheet or create one if needed (for writing)""" + for sheet in self.stylesheets: + return sheet + + style_node = StyleElement() + self.defs.append(style_node) + return style_node.stylesheet() diff --git a/share/extensions/inkex/elements/_text.py b/share/extensions/inkex/elements/_text.py new file mode 100644 index 0000000..766344c --- /dev/null +++ b/share/extensions/inkex/elements/_text.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# pylint: disable=arguments-differ +""" +Provide text based element classes interface. + +Because text is not rendered at all, no information about a text's path +size or actual location can be generated yet. +""" + +from ..paths import Path +from ..transforms import Transform, BoundingBox +from ..units import convert_unit + +from ._base import BaseElement, ShapeElement +from ._polygons import PathElementBase + +class FlowRegion(ShapeElement): + """SVG Flow Region (SVG 2.0)""" + tag_name = 'flowRegion' + + def get_path(self): + # This ignores flowRegionExcludes + return sum([child.path for child in self], Path()) + +class FlowRoot(ShapeElement): + """SVG Flow Root (SVG 2.0)""" + tag_name = 'flowRoot' + + @property + def region(self): + """Return the first flowRegion in this flowRoot""" + return self.findone('svg:flowRegion') + + def get_path(self): + region = self.region + return region.get_path() if region is not None else Path() + +class FlowPara(ShapeElement): + """SVG Flow Paragraph (SVG 2.0)""" + tag_name = 'flowPara' + + def get_path(self): + # XXX: These empty paths mean the bbox for text elements will be nothing. + return Path() + +class FlowDiv(ShapeElement): + """SVG Flow Div (SVG 2.0)""" + tag_name = 'flowDiv' + + def get_path(self): + # XXX: These empty paths mean the bbox for text elements will be nothing. + return Path() + +class FlowSpan(ShapeElement): + """SVG Flow Span (SVG 2.0)""" + tag_name = 'flowSpan' + + def get_path(self): + # XXX: These empty paths mean the bbox for text elements will be nothing. + return Path() + +class TextElement(ShapeElement): + """A Text element""" + tag_name = 'text' + x = property(lambda self: convert_unit(self.get('x', 0), 'px')) + y = property(lambda self: convert_unit(self.get('y', 0), 'px')) + + def get_path(self): + return Path() + + def tspans(self): + """Returns all children that are tspan elements""" + return self.findall('svg:tspan') + + def get_text(self, sep="\n"): + """Return the text content including tspans""" + nodes = [self] + list(self.tspans()) + return sep.join([elem.text for elem in nodes if elem.text is not None]) + + def shape_box(self, transform=None): + """ + Returns a horrible bounding box that just contains the coord points + of the text without width or height (which is impossible to calculate) + """ + effective_transform = Transform(transform) * self.transform + x, y = effective_transform.apply_to_point((self.x, self.y)) + bbox = BoundingBox(x, y) + for tspan in self.tspans(): + bbox += tspan.bounding_box(effective_transform) + return bbox + +class TextPath(ShapeElement): + """A textPath element""" + tag_name = 'textPath' + + def get_path(self): + return Path() + +class Tspan(ShapeElement): + """A tspan text element""" + tag_name = 'tspan' + x = property(lambda self: convert_unit(self.get('x', 0), 'px')) + y = property(lambda self: convert_unit(self.get('y', 0), 'px')) + + @classmethod + def superscript(cls, text): + """Adds a superscript tspan element""" + return cls(text, style="font-size:65%;baseline-shift:super") + + def get_path(self): + return Path() + + def shape_box(self, transform=None): + """ + Returns a horrible bounding box that just contains the coord points + of the text without width or height (which is impossible to calculate) + """ + effective_transform = Transform(transform) * self.transform + x1, y1 = effective_transform.apply_to_point((self.x, self.y)) + fontsize = convert_unit(self.style.get('font-size', '1em'), 'px') + x2 = self.x + 0 # XXX This is impossible to calculate! + y2 = self.y + float(fontsize) + x2, y2 = effective_transform.apply_to_point((x2, y2)) + return BoundingBox((x1, x2), (y1, y2)) + + +class SVGfont(BaseElement): + """An svg font element""" + tag_name = 'font' + +class FontFace(BaseElement): + """An svg font font-face element""" + tag_name = 'font-face' + +class Glyph(PathElementBase): + """An svg font glyph element""" + tag_name = 'glyph' + +class MissingGlyph(BaseElement): + """An svg font missing-glyph element""" + tag_name = 'missing-glyph' diff --git a/share/extensions/inkex/elements/_use.py b/share/extensions/inkex/elements/_use.py new file mode 100644 index 0000000..b5a38c5 --- /dev/null +++ b/share/extensions/inkex/elements/_use.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2020 Martin Owens <doctormo@gmail.com> +# Thomas Holder <thomas.holder@schrodinger.com> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +""" +Interface for the Use and Symbol elements +""" + +from ..transforms import Transform + +from ._groups import Group, GroupBase +from ._base import ShapeElement + +class Symbol(GroupBase): + """SVG symbol element""" + tag_name = 'symbol' + +class Use(ShapeElement): + """A 'use' element that links to another in the document""" + tag_name = 'use' + + @classmethod + def new(cls, elem, x, y, **attrs): # pylint: disable=arguments-differ + ret = super(Use, cls).new(x=x, y=y, **attrs) + ret.href = elem + return ret + + def get_path(self): + """Returns the path of the cloned href plus any transformation""" + path = self.href.path + path.transform(self.href.transform) + return path + + def effective_style(self): + """Href's style plus this object's own styles""" + style = self.href.effective_style() + style.update(self.style) + return style + + def unlink(self): + """Unlink this clone, replacing it with a copy of the original""" + copy = self.href.copy() + if isinstance(copy, Symbol): + group = Group(**copy.attrib) + group.extend(copy) + copy = group + copy.transform *= self.transform + copy.style = self.style + copy.style + self.replace_with(copy) + copy.set_random_ids() + return copy + + def shape_box(self, transform=None): + effective_transform = Transform(transform) * self.transform + return self.href.bounding_box(effective_transform) 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 Binary files differnew file mode 100644 index 0000000..7d6722e --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..6c98af5 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..b460938 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..7f8204a --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..82c6234 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..439198f --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..cd16936 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..5e61017 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc 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) |