Skip to content

Commit e5ea75a

Browse files
authored
Added a matplotlib parser (#71)
* Added a matplotlib parser * Extende matplotlib parsing coverage * Extend matplotlib parsing * formatting * Fix bugs
1 parent 7749cdd commit e5ea75a

9 files changed

Lines changed: 3030 additions & 43 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Matplotlib import compatibility
2+
3+
on:
4+
push:
5+
branches: [main, devel]
6+
pull_request:
7+
8+
jobs:
9+
import-fixtures:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
matplotlib: ['3.8.*', '3.9.*', '3.10.*', '3.11.*']
15+
env:
16+
MPLBACKEND: Agg
17+
steps:
18+
- uses: actions/checkout@v4
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: '3.11'
22+
- run: python -m pip install '.[test]' 'numpy<2' 'matplotlib==${{ matrix.matplotlib }}'
23+
- run: python -m pytest src/maxplotlib/tests/test_matplotlib_import.py src/maxplotlib/tests/test_matplotlib_import_extended.py

‎docs/matplotlib-import-support.md‎

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# Matplotlib import support and roadmap
2+
3+
`Canvas.from_matplotlib(source, strict=False, **canvas_kwargs)` accepts an
4+
in-memory Matplotlib figure, axes, or rectangular axes array. This checklist
5+
covers the intended import surface. It is a roadmap, not a promise that every
6+
item already works on every rendering backend.
7+
8+
Checked items are implemented within their stated scope. Unless explicitly
9+
stated otherwise, fidelity checks below refer to the **Matplotlib backend**.
10+
Complex built-in artists are retained as detached, editable native geometry;
11+
these entries deliberately raise on backends that cannot represent them.
12+
Portable entries continue to support the other renderers, with the limitations
13+
listed below. Unchecked items are
14+
pending or partial. Importing drawn geometry does not recover the original
15+
samples, plotting call, callback, or statistical model. Matplotlib, Plotly,
16+
TikZ, and plotext have different rendering capabilities; import success does
17+
not guarantee that every backend can render the result identically.
18+
19+
## Inputs and ownership
20+
21+
- [x] Whole `Figure`, individual `Axes`, flat lists/arrays, and 2D lists/arrays.
22+
- [x] Explicit 2D array order; infer ordinary grids from flat input.
23+
- [x] Reject empty, ragged, non-axes, duplicate, and mixed-figure inputs.
24+
- [x] Snapshot plot arrays and styles without reparenting source artists.
25+
- [x] Canvas keyword overrides for figure size, DPI, and other canvas options.
26+
- [x] Warnings for recognized unsupported content; strict mode raises.
27+
- [x] Structured import report with artist identities, severity, and fallbacks.
28+
- [ ] Complete detection of unsupported style properties in strict mode.
29+
- [ ] Version compatibility matrix and fixtures across supported Matplotlib versions.
30+
- [x] Optional serialized figure input with an explicit trust boundary.
31+
32+
PNG/JPEG input and reconstruction of original data from PDF/SVG are separate
33+
features, not part of this object importer. Arbitrary custom Python artists
34+
need an adapter API or an explicit raster fallback; universal semantic
35+
conversion is not possible.
36+
37+
## Figure and layout
38+
39+
- [x] Ordinary rectangular subplot grids and individual axes.
40+
- [x] Figure dimensions and export DPI defaults.
41+
- [x] Figure title and shared axis label text.
42+
- [x] One `twinx` and one `twiny` per primary subplot; import only selected axes.
43+
- [ ] Full twin-axis spine positioning, multiple twins, and cross-backend parity.
44+
Matplotlib supports multiple twins and spine positions; backend parity is pending.
45+
- [x] Shared-axis relationships and linked limits after import.
46+
- [x] Preserve omitted/empty grid cells without drawing extra empty axes.
47+
- [x] Spanning cells, subplot mosaics, nested GridSpec, and subfigures
48+
(subfigure rectangles/decorations are flattened into the destination figure).
49+
- [x] Grid width/height ratios, margins, spacing, constrained/tight layout
50+
for ordinary/nested GridSpec; subfigures retain their snapshot rectangles.
51+
- [x] Arbitrary axes rectangles, overlapping axes, inset axes, inset-zoom
52+
connectors (`indicate_inset_zoom`/`indicate_inset`, Matplotlib 3.10+). The
53+
connector spans two Axes and recomputes its geometry from live limits on
54+
every draw, so it is rebuilt against the reconstructed parent/inset pair
55+
rather than snapshotted. Matplotlib < 3.10's tuple-returning API still falls
56+
back to native geometry.
57+
- [x] Secondary axes with forward/inverse coordinate functions.
58+
- [x] Figure backgrounds, frame styling, and figure-level text/patches/images.
59+
- [x] Figure title/shared-label typography and placement.
60+
- [x] Figure-level legends and shared colorbars.
61+
62+
Irregular grids use one row of addressable Canvas slots and retain the source
63+
axes rectangles when rendered with Matplotlib. A 2D input array defines its own
64+
layout; slots occupied only by a twin remain empty rather than drawing extra axes.
65+
Ordinary/nested GridSpec layout engines reflow on resize. Subfigure layout-engine
66+
reflow and full cross-backend layout parity remain pending.
67+
68+
## Lines, markers, and collections
69+
70+
- [x] Numeric 2D lines, markers, NaN gaps, line/marker colors and widths.
71+
- [x] Step draw styles in imported line entries (Matplotlib rendering).
72+
- [x] Horizontal/vertical reference lines with fractional extents (Matplotlib).
73+
- [x] Plain line collections, including `hlines`/`vlines`, as individual lines.
74+
- [x] Standard scatter marker shapes, sizes, scalar colors, and per-point colors.
75+
- [x] Scatter colormaps and normalization copied for Matplotlib rendering.
76+
- [x] Preserve date/time, categorical, quantity/unit converters and formatters.
77+
- [x] Custom dash sequences, cap/join styles, markevery, and gap colors.
78+
- [x] Infinite `axline` semantics, event plots, stem containers.
79+
- [x] Scalar-mapped line collections, offset collections, per-point transforms.
80+
- [ ] Custom scatter paths and hollow markers on every backend.
81+
- [ ] Match scatter area/size semantics and normalization on every backend.
82+
- [x] Preserve ordering between artist types at equal z-order.
83+
84+
## Bars, errors, fills, and statistical plots
85+
86+
- [x] Vertical/horizontal bars, positions, dimensions, baselines, and styling.
87+
- [x] Stacked/grouped bars as their resolved rectangles.
88+
- [x] Histogram bars as geometry (original samples are unavailable).
89+
- [x] Error-bar containers with data lines: symmetric/asymmetric x/y errors,
90+
caps, colors, widths, and labels; avoid duplicated component artists.
91+
- [x] Error-only plots and bar errors as line/cap geometry, without inventing
92+
asymmetric centers that the source no longer retains.
93+
- [x] Simple data-coordinate polygon collections, including `fill_between`,
94+
`fill_betweenx`, and stackplot regions, as filled polygon geometry.
95+
- [x] Data-coordinate polygon patches and stairs values/edges/baseline.
96+
- [x] Error limits/arrows, subsampled errors, and independently styled components.
97+
- [ ] Restore semantic fill boundaries/where masks when recoverable.
98+
Drawn paths and retained container metadata are preserved; Matplotlib usually
99+
does not retain the original where-mask or samples.
100+
- [x] Spans in blended coordinates; general rectangles, circles, ellipses, wedges.
101+
- [x] Compound polygons, holes, curved paths, arbitrary PathPatch geometry.
102+
- [x] Box plots, violin plots, pie/donut charts, hist2d, hexbin.
103+
- [x] Preserve statistical groupings when containers/metadata retain them:
104+
`subplot.import_groups` and entry `source_container_ids` retain bar, errorbar,
105+
and stem memberships, labels, orientation, and available data values.
106+
107+
## Images, fields, and colorbars
108+
109+
- [x] `imshow` arrays, extent, origin, colormap, normalization, interpolation
110+
copied into entries (Matplotlib rendering).
111+
- [ ] Match image extent/origin, RGB(A), masks, alpha, and interpolation on all backends.
112+
- [ ] Nonlinear normalization, clim, under/over/bad colors on all backends.
113+
- [x] `pcolor`, `pcolormesh`, nonuniform grids, QuadMesh, triangular meshes.
114+
- [x] Contours, filled contours, levels, labels, and contour topology.
115+
- [x] Quiver, barbs, streamplots, vector-field keys.
116+
- [x] Axes colorbars tied to the correct image/scatter/mesh mappable.
117+
- [x] Colorbar orientation, label, ticks, limits, extend, and normalization.
118+
- [x] Multiple/shared colorbars and explicit colorbar axes.
119+
120+
## Text and annotations
121+
122+
- [x] Data-coordinate text, font family/size/weight/style, color, alignment,
123+
rotation (backend support varies).
124+
- [x] Data-coordinate annotations with optional arrows and independent copied
125+
arrow properties; references to other patches are explicitly rejected.
126+
- [x] Center title and x/y labels with basic typography and label padding.
127+
- [x] Text boxes, multiline spacing, math/TeX fidelity, wrapping, clipping
128+
through Matplotlib (TeX still requires the source environment’s TeX setup).
129+
- [ ] Axes/figure fractions, point/pixel offsets, blended and callable coordinates.
130+
- [ ] Annotation arrows with full backend parity and artist-relative coordinates.
131+
- [x] Left/right titles, custom title/label positions, offset text.
132+
- [x] AnnotationBbox, OffsetBox, tables, and other composite text artists.
133+
134+
## Axes, ticks, grids, and legends
135+
136+
- [x] Limits including reversed limits; default linear/log scales.
137+
- [x] Explicit major tick locations/labels with FixedLocator.
138+
- [x] Basic grid visibility, axes visibility, background, aspect, axisbelow.
139+
- [x] Basic per-axes legend visibility and artist labels.
140+
- [x] Non-default log bases; symlog, logit, asinh, function/custom scales.
141+
- [x] Automatic/fixed minor ticks, locator/formatter configuration and units.
142+
- [x] Tick placement, direction, length, width, color, rotation, and font styling.
143+
- [x] Per-axis major/minor grid visibility and line styling.
144+
- [x] Spine visibility, colors, widths, bounds, and positions.
145+
- [x] Autoscale flags, sticky edges, margins, adjustable/anchor/box aspect.
146+
- [x] Legend order, renamed labels, proxy handles, multiple legends, grouping.
147+
- [x] Legend location/anchor, columns, title, typography, frame and spacing.
148+
149+
## Transforms, metadata, and advanced axes
150+
151+
- [x] General affine/nonlinear/blended transforms, coordinate rebinding.
152+
- [x] Clip paths/boxes, path effects, rasterization, sketch settings, filters.
153+
- [x] Hidden artists retained as hidden editable entries.
154+
- [ ] Artist IDs, URLs, picking, metadata, and accessibility descriptions.
155+
- [x] Polar, geographic/custom projections, axisartist and parasite axes via
156+
native axes snapshots; custom projection classes must remain available.
157+
- [x] 3D lines/scatter, surfaces, wireframes, collections, camera/projection.
158+
- [ ] Animations, widgets, callbacks, and interactive state (separate adapters).
159+
Static artist state is copied; arbitrary callback closures and GUI event loops
160+
cannot be reconstructed from a figure’s drawn geometry.
161+
- [x] Optional raster fallback for unsupported artists with explicit loss reporting.
162+
163+
## Validation and next priorities
164+
165+
- [x] Tests for all three input forms, source independence, strict/warning modes.
166+
- [x] Matplotlib reconstruction tests for supported geometry and axis settings.
167+
- [x] Plotly smoke/geometry tests for representative supported imports.
168+
- [x] Image comparisons with tolerances, plus representative portable export
169+
tests for Matplotlib PNG/SVG, Plotly HTML, plotext text, and TikZ source.
170+
- [x] Large figures, empty/masked data, performance and memory benchmarks.
171+
172+
New import controls:
173+
174+
```python
175+
canvas = Canvas.from_matplotlib(fig, strict=True)
176+
report = canvas.import_report.to_dict() # identities, severity, fallback, backends
177+
canvas = Canvas.from_matplotlib("figure.pickle", trusted=True)
178+
canvas = Canvas.from_matplotlib(fig, fallback="raster")
179+
```
180+
181+
`trusted=True` is required before any pickle is read. Pickles can execute code;
182+
load only files whose producer you trust and use a matching Matplotlib version.
183+
`fallback="native"` (default) retains built-in native geometry with rebound
184+
transforms. `fallback="skip"` warns (or raises in strict mode) for those artists.
185+
`fallback="raster"` snapshots the selected figure content and records the loss of
186+
editable data and vector geometry; it supports Matplotlib and Plotly. Callback
187+
functions and custom scales remain Python objects, not portable serialization.
188+
Strict mode checks import losses, not universal rendering parity.
189+
190+
Remaining priorities are cross-backend fidelity, subfigure reflow, portable
191+
projection adapters, and live interaction adapters. Source metadata does not
192+
usually retain original fill masks or box/violin samples; these are imported as
193+
geometry without inventing lost statistical inputs.
194+
195+
Reference APIs: [Matplotlib artists](https://matplotlib.org/stable/tutorials/artists.html),
196+
[containers](https://matplotlib.org/stable/api/container_api.html), and
197+
[annotations](https://matplotlib.org/stable/users/explain/text/annotations.html).
198+
199+
200+
Compatibility fixtures live in `test_matplotlib_import.py` and
201+
`test_matplotlib_import_extended.py`. The CI matrix covers Matplotlib 3.8, 3.9,
202+
3.10 and 3.11 on Python 3.11. Snapshots use version-sensitive Matplotlib state;
203+
there is no cross-version pickle compatibility promise.
204+
205+
Run `python scripts/benchmark_matplotlib_import.py --points 100000 --panels 4`
206+
for reproducible timing/allocation observations. A local Matplotlib 3.11.1 run
207+
imported 400,000 points in 0.41 s with 8.75 MiB peak Python allocations and rendered
208+
in 0.79 s. These are observations, not performance guarantees; native allocations
209+
outside Python are excluded from the memory measurement.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Measure import time and peak Python allocation for reproducible fixtures.
2+
3+
Run from an installed checkout: python scripts/benchmark_matplotlib_import.py
4+
Timings are observations, not brittle pass/fail thresholds. Native renderer
5+
allocations outside Python are not included in tracemalloc's peak measurement.
6+
"""
7+
8+
import argparse
9+
import json
10+
import time
11+
import tracemalloc
12+
13+
import matplotlib
14+
15+
matplotlib.use("Agg")
16+
import matplotlib.pyplot as plt
17+
import numpy as np
18+
19+
from maxplotlib import Canvas
20+
21+
22+
def benchmark(points, panels):
23+
fig, axes = plt.subplots(panels, 1, figsize=(8, max(3, panels * 2)))
24+
x = np.linspace(0, 10, points)
25+
for index, ax in enumerate(np.atleast_1d(axes)):
26+
ax.plot(x, np.sin(x + index))
27+
ax.scatter([], [])
28+
tracemalloc.start()
29+
started = time.perf_counter()
30+
canvas = Canvas.from_matplotlib(fig, strict=True)
31+
imported = time.perf_counter()
32+
_, peak = tracemalloc.get_traced_memory()
33+
tracemalloc.stop()
34+
result, _ = canvas.render()
35+
result.canvas.draw()
36+
finished = time.perf_counter()
37+
stats = dict(
38+
matplotlib=matplotlib.__version__,
39+
points_per_panel=points,
40+
panels=panels,
41+
import_seconds=imported - started,
42+
render_seconds=finished - imported,
43+
import_peak_python_mib=peak / 1024**2,
44+
entries=sum(len(plot.line_data) for _, _, plot in canvas.iter_subplots()),
45+
)
46+
plt.close(fig)
47+
plt.close(result)
48+
return stats
49+
50+
51+
if __name__ == "__main__":
52+
parser = argparse.ArgumentParser(description=__doc__)
53+
parser.add_argument("--points", type=int, default=100_000)
54+
parser.add_argument("--panels", type=int, default=4)
55+
arguments = parser.parse_args()
56+
if arguments.points < 1 or arguments.panels < 1:
57+
parser.error("points and panels must be positive")
58+
print(json.dumps(benchmark(arguments.points, arguments.panels), indent=2))

0 commit comments

Comments
 (0)