qmlmathplot 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qmlmathplot/camera.py CHANGED
@@ -219,6 +219,13 @@ class Camera(QObject):
219
219
  xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
220
220
  ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
221
221
 
222
+ def _get_viewport(self) -> QVector2D:
223
+ width, height = self._effective_size()
224
+ return QVector2D(width, height)
225
+
226
+ #: Last reported size in logical pixels (the reference size before the first report).
227
+ viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
228
+
222
229
  # --------------------------------------------------------------- ticks
223
230
  def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
224
231
  """Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
@@ -280,6 +287,27 @@ class Camera(QObject):
280
287
  self.viewChanged.emit()
281
288
  self.ticksChanged.emit()
282
289
 
290
+ @Slot(float, float, float, float)
291
+ def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
292
+ """Set the centre and *both* scales at once (world units per logical pixel).
293
+
294
+ The camera normally derives the y scale from ``aspect``; this is the primitive for a
295
+ caller that has two scales in hand — the exporter borrowing the camera for a render,
296
+ and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
297
+ numeric aspect is re-derived from the pair so the two scales are exactly as asked.
298
+ """
299
+ scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
300
+ scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
301
+ self._centre = QVector2D(float(centre_x), float(centre_y))
302
+ self._zoom = scale_x
303
+ if isinstance(self._aspect, str):
304
+ self._y_scale = scale_y
305
+ else:
306
+ self._aspect = scale_x / scale_y
307
+ self.aspectChanged.emit()
308
+ self._home = False
309
+ self._view_changed()
310
+
283
311
  # --------------------------------------------------------------- slots
284
312
  @Slot(float, float)
285
313
  def setViewport(self, width: float, height: float) -> None:
qmlmathplot/curve.py CHANGED
@@ -25,9 +25,11 @@ from .qsb import bake
25
25
 
26
26
  __all__ = ["Curve", "CurveListModel"]
27
27
 
28
- #: First colour of the default cycle (also the classic single-curve colour).
29
- DEFAULT_COLOR = "#33ccff"
30
- DEFAULT_COLOR_CYCLE = ("#33ccff", "#ff8866", "#7ddc7d", "#e0c341", "#c98bff")
28
+ #: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
29
+ DEFAULT_COLOR = "#1f77b4"
30
+ #: matplotlib's default ten-colour cycle.
31
+ DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
32
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf")
31
33
 
32
34
 
33
35
  def _color(value: object) -> QColor:
qmlmathplot/export.py ADDED
@@ -0,0 +1,164 @@
1
+ """Export layer: render a region of this canvas at a pixel size (docs/api-design.md §9).
2
+
3
+ An export is not a re-draw at another ``dpi`` — the plot *is* a Qt scene, so the same scene is
4
+ rendered into a hidden ``QQuickWidget`` and read back synchronously. Everything (curves, grid,
5
+ axes, theme) follows the live styling, so an export cannot drift from what the user sees; only
6
+ the camera is *borrowed* for the duration of the render and restored before the event loop runs
7
+ again, so the live view never repaints the export state.
8
+
9
+ QtWidgets is imported lazily (a pure-QML app that never exports never loads it).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Sequence
15
+ from typing import TYPE_CHECKING
16
+
17
+ from PySide6.QtCore import QSize, Qt, QUrl
18
+ from PySide6.QtGui import QColor, QImage
19
+
20
+ if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
21
+ from PySide6.QtQuickWidgets import QQuickWidget
22
+ from PySide6.QtWidgets import QApplication
23
+
24
+ from .plot import Plot
25
+
26
+ __all__ = ["ADJUSTABLES", "render"]
27
+
28
+ #: How a requested range and a requested pixel size are reconciled (see the module docstring).
29
+ ADJUSTABLES = ("box", "datalim", "stretch")
30
+
31
+
32
+ def _pair(value: Sequence[float], name: str) -> tuple[float, float]:
33
+ try:
34
+ lo, hi = (float(value[0]), float(value[1]))
35
+ except (TypeError, IndexError, KeyError):
36
+ raise ValueError(f"{name} must be a (min, max) pair, got {value!r}") from None
37
+ if not hi > lo:
38
+ raise ValueError(f"{name} must be increasing, got {value!r}")
39
+ return lo, hi
40
+
41
+
42
+ def _widgets() -> tuple[type[QApplication], type[QQuickWidget]]:
43
+ """The QtWidgets classes the export needs, or a clear error when they are not usable."""
44
+ try:
45
+ from PySide6.QtQuickWidgets import QQuickWidget
46
+ from PySide6.QtWidgets import QApplication
47
+ except ImportError as exc: # pragma: no cover - both ship with PySide6
48
+ raise RuntimeError("exporting needs QtWidgets/QtQuickWidgets (PySide6)") from exc
49
+ if not isinstance(QApplication.instance(), QApplication):
50
+ # Creating a QWidget under a bare QGuiApplication aborts the process inside Qt (and
51
+ # `QApplication.instance()` *is* the QGuiApplication there), so this has to be refused
52
+ # before the offscreen widget is touched.
53
+ raise RuntimeError(
54
+ "exporting needs a QApplication: the offscreen render uses a QQuickWidget; "
55
+ "create one (MathPlotWidget does it for you) or use a widgets host"
56
+ )
57
+ return QApplication, QQuickWidget
58
+
59
+
60
+ def render(
61
+ plot: Plot,
62
+ *,
63
+ xlim: Sequence[float] | None = None,
64
+ ylim: Sequence[float] | None = None,
65
+ width: float | None = None,
66
+ height: float | None = None,
67
+ dpi: float = 1.0,
68
+ adjustable: str | None = None,
69
+ transparent: bool = False,
70
+ ) -> QImage:
71
+ """Render ``plot`` offscreen and return the grabbed image, in device pixels.
72
+
73
+ ``xlim`` / ``ylim`` default to the camera (the live centre and scale, extended to the export
74
+ size), ``width`` / ``height`` default to the live view's device size (``dpi`` multiplies
75
+ both), and ``adjustable`` reconciles the two parameter sets.
76
+ """
77
+ dpi = float(dpi)
78
+ if not dpi > 0:
79
+ raise ValueError(f"dpi must be positive, got {dpi!r}")
80
+ mode = "box" if adjustable is None else str(adjustable) # matplotlib's own default
81
+ if mode not in ADJUSTABLES:
82
+ raise ValueError(f"adjustable must be one of {ADJUSTABLES}, got {adjustable!r}")
83
+ for name, value in (("width", width), ("height", height)):
84
+ if value is not None and not float(value) > 0:
85
+ raise ValueError(f"{name} must be positive, got {value!r}")
86
+
87
+ _app, quick_widget = _widgets()
88
+ from .view import qml_component_path, register_qml_types
89
+
90
+ register_qml_types()
91
+ camera = plot.camera
92
+ live = (camera.centre.x(), camera.centre.y(), camera.scale_x, camera.scale_y)
93
+ live_size = (camera.viewport.x(), camera.viewport.y())
94
+ background = plot.background
95
+
96
+ widget: QQuickWidget = quick_widget()
97
+ widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
98
+ widget.setResizeMode(quick_widget.ResizeMode.SizeRootObjectToView)
99
+ widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
100
+ image = QImage()
101
+ device = (0, 0)
102
+ try:
103
+ if transparent:
104
+ plot.background = QColor(0, 0, 0, 0)
105
+ widget.setClearColor(QColor(0, 0, 0, 0))
106
+ widget.setSource(QUrl.fromLocalFile(qml_component_path()))
107
+ if widget.status() != quick_widget.Status.Ready:
108
+ errors = "; ".join(error.toString() for error in widget.errors())
109
+ raise RuntimeError(f"the QML component failed to load: {errors}")
110
+ root = widget.rootObject() # the widget itself has no such property
111
+ if root is None:
112
+ raise RuntimeError("the QML component has no root object")
113
+ root.setProperty("plot", plot)
114
+
115
+ # width/height are device pixels, so the offscreen view gets the logical size
116
+ pixel_ratio = widget.devicePixelRatioF()
117
+ device = (
118
+ round((float(width) if width is not None else live_size[0] * pixel_ratio) * dpi),
119
+ round((float(height) if height is not None else live_size[1] * pixel_ratio) * dpi),
120
+ )
121
+ logical = (device[0] / pixel_ratio, device[1] / pixel_ratio)
122
+ widget.resize(round(logical[0]), round(logical[1]))
123
+ widget.show() # offscreen: WA_DontShowOnScreen, never exposed
124
+
125
+ # The range: the camera, extended to the export size, unless the caller asked for one
126
+ range_x = _pair(xlim, "xlim") if xlim is not None else (
127
+ live[0] - 0.5 * logical[0] * live[2], live[0] + 0.5 * logical[0] * live[2])
128
+ range_y = _pair(ylim, "ylim") if ylim is not None else (
129
+ live[1] - 0.5 * logical[1] * live[3], live[1] + 0.5 * logical[1] * live[3])
130
+ span_x, span_y = range_x[1] - range_x[0], range_y[1] - range_y[0]
131
+ centre = (0.5 * (range_x[0] + range_x[1]), 0.5 * (range_y[0] + range_y[1]))
132
+
133
+ if mode == "stretch": # the range maps onto the size exactly
134
+ scale = (span_x / logical[0], span_y / logical[1])
135
+ elif mode == "box": # the live ratio, the range drawn as large as it fits
136
+ # The largest scale (the smallest world units per pixel) for which the requested
137
+ # range still fits: the binding axis is exact, the other keeps background margins.
138
+ # Drawn without distortion — a world circle stays round, only the visible span
139
+ # changes when the requested range's aspect differs from the size's.
140
+ ratio = live[2] / live[3]
141
+ scale_x = max(span_x / logical[0], ratio * span_y / logical[1])
142
+ scale = (scale_x, scale_x / ratio)
143
+ else: # "datalim": the live scales, the limits expand
144
+ scale = (max(live[2], span_x / logical[0]), max(live[3], span_y / logical[1]))
145
+
146
+ camera.setViewport(logical[0], logical[1])
147
+ camera.set_view(centre[0], centre[1], scale[0], scale[1])
148
+ image = widget.grabFramebuffer()
149
+ finally:
150
+ # Restore before the event loop runs again, so no live view repaints the export state.
151
+ camera.set_view(live[0], live[1], live[2], live[3])
152
+ camera.setViewport(live_size[0], live_size[1])
153
+ plot.background = background
154
+ widget.hide()
155
+ widget.deleteLater()
156
+
157
+ if image.size() != QSize(device[0], device[1]):
158
+ # At a fractional devicePixelRatio the offscreen widget can land within one pixel of the
159
+ # requested size; make the promise exact (a 1 px nearest resample is invisible).
160
+ image = image.scaled(
161
+ device[0], device[1],
162
+ Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.FastTransformation,
163
+ )
164
+ return image
qmlmathplot/mpl.py ADDED
@@ -0,0 +1,232 @@
1
+ """Hand-off to matplotlib — and through it to sympy's plotting.
2
+
3
+ This is deliberately a **second renderer**, not the one the Qt view uses, and it is a *sampled*
4
+ one: matplotlib draws polylines, so an oscillating function such as ``sin(1/x)`` aliases exactly
5
+ as it does in any sampling renderer — which is the reason the Qt side exists and evaluates the
6
+ expression per pixel instead. Fonts and antialiasing differ from the Qt view as well, and the
7
+ style mapping below is partial. What this buys is **vector output** (``.svg``/``.pdf``) and
8
+ matplotlib's ecosystem (and sympy's plotting), nothing else.
9
+
10
+ matplotlib is an *optional* dependency (the ``matplotlib`` extra): this module imports nothing
11
+ until one of its functions is called, and ``import qmlmathplot`` never pulls it in.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+ from typing import TYPE_CHECKING
18
+
19
+ import sympy as sp
20
+
21
+ if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
22
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
23
+ from matplotlib.figure import Figure
24
+
25
+ from .curve import Curve
26
+ from .plot import Plot
27
+
28
+ __all__ = ["figure_for", "save_via_matplotlib", "sympy_plot_for"]
29
+
30
+ #: Logical pixels to points (96 px/inch, 72 pt/inch): our sizes are in px, matplotlib's in pt.
31
+ PX_TO_PT = 72.0 / 96.0
32
+ #: The DPI the hand-off assumes for sizes that only exist in pixels (the live viewport).
33
+ DEFAULT_DPI = 100.0
34
+ _EXTRA_HINT = (
35
+ "the matplotlib hand-off needs the optional extra: pip install 'qmlmathplot[matplotlib]'"
36
+ )
37
+
38
+
39
+ def _require() -> tuple[type[Figure], type[FigureCanvasAgg]]:
40
+ """The matplotlib classes for a headless figure, or a clear error naming the extra."""
41
+ try:
42
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
43
+ from matplotlib.figure import Figure
44
+ except ImportError as exc: # no matplotlib installed
45
+ raise ImportError(_EXTRA_HINT) from exc
46
+ return Figure, FigureCanvasAgg
47
+
48
+
49
+ def _pair(value: Sequence[float], name: str) -> tuple[float, float]:
50
+ try:
51
+ lo, hi = float(value[0]), float(value[1])
52
+ except (TypeError, IndexError, KeyError):
53
+ raise ValueError(f"{name} must be a (min, max) pair, got {value!r}") from None
54
+ if not hi > lo:
55
+ raise ValueError(f"{name} must be increasing, got {value!r}")
56
+ return lo, hi
57
+
58
+
59
+ def _visible_curves(plot: Plot) -> list[Curve]:
60
+ """The curves the Qt view would draw (a hidden curve is not handed over)."""
61
+ return [plot.curves.at(index) for index in range(len(plot.curves))
62
+ if plot.curves.at(index).visible]
63
+
64
+
65
+ def _sample(expression: str, x0: float, x1: float, samples: int) -> tuple[object, object]:
66
+ """Uniform samples of one curve; NaN where the function is outside its domain.
67
+
68
+ matplotlib breaks the line at NaN, which is how the hand-off keeps ``log(x)`` from being
69
+ drawn for x < 0. Infinities (poles, ``1/x`` at 0) are turned into NaN as well, so a pole
70
+ breaks the line instead of drawing a connector across it.
71
+ """
72
+ import numpy
73
+
74
+ symbol = sp.Symbol("x")
75
+ try:
76
+ function = sp.lambdify(symbol, sp.sympify(expression, locals={"x": symbol}),
77
+ modules=["numpy"])
78
+ with numpy.errstate(all="ignore"): # log(x<0) etc.: NaN, not a warning storm
79
+ xs = numpy.linspace(x0, x1, samples)
80
+ ys = numpy.asarray(function(xs), dtype=float)
81
+ except Exception as exc:
82
+ # Broad on purpose: sympify, lambdify and the evaluation itself all report by raising,
83
+ # and the caller wants one clear message naming the expression.
84
+ raise ValueError(f"cannot sample {expression!r}: {exc}") from exc
85
+ if ys.ndim == 0: # a constant expression evaluates to a scalar
86
+ ys = numpy.full(xs.shape, float(ys))
87
+ ys[~numpy.isfinite(ys)] = numpy.nan
88
+ return xs, ys
89
+
90
+
91
+ def figure_for(
92
+ plot: Plot,
93
+ *,
94
+ xlim: Sequence[float] | None = None,
95
+ ylim: Sequence[float] | None = None,
96
+ samples: int = 2000,
97
+ adjustable: str = "box",
98
+ size: tuple[float, float] | None = None,
99
+ dpi: float = DEFAULT_DPI,
100
+ transparent: bool = False,
101
+ ) -> Figure:
102
+ """A matplotlib ``Figure`` with one Axes, reflecting this plot as far as it maps.
103
+
104
+ ``xlim`` / ``ylim`` default to the camera's visible range; ``size`` is the figure size in
105
+ device pixels (default: the live viewport) and ``dpi`` turns it into inches. ``adjustable``
106
+ follows the export's modes: ``"box"`` and ``"datalim"`` map to matplotlib's own, and
107
+ ``"stretch"`` (independent scales) becomes matplotlib's ``"auto"`` aspect.
108
+ """
109
+ if adjustable not in ("box", "datalim", "stretch"):
110
+ raise ValueError(f'adjustable must be "box", "datalim" or "stretch", got {adjustable!r}')
111
+ Figure, FigureCanvasAgg = _require()
112
+ dpi = float(dpi)
113
+ if not dpi > 0:
114
+ raise ValueError(f"dpi must be positive, got {dpi!r}")
115
+
116
+ x_range = _pair(xlim, "xlim") if xlim is not None else (plot.xlim.x(), plot.xlim.y())
117
+ y_range = _pair(ylim, "ylim") if ylim is not None else (plot.ylim.x(), plot.ylim.y())
118
+ if size is None: # the live viewport, at the hand-off's dpi
119
+ viewport = plot.camera.viewport
120
+ size = (viewport.x(), viewport.y())
121
+ inches = (size[0] / dpi, size[1] / dpi)
122
+
123
+ figure = Figure(figsize=inches, dpi=dpi)
124
+ FigureCanvasAgg(figure) # headless canvas: no pyplot, no GUI backend
125
+ axes = figure.add_subplot()
126
+ axes.set_facecolor(plot.background.name())
127
+ figure.patch.set_facecolor("none" if transparent else plot.background.name())
128
+ axes.set_xlim(*x_range)
129
+ axes.set_ylim(*y_range)
130
+
131
+ if adjustable == "stretch": # independent scales: matplotlib's "auto"
132
+ axes.set_aspect("auto")
133
+ else:
134
+ aspect = plot.camera.aspect # "auto" | number, same convention as ours
135
+ axes.set_aspect("auto" if isinstance(aspect, str) else float(aspect),
136
+ adjustable=adjustable)
137
+
138
+ if plot.axes_position == "zero": # our axes through world (0,0), no box
139
+ axes.spines["left"].set_position("zero")
140
+ axes.spines["bottom"].set_position("zero")
141
+ axes.spines["top"].set_visible(False)
142
+ axes.spines["right"].set_visible(False)
143
+ for spine in axes.spines.values():
144
+ spine.set_color(plot.axis_color.name())
145
+ spine.set_linewidth(plot.axis_width * PX_TO_PT)
146
+
147
+ axes.set_axisbelow(True) # the Qt view paints its grid below the curves
148
+ if plot.grid:
149
+ axes.grid(True, color=plot.grid_color.name(),
150
+ linewidth=plot.grid_width * PX_TO_PT, alpha=plot.grid_alpha,
151
+ linestyle=plot.grid_style)
152
+ else:
153
+ # Passing line properties together with `False` makes matplotlib *enable* the grid.
154
+ axes.grid(False)
155
+ axes.tick_params(colors=plot.tick_color.name(), labelcolor=plot.text_color.name(),
156
+ labelsize=plot.tick_font_size * PX_TO_PT)
157
+ axes.xaxis.label.set_fontsize(plot.font_size * PX_TO_PT)
158
+ axes.yaxis.label.set_fontsize(plot.font_size * PX_TO_PT)
159
+ if plot.title:
160
+ axes.set_title(plot.title, color=plot.title_color.name(),
161
+ fontsize=plot.title_font_size * PX_TO_PT,
162
+ fontweight="bold" if plot.title_bold else "normal")
163
+
164
+ for curve in _visible_curves(plot):
165
+ xs, ys = _sample(curve.expression, x_range[0], x_range[1], samples)
166
+ axes.plot(xs, ys, color=curve.color.name(), linewidth=curve.lineWidth * PX_TO_PT,
167
+ label=curve.label or curve.expression)
168
+ return figure
169
+
170
+
171
+ def save_via_matplotlib(
172
+ plot: Plot,
173
+ path: str,
174
+ *,
175
+ xlim: Sequence[float] | None = None,
176
+ ylim: Sequence[float] | None = None,
177
+ samples: int = 2000,
178
+ adjustable: str = "box",
179
+ width: float | None = None,
180
+ height: float | None = None,
181
+ dpi: float = DEFAULT_DPI,
182
+ transparent: bool = False,
183
+ ) -> None:
184
+ """Write the hand-off figure with matplotlib's own ``savefig`` (svg/pdf/png/…)."""
185
+ size = None
186
+ if width is not None or height is not None:
187
+ viewport = plot.camera.viewport
188
+ size = (float(width) if width is not None else viewport.x(),
189
+ float(height) if height is not None else viewport.y())
190
+ figure = figure_for(plot, xlim=xlim, ylim=ylim, samples=samples, adjustable=adjustable,
191
+ size=size, dpi=dpi, transparent=transparent)
192
+ figure.savefig(str(path), transparent=transparent)
193
+
194
+
195
+ def sympy_plot_for(
196
+ plot: Plot,
197
+ *,
198
+ xlim: Sequence[float] | None = None,
199
+ ylim: Sequence[float] | None = None,
200
+ samples: int = 2000,
201
+ adaptive: bool = False,
202
+ ) -> object:
203
+ """Hand this plot to ``sympy.plotting``, uniform by default (``adaptive`` asks for sympy's.
204
+
205
+ Returns whatever ``sympy.plotting.plot(..., show=False)`` returns (a matplotlib-backend
206
+ ``Plot`` object in sympy 1.14), so ``handle.save(path)``/``handle.show()`` work, the window
207
+ is the same one the matplotlib hand-off would show, and every curve keeps its colour.
208
+ """
209
+ _require() # sympy's plotting backend is matplotlib too
210
+ symbol = sp.Symbol("x")
211
+ curves = _visible_curves(plot)
212
+ if not curves:
213
+ raise ValueError("nothing to hand to sympy: the plot has no visible curve")
214
+ x_range = _pair(xlim, "xlim") if xlim is not None else (plot.xlim.x(), plot.xlim.y())
215
+ y_range = _pair(ylim, "ylim") if ylim is not None else (plot.ylim.x(), plot.ylim.y())
216
+ expressions = []
217
+ for curve in curves:
218
+ try:
219
+ expressions.append(sp.sympify(curve.expression, locals={"x": symbol}))
220
+ except Exception as exc:
221
+ raise ValueError(f"cannot parse {curve.expression!r}: {exc}") from exc
222
+ handle = sp.plotting.plot(
223
+ *expressions, (symbol, x_range[0], x_range[1]), show=False,
224
+ adaptive=adaptive, # see Plot.to_sympy for why False is the default
225
+ nb_of_points=samples,
226
+ label=[curve.label or curve.expression for curve in curves],
227
+ )
228
+ handle.xlim = x_range # the same window as the matplotlib hand-off
229
+ handle.ylim = y_range
230
+ for index, curve in enumerate(curves): # per-series style (not a global line_color)
231
+ handle[index].line_color = curve.color.name()
232
+ return handle
qmlmathplot/plot.py CHANGED
@@ -9,10 +9,11 @@ names — see ``theme``.
9
9
  from __future__ import annotations
10
10
 
11
11
  from PySide6.QtCore import Property, QObject, Signal, Slot
12
- from PySide6.QtGui import QColor, QVector2D
12
+ from PySide6.QtGui import QColor, QImage, QVector2D
13
13
 
14
14
  from .camera import Camera
15
15
  from .curve import DEFAULT_COLOR_CYCLE, Curve, CurveListModel
16
+ from .themes import names, resolve
16
17
 
17
18
  __all__ = ["Plot"]
18
19
 
@@ -60,10 +61,14 @@ class Plot(QObject):
60
61
  cameraChanged = Signal()
61
62
  curvesChanged = Signal()
62
63
  viewChanged = Signal()
63
- aspectChanged = Signal() # forwarded from the camera (the forwarded `aspect`)
64
+ aspectChanged = Signal() # forwarded from the camera (the forwarded `aspect`)
64
65
  themeChanged = Signal()
65
66
 
66
67
  backgroundChanged = Signal()
68
+ axisColorChanged = Signal()
69
+ axisWidthChanged = Signal()
70
+ axesPositionChanged = Signal()
71
+ available_themesChanged = Signal()
67
72
  gridChanged = Signal()
68
73
  gridColorChanged = Signal()
69
74
  gridWidthChanged = Signal()
@@ -85,11 +90,15 @@ class Plot(QObject):
85
90
  def __init__(self, parent: QObject | None = None) -> None:
86
91
  super().__init__(parent)
87
92
  self._camera = Camera(self)
93
+ self._theme = ""
88
94
  self._curves = CurveListModel(DEFAULT_COLOR_CYCLE, self.line_width, self)
89
95
  self._camera.viewChanged.connect(self.viewChanged)
90
96
  self._camera.aspectChanged.connect(self.aspectChanged)
91
97
  self.cameraChanged.emit()
92
98
  self.curvesChanged.emit()
99
+ # matplotlib's look out of the box: the theme file is the single source of truth, so
100
+ # the fallbacks above and the theme agree instead of drifting apart.
101
+ self._set_theme("default")
93
102
 
94
103
  # ------------------------------------------------------------- the model
95
104
  def _get_camera(self) -> Camera:
@@ -128,7 +137,9 @@ class Plot(QObject):
128
137
  ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
129
138
  # `aspect` is forwarded too because a QML *binding* cannot reach through an object chain
130
139
  # (`plot.camera.aspect: 1.0` is rejected by QML), while a direct property binds fine.
131
- aspect: str | float = Property("QVariant", _get_aspect, _set_aspect, notify=aspectChanged)
140
+ aspect: str | float = Property(
141
+ "QVariant", _get_aspect, _set_aspect, notify=aspectChanged
142
+ )
132
143
 
133
144
  @Slot(str, "QVariant", "QVariant", str, result=QObject)
134
145
  @Slot(str, result=QObject)
@@ -146,23 +157,135 @@ class Plot(QObject):
146
157
  def remove_curve(self, curve: Curve) -> None:
147
158
  self._curves.remove_curve(curve)
148
159
 
160
+ # --------------------------------------------------------------- export
161
+ @Slot(result="QVariant")
162
+ def to_image(
163
+ self,
164
+ *,
165
+ xlim: object = None,
166
+ ylim: object = None,
167
+ width: float | None = None,
168
+ height: float | None = None,
169
+ dpi: float = 1.0,
170
+ adjustable: str | None = None,
171
+ transparent: bool = False,
172
+ ) -> QImage:
173
+ """Render this canvas region offscreen and return it (device pixels), see
174
+ ``docs/api-design.md`` §9. QML calls it with no arguments: the live view, its own size.
175
+ """
176
+ from .export import render # lazy: a pure-QML app never needs QtWidgets
177
+
178
+ return render(
179
+ self,
180
+ xlim=xlim,
181
+ ylim=ylim,
182
+ width=width,
183
+ height=height,
184
+ dpi=dpi,
185
+ adjustable=adjustable,
186
+ transparent=transparent,
187
+ )
188
+
189
+ def to_matplotlib(
190
+ self,
191
+ *,
192
+ xlim: object = None,
193
+ ylim: object = None,
194
+ samples: int = 2000,
195
+ adjustable: str = "box",
196
+ ) -> object:
197
+ """Hand this plot to matplotlib and return a ``matplotlib.figure.Figure`` (one Axes).
198
+
199
+ This is a **second renderer**, and a *sampled* one: matplotlib draws polylines, so an
200
+ oscillating function such as ``sin(1/x)`` aliases (exactly what the Qt view avoids by
201
+ evaluating the expression per pixel), the fonts and antialiasing differ from the Qt
202
+ view, and the style mapping is partial — background, grid, per-curve colour/width,
203
+ tick/text colours, font sizes, title, axis colour/width, the limits, the aspect with
204
+ matplotlib's ``adjustable``, and ``axes_position="zero"`` (spines through the origin).
205
+ What it buys is vector output and matplotlib's ecosystem, nothing else. Needs the
206
+ optional ``matplotlib`` extra (``pip install 'qmlmathplot[matplotlib]'``).
207
+ """
208
+ from .mpl import figure_for
209
+
210
+ return figure_for(
211
+ self, xlim=xlim, ylim=ylim, samples=samples, adjustable=adjustable
212
+ )
213
+
214
+ def to_sympy(
215
+ self,
216
+ *,
217
+ xlim: object = None,
218
+ ylim: object = None,
219
+ samples: int = 2000,
220
+ adaptive: bool = False,
221
+ ) -> object:
222
+ """Hand this plot to ``sympy.plotting`` and return the Plot object it builds.
223
+
224
+ Through sympy this reaches any of sympy's plotting backends. The sampling is ours
225
+ (uniform, ``samples`` points) unless ``adaptive=True`` asks for sympy's own sampler; the
226
+ aliasing caveat of :meth:`to_matplotlib` applies either way, because an oscillating
227
+ function cannot be resolved by sampling at all. Measured on ``sin(1/x)`` over [-1, 1]:
228
+ sympy 1.14's adaptive sampler produced **289** points — *fewer* than the fixed 2000 —
229
+ emitted no warning, and drew the same block of vertical connectors, which is why the
230
+ fixed count is the default. Hidden curves are skipped, each curve keeps its colour, and a
231
+ passed ``ylim`` is honoured (sympy has no y-range of its own). Needs the optional
232
+ ``matplotlib`` extra.
233
+ """
234
+ from .mpl import sympy_plot_for
235
+
236
+ return sympy_plot_for(
237
+ self, xlim=xlim, ylim=ylim, samples=samples, adaptive=adaptive
238
+ )
239
+
240
+ @Slot(str)
241
+ def savefig(self, path: str, *, backend: str = "qt", **kwargs: object) -> None:
242
+ """Write the plot to ``path`` (the format comes from the suffix); QML passes a path only.
243
+
244
+ ``backend="qt"`` (the default) renders through the offscreen Qt view — device pixels,
245
+ with :meth:`to_image`'s keywords. ``backend="matplotlib"`` renders a *sampled* figure
246
+ through :meth:`to_matplotlib` and calls matplotlib's own ``savefig``, so vector formats
247
+ (``.svg``, ``.pdf``) work and the look is matplotlib's, not the Qt view's.
248
+ """
249
+ if backend == "qt":
250
+ if not self.to_image(**kwargs).save(str(path)):
251
+ raise OSError(f"could not write the export to {path!r}")
252
+ return
253
+ if backend == "matplotlib":
254
+ from .mpl import save_via_matplotlib
255
+
256
+ save_via_matplotlib(self, str(path), **kwargs)
257
+ return
258
+ raise ValueError(f'backend must be "qt" or "matplotlib", got {backend!r}')
259
+
149
260
  # -------------------------------------------------------------- styling
150
- background: QColor = _style(QColor, "background", backgroundChanged, QColor("#14141e"))
151
- grid: bool = _style(bool, "grid", gridChanged, True)
152
- grid_color: QColor = _style(QColor, "grid_color", gridColorChanged, QColor("#2a2a3a"))
153
- grid_width: float = _style(float, "grid_width", gridWidthChanged, 1.0)
154
- grid_alpha: float = _style(float, "grid_alpha", gridAlphaChanged, 0.5)
261
+ # The fallbacks repeat matplotlib's default style (themes/default.mplstyle, applied in
262
+ # __init__) so a plot looks the same whether or not a theme was applied.
263
+ background: QColor = _style(
264
+ QColor, "background", backgroundChanged, QColor("white")
265
+ )
266
+ grid: bool = _style(bool, "grid", gridChanged, False)
267
+ grid_color: QColor = _style(
268
+ QColor, "grid_color", gridColorChanged, QColor("#b0b0b0")
269
+ )
270
+ grid_width: float = _style(float, "grid_width", gridWidthChanged, 1.07)
271
+ grid_alpha: float = _style(float, "grid_alpha", gridAlphaChanged, 1.0)
155
272
  #: "-" | "--" | ":" | "-." (QML turns it into a dash pattern).
156
273
  grid_style: str = _style(str, "grid_style", gridStyleChanged, "-")
157
- text_color: QColor = _style(QColor, "text_color", textColorChanged, QColor("#c8c8d2"))
158
- tick_color: QColor = _style(QColor, "tick_color", tickColorChanged, QColor("#6a6a80"))
159
- tick_length: float = _style(float, "tick_length", tickLengthChanged, 6.0)
160
- font_size: float = _style(float, "font_size", fontSizeChanged, 12.0)
161
- tick_font_size: float = _style(float, "tick_font_size", tickFontSizeChanged, 11.0)
274
+ axis_color: QColor = _style(QColor, "axis_color", axisColorChanged, QColor("black"))
275
+ axis_width: float = _style(float, "axis_width", axisWidthChanged, 1.07)
276
+ text_color: QColor = _style(QColor, "text_color", textColorChanged, QColor("black"))
277
+ tick_color: QColor = _style(QColor, "tick_color", tickColorChanged, QColor("black"))
278
+ tick_length: float = _style(float, "tick_length", tickLengthChanged, 4.67)
279
+ font_size: float = _style(float, "font_size", fontSizeChanged, 13.33)
280
+ tick_font_size: float = _style(float, "tick_font_size", tickFontSizeChanged, 13.33)
162
281
  title: str = _style(str, "title", titleChanged, "")
163
- title_color: QColor = _style(QColor, "title_color", titleColorChanged, QColor("#e8e8f0"))
164
- title_font_size: float = _style(float, "title_font_size", titleFontSizeChanged, 15.0)
165
- title_bold: bool = _style(bool, "title_bold", titleBoldChanged, True)
282
+ title_color: QColor = _style(
283
+ QColor, "title_color", titleColorChanged, QColor("black")
284
+ )
285
+ title_font_size: float = _style(
286
+ float, "title_font_size", titleFontSizeChanged, 16.0
287
+ )
288
+ title_bold: bool = _style(bool, "title_bold", titleBoldChanged, False)
166
289
  ticks_visible: bool = _style(bool, "ticks_visible", ticksVisibleChanged, True)
167
290
 
168
291
  def _get_color_cycle(self) -> list[str]:
@@ -176,37 +299,68 @@ class Plot(QObject):
176
299
  self._curves.set_color_cycle(tuple(cycle))
177
300
  self.colorCycleChanged.emit()
178
301
 
179
- color_cycle: list[str] = Property("QVariant", _get_color_cycle, _set_color_cycle,
180
- notify=colorCycleChanged)
302
+ color_cycle: list[str] = Property(
303
+ "QVariant", _get_color_cycle, _set_color_cycle, notify=colorCycleChanged
304
+ )
181
305
 
182
306
  def _get_line_width(self) -> float:
183
- return float(getattr(self, "_line_width", 1.5))
307
+ return float(getattr(self, "_line_width", 2.0))
184
308
 
185
309
  def _set_line_width(self, value: float) -> None:
186
310
  value = float(value)
187
311
  if value == self._get_line_width():
188
312
  return
189
313
  self._line_width = value
190
- self._curves.set_default_line_width(value) # default for the curves added later
314
+ self._curves.set_default_line_width(value) # default for the curves added later
191
315
  self.lineWidthChanged.emit()
192
316
 
193
317
  #: Default line width of new curves (logical pixels).
194
- line_width: float = Property(float, _get_line_width, _set_line_width,
195
- notify=lineWidthChanged)
318
+ line_width: float = Property(
319
+ float, _get_line_width, _set_line_width, notify=lineWidthChanged
320
+ )
321
+
322
+ def _get_axes_position(self) -> str:
323
+ return getattr(self, "_axes_position", "zero")
324
+
325
+ def _set_axes_position(self, value: str) -> None:
326
+ value = str(value)
327
+ if value not in ("zero", "edge"):
328
+ raise ValueError(f'axesPosition must be "zero" or "edge", got {value!r}')
329
+ if value == self._get_axes_position():
330
+ return
331
+ self._axes_position = value
332
+ self.axesPositionChanged.emit()
333
+
334
+ #: Where the ticks ride: the axes through world (0,0), or the item's edges.
335
+ axes_position: str = Property(
336
+ str, _get_axes_position, _set_axes_position, notify=axesPositionChanged
337
+ )
338
+
339
+ def _get_available_themes(self) -> list[str]:
340
+ return list(names())
341
+
342
+ #: Theme names a host can offer (``themes.names()``); read-only.
343
+ available_themes: list[str] = Property(
344
+ "QVariant", _get_available_themes, notify=available_themesChanged
345
+ )
196
346
 
197
347
  def _get_theme(self) -> str:
198
348
  return getattr(self, "_theme", "")
199
349
 
200
350
  def _set_theme(self, name: str) -> None:
201
- """Apply a named theme: every entry is a style property of this object."""
202
- name = str(name)
203
- if name == self._get_theme():
204
- return
205
- from .themes import resolve # lazy: a missing themes module must not break import
351
+ """Apply a named theme: every entry is a style property of this object.
206
352
 
207
- resolved = resolve(name) # unknown name -> KeyError, theme unchanged
353
+ Assigning a theme always applies it — that is what makes ``plot.theme = "default"`` a
354
+ reset back to matplotlib's look. The style setters emit only on a real change, so a
355
+ re-application does not churn. An unknown name raises ``KeyError`` before applying
356
+ anything, leaving the current look alone.
357
+ """
358
+ name = str(name)
359
+ resolved = resolve(name)
208
360
  for key, value in resolved.items():
209
361
  setattr(self, key, value)
362
+ if name == self._get_theme():
363
+ return
210
364
  self._theme = name
211
365
  self.themeChanged.emit()
212
366
 
@@ -141,32 +141,68 @@ Item {
141
141
  }
142
142
  }
143
143
 
144
- // Ticks, labels and title: screen-space furniture, so plain items.
144
+ // Ticks, labels, axes and title: screen-space furniture, so plain items.
145
145
  Item {
146
+ id: furniture
147
+
146
148
  anchors.fill: parent
147
149
  visible: root.plot.ticks_visible
148
150
 
151
+ // The axes are the lines through world (0,0). When 0 is off screen the axis sticks to
152
+ // the nearest edge instead, so its labels are never lost (the grid stays where it is).
153
+ // "edge" pins both axes to the item's edges and draws no axis line: the old behaviour.
154
+ readonly property bool atZero: root.plot.axes_position === "zero"
155
+ readonly property real xAxisY: atZero
156
+ ? Math.max(0, Math.min(height, root.mapToScreen(0, 0).y))
157
+ : height
158
+ readonly property real yAxisX: atZero
159
+ ? Math.max(0, Math.min(width, root.mapToScreen(0, 0).x))
160
+ : 0
161
+
162
+ Rectangle {
163
+ visible: furniture.atZero
164
+ x: 0
165
+ width: parent.width
166
+ height: root.plot.axis_width
167
+ y: furniture.xAxisY - height / 2
168
+ color: root.plot.axis_color
169
+ }
170
+ Rectangle {
171
+ visible: furniture.atZero
172
+ x: furniture.yAxisX - width / 2
173
+ y: 0
174
+ width: root.plot.axis_width
175
+ height: parent.height
176
+ color: root.plot.axis_color
177
+ }
178
+
149
179
  Repeater {
150
180
  model: root.plot.camera.ticks_x
151
181
 
182
+ // The mark and the label ride the x axis; both are clamped so they stay on screen
183
+ // when the axis is pinned to an edge (a label may never leave the item).
152
184
  delegate: Item {
153
185
  required property var modelData
154
186
 
155
187
  x: Math.round(root.mapToScreen(modelData[0], 0).x)
156
188
  width: 1
157
- height: root.plot.tick_length
158
- anchors.bottom: parent.bottom
189
+ height: furniture.height
159
190
 
160
191
  Rectangle {
161
- anchors.fill: parent
192
+ y: Math.max(0, Math.min(furniture.xAxisY, furniture.height - height))
193
+ width: 1
194
+ height: root.plot.tick_length
162
195
  color: root.plot.tick_color
163
196
  }
164
197
  Text {
165
198
  text: modelData[1]
166
199
  color: root.plot.text_color
167
200
  font.pixelSize: root.plot.tick_font_size
168
- x: -Math.round(width / 2)
169
- y: -height - 3
201
+ // centred on the tick, but never half off the item
202
+ x: Math.max(-parent.x, Math.min(-Math.round(width / 2),
203
+ furniture.width - width - parent.x))
204
+ y: Math.max(0, Math.min(furniture.xAxisY + root.plot.tick_length + 3,
205
+ furniture.height - height))
170
206
  }
171
207
  }
172
208
  }
@@ -177,21 +213,26 @@ Item {
177
213
  delegate: Item {
178
214
  required property var modelData
179
215
 
216
+ x: 0
180
217
  y: Math.round(root.mapToScreen(0, modelData[0]).y)
181
- width: root.plot.tick_length
218
+ width: furniture.width
182
219
  height: 1
183
- anchors.left: parent.left
184
220
 
185
221
  Rectangle {
186
- anchors.fill: parent
222
+ x: Math.max(0, Math.min(furniture.yAxisX - width, furniture.width - width))
223
+ width: root.plot.tick_length
224
+ height: 1
187
225
  color: root.plot.tick_color
188
226
  }
189
227
  Text {
190
228
  text: modelData[1]
191
229
  color: root.plot.text_color
192
230
  font.pixelSize: root.plot.tick_font_size
193
- x: root.plot.tick_length + 3
194
- y: -Math.round(height / 2)
231
+ x: Math.max(3, Math.min(furniture.yAxisX - width - 3,
232
+ furniture.width - width - 3))
233
+ // centred on the tick, but never half off the item
234
+ y: Math.max(-parent.y, Math.min(-Math.round(height / 2),
235
+ furniture.height - height - parent.y))
195
236
  }
196
237
  }
197
238
  }
@@ -0,0 +1,26 @@
1
+ # Matplotlib's default style (the values in matplotlib.rcParamsDefault), so a plot with no
2
+ # theme looks like a matplotlib plot out of the box. Written out rather than imported because
3
+ # Matplotlib keeps its defaults in rcParams, not in a stylelib file.
4
+ axes.facecolor: white
5
+ figure.facecolor: white
6
+ axes.edgecolor: black
7
+ axes.linewidth: 0.8
8
+ axes.grid: False
9
+ grid.color: b0b0b0
10
+ grid.linestyle: -
11
+ grid.linewidth: 0.8
12
+ grid.alpha: 1.0
13
+ axes.prop_cycle: cycler('color', ['1f77b4', 'ff7f0e', '2ca02c', 'd62728', '9467bd', '8c564b', 'e377c2', '7f7f7f', 'bcbd22', '17becf'])
14
+ lines.linewidth: 1.5
15
+ text.color: black
16
+ axes.labelcolor: black
17
+ xtick.color: black
18
+ ytick.color: black
19
+ xtick.major.size: 3.5
20
+ ytick.major.size: 3.5
21
+ font.size: 10
22
+ xtick.labelsize: medium
23
+ ytick.labelsize: medium
24
+ axes.titlecolor: black
25
+ axes.titlesize: large
26
+ axes.titleweight: normal
qmlmathplot/themes.py CHANGED
@@ -60,6 +60,8 @@ _SOURCES: dict[str, tuple[str, ...]] = {
60
60
  "grid_alpha": ("grid.alpha",),
61
61
  "grid_style": ("grid.linestyle",),
62
62
  "color_cycle": ("axes.prop_cycle",),
63
+ "axis_color": ("axes.edgecolor",),
64
+ "axis_width": ("axes.linewidth",),
63
65
  "line_width": ("lines.linewidth",),
64
66
  "text_color": ("text.color", "axes.labelcolor"),
65
67
  "tick_color": ("xtick.color", "ytick.color"),
@@ -75,26 +77,29 @@ _SOURCES: dict[str, tuple[str, ...]] = {
75
77
  _BOOL_KEYS = frozenset({"axes.grid", "axes.titleweight"})
76
78
  #: Keys whose value is a size in points (converted to logical pixels).
77
79
  _PT_KEYS = frozenset({
78
- "grid.linewidth", "lines.linewidth", "xtick.major.size", "ytick.major.size",
80
+ "grid.linewidth", "lines.linewidth", "axes.linewidth",
81
+ "xtick.major.size", "ytick.major.size",
79
82
  "font.size", "xtick.labelsize", "ytick.labelsize", "axes.titlesize",
80
83
  })
81
84
  #: Keys whose value is a colour.
82
85
  _COLOR_KEYS = frozenset({
83
86
  "xtick.color", "ytick.color", "text.color", "axes.labelcolor", "axes.titlecolor",
84
- "grid.color", "axes.facecolor", "figure.facecolor",
87
+ "grid.color", "axes.facecolor", "figure.facecolor", "axes.edgecolor",
85
88
  })
86
89
 
87
90
 
88
91
  def names() -> list[str]:
89
- """Names of the bundled themes (file stems), plus ``"default"`` (no overrides)."""
92
+ """Names of the bundled themes (file stems), ``"default"`` first.
93
+
94
+ ``"default"`` is Matplotlib's default style (``themes/default.mplstyle``), which is also
95
+ what a Plot uses before any theme is applied, so ``plot.theme = "default"`` restores it.
96
+ """
90
97
  stems = sorted(p.stem for p in THEME_DIR.glob("*.mplstyle")) if THEME_DIR.is_dir() else []
91
- return ["default", *stems]
98
+ return ["default", *(s for s in stems if s != "default")]
92
99
 
93
100
 
94
101
  def raw(name: str) -> dict[str, str]:
95
102
  """The theme's raw ``rcParams`` (key -> text value), before conversion."""
96
- if name == "default":
97
- return {}
98
103
  path = Path(name)
99
104
  if not path.is_file():
100
105
  path = THEME_DIR / f"{name}.mplstyle"
qmlmathplot/widget.py CHANGED
@@ -37,9 +37,9 @@ class MathPlotWidget(QWidget):
37
37
  """Function plotting widget (QWidget version).
38
38
 
39
39
  :param expression: expression in sympy syntax, ``"sin(x)"`` by default
40
- :param line_width: line width (logical pixels)
41
- :param curve_color: curve color (``#rrggbb``)
42
- :param background_color: background color (``#rrggbb``)
40
+ :param line_width: line width (logical pixels); None keeps the plot's own default
41
+ :param curve_color: curve color (``#rrggbb``); None keeps the plot's own default
42
+ :param background_color: background color (``#rrggbb``); None keeps the plot's own default
43
43
  :param aspect: ``"auto"`` (default) or the ratio of the y scale to the x scale
44
44
 
45
45
  The signals ``expressionChanged`` / ``errorChanged`` stay in sync with the first curve; a
@@ -54,14 +54,15 @@ class MathPlotWidget(QWidget):
54
54
  expression: str = "sin(x)",
55
55
  parent: QWidget | None = None,
56
56
  *,
57
- line_width: float = 1.5,
58
- curve_color: str = "#33ccff",
59
- background_color: str = "#14141e",
57
+ line_width: float | None = None,
58
+ curve_color: str | None = None,
59
+ background_color: str | None = None,
60
60
  aspect: str | float = "auto",
61
61
  ) -> None:
62
62
  # Check before super().__init__(): constructing a QWidget without a
63
- # QApplication aborts inside Qt, so the friendly error would never be reached.
64
- if QApplication.instance() is None:
63
+ # QApplication aborts inside Qt, so the friendly error would never be reached (note
64
+ # that `QApplication.instance()` is the QGuiApplication in a pure-QML host).
65
+ if not isinstance(QApplication.instance(), QApplication):
65
66
  raise RuntimeError("please create a QApplication first — QQuickWidget needs QtWidgets")
66
67
  super().__init__(parent)
67
68
  register_qml_types()
@@ -79,8 +80,12 @@ class MathPlotWidget(QWidget):
79
80
 
80
81
  self._root = self._quick.rootObject() # status is Ready, so this is always valid
81
82
  self._plot = Plot(self)
82
- self._plot.background = background_color
83
- self._plot.line_width = float(line_width)
83
+ # None for a style argument means "keep the plot's own default" (which the plot takes
84
+ # from its theme), so the widget does not re-state matplotlib's numbers.
85
+ if background_color is not None:
86
+ self._plot.background = background_color
87
+ if line_width is not None:
88
+ self._plot.line_width = float(line_width)
84
89
  self._curve = self._plot.add_curve(expression, color=curve_color, line_width=line_width)
85
90
  self._curve.expressionChanged.connect(self.expressionChanged)
86
91
  self._curve.errorChanged.connect(self.errorChanged)
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmlmathplot
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
5
5
  Author: I-AM-A-NOOB
6
6
  Author-email: I-AM-A-NOOB <suzc08@outlook.com>
7
7
  Requires-Dist: sympy>=1.14
8
8
  Requires-Dist: pyside6>=6.7
9
- Requires-Python: >=3.14
9
+ Requires-Dist: matplotlib>=3.8 ; extra == 'matplotlib'
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: matplotlib
10
12
  Description-Content-Type: text/markdown
11
13
 
12
14
  # QMLMathPlot
@@ -38,6 +40,10 @@ window.show()
38
40
  app.exec()
39
41
  ```
40
42
 
43
+ An un-themed plot looks like matplotlib (white background, tab10 colours, grey grid, black
44
+ axes through the origin with the tick labels riding them) — the default style sheet is applied
45
+ at construction, so `plot.theme = "default"` is the reset.
46
+
41
47
  `MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
42
48
  `line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
43
49
  `reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
@@ -116,7 +122,7 @@ The model:
116
122
 
117
123
  | Object | Members |
118
124
  |---|---|
119
- | `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
125
+ | `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
120
126
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, slots `setViewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `pan_pixels(dx, dy)` / `reset()` |
121
127
  | `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
122
128
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
@@ -135,8 +141,8 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
135
141
  Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
136
142
  while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
137
143
  bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
138
- chrome), and an export is a canvas region at a pixel size rather than a re-rendered page (not
139
- implemented yet).
144
+ chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
145
+ (§9, implemented — see *Export* below).
140
146
 
141
147
  ## The camera (centre, zoom, aspect)
142
148
 
@@ -164,12 +170,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
164
170
 
165
171
  ## Themes
166
172
 
167
- `Plot.theme` applies a style sheet to the style properties above:
173
+ `Plot.theme` applies a style sheet to the style properties above; the default is
174
+ matplotlib's own default style, so an untouched plot already looks like matplotlib and
175
+ `plot.theme = "default"` is the reset:
168
176
 
169
177
  ```python
170
178
  plot.theme = "ggplot" # any of the bundled sheets
179
+ plot.theme = "default" # back to matplotlib's look
171
180
  plot.theme = "my-style" # themes/my-style.mplstyle next to the package
172
181
  plot.theme = "path/to/any.mplstyle" # any file in the same format
182
+ plot.available_themes # the names, for a host's picker (both demos use one)
173
183
  ```
174
184
 
175
185
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
@@ -179,6 +189,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
179
189
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
180
190
  theme alone.
181
191
 
192
+ ## Axes, grid and ticks
193
+
194
+ - The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
195
+ `grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
196
+ - The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
197
+ `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
198
+ axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
199
+ nearest edge, so its labels are never lost (and a label never leaves the item); the grid
200
+ stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
201
+ - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
202
+ - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
203
+ `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
204
+ and formats them, so a frame never recomputes ticks.
205
+ - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
206
+
207
+ ## Export
208
+
209
+ `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
210
+
211
+ ```python
212
+ plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
213
+ plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
214
+ image = plot.to_image(width=800, height=600) # -> QImage
215
+ plot.to_image() # the live view, its own size
216
+ ```
217
+
218
+ - `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
219
+ size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
220
+ - `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
221
+ multiplies both (`dpi=2.0` doubles the pixels).
222
+ - `adjustable` reconciles a requested range with a requested size when their aspects disagree —
223
+ Matplotlib's parameter, and its own default:
224
+
225
+ | `adjustable` | What happens |
226
+ |---|---|
227
+ | `"box"` (**default**) | one scale, taken from the live scale ratio, chosen so the requested range fits: the range is centred and the leftover pixels stay background — *nothing is distorted* |
228
+ | `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
229
+ | `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
230
+
231
+ With no explicit range all three agree, because the range derived from the camera already has
232
+ the export size's aspect.
233
+ - `transparent=True` clears to alpha 0.
234
+ - Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
235
+ drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
236
+ export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
237
+
238
+ ### Vector output, matplotlib and sympy
239
+
240
+ `to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
241
+ renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
242
+ `sympy.plotting`, which is what you want when the figure should live in sympy's world:
243
+
244
+ ```python
245
+ figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
246
+ plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
247
+ handle = plot.to_sympy(xlim=(-1, 1))
248
+ handle.save("out.png")
249
+ ```
250
+
251
+ matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
252
+ package imports it until one of these is called, and the error names the extra. `sympy` itself is
253
+ a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
254
+ Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
255
+ usable next to a notebook or a matplotlib-only tool.
256
+
257
+ **This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
258
+ evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
259
+ aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
260
+ solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
261
+ `sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
262
+ warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
263
+ the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
264
+ actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
265
+ available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
266
+ renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
267
+ colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
268
+ axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
269
+ `axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
270
+ matplotlib's ecosystem — nothing else.
271
+
182
272
  ## RHI backends
183
273
 
184
274
  `--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
@@ -197,6 +287,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
197
287
  | Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
198
288
  | View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
199
289
  | Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
290
+ | Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
291
+ | Hand-off | `src/qmlmathplot/mpl.py` | `Plot.to_matplotlib()` / `to_sympy()` / `savefig(backend="matplotlib")`: a sampled second renderer for vector output (matplotlib is an optional extra, imported lazily) |
200
292
  | Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
201
293
  | Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
202
294
  | Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
@@ -237,15 +329,18 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
237
329
  - `test_curve.py`: a failed expression keeps the last working shaders, the style
238
330
  setters are idempotent, and the list model reports the right roles.
239
331
  - `test_plot.py`: the plot's properties convert, notify exactly once and are wired
240
- (a PySide property with an unresolved notify name silently loses it), the limits
241
- are forwarded to the camera, and a theme applies / an unknown name is rejected.
332
+ (a PySide property with an unresolved notify name silently loses it), the defaults are
333
+ matplotlib's default style, the limits are forwarded to the camera, and a theme applies
334
+ (and re-applies as a reset) while an unknown name is rejected.
242
335
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
243
336
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
244
337
  the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
245
338
  - `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
246
339
  shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
247
340
  columns, poles not connected, nothing drawn outside the domain, and the QML
248
- furniture (grid lines exactly at the tick positions, ticks/labels, title).
341
+ furniture (grid lines exactly at the tick positions, the axes crossing at the origin
342
+ with their labels on them, the axis pinning to the edge once the origin is panned
343
+ away, the title).
249
344
  - `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
250
345
  appearing inside a layout, an expression change / an invalid expression keeping the
251
346
  previous graph, curves added/hidden/removed through the model, **the wheel over a
@@ -255,6 +350,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
255
350
  needs one, and a process may only have one).
256
351
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
257
352
  must not steal the pan).
353
+ - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
354
+ `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
355
+ outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
356
+ `.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
357
+ `qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
358
+ extra.
359
+ - `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
360
+ (`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
361
+ `adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
362
+ in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
363
+ camera and background survive an export, and `transparent` clears to alpha 0.
258
364
 
259
365
  ## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
260
366
 
@@ -355,6 +461,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
355
461
  same x 9 times, and macros are preprocessor expansion with no call semantics.
356
462
  - PySide ties a property's notify signal to the `Signal` **object**; a name string
357
463
  silently leaves the property without one (and QML bindings then never refresh).
464
+ - Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
465
+ axis position put the 1.07 px y-axis half off the centre column, so the line came out
466
+ pale where it should be black.
467
+ - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
468
+ widget has no such property, so it silently does nothing): inject the model with
469
+ `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
358
470
 
359
471
  ## Releasing
360
472
 
@@ -376,6 +488,12 @@ be rehearsed:
376
488
  pip install -i https://test.pypi.org/simple/ qmlmathplot
377
489
  ```
378
490
 
491
+ If the publish step fails with `invalid-publisher: valid token, but no corresponding
492
+ publisher`, the trusted-publisher entry is missing or does not match. The action prints the
493
+ claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
494
+ exactly, **including the environment name**, and it must exist on the index the run targets: a
495
+ manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
496
+
379
497
  The workflow runs on Windows: that is the platform this library is verified on (the shader baker
380
498
  `qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
381
499
  and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
@@ -1,10 +1,12 @@
1
1
  qmlmathplot/__init__.py,sha256=C_fYVkO8WQPVW1K_wvjdH8OniRbUpdCkIPB-ULvP6Kk,2832
2
2
  qmlmathplot/app.py,sha256=9ojtrU9_wIcgUq7Ib449KRkuLSsr7FLzSBNguI-T8l4,3124
3
- qmlmathplot/camera.py,sha256=CJQ71gMNE-hx3ns1fiLT0j8sJulllnKeYyrZm7fOZrY,15480
4
- qmlmathplot/curve.py,sha256=BaRhxcLSoX0rLpaiSOrUF52uypDqHu_iVvRToEMtdos,10587
3
+ qmlmathplot/camera.py,sha256=YSLtGODs_efGwmtKbjT9osss-jmyV7MaLEYOu_zcxg0,16894
4
+ qmlmathplot/curve.py,sha256=Q-3S8WhsHWanWnZXkHH_nak8q7CXDHSioqyUyblKQJ0,10723
5
+ qmlmathplot/export.py,sha256=QfoE4TKnKt-xdaUAkB8IsyupyfwFKu8hn0ikKp8mzrI,8065
5
6
  qmlmathplot/model.py,sha256=WAY2VyoMKR5vGkPxrr2GOfUnl0xXW01ntfQ_qW8i2Wg,24961
6
- qmlmathplot/plot.py,sha256=4uG3EOat_F1wQRQ-BGmOAqnK1lBQ0h-pPJrzDD3Dq_I,8955
7
- qmlmathplot/qml/PlotView.qml,sha256=PBIEwAXGDVpoJEzwUdXDk2FIZqkOak6He4VsVEUJq5w,9249
7
+ qmlmathplot/mpl.py,sha256=Liaqq1NWBjuSe95uxBsx2YbUULU4F5kvcvWDQErnE-Y,10684
8
+ qmlmathplot/plot.py,sha256=XDp_Jg2FQLQLDmUhUy5tuzAFCerihwitaBICp4i_xuA,15306
9
+ qmlmathplot/qml/PlotView.qml,sha256=4XfSGcmlsD6LNua_qhB5DtVuL-PTFO3gG4LhJzzQC7M,11519
8
10
  qmlmathplot/qsb.py,sha256=kTaC3hKAZYSNU5M2ySRFcWUdwTZT8DAHUdsUgKRCanc,3964
9
11
  qmlmathplot/themes/LICENSE.matplotlib,sha256=ojr3trhyymyw7GeYxkhO2JYoAxUVscbgMFz9b1qrcVM,4928
10
12
  qmlmathplot/themes/README.md,sha256=4IHDS6yasksnvrWvhEa5b5aUIa73US2C72-ndS3blow,767
@@ -12,6 +14,7 @@ qmlmathplot/themes/Solarize_Light2.mplstyle,sha256=En_R213nyEuOVDRw_RyN51uYLgJFP
12
14
  qmlmathplot/themes/bmh.mplstyle,sha256=IxH21Mw1K17WATUfza3HdG7uBdT1-Sksv0H0P9DsiVY,714
13
15
  qmlmathplot/themes/classic.mplstyle,sha256=ewUO1vd72JCowfCwc5i3CJrt1X5wmjSI1UzcSNNhXzc,25082
14
16
  qmlmathplot/themes/dark_background.mplstyle,sha256=8nQHo3xgzWx6SPf8rsQ2gT89NXyQHaLhkgsDbAantGk,633
17
+ qmlmathplot/themes/default.mplstyle,sha256=2fVjCiF2yDrw87nz0i4k39fY3izejl5AZ-OKF9SlXwU,869
15
18
  qmlmathplot/themes/fast.mplstyle,sha256=HDqa0GATC9GjNeRA8rYiZM-qh7hTxsraeyYziGlbgzg,299
16
19
  qmlmathplot/themes/fivethirtyeight.mplstyle,sha256=lkCjlDtqUnDiMxEr6BDa-qMMIfVDxVS8ycfJcv8bmtw,817
17
20
  qmlmathplot/themes/ggplot.mplstyle,sha256=S7Oc_ty3DpUM0A2kctZBemMjj7IS0ccJl06QJFQh2Xw,995
@@ -34,10 +37,10 @@ qmlmathplot/themes/seaborn-v0_8-white.mplstyle,sha256=VY6sw8wkqbl0leWtWa5gz8xfDM
34
37
  qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle,sha256=IOm2H1utXO_zR7FWqMLBiRxHyxABL3kq1fh0-6BDJ0E,694
35
38
  qmlmathplot/themes/seaborn-v0_8.mplstyle,sha256=N9lUFHvOn06wT4MODXpVVGQMSueONejeAfCX5UfWrIM,1187
36
39
  qmlmathplot/themes/tableau-colorblind10.mplstyle,sha256=PzUMoOtw0V6l0bPk8ApRAKvcxdJmzRU2bVOkNqz8DnU,192
37
- qmlmathplot/themes.py,sha256=Ny_B1ydTsIsiNqnj-Je68seRU62UQ9VLiDBwpVS3-JA,8937
40
+ qmlmathplot/themes.py,sha256=QocZLOgPYXCJW9fYc6i5q6nHEE_S4mQVZXySZ4ISCPw,9225
38
41
  qmlmathplot/view.py,sha256=G6W0WfI4yT0eX4rHswqwu3y48MQTXjcA0BnWpIS1vD0,1746
39
- qmlmathplot/widget.py,sha256=CGszc9CqmS0-zFDQUs2vJm_xPjPY1ukEtjEtsF5BXEU,7406
40
- qmlmathplot-0.1.0.dist-info/WHEEL,sha256=3iQx15CyXwGi4dbygedzA4M5T1eqO_SjwdPfUmqt2Cw,81
41
- qmlmathplot-0.1.0.dist-info/entry_points.txt,sha256=4z36D_RpPSYLiJDRM8iXnrSe1T4c2alfQJPcJTss3qY,54
42
- qmlmathplot-0.1.0.dist-info/METADATA,sha256=tmTr3CjN0lr7ARxm8LmyFbio0jDXobWy9MiUfVU37zQ,22103
43
- qmlmathplot-0.1.0.dist-info/RECORD,,
42
+ qmlmathplot/widget.py,sha256=HXSQko0YwixG0bx--BP6k_Fd_fn-58P-ywT2-HZw0E0,7899
43
+ qmlmathplot-0.2.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
44
+ qmlmathplot-0.2.0.dist-info/entry_points.txt,sha256=4z36D_RpPSYLiJDRM8iXnrSe1T4c2alfQJPcJTss3qY,54
45
+ qmlmathplot-0.2.0.dist-info/METADATA,sha256=qlC49LZEVjSKctg5FELNUSiMnUkisgwwAd5VtKONzqU,30542
46
+ qmlmathplot-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.12.24
2
+ Generator: uv 0.13.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any