66import atexit
77import functools
88import hashlib
9- import itertools
109import os
1110from pathlib import Path
1211import re
@@ -139,38 +138,81 @@ def _shlex_quote_bytes(b):
139138 else b"'" + b .replace (b"'" , b"'\" '\" '" ) + b"'" )
140139
141140
142- class _SVGConverter (object ):
141+ class _ConverterError (Exception ):
142+ pass
143+
144+
145+ class _Converter (object ):
143146 def __init__ (self ):
144147 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...
150152 atexit .register (self .__del__ )
151153
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 ()
161167 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+
173214
215+ class _SVGConverter (_Converter ):
174216 def __call__ (self , orig , dest ):
175217 if (not self ._proc # First run.
176218 or self ._proc .poll () is not None ): # Inkscape terminated.
@@ -187,23 +229,22 @@ def __call__(self, orig, dest):
187229 # seem to sometimes deadlock when stderr is redirected to a pipe,
188230 # so we redirect it to a temporary file instead. This is not
189231 # necessary anymore as of Inkscape 0.92.1.
190- self . _stderr = TemporaryFile ()
232+ stderr = TemporaryFile ()
191233 self ._proc = subprocess .Popen (
192234 ["inkscape" , "--without-gui" , "--shell" ],
193235 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" )
203243
204244 # Inkscape uses glib's `g_shell_parse_argv`, which has a consistent
205245 # 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 ]))
207248 if b"\n " in orig_b or b"\n " in dest_b :
208249 # Who knows whether the current folder name has a newline, or if
209250 # our encoding is even ASCII compatible... Just fall back on the
@@ -213,35 +254,22 @@ def fsencode(s):
213254 'inkscape' , '-z' , old , '--export-png' , new ])(orig , dest )
214255 self ._proc .stdin .write (orig_b + b" --export-png=" + dest_b + b"\n " )
215256 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...
221263 self ._stderr .seek (0 )
222264 raise ImageComparisonFailure (
223265 self ._stderr .read ().decode (
224266 sys .getfilesystemencoding (), "replace" ))
225267
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-
235268
236269def _update_converter ():
237270 gs , gs_v = matplotlib .checkdep_ghostscript ()
238271 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 ()
245273 if matplotlib .checkdep_inkscape () is not None :
246274 converter ['svg' ] = _SVGConverter ()
247275
0 commit comments