diff options
Diffstat (limited to 'share/extensions/inkex/base.py')
-rw-r--r-- | share/extensions/inkex/base.py | 537 |
1 files changed, 537 insertions, 0 deletions
diff --git a/share/extensions/inkex/base.py b/share/extensions/inkex/base.py new file mode 100644 index 0000000..63f9218 --- /dev/null +++ b/share/extensions/inkex/base.py @@ -0,0 +1,537 @@ +# 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. +""" + +import os +import re +import sys +import copy + +from typing import ( + Dict, + List, + Tuple, + Type, + Optional, + Callable, + Any, + Union, + IO, + TYPE_CHECKING, + cast, +) + +from argparse import ArgumentParser, Namespace +from lxml import etree + +from .interfaces.IElement import IBaseElement, ISVGDocumentElement +from .utils import filename_arg, AbortExtension, ABORT_STATUS, errormsg, do_nothing +from .elements._parser import load_svg +from .elements._utils import NSS +from .localization import localize + +stdout = sys.stdout.buffer # type: ignore + + +class InkscapeExtension: + """ + 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. + extra_nss = {} # type: Dict[str, str] + + def __init__(self): + # type: () -> None + NSS.update(self.extra_nss) + self.file_io = None # type: Optional[IO] + self.options = Namespace() + self.document = None # type: Union[None, bytes, str, etree.element] + 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") + """ + # 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) + ... + .. code-block:: python + .. def method_foo(self, arguments): + .. # do something + """ + + def _inner(value): + name = f"""{prefix}_{value.strip('"').lower()}""".replace("-", "_") + try: + return getattr(self, name) + except AttributeError as error: + if name.startswith("_"): + return do_nothing + raise AbortExtension(f"Can not find method {name}") from error + + return _inner + + @staticmethod + def arg_number_ranges(): + + """Parses a number descriptor. e.g: + ``1,2,4-5,7,9-`` is parsed to ``1, 2, 4, 5, 7, 9, 10, ..., lastvalue`` + + .. versionadded:: 1.2 + + Usage: + + .. code-block:: python + + # in add_arguments() + pars.add_argument("--pages", type=self.arg_number_ranges(), default=1-) + # later on, pages is then a list of ints + pages = self.options.pages(lastvalue) + + """ + + def _inner(value): + def method(pages, lastvalue, startvalue=1): + # replace ranges, such as -3, 10- with startvalue,2,3,10..lastvalue + pages = re.sub( + r"(\d+|)\s?-\s?(\d+|)", + lambda m: ",".join( + map( + str, + range( + int(m.group(1) or startvalue), + int(m.group(2) or lastvalue) + 1, + ), + ) + ) + if not (m.group(1) or m.group(2)) == "" + else "", + pages, + ) + + pages = map(int, re.findall(r"(\d+)", pages)) + pages = tuple({i for i in pages if i <= lastvalue}) + return pages + + return lambda lastvalue, startvalue=1: method( + value, lastvalue, startvalue=startvalue + ) + + return _inner + + @staticmethod + def arg_class(options: List[Type]) -> Callable[[str], Any]: + """Used by add_argument to match an option with a class + + Types to choose from are given by the options list + + .. versionadded:: 1.2 + + Usage: + + .. code-block:: python + + pars.add_argument("--class", type=self.arg_class([ClassA, ClassB]), + default="ClassA") + """ + + def _inner(value: str): + name = value.strip('"') + for i in options: + if name == i.__name__: + return i + raise AbortExtension(f"Can not find class {name}") + + return _inner + + def debug(self, msg): + # type: (str) -> None + """Write a debug message""" + errormsg(f"DEBUG<{type(self).__name__}> {msg}\n") + + @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 + elif "DOCUMENT_PATH" not in os.environ: + os.environ["DOCUMENT_PATH"] = self.options.input_file + + if self.options.output is None: + self.options.output = output + + self.load_raw() + self.save_raw(self.effect()) + except AbortExtension as err: + errormsg(str(err)) + 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): + # pylint: disable=consider-using-with + 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): + with open(self.options.output, "wb") as stream: + self.save(stream) + else: + if sys.platform == "win32" and not "PYTEST_CURRENT_TEST" in os.environ: + # When calling an extension from within Inkscape on Windows, + # Python thinks that the output stream is seekable + # (https://gitlab.com/inkscape/inkscape/-/issues/3273) + self.options.output.seekable = lambda self: False + + def seek_replacement(offset: int, whence: int = 0): + raise AttributeError( + "We can't seek in the stream passed by Inkscape on Windows" + ) + + def tell_replacement(): + raise AttributeError( + "We can't tell in the stream passed by Inkscape on Windows" + ) + + # Some libraries (e.g. ZipFile) don't query seekable, but check for an error + # on seek + self.options.output.seek = seek_replacement + self.options.output.tell = tell_replacement + self.save(self.options.output) + + def load(self, stream): + # type: (IO) -> str + """Takes the input stream and creates a document for parsing""" + raise NotImplementedError(f"No input handle for {self.name}") + + def save(self, stream): + # type: (IO) -> None + """Save the given document to the output file""" + raise NotImplementedError(f"No output handle for {self.name}") + + def effect(self): + # type: () -> Any + """Apply some effects on the document or local context""" + raise NotImplementedError(f"No effect handle for {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() + + @classmethod + def svg_path(cls, default=None): + # type: (Optional[str]) -> Optional[str] + """ + Return the folder the svg is contained in. + Returns None if there is no file. + + .. versionchanged:: 1.1 + A default path can be given which is returned in case no path to the + SVG file can be determined. + """ + path = cls.document_path() + if path: + return os.path.dirname(path) + if default: + return default + return path # Return None or '' for context + + @classmethod + def ext_path(cls): + # type: () -> str + """Return the folder the extension script is in""" + return os.path.dirname(sys.modules[cls.__module__].__file__ or "") + + @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 + + .. versionadded:: 1.1""" + filename = cls.absolute_href(name, cwd=cls.ext_path()) + if abort_on_fail and not os.path.isfile(filename): + raise AbortExtension(f"Could not find resource file: {filename}") + return filename + + @classmethod + def document_path(cls): + # type: () -> Optional[str] + """Returns the saved location of the document + + * Normal return is a string containing the saved location + * Empty string means the document was never saved + * 'None' means this version of Inkscape doesn't support DOCUMENT_PATH + + DO NOT READ OR WRITE TO THE DOCUMENT FILENAME! + + * Inkscape may have not written the latest changes, leaving you reading old + data. + * Inkscape will not respect anything you write to the file, causing data loss. + + .. versionadded:: 1.1 + """ + return os.environ.get("DOCUMENT_PATH", None) + + @classmethod + def absolute_href(cls, filename, default="~/", cwd=None): + # type: (str, str, Optional[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 working directory to use if the svg's filename is not + available. + + .. versionchanged:: 1.1 + If you set default to None, then the user will be given errors if + there's no working directory available from Inkscape. + """ + filename = os.path.expanduser(filename) + if not os.path.isabs(filename): + filename = os.path.expanduser(filename) + if not os.path.isabs(filename): + if cwd is None: + cwd = cls.svg_path(default) + if cwd is None: + raise AbortExtension( + "Can not use relative path, Inkscape isn't telling us the " + "current working directory." + ) + if cwd == "": + raise AbortExtension( + "The SVG must be saved before you can use relative paths." + ) + filename = os.path.join(cwd, 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): # pylint: disable=abstract-method + """ + Provide a temporary directory for extensions to stash files. + """ + + dir_suffix = "" + dir_prefix = "inktmp" + + def __init__(self, *args, **kwargs): + self.tempdir = None + super().__init__(*args, **kwargs) + + def load_raw(self): + # type: () -> None + """Create the temporary directory""" + # pylint: disable=import-outside-toplevel + from tempfile import TemporaryDirectory + + # Need to hold a reference to the Directory object or else it might get GC'd + self._tempdir = TemporaryDirectory( # pylint: disable=consider-using-with + prefix=self.dir_prefix, suffix=self.dir_suffix + ) + self.tempdir = os.path.realpath(self._tempdir.name) + super().load_raw() + + def clean_up(self): + # type: () -> None + """Delete the temporary directory""" + self.tempdir = None + self._tempdir.cleanup() + super().clean_up() + + +class SvgInputMixin(_Base): # pylint: disable=too-few-public-methods, abstract-method + """ + Expects the file input to be an svg document and will parse it. + """ + + # Select all objects if none are selected + select_all: Tuple[Type[IBaseElement], ...] = () + + def __init__(self): + super().__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: ISVGDocumentElement = 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, abstract-method + """ + 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)): + 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( + f"Unknown type of document: {type(self.document).__name__} can not" + + "save." + ) + + 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): # pylint: disable=abstract-method + """ + 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 |