Skip to content

Instantly share code, notes, and snippets.

@kerrickstaley
Created March 5, 2026 20:36
Show Gist options
  • Select an option

  • Save kerrickstaley/988f3b5792cf4d3f7e0e6da983cbc982 to your computer and use it in GitHub Desktop.

Select an option

Save kerrickstaley/988f3b5792cf4d3f7e0e6da983cbc982 to your computer and use it in GitHub Desktop.
Tool for sharing a Plotly graph via image and/or web-page hosted on GitHub Gist
import base64
import concurrent.futures
import json
import subprocess
import tempfile
from pathlib import Path
from IPython.display import Javascript, display
def _copy_text_to_clipboard(text: str):
display(
Javascript(
f'''
(async () => {{
try {{
await navigator.clipboard.writeText({json.dumps(text)});
}} catch (e) {{
console.error(e);
}}
}})();
'''
)
)
def _copy_png_to_clipboard(png_b64: str):
display(
Javascript(
f'''
(async () => {{
try {{
const bytes = Uint8Array.from(atob({json.dumps(png_b64)}), c => c.charCodeAt(0));
const blob = new Blob([bytes], {{ type: 'image/png' }});
await navigator.clipboard.write([new ClipboardItem({{ 'image/png': blob }})]);
}} catch (e) {{
console.error(e);
}}
}})();
'''
)
)
def _copy_png_and_text_to_clipboard(png_b64: str, text: str):
display(
Javascript(
f'''
(async () => {{
try {{
const bytes = Uint8Array.from(atob({json.dumps(png_b64)}), c => c.charCodeAt(0));
const imageBlob = new Blob([bytes], {{ type: 'image/png' }});
const textBlob = new Blob([{json.dumps(text)}], {{ type: 'text/plain' }});
const item = new ClipboardItem({{
'image/png': imageBlob,
'text/plain': textBlob
}});
await navigator.clipboard.write([item]);
}} catch (e) {{
console.error(e);
}}
}})();
'''
)
)
def _plotly_html_to_gisthost_url(fig_html: str, filename: str = 'index.html') -> str:
with tempfile.TemporaryDirectory() as tmpdir:
filepath = Path(tmpdir) / filename
filepath.write_text(fig_html)
result = subprocess.run(
['gh', 'gist', 'create', str(filepath)],
capture_output=True,
text=True,
check=True,
)
gist_url = result.stdout.strip().splitlines()[-1]
gist_id = gist_url.rstrip('/').split('/')[-1]
return f'https://gisthost.github.io/?{gist_id}\n'
def copyable_figure(fig, **kwargs):
'''Return widget buttons for copying PNG and Githost URL for a Plotly figure.'''
import ipywidgets as widgets
fig_html = fig.to_html(include_plotlyjs='cdn', full_html=True)
png_kwargs = dict(kwargs)
png_kwargs.setdefault('scale', 2.0)
png_b64 = None
gisthost_url = None
copy_png_button = widgets.Button(description='Render PNG')
copy_githost_button = widgets.Button(description='Create Gist')
copy_both_button = widgets.Button(description='Render PNG + Gist')
def _gen_png():
nonlocal png_b64
if png_b64 is None:
png_bytes = fig.to_image(format='png', **png_kwargs)
png_b64 = base64.b64encode(png_bytes).decode()
def _gen_gist():
nonlocal gisthost_url
if gisthost_url is None:
gisthost_url = _plotly_html_to_gisthost_url(fig_html)
def _update_button_state(busy):
copy_png_button.description = 'Copy PNG' if png_b64 else 'Render PNG'
copy_githost_button.description = 'Copy Gist URL' if gisthost_url else 'Create Gist'
if png_b64 and gisthost_url:
copy_both_button.description = 'Copy PNG + URL'
elif png_b64:
copy_both_button.description = 'Create Gist'
elif gisthost_url:
copy_both_button.description = 'Render PNG'
else:
copy_both_button.description = 'Render PNG + Gist'
for b in (copy_png_button, copy_githost_button, copy_both_button):
b.disabled = busy
def on_copy_png_click(_):
try:
if png_b64 is None:
_update_button_state(busy=True)
copy_png_button.description = 'Rendering PNG...'
_gen_png()
else:
_copy_png_to_clipboard(png_b64)
copy_png_button.description = 'Copied!'
return
except Exception as exc:
copy_png_button.description = f'Failed: {str(exc)[:30]}'
return
_update_button_state(busy=False)
def on_copy_githost_click(_):
try:
if gisthost_url is None:
_update_button_state(busy=True)
copy_githost_button.description = 'Creating gist...'
_gen_gist()
else:
_copy_text_to_clipboard(gisthost_url)
copy_githost_button.description = 'Copied!'
return
except subprocess.CalledProcessError as exc:
error_text = exc.stderr.strip() or exc.stdout.strip() or str(exc)
copy_githost_button.description = f'Failed: {error_text[:30]}'
return
except Exception as exc:
copy_githost_button.description = f'Failed: {str(exc)[:30]}'
return
_update_button_state(busy=False)
def on_copy_both_click(_):
try:
if png_b64 is None or gisthost_url is None:
_update_button_state(busy=True)
need_png = png_b64 is None
need_gist = gisthost_url is None
if need_png and need_gist:
copy_both_button.description = 'Rendering + creating gist...'
elif need_png:
copy_both_button.description = 'Rendering PNG...'
else:
copy_both_button.description = 'Creating gist...'
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
futures = []
if need_png:
futures.append(executor.submit(_gen_png))
if need_gist:
futures.append(executor.submit(_gen_gist))
for f in futures:
f.result()
else:
_copy_png_and_text_to_clipboard(png_b64, gisthost_url)
copy_both_button.description = 'Copied!'
return
except subprocess.CalledProcessError as exc:
error_text = exc.stderr.strip() or exc.stdout.strip() or str(exc)
copy_both_button.description = f'Failed: {error_text[:30]}'
return
except Exception as exc:
copy_both_button.description = f'Failed: {str(exc)[:30]}'
return
_update_button_state(busy=False)
copy_png_button.on_click(on_copy_png_click)
copy_githost_button.on_click(on_copy_githost_click)
copy_both_button.on_click(on_copy_both_click)
return widgets.HBox([copy_png_button, copy_githost_button, copy_both_button])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment