6
6
import atexit
7
7
import functools
8
8
import hashlib
9
- import itertools
10
9
import os
11
10
from pathlib import Path
12
11
import re
@@ -139,38 +138,81 @@ def _shlex_quote_bytes(b):
139
138
else b"'" + b .replace (b"'" , b"'\" '\" '" ) + b"'" )
140
139
141
140
142
- class _SVGConverter (object ):
141
+ class _ConverterError (Exception ):
142
+ pass
143
+
144
+
145
+ class _Converter (object ):
143
146
def __init__ (self ):
144
147
self ._proc = None
145
- # We cannot rely on the GC to trigger `__del__` at exit because
146
- # other modules (e.g. `subprocess`) may already have their globals
147
- # set to `None`, which make `proc.communicate` or `proc.terminate`
148
- # fail. By relying on `atexit` we ensure the destructor runs before
149
- # `None`-setting occurs.
148
+ # Explicitly register deletion from an atexit handler because if we
149
+ # wait until the object is GC'd (which occurs later), then some module
150
+ # globals (e.g. signal.SIGKILL) has already been set to None, and
151
+ # kill() doesn't work anymore...
150
152
atexit .register (self .__del__ )
151
153
152
- def _read_to_prompt (self ):
153
- """Did Inkscape reach the prompt without crashing?
154
- """
155
- stream = iter (functools .partial (self ._proc .stdout .read , 1 ), b"" )
156
- prompt = (b"\n " , b">" )
157
- n = len (prompt )
158
- its = itertools .tee (stream , n )
159
- for i , it in enumerate (its ):
160
- next (itertools .islice (it , i , i ), None ) # Advance `it` by `i`.
154
+ def __del__ (self ):
155
+ if self ._proc :
156
+ self ._proc .kill ()
157
+ self ._proc .wait ()
158
+ for stream in filter (None , [self ._proc .stdin ,
159
+ self ._proc .stdout ,
160
+ self ._proc .stderr ]):
161
+ stream .close ()
162
+ self ._proc = None
163
+
164
+ def _read_until (self , terminator ):
165
+ """Read until the prompt is reached."""
166
+ buf = bytearray ()
161
167
while True :
162
- window = tuple (map (next , its ))
163
- if len (window ) != n :
164
- # Ran out of data -- one of the `next(it)` raised
165
- # StopIteration, so the tuple is shorter.
166
- return False
167
- if self ._proc .poll () is not None :
168
- # Inkscape exited.
169
- return False
170
- if window == prompt :
171
- # Successfully read until prompt.
172
- return True
168
+ c = self ._proc .stdout .read (1 )
169
+ if not c :
170
+ raise _ConverterError
171
+ buf .extend (c )
172
+ if buf .endswith (terminator ):
173
+ return bytes (buf [:- len (terminator )])
174
+
175
+
176
+ class _GSConverter (_Converter ):
177
+ def __call__ (self , orig , dest ):
178
+ if not self ._proc :
179
+ self ._stdout = TemporaryFile ()
180
+ self ._proc = subprocess .Popen (
181
+ [matplotlib .checkdep_ghostscript .executable ,
182
+ "-dNOPAUSE" , "-sDEVICE=png16m" ],
183
+ # As far as I can see, ghostscript never outputs to stderr.
184
+ stdin = subprocess .PIPE , stdout = subprocess .PIPE )
185
+ try :
186
+ self ._read_until (b"\n GS" )
187
+ except _ConverterError :
188
+ raise OSError ("Failed to start Ghostscript" )
189
+
190
+ def encode_and_escape (name ):
191
+ return (os .fsencode (name )
192
+ .replace (b"\\ " , b"\\ \\ " )
193
+ .replace (b"(" , br"\(" )
194
+ .replace (b")" , br"\)" ))
195
+
196
+ self ._proc .stdin .write (
197
+ b"<< /OutputFile ("
198
+ + encode_and_escape (dest )
199
+ + b") >> setpagedevice ("
200
+ + encode_and_escape (orig )
201
+ + b") run flush\n " )
202
+ self ._proc .stdin .flush ()
203
+ # GS> if nothing left on the stack; GS<n> if n items left on the stack.
204
+ err = self ._read_until (b"GS" )
205
+ stack = self ._read_until (b">" )
206
+ if stack or not os .path .exists (dest ):
207
+ stack_size = int (stack [1 :]) if stack else 0
208
+ self ._proc .stdin .write (b"pop\n " * stack_size )
209
+ # Using the systemencoding should at least get the filenames right.
210
+ raise ImageComparisonFailure (
211
+ (err + b"GS" + stack + b">" )
212
+ .decode (sys .getfilesystemencoding (), "replace" ))
213
+
173
214
215
+ class _SVGConverter (_Converter ):
174
216
def __call__ (self , orig , dest ):
175
217
if (not self ._proc # First run.
176
218
or self ._proc .poll () is not None ): # Inkscape terminated.
@@ -187,23 +229,22 @@ def __call__(self, orig, dest):
187
229
# seem to sometimes deadlock when stderr is redirected to a pipe,
188
230
# so we redirect it to a temporary file instead. This is not
189
231
# necessary anymore as of Inkscape 0.92.1.
190
- self . _stderr = TemporaryFile ()
232
+ stderr = TemporaryFile ()
191
233
self ._proc = subprocess .Popen (
192
234
["inkscape" , "--without-gui" , "--shell" ],
193
235
stdin = subprocess .PIPE , stdout = subprocess .PIPE ,
194
- stderr = self ._stderr , env = env )
195
- if not self ._read_to_prompt ():
196
- raise OSError ("Failed to start Inkscape" )
197
-
198
- try :
199
- fsencode = os .fsencode
200
- except AttributeError : # Py2.
201
- def fsencode (s ):
202
- return s .encode (sys .getfilesystemencoding ())
236
+ stderr = stderr , env = env )
237
+ # Slight abuse, but makes shutdown handling easier.
238
+ self ._proc .stderr = stderr
239
+ try :
240
+ self ._read_until (b"\n >" )
241
+ except _ConverterError :
242
+ raise OSError ("Failed to start Inkscape in interactive mode" )
203
243
204
244
# Inkscape uses glib's `g_shell_parse_argv`, which has a consistent
205
245
# behavior across platforms, so we can just use `shlex.quote`.
206
- orig_b , dest_b = map (_shlex_quote_bytes , map (fsencode , [orig , dest ]))
246
+ orig_b , dest_b = map (_shlex_quote_bytes ,
247
+ map (os .fsencode , [orig , dest ]))
207
248
if b"\n " in orig_b or b"\n " in dest_b :
208
249
# Who knows whether the current folder name has a newline, or if
209
250
# our encoding is even ASCII compatible... Just fall back on the
@@ -213,35 +254,22 @@ def fsencode(s):
213
254
'inkscape' , '-z' , old , '--export-png' , new ])(orig , dest )
214
255
self ._proc .stdin .write (orig_b + b" --export-png=" + dest_b + b"\n " )
215
256
self ._proc .stdin .flush ()
216
- if not self ._read_to_prompt ():
217
- # Inkscape's output is not localized but gtk's is, so the
218
- # output stream probably has a mixed encoding. Using
219
- # `getfilesystemencoding` should at least get the filenames
220
- # right...
257
+ try :
258
+ self ._read_until (b"\n >" )
259
+ except _ConverterError :
260
+ # Inkscape's output is not localized but gtk's is, so the output
261
+ # stream probably has a mixed encoding. Using the filesystem
262
+ # encoding should at least get the filenames right...
221
263
self ._stderr .seek (0 )
222
264
raise ImageComparisonFailure (
223
265
self ._stderr .read ().decode (
224
266
sys .getfilesystemencoding (), "replace" ))
225
267
226
- def __del__ (self ):
227
- if self ._proc :
228
- if self ._proc .poll () is None : # Not exited yet.
229
- self ._proc .communicate (b"quit\n " )
230
- self ._proc .wait ()
231
- self ._proc .stdin .close ()
232
- self ._proc .stdout .close ()
233
- self ._stderr .close ()
234
-
235
268
236
269
def _update_converter ():
237
270
gs , gs_v = matplotlib .checkdep_ghostscript ()
238
271
if gs_v is not None :
239
- def cmd (old , new ):
240
- return [str (gs ), '-q' , '-sDEVICE=png16m' , '-dNOPAUSE' , '-dBATCH' ,
241
- '-sOutputFile=' + new , old ]
242
- converter ['pdf' ] = make_external_conversion_command (cmd )
243
- converter ['eps' ] = make_external_conversion_command (cmd )
244
-
272
+ converter ['pdf' ] = converter ['eps' ] = _GSConverter ()
245
273
if matplotlib .checkdep_inkscape () is not None :
246
274
converter ['svg' ] = _SVGConverter ()
247
275
0 commit comments