Coverage for scanpath_studio/html_embed.py: 100%

21 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""Same-origin HTML iframe helper shared by plots, tours, and Share. 

2 

3Also serves the installed plotly package's own ``plotly.min.js`` from this app's 

4Streamlit server (ENG-64), so the in-app figures draw without a request to 

5``cdn.plot.ly`` — offline, in the desktop app, and on the hosted demo alike. 

6""" 

7 

8from __future__ import annotations 

9 

10from importlib import resources 

11from pathlib import Path 

12 

13import streamlit as st 

14from plotly.offline import get_plotlyjs_version 

15from streamlit.components.v1 import declare_component 

16 

17PLOTLYJS_FILENAME = "plotly.min.js" 

18 

19 

20def embed_html_iframe( 

21 html: str, *, height: int, alt: str | None = None, focusable: bool = False 

22) -> None: 

23 """Render script-bearing HTML in an iframe without the deprecated API. 

24 

25 ``st.iframe`` replaced the old components embed in Streamlit 1.58. Keeping 

26 this in one module prevents the plot, guided-tour, and Share surfaces from 

27 drifting onto different embed APIs. The compatibility import is deliberately 

28 lazy so current Streamlit runs never import or call the deprecated function. 

29 

30 ``alt`` names a *visible* embed — a figure, the Share link box — for 

31 assistive technology (Streamlit 1.65). The zero-height script carriers 

32 leave it unset: there is nothing on screen to describe. 

33 

34 ``focusable`` puts the frame in the tab order, and with it the controls 

35 inside it (a figure's zoom buttons, #374 F19). Off by default: a script 

36 carrier must never take a Tab stop. 

37 """ 

38 # Tiny script-only embeds need an explicit body. Streamlit's srcdoc 

39 # autosizing observer otherwise races the parser and tries to observe a null 

40 # body, producing a browser MutationObserver error even with a fixed height. 

41 # A whole document supplies its own. Which one this is shows in its first 

42 # bytes — a replay's markup runs to 55 MB, and lower-casing all of it to 

43 # look for "<body" cost 0.2 s a rerun (PERF-16). 

44 source = html 

45 if not html[:1024].lstrip().lower().startswith(_DOCUMENT_STARTS): 

46 source = f"<!doctype html><html><body>{html}</body></html>" 

47 st.iframe( 

48 source, height=max(1, int(height)), tab_index=0 if focusable else -1, alt=alt 

49 ) 

50 

51 

52_DOCUMENT_STARTS = ("<!doctype", "<html", "<head", "<body") 

53 

54 

55def plotlyjs_dir() -> Path: 

56 """The installed plotly package's ``package_data`` folder. 

57 

58 It holds the ``plotly.min.js`` that plotly.py itself inlines for 

59 ``include_plotlyjs=True`` — the build its figure JSON is written for. On the 

60 frozen desktop bundle it is under the unpacked ``plotly/`` package, which the 

61 spec collects with ``collect_data_files("plotly")``. 

62 """ 

63 return Path(str(resources.files("plotly") / "package_data")) 

64 

65 

66def plotlyjs_src() -> str: 

67 """URL of the bundled ``plotly.min.js`` on this app's own server. 

68 

69 Pass it as ``fig.to_html(include_plotlyjs=plotlyjs_src())``. Streamlit serves 

70 a registered custom-component directory at ``component/<name>/<file>`` 

71 (``declare_component(path=…)``), so the installed plotly package's own folder 

72 is registered as one — nothing is copied, and the file is always the one 

73 matching the installed plotly.py. Registration needs a script run and is 

74 idempotent, so it happens on every call rather than once at import. 

75 

76 The URL is **relative on purpose**. The figure is a ``srcdoc`` iframe, which 

77 resolves URLs against the Streamlit page's own address — the same contract 

78 Streamlit's ``index.html`` relies on for its ``./static/…`` bundle. A 

79 root-relative ``/component/…`` would miss both ``server.baseUrlPath`` and a 

80 proxy prefix such as Community Cloud's ``/~/+/``. 

81 

82 The plotly.js version is part of the component name, so an upgrade changes 

83 the URL rather than being served under a cached old one — and the URL keeps 

84 ending in ``.js``, which is what ``to_html`` needs to treat it as a script 

85 address. 

86 """ 

87 component = declare_component( 

88 f"plotlyjs-{get_plotlyjs_version()}", path=plotlyjs_dir() 

89 ) 

90 return f"component/{component.name}/{PLOTLYJS_FILENAME}" 

91 

92 

93# Streamlit's component route answers `Cache-Control: public` with no max-age and 

94# no validator, so a browser stores the 4.8 MB script but never reuses it: every 

95# re-drawn figure (a new srcdoc document) downloaded it again, measured at the 

96# full 1.48 MB gzip body per toggle. The Streamlit page outlives its figure 

97# iframes, so the first draw leaves a Blob URL of the script on it (read back out 

98# of the HTTP cache with `force-cache`, not downloaded twice) and later draws load 

99# that. `document.write` keeps the load parser-blocking, which the figure's own 

100# inline `Plotly.newPlot` script depends on. The memo is keyed by the URL, which 

101# carries the plotly.js version, and a page that cannot be reached (not 

102# same-origin) just falls back to the plain URL. 

103_PLOTLYJS_LOADER = """<script> 

104(function () { 

105 var src = "__SRC__", url = src, host = null; 

106 try { 

107 host = window.parent !== window ? window.parent : null; 

108 if (host) void host.document; 

109 } catch (err) { 

110 host = null; 

111 } 

112 var memo = host && host.__scanpathPlotlyJs; 

113 if (memo && memo.src === src) url = memo.url; 

114 document.write('<script charset="utf-8" src="' + url + '"><\\/script>'); 

115 if (!host || url !== src) return; 

116 window.addEventListener("load", function () { 

117 fetch(src, { cache: "force-cache" }) 

118 .then(function (r) { return r.ok ? r.blob() : null; }) 

119 .then(function (blob) { 

120 var prev = host.__scanpathPlotlyJs; 

121 if (!blob || (prev && prev.src === src)) return; 

122 if (prev) host.URL.revokeObjectURL(prev.url); 

123 var copy = new host.Blob([blob], { type: "application/javascript" }); 

124 host.__scanpathPlotlyJs = { src: src, url: host.URL.createObjectURL(copy) }; 

125 }) 

126 .catch(function () {}); 

127 }); 

128})(); 

129</script>""" 

130 

131 

132def plotlyjs_script() -> str: 

133 """The ``<script>`` that loads the bundled plotly.js into a figure iframe. 

134 

135 Put it ahead of ``fig.to_html(include_plotlyjs=False, …)``. It loads 

136 :func:`plotlyjs_src` on the first draw and, after that, the copy the 

137 Streamlit page kept of it — see ``_PLOTLYJS_LOADER`` for why. 

138 """ 

139 return _PLOTLYJS_LOADER.replace("__SRC__", plotlyjs_src())