diff options
Diffstat (limited to '')
-rw-r--r-- | share/extensions/inkex/command.py | 309 |
1 files changed, 309 insertions, 0 deletions
diff --git a/share/extensions/inkex/command.py b/share/extensions/inkex/command.py new file mode 100644 index 0000000..8f9abbc --- /dev/null +++ b/share/extensions/inkex/command.py @@ -0,0 +1,309 @@ +# 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 +import sys +from shutil import which as warlock + +from subprocess import Popen, PIPE +from tempfile import TemporaryDirectory +from typing import List +from lxml.etree import ElementTree + +from .elements import SvgDocumentElement + +INKSCAPE_EXECUTABLE_NAME = os.environ.get("INKSCAPE_COMMAND") +if INKSCAPE_EXECUTABLE_NAME is None: + if sys.platform == "win32": + # prefer inkscape.exe over inkscape.com which spawns a command window + INKSCAPE_EXECUTABLE_NAME = "inkscape.exe" + else: + INKSCAPE_EXECUTABLE_NAME = "inkscape" + + +class CommandNotFound(IOError): + """Command is not found""" + + +class ProgramRunError(ValueError): + """A specialized ValueError that is raised when a call to an external command fails. + It stores additional information about a failed call to an external program. + + If only the ``program`` parameter is given, it is interpreted as the error message. + Otherwise, the error message is compiled from all constructor parameters.""" + + program: str + """The absolute path to the called executable""" + + returncode: int + """Return code of the program call""" + + stderr: str + """stderr stream output of the call""" + + stdout: str + """stdout stream output of the call""" + + arguments: List + """Arguments of the call""" + + def __init__(self, program, returncode=None, stderr=None, stdout=None, args=None): + self.program = program + self.returncode = returncode + self.stderr = stderr + self.stdout = stdout + self.arguments = args + super().__init__(str(self)) + + def __str__(self): + if self.returncode is None: + return self.program + return ( + f"Return Code: {self.returncode}: {self.stderr}\n{self.stdout}" + f"\nargs: {self.args}" + ) + + +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 + # On Windows, shutil.which may give preference to .py files in the current directory + # (such as pdflatex.py), e.g. if .PY is in pathext, because the current directory is + # prepended to PATH. This can be suppressed by explicitly appending the current + # directory. + + try: + if sys.platform == "win32": + prog = warlock(program, path=os.environ["PATH"] + ";" + os.curdir) + if prog: + return prog + except ImportError: + pass + + try: + # Python3 only version of which + 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(f"Can not find the command: '{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 f"{arg}={str(val)}" + return str(arg) + + +def to_args(prog, *positionals, **arguments): + """Compile arguments and keyword arguments into a list of strings which Popen will + understand. + + :param prog: + Program executable prepended to the output. + :type first: ``str`` + + :Arguments: + * (``str``) -- String added as given + * (``tuple``) -- Ordered version of Kwyward Arguments, see below + + :Keyword Arguments: + * *name* (``str``) -- + Becomes ``--name="val"`` + * *name* (``bool``) -- + Becomes ``--name`` + * *name* (``list``) -- + Becomes ``--name="val1"`` ... + * *n* (``str``) -- + Becomes ``-n=val`` + * *n* (``bool``) -- + Becomes ``-n`` + + :return: Returns a list of compiled arguments ready for Popen. + :rtype: ``list[str]`` + """ + 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 to_args_sorted(prog, *positionals, **arguments): + """same as :func:`to_args`, but keyword arguments are sorted beforehand + + .. versionadded:: 1.2""" + return to_args(prog, *positionals, **dict(sorted(arguments.items()))) + + +def _call(program, *args, **kwargs): + stdin = kwargs.pop("stdin", None) + if isinstance(stdin, str): + stdin = stdin.encode("utf-8") + inpipe = PIPE if stdin else None + + args = to_args(which(program), *args, **kwargs) + + kwargs = {} + if sys.platform == "win32": + kwargs["creationflags"] = 0x08000000 # create no console window + + with 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 + **kwargs, + ) as process: + (stdout, stderr) = process.communicate(input=stdin) + if process.returncode == 0: + return stdout + raise ProgramRunError(program, 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 :class:`ProgramRunError` if return code is not 0. + + Keyword arguments: + return_binary: Should stdout return raw bytes (default: False) + + .. versionadded:: 1.1 + stdin: The string or bytes containing the stdin (default: None) + + All other arguments converted using :func:`to_args` function. + """ + # We use this long input because it's less likely to conflict with --binary= + binary = kwargs.pop("return_binary", False) + stdout = _call(program, *args, **kwargs) + # Convert binary to string when we wish to have strings we do this here + # so the mock tests will also run the conversion (always returns bytes) + if not binary and isinstance(stdout, bytes): + return stdout.decode(sys.stdout.encoding or "utf-8") + return stdout + + +def inkscape(svg_file, *args, **kwargs): + """ + Call Inkscape with the given svg_file and the given arguments, see call() + """ + 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 tmpdir: + svg_file = write_svg(svg, tmpdir, "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 |