diff --git a/INSTALL.rst b/INSTALL.rst index 0145863cfbcc..522ccac2059d 100644 --- a/INSTALL.rst +++ b/INSTALL.rst @@ -55,7 +55,8 @@ To run the test suite: directories from the source distribution. * install test dependencies: `pytest `_, MiKTeX, GhostScript, ffmpeg, avconv, ImageMagick, and `Inkscape - `_. + `_. On MacOS, GhostScript and Inkscape are not + needed. * run ``python -mpytest``. Third-party distributions of Matplotlib diff --git a/doc/users/next_whats_new/quicklook_converter.rst b/doc/users/next_whats_new/quicklook_converter.rst new file mode 100644 index 000000000000..35e053b6a016 --- /dev/null +++ b/doc/users/next_whats_new/quicklook_converter.rst @@ -0,0 +1,6 @@ +Image comparisons use QuickLook on MacOS +---------------------------------------- + +Previously, you had to install Inkscape and GhostScript to convert the +svg and pdf files generated by the test suite into bitmap files. Now, +we use MacOS's built-in QuickLook instead. diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 56ce243c7c70..a7af133a2081 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -268,8 +268,8 @@ def _get_executable_info(name): ---------- name : str The executable to query. The following values are currently supported: - "dvipng", "gs", "inkscape", "magick", "pdftops". This list is subject - to change without notice. + "dvipng", "gs", "inkscape", "magick", "pdftops", "qlmanage". This list + is subject to change without notice. Returns ------- @@ -378,6 +378,19 @@ def impl(args, regex, min_ver=None, ignore_exit_code=False): f"You have pdftops version {info.version} but the minimum " f"version supported by Matplotlib is 3.0") return info + elif name == "qlmanage": + try: + subprocess.check_call( + ["qlmanage", "-h"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + # qlmanage has no version number, use the OS version + version = subprocess.check_output(["sw_vers", "-productVersion"]) + except (FileNotFoundError, subprocess.CalledProcessError): + raise ExecutableNotFoundError() + else: + return _ExecInfo("qlmanage", version) else: raise ValueError("Unknown executable: {!r}".format(name)) diff --git a/lib/matplotlib/testing/compare.py b/lib/matplotlib/testing/compare.py index 06f2074e0ef7..dae4a6e0b028 100644 --- a/lib/matplotlib/testing/compare.py +++ b/lib/matplotlib/testing/compare.py @@ -36,7 +36,7 @@ def get_cache_dir(): return str(cache_dir) -def get_file_hash(path, block_size=2 ** 20): +def get_file_hash(path, block_size=2 ** 20, converter=None): md5 = hashlib.md5() with open(path, 'rb') as fd: while True: @@ -45,11 +45,13 @@ def get_file_hash(path, block_size=2 ** 20): break md5.update(data) - if Path(path).suffix == '.pdf': - md5.update(str(mpl._get_executable_info("gs").version) + if converter is not None: + md5.update(str(converter._version).encode('utf-8')) + elif Path(path).suffix == '.pdf': + md5.update(str(mpl._get_executable_info("gs")._version) .encode('utf-8')) elif Path(path).suffix == '.svg': - md5.update(str(mpl._get_executable_info("inkscape").version) + md5.update(str(mpl._get_executable_info("inkscape")._version) .encode('utf-8')) return md5.hexdigest() @@ -118,7 +120,34 @@ def _read_until(self, terminator): return bytes(buf[:-len(terminator)]) +class _QLConverter(_Converter): + def __init__(self): + super().__init__() + self._proc = None + self._tmpdir = TemporaryDirectory() + self._tmppath = Path(self._tmpdir.name) + self._version = mpl._get_executable_info("qlmanage").version + + def __call__(self, orig, dest): + try: + # qlmanage does not follow symlinks so copy the file + copied = str(self._tmppath / orig.name) + shutil.copy(orig, copied) + subprocess.check_call( + ["qlmanage", "-t", "-f4", "-o", self._tmpdir.name, copied], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + (self._tmppath / (orig.name + ".png")).rename(dest) + except Exception as e: + raise _ConverterError(e) + + class _GSConverter(_Converter): + def __init__(self): + super().__init__() + self._version = mpl._get_executable_info("gs").version + def __call__(self, orig, dest): if not self._proc: self._proc = subprocess.Popen( @@ -157,8 +186,12 @@ def encode_and_escape(name): class _SVGConverter(_Converter): + def __init__(self): + super().__init__() + self._version = mpl._get_executable_info("inkscape").version + def __call__(self, orig, dest): - old_inkscape = mpl._get_executable_info("inkscape").version < "1" + old_inkscape = self._version < "1" terminator = b"\n>" if old_inkscape else b"> " if not hasattr(self, "_tmpdir"): self._tmpdir = TemporaryDirectory() @@ -226,18 +259,26 @@ def __del__(self): def _update_converter(): + try: + mpl._get_executable_info("qlmanage") + except mpl.ExecutableNotFoundError: + pass + else: + converter['pdf'] = converter['svg'] = _QLConverter() try: mpl._get_executable_info("gs") except mpl.ExecutableNotFoundError: pass else: - converter['pdf'] = converter['eps'] = _GSConverter() + conv = _GSConverter() + converter.setdefault('eps', conv) + converter.setdefault('pdf', conv) try: mpl._get_executable_info("inkscape") except mpl.ExecutableNotFoundError: pass else: - converter['svg'] = _SVGConverter() + converter.setdefault('svg', _SVGConverter()) #: A dictionary that maps filename extensions to functions which @@ -283,15 +324,16 @@ def convert(filename, cache): # is out of date. if not newpath.exists() or newpath.stat().st_mtime < path.stat().st_mtime: cache_dir = Path(get_cache_dir()) if cache else None + cvt = converter[path.suffix[1:]] if cache_dir is not None: - hash_value = get_file_hash(path) + hash_value = get_file_hash(path, converter=cvt) cached_path = cache_dir / (hash_value + newpath.suffix) if cached_path.exists(): shutil.copyfile(cached_path, newpath) return str(newpath) - converter[path.suffix[1:]](path, newpath) + cvt(path, newpath) if cache_dir is not None: shutil.copyfile(newpath, cached_path)