Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/anndata/_repr/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
get_setting,
is_backed,
is_view,
render_markdown,
)

if TYPE_CHECKING:
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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 += "..."
Expand All @@ -547,6 +556,7 @@ def _render_header(
parts.append(
f'<span class="anndata-readme__icon" '
f'data-readme="{escaped_readme}" '
f'data-readme-format="{readme_format}" '
f'title="{escaped_tooltip}" '
f'role="button" tabindex="0" aria-label="View README">'
f"ⓘ"
Expand Down
113 changes: 110 additions & 3 deletions src/anndata/_repr/static/repr.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
}

Expand Down
13 changes: 9 additions & 4 deletions src/anndata/_repr/static/repr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
34 changes: 34 additions & 0 deletions src/anndata/_repr/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. ``<script>`` tags become ``&lt;script&gt;`` in the output).
"""
# markdown-it-py: dependency of rich (pip, hatch, many CLI tools)
try:
from markdown_it import MarkdownIt

# html=False escapes raw HTML tags in the source;
# enable table and strikethrough for GitHub-flavored markdown
md = MarkdownIt("commonmark", {"html": False})
md.enable("table")
md.enable("strikethrough")
return md.render(text)
except ImportError:
pass

# mistune: dependency of nbconvert (Jupyter)
try:
import mistune

return mistune.create_markdown(escape=True)(text)
except ImportError:
pass

return None


def sanitize_for_id(text: str) -> str:
"""Sanitize a string for use as an HTML id attribute."""
# Replace non-alphanumeric chars with underscore
Expand Down
7 changes: 5 additions & 2 deletions tests/repr/test_repr_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -774,13 +774,16 @@ def test_readme_tooltip_truncated(self, validate_html):
v.assert_text_visible("...")

def test_readme_data_attribute_contains_content(self, validate_html):
"""Test data-readme attribute contains full content."""
"""Test data-readme attribute contains content and format flag."""
adata = AnnData(np.zeros((10, 5)))
adata.uns["README"] = "Test content"
html = adata._repr_html_()
v = validate_html(html)
v.assert_element_exists(".anndata-readme__icon")
v.assert_attribute_value(".anndata-readme__icon", "data-readme", "Test content")
# Content is either rendered markdown (html) or plain text
assert "Test content" in v.html
# Format flag must be present
assert 'data-readme-format="html"' in v.html or 'data-readme-format="text"' in v.html

def test_readme_icon_accessibility(self, validate_html):
"""Test readme icon has accessibility attributes."""
Expand Down
6 changes: 3 additions & 3 deletions tests/visual_inspect_repr_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -2111,8 +2111,8 @@ def format(self, obj, context):
"17. README Icon",
adata_readme._repr_html_(),
"When <code>uns['README']</code> contains a string, a small ⓘ icon appears in the header. "
"Click the icon to open a modal with the README content displayed as plain text "
"(raw markdown source, not rendered). Press Escape or click outside to close.",
"Click the icon to open a modal with rendered markdown (if markdown-it-py or mistune "
"is installed) or plain text as fallback. Press Escape or click outside to close.",
))

# Test 18: README icon in No-JS mode
Expand Down Expand Up @@ -2915,7 +2915,7 @@ def __repr__(self):
"</ul>"
"<b>Evil README (click icon to open modal):</b><br>"
"<ul>"
"<li>README is displayed as plain text via textContent, so no vectors can fire</li>"
"<li>README is rendered as markdown (if renderer available) with raw HTML escaped</li>"
"<li>Contains: script tags, event handlers, style injection, closing tags</li>"
"<li>Unicode: RTL override, null bytes, emoji</li>"
"<li>Template injection attempts, 50KB size bomb</li>"
Expand Down
Loading