From 94b59c40b9858573b9bd3f7d6eb317f7b88f6ac0 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Wed, 14 Jan 2026 07:45:24 -0500 Subject: [PATCH 1/6] Feature: Respect savefig rcParams in Download button Fixes #138, #234, #339 The Download button now respects ALL matplotlib savefig.* rcParams instead of always saving as PNG with hardcoded settings. Implementation: - Override Toolbar.save_figure() to call canvas._send_save_buffer() - Add Canvas._send_save_buffer() that calls figure.savefig() without hardcoded parameters (respects all rcParams) - Send buffer + format metadata to frontend via ipywidgets comm - Update frontend handle_save() to accept buffers from backend - Support multiple formats with correct MIME types: PNG, PDF, SVG, EPS, JPEG, TIFF, PS, TIF - Set correct file extensions based on format - Maintain backward compatibility with canvas.toDataURL() fallback Respects these rcParams: - savefig.format (png, pdf, svg, jpg, eps, etc.) - savefig.dpi (resolution) - savefig.transparent (transparent backgrounds) - savefig.facecolor (custom background colors) - All other savefig.* parameters Co-Authored-By: Claude Sonnet 4.5 --- ipympl/backend_nbagg.py | 61 ++++++++++++++++++++++++++++++++++++++++ src/mpl_widget.ts | 62 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/ipympl/backend_nbagg.py b/ipympl/backend_nbagg.py index a6bb904b..633dafae 100644 --- a/ipympl/backend_nbagg.py +++ b/ipympl/backend_nbagg.py @@ -128,6 +128,10 @@ def __init__(self, canvas, *args, **kwargs): self.on_msg(self.canvas._handle_message) + def save_figure(self, *args): + """Override to use rcParams-aware save.""" + self.canvas._send_save_buffer() + def export(self): buf = io.BytesIO() self.canvas.figure.savefig(buf, format='png', dpi='figure') @@ -327,6 +331,63 @@ def send_binary(self, data): # Actually send the data self.send({'data': '{"type": "binary"}'}, buffers=[data]) + def download(self): + """ + Trigger a download of the figure respecting savefig rcParams. + + This is a programmatic way to trigger the same download that happens + when the user clicks the Download button in the toolbar. + + The figure will be saved using all applicable savefig.* rcParams + including format, dpi, transparent, facecolor, etc. + + Examples + -------- + >>> fig, ax = plt.subplots() + >>> ax.plot([1, 2, 3], [1, 4, 2]) + >>> fig.canvas.download() # Downloads with current rcParams + + >>> # Download as PDF + >>> plt.rcParams['savefig.format'] = 'pdf' + >>> fig.canvas.download() + + >>> # Download with custom DPI + >>> plt.rcParams['savefig.dpi'] = 300 + >>> fig.canvas.download() + """ + self._send_save_buffer() + + def _send_save_buffer(self): + """Generate figure buffer respecting savefig rcParams and send to frontend.""" + buf = io.BytesIO() + + # Call savefig WITHOUT any parameters - fully respects all rcParams + self.figure.savefig(buf) + + # Detect the format that was actually used + # Priority: explicitly set format, or rcParams, or default 'png' + fmt = rcParams.get('savefig.format', 'png') + + # Validate format is supported by the frontend + supported_formats = {'png', 'jpg', 'jpeg', 'pdf', 'svg', 'eps', 'ps', 'tif', 'tiff'} + if fmt not in supported_formats: + warn( + f"Download format '{fmt}' is not supported by the ipympl frontend, " + f"falling back to PNG. Supported formats: {', '.join(sorted(supported_formats))}", + UserWarning, + stacklevel=3 + ) + + # Get the buffer data + data = buf.getvalue() + + # Send to frontend with format metadata + msg_data = { + "type": "save", + "format": fmt + } + self.send({'data': json.dumps(msg_data)}, buffers=[data]) + def new_timer(self, *args, **kwargs): return TimerTornado(*args, **kwargs) diff --git a/src/mpl_widget.ts b/src/mpl_widget.ts index acd4b58f..c917b65a 100644 --- a/src/mpl_widget.ts +++ b/src/mpl_widget.ts @@ -148,13 +148,69 @@ export class MPLCanvasModel extends DOMWidgetModel { } } - handle_save() { + handle_save(msg?: any, buffers?: (ArrayBuffer | ArrayBufferView)[]) { + let blob_url: string; + let filename: string; + let should_revoke = false; + + // If called with buffers, use the backend-generated buffer + if (buffers && buffers.length > 0) { + const url_creator = window.URL || window.webkitURL; + + // Get format from message (already parsed by on_comm_message) + const format = msg.format || 'png'; + + // Map format to MIME type + const mimeTypes: { [key: string]: string } = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'pdf': 'application/pdf', + 'svg': 'image/svg+xml', + 'eps': 'application/postscript', + 'ps': 'application/postscript', + 'tif': 'image/tiff', + 'tiff': 'image/tiff' + }; + + const mimeType = mimeTypes[format]; + + // If format is unknown, fall back to canvas toDataURL method + if (!mimeType) { + console.warn(`Unknown save format '${format}', falling back to PNG`); + blob_url = this.offscreen_canvas.toDataURL(); + filename = this.get('_figure_label') + '.png'; + } else { + // Convert buffer to Uint8Array + const buffer = new Uint8Array( + ArrayBuffer.isView(buffers[0]) ? buffers[0].buffer : buffers[0] + ); + + // Create blob with correct MIME type + const blob = new Blob([buffer], { type: mimeType }); + blob_url = url_creator.createObjectURL(blob); + filename = this.get('_figure_label') + '.' + format; + should_revoke = true; + } + } else { + // Fallback to old behavior (use canvas toDataURL) + blob_url = this.offscreen_canvas.toDataURL(); + filename = this.get('_figure_label') + '.png'; + } + + // Trigger download const save = document.createElement('a'); - save.href = this.offscreen_canvas.toDataURL(); - save.download = this.get('_figure_label') + '.png'; + save.href = blob_url; + save.download = filename; document.body.appendChild(save); save.click(); document.body.removeChild(save); + + // Clean up blob URL if needed + if (should_revoke) { + const url_creator = window.URL || window.webkitURL; + url_creator.revokeObjectURL(blob_url); + } } handle_resize(msg: { [index: string]: any }) { From da128dc0fcfaaae9baf9190777c543144b0d8cf2 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Thu, 15 Jan 2026 02:19:14 -0500 Subject: [PATCH 2/6] Test: Add comprehensive tests for rcParams support Manual test notebook (tests/manual_test_rcparams_save.ipynb): - Tests all savefig.* rcParams (format, transparent, facecolor, dpi) - Tests multiple formats: PNG, PDF, SVG, JPEG - Includes verification checklist - Documents expected behavior for each test case Python unit tests (tests/test_download.py): - Test _send_save_buffer respects savefig.format (PNG, PDF, SVG) - Test download() method calls _send_save_buffer - Test Toolbar.save_figure() calls _send_save_buffer - Test respects savefig.dpi and savefig.transparent rcParams - Test warns on unsupported format (e.g., webp) - Add 'tests' to pytest testpaths in pyproject.toml Addresses issues #138, #234, #339 Co-Authored-By: Claude Sonnet 4.5 --- pyproject.toml | 1 + tests/manual_test_rcparams_save.ipynb | 257 ++++++++++++++++++++++++++ tests/test_download.py | 214 +++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 tests/manual_test_rcparams_save.ipynb create mode 100644 tests/test_download.py diff --git a/pyproject.toml b/pyproject.toml index 738ad1f3..73c3cb66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,7 @@ source = "code" [tool.pytest.ini_options] testpaths = [ + "tests", "docs/examples", ] norecursedirs = [ diff --git a/tests/manual_test_rcparams_save.ipynb b/tests/manual_test_rcparams_save.ipynb new file mode 100644 index 00000000..7c00b297 --- /dev/null +++ b/tests/manual_test_rcparams_save.ipynb @@ -0,0 +1,257 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Test ipympl Download Button with rcParams\n", + "\n", + "This notebook tests that the Download button respects matplotlib's `savefig.*` rcParams.\n", + "\n", + "**Issues being tested:**\n", + "- #138: Download button should respect `savefig.format`\n", + "- #234: Download button should respect `savefig.transparent` and `savefig.facecolor`\n", + "- #339: Enable PDF downloads for vector graphics\n", + "\n", + "**How to test:**\n", + "1. Run each cell\n", + "2. Click the Download button (floppy disk icon) in the toolbar\n", + "3. Check the downloaded file has the correct format and properties" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%matplotlib ipympl\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 1: Default Behavior (PNG)\n", + "\n", + "Without setting any rcParams, the download should default to PNG format." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Reset to defaults\n# plt.rcdefaults()\n\nfig, ax = plt.subplots()\nax.plot([1, 2, 3, 4], [1, 4, 2, 3], 'ro-')\nax.set_title('Test 1: Default PNG')\nax.set_xlabel('X axis')\nax.set_ylabel('Y axis')\nplt.show()\n\n# Expected: Downloads as 'Figure 1.png'" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 2: PDF Format\n", + "\n", + "Setting `savefig.format = 'pdf'` should download as PDF (vector graphics)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "plt.rcParams['savefig.format'] = 'pdf'\n\nfig, ax = plt.subplots()\nx = np.linspace(0, 2*np.pi, 100)\nax.plot(x, np.sin(x), label='sin(x)')\nax.plot(x, np.cos(x), label='cos(x)')\nax.set_title('Test 2: PDF Format')\nax.legend()\nplt.show()\n\n# Expected: Downloads as 'Figure 2.pdf'\n# Can verify it's a real PDF by opening in a PDF viewer" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 3: SVG Format\n", + "\n", + "Setting `savefig.format = 'svg'` should download as SVG (vector graphics)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "plt.rcParams['savefig.format'] = 'svg'\n\nfig, ax = plt.subplots()\ncategories = ['A', 'B', 'C', 'D']\nvalues = [23, 45, 56, 78]\nax.bar(categories, values, color='steelblue')\nax.set_title('Test 3: SVG Format')\nax.set_ylabel('Values')\nplt.show()\n\n# Expected: Downloads as 'Figure 3.svg'\n# Can verify it's SVG by opening in text editor - should see XML" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 4: Transparent Background (PNG)\n", + "\n", + "Setting `savefig.transparent = True` should create PNG with transparent background." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "plt.rcParams['savefig.format'] = 'png'\nplt.rcParams['savefig.transparent'] = True\n\nfig, ax = plt.subplots()\nfig.patch.set_facecolor('red') # Set red background in notebook\nax.plot([1, 2, 3, 4], [2, 4, 1, 3], 'go-', linewidth=2)\nax.set_title('Test 4: Transparent Background')\nplt.show()\n\n# Expected: Downloads as 'Figure 4.png'\n# Background should be TRANSPARENT, not red\n# Can verify by opening PNG in image viewer with transparency support" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 5: Custom Face Color\n", + "\n", + "Setting `savefig.facecolor` should use that background color." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "plt.rcParams['savefig.format'] = 'png'\nplt.rcParams['savefig.transparent'] = False\nplt.rcParams['savefig.facecolor'] = '#111111' # Dark gray\n\nfig, ax = plt.subplots()\nax.plot([1, 2, 3, 4], [1, 3, 2, 4], 'y-', linewidth=3)\nax.set_title('Test 5: Dark Background', color='white')\nax.tick_params(colors='white')\nax.spines['bottom'].set_color('white')\nax.spines['top'].set_color('white')\nax.spines['left'].set_color('white')\nax.spines['right'].set_color('white')\nplt.show()\n\n# Expected: Downloads as 'Figure 5.png'\n# Background should be dark gray (#111111)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 6: High DPI PNG\n", + "\n", + "Setting `savefig.dpi` should affect resolution of raster formats." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "plt.rcParams['savefig.format'] = 'png'\nplt.rcParams['savefig.transparent'] = False\nplt.rcParams['savefig.facecolor'] = 'white'\nplt.rcParams['savefig.dpi'] = 300 # High resolution\n\nfig, ax = plt.subplots(figsize=(6, 4))\nx = np.linspace(0, 10, 1000)\nax.plot(x, np.sin(x) * np.exp(-x/10), 'b-')\nax.set_title('Test 6: High DPI (300)')\nax.grid(True, alpha=0.3)\nplt.show()\n\n# Expected: Downloads as 'Figure 6.png'\n# File should be larger than test 1 (higher resolution)\n# Dimensions should be ~1800x1200 pixels (6*300 x 4*300)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 7: JPEG Format\n", + "\n", + "Setting `savefig.format = 'jpg'` should download as JPEG." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "plt.rcParams['savefig.format'] = 'jpg'\nplt.rcParams['savefig.dpi'] = 'figure' # Reset to default\n\nfig, ax = plt.subplots()\nax.scatter(np.random.randn(100), np.random.randn(100), \n c=np.random.randn(100), cmap='viridis', s=100, alpha=0.6)\nax.set_title('Test 7: JPEG Format')\nplt.colorbar(ax.collections[0], ax=ax)\nplt.show()\n\n# Expected: Downloads as 'Figure 7.jpg'\n# JPEG format (lossy compression, no transparency support)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Test 8: Verify Backward Compatibility\n", + "\n", + "If we somehow call the old code path, it should still work." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "06d603a699434af98bde3e15a5c5c1e8", + "version_major": 2, + "version_minor": 0 + }, + "image/png": "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TWD966+/zAcrbbOOjbwcuq/uu+8+s9805DppCHzkkUfMdmtvuoZBJw32CQkJBEBYmwNArvTr189RrFixzOsTJ0506EtoypQp2dZbtmyZuf2///2vuT558mRzfdWqVed97EOHDpl1XnrppVwfjYSEBEe3bt3M/ZyXq6++2nHkyJFs6+3cudPh4+PjuOuuuy76mPr8WR/PeQkODnZMnTr1gvdNTU11pKSkOO6++25Ho0aNMm9fuHCheYznnnvugvevXLmy47rrrnOcOnXK0bNnT0dISIjjl19+ybaOXtfH0sdU48ePdwQFBTkefPBBR7t27TLXq1mzpuO22267YFuTk5PNek888UTm7fPmzTOP37Zt22zrp6WlOcqXL+9o3LixIz09Pdu+9fPzM22/kGHDhpnHnT17tiM3nn32WbP+kiVLst3+wAMPOLy8vBybNm0y13fs2GHWi46ONm10GjFihLn9hhtuyHb/xx9/3NweHx+feZu2XR/z3N/Pjh07muOemJh4Scdb6etEXy/ncu5f/d9J19PbPvjgg2zrvvHGG+b2P/74I1tbsz6uc/vHjh2beZsu6236Myf9vcrpGOlrSH9/HnvssWy316tXL9vvE2BFlICBPNIeGe3N054W7S1zXrREpr0ozjKXXtceL+2F0F6n7du3X/Y+1x6TW265xfTcjBo1ShYuXGgeW3sWO3bsaHqnnLSHUNs1evToXD++9uxoSXXp0qVmO7XnTScuZO1BUZMmTTK9kTqLU0u12tukz6OTUJxmzZpl/s9NOe3IkSOm90if948//jAlwaz0ubREqe1T2uunva3ae6q9Rloy3LNnj+nRydoLqtuvExN08oEeC22r/q/rZW2rk5bns9IJNtqLqOMrteco677Vnrr8pkMGtK069jIr7QnV3tFzJyJp71nWXkjtEVbnlsudt587U7x+/foSHR2d7TbdVu0F057aSzneeXX77bf/6/mdvacFRSdKaa+u9u5q2Vrpvl2/fr0pQQNWRgAE8kjLj1pu0yChb4RZLzrm6/Dhw2Y9nTyhgUXLiBqC9LpePvjggzzve33T1WClY920pKflZS3BaSlZ37Av95QXGgaaNm2aOeZO3/hr1KiRLcTpc998882mHKjlXS39aWi86667zHixrOVMHx8fE4ovRsvYOtO4S5cuEhkZ+a+fa/jTAOIMgL/++qsJ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6+/brFmzLPH681QEwEKQmJgo0dHR8vHHH+dq/R07dkjXrl2lTZs2snLlShkyZIg8+uij5hfdadGiRXLLLbdInz59ZPXq1eb/m2++WZYsWSLuvn0aNvSN56effpIVK1ZIu3btTPjQbc1K/0jExsZmu+gfAnffPif945217TVr1nTL45eXbfzggw+ybZt+1VZoaKj07t3bLY+hvpE89NBDsnjxYpk7d66kpqZKp06dzHZb4XWYl+3zpNdhXrbP016HedlGT3od6rdmDRs2TJYvX24u7du3l+7du8u6des8/vXnsRwoVLrLp02bdsF1nn76aUedOnWy3Xb//fc7WrZsmXn95ptvdlx77bXZ1uncubPj1ltvdbj79uWkXr16jldeeSXz+tixYx0hISEOd5Ob7Zs3b55Z79ixY+ddx12PX16Poa7v5eXl2Llzp9sfQxUXF2e2c8GCBZZ8HeZm+zz5dZib7fP012FejqGnvQ5Llizp+Pzzzy33+vMU9AC6If1Uo5/8surcubP51JSSknLBdf766y/xNOnp6eZLufWTa1YnT56UypUrm0+O119//b96Jtxdo0aNpFy5ctKhQweZN29etp9Z6fip0aNHyzXXXGOOlyccw/j4ePP/ub9zVnkd5mb7PPl1eCnb56mvw7wcQ095Haalpck333xjeje1FGy115+nIAC6oQMHDkjZsmWz3abXtSRw+PDhC66jt3uad9991/wh0K57pzp16pixKzNmzJCJEyeacsWVV14pW7ZsEXenbzYjR440pQodZ1W7dm3z5qMlNycrHT8tJ+lYnnvuuSfb7e56DLWTc+DAgdK6dWuJjIy03Oswt9vnqa/D3G6fJ78O83IMPeF1uGbNGilevLgZrzdgwAAzXk/H7Vnp9edJfF3dAORMB+lmlVGZy357Tuuce5u70z9IL7/8snz//fdm4LNTy5YtzcVJ/2A1btxYPvroI/nwww/FnekbjV6c9BOujs155513pG3btpY6fkrfXEqUKGEGrWflrsfw4YcflpiYGPnjjz8s+Tq8lO3zxNdhbrfPk1+HeTmGnvA61OOxatUqOX78uAnm/fr1M2MfzxcCPfH150noAXRD4eHh//oEExcXJ76+vlKqVKkLrnPupyF39u2338rdd98t3333nSlbXIi3t7eZIefq3qO80j/AWdtuhePn/GM7ZswYM/ja39/f7Y/hI488YnpCtBSo5TCrvQ4vZfs88XWYl+3ztNdhXrbRU16H2rYaNWpI06ZNzaxnnXimE1ms8vrzNARAN6SfVHUWWFZz5swxLxo/P78LrnPFFVeIJ9AeBz2FwYQJE+S6667L1R84/eSoZR1PpGNusrbd04+fk35637p1qwkQ7nwM9bm1V0VLgb/99ptUrVrVUq/DvGyfJ70O87p9nvQ6vJxt9JTXYU5tSUpK8vjXn8dy9SwUOzhx4oRj5cqV5qK7/L333jPLu3btMj9/9tlnHX369Mlcf/v27Y6iRYs6nnjiCcf69esdo0ePdvj5+TkmT56cuc6ff/7p8PHxcQwbNsyxYcMG87+vr69j8eLFbr99EyZMMG395JNPHLGxsZmX48ePZ67z8ssvO2bPnu3Ytm2beaz+/fub+yxZssTtt+/99983s/E2b97sWLt2rfm53m/KlCluefzyso1Od9xxh6NFixY5PqY7HcMHHnjAzIScP39+tt+5U6dOZa7jya/DvGyfJ70O87J9nvY6zMs2etLrcPDgwY6FCxc6duzY4YiJiXEMGTLE4e3t7ZgzZ47Hv/48FQGwEDhPR3DupV+/fubn+v9VV12V7T76R6BRo0YOf39/R5UqVRyffvrpvx530qRJjtq1a5sXhU6Xz/qHzZ23T5cvtL56/PHHHZUqVTLbX6ZMGUenTp0cf/31l0ds31tvveWoXr26IzAw0JzmoHXr1o6ZM2e67fHL6++oBoUiRYo4Ro4cmeNjutMxzGnb9KKnyHDy5NdhXrbPk16Hedk+T3sd5vV31FNeh3fddZejcuXKme3o0KFDZvjz9Nefp/LSf1zdCwkAAIDCwxhAAAAAmyEAAgAA2AwBEAAAwGYIgAAAADZDAAQAALAZAiAAAIDNEAABAABshgAIAABgMwRAAAAAmyEAAgAA2AwBEAAAwGYIgAAAADZDAAQAALAZAiAAAIDNEAABAABshgAIAABgMwRAAAAAmyEAAgAA2AwBEAAAwGYIgAAAADZDAAQAALAZAiAAAIDNEAABAABshgAIAABgMwRAAAAAmyEAAgAA2AwBEAAAwGYIgAAAADZDAAQAALAZAiAAAIDNEAABAADEXv4PqKzX6XDQacoAAAAASUVORK5CYII=", + "text/html": [ + "\n", + "
\n", + "
\n", + " Figure\n", + "
\n", + " \n", + "
\n", + " " + ], + "text/plain": [ + "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Regular savefig to /tmp/test_savefig.png works\n" + ] + } + ], + "source": [ + "plt.rcdefaults()\n", + "# Test that default matplotlib savefig still works\n", + "fig, ax = plt.subplots()\n", + "ax.plot([1, 2, 3], [1, 4, 2])\n", + "ax.set_title('Test 8: Backward Compatibility')\n", + "plt.show()\n", + "\n", + "# Use regular savefig to compare\n", + "plt.savefig('test_savefig.png')\n", + "print(\"Regular savefig to /tmp/test_savefig.png works\")\n", + "\n", + "# Now test the widget button\n", + "# Expected: Downloads as 'Figure 8.jpg' (jpg still set from Test 7)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Verification Checklist\n", + "\n", + "After running all tests and clicking Download on each figure, verify:\n", + "\n", + "- [ ] Test 1: File named `Figure 1.png`, PNG format\n", + "- [ ] Test 2: File named `Figure 2.pdf`, can open in PDF viewer\n", + "- [ ] Test 3: File named `Figure 3.svg`, can open as text/XML\n", + "- [ ] Test 4: File named `Figure 4.png`, has transparent background\n", + "- [ ] Test 5: File named `Figure 5.png`, has dark (#111111) background\n", + "- [ ] Test 6: File named `Figure 6.png`, is high resolution (~1800x1200)\n", + "- [ ] Test 7: File named `Figure 7.jpg`, JPEG format\n", + "- [ ] Test 8: File named `Figure 8.jpg`, downloads successfully\n", + "\n", + "All tests passing indicates rcParams are properly respected! šŸŽ‰" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 00000000..45a33325 --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,214 @@ +"""Tests for download functionality respecting rcParams.""" + +import io +import json +from unittest.mock import MagicMock, patch + +import matplotlib +import matplotlib.pyplot as plt +import pytest + + +def test_send_save_buffer_respects_format(): + """Test that _send_save_buffer respects savefig.format rcParam.""" + matplotlib.use('module://ipympl.backend_nbagg') + + # Test PNG format (default) + plt.rcParams['savefig.format'] = 'png' + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 4, 2]) + + canvas = fig.canvas + canvas.send = MagicMock() + + canvas._send_save_buffer() + + # Verify send was called + assert canvas.send.called + call_args = canvas.send.call_args + + # Check message format + msg_data = json.loads(call_args[0][0]['data']) + assert msg_data['type'] == 'save' + assert msg_data['format'] == 'png' + + # Check buffer is not empty + buffers = call_args[1]['buffers'] + assert len(buffers) == 1 + assert len(buffers[0]) > 0 + + plt.close(fig) + + +def test_send_save_buffer_respects_pdf_format(): + """Test that _send_save_buffer respects PDF format.""" + matplotlib.use('module://ipympl.backend_nbagg') + + plt.rcParams['savefig.format'] = 'pdf' + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 4, 2]) + + canvas = fig.canvas + canvas.send = MagicMock() + + canvas._send_save_buffer() + + call_args = canvas.send.call_args + msg_data = json.loads(call_args[0][0]['data']) + assert msg_data['format'] == 'pdf' + + # Verify buffer starts with PDF signature + buffers = call_args[1]['buffers'] + assert buffers[0][:4] == b'%PDF' + + plt.close(fig) + + +def test_send_save_buffer_respects_svg_format(): + """Test that _send_save_buffer respects SVG format.""" + matplotlib.use('module://ipympl.backend_nbagg') + + plt.rcParams['savefig.format'] = 'svg' + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 4, 2]) + + canvas = fig.canvas + canvas.send = MagicMock() + + canvas._send_save_buffer() + + call_args = canvas.send.call_args + msg_data = json.loads(call_args[0][0]['data']) + assert msg_data['format'] == 'svg' + + # Verify buffer contains SVG content + buffers = call_args[1]['buffers'] + buffer_str = buffers[0].decode('utf-8') + assert ' low_dpi_size + + +def test_send_save_buffer_respects_transparent(): + """Test that _send_save_buffer respects savefig.transparent rcParam.""" + matplotlib.use('module://ipympl.backend_nbagg') + + plt.rcParams['savefig.format'] = 'png' + plt.rcParams['savefig.transparent'] = True + + fig, ax = plt.subplots() + fig.patch.set_facecolor('red') + ax.plot([1, 2, 3], [1, 4, 2]) + + canvas = fig.canvas + canvas.send = MagicMock() + + canvas._send_save_buffer() + + # Verify buffer was created (checking actual transparency would require PIL) + call_args = canvas.send.call_args + buffers = call_args[1]['buffers'] + assert len(buffers[0]) > 0 + + plt.close(fig) + + +def test_send_save_buffer_warns_on_unsupported_format(): + """Test that _send_save_buffer warns about unsupported formats.""" + matplotlib.use('module://ipympl.backend_nbagg') + + # Test with an unsupported format + plt.rcParams['savefig.format'] = 'webp' + + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 4, 2]) + + canvas = fig.canvas + canvas.send = MagicMock() + + # Should issue a warning + with pytest.warns(UserWarning, match="Download format 'webp' is not supported"): + canvas._send_save_buffer() + + # Should still send the buffer (frontend will fall back to PNG) + assert canvas.send.called + + plt.close(fig) From 1d666403f732deb1aa89191d1747474c15321d38 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Thu, 15 Jan 2026 02:21:13 -0500 Subject: [PATCH 3/6] Feature: Add fig.canvas.download() for programmatic downloads - Add public download() method to Canvas class - Allows triggering downloads from Python code without clicking button - Respects all savefig rcParams like the toolbar button - Includes comprehensive docstring with examples - Add test file demonstrating programmatic usage Use cases: - Batch downloading multiple figures - Automated workflows - Custom save logic in notebooks Example: fig, ax = plt.subplots() ax.plot([1, 2, 3]) fig.canvas.download() # Triggers browser download Co-Authored-By: Claude Sonnet 4.5 --- test_programmatic_download.py | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 test_programmatic_download.py diff --git a/test_programmatic_download.py b/test_programmatic_download.py new file mode 100644 index 00000000..d1a84a0f --- /dev/null +++ b/test_programmatic_download.py @@ -0,0 +1,60 @@ +""" +Test programmatic download using fig.canvas.download() + +This demonstrates the new public API for triggering downloads from Python code +without clicking the toolbar button. +""" + +# This would be run in a Jupyter notebook with %matplotlib ipympl +import matplotlib +matplotlib.use('module://ipympl.backend_nbagg') + +import matplotlib.pyplot as plt +import numpy as np + +# Example 1: Simple programmatic download +print("Example 1: Simple download") +fig, ax = plt.subplots() +ax.plot([1, 2, 3], [1, 4, 2]) +ax.set_title('Programmatic Download Test') + +# Trigger download programmatically - no button click needed! +fig.canvas.download() +print(" -> Downloads as PNG (default format)") + +# Example 2: Download as PDF +print("\nExample 2: Download as PDF") +plt.rcParams['savefig.format'] = 'pdf' + +fig, ax = plt.subplots() +ax.plot(np.linspace(0, 10, 100), np.sin(np.linspace(0, 10, 100))) +ax.set_title('PDF Download') + +fig.canvas.download() +print(" -> Downloads as PDF") + +# Example 3: Batch download multiple figures +print("\nExample 3: Batch download 3 figures") +plt.rcParams['savefig.format'] = 'png' + +for i in range(3): + fig, ax = plt.subplots() + ax.plot(np.random.randn(50)) + ax.set_title(f'Figure {i+1}') + fig.canvas.download() + print(f" -> Downloaded Figure {i+1}") + +# Example 4: Download with custom settings +print("\nExample 4: Custom DPI and transparent background") +plt.rcParams['savefig.dpi'] = 150 +plt.rcParams['savefig.transparent'] = True + +fig, ax = plt.subplots() +ax.scatter(np.random.randn(100), np.random.randn(100)) +ax.set_title('High-res Transparent') + +fig.canvas.download() +print(" -> Downloads as 150 DPI PNG with transparent background") + +print("\nāœ… All programmatic downloads triggered!") +print("Check your Downloads folder for the files.") From 2c0c8520f8a34c2c229463047149f3b93f978db2 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Thu, 15 Jan 2026 11:25:17 -0500 Subject: [PATCH 4/6] Fix: Check format before savefig for matplotlib 3.5 compatibility matplotlib 3.5 may reject invalid formats before we can check them, so we need to validate the format from rcParams BEFORE calling savefig() to ensure our warning is properly emitted. Fixes test_send_save_buffer_warns_on_unsupported_format on matplotlib 3.5. --- ipympl/backend_nbagg.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/ipympl/backend_nbagg.py b/ipympl/backend_nbagg.py index 633dafae..8b2e4dab 100644 --- a/ipympl/backend_nbagg.py +++ b/ipympl/backend_nbagg.py @@ -359,13 +359,7 @@ def download(self): def _send_save_buffer(self): """Generate figure buffer respecting savefig rcParams and send to frontend.""" - buf = io.BytesIO() - - # Call savefig WITHOUT any parameters - fully respects all rcParams - self.figure.savefig(buf) - - # Detect the format that was actually used - # Priority: explicitly set format, or rcParams, or default 'png' + # Get the format before calling savefig to properly warn about unsupported formats fmt = rcParams.get('savefig.format', 'png') # Validate format is supported by the frontend @@ -378,6 +372,11 @@ def _send_save_buffer(self): stacklevel=3 ) + buf = io.BytesIO() + + # Call savefig WITHOUT any parameters - fully respects all rcParams + self.figure.savefig(buf) + # Get the buffer data data = buf.getvalue() From 317a1b8cbdae026c506bbf526d5eeacbb5520356 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Thu, 15 Jan 2026 11:44:30 -0500 Subject: [PATCH 5/6] Support all matplotlib formats in Download button Remove artificial format restrictions - if matplotlib can generate it, we support downloading it. Use known MIME types where available, or fall back to application/octet-stream for unknown formats. The filename extension ensures proper OS handling regardless. This enables formats like PGF (LaTeX graphics), SVG compressed (svgz), and raw RGBA formats to download correctly. Co-Authored-By: Claude Sonnet 4.5 --- ipympl/backend_nbagg.py | 16 +++------------- src/mpl_widget.ts | 38 ++++++++++++++++++-------------------- tests/test_download.py | 20 ++++++++++++-------- 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/ipympl/backend_nbagg.py b/ipympl/backend_nbagg.py index 8b2e4dab..900c6048 100644 --- a/ipympl/backend_nbagg.py +++ b/ipympl/backend_nbagg.py @@ -359,24 +359,14 @@ def download(self): def _send_save_buffer(self): """Generate figure buffer respecting savefig rcParams and send to frontend.""" - # Get the format before calling savefig to properly warn about unsupported formats - fmt = rcParams.get('savefig.format', 'png') - - # Validate format is supported by the frontend - supported_formats = {'png', 'jpg', 'jpeg', 'pdf', 'svg', 'eps', 'ps', 'tif', 'tiff'} - if fmt not in supported_formats: - warn( - f"Download format '{fmt}' is not supported by the ipympl frontend, " - f"falling back to PNG. Supported formats: {', '.join(sorted(supported_formats))}", - UserWarning, - stacklevel=3 - ) - buf = io.BytesIO() # Call savefig WITHOUT any parameters - fully respects all rcParams self.figure.savefig(buf) + # Get the format that was used + fmt = rcParams.get('savefig.format', 'png') + # Get the buffer data data = buf.getvalue() diff --git a/src/mpl_widget.ts b/src/mpl_widget.ts index c917b65a..10213a2c 100644 --- a/src/mpl_widget.ts +++ b/src/mpl_widget.ts @@ -160,38 +160,36 @@ export class MPLCanvasModel extends DOMWidgetModel { // Get format from message (already parsed by on_comm_message) const format = msg.format || 'png'; - // Map format to MIME type + // Map format to MIME type - use known types where available const mimeTypes: { [key: string]: string } = { 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'pdf': 'application/pdf', 'svg': 'image/svg+xml', + 'svgz': 'image/svg+xml', 'eps': 'application/postscript', 'ps': 'application/postscript', 'tif': 'image/tiff', - 'tiff': 'image/tiff' + 'tiff': 'image/tiff', + 'pgf': 'application/x-latex', + 'raw': 'application/octet-stream', + 'rgba': 'application/octet-stream' }; - const mimeType = mimeTypes[format]; - - // If format is unknown, fall back to canvas toDataURL method - if (!mimeType) { - console.warn(`Unknown save format '${format}', falling back to PNG`); - blob_url = this.offscreen_canvas.toDataURL(); - filename = this.get('_figure_label') + '.png'; - } else { - // Convert buffer to Uint8Array - const buffer = new Uint8Array( - ArrayBuffer.isView(buffers[0]) ? buffers[0].buffer : buffers[0] - ); + // Use known MIME type or generic fallback + const mimeType = mimeTypes[format] || 'application/octet-stream'; - // Create blob with correct MIME type - const blob = new Blob([buffer], { type: mimeType }); - blob_url = url_creator.createObjectURL(blob); - filename = this.get('_figure_label') + '.' + format; - should_revoke = true; - } + // Convert buffer to Uint8Array + const buffer = new Uint8Array( + ArrayBuffer.isView(buffers[0]) ? buffers[0].buffer : buffers[0] + ); + + // Create blob with MIME type + const blob = new Blob([buffer], { type: mimeType }); + blob_url = url_creator.createObjectURL(blob); + filename = this.get('_figure_label') + '.' + format; + should_revoke = true; } else { // Fallback to old behavior (use canvas toDataURL) blob_url = this.offscreen_canvas.toDataURL(); diff --git a/tests/test_download.py b/tests/test_download.py index 45a33325..7f70848e 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -191,12 +191,12 @@ def test_send_save_buffer_respects_transparent(): plt.close(fig) -def test_send_save_buffer_warns_on_unsupported_format(): - """Test that _send_save_buffer warns about unsupported formats.""" +def test_send_save_buffer_with_pgf_format(): + """Test that _send_save_buffer works with PGF format.""" matplotlib.use('module://ipympl.backend_nbagg') - # Test with an unsupported format - plt.rcParams['savefig.format'] = 'webp' + # Test with PGF format (LaTeX graphics format) + plt.rcParams['savefig.format'] = 'pgf' fig, ax = plt.subplots() ax.plot([1, 2, 3], [1, 4, 2]) @@ -204,11 +204,15 @@ def test_send_save_buffer_warns_on_unsupported_format(): canvas = fig.canvas canvas.send = MagicMock() - # Should issue a warning - with pytest.warns(UserWarning, match="Download format 'webp' is not supported"): - canvas._send_save_buffer() + # Should work without warnings + canvas._send_save_buffer() - # Should still send the buffer (frontend will fall back to PNG) + # Should send the buffer with format='pgf' assert canvas.send.called + call_args = canvas.send.call_args + assert 'data' in call_args[0][0] + import json + msg_data = json.loads(call_args[0][0]['data']) + assert msg_data['format'] == 'pgf' plt.close(fig) From 4878729e0bfa88af571215aeda6ea454005c7302 Mon Sep 17 00:00:00 2001 From: Ian Hunt-Isaak Date: Thu, 15 Jan 2026 23:24:25 -0500 Subject: [PATCH 6/6] Refactor: Use pytest parametrize for format tests Use pytest.mark.parametrize to reduce code duplication in format tests. Remove PGF test as LaTeX backend is not available in CI environment. Co-Authored-By: Claude Sonnet 4.5 --- tests/test_download.py | 95 ++++++------------------------------------ 1 file changed, 13 insertions(+), 82 deletions(-) diff --git a/tests/test_download.py b/tests/test_download.py index 7f70848e..9aa5d34b 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -9,12 +9,19 @@ import pytest -def test_send_save_buffer_respects_format(): +@pytest.mark.parametrize( + "format_name,signature_check", + [ + ("png", lambda buf: len(buf) > 0 and buf[:8] == b'\x89PNG\r\n\x1a\n'), + ("pdf", lambda buf: buf[:4] == b'%PDF'), + ("svg", lambda buf: b' 0 - - plt.close(fig) - - -def test_send_save_buffer_respects_pdf_format(): - """Test that _send_save_buffer respects PDF format.""" - matplotlib.use('module://ipympl.backend_nbagg') - - plt.rcParams['savefig.format'] = 'pdf' - fig, ax = plt.subplots() - ax.plot([1, 2, 3], [1, 4, 2]) - - canvas = fig.canvas - canvas.send = MagicMock() - - canvas._send_save_buffer() - - call_args = canvas.send.call_args - msg_data = json.loads(call_args[0][0]['data']) - assert msg_data['format'] == 'pdf' - - # Verify buffer starts with PDF signature - buffers = call_args[1]['buffers'] - assert buffers[0][:4] == b'%PDF' - - plt.close(fig) - - -def test_send_save_buffer_respects_svg_format(): - """Test that _send_save_buffer respects SVG format.""" - matplotlib.use('module://ipympl.backend_nbagg') - - plt.rcParams['savefig.format'] = 'svg' - fig, ax = plt.subplots() - ax.plot([1, 2, 3], [1, 4, 2]) - - canvas = fig.canvas - canvas.send = MagicMock() - - canvas._send_save_buffer() - - call_args = canvas.send.call_args - msg_data = json.loads(call_args[0][0]['data']) - assert msg_data['format'] == 'svg' - - # Verify buffer contains SVG content - buffers = call_args[1]['buffers'] - buffer_str = buffers[0].decode('utf-8') - assert ' 0 plt.close(fig) - - -def test_send_save_buffer_with_pgf_format(): - """Test that _send_save_buffer works with PGF format.""" - matplotlib.use('module://ipympl.backend_nbagg') - - # Test with PGF format (LaTeX graphics format) - plt.rcParams['savefig.format'] = 'pgf' - - fig, ax = plt.subplots() - ax.plot([1, 2, 3], [1, 4, 2]) - - canvas = fig.canvas - canvas.send = MagicMock() - - # Should work without warnings - canvas._send_save_buffer() - - # Should send the buffer with format='pgf' - assert canvas.send.called - call_args = canvas.send.call_args - assert 'data' in call_args[0][0] - import json - msg_data = json.loads(call_args[0][0]['data']) - assert msg_data['format'] == 'pgf' - - plt.close(fig)