├── .gitignore ├── LICENSE ├── pyproject.toml ├── README.md └── src └── addcopyfighandler.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.egg-info 3 | __pycache__ 4 | build 5 | dist 6 | .venv* 7 | venv* 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Josh Burnett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "addcopyfighandler" 7 | authors=[ 8 | {name = "Josh Burnett", email="github@burnettsonline.org"} 9 | ] 10 | description="Adds a Ctrl+C handler to matplotlib figures for copying the figure to the clipboard" 11 | readme = "README.md" 12 | dynamic = ["version"] 13 | dependencies = [ 14 | "matplotlib", 15 | "pywin32;platform_system=='Windows'" 16 | ] 17 | keywords=["addcopyfighandler", "figure", "matplotlib", "handler", "copy"] 18 | platforms=['windows', 'linux'] 19 | classifiers = [ 20 | 'Development Status :: 5 - Production/Stable', 21 | 'Intended Audience :: Developers', 22 | 'License :: OSI Approved :: MIT License', 23 | 'Programming Language :: Python', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.7', 26 | 'Programming Language :: Python :: 3.8', 27 | 'Programming Language :: Python :: 3.9', 28 | 'Programming Language :: Python :: 3.10', 29 | 'Programming Language :: Python :: 3.11', 30 | 'Programming Language :: Python :: 3.12', 31 | ] 32 | [project.urls] 33 | Repository = "https://github.com/joshburnett/addcopyfighandler" 34 | 35 | [tool.flit.sdist] 36 | exclude = [ 37 | "dist", 38 | "build", 39 | ".gitignore", 40 | ".venv" 41 | ] 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | addcopyfighandler: Add a Ctrl+C / Cmd+C handler to matplotlib figures for copying the figure to the clipboard 2 | ====================================================================================================== 3 | 4 | Importing this module (after importing matplotlib or pyplot) will add a handler 5 | to all subsequently-created matplotlib figures 6 | so that pressing Ctrl+C (or Cmd+C on MacOS) with a matplotlib figure window selected will copy 7 | the figure to the clipboard as an image. The copied image is generated through 8 | matplotlib.pyplot.savefig(), and thus is affected by the relevant rcParams 9 | settings (savefig.dpi, savefig.format, etc.). 10 | 11 | Uses code & concepts from: 12 | - https://stackoverflow.com/questions/31607458/how-to-add-clipboard-support-to-matplotlib-figures 13 | - https://stackoverflow.com/questions/34322132/copy-image-to-clipboard-in-python3 14 | 15 | 16 | ## Windows-specific behavior: 17 | 18 | - addcopyfighandler should work regardless of which graphical backend is being used by matplotlib 19 | (tkagg, gtk3agg, qtagg, etc.). 20 | - If `matplotlib.rcParams['savefig.format']` is `'svg'`, the figure will be copied to the clipboard 21 | as an SVG. 22 | - If Pillow is installed, all non-SVG format specifiers will be overridden, and the 23 | figure will be copied to the clipboard as a Device-Independant Bitmap. 24 | - If Pillow is not installed, the supported format specifiers are `'png'`, `'jpg'`, `'jpeg'`, and `'svg'`. 25 | All other format specifiers will be overridden, and the figure will be copied to the clipboard as PNG data. 26 | 27 | 28 | ## Linux-specific behavior: 29 | 30 | - Requires either Qt or GTK libraries for clipboard interaction. Automatically detects which is being used from 31 | `matplotlib.get_backend()`. 32 | - Qt support requires `PyQt5`, `PyQt6`, `PySide2` or `PySide6`. 33 | - GTK support requires `pycairo`, `PyGObject` and `PIL` or `pillow` to be installed. 34 | - Only GTK 3 is supported, as GTK 4 has totally changed the way clipboard data is handled and I can't figure 35 | it out. I'm totally open to someone else solving this and submitting a PR if they want. I don't use GTK. 36 | - 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. 37 | 38 | 39 | ## MacOS-specific behavior: 40 | 41 | - Requires Qt, whether PyQt5/6 or PySide2/6. 42 | - The figure will be copied to the clipboard as a PNG, regardless of matplotlib.rcParams['savefig.format']. 43 | 44 | Releases 45 | -------- 46 | ### 3.2.4: 2024-09-03 47 | 48 | - Fix [issue when using the Agg backend on Windows](https://github.com/joshburnett/addcopyfighandler/issues/20), which doesn't support get_window_title(). 49 | 50 | ### 3.2.3: 2024-09-03 51 | 52 | - Fixed [an issue with copying when there are multiple figures](https://github.com/joshburnett/addcopyfighandler/issues/19). 53 | 54 | ### 3.2.2: 2024-06-24 55 | 56 | - Thanks to @rgw5267 for finding and fixing [a bug related to the event handler being registered multiple times](https://github.com/joshburnett/addcopyfighandler/issues/17). 57 | 58 | ### 3.2.1: 2024-06-13 59 | 60 | - Made backend checks case-insensitive due to undocumented changes in matplotlib 3.9. 61 | 62 | ### 3.2.0: 2024-02-13 63 | 64 | - Added MacOS support (thanks @orlp!). No SVG support, same as Linux. 65 | 66 | ### 3.1.1: 2024-02-13 67 | 68 | - Wrap matplotlib.pyploy.figure appropriately to maintain docstring (thanks @eendebakpt!) 69 | 70 | ### 3.1.0: 2024-02-13 71 | 72 | - Add support for PyQt6 and PySide6 on Linux (already supported on Windows) 73 | 74 | ### 3.0.0: 2021-03-28 75 | 76 | - 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. 77 | - 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. 78 | 79 | ### 2.1.0: 2020-08-27 80 | 81 | - Remove Pillow. 82 | - Add support for png & svg file formats. 83 | 84 | ### 2.0.0: 2019-06-07 85 | 86 | - Remove Qt requirement. Now use Pillow to grab the figure image, and win32clipboard to manage the Windows clipboard. 87 | 88 | 89 | ### 1.0.2: 2018-11-27 90 | 91 | - Force use of Qt4Agg or Qt5Agg. Some installs will default to TkAgg backend, which this module 92 | doesn't support. Forcing the backend to switch when loading this module saves the user from having 93 | to manually specify one of the Qt backends in every analysis. 94 | 95 | 96 | ### 1.0.1: 2018-11-27 97 | 98 | - Improve setup.py: remove need for importing module, add proper installation dependencies 99 | - Change readme from ReST to Markdown 100 | 101 | 102 | ### 1.0: 2017-08-09 103 | 104 | - Initial release 105 | 106 | -------------------------------------------------------------------------------- /src/addcopyfighandler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Monkey-patch plt.figure() to support Ctrl+C & Cmd+C for copying to clipboard as an image 3 | 4 | Importing this module (after importing matplotlib or pyplot) will add a handler 5 | to all subsequently-created matplotlib figures 6 | so that pressing Ctrl+C (or Cmd+C on MacOS) with a matplotlib figure window in the foreground will copy 7 | the figure to the clipboard as an image. The copied image is generated through 8 | matplotlib.pyplot.savefig(), and thus is affected by the relevant matplotlib.rcParams 9 | settings (savefig.dpi, savefig.format, etc.). 10 | 11 | @authors: Josh Burnett, Sylvain Finot, Orson Peters 12 | Modified from code found on Stack Exchange: 13 | https://stackoverflow.com/questions/31607458/how-to-add-clipboard-support-to-matplotlib-figures 14 | https://stackoverflow.com/questions/34322132/copy-image-to-clipboard-in-python3 15 | 16 | Different OSes and GUI libraries require different methods of clipboard interaction. 17 | 18 | On Windows: 19 | - addcopyfighandler should work regardless of which graphical backend is being used by matplotlib 20 | (tkagg, gtk3agg, qt5agg, etc.) 21 | - If matplotlib.rcParams['savefig.format'] is 'svg,' the figure will be copied to the clipboard 22 | as an SVG. 23 | - If Pillow is installed, all non-SVG format specifiers will be overridden and the 24 | figure will be copied to the clipboard as a Device-Independant Bitmap. 25 | - If Pillow is not installed, the supported format specifiers are 'png,' 'jpg,' 'jpeg,' and 'svg.' 26 | All other format specifiers will be overridden and the figure will be copied to the clipboard as PNG data. 27 | 28 | On Linux: 29 | - Requires either Qt or GTK3 libraries for clipboard interaction. Automatically detects which is being used from 30 | matplotlib.get_backend(). 31 | - Qt support requires PyQt5/6 or PySide2/6. 32 | - GTK3 support requires pycairo, PyGObject and PIL/pillow to be installed. 33 | - GTK4 not supported, as it has totally changed the way clipboard data is handled and I can't figure it out. 34 | - The figure will be copied to the clipboard as a PNG, regardless of matplotlib.rcParams['savefig.format']. 35 | 36 | On MacOS 37 | - Requires Qt, whether PyQt5/6 or PySide2/6. 38 | - The figure will be copied to the clipboard as a PNG, regardless of matplotlib.rcParams['savefig.format']. 39 | """ 40 | 41 | import platform 42 | from io import BytesIO 43 | from functools import wraps 44 | 45 | import matplotlib.backends 46 | import matplotlib.pyplot as plt 47 | 48 | __version__ = '3.2.4' 49 | __version_info__ = tuple(int(i) if i.isdigit() else i for i in __version__.split('.')) 50 | 51 | oldfig = plt.figure 52 | 53 | ostype = platform.system().lower() 54 | if ostype == 'darwin': 55 | plt.switch_backend('qtagg') 56 | 57 | backend = plt.get_backend() 58 | 59 | if ostype == 'windows': 60 | from win32gui import GetWindowText, GetForegroundWindow 61 | import win32clipboard 62 | 63 | def copyfig(fig=None, format=None, *args, **kwargs): 64 | """ 65 | Parameters 66 | ---------- 67 | fig : matplotlib figure, optional 68 | If None, get the figure that has UI focus 69 | format : type of image to be pasted to the clipboard ('png', 'svg', 'jpg', 'jpeg') 70 | If None, uses matplotlib.rcParams["savefig.format"] 71 | *args : arguments that are passed to savefig 72 | **kwargs : keywords arguments that are passed to savefig 73 | 74 | Raises 75 | ------ 76 | ValueError 77 | If the desired format is not supported. 78 | 79 | AttributeError 80 | If no figure is found 81 | """ 82 | 83 | # Determined available values by digging into windows API 84 | format_map = {'png': 'PNG', 85 | 'svg': 'image/svg+xml', 86 | 'jpg': 'JFIF', 87 | 'jpeg': 'JFIF', 88 | } 89 | 90 | # If no format is passed to savefig get the default one 91 | if format is None: 92 | format = plt.rcParams['savefig.format'] 93 | format = format.lower() 94 | 95 | if format not in format_map: 96 | format = 'png' 97 | 98 | if fig is None: 99 | # Find the figure window that has UI focus right now (not necessarily 100 | # the same as plt.gcf() when in interactive mode) 101 | fig_window_text = GetWindowText(GetForegroundWindow()) 102 | for i in plt.get_fignums(): 103 | fig_to_check = oldfig(i) 104 | if fig_to_check.canvas.manager.get_window_title() == fig_window_text: 105 | fig = fig_to_check 106 | break 107 | else: 108 | try: 109 | fig_window_text = fig.canvas.manager.get_window_title() 110 | except AttributeError: 111 | # A figure was specified, but the backend doesn't support get_window_title(). This happens w/ Agg. 112 | fig_window_text = None 113 | 114 | if fig is None: 115 | raise AttributeError('No figure found!') 116 | 117 | # Store the image in a buffer using savefig(). This has the 118 | # advantage of applying all the default savefig parameters 119 | # such as resolution and background color, which would be ignored 120 | # if we simply grab the canvas as displayed. 121 | with BytesIO() as buf: 122 | fig.savefig(buf, format=format, *args, **kwargs) 123 | 124 | if format != 'svg': 125 | try: 126 | from PIL import Image 127 | im = Image.open(buf) 128 | with BytesIO() as output: 129 | im.convert("RGB").save(output, "BMP") 130 | data = output.getvalue()[14:] # The file header off-set of BMP is 14 bytes 131 | format_id = win32clipboard.CF_DIB # DIB = device independent bitmap 132 | 133 | except ImportError: 134 | data = buf.getvalue() 135 | format_id = win32clipboard.RegisterClipboardFormat(format_map[format]) 136 | else: 137 | data = buf.getvalue() 138 | format_id = win32clipboard.RegisterClipboardFormat(format_map[format]) 139 | 140 | win32clipboard.OpenClipboard() 141 | win32clipboard.EmptyClipboard() 142 | win32clipboard.SetClipboardData(format_id, data) 143 | win32clipboard.CloseClipboard() 144 | 145 | if fig_window_text is not None: 146 | print(f'Figure copied: Window title="{fig_window_text}"') 147 | else: 148 | print('Figure copied') 149 | 150 | elif 'qt' in backend.lower(): 151 | # Use Qt version from matplotlib. 152 | import importlib 153 | 154 | try: 155 | qtver = matplotlib.backends.qt_compat.QT_API 156 | QtGui = importlib.import_module(qtver + ".QtGui") 157 | QtWidgets = importlib.import_module(qtver + ".QtWidgets") 158 | QImage = QtGui.QImage 159 | QApplication = QtWidgets.QApplication 160 | clipboard = QtGui.QGuiApplication.clipboard 161 | except ImportError: 162 | raise ValueError(f'Unsupported matplotlib backend ({backend}).') 163 | 164 | def copyfig(fig=None, *args, **kwargs): 165 | """ 166 | Parameters 167 | ---------- 168 | fig : matplotlib figure, optional 169 | If None, get the figure that has UI focus 170 | format : type of image to be pasted to the clipboard ('png', 'jpg', 'jpeg', 'tiff') 171 | If None, uses matplotlib.rcParams["savefig.format"] 172 | If resulting format is not in ('png', 'jpg', 'jpeg', 'tiff'), will override to PNG. 173 | *args : arguments that are passed to savefig 174 | **kwargs : keywords arguments that are passed to savefig 175 | 176 | Raises 177 | ------ 178 | AttributeError 179 | If no figure is found 180 | """ 181 | 182 | if fig is None: 183 | # Find the figure window that has UI focus right now (not necessarily 184 | # the same as plt.gcf() when in interactive mode) 185 | fig_window_text = QApplication.activeWindow().windowTitle() 186 | for i in plt.get_fignums(): 187 | fig_to_check = oldfig(i) 188 | if fig_to_check.canvas.manager.get_window_title() == fig_window_text: 189 | fig = fig_to_check 190 | break 191 | else: 192 | fig_window_text = fig.canvas.manager.get_window_title() 193 | 194 | if fig is None: 195 | raise AttributeError('No figure found!') 196 | 197 | # Store the image in a buffer using savefig(). This has the 198 | # advantage of applying all the default savefig parameters 199 | # such as resolution and background color, which would be ignored 200 | # if we simply grab the canvas as displayed. 201 | with BytesIO() as buf: 202 | fig.savefig(buf, format='png', *args, **kwargs) 203 | clipboard().setImage(QImage.fromData(buf.getvalue())) 204 | 205 | print(f'Figure copied: Window title="{fig_window_text}"') 206 | 207 | elif ostype == 'linux' and backend.lower() == 'gtk3agg': 208 | # Only GTK 3 is supported, as GTK 4 has totally changed the way clipboard data is handled and I can't figure 209 | # it out. I'm totally open to someone else solving this and submitting a PR if they want. I don't use GTK. 210 | import subprocess 211 | import gi 212 | gi.require_version('Gtk', '3.0') 213 | from gi.repository import Gtk, GLib, GdkPixbuf, Gdk 214 | 215 | from PIL import Image 216 | 217 | from gi.repository.Gtk import Clipboard 218 | clipboard = Clipboard.get(Gdk.SELECTION_CLIPBOARD) 219 | 220 | def copyfig(fig=None, *args, **kwargs): 221 | """ 222 | Parameters 223 | ---------- 224 | fig : matplotlib figure, optional 225 | If None, get the figure that has UI focus 226 | *args : arguments that are passed to savefig 227 | **kwargs : keywords arguments that are passed to savefig 228 | 229 | Raises 230 | ------ 231 | AttributeError 232 | If no figure is found 233 | """ 234 | 235 | if fig is None: 236 | # Find the figure window that has UI focus right now (not necessarily 237 | # the same as plt.gcf() when in interactive mode) 238 | pid = ( 239 | subprocess.run(['xprop', '-root', '_NET_ACTIVE_WINDOW'], capture_output=True) 240 | .stdout.decode('UTF-8').strip().rsplit(' ', 1)[-1] 241 | ) 242 | fig_window_text = ( 243 | subprocess.run(['xprop', '-id', pid, 'WM_NAME'], capture_output=True) 244 | .stdout.decode('UTF-8').strip().split(' = ')[-1].strip('"') 245 | ) 246 | 247 | for i in plt.get_fignums(): 248 | fig_to_check = oldfig(i) 249 | if fig_to_check.canvas.manager.get_window_title() == fig_window_text: 250 | fig = fig_to_check 251 | break 252 | else: 253 | # The above methods apparently no longer work w/ Wayland, so let's just use plt.gcf() 254 | # and hope for the best 255 | fig = plt.gcf() 256 | else: 257 | fig_window_text = fig.canvas.manager.get_window_title() 258 | 259 | # Store the image in a buffer using savefig(). This has the 260 | # advantage of applying all the default savefig parameters 261 | # such as resolution and background color, which would be ignored 262 | # if we simply grab the canvas as displayed. 263 | with BytesIO() as buf: 264 | fig.savefig(buf, format='png', *args, **kwargs) 265 | im = Image.open(buf, formats=['PNG']) 266 | 267 | w, h = im.size 268 | data = GLib.Bytes.new(im.tobytes()) 269 | pixbuf = GdkPixbuf.Pixbuf.new_from_bytes(data, GdkPixbuf.Colorspace.RGB, 270 | True, 8, w, h, w * 4) 271 | 272 | clipboard.set_image(pixbuf) 273 | clipboard.store() 274 | 275 | print(f'Figure copied: Window title="{fig_window_text}"') 276 | 277 | else: 278 | raise ValueError(f'Unsupported matplotlib backend ({backend}) on this OS.') 279 | 280 | 281 | def _clipboard_handler(event): 282 | if event.key in ('ctrl+c', 'cmd+c'): 283 | copyfig() 284 | 285 | 286 | @wraps(plt.figure) 287 | def newfig(*args, **kwargs): 288 | fig = oldfig(*args, **kwargs) 289 | if not getattr(fig.canvas, 'copyfig_handler_connected', False): 290 | fig.canvas.mpl_connect('key_press_event', _clipboard_handler) 291 | fig.canvas.copyfig_handler_connected = True 292 | 293 | return fig 294 | 295 | 296 | plt.figure = newfig 297 | --------------------------------------------------------------------------------