diff --git a/docs/release-notes/2236.feat.md b/docs/release-notes/2236.feat.md
index 38093264d..096a078a3 100644
--- a/docs/release-notes/2236.feat.md
+++ b/docs/release-notes/2236.feat.md
@@ -1 +1 @@
-Add rich HTML representation for {class}`~anndata.AnnData` objects in Jupyter notebooks with foldable sections, search/filter, category color visualization, dark mode support, and configurable settings via {attr}`anndata.settings`
+Add rich HTML representation for {class}`~anndata.AnnData` objects in Jupyter notebooks with foldable sections, search/filter, category color visualization, dark mode support, and configurable settings via {attr}`anndata.settings`. Rendering is built on {mod}`jinja2` and {mod}`markupsafe` with autoescape-by-default; extension packages register {class}`~anndata._repr.TypeFormatter` / {class}`~anndata._repr.SectionFormatter` subclasses that return {class}`~anndata._repr.FormattedOutput` with {class}`markupsafe.Markup`-typed HTML fragments (`preview_markup`, `type_markup`, `expanded_markup`).
diff --git a/pyproject.toml b/pyproject.toml
index 8a7a0cab0..9fd97ff0f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -47,6 +47,8 @@ dependencies = [
"zarr >=3.1",
"typing-extensions; python_version<'3.13'",
"scverse-misc>=0.0.3",
+ "jinja2>=3.1",
+ "markupsafe>=3.0",
]
dynamic = [ "version" ]
diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py
index 6c39c6394..7f7b5a93f 100644
--- a/src/anndata/_repr/__init__.py
+++ b/src/anndata/_repr/__init__.py
@@ -68,6 +68,8 @@
Example - format by Python type::
+ from markupsafe import Markup
+
from anndata._repr import register_formatter, TypeFormatter, FormattedOutput
@@ -82,10 +84,27 @@ def format(self, obj, context):
return FormattedOutput(
type_name=f"MyArray {obj.shape}",
css_class="anndata-dtype--myarray",
- # preview_html provides HTML for the preview column (rightmost)
- preview_html=f'({obj.n_items} items)',
+ # ``preview_markup`` takes trusted HTML. Build it with
+ # ``Markup('{}').format(value)`` — MarkupSafe
+ # autoescapes each non-``Markup`` arg at that boundary.
+ preview_markup=Markup(
+ '{} items'
+ ).format(obj.n_items),
)
+ **Preview contract**: if no custom HTML is needed, prefer the plain-text
+ ``preview`` field (autoescaped end-to-end). Use ``preview_markup`` when
+ you need custom structure. Three valid idioms:
+
+ - ``Markup('{}').format(value)`` — standard MarkupSafe pattern;
+ each non-``Markup`` arg is autoescaped.
+ - ``Markup(obj._repr_html_())`` — wrap trusted HTML from another package.
+ - ``get_macros().my_macro(value)`` — invoke a Jinja macro directly, which
+ also benefits from the engine's NUL-scrub finalize hook.
+
+ Never build HTML via ``Markup(f'...{value}...')`` — the f-string substitutes
+ ``value`` before ``Markup`` sees it, bypassing autoescape.
+
**Error handling**: Formatters can signal errors in two ways:
1. **Raise an exception** - The registry catches it, emits a warning with the
@@ -97,10 +116,12 @@ def format(self, obj, context):
``FormattedOutput(error="reason")`` directly. The row will be highlighted
red and the error shown in the preview column.
- When ``error`` is set, it takes precedence over ``preview`` and ``preview_html``.
+ When ``error`` is set, it takes precedence over ``preview`` and ``preview_markup``.
Example - format by embedded type hint (for tagged data in uns)::
+ from markupsafe import Markup
+
from anndata._repr import register_formatter, TypeFormatter, FormattedOutput
from anndata._repr import extract_uns_type_hint
@@ -118,7 +139,9 @@ def format(self, obj, context):
hint, data = extract_uns_type_hint(obj)
return FormattedOutput(
type_name="config",
- preview_html="Custom config preview",
+ preview_markup=Markup(
+ '{}'
+ ).format(data.get("name", "(unnamed)")),
)
Data structure for type hints (works in any section)::
@@ -277,7 +300,7 @@ def _repr_html_(self):
parts.append(
render_section(
"items",
- "\\n".join(entries),
+ Markup("\\n").join(entries),
n_items=len(self.items),
)
)
@@ -290,12 +313,12 @@ def _repr_html_(self):
from anndata._repr import generate_repr_html, FormattedEntry, FormattedOutput
- nested_html = generate_repr_html(adata, depth=1, max_depth=3)
+ # generate_repr_html already returns Markup; no Markup(...) wrap needed.
entry = FormattedEntry(
key="table",
output=FormattedOutput(
type_name=f"AnnData ({adata.n_obs} x {adata.n_vars})",
- expanded_html=nested_html, # Collapsible content below the row
+ expanded_markup=generate_repr_html(adata, depth=1, max_depth=3),
),
)
@@ -321,7 +344,6 @@ def _repr_html_(self):
DEFAULT_PREVIEW_ITEMS,
DEFAULT_TYPE_WIDTH,
DEFAULT_UNIQUE_LIMIT,
- NOT_SERIALIZABLE_MSG,
)
# Documentation base URL
@@ -346,10 +368,6 @@ def get_section_doc_url(section: str) -> str:
return f"{DOCS_BASE_URL}generated/anndata.AnnData.{section}.html"
-# Import main functionality
-# Inline styles for graceful degradation (from single source of truth)
-from .._repr_constants import STYLE_HIDDEN # noqa: E402
-
# Building blocks for packages that want to create their own _repr_html_
# These allow reusing anndata's styling while building custom representations
from .components import ( # noqa: E402
@@ -361,6 +379,7 @@ def get_section_doc_url(section: str) -> str:
render_warning_icon,
)
from .css import get_css # noqa: E402
+from .environment import get_macros # noqa: E402
from .html import ( # noqa: E402
generate_repr_html,
render_formatted_entry,
@@ -384,7 +403,6 @@ def get_section_doc_url(section: str) -> str:
# HTML rendering helpers for building custom sections
from .utils import ( # noqa: E402
- escape_html,
format_memory_size,
format_number,
validate_key,
@@ -402,9 +420,6 @@ def get_section_doc_url(section: str) -> str:
"DEFAULT_UNIQUE_LIMIT",
"DEFAULT_MAX_FIELD_WIDTH",
"DEFAULT_TYPE_WIDTH",
- "DOCS_BASE_URL",
- "get_section_doc_url",
- "NOT_SERIALIZABLE_MSG",
# CSS dtype constants for custom formatters
"CSS_DTYPE_NDARRAY",
"CSS_DTYPE_ANNDATA",
@@ -425,12 +440,11 @@ def get_section_doc_url(section: str) -> str:
# Building blocks for custom _repr_html_ implementations
"get_css",
"get_javascript",
- "escape_html",
+ "get_macros",
"format_number",
"format_memory_size",
"render_section",
"render_formatted_entry",
- "STYLE_HIDDEN",
# UI component helpers
"render_search_box",
"render_copy_button",
diff --git a/src/anndata/_repr/components.py b/src/anndata/_repr/components.py
index db2530117..8c6f22213 100644
--- a/src/anndata/_repr/components.py
+++ b/src/anndata/_repr/components.py
@@ -17,13 +17,11 @@
from dataclasses import dataclass, field
-from .._repr_constants import (
- CSS_ENTRY,
- CSS_TEXT_MUTED,
- NOT_SERIALIZABLE_MSG,
- STYLE_HIDDEN,
-)
-from .utils import escape_html, sanitize_css_color
+from markupsafe import Markup
+
+from .._repr_constants import CSS_ENTRY
+from .environment import get_macros
+from .utils import sanitize_css_color
def render_entry_row_open(
@@ -34,7 +32,7 @@ def render_entry_row_open(
is_error: bool = False,
has_expandable_content: bool = False,
extra_classes: str = "",
-) -> str:
+) -> Markup:
"""Render the opening tag for an entry row.
For regular entries, returns ``
``.
@@ -58,9 +56,8 @@ def render_entry_row_open(
Returns
-------
- Opening tag(s) with class and data attributes
+ ``Markup`` HTML for the opening tag(s) with class and data attributes.
"""
- # Build CSS class string
classes = [CSS_ENTRY]
if extra_classes:
classes.append(extra_classes)
@@ -69,21 +66,12 @@ def render_entry_row_open(
if is_error:
classes.append("error")
css_class = " ".join(classes)
-
- escaped_key = escape_html(key)
- escaped_dtype = escape_html(dtype)
-
- if has_expandable_content:
- return (
- f''
- f''
- )
- return f'
'
+ return Markup(get_macros().row_open(key, dtype, css_class, has_expandable_content))
def render_warning_icon(
warnings: list[str], *, is_not_serializable: bool = False
-) -> str:
+) -> Markup:
"""Render warning icon with tooltip if there are warnings or serialization issues.
Parameters
@@ -95,29 +83,12 @@ def render_warning_icon(
Returns
-------
- HTML string for warning icon, or empty string if no warnings.
+ ``Markup`` HTML for warning icon, or empty ``Markup`` if no warnings.
"""
- if not warnings and not is_not_serializable:
- return ""
-
- # Build the tooltip message
- if is_not_serializable:
- if warnings:
- # "Not serializable: reason1; reason2"
- reasons = "; ".join(warnings)
- title = f"{NOT_SERIALIZABLE_MSG}: {reasons}"
- else:
- # Just "Not serializable to H5AD/Zarr"
- title = NOT_SERIALIZABLE_MSG
- else:
- # Independent warnings joined with ";"
- title = "; ".join(warnings)
-
- title = escape_html(title)
- return f'(!)'
-
-
-def render_search_box(container_id: str = "") -> str:
+ return Markup(get_macros().warning_icon(warnings or [], is_not_serializable))
+
+
+def render_search_box(container_id: str = "") -> Markup:
"""
Render a search box with filter indicator and search mode toggles.
@@ -132,35 +103,13 @@ def render_search_box(container_id: str = "") -> str:
Returns
-------
- HTML string for the search box
-
- Example
- -------
- >>> container_id = "spatialdata-123"
- >>> parts = ['
'
+ return Markup(get_macros().truncation_indicator(format_number(remaining)))
def get_section_tooltip(section: str) -> str:
@@ -196,43 +156,45 @@ def get_section_tooltip(section: str) -> str:
return tooltips.get(section, "")
-def render_x_entry(obj: object, context: FormatterContext) -> str:
+def render_x_entry(obj: object, context: FormatterContext) -> Markup:
"""Render X as a single compact entry row.
Works with AnnData, Raw, and any object with an X attribute.
Handles missing or broken X attributes gracefully.
"""
- parts = ['
']
- parts.append("X")
+ state = "ok"
+ type_name = ""
+ css_class = ""
+ error_msg = ""
try:
X = obj.X
except Exception as e: # noqa: BLE001
- # Handle missing or broken X attribute gracefully
+ state = "attribute_error"
error_msg = f"error: {type(e).__name__}"
- parts.append(
- f'({escape_html(error_msg)})'
- )
- parts.append("
")
- return "\n".join(parts)
-
- if X is None:
- parts.append("None")
else:
- # Format the X matrix (formatter includes all info like sparsity, on disk, etc.)
- try:
- output = formatter_registry.format_value(X, context)
- parts.append(
- f'{escape_html(output.type_name)}'
- )
- except Exception as e: # noqa: BLE001
- error_msg = f"error formatting: {type(e).__name__}"
- parts.append(
- f'({escape_html(error_msg)})'
- )
-
- parts.append("
")
- return "\n".join(parts)
+ if X is None:
+ state = "none"
+ else:
+ try:
+ output = formatter_registry.format_value(X, context)
+ type_name = output.type_name
+ css_class = output.css_class
+ except Exception as e: # noqa: BLE001
+ state = "format_error"
+ error_msg = f"error formatting: {type(e).__name__}"
+
+ return Markup(
+ get_env()
+ .get_template("x_entry.j2")
+ .render(
+ x_label="X",
+ state=state,
+ type_name=type_name,
+ css_class=css_class,
+ error_msg=error_msg,
+ )
+ )
def render_formatted_entry(
@@ -240,9 +202,9 @@ def render_formatted_entry(
section: str = "",
*,
extra_warnings: list[str] | None = None,
- append_type_html: bool = False,
+ append_type_markup: bool = False,
preview_note: str | None = None,
-) -> str:
+) -> Markup:
"""
Render a FormattedEntry as a table row.
@@ -257,15 +219,15 @@ def render_formatted_entry(
Optional section name (used for meta column rendering)
extra_warnings
Additional warnings to display (e.g., key validation warnings)
- append_type_html
- If True, append type_html below type_name instead of replacing it.
+ append_type_markup
+ If True, append type_markup below type_name instead of replacing it.
Used for mapping entries (obsm, varm, etc.) to show extra content.
preview_note
Optional note to prepend to preview text (for type hints in uns)
Returns
-------
- HTML string for the table row(s)
+ ``Markup`` HTML for the table row(s)
Examples
--------
@@ -292,13 +254,13 @@ def render_formatted_entry(
With expandable nested content::
- nested_html = generate_repr_html(adata, depth=1)
+ # generate_repr_html already returns Markup — no wrap needed.
entry = FormattedEntry(
key="cell_table",
output=FormattedOutput(
type_name="AnnData (150 × 30)",
css_class=CSS_DTYPE_ANNDATA,
- expanded_html=nested_html,
+ expanded_markup=generate_repr_html(adata, depth=1),
),
)
html = render_formatted_entry(entry)
@@ -325,78 +287,45 @@ def render_formatted_entry(
html = render_formatted_entry(entry)
"""
output = entry.output
- extra_warnings = extra_warnings or []
-
- # Compute entry CSS classes
- # Both hard errors and serialization issues get red background
- all_warnings = extra_warnings + list(output.warnings)
+ all_warnings = (extra_warnings or []) + list(output.warnings)
has_error = output.error is not None or not output.is_serializable
-
- has_expandable_content = output.expanded_html is not None
- # Detect wrap button needs from output css_class
+ has_expandable_content = output.expanded_markup is not None
has_categories = output.css_class == CSS_DTYPE_CATEGORY and bool(
- output.preview_html
+ output.preview_markup
)
has_columns_list = output.css_class == CSS_DTYPE_DATAFRAME and bool(
- output.preview_html
- )
-
- # Build row using consolidated helper
- parts = [
- render_entry_row_open(
- entry.key,
- output.type_name,
- has_warnings=bool(all_warnings),
- is_error=has_error,
- has_expandable_content=has_expandable_content,
- )
- ]
-
- # Name cell
- parts.append(render_name_cell(entry.key))
-
- # Type cell
- type_cell_config = TypeCellConfig(
- type_name=output.type_name,
- css_class=output.css_class,
- type_html=output.type_html if append_type_html else None,
- tooltip=output.tooltip,
- warnings=all_warnings,
- is_not_serializable=not output.is_serializable,
- has_columns_list=has_columns_list,
- has_categories_list=has_categories,
- append_type_html=append_type_html,
+ output.preview_markup
)
- parts.append(render_entry_type_cell(type_cell_config))
- # Preview cell
- # Error takes precedence over preview/preview_html
- preview_html = output.preview_html
+ preview_markup = output.preview_markup
preview_text = output.preview
-
- if output.error and not preview_html:
- # Generate error preview if error is set but no preview_html provided
- error_text = escape_html(output.error)
- preview_html = f'{error_text}'
+ if output.error and not preview_markup:
+ preview_markup = Markup(get_macros().error_preview(output.error))
if preview_note and preview_text:
preview_text = f"{preview_note} {preview_text}"
elif preview_note:
preview_text = preview_note
- parts.append(
- render_entry_preview_cell(
- preview_html=preview_html,
+ rendered = (
+ get_env()
+ .get_template("entry.j2")
+ .render(
+ entry_key=entry.key,
+ type_name=output.type_name,
+ css_class=output.css_class,
+ type_markup=output.type_markup,
+ tooltip=output.tooltip,
+ all_warnings=all_warnings,
+ is_not_serializable=not output.is_serializable,
+ has_error=has_error,
+ has_expandable_content=has_expandable_content,
+ has_columns_list=has_columns_list,
+ has_categories_list=has_categories,
+ append_type_markup=append_type_markup,
+ preview_markup=preview_markup,
preview_text=preview_text,
+ expanded_markup=output.expanded_markup,
)
)
-
- # Expandable entries use /; render_nested_content
- # closes the and adds the nested content div.
- if has_expandable_content:
- parts.append(render_nested_content(output.expanded_html))
- parts.append("")
- else:
- parts.append("
")
-
- return "\n".join(parts)
+ return Markup(rendered)
diff --git a/src/anndata/_repr/environment.py b/src/anndata/_repr/environment.py
new file mode 100644
index 000000000..68d55da57
--- /dev/null
+++ b/src/anndata/_repr/environment.py
@@ -0,0 +1,76 @@
+"""
+Jinja2 Environment for the AnnData HTML repr.
+
+The repr renders by feeding structured values (plus pre-produced HTML
+fragments wrapped in ``markupsafe.Markup``) into templates loaded from
+``anndata._repr.templates``. Autoescape is on by default:
+
+- Plain ``str`` values are escaped (``<``, ``>``, ``&``, ``"``, ``'``).
+- ``Markup`` values pass through verbatim because Jinja recognises the type.
+
+The trust contract is therefore typed rather than conventional: data arrives
+as ``str`` and gets escaped; HTML arrives as ``Markup`` and is trusted.
+"""
+
+from __future__ import annotations
+
+from functools import cache
+
+from jinja2 import Environment, PackageLoader, select_autoescape
+
+from .._repr_constants import (
+ CSS_COLORS,
+ CSS_COLORS_SWATCH,
+ CSS_COLORS_SWATCH_INVALID,
+ CSS_DTYPE_UNKNOWN,
+ CSS_NESTED_ANNDATA,
+ CSS_TEXT_ERROR,
+ CSS_TEXT_MUTED,
+ CSS_TEXT_WARNING,
+ NOT_SERIALIZABLE_MSG,
+ STYLE_HIDDEN,
+)
+
+
+def _scrub_nulls(value):
+ # Null bytes in user data break HTML parsers; replace pre-escape.
+ # Markup values pass through unchanged.
+ if isinstance(value, str) and not hasattr(value, "__html__"):
+ return value.replace("\x00", "\ufffd")
+ return value
+
+
+@cache
+def get_env() -> Environment:
+ env = Environment(
+ loader=PackageLoader("anndata._repr", "templates"),
+ autoescape=select_autoescape(default=True, default_for_string=True),
+ trim_blocks=True,
+ lstrip_blocks=True,
+ finalize=_scrub_nulls,
+ )
+ env.globals.update(
+ CSS_COLORS=CSS_COLORS,
+ CSS_COLORS_SWATCH=CSS_COLORS_SWATCH,
+ CSS_COLORS_SWATCH_INVALID=CSS_COLORS_SWATCH_INVALID,
+ CSS_DTYPE_UNKNOWN=CSS_DTYPE_UNKNOWN,
+ CSS_NESTED_ANNDATA=CSS_NESTED_ANNDATA,
+ CSS_TEXT_ERROR=CSS_TEXT_ERROR,
+ CSS_TEXT_MUTED=CSS_TEXT_MUTED,
+ CSS_TEXT_WARNING=CSS_TEXT_WARNING,
+ NOT_SERIALIZABLE_MSG=NOT_SERIALIZABLE_MSG,
+ STYLE_HIDDEN=STYLE_HIDDEN,
+ )
+ return env
+
+
+@cache
+def get_macros():
+ """Cached handle to the macros module from ``_macros.j2``.
+
+ Extension packages can invoke any macro as ``get_macros().name(args)``.
+ Macro calls render through this module's Jinja environment, so
+ arguments flow through autoescape + the NUL-scrub finalize hook — the
+ safest way to build custom HTML from user data.
+ """
+ return get_env().get_template("_macros.j2").module
diff --git a/src/anndata/_repr/formatters.py b/src/anndata/_repr/formatters.py
index 8407455f3..fb3c2836d 100644
--- a/src/anndata/_repr/formatters.py
+++ b/src/anndata/_repr/formatters.py
@@ -21,12 +21,10 @@
import numpy as np
import pandas as pd
+from markupsafe import Markup
from .._repr_constants import (
COLOR_PREVIEW_LIMIT,
- CSS_COLORS,
- CSS_COLORS_SWATCH,
- CSS_COLORS_SWATCH_INVALID,
CSS_DTYPE_ANNDATA,
CSS_DTYPE_ARRAY_API,
CSS_DTYPE_AWKWARD,
@@ -42,11 +40,10 @@
CSS_DTYPE_STRING,
CSS_DTYPE_TPU,
CSS_DTYPE_UNKNOWN,
- CSS_NESTED_ANNDATA,
- CSS_TEXT_MUTED,
)
from ..compat import has_xp
-from .components import render_category_list
+from .components import render_category_list, render_muted_span
+from .environment import get_macros
from .lazy import get_lazy_categorical_info, is_lazy_column
from .registry import (
FormattedOutput,
@@ -56,7 +53,6 @@
from .utils import (
check_color_category_mismatch,
check_invalid_colors,
- escape_html,
format_invalid_colors_warning,
format_number,
get_categories_for_display,
@@ -393,30 +389,31 @@ def format(self, obj: pd.DataFrame, context: FormatterContext) -> FormattedOutpu
n_rows, n_cols = len(df), len(df.columns)
cols = list(df.columns)
- # Build preview_html with column list for obsm/varm sections
+ # Build preview_markup with column list for obsm/varm sections
# Uses anndata-columns class for CSS truncation and JS wrap button
- preview_html = None
+ preview_markup: Markup | None = None
if n_cols > 0 and context.section in ("obsm", "varm"):
- col_str = ", ".join(escape_html(str(c)) for c in cols)
- preview_html = f'[{col_str}]'
+ preview_markup = Markup(
+ get_macros().columns_preview([str(c) for c in cols])
+ )
# Check if expandable _repr_html_ is enabled
expand_dataframes = get_setting("repr_html_dataframe_expand", default=False)
- expanded_html = None
+ expanded_markup: Markup | None = None
if expand_dataframes and n_rows > 0 and n_cols > 0:
# Use pandas _repr_html_() for native Jupyter-style output
# Respects pd.options.display settings (max_rows, max_columns, etc.)
# Intentional broad catch: _repr_html_() can fail in many ways
# (memory, recursion, custom dtypes, etc.) - gracefully degrade
with contextlib.suppress(Exception):
- expanded_html = df._repr_html_()
+ expanded_markup = Markup(df._repr_html_())
return FormattedOutput(
type_name=f"DataFrame ({format_number(n_rows)} × {format_number(n_cols)})",
css_class=CSS_DTYPE_DATAFRAME,
- expanded_html=expanded_html,
- preview_html=preview_html,
+ expanded_markup=expanded_markup,
+ preview_markup=preview_markup,
is_serializable=True,
)
@@ -542,8 +539,8 @@ def format( # noqa: PLR0912
else f"category ({n_categories})"
)
- # Build preview_html with category list and colors
- preview_html = None
+ # Build preview_markup with category list and colors
+ preview_markup: Markup | None = None
error = None
if context.section in ("obs", "var") and context.key is not None:
try:
@@ -555,11 +552,9 @@ def format( # noqa: PLR0912
if len(categories) == 0:
# Metadata-only mode or no categories: show just count
if n_total is not None:
- preview_html = f'({n_total} categories)'
+ preview_markup = render_muted_span(f"({n_total} categories)")
else:
- preview_html = (
- f'(categories)'
- )
+ preview_markup = render_muted_span("(categories)")
else:
# Get colors for categories
colors = None
@@ -582,7 +577,7 @@ def format( # noqa: PLR0912
if (n_total and was_truncated)
else 0
)
- preview_html = render_category_list(
+ preview_markup = render_category_list(
categories, colors, context.max_categories, n_hidden=n_hidden
)
except Exception as e: # noqa: BLE001
@@ -615,7 +610,7 @@ def format( # noqa: PLR0912
return FormattedOutput(
type_name=type_name,
css_class=CSS_DTYPE_CATEGORY,
- preview_html=preview_html,
+ preview_markup=preview_markup,
is_serializable=True,
warnings=warnings,
error=error,
@@ -887,7 +882,7 @@ def format(self, obj: object, context: FormatterContext) -> FormattedOutput:
shape_str = f"{format_number(obj.n_obs)} × {format_number(obj.n_vars)}" # type: ignore[attr-defined]
# Generate expanded HTML if within depth limit
- expanded_html = None
+ expanded_markup: Markup | None = None
if context.depth < context.max_depth - 1:
# Lazy import to avoid circular dependency
from .html import generate_repr_html
@@ -899,13 +894,15 @@ def format(self, obj: object, context: FormatterContext) -> FormattedOutput:
show_header=True,
show_search=False,
)
- expanded_html = f'
{nested_html}
'
+ expanded_markup = Markup(
+ get_macros().nested_anndata_wrapper(Markup(nested_html))
+ )
return FormattedOutput(
type_name=f"AnnData ({shape_str})",
css_class=CSS_DTYPE_ANNDATA,
tooltip="Nested AnnData object",
- expanded_html=expanded_html,
+ expanded_markup=expanded_markup,
is_serializable=True,
)
@@ -1032,29 +1029,24 @@ def format(self, obj: list, context: FormatterContext) -> FormattedOutput:
n_colors = len(colors)
# Build color swatch HTML with sanitized colors, counting invalid ones
- swatches = []
+ swatches: list[Markup] = []
invalid_count = 0
for color in colors[:COLOR_PREVIEW_LIMIT]:
# Sanitize color to prevent CSS injection
- safe_color = sanitize_css_color(str(color))
+ label = str(color)
+ safe_color = sanitize_css_color(label)
if safe_color:
swatches.append(
- f''
+ Markup(get_macros().color_swatch(safe_color, label, valid=True))
)
else:
- # Invalid/unsafe color - show as text only, no style
invalid_count += 1
swatches.append(
- f'?"""
+ Markup(get_macros().color_swatch("", label, valid=False))
)
- if n_colors > COLOR_PREVIEW_LIMIT:
- swatches.append(
- f'+{n_colors - COLOR_PREVIEW_LIMIT}'
- )
+ overflow = max(0, n_colors - COLOR_PREVIEW_LIMIT)
- preview_html = f'{"".join(swatches)}'
+ preview_markup = Markup(get_macros().color_preview(swatches, overflow))
# Build warnings list (only for colors within preview limit)
warnings = []
@@ -1067,7 +1059,7 @@ def format(self, obj: list, context: FormatterContext) -> FormattedOutput:
return FormattedOutput(
type_name=f"colors ({n_colors})",
css_class=CSS_DTYPE_OBJECT,
- preview_html=preview_html,
+ preview_markup=preview_markup,
is_serializable=True,
warnings=warnings,
)
diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py
index 6dc30c4f3..80956e8e5 100644
--- a/src/anndata/_repr/html.py
+++ b/src/anndata/_repr/html.py
@@ -11,15 +11,22 @@
from __future__ import annotations
+import re
import uuid
from typing import TYPE_CHECKING
+from markupsafe import Markup
+
from .._repr_constants import (
+ CHAR_WIDTH_PX,
+ COPY_BUTTON_PADDING_PX,
CSS_BADGE_BACKED,
CSS_BADGE_EXTENSION,
CSS_BADGE_LAZY,
CSS_BADGE_VIEW,
+ DEFAULT_FIELD_WIDTH_PX,
DEFAULT_MAX_README_SIZE,
+ MIN_FIELD_WIDTH_PX,
TOOLTIP_TRUNCATE_LENGTH,
)
from .._types import AnnDataElem
@@ -36,8 +43,12 @@
DEFAULT_TYPE_WIDTH,
DEFAULT_UNIQUE_LIMIT,
)
+from . import (
+ formatters as _formatters, # noqa: F401 # side-effect: register built-in formatters
+)
from .components import (
render_badge,
+ render_filepath_span,
render_search_box,
)
from .core import (
@@ -47,6 +58,7 @@
render_x_entry,
)
from .css import get_css
+from .environment import get_env, get_macros
from .javascript import get_javascript
from .lazy import get_lazy_backing_info, is_lazy_adata
from .registry import (
@@ -63,7 +75,6 @@
_render_uns_section,
)
from .utils import (
- escape_html,
format_index_preview,
format_memory_size,
format_number,
@@ -79,14 +90,11 @@
from .registry import SectionFormatter
-# Import formatters to register them (side-effect import)
-from .._repr_constants import (
- CHAR_WIDTH_PX,
- COPY_BUTTON_PADDING_PX,
- DEFAULT_FIELD_WIDTH_PX,
- MIN_FIELD_WIDTH_PX,
-)
-from . import formatters as _formatters # noqa: F401
+
+# container_id is interpolated verbatim into a '
+ ESCAPED = "<script>alert("xss")</script>"
+
+ def _register_and_render(self, *, field: str) -> str:
+ """Register a one-off TypeFormatter that sets ``field`` to bare str and render."""
+ from anndata._repr.registry import (
+ FormattedOutput,
+ TypeFormatter,
+ register_formatter,
+ )
+
+ attack = self.ATTACK
+
+ class _ContractViolator(TypeFormatter):
+ priority = 10000 # beat every built-in formatter
+
+ def can_format(self, obj, context):
+ return isinstance(obj, _ContractViolator._Sentinel)
+
+ def format(self, obj, context):
+ # Bare str — violates the Markup | None contract.
+ return FormattedOutput(type_name="violator", **{field: attack})
+
+ class _Sentinel:
+ pass
+
+ formatter = _ContractViolator()
+ register_formatter(formatter)
+ try:
+ adata = AnnData(np.zeros((2, 2)))
+ adata.uns["evil"] = _ContractViolator._Sentinel()
+ return adata._repr_html_()
+ finally:
+ formatter_registry.unregister_type_formatter(formatter)
+
+ def test_bare_str_preview_markup_is_escaped(self) -> None:
+ html = self._register_and_render(field="preview_markup")
+ assert self.ATTACK not in html, (
+ "bare str preview_markup leaked raw — autoescape failed"
+ )
+ assert self.ESCAPED in html
+
+ def test_bare_str_type_markup_is_escaped(self) -> None:
+ html = self._register_and_render(field="type_markup")
+ assert self.ATTACK not in html
+ assert self.ESCAPED in html
+
+ def test_bare_str_expanded_markup_is_escaped(self) -> None:
+ html = self._register_and_render(field="expanded_markup")
+ assert self.ATTACK not in html
+ assert self.ESCAPED in html
+
+ def test_markup_wrapped_preview_passes_through(self) -> None:
+ """Positive control: ``Markup`` input flows through autoescape verbatim."""
+ from markupsafe import Markup
+
+ from anndata._repr.registry import (
+ FormattedOutput,
+ TypeFormatter,
+ register_formatter,
+ )
+
+ safe_html = 'hello'
+
+ class _TrustedFormatter(TypeFormatter):
+ priority = 10000
+
+ def can_format(self, obj, context):
+ return isinstance(obj, _TrustedFormatter._Sentinel)
+
+ def format(self, obj, context):
+ return FormattedOutput(
+ type_name="trusted",
+ preview_markup=Markup(safe_html),
+ )
+
+ class _Sentinel:
+ pass
+
+ formatter = _TrustedFormatter()
+ register_formatter(formatter)
+ try:
+ adata = AnnData(np.zeros((2, 2)))
+ adata.uns["ok"] = _TrustedFormatter._Sentinel()
+ html = adata._repr_html_()
+ assert safe_html in html, (
+ "Markup-wrapped preview did not pass through verbatim"
+ )
+ finally:
+ formatter_registry.unregister_type_formatter(formatter)
+
+ def test_section_formatter_render_html_is_trusted(self) -> None:
+ """Document the ecosystem-extension contract: ``SectionFormatter.render_html``
+ output is trusted verbatim.
+
+ ``_render_custom_section`` wraps ``render_html`` return values in
+ ``Markup(...)`` — that is a trust assertion, not a sanitizer. An
+ extension author who returns a bare ``str`` with embedded HTML is
+ responsible for the contents being safe. This test pins the current
+ behavior so any future shift to autoescape is an explicit decision.
+ """
+ from anndata._repr.registry import (
+ FormattedEntry,
+ FormattedOutput,
+ SectionFormatter,
+ formatter_registry,
+ )
+
+ raw_html = '
trusted raw html
'
+
+ class _TrustedSection(SectionFormatter):
+ @property
+ def section_name(self) -> str:
+ return "_trusted_section"
+
+ def should_show(self, obj):
+ return True
+
+ def get_entries(self, obj, context):
+ return [
+ FormattedEntry(key="ignored", output=FormattedOutput(type_name="x"))
+ ]
+
+ def render_html(self, obj, context):
+ # Bare str — anndata trusts this verbatim per the extension contract.
+ return raw_html
+
+ formatter = _TrustedSection()
+ formatter_registry.register_section_formatter(formatter)
+ try:
+ adata = AnnData(np.zeros((2, 2)))
+ html = adata._repr_html_()
+ assert raw_html in html, (
+ "render_html(str) must be passed through unmodified — "
+ "trust boundary is the SectionFormatter contract"
+ )
+ finally:
+ # No public unregister for section formatters; pop from internal dict.
+ for name in formatter.section_names:
+ formatter_registry._section_formatters.pop(name, None)
diff --git a/tests/repr/test_repr_utils.py b/tests/repr/test_repr_utils.py
index e3b500927..e959dac36 100644
--- a/tests/repr/test_repr_utils.py
+++ b/tests/repr/test_repr_utils.py
@@ -181,14 +181,6 @@ def test_get_matching_column_colors_no_uns_key(self):
class TestFormatting:
"""Tests for formatting utilities."""
- def test_escape_html(self):
- """Test HTML escaping."""
- from anndata._repr.utils import escape_html
-
- assert escape_html("'
- return f"
{escape_html(val)}
"
+ return Markup("
{}
").format(val)
try:
html = adata._repr_html_()
diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py
index 4bbd5da7e..ee9ab9c68 100644
--- a/tests/visual_inspect_repr_html.py
+++ b/tests/visual_inspect_repr_html.py
@@ -34,6 +34,7 @@
import numpy as np
import pandas as pd
import scipy.sparse as sp
+from markupsafe import Markup
import anndata as ad
@@ -49,7 +50,6 @@
from anndata._repr import ( # noqa: E402
FormattedOutput,
TypeFormatter,
- escape_html,
extract_uns_type_hint,
register_formatter,
)
@@ -219,7 +219,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
type_name=f"DiGraph ({n_nodes} nodes, {n_leaves} leaves)",
css_class="anndata-dtype--tree",
tooltip=f"Phylogenetic tree with {n_nodes} total nodes",
- expanded_html=svg_html,
+ expanded_markup=Markup(svg_html),
)
entries.append(FormattedEntry(key=key, output=output))
return entries
@@ -258,7 +258,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
type_name=f"DiGraph ({n_nodes} nodes, {n_leaves} leaves)",
css_class="anndata-dtype--tree",
tooltip=f"Phylogenetic tree with {n_nodes} total nodes",
- expanded_html=svg_html,
+ expanded_markup=Markup(svg_html),
)
entries.append(FormattedEntry(key=key, output=output))
return entries
@@ -276,7 +276,7 @@ class TreeMetadataSectionFormatter(SectionFormatter):
Renders like the X entry — a single non-foldable line showing
key=value pairs for label, alignment, and allow_overlap.
- All values are escaped via ``escape_html(repr(val))``.
+ All values are escaped via ``Markup(...).format(...)``.
"""
@property
@@ -319,11 +319,9 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
entries.append(FormattedEntry(key=label, output=output))
return entries
- def render_html(self, obj, context: FormatterContext) -> str:
+ def render_html(self, obj, context: FormatterContext) -> Markup:
"""Render as a compact line instead of a foldable section."""
- from anndata._repr.utils import escape_html
-
- pairs = []
+ pairs: list[Markup] = []
for attr, label in [
("_tree_label", "label"),
("_alignment", "alignment"),
@@ -332,16 +330,17 @@ def render_html(self, obj, context: FormatterContext) -> str:
val = getattr(obj, attr, None)
if val is not None:
pairs.append(
- f'{label}='
- f"{escape_html(repr(val))}"
+ Markup(
+ '{label}={val}'
+ ).format(label=label, val=repr(val))
)
- summary = " ".join(pairs)
- return (
+ summary = Markup(" ").join(pairs)
+ return Markup(
'