Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions ipympl/backend_nbagg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -327,6 +331,52 @@ 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)

# Get the format that was used
fmt = rcParams.get('savefig.format', 'png')

# 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)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ source = "code"

[tool.pytest.ini_options]
testpaths = [
"tests",
"docs/examples",
]
norecursedirs = [
Expand Down
60 changes: 57 additions & 3 deletions src/mpl_widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,67 @@ 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 - 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',
'pgf': 'application/x-latex',
'raw': 'application/octet-stream',
'rgba': 'application/octet-stream'
};

// Use known MIME type or generic fallback
const mimeType = mimeTypes[format] || 'application/octet-stream';

// 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();
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 }) {
Expand Down
60 changes: 60 additions & 0 deletions test_programmatic_download.py
Original file line number Diff line number Diff line change
@@ -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.")
257 changes: 257 additions & 0 deletions tests/manual_test_rcparams_save.ipynb

Large diffs are not rendered by default.

149 changes: 149 additions & 0 deletions tests/test_download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""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


@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'<?xml' in buf or b'<svg' in buf),
]
)
def test_send_save_buffer_respects_format(format_name, signature_check):
"""Test that _send_save_buffer respects savefig.format rcParam."""
matplotlib.use('module://ipympl.backend_nbagg')

plt.rcParams['savefig.format'] = format_name
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'] == format_name

# Check buffer signature
buffers = call_args[1]['buffers']
assert len(buffers) == 1
assert signature_check(buffers[0])

plt.close(fig)


def test_download_method_calls_send_save_buffer():
"""Test that download() method calls _send_save_buffer()."""
matplotlib.use('module://ipympl.backend_nbagg')

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 2])

canvas = fig.canvas

# Mock _send_save_buffer
with patch.object(canvas, '_send_save_buffer') as mock_send:
canvas.download()
mock_send.assert_called_once()

plt.close(fig)


def test_toolbar_save_figure_calls_send_save_buffer():
"""Test that Toolbar.save_figure() calls canvas._send_save_buffer()."""
matplotlib.use('module://ipympl.backend_nbagg')

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 4, 2])

canvas = fig.canvas
toolbar = canvas.toolbar

# Mock _send_save_buffer
with patch.object(canvas, '_send_save_buffer') as mock_send:
toolbar.save_figure()
mock_send.assert_called_once()

plt.close(fig)


def test_send_save_buffer_respects_dpi():
"""Test that _send_save_buffer respects savefig.dpi rcParam."""
matplotlib.use('module://ipympl.backend_nbagg')

# Test with high DPI
plt.rcParams['savefig.format'] = 'png'
plt.rcParams['savefig.dpi'] = 300

fig, ax = plt.subplots(figsize=(2, 2))
ax.plot([1, 2, 3], [1, 4, 2])

canvas = fig.canvas
canvas.send = MagicMock()

canvas._send_save_buffer()

# Get buffer size with high DPI
call_args = canvas.send.call_args
high_dpi_size = len(call_args[1]['buffers'][0])

plt.close(fig)

# Test with low DPI
plt.rcParams['savefig.dpi'] = 50

fig2, ax2 = plt.subplots(figsize=(2, 2))
ax2.plot([1, 2, 3], [1, 4, 2])

canvas2 = fig2.canvas
canvas2.send = MagicMock()

canvas2._send_save_buffer()

# Get buffer size with low DPI
call_args2 = canvas2.send.call_args
low_dpi_size = len(call_args2[1]['buffers'][0])

plt.close(fig2)

# High DPI should produce larger file
assert high_dpi_size > 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)
Loading