|
17 | 17 |
|
18 | 18 | import io |
19 | 19 | 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 |
20 | 27 |
|
21 | 28 |
|
22 | 29 | # --------------------------------------------------------------------------- |
@@ -99,6 +106,63 @@ def _make_thumbnail_png(widget) -> bytes: |
99 | 106 | return buf.read() |
100 | 107 |
|
101 | 108 |
|
| 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 | + |
102 | 166 | # --------------------------------------------------------------------------- |
103 | 167 | # Scraper |
104 | 168 | # --------------------------------------------------------------------------- |
@@ -155,35 +219,18 @@ def __call__(self, block, block_vars, gallery_conf): |
155 | 219 |
|
156 | 220 | # ── 3. Return rST ────────────────────────────────────────────────── |
157 | 221 | 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). |
170 | 222 | try: |
171 | 223 | 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/. |
174 | 224 | page_dir = png_path.parent.parent # strip /images |
175 | 225 | 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) |
177 | 227 | except Exception: |
178 | 228 | depth = 1 |
179 | 229 | prefix = "../" * depth |
180 | 230 | src = f"{prefix}_static/viewer_widgets/{html_name}" |
181 | 231 | return ( |
182 | 232 | "\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" |
187 | 234 | ) |
188 | 235 | else: |
189 | 236 | rel_png = png_path.name |
|
0 commit comments