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. ``