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
« 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.
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"""
8from __future__ import annotations
10from importlib import resources
11from pathlib import Path
13import streamlit as st
14from plotly.offline import get_plotlyjs_version
15from streamlit.components.v1 import declare_component
17PLOTLYJS_FILENAME = "plotly.min.js"
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.
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.
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.
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 )
52_DOCUMENT_STARTS = ("<!doctype", "<html", "<head", "<body")
55def plotlyjs_dir() -> Path:
56 """The installed plotly package's ``package_data`` folder.
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"))
66def plotlyjs_src() -> str:
67 """URL of the bundled ``plotly.min.js`` on this app's own server.
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.
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 ``/~/+/``.
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}"
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>"""
132def plotlyjs_script() -> str:
133 """The ``<script>`` that loads the bundled plotly.js into a figure iframe.
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())