From dd70c44f5d2222d494b575f17730238a5c9f4116 Mon Sep 17 00:00:00 2001 From: Josh Burnett Date: Sun, 28 Mar 2021 20:09:23 -0400 Subject: [PATCH] 3.0.0 release: Change Windows behavior, using PIL if available (defaulting to DIB format). Lots of comment updates & README updates. --- LICENSE | 21 ++++++ MANIFEST.in | 3 +- README.md | 46 +++++++++++-- addcopyfighandler.py | 141 +++++++++++++++++++-------------------- generate_source_dist.bat | 3 +- setup.py | 31 +++++---- 6 files changed, 148 insertions(+), 97 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..377ed23 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 joshburnett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 4bf4483..74215c3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include README.md \ No newline at end of file +include README.md +include LICENSE \ No newline at end of file diff --git a/README.md b/README.md index da3527f..8d221da 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,54 @@ addcopyfighandler: Add a Ctrl+C handler to matplotlib figures for copying the figure to the clipboard ====================================================================================================== -Simply importing this module (after importing matplotlib or pyplot) will add a handler +Importing this module (after importing matplotlib or pyplot) will add a handler +to all subsequently-created matplotlib figures so that pressing Ctrl+C with a matplotlib figure window selected will copy -the figure to the clipboard as an image. +the figure to the clipboard as an image. The copied image is generated through +matplotlib.pyplot.savefig(), and thus is affected by the relevant rcParams +settings (savefig.dpi, savefig.format, etc.). -Original concept code taken from: -https://stackoverflow.com/questions/31607458/how-to-add-clipboard-support-to-matplotlib-figures +Windows and Linux are currently supported. Pull requests implementing macOS support are welcome. + +Uses code & concepts from: +- https://stackoverflow.com/questions/31607458/how-to-add-clipboard-support-to-matplotlib-figures +- https://stackoverflow.com/questions/34322132/copy-image-to-clipboard-in-python3 + + +## Windows-specific behavior: + +- addcopyfighandler should work regardless of which graphical backend is being used by matplotlib + (tkagg, gtk3agg, qt5agg, etc.). +- If `matplotlib.rcParams['savefig.format']` is `'svg'`, the figure will be copied to the clipboard + as an SVG. +- If Pillow is installed, all non-SVG format specifiers will be overridden, and the + figure will be copied to the clipboard as a Device-Independant Bitmap. +- If Pillow is not installed, the supported format specifiers are `'png'`, `'jpg'`, `'jpeg'`, and `'svg'`. + All other format specifiers will be overridden, and the figure will be copied to the clipboard as PNG data. + + +## Linux-specific behavior: + +- Requires either Qt or GTK libraries for clipboard interaction. Automatically detects which is being used from + `matplotlib.get_backend()`. + - Qt support requires `PyQt5` or `PySide2`. + - GTK support requires `pycairo`, `PyGObject` and `PIL` or `pillow` to be installed. +- The figure will be copied to the clipboard as a PNG, regardless of `matplotlib.rcParams['savefig.format']`. Alas, SVG output is not currently supported. Pull requests that enable SVG support would be welcomed. Releases -------- +### 3.0.0: 2021-03-28 + +- Add Linux support (tested on Ubuntu). Requires PyQt5, PySide2, or PyObject libraries; relevant library chosen based on matplotlib graphical backend in use. No SVG support. +- On Windows, non SVG-formats will now use the Pillow library if installed, storing the figure to the clipboard as a device-indepenent bitmap (as previously handled in v2.0). This is compatible with a wider range of Windows applications. + ### 2.1.0: 2020-08-27 -- Remove Pillow -- Add support for png, svg, jpg, jpeg fileformat. +- Remove Pillow. +- Add support for png & svg file formats. -### 2.0.0: 2019-04-15 +### 2.0.0: 2019-06-07 - Remove Qt requirement. Now use Pillow to grab the figure image, and win32clipboard to manage the Windows clipboard. diff --git a/addcopyfighandler.py b/addcopyfighandler.py index bc36772..eecc8e9 100644 --- a/addcopyfighandler.py +++ b/addcopyfighandler.py @@ -2,6 +2,13 @@ """ Monkey-patch plt.figure() to support Ctrl+C for copying to clipboard as an image +Importing this module (after importing matplotlib or pyplot) will add a handler +to all subsequently-created matplotlib figures +so that pressing Ctrl+C with a matplotlib figure window in the foreground will copy +the figure to the clipboard as an image. The copied image is generated through +matplotlib.pyplot.savefig(), and thus is affected by the relevant matplotlib.rcParams +settings (savefig.dpi, savefig.format, etc.). + @authors: Josh Burnett, Sylvain Finot Modified from code found on Stack Exchange: https://stackoverflow.com/questions/31607458/how-to-add-clipboard-support-to-matplotlib-figures @@ -11,22 +18,29 @@ On Windows: - addcopyfighandler should work regardless of which graphical backend is being used by matplotlib - (tkagg, gtk3agg, qt4agg, qt5agg, etc.) - - Supported clipboard image file formats are PNG, SVG, JPG, and JPEG. + (tkagg, gtk3agg, qt5agg, etc.) + - If matplotlib.rcParams['savefig.format'] is 'svg,' the figure will be copied to the clipboard + as an SVG. + - If Pillow is installed, all non-SVG format specifiers will be overridden and the + figure will be copied to the clipboard as a Device-Independant Bitmap. + - If Pillow is not installed, the supported format specifiers are 'png,' 'jpg,' 'jpeg,' and 'svg.' + All other format specifiers will be overridden and the figure will be copied to the clipboard as PNG data. On Linux: - Requires either Qt or GTK libraries for clipboard interaction. Automatically detects which is being used from matplotlib.get_backend(). - - Qt support requires one of (PyQt4, PyQt5, PySide2). + - Qt support requires PyQt5 or PySide2. - GTK support requires pycairo, PyGObject and PIL/pillow to be installed. - + - The figure will be copied to the clipboard as a PNG, regardless of matplotlib.rcParams['savefig.format']. """ import platform import matplotlib.pyplot as plt from io import BytesIO -__version__ = (3, 0, 0) +__version__ = '3.0.0' +__version_info__ = tuple(int(i) if i.isdigit() else i for i in __version__.split('.')) + oldfig = plt.figure ostype = platform.system().lower() @@ -57,40 +71,54 @@ def copyfig(fig=None, format=None, *args, **kwargs): """ # Determined available values by digging into windows API - format_map = {"png": "PNG", - "svg": "image/svg+xml", - "jpg": "JFIF", - "jpeg": "JFIF"} + format_map = {'png': 'PNG', + 'svg': 'image/svg+xml', + 'jpg': 'JFIF', + 'jpeg': 'JFIF', + } # If no format is passed to savefig get the default one if format is None: - format = plt.rcParams["savefig.format"] + format = plt.rcParams['savefig.format'] format = format.lower() if format not in format_map: - raise ValueError(f"Format {format} is not supported " - f"(supported formats: {', '.join(list(format_map.keys()))})") + format = 'png' if fig is None: # Find the figure window that has UI focus right now (not necessarily # the same as plt.gcf() when in interactive mode) fig_window_text = GetWindowText(GetForegroundWindow()) for i in plt.get_fignums(): - if plt.figure(i).canvas.get_window_title() == fig_window_text: + if plt.figure(i).canvas.manager.get_window_title() == fig_window_text: fig = plt.figure(i) break if fig is None: - raise AttributeError("No figure found!") + raise AttributeError('No figure found!') # Store the image in a buffer using savefig(). This has the # advantage of applying all the default savefig parameters # such as resolution and background color, which would be ignored - # if you simply grab the canvas. - format_id = win32clipboard.RegisterClipboardFormat(format_map[format]) + # if we simply grab the canvas as displayed. with BytesIO() as buf: fig.savefig(buf, format=format, *args, **kwargs) - data = buf.getvalue() + + if format != 'svg': + try: + from PIL import Image + im = Image.open(buf) + with BytesIO() as output: + im.convert("RGB").save(output, "BMP") + data = output.getvalue()[14:] # The file header off-set of BMP is 14 bytes + format_id = win32clipboard.CF_DIB # DIB = device independent bitmap + + except ImportError: + data = buf.getvalue() + format_id = win32clipboard.RegisterClipboardFormat(format_map[format]) + else: + data = buf.getvalue() + format_id = win32clipboard.RegisterClipboardFormat(format_map[format]) win32clipboard.OpenClipboard() win32clipboard.EmptyClipboard() @@ -99,20 +127,16 @@ def copyfig(fig=None, format=None, *args, **kwargs): elif ostype == 'linux': backend = plt.get_backend() - if backend in ['Qt5Agg', 'Qt4Agg']: - if backend == 'Qt4Agg': - from PyQt4.QtGui import QApplication, QImage - clipboard = QApplication.clipboard - if backend == 'Qt5Agg': - try: - from PySide2.QtGui import QGuiApplication, QImage - from PySide2.QtWidgets import QApplication - except ImportError: - from PyQt5.QtGui import QGuiApplication, QImage - from PyQt5.QtWidgets import QApplication - clipboard = QGuiApplication.clipboard - - def copyfig(fig=None, format=None, *args, **kwargs): + if backend == 'Qt5Agg': + try: + from PySide2.QtGui import QGuiApplication, QImage + from PySide2.QtWidgets import QApplication + except ImportError: + from PyQt5.QtGui import QGuiApplication, QImage + from PyQt5.QtWidgets import QApplication + clipboard = QGuiApplication.clipboard + + def copyfig(fig=None, *args, **kwargs): """ Parameters ---------- @@ -130,37 +154,29 @@ def copyfig(fig=None, format=None, *args, **kwargs): If no figure is found """ - if format is None: - format = plt.rcParams["savefig.format"] - format = format.lower() - - formats = ['png', 'jpg', 'jpeg', 'tiff'] - if format not in formats: - format = 'png' - if fig is None: # Find the figure window that has UI focus right now (not necessarily # the same as plt.gcf() when in interactive mode) fig_window_text = QApplication.activeWindow().windowTitle() for i in plt.get_fignums(): - if plt.figure(i).canvas.get_window_title() == fig_window_text: + if plt.figure(i).canvas.manager.get_window_title() == fig_window_text: fig = plt.figure(i) break if fig is None: - raise AttributeError("No figure found!") + raise AttributeError('No figure found!') # Store the image in a buffer using savefig(). This has the # advantage of applying all the default savefig parameters # such as resolution and background color, which would be ignored - # if you simply grab the canvas. + # if we simply grab the canvas as displayed. with BytesIO() as buf: - fig.savefig(buf, format=format, *args, **kwargs) + fig.savefig(buf, format='png', *args, **kwargs) clipboard().setImage(QImage.fromData(buf.getvalue())) elif backend == 'GTK3Agg': import gi - gi.require_version("Gtk", "3.0") + gi.require_version('Gtk', '3.0') from gi.repository import Gtk from gi.repository.Gtk import Clipboard from gi.repository import GLib, GdkPixbuf, Gdk @@ -169,15 +185,12 @@ def copyfig(fig=None, format=None, *args, **kwargs): clipboard = Clipboard.get(Gdk.SELECTION_CLIPBOARD) - def copyfig(fig=None, format=None, *args, **kwargs): + def copyfig(fig=None, *args, **kwargs): """ Parameters ---------- fig : matplotlib figure, optional If None, get the figure that has UI focus - format : type of image to be pasted to the clipboard ('png', 'jpg', 'jpeg', 'tiff') - If None, uses matplotlib.rcParams["savefig.format"] - If resulting format is not in ('png', 'jpg', 'jpeg', 'tiff'), will override to PNG. *args : arguments that are passed to savefig **kwargs : keywords arguments that are passed to savefig @@ -187,20 +200,6 @@ def copyfig(fig=None, format=None, *args, **kwargs): If no figure is found """ - if format is None: - format = plt.rcParams["savefig.format"] - format = format.lower() - - format_map = { - 'png': 'PNG', - 'jpg': 'JPEG', - 'jpeg': 'JPEG', - 'tiff': 'TIFF', - } - - if format not in format_map: - format = 'png' - if fig is None: # Find the figure window that has UI focus right now (not necessarily # the same as plt.gcf() when in interactive mode) @@ -214,33 +213,27 @@ def copyfig(fig=None, format=None, *args, **kwargs): ) for i in plt.get_fignums(): - if plt.figure(i).canvas.get_window_title() == fig_window_text: + if plt.figure(i).canvas.manager.get_window_title() == fig_window_text: fig = plt.figure(i) break # Store the image in a buffer using savefig(). This has the # advantage of applying all the default savefig parameters # such as resolution and background color, which would be ignored - # if you simply grab the canvas. + # if we simply grab the canvas as displayed. with BytesIO() as buf: fig.savefig(buf, format=format, *args, **kwargs) - im = Image.open(buf, formats=[format_map[format]]) + im = Image.open(buf, formats=['PNG']) w, h = im.size data = GLib.Bytes.new(im.tobytes()) - if im.mode == 'RGBA': - pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, - True, 8, w, h, w * 4) - elif im.mode == 'RGB': - pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, - False, 8, w, h, w * 3) - else: - raise ValueError(f'Unsupported image format ({im.mode}). Must be RGB or RGBA.') + pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, + True, 8, w, h, w * 4) clipboard.set_image(pixbuf) clipboard.store() else: - raise ValueError(f'Unsupported matplotlib backend ({backend}). On Linux must be Qt4Agg, Qt5Agg, or GTK3Agg.') + raise ValueError(f'Unsupported matplotlib backend ({backend}). On Linux must be Qt5Agg, or GTK3Agg.') else: raise ValueError(f'addcopyfighandler: Supported OSes are Windows and Linux. Current OS: {ostype}') diff --git a/generate_source_dist.bat b/generate_source_dist.bat index 172f066..7806a8e 100644 --- a/generate_source_dist.bat +++ b/generate_source_dist.bat @@ -1 +1,2 @@ -python setup.py sdist --formats=zip bdist_wheel --universal +python setup.py sdist --formats=gztar +python setup.py bdist_wheel --universal \ No newline at end of file diff --git a/setup.py b/setup.py index 058fa0f..365331c 100644 --- a/setup.py +++ b/setup.py @@ -10,28 +10,29 @@ def get_version(filename='addcopyfighandler.py'): version = '' with open(filename, 'r') as fp: for line in fp: - m = re.search('__version__ .* \((.*)\)', line) + m = re.search("__version__ = '(.*)'", line) if m is not None: - version = m.group(1).replace(', ', '.') + version = m.group(1) break return version # What packages are required for this module to be executed? REQUIRED = [ - 'matplotlib', 'pywin32', 'pillow', + 'matplotlib', + 'pywin32;platform_system=="Windows"', ] setup( - name = "addcopyfighandler", - version = get_version(), + name="addcopyfighandler", + version=get_version(), py_modules=["addcopyfighandler"], install_requires=REQUIRED, classifiers=[ - 'Development Status :: 4 - Beta', + 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', @@ -40,16 +41,18 @@ def get_version(filename='addcopyfighandler.py'): 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', ], # metadata for upload to PyPI - author = "Josh Burnett", - author_email = "github@burnettsonline.org", - description = "Adds a Ctrl+C handler to matplotlib figures for copying the figure to the clipboard", + author="Josh Burnett", + author_email="github@burnettsonline.org", + description="Adds a Ctrl+C handler to matplotlib figures for copying the figure to the clipboard", long_description=long_description, long_description_content_type="text/markdown", - license = "MIT", - keywords = "addcopyfighandler figure matplotlib handler copy", - url = "https://github.com/joshburnett/addcopyfighandler", - platform="windows", -) \ No newline at end of file + license="MIT", + keywords="addcopyfighandler figure matplotlib handler copy", + url="https://github.com/joshburnett/addcopyfighandler", + platforms=['windows', 'linux'], +)