From 3902a400ad7547de6427ceb68274f617004fc1d0 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:52:40 +0100 Subject: [PATCH 01/14] fix(graphic): render toPixmap/toImage into a QRectF QwtGraphic.toPixmap(size), QwtGraphic.toImage() and QwtGraphic.toImage(size) raised a TypeError instead of rendering the graphic, and could abort the process because the painter was still active when the pixmap or image was destroyed. The Qwt C++ code passes a QRect to render(), which is implicitly converted to a QRectF. The Python port passed the QRect as is, which render() rejects, and QRect(0, 0, width, height) also fails when the size is a QSizeF. toImage(size) did not end its painter either. - Build a QRectF, like toPixmap() already does, and end the painter in toImage(size). - Regression tests: default size, given size and kept aspect ratio, for both toPixmap and toImage. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/graphic.py | 9 +- qwt/tests/test_issue115_porting_bugs.py | 105 ++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 qwt/tests/test_issue115_porting_bugs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e6f75a..9a09b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Bug fixes - Fixed [Issue #113](https://github.com/PlotPyStack/PythonQwt/issues/113): after replacing the scale draw of an axis (`QwtPlot.setAxisScaleDraw`), the axis ticks could be misaligned with the grid lines and plot items, until the next scale division change (e.g. after panning or zooming): a replot did not fix it. The new scale draw was laid out before getting the position and length of the previous one, so that the axis reserved space for its end labels that the canvas did not take into account. The new scale draw now takes the geometry of the previous one, and the axis stays aligned with the canvas +- Fixed `QwtGraphic.toPixmap(size)`, `QwtGraphic.toImage()` and `QwtGraphic.toImage(size)`, which raised a `TypeError` (possibly aborting the process, as the painter was still active on the destroyed pixmap or image) instead of rendering the graphic: they now render it like `QwtGraphic.toPixmap()` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/graphic.py b/qwt/graphic.py index 9e18a4b..e78345c 100644 --- a/qwt/graphic.py +++ b/qwt/graphic.py @@ -15,7 +15,7 @@ import math -from qtpy.QtCore import QObject, QPointF, QRect, QRectF, QSize, QSizeF, Qt +from qtpy.QtCore import QObject, QPointF, QRectF, QSize, QSizeF, Qt from qtpy.QtGui import ( QImage, QPaintEngine, @@ -603,7 +603,7 @@ def toPixmap(self, *args): aspectRatioMode = args[-1] pixmap = QPixmap(size) pixmap.fill(Qt.transparent) - r = QRect(0, 0, size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) painter = QPainter(pixmap) self.render(painter, r, aspectRatioMode) painter.end() @@ -652,7 +652,7 @@ def toImage(self, *args): h = math.ceil(sz.height()) image = QImage(w, h, QImage.Format_ARGB32) image.fill(0) - r = QRect(0, 0, sz.width(), sz.height()) + r = QRectF(0.0, 0.0, sz.width(), sz.height()) painter = QPainter(image) self.render(painter, r, Qt.KeepAspectRatio) painter.end() @@ -664,9 +664,10 @@ def toImage(self, *args): aspectRatioMode = args[-1] image = QImage(size, QImage.Format_ARGB32_Premultiplied) image.fill(0) - r = QRect(0, 0, size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) painter = QPainter(image) self.render(painter, r, aspectRatioMode) + painter.end() return image def drawPath(self, path): diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py new file mode 100644 index 0000000..ca35a32 --- /dev/null +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the Qwt License +# (see LICENSE file for more details) + +""" +Tests for issue 115: porting bugs making several rarely used APIs fail. + +Each test covers a difference between the Python port and the Qwt C++ code, +which made the API raise an exception or ignore some of its arguments. +""" + +import pytest +from qtpy import QtCore as QC +from qtpy import QtGui as QG +from qtpy import QtWidgets as QW + +from qwt.graphic import QwtGraphic + +RED = QG.QColor(QC.Qt.red) + + +def _ensure_app(): + # A live QApplication must exist before constructing any QWidget or + # QPixmap, otherwise Qt aborts the process. Tests run in a shared + # interpreter, but no test keeps a persistent Python reference to the + # application, so the singleton may be garbage-collected between tests. + return QW.QApplication.instance() or QW.QApplication([]) + + +# --------------------------------------------------------------------------- +# QwtGraphic.toPixmap / QwtGraphic.toImage +# --------------------------------------------------------------------------- + + +def _make_graphic(): + """Return a graphic made of a red rectangle of 20 x 10 points""" + graphic = QwtGraphic() + path = QG.QPainterPath() + path.addRect(QC.QRectF(0.0, 0.0, 20.0, 10.0)) + painter = QG.QPainter(graphic) + painter.setPen(QC.Qt.NoPen) + painter.setBrush(RED) + painter.drawPath(path) + painter.end() + return graphic + + +def _to_qimage(result): + """Return result as a QImage (result: QPixmap or QImage)""" + if isinstance(result, QG.QPixmap): + return result.toImage() + return result + + +def _is_red(image, x, y): + color = image.pixelColor(x, y) + return color.alpha() == 255 and color.rgb() == RED.rgb() + + +@pytest.mark.parametrize("method", ("toPixmap", "toImage")) +def test_graphic_conversion_default_size(method): + """toPixmap() and toImage() render the graphic in its default size""" + app = _ensure_app() # keep a reference alive for the duration of the test + result = getattr(_make_graphic(), method)() + assert not result.isNull() + assert result.size() == QC.QSize(20, 10) + assert _is_red(_to_qimage(result), 10, 5) + del app + + +@pytest.mark.parametrize("method", ("toPixmap", "toImage")) +def test_graphic_conversion_given_size(method): + """toPixmap(size) and toImage(size) scale the graphic to the given size""" + app = _ensure_app() # keep a reference alive for the duration of the test + result = getattr(_make_graphic(), method)(QC.QSize(40, 20)) + assert not result.isNull() + assert result.size() == QC.QSize(40, 20) + image = _to_qimage(result) + assert _is_red(image, 20, 10) + assert _is_red(image, 2, 2) + assert _is_red(image, 37, 17) + del app + + +@pytest.mark.parametrize("method", ("toPixmap", "toImage")) +def test_graphic_conversion_keep_aspect_ratio(method): + """toPixmap(size, mode) and toImage(size, mode) honor the aspect ratio""" + app = _ensure_app() # keep a reference alive for the duration of the test + result = getattr(_make_graphic(), method)(QC.QSize(40, 40), QC.Qt.KeepAspectRatio) + assert result.size() == QC.QSize(40, 40) + image = _to_qimage(result) + # The 2:1 graphic is scaled to 40 x 20 and centered vertically + assert _is_red(image, 20, 20) + assert image.pixelColor(20, 2).alpha() == 0 + assert image.pixelColor(20, 37).alpha() == 0 + del app + + +if __name__ == "__main__": + for name in ("toPixmap", "toImage"): + test_graphic_conversion_default_size(name) + test_graphic_conversion_given_size(name) + test_graphic_conversion_keep_aspect_ratio(name) + print("OK") From 95457aeb999e35acea7df31da64710382d5629bb Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:54:08 +0100 Subject: [PATCH 02/14] fix(plot_marker): draw the legend icon symbol into a QRectF QwtPlotMarker.legendIcon raised an AssertionError (QSize icon size) or a TypeError (QSizeF icon size) for markers with a symbol, so that such a marker could not be shown in a legend. The Qwt C++ code passes a QRect to QwtSymbol::drawSymbol, which is implicitly converted to a QRectF. The Python port passed the QRect as is, which QwtSymbol.drawSymbol rejects, and QRect(0, 0, width, height) also fails when the size is a QSizeF. - Build a QRectF, like QwtPlotCurve.legendIcon already does. - Regression tests: legend icon with QSize and QSizeF sizes, marker with a symbol shown in a QwtLegend. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/plot_marker.py | 4 +-- qwt/tests/test_issue115_porting_bugs.py | 46 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a09b03..be4ddb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Fixed [Issue #113](https://github.com/PlotPyStack/PythonQwt/issues/113): after replacing the scale draw of an axis (`QwtPlot.setAxisScaleDraw`), the axis ticks could be misaligned with the grid lines and plot items, until the next scale division change (e.g. after panning or zooming): a replot did not fix it. The new scale draw was laid out before getting the position and length of the previous one, so that the axis reserved space for its end labels that the canvas did not take into account. The new scale draw now takes the geometry of the previous one, and the axis stays aligned with the canvas - Fixed `QwtGraphic.toPixmap(size)`, `QwtGraphic.toImage()` and `QwtGraphic.toImage(size)`, which raised a `TypeError` (possibly aborting the process, as the painter was still active on the destroyed pixmap or image) instead of rendering the graphic: they now render it like `QwtGraphic.toPixmap()` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPlotMarker.legendIcon` for markers with a symbol, which raised an `AssertionError` or a `TypeError`: such markers can now be shown in a legend ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/plot_marker.py b/qwt/plot_marker.py index 1db25cd..8de25d5 100644 --- a/qwt/plot_marker.py +++ b/qwt/plot_marker.py @@ -13,7 +13,7 @@ :members: """ -from qtpy.QtCore import QLineF, QObject, QPointF, QRect, QRectF, QSizeF, Qt +from qtpy.QtCore import QLineF, QObject, QPointF, QRectF, QSizeF, Qt from qtpy.QtGui import QPainter, QPen from qwt.graphic import QwtGraphic @@ -628,6 +628,6 @@ def legendIcon(self, index, size): x = 0.5 * size.width() painter.drawLine(QLineF(x, 0.0, x, size.height())) if self.__data.symbol: - r = QRect(0, 0, size.width(), size.height()) + r = QRectF(0.0, 0.0, size.width(), size.height()) self.__data.symbol.drawSymbol(painter, r) return icon diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index ca35a32..4499615 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -15,6 +15,7 @@ from qtpy import QtGui as QG from qtpy import QtWidgets as QW +from qwt import QwtLegend, QwtPlot, QwtPlotItem, QwtPlotMarker, QwtSymbol from qwt.graphic import QwtGraphic RED = QG.QColor(QC.Qt.red) @@ -97,9 +98,54 @@ def test_graphic_conversion_keep_aspect_ratio(method): del app +# --------------------------------------------------------------------------- +# QwtPlotMarker.legendIcon +# --------------------------------------------------------------------------- + + +def _make_symbol(): + return QwtSymbol( + QwtSymbol.Rect, QG.QBrush(RED), QG.QPen(QC.Qt.NoPen), QC.QSize(8, 8) + ) + + +@pytest.mark.parametrize( + "size", (QC.QSize(8, 8), QC.QSizeF(8.0, 8.0)), ids=("QSize", "QSizeF") +) +def test_marker_legend_icon_with_symbol(size): + """The legend icon of a marker with a symbol shows the symbol""" + app = _ensure_app() # keep a reference alive for the duration of the test + marker = QwtPlotMarker.make(xvalue=0.0, yvalue=0.0, symbol=_make_symbol()) + icon = marker.legendIcon(0, size) + assert not icon.isNull() + assert _is_red(icon.toImage(), 4, 4) + del app + + +def test_marker_with_symbol_in_legend(): + """A marker with a symbol can be shown in a legend""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = QwtPlot() + legend = QwtLegend() + plot.insertLegend(legend) + marker = QwtPlotMarker.make( + xvalue=1.0, yvalue=1.0, title="Marker", symbol=_make_symbol(), plot=plot + ) + marker.setItemAttribute(QwtPlotItem.Legend, True) + plot.updateLegend(marker) + label = legend.legendWidget(marker) + assert label is not None + assert not label.icon().isNull() + plot.close() + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) test_graphic_conversion_given_size(name) test_graphic_conversion_keep_aspect_ratio(name) + for size in (QC.QSize(8, 8), QC.QSizeF(8.0, 8.0)): + test_marker_legend_icon_with_symbol(size) + test_marker_with_symbol_in_legend() print("OK") From 86b10a9f62faa7bac69322170855f42ac31efb2d Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:55:24 +0100 Subject: [PATCH 03/14] fix(plot_grid): honor minor pen arguments in QwtPlotGrid.make QwtPlotGrid.make raised a TypeError when only some of the minor pen arguments (mincolor, minwidth, minstyle) were given, e.g. QwtPlotGrid.make(mincolor="red"): the default minor width and style were chosen by testing the major width and style arguments, which are never None at that point, instead of the minor ones, so None was passed to QPen. - Test minwidth and minstyle against None, as documented, and convert the minor width to float like the major one. - Regression tests: every combination of minor pen arguments, and major pen unchanged; calls which already worked keep the same pens. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/plot_grid.py | 4 +- qwt/tests/test_issue115_porting_bugs.py | 59 ++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be4ddb1..ac91cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed [Issue #113](https://github.com/PlotPyStack/PythonQwt/issues/113): after replacing the scale draw of an axis (`QwtPlot.setAxisScaleDraw`), the axis ticks could be misaligned with the grid lines and plot items, until the next scale division change (e.g. after panning or zooming): a replot did not fix it. The new scale draw was laid out before getting the position and length of the previous one, so that the axis reserved space for its end labels that the canvas did not take into account. The new scale draw now takes the geometry of the previous one, and the axis stays aligned with the canvas - Fixed `QwtGraphic.toPixmap(size)`, `QwtGraphic.toImage()` and `QwtGraphic.toImage(size)`, which raised a `TypeError` (possibly aborting the process, as the painter was still active on the destroyed pixmap or image) instead of rendering the graphic: they now render it like `QwtGraphic.toPixmap()` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPlotMarker.legendIcon` for markers with a symbol, which raised an `AssertionError` or a `TypeError`: such markers can now be shown in a legend ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPlotGrid.make`, which raised a `TypeError` when only some of the minor pen arguments (`mincolor`, `minwidth`, `minstyle`) were given: each of them is now honored, the missing ones taking their documented default value ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/plot_grid.py b/qwt/plot_grid.py index a75a07c..e72bd32 100644 --- a/qwt/plot_grid.py +++ b/qwt/plot_grid.py @@ -106,8 +106,8 @@ def make( item.setPen(QPen(color, width, style)) if mincolor is not None or minwidth is not None or minstyle is not None: mincolor = qcolor_from_str(mincolor, Qt.gray) - minwidth = 1.0 if width is None else minwidth - minstyle = Qt.DotLine if style is None else minstyle + minwidth = 1.0 if minwidth is None else float(minwidth) + minstyle = Qt.DotLine if minstyle is None else minstyle item.setMinorPen(QPen(mincolor, minwidth, minstyle)) if enablemajor is not None: if isinstance(enablemajor, tuple) and len(enablemajor) == 2: diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index 4499615..b3e16c4 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -15,7 +15,14 @@ from qtpy import QtGui as QG from qtpy import QtWidgets as QW -from qwt import QwtLegend, QwtPlot, QwtPlotItem, QwtPlotMarker, QwtSymbol +from qwt import ( + QwtLegend, + QwtPlot, + QwtPlotGrid, + QwtPlotItem, + QwtPlotMarker, + QwtSymbol, +) from qwt.graphic import QwtGraphic RED = QG.QColor(QC.Qt.red) @@ -140,6 +147,55 @@ def test_marker_with_symbol_in_legend(): del app +# --------------------------------------------------------------------------- +# QwtPlotGrid.make +# --------------------------------------------------------------------------- + + +def _pen_attrs(pen): + return pen.color().name(), pen.widthF(), pen.style() + + +@pytest.mark.parametrize( + "kwargs, expected", + ( + ({"mincolor": "red"}, ("#ff0000", 1.0, QC.Qt.DotLine)), + ({"minwidth": 2}, ("#a0a0a4", 2.0, QC.Qt.DotLine)), + ({"minstyle": QC.Qt.DashLine}, ("#a0a0a4", 1.0, QC.Qt.DashLine)), + ({"mincolor": "red", "minwidth": 2}, ("#ff0000", 2.0, QC.Qt.DotLine)), + ( + {"mincolor": "red", "minstyle": QC.Qt.DashLine}, + ("#ff0000", 1.0, QC.Qt.DashLine), + ), + ( + {"minwidth": 3.0, "minstyle": QC.Qt.SolidLine}, + ("#a0a0a4", 3.0, QC.Qt.SolidLine), + ), + ( + {"mincolor": "blue", "minwidth": 0, "minstyle": QC.Qt.DashDotLine}, + ("#0000ff", 0.0, QC.Qt.DashDotLine), + ), + ), +) +def test_grid_make_minor_pen(kwargs, expected): + """QwtPlotGrid.make honors each minor pen argument, with documented defaults""" + app = _ensure_app() # keep a reference alive for the duration of the test + grid = QwtPlotGrid.make(color="green", width=4, style=QC.Qt.DashLine, **kwargs) + assert _pen_attrs(grid.minorPen()) == expected + assert _pen_attrs(grid.majorPen()) == ("#00ff00", 4.0, QC.Qt.DashLine) + del app + + +def test_grid_make_without_minor_pen(): + """Without minor pen arguments, both pens are built from the major ones""" + app = _ensure_app() # keep a reference alive for the duration of the test + grid = QwtPlotGrid.make(color="green", width=0, style=QC.Qt.SolidLine) + expected = ("#00ff00", 0.0, QC.Qt.SolidLine) + assert _pen_attrs(grid.majorPen()) == expected + assert _pen_attrs(grid.minorPen()) == expected + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -148,4 +204,5 @@ def test_marker_with_symbol_in_legend(): for size in (QC.QSize(8, 8), QC.QSizeF(8.0, 8.0)): test_marker_legend_icon_with_symbol(size) test_marker_with_symbol_in_legend() + test_grid_make_without_minor_pen() print("OK") From f266dd8021862e72065ed2cf0e960f92d2510791 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:58:40 +0100 Subject: [PATCH 04/14] fix(transform): support arrays and NumPy < 2 in QwtPowerTransform QwtPowerTransform could not be used to draw curves: curve data are mapped by QwtScaleMap.transform as NumPy arrays, and the scalar test `if value < 0.0` raised "ValueError: The truth value of an array with more than one element is ambiguous". It also relied on numpy.pow, which was added in NumPy 2.0, whereas PythonQwt requires NumPy >= 1.21. - Apply the Qwt C++ formula (value < 0.0 ? -pow(-value, e) : pow(value, e)) element-wise with numpy.less, numpy.where and numpy.power. For scalars, results are bit-for-bit identical to the previous implementation (value, type, sign of zero, infinities, NaN and warnings), as checked on 364 scalar cases. - Regression tests: scalars against the C++ formula, arrays and round trip, numpy.pow unavailable (simulating NumPy < 2.0), scale map on arrays, curve rendered on a power-scaled axis. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/tests/test_issue115_porting_bugs.py | 110 ++++++++++++++++++++++++ qwt/transform.py | 26 +++--- 3 files changed, 126 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac91cd1..4a003a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed `QwtGraphic.toPixmap(size)`, `QwtGraphic.toImage()` and `QwtGraphic.toImage(size)`, which raised a `TypeError` (possibly aborting the process, as the painter was still active on the destroyed pixmap or image) instead of rendering the graphic: they now render it like `QwtGraphic.toPixmap()` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPlotMarker.legendIcon` for markers with a symbol, which raised an `AssertionError` or a `TypeError`: such markers can now be shown in a legend ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPlotGrid.make`, which raised a `TypeError` when only some of the minor pen arguments (`mincolor`, `minwidth`, `minstyle`) were given: each of them is now honored, the missing ones taking their documented default value ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPowerTransform`, which raised a `ValueError` when drawing a curve on an axis with a power scale (curve data are mapped as NumPy arrays), and an `AttributeError` with NumPy < 2.0 (`numpy.pow` does not exist there): it now transforms scalars (with unchanged results) as well as NumPy arrays, with any supported NumPy version ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index b3e16c4..a0bc2ce 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -10,6 +10,9 @@ which made the API raise an exception or ignore some of its arguments. """ +import math + +import numpy as np import pytest from qtpy import QtCore as QC from qtpy import QtGui as QG @@ -17,13 +20,19 @@ from qwt import ( QwtLegend, + QwtLinearScaleEngine, QwtPlot, + QwtPlotCurve, QwtPlotGrid, QwtPlotItem, QwtPlotMarker, + QwtPlotRenderer, + QwtScaleMap, QwtSymbol, ) from qwt.graphic import QwtGraphic +from qwt.plot_curve import series_to_polyline +from qwt.transform import QwtPowerTransform RED = QG.QColor(QC.Qt.red) @@ -196,6 +205,105 @@ def test_grid_make_without_minor_pen(): del app +# --------------------------------------------------------------------------- +# QwtPowerTransform +# --------------------------------------------------------------------------- + +POWER_VALUES = (-9.0, -2.5, -1.0, 0.0, 0.25, 1.0, 4.0, 100.0) + + +def _cpp_transform(value, exponent): + """Reference: QwtPowerTransform::transform of Qwt C++""" + if value < 0.0: + return -math.pow(-value, 1.0 / exponent) + return math.pow(value, 1.0 / exponent) + + +def _cpp_inv_transform(value, exponent): + """Reference: QwtPowerTransform::invTransform of Qwt C++""" + if value < 0.0: + return -math.pow(-value, exponent) + return math.pow(value, exponent) + + +@pytest.mark.parametrize("exponent", (2, 3.0, 0.5)) +def test_power_transform_scalars(exponent): + """Scalars are transformed as in Qwt C++, keeping their sign""" + transform = QwtPowerTransform(exponent) + for value in POWER_VALUES: + result = transform.transform(value) + assert np.ndim(result) == 0 + assert result == pytest.approx(_cpp_transform(value, exponent)) + result = transform.invTransform(value) + assert np.ndim(result) == 0 + assert result == pytest.approx(_cpp_inv_transform(value, exponent)) + + +@pytest.mark.parametrize("exponent", (2, 3.0, 0.5)) +def test_power_transform_arrays(exponent): + """Arrays are transformed element-wise, and back""" + transform = QwtPowerTransform(exponent) + values = np.array(POWER_VALUES) + expected = [_cpp_transform(value, exponent) for value in POWER_VALUES] + np.testing.assert_allclose(transform.transform(values), expected) + expected = [_cpp_inv_transform(value, exponent) for value in POWER_VALUES] + np.testing.assert_allclose(transform.invTransform(values), expected) + roundtrip = transform.invTransform(transform.transform(values)) + np.testing.assert_allclose(roundtrip, values, atol=1e-12) + + +def test_power_transform_without_numpy_pow(monkeypatch): + """QwtPowerTransform does not need `numpy.pow` (added in NumPy 2.0)""" + monkeypatch.delattr(np, "pow", raising=False) + transform = QwtPowerTransform(2) + assert transform.transform(-9.0) == pytest.approx(-3.0) + assert transform.invTransform(-3.0) == pytest.approx(-9.0) + + +def test_power_transform_scale_map(): + """A scale map with a power transformation maps arrays like scalars""" + smap = QwtScaleMap() + smap.setTransformation(QwtPowerTransform(2)) + smap.setScaleInterval(-100.0, 100.0) + smap.setPaintInterval(0.0, 400.0) + values = np.array(POWER_VALUES) + expected = [smap.transform(value) for value in POWER_VALUES] + np.testing.assert_allclose(smap.transform(values), expected) + + +def test_power_transform_curve_rendering(): + """A curve can be drawn on an axis with a power transformation""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = QwtPlot() + plot.resize(400, 300) + engine = QwtLinearScaleEngine() + engine.setTransformation(QwtPowerTransform(2)) + plot.setAxisScaleEngine(QwtPlot.yLeft, engine) + plot.setAxisScale(QwtPlot.yLeft, -100.0, 100.0) + x = np.linspace(-10.0, 10.0, 21) + curve = QwtPlotCurve.make(x, x * np.abs(x), plot=plot) + plot.replot() + + xmap = plot.canvasMap(QwtPlot.xBottom) + ymap = plot.canvasMap(QwtPlot.yLeft) + polyline = series_to_polyline(xmap, ymap, curve.data(), 0, x.size - 1) + for index in range(x.size): + point = polyline[index] + sample = curve.data().sample(index) + assert point.x() == pytest.approx(xmap.transform(sample.x())) + assert point.y() == pytest.approx(ymap.transform(sample.y())) + + image = QG.QImage(400, 300, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.white) + painter = QG.QPainter(image) + try: + QwtPlotRenderer().render(plot, painter, QC.QRectF(0.0, 0.0, 400.0, 300.0)) + finally: + painter.end() + plot.close() + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -205,4 +313,6 @@ def test_grid_make_without_minor_pen(): test_marker_legend_icon_with_symbol(size) test_marker_with_symbol_in_legend() test_grid_make_without_minor_pen() + test_power_transform_scale_map() + test_power_transform_curve_rendering() print("OK") diff --git a/qwt/transform.py b/qwt/transform.py index 00b72a7..8a47df8 100644 --- a/qwt/transform.py +++ b/qwt/transform.py @@ -207,12 +207,14 @@ def copy(self): class QwtPowerTransform(QwtTransform): """ - A transformation using `numpy.pow()` + A transformation using `numpy.power()` `QwtPowerTransform` preserves the sign of a value. F.e. a transformation with a factor of 2 transforms a value of -3 to -9 and v.v. Thus `QwtPowerTransform` can be used for scales including negative values. + + Values may be scalars or NumPy arrays (transformed element-wise). """ def __init__(self, exponent): @@ -223,33 +225,35 @@ def transform(self, value): """ Transformation function - :param float value: Value + :param value: Value (scalar or NumPy array) :return: Modified value .. seealso:: :py:meth:`invTransform()` """ - if value < 0.0: - return -np.pow(-value, 1.0 / self.__exponent) - else: - return np.pow(value, 1.0 / self.__exponent) + return self.__signed_power(value, 1.0 / self.__exponent) def invTransform(self, value): """ Inverse transformation function - :param float value: Value + :param value: Value (scalar or NumPy array) :return: Modified value .. seealso:: :py:meth:`transform()` """ - if value < 0.0: - return -np.pow(-value, self.__exponent) - else: - return np.pow(value, self.__exponent) + return self.__signed_power(value, self.__exponent) + + @staticmethod + def __signed_power(value, exponent): + # Same as Qwt C++ (`value < 0.0 ? -pow(-value, e) : pow(value, e)`), + # element-wise for arrays: scalars give the same results as before + negative = np.less(value, 0.0) + result = np.power(np.where(negative, np.abs(value), value), exponent) + return np.where(negative, np.negative(result), result)[()] def copy(self): """ From e8a257082556f89d833eba8b9faf7690a965a2c0 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:59:36 +0100 Subject: [PATCH 05/14] fix(plot_directpainter): set and clear attributes as requested QwtPlotDirectPainter.setAttribute decided whether to set or to clear the attribute by comparing its current state with `on`, instead of using `on`: enabling AtomicPainter twice disabled it, and FullRepaint or CopyBackingStore could not be disabled anymore once enabled (their non-boolean flag values never compare equal to `on`). - Restore the Qwt C++ logic: change the attribute only when its state differs from `on`, set it when `on` is true and clear it otherwise, and reset the painter only when AtomicPainter gets enabled. - Regression tests: set, set again, clear, clear again and default `on` for each attribute, other attributes unchanged; painter reset count. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/plot_directpainter.py | 13 ++--- qwt/tests/test_issue115_porting_bugs.py | 67 +++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a003a6..b40f98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed `QwtPlotMarker.legendIcon` for markers with a symbol, which raised an `AssertionError` or a `TypeError`: such markers can now be shown in a legend ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPlotGrid.make`, which raised a `TypeError` when only some of the minor pen arguments (`mincolor`, `minwidth`, `minstyle`) were given: each of them is now honored, the missing ones taking their documented default value ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPowerTransform`, which raised a `ValueError` when drawing a curve on an axis with a power scale (curve data are mapped as NumPy arrays), and an `AttributeError` with NumPy < 2.0 (`numpy.pow` does not exist there): it now transforms scalars (with unchanged results) as well as NumPy arrays, with any supported NumPy version ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtPlotDirectPainter.setAttribute`, which did not set or clear attributes as requested: enabling `AtomicPainter` twice disabled it, and `FullRepaint` or `CopyBackingStore` could not be disabled anymore once enabled ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/plot_directpainter.py b/qwt/plot_directpainter.py index 06697d3..c566277 100644 --- a/qwt/plot_directpainter.py +++ b/qwt/plot_directpainter.py @@ -119,12 +119,13 @@ def setAttribute(self, attribute, on=True): :py:meth:`testAttribute()` """ - if self.testAttribute(attribute) != on: - self.__data.attributes |= attribute - else: - self.__data.attributes &= ~attribute - if attribute == self.AtomicPainter and on: - self.reset() + if bool(self.testAttribute(attribute)) != on: + if on: + self.__data.attributes |= attribute + else: + self.__data.attributes &= ~attribute + if attribute == self.AtomicPainter and on: + self.reset() def testAttribute(self, attribute): """ diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index a0bc2ce..d869d23 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -23,6 +23,7 @@ QwtLinearScaleEngine, QwtPlot, QwtPlotCurve, + QwtPlotDirectPainter, QwtPlotGrid, QwtPlotItem, QwtPlotMarker, @@ -304,6 +305,69 @@ def test_power_transform_curve_rendering(): del app +# --------------------------------------------------------------------------- +# QwtPlotDirectPainter.setAttribute +# --------------------------------------------------------------------------- + +DIRECT_PAINTER_ATTRIBUTES = ( + QwtPlotDirectPainter.AtomicPainter, + QwtPlotDirectPainter.FullRepaint, + QwtPlotDirectPainter.CopyBackingStore, +) + + +class _CountingDirectPainter(QwtPlotDirectPainter): + """Direct painter counting the calls to `reset`""" + + def __init__(self): + super().__init__() + self.reset_count = 0 + + def reset(self): + self.reset_count += 1 + super().reset() + + +@pytest.mark.parametrize( + "attribute", + DIRECT_PAINTER_ATTRIBUTES, + ids=("AtomicPainter", "FullRepaint", "CopyBackingStore"), +) +def test_direct_painter_set_attribute(attribute): + """setAttribute sets or clears an attribute, as requested""" + app = _ensure_app() # keep a reference alive for the duration of the test + painter = QwtPlotDirectPainter() + others = [attr for attr in DIRECT_PAINTER_ATTRIBUTES if attr != attribute] + for other in others: + painter.setAttribute(other, True) + assert not painter.testAttribute(attribute) + for on in (True, True, False, False, True): + painter.setAttribute(attribute, on) + assert bool(painter.testAttribute(attribute)) is on + assert all(painter.testAttribute(other) for other in others) + painter.setAttribute(attribute, False) + painter.setAttribute(attribute) # `on` defaults to True + assert painter.testAttribute(attribute) + del app + + +def test_direct_painter_atomic_painter_reset(): + """Enabling AtomicPainter resets the painter, as in Qwt C++""" + app = _ensure_app() # keep a reference alive for the duration of the test + painter = _CountingDirectPainter() + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, True) + assert painter.reset_count == 1 + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, True) # unchanged + assert painter.reset_count == 1 + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, False) + assert painter.reset_count == 1 + painter.setAttribute(QwtPlotDirectPainter.AtomicPainter, True) + assert painter.reset_count == 2 + painter.setAttribute(QwtPlotDirectPainter.FullRepaint, True) + assert painter.reset_count == 2 + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -315,4 +379,7 @@ def test_power_transform_curve_rendering(): test_grid_make_without_minor_pen() test_power_transform_scale_map() test_power_transform_curve_rendering() + for attr in DIRECT_PAINTER_ATTRIBUTES: + test_direct_painter_set_attribute(attr) + test_direct_painter_atomic_painter_reset() print("OK") From 13e0a0fcbafed7f76efd68ef91f464d6c063f1c4 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:02:24 +0100 Subject: [PATCH 06/14] fix(graphic): record clipped paint operations Painting into a QwtGraphic with clipping enabled raised "TypeError: unsupported operand type(s) for &=: 'QRectF' and 'QRect'" on each paint operation. As the exception is raised in a Qt virtual method (paint engine drawPath, drawPixmap and drawImage), PyQt aborts the process with its default exception hook. QwtGraphic.updateBoundingRect intersects the bounding rectangle with the bounding rectangle of the clip region, which is a QRect. The Qwt C++ code converts it implicitly to a QRectF; the Python port did not. - Convert the clip region bounding rectangle to a QRectF before mapping and intersecting it. - Regression test: clipped path, pixmap and image recorded with a translated painter; control point rectangle not clipped, bounding rectangle clipped. Exceptions raised in Qt virtual methods are collected so that the test fails instead of aborting the process. - Changelog entry. Found while writing the tests for the painter command copy. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/graphic.py | 2 +- qwt/tests/test_issue115_porting_bugs.py | 53 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b40f98e..9407c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Fixed `QwtPlotGrid.make`, which raised a `TypeError` when only some of the minor pen arguments (`mincolor`, `minwidth`, `minstyle`) were given: each of them is now honored, the missing ones taking their documented default value ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPowerTransform`, which raised a `ValueError` when drawing a curve on an axis with a power scale (curve data are mapped as NumPy arrays), and an `AttributeError` with NumPy < 2.0 (`numpy.pow` does not exist there): it now transforms scalars (with unchanged results) as well as NumPy arrays, with any supported NumPy version ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPlotDirectPainter.setAttribute`, which did not set or clear attributes as requested: enabling `AtomicPainter` twice disabled it, and `FullRepaint` or `CopyBackingStore` could not be disabled anymore once enabled ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed painting into a `QwtGraphic` with clipping enabled, which raised a `TypeError` on each paint operation (aborting the process with PyQt's default exception hook): clipped paint operations are now recorded, and the bounding rectangle of the graphic is clipped as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/graphic.py b/qwt/graphic.py index e78345c..5370265 100644 --- a/qwt/graphic.py +++ b/qwt/graphic.py @@ -757,7 +757,7 @@ def updateBoundingRect(self, rect): br = QRectF(rect) painter = self.paintEngine().painter() if painter and painter.hasClipping(): - cr = painter.clipRegion().boundingRect() + cr = QRectF(painter.clipRegion().boundingRect()) cr = painter.transform().mapRect(cr) br &= cr if self.__data.boundingRect.width() < 0: diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index d869d23..72dca16 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -10,7 +10,10 @@ which made the API raise an exception or ignore some of its arguments. """ +import contextlib import math +import sys +import traceback import numpy as np import pytest @@ -46,6 +49,26 @@ def _ensure_app(): return QW.QApplication.instance() or QW.QApplication([]) +@contextlib.contextmanager +def _qt_exceptions(): + """Collect the exceptions raised in Qt virtual method overrides. + + PyQt aborts the process on such exceptions when the default exception + hook is installed: collecting them makes the test fail instead. + """ + errors = [] + old_hook = sys.excepthook + sys.excepthook = lambda *exc_info: errors.append(exc_info) + try: + yield errors + finally: + sys.excepthook = old_hook + + +def _format_exceptions(errors): + return "".join("".join(traceback.format_exception(*exc)) for exc in errors) + + # --------------------------------------------------------------------------- # QwtGraphic.toPixmap / QwtGraphic.toImage # --------------------------------------------------------------------------- @@ -115,6 +138,35 @@ def test_graphic_conversion_keep_aspect_ratio(method): del app +def test_graphic_clipped_recording(): + """Clipped paint operations are recorded, with a clipped bounding rect""" + app = _ensure_app() # keep a reference alive for the duration of the test + pixmap = QG.QPixmap(4, 4) + pixmap.fill(QC.Qt.blue) + image = QG.QImage(4, 4, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.green) + path = QG.QPainterPath() + path.addRect(QC.QRectF(0.0, 0.0, 20.0, 20.0)) + subrect = QC.QRectF(0.0, 0.0, 4.0, 4.0) + graphic = QwtGraphic() + with _qt_exceptions() as errors: + painter = QG.QPainter(graphic) + painter.translate(5.0, 5.0) + painter.setClipRect(QC.QRectF(0.0, 0.0, 10.0, 10.0)) + painter.setPen(QC.Qt.NoPen) + painter.setBrush(RED) + painter.drawPath(path) + painter.drawPixmap(QC.QRectF(0.0, 0.0, 4.0, 4.0), pixmap, subrect) + painter.drawImage(QC.QRectF(8.0, 8.0, 4.0, 4.0), image, subrect) + painter.end() + assert not errors, _format_exceptions(errors) + assert len(graphic.commands()) > 3 + # Control points are not clipped, the bounding rectangle is + assert graphic.controlPointRect() == QC.QRectF(5.0, 5.0, 20.0, 20.0) + assert graphic.boundingRect() == QC.QRectF(5.0, 5.0, 10.0, 10.0) + del app + + # --------------------------------------------------------------------------- # QwtPlotMarker.legendIcon # --------------------------------------------------------------------------- @@ -373,6 +425,7 @@ def test_direct_painter_atomic_painter_reset(): test_graphic_conversion_default_size(name) test_graphic_conversion_given_size(name) test_graphic_conversion_keep_aspect_ratio(name) + test_graphic_clipped_recording() for size in (QC.QSize(8, 8), QC.QSizeF(8.0, 8.0)): test_marker_legend_icon_with_symbol(size) test_marker_with_symbol_in_legend() From 6d29a36a76275996ef52448e5e99a6889e3335a3 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:03:35 +0100 Subject: [PATCH 07/14] fix(painter_command): copy painter commands The QwtPainterCommand copy constructor (QwtPainterCommand(other)) only worked for path commands: - state commands raised an AttributeError, because the copied data was compared (`==`) instead of being assigned (`=`); - pixmap and image commands raised "TypeError: cannot pickle 'QPixmap'" (or 'QImage'), because copy.deepcopy cannot copy Qt objects. The Qwt C++ code copies the command data member-wise, Qt value types being copied by value. - Copy the command data attribute by attribute, Qt value types (pen, brush, font, transform, region, path, pixmap, image, point, rectangle) with their copy constructor, other values (enums, flags, numbers) being immutable. - Regression tests: copy of path, pixmap, image and state commands (same type and equal data), copies independent of the originals, graphic replaying the copied commands rendered like the original. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/painter_command.py | 47 +++++++++-- qwt/tests/test_issue115_porting_bugs.py | 104 ++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9407c73..314b33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed `QwtPowerTransform`, which raised a `ValueError` when drawing a curve on an axis with a power scale (curve data are mapped as NumPy arrays), and an `AttributeError` with NumPy < 2.0 (`numpy.pow` does not exist there): it now transforms scalars (with unchanged results) as well as NumPy arrays, with any supported NumPy version ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtPlotDirectPainter.setAttribute`, which did not set or clear attributes as requested: enabling `AtomicPainter` twice disabled it, and `FullRepaint` or `CopyBackingStore` could not be disabled anymore once enabled ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed painting into a `QwtGraphic` with clipping enabled, which raised a `TypeError` on each paint operation (aborting the process with PyQt's default exception hook): clipped paint operations are now recorded, and the bounding rectangle of the graphic is clipped as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed the `QwtPainterCommand` copy constructor (`QwtPainterCommand(other)`), which raised an `AttributeError` for state commands and a `TypeError` (Qt objects cannot be pickled) for pixmap and image commands: commands of every type can now be copied, the copy being independent of the original ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/painter_command.py b/qwt/painter_command.py index d923be9..3fdf225 100644 --- a/qwt/painter_command.py +++ b/qwt/painter_command.py @@ -13,9 +13,44 @@ :members: """ -import copy - -from qtpy.QtGui import QPaintEngine, QPainterPath +from qtpy.QtCore import QPointF, QRectF +from qtpy.QtGui import ( + QBrush, + QFont, + QImage, + QPaintEngine, + QPainterPath, + QPen, + QPixmap, + QRegion, + QTransform, +) + +# Qt value types stored in painter command data, copied with their copy +# constructor (other stored values are immutable: enums, flags, numbers...) +_QT_VALUE_TYPES = ( + QBrush, + QFont, + QImage, + QPainterPath, + QPen, + QPixmap, + QPointF, + QRectF, + QRegion, + QTransform, +) + + +def _copy_data(data): + """Return a copy of painter command data, like the Qwt C++ copy + constructors of `PixmapData`, `ImageData` and `StateData`""" + result = data.__class__() + for name, value in vars(data).items(): + if isinstance(value, _QT_VALUE_TYPES): + value = type(value)(value) + setattr(result, name, value) + return result def _flag_int(flag): @@ -215,11 +250,11 @@ def copy(self, other): if other.__type == self.Path: self.__path = QPainterPath(other.__path) elif other.__type == self.Pixmap: - self.__pixmapData = copy.deepcopy(other.__pixmapData) + self.__pixmapData = _copy_data(other.__pixmapData) elif other.__type == self.Image: - self.__imageData = copy.deepcopy(other.__imageData) + self.__imageData = _copy_data(other.__imageData) elif other.__type == self.State: - self.__stateData == copy.deepcopy(other.__stateData) + self.__stateData = _copy_data(other.__stateData) def reset(self): self.__type = self.Invalid diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index 72dca16..2d0093a 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -35,6 +35,7 @@ QwtSymbol, ) from qwt.graphic import QwtGraphic +from qwt.painter_command import QwtPainterCommand from qwt.plot_curve import series_to_polyline from qwt.transform import QwtPowerTransform @@ -420,6 +421,106 @@ def test_direct_painter_atomic_painter_reset(): del app +# --------------------------------------------------------------------------- +# QwtPainterCommand copy +# --------------------------------------------------------------------------- + +COMMAND_TYPES = { + QwtPainterCommand.Path: "path", + QwtPainterCommand.Pixmap: "pixmapData", + QwtPainterCommand.Image: "imageData", + QwtPainterCommand.State: "stateData", +} + + +def _make_recorded_graphic(): + """Return a graphic recording path, pixmap, image and state commands""" + pixmap = QG.QPixmap(4, 4) + pixmap.fill(QC.Qt.blue) + image = QG.QImage(4, 4, QG.QImage.Format_ARGB32) + image.fill(QC.Qt.green) + path = QG.QPainterPath() + path.addEllipse(QC.QRectF(0.0, 0.0, 20.0, 10.0)) + graphic = QwtGraphic() + with _qt_exceptions() as errors: + painter = QG.QPainter(graphic) + painter.setRenderHint(QG.QPainter.Antialiasing) + painter.setPen(QG.QPen(RED, 2.0)) + painter.setBrush(QG.QBrush(QC.Qt.yellow)) + painter.setFont(QG.QFont("Arial", 12)) + painter.setOpacity(0.8) + painter.setClipRect(QC.QRectF(0.0, 0.0, 30.0, 30.0)) + painter.translate(1.0, 2.0) + painter.drawPath(path) + rect, subrect = QC.QRectF(5.0, 5.0, 4.0, 4.0), QC.QRectF(0.0, 0.0, 4.0, 4.0) + painter.drawPixmap(rect, pixmap, subrect) + painter.drawImage(rect.translated(7.0, 0.0), image, subrect) + painter.end() + assert not errors, _format_exceptions(errors) + return graphic + + +def _comparable(value): + """Return a value which can be compared with `==`""" + if isinstance(value, QG.QPixmap): + return value.toImage() + return value + + +def _command_data(command): + return getattr(command, COMMAND_TYPES[command.type()])() + + +def test_painter_command_copy(): + """Copying a painter command gives an equal command, for every type""" + app = _ensure_app() # keep a reference alive for the duration of the test + commands = _make_recorded_graphic().commands() + assert {command.type() for command in commands} == set(COMMAND_TYPES) + for command in commands: + copied = QwtPainterCommand(command) + assert copied.type() == command.type() + data, copied_data = _command_data(command), _command_data(copied) + assert copied_data is not data + if command.type() == QwtPainterCommand.Path: + assert copied_data == data + continue + for name, value in vars(data).items(): + copied_value = getattr(copied_data, name) + assert _comparable(copied_value) == _comparable(value), name + del app + + +def test_painter_command_copy_is_independent(): + """Modifying a copied painter command leaves the original unchanged""" + app = _ensure_app() # keep a reference alive for the duration of the test + for command in _make_recorded_graphic().commands(): + copied = QwtPainterCommand(command) + data, copied_data = _command_data(command), _command_data(copied) + if command.type() == QwtPainterCommand.Path: + reference = QG.QPainterPath(data) + copied_data.lineTo(100.0, 100.0) + assert data == reference + elif command.type() in (QwtPainterCommand.Pixmap, QwtPainterCommand.Image): + reference = QC.QRectF(data.rect) + copied_data.rect.setWidth(99.0) + assert data.rect == reference + elif copied_data.pen is not None: + reference = QG.QPen(data.pen) + copied_data.pen.setWidthF(9.0) + assert data.pen == reference + del app + + +def test_painter_command_copy_replay(): + """A graphic replaying copied commands renders like the original one""" + app = _ensure_app() # keep a reference alive for the duration of the test + graphic = _make_recorded_graphic() + replayed = QwtGraphic() + replayed.setCommands([QwtPainterCommand(cmd) for cmd in graphic.commands()]) + assert replayed.toImage() == graphic.toImage() + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -435,4 +536,7 @@ def test_direct_painter_atomic_painter_reset(): for attr in DIRECT_PAINTER_ATTRIBUTES: test_direct_painter_set_attribute(attr) test_direct_painter_atomic_painter_reset() + test_painter_command_copy() + test_painter_command_copy_is_independent() + test_painter_command_copy_replay() print("OK") From 61c3d77641b9ed5405c2389bb5a1afa19aa8aecd Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:04:54 +0100 Subject: [PATCH 08/14] fix(dyngrid_layout): compute the number of rows with integer division QwtDynGridLayout.setGeometry and QwtDynGridLayout.stretchGrid computed the number of rows with a true division (`/`) where the Qwt C++ code uses an integer division: numRows() returned a float (e.g. 2.5 for 3 items on 2 columns), and laying out the items with vertical expansion (setExpandingDirections(Qt.Vertical)) raised "TypeError: 'float' object cannot be interpreted as an integer". - Use an integer division (`//`) in both methods. The other row count computations already convert the quotient to an integer and are left unchanged. - Regression tests: integer number of rows for several item and column counts, rows stretched to the layout height with vertical expansion. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/dyngrid_layout.py | 4 +-- qwt/tests/test_issue115_porting_bugs.py | 47 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 314b33e..8bf658e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Fixed `QwtPlotDirectPainter.setAttribute`, which did not set or clear attributes as requested: enabling `AtomicPainter` twice disabled it, and `FullRepaint` or `CopyBackingStore` could not be disabled anymore once enabled ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed painting into a `QwtGraphic` with clipping enabled, which raised a `TypeError` on each paint operation (aborting the process with PyQt's default exception hook): clipped paint operations are now recorded, and the bounding rectangle of the graphic is clipped as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed the `QwtPainterCommand` copy constructor (`QwtPainterCommand(other)`), which raised an `AttributeError` for state commands and a `TypeError` (Qt objects cannot be pickled) for pixmap and image commands: commands of every type can now be copied, the copy being independent of the original ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtDynGridLayout` (used by `QwtLegend`), whose `numRows()` returned a float (e.g. `2.5` for 3 items on 2 columns) and which raised a `TypeError` when laying out its items with vertical expansion (`setExpandingDirections(Qt.Vertical)`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/dyngrid_layout.py b/qwt/dyngrid_layout.py index 45850da..56d2a28 100644 --- a/qwt/dyngrid_layout.py +++ b/qwt/dyngrid_layout.py @@ -159,7 +159,7 @@ def setGeometry(self, rect): if self.isEmpty(): return self.__data.numColumns = self.columnsForWidth(rect.width()) - self.__data.numRows = self.itemCount() / self.__data.numColumns + self.__data.numRows = self.itemCount() // self.__data.numColumns if self.itemCount() % self.__data.numColumns: self.__data.numRows += 1 itemGeometries = self.layoutItems(rect, self.__data.numColumns) @@ -314,7 +314,7 @@ def stretchGrid(self, rect, numColumns, rowHeight, colWidth): colWidth[col] += space xDelta -= space if expandV: - numRows = self.itemCount() / numColumns + numRows = self.itemCount() // numColumns if self.itemCount() % numColumns: numRows += 1 yDelta = rect.height() - hmargins - (numRows - 1) * self.spacing() diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index 2d0093a..c9044e3 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -34,6 +34,7 @@ QwtScaleMap, QwtSymbol, ) +from qwt.dyngrid_layout import QwtDynGridLayout from qwt.graphic import QwtGraphic from qwt.painter_command import QwtPainterCommand from qwt.plot_curve import series_to_polyline @@ -521,6 +522,51 @@ def test_painter_command_copy_replay(): del app +# --------------------------------------------------------------------------- +# QwtDynGridLayout +# --------------------------------------------------------------------------- + + +def _make_grid_layout(count, max_columns): + widget = QW.QWidget() + layout = QwtDynGridLayout(widget) + layout.setMaxColumns(max_columns) + for index in range(count): + layout.addWidget(QW.QLabel(f"Item {index}")) + return widget, layout + + +@pytest.mark.parametrize( + "count, max_columns, rows", + ((1, 1, 1), (3, 2, 2), (4, 2, 2), (5, 3, 2), (6, 1, 6), (7, 3, 3)), +) +def test_dyngrid_layout_rows(count, max_columns, rows): + """The number of rows of the layout is an integer""" + app = _ensure_app() # keep a reference alive for the duration of the test + widget, layout = _make_grid_layout(count, max_columns) + layout.setGeometry(QC.QRect(0, 0, 2000, 2000)) + assert layout.numColumns() == max_columns + assert layout.numRows() == rows + assert isinstance(layout.numRows(), int) + widget.close() + del app + + +def test_dyngrid_layout_vertical_expanding(): + """Rows are stretched to fill the layout height with vertical expansion""" + app = _ensure_app() # keep a reference alive for the duration of the test + widget, layout = _make_grid_layout(3, 2) + layout.setExpandingDirections(QC.Qt.Vertical) + layout.setGeometry(QC.QRect(0, 0, 400, 300)) + geometries = [layout.itemAt(index).geometry() for index in range(3)] + assert geometries[0].top() == geometries[1].top() + assert geometries[2].top() > geometries[0].bottom() + margins = layout.contentsMargins() + assert geometries[2].bottom() + 1 + margins.bottom() == 300 + widget.close() + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -539,4 +585,5 @@ def test_painter_command_copy_replay(): test_painter_command_copy() test_painter_command_copy_is_independent() test_painter_command_copy_replay() + test_dyngrid_layout_vertical_expanding() print("OK") From aa1180de36c2304cb2c0fcbd77ae10debfbe54dc Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:10:59 +0100 Subject: [PATCH 09/14] fix(plot_canvas): support rounded borders in canvas style sheets A canvas style sheet with rounded borders, e.g. plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;"), raised a TypeError on each style change, resize and paint event. As these exceptions are raised in Qt virtual methods, PyQt aborts the process with its default exception hook (0xC0000409 on Windows). Three porting bugs were on this code path, which the Qwt C++ code avoids with implicit conversions: - QwtStyleSheetRecorder.drawPath built QRectF(QPointF, QSize): the size is now converted to a QSizeF; - qwtCombinePathList used `rect.bottom` instead of `rect.bottom()` and None placeholders instead of empty paths (so that any border without all its corners failed), and reverted the input sub-paths in place: it now works on a QRectF, empty paths and copies of the sub-paths, as in Qwt C++; - qwtFillBackground passed the QRectF corner rectangles of the style sheet to QRegion.intersects, QPixmap and QWidget.mapTo, which expect integer types: they are now converted with QRectF.toAlignedRect(), as in Qwt C++. QRect rectangles (plain canvas with a border radius) are left unchanged by this conversion. Canvases without rounded style sheet borders render exactly as before: same images, pixel for pixel, with and without backing store, for canvases without style sheet, with non-rounded style sheets and with a border radius set by setBorderRadius. - Regression tests: incomplete and complete rounded corners combined without modifying the input paths; canvases with three rounded style sheets, with and without backing store, rendered without exception (exceptions raised in Qt virtual methods are collected), with a border path and a rounded corner filled with the plot background. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/plot_canvas.py | 23 ++++++-- qwt/tests/test_issue115_porting_bugs.py | 72 +++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf658e..f1f4910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Fixed painting into a `QwtGraphic` with clipping enabled, which raised a `TypeError` on each paint operation (aborting the process with PyQt's default exception hook): clipped paint operations are now recorded, and the bounding rectangle of the graphic is clipped as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed the `QwtPainterCommand` copy constructor (`QwtPainterCommand(other)`), which raised an `AttributeError` for state commands and a `TypeError` (Qt objects cannot be pickled) for pixmap and image commands: commands of every type can now be copied, the copy being independent of the original ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtDynGridLayout` (used by `QwtLegend`), whose `numRows()` returned a float (e.g. `2.5` for 3 items on 2 columns) and which raised a `TypeError` when laying out its items with vertical expansion (`setExpandingDirections(Qt.Vertical)`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed canvas style sheets with rounded borders (e.g. `plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;")`), which raised a `TypeError` on each style change, resize and paint event (aborting the process with PyQt's default exception hook): the rounded border of the style sheet is now taken into account to clip the plot items and to fill the corners with the background of the parent widget, as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/plot_canvas.py b/qwt/plot_canvas.py index 9205e44..720bb8d 100644 --- a/qwt/plot_canvas.py +++ b/qwt/plot_canvas.py @@ -16,7 +16,17 @@ import weakref from collections.abc import Sequence -from qtpy.QtCore import QEvent, QObject, QPoint, QPointF, QRect, QRectF, QSize, Qt +from qtpy.QtCore import ( + QEvent, + QObject, + QPoint, + QPointF, + QRect, + QRectF, + QSize, + QSizeF, + Qt, +) from qtpy.QtGui import ( QBrush, QGradient, @@ -81,7 +91,7 @@ def drawRects(self, rects, count): ) def drawPath(self, path): - rect = QRectF(QPointF(0.0, 0.0), self.__size) + rect = QRectF(QPointF(0.0, 0.0), QSizeF(self.__size)) if path.controlPointRect().contains(rect.center()): self.setCornerRects(path) self.alignCornerRects(rect) @@ -188,9 +198,11 @@ def qwtCombinePathList(rect, pathList): if not pathList: return QPainterPath() - ordered = [None] * 8 + rect = QRectF(rect) + ordered = [QPainterPath() for _i in range(8)] for subPath in pathList: index = -1 + subPath = QPainterPath(subPath) br = subPath.controlPointRect() if br.center().x() < rect.center().x(): if br.center().y() < rect.center().y(): @@ -199,7 +211,7 @@ def qwtCombinePathList(rect, pathList): else: index = 0 else: - if abs(br.bottom() - rect.bottom) < abs(br.left() - rect.left()): + if abs(br.bottom() - rect.bottom()) < abs(br.left() - rect.left()): index = 6 else: index = 7 @@ -297,7 +309,8 @@ def qwtFillBackground(*args): else: clipRegion = widget.contentsRect() bgWidget = qwtBackgroundWidget(widget.parentWidget()) - for rect in fillRects: + for fillRect in fillRects: + rect = QRectF(fillRect).toAlignedRect() if clipRegion.intersects(rect): pm = QPixmap(rect.size()) QwtPainter.fillPixmap( diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index c9044e3..2c20d07 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -37,6 +37,7 @@ from qwt.dyngrid_layout import QwtDynGridLayout from qwt.graphic import QwtGraphic from qwt.painter_command import QwtPainterCommand +from qwt.plot_canvas import QwtPlotCanvas, qwtCombinePathList from qwt.plot_curve import series_to_polyline from qwt.transform import QwtPowerTransform @@ -567,6 +568,73 @@ def test_dyngrid_layout_vertical_expanding(): del app +# --------------------------------------------------------------------------- +# Canvas style sheets with rounded borders +# --------------------------------------------------------------------------- + + +def _curve_path(start, ctrl1, ctrl2, end): + path = QG.QPainterPath(QC.QPointF(*start)) + path.cubicTo(QC.QPointF(*ctrl1), QC.QPointF(*ctrl2), QC.QPointF(*end)) + return path + + +@pytest.mark.parametrize("rect_type", (QC.QRect, QC.QRectF)) +def test_combine_path_list_incomplete_border(rect_type): + """Incomplete rounded borders are not combined (as in Qwt C++)""" + rect = rect_type(0, 0, 100, 100) + assert qwtCombinePathList(rect, []).isEmpty() + corner = _curve_path((0.0, 10.0), (0.0, 7.0), (0.0, 6.0), (0.0, 5.0)) + assert qwtCombinePathList(rect, [corner]).isEmpty() + + +def test_combine_path_list_rounded_corner(): + """The sub-paths of a rounded corner are combined with the other corners""" + rect = QC.QRectF(0.0, 0.0, 100.0, 100.0) + left = _curve_path((0.0, 5.0), (0.0, 6.0), (0.0, 7.0), (0.0, 10.0)) + top = _curve_path((5.0, 0.0), (6.0, 0.0), (7.0, 0.0), (10.0, 0.0)) + references = [QG.QPainterPath(left), QG.QPainterPath(top)] + path = qwtCombinePathList(rect, [left, top]) + assert not path.isEmpty() + assert path.boundingRect() == rect + # The sub-paths are reverted when needed, but the input ones are unchanged + assert [left, top] == references + + +ROUNDED_STYLE_SHEETS = ( + "border-radius: 10px; background-color: white;", + "border: 2px solid black; border-radius: 8px;", + "border: 1px solid gray; border-top-left-radius: 12px; background-color: white;", +) + + +@pytest.mark.parametrize("backing_store", (False, True), ids=("direct", "backing")) +@pytest.mark.parametrize("style_sheet", ROUNDED_STYLE_SHEETS) +def test_canvas_rounded_style_sheet(style_sheet, backing_store): + """A canvas style sheet with rounded borders is supported""" + app = _ensure_app() # keep a reference alive for the duration of the test + plot = QwtPlot() + canvas = plot.canvas() + canvas.setPaintAttribute(QwtPlotCanvas.BackingStore, backing_store) + with _qt_exceptions() as errors: + canvas.setStyleSheet(style_sheet) + plot.resize(400, 300) + plot.show() + app.processEvents() + plot.replot() + image = canvas.grab().toImage() + border_path = canvas.borderPath(canvas.rect()) + assert not errors, _format_exceptions(errors) + assert not border_path.isEmpty() + if "background-color: white" in style_sheet: + # Rounded top left corner: filled with the background of the plot + white = QG.QColor(QC.Qt.white).rgb() + assert image.pixelColor(0, 0).rgb() != white + assert image.pixelColor(image.width() // 2, image.height() // 2).rgb() == white + plot.close() + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -586,4 +654,8 @@ def test_dyngrid_layout_vertical_expanding(): test_painter_command_copy_is_independent() test_painter_command_copy_replay() test_dyngrid_layout_vertical_expanding() + test_combine_path_list_rounded_corner() + for sheet in ROUNDED_STYLE_SHEETS: + for backing in (False, True): + test_canvas_rounded_style_sheet(sheet, backing) print("OK") From 36285dcc4c534db3eae61f4a43fa6ab2e6c20f15 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:11:45 +0100 Subject: [PATCH 10/14] fix(plot_renderer): list image formats properly in the export dialog The image filter of the file dialog shown by QwtPlotRenderer.exportTo read "Images (*.b'bmp' *.b'png' ...)": the image format names returned by QImageWriter.supportedImageFormats() are QByteArray objects, which were converted with str() instead of being decoded. - Decode the format names (bytes(fmt).decode(), supported by PyQt5, PyQt6 and PySide6). - Regression test: file dialog replaced by a stub capturing the filters, image filter equal to the decoded format names. - Changelog entry. The export itself (QwtPlotRenderer.renderDocument) is not affected: PNG, SVG and PDF exports already work with the three Qt bindings. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/plot_renderer.py | 2 +- qwt/tests/test_issue115_porting_bugs.py | 28 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1f4910..66ac46d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Fixed the `QwtPainterCommand` copy constructor (`QwtPainterCommand(other)`), which raised an `AttributeError` for state commands and a `TypeError` (Qt objects cannot be pickled) for pixmap and image commands: commands of every type can now be copied, the copy being independent of the original ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtDynGridLayout` (used by `QwtLegend`), whose `numRows()` returned a float (e.g. `2.5` for 3 items on 2 columns) and which raised a `TypeError` when laying out its items with vertical expansion (`setExpandingDirections(Qt.Vertical)`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed canvas style sheets with rounded borders (e.g. `plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;")`), which raised a `TypeError` on each style change, resize and paint event (aborting the process with PyQt's default exception hook): the rounded border of the style sheet is now taken into account to clip the plot items and to fill the corners with the background of the parent widget, as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed the image formats listed by the file dialog of `QwtPlotRenderer.exportTo`, which read `*.b'bmp' *.b'png' ...` instead of `*.bmp *.png ...` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/plot_renderer.py b/qwt/plot_renderer.py index d8b2640..44e72e5 100644 --- a/qwt/plot_renderer.py +++ b/qwt/plot_renderer.py @@ -722,7 +722,7 @@ def exportTo(self, plot, documentname, sizeMM=None, resolution=85): for idx, fmt in enumerate(imageFormats): if idx > 0: imageFilter += " " - imageFilter += "*." + str(fmt) + imageFilter += "*." + bytes(fmt).decode() imageFilter += ")" filter_ += [imageFilter] filename, _s = getsavefilename( diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index 2c20d07..c7b112e 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -21,6 +21,7 @@ from qtpy import QtGui as QG from qtpy import QtWidgets as QW +import qwt.plot_renderer from qwt import ( QwtLegend, QwtLinearScaleEngine, @@ -635,6 +636,33 @@ def test_canvas_rounded_style_sheet(style_sheet, backing_store): del app +# --------------------------------------------------------------------------- +# QwtPlotRenderer.exportTo +# --------------------------------------------------------------------------- + + +def test_renderer_export_to_image_filter(monkeypatch): + """The file dialog of exportTo lists the image formats properly""" + app = _ensure_app() # keep a reference alive for the duration of the test + captured = [] + + def fake_getsavefilename(*args, **kwargs): + captured.append(args[3]) # filters + return "", "" # dialog canceled + + monkeypatch.setattr(qwt.plot_renderer, "getsavefilename", fake_getsavefilename) + plot = QwtPlot() + assert QwtPlotRenderer().exportTo(plot, "plot") is False + filters = captured[0].split(";;") + formats = QG.QImageWriter.supportedImageFormats() + expected = " ".join("*." + bytes(fmt).decode() for fmt in formats) + assert filters[-1] == f"Images ({expected})" + assert "*.png" in filters[-1].split() + assert "b'" not in captured[0] + plot.close() + del app + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) From 27348a2d4a11d6801b11b71a0c68df95b2a044fe Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:13:19 +0100 Subject: [PATCH 11/14] fix(interval): intersect overlapping and touching intervals correctly QwtInterval.intersect (and the `&` operator) returned an invalid interval for partially overlapping intervals, e.g. QwtInterval(0, 2) & QwtInterval(1, 3), and for intervals sharing an included bound: after ordering the intervals by their minimum, it tested `i1.maxValue() < i2.maxValue()` instead of `i1.maxValue() < i2.minValue()` (Qwt C++). QwtInterval.intersects gave the opposite result for intervals sharing a bound: `[0, 1]` and `[1, 2]` did not intersect, while `[0, 1)` and `(1, 2]` did. Qwt C++ returns `!(i1 excludes its maximum || i2 excludes its minimum)`. - Restore the Qwt C++ conditions; intersects now always returns a bool. - Results which were already valid are unchanged: checked on all the 4096 pairs of intervals with bounds in 0..3 and any border flags (including invalid intervals), both methods now match a direct transcription of the Qwt C++ code. - Regression tests: table of overlapping, nested, equal, disjoint and touching intervals with various border flags, in both orders, with `intersect` and `&`; `intersects` cases; invalid intervals. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + qwt/interval.py | 6 +- qwt/tests/test_issue115_porting_bugs.py | 77 +++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66ac46d..4fdf52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Fixed `QwtDynGridLayout` (used by `QwtLegend`), whose `numRows()` returned a float (e.g. `2.5` for 3 items on 2 columns) and which raised a `TypeError` when laying out its items with vertical expansion (`setExpandingDirections(Qt.Vertical)`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed canvas style sheets with rounded borders (e.g. `plot.canvas().setStyleSheet("border: 2px solid black; border-radius: 8px;")`), which raised a `TypeError` on each style change, resize and paint event (aborting the process with PyQt's default exception hook): the rounded border of the style sheet is now taken into account to clip the plot items and to fill the corners with the background of the parent widget, as in Qwt ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed the image formats listed by the file dialog of `QwtPlotRenderer.exportTo`, which read `*.b'bmp' *.b'png' ...` instead of `*.bmp *.png ...` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Fixed `QwtInterval.intersect` (and the `&` operator), which returned an invalid interval for partially overlapping intervals (e.g. `QwtInterval(0, 2) & QwtInterval(1, 3)` instead of `[1, 2]`), and `QwtInterval.intersects`, which gave the opposite result for intervals sharing a bound (e.g. `False` for `[0, 1]` and `[1, 2]`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/qwt/interval.py b/qwt/interval.py index bd2a5df..4b51753 100644 --- a/qwt/interval.py +++ b/qwt/interval.py @@ -296,7 +296,7 @@ def intersect(self, other): if i1.borderFlags() & self.ExcludeMinimum: i1, i2 = i2, i1 - if i1.maxValue() < i2.maxValue(): + if i1.maxValue() < i2.minValue(): return QwtInterval() if i1.maxValue() == i2.minValue(): @@ -346,9 +346,9 @@ def intersects(self, other): if i1.maxValue() > i2.minValue(): return True elif i1.maxValue() == i2.minValue(): - return ( + return not ( i1.borderFlags() & self.ExcludeMaximum - and i2.borderFlags() & self.ExcludeMinimum + or i2.borderFlags() & self.ExcludeMinimum ) return False diff --git a/qwt/tests/test_issue115_porting_bugs.py b/qwt/tests/test_issue115_porting_bugs.py index c7b112e..ecc2f95 100644 --- a/qwt/tests/test_issue115_porting_bugs.py +++ b/qwt/tests/test_issue115_porting_bugs.py @@ -23,6 +23,7 @@ import qwt.plot_renderer from qwt import ( + QwtInterval, QwtLegend, QwtLinearScaleEngine, QwtPlot, @@ -663,6 +664,78 @@ def fake_getsavefilename(*args, **kwargs): del app +# --------------------------------------------------------------------------- +# QwtInterval.intersect / QwtInterval.intersects +# --------------------------------------------------------------------------- + +INC = QwtInterval.IncludeBorders +EXC_MIN = QwtInterval.ExcludeMinimum +EXC_MAX = QwtInterval.ExcludeMaximum + +# (interval 1, interval 2, expected intersection or None if invalid), +# intervals being (min, max, border flags); expected values follow Qwt C++ +INTERSECT_CASES = ( + ((0, 2, INC), (1, 3, INC), (1, 2, INC)), + ((1, 3, INC), (0, 2, INC), (1, 2, INC)), + ((0, 3, INC), (1, 2, INC), (1, 2, INC)), + ((0, 2, INC), (0, 2, INC), (0, 2, INC)), + ((0, 1, INC), (2, 3, INC), None), + ((0, 1, INC), (1, 2, INC), (1, 1, INC)), + ((0, 1, EXC_MAX), (1, 2, INC), None), + ((0, 1, INC), (1, 2, EXC_MIN), None), + ((0, 2, EXC_MIN), (1, 3, EXC_MAX), (1, 2, INC)), + ((0, 2, EXC_MAX), (1, 3, INC), (1, 2, EXC_MAX)), + ((0, 2, INC), (0, 1, EXC_MIN), (0, 1, EXC_MIN)), +) + + +def _interval(values): + return QwtInterval(*values) + + +@pytest.mark.parametrize("values1, values2, expected", INTERSECT_CASES) +def test_interval_intersect(values1, values2, expected): + """QwtInterval.intersect and `&` return the intersection of two intervals""" + interval1, interval2 = _interval(values1), _interval(values2) + for result in (interval1.intersect(interval2), interval1 & interval2): + if expected is None: + assert not result.isValid() + else: + assert result == _interval(expected) + + +def test_interval_intersect_invalid(): + """The intersection with an invalid interval is invalid""" + assert not QwtInterval().intersect(QwtInterval(0, 1)).isValid() + assert not QwtInterval(0, 1).intersect(QwtInterval()).isValid() + + +@pytest.mark.parametrize( + "values1, values2, expected", + ( + ((0, 2, INC), (1, 3, INC), True), + ((1, 3, INC), (0, 2, INC), True), + ((0, 3, INC), (1, 2, INC), True), + ((0, 1, INC), (2, 3, INC), False), + ((0, 1, INC), (1, 2, INC), True), + ((1, 2, INC), (0, 1, INC), True), + ((0, 1, EXC_MAX), (1, 2, INC), False), + ((0, 1, INC), (1, 2, EXC_MIN), False), + ((0, 1, EXC_MAX), (1, 2, EXC_MIN), False), + ), +) +def test_interval_intersects(values1, values2, expected): + """QwtInterval.intersects tells whether two intervals overlap""" + result = _interval(values1).intersects(_interval(values2)) + assert result is expected + + +def test_interval_intersects_invalid(): + """An invalid interval does not intersect any interval""" + assert QwtInterval().intersects(QwtInterval(0, 1)) is False + assert QwtInterval(0, 1).intersects(QwtInterval()) is False + + if __name__ == "__main__": for name in ("toPixmap", "toImage"): test_graphic_conversion_default_size(name) @@ -686,4 +759,8 @@ def fake_getsavefilename(*args, **kwargs): for sheet in ROUNDED_STYLE_SHEETS: for backing in (False, True): test_canvas_rounded_style_sheet(sheet, backing) + for values1, values2, expected in INTERSECT_CASES: + test_interval_intersect(values1, values2, expected) + test_interval_intersect_invalid() + test_interval_intersects_invalid() print("OK") From b8ecfea00023bf959378ea51c703ee5e2fc164c8 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:14:12 +0100 Subject: [PATCH 12/14] build: ship the Qt Designer plugin and the changelog in the sdist The source distribution did not include qtdesigner/qwtplugin.py, the Qt Designer plugin added in 0.16.3 (whose installation, documented in the "Qt Designer" section of the reference, relies on this file), nor CHANGELOG.md, referenced by the README. - MANIFEST.in: include CHANGELOG.md and qtdesigner/*.py (an explicit pattern rather than graft, so that no __pycache__ is ever shipped). - Checked with `python -m build`: the sdist gains exactly these two files, the wheel content is unchanged. - Changelog entry. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ MANIFEST.in | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdf52b..141f2f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ - Fixed the image formats listed by the file dialog of `QwtPlotRenderer.exportTo`, which read `*.b'bmp' *.b'png' ...` instead of `*.bmp *.png ...` ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) - Fixed `QwtInterval.intersect` (and the `&` operator), which returned an invalid interval for partially overlapping intervals (e.g. `QwtInterval(0, 2) & QwtInterval(1, 3)` instead of `[1, 2]`), and `QwtInterval.intersects`, which gave the opposite result for intervals sharing a bound (e.g. `False` for `[0, 1]` and `[1, 2]`) ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +### Other changes + +- The source distribution now includes the Qt Designer plugin (`qtdesigner/qwtplugin.py`, see the "Qt Designer" section of the documentation) and this changelog ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) + ## Version 0.16.3 ### New features diff --git a/MANIFEST.in b/MANIFEST.in index f814f5f..608fba5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,4 @@ graft doc -include *.desktop \ No newline at end of file +include *.desktop +include CHANGELOG.md +include qtdesigner/*.py \ No newline at end of file From 217f1021f01c8a5113f9cdd554a2aa2485725c62 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:16:58 +0100 Subject: [PATCH 13/14] docs: render Google-style docstrings and document datetime scale classes The documentation build reported 3 errors and 1 warning: the docstrings of the qwt.qtdesigner module follow the Google style (Args/Returns), which Sphinx does not understand without the napoleon extension. Besides, QwtDateTimeScaleDraw and QwtDateTimeScaleEngine were missing from the reference documentation. - doc/conf.py: enable sphinx.ext.napoleon. - Add QwtDateTimeScaleDraw and QwtDateTimeScaleEngine to the module documentation of qwt.scale_draw and qwt.scale_engine. - QwtDateTimeScaleDraw.get_format/label: drop the return types from the Returns sections (already in the signatures), as "QwtText" was an ambiguous cross-reference. - QwtPlotLayout.expandLineBreaks: move its "Returns:" list into the `:return:` field, which napoleon would otherwise misinterpret. - Changelog entry. `sphinx -b html doc ... -W` now succeeds without any warning. Compared with a build without napoleon, only the qtdesigner, scale and plot_layout reference pages change. Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + doc/conf.py | 2 +- qwt/plot_layout.py | 4 +--- qwt/scale_draw.py | 10 ++++++++-- qwt/scale_engine.py | 6 ++++++ 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 141f2f0..d8990ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Other changes - The source distribution now includes the Qt Designer plugin (`qtdesigner/qwtplugin.py`, see the "Qt Designer" section of the documentation) and this changelog ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) +- Documentation: the reference of the `qwt.qtdesigner` module, whose docstrings follow the Google style, is now rendered properly (it produced formatting errors), and `QwtDateTimeScaleDraw` and `QwtDateTimeScaleEngine` are now documented ([Issue #115](https://github.com/PlotPyStack/PythonQwt/issues/115)) ## Version 0.16.3 diff --git a/doc/conf.py b/doc/conf.py index 5969162..2f92ca9 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -19,7 +19,7 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc"] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] try: import sphinx.ext.viewcode # noqa: F401 diff --git a/qwt/plot_layout.py b/qwt/plot_layout.py index 0919914..70424ef 100644 --- a/qwt/plot_layout.py +++ b/qwt/plot_layout.py @@ -746,9 +746,7 @@ def expandLineBreaks(self, options, rect): :param options: Options how to layout the legend :param QRectF rect: Bounding rectangle for title, footer, axes and canvas. - :return: tuple `(dimTitle, dimFooter, dimAxes)` - - Returns: + :return: tuple `(dimTitle, dimFooter, dimAxes)`, where: * `dimTitle`: Expanded height of the title widget * `dimFooter`: Expanded height of the footer widget diff --git a/qwt/scale_draw.py b/qwt/scale_draw.py index 15c4d5b..348f26a 100644 --- a/qwt/scale_draw.py +++ b/qwt/scale_draw.py @@ -17,6 +17,12 @@ .. autoclass:: QwtScaleDraw :members: + +QwtDateTimeScaleDraw +-------------------- + +.. autoclass:: QwtDateTimeScaleDraw + :members: """ import math @@ -1294,7 +1300,7 @@ def get_format(self) -> str: """Get the current datetime format string Returns: - str: Format string + Format string """ return self._format @@ -1313,7 +1319,7 @@ def label(self, value: float) -> QwtText: value: Unix timestamp (seconds since epoch) Returns: - QwtText: Formatted label + Formatted label """ try: dt = datetime.fromtimestamp(value) diff --git a/qwt/scale_engine.py b/qwt/scale_engine.py index 8b6545f..ea63761 100644 --- a/qwt/scale_engine.py +++ b/qwt/scale_engine.py @@ -23,6 +23,12 @@ .. autoclass:: QwtLogScaleEngine :members: + +QwtDateTimeScaleEngine +---------------------- + +.. autoclass:: QwtDateTimeScaleEngine + :members: """ import math From 645ac5521bbe61ee379f84486d7aa184a600b707 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:17:11 +0100 Subject: [PATCH 14/14] chore: bump version to 0.17.0 Python 3.10 is now required (minimum supported version raised), hence a new minor version, which also gathers the fixes for #113 and #115. - qwt/__init__.py: __version__ = "0.17.0". - CHANGELOG.md: "Unreleased" section renamed to "Version 0.17.0". Refs #115 Assisted-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- qwt/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8990ba..6932f2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PythonQwt Releases -## Unreleased +## Version 0.17.0 ### Requirements diff --git a/qwt/__init__.py b/qwt/__init__.py index 5519dd2..6bfa467 100644 --- a/qwt/__init__.py +++ b/qwt/__init__.py @@ -63,7 +63,7 @@ from qwt.text import QwtText # noqa: F401 from qwt.toqimage import array_to_qimage as toQImage # noqa: F401 -__version__ = "0.16.3" +__version__ = "0.17.0" QWT_VERSION_STR = "6.1.5"