Skip to content

Commit 6d29a36

Browse files
committed
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
1 parent 13e0a0f commit 6d29a36

3 files changed

Lines changed: 146 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- 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))
1616
- 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))
1717
- 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))
18+
- 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))
1819

1920
## Version 0.16.3
2021

‎qwt/painter_command.py‎

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,44 @@
1313
:members:
1414
"""
1515

16-
import copy
17-
18-
from qtpy.QtGui import QPaintEngine, QPainterPath
16+
from qtpy.QtCore import QPointF, QRectF
17+
from qtpy.QtGui import (
18+
QBrush,
19+
QFont,
20+
QImage,
21+
QPaintEngine,
22+
QPainterPath,
23+
QPen,
24+
QPixmap,
25+
QRegion,
26+
QTransform,
27+
)
28+
29+
# Qt value types stored in painter command data, copied with their copy
30+
# constructor (other stored values are immutable: enums, flags, numbers...)
31+
_QT_VALUE_TYPES = (
32+
QBrush,
33+
QFont,
34+
QImage,
35+
QPainterPath,
36+
QPen,
37+
QPixmap,
38+
QPointF,
39+
QRectF,
40+
QRegion,
41+
QTransform,
42+
)
43+
44+
45+
def _copy_data(data):
46+
"""Return a copy of painter command data, like the Qwt C++ copy
47+
constructors of `PixmapData`, `ImageData` and `StateData`"""
48+
result = data.__class__()
49+
for name, value in vars(data).items():
50+
if isinstance(value, _QT_VALUE_TYPES):
51+
value = type(value)(value)
52+
setattr(result, name, value)
53+
return result
1954

2055

2156
def _flag_int(flag):
@@ -215,11 +250,11 @@ def copy(self, other):
215250
if other.__type == self.Path:
216251
self.__path = QPainterPath(other.__path)
217252
elif other.__type == self.Pixmap:
218-
self.__pixmapData = copy.deepcopy(other.__pixmapData)
253+
self.__pixmapData = _copy_data(other.__pixmapData)
219254
elif other.__type == self.Image:
220-
self.__imageData = copy.deepcopy(other.__imageData)
255+
self.__imageData = _copy_data(other.__imageData)
221256
elif other.__type == self.State:
222-
self.__stateData == copy.deepcopy(other.__stateData)
257+
self.__stateData = _copy_data(other.__stateData)
223258

224259
def reset(self):
225260
self.__type = self.Invalid

‎qwt/tests/test_issue115_porting_bugs.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
QwtSymbol,
3636
)
3737
from qwt.graphic import QwtGraphic
38+
from qwt.painter_command import QwtPainterCommand
3839
from qwt.plot_curve import series_to_polyline
3940
from qwt.transform import QwtPowerTransform
4041

@@ -420,6 +421,106 @@ def test_direct_painter_atomic_painter_reset():
420421
del app
421422

422423

424+
# ---------------------------------------------------------------------------
425+
# QwtPainterCommand copy
426+
# ---------------------------------------------------------------------------
427+
428+
COMMAND_TYPES = {
429+
QwtPainterCommand.Path: "path",
430+
QwtPainterCommand.Pixmap: "pixmapData",
431+
QwtPainterCommand.Image: "imageData",
432+
QwtPainterCommand.State: "stateData",
433+
}
434+
435+
436+
def _make_recorded_graphic():
437+
"""Return a graphic recording path, pixmap, image and state commands"""
438+
pixmap = QG.QPixmap(4, 4)
439+
pixmap.fill(QC.Qt.blue)
440+
image = QG.QImage(4, 4, QG.QImage.Format_ARGB32)
441+
image.fill(QC.Qt.green)
442+
path = QG.QPainterPath()
443+
path.addEllipse(QC.QRectF(0.0, 0.0, 20.0, 10.0))
444+
graphic = QwtGraphic()
445+
with _qt_exceptions() as errors:
446+
painter = QG.QPainter(graphic)
447+
painter.setRenderHint(QG.QPainter.Antialiasing)
448+
painter.setPen(QG.QPen(RED, 2.0))
449+
painter.setBrush(QG.QBrush(QC.Qt.yellow))
450+
painter.setFont(QG.QFont("Arial", 12))
451+
painter.setOpacity(0.8)
452+
painter.setClipRect(QC.QRectF(0.0, 0.0, 30.0, 30.0))
453+
painter.translate(1.0, 2.0)
454+
painter.drawPath(path)
455+
rect, subrect = QC.QRectF(5.0, 5.0, 4.0, 4.0), QC.QRectF(0.0, 0.0, 4.0, 4.0)
456+
painter.drawPixmap(rect, pixmap, subrect)
457+
painter.drawImage(rect.translated(7.0, 0.0), image, subrect)
458+
painter.end()
459+
assert not errors, _format_exceptions(errors)
460+
return graphic
461+
462+
463+
def _comparable(value):
464+
"""Return a value which can be compared with `==`"""
465+
if isinstance(value, QG.QPixmap):
466+
return value.toImage()
467+
return value
468+
469+
470+
def _command_data(command):
471+
return getattr(command, COMMAND_TYPES[command.type()])()
472+
473+
474+
def test_painter_command_copy():
475+
"""Copying a painter command gives an equal command, for every type"""
476+
app = _ensure_app() # keep a reference alive for the duration of the test
477+
commands = _make_recorded_graphic().commands()
478+
assert {command.type() for command in commands} == set(COMMAND_TYPES)
479+
for command in commands:
480+
copied = QwtPainterCommand(command)
481+
assert copied.type() == command.type()
482+
data, copied_data = _command_data(command), _command_data(copied)
483+
assert copied_data is not data
484+
if command.type() == QwtPainterCommand.Path:
485+
assert copied_data == data
486+
continue
487+
for name, value in vars(data).items():
488+
copied_value = getattr(copied_data, name)
489+
assert _comparable(copied_value) == _comparable(value), name
490+
del app
491+
492+
493+
def test_painter_command_copy_is_independent():
494+
"""Modifying a copied painter command leaves the original unchanged"""
495+
app = _ensure_app() # keep a reference alive for the duration of the test
496+
for command in _make_recorded_graphic().commands():
497+
copied = QwtPainterCommand(command)
498+
data, copied_data = _command_data(command), _command_data(copied)
499+
if command.type() == QwtPainterCommand.Path:
500+
reference = QG.QPainterPath(data)
501+
copied_data.lineTo(100.0, 100.0)
502+
assert data == reference
503+
elif command.type() in (QwtPainterCommand.Pixmap, QwtPainterCommand.Image):
504+
reference = QC.QRectF(data.rect)
505+
copied_data.rect.setWidth(99.0)
506+
assert data.rect == reference
507+
elif copied_data.pen is not None:
508+
reference = QG.QPen(data.pen)
509+
copied_data.pen.setWidthF(9.0)
510+
assert data.pen == reference
511+
del app
512+
513+
514+
def test_painter_command_copy_replay():
515+
"""A graphic replaying copied commands renders like the original one"""
516+
app = _ensure_app() # keep a reference alive for the duration of the test
517+
graphic = _make_recorded_graphic()
518+
replayed = QwtGraphic()
519+
replayed.setCommands([QwtPainterCommand(cmd) for cmd in graphic.commands()])
520+
assert replayed.toImage() == graphic.toImage()
521+
del app
522+
523+
423524
if __name__ == "__main__":
424525
for name in ("toPixmap", "toImage"):
425526
test_graphic_conversion_default_size(name)
@@ -435,4 +536,7 @@ def test_direct_painter_atomic_painter_reset():
435536
for attr in DIRECT_PAINTER_ATTRIBUTES:
436537
test_direct_painter_set_attribute(attr)
437538
test_direct_painter_atomic_painter_reset()
539+
test_painter_command_copy()
540+
test_painter_command_copy_is_independent()
541+
test_painter_command_copy_replay()
438542
print("OK")

0 commit comments

Comments
 (0)