Skip to content

Commit 7954aad

Browse files
ianhiclaude
andcommitted
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. Backend changes: - Add _send_save_buffer() method that calls figure.savefig() without hardcoded parameters (respects all rcParams) - Add 'save_figure' message handler to intercept Download button clicks - Send buffer + format metadata to frontend via ipywidgets comm Frontend changes: - Update 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 <noreply@anthropic.com>
1 parent 0f19f0a commit 7954aad

3 files changed

Lines changed: 86 additions & 7 deletions

File tree

ipympl/backend_nbagg.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,9 @@ def _handle_message(self, object, content, buffers):
277277
_, _, w, h = self.figure.bbox.bounds
278278
self.manager.resize(w, h)
279279

280+
elif content['type'] == 'save_figure':
281+
self._send_save_buffer()
282+
280283
elif content['type'] == 'set_dpi_ratio':
281284
Canvas.current_dpi_ratio = content['dpi_ratio']
282285
self.manager.handle_json(content)
@@ -327,6 +330,29 @@ def send_binary(self, data):
327330
# Actually send the data
328331
self.send({'data': '{"type": "binary"}'}, buffers=[data])
329332

333+
def _send_save_buffer(self):
334+
"""Generate figure buffer respecting savefig rcParams and send to frontend."""
335+
from matplotlib import rcParams
336+
337+
buf = io.BytesIO()
338+
339+
# Call savefig WITHOUT any parameters - fully respects all rcParams
340+
self.figure.savefig(buf)
341+
342+
# Detect the format that was actually used
343+
# Priority: explicitly set format, or rcParams, or default 'png'
344+
fmt = rcParams.get('savefig.format', 'png')
345+
346+
# Get the buffer data
347+
data = buf.getvalue()
348+
349+
# Send to frontend with format metadata
350+
msg_data = {
351+
"type": "save",
352+
"format": fmt
353+
}
354+
self.send({'data': json.dumps(msg_data)}, buffers=[data])
355+
330356
def new_timer(self, *args, **kwargs):
331357
return TimerTornado(*args, **kwargs)
332358

src/mpl_widget.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,58 @@ export class MPLCanvasModel extends DOMWidgetModel {
148148
}
149149
}
150150

151-
handle_save() {
152-
const save = document.createElement('a');
153-
save.href = this.offscreen_canvas.toDataURL();
154-
save.download = this.get('_figure_label') + '.png';
155-
document.body.appendChild(save);
156-
save.click();
157-
document.body.removeChild(save);
151+
handle_save(msg?: any, buffers?: (ArrayBuffer | ArrayBufferView)[]) {
152+
// If called with buffers (new path), use the backend-generated buffer
153+
if (buffers && buffers.length > 0) {
154+
const url_creator = window.URL || window.webkitURL;
155+
156+
// Get format from message (already parsed by on_comm_message)
157+
const format = msg.format || 'png';
158+
159+
// Convert buffer to Uint8Array
160+
const buffer = new Uint8Array(
161+
ArrayBuffer.isView(buffers[0]) ? buffers[0].buffer : buffers[0]
162+
);
163+
164+
// Map format to MIME type
165+
const mimeTypes: { [key: string]: string } = {
166+
'png': 'image/png',
167+
'jpg': 'image/jpeg',
168+
'jpeg': 'image/jpeg',
169+
'pdf': 'application/pdf',
170+
'svg': 'image/svg+xml',
171+
'eps': 'application/postscript',
172+
'ps': 'application/postscript',
173+
'tif': 'image/tiff',
174+
'tiff': 'image/tiff'
175+
};
176+
177+
const mimeType = mimeTypes[format] || 'application/octet-stream';
178+
179+
// Create blob with correct MIME type
180+
const blob = new Blob([buffer], { type: mimeType });
181+
const blob_url = url_creator.createObjectURL(blob);
182+
183+
// Create download link with correct extension
184+
const save = document.createElement('a');
185+
save.href = blob_url;
186+
save.download = this.get('_figure_label') + '.' + format;
187+
document.body.appendChild(save);
188+
save.click();
189+
document.body.removeChild(save);
190+
191+
// Clean up the blob URL
192+
url_creator.revokeObjectURL(blob_url);
193+
} else {
194+
// Fallback to old behavior (use canvas toDataURL)
195+
// This maintains backward compatibility
196+
const save = document.createElement('a');
197+
save.href = this.offscreen_canvas.toDataURL();
198+
save.download = this.get('_figure_label') + '.png';
199+
document.body.appendChild(save);
200+
save.click();
201+
document.body.removeChild(save);
202+
}
158203
}
159204

160205
handle_resize(msg: { [index: string]: any }) {

src/toolbar_widget.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ export class ToolbarView extends DOMWidgetView {
144144

145145
toolbar_button_onclick(name: string) {
146146
return (_event: Event): void => {
147+
// Special case for save_figure - send custom message for rcParams handling
148+
if (name === 'save_figure') {
149+
this.send({
150+
type: 'save_figure',
151+
});
152+
return;
153+
}
154+
147155
// Special case for pan and zoom as they are toggle buttons
148156
if (name === 'pan' || name === 'zoom') {
149157
if (this.model.get('_current_action') === name) {

0 commit comments

Comments
 (0)