diff --git a/src/anndata/_repr/__init__.py b/src/anndata/_repr/__init__.py
index ea378af8a..01ea8f533 100644
--- a/src/anndata/_repr/__init__.py
+++ b/src/anndata/_repr/__init__.py
@@ -143,6 +143,10 @@ def get_entries(self, obj, context):
FormatterContext,
# Type formatter registry
FormatterRegistry,
+ # Object-level customization
+ HeaderConfig,
+ IndexPreviewConfig,
+ ObjectFormatter,
SectionFormatter,
TypeFormatter,
# Type hint extraction (for tagged data in uns)
@@ -172,6 +176,9 @@ def get_entries(self, obj, context):
"register_formatter",
"SectionFormatter",
"TypeFormatter",
+ "ObjectFormatter",
+ "HeaderConfig",
+ "IndexPreviewConfig",
"FormattedOutput",
"FormattedEntry",
"FormatterContext",
diff --git a/src/anndata/_repr/html.py b/src/anndata/_repr/html.py
index e13979b78..97fa98d67 100644
--- a/src/anndata/_repr/html.py
+++ b/src/anndata/_repr/html.py
@@ -11,6 +11,7 @@
from __future__ import annotations
+import contextlib
import uuid
from typing import TYPE_CHECKING
@@ -57,7 +58,12 @@
import pandas as pd
from anndata import AnnData
- from anndata._repr.registry import FormattedEntry, FormattedOutput
+ from anndata._repr.registry import (
+ FormattedEntry,
+ FormattedOutput,
+ HeaderConfig,
+ IndexPreviewConfig,
+ )
# Import formatters to register them (side-effect import)
import anndata._repr.formatters # noqa: F401
@@ -120,8 +126,68 @@ def _calculate_field_name_width(adata: AnnData, max_width: int) -> int:
return max(80, min(width_px, max_width))
+def _get_repr_settings(
+ max_depth: int | None, fold_threshold: int | None, max_items: int | None
+) -> tuple[int, int, int]:
+ """Get repr settings with defaults."""
+ if max_depth is None:
+ max_depth = _get_setting("repr_html_max_depth", default=DEFAULT_MAX_DEPTH)
+ if fold_threshold is None:
+ fold_threshold = _get_setting(
+ "repr_html_fold_threshold", default=DEFAULT_FOLD_THRESHOLD
+ )
+ if max_items is None:
+ max_items = _get_setting("repr_html_max_items", default=DEFAULT_MAX_ITEMS)
+ return max_depth, fold_threshold, max_items
+
+
+def _render_header_part(
+ obj: Any,
+ context: FormatterContext,
+ object_formatter: Any,
+ *,
+ show_search: bool,
+ depth: int,
+ container_id: str,
+) -> str:
+ """Render the header using either ObjectFormatter or default."""
+ if object_formatter is not None:
+ header_config = object_formatter.get_header_config(obj, context)
+ return _render_header_from_config(
+ obj,
+ header_config,
+ show_search=show_search and depth == 0,
+ container_id=container_id,
+ )
+ return _render_header(
+ obj,
+ show_search=show_search and depth == 0,
+ container_id=container_id,
+ )
+
+
+def _render_index_part(
+ obj: Any, context: FormatterContext, object_formatter: Any
+) -> str:
+ """Render the index preview using either ObjectFormatter or default."""
+ if object_formatter is not None:
+ index_config = object_formatter.get_index_preview_config(obj, context)
+ if index_config is not None:
+ return _render_index_preview_from_config(index_config)
+ return ""
+ return _render_index_preview(obj)
+
+
+def _render_footer_part(obj: Any, object_formatter: Any) -> str:
+ """Render the footer using either ObjectFormatter or default."""
+ if object_formatter is not None:
+ custom_version = object_formatter.get_footer_version(obj)
+ return _render_footer(obj, custom_version=custom_version)
+ return _render_footer(obj)
+
+
def generate_repr_html(
- adata: AnnData,
+ obj: Any,
*,
depth: int = 0,
max_depth: int | None = None,
@@ -132,14 +198,19 @@ def generate_repr_html(
_container_id: str | None = None,
) -> str:
"""
- Generate HTML representation for an AnnData object.
+ Generate HTML representation for an AnnData or AnnData-like object.
+
+ This function supports extension packages via ObjectFormatter registration.
+ If an ObjectFormatter is registered for the object type, it controls the
+ header, index preview, sections, and footer. Otherwise, the default
+ AnnData rendering is used.
Parameters
----------
- adata
- The AnnData object to represent
+ obj
+ The object to represent (AnnData, MuData, SpatialData, etc.)
depth
- Current recursion depth (for nested AnnData in .uns)
+ Current recursion depth (for nested objects)
max_depth
Maximum recursion depth. Uses settings/default if None.
fold_threshold
@@ -157,97 +228,127 @@ def generate_repr_html(
-------
HTML string
"""
- # Get settings with defaults
- if max_depth is None:
- max_depth = _get_setting("repr_html_max_depth", default=DEFAULT_MAX_DEPTH)
- if fold_threshold is None:
- fold_threshold = _get_setting(
- "repr_html_fold_threshold", default=DEFAULT_FOLD_THRESHOLD
- )
- if max_items is None:
- max_items = _get_setting("repr_html_max_items", default=DEFAULT_MAX_ITEMS)
+ max_depth, fold_threshold, max_items = _get_repr_settings(
+ max_depth, fold_threshold, max_items
+ )
- # Check if HTML repr is enabled
+ # Early returns for special cases
if not _get_setting("repr_html_enabled", default=True):
- # Fallback to text repr
- return f"
']
- # Version
- version = get_anndata_version()
- parts.append(f"
anndata v{version}")
+ # Version (custom or anndata)
+ if custom_version:
+ parts.append(f"
{escape_html(custom_version)}")
+ else:
+ version = get_anndata_version()
+ parts.append(f"
anndata v{version}")
# Memory usage
try:
- mem_bytes = adata.__sizeof__()
+ mem_bytes = obj.__sizeof__()
mem_str = format_memory_size(mem_bytes)
parts.append(f'
~{mem_str}')
except Exception: # noqa: BLE001
@@ -603,6 +801,18 @@ def _render_footer(adata: AnnData) -> str:
return "\n".join(parts)
+def _render_index_preview_from_config(config: IndexPreviewConfig) -> str:
+ """Render index preview using an IndexPreviewConfig from an ObjectFormatter."""
+ if not config.items:
+ return ""
+
+ parts = ['
']
+ for label, preview_html in config.items:
+ parts.append(f"
{escape_html(label)} {preview_html}
")
+ parts.append("
")
+ return "\n".join(parts)
+
+
def _render_index_preview(adata: AnnData) -> str:
"""Render preview of obs_names and var_names."""
parts = ['
']
diff --git a/src/anndata/_repr/registry.py b/src/anndata/_repr/registry.py
index 88403895b..fe6030f59 100644
--- a/src/anndata/_repr/registry.py
+++ b/src/anndata/_repr/registry.py
@@ -199,6 +199,144 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput:
...
+@dataclass
+class HeaderConfig:
+ """
+ Configuration for customizing the header of an object's HTML repr.
+
+ This allows extension packages to customize what's shown in the header,
+ including badges, shape info, and whether to show certain elements.
+ """
+
+ type_name: str
+ """Display name for the type (e.g., 'AnnData', 'SpatialData', 'MuData')"""
+
+ shape_str: str | None = None
+ """Shape string to display (e.g., '100 obs × 50 vars'). None to hide."""
+
+ badges: list[tuple[str, str, str]] = field(default_factory=list)
+ """List of (text, css_class, tooltip) tuples for badges"""
+
+ file_path: str | None = None
+ """File path to display (for backed objects)"""
+
+ show_readme: bool = True
+ """Whether to show README icon if uns['README'] exists"""
+
+
+@dataclass
+class IndexPreviewConfig:
+ """Configuration for index preview section."""
+
+ items: list[tuple[str, str]] = field(default_factory=list)
+ """List of (label, preview_html) tuples to display"""
+
+
+class ObjectFormatter(ABC):
+ """
+ Base class for object-level formatters that customize the entire repr.
+
+ Subclass this when you need to customize everything about how an object
+ is represented, including:
+ - Header (type name, shape, badges)
+ - Index preview (obs_names, var_names, or custom)
+ - Which sections to show (can skip X, add custom sections)
+ - Footer content
+
+ This is ideal for packages like SpatialData that have completely different
+ structures than AnnData but want to reuse the styling and infrastructure.
+
+ Example usage::
+
+ from anndata._repr import (
+ register_formatter,
+ ObjectFormatter,
+ HeaderConfig,
+ IndexPreviewConfig,
+ )
+
+
+ @register_formatter
+ class SpatialDataFormatter(ObjectFormatter):
+ priority = 100
+
+ def can_format(self, obj):
+ return type(obj).__name__ == "SpatialData"
+
+ def get_header_config(self, obj, context):
+ return HeaderConfig(
+ type_name="SpatialData",
+ shape_str=None, # No central shape
+ badges=[("Backed", "adata-badge-backed", "Zarr storage")]
+ if obj.is_backed()
+ else [],
+ )
+
+ def get_index_preview_config(self, obj, context):
+ # SpatialData shows coordinate systems instead of obs/var names
+ cs_preview = ", ".join(obj.coordinate_systems[:5])
+ return IndexPreviewConfig(items=[("coordinate_systems:", cs_preview)])
+
+ def get_sections(self, obj):
+ # Return list of section names to render
+ return ["images", "labels", "points", "shapes", "tables"]
+
+ def should_render_x(self, obj):
+ return False # SpatialData has no X
+
+ Note: The formatter still uses the CSS, JavaScript, and rendering
+ infrastructure from anndata. Custom sections are rendered using
+ registered SectionFormatters.
+ """
+
+ priority: int = 0
+ """Priority for checking this formatter (higher = checked first)"""
+
+ @abstractmethod
+ def can_format(self, obj: Any) -> bool:
+ """Return True if this formatter handles the given object."""
+ ...
+
+ @abstractmethod
+ def get_header_config(self, obj: Any, context: FormatterContext) -> HeaderConfig:
+ """Get header configuration for this object."""
+ ...
+
+ def get_index_preview_config(
+ self, obj: Any, context: FormatterContext
+ ) -> IndexPreviewConfig | None:
+ """
+ Get index preview configuration.
+
+ Return None to skip the index preview section entirely.
+ """
+ return None
+
+ def get_sections(self, obj: Any) -> list[str]:
+ """
+ Get list of section names to render.
+
+ Return standard section names (obs, var, uns, etc.) and/or custom
+ section names that have registered SectionFormatters.
+
+ Return empty list to only render custom sections (via SectionFormatters
+ that have should_show() return True for this object).
+ """
+ return []
+
+ def should_render_x(self, obj: Any) -> bool:
+ """Whether to render the X entry. Return False for objects without X."""
+ return True
+
+ def get_footer_version(self, obj: Any) -> str | None:
+ """
+ Get version string for footer.
+
+ Return None to use anndata version, or a custom version string.
+ """
+ return None
+
+
class SectionFormatter(ABC):
"""
Base class for section-specific formatters.
@@ -338,7 +476,7 @@ def format(self, obj: Any, context: FormatterContext) -> FormattedOutput:
class FormatterRegistry:
"""
- Registry for type and section formatters.
+ Registry for type, section, and object formatters.
This is the central registry that manages all formatters. It supports:
- Registering new formatters at runtime
@@ -350,6 +488,7 @@ class FormatterRegistry:
def __init__(self) -> None:
self._type_formatters: list[TypeFormatter] = []
self._section_formatters: dict[str, SectionFormatter] = {}
+ self._object_formatters: list[ObjectFormatter] = []
self._fallback = FallbackFormatter()
def register_type_formatter(self, formatter: TypeFormatter) -> None:
@@ -366,6 +505,16 @@ def register_section_formatter(self, formatter: SectionFormatter) -> None:
"""Register a section formatter."""
self._section_formatters[formatter.section_name] = formatter
+ def register_object_formatter(self, formatter: ObjectFormatter) -> None:
+ """
+ Register an object formatter.
+
+ Object formatters are checked in priority order (highest first).
+ """
+ self._object_formatters.append(formatter)
+ # Keep sorted by priority (highest first)
+ self._object_formatters.sort(key=lambda f: -f.priority)
+
def unregister_type_formatter(self, formatter: TypeFormatter) -> bool:
"""Unregister a type formatter. Returns True if found and removed."""
try:
@@ -374,6 +523,21 @@ def unregister_type_formatter(self, formatter: TypeFormatter) -> bool:
except ValueError:
return False
+ def get_object_formatter(self, obj: Any) -> ObjectFormatter | None:
+ """
+ Get an object formatter for the given object, or None if none match.
+
+ Tries each registered object formatter in priority order.
+ """
+ for formatter in self._object_formatters:
+ try:
+ if formatter.can_format(obj):
+ return formatter
+ except Exception: # noqa: BLE001
+ # Intentional broad catch: formatters shouldn't crash
+ continue
+ return None
+
def format_value(self, obj: Any, context: FormatterContext) -> FormattedOutput:
"""
Format a value using the appropriate formatter.
@@ -521,8 +685,8 @@ def format(self, obj, context):
def register_formatter(
- formatter: TypeFormatter | SectionFormatter,
-) -> TypeFormatter | SectionFormatter:
+ formatter: TypeFormatter | SectionFormatter | ObjectFormatter,
+) -> TypeFormatter | SectionFormatter | ObjectFormatter:
"""
Register a formatter with the global registry.
@@ -544,8 +708,10 @@ class MyFormatter(TypeFormatter):
formatter_registry.register_type_formatter(formatter)
elif isinstance(formatter, SectionFormatter):
formatter_registry.register_section_formatter(formatter)
+ elif isinstance(formatter, ObjectFormatter):
+ formatter_registry.register_object_formatter(formatter)
else:
- msg = f"Expected TypeFormatter or SectionFormatter, got {type(formatter)}"
+ msg = f"Expected TypeFormatter, SectionFormatter, or ObjectFormatter, got {type(formatter)}"
raise TypeError(msg)
return formatter
diff --git a/tests/visual_inspect_repr_html.py b/tests/visual_inspect_repr_html.py
index dc70008e0..134342830 100644
--- a/tests/visual_inspect_repr_html.py
+++ b/tests/visual_inspect_repr_html.py
@@ -240,7 +240,7 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
from anndata._repr import (
FormattedEntry,
FormattedOutput,
- FormatterContext, # noqa: TC001
+ FormatterContext,
SectionFormatter,
register_formatter,
)
@@ -317,6 +317,296 @@ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
MuData = None # type: ignore[assignment,misc]
+# Check for SpatialData
+try:
+ from spatialdata import SpatialData
+
+ from anndata._repr import (
+ FormattedEntry,
+ FormattedOutput,
+ FormatterContext, # noqa: TC001
+ HeaderConfig,
+ IndexPreviewConfig,
+ ObjectFormatter,
+ SectionFormatter,
+ register_formatter,
+ )
+ from anndata._repr.html import generate_repr_html
+ from anndata._repr.utils import format_number
+
+ HAS_SPATIALDATA = True
+
+ # Register an ObjectFormatter for SpatialData
+ @register_formatter
+ class SpatialDataObjectFormatter(ObjectFormatter):
+ """
+ ObjectFormatter for SpatialData.
+
+ This demonstrates how packages with completely different structures
+ (no central X, different sections) can still reuse anndata's HTML repr.
+ """
+
+ priority = 100
+
+ def can_format(self, obj) -> bool:
+ return type(obj).__name__ == "SpatialData"
+
+ def get_header_config(self, obj, context: FormatterContext) -> HeaderConfig:
+ badges = []
+ file_path = None
+
+ # Check for backed/zarr storage
+ if obj.is_backed():
+ path = str(obj.path) if obj.path else None
+ badges.append((
+ "Zarr",
+ "adata-badge adata-badge-backed",
+ "Zarr storage",
+ ))
+ file_path = path
+
+ return HeaderConfig(
+ type_name="SpatialData",
+ shape_str=None, # SpatialData has no central shape
+ badges=badges,
+ file_path=file_path,
+ show_readme=False, # SpatialData doesn't use README in uns
+ )
+
+ def get_index_preview_config(
+ self, obj, context: FormatterContext
+ ) -> IndexPreviewConfig | None:
+ # Show coordinate systems instead of obs/var names
+ items = []
+ coord_systems = list(obj.coordinate_systems)
+ if coord_systems:
+ preview = ", ".join(coord_systems[:5])
+ if len(coord_systems) > 5:
+ preview += f", ... ({len(coord_systems)} total)"
+ items.append(("coordinate_systems:", preview))
+ return IndexPreviewConfig(items=items) if items else None
+
+ def get_sections(self, obj) -> list[str]:
+ # SpatialData uses only custom sections
+ return []
+
+ def should_render_x(self, obj) -> bool:
+ return False # SpatialData has no X
+
+ def get_footer_version(self, obj) -> str | None:
+ try:
+ from importlib.metadata import version
+
+ return f"spatialdata v{version('spatialdata')}"
+ except Exception: # noqa: BLE001
+ return "spatialdata"
+
+ # Register SectionFormatters for each SpatialData section
+ @register_formatter
+ class ImagesSectionFormatter(SectionFormatter):
+ """SectionFormatter for SpatialData's .images attribute."""
+
+ section_name = "images"
+ priority = 200
+
+ @property
+ def after_section(self) -> str | None:
+ return None # Show at the beginning
+
+ @property
+ def doc_url(self) -> str:
+ return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.images"
+
+ @property
+ def tooltip(self) -> str:
+ return "2D/3D image data (xarray.DataArray)"
+
+ def should_show(self, obj) -> bool:
+ return hasattr(obj, "images") and len(obj.images) > 0
+
+ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
+ entries = []
+ for name, image in obj.images.items():
+ # Get shape info from DataArray
+ shape_str = " × ".join(str(s) for s in image.shape)
+ dims_str = ", ".join(image.dims)
+ dtype_str = str(image.dtype)
+ output = FormattedOutput(
+ type_name=f"DataArray[{dims_str}] ({shape_str}) {dtype_str}",
+ css_class="dtype-ndarray",
+ tooltip=f"Image: {name}",
+ is_expandable=False,
+ is_serializable=True,
+ )
+ entries.append(FormattedEntry(key=name, output=output))
+ return entries
+
+ @register_formatter
+ class LabelsSectionFormatter(SectionFormatter):
+ """SectionFormatter for SpatialData's .labels attribute."""
+
+ section_name = "labels"
+ priority = 190
+
+ @property
+ def after_section(self) -> str | None:
+ return None
+
+ @property
+ def doc_url(self) -> str:
+ return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.labels"
+
+ @property
+ def tooltip(self) -> str:
+ return "2D/3D label/segmentation data (xarray.DataArray)"
+
+ def should_show(self, obj) -> bool:
+ return hasattr(obj, "labels") and len(obj.labels) > 0
+
+ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
+ entries = []
+ for name, label in obj.labels.items():
+ shape_str = " × ".join(str(s) for s in label.shape)
+ dims_str = ", ".join(label.dims)
+ dtype_str = str(label.dtype)
+ output = FormattedOutput(
+ type_name=f"DataArray[{dims_str}] ({shape_str}) {dtype_str}",
+ css_class="dtype-ndarray",
+ tooltip=f"Labels: {name}",
+ is_expandable=False,
+ is_serializable=True,
+ )
+ entries.append(FormattedEntry(key=name, output=output))
+ return entries
+
+ @register_formatter
+ class PointsSectionFormatter(SectionFormatter):
+ """SectionFormatter for SpatialData's .points attribute."""
+
+ section_name = "points"
+ priority = 180
+
+ @property
+ def after_section(self) -> str | None:
+ return None
+
+ @property
+ def doc_url(self) -> str:
+ return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.points"
+
+ @property
+ def tooltip(self) -> str:
+ return "Point cloud data (dask.dataframe.DataFrame)"
+
+ def should_show(self, obj) -> bool:
+ return hasattr(obj, "points") and len(obj.points) > 0
+
+ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
+ entries = []
+ for name, pts in obj.points.items():
+ # Dask dataframe - get shape info
+ n_cols = len(pts.columns)
+ output = FormattedOutput(
+ type_name=f"DataFrame (? × {n_cols})",
+ css_class="dtype-dataframe",
+ tooltip=f"Points: {name} (Dask DataFrame, rows computed lazily)",
+ is_expandable=False,
+ is_serializable=True,
+ )
+ entries.append(FormattedEntry(key=name, output=output))
+ return entries
+
+ @register_formatter
+ class ShapesSectionFormatter(SectionFormatter):
+ """SectionFormatter for SpatialData's .shapes attribute."""
+
+ section_name = "shapes"
+ priority = 170
+
+ @property
+ def after_section(self) -> str | None:
+ return None
+
+ @property
+ def doc_url(self) -> str:
+ return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.shapes"
+
+ @property
+ def tooltip(self) -> str:
+ return "Geometric shapes (geopandas.GeoDataFrame)"
+
+ def should_show(self, obj) -> bool:
+ return hasattr(obj, "shapes") and len(obj.shapes) > 0
+
+ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
+ entries = []
+ for name, shape in obj.shapes.items():
+ n_rows, n_cols = shape.shape
+ output = FormattedOutput(
+ type_name=f"GeoDataFrame ({format_number(n_rows)} × {n_cols})",
+ css_class="dtype-dataframe",
+ tooltip=f"Shapes: {name}",
+ is_expandable=False,
+ is_serializable=True,
+ )
+ entries.append(FormattedEntry(key=name, output=output))
+ return entries
+
+ @register_formatter
+ class TablesSectionFormatter(SectionFormatter):
+ """SectionFormatter for SpatialData's .tables attribute."""
+
+ section_name = "tables"
+ priority = 160
+
+ @property
+ def after_section(self) -> str | None:
+ return None
+
+ @property
+ def doc_url(self) -> str:
+ return "https://spatialdata.scverse.org/en/latest/api/SpatialData.html#spatialdata.SpatialData.tables"
+
+ @property
+ def tooltip(self) -> str:
+ return "Annotation tables (AnnData)"
+
+ def should_show(self, obj) -> bool:
+ return hasattr(obj, "tables") and len(obj.tables) > 0
+
+ def get_entries(self, obj, context: FormatterContext) -> list[FormattedEntry]:
+ entries = []
+ for name, table in obj.tables.items():
+ shape_str = (
+ f"{format_number(table.n_obs)} × {format_number(table.n_vars)}"
+ )
+ # Generate nested HTML for expandable content
+ can_expand = context.depth < context.max_depth
+ nested_html = None
+ if can_expand:
+ nested_html = generate_repr_html(
+ table,
+ depth=context.depth + 1,
+ max_depth=context.max_depth,
+ show_header=True,
+ show_search=False,
+ )
+ output = FormattedOutput(
+ type_name=f"AnnData ({shape_str})",
+ css_class="dtype-anndata",
+ tooltip=f"Table: {name}",
+ html_content=nested_html,
+ is_expandable=can_expand,
+ is_serializable=True,
+ )
+ entries.append(FormattedEntry(key=name, output=output))
+ return entries
+
+except ImportError:
+ HAS_SPATIALDATA = False
+ SpatialData = None # type: ignore[assignment,misc]
+
+
def create_test_mudata():
"""Create a comprehensive test MuData with multiple modalities."""
if not HAS_MUDATA:
@@ -388,6 +678,81 @@ def create_test_mudata():
return mdata
+def create_test_spatialdata():
+ """Create a comprehensive test SpatialData with all element types."""
+ if not HAS_SPATIALDATA:
+ return None
+
+ import dask.array as da
+ import dask.dataframe as dd
+ import geopandas as gpd
+ from shapely.geometry import Point
+ from spatialdata.models import (
+ Image2DModel,
+ Labels2DModel,
+ PointsModel,
+ ShapesModel,
+ TableModel,
+ )
+
+ np.random.seed(42)
+
+ # Create image (3-channel RGB, 100x100)
+ img_data = da.zeros((3, 100, 100), dtype=np.uint8)
+ image = Image2DModel.parse(img_data, c_coords=["r", "g", "b"])
+
+ # Create labels (segmentation mask)
+ labels_data = da.zeros((100, 100), dtype=np.int32)
+ labels = Labels2DModel.parse(labels_data)
+
+ # Create points (transcript locations)
+ n_points = 50
+ points_df = pd.DataFrame({
+ "x": np.random.uniform(0, 100, n_points),
+ "y": np.random.uniform(0, 100, n_points),
+ "gene": np.random.choice(["GAPDH", "ACTB", "CD3E", "MS4A1"], n_points),
+ })
+ points = PointsModel.parse(dd.from_pandas(points_df, npartitions=1))
+
+ # Create shapes (cell boundaries)
+ n_cells = 20
+ circles = gpd.GeoDataFrame({
+ "geometry": [
+ Point(np.random.uniform(10, 90), np.random.uniform(10, 90)).buffer(3)
+ for _ in range(n_cells)
+ ],
+ "cell_id": list(range(n_cells)),
+ })
+ shapes = ShapesModel.parse(circles)
+
+ # Create table (AnnData with cell annotations)
+ table = AnnData(
+ np.random.randn(n_cells, 10),
+ obs=pd.DataFrame({
+ "cell_type": pd.Categorical(
+ ["T cell", "B cell", "Macrophage", "Epithelial"] * 5
+ ),
+ "region": ["cells"] * n_cells, # Must match region parameter
+ "cell_id": list(range(n_cells)),
+ }),
+ var=pd.DataFrame({"gene_name": [f"marker_{i}" for i in range(10)]}),
+ )
+ table = TableModel.parse(
+ table, region="cells", region_key="region", instance_key="cell_id"
+ )
+
+ # Create SpatialData
+ sdata = SpatialData(
+ images={"tissue_image": image},
+ labels={"cell_segmentation": labels},
+ points={"transcripts": points},
+ shapes={"cells": shapes},
+ tables={"cell_annotations": table},
+ )
+
+ return sdata
+
+
def create_test_treedata():
"""Create a TreeData object with observation and variable trees."""
if not HAS_TREEDATA:
@@ -654,7 +1019,7 @@ def strip_script_tags(html: str) -> str:
return re.sub(r"", "", html, flags=re.DOTALL)
-def main(): # noqa: PLR0915
+def main(): # noqa: PLR0912, PLR0915
"""Generate visual test HTML file."""
print("Generating visual test cases...")
@@ -1204,6 +1569,31 @@ def format(self, obj, context):
else:
print(" 19. MuData (skipped - mudata not installed)")
+ # Test 20: SpatialData (spatial omics data)
+ # This demonstrates how SpatialData can fully customize the repr using ObjectFormatter:
+ # 1. Registering an ObjectFormatter to customize header, index preview, and skip X
+ # 2. Registering SectionFormatters for images, labels, points, shapes, tables
+ if HAS_SPATIALDATA:
+ print(" 20. SpatialData (spatial omics data)")
+ from anndata._repr.html import generate_repr_html
+
+ sdata = create_test_spatialdata()
+ if sdata is not None:
+ sections.append((
+ "20. SpatialData (Spatial Omics Data)",
+ generate_repr_html(sdata),
+ "Demonstrates how packages with completely different structures can reuse "
+ "anndata's HTML repr by registering an ObjectFormatter. "
+ "SpatialData has no central X matrix, uses different sections (images, labels, "
+ "points, shapes, tables), and shows coordinate systems instead of obs/var names. "
+ "The ObjectFormatter customizes the header (no shape), index preview "
+ "(coordinate_systems), footer (spatialdata version), and skips X. Each section "
+ "has its own SectionFormatter with documentation links. "
+ "The tables section contains expandable AnnData objects.",
+ ))
+ else:
+ print(" 20. SpatialData (skipped - spatialdata not installed)")
+
# Generate HTML file
output_path = Path(__file__).parent / "repr_html_visual_test.html"
html_content = create_html_page(sections)