Download an html version of the app

I am trying to find a method to let the users of my app download a html version of the app, which can then be opened when the Bokeh server is not accessible.
The downloadable version has to be cleaned up before downloading to hide unnecessary elements or elements triggering a python callback, which would not work in a standalone html file.

My trial has two basic steps:

  • Creating the html file with file_html()
  • Downloading the file using a Javascript callback based on this example Export CSV, the code of which can be found here: export_csv.

I came to the following working code:

from bokeh.io import curdoc
from bokeh.layouts import column
from bokeh.models import Button, CustomJS, Div, Paragraph
from bokeh.resources import CDN
from bokeh.embed import file_html

js_code = """
const filetext = html_doc.text

const filename = "HTML Export.html"
const blob = new Blob([filetext], { type: 'application/xhtml+xml;charset=utf-8;' })

const link = document.createElement('a') // Creates an <a> type element (anchor = hyperlink)
link.href = URL.createObjectURL(blob) // Sets the blob as the hyperlink the anchor element points to
link.download = filename // Tells that the file has to be downloaded and sets the file name.
link.target = '_blank' // For some browsers, may influence the way the file is downloaded
link.style.visibility = 'hidden' // Actually not necessary, sinse object not in DOM
link.dispatchEvent(new MouseEvent('click')) // Simulates clicking of the link.
URL.revokeObjectURL(link.href) // Release the object URL
link.remove() // Remove anchor element
"""

my_div = Div(text="Some display element (could be a chart)")
my_div2 = Div(text="Another display element (could be a second chart)")
html_create_button = Button(label="Create HTML")

# Create a model to pass the up-to-date file content to Javascript.
# Since it only works if added to the layout but does not have to be seen, make it invisible.
html_doc = Paragraph(visible=False)

def html_create_callback():
    # Preparation: hide button which would be dead in the standalone html:
    html_create_button.visible = False
    # Create html file:
    html_doc.text = file_html(models=my_layout, resources=CDN, title="HTML Export", suppress_callback_warning=True)
    html_create_button.visible = True

# Python callback:
html_create_button.on_click(html_create_callback)

# Create a Javascript callback that automatically starts when html_doc.text changes:
html_doc.js_on_change("text", CustomJS(args=dict(html_doc=html_doc), code=js_code))

my_layout = column(my_div, my_div2, html_create_button, html_doc)

curdoc().add_root(my_layout)

This code works but raised for me several questions:

  1. I first need to clean up the app, which is done with a Python callback (in my real app, I have a lot of changes which would be difficult to write in Javascript), then the Javascript download code has to be executed.
    My question: would it be possible to execute the Javascript code “inline” in the Python code without having to configure an extra Javascript callback which also has to be somehow triggered?
    Something like:
Execute Python code
RunJS(args=dict(some_input=some_input), code="Some Javascript code to be executed now")
Execute further Python code
  1. In my example, I use a Paragraph widget to handover the file content and to trigger the Javascript callback with paragraph.js_on_change("text", CustomJS(…
    I only managed to trigger the Javascript callback when the Paragraph widget was added to the app layout, although I actually don’t want to show it.
    I know I can have the handover part done with DataModel (see use-a-variable-as-argument-to-customjs), though I don’t know how to trigger the Javascript callback with a DataModel.
    Is there a better way?

  2. Feel free to review the Javascript code of my example (including comments). Do the two last rows
    URL.revokeObjectURL(link.href)
    link.remove()
    make sense? Expecially if the file is big and the user downloads it several times, the memory should not stay occupied unnecessarily.

  3. General question about file_html(): Is it possible to add an icon to be displayed in the tab of the browser? Currently, there is only a blank page symbol:
    Tab icon

  4. Would it make sense to implement in Bokeh a download() function which could be used as follows:
    download(file=file_content, name=file_name)
    where file_content would be a previously prepared file, like for instance csv, picture, html file,…?
    The preparation of the file wouldn’t necessary have to be done with Bokeh itself. For instance, pandas.DataFrame.to_csv could be used to prepare a csv.
    I would see such a function as a reverse FileInput. Though, I don’t think that any graphical widget would be necessary for such a download function, since the user has the possibility to connect the function to widgets like buttons on his/her own.

My system:
Windows 10
Python 3.12.0
Bokeh 3.3.0

1 Like

@Icoti I only have comments to a few of your questions.

  1. In my example below I did not observe any issues with respect to not having the download paragraph widget in the static app layout for download (I use a Div). I use get_model_by_name in order to get the layout part for the static app.

  2. One can also save the layout as a file to disk where Bokeh server is running in the python callback and then use the CustomJS callback to read from disk and save to user.

  3. I guess in order to show a favicon one would need that such an icon is also downloaded and have a link in head of the html document.

<link rel="icon" type="image/png" href="/favicon.png"/>

but I am pretty sure one needs the document to run on a server in order to display the favicon. (And if I understood you correctly, this is a case of just opening a html document directly in the browser from the download folder?)

#main.py
from os.path import dirname, join, basename, normpath
from bokeh.io import curdoc
from bokeh.embed import file_html
from bokeh.document import Document
from bokeh.layouts import column
from bokeh.models import Button, CustomJS, Div
from bokeh.resources import CDN
from functools import partial

FNAME  = 'HTML Export.html'
app_path = normpath(dirname(__file__))
app_endpoint = basename(app_path)

download_cb_code = '''
  function upload(file) {
    fetch(file)
      .then((response) => {
        if (!response.ok) {
          throw new Error(`HTTP error: ${response.status}`);
        }
        return response.blob();
      })
      .then((blob) => downLoad(blob))
      .catch((err) => console.error(`Fetch problem: ${err.message}`));
  }

  function downLoad(blob) {
    const filename = 'HTML Export.html'

    //addresses IE
    if (navigator.msSaveBlob) {
        navigator.msSaveBlob(blob, filename)
    } else {
        const link = document.createElement('a')
        link.href = URL.createObjectURL(blob)
        link.download = filename
        link.target = '_blank'
        link.style.visibility = 'hidden'
        link.dispatchEvent(new MouseEvent('click'))
    }
  }

  const bk_file_div = window.Bokeh.documents[0].get_model_by_name('bk_file_div');
  
  if (bk_file_div.text == 'file_ready') {
    upload(file);
    bk_file_div.text = 'file_notready';
  }
'''

my_div = Div(text="Some display element (could be a chart)")
my_div2 = Div(text="Another display element (could be a second chart)")

file_ready_div = Div(text = '', visible = False, name = 'bk_file_div')
file_ready_div.js_on_change(
    'text',
    CustomJS(
        args={'file': join(app_endpoint, 'static', FNAME)},
        code = download_cb_code
        )
    )

button = Button(label="Download", button_type="success")

layout_static = column(my_div, my_div2, name = 'static')
layout_app = column(layout_static, file_ready_div, button)

def btn_callback(layout):
    fout = join(app_path, 'static', FNAME)
    doc = curdoc()
    model = doc.get_model_by_name('static')
    with open(fout, "w") as f:
        f.write(file_html(model, resources=CDN, title="HTML Export"))
    file_ready_div.text = 'file_ready'

button.on_event('button_click', partial(btn_callback, layout_static))

curdoc().add_root(layout_app)
curdoc().title = "Export app"

@Jonas_Grave_Kristens Thanks for the answers.

  1. In your code, the Div used to trigger the Javascript callback is (like paragraph in my code) in the layout_app, although we don’t want to see it.
    My question was if it is possible to trigger the Javascript callback with an element which is not in the layout.

  2. Saving the layout as a file to disk before fetching it with Javascript is indeed an option, I will think about it. In my current code, I avoid this step because I don’t see the necessity for it, since I can directly handover the file content.

  3. Yes, the html file has to be fully portable, it will be opened by users not having access to the server or any directories containing the icon. So I guess that the icon has to be embedded in the html file.

Unfortunately, I didn’t manage to get your code working. I started it with bokeh serve --show file.py.
I had to create the directory /static to get at least the html file saved to the disk.
But then the fetching of the file in the Javascript didn’t work and leads to the error:

Failed to load resource: the server Data/static/HTML%20Export.html responded with a status of 404 (Not Found)
VM14:14 Fetch problem: HTTP error: 404

It seams that the handling of the path doesn’t work as expected.
Maybe this could be due to a different operating system, leading to different behavior of os?
I use Windows 10.

@Icoti The reason you get the error is because I run the app as a Bokeh directory app, hence you need to run the bokeh command on the directory of the app:

bokeh serve <name_of_directory>

In the directory the app needs to be called main.py and, for this case, you need a folder called static (see the docs).

  1. This is the only way I know of with respect to triggering the JS callback. In my setup the element triggering the JS is in the server layout, but not in the layout you save to the file (but maybe I misunderstood this comment):
layout_static = column(my_div, my_div2, name = 'static')
layout_app = column(layout_static, file_ready_div, button)

@Jonas_Grave_Kristens Sorry, I was not familiar with the directory app format.
The code works fine if I follow your instructions.

  1. My initial question was if it is possible to trigger the CustomJS callback from an element which is not in any layout at all (neither server layout nor layout to be saved to the file). Since we don’t want to show the div or paragraph element, my understanding would be that it is not necessary to have it in the layout.
    On the other hand, I understand that the system has to know on which browser page the download has to be started, so some kind of info is needed about which layout is to use.

About my question number 1., I found another very similar question:

But I could not see if the answer is “definitively not” or if there is any reasonable way to start Javascript code from Python, without extra callback that has to be separately triggered.

Here is a reworked version of my initial code with following optimizations:

  • Handover of file content with DataModel subclass
  • Deleting the file content in DataModel subclass after downloading to release memory space
from bokeh.io import curdoc
from bokeh.layouts import column
from bokeh.models import Button, CustomJS, Div, Paragraph
from bokeh.resources import CDN
from bokeh.embed import file_html
from bokeh.core.properties import String, expr
from bokeh.model import DataModel

js_code = """
// Handed over from Python:
// file_content as DataModel with .string attribute
// cb_obj as the model that triggered the Javascript callback, here a paragraph with text attribute.

console.log("Javascript download code triggered.")

const filetext = file_content.text
const trigger_text = cb_obj.text

function download(filetext) {
    const filename = "HTML Export.html"
    const blob = new Blob([filetext], { type: 'application/xhtml+xml;charset=utf-8;' })

    const link = document.createElement('a') // Creates an <a> type element (anchor = hyperlink)
    link.href = URL.createObjectURL(blob) // Sets the blob as the hyperlink the anchor element points to
    link.download = filename // Tells that the file has to be downloaded and sets the file name.
    link.target = '_blank' // For some browsers, may influence the way the file is downloaded
    link.style.visibility = 'hidden' // Actually not necessary, sinse object not in DOM
    link.dispatchEvent(new MouseEvent('click')) // Simulates clicking of the link.
    URL.revokeObjectURL(link.href) // Release the object URL
    link.remove() // Remove anchor element
}

if (trigger_text == "file_ready") {
    console.log("cb_obj.text == \'file_ready\', initiating download...")
    download(filetext)
    console.log("Download completed.")
    // Empty the text of the DataModel, which can be quite big depending on file size:
    console.log("Setting file_content.text to \'\'...")
    file_content.text = ""
    // Reset the text of the triggering Paragraph model:
    console.log("Setting cb_obj.text (triggerJS.text in Python) to \'file_not_ready\'...")
    cb_obj.text = "file_not_ready"
} else { // The Javascript code will be restarted after download because cb_obj.text (triggerJS.text in Python) changed. Handle this case:
    console.log("cb_obj.text != \'file_ready\'. Javascript was most probably triggered because cb_obj.text was reset to \'file_not_ready\' after the last download. Quitting Javascript without doing anything.")
}
"""

my_div = Div(text="Some display element (could be a chart)")
my_div2 = Div(text="Another display element (could be a second chart)")
html_create_button = Button(label="Create HTML")

# Create a DataModel subclass to pass the up-to-date file content to Javascript.
class string_buffer(DataModel):
    text = String()
# Create an instance of it:
file_content = string_buffer(text="")

# Create a model to trigger the Javascript code executing the download.
# Since it only works if added to the layout but does not have to be seen, make it invisible.
triggerJS = Paragraph(text="file_not_ready", visible=False)

def html_create_callback():
    # Preparation: hide button which would be dead in the standalone html:
    html_create_button.visible = False
    # Create html file:
    html_doc = file_html(models=my_layout, resources=CDN, title="HTML Export", suppress_callback_warning=True)
    html_create_button.visible = True
    file_content.text = str(html_doc)
    triggerJS.text = "file_ready"

# Python callback:
html_create_button.on_click(html_create_callback)

# Javascript callback:
triggerJS.js_on_change("text", CustomJS(args=dict(file_content=file_content), code=js_code))

my_layout = column(my_div, my_div2, html_create_button, triggerJS)

curdoc().add_root(my_layout)

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.