Source code for figrecipe._editable._editable_export

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# File: figrecipe/_editable/_editable_export.py

"""
Main export function for Schema v0.3 (scitex.plt.figure.editable).

This module provides the top-level export_editable_figure() function that
orchestrates geometry extraction for all elements in a matplotlib figure,
producing a JSON-serializable dictionary suitable for interactive editing.
"""

from datetime import datetime
from typing import Any, Dict, List, Optional

import numpy as np

from ._geometry_extraction import (
    extract_axes_bbox_px,
    extract_bar_group_geometry,
    extract_image_geometry,
    extract_line_geometry,
    extract_polygon_geometry,
    extract_rectangle_geometry,
    extract_scatter_geometry,
    extract_text_geometry,
)


def export_editable_figure(
    fig,
    title: str = "",
    description: str = "",
    include_full_paths: bool = False,
    simplify_threshold: float = 0.5,
) -> Dict[str, Any]:
    """
    Export a matplotlib figure with geometry data for interactive editing.

    This produces a Schema v0.3 compliant dictionary with axes-local pixel
    coordinates for all visual elements, suitable for shape-based hit testing.

    Parameters
    ----------
    fig : matplotlib.figure.Figure or scitex.plt.FigureWrapper
        The figure to export
    title : str
        Optional title for the figure metadata
    description : str
        Optional description for the figure metadata
    include_full_paths : bool
        If True, include full (unsimplified) paths for lines. Default False.
    simplify_threshold : float
        Maximum pixel error for path simplification. Default 0.5.

    Returns
    -------
    dict
        Schema v0.3 compliant dictionary with structure:
        - scitex_schema: "scitex.plt.figure.editable"
        - scitex_schema_version: "0.3.0"
        - figure: {size_px, dpi, ...}
        - axes: {ax_id: {bbox_px, ...}, ...}
        - elements: {elem_id: {geometry_px, style, ...}, ...}
        - statistics: {} (placeholder for stats integration)
        - data: {} (placeholder for linked data)
        - output: {} (rendering metadata)
    """
    # Unwrap figrecipe RecordingFigure (.fig / ._fig) or a scitex-style
    # FigureWrapper (.figure); otherwise assume a bare matplotlib Figure.
    mpl_fig = fig
    for _attr in ("figure", "fig", "_fig"):
        _candidate = getattr(fig, _attr, None)
        if _candidate is not None and hasattr(_candidate, "get_axes"):
            mpl_fig = _candidate
            break

    # Ensure we have a renderer
    try:
        renderer = mpl_fig.canvas.get_renderer()
    except Exception:
        mpl_fig.canvas.draw()
        renderer = mpl_fig.canvas.get_renderer()

    # Figure dimensions
    fig_width_px = int(mpl_fig.get_figwidth() * mpl_fig.dpi)
    fig_height_px = int(mpl_fig.get_figheight() * mpl_fig.dpi)

    # Build output structure
    output = {
        "scitex_schema": "scitex.plt.figure.editable",
        "scitex_schema_version": "0.3.0",
        "meta": {
            "title": title,
            "description": description,
            "exported_at": datetime.now().isoformat(),
        },
        "figure": {
            "size_px": [fig_width_px, fig_height_px],
            "dpi": mpl_fig.dpi,
            "figsize_inches": [mpl_fig.get_figwidth(), mpl_fig.get_figheight()],
        },
        "axes": {},
        "elements": {},
        "statistics": {},
        "data": {},
        "output": {
            "format": "png",
            "width_px": fig_width_px,
            "height_px": fig_height_px,
        },
    }

    # Get all axes
    axes_list = mpl_fig.get_axes()

    for ax_idx, ax in enumerate(axes_list):
        # Handle scitex AxisWrapper
        mpl_ax = ax._axis_mpl if hasattr(ax, "_axis_mpl") else ax

        ax_id = f"ax_{ax_idx:02d}"

        # Extract axes bbox
        axes_bbox = extract_axes_bbox_px(mpl_ax, mpl_fig)
        output["axes"][ax_id] = {
            "bbox_px": axes_bbox,
            "position": list(mpl_ax.get_position().bounds),
            "xlim": list(mpl_ax.get_xlim()),
            "ylim": list(mpl_ax.get_ylim()),
        }

        # Extract elements from this axes
        elements = _extract_axes_elements(
            mpl_ax,
            mpl_fig,
            ax_id,
            renderer,
            include_full_paths=include_full_paths,
            simplify_threshold=simplify_threshold,
        )
        output["elements"].update(elements)

    return output


def _extract_axes_elements(
    ax,
    fig,
    ax_id: str,
    renderer,
    include_full_paths: bool = False,
    simplify_threshold: float = 0.5,
) -> Dict[str, Any]:
    """
    Extract all visual elements from a single axes.

    Parameters
    ----------
    ax : matplotlib.axes.Axes
        The axes object
    fig : matplotlib.figure.Figure
        The figure object
    ax_id : str
        Identifier for the axes (e.g., "ax_00")
    renderer : RendererBase
        Matplotlib renderer for text bbox extraction
    include_full_paths : bool
        Include unsimplified paths
    simplify_threshold : float
        Path simplification threshold in pixels

    Returns
    -------
    dict
        Dictionary of {element_id: element_data}
    """
    elements = {}
    zorder_counter = 0

    # Extract lines (Line2D)
    for idx, line in enumerate(ax.lines):
        elem_id = f"{ax_id}_line_{idx:02d}"
        label = line.get_label()
        if label is None or label.startswith("_"):
            label = f"line_{idx}"

        geom = extract_line_geometry(line, ax, fig, simplify_threshold)

        elements[elem_id] = {
            "id": elem_id,
            "axes_id": ax_id,
            "element_type": "line",
            "label": label,
            "geometry_px": geom,
            "style": _extract_line_style(line),
            "editable_styles": ["color", "linewidth", "linestyle", "alpha", "marker"],
            "zorder": line.get_zorder(),
            "visible": line.get_visible(),
        }
        zorder_counter += 1

    # Extract collections (scatter, polygon fills, etc.)
    for idx, coll in enumerate(ax.collections):
        coll_type = type(coll).__name__

        if coll_type == "PathCollection":
            # Scatter plot
            elem_id = f"{ax_id}_scatter_{idx:02d}"
            label = coll.get_label()
            if label is None or label.startswith("_"):
                label = f"scatter_{idx}"

            geom = extract_scatter_geometry(coll, ax, fig)

            elements[elem_id] = {
                "id": elem_id,
                "axes_id": ax_id,
                "element_type": "scatter",
                "label": label,
                "geometry_px": geom,
                "style": _extract_scatter_style(coll),
                "editable_styles": ["facecolor", "edgecolor", "s", "alpha", "marker"],
                "zorder": coll.get_zorder(),
                "visible": coll.get_visible(),
            }

        elif coll_type in ("PolyCollection", "FillBetweenPolyCollection"):
            # Fill_between, violin, etc.
            elem_id = f"{ax_id}_fill_{idx:02d}"
            label = coll.get_label()
            if label is None or label.startswith("_"):
                label = f"fill_{idx}"

            geom = extract_polygon_geometry(coll, ax, fig)

            elements[elem_id] = {
                "id": elem_id,
                "axes_id": ax_id,
                "element_type": "fill",
                "label": label,
                "geometry_px": geom,
                "style": _extract_polygon_style(coll),
                "editable_styles": ["facecolor", "edgecolor", "alpha"],
                "zorder": coll.get_zorder(),
                "visible": coll.get_visible(),
            }

        elif coll_type == "LineCollection":
            # Multiple lines (e.g., errorbar caps)
            elem_id = f"{ax_id}_linecoll_{idx:02d}"
            elements[elem_id] = {
                "id": elem_id,
                "axes_id": ax_id,
                "element_type": "line_collection",
                "label": f"linecoll_{idx}",
                "geometry_px": {"coord_space": "axes", "bbox": None},
                "style": {},
                "editable_styles": ["color", "linewidth", "alpha"],
                "zorder": coll.get_zorder(),
                "visible": coll.get_visible(),
            }

    # Extract patches (bars, rectangles)
    bar_patches = []
    other_patches = []

    for patch in ax.patches:
        patch_type = type(patch).__name__
        if patch_type == "Rectangle":
            # Check if it's part of a bar plot (has nonzero width and height)
            if patch.get_width() != 0 and patch.get_height() != 0:
                bar_patches.append(patch)
            else:
                other_patches.append(patch)
        else:
            other_patches.append(patch)

    # Group bar patches by similar x position (bars) or y position (horizontal bars)
    if bar_patches:
        elem_id = f"{ax_id}_bars"
        geom = extract_bar_group_geometry(bar_patches, ax, fig)

        elements[elem_id] = {
            "id": elem_id,
            "axes_id": ax_id,
            "element_type": "bar",
            "label": "bars",
            "geometry_px": geom,
            "style": _extract_bar_style(bar_patches[0] if bar_patches else None),
            "editable_styles": ["facecolor", "edgecolor", "alpha", "linewidth"],
            "zorder": bar_patches[0].get_zorder() if bar_patches else 0,
            "visible": True,
        }

    # Extract images
    for idx, img in enumerate(ax.images):
        elem_id = f"{ax_id}_image_{idx:02d}"
        geom = extract_image_geometry(img, ax, fig)

        elements[elem_id] = {
            "id": elem_id,
            "axes_id": ax_id,
            "element_type": "image",
            "label": f"image_{idx}",
            "geometry_px": geom,
            "style": {"cmap": str(img.get_cmap().name), "alpha": img.get_alpha()},
            "editable_styles": ["cmap", "alpha", "clim"],
            "zorder": img.get_zorder(),
            "visible": img.get_visible(),
        }

    # Extract texts (excluding axis labels and title which are handled separately)
    for idx, text in enumerate(ax.texts):
        if not text.get_text():
            continue

        elem_id = f"{ax_id}_text_{idx:02d}"
        geom = extract_text_geometry(text, ax, fig)

        elements[elem_id] = {
            "id": elem_id,
            "axes_id": ax_id,
            "element_type": "text",
            "label": text.get_text()[:20],
            "text": text.get_text(),
            "geometry_px": geom,
            "style": _extract_text_style(text),
            "editable_styles": ["fontsize", "color", "fontweight", "alpha"],
            "zorder": text.get_zorder(),
            "visible": text.get_visible(),
        }

    return elements


def _extract_line_style(line) -> Dict[str, Any]:
    """Extract style properties from a Line2D."""
    color = line.get_color()
    if isinstance(color, np.ndarray):
        color = color.tolist()

    return {
        "color": color,
        "linewidth": line.get_linewidth(),
        "linestyle": line.get_linestyle(),
        "alpha": line.get_alpha(),
        "marker": line.get_marker(),
        "markersize": line.get_markersize(),
    }


def _extract_scatter_style(coll) -> Dict[str, Any]:
    """Extract style properties from a PathCollection (scatter)."""
    facecolors = coll.get_facecolors()
    edgecolors = coll.get_edgecolors()

    fc = facecolors[0].tolist() if len(facecolors) > 0 else [0, 0, 0, 1]
    ec = edgecolors[0].tolist() if len(edgecolors) > 0 else [0, 0, 0, 1]

    sizes = coll.get_sizes()
    s = float(sizes[0]) if len(sizes) > 0 else 36

    return {
        "facecolor": fc,
        "edgecolor": ec,
        "s": s,
        "alpha": coll.get_alpha(),
    }


def _extract_polygon_style(coll) -> Dict[str, Any]:
    """Extract style properties from a PolyCollection."""
    facecolors = coll.get_facecolors()
    edgecolors = coll.get_edgecolors()

    fc = facecolors[0].tolist() if len(facecolors) > 0 else [0, 0, 0, 0.3]
    ec = edgecolors[0].tolist() if len(edgecolors) > 0 else [0, 0, 0, 1]

    return {
        "facecolor": fc,
        "edgecolor": ec,
        "alpha": coll.get_alpha(),
    }


def _extract_bar_style(patch) -> Dict[str, Any]:
    """Extract style properties from a Rectangle patch."""
    if patch is None:
        return {}

    fc = patch.get_facecolor()
    ec = patch.get_edgecolor()

    return {
        "facecolor": list(fc) if hasattr(fc, "__iter__") else fc,
        "edgecolor": list(ec) if hasattr(ec, "__iter__") else ec,
        "linewidth": patch.get_linewidth(),
        "alpha": patch.get_alpha(),
    }


def _extract_text_style(text) -> Dict[str, Any]:
    """Extract style properties from a Text object."""
    return {
        "fontsize": text.get_fontsize(),
        "color": text.get_color(),
        "fontweight": text.get_fontweight(),
        "fontstyle": text.get_fontstyle(),
        "fontfamily": text.get_fontfamily(),
        "alpha": text.get_alpha(),
        "ha": text.get_ha(),
        "va": text.get_va(),
        "rotation": text.get_rotation(),
    }


# EOF