diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py index a9342f2c9..e60b72bd4 100644 --- a/src/anndata/_repr/html.py +++ b/src/anndata/_repr/html.py @@ -74,6 +74,7 @@ get_setting, is_backed, is_view, + render_markdown, ) if TYPE_CHECKING: @@ -466,7 +467,7 @@ def _render_custom_section( ) -def _render_header( +def _render_header( # noqa: PLR0912, PLR0915 adata: AnnData, *, show_search: bool = False, container_id: str = "" ) -> str: """Render the header with type, shape, badges, and optional search box.""" @@ -537,8 +538,16 @@ def _render_header( ) readme_content += truncation_note - escaped_readme = escape_html(readme_content) - # Truncate for no-JS tooltip (first 500 chars) + # Try rendering markdown; fall back to plain text + rendered_html = render_markdown(readme_content) + if rendered_html is not None: + escaped_readme = escape_html(rendered_html) + readme_format = "html" + else: + escaped_readme = escape_html(readme_content) + readme_format = "text" + + # Truncate for no-JS tooltip (first 500 chars of source text) tooltip_text = readme_content[:TOOLTIP_TRUNCATE_LENGTH] if len(readme_content) > TOOLTIP_TRUNCATE_LENGTH: tooltip_text += "..." @@ -547,6 +556,7 @@ def _render_header( parts.append( f'' f"ⓘ" diff --git a/src/anndata/_repr/static/repr.css b/src/anndata/_repr/static/repr.css index e8a4faec9..50ffbebac 100644 --- a/src/anndata/_repr/static/repr.css +++ b/src/anndata/_repr/static/repr.css @@ -281,13 +281,120 @@ line-height: 1.6; color: var(--anndata-text-primary); + h1, + h2, + h3, + h4 { + margin-top: 16px; + margin-bottom: 8px; + font-weight: 600; + color: var(--anndata-text-primary); + } + + h1 { + font-size: 1.5em; + padding-bottom: 8px; + border-bottom: 1px solid var(--anndata-border-color); + } + + h2 { + font-size: 1.3em; + } + + h3 { + font-size: 1.1em; + } + + p { + margin: 0 0 12px 0; + } + + code { + background: var(--anndata-code-bg); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--anndata-font-mono); + font-size: 0.9em; + } + pre { - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; + background: var(--anndata-code-bg); + padding: 12px; + border-radius: var(--anndata-radius); + overflow-x: auto; + margin: 12px 0; font-family: var(--anndata-font-mono); font-size: 0.9em; line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + + code { + background: none; + padding: 0; + } + } + + ul, + ol { + margin: 0 0 12px 24px; + padding-left: 0; + list-style-position: outside; + } + + li { + margin: 4px 0; + padding-left: 4px; + } + + a { + color: var(--anndata-link-color); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + blockquote { + margin: 12px 0; + padding: 8px 16px; + border-left: 3px solid var(--anndata-accent-color); + background: var(--anndata-bg-secondary); + color: var(--anndata-text-secondary); + } + + table { + border-collapse: collapse; + margin: 12px 0; + font-size: 0.9em; + width: auto; + } + + th, + td { + border: 1px solid var(--anndata-border-color); + padding: 6px 12px; + text-align: left; + } + + th { + background: var(--anndata-bg-secondary); + font-weight: 600; + } + + tbody tr:hover { + background: var(--anndata-bg-secondary); + } + + hr { + border: none; + border-top: 1px solid var(--anndata-border-color); + margin: 12px 0; + } + + img { + max-width: 100%; } } diff --git a/src/anndata/_repr/static/repr.js b/src/anndata/_repr/static/repr.js index 9d9747d8b..3e131b267 100644 --- a/src/anndata/_repr/static/repr.js +++ b/src/anndata/_repr/static/repr.js @@ -462,12 +462,17 @@ if (readmeIcon) { closeBtn.setAttribute("aria-label", "Close"); header.appendChild(closeBtn); - // Content — plain text (no markdown parsing, XSS-safe via textContent) + // Content — rendered markdown (innerHTML) or plain text (textContent) const content = document.createElement("div"); content.className = "anndata-readme__content"; - const pre = document.createElement("pre"); - pre.textContent = readmeContent; - content.appendChild(pre); + const readmeFormat = readmeIcon.dataset.readmeFormat; + if (readmeFormat === "html") { + content.innerHTML = readmeContent; + } else { + const pre = document.createElement("pre"); + pre.textContent = readmeContent; + content.appendChild(pre); + } modal.appendChild(header); modal.appendChild(content); diff --git a/src/anndata/_repr/utils.py b/src/anndata/_repr/utils.py index fee7b33f0..c0d52a5cd 100644 --- a/src/anndata/_repr/utils.py +++ b/src/anndata/_repr/utils.py @@ -511,6 +511,40 @@ def escape_html(text: str) -> str: return html.escape(str(text)) +def render_markdown(text: str) -> str | None: + """Render markdown to HTML using any available renderer. + + Tries common markdown libraries in order of likelihood to be installed + in a Jupyter environment. Returns ``None`` if no renderer is available, + signaling that the caller should fall back to plain text. + + Raw HTML in the markdown source is escaped by each renderer to prevent + XSS (e.g. ``