|
7 | 7 | This scraper: |
8 | 8 | 1. Finds a anyplotlib widget in ``example_globals`` (any object from the ``anyplotlib`` |
9 | 9 | package that has ``_repr_html_``). |
10 | | -2. Renders a **static thumbnail PNG** via matplotlib for the gallery index. |
| 10 | +2. Renders a **pixel-accurate dark-theme thumbnail PNG** by loading the widget's |
| 11 | + standalone HTML in headless Chromium (Playwright) — the exact same renderer |
| 12 | + the user sees in a notebook. Falls back to a plain matplotlib placeholder |
| 13 | + if Playwright is not installed. |
11 | 14 | 3. Writes the **full interactive HTML** (iframe + widget JS) alongside the PNG. |
12 | 15 | 4. Returns rST that embeds both: the PNG as a fallback image AND an iframe for |
13 | 16 | interactive use, using a ``.. raw:: html`` block. |
@@ -40,64 +43,95 @@ def _find_viewer(globals_dict: dict): |
40 | 43 |
|
41 | 44 |
|
42 | 45 | def _make_thumbnail_png(widget) -> bytes: |
43 | | - """Render a small static thumbnail PNG for the gallery index card.""" |
| 46 | + """Render a thumbnail PNG of *widget* using headless Chromium (Playwright). |
| 47 | +
|
| 48 | + The widget is rendered at its native size with the dark theme forced on. |
| 49 | + Falls back to a minimal matplotlib placeholder if Playwright is not |
| 50 | + available in the current environment. |
| 51 | + """ |
| 52 | + try: |
| 53 | + return _playwright_thumbnail(widget) |
| 54 | + except Exception: |
| 55 | + return _matplotlib_fallback_png(widget) |
| 56 | + |
| 57 | + |
| 58 | +def _playwright_thumbnail(widget) -> bytes: |
| 59 | + """Render *widget* in headless Chromium and return dark-theme PNG bytes. |
| 60 | +
|
| 61 | + Mirrors the ``_screenshot_widget`` helper in ``tests/conftest.py`` but |
| 62 | + forces the dark Dracula theme by: |
| 63 | +
|
| 64 | + * Replacing the page background with ``#1e1e2e`` so ``_isDarkBg()`` |
| 65 | + inside the widget JS immediately detects a dark parent. |
| 66 | + * Calling ``page.emulate_media(color_scheme='dark')`` so the |
| 67 | + ``prefers-color-scheme`` media query also resolves to dark (the |
| 68 | + fallback path in ``_isDarkBg`` when no explicit background is set). |
| 69 | + """ |
| 70 | + import tempfile |
| 71 | + from playwright.sync_api import sync_playwright |
| 72 | + from anyplotlib._repr_utils import build_standalone_html |
| 73 | + |
| 74 | + # Build the fully self-contained HTML page. |
| 75 | + html = build_standalone_html(widget, resizable=False) |
| 76 | + |
| 77 | + # Inject the render-complete sentinel exactly as conftest.py does so |
| 78 | + # Playwright can wait for the canvas to be fully painted. |
| 79 | + html = html.replace( |
| 80 | + "renderFn({ model, el });", |
| 81 | + "renderFn({ model, el }); window._aplReady = true;", |
| 82 | + ) |
| 83 | + |
| 84 | + # Override the transparent page background with the dark theme colour. |
| 85 | + # This makes _isDarkBg() in figure_esm.js immediately return True and |
| 86 | + # avoids a flash of the light theme before the media-query listener fires. |
| 87 | + html = html.replace("background: transparent;", "background: #1e1e2e;") |
| 88 | + |
| 89 | + with tempfile.NamedTemporaryFile( |
| 90 | + suffix=".html", mode="w", encoding="utf-8", delete=False |
| 91 | + ) as fh: |
| 92 | + fh.write(html) |
| 93 | + tmp_path = Path(fh.name) |
| 94 | + |
| 95 | + try: |
| 96 | + with sync_playwright() as pw: |
| 97 | + browser = pw.chromium.launch(headless=True) |
| 98 | + try: |
| 99 | + page = browser.new_page() |
| 100 | + # Set OS-level dark preference so every media query agrees. |
| 101 | + page.emulate_media(color_scheme="dark") |
| 102 | + page.goto(tmp_path.as_uri()) |
| 103 | + page.wait_for_function( |
| 104 | + "() => window._aplReady === true", timeout=15_000 |
| 105 | + ) |
| 106 | + # Two rAFs: first lets the compositor flush canvas pixels; |
| 107 | + # second ensures element bounds are stable (mirrors conftest.py). |
| 108 | + page.evaluate( |
| 109 | + "() => new Promise(r =>" |
| 110 | + " requestAnimationFrame(() => requestAnimationFrame(r)))" |
| 111 | + ) |
| 112 | + png_bytes = page.locator("#widget-root").screenshot() |
| 113 | + finally: |
| 114 | + page.close() |
| 115 | + browser.close() |
| 116 | + finally: |
| 117 | + tmp_path.unlink(missing_ok=True) |
| 118 | + |
| 119 | + return png_bytes |
| 120 | + |
| 121 | + |
| 122 | +def _matplotlib_fallback_png(widget) -> bytes: |
| 123 | + """Minimal dark-background placeholder used when Playwright is unavailable.""" |
44 | 124 | import matplotlib |
45 | 125 | matplotlib.use("Agg") |
46 | 126 | import matplotlib.pyplot as plt |
47 | | - import numpy as np |
48 | 127 |
|
| 128 | + kind = type(widget).__name__ |
49 | 129 | fig, ax = plt.subplots(figsize=(4, 3), dpi=72) |
50 | 130 | ax.set_facecolor("#1e1e2e") |
51 | 131 | fig.patch.set_facecolor("#1e1e2e") |
52 | | - ax.tick_params(colors="#cdd6f4") |
53 | | - for spine in ax.spines.values(): |
54 | | - spine.set_edgecolor("#44475a") |
55 | | - |
56 | | - kind = type(widget).__name__ |
57 | | - |
58 | | - try: |
59 | | - if kind == "Viewer2D": |
60 | | - import json |
61 | | - raw = widget._raw_u8 |
62 | | - cmap = widget.colormap_name or "gray" |
63 | | - ax.imshow(raw, cmap=cmap, aspect="auto", interpolation="nearest") |
64 | | - ax.set_title("Viewer2D", color="#cdd6f4", fontsize=9) |
65 | | - ax.set_xticks([]); ax.set_yticks([]) |
66 | | - |
67 | | - elif kind == "Viewer1D": |
68 | | - import json |
69 | | - data = np.array(json.loads(widget.data_json)) |
70 | | - x_axis = np.array(json.loads(widget.x_axis_json)) |
71 | | - ax.plot(x_axis, data, color="#4fc3f7", linewidth=1) |
72 | | - ax.set_title("Viewer1D", color="#cdd6f4", fontsize=9) |
73 | | - ax.set_facecolor("#181825") |
74 | | - |
75 | | - elif kind == "Figure": |
76 | | - from anyplotlib.figure_plots import Plot2D, Plot1D |
77 | | - import json |
78 | | - plots = list(widget._plots_map.values()) |
79 | | - ax.set_title(f"Figure ({widget._nrows}×{widget._ncols})", |
80 | | - color="#cdd6f4", fontsize=9) |
81 | | - if plots: |
82 | | - p = plots[0] |
83 | | - if isinstance(p, Plot2D): |
84 | | - ax.imshow(p._raw_u8, cmap=p._state.get("colormap_name", "gray"), |
85 | | - aspect="auto", interpolation="nearest") |
86 | | - elif isinstance(p, Plot1D): |
87 | | - d = np.asarray(p._state.get("data", [])) |
88 | | - x = np.asarray(p._state.get("x_axis", np.arange(len(d)))) |
89 | | - ax.plot(x, d, color=p._state.get("line_color", "#4fc3f7"), linewidth=1) |
90 | | - ax.set_xticks([]); ax.set_yticks([]) |
91 | | - else: |
92 | | - ax.text(0.5, 0.5, kind, ha="center", va="center", |
93 | | - color="#cdd6f4", transform=ax.transAxes) |
94 | | - ax.axis("off") |
95 | | - |
96 | | - except Exception: |
97 | | - ax.text(0.5, 0.5, kind, ha="center", va="center", |
98 | | - color="#cdd6f4", transform=ax.transAxes) |
99 | | - ax.axis("off") |
100 | | - |
| 132 | + ax.text(0.5, 0.5, kind, ha="center", va="center", |
| 133 | + color="#cdd6f4", transform=ax.transAxes, fontsize=12) |
| 134 | + ax.axis("off") |
101 | 135 | plt.tight_layout(pad=0.3) |
102 | 136 | buf = io.BytesIO() |
103 | 137 | fig.savefig(buf, format="png", dpi=72, facecolor=fig.get_facecolor()) |
|
0 commit comments