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