summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/command.py
blob: 8f9abbc758ddd558e8dcd3506f5fb79930827493 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
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