Download component as image using clientside_callback

Hello,

I present a simple example for downloading a component as an image using clientside_callbacks. We achieve this by using the dom-to-image javascript function.

I wanted to display the filters and a message alongside the graph for sharing purposes. Prior to this, I found users were sharing the graphs they created but not including the filters they applied to the data being shown. I initially tried this using html2pdf; however 1) the users prefer images for sharing and 2) the pdf kept cutting off and I couldn’t get it debugged properly. I finally found dom-to-image which made doing this a lot easier.

Here is how I implemented it:

  1. Add the dom-to-image function to our external scripts (link to cdn in code), or download and serve it locally.
  2. Create button with id=download-image
  3. Create a component with `id=‘component-to-save’
  4. Define a clientside_callback when the button is clicked
  5. Call the following function:
function(n_clicks){
    if(n_clicks > 0){
        domtoimage.toBlob(document.getElementById('component-to-save'))
            .then(function (blob) {
                window.saveAs(blob, 'download.png');
            }
        );
    }
}

Full example:

import dash
from dash import html, clientside_callback, Input, Output
import dash_bootstrap_components as dbc

app = dash.Dash(
    __name__,
    external_stylesheets=[dbc.themes.BOOTSTRAP],
    external_scripts=[
        {'src': 'https://cdn.jsdelivr.net/npm/dom-to-image@2.6.0/dist/dom-to-image.min.js'}
    ]
)

app.layout = html.Div(
    [
        html.H1('Title of the page'),
        html.P('The outside of the card is not saved.'),
        dbc.Button(
            'Download as image',
            id='download-image'
        ),
        dbc.Card(
            'Everything in here is downloaded as an image',
            body=True,
            id='component-to-save'
        )
    ]
)


clientside_callback(
    """
    function(n_clicks){
        if(n_clicks > 0){
            domtoimage.toBlob(document.getElementById('component-to-save'))
                .then(function (blob) {
                    window.saveAs(blob, 'download.png');
                }
            );
        }
    }
    """,
    Output('download-image', 'n_clicks'),
    Input('download-image', 'n_clicks')
)

if __name__ == "__main__":
    app.run_server(debug=True)

I hope this helps!

Brad

6 Likes

HI Brad @raptorbrad
This is amazing. Thank you for sharing with the community. This makes sharing images a lot simpler. And all this will just one clientside_callback :clap:

1 Like

Update: I started running into the following error in the dom-to-image function.
TypeError setting getter-only properly className...

After some digging, I found there is an issue with FontAwesome icons being located on the same page the download is happening (either in or outside of the downloadable content). Bootstrap icons do not raise this error.

1 Like

I’ve since figured this out with html2canvas. Same setup, except the src will be:

app = dash.Dash(
    __name__,
    external_stylesheets=[dbc.themes.BOOTSTRAP],
    external_scripts=[
        {'src': 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.0/html2canvas.min.js'}
    ]
)

And the clientside_callback function will look like:

clientside_callback(
    """
    function(n_clicks){
        if(n_clicks > 0){
            html2canvas(document.getElementById("component-to-save"), {useCORS: true}).then(function (canvas) {
                var anchorTag = document.createElement("a");
                document.body.appendChild(anchorTag);
                anchorTag.download = "download.png";
                anchorTag.href = canvas.toDataURL();
                anchorTag.target = '_blank';
                anchorTag.click();
            });
        }
    }
    """,
    Output('download-image', 'n_clicks'),
    Input('download-image', 'n_clicks')

From what I found, html2pdf was built using html2canvas, so the above code might work with the html2pdf function to generate PDFs of your app. Note that html2pdf is a separate javascript function and will needed to be linked in the external stylesheets.

I realize this is a relatively old topic, but for the life of me, I cannot figure out how to add this functionality to apps (multiple) with existing callbacks that take information in from dcc.store and build multiple tables. e.g.,

@app.callback(
    Output('table1', 'children'),
    Output('table2', 'children'),
    Output('table3', 'children'),
    Output('table4', 'children'),
    Input('session-data', 'data')
)
def update_page(data):
   [build and display tables here]

If I make the changes and add the ‘clientside_callback’ to the end of the app, it throws the following error: ‘Attempting to connect a callback Input item to component ‘download-image’, but no components with that id exists in the layout’ (it actually throws the error for every component in the entire app).

Any guidance as to how to make this work? Ideally, I would be able to use the code on multiple pages to download multiple tables and charts.

It sounds like you don’t have download-image as an id of anything which is why it throws that error. In the example at the start of this thread, I create a button that has the id download-image. When the button is clicked, it fires the clientside callback that downloads an image of the component with id component-to-save.

If you are trying to save an image of your tables, you’ll want to wrap all of them in a component with id component-to-save. Then you’ll need to add a button to actually trigger the clientside download.

Thanks for the quick reply. I did add a button to my layout. A simplified version looks like:

layout = html.Div(
             [
                  html.Div(
                      [
                          html.Label("Sample Table ", style=label_style),
                          html.Button("Download as image", id="download-image"),
                          html.Div(id='sample-table')
                      ]
                 )
            ]
)

And then I modified ‘component-to-save’ in the callback to read ‘sample-table’.

I think the issue, and my primary point of confusion, is the fact that I have a callback with multiple outputs and a single input (shown in my previous post) and then later on there is the clientside callback code with its own Input and Output. Is it possible to have two separate callbacks in an app? If not, how do you combine the two into a single callback where n_clicks doesn’t mess with the page load?

I’ve tried to merge them (by adding the clientside_callback Inputs and Outputs like so:

@app.callback(
    Output('table1', 'children'),
    Output('table2', 'children'),
    Output('table3', 'children'),
    Output('table4', 'children'),
    Output('download-image', 'n_clicks'),
    Input('download-image', 'n_clicks'),
    Input('session-data', 'data')
)
def update_page(data):
   [build and display tables here]

Removing the Input and Output from the clientside_callback:

clientside_callback(
    """
    function(n_clicks){
        if(n_clicks > 0){
            html2canvas(document.getElementById("sample-table"), {useCORS: true}).then(function (canvas) {
                var anchorTag = document.createElement("a");
                document.body.appendChild(anchorTag);
                anchorTag.download = "download.png";
                anchorTag.href = canvas.toDataURL();
                anchorTag.target = '_blank';
                anchorTag.click();
            });
        }
    }
    """
)

But I keep getting “IndexError: list index out of range” errors that prevent the app from running. I’ve tried moving the callback to my app.py and my index.py as well with no luck. I admit that I have no experience incorporating js into a dash app, but I still feel like I am missing something basic here.

1 Like

It looks like you are trying to do too many things within a single callback. The clientside callback to download the image should be a separate callback from your update_page callback. Additionally, if you remove the inputs and outputs from the clientside callback, it will never be run since nothing is triggering it.

Usually the index out of range error corresponds to an incorrect number of inputs or outputs for a callback.

Okay, I have no idea what I was doing before, but I got it working.

The only difference is, because I have a multi-file application, I needed to put the reference to the external javascript library (html2canvas) in app.py. The rest of the code (button and callback) go in each ‘page’ file (page1.py, page2, py, etc.). I’m sure there is a way to do this without duplicating code, but it is too short for me to worry about it.

Thank you for the initial snippet and for keeping me banging on it until I figured it out!