Skip to content

Commit f4a6e80

Browse files
committed
docs(sphinx) Reserve demo image space with aspect-ratio
why: The demo GIFs render as width:100% with no intrinsic size, so the browser reserves no height until each GIF decodes and the page jumps as you move through the sidebar. Sphinx only emits dimensions for :scale:, and those come out as fixed pixels that distort the image on narrow viewports. what: - Add an html-page-context hook stamping each demo <img> with width:100%; aspect-ratio:W/H, computed from the source GIF - Reserves the correct box before load (no layout shift) while staying fluid and keeping lazy loading
1 parent 9006e44 commit f4a6e80

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

docs/_ext/aspect_ratio.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Reserve demo image space via aspect-ratio so pages don't shift on load (CLS).
2+
3+
Sphinx's HTML writer renders image ``:width:`` as an inline pixel style, which
4+
overrides the theme's ``height: auto`` and distorts responsive images. Instead
5+
this stamps each demo ``<img>`` with ``width:100%; aspect-ratio:W/H`` (computed
6+
from the source GIF), so the browser reserves the correct box before the image
7+
loads while keeping it fluid.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
import typing as t
14+
from pathlib import Path
15+
16+
from sphinx.util.images import get_image_size
17+
18+
if t.TYPE_CHECKING:
19+
from docutils import nodes
20+
from sphinx.application import Sphinx
21+
22+
_IMG = re.compile(r'<img\b[^>]*?\bsrc="[^"]+?/([^"/]+?\.gif)"[^>]*?/?>')
23+
_STYLE = re.compile(r'style="[^"]*"')
24+
_sizes: dict[str, tuple[int, int]] = {}
25+
26+
27+
def _index_sizes(app: Sphinx) -> None:
28+
"""Map each source GIF's basename to its intrinsic ``(width, height)``."""
29+
for path in Path(app.srcdir).rglob("*.gif"):
30+
size = get_image_size(path)
31+
if size is not None:
32+
_sizes[path.name] = size
33+
34+
35+
def _inject(
36+
app: Sphinx,
37+
pagename: str,
38+
templatename: str,
39+
context: dict[str, t.Any],
40+
doctree: nodes.document | None,
41+
) -> None:
42+
"""Rewrite demo ``<img>`` tags to carry ``width:100%; aspect-ratio:W/H``."""
43+
body = context.get("body")
44+
if not body or "_images/" not in body:
45+
return
46+
47+
def repl(match: re.Match[str]) -> str:
48+
tag, name = match.group(0), match.group(1)
49+
size = _sizes.get(name)
50+
if size is None or "aspect-ratio" in tag:
51+
return tag
52+
style = f"width:100%;aspect-ratio:{size[0]}/{size[1]}"
53+
if 'style="' in tag:
54+
return _STYLE.sub(f'style="{style}"', tag)
55+
return tag[:-1].rstrip("/") + f' style="{style}" />'
56+
57+
context["body"] = _IMG.sub(repl, body)
58+
59+
60+
def setup(app: Sphinx) -> dict[str, t.Any]:
61+
"""Register the aspect-ratio injector."""
62+
app.connect("builder-inited", _index_sizes)
63+
app.connect("html-page-context", _inject)
64+
return {"parallel_read_safe": True, "parallel_write_safe": True}

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
extra_extensions=[
3939
"sphinx_autodoc_api_style",
4040
"sphinx_autodoc_argparse.exemplar",
41+
"aspect_ratio",
4142
],
4243
intersphinx_mapping={
4344
"py": ("https://docs.python.org/", None),

0 commit comments

Comments
 (0)