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
61 changes: 61 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,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)

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
62 changes: 59 additions & 3 deletions src/mpl_widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
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.

Loading
Loading