1# coding=utf-8
2#
3# Copyright (C) 2019 Martin Owens
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110, USA.
18#
19"""
20This API provides methods for calling Inkscape to execute a given
21Inkscape command. This may be needed for various compiling options
22(e.g., png), running other extensions or performing other options only
23available via the shell API.
24
25Best practice is to avoid using this API except when absolutely necessary,
26since it is resource-intensive to invoke a new Inkscape instance.
27
28However, in any circumstance when it is necessary to call Inkscape, it
29is strongly recommended that you do so through this API, rather than calling
30it yourself, to take advantage of the security settings and testing functions.
31
32"""
33
34import os
35import sys
36
37from subprocess import Popen, PIPE
38from tempfile import TemporaryDirectory
39from lxml.etree import ElementTree
40
41from .elements import SvgDocumentElement
42
43INKSCAPE_EXECUTABLE_NAME = os.environ.get('INKSCAPE_COMMAND')
44if INKSCAPE_EXECUTABLE_NAME == None:
45    if sys.platform == 'win32':
46        # prefer inkscape.exe over inkscape.com which spawns a command window
47        INKSCAPE_EXECUTABLE_NAME = 'inkscape.exe'
48    else:
49        INKSCAPE_EXECUTABLE_NAME = 'inkscape'
50
51class CommandNotFound(IOError):
52    """Command is not found"""
53    pass
54
55class ProgramRunError(ValueError):
56    """Command returned non-zero output"""
57    pass
58
59def which(program):
60    """
61    Attempt different methods of trying to find if the program exists.
62    """
63    if os.path.isabs(program) and os.path.isfile(program):
64        return program
65
66    from shutil import which as warlock
67    prog = warlock(program)
68    if prog:
69            return prog
70
71    # There may be other methods for doing a `which` command for other
72    # operating systems; These should go here as they are discovered.
73
74    raise CommandNotFound(f"Can not find the command: '{program}'")
75
76def write_svg(svg, *filename):
77    """Writes an svg to the given filename"""
78    filename = os.path.join(*filename)
79    if os.path.isfile(filename):
80        return filename
81    with open(filename, 'wb') as fhl:
82        if isinstance(svg, SvgDocumentElement):
83            svg = ElementTree(svg)
84        if hasattr(svg, 'write'):
85            # XML document
86            svg.write(fhl)
87        elif isinstance(svg, bytes):
88            fhl.write(svg)
89        else:
90            raise ValueError("Not sure what type of SVG data this is.")
91    return filename
92
93
94def to_arg(arg, oldie=False):
95    """Convert a python argument to a command line argument"""
96    if isinstance(arg, (tuple, list)):
97        (arg, val) = arg
98        arg = '-' + arg
99        if len(arg) > 2 and not oldie:
100            arg = '-' + arg
101        if val is True:
102            return arg
103        if val is False:
104            return None
105        return f"{arg}={str(val)}"
106    return str(arg)
107
108def to_args(prog, *positionals, **arguments):
109    """Compile arguments and keyword arguments into a list of strings which Popen will understand.
110
111    :param prog:
112        Program executable prepended to the output.
113    :type first: ``str``
114    :param *args:
115        See below
116    :param **kwargs:
117        See below
118
119    :Arguments:
120        * (``str``) -- String added as given
121        * (``tuple``) -- Ordered version of Kwyward Arguments, see below
122
123    :Keyword Arguments:
124        * *name* (``str``) --
125          Becomes ``--name="val"``
126        * *name* (``bool``) --
127          Becomes ``--name``
128        * *name* (``list``) --
129          Becomes ``--name="val1"`` ...
130        * *n* (``str``) --
131          Becomes ``-n=val``
132        * *n* (``bool``) --
133          Becomes ``-n``
134
135    :return: Returns a list of compiled arguments ready for Popen.
136    :rtype: ``list[str]``
137    """
138    args = [prog]
139    oldie = arguments.pop('oldie', False)
140    for arg, value in arguments.items():
141        arg = arg.replace('_', '-').strip()
142
143        if isinstance(value, tuple):
144            value = list(value)
145        elif not isinstance(value, list):
146            value = [value]
147
148        for val in value:
149            args.append(to_arg((arg, val), oldie))
150
151    args += [to_arg(pos, oldie) for pos in positionals if pos is not None]
152    # Filter out empty non-arguments
153    return [arg for arg in args if arg is not None]
154
155def _call(program, *args, **kwargs):
156    stdin = kwargs.pop('stdin', None)
157    if isinstance(stdin, str):
158        stdin = stdin.encode('utf-8')
159    inpipe = PIPE if stdin else None
160
161    args = to_args(which(program), *args, **kwargs)
162    process = Popen(
163        args,
164        shell=False, # Never have shell=True
165        stdin=inpipe, # StdIn not used (yet)
166        stdout=PIPE, # Grab any output (return it)
167        stderr=PIPE, # Take all errors, just incase
168    )
169    (stdout, stderr) = process.communicate(input=stdin)
170    if process.returncode == 0:
171        return stdout
172    raise ProgramRunError(f"Return Code: {process.returncode}: {stderr}\n{stdout}\nargs: {args}")
173
174def call(program, *args, **kwargs):
175    """
176    Generic caller to open any program and return its stdout.
177
178    stdout = call('executable', arg1, arg2, dash_dash_arg='foo', d=True, ...)
179
180    Will raise ProgramRunError() if return code is not 0.
181
182     * return_binary - Should stdout return raw bytes (default: False)
183     * stdin - The string or bytes containing the stdin (default: None)
184     * All other arguments converted using to_args(...) function.
185    """
186    # We use this long input because it's less likely to conflict with --binary=
187    binary = kwargs.pop('return_binary', False)
188    stdout = _call(program, *args, **kwargs)
189    # Convert binary to string when we wish to have strings we do this here
190    # so the mock tests will also run the conversion (always returns bytes)
191    if not binary and isinstance(stdout, bytes):
192        return stdout.decode(sys.stdout.encoding or 'utf-8')
193    return stdout
194
195def inkscape(svg_file, *args, **kwargs):
196    """
197    Call Inkscape with the given svg_file and the given arguments, see call()
198    """
199    return call(INKSCAPE_EXECUTABLE_NAME, svg_file, *args, **kwargs)
200
201def inkscape_command(svg, select=None, verbs=()):
202    """
203    Executes a list of commands, a mixture of verbs, selects etc.
204
205    inkscape_command('<svg...>', ('verb', 'VerbName'), ...)
206    """
207    with TemporaryDirectory(prefix='inkscape-command') as tmpdir:
208        svg_file = write_svg(svg, tmpdir, 'input.svg')
209        select = ('select', select) if select else None
210        verbs += ('FileSave', 'FileQuit')
211        inkscape(svg_file, select, batch_process=True, verb=';'.join(verbs))
212        with open(svg_file, 'rb') as fhl:
213            return fhl.read()
214
215def take_snapshot(svg, dirname, name='snapshot', ext='png', dpi=96, **kwargs):
216    """
217    Take a snapshot of the given svg file.
218
219    Resulting filename is yielded back, after generator finishes, the
220    file is deleted so you must deal with the file inside the for loop.
221    """
222    svg_file = write_svg(svg, dirname, name + '.svg')
223    ext_file = os.path.join(dirname, name + '.' + str(ext).lower())
224    inkscape(svg_file, export_dpi=dpi, export_filename=ext_file, export_type=ext, **kwargs)
225    return ext_file
226
227
228def is_inkscape_available():
229    """Return true if the Inkscape executable is available."""
230    try:
231        return bool(which(INKSCAPE_EXECUTABLE_NAME))
232    except CommandNotFound:
233        return False
234