Skip to content

Commit 2875285

Browse files
committed
Add responsive iframe embedding for interactive widgets in HTML output
1 parent 7ab8bf9 commit 2875285

1 file changed

Lines changed: 66 additions & 19 deletions

File tree

docs/_sg_html_scraper.py

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717

1818
import io
1919
from pathlib import Path
20+
from uuid import uuid4
21+
22+
# Maximum iframe width (px) that fits comfortably inside the pydata-sphinx-theme
23+
# content column on a desktop browser. Figures wider than this are scaled down
24+
# proportionally via CSS transform; a JS resize listener makes the embed fully
25+
# responsive so it also looks correct on tablets and phones.
26+
MAX_DOC_WIDTH = 684
2027

2128

2229
# ---------------------------------------------------------------------------
@@ -99,6 +106,63 @@ def _make_thumbnail_png(widget) -> bytes:
99106
return buf.read()
100107

101108

109+
def _iframe_html(src: str, w: int, h: int) -> str:
110+
"""Return a single-line HTML snippet that embeds *src* responsively.
111+
112+
The iframe is always rendered at its native resolution (``w × h`` px) so
113+
the interactive widget is pixel-perfect on wide screens. On narrower
114+
viewports (docs sidebar layout, tablet, phone) a CSS ``transform:scale()``
115+
shrinks the whole iframe proportionally — CSS transforms correctly
116+
translate pointer events, so dragging and scrolling continue to work.
117+
118+
A tiny inline script re-runs the scale calculation on every ``resize``
119+
event so the embed reflows without a page reload.
120+
"""
121+
uid = f"f{uuid4().hex[:8]}"
122+
123+
# Static initial scale so the page renders correctly before JS runs
124+
init_scale = min(1.0, MAX_DOC_WIDTH / w)
125+
init_w = round(w * init_scale)
126+
init_h = round(h * init_scale)
127+
scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".")
128+
129+
# Inline JS: re-scale whenever the window is resized.
130+
# Uses the wrapper's parent width as the available space so the figure
131+
# always fills (but never overflows) the content column.
132+
js = (
133+
f"(function(){{"
134+
f"var wrap=document.getElementById('{uid}'),"
135+
f"ifr=wrap.querySelector('iframe'),"
136+
f"nw={w},nh={h};"
137+
f"function r(){{"
138+
f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:nw;"
139+
f"var s=Math.min(1,avail/nw);"
140+
f"wrap.style.width=Math.round(nw*s)+'px';"
141+
f"wrap.style.height=Math.round(nh*s)+'px';"
142+
f"ifr.style.transform='scale('+s+')';"
143+
f"}}"
144+
f"r();window.addEventListener('resize',r);"
145+
f"}})()"
146+
)
147+
148+
# The wrapper is sized to the *scaled* dimensions and clips overflow.
149+
# The iframe is absolutely positioned at (0,0) at its full native size;
150+
# CSS transform scales it to fit exactly inside the wrapper.
151+
return (
152+
f'<div style="display:block;text-align:center;line-height:0;margin:12px 0;">'
153+
f'<div id="{uid}" style="display:inline-block;overflow:hidden;'
154+
f'position:relative;width:{init_w}px;height:{init_h}px;">'
155+
f'<iframe src="{src}" frameborder="0" scrolling="no" '
156+
f'style="width:{w}px;height:{h}px;border:none;overflow:hidden;display:block;'
157+
f'transform-origin:top left;transform:scale({scale_css});'
158+
f'position:absolute;top:0;left:0;">'
159+
f'</iframe>'
160+
f'</div>'
161+
f'<script>{js}</script>'
162+
f'</div>'
163+
)
164+
165+
102166
# ---------------------------------------------------------------------------
103167
# Scraper
104168
# ---------------------------------------------------------------------------
@@ -155,35 +219,18 @@ def __call__(self, block, block_vars, gallery_conf):
155219

156220
# ── 3. Return rST ──────────────────────────────────────────────────
157221
if interactive:
158-
# Compute the relative path from the *built* HTML page back up to
159-
# _static/viewer_widgets/.
160-
#
161-
# The PNG (and its sibling HTML) sits at e.g.:
162-
# <build>/auto_examples/Markers/images/sphx_glr_plot_circles_001.png
163-
# The built page for this example is at:
164-
# <build>/auto_examples/Markers/plot_circles.html
165-
# _static/viewer_widgets/ lives at:
166-
# <build>/_static/viewer_widgets/
167-
#
168-
# We derive depth by counting the parts of the gallery output path
169-
# relative to the Sphinx source dir (which mirrors the build root).
170222
try:
171223
src_dir = Path(gallery_conf["src_dir"])
172-
# png_path is inside the gallery output images/ subdir.
173-
# The page itself is one directory above images/.
174224
page_dir = png_path.parent.parent # strip /images
175225
rel_parts = page_dir.relative_to(src_dir).parts
176-
depth = len(rel_parts) # e.g. 2 for auto_examples/Markers
226+
depth = len(rel_parts)
177227
except Exception:
178228
depth = 1
179229
prefix = "../" * depth
180230
src = f"{prefix}_static/viewer_widgets/{html_name}"
181231
return (
182232
"\n\n.. raw:: html\n\n"
183-
f' <div style="display:block;text-align:center;line-height:0;margin:12px 0;">'
184-
f'<iframe src="{src}" frameborder="0" scrolling="no"'
185-
f' style="width:{w}px;height:{h}px;border:none;overflow:hidden;'
186-
f'display:inline-block;max-width:100%;"></iframe></div>\n\n'
233+
" " + _iframe_html(src, w, h) + "\n\n"
187234
)
188235
else:
189236
rel_png = png_path.name

0 commit comments

Comments
 (0)