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