qmlmathplot 0.1.0__py3-none-any.whl → 0.3.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/__init__.py CHANGED
@@ -51,7 +51,7 @@ Demos: ``examples/minimal.py``, ``examples/explorer_qtwidgets.py``,
51
51
  from typing import TYPE_CHECKING
52
52
 
53
53
  from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
54
- from .curve import Curve, CurveListModel
54
+ from .curve import Curve, CurveListModel, ImplicitCurve
55
55
  from .plot import Plot
56
56
  from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
57
57
 
@@ -64,6 +64,7 @@ __all__ = [
64
64
  "CurveListModel",
65
65
  "HOME_SIZE",
66
66
  "HOME_VIEW",
67
+ "ImplicitCurve",
67
68
  "MathPlotWidget",
68
69
  "nice_ticks",
69
70
  "Plot",
@@ -0,0 +1,13 @@
1
+ """``python -m qmlmathplot`` — the same entry point as the installed ``qmlmathplot`` script.
2
+
3
+ Keeping it here means the command works even when the console script is not on ``PATH`` (a
4
+ ``pip install --user``, a virtualenv used through ``python -m``, or a checkout run with
5
+ ``uv run``).
6
+ """
7
+
8
+ import sys
9
+
10
+ from .app import main
11
+
12
+ if __name__ == "__main__":
13
+ sys.exit(main())
qmlmathplot/app.py CHANGED
@@ -1,7 +1,10 @@
1
- """MVP entry point: wraps the component into a minimal verifiable window (called by `main.py` /
2
- `uv run qmlmathplot`).
1
+ """MVP entry point: wraps the component into a minimal verifiable window.
3
2
 
4
- python main.py --backend d3d11 "sin(1/x)"
3
+ Reachable as the installed ``qmlmathplot`` console script, as ``python -m qmlmathplot``, and as
4
+ ``examples/minimal.py``:
5
+
6
+ python -m qmlmathplot --backend d3d11 "sin(1/x)"
7
+ uv run qmlmathplot "tan(x)"
5
8
 
6
9
  The RHI backend comes from the launch argument; without it, Qt's default backend is used. When
7
10
  specified it must be written to ``QSG_RHI_BACKEND`` *before* ``QGuiApplication`` — Qt reads it
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
@@ -1,8 +1,12 @@
1
1
  """Curve layer: one expression -> GLSL -> .qsb plus its style, and the list model QML repeats.
2
2
 
3
- A curve knows nothing about the view: the camera decides where we look, the curve decides
4
- what is drawn. One curve = one fragment shader pass (see ``model.FRAGMENT_TEMPLATE``), so
5
- ``visible`` is the lever that costs nothing.
3
+ A curve knows nothing about the view: the camera decides where we look, the curve decides what is
4
+ drawn. One curve = one fragment shader pass, so ``visible`` is the lever that costs nothing.
5
+
6
+ Two artists share the class: ``Curve`` draws ``y = f(x)`` (see ``model.FRAGMENT_TEMPLATE``) and
7
+ ``ImplicitCurve`` draws the level set ``F(x, y) = 0`` (``model.IMPLICIT_FRAGMENT_TEMPLATE``).
8
+ They differ only in the shader *sources* — the uniform block, the model roles and the QML side
9
+ are the same — so ``curve_for()`` can pick one from the expression's free symbols.
6
10
  """
7
11
 
8
12
  from __future__ import annotations
@@ -20,23 +24,76 @@ from PySide6.QtCore import (
20
24
  )
21
25
  from PySide6.QtGui import QColor
22
26
 
23
- from .model import shader_sources
27
+ from .model import implicit_shader_sources, shader_sources
24
28
  from .qsb import bake
25
29
 
26
- __all__ = ["Curve", "CurveListModel"]
30
+ __all__ = ["Curve", "CurveListModel", "ImplicitCurve", "curve_for", "parsed"]
27
31
 
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")
32
+ #: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
33
+ DEFAULT_COLOR = "#1f77b4"
34
+ #: matplotlib's default ten-colour cycle.
35
+ DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
36
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf")
31
37
 
32
38
 
33
39
  def _color(value: object) -> QColor:
34
- """A QColor from a QColor or anything QColor() understands ("#rrggbb", a name, …)."""
40
+ """A QColor from a QColor, anything QColor() understands ("#rrggbb", a name, …), or None
41
+ for the default curve colour."""
42
+ if value is None:
43
+ return QColor(DEFAULT_COLOR)
35
44
  return QColor(value) if not isinstance(value, QColor) else QColor(value)
36
45
 
37
46
 
47
+ def parsed(value: str | sp.Expr) -> tuple[str, sp.Expr]:
48
+ """The canonical string and the parsed expression of a curve input.
49
+
50
+ A string is parsed here, once; a sympy expression is used as it is (no ``Expr -> str ->
51
+ Expr`` round trip). The canonical string is what identifies the expression.
52
+ """
53
+ if isinstance(value, sp.Basic):
54
+ return str(value), value
55
+ return str(value), sp.sympify(value, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
56
+
57
+
58
+ def curve_for(
59
+ expression: str | sp.Expr,
60
+ *,
61
+ implicit: bool | None = None,
62
+ color: object = None,
63
+ line_width: object = None,
64
+ label: str = "",
65
+ parent: QObject | None = None,
66
+ ) -> Curve:
67
+ """Create the artist an expression needs.
68
+
69
+ Auto-routed by the free symbols: ``x`` (or none) is a 1-D curve ``y = f(x)``, ``x`` and ``y``
70
+ is an implicit curve (the level set ``F(x, y) = 0``), and anything else raises — a parameter
71
+ must be substituted before it is plotted, and silently evaluating it as a pixel coordinate is
72
+ exactly the trap this avoids. ``implicit=True`` picks the level-set artist regardless of the
73
+ symbols (``add_implicit``).
74
+ """
75
+ text, expr = parsed(expression)
76
+ names = {symbol.name for symbol in expr.free_symbols}
77
+ if names - {"x", "y"}:
78
+ raise ValueError(
79
+ f"{sorted(names - {'x', 'y'})} must be substituted first: only x (or x and y) may "
80
+ f"be free, got {text!r}"
81
+ )
82
+ cls: type[Curve] = ImplicitCurve if (implicit or "y" in names) else Curve
83
+ return cls(expr, color=color, line_width=line_width, label=label, parent=parent)
84
+
85
+
38
86
  class Curve(QObject):
39
- """One curve: an expression, its baked shaders, its style and its error text."""
87
+ """One curve: an expression, its baked shaders, its style and its error text.
88
+
89
+ The fragment shader follows the expression: ``y = f(x)`` while only ``x`` (or nothing) is
90
+ free, the level set ``F(x, y) = 0`` as soon as ``y`` appears — so ``add_curve`` and assigning
91
+ to ``expression`` both route themselves, and a text field can turn a function into a level
92
+ set. ``ImplicitCurve`` forces the level-set template regardless of the symbols.
93
+ """
94
+
95
+ #: Forced to the level-set template (see ``ImplicitCurve``).
96
+ _force_implicit = False
40
97
 
41
98
  expressionChanged = Signal()
42
99
  styleChanged = Signal()
@@ -47,7 +104,7 @@ class Curve(QObject):
47
104
 
48
105
  def __init__(
49
106
  self,
50
- expression: str = "sin(x)",
107
+ expression: str | sp.Expr = "sin(x)",
51
108
  color: str | QColor = DEFAULT_COLOR,
52
109
  line_width: float = 1.5,
53
110
  label: str = "",
@@ -55,7 +112,8 @@ class Curve(QObject):
55
112
  ) -> None:
56
113
  super().__init__(parent)
57
114
  self._symbol = sp.Symbol("x")
58
- self._expression = str(expression)
115
+ self._expression = ""
116
+ self._expr: sp.Expr = sp.Integer(0) # replaced by the first assignment below
59
117
  self._color = _color(color)
60
118
  self._line_width = float(line_width)
61
119
  self._label = str(label)
@@ -63,23 +121,62 @@ class Curve(QObject):
63
121
  self._error = ""
64
122
  self._vertex = QUrl()
65
123
  self._fragment = QUrl()
66
- self._compile(self._expression)
124
+ self._set_expression(expression)
67
125
 
68
126
  # ---------------------------------------------------------------- Expression
69
127
  def _get_expression(self) -> str:
70
128
  return self._expression
71
129
 
72
- def _set_expression(self, source: str) -> None:
73
- source = str(source)
74
- if source == self._expression:
130
+ def _set_expression(self, source: str | sp.Expr) -> None:
131
+ """Set the expression from a string (parsed here, once) or from a sympy expression.
132
+
133
+ The canonical **string** is what identifies the expression — ``sp.sin(x)`` and
134
+ ``"sin(x)"`` are the same, and re-setting either is free — while the parsed
135
+ ``sp.Expr`` is cached for the bake and for the matplotlib hand-off, so neither ever
136
+ stringifies and re-parses again.
137
+ """
138
+ if isinstance(source, str):
139
+ canonical = source
140
+ else:
141
+ canonical = str(source)
142
+ if canonical == self._expression:
75
143
  return
76
- self._expression = source
144
+ if isinstance(source, sp.Basic): # already an expression: use it as it is
145
+ expr: sp.Expr = source
146
+ else:
147
+ try:
148
+ expr = sp.sympify(source, locals={"x": self._symbol, "y": sp.Symbol("y")})
149
+ except Exception as exc:
150
+ # Keep the last parsed expression: the shaders stay, `expression` is what was
151
+ # typed and `error` says why.
152
+ self._expression = str(canonical)
153
+ self._error = f"{type(exc).__name__}: {exc}"
154
+ self.expressionChanged.emit()
155
+ self.errorChanged.emit()
156
+ return
157
+ previous = self._expr
158
+ self._expression = str(canonical)
159
+ self._expr = expr
77
160
  self.expressionChanged.emit()
78
- self._compile(source)
161
+ if not self._compile(expr):
162
+ # `expression` stays what was set (the text field shows it), `expr` stays what is
163
+ # drawn — the same rule as the unparsable string above.
164
+ self._expr = previous
79
165
 
80
166
  # Annotate the value type (PySide6 idiom): Property is a descriptor, so without the
81
167
  # annotation a type checker only sees the Property object and flags every read and write.
82
- expression: str = Property(str, _get_expression, _set_expression, notify=expressionChanged)
168
+ # Declared "QVariant" so an ``sp.Expr`` reaches the setter unconverted; the getter is always
169
+ # the canonical string (QML, a project file and the text field all want one).
170
+ expression: str | sp.Expr = Property("QVariant", _get_expression, _set_expression,
171
+ notify=expressionChanged)
172
+
173
+ def _get_expr(self) -> sp.Expr:
174
+ return self._expr
175
+
176
+ #: The parsed expression being drawn (read-only): the object form the setter accepts, and
177
+ #: the one the bake and the matplotlib hand-off use. It follows the last expression that
178
+ #: compiled, so `expression` can show a failing edit while `expr` keeps the drawn curve.
179
+ expr: sp.Expr = Property("QVariant", _get_expr, notify=expressionChanged)
83
180
 
84
181
  # ------------------------------------------------------------------ Style
85
182
  def _get_color(self) -> QColor:
@@ -145,23 +242,57 @@ class Curve(QObject):
145
242
 
146
243
  error: str = Property(str, _get_error, notify=errorChanged)
147
244
 
148
- def _compile(self, source: str) -> None:
149
- """expression -> GLSL -> .qsb; on failure keep the last working shaders and write the
150
- reason into ``error``."""
245
+ @property
246
+ def implicit(self) -> bool:
247
+ """Whether this curve is currently drawn as a level set ``F(x, y) = 0``.
248
+
249
+ Read-only, and it follows the expression: assigning ``"x**2 + y**2 - 1"`` switches this
250
+ curve's shader to the implicit one. The matplotlib hand-off uses it to skip level sets,
251
+ which cannot be sampled into a polyline.
252
+ """
253
+ return self._force_implicit or "y" in {symbol.name for symbol in self._expr.free_symbols}
254
+
255
+ def _sources(self, expr: sp.Expr) -> tuple[str, str]:
256
+ """The shader sources for this expression; anything unrenderable raises ``ValueError``
257
+ (which ``_compile`` turns into ``error``, keeping the last working shaders)."""
258
+ names = {symbol.name for symbol in expr.free_symbols}
259
+ if names - {"x", "y"}:
260
+ raise ValueError(
261
+ f"only x (or x and y) may be free, got {sorted(names)}: substitute parameters "
262
+ "first"
263
+ )
264
+ return implicit_shader_sources(expr) if (self._force_implicit or "y" in names) \
265
+ else shader_sources(expr)
266
+
267
+ def _compile(self, expr: sp.Expr) -> bool:
268
+ """expr -> GLSL -> .qsb; on failure keep the last working shaders, write the reason into
269
+ ``error`` and return False. The expression is parsed by the setter, never here."""
151
270
  try:
152
- expr = sp.sympify(source, locals={"x": self._symbol})
153
- vert_src, frag_src = shader_sources(expr)
271
+ vert_src, frag_src = self._sources(expr)
154
272
  vertex = bake(vert_src, "vert")
155
273
  fragment = bake(frag_src, "frag")
156
274
  except Exception as exc: # noqa: BLE001 — expression or bake can fail; both reach the UI
157
275
  self._error = f"{type(exc).__name__}: {exc}"
158
276
  self.errorChanged.emit()
159
- return
277
+ return False
160
278
  self._error = ""
161
279
  self._vertex = QUrl.fromLocalFile(str(vertex))
162
280
  self._fragment = QUrl.fromLocalFile(str(fragment))
163
281
  self.errorChanged.emit()
164
282
  self.shadersChanged.emit()
283
+ return True
284
+
285
+
286
+ class ImplicitCurve(Curve):
287
+ """A curve drawn from the level set ``F(x, y) = 0``, whatever the free symbols.
288
+
289
+ No sampling and no envelope band: one evaluation of ``F`` and its two partials per pixel give
290
+ the distance to the level set (``docs/api-design.md`` §6b). Thin features and several
291
+ branches through one pixel are not handled yet, and a domain/pole curve is not special-cased
292
+ either — pixels where ``F`` is undefined come out NaN and are dropped.
293
+ """
294
+
295
+ _force_implicit = True
165
296
 
166
297
 
167
298
  class CurveListModel(QAbstractListModel):
@@ -243,21 +374,16 @@ class CurveListModel(QAbstractListModel):
243
374
  curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
244
375
 
245
376
  # ---------------------------------------------------------------- public
246
- @Slot(str, "QVariant", "QVariant", str, result=QObject)
247
- @Slot(str, result=QObject)
248
- def add_curve(
249
- self,
250
- expression: str,
251
- color: object = None,
252
- line_width: object = None,
253
- label: str = "",
254
- ) -> Curve:
255
- """Append a curve. Colour and line width default to the plot's cycle and width."""
377
+ # "QVariant" so a sympy expression is not stringified on the way in (see Plot.add_curve).
378
+ @Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
379
+ @Slot("QVariant", result=QObject)
380
+ def _defaults(self, color: object, line_width: object) -> tuple[QColor, float]:
256
381
  if color is None:
257
382
  color = self._cycle[len(self._curves) % len(self._cycle)]
258
- if line_width is None:
259
- line_width = self._default_line_width
260
- curve = Curve(expression, color, float(line_width), label, parent=self) # type: ignore[arg-type]
383
+ return _color(color), (self._default_line_width if line_width is None
384
+ else float(line_width)) # type: ignore[arg-type]
385
+
386
+ def _append(self, curve: Curve) -> Curve:
261
387
  row = len(self._curves)
262
388
  self.beginInsertRows(QModelIndex(), row, row)
263
389
  self._curves.append(curve)
@@ -265,6 +391,37 @@ class CurveListModel(QAbstractListModel):
265
391
  self._watch(curve)
266
392
  return curve
267
393
 
394
+ @Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
395
+ @Slot("QVariant", result=QObject)
396
+ def add_curve(
397
+ self,
398
+ expression: str | sp.Expr,
399
+ color: object = None,
400
+ line_width: object = None,
401
+ label: str = "",
402
+ ) -> Curve:
403
+ """Append the artist the expression needs: ``y = f(x)``, or the level set
404
+ ``F(x, y) = 0`` when both symbols are free (see ``curve_for``). Colour and line width
405
+ default to the plot's cycle and width."""
406
+ default_color, default_width = self._defaults(color, line_width)
407
+ return self._append(curve_for(expression, color=default_color, line_width=default_width,
408
+ label=label, parent=self))
409
+
410
+ @Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
411
+ @Slot("QVariant", result=QObject)
412
+ def add_implicit(
413
+ self,
414
+ expression: str | sp.Expr,
415
+ color: object = None,
416
+ line_width: object = None,
417
+ label: str = "",
418
+ ) -> Curve:
419
+ """Append an implicit curve (the level set ``F(x, y) = 0``) explicitly, whatever the free
420
+ symbols: ``x`` alone is a valid level set (vertical lines)."""
421
+ default_color, default_width = self._defaults(color, line_width)
422
+ return self._append(curve_for(expression, implicit=True, color=default_color,
423
+ line_width=default_width, label=label, parent=self))
424
+
268
425
  @Slot(QObject)
269
426
  def remove_curve(self, curve: Curve) -> None:
270
427
  row = self.index_of(curve)
qmlmathplot/export.py ADDED
@@ -0,0 +1,174 @@
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's own view (its centre and scales applied to the
74
+ size it was told it has — that is exactly ``camera.xlim`` / ``ylim``), ``width`` /
75
+ ``height`` default to the live view's device size (``dpi`` multiplies both), and
76
+ ``adjustable`` reconciles the two parameter sets. The pixel size changes the resolution,
77
+ never the region: the region cannot depend on the screen's ``devicePixelRatio``.
78
+ """
79
+ dpi = float(dpi)
80
+ if not dpi > 0:
81
+ raise ValueError(f"dpi must be positive, got {dpi!r}")
82
+ mode = "box" if adjustable is None else str(adjustable) # matplotlib's own default
83
+ if mode not in ADJUSTABLES:
84
+ raise ValueError(f"adjustable must be one of {ADJUSTABLES}, got {adjustable!r}")
85
+ for name, value in (("width", width), ("height", height)):
86
+ if value is not None and not float(value) > 0:
87
+ raise ValueError(f"{name} must be positive, got {value!r}")
88
+
89
+ _app, quick_widget = _widgets()
90
+ from .view import qml_component_path, register_qml_types
91
+
92
+ register_qml_types()
93
+ camera = plot.camera
94
+ live = (camera.centre.x(), camera.centre.y(), camera.scale_x, camera.scale_y)
95
+ live_size = (camera.viewport.x(), camera.viewport.y())
96
+ background = plot.background
97
+
98
+ widget: QQuickWidget = quick_widget()
99
+ widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
100
+ widget.setResizeMode(quick_widget.ResizeMode.SizeRootObjectToView)
101
+ widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
102
+ image = QImage()
103
+ device = (0, 0)
104
+ try:
105
+ if transparent:
106
+ plot.background = QColor(0, 0, 0, 0)
107
+ widget.setClearColor(QColor(0, 0, 0, 0))
108
+ widget.setSource(QUrl.fromLocalFile(qml_component_path()))
109
+ if widget.status() != quick_widget.Status.Ready:
110
+ errors = "; ".join(error.toString() for error in widget.errors())
111
+ raise RuntimeError(f"the QML component failed to load: {errors}")
112
+ root = widget.rootObject() # the widget itself has no such property
113
+ if root is None:
114
+ raise RuntimeError("the QML component has no root object")
115
+ root.setProperty("plot", plot)
116
+
117
+ # width/height are device pixels, so the offscreen view gets the logical size
118
+ pixel_ratio = widget.devicePixelRatioF()
119
+ device = (
120
+ round((float(width) if width is not None else live_size[0] * pixel_ratio) * dpi),
121
+ round((float(height) if height is not None else live_size[1] * pixel_ratio) * dpi),
122
+ )
123
+ logical = (device[0] / pixel_ratio, device[1] / pixel_ratio)
124
+ widget.resize(round(logical[0]), round(logical[1]))
125
+ widget.show() # offscreen: WA_DontShowOnScreen, never exposed
126
+
127
+ # The range: the camera's own view — its centre and scales applied to the size it was
128
+ # told it has, which is exactly ``camera.xlim``/``ylim`` — unless the caller asked for
129
+ # one. Not ``logical`` (the export's size): the scale is world units per logical pixel
130
+ # *of the live view*, so deriving the range from the offscreen widget's logical size
131
+ # made the region depend on the screen's devicePixelRatio and silently cropped it by
132
+ # that factor (at DPR 1.5 a ±2.5 view came out as ±1.67 at 400×400 device pixels, and a
133
+ # level set whose visible arc sat at |x| > 1.67 was not drawn at all). ``width``,
134
+ # ``height`` and ``dpi`` change the number of pixels, never the region; a caller that
135
+ # wants a different region passes ``xlim``/``ylim``.
136
+ range_x = _pair(xlim, "xlim") if xlim is not None else (
137
+ live[0] - 0.5 * live_size[0] * live[2], live[0] + 0.5 * live_size[0] * live[2])
138
+ range_y = _pair(ylim, "ylim") if ylim is not None else (
139
+ live[1] - 0.5 * live_size[1] * live[3], live[1] + 0.5 * live_size[1] * live[3])
140
+ span_x, span_y = range_x[1] - range_x[0], range_y[1] - range_y[0]
141
+ centre = (0.5 * (range_x[0] + range_x[1]), 0.5 * (range_y[0] + range_y[1]))
142
+
143
+ if mode == "stretch": # the range maps onto the size exactly
144
+ scale = (span_x / logical[0], span_y / logical[1])
145
+ elif mode == "box": # the live ratio, the range drawn as large as it fits
146
+ # The largest scale (the smallest world units per pixel) for which the requested
147
+ # range still fits: the binding axis is exact, the other keeps background margins.
148
+ # Drawn without distortion — a world circle stays round, only the visible span
149
+ # changes when the requested range's aspect differs from the size's.
150
+ ratio = live[2] / live[3]
151
+ scale_x = max(span_x / logical[0], ratio * span_y / logical[1])
152
+ scale = (scale_x, scale_x / ratio)
153
+ else: # "datalim": the live scales, the limits expand
154
+ scale = (max(live[2], span_x / logical[0]), max(live[3], span_y / logical[1]))
155
+
156
+ camera.setViewport(logical[0], logical[1])
157
+ camera.set_view(centre[0], centre[1], scale[0], scale[1])
158
+ image = widget.grabFramebuffer()
159
+ finally:
160
+ # Restore before the event loop runs again, so no live view repaints the export state.
161
+ camera.set_view(live[0], live[1], live[2], live[3])
162
+ camera.setViewport(live_size[0], live_size[1])
163
+ plot.background = background
164
+ widget.hide()
165
+ widget.deleteLater()
166
+
167
+ if image.size() != QSize(device[0], device[1]):
168
+ # At a fractional devicePixelRatio the offscreen widget can land within one pixel of the
169
+ # requested size; make the promise exact (a 1 px nearest resample is invisible).
170
+ image = image.scaled(
171
+ device[0], device[1],
172
+ Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.FastTransformation,
173
+ )
174
+ return image