diff --git a/.github/workflows/matplotlib-import.yml b/.github/workflows/matplotlib-import.yml new file mode 100644 index 0000000..ab33b36 --- /dev/null +++ b/.github/workflows/matplotlib-import.yml @@ -0,0 +1,23 @@ +name: Matplotlib import compatibility + +on: + push: + branches: [main, devel] + pull_request: + +jobs: + import-fixtures: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + matplotlib: ['3.8.*', '3.9.*', '3.10.*', '3.11.*'] + env: + MPLBACKEND: Agg + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: python -m pip install '.[test]' 'numpy<2' 'matplotlib==${{ matrix.matplotlib }}' + - run: python -m pytest src/maxplotlib/tests/test_matplotlib_import.py src/maxplotlib/tests/test_matplotlib_import_extended.py diff --git a/docs/matplotlib-import-support.md b/docs/matplotlib-import-support.md new file mode 100644 index 0000000..5bef77d --- /dev/null +++ b/docs/matplotlib-import-support.md @@ -0,0 +1,209 @@ +# Matplotlib import support and roadmap + +`Canvas.from_matplotlib(source, strict=False, **canvas_kwargs)` accepts an +in-memory Matplotlib figure, axes, or rectangular axes array. This checklist +covers the intended import surface. It is a roadmap, not a promise that every +item already works on every rendering backend. + +Checked items are implemented within their stated scope. Unless explicitly +stated otherwise, fidelity checks below refer to the **Matplotlib backend**. +Complex built-in artists are retained as detached, editable native geometry; +these entries deliberately raise on backends that cannot represent them. +Portable entries continue to support the other renderers, with the limitations +listed below. Unchecked items are +pending or partial. Importing drawn geometry does not recover the original +samples, plotting call, callback, or statistical model. Matplotlib, Plotly, +TikZ, and plotext have different rendering capabilities; import success does +not guarantee that every backend can render the result identically. + +## Inputs and ownership + +- [x] Whole `Figure`, individual `Axes`, flat lists/arrays, and 2D lists/arrays. +- [x] Explicit 2D array order; infer ordinary grids from flat input. +- [x] Reject empty, ragged, non-axes, duplicate, and mixed-figure inputs. +- [x] Snapshot plot arrays and styles without reparenting source artists. +- [x] Canvas keyword overrides for figure size, DPI, and other canvas options. +- [x] Warnings for recognized unsupported content; strict mode raises. +- [x] Structured import report with artist identities, severity, and fallbacks. +- [ ] Complete detection of unsupported style properties in strict mode. +- [ ] Version compatibility matrix and fixtures across supported Matplotlib versions. +- [x] Optional serialized figure input with an explicit trust boundary. + +PNG/JPEG input and reconstruction of original data from PDF/SVG are separate +features, not part of this object importer. Arbitrary custom Python artists +need an adapter API or an explicit raster fallback; universal semantic +conversion is not possible. + +## Figure and layout + +- [x] Ordinary rectangular subplot grids and individual axes. +- [x] Figure dimensions and export DPI defaults. +- [x] Figure title and shared axis label text. +- [x] One `twinx` and one `twiny` per primary subplot; import only selected axes. +- [ ] Full twin-axis spine positioning, multiple twins, and cross-backend parity. + Matplotlib supports multiple twins and spine positions; backend parity is pending. +- [x] Shared-axis relationships and linked limits after import. +- [x] Preserve omitted/empty grid cells without drawing extra empty axes. +- [x] Spanning cells, subplot mosaics, nested GridSpec, and subfigures + (subfigure rectangles/decorations are flattened into the destination figure). +- [x] Grid width/height ratios, margins, spacing, constrained/tight layout + for ordinary/nested GridSpec; subfigures retain their snapshot rectangles. +- [x] Arbitrary axes rectangles, overlapping axes, inset axes, inset-zoom + connectors (`indicate_inset_zoom`/`indicate_inset`, Matplotlib 3.10+). The + connector spans two Axes and recomputes its geometry from live limits on + every draw, so it is rebuilt against the reconstructed parent/inset pair + rather than snapshotted. Matplotlib < 3.10's tuple-returning API still falls + back to native geometry. +- [x] Secondary axes with forward/inverse coordinate functions. +- [x] Figure backgrounds, frame styling, and figure-level text/patches/images. +- [x] Figure title/shared-label typography and placement. +- [x] Figure-level legends and shared colorbars. + +Irregular grids use one row of addressable Canvas slots and retain the source +axes rectangles when rendered with Matplotlib. A 2D input array defines its own +layout; slots occupied only by a twin remain empty rather than drawing extra axes. +Ordinary/nested GridSpec layout engines reflow on resize. Subfigure layout-engine +reflow and full cross-backend layout parity remain pending. + +## Lines, markers, and collections + +- [x] Numeric 2D lines, markers, NaN gaps, line/marker colors and widths. +- [x] Step draw styles in imported line entries (Matplotlib rendering). +- [x] Horizontal/vertical reference lines with fractional extents (Matplotlib). +- [x] Plain line collections, including `hlines`/`vlines`, as individual lines. +- [x] Standard scatter marker shapes, sizes, scalar colors, and per-point colors. +- [x] Scatter colormaps and normalization copied for Matplotlib rendering. +- [x] Preserve date/time, categorical, quantity/unit converters and formatters. +- [x] Custom dash sequences, cap/join styles, markevery, and gap colors. +- [x] Infinite `axline` semantics, event plots, stem containers. +- [x] Scalar-mapped line collections, offset collections, per-point transforms. +- [ ] Custom scatter paths and hollow markers on every backend. +- [ ] Match scatter area/size semantics and normalization on every backend. +- [x] Preserve ordering between artist types at equal z-order. + +## Bars, errors, fills, and statistical plots + +- [x] Vertical/horizontal bars, positions, dimensions, baselines, and styling. +- [x] Stacked/grouped bars as their resolved rectangles. +- [x] Histogram bars as geometry (original samples are unavailable). +- [x] Error-bar containers with data lines: symmetric/asymmetric x/y errors, + caps, colors, widths, and labels; avoid duplicated component artists. +- [x] Error-only plots and bar errors as line/cap geometry, without inventing + asymmetric centers that the source no longer retains. +- [x] Simple data-coordinate polygon collections, including `fill_between`, + `fill_betweenx`, and stackplot regions, as filled polygon geometry. +- [x] Data-coordinate polygon patches and stairs values/edges/baseline. +- [x] Error limits/arrows, subsampled errors, and independently styled components. +- [ ] Restore semantic fill boundaries/where masks when recoverable. + Drawn paths and retained container metadata are preserved; Matplotlib usually + does not retain the original where-mask or samples. +- [x] Spans in blended coordinates; general rectangles, circles, ellipses, wedges. +- [x] Compound polygons, holes, curved paths, arbitrary PathPatch geometry. +- [x] Box plots, violin plots, pie/donut charts, hist2d, hexbin. +- [x] Preserve statistical groupings when containers/metadata retain them: + `subplot.import_groups` and entry `source_container_ids` retain bar, errorbar, + and stem memberships, labels, orientation, and available data values. + +## Images, fields, and colorbars + +- [x] `imshow` arrays, extent, origin, colormap, normalization, interpolation + copied into entries (Matplotlib rendering). +- [ ] Match image extent/origin, RGB(A), masks, alpha, and interpolation on all backends. +- [ ] Nonlinear normalization, clim, under/over/bad colors on all backends. +- [x] `pcolor`, `pcolormesh`, nonuniform grids, QuadMesh, triangular meshes. +- [x] Contours, filled contours, levels, labels, and contour topology. +- [x] Quiver, barbs, streamplots, vector-field keys. +- [x] Axes colorbars tied to the correct image/scatter/mesh mappable. +- [x] Colorbar orientation, label, ticks, limits, extend, and normalization. +- [x] Multiple/shared colorbars and explicit colorbar axes. + +## Text and annotations + +- [x] Data-coordinate text, font family/size/weight/style, color, alignment, + rotation (backend support varies). +- [x] Data-coordinate annotations with optional arrows and independent copied + arrow properties; references to other patches are explicitly rejected. +- [x] Center title and x/y labels with basic typography and label padding. +- [x] Text boxes, multiline spacing, math/TeX fidelity, wrapping, clipping + through Matplotlib (TeX still requires the source environment’s TeX setup). +- [ ] Axes/figure fractions, point/pixel offsets, blended and callable coordinates. +- [ ] Annotation arrows with full backend parity and artist-relative coordinates. +- [x] Left/right titles, custom title/label positions, offset text. +- [x] AnnotationBbox, OffsetBox, tables, and other composite text artists. + +## Axes, ticks, grids, and legends + +- [x] Limits including reversed limits; default linear/log scales. +- [x] Explicit major tick locations/labels with FixedLocator. +- [x] Basic grid visibility, axes visibility, background, aspect, axisbelow. +- [x] Basic per-axes legend visibility and artist labels. +- [x] Non-default log bases; symlog, logit, asinh, function/custom scales. +- [x] Automatic/fixed minor ticks, locator/formatter configuration and units. +- [x] Tick placement, direction, length, width, color, rotation, and font styling. +- [x] Per-axis major/minor grid visibility and line styling. +- [x] Spine visibility, colors, widths, bounds, and positions. +- [x] Autoscale flags, sticky edges, margins, adjustable/anchor/box aspect. +- [x] Legend order, renamed labels, proxy handles, multiple legends, grouping. +- [x] Legend location/anchor, columns, title, typography, frame and spacing. + +## Transforms, metadata, and advanced axes + +- [x] General affine/nonlinear/blended transforms, coordinate rebinding. +- [x] Clip paths/boxes, path effects, rasterization, sketch settings, filters. +- [x] Hidden artists retained as hidden editable entries. +- [ ] Artist IDs, URLs, picking, metadata, and accessibility descriptions. +- [x] Polar, geographic/custom projections, axisartist and parasite axes via + native axes snapshots; custom projection classes must remain available. +- [x] 3D lines/scatter, surfaces, wireframes, collections, camera/projection. +- [ ] Animations, widgets, callbacks, and interactive state (separate adapters). + Static artist state is copied; arbitrary callback closures and GUI event loops + cannot be reconstructed from a figure’s drawn geometry. +- [x] Optional raster fallback for unsupported artists with explicit loss reporting. + +## Validation and next priorities + +- [x] Tests for all three input forms, source independence, strict/warning modes. +- [x] Matplotlib reconstruction tests for supported geometry and axis settings. +- [x] Plotly smoke/geometry tests for representative supported imports. +- [x] Image comparisons with tolerances, plus representative portable export + tests for Matplotlib PNG/SVG, Plotly HTML, plotext text, and TikZ source. +- [x] Large figures, empty/masked data, performance and memory benchmarks. + +New import controls: + +```python +canvas = Canvas.from_matplotlib(fig, strict=True) +report = canvas.import_report.to_dict() # identities, severity, fallback, backends +canvas = Canvas.from_matplotlib("figure.pickle", trusted=True) +canvas = Canvas.from_matplotlib(fig, fallback="raster") +``` + +`trusted=True` is required before any pickle is read. Pickles can execute code; +load only files whose producer you trust and use a matching Matplotlib version. +`fallback="native"` (default) retains built-in native geometry with rebound +transforms. `fallback="skip"` warns (or raises in strict mode) for those artists. +`fallback="raster"` snapshots the selected figure content and records the loss of +editable data and vector geometry; it supports Matplotlib and Plotly. Callback +functions and custom scales remain Python objects, not portable serialization. +Strict mode checks import losses, not universal rendering parity. + +Remaining priorities are cross-backend fidelity, subfigure reflow, portable +projection adapters, and live interaction adapters. Source metadata does not +usually retain original fill masks or box/violin samples; these are imported as +geometry without inventing lost statistical inputs. + +Reference APIs: [Matplotlib artists](https://matplotlib.org/stable/tutorials/artists.html), +[containers](https://matplotlib.org/stable/api/container_api.html), and +[annotations](https://matplotlib.org/stable/users/explain/text/annotations.html). + + +Compatibility fixtures live in `test_matplotlib_import.py` and +`test_matplotlib_import_extended.py`. The CI matrix covers Matplotlib 3.8, 3.9, +3.10 and 3.11 on Python 3.11. Snapshots use version-sensitive Matplotlib state; +there is no cross-version pickle compatibility promise. + +Run `python scripts/benchmark_matplotlib_import.py --points 100000 --panels 4` +for reproducible timing/allocation observations. A local Matplotlib 3.11.1 run +imported 400,000 points in 0.41 s with 8.75 MiB peak Python allocations and rendered +in 0.79 s. These are observations, not performance guarantees; native allocations +outside Python are excluded from the memory measurement. diff --git a/scripts/benchmark_matplotlib_import.py b/scripts/benchmark_matplotlib_import.py new file mode 100644 index 0000000..1121383 --- /dev/null +++ b/scripts/benchmark_matplotlib_import.py @@ -0,0 +1,58 @@ +"""Measure import time and peak Python allocation for reproducible fixtures. + +Run from an installed checkout: python scripts/benchmark_matplotlib_import.py +Timings are observations, not brittle pass/fail thresholds. Native renderer +allocations outside Python are not included in tracemalloc's peak measurement. +""" + +import argparse +import json +import time +import tracemalloc + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +from maxplotlib import Canvas + + +def benchmark(points, panels): + fig, axes = plt.subplots(panels, 1, figsize=(8, max(3, panels * 2))) + x = np.linspace(0, 10, points) + for index, ax in enumerate(np.atleast_1d(axes)): + ax.plot(x, np.sin(x + index)) + ax.scatter([], []) + tracemalloc.start() + started = time.perf_counter() + canvas = Canvas.from_matplotlib(fig, strict=True) + imported = time.perf_counter() + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + result, _ = canvas.render() + result.canvas.draw() + finished = time.perf_counter() + stats = dict( + matplotlib=matplotlib.__version__, + points_per_panel=points, + panels=panels, + import_seconds=imported - started, + render_seconds=finished - imported, + import_peak_python_mib=peak / 1024**2, + entries=sum(len(plot.line_data) for _, _, plot in canvas.iter_subplots()), + ) + plt.close(fig) + plt.close(result) + return stats + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--points", type=int, default=100_000) + parser.add_argument("--panels", type=int, default=4) + arguments = parser.parse_args() + if arguments.points < 1 or arguments.panels < 1: + parser.error("points and panels must be positive") + print(json.dumps(benchmark(arguments.points, arguments.panels), indent=2)) diff --git a/src/maxplotlib/backends/matplotlib/import_state.py b/src/maxplotlib/backends/matplotlib/import_state.py new file mode 100644 index 0000000..286dedc --- /dev/null +++ b/src/maxplotlib/backends/matplotlib/import_state.py @@ -0,0 +1,342 @@ +"""Detached Matplotlib state used by the object importer. + +Transforms must be rebound, not frozen in source display coordinates. The +snapshot memo cuts ownership links before copying and replaces them with tokens; +each render binds those tokens to its new figure/axes. No source artists are +reparented, and repeated renders do not share mutable Matplotlib state. +""" + +import copy +from dataclasses import asdict, dataclass, field + +from matplotlib.artist import Artist +from matplotlib.cbook import CallbackRegistry +from matplotlib.collections import Collection +from matplotlib.image import AxesImage +from matplotlib.lines import Line2D +from matplotlib.patches import Patch +from matplotlib.table import Table +from matplotlib.text import Text +from matplotlib.transforms import Bbox, BboxTransformTo + + +@dataclass +class ImportDiagnostic: + artist_id: int + artist_type: str + label: str + severity: str + message: str + fallback: str | None = None + backends: tuple = ("matplotlib",) + + +@dataclass +class ImportReport: + """Import decisions, including losses and backend-specific representations.""" + + diagnostics: list = field(default_factory=list) + + def add( + self, + artist, + message, + *, + severity="info", + fallback=None, + backends=("matplotlib",), + ): + item = ImportDiagnostic( + id(artist), + type(artist).__name__, + str(getattr(artist, "get_label", lambda: "")()), + severity, + message, + fallback, + backends, + ) + self.diagnostics.append(item) + return item + + def to_dict(self): + return asdict(self) + + +def root_figure(figure): + while getattr(figure, "figure", figure) is not figure: + figure = figure.figure + return figure + + +def figure_bounds(ax, figure): + return tuple( + ax.get_position(original=True) + .transformed(ax.figure.transSubfigure) + .transformed(figure.transFigure.inverted()) + .bounds + ) + + +def _bindings(ax, fig): + bindings = { + "figure": fig, + "transFigure": fig.transFigure, + "transSubfigure": fig.transSubfigure, + "dpi_scale_trans": fig.dpi_scale_trans, + "fig_bbox": fig.bbox, + } + if ax is not None: + bindings.update( + axes=ax, + transData=ax.transData, + transAxes=ax.transAxes, + transScale=ax.transScale, + transLimits=ax.transLimits, + ax_bbox=ax.bbox, + xaxis=ax.xaxis, + yaxis=ax.yaxis, + xaxis_transform=ax.get_xaxis_transform(), + yaxis_transform=ax.get_yaxis_transform(), + ) + return bindings + + +class _BindingToken: + """Weak-referenceable marker for copied Transform parent links.""" + + +class ReboundSnapshot: + """Copy a payload without retaining its owning axes, figure or callbacks.""" + + def __init__(self, payload, ax=None, fig=None): + owner = fig if fig is not None else ax.figure + fig = root_figure(owner) + self.owner_bounds = None + self.tokens = {} + memo = {} + bindings = _bindings(ax, fig) + if owner is not fig: + self.owner_bounds = tuple( + owner.bbox.transformed(fig.transFigure.inverted()).bounds + ) + bindings.update(owner=owner, owner_transform=owner.transSubfigure) + for name, value in bindings.items(): + # Some canonical transforms are aliases of one another. + if id(value) not in memo: + self.tokens[name] = memo[id(value)] = _BindingToken() + artists = [] + if isinstance(payload, Artist): + artists = payload.findobj() + for artist in artists: + for name in ("_remove_method", "stale_callback"): + value = getattr(artist, name, None) + if value is not None: + memo[id(value)] = None + callbacks = getattr(artist, "_callbacks", None) + if callbacks is not None: + memo[id(callbacks)] = CallbackRegistry() + self.payload = copy.deepcopy(payload, memo) + + def clone(self, ax=None, fig=None): + fig = fig if fig is not None else ax.figure + bindings = _bindings(ax, fig) + if self.owner_bounds is not None: + bindings.update( + owner=fig, + owner_transform=BboxTransformTo(Bbox.from_bounds(*self.owner_bounds)) + + fig.transFigure, + ) + return copy.deepcopy( + self.payload, + {id(token): bindings[name] for name, token in self.tokens.items()}, + ) + + def draw(self, ax, **overrides): + artist = self.clone(ax) + artist.set(**overrides) + clipbox, clippath = artist.get_clip_box(), artist.get_clip_path() + # add_* establishes the new removal and stale callbacks. + if isinstance(artist, Collection): + ax.add_collection(artist, autolim=False) + elif isinstance(artist, Line2D): + ax.add_line(artist) + elif isinstance(artist, Patch): + ax.add_patch(artist) + elif isinstance(artist, AxesImage): + ax.add_image(artist) + elif isinstance(artist, Table): + ax.add_table(artist) + elif isinstance(artist, Text): + ax._add_text(artist) + else: + ax.add_artist(artist) + artist.set_clip_path(clippath) + artist.set_clip_box(clipbox) + return artist + + +def capture_axis_state(ax): + """Capture decorations which have no backend-neutral equivalent yet.""" + state = {"axes": {}, "spines": {}, "titles": {}} + for name in ("x", "y"): + axis = getattr(ax, name + "axis") + state["axes"][name] = dict( + scale=ReboundSnapshot(axis._scale, ax), + major_locator=ReboundSnapshot(axis.get_major_locator(), ax), + minor_locator=ReboundSnapshot(axis.get_minor_locator(), ax), + major_formatter=ReboundSnapshot(axis.get_major_formatter(), ax), + minor_formatter=ReboundSnapshot(axis.get_minor_formatter(), ax), + units=copy.deepcopy(axis.get_units()), + converter=copy.deepcopy( + axis.get_converter() + if hasattr(axis, "get_converter") + else axis.converter + ), + major_kw=_tick_params(axis, "major"), + minor_kw=_tick_params(axis, "minor"), + ticks={ + which: [ + _tick_style(tick) + for tick in getattr(axis, "get_" + which + "_ticks")() + ] + for which in ("major", "minor") + }, + label_position=axis.get_label_position(), + offset_style=_text_properties(axis.get_offset_text()), + ) + for name, spine in ax.spines.items(): + state["spines"][name] = dict( + visible=spine.get_visible(), + edgecolor=spine.get_edgecolor(), + linewidth=spine.get_linewidth(), + linestyle=spine.get_linestyle(), + bounds=spine.get_bounds(), + ) + if spine.spine_type in ("left", "right", "top", "bottom"): + state["spines"][name]["position"] = copy.deepcopy(spine.get_position()) + for loc, title in ( + ("left", ax._left_title), + ("center", ax.title), + ("right", ax._right_title), + ): + state["titles"][loc] = ( + title.get_text(), + _text_properties(title), + title.get_position(), + ) + state["autotitlepos"] = ax._autotitlepos + state["label_coords"] = { + name: ( + axis.label.get_position(), + ReboundSnapshot(axis.label.get_transform(), ax), + ) + for name, axis in (("x", ax.xaxis), ("y", ax.yaxis)) + if not axis._autolabelpos + } + return state + + +def _text_properties(text): + return dict( + fontproperties=copy.deepcopy(text.get_fontproperties()), + color=text.get_color(), + rotation=text.get_rotation(), + rotation_mode=text.get_rotation_mode(), + horizontalalignment=text.get_ha(), + verticalalignment=text.get_va(), + visible=text.get_visible(), + usetex=text.get_usetex(), + ) + + +def _tick_params(axis, which): + tick = getattr(axis, "get_" + which + "_ticks")(1)[0] + defaults = dict( + length=tick._size, + width=tick._width, + pad=tick._base_pad, + direction=tick._tickdir, + color=tick.tick1line.get_color(), + ) + defaults.update(copy.deepcopy(getattr(axis, "_" + which + "_tick_kw"))) + return defaults + + +def _tick_line_style(line): + return { + name: getattr(line, "get_" + name)() + for name in ( + "color", + "marker", + "markersize", + "markeredgewidth", + "markeredgecolor", + "visible", + "zorder", + ) + } + + +def _tick_style(tick): + return dict( + tick1line=_tick_line_style(tick.tick1line), + tick2line=_tick_line_style(tick.tick2line), + label1=_text_properties(tick.label1), + label2=_text_properties(tick.label2), + gridline=dict( + visible=tick.gridline.get_visible(), + color=tick.gridline.get_color(), + linewidth=tick.gridline.get_linewidth(), + linestyle=tick.gridline.get_linestyle(), + alpha=tick.gridline.get_alpha(), + ), + ) + + +def apply_axis_state(ax, state, *, units=True): + for name, props in state["spines"].items(): + props = copy.deepcopy(props) + bounds = props.pop("bounds") + ax.spines[name].set(**props) + if bounds is not None: + ax.spines[name].set_bounds(*bounds) + for name, settings in state["axes"].items(): + axis = getattr(ax, name + "axis") + # Scales are installed before locators/formatters: changing a scale + # installs defaults and would otherwise erase the imported tick setup. + getattr(ax, "set_" + name + "scale")(settings["scale"].clone(ax)) + if units: + converter = copy.deepcopy(settings["converter"]) + if hasattr(axis, "set_converter"): + if not getattr(axis, "_converter_is_explicit", False): + axis.set_converter(converter) + else: + axis.converter = converter + axis.set_units(copy.deepcopy(settings["units"])) + for kind in ("major", "minor"): + getattr(axis, "set_" + kind + "_locator")( + settings[kind + "_locator"].clone(ax) + ) + getattr(axis, "set_" + kind + "_formatter")( + settings[kind + "_formatter"].clone(ax) + ) + axis.set_tick_params(which=kind, **copy.deepcopy(settings[kind + "_kw"])) + ticks = getattr(axis, "get_" + kind + "_ticks")() + styles = settings["ticks"][kind] + for i, tick in enumerate(ticks): + if styles: + style = styles[min(i, len(styles) - 1)] + for part, props in style.items(): + getattr(tick, part).set(**props) + axis.set_label_position(settings["label_position"]) + axis.get_offset_text().set(**settings["offset_style"]) + for loc, (text, props, position) in ( + state["titles"].items() if hasattr(ax, "set_title") else () + ): + title = ax.set_title(text, loc=loc, **props) + title.set_position(position) + ax._autotitlepos = state["autotitlepos"] + for name, (position, transform) in state["label_coords"].items(): + getattr(ax, name + "axis").set_label_coords( + *position, transform=transform.clone(ax) + ) diff --git a/src/maxplotlib/backends/matplotlib/importer.py b/src/maxplotlib/backends/matplotlib/importer.py new file mode 100644 index 0000000..15036b2 --- /dev/null +++ b/src/maxplotlib/backends/matplotlib/importer.py @@ -0,0 +1,1078 @@ +"""Translate Matplotlib artists into backend-independent canvas entries.""" + +import copy +import os +import pickle +import warnings + +import numpy as np +from matplotlib.artist import Artist +from matplotlib.axes import Axes +from matplotlib.collections import LineCollection, PathCollection, PolyCollection +from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer +from matplotlib.figure import Figure +from matplotlib.legend import Legend +from matplotlib.lines import AxLine +from matplotlib.markers import MarkerStyle +from matplotlib.patches import Polygon, StepPatch +from matplotlib.path import Path +from matplotlib.text import Annotation +from matplotlib.ticker import FixedLocator +from matplotlib.transforms import IdentityTransform + +try: + from matplotlib.inset import InsetIndicator +except ImportError: # Matplotlib < 3.10 returns a plain (Rectangle, patches) tuple. + InsetIndicator = None + +from .import_state import ( + ImportReport, + ReboundSnapshot, + capture_axis_state, + figure_bounds, + root_figure, +) + + +def import_matplotlib( + canvas_cls, + source, + *, + strict=False, + trusted=False, + fallback="native", + **canvas_kwargs, +): + if fallback not in ("native", "skip", "raster"): + raise ValueError("fallback must be 'native', 'skip', or 'raster'") + if isinstance(source, (str, bytes, os.PathLike)) or hasattr(source, "read"): + if not trusted: + raise ValueError( + "Serialized Matplotlib input requires trusted=True; " + "unpickling can execute arbitrary Python code" + ) + if isinstance(source, bytes): + source = pickle.loads(source) + elif hasattr(source, "read"): + source = pickle.load(source) + else: + with open(source, "rb") as stream: + source = pickle.load(stream) + report = ImportReport() + + def unsupported(message, artist=None): + artist = artist if artist is not None else unsupported.artist + report.add( + artist, + message, + severity="error" if strict else "warning", + fallback="skipped", + ) + if strict: + error = NotImplementedError(message) + error.import_report = report + raise error + warnings.warn(message, UserWarning, stacklevel=3) + + unsupported.artist = source + unsupported.report = report + unsupported.fallback = fallback + + whole_figure = isinstance(source, Figure) + explicit_shape = None + if whole_figure: + axes = list(source.axes) + elif isinstance(source, Axes): + axes = [source] + explicit_shape = (1, 1) + else: + try: + array = np.asarray(source, dtype=object) + except (TypeError, ValueError) as exc: + raise TypeError( + "Expected a Figure, Axes, or rectangular array of Axes" + ) from exc + if array.ndim not in (1, 2): + raise TypeError("Expected a Figure, Axes, or 1D/2D array of Axes") + axes = list(array.flat) + if array.ndim == 2: + explicit_shape = array.shape + if not axes: + raise ValueError("At least one Matplotlib Axes is required") + if not all(isinstance(ax, Axes) for ax in axes): + raise TypeError("Every array element must be a Matplotlib Axes") + if len({id(ax) for ax in axes}) != len(axes): + raise ValueError("Duplicate Axes are not supported") + figure = root_figure(source if whole_figure else axes[0].get_figure()) + if any(root_figure(ax.get_figure()) is not figure for ax in axes): + raise ValueError("All Axes must belong to the same Figure") + + if fallback == "raster": + return _raster_import( + canvas_cls, figure, axes, whole_figure, report, **canvas_kwargs + ) + + # Colorbar axes are decorations, not independent data subplots. + selected = [] + twins = [] + colorbars = [] + # The source figure's creation order identifies the primary axes even when + # the caller passes the twin first in an array. + for index, ax in sorted( + enumerate(axes), key=lambda item: figure.axes.index(item[1]) + ): + if getattr(ax, "_colorbar", None) is not None: + colorbars.append(ax._colorbar) + else: + parent = next( + ( + previous + for _, previous in selected + if ax in previous._twinned_axes.get_siblings(previous) + ), + None, + ) + if parent is None: + selected.append((index, ax)) + else: + direction = "x" if ax.get_shared_x_axes().joined(ax, parent) else "y" + twins.append((parent, ax, direction)) + selected.sort(key=lambda item: item[0]) + if not selected: + raise ValueError("No supported Axes to import") + + if explicit_shape is not None: + nrows, ncols = explicit_shape + positioned = [(i // ncols, i % ncols, ax) for i, ax in selected] + else: + specs = [ax.get_subplotspec() for _, ax in selected] + common_grid = specs[0].get_gridspec() if specs[0] is not None else None + simple_grid = common_grid is not None and all( + spec is not None + and spec.get_gridspec() is common_grid + and spec.rowspan.stop - spec.rowspan.start == 1 + and spec.colspan.stop - spec.colspan.start == 1 + for spec in specs + ) + if simple_grid and len({spec.num1 for spec in specs}) == len(specs): + nrows, ncols = common_grid.get_geometry() + positioned = [ + (spec.rowspan.start, spec.colspan.start, ax) + for spec, (_, ax) in zip(specs, selected) + ] + else: + # Irregular layouts use stable editable slots and retain their + # actual axes rectangles for the Matplotlib renderer. + nrows, ncols = 1, len(selected) + positioned = [(0, i, ax) for i, (_, ax) in enumerate(selected)] + + if "nrows" in canvas_kwargs or "ncols" in canvas_kwargs: + raise TypeError("nrows and ncols are inferred from the imported axes") + canvas_kwargs.setdefault("figsize", tuple(figure.get_size_inches())) + canvas_kwargs.setdefault("dpi", figure.dpi) + canvas = canvas_cls(nrows=nrows, ncols=ncols, **canvas_kwargs) + canvas.import_report = report + canvas._import_layout = {} + canvas._import_in_layout = {} + canvas._import_shared_axes = [] + canvas._import_extra_twins = [] + canvas._import_colorbars = [] + canvas._import_figure_artists = [] + source_targets = {} + source_slots = {} + for row, col, ax in positioned: + target = canvas.add_subplot(row=row, col=col) + source_targets[ax] = target + source_slots[ax] = (row, col) + if explicit_shape is None: + canvas._import_layout[(row, col)] = figure_bounds(ax, figure) + canvas._import_in_layout[(row, col)] = ax.get_in_layout() + _import_axes(ax, target, unsupported) + for parent, twin, direction in twins: + if parent is ax: + factory = canvas.twinx if direction == "x" else canvas.twiny + registry = ( + canvas._twinx_subplots + if direction == "x" + else canvas._twiny_subplots + ) + if (row, col) in registry: + from maxplotlib.subfigure.line_plot import LinePlot + + twin_target = LinePlot() + canvas._import_extra_twins.append( + ((row, col), direction, twin_target) + ) + else: + twin_target = factory(row=row, col=col) + source_targets[twin] = twin_target + _import_axes(twin, twin_target, unsupported) + if explicit_shape is None and all(ax.figure is figure for _, ax in selected): + specs = {source_slots[ax]: ax.get_subplotspec() for _, ax in selected} + canvas._import_layout_specs = ReboundSnapshot(specs, fig=figure) + canvas._import_layout_engine = copy.deepcopy(figure.get_layout_engine()) + if canvas._import_layout_engine is None: + canvas.subplots_adjust( + **{ + name: getattr(figure.subplotpars, name) + for name in ("left", "right", "bottom", "top", "wspace", "hspace") + } + ) + for name in ("x", "y"): + for index, (_, ax) in enumerate(selected): + parent = next( + ( + previous + for _, previous in selected[:index] + if getattr(ax, "get_shared_" + name + "_axes")().joined( + ax, previous + ) + ), + None, + ) + if parent is not None: + canvas._import_shared_axes.append( + (name, source_slots[parent], source_slots[ax]) + ) + for colorbar in colorbars: + owner = getattr(colorbar.mappable, "axes", None) + if owner is not None and owner not in source_targets: + unsupported("Colorbar mappable is outside the selected axes", colorbar.ax) + continue + canvas._import_colorbars.append( + _capture_colorbar(colorbar, source_targets.get(owner)) + ) + report.add(colorbar.ax, "Colorbar is linked to its imported mappable") + if whole_figure: + canvas._import_figure_patch = ReboundSnapshot(figure.patch, fig=figure) + canvas._import_figure_style = dict( + facecolor=figure.get_facecolor(), + edgecolor=figure.get_edgecolor(), + linewidth=figure.patch.get_linewidth(), + frameon=figure.get_frameon(), + ) + for getter, setter in ( + ("get_suptitle", "suptitle"), + ("get_supxlabel", "supxlabel"), + ("get_supylabel", "supylabel"), + ): + value = getattr(figure, getter, lambda: "")() + if value: + text = getattr(figure, "_" + setter) + getattr(canvas, setter)( + value, + x=text.get_position()[0], + y=text.get_position()[1], + **_text_style(text), + ) + titles = {figure._suptitle, figure._supxlabel, figure._supylabel} + for artist in ( + list(figure.legends) + + list(figure.artists) + + list(figure.lines) + + list(figure.patches) + + list(figure.images) + + [text for text in figure.texts if text not in titles] + ): + canvas._import_figure_artists.append(ReboundSnapshot(artist, fig=figure)) + report.add( + artist, "Figure decoration retained for Matplotlib", fallback="native" + ) + + def subfigure_decorations(owner): + for subfigure in owner.subfigs: + background = ReboundSnapshot(subfigure.patch, fig=subfigure) + background.payload.set_zorder( + min((ax.get_zorder() for ax in figure.axes), default=0) - 1 + ) + canvas._import_figure_artists.append(background) + for artist in ( + list(subfigure.texts) + + list(subfigure.legends) + + list(subfigure.artists) + + list(subfigure.lines) + + list(subfigure.patches) + + list(subfigure.images) + ): + canvas._import_figure_artists.append( + ReboundSnapshot(artist, fig=subfigure) + ) + subfigure_decorations(subfigure) + + subfigure_decorations(figure) + return canvas + + +def _style(artist): + return dict( + label=artist.get_label(), + alpha=copy.deepcopy(artist.get_alpha()), + zorder=artist.get_zorder(), + visible=artist.get_visible(), + gid=artist.get_gid(), + url=artist.get_url(), + rasterized=artist.get_rasterized(), + clip_on=artist.get_clip_on(), + snap=artist.get_snap(), + in_layout=artist.get_in_layout(), + ) + + +def _scatter_marker(path): + # Keep standard marker names portable to the other renderers. + for marker in ( + "o", + "s", + "^", + "v", + "<", + ">", + "D", + "d", + "*", + "+", + "x", + "p", + "h", + "H", + ".", + "P", + "X", + ): + style = MarkerStyle(marker) + candidate = style.get_path().transformed(style.get_transform()) + if np.array_equal(path.vertices, candidate.vertices) and np.array_equal( + path.codes, candidate.codes + ): + return marker + return copy.deepcopy(path) + + +def _line_style(line): + return dict( + color=line.get_color(), + linestyle=( + copy.deepcopy(line._unscaled_dash_pattern) + if line.is_dashed() + else line.get_linestyle() + ), + linewidth=line.get_linewidth(), + marker=None if line.get_marker() in ("None", "", " ") else line.get_marker(), + markersize=line.get_markersize(), + markerfacecolor=line.get_markerfacecolor(), + markeredgecolor=line.get_markeredgecolor(), + markeredgewidth=line.get_markeredgewidth(), + drawstyle=line.get_drawstyle(), + dash_capstyle=line.get_dash_capstyle(), + dash_joinstyle=line.get_dash_joinstyle(), + solid_capstyle=line.get_solid_capstyle(), + solid_joinstyle=line.get_solid_joinstyle(), + markevery=copy.deepcopy(line.get_markevery()), + gapcolor=line.get_gapcolor(), + antialiased=line.get_antialiased(), + markerfacecoloralt=line.get_markerfacecoloralt(), + fillstyle=line.get_fillstyle(), + **_style(line), + ) + + +def _text_style(text): + kwargs = dict( + color=text.get_color(), + fontsize=text.get_fontsize(), + fontweight=text.get_fontweight(), + fontstyle=text.get_fontstyle(), + fontfamily=list(text.get_fontfamily()), + rotation=text.get_rotation(), + ha=text.get_ha(), + va=text.get_va(), + usetex=text.get_usetex(), + rotation_mode=text.get_rotation_mode(), + linespacing=text._linespacing, + fontstretch=text.get_fontproperties().get_stretch(), + fontvariant=text.get_fontproperties().get_variant(), + parse_math=text.get_parse_math(), + wrap=text.get_wrap(), + ) + + box = text.get_bbox_patch() + if box is not None: + kwargs["bbox"] = dict( + boxstyle=copy.deepcopy(box.get_boxstyle()), + facecolor=box.get_facecolor(), + edgecolor=box.get_edgecolor(), + linewidth=box.get_linewidth(), + linestyle=box.get_linestyle(), + alpha=box.get_alpha(), + hatch=box.get_hatch(), + ) + return kwargs + + +def _import_errorbar(container, ax, target, unsupported): + """Return whether the container was consumed as one semantic entry.""" + line, caps, ranges = container.lines + # Without a data line the original center of asymmetric errors is lost. + # Leave these artists for the geometry import instead of inventing centers. + if line is None: + return False + if line.get_transform() != ax.transData or any( + bars.get_transform() != ax.transData for bars in ranges + ): + return False + components = [line, *caps, *ranges] + if any( + _needs_native_style(artist) or artist.get_visible() != line.get_visible() + for artist in components + ): + return False + if any(cap.get_marker() not in ("_", "|") for cap in caps): + return False + if caps and any( + any( + getattr(cap, "get_" + prop)() != getattr(caps[0], "get_" + prop)() + for prop in ("color", "markersize", "markeredgewidth", "alpha", "visible") + ) + for cap in caps[1:] + ): + return False + x, y = (np.array(v, copy=True) for v in line.get_data(orig=False)) + errors = {} + for axis, bars in zip( + [i for i, flag in enumerate((container.has_xerr, container.has_yerr)) if flag], + ranges, + ): + segments = bars.get_segments() + if len(segments) != len(x): + return False + bounds = np.full((len(x), 2), np.nan) + for i, segment in enumerate(segments): + if len(segment) == 2: + bounds[i] = segment[:, axis] + centers = x if axis == 0 else y + error = np.array([centers - bounds[:, 0], bounds[:, 1] - centers]) + if np.any(error < -1e-12): + unsupported( + "Error-bar centers are outside their ranges; importing geometry" + ) + return False + errors["xerr" if axis == 0 else "yerr"] = np.maximum(error, 0) + kwargs = _line_style(line) + kwargs["label"] = container.get_label() + if any( + not np.array_equal(bars.get_colors(), ranges[0].get_colors()) + or not np.array_equal(bars.get_linewidths(), ranges[0].get_linewidths()) + for bars in ranges[1:] + ): + return False + if ranges: + base_zorder = ranges[0].get_zorder() + delta = line.get_zorder() - base_zorder + if not np.isclose(abs(delta), 0.1) or any( + bar.get_zorder() != base_zorder for bar in ranges + ): + return False + kwargs["zorder"] = base_zorder + kwargs["barsabove"] = delta < 0 + colors = ranges[0].get_colors() + widths = ranges[0].get_linewidths() + if len(colors): + kwargs["ecolor"] = tuple(colors[0]) + if len(widths): + kwargs["elinewidth"] = float(widths[0]) + kwargs["capsize"] = caps[0].get_markersize() / 2 if caps else 0 + if caps: + kwargs["capthick"] = caps[0].get_markeredgewidth() + target.errorbar(x, y, **errors, **kwargs) + return True + + +def _collection_style(collection, index): + kwargs = _style(collection) + if index: + kwargs["label"] = "" + for key, values in ( + ("facecolor", collection.get_facecolors()), + ("edgecolor", collection.get_edgecolors()), + ("linewidth", collection.get_linewidths()), + ( + "linestyle", + getattr(collection, "_us_linestyles", collection.get_linestyles()), + ), + ("antialiased", collection._antialiaseds), + ): + kwargs[key] = ( + copy.deepcopy(values[index % len(values)]) if len(values) else "none" + ) + offset, dashes = kwargs["linestyle"] + kwargs["linestyle"] = (offset, tuple(dashes) if dashes is not None else None) + return kwargs + + +def _import_collection(collection, ax, target, unsupported): + """Import plain line/polygon collections; return False for scatter.""" + if not isinstance(collection, (LineCollection, PolyCollection)): + return False + if ( + collection.get_transform() != ax.transData + or np.any(collection.get_offsets()) + or collection.get_array() is not None + or np.size(collection.get_transforms()) != 0 + or np.ndim(collection.get_alpha()) != 0 + ): + _native(collection, ax, target, unsupported) + return True + if isinstance(collection, LineCollection): + for i, segment in enumerate(collection.get_segments()): + if not len(segment): + continue + kwargs = _collection_style(collection, i) + kwargs.pop("facecolor") + kwargs["color"] = kwargs.pop("edgecolor") + target.plot(segment[:, 0].copy(), segment[:, 1].copy(), **kwargs) + else: + for i, path in enumerate(collection.get_paths()): + if path.codes is not None and ( + np.count_nonzero(path.codes == Path.MOVETO) > 1 + or np.any( + ~np.isin(path.codes, [Path.MOVETO, Path.LINETO, Path.CLOSEPOLY]) + ) + ): + _native(collection, ax, target, unsupported) + return True + vertices = path.vertices + if path.codes is not None and path.codes[-1] == Path.CLOSEPOLY: + vertices = vertices[:-1] + target.fill( + vertices[:, 0].copy(), + vertices[:, 1].copy(), + hatch=collection.get_hatch(), + **_collection_style(collection, i), + ) + return True + + +def _import_axes(ax, target, unsupported): + if ax.name != "rectilinear" or type(ax).__module__.startswith("mpl_toolkits."): + target._import_projection = ReboundSnapshot(ax, fig=ax.figure) + target._import_projection_artist_ids = { + name: [id(artist) for artist in getattr(ax, name)] + for name in ( + "lines", + "collections", + "images", + "patches", + "texts", + "artists", + ) + } + unsupported.report.add( + ax, + "Projection, camera and native artists retained for " + "Matplotlib; edit through the rendered axes", + fallback="native", + ) + return + consumed = set() + target.import_groups = { + id(container): dict( + type=type(container).__name__, + label=container.get_label(), + artist_ids=[id(artist) for artist in container.get_children()], + orientation=getattr(container, "orientation", None), + datavalues=copy.deepcopy(getattr(container, "datavalues", None)), + ) + for container in ax.containers + } + # A bar container retains grouping and orientation, unlike raw rectangles. + for container in _tracked(ax.containers, ax, target, unsupported): + if isinstance(container, ErrorbarContainer): + if _import_errorbar(container, ax, target, unsupported): + consumed.update(container.get_children()) + continue + if isinstance(container, StemContainer): + unsupported.report.add( + container, "Stem components retained as geometry", fallback="geometry" + ) + continue + if not isinstance(container, BarContainer): + unsupported(f"Unsupported {type(container).__name__}; container skipped") + consumed.update(container.get_children()) + continue + for i, patch in enumerate(container.patches): + consumed.add(patch) + if _needs_native_style(patch): + _native(patch, ax, target, unsupported) + target.line_data[-1]["source_order"] = ax.get_children().index(patch) + continue + kwargs = _style(patch) + kwargs.update( + color=patch.get_facecolor(), + edgecolor=patch.get_edgecolor(), + linewidth=patch.get_linewidth(), + hatch=patch.get_hatch(), + linestyle=patch.get_linestyle(), + antialiased=patch.get_antialiased(), + fill=patch.get_fill(), + label=container.get_label() if i == 0 else "", + ) + if container.orientation == "horizontal": + target.barh( + [patch.get_y() + patch.get_height() / 2], + [patch.get_width()], + left=patch.get_x(), + height=patch.get_height(), + **kwargs, + ) + else: + target.bar( + [patch.get_x() + patch.get_width() / 2], + [patch.get_height()], + bottom=patch.get_y(), + width=patch.get_width(), + **kwargs, + ) + + target.line_data[-1]["source_order"] = ax.get_children().index(patch) + + for line in _tracked(ax.lines, ax, target, unsupported): + if line in consumed: + continue + if ( + isinstance(line, AxLine) + or _needs_native_style(line) + or getattr(line._marker, "_user_transform", None) is not None + ): + _native(line, ax, target, unsupported) + continue + x, y = line.get_data(orig=False) + if ( + line.get_transform() == ax.get_yaxis_transform() + and len(y) == 2 + and y[0] == y[1] + ): + target.axhline(y[0], xmin=x[0], xmax=x[1], **_line_style(line)) + continue + if ( + line.get_transform() == ax.get_xaxis_transform() + and len(x) == 2 + and x[0] == x[1] + ): + target.axvline(x[0], ymin=y[0], ymax=y[1], **_line_style(line)) + continue + if line.get_transform() != ax.transData: + _native(line, ax, target, unsupported) + continue + target.plot( + np.array(x, copy=True), + np.array(y, copy=True), + **_line_style(line), + ) + for collection in _tracked(ax.collections, ax, target, unsupported): + if collection in consumed: + continue + if _needs_native_style(collection): + _native(collection, ax, target, unsupported) + continue + if _import_collection(collection, ax, target, unsupported): + continue + if not isinstance(collection, PathCollection): + _native(collection, ax, target, unsupported) + continue + paths = collection.get_paths() + if ( + len(paths) != 1 + or collection.get_offset_transform() != ax.transData + or not isinstance(collection.get_transform(), IdentityTransform) + ): + _native(collection, ax, target, unsupported) + continue + offsets = np.ma.asarray(collection.get_offsets()).filled(np.nan) + kwargs = _style(collection) + kwargs.update( + s=collection.get_sizes().copy(), + marker=_scatter_marker(paths[0]), + edgecolors=collection.get_edgecolors().copy(), + linewidths=collection.get_linewidths().copy(), + ) + values = collection.get_array() + if values is not None: + kwargs.update( + c=values.copy(), + cmap=copy.copy(collection.get_cmap()), + norm=copy.deepcopy(collection.norm), + ) + else: + colors = collection.get_facecolors() + if len(colors) == 1: + kwargs["color"] = tuple(colors[0]) + elif len(colors): + kwargs["c"] = colors.copy() + else: + kwargs["facecolors"] = "none" + target.scatter(offsets[:, 0].copy(), offsets[:, 1].copy(), **kwargs) + for image in _tracked(ax.images, ax, target, unsupported): + if image.get_transform() != ax.transData or _needs_native_style(image): + _native(image, ax, target, unsupported) + continue + target.add_imshow( + image.get_array().copy(), + extent=tuple(image.get_extent()), + origin=image.origin, + cmap=copy.copy(image.get_cmap()), + norm=copy.deepcopy(image.norm), + interpolation=image.get_interpolation(), + interpolation_stage=getattr(image, "_interpolation_stage", "data"), + filternorm=image.get_filternorm(), + filterrad=image.get_filterrad(), + resample=image.get_resample(), + **_style(image), + ) + for text in _tracked(ax.texts, ax, target, unsupported): + if ( + text.get_bbox_patch() is not None + or _needs_native_style(text) + or text.get_wrap() + ): + _native(text, ax, target, unsupported) + continue + if isinstance(text, Annotation): + if text.xycoords != "data" or text.anncoords != "data": + _native(text, ax, target, unsupported) + continue + arrowprops = text.arrowprops + if arrowprops and any(key in arrowprops for key in ("patchA", "patchB")): + _native(text, ax, target, unsupported) + continue + target.annotate( + text.get_text(), + xy=tuple(text.xy), + xytext=tuple(text.get_position()), + arrowprops=copy.deepcopy(arrowprops), + annotation_clip=text.get_annotation_clip(), + **_text_style(text), + **_style(text), + ) + continue + if text.get_transform() != ax.transData: + _native(text, ax, target, unsupported) + continue + target.text( + *text.get_position(), + text.get_text(), + **_text_style(text), + **_style(text), + ) + for patch in _tracked(ax.patches, ax, target, unsupported): + if patch in consumed: + continue + if patch.get_data_transform() != ax.transData or _needs_native_style(patch): + _native(patch, ax, target, unsupported) + consumed.add(patch) + continue + kwargs = dict( + facecolor=patch.get_facecolor(), + edgecolor=patch.get_edgecolor(), + linewidth=patch.get_linewidth(), + linestyle=patch.get_linestyle(), + hatch=patch.get_hatch(), + fill=patch.get_fill(), + **_style(patch), + ) + if isinstance(patch, StepPatch): + data = patch.get_data() + target.stairs( + data.values.copy(), + data.edges.copy(), + baseline=copy.deepcopy(data.baseline), + orientation=patch.orientation, + **kwargs, + ) + consumed.add(patch) + elif isinstance(patch, Polygon): + xy = patch.get_xy() + target.fill( + xy[:, 0].copy(), xy[:, 1].copy(), closed=patch.get_closed(), **kwargs + ) + consumed.add(patch) + else: + _native(patch, ax, target, unsupported) + target._import_inset_indicators = {} + for artist in _tracked(list(ax.artists) + list(ax.tables), ax, target, unsupported): + if isinstance(artist, Legend): + continue + if ( + InsetIndicator is not None + and isinstance(artist, InsetIndicator) + and artist._inset_ax in ax.child_axes + ): + index = ax.child_axes.index(artist._inset_ax) + target._import_inset_indicators[index] = _capture_inset_indicator(artist) + unsupported.report.add( + artist, + "Inset indicator rebuilt from the live inset axes", + fallback="native", + ) + continue + _native(artist, ax, target, unsupported) + target._import_child_axes = [] + for child in ax.child_axes: + _import_child(child, ax, target, unsupported) + + title_kwargs = _text_style(ax.title) + title_kwargs["x"] = ax.title.get_position()[0] + title_kwargs["pad"] = ax.titleOffsetTrans.transform((0, 0))[1] * 72 / ax.figure.dpi + if not ax._autotitlepos: + title_kwargs["y"] = ax.title.get_position()[1] + target.set_title(ax.get_title(), **title_kwargs) + target.set_xlabel( + ax.get_xlabel(), labelpad=ax.xaxis.labelpad, **_text_style(ax.xaxis.label) + ) + target.set_ylabel( + ax.get_ylabel(), labelpad=ax.yaxis.labelpad, **_text_style(ax.yaxis.label) + ) + target.set_xlim(*ax.get_xlim()) + target.set_ylim(*ax.get_ylim()) + for name in ("x", "y"): + scale = getattr(ax, f"get_{name}scale")() + getattr(target, f"set_{name}scale")(scale) + axis = getattr(ax, f"{name}axis") + if isinstance(axis.get_major_locator(), FixedLocator): + getattr(target, f"set_{name}ticks")( + axis.get_majorticklocs().copy(), + labels=axis.get_major_formatter().format_ticks( + axis.get_majorticklocs() + ), + ) + target.set_grid( + any(line.get_visible() for line in ax.get_xgridlines() + ax.get_ygridlines()) + ) + target.set_legend(ax.get_legend() is not None and ax.get_legend().get_visible()) + target.set_facecolor(ax.get_facecolor()) + target.set_axisbelow(ax.get_axisbelow()) + target.set_aspect(ax.get_aspect()) + target.set_visible(ax.get_visible()) + if not ax.axison: + target.set_axis_off() + for name in ( + "adjustable", + "anchor", + "box_aspect", + "frame_on", + "alpha", + "zorder", + "rasterized", + "autoscalex_on", + "autoscaley_on", + ): + getattr(target, "set_" + name)(getattr(ax, "get_" + name)()) + target.set_xmargin(ax.margins()[0]) + target.set_ymargin(ax.margins()[1]) + unsupported.report.add( + ax, + "Axis scales, tick and spine styles, and legend layout " + "retained for Matplotlib", + fallback="native", + ) + target._import_grid = target._grid + target._import_scales = (target._xaxis_scale, target._yaxis_scale) + target._import_axis_state = capture_axis_state(ax) + target._import_legends = [ + ReboundSnapshot(legend, ax) + for legend in ax.get_children() + if isinstance(legend, Legend) + ] + target.line_data.sort(key=lambda entry: entry.get("source_order", 0)) + for entries in target.layered_line_data.values(): + entries.sort(key=lambda entry: entry.get("source_order", 0)) + + +def _needs_native_style(artist): + box = artist.get_clip_box() + custom_box = ( + box is not None + and artist.axes is not None + and not np.array_equal(box.bounds, artist.axes.bbox.bounds) + ) + return ( + custom_box + or artist.get_path_effects() + or artist.get_agg_filter() is not None + or artist.get_sketch_params() is not None + or artist.get_clip_path() is not None + ) + + +def _native(artist, ax, target, unsupported): + if unsupported.fallback == "skip" or not type(artist).__module__.startswith( + ("matplotlib.", "mpl_toolkits.") + ): + unsupported(f"Unsupported {type(artist).__name__}; artist skipped", artist) + return + snapshot = ReboundSnapshot(artist, ax) + target._add( + dict(plot_type="matplotlib_artist", snapshot=snapshot, layer=0, kwargs={}), 0 + ) + unsupported.report.add( + artist, + "Editable artist geometry retained for Matplotlib; " + "other backends require a raster import", + fallback="native", + ) + + +def _tracked(artists, ax, target, unsupported): + order = {id(artist): i for i, artist in enumerate(ax.get_children())} + for artist in artists: + unsupported.artist = artist + start = len(target.line_data) + yield artist + entries = target.line_data[start:] + children = getattr(artist, "get_children", lambda: [])() + index = order.get( + id(artist), + min( + (order.get(id(child), len(order)) for child in children), + default=len(order), + ), + ) + for entry in entries: + entry["source_artist_id"] = id(artist) + entry["source_container_ids"] = [ + identifier + for identifier, group in target.import_groups.items() + if identifier == id(artist) or id(artist) in group["artist_ids"] + ] + entry.setdefault("source_order", index) + if isinstance(artist, Artist): + entry["source_sticky_edges"] = ( + list(artist.sticky_edges.x), + list(artist.sticky_edges.y), + ) + entry["source_picker"] = artist.get_picker() + if entries: + unsupported.report.add( + artist, + "Imported " + str(len(entries)) + " plot entries", + fallback="geometry" if len(entries) > 1 else None, + ) + + +def _capture_colorbar(colorbar, target): + axis = ( + colorbar.ax.yaxis if colorbar.orientation == "vertical" else colorbar.ax.xaxis + ) + return dict( + target=target, + artist_id=id(colorbar.mappable), + standalone=( + dict( + norm=copy.deepcopy(colorbar.mappable.norm), + cmap=copy.copy(colorbar.mappable.get_cmap()), + ) + if target is None + else None + ), + position=tuple(colorbar.ax.get_position().bounds), + kwargs=dict( + orientation=colorbar.orientation, + extend=colorbar.extend, + extendfrac=colorbar.extendfrac, + extendrect=colorbar.extendrect, + spacing=colorbar.spacing, + drawedges=colorbar.drawedges, + boundaries=copy.deepcopy(colorbar.boundaries), + values=copy.deepcopy(colorbar.values), + ), + label=axis.label.get_text(), + label_style=_text_style(axis.label), + ticks=copy.deepcopy(colorbar.get_ticks()), + formatter=ReboundSnapshot(colorbar.formatter, colorbar.ax), + state=capture_axis_state(colorbar.ax), + ) + + +def _capture_inset_indicator(indicator): + """Style for ``indicate_inset_zoom``; connectors are recomputed on render. + + ``InsetIndicator`` spans two Axes and recomputes its rectangle/connector + geometry from live axes limits on every draw, so it cannot be captured as + detached geometry the way single-axes artists are. Recreating it with + ``Axes.indicate_inset_zoom`` on the reconstructed parent/inset pair keeps + that live behavior instead of freezing a stale snapshot. + """ + rectangle = indicator.rectangle + return dict( + facecolor=copy.deepcopy(rectangle.get_facecolor()), + edgecolor=copy.deepcopy(rectangle.get_edgecolor()), + linewidth=rectangle.get_linewidth(), + linestyle=rectangle.get_linestyle(), + alpha=indicator.get_alpha(), + zorder=indicator.get_zorder(), + visible=indicator.get_visible(), + ) + + +def _import_child(child, ax, target, unsupported): + from matplotlib.axes._secondary_axes import SecondaryAxis + + from maxplotlib.subfigure.line_plot import LinePlot + + if isinstance(child, SecondaryAxis): + target._import_child_axes.append( + dict( + kind="secondary", + orientation=child._orientation, + location=child._loc, + functions=child._functions, + locator=ReboundSnapshot(child.get_axes_locator(), ax), + state=capture_axis_state(child), + xlabel=child.get_xlabel(), + ylabel=child.get_ylabel(), + ) + ) + else: + subplot = LinePlot() + _import_axes(child, subplot, unsupported) + bounds = ax.transAxes.inverted().transform_bbox(child.bbox).bounds + target._import_child_axes.append( + dict( + kind="inset", + bounds=tuple(bounds), + subplot=subplot, + locator=ReboundSnapshot(child.get_axes_locator(), ax), + ) + ) + unsupported.report.add( + child, "Child axes retained for Matplotlib", fallback="native" + ) + + +def _raster_import(canvas_cls, figure, axes, whole_figure, report, **kwargs): + from matplotlib.backends.backend_agg import FigureCanvasAgg + + snapshot = copy.deepcopy(figure) + if not whole_figure: + indices = [figure.axes.index(ax) for ax in axes] + for i, ax in enumerate(list(snapshot.axes)): + if i not in indices: + ax.remove() + FigureCanvasAgg(snapshot).draw() + rgba = np.asarray(snapshot.canvas.buffer_rgba()).copy() + kwargs.setdefault("figsize", tuple(figure.get_size_inches())) + kwargs.setdefault("dpi", figure.dpi) + canvas = canvas_cls(**kwargs) + subplot = canvas.add_subplot(row=0, col=0) + subplot.add_imshow(rgba) + subplot.set_axis_off() + subplot.set_grid(False) + canvas.import_report = report + report.add( + figure, + "Raster snapshot: data, artist editing, vector paths and interactive " + "state are lost", + severity="warning", + fallback="raster", + backends=("matplotlib", "plotly"), + ) + return canvas diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index ad8abbc..43a882c 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -393,6 +393,40 @@ def __init__( # Factory # ------------------------------------------------------------------ + @classmethod + def from_matplotlib( + cls, source, *, strict=False, trusted=False, fallback="native", **canvas_kwargs + ): + """Snapshot a Matplotlib figure, axes, or rectangular axes array. + + Portable geometry becomes independent plot entries. With the default + ``fallback="native"``, other built-in artists retain detached native + geometry for Matplotlib, including transforms and decorations. These + entries reject rendering on backends that cannot represent them. + ``fallback="skip"`` instead warns about unsupported artists; + ``fallback="raster"`` explicitly flattens the selection into an image. + + ``strict=True`` raises on import losses; it does not promise identical + output on every backend. ``canvas.import_report`` contains artist + identities, severities, representations and backend restrictions. + + Paths, pickle bytes and binary streams require ``trusted=True`` before + reading: unpickling can execute arbitrary code. Serialized figures are + only suitable for trusted producers and matching Matplotlib versions. + ``canvas_kwargs`` override figure defaults. Two-dimensional input + arrays define their own slot order; other inputs preserve source layout. + """ + from maxplotlib.backends.matplotlib.importer import import_matplotlib + + return import_matplotlib( + cls, + source, + strict=strict, + trusted=trusted, + fallback=fallback, + **canvas_kwargs, + ) + @classmethod def subplots( cls, @@ -2302,14 +2336,50 @@ def plot_matplotlib( if verbose: print(f"Created Matplotlib figure and axes with shape {axes.shape}") + if hasattr(self, "_import_layout"): + for row in range(self.nrows): + for col in range(self.ncols): + if (row, col) not in self._subplot_dict: + axes[row, col].remove() + axes[row, col] = None + for slot, bounds in self._import_layout.items(): + axes[slot].set_position(bounds) + if hasattr(self, "_import_layout_specs"): + for slot, spec in self._import_layout_specs.clone(fig=fig).items(): + if spec is not None: + axes[slot].set_subplotspec(spec) + else: + bounds = axes[slot].get_position(original=True).bounds + axes[slot].remove() + axes[slot] = fig.add_axes(bounds) + if self._import_layout_engine is not None: + import copy + + fig.set_layout_engine(copy.deepcopy(self._import_layout_engine)) + for slot, in_layout in self._import_in_layout.items(): + axes[slot].set_in_layout(in_layout) + for direction, parent, child in self._import_shared_axes: + getattr(axes[child], "share" + direction)(axes[parent]) + if hasattr(self, "_import_figure_style"): + fig.set(**self._import_figure_style) + fig.patch = self._import_figure_patch.clone(fig=fig) + transform = fig.patch.get_transform() + fig._set_artist_props(fig.patch) + fig.patch.set_transform(transform) for (row, col), subplot in self._subplot_dict.items(): ax = axes[row][col] + if hasattr(subplot, "_import_projection"): + position = ax.get_position().bounds + ax.remove() + ax = subplot._import_projection.clone(fig=fig) + fig.add_axes(ax) + ax.set_position(position) + axes[row, col] = ax if isinstance(subplot, TikzFigure): plot_matplotlib(subplot, ax, layers=layers) + ax.grid(False) else: subplot.plot_matplotlib(ax, layers=layers) - # ax.set_title(f"Subplot ({row}, {col})") - ax.grid() if verbose: print("Finished plotting subplots.") @@ -2355,6 +2425,43 @@ def plot_matplotlib( twin_axis = axes[row][col].twiny() twin_subplot.plot_matplotlib(twin_axis, layers=layers) self._matplotlib_twiny_axes[(row, col)] = twin_axis + if hasattr(self, "_import_layout"): + from maxplotlib.backends.matplotlib.import_state import apply_axis_state + + for slot, direction, subplot in self._import_extra_twins: + twin_axis = getattr(axes[slot], "twin" + direction)() + subplot.plot_matplotlib(twin_axis, layers=layers) + for specification in self._import_colorbars: + if specification["standalone"] is not None: + import copy + + from matplotlib.cm import ScalarMappable + + mappable = ScalarMappable( + **copy.deepcopy(specification["standalone"]) + ) + else: + artists = specification["target"]._import_rendered_artists.get( + specification["artist_id"], [] + ) + mappable = next( + (artist for artist in artists if hasattr(artist, "get_cmap")), + None, + ) + if mappable is None: + continue # Its layer was excluded from this render. + cax = fig.add_axes(specification["position"]) + colorbar = fig.colorbar(mappable, cax=cax, **specification["kwargs"]) + colorbar.set_label( + specification["label"], **specification["label_style"] + ) + colorbar.set_ticks(specification["ticks"]) + colorbar.formatter = specification["formatter"].clone(cax) + colorbar.update_ticks() + apply_axis_state(cax, specification["state"]) + for snapshot in self._import_figure_artists: + artist = snapshot.clone(fig=fig) + fig.add_artist(artist) if matplotlib_customizations is not None: _apply_matplotlib_customizations(fig, axes, matplotlib_customizations) if matplotlib_postprocess is not None: @@ -2363,6 +2470,39 @@ def plot_matplotlib( matplotlib_postprocess(fig, axes) return fig, axes + def _validate_import_backend(self, backend, *, allow_unsupported=False): + """Never silently drop native imported axes or figure decorations.""" + if not hasattr(self, "import_report") or allow_unsupported: + return + reasons = [] + if getattr(self, "_import_extra_twins", []): + reasons.append("multiple twin axes") + if getattr(self, "_import_figure_artists", []): + reasons.append("native figure decorations") + if getattr(self, "_import_colorbars", []): + reasons.append("native colorbars") + subplots = ( + list(self._subplot_dict.values()) + + list(self._twinx_subplots.values()) + + list(self._twiny_subplots.values()) + ) + for subplot in subplots: + if hasattr(subplot, "_import_projection"): + reasons.append("native projections") + if getattr(subplot, "_import_child_axes", []): + reasons.append("inset/secondary axes") + for state in ( + getattr(subplot, "_import_axis_state", {}).get("axes", {}).values() + ): + scale = state["scale"].payload + if scale.name not in ("linear", "log"): + reasons.append("native axis scales") + if reasons: + raise NotImplementedError( + f"{backend} cannot render these imported features: {', '.join(sorted(set(reasons)))}. " + "Use Matplotlib or import with fallback='raster'." + ) + def plot_tikzfigure( self, savefig: bool = False, @@ -2380,6 +2520,7 @@ def plot_tikzfigure( Returns: TikzFigure: Figure object that can be shown, saved, or compiled. """ + self._validate_import_backend("tikzfigure") if verbose: print(f"Plotting tikzfigure with {len(self._subplot_dict)} subplot(s)") @@ -2699,6 +2840,7 @@ def plot_plotext( layers: list | None = None, verbose: bool = False, ) -> PlotextFigure: + self._validate_import_backend("plotext") if self._twinx_subplots: raise NotImplementedError( "twinx plots are not supported by the plotext backend" @@ -2746,6 +2888,7 @@ def plot_plotly( """ + self._validate_import_backend("plotly", allow_unsupported=allow_unsupported) resolved_usetex = self._usetex if usetex is None else usetex for subplot in self._subplot_dict.values(): diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index ce157a0..346db44 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -900,6 +900,8 @@ def set_ylim(self, bottom=None, top=None): def set_grid(self, visible: bool = True): """Show or hide the grid.""" self._grid = visible + if hasattr(self, "_import_grid"): + self._import_grid_edited = True def tick_params(self, **kwargs): """Configure tick appearance using Matplotlib-style keyword arguments.""" @@ -913,10 +915,14 @@ def set_legend(self, visible: bool = True, **kwargs): def set_xscale(self, scale: str): """Set the x-axis scale type: 'linear', 'log', or 'symlog'.""" self._xaxis_scale = scale + if hasattr(self, "_import_scales"): + self._import_xscale_edited = True def set_yscale(self, scale: str): """Set the y-axis scale type: 'linear', 'log', or 'symlog'.""" self._yaxis_scale = scale + if hasattr(self, "_import_scales"): + self._import_yscale_edited = True def set_axis_off(self): """Hide the axis frame, ticks, and labels.""" @@ -1376,7 +1382,7 @@ def text(self, x, y, s, layer=0, **kwargs): def add_imshow(self, data, layer=0, **kwargs): ld = { - "data": np.array(data), + "data": np.asanyarray(data).copy(), "layer": layer, "plot_type": "imshow", "kwargs": kwargs, @@ -1425,6 +1431,15 @@ def plot_matplotlib( ax (matplotlib.axes.Axes): Axis on which to plot the lines. """ im = None + self._import_rendered_artists = {} + if hasattr(self, "_import_projection_artist_ids"): + for name, identifiers in self._import_projection_artist_ids.items(): + for identifier, artist in zip(identifiers, getattr(ax, name)): + self._import_rendered_artists[identifier] = [artist] + if hasattr(self, "_import_axis_state"): + from maxplotlib.backends.matplotlib.import_state import apply_axis_state + + apply_axis_state(ax, self._import_axis_state, units=False) contour_sets = [] for layer_name, layer_lines in self.layered_line_data.items(): if layers and layer_name not in layers: @@ -1432,10 +1447,12 @@ def plot_matplotlib( for line in layer_lines: artists_before = ( set(map(id, ax.get_children())) - if line.get("meta") is not None + if line.get("meta") is not None or "source_artist_id" in line else None ) - if line["plot_type"] == "plot": + if line["plot_type"] == "matplotlib_artist": + line["snapshot"].draw(ax, **line["kwargs"]) + elif line["plot_type"] == "plot": ax.plot( (line["x"] + self._xshift) * self._xscale, (line["y"] + self._yshift) * self._yscale, @@ -1693,6 +1710,8 @@ def plot_matplotlib( ax.text(line["x"], line["y"], line["s"], **line["kwargs"]) elif line["plot_type"] == "axvline": ax.axvline(x=line["x"], **line["kwargs"]) + elif line["plot_type"] == "axhline": + ax.axhline(y=line["y"], **line["kwargs"]) elif line["plot_type"] == "imshow": im = ax.imshow( line["data"], @@ -1708,13 +1727,33 @@ def plot_matplotlib( cax = divider.append_axes("right", size="5%", pad=0.05) plt.colorbar(im, cax=cax, label="Potential (V)") + if "source_artist_id" in line: + created = [ + artist + for artist in ax.get_children() + if id(artist) not in artists_before + ] + for artist in created: + if "source_sticky_edges" in line: + artist.sticky_edges.x[:] = line["source_sticky_edges"][0] + artist.sticky_edges.y[:] = line["source_sticky_edges"][1] + if line.get("source_picker") is not None: + artist.set_picker(line["source_picker"]) + if created: + self._import_rendered_artists.setdefault( + line["source_artist_id"], [] + ).extend(created) if line.get("meta") is not None: # Mirror the Plotly ``meta=`` tag onto the Matplotlib # artists so callers can find them again by identity # rather than by drawing order. self._tag_matplotlib_artists(ax, artists_before, line["meta"]) - if self._title: + if hasattr(self, "_import_projection"): + return + if hasattr(self, "_import_axis_state"): + apply_axis_state(ax, self._import_axis_state) + if self._title is not None: ax.set_title(self._title, **self._title_kwargs) if self._xlabel: ax.set_xlabel(self._xlabel, **self._xlabel_kwargs) @@ -1722,8 +1761,12 @@ def plot_matplotlib( ax.set_ylabel(self._ylabel, **self._ylabel_kwargs) if self._legend and len(self.line_data) > 0: ax.legend(**self._legend_kwargs) - if self._grid: - ax.grid() + if ( + not hasattr(self, "_import_grid") + or self._grid != self._import_grid + or getattr(self, "_import_grid_edited", False) + ): + ax.grid(self._grid) if self._axis_settings: axis_settings = dict(self._axis_settings) axis_args = axis_settings.pop("args", ()) @@ -1736,9 +1779,17 @@ def plot_matplotlib( ax.axis(ymin=self.ymin) if self.ymax is not None: ax.axis(ymax=self.ymax) - if self._xaxis_scale is not None: + if self._xaxis_scale is not None and ( + not hasattr(self, "_import_scales") + or self._xaxis_scale != self._import_scales[0] + or getattr(self, "_import_xscale_edited", False) + ): ax.set_xscale(self._xaxis_scale) - if self._yaxis_scale is not None: + if self._yaxis_scale is not None and ( + not hasattr(self, "_import_scales") + or self._yaxis_scale != self._import_scales[1] + or getattr(self, "_import_yscale_edited", False) + ): ax.set_yscale(self._yaxis_scale) if self._xticks is not None: ax.set_xticks( @@ -1850,6 +1901,40 @@ def plot_matplotlib( ax.clabel(contour_set, **self._clabel_kwargs) if self._rasterization_zorder is not None: ax.set_rasterization_zorder(self._rasterization_zorder) + if hasattr(self, "_import_axis_state"): + ax.set_xlim(self.xmin, self.xmax, auto=None) + ax.set_ylim(self.ymin, self.ymax, auto=None) + # Explicit edits made through the neutral setters still win. + if self._tick_params: + params = dict(self._tick_params) + if "rotation" in params: + params["labelrotation"] = params.pop("rotation") + ax.tick_params(**params) + if self._legend and self._import_legends and not self._legend_kwargs: + if ax.legend_ is not None: + ax.legend_.remove() + for i, snapshot in enumerate(self._import_legends): + legend = snapshot.clone(ax) + if i < len(self._import_legends) - 1: + ax.add_artist(legend) + else: + ax.legend_ = legend + legend._remove_method = ax._remove_legend + indicators = getattr(self, "_import_inset_indicators", {}) + for index, child in enumerate(self._import_child_axes): + if child["kind"] == "secondary": + secondary = getattr( + ax, "secondary_" + child["orientation"] + "axis" + )(child["location"], functions=child["functions"]) + secondary.set_axes_locator(child["locator"].clone(ax)) + secondary.set(xlabel=child["xlabel"], ylabel=child["ylabel"]) + apply_axis_state(secondary, child["state"]) + else: + inset = ax.inset_axes(child["bounds"]) + inset.set_axes_locator(child["locator"].clone(ax)) + child["subplot"].plot_matplotlib(inset, layers=layers) + if index in indicators: + ax.indicate_inset_zoom(inset, **indicators[index]) @staticmethod def _tag_matplotlib_artists(ax, artists_before, meta): @@ -2137,6 +2222,10 @@ def plot_tikzfigure(self, layers=None, verbose: bool = False) -> TikzFigure: return tikz_figure def plot_plotly(self, layers=None, allow_unsupported=False): + if hasattr(self, "_import_projection"): + raise NotImplementedError( + "Imported projections require the Matplotlib backend or fallback='raster'" + ) """ Plot all lines using Plotly. @@ -2189,7 +2278,7 @@ def plot_plotly(self, layers=None, allow_unsupported=False): # current backend. Keep the default strict so a mixed plot cannot # silently lose data, while allowing callers to deliberately render # the Plotly-compatible portions of a canvas. - unsupported_plot_types = set() + unsupported_plot_types = {"matplotlib_artist"} def tx(values): return self._transform_x(values) @@ -2209,6 +2298,8 @@ def plotly_color(value): if isinstance(value, np.generic): value = value.item() if isinstance(value, (list, tuple, np.ndarray)): + if np.ndim(value) == 2: + return [plotly_color(color) for color in value] arr = np.asarray(value).astype(float).reshape(-1) if arr.size in (3, 4): rgb = (arr[:3] * 255.0) if np.all(arr[:3] <= 1.0) else arr[:3] @@ -2301,6 +2392,9 @@ def bar_marker(kwargs): if kwargs.get("color") is not None or c_values is None: marker_color = plotly_color(kwargs.get("color", None)) colorscale = None + elif np.ndim(c_values) == 2: + marker_color = plotly_color(c_values) + colorscale = None else: if isinstance(c_values, str) or ( np.ndim(c_values) > 0 @@ -2313,6 +2407,14 @@ def bar_marker(kwargs): colorscale = _colormap_to_plotly_colorscale( kwargs.get("cmap", "viridis") ) + if c_values is not None and kwargs.get("norm") is not None: + from matplotlib.cm import ScalarMappable + + mapped = ScalarMappable( + norm=kwargs["norm"], cmap=kwargs.get("cmap", "viridis") + ) + marker_color = plotly_color(mapped.to_rgba(np.ma.asarray(c_values))) + colorscale = None marker_dict = dict( color=marker_color, colorscale=colorscale, @@ -2321,7 +2423,11 @@ def bar_marker(kwargs): showscale=(colorscale is not None) and bool(kwargs.get("colorbar", False)), symbol=marker_map.get(marker, marker), - size=kwargs.get("s", None), + size=( + np.sqrt(np.asarray(kwargs["s"])) * (96 / 72) + if kwargs.get("s") is not None + else None + ), opacity=kwargs.get("alpha", None), ) edgecolor = kwargs.get("edgecolors", kwargs.get("edgecolor")) @@ -2333,6 +2439,16 @@ def bar_marker(kwargs): ), width=linewidth if linewidth is not None else 1, ) + if ( + isinstance(kwargs.get("facecolors"), str) + and kwargs["facecolors"] == "none" + ): + symbol = marker_dict["symbol"] + if isinstance(symbol, str) and not symbol.endswith("-open"): + marker_dict["symbol"] = symbol + "-open" + marker_dict["color"] = ( + plotly_color(edgecolor) if edgecolor is not None else "black" + ) trace = go.Scatter( x=tx(line["x"]), y=ty(line["y"]), @@ -3074,6 +3190,8 @@ def bar_marker(kwargs): kwargs = line["kwargs"] marker = kwargs.get("marker") mode = "lines+markers" if marker is not None else "lines" + if kwargs.get("linestyle") in ("None", "", " "): + mode = "markers" if marker is not None else "none" x_vals = tx(line["x"]) y_vals = ty(line["y"]) yerr = line.get("yerr") @@ -3089,6 +3207,26 @@ def bar_marker(kwargs): capsize = kwargs.get("capsize") error_width = None if capsize is None else float(capsize) error_linewidth = kwargs.get("elinewidth", kwargs.get("capthick")) + + def error_spec(values, scale): + if values is None: + return None + values = np.asarray(values) * abs(scale) + spec = dict( + type="data", + visible=True, + width=error_width, + thickness=error_linewidth, + color=plotly_color(kwargs.get("ecolor", kwargs.get("color"))), + ) + if values.ndim == 2: + spec.update( + array=values[1], arrayminus=values[0], symmetric=False + ) + else: + spec["array"] = values + return spec + trace = go.Scatter( x=x_vals, y=y_vals, @@ -3111,28 +3249,8 @@ def bar_marker(kwargs): if marker is not None else None ), - error_y=( - dict( - type="data", - array=yerr, - visible=True, - width=error_width, - thickness=error_linewidth, - ) - if yerr is not None - else None - ), - error_x=( - dict( - type="data", - array=xerr, - visible=True, - width=error_width, - thickness=error_linewidth, - ) - if xerr is not None - else None - ), + error_y=error_spec(yerr, self._yscale), + error_x=error_spec(xerr, self._xscale), ) traces.append(trace) elif plot_type in ("axhline", "axvline", "hlines", "vlines"): @@ -3302,10 +3420,13 @@ def bar_marker(kwargs): "center": "middle", "baseline": "bottom", } + font_family = kwargs.get("fontfamily", kwargs.get("family", None)) + if isinstance(font_family, (list, tuple)): + font_family = ", ".join(font_family) font = dict( color=plotly_color(kwargs.get("color", None)), size=kwargs.get("fontsize", None), - family=kwargs.get("fontfamily", kwargs.get("family", None)), + family=font_family, weight=kwargs.get("fontweight", None), ) if plot_type == "text": @@ -3330,7 +3451,7 @@ def bar_marker(kwargs): x=x, y=y, text=line["text"], - showarrow=True, + showarrow=kwargs.get("arrowprops", {}) is not None, arrowhead=2, ax=0, ay=-30, @@ -3339,17 +3460,70 @@ def bar_marker(kwargs): if line.get("xytext") is not None: tx_val = txs(float(line["xytext"][0])) ty_val = tys(float(line["xytext"][1])) - ann.update(axref="x", ayref="y", ax=tx_val, ay=ty_val) + if ann["showarrow"]: + ann.update(axref="x", ayref="y", ax=tx_val, ay=ty_val) + else: + ann.update(x=tx_val, y=ty_val) annotations.append(ann) elif plot_type == "imshow": kwargs = line["kwargs"] - heatmap = go.Heatmap( - z=line["data"], - colorscale=kwargs.get("cmap", "Viridis"), - showscale=True, + data = np.ma.asarray(line["data"]) + rows, cols = data.shape[:2] + origin = kwargs.get("origin", "upper") + extent = kwargs.get( + "extent", + ( + -0.5, + cols - 0.5, + rows - 0.5 if origin == "upper" else -0.5, + -0.5 if origin == "upper" else rows - 0.5, + ), + ) + left, right, bottom, top = extent + dx = (right - left) / cols + dy = ((bottom - top) if origin == "upper" else (top - bottom)) / rows + x0 = left + dx / 2 + y0 = (top if origin == "upper" else bottom) + dy / 2 + if data.ndim == 3 or kwargs.get("norm") is not None: + from matplotlib.cm import ScalarMappable + + mapper = ScalarMappable( + norm=kwargs.get("norm"), cmap=kwargs.get("cmap", "viridis") + ) + rgba = mapper.to_rgba(data) + if kwargs.get("alpha") is not None: + rgba[..., 3] *= np.asarray(kwargs["alpha"]) + pixels = np.asarray(rgba, dtype=float).copy() + pixels[..., :3] *= 255 + trace = go.Image( + z=pixels, + colormodel="rgba", + x0=x0, + y0=y0, + dx=dx, + dy=dy, + visible=kwargs.get("visible", True), + ) + else: + trace = go.Heatmap( + z=data.astype(float).filled(np.nan), + x0=x0, + y0=y0, + dx=dx, + dy=dy, + colorscale=_colormap_to_plotly_colorscale( + kwargs.get("cmap", "viridis") + ), + zmin=kwargs.get("vmin"), + zmax=kwargs.get("vmax"), + showscale=kwargs.get("colorbar", False), + opacity=kwargs.get("alpha"), + visible=kwargs.get("visible", True), + ) + traces.append(trace) + last_heatmap_idx = ( + len(traces) - 1 if isinstance(trace, go.Heatmap) else None ) - traces.append(heatmap) - last_heatmap_idx = len(traces) - 1 elif plot_type == "colorbar": if last_heatmap_idx is not None: label = line.get("label", "") or line["kwargs"].get("label", "") diff --git a/src/maxplotlib/tests/test_matplotlib_import.py b/src/maxplotlib/tests/test_matplotlib_import.py new file mode 100644 index 0000000..e1e8f25 --- /dev/null +++ b/src/maxplotlib/tests/test_matplotlib_import.py @@ -0,0 +1,348 @@ +import matplotlib.pyplot as plt +import numpy as np +import pytest + +from maxplotlib import Canvas + + +@pytest.fixture(autouse=True) +def close_figures(): + with plt.rc_context(): + yield + plt.close("all") + + +@pytest.mark.parametrize("kind", ["figure", "axes", "array", "flat", "list"]) +def test_input_forms_and_layout(kind): + fig, axs = plt.subplots(2, 2, figsize=(8, 5)) + for i, ax in enumerate(axs.flat): + ax.plot([1, 2], [i, i + 1], color="red", label=f"line {i}") + ax.set_title(f"Panel {i}") + source = { + "figure": fig, + "axes": axs[1, 1], + "array": axs, + "flat": axs.ravel(), + "list": axs.tolist(), + }[kind] + canvas = Canvas.from_matplotlib(source, strict=True) + rendered, imported = canvas.render(backend="matplotlib") + assert imported.shape == ((1, 1) if kind == "axes" else (2, 2)) + for i, ax in enumerate(imported.flat): + expected = 3 if kind == "axes" else i + np.testing.assert_array_equal(ax.lines[0].get_ydata(), [expected, expected + 1]) + assert ax.get_title() == f"Panel {expected}" + assert ax.lines[0].get_color() == "red" + np.testing.assert_allclose(rendered.get_size_inches(), [8, 5]) + + +def test_explicit_array_order_and_flat_vertical_layout(): + _, axs = plt.subplots(2, 1) + axs[0].set_title("top") + axs[1].set_title("bottom") + canvas = Canvas.from_matplotlib(axs) + assert (canvas.nrows, canvas.ncols) == (2, 1) + canvas = Canvas.from_matplotlib(axs[::-1].reshape(1, 2)) + _, imported = canvas.render() + assert imported.shape == (1, 2) + assert imported[0, 0].get_title() == "bottom" + + +def test_data_snapshot_settings_and_plotly_render(): + fig, ax = plt.subplots() + (line,) = ax.plot([1, 2, 3], [4, 5, 6], marker="o", label="series") + ax.set(xlabel="Time", ylabel="Value", xscale="log", ylim=(10, 0)) + ax.legend() + fig.suptitle("Imported") + canvas = Canvas.from_matplotlib(fig) + line.set_ydata([100, 200, 300]) + _, imported = canvas.render() + result = imported[0, 0] + np.testing.assert_array_equal(result.lines[0].get_ydata(), [4, 5, 6]) + assert result.get_xlabel() == "Time" + assert result.get_ylabel() == "Value" + assert result.get_xscale() == "log" + assert result.get_ylim() == (10, 0) + assert result.get_legend().get_texts()[0].get_text() == "series" + plotly_fig = canvas.render(backend="plotly") + assert len(plotly_fig.data) == 1 + + +def test_scatter_bars_images_and_text(): + fig, axs = plt.subplots(2, 2) + axs[0, 0].scatter([1, 2], [3, 4], c=[0.1, 0.8], s=[20, 50], marker="s") + axs[0, 1].bar([1, 2], [3, 4], bottom=[2, 1], width=0.4, label="bars") + axs[1, 0].barh([1, 2], [3, 4], left=2, height=0.3) + axs[1, 1].imshow([[1, 2], [3, 4]], origin="lower", extent=(1, 3, 2, 6)) + axs[1, 1].text(2, 3, "hello") + canvas = Canvas.from_matplotlib(fig, strict=True) + _, imported = canvas.render() + scatter = imported[0, 0].collections[0] + np.testing.assert_allclose(scatter.get_offsets(), [[1, 3], [2, 4]]) + np.testing.assert_allclose(scatter.get_array(), [0.1, 0.8]) + np.testing.assert_allclose(scatter.get_sizes(), [20, 50]) + assert imported[0, 1].patches[0].get_y() == 2 + assert imported[0, 1].patches[0].get_height() == 3 + assert imported[1, 0].patches[0].get_x() == 2 + assert imported[1, 0].patches[0].get_width() == 3 + np.testing.assert_array_equal( + imported[1, 1].images[0].get_array(), [[1, 2], [3, 4]] + ) + assert imported[1, 1].texts[0].get_text() == "hello" + plotly_fig = canvas.render(backend="plotly") + assert len(plotly_fig.data) >= 6 + + +def test_scatter_solid_and_hollow_colors(): + _, ax = plt.subplots() + ax.scatter([1], [2], color="red", marker="s") + ax.scatter([2], [3], facecolors="none", edgecolors="blue") + canvas = Canvas.from_matplotlib(ax) + _, imported = canvas.render() + np.testing.assert_allclose( + imported[0, 0].collections[0].get_facecolors(), [[1, 0, 0, 1]] + ) + assert len(imported[0, 0].collections[1].get_facecolors()) == 0 + + +def test_scatter_per_point_colors_render_on_both_backends(): + _, ax = plt.subplots() + ax.scatter([1, 2], [3, 4], c=["red", "blue"]) + canvas = Canvas.from_matplotlib(ax) + _, imported = canvas.render() + np.testing.assert_allclose( + imported[0, 0].collections[0].get_facecolors(), + [[1, 0, 0, 1], [0, 0, 1, 1]], + ) + plotly_fig = canvas.render(backend="plotly") + assert list(plotly_fig.data[0].marker.color) == [ + "rgba(255,0,0,1.0)", + "rgba(0,0,255,1.0)", + ] + + +@pytest.mark.parametrize("grid", [True, False]) +def test_grid_state(grid): + _, ax = plt.subplots() + ax.grid(grid) + canvas = Canvas.from_matplotlib(ax) + _, imported = canvas.render() + assert all(line.get_visible() == grid for line in imported[0, 0].get_xgridlines()) + + +def test_unsupported_artists_warn_or_raise_without_changing_source(): + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4]) + from matplotlib.artist import Artist + + class CustomArtist(Artist): + pass + + ax.add_artist(CustomArtist()) + children = ax.get_children() + with pytest.warns(UserWarning, match="artist skipped"): + canvas = Canvas.from_matplotlib(fig) + assert len(canvas._subplot_matrix[0][0].line_data) == 1 + with pytest.raises(NotImplementedError, match="artist skipped"): + Canvas.from_matplotlib(fig, strict=True) + assert ax.get_children() == children + + +def test_colorbar_is_reported_and_twin_is_imported(): + fig, ax = plt.subplots() + image = ax.imshow([[1, 2], [3, 4]]) + fig.colorbar(image, ax=ax) + ax.twinx().plot([1, 2], [3, 4]) + canvas = Canvas.from_matplotlib(fig, strict=True) + rendered, _ = canvas.render() + rendered.canvas.draw() + assert len(canvas._subplots) == 1 + assert len(canvas._twinx_subplots) == 1 + assert len(rendered.axes) == 3 + bar = rendered.axes[-1]._colorbar + assert bar.mappable is rendered.axes[0].images[0] + + +@pytest.mark.parametrize("direction", ["twinx", "twiny"]) +@pytest.mark.parametrize("input_kind", ["figure", "reversed", "single"]) +def test_twin_axes(direction, input_kind): + fig, ax = plt.subplots() + ax.plot([1, 2], [3, 4], color="red") + twin = getattr(ax, direction)() + twin.plot([1, 2], [30, 40], color="blue") + twin.set_ylabel("Secondary") + source = {"figure": fig, "reversed": [twin, ax], "single": twin}[input_kind] + canvas = Canvas.from_matplotlib(source, strict=True) + rendered, axes = canvas.render() + if input_kind == "single": + assert len(rendered.axes) == 1 + result = axes[0, 0] + else: + assert len(rendered.axes) == 2 + result = rendered.axes[1] + np.testing.assert_array_equal(axes[0, 0].lines[0].get_ydata(), [3, 4]) + np.testing.assert_array_equal(result.lines[0].get_ydata(), [30, 40]) + assert result.get_ylabel() == "Secondary" + if direction == "twinx": + assert len(canvas.render(backend="plotly").data) == ( + 1 if input_kind == "single" else 2 + ) + + +@pytest.mark.parametrize("xerr", [None, [0.1, 0.2]]) +def test_errorbar_asymmetric_errors_are_not_duplicated(xerr): + _, ax = plt.subplots() + source = ax.errorbar( + [1, 2], + [3, 4], + xerr=xerr, + yerr=[[0.2, 0.3], [0.4, 0.5]], + fmt="o-", + capsize=5, + label="errors", + ecolor="red", + ) + canvas = Canvas.from_matplotlib(ax, strict=True) + assert len(canvas.subplot().line_data) == 1 + _, imported = canvas.render() + result = imported[0, 0].containers[0] + for original, copied in zip(source.lines[2], result.lines[2]): + np.testing.assert_allclose(original.get_segments(), copied.get_segments()) + assert result.lines[1][0].get_markersize() == 10 + np.testing.assert_allclose( + canvas.render(backend="plotly").data[0].error_y.array, [0.4, 0.5] + ) + + +def test_bars_with_errors_and_error_only_geometry(): + _, ax = plt.subplots() + ax.bar([1, 2], [3, 4], yerr=[0.2, 0.4], capsize=3) + canvas = Canvas.from_matplotlib(ax, strict=True) + _, imported = canvas.render() + assert len(imported[0, 0].patches) == 2 + segments = [ + line.get_xydata() + for line in imported[0, 0].lines + if len(line.get_xydata()) == 2 + ] + assert any(np.allclose(segment, [[1, 2.8], [1, 3.2]]) for segment in segments) + + +def test_fill_between_and_polygons_preserve_geometry(): + from matplotlib.patches import Polygon + + _, ax = plt.subplots() + region = ax.fill_between([0, 1, 2], [2, 3, 2], [1, 1, 0], color="red", alpha=0.3) + ax.add_patch(Polygon([[0, 0], [1, 0], [0, 1]], color="blue")) + canvas = Canvas.from_matplotlib(ax, strict=True) + _, imported = canvas.render() + assert len(imported[0, 0].patches) == 2 + np.testing.assert_allclose( + imported[0, 0].patches[0].get_xy(), region.get_paths()[0].vertices[:-1] + ) + assert len(canvas.render(backend="plotly").data) == 2 + + +def test_line_collections_and_reference_lines(): + _, ax = plt.subplots() + ax.hlines([1, 2], [0, 1], [3, 4], colors=["red", "blue"]) + ax.axhline(3, xmin=0.2, xmax=0.8) + ax.axvline(2, ymin=0.1, ymax=0.7) + canvas = Canvas.from_matplotlib(ax, strict=True) + _, imported = canvas.render() + assert len(imported[0, 0].lines) == 4 + np.testing.assert_allclose(imported[0, 0].lines[2].get_xdata(), [0.2, 0.8]) + np.testing.assert_allclose(imported[0, 0].lines[3].get_ydata(), [0.1, 0.7]) + canvas.render(backend="plotly") + + +def test_stairs_and_annotation_with_title_styling(): + _, ax = plt.subplots() + stairs = ax.stairs([1, 3, 2], [0, 1, 2, 3], fill=True) + annotation = ax.annotate( + "peak", + (1.5, 3), + xytext=(2, 4), + arrowprops={"arrowstyle": "->"}, + fontweight="bold", + ) + ax.set_title("Styled", color="red", fontsize=18) + canvas = Canvas.from_matplotlib(ax, strict=True) + annotation.set_text("changed") + _, imported = canvas.render() + result = imported[0, 0] + np.testing.assert_allclose( + result.patches[0].get_path().vertices, stairs.get_path().vertices + ) + assert result.texts[0].get_text() == "peak" + assert result.texts[0].arrow_patch is not None + assert result.title.get_color() == "red" + assert result.title.get_fontsize() == 18 + canvas.render(backend="plotly") + + +def test_annotation_without_arrow_and_marker_only_errors_in_plotly(): + _, ax = plt.subplots() + ax.annotate("label", (1, 2), xytext=(3, 4)) + ax.errorbar([1, 2], [3, 4], yerr=[0.1, 0.2], fmt="o") + canvas = Canvas.from_matplotlib(ax, strict=True) + result = canvas.render(backend="plotly") + annotation = next(a for a in result.layout.annotations if a.text == "label") + assert not annotation.showarrow + assert (annotation.x, annotation.y) == (3, 4) + assert result.data[0].mode == "markers" + + +def test_unsupported_annotation_coordinates_and_multiple_twins(): + fig, ax = plt.subplots() + ax.annotate("label", (0.5, 0.5), xycoords="axes fraction") + imported, axes = Canvas.from_matplotlib(ax, strict=True).render() + imported.canvas.draw() + assert axes[0, 0].texts[0].xycoords == "axes fraction" + fig2, base = plt.subplots() + base.twinx() + base.twinx() + imported, _ = Canvas.from_matplotlib(fig2, strict=True).render() + assert len(imported.axes) == 3 + + +def test_dashed_line_collection_and_disconnected_fill(): + _, ax = plt.subplots() + ax.hlines([1, 2], 0, 3, linestyles="dashed") + region = ax.fill_between( + [0, 1, 2, 3, 4], [1, 2, 3, 2, 1], where=[True, True, False, True, True] + ) + canvas = Canvas.from_matplotlib(ax, strict=True) + _, imported = canvas.render() + assert len(imported[0, 0].patches) == len(region.get_paths()) == 2 + canvas.render(backend="plotly") + + +def test_spanning_layout_is_reported(): + fig = plt.figure() + grid = fig.add_gridspec(2, 2) + fig.add_subplot(grid[0, :]) + fig.add_subplot(grid[1, 0]) + canvas = Canvas.from_matplotlib(fig, strict=True) + imported, _ = canvas.render() + for source, result in zip(fig.axes, imported.axes): + np.testing.assert_allclose( + source.get_position().bounds, result.get_position().bounds + ) + + +@pytest.mark.parametrize("source", [None, 42, [1, 2], [[[1]]]]) +def test_invalid_input(source): + with pytest.raises(TypeError): + Canvas.from_matplotlib(source) + + +def test_empty_duplicate_and_mixed_figures(): + with pytest.raises(ValueError, match="At least one"): + Canvas.from_matplotlib([]) + _, ax = plt.subplots() + _, other = plt.subplots() + with pytest.raises(ValueError, match="Duplicate"): + Canvas.from_matplotlib([ax, ax]) + with pytest.raises(ValueError, match="same Figure"): + Canvas.from_matplotlib([ax, other]) diff --git a/src/maxplotlib/tests/test_matplotlib_import_extended.py b/src/maxplotlib/tests/test_matplotlib_import_extended.py new file mode 100644 index 0000000..6ac9f3b --- /dev/null +++ b/src/maxplotlib/tests/test_matplotlib_import_extended.py @@ -0,0 +1,612 @@ +"""Behavioral regression fixtures for the importer compatibility matrix.""" + +import io +import json +import pickle + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np +import pytest +from matplotlib.backends.backend_agg import FigureCanvasAgg +from matplotlib.colors import LogNorm +from matplotlib.patches import Circle, PathPatch +from matplotlib.path import Path +from matplotlib.ticker import AutoMinorLocator, MultipleLocator, StrMethodFormatter +from matplotlib.transforms import Affine2D + +from maxplotlib import Canvas + + +@pytest.fixture(autouse=True) +def close_figures(): + with plt.rc_context({"text.usetex": False}): + yield + plt.close("all") + + +def render(source, **kwargs): + canvas = Canvas.from_matplotlib(source, strict=True, **kwargs) + fig, axes = canvas.render() + fig.canvas.draw() + return canvas, fig, axes + + +def test_trusted_pickle_boundary_and_report(tmp_path, monkeypatch): + fig, ax = plt.subplots() + ax.plot([1, 2], gid="line-id") + data = pickle.dumps(fig) + path = tmp_path / "figure.pickle" + path.write_bytes(data) + with monkeypatch.context() as boundary: + + def reject_unpickling(*args, **kwargs): + pytest.fail("Untrusted input reached pickle") + + boundary.setattr(pickle, "load", reject_unpickling) + boundary.setattr(pickle, "loads", reject_unpickling) + for source in (data, path, io.BytesIO(data)): + with pytest.raises(ValueError, match="trusted=True"): + Canvas.from_matplotlib(source) + for source in (data, path, io.BytesIO(data)): + canvas = Canvas.from_matplotlib(source, trusted=True, strict=True) + result, axes = canvas.render() + assert axes[0, 0].lines[0].get_gid() == "line-id" + assert canvas.import_report.diagnostics[0].artist_type == "Line2D" + json.dumps(canvas.import_report.to_dict()) + + +def test_native_geometry_clipping_rebinding_and_repeated_render(): + fig, ax = plt.subplots() + circle = Circle( + (0.5, 0.5), 0.2, transform=Affine2D().translate(0.1, 0) + ax.transAxes + ) + ax.add_patch(circle) + vertices = [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)] + path = Path( + vertices, [Path.MOVETO, Path.CURVE4, Path.CURVE4, Path.CURVE4, Path.CLOSEPOLY] + ) + ax.add_patch(PathPatch(path, facecolor="red")) + canvas, first, axes = render(fig) + circle.set_radius(0.9) + assert axes[0, 0].patches[0].radius == 0.2 + canvas.set_size_inches(9, 4) + second, again = canvas.render() + second.canvas.draw() + assert again[0, 0].patches[0] is not axes[0, 0].patches[0] + np.testing.assert_allclose( + again[0, 0].patches[0].get_transform().transform([0, 0]), + again[0, 0].transAxes.transform([0.6, 0.5]), + ) + assert circle.axes is ax and circle.figure is fig + with pytest.raises(NotImplementedError, match="matplotlib_artist"): + canvas.render(backend="plotly") + + +@pytest.mark.parametrize( + "kind", + [ + "mesh", + "contour", + "tri", + "quiver", + "barbs", + "stream", + "hexbin", + "pie", + "box", + "violin", + "event", + "stem", + "table", + ], +) +def test_complex_geometry(kind): + fig, ax = plt.subplots() + x, y = np.meshgrid(np.arange(4), np.arange(3)) + z = x + y + if kind == "mesh": + ax.pcolormesh(x, y, z, shading="nearest", norm=LogNorm(vmin=1, vmax=6)) + elif kind == "contour": + c = ax.contour(x, y, z, levels=[1, 2, 3]) + ax.clabel(c) + elif kind == "tri": + ax.tripcolor([0, 1, 0, 1], [0, 0, 1, 1], [1, 2, 3, 4]) + elif kind == "quiver": + q = ax.quiver(x, y, x + 1, y + 1) + ax.quiverkey(q, 0.8, 0.9, 1, "unit") + elif kind == "barbs": + ax.barbs(x, y, x + 1, y + 1) + elif kind == "stream": + ax.streamplot(np.arange(4), np.arange(3), x + 1, y + 1) + elif kind == "hexbin": + ax.hexbin(x.ravel(), y.ravel(), gridsize=3) + elif kind == "pie": + ax.pie([1, 2, 3], labels=["a", "b", "c"], wedgeprops={"width": 0.3}) + elif kind == "box": + ax.boxplot([[1, 2, 3], [3, 4, 6]]) + elif kind == "violin": + ax.violinplot([[1, 2, 3], [3, 4, 6]]) + elif kind == "event": + ax.eventplot([[1, 2], [3, 4]]) + elif kind == "stem": + ax.stem([1, 2], [3, 4]) + elif kind == "table": + ax.table(cellText=[["a", "b"], ["c", "d"]]) + canvas, imported, axes = render(fig) + assert canvas.subplot().line_data + assert imported is not fig + again, _ = canvas.render() + again.canvas.draw() + + +def test_shared_axes_empty_slots_and_spine_positions(): + fig, axes = plt.subplots(2, 2, sharex=True, sharey=True) + fig.delaxes(axes[0, 1]) + axes[0, 0].spines["right"].set(position=("outward", 32), color="red", linewidth=3) + canvas, imported, copied = render(fig) + assert len(imported.axes) == 3 + assert copied[0, 1] is None + copied[1, 0].set_xlim(2, 8) + assert copied[0, 0].get_xlim() == (2, 8) + assert copied[0, 0].spines["right"].get_position() == ("outward", 32) + assert copied[0, 0].spines["right"].get_linewidth() == 3 + + +@pytest.mark.parametrize( + "scale,kw", + [ + ("log", {"base": 2}), + ("symlog", {"linthresh": 3}), + ("asinh", {"linear_width": 2}), + ("logit", {}), + ], +) +def test_scales_and_axis_configuration(scale, kw): + fig, ax = plt.subplots() + ax.set_xscale(scale, **kw) + ax.set_xlim(0.1, 0.9) + ax.yaxis.set_minor_locator(AutoMinorLocator(3)) + ax.yaxis.set_major_locator(MultipleLocator(0.25)) + ax.yaxis.set_major_formatter(StrMethodFormatter("{x:.2f}")) + ax.tick_params(axis="y", which="minor", direction="inout", length=8, color="red") + ax.grid(True, axis="y", which="minor", linestyle="--", color="green", alpha=0.4) + ax.grid(False, axis="x", which="both") + canvas, _, copied = render(fig) + result = copied[0, 0] + assert result.get_xscale() == scale + np.testing.assert_allclose( + result.xaxis.get_transform().transform([0.2, 0.8]), + ax.xaxis.get_transform().transform([0.2, 0.8]), + ) + assert not any(line.get_visible() for line in result.get_xgridlines()) + assert result.yaxis.get_minor_ticks()[0].gridline.get_color() == "green" + assert result.yaxis.get_minor_ticks()[0].tick1line.get_markersize() == 8 + canvas.subplot().set_title("edited") + canvas.subplot().set_xlim(0.2, 0.8) + _, edited = canvas.render() + assert edited[0, 0].get_title() == "edited" + assert edited[0, 0].get_xlim() == (0.2, 0.8) + + +def test_dates_categories_and_formatters(): + import datetime + + fig, axes = plt.subplots(1, 2) + axes[0].plot([datetime.datetime(2023, 1, 1), datetime.datetime(2023, 1, 2)], [1, 2]) + axes[1].plot(["apple", "pear"], [1, 2]) + fig.canvas.draw() + _, _, copied = render(fig) + for source, target in zip(axes, copied.flat): + assert [t.get_text() for t in source.get_xticklabels()] == [ + t.get_text() for t in target.get_xticklabels() + ] + assert target.xaxis.get_major_formatter().axis is target.xaxis + + +def test_hidden_artists_metadata_dashes_and_order(): + fig, ax = plt.subplots() + ax.scatter([1], [2], zorder=2) + (line,) = ax.plot( + [1, 2], + [2, 3], + linestyle=(2, (3, 2, 1, 2)), + dash_capstyle="round", + gapcolor="red", + markevery=2, + visible=False, + gid="hidden", + url="https://example.test", + picker=5, + ) + canvas, _, copied = render(fig) + result = copied[0, 0].lines[0] + assert not result.get_visible() + assert result.get_gid() == "hidden" + assert result.get_url() == "https://example.test" + assert result.get_picker() == 5 + assert result._unscaled_dash_pattern == line._unscaled_dash_pattern + assert result.get_gapcolor() == "red" + assert copied[0, 0].get_children()[0] is copied[0, 0].collections[0] + canvas.subplot().line_data[1]["kwargs"]["visible"] = True + _, updated = canvas.render() + assert updated[0, 0].lines[0].get_visible() + + +def test_legend_proxies_multiple_legends_and_figure_decorations(): + from matplotlib.lines import Line2D + + fig, ax = plt.subplots() + (line,) = ax.plot([1, 2], label="original") + first = ax.legend( + [Line2D([], [], color="red")], + ["proxy"], + loc="upper left", + title="Proxy", + framealpha=0.3, + ) + ax.add_artist(first) + ax.legend([line], ["renamed"], loc="lower right", ncols=2) + fig.legend([line], ["global"], loc="upper center") + fig.text(0.1, 0.1, "footer") + fig.patch.set_facecolor("beige") + fig.suptitle("Super", x=0.3, fontsize=21, color="red") + _, copied, axes = render(fig) + from matplotlib.legend import Legend + + legends = [ + child for child in axes[0, 0].get_children() if isinstance(child, Legend) + ] + assert [[t.get_text() for t in legend.get_texts()] for legend in legends] == [ + ["proxy"], + ["renamed"], + ] + assert copied.get_facecolor() == fig.get_facecolor() + assert copied._suptitle.get_position()[0] == 0.3 + assert any(isinstance(a, Legend) for a in copied.artists) + + +def test_colorbar_ownership_multiple_shared_and_horizontal(): + fig, axes = plt.subplots(1, 2) + image = axes[0].imshow([[1, 2], [3, 4]], norm=LogNorm(1, 4)) + points = axes[1].scatter([1, 2], [2, 3], c=[10, 20]) + fig.colorbar(image, ax=axes, orientation="horizontal", label="image", extend="both") + fig.colorbar(points, ax=axes[1], label="points", ticks=[10, 15, 20]) + canvas, copied, imported = render(fig) + bars = [ax._colorbar for ax in copied.axes if hasattr(ax, "_colorbar")] + assert bars[0].mappable is imported[0, 0].images[0] + assert bars[1].mappable is imported[0, 1].collections[0] + assert bars[0].orientation == "horizontal" and bars[0].extend == "both" + np.testing.assert_allclose(bars[1].get_ticks(), [10, 15, 20]) + image.set_clim(2, 10) + assert bars[0].mappable.norm.vmin == 1 + + +def test_secondary_inset_and_annotation_coordinates(): + fig, ax = plt.subplots() + secondary = ax.secondary_xaxis("top", functions=(lambda x: x * 2, lambda x: x / 2)) + secondary.set_xlabel("double") + inset = ax.inset_axes([0.2, 0.2, 0.4, 0.4]) + inset.plot([1, 2]) + ax.annotate( + "offset", + (0.5, 0.5), + xycoords="axes fraction", + xytext=(12, 6), + textcoords="offset points", + arrowprops={"arrowstyle": "->"}, + ) + _, copied, axes = render(fig) + result = axes[0, 0] + assert len(result.child_axes) == 2 + result.set_xlim(1, 3) + copied.canvas.draw() + assert result.child_axes[0].get_xlim() == (2, 6) + assert result.child_axes[0].get_xlabel() == "double" + assert result.texts[0].xycoords == "axes fraction" + + +@pytest.mark.skipif( + tuple(int(part) for part in matplotlib.__version__.split(".")[:2]) < (3, 10), + reason="indicate_inset_zoom only returns a rebindable InsetIndicator on 3.10+", +) +def test_inset_zoom_indicator_rebinds_to_reconstructed_axes(): + fig, ax = plt.subplots() + ax.plot([0, 1, 2, 3], [0, 1, 4, 9]) + inset = ax.inset_axes([0.5, 0.5, 0.4, 0.4]) + inset.plot([0, 1, 2, 3], [0, 1, 4, 9]) + inset.set_xlim(0, 1) + inset.set_ylim(0, 1) + ax.indicate_inset_zoom(inset, edgecolor="black", linewidth=2, alpha=0.75) + + _, copied, axes = render(fig) + result = axes[0, 0] + indicators = [ + artist for artist in result.artists if type(artist).__name__ == "InsetIndicator" + ] + assert len(indicators) == 1 + indicator = indicators[0] + # The indicator must reference the *reconstructed* inset axes, not the + # source figure's, so it keeps tracking the copy's live limits. + assert indicator._inset_ax is result.child_axes[0] + assert indicator.rectangle.get_edgecolor() == (0.0, 0.0, 0.0, 0.75) + assert indicator.rectangle.get_linewidth() == 2.0 + + +@pytest.mark.parametrize("projection", ["polar", "3d"]) +def test_native_projections(projection): + fig = plt.figure() + ax = fig.add_subplot(projection=projection) + if projection == "3d": + ax.plot([0, 1], [1, 2], [3, 4]) + ax.plot_surface(*np.meshgrid([0, 1], [0, 1]), np.array([[1, 2], [3, 4]])) + ax.view_init(elev=40, azim=25) + else: + ax.plot([0, 1, 2], [1, 2, 3]) + canvas, _, axes = render(fig) + assert axes[0, 0].name == projection + if projection == "3d": + assert axes[0, 0].elev == 40 + with pytest.raises(NotImplementedError, match="projections"): + canvas.render(backend="plotly") + + +def test_raster_loss_report_and_plotly_rgba_extent(): + fig, ax = plt.subplots() + ax.imshow([[1, 2], [3, 4]], norm=LogNorm(1, 4), extent=(2, 6, 3, 9), origin="lower") + canvas = Canvas.from_matplotlib(fig, strict=True) + image = canvas.render(backend="plotly").data[0] + assert image.type == "image" + assert (image.x0, image.dx, image.y0, image.dy) == (3, 2, 4.5, 3) + raster = Canvas.from_matplotlib(fig, fallback="raster") + assert raster.import_report.diagnostics[0].fallback == "raster" + assert "lost" in raster.import_report.diagnostics[0].message + assert raster.render(backend="plotly").data[0].type == "image" + + +def test_error_limits_subsampling_and_independent_components(): + fig, ax = plt.subplots() + error = ax.errorbar( + [1, 2, 3, 4], [2, 3, 4, 5], yerr=0.3, errorevery=2, lolims=True, capsize=4 + ) + error.lines[1][0].set_color("red") + _, _, copied = render(fig) + assert len(copied[0, 0].lines) >= 3 + from matplotlib.colors import to_rgba + + assert any( + to_rgba(line.get_color()) == to_rgba("red") for line in copied[0, 0].lines + ) + + +def test_raster_image_comparison_and_exports(tmp_path): + fig, ax = plt.subplots(figsize=(4, 3), dpi=100) + ax.plot([0, 1, 2], [1, 3, 2], color="red", linestyle=(0, (4, 2)), marker="o") + ax.set_title("snapshot") + ax.tick_params(direction="in", colors="blue") + ax.grid(False) + FigureCanvasAgg(fig).draw() + original = np.asarray(fig.canvas.buffer_rgba()).copy() + canvas = Canvas.from_matplotlib(fig, strict=True) + result, _ = canvas.render(savefig=True) + FigureCanvasAgg(result).draw() + actual = np.asarray(result.canvas.buffer_rgba()).copy() + assert original.shape == actual.shape + assert np.mean(np.abs(original.astype(float) - actual)) < 2.0 + canvas.savefig(tmp_path / "figure.png") + canvas.savefig(tmp_path / "figure.svg") + canvas.savefig(tmp_path / "figure.html", backend="plotly") + assert all( + (tmp_path / name).stat().st_size > 100 + for name in ("figure.png", "figure.svg", "figure.html") + ) + + +@pytest.mark.parametrize("layout", ["constrained", "tight", None]) +def test_layout_ratios_and_reflow(layout): + fig, axes = plt.subplots(2, 2, layout=layout, gridspec_kw={"width_ratios": [1, 3]}) + axes[0, 0].set_ylabel("Vertical label") + fig.canvas.draw() + canvas, copied, imported = render(fig) + for original, result in zip(axes.flat, imported.flat): + np.testing.assert_allclose( + result.get_position().bounds, original.get_position().bounds, atol=0.02 + ) + canvas.set_size_inches(9, 5) + resized, result = canvas.render() + resized.canvas.draw() + assert result[0, 1].get_position().width / result[ + 0, 0 + ].get_position().width == pytest.approx(3) + + +def test_subfigures_mosaic_and_nested_layout(): + fig = plt.figure() + left, right = fig.subfigures(1, 2) + a = left.subplots() + grid = right.add_gridspec(2, 2) + b = right.add_subplot(grid[0, :]) + c = right.add_subplot(grid[1, 0].subgridspec(1, 1)[0]) + for ax in (a, b, c): + ax.plot([1, 2]) + _, _, imported = render(fig) + for source, result in zip((a, b, c), imported.flat): + expected = ( + source.get_position() + .transformed(source.figure.transSubfigure) + .transformed(fig.transFigure.inverted()) + ) + np.testing.assert_allclose(result.get_position().bounds, expected.bounds) + + +def test_composite_text_boxes_effects_and_clip_boxes(): + import matplotlib.patheffects as pe + from matplotlib.offsetbox import AnchoredText, AnnotationBbox, TextArea + from matplotlib.transforms import Bbox, TransformedBbox + + fig, ax = plt.subplots() + ax.add_artist(AnnotationBbox(TextArea("boxed"), (0.5, 0.5))) + ax.add_artist(AnchoredText("anchor", loc="upper left")) + ax.text(0.2, 0.4, "$x^2$\nline two", linespacing=1.7, bbox={"facecolor": "yellow"}) + (line,) = ax.plot( + [0, 1], [0, 1], path_effects=[pe.Stroke(linewidth=4), pe.Normal()] + ) + line.set_clip_box( + TransformedBbox(Bbox.from_bounds(0.1, 0.1, 0.6, 0.6), ax.transAxes) + ) + canvas, copied, axes = render(fig) + result = axes[0, 0] + assert result.texts[0].get_bbox_patch() is not None + assert result.texts[0]._linespacing == 1.7 + assert len(result.lines[0].get_path_effects()) == 2 + assert len(result.artists) == 2 + assert result.lines[0].get_clip_box().bounds == pytest.approx( + line.get_clip_box().bounds + ) + + +def test_unit_converter_and_masked_empty_data(): + import pint + + units = pint.UnitRegistry() + units.setup_matplotlib() + fig, axes = plt.subplots(1, 2) + axes[0].plot(np.arange(3) * units.second, np.arange(3) * units.meter) + axes[1].scatter([], []) + axes[1].plot(np.ma.array([0, 1, 2], mask=[0, 1, 0]), [1, 2, 3]) + axes[1].imshow(np.ma.array([[1, 2], [3, 4]], mask=[[0, 1], [0, 0]])) + _, _, imported = render(fig) + assert str(imported[0, 0].xaxis.get_units()) == "second" + assert len(imported[0, 1].collections[0].get_offsets()) == 0 + assert np.isnan(imported[0, 1].lines[0].get_xdata()[1]) + assert imported[0, 1].images[0].get_array().mask[0, 1] + + +@pytest.mark.parametrize( + "backend,extension", [("plotext", "txt"), ("tikzfigure", "tikz")] +) +def test_portable_import_export_remaining_backends(tmp_path, backend, extension): + fig, ax = plt.subplots() + ax.plot([0, 1, 2], [1, 3, 2], color="red", marker="o") + canvas = Canvas.from_matplotlib(fig, strict=True) + path = tmp_path / ("imported." + extension) + canvas.savefig(str(path), backend=backend) + assert path.is_file() and path.stat().st_size > 0 + + +def test_retained_container_group_metadata(): + fig, ax = plt.subplots() + bars = ax.bar([0, 1], [2, 3], label="group") + errors = ax.errorbar([0, 1], [2, 3], yerr=0.5, lolims=True, label="limits") + canvas, _, _ = render(fig) + subplot = canvas.subplot() + assert subplot.import_groups[id(bars)]["label"] == "group" + np.testing.assert_allclose(subplot.import_groups[id(bars)]["datavalues"], [2, 3]) + assert ( + len( + [ + entry + for entry in subplot.line_data + if id(bars) in entry["source_container_ids"] + ] + ) + == 2 + ) + assert ( + len( + [ + entry + for entry in subplot.line_data + if id(errors) in entry["source_container_ids"] + ] + ) + >= 2 + ) + + +def test_source_and_render_lifetimes_are_independent(): + import gc + import weakref + + fig, ax = plt.subplots() + ax.add_patch(Circle((0.5, 0.5), 0.2)) + canvas = Canvas.from_matplotlib(fig, strict=True) + reference = weakref.ref(fig) + plt.close(fig) + del ax, fig + gc.collect() + assert reference() is None + first, axes = canvas.render() + axes[0, 0].patches[0].set_radius(0.8) + second, axes = canvas.render() + assert axes[0, 0].patches[0].radius == 0.2 + + +def test_scatter_plotly_normalization_sizes_and_hollow_markers(): + fig, ax = plt.subplots() + points = ax.scatter([0, 1], [1, 2], c=[1, 10], s=[9, 36], norm=LogNorm(1, 100)) + ax.scatter([2], [3], facecolors="none", edgecolors="red") + canvas = Canvas.from_matplotlib(fig, strict=True) + result = canvas.render(backend="plotly") + assert result.data[0].marker.size == pytest.approx([4, 8]) + assert result.data[1].marker.symbol == "circle-open" + expected = points.cmap(points.norm([1, 10])) + for color, rgba in zip(result.data[0].marker.color, expected): + r, g, b = np.round(rgba[:3] * 255).astype(int) + assert color == f"rgba({r},{g},{b},{rgba[3]})" + + +def test_arbitrary_axes_rectangles_are_preserved(): + fig = plt.figure() + first = fig.add_axes([0.1, 0.2, 0.6, 0.5]) + second = fig.add_axes([0.4, 0.3, 0.5, 0.6]) + first.plot([0, 1]) + second.plot([1, 0]) + _, _, copied = render(fig) + for source, result in zip((first, second), copied.flat): + np.testing.assert_allclose( + source.get_position().bounds, result.get_position().bounds + ) + + +def test_hidden_fixed_ticks_and_secondary_data_location(): + fig, ax = plt.subplots() + ax.set_xticks([0, 1, 2], ["a", "b", "c"]) + ax.get_xticklabels()[1].set_visible(False) + _, _, copied = render(fig) + assert [tick.get_text() for tick in copied[0, 0].get_xticklabels()] == ["a", "c"] + + +def test_standalone_and_projection_colorbars(): + from matplotlib.cm import ScalarMappable + + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + points = ax.scatter([1, 2], [2, 3], [3, 4], c=[1, 2]) + fig.colorbar(points, ax=ax) + fig.colorbar(ScalarMappable(norm=LogNorm(1, 10)), ax=ax, orientation="horizontal") + _, copied, axes = render(fig) + bars = [axis._colorbar for axis in copied.axes if hasattr(axis, "_colorbar")] + assert bars[0].mappable is axes[0, 0].collections[0] + assert isinstance(bars[1].norm, LogNorm) + + +def test_custom_title_placement_and_clearing_imported_settings(): + fig, ax = plt.subplots() + ax.set_title("custom", x=0.2, y=0.85, pad=13) + ax.set_title("left", loc="left", x=0.05, y=0.9) + ax.set_xscale("symlog", linthresh=2) + ax.grid(True, axis="y") + canvas, _, copied = render(fig) + assert copied[0, 0].title.get_position() == (0.2, 0.85) + assert copied[0, 0]._left_title.get_position() == (0.05, 0.9) + canvas.subplot().set_title("") + canvas.subplot().set_xscale("linear") + canvas.subplot().set_grid(True) + _, edited = canvas.render() + assert edited[0, 0].get_title() == "" + assert edited[0, 0].get_xscale() == "linear" + assert all(line.get_visible() for line in edited[0, 0].get_xgridlines())