Skip to content

Commit 032f796

Browse files
committed
Enhance thumbnail rendering: implement pixel-accurate dark-theme PNG generation using headless Chromium (Playwright) with fallback to matplotlib
1 parent b014e94 commit 032f796

2 files changed

Lines changed: 87 additions & 52 deletions

File tree

docs/_sg_html_scraper.py

Lines changed: 86 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
This scraper:
88
1. Finds a anyplotlib widget in ``example_globals`` (any object from the ``anyplotlib``
99
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.
1114
3. Writes the **full interactive HTML** (iframe + widget JS) alongside the PNG.
1215
4. Returns rST that embeds both: the PNG as a fallback image AND an iframe for
1316
interactive use, using a ``.. raw:: html`` block.
@@ -40,64 +43,95 @@ def _find_viewer(globals_dict: dict):
4043

4144

4245
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."""
44124
import matplotlib
45125
matplotlib.use("Agg")
46126
import matplotlib.pyplot as plt
47-
import numpy as np
48127

128+
kind = type(widget).__name__
49129
fig, ax = plt.subplots(figsize=(4, 3), dpi=72)
50130
ax.set_facecolor("#1e1e2e")
51131
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")
101135
plt.tight_layout(pad=0.3)
102136
buf = io.BytesIO()
103137
fig.savefig(buf, format="png", dpi=72, facecolor=fig.get_facecolor())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ docs = [
2424
"sphinx-gallery>=0.18",
2525
"pillow>=10.0",
2626
"matplotlib>=3.7",
27+
"playwright>=1.58.0",
2728
]
2829
jupyter = [
2930
"jupyterlab>=4.5.5",

0 commit comments

Comments
 (0)