From 25553419392c7428cdc198f50baafa9b8de777fe Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Wed, 23 Sep 2026 09:00:01 +0200 Subject: [PATCH 1/5] Added a matplotlib parser --- docs/matplotlib-import-support.md | 154 +++++ .../backends/matplotlib/importer.py | 545 ++++++++++++++++++ src/maxplotlib/canvas/canvas.py | 22 +- src/maxplotlib/subfigure/line_plot.py | 72 ++- .../tests/test_matplotlib_import.py | 339 +++++++++++ 5 files changed, 1102 insertions(+), 30 deletions(-) create mode 100644 docs/matplotlib-import-support.md create mode 100644 src/maxplotlib/backends/matplotlib/importer.py create mode 100644 src/maxplotlib/tests/test_matplotlib_import.py diff --git a/docs/matplotlib-import-support.md b/docs/matplotlib-import-support.md new file mode 100644 index 0000000..a5a64a2 --- /dev/null +++ b/docs/matplotlib-import-support.md @@ -0,0 +1,154 @@ +# 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. 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. +- [ ] 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. +- [ ] 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. +- [ ] Shared-axis relationships and linked limits after import. +- [ ] Preserve omitted/empty grid cells without drawing extra empty axes. +- [ ] Spanning cells, subplot mosaics, nested GridSpec, and subfigures. +- [ ] Grid width/height ratios, margins, spacing, constrained/tight layout. +- [ ] Arbitrary axes rectangles, overlapping axes, inset axes, inset connectors. +- [ ] Secondary axes with forward/inverse coordinate functions. +- [ ] Figure backgrounds, frame styling, and figure-level text/patches/images. +- [ ] Figure title/shared-label typography and placement. +- [ ] Figure-level legends and shared colorbars. + +Irregular grids currently warn and fall back to a single row. A 2D input array +defines primary-axis slots; slots occupied only by a twin are not compacted. + +## 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. +- [ ] Preserve date/time, categorical, quantity/unit converters and formatters. +- [ ] Custom dash sequences, cap/join styles, markevery, and gap colors. +- [ ] Infinite `axline` semantics, event plots, stem containers. +- [ ] 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. +- [ ] 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. +- [ ] Error limits/arrows, subsampled errors, and independently styled components. +- [ ] Restore semantic fill boundaries/where masks when recoverable. +- [ ] Spans in blended coordinates; general rectangles, circles, ellipses, wedges. +- [ ] Compound polygons, holes, curved paths, arbitrary PathPatch geometry. +- [ ] Box plots, violin plots, pie/donut charts, hist2d, hexbin. +- [ ] Preserve statistical groupings when containers/metadata retain them. + +## 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. +- [ ] `pcolor`, `pcolormesh`, nonuniform grids, QuadMesh, triangular meshes. +- [ ] Contours, filled contours, levels, labels, and contour topology. +- [ ] Quiver, barbs, streamplots, vector-field keys. +- [ ] Axes colorbars tied to the correct image/scatter/mesh mappable. +- [ ] Colorbar orientation, label, ticks, limits, extend, and normalization. +- [ ] 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. +- [ ] Text boxes, multiline spacing, math/TeX fidelity, wrapping, clipping. +- [ ] Axes/figure fractions, point/pixel offsets, blended and callable coordinates. +- [ ] Annotation arrows with full backend parity and artist-relative coordinates. +- [ ] Left/right titles, custom title/label positions, offset text. +- [ ] 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. +- [ ] Non-default log bases; symlog, logit, asinh, function/custom scales. +- [ ] Automatic/fixed minor ticks, locator/formatter configuration and units. +- [ ] Tick placement, direction, length, width, color, rotation, and font styling. +- [ ] Per-axis major/minor grid visibility and line styling. +- [ ] Spine visibility, colors, widths, bounds, and positions. +- [ ] Autoscale flags, sticky edges, margins, adjustable/anchor/box aspect. +- [ ] Legend order, renamed labels, proxy handles, multiple legends, grouping. +- [ ] Legend location/anchor, columns, title, typography, frame and spacing. + +## Transforms, metadata, and advanced axes + +- [ ] General affine/nonlinear/blended transforms, coordinate rebinding. +- [ ] Clip paths/boxes, path effects, rasterization, sketch settings, filters. +- [ ] Hidden artists retained as hidden editable entries (currently skipped). +- [ ] Artist IDs, URLs, picking, metadata, and accessibility descriptions. +- [ ] Polar, geographic/custom projections, axisartist and parasite axes. +- [ ] 3D lines/scatter, surfaces, wireframes, collections, camera/projection. +- [ ] Animations, widgets, callbacks, and interactive state (separate adapters). +- [ ] 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. +- [ ] Image comparisons with tolerances, plus export tests for all backends. +- [ ] Large figures, empty/masked data, performance and memory benchmarks. + +Next priorities: colorbar ownership; layout/shared-axis fidelity; axis scales, +ticks and legends; image/mesh support; comprehensive diagnostics. Add semantic +entries when source metadata supports them, otherwise identify geometry-only +imports and backend limitations explicitly. + +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). diff --git a/src/maxplotlib/backends/matplotlib/importer.py b/src/maxplotlib/backends/matplotlib/importer.py new file mode 100644 index 0000000..bdd2494 --- /dev/null +++ b/src/maxplotlib/backends/matplotlib/importer.py @@ -0,0 +1,545 @@ +"""Translate Matplotlib artists into backend-independent canvas entries.""" + +import copy +import warnings + +import numpy as np +from matplotlib.axes import Axes +from matplotlib.collections import LineCollection, PathCollection, PolyCollection +from matplotlib.container import BarContainer, ErrorbarContainer +from matplotlib.figure import Figure +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 + + +def import_matplotlib(canvas_cls, source, *, strict=False, **canvas_kwargs): + def unsupported(message): + if strict: + raise NotImplementedError(message) + warnings.warn(message, UserWarning, stacklevel=3) + + 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 = source if whole_figure else axes[0].get_figure() + if any(ax.get_figure() is not figure for ax in axes): + raise ValueError("All Axes must belong to the same Figure") + + # Colorbar axes are decorations, not independent data subplots. + selected = [] + twins = [] + # 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: + unsupported("Colorbar axes are not imported") + elif ax.name != "rectilinear": + unsupported(f"Unsupported {ax.name!r} projection; axes skipped") + 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" + if any(p is parent and d == direction for p, _, d in twins): + unsupported("Multiple twins in the same direction are not imported") + else: + 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: + if len(selected) > 1: + unsupported( + "Irregular, nested, or spanning subplot layout cannot be preserved; " + "importing axes in one row" + ) + 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) + for row, col, ax in positioned: + target = canvas.add_subplot(row=row, col=col) + _import_axes(ax, target, unsupported) + for parent, twin, direction in twins: + if parent is ax: + factory = canvas.twinx if direction == "x" else canvas.twiny + _import_axes(twin, factory(row=row, col=col), unsupported) + if whole_figure: + for getter, setter in ( + ("get_suptitle", "suptitle"), + ("get_supxlabel", "supxlabel"), + ("get_supylabel", "supylabel"), + ): + value = getattr(figure, getter, lambda: "")() + if value: + getattr(canvas, setter)(value) + if figure.legends: + unsupported("Figure-level legends are not imported") + if figure.artists or figure.lines or figure.patches or figure.images: + unsupported("Figure-level artists are not imported") + titles = {figure._suptitle, figure._supxlabel, figure._supylabel} + if any(text not in titles for text in figure.texts): + unsupported("Figure-level text is not imported") + return canvas + + +def _style(artist): + return dict( + label=artist.get_label(), + alpha=copy.deepcopy(artist.get_alpha()), + zorder=artist.get_zorder(), + ) + + +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=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(), + **_style(line), + ) + + +def _text_style(text): + return 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(), + ) + + +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 not line.get_visible(): + return False + if line.get_transform() != ax.transData or any( + bars.get_transform() != ax.transData for bars in ranges + ): + unsupported("Error bars with non-data transforms are not imported") + return True + if any(cap.get_marker() not in ("_", "|") for cap in caps): + unsupported("Error-bar limit arrows are not supported; importing geometry") + 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): + unsupported("Subsampled error bars are imported as geometry") + 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 ranges: + 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", collection.get_linestyles()), + ): + 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 + ): + unsupported("Transformed or scalar-mapped collection is not imported") + 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]) + ) + ): + unsupported("Compound or curved polygon paths are not imported") + continue + 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): + consumed = set() + # A bar container retains grouping and orientation, unlike raw rectangles. + for container in ax.containers: + if isinstance(container, ErrorbarContainer): + if _import_errorbar(container, ax, target, unsupported): + consumed.update(container.get_children()) + 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 not patch.get_visible(): + continue + kwargs = _style(patch) + kwargs.update( + color=patch.get_facecolor(), + edgecolor=patch.get_edgecolor(), + linewidth=patch.get_linewidth(), + hatch=patch.get_hatch(), + 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, + ) + + for line in ax.lines: + if line in consumed or not line.get_visible(): + 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: + unsupported("Line with a non-data transform is not imported") + continue + target.plot( + np.array(x, copy=True), + np.array(y, copy=True), + **_line_style(line), + ) + for collection in ax.collections: + if collection in consumed or not collection.get_visible(): + continue + if _import_collection(collection, ax, target, unsupported): + continue + if not isinstance(collection, PathCollection): + unsupported(f"Unsupported {type(collection).__name__}; collection skipped") + continue + paths = collection.get_paths() + if ( + len(paths) != 1 + or collection.get_offset_transform() != ax.transData + or not isinstance(collection.get_transform(), IdentityTransform) + ): + unsupported("Collection is not a supported scatter plot; skipped") + 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 ax.images: + if not image.get_visible(): + continue + if image.get_transform() != ax.transData: + unsupported("Image with a non-data transform is not imported") + 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(), + **_style(image), + ) + for text in ax.texts: + if not text.get_visible(): + continue + if isinstance(text, Annotation): + if text.xycoords != "data" or text.anncoords != "data": + unsupported("Annotations outside data coordinates are not imported") + continue + arrowprops = text.arrowprops + if arrowprops and any(key in arrowprops for key in ("patchA", "patchB")): + unsupported( + "Annotation arrows referring to other patches are not imported" + ) + 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: + unsupported("Non-data-coordinate text is not imported") + continue + target.text( + *text.get_position(), + text.get_text(), + **_text_style(text), + **_style(text), + ) + for patch in ax.patches: + if patch in consumed or not patch.get_visible(): + continue + if patch.get_data_transform() != ax.transData: + 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) + if any(p not in consumed and p.get_visible() for p in ax.patches) or ax.artists: + unsupported("Unsupported patches or custom artists are not imported") + if ax.child_axes: + unsupported("Inset and secondary child axes are not imported") + + target.set_title(ax.get_title(), **_text_style(ax.title)) + 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")() + if scale not in ("linear", "log"): + unsupported(f"{name}-axis scale {scale!r} is not imported") + else: + 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=[label.get_text() for label in axis.get_ticklabels()], + ) + 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() diff --git a/src/maxplotlib/canvas/canvas.py b/src/maxplotlib/canvas/canvas.py index ad8abbc..d8e9a41 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -393,6 +393,25 @@ def __init__( # Factory # ------------------------------------------------------------------ + @classmethod + def from_matplotlib(cls, source, *, strict=False, **canvas_kwargs): + """Import a Figure, Axes, or one-/two-dimensional array of Axes. + + Copies 2D lines, scatter, bars, errors, polygons, stairs, images, + data-coordinate text/annotations, and basic axis settings into + independent plot entries. Supports one twinx/twiny per primary axes. + Two-dimensional arrays define the layout explicitly; figures and flat + arrays use a common, unspanned GridSpec when available. + + Unsupported content emits UserWarning, or raises NotImplementedError + with ``strict=True``. This is a semantic import, not a pixel-perfect + reproduction: custom ticks, legend placement, and backend-specific + decoration may differ. ``canvas_kwargs`` override figure defaults. + """ + from maxplotlib.backends.matplotlib.importer import import_matplotlib + + return import_matplotlib(cls, source, strict=strict, **canvas_kwargs) + @classmethod def subplots( cls, @@ -2306,10 +2325,9 @@ def plot_matplotlib( ax = axes[row][col] 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.") diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index ce157a0..81b9f8f 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -1693,6 +1693,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"], @@ -1722,8 +1724,7 @@ 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() + ax.grid(self._grid) if self._axis_settings: axis_settings = dict(self._axis_settings) axis_args = axis_settings.pop("args", ()) @@ -2209,6 +2210,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 +2304,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 @@ -3074,6 +3080,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 +3097,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 +3139,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 +3310,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 +3341,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,13 +3350,18 @@ 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"), + colorscale=_colormap_to_plotly_colorscale( + kwargs.get("cmap", "Viridis") + ), showscale=True, ) traces.append(heatmap) diff --git a/src/maxplotlib/tests/test_matplotlib_import.py b/src/maxplotlib/tests/test_matplotlib_import.py new file mode 100644 index 0000000..dc24ef8 --- /dev/null +++ b/src/maxplotlib/tests/test_matplotlib_import.py @@ -0,0 +1,339 @@ +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]) + ax.pcolormesh([[1, 2], [3, 4]]) + children = ax.get_children() + with pytest.warns(UserWarning, match="collection skipped"): + canvas = Canvas.from_matplotlib(fig) + assert len(canvas._subplot_matrix[0][0].line_data) == 1 + with pytest.raises(NotImplementedError, match="collection 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]) + with pytest.warns(UserWarning) as caught: + canvas = Canvas.from_matplotlib(fig) + assert any("Colorbar" in str(w.message) for w in caught) + assert not any("Twin" in str(w.message) for w in caught) + assert len(canvas._subplots) == 1 + assert len(canvas._twinx_subplots) == 1 + + +@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[0].get_xdata(), [0.2, 0.8]) + np.testing.assert_allclose(imported[0, 0].lines[1].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") + with pytest.raises(NotImplementedError, match="outside data coordinates"): + Canvas.from_matplotlib(ax, strict=True) + fig2, base = plt.subplots() + base.twinx() + base.twinx() + with pytest.raises(NotImplementedError, match="Multiple twins"): + Canvas.from_matplotlib(fig2, strict=True) + + +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]) + with pytest.warns(UserWarning, match="layout"): + canvas = Canvas.from_matplotlib(fig) + assert (canvas.nrows, canvas.ncols) == (1, 2) + with pytest.raises(NotImplementedError, match="layout"): + Canvas.from_matplotlib(fig, strict=True) + + +@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]) From 9a559905887c748eb7e33fcf61fd9df4165fdc5f Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 08:31:50 +0200 Subject: [PATCH 2/5] Extende matplotlib parsing coverage --- docs/matplotlib-import-support.md | 160 +++-- .../backends/matplotlib/importer.py | 625 ++++++++++++++++-- src/maxplotlib/canvas/canvas.py | 153 ++++- src/maxplotlib/subfigure/line_plot.py | 184 +++++- .../tests/test_matplotlib_import.py | 45 +- 5 files changed, 993 insertions(+), 174 deletions(-) diff --git a/docs/matplotlib-import-support.md b/docs/matplotlib-import-support.md index a5a64a2..4514a68 100644 --- a/docs/matplotlib-import-support.md +++ b/docs/matplotlib-import-support.md @@ -5,7 +5,12 @@ 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. Unchecked items are +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 @@ -19,10 +24,10 @@ not guarantee that every backend can render the result identically. - [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. -- [ ] Structured import report with artist identities, severity, and fallbacks. +- [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. -- [ ] Optional serialized figure input with an explicit trust boundary. +- [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 @@ -36,18 +41,26 @@ conversion is not possible. - [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. -- [ ] Shared-axis relationships and linked limits after import. -- [ ] Preserve omitted/empty grid cells without drawing extra empty axes. -- [ ] Spanning cells, subplot mosaics, nested GridSpec, and subfigures. -- [ ] Grid width/height ratios, margins, spacing, constrained/tight layout. + 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. - [ ] Arbitrary axes rectangles, overlapping axes, inset axes, inset connectors. -- [ ] Secondary axes with forward/inverse coordinate functions. -- [ ] Figure backgrounds, frame styling, and figure-level text/patches/images. -- [ ] Figure title/shared-label typography and placement. -- [ ] Figure-level legends and shared colorbars. - -Irregular grids currently warn and fall back to a single row. A 2D input array -defines primary-axis slots; slots occupied only by a twin are not compacted. + Rectangles, overlaps and child insets are implemented; connectors referring to + other axes still need live-reference rebinding. +- [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 @@ -57,13 +70,13 @@ defines primary-axis slots; slots occupied only by a twin are not compacted. - [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. -- [ ] Preserve date/time, categorical, quantity/unit converters and formatters. -- [ ] Custom dash sequences, cap/join styles, markevery, and gap colors. -- [ ] Infinite `axline` semantics, event plots, stem containers. -- [ ] Scalar-mapped line collections, offset collections, per-point transforms. +- [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. -- [ ] Preserve ordering between artist types at equal z-order. +- [x] Preserve ordering between artist types at equal z-order. ## Bars, errors, fills, and statistical plots @@ -77,12 +90,16 @@ defines primary-axis slots; slots occupied only by a twin are not compacted. - [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. -- [ ] Error limits/arrows, subsampled errors, and independently styled components. +- [x] Error limits/arrows, subsampled errors, and independently styled components. - [ ] Restore semantic fill boundaries/where masks when recoverable. -- [ ] Spans in blended coordinates; general rectangles, circles, ellipses, wedges. -- [ ] Compound polygons, holes, curved paths, arbitrary PathPatch geometry. -- [ ] Box plots, violin plots, pie/donut charts, hist2d, hexbin. -- [ ] Preserve statistical groupings when containers/metadata retain them. + 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 @@ -90,12 +107,12 @@ defines primary-axis slots; slots occupied only by a twin are not compacted. 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. -- [ ] `pcolor`, `pcolormesh`, nonuniform grids, QuadMesh, triangular meshes. -- [ ] Contours, filled contours, levels, labels, and contour topology. -- [ ] Quiver, barbs, streamplots, vector-field keys. -- [ ] Axes colorbars tied to the correct image/scatter/mesh mappable. -- [ ] Colorbar orientation, label, ticks, limits, extend, and normalization. -- [ ] Multiple/shared colorbars and explicit colorbar axes. +- [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 @@ -104,11 +121,12 @@ defines primary-axis slots; slots occupied only by a twin are not compacted. - [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. -- [ ] Text boxes, multiline spacing, math/TeX fidelity, wrapping, clipping. +- [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. -- [ ] Left/right titles, custom title/label positions, offset text. -- [ ] AnnotationBbox, OffsetBox, tables, and other composite text artists. +- [x] Left/right titles, custom title/label positions, offset text. +- [x] AnnotationBbox, OffsetBox, tables, and other composite text artists. ## Axes, ticks, grids, and legends @@ -116,39 +134,73 @@ defines primary-axis slots; slots occupied only by a twin are not compacted. - [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. -- [ ] Non-default log bases; symlog, logit, asinh, function/custom scales. -- [ ] Automatic/fixed minor ticks, locator/formatter configuration and units. -- [ ] Tick placement, direction, length, width, color, rotation, and font styling. -- [ ] Per-axis major/minor grid visibility and line styling. -- [ ] Spine visibility, colors, widths, bounds, and positions. -- [ ] Autoscale flags, sticky edges, margins, adjustable/anchor/box aspect. -- [ ] Legend order, renamed labels, proxy handles, multiple legends, grouping. -- [ ] Legend location/anchor, columns, title, typography, frame and spacing. +- [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 -- [ ] General affine/nonlinear/blended transforms, coordinate rebinding. -- [ ] Clip paths/boxes, path effects, rasterization, sketch settings, filters. -- [ ] Hidden artists retained as hidden editable entries (currently skipped). +- [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. -- [ ] Polar, geographic/custom projections, axisartist and parasite axes. -- [ ] 3D lines/scatter, surfaces, wireframes, collections, camera/projection. +- [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). -- [ ] Optional raster fallback for unsupported artists with explicit loss reporting. + 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. -- [ ] Image comparisons with tolerances, plus export tests for all backends. -- [ ] Large figures, empty/masked data, performance and memory benchmarks. - -Next priorities: colorbar ownership; layout/shared-axis fidelity; axis scales, -ticks and legends; image/mesh support; comprehensive diagnostics. Add semantic -entries when source metadata supports them, otherwise identify geometry-only -imports and backend limitations explicitly. +- [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/src/maxplotlib/backends/matplotlib/importer.py b/src/maxplotlib/backends/matplotlib/importer.py index bdd2494..1792cfe 100644 --- a/src/maxplotlib/backends/matplotlib/importer.py +++ b/src/maxplotlib/backends/matplotlib/importer.py @@ -1,13 +1,18 @@ """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 +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 @@ -15,13 +20,59 @@ from matplotlib.ticker import FixedLocator from matplotlib.transforms import IdentityTransform +from .import_state import ( + ImportReport, + ReboundSnapshot, + capture_axis_state, + figure_bounds, + root_figure, +) -def import_matplotlib(canvas_cls, source, *, strict=False, **canvas_kwargs): - def unsupported(message): + +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: - raise NotImplementedError(message) + 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: @@ -47,22 +98,26 @@ def unsupported(message): 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 = source if whole_figure else axes[0].get_figure() - if any(ax.get_figure() is not figure for ax in axes): + 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: - unsupported("Colorbar axes are not imported") - elif ax.name != "rectilinear": - unsupported(f"Unsupported {ax.name!r} projection; axes skipped") + colorbars.append(ax._colorbar) else: parent = next( ( @@ -76,10 +131,7 @@ def unsupported(message): selected.append((index, ax)) else: direction = "x" if ax.get_shared_x_axes().joined(ax, parent) else "y" - if any(p is parent and d == direction for p, _, d in twins): - unsupported("Multiple twins in the same direction are not imported") - else: - twins.append((parent, ax, direction)) + twins.append((parent, ax, direction)) selected.sort(key=lambda item: item[0]) if not selected: raise ValueError("No supported Axes to import") @@ -104,11 +156,8 @@ def unsupported(message): for spec, (_, ax) in zip(specs, selected) ] else: - if len(selected) > 1: - unsupported( - "Irregular, nested, or spanning subplot layout cannot be preserved; " - "importing axes in one row" - ) + # 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)] @@ -117,14 +166,86 @@ def unsupported(message): 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 - _import_axes(twin, factory(row=row, col=col), unsupported) + 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"), @@ -132,14 +253,48 @@ def unsupported(message): ): value = getattr(figure, getter, lambda: "")() if value: - getattr(canvas, setter)(value) - if figure.legends: - unsupported("Figure-level legends are not imported") - if figure.artists or figure.lines or figure.patches or figure.images: - unsupported("Figure-level artists are not imported") + 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} - if any(text not in titles for text in figure.texts): - unsupported("Figure-level text is not imported") + 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 @@ -148,6 +303,13 @@ def _style(artist): 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(), ) @@ -184,7 +346,11 @@ def _scatter_marker(path): def _line_style(line): return dict( color=line.get_color(), - linestyle=line.get_linestyle(), + 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(), @@ -192,12 +358,21 @@ def _line_style(line): 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): - return dict( + kwargs = dict( color=text.get_color(), fontsize=text.get_fontsize(), fontweight=text.get_fontweight(), @@ -206,8 +381,28 @@ def _text_style(text): 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.""" @@ -216,15 +411,23 @@ def _import_errorbar(container, ax, target, unsupported): # Leave these artists for the geometry import instead of inventing centers. if line is None: return False - if not line.get_visible(): - return False if line.get_transform() != ax.transData or any( bars.get_transform() != ax.transData for bars in ranges ): - unsupported("Error bars with non-data transforms are not imported") - return True + 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): - unsupported("Error-bar limit arrows are not supported; importing geometry") + 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 = {} @@ -234,7 +437,6 @@ def _import_errorbar(container, ax, target, unsupported): ): segments = bars.get_segments() if len(segments) != len(x): - unsupported("Subsampled error bars are imported as geometry") return False bounds = np.full((len(x), 2), np.nan) for i, segment in enumerate(segments): @@ -250,7 +452,19 @@ def _import_errorbar(container, ax, target, unsupported): 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): @@ -272,7 +486,8 @@ def _collection_style(collection, index): ("facecolor", collection.get_facecolors()), ("edgecolor", collection.get_edgecolors()), ("linewidth", collection.get_linewidths()), - ("linestyle", collection.get_linestyles()), + ("linestyle", getattr(collection, "_us_linestyles", collection.get_linestyles())), + ("antialiased", collection._antialiaseds), ): kwargs[key] = ( copy.deepcopy(values[index % len(values)]) if len(values) else "none" @@ -290,8 +505,10 @@ def _import_collection(collection, ax, target, unsupported): 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 ): - unsupported("Transformed or scalar-mapped collection is not imported") + _native(collection, ax, target, unsupported) return True if isinstance(collection, LineCollection): for i, segment in enumerate(collection.get_segments()): @@ -309,8 +526,8 @@ def _import_collection(collection, ax, target, unsupported): ~np.isin(path.codes, [Path.MOVETO, Path.LINETO, Path.CLOSEPOLY]) ) ): - unsupported("Compound or curved polygon paths are not imported") - continue + _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] @@ -324,20 +541,57 @@ def _import_collection(collection, ax, target, unsupported): 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 ax.containers: + 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 not patch.get_visible(): + 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( @@ -345,6 +599,9 @@ def _import_axes(ax, target, unsupported): 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": @@ -364,8 +621,17 @@ def _import_axes(ax, target, unsupported): **kwargs, ) - for line in ax.lines: - if line in consumed or not line.get_visible(): + 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 ( @@ -383,20 +649,23 @@ def _import_axes(ax, target, unsupported): target.axvline(x[0], ymin=y[0], ymax=y[1], **_line_style(line)) continue if line.get_transform() != ax.transData: - unsupported("Line with a non-data transform is not imported") + _native(line, ax, target, unsupported) continue target.plot( np.array(x, copy=True), np.array(y, copy=True), **_line_style(line), ) - for collection in ax.collections: - if collection in consumed or not collection.get_visible(): + 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): - unsupported(f"Unsupported {type(collection).__name__}; collection skipped") + _native(collection, ax, target, unsupported) continue paths = collection.get_paths() if ( @@ -404,7 +673,7 @@ def _import_axes(ax, target, unsupported): or collection.get_offset_transform() != ax.transData or not isinstance(collection.get_transform(), IdentityTransform) ): - unsupported("Collection is not a supported scatter plot; skipped") + _native(collection, ax, target, unsupported) continue offsets = np.ma.asarray(collection.get_offsets()).filled(np.nan) kwargs = _style(collection) @@ -430,11 +699,9 @@ def _import_axes(ax, target, unsupported): else: kwargs["facecolors"] = "none" target.scatter(offsets[:, 0].copy(), offsets[:, 1].copy(), **kwargs) - for image in ax.images: - if not image.get_visible(): - continue - if image.get_transform() != ax.transData: - unsupported("Image with a non-data transform is not imported") + 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(), @@ -443,20 +710,27 @@ def _import_axes(ax, target, unsupported): 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 ax.texts: - if not text.get_visible(): + 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": - unsupported("Annotations outside data coordinates are not imported") + _native(text, ax, target, unsupported) continue arrowprops = text.arrowprops if arrowprops and any(key in arrowprops for key in ("patchA", "patchB")): - unsupported( - "Annotation arrows referring to other patches are not imported" - ) + _native(text, ax, target, unsupported) continue target.annotate( text.get_text(), @@ -469,7 +743,7 @@ def _import_axes(ax, target, unsupported): ) continue if text.get_transform() != ax.transData: - unsupported("Non-data-coordinate text is not imported") + _native(text, ax, target, unsupported) continue target.text( *text.get_position(), @@ -477,10 +751,12 @@ def _import_axes(ax, target, unsupported): **_text_style(text), **_style(text), ) - for patch in ax.patches: - if patch in consumed or not patch.get_visible(): + for patch in _tracked(ax.patches, ax, target, unsupported): + if patch in consumed: continue - if patch.get_data_transform() != ax.transData: + 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(), @@ -507,12 +783,21 @@ def _import_axes(ax, target, unsupported): xy[:, 0].copy(), xy[:, 1].copy(), closed=patch.get_closed(), **kwargs ) consumed.add(patch) - if any(p not in consumed and p.get_visible() for p in ax.patches) or ax.artists: - unsupported("Unsupported patches or custom artists are not imported") - if ax.child_axes: - unsupported("Inset and secondary child axes are not imported") + else: + _native(patch, ax, target, unsupported) + for artist in _tracked(list(ax.artists) + list(ax.tables), ax, target, unsupported): + if not isinstance(artist, Legend): + _native(artist, ax, target, unsupported) + target._import_child_axes = [] + for child in ax.child_axes: + _import_child(child, ax, target, unsupported) - target.set_title(ax.get_title(), **_text_style(ax.title)) + 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) ) @@ -523,15 +808,14 @@ def _import_axes(ax, target, unsupported): target.set_ylim(*ax.get_ylim()) for name in ("x", "y"): scale = getattr(ax, f"get_{name}scale")() - if scale not in ("linear", "log"): - unsupported(f"{name}-axis scale {scale!r} is not imported") - else: - getattr(target, f"set_{name}scale")(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=[label.get_text() for label in axis.get_ticklabels()], + 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()) @@ -543,3 +827,200 @@ def _import_axes(ax, target, unsupported): 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 _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 d8e9a41..43a882c 100644 --- a/src/maxplotlib/canvas/canvas.py +++ b/src/maxplotlib/canvas/canvas.py @@ -394,23 +394,38 @@ def __init__( # ------------------------------------------------------------------ @classmethod - def from_matplotlib(cls, source, *, strict=False, **canvas_kwargs): - """Import a Figure, Axes, or one-/two-dimensional array of Axes. - - Copies 2D lines, scatter, bars, errors, polygons, stairs, images, - data-coordinate text/annotations, and basic axis settings into - independent plot entries. Supports one twinx/twiny per primary axes. - Two-dimensional arrays define the layout explicitly; figures and flat - arrays use a common, unspanned GridSpec when available. - - Unsupported content emits UserWarning, or raises NotImplementedError - with ``strict=True``. This is a semantic import, not a pixel-perfect - reproduction: custom ticks, legend placement, and backend-specific - decoration may differ. ``canvas_kwargs`` override figure defaults. + 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, **canvas_kwargs) + return import_matplotlib( + cls, + source, + strict=strict, + trusted=trusted, + fallback=fallback, + **canvas_kwargs, + ) @classmethod def subplots( @@ -2321,8 +2336,45 @@ 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) @@ -2373,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: @@ -2381,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, @@ -2398,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)") @@ -2717,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" @@ -2764,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 81b9f8f..3f85a43 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, @@ -1710,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) @@ -1724,7 +1761,9 @@ 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) - ax.grid(self._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", ()) @@ -1737,9 +1776,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( @@ -1851,6 +1898,37 @@ 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 + for child in 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) @staticmethod def _tag_matplotlib_artists(ax, artists_before, meta): @@ -2138,6 +2216,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. @@ -2190,7 +2272,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) @@ -2319,6 +2401,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, @@ -2327,7 +2417,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")) @@ -2339,6 +2433,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"]), @@ -3357,15 +3461,63 @@ def error_spec(values, scale): annotations.append(ann) elif plot_type == "imshow": kwargs = line["kwargs"] - heatmap = go.Heatmap( - z=line["data"], - colorscale=_colormap_to_plotly_colorscale( - kwargs.get("cmap", "Viridis") + 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, ), - showscale=True, ) - traces.append(heatmap) - last_heatmap_idx = len(traces) - 1 + 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 + ) 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 index dc24ef8..e1e8f25 100644 --- a/src/maxplotlib/tests/test_matplotlib_import.py +++ b/src/maxplotlib/tests/test_matplotlib_import.py @@ -133,12 +133,17 @@ def test_grid_state(grid): def test_unsupported_artists_warn_or_raise_without_changing_source(): fig, ax = plt.subplots() ax.plot([1, 2], [3, 4]) - ax.pcolormesh([[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="collection skipped"): + 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="collection skipped"): + with pytest.raises(NotImplementedError, match="artist skipped"): Canvas.from_matplotlib(fig, strict=True) assert ax.get_children() == children @@ -148,12 +153,14 @@ def test_colorbar_is_reported_and_twin_is_imported(): image = ax.imshow([[1, 2], [3, 4]]) fig.colorbar(image, ax=ax) ax.twinx().plot([1, 2], [3, 4]) - with pytest.warns(UserWarning) as caught: - canvas = Canvas.from_matplotlib(fig) - assert any("Colorbar" in str(w.message) for w in caught) - assert not any("Twin" in str(w.message) for w in caught) + 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"]) @@ -244,8 +251,8 @@ def test_line_collections_and_reference_lines(): 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[0].get_xdata(), [0.2, 0.8]) - np.testing.assert_allclose(imported[0, 0].lines[1].get_ydata(), [0.1, 0.7]) + 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") @@ -289,13 +296,14 @@ def test_annotation_without_arrow_and_marker_only_errors_in_plotly(): def test_unsupported_annotation_coordinates_and_multiple_twins(): fig, ax = plt.subplots() ax.annotate("label", (0.5, 0.5), xycoords="axes fraction") - with pytest.raises(NotImplementedError, match="outside data coordinates"): - Canvas.from_matplotlib(ax, strict=True) + 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() - with pytest.raises(NotImplementedError, match="Multiple twins"): - Canvas.from_matplotlib(fig2, strict=True) + imported, _ = Canvas.from_matplotlib(fig2, strict=True).render() + assert len(imported.axes) == 3 def test_dashed_line_collection_and_disconnected_fill(): @@ -315,11 +323,12 @@ def test_spanning_layout_is_reported(): grid = fig.add_gridspec(2, 2) fig.add_subplot(grid[0, :]) fig.add_subplot(grid[1, 0]) - with pytest.warns(UserWarning, match="layout"): - canvas = Canvas.from_matplotlib(fig) - assert (canvas.nrows, canvas.ncols) == (1, 2) - with pytest.raises(NotImplementedError, match="layout"): - Canvas.from_matplotlib(fig, strict=True) + 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]]]]) From 15162f1a062d05692099a8323930c4ce2e1de7df Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 09:39:56 +0200 Subject: [PATCH 3/5] Extend matplotlib parsing --- .github/workflows/matplotlib-import.yml | 23 + docs/matplotlib-import-support.md | 9 +- scripts/benchmark_matplotlib_import.py | 58 ++ .../backends/matplotlib/import_state.py | 342 ++++++++++ .../backends/matplotlib/importer.py | 45 +- src/maxplotlib/subfigure/line_plot.py | 5 +- .../tests/test_matplotlib_import_extended.py | 608 ++++++++++++++++++ 7 files changed, 1084 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/matplotlib-import.yml create mode 100644 scripts/benchmark_matplotlib_import.py create mode 100644 src/maxplotlib/backends/matplotlib/import_state.py create mode 100644 src/maxplotlib/tests/test_matplotlib_import_extended.py 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 index 4514a68..5bef77d 100644 --- a/docs/matplotlib-import-support.md +++ b/docs/matplotlib-import-support.md @@ -48,9 +48,12 @@ conversion is not possible. (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. -- [ ] Arbitrary axes rectangles, overlapping axes, inset axes, inset connectors. - Rectangles, overlaps and child insets are implemented; connectors referring to - other axes still need live-reference rebinding. +- [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. 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 index 1792cfe..ef754ba 100644 --- a/src/maxplotlib/backends/matplotlib/importer.py +++ b/src/maxplotlib/backends/matplotlib/importer.py @@ -20,6 +20,11 @@ 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, @@ -785,9 +790,24 @@ def _import_axes(ax, target, unsupported): 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 not isinstance(artist, Legend): - _native(artist, 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) @@ -965,6 +985,27 @@ def _capture_colorbar(colorbar, target): ) +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 diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index 3f85a43..fca77b1 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -1917,7 +1917,8 @@ def plot_matplotlib( else: ax.legend_ = legend legend._remove_method = ax._remove_legend - for child in self._import_child_axes: + 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" @@ -1929,6 +1930,8 @@ def plot_matplotlib( 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): 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..ed1e178 --- /dev/null +++ b/src/maxplotlib/tests/test_matplotlib_import_extended.py @@ -0,0 +1,608 @@ +"""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" + + +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()) From 546941e44dba78e8aa705cac72da9402641f1b29 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 10:38:26 +0200 Subject: [PATCH 4/5] formatting --- .../backends/matplotlib/importer.py | 31 +++++++++++++------ src/maxplotlib/subfigure/line_plot.py | 7 +++-- .../tests/test_matplotlib_import_extended.py | 16 +++++----- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/maxplotlib/backends/matplotlib/importer.py b/src/maxplotlib/backends/matplotlib/importer.py index ef754ba..15036b2 100644 --- a/src/maxplotlib/backends/matplotlib/importer.py +++ b/src/maxplotlib/backends/matplotlib/importer.py @@ -421,8 +421,10 @@ def _import_errorbar(container, ax, target, unsupported): ): return False components = [line, *caps, *ranges] - if any(_needs_native_style(artist) or artist.get_visible() != line.get_visible() - for artist in components): + 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 @@ -466,7 +468,9 @@ def _import_errorbar(container, ax, target, unsupported): 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): + 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 @@ -491,7 +495,10 @@ def _collection_style(collection, index): ("facecolor", collection.get_facecolors()), ("edgecolor", collection.get_edgecolors()), ("linewidth", collection.get_linewidths()), - ("linestyle", getattr(collection, "_us_linestyles", collection.get_linestyles())), + ( + "linestyle", + getattr(collection, "_us_linestyles", collection.get_linestyles()), + ), ("antialiased", collection._antialiaseds), ): kwargs[key] = ( @@ -828,14 +835,14 @@ def _import_axes(ax, target, unsupported): 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 - ) + 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()), + 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()) @@ -1029,8 +1036,12 @@ def _import_child(child, ax, target, unsupported): _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)) + 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" diff --git a/src/maxplotlib/subfigure/line_plot.py b/src/maxplotlib/subfigure/line_plot.py index fca77b1..346db44 100644 --- a/src/maxplotlib/subfigure/line_plot.py +++ b/src/maxplotlib/subfigure/line_plot.py @@ -1761,8 +1761,11 @@ 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 (not hasattr(self, "_import_grid") or self._grid != self._import_grid - or getattr(self, "_import_grid_edited", False)): + 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) diff --git a/src/maxplotlib/tests/test_matplotlib_import_extended.py b/src/maxplotlib/tests/test_matplotlib_import_extended.py index ed1e178..aeb1043 100644 --- a/src/maxplotlib/tests/test_matplotlib_import_extended.py +++ b/src/maxplotlib/tests/test_matplotlib_import_extended.py @@ -42,8 +42,10 @@ def test_trusted_pickle_boundary_and_report(tmp_path, monkeypatch): 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)): @@ -319,9 +321,7 @@ def test_inset_zoom_indicator_rebinds_to_reconstructed_axes(): _, copied, axes = render(fig) result = axes[0, 0] indicators = [ - artist - for artist in result.artists - if type(artist).__name__ == "InsetIndicator" + artist for artist in result.artists if type(artist).__name__ == "InsetIndicator" ] assert len(indicators) == 1 indicator = indicators[0] @@ -446,9 +446,7 @@ def test_composite_text_boxes_effects_and_clip_boxes(): 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"} - ) + 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()] ) @@ -565,7 +563,9 @@ def test_arbitrary_axes_rectangles_are_preserved(): 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) + np.testing.assert_allclose( + source.get_position().bounds, result.get_position().bounds + ) def test_hidden_fixed_ticks_and_secondary_data_location(): @@ -578,6 +578,7 @@ def test_hidden_fixed_ticks_and_secondary_data_location(): 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]) @@ -589,7 +590,6 @@ def test_standalone_and_projection_colorbars(): 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) From 083ecec4e51d016b5d37043a0a7f3758fec24000 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 26 Sep 2026 11:12:01 +0200 Subject: [PATCH 5/5] Fix bugs --- src/maxplotlib/tests/test_matplotlib_import_extended.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/maxplotlib/tests/test_matplotlib_import_extended.py b/src/maxplotlib/tests/test_matplotlib_import_extended.py index aeb1043..6ac9f3b 100644 --- a/src/maxplotlib/tests/test_matplotlib_import_extended.py +++ b/src/maxplotlib/tests/test_matplotlib_import_extended.py @@ -309,6 +309,10 @@ def test_secondary_inset_and_annotation_coordinates(): 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])