qplotter 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1445 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import os
|
|
3
|
+
import weakref
|
|
4
|
+
from itertools import zip_longest
|
|
5
|
+
from os import path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PyQt6 import QtCore, QtGui, QtPrintSupport, QtSvg
|
|
10
|
+
from PyQt6 import QtWidgets as qtw
|
|
11
|
+
from pyqtgraph import ErrorBarItem, PlotItem
|
|
12
|
+
from pyqtgraph.exporters import CSVExporter, ImageExporter, SVGExporter
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from pyqtgraph.GraphicsScene.exportDialog import (
|
|
16
|
+
ExportDialog as _PyqtgraphExportDialog,
|
|
17
|
+
)
|
|
18
|
+
from pyqtgraph.GraphicsScene.exportDialog import (
|
|
19
|
+
FormatExportListWidgetItem as _FormatExportListWidgetItem,
|
|
20
|
+
)
|
|
21
|
+
except ImportError: # pragma: no cover - fail-closed compatibility path
|
|
22
|
+
_PyqtgraphExportDialog = None
|
|
23
|
+
_FormatExportListWidgetItem = None
|
|
24
|
+
|
|
25
|
+
from qplot.diagnostics import log_exception
|
|
26
|
+
|
|
27
|
+
from ._export_paths import (
|
|
28
|
+
ExportDestinationTransaction,
|
|
29
|
+
UnsafeExportDestinationError,
|
|
30
|
+
approve_export_path,
|
|
31
|
+
choose_export_path,
|
|
32
|
+
choose_export_path_with_suffixes,
|
|
33
|
+
collect_protected_database_files,
|
|
34
|
+
prepare_export_destination,
|
|
35
|
+
write_export_atomically,
|
|
36
|
+
)
|
|
37
|
+
from ._preferences import (
|
|
38
|
+
COPY_PLOT_IMAGE_RESOLUTION_300_DPI,
|
|
39
|
+
COPY_PLOT_IMAGE_RESOLUTION_KEY,
|
|
40
|
+
COPY_PLOT_IMAGE_RESOLUTION_SCREEN,
|
|
41
|
+
COPY_PLOT_IMAGE_RESOLUTION_SVG,
|
|
42
|
+
)
|
|
43
|
+
from ._svg_export import svg_bytes, write_svg_stage
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from ._dataset_handle import DatasetKey
|
|
47
|
+
|
|
48
|
+
_MAX_EXPORTED_IMAGE_SIZE = 20_000
|
|
49
|
+
_HIGH_DPI_COPY_RESOLUTION = 300
|
|
50
|
+
_INCHES_PER_METER = 39.370_078_740_157_48
|
|
51
|
+
_DpiAxis = Literal["x", "y"]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_UnsafePrintDestinationError = UnsafeExportDestinationError
|
|
55
|
+
_PrintPdfDestination = ExportDestinationTransaction
|
|
56
|
+
_SAFE_PYQTGRAPH_FILE_EXPORTERS = (ImageExporter, CSVExporter, SVGExporter)
|
|
57
|
+
_SAFE_PYQTGRAPH_COPY_EXPORTERS = (ImageExporter, SVGExporter)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _csv_shared_coordinates_match(reference: Any, candidate: Any) -> bool:
|
|
61
|
+
"""Check an exact coordinate prefix without NumPy mixed-type coercion."""
|
|
62
|
+
reference = np.asarray(reference)
|
|
63
|
+
candidate = np.asarray(candidate)
|
|
64
|
+
if reference.ndim != 1 or candidate.ndim != 1 or candidate.size > reference.size:
|
|
65
|
+
return False
|
|
66
|
+
for left, right in zip(reference, candidate, strict=False):
|
|
67
|
+
if isinstance(left, np.generic):
|
|
68
|
+
left = left.item()
|
|
69
|
+
if isinstance(right, np.generic):
|
|
70
|
+
right = right.item()
|
|
71
|
+
if left != right and not (left != left and right != right):
|
|
72
|
+
return False
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _PrintPdfExportCancelled(RuntimeError):
|
|
77
|
+
"""Raised when a user declines qPlot's exact PDF replacement prompt."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _DisabledPyqtgraphExportDialog:
|
|
81
|
+
"""Fail-closed cache object for GraphicsScene.showExportDialog()."""
|
|
82
|
+
|
|
83
|
+
def show(self, _item: Any = None) -> None:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
_PyqtgraphExportDialog is not None
|
|
92
|
+
and _FormatExportListWidgetItem is not None
|
|
93
|
+
):
|
|
94
|
+
class _SafePyqtgraphExportDialog(_PyqtgraphExportDialog):
|
|
95
|
+
"""PyQtGraph's format UI with qPlot-owned file publication."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, scene: Any, owner: Any):
|
|
98
|
+
self._qplot_owner_ref = weakref.ref(owner)
|
|
99
|
+
super().__init__(scene)
|
|
100
|
+
owner.destroyed.connect(self._qplot_owner_destroyed)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def _qplot_owner(self) -> Any | None:
|
|
104
|
+
return self._qplot_owner_ref()
|
|
105
|
+
|
|
106
|
+
def updateFormatList(self) -> None:
|
|
107
|
+
"""Expose only exact built-in exporters qPlot can stage safely."""
|
|
108
|
+
current = self.ui.formatList.currentItem()
|
|
109
|
+
current_class = getattr(current, "expClass", None)
|
|
110
|
+
self.ui.formatList.clear()
|
|
111
|
+
selected_row = 0
|
|
112
|
+
for row, exporter_class in enumerate(_SAFE_PYQTGRAPH_FILE_EXPORTERS):
|
|
113
|
+
item = _FormatExportListWidgetItem(
|
|
114
|
+
exporter_class,
|
|
115
|
+
QtCore.QCoreApplication.translate(
|
|
116
|
+
"Exporter",
|
|
117
|
+
exporter_class.Name,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
self.ui.formatList.addItem(item)
|
|
121
|
+
if exporter_class is current_class:
|
|
122
|
+
selected_row = row
|
|
123
|
+
self.ui.formatList.setCurrentRow(selected_row)
|
|
124
|
+
|
|
125
|
+
def exportFormatChanged(self, item: Any, previous: Any) -> None:
|
|
126
|
+
"""Instantiate only an exact allowlisted exporter class."""
|
|
127
|
+
exporter_class = getattr(item, "expClass", None)
|
|
128
|
+
if exporter_class not in _SAFE_PYQTGRAPH_FILE_EXPORTERS:
|
|
129
|
+
self.currentExporter = None
|
|
130
|
+
self.ui.paramTree.clear()
|
|
131
|
+
self.ui.exportBtn.setEnabled(False)
|
|
132
|
+
self.ui.copyBtn.setEnabled(False)
|
|
133
|
+
return
|
|
134
|
+
super().exportFormatChanged(item, previous)
|
|
135
|
+
exporter_type = type(self.currentExporter)
|
|
136
|
+
if isinstance(self.currentExporter, CSVExporter):
|
|
137
|
+
# Numeric exports always retain the original precision. The
|
|
138
|
+
# upstream decimal-place control is unused by both our writers.
|
|
139
|
+
params = self.currentExporter.parameters()
|
|
140
|
+
params.removeChild(params.child("precision"))
|
|
141
|
+
self.ui.exportBtn.setEnabled(
|
|
142
|
+
exporter_type in _SAFE_PYQTGRAPH_FILE_EXPORTERS
|
|
143
|
+
)
|
|
144
|
+
self.ui.copyBtn.setEnabled(
|
|
145
|
+
exporter_type in _SAFE_PYQTGRAPH_COPY_EXPORTERS
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@QtCore.pyqtSlot()
|
|
149
|
+
def exportClicked(self) -> None:
|
|
150
|
+
self.selectBox.hide()
|
|
151
|
+
owner = self._qplot_owner
|
|
152
|
+
if owner is not None:
|
|
153
|
+
owner._export_pyqtgraph_exporter(self.currentExporter)
|
|
154
|
+
|
|
155
|
+
@QtCore.pyqtSlot()
|
|
156
|
+
def copyClicked(self) -> None:
|
|
157
|
+
self.selectBox.hide()
|
|
158
|
+
owner = self._qplot_owner
|
|
159
|
+
if owner is not None:
|
|
160
|
+
owner._copy_pyqtgraph_exporter(self.currentExporter)
|
|
161
|
+
|
|
162
|
+
@QtCore.pyqtSlot()
|
|
163
|
+
def _qplot_owner_destroyed(self) -> None:
|
|
164
|
+
self.dispose()
|
|
165
|
+
|
|
166
|
+
def dispose(self) -> None:
|
|
167
|
+
"""Remove both the dialog and its separately scene-owned box."""
|
|
168
|
+
self.close()
|
|
169
|
+
try:
|
|
170
|
+
if self.selectBox.scene() is self.scene:
|
|
171
|
+
self.scene.removeItem(self.selectBox)
|
|
172
|
+
except RuntimeError:
|
|
173
|
+
pass
|
|
174
|
+
self.currentExporter = None
|
|
175
|
+
self.deleteLater()
|
|
176
|
+
else:
|
|
177
|
+
_SafePyqtgraphExportDialog = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if TYPE_CHECKING:
|
|
181
|
+
class _PlotExportBase(qtw.QMainWindow):
|
|
182
|
+
_dataset_key: DatasetKey
|
|
183
|
+
ds: Any
|
|
184
|
+
param: Any
|
|
185
|
+
plot: Any
|
|
186
|
+
widget: Any
|
|
187
|
+
|
|
188
|
+
def show_error(
|
|
189
|
+
self,
|
|
190
|
+
title: str,
|
|
191
|
+
message: str,
|
|
192
|
+
details: str | None = None,
|
|
193
|
+
) -> None: ...
|
|
194
|
+
def show_status(self, message: str, timeout: int = 5000) -> None: ...
|
|
195
|
+
else:
|
|
196
|
+
class _PlotExportBase:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class PlotExportMixin(_PlotExportBase):
|
|
201
|
+
"""
|
|
202
|
+
Plot-window printing, export, PDF, and clipboard-image helpers.
|
|
203
|
+
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
@QtCore.pyqtSlot()
|
|
207
|
+
def print_plot(self) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Opens the native print dialog and prints the visible plot area.
|
|
210
|
+
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
printer = self._create_plot_printer()
|
|
214
|
+
dialog = QtPrintSupport.QPrintDialog(printer, self)
|
|
215
|
+
dialog.setWindowTitle("Print Plot")
|
|
216
|
+
dialog.setMinMax(1, 1)
|
|
217
|
+
dialog.setFromTo(1, 1)
|
|
218
|
+
dialog.setOption(
|
|
219
|
+
QtPrintSupport.QAbstractPrintDialog.PrintDialogOption.PrintPageRange,
|
|
220
|
+
False,
|
|
221
|
+
)
|
|
222
|
+
# Keep Qt's generic file field disabled: on some platforms that
|
|
223
|
+
# dialog probes the selected path before returning. Native/system
|
|
224
|
+
# PDF destinations may still be exposed on the configured printer
|
|
225
|
+
# after acceptance; those are redirected through staging below.
|
|
226
|
+
dialog.setOption(
|
|
227
|
+
QtPrintSupport.QAbstractPrintDialog.PrintDialogOption.PrintToFile,
|
|
228
|
+
False,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if dialog.exec() != qtw.QDialog.DialogCode.Accepted:
|
|
232
|
+
self.show_status("Plot printing cancelled.", 3000)
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
pdf_destination = self._prepare_print_pdf_destination(printer)
|
|
236
|
+
if pdf_destination is not None:
|
|
237
|
+
if not self._print_plot_pdf_atomically(printer, pdf_destination):
|
|
238
|
+
raise RuntimeError(
|
|
239
|
+
"The plot PDF could not be rendered or published."
|
|
240
|
+
)
|
|
241
|
+
self.show_status(
|
|
242
|
+
f"Printed plot to PDF: {pdf_destination.filename}",
|
|
243
|
+
5000,
|
|
244
|
+
)
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
if not self._render_plot_to_printer(printer):
|
|
248
|
+
raise RuntimeError("The plot could not be rendered to the printer.")
|
|
249
|
+
except _PrintPdfExportCancelled:
|
|
250
|
+
self.show_status("Plot printing cancelled.", 3000)
|
|
251
|
+
return False
|
|
252
|
+
except _UnsafePrintDestinationError as err:
|
|
253
|
+
self.show_status("Could not print plot to PDF.", 5000)
|
|
254
|
+
if hasattr(self, "show_error"):
|
|
255
|
+
self.show_error(
|
|
256
|
+
"Print to PDF Failed",
|
|
257
|
+
"qPlot could not safely use the selected PDF destination.",
|
|
258
|
+
str(err),
|
|
259
|
+
)
|
|
260
|
+
return False
|
|
261
|
+
except Exception as err:
|
|
262
|
+
log_exception("Plot printing failed", err, __name__)
|
|
263
|
+
self.show_status("Could not print plot.", 5000)
|
|
264
|
+
if hasattr(self, "show_error"):
|
|
265
|
+
self.show_error(
|
|
266
|
+
"Print Failed",
|
|
267
|
+
"Could not print the plot.",
|
|
268
|
+
str(err),
|
|
269
|
+
)
|
|
270
|
+
return False
|
|
271
|
+
|
|
272
|
+
self.show_status("Plot sent to printer.", 3000)
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _prepare_print_pdf_destination(
|
|
277
|
+
self,
|
|
278
|
+
printer: QtPrintSupport.QPrinter,
|
|
279
|
+
) -> _PrintPdfDestination | None:
|
|
280
|
+
"""
|
|
281
|
+
Validates a concrete PDF path returned by the system print dialog.
|
|
282
|
+
|
|
283
|
+
An empty output filename denotes a physical printer. Other opaque
|
|
284
|
+
print-to-file destinations are rejected because qPlot cannot redirect
|
|
285
|
+
them through its atomic publication path.
|
|
286
|
+
|
|
287
|
+
"""
|
|
288
|
+
output_file_name = getattr(printer, "outputFileName", None)
|
|
289
|
+
if not callable(output_file_name):
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
selected_path = str(output_file_name() or "")
|
|
293
|
+
if not selected_path:
|
|
294
|
+
return None
|
|
295
|
+
if selected_path.strip().casefold() == "file:":
|
|
296
|
+
raise _UnsafePrintDestinationError(
|
|
297
|
+
"The printer did not provide a concrete PDF filename."
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
output_format = getattr(printer, "outputFormat", None)
|
|
301
|
+
if (
|
|
302
|
+
not callable(output_format)
|
|
303
|
+
or output_format()
|
|
304
|
+
!= QtPrintSupport.QPrinter.OutputFormat.PdfFormat
|
|
305
|
+
):
|
|
306
|
+
raise _UnsafePrintDestinationError(
|
|
307
|
+
"Only PDF file output can be staged safely."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
selected_path = path.abspath(selected_path)
|
|
311
|
+
if path.splitext(selected_path)[1].casefold() != ".pdf":
|
|
312
|
+
raise _UnsafePrintDestinationError(
|
|
313
|
+
"The selected print destination must end in .pdf."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# The system dialog cannot give qPlot the target signature it observed
|
|
317
|
+
# while asking about replacement. Capture it now and require qPlot's
|
|
318
|
+
# own confirmation for this exact existing file; a file that appeared
|
|
319
|
+
# after the system prompt must never inherit unrelated consent.
|
|
320
|
+
destination = approve_export_path(
|
|
321
|
+
self,
|
|
322
|
+
selected_path,
|
|
323
|
+
replace_title="Replace Printed PDF?",
|
|
324
|
+
file_description="printed PDF file",
|
|
325
|
+
existing_target_validator=(
|
|
326
|
+
PlotExportMixin._validate_existing_print_pdf
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
if destination is None:
|
|
330
|
+
raise _PrintPdfExportCancelled
|
|
331
|
+
return destination
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _print_plot_pdf_atomically(
|
|
335
|
+
self,
|
|
336
|
+
printer: QtPrintSupport.QPrinter,
|
|
337
|
+
destination: _PrintPdfDestination,
|
|
338
|
+
) -> bool:
|
|
339
|
+
"""Renders a page-formatted PDF beside its target, then publishes it."""
|
|
340
|
+
return write_export_atomically(
|
|
341
|
+
destination,
|
|
342
|
+
lambda staging_path: self._write_print_pdf_stage(
|
|
343
|
+
printer,
|
|
344
|
+
staging_path,
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _write_print_pdf_stage(
|
|
350
|
+
self,
|
|
351
|
+
printer: QtPrintSupport.QPrinter,
|
|
352
|
+
staging_path: str,
|
|
353
|
+
) -> bool:
|
|
354
|
+
"""Redirects the configured PDF printer to one private staging file."""
|
|
355
|
+
if path.splitext(staging_path)[1].casefold() != ".pdf":
|
|
356
|
+
raise _UnsafePrintDestinationError(
|
|
357
|
+
"The private print staging path is not a PDF file."
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
printer.setOutputFileName(staging_path)
|
|
361
|
+
actual_path = str(printer.outputFileName() or "")
|
|
362
|
+
if (
|
|
363
|
+
path.normcase(path.realpath(path.abspath(actual_path)))
|
|
364
|
+
!= path.normcase(path.realpath(path.abspath(staging_path)))
|
|
365
|
+
):
|
|
366
|
+
raise _UnsafePrintDestinationError(
|
|
367
|
+
"The printer did not accept qPlot's private staging path."
|
|
368
|
+
)
|
|
369
|
+
if printer.outputFormat() != QtPrintSupport.QPrinter.OutputFormat.PdfFormat:
|
|
370
|
+
raise _UnsafePrintDestinationError(
|
|
371
|
+
"The printer did not retain PDF output while staging."
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
return self._paint_plot_to_device(printer)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _revalidate_print_pdf_destination(
|
|
378
|
+
self,
|
|
379
|
+
destination: _PrintPdfDestination,
|
|
380
|
+
) -> None:
|
|
381
|
+
"""Rejects a destination that changed while its PDF was rendering."""
|
|
382
|
+
destination.validate()
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _protected_database_identities(
|
|
386
|
+
self,
|
|
387
|
+
) -> frozenset[object]:
|
|
388
|
+
"""Compatibility wrapper for the former Print-only collector."""
|
|
389
|
+
return collect_protected_database_files(self).identities
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _ensure_print_target_is_not_protected(
|
|
394
|
+
filename: str,
|
|
395
|
+
protected_identities: frozenset[object],
|
|
396
|
+
) -> None:
|
|
397
|
+
"""Compatibility check for retained Print target identities."""
|
|
398
|
+
from qplot.datahandling.file_identity import database_file_identity
|
|
399
|
+
|
|
400
|
+
if database_file_identity(filename) in protected_identities:
|
|
401
|
+
raise _UnsafePrintDestinationError(
|
|
402
|
+
"The selected PDF path refers to an input database file."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _print_directory_identity(file_stat: os.stat_result) -> tuple[int, int, int]:
|
|
408
|
+
"""Returns stable identity fields for a selected output directory."""
|
|
409
|
+
return (
|
|
410
|
+
int(file_stat.st_dev),
|
|
411
|
+
int(file_stat.st_ino),
|
|
412
|
+
int(file_stat.st_mode),
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@staticmethod
|
|
417
|
+
def _print_target_signature(filename: str) -> tuple[object, ...] | None:
|
|
418
|
+
"""Compatibility wrapper around the shared target inspection."""
|
|
419
|
+
destination = prepare_export_destination(
|
|
420
|
+
None,
|
|
421
|
+
filename,
|
|
422
|
+
replacement_confirmed=True,
|
|
423
|
+
existing_target_validator=(
|
|
424
|
+
PlotExportMixin._validate_existing_print_pdf
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
return destination.target_signature
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _validate_existing_print_pdf(filename: str) -> bool:
|
|
432
|
+
"""Retain Print's format check for an existing replacement target."""
|
|
433
|
+
try:
|
|
434
|
+
with open(filename, "rb") as existing_file:
|
|
435
|
+
header = existing_file.read(5)
|
|
436
|
+
except OSError as err:
|
|
437
|
+
raise _UnsafePrintDestinationError(
|
|
438
|
+
"The selected PDF file could not be read safely."
|
|
439
|
+
) from err
|
|
440
|
+
if header != b"%PDF-":
|
|
441
|
+
raise _UnsafePrintDestinationError(
|
|
442
|
+
"The selected destination is not an existing PDF file."
|
|
443
|
+
)
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _create_plot_printer(self) -> QtPrintSupport.QPrinter:
|
|
448
|
+
"""
|
|
449
|
+
Creates a printer whose device-space sizing matches the plot view.
|
|
450
|
+
|
|
451
|
+
pyqtgraph uses cosmetic pens and device-sized text for several plot
|
|
452
|
+
elements. Screen-resolution printer coordinates preserve their
|
|
453
|
+
intended physical proportions while the print engine retains vector
|
|
454
|
+
output.
|
|
455
|
+
|
|
456
|
+
"""
|
|
457
|
+
printer = QtPrintSupport.QPrinter(
|
|
458
|
+
QtPrintSupport.QPrinter.PrinterMode.ScreenResolution
|
|
459
|
+
)
|
|
460
|
+
printer.setCreator("qPlot")
|
|
461
|
+
|
|
462
|
+
title = "qPlot plot"
|
|
463
|
+
window_title = getattr(self, "windowTitle", None)
|
|
464
|
+
if callable(window_title):
|
|
465
|
+
candidate = window_title().strip()
|
|
466
|
+
if candidate:
|
|
467
|
+
title = candidate
|
|
468
|
+
printer.setDocName(title)
|
|
469
|
+
|
|
470
|
+
widget = self.__dict__.get("widget")
|
|
471
|
+
if widget is not None:
|
|
472
|
+
orientation = QtGui.QPageLayout.Orientation.Portrait
|
|
473
|
+
if widget.width() > widget.height():
|
|
474
|
+
orientation = QtGui.QPageLayout.Orientation.Landscape
|
|
475
|
+
printer.setPageOrientation(orientation)
|
|
476
|
+
|
|
477
|
+
return printer
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _render_plot_to_printer(
|
|
481
|
+
self,
|
|
482
|
+
printer: QtGui.QPagedPaintDevice,
|
|
483
|
+
) -> bool:
|
|
484
|
+
"""
|
|
485
|
+
Renders to a physical printer while rejecting direct file output.
|
|
486
|
+
|
|
487
|
+
"""
|
|
488
|
+
self._ensure_printer_has_no_file_output(printer)
|
|
489
|
+
return self._paint_plot_to_device(printer)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _paint_plot_to_device(
|
|
493
|
+
self,
|
|
494
|
+
printer: QtGui.QPagedPaintDevice,
|
|
495
|
+
) -> bool:
|
|
496
|
+
"""
|
|
497
|
+
Renders the current plot view into a paged device's printable area.
|
|
498
|
+
|
|
499
|
+
Rendering the graphics view through ``QPainter`` retains vector line
|
|
500
|
+
work and text while excluding the surrounding plot-window controls.
|
|
501
|
+
|
|
502
|
+
"""
|
|
503
|
+
widget = self.__dict__.get("widget")
|
|
504
|
+
if widget is None or not hasattr(widget, "render"):
|
|
505
|
+
return False
|
|
506
|
+
|
|
507
|
+
source = QtCore.QRect(widget.rect())
|
|
508
|
+
if source.width() < 1 or source.height() < 1:
|
|
509
|
+
return False
|
|
510
|
+
|
|
511
|
+
painter = QtGui.QPainter()
|
|
512
|
+
if not painter.begin(printer):
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
print_finished = False
|
|
516
|
+
try:
|
|
517
|
+
printable = QtCore.QRectF(painter.viewport())
|
|
518
|
+
destination = self._fit_print_rect(source.size(), printable)
|
|
519
|
+
if destination.isEmpty():
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
|
523
|
+
painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing)
|
|
524
|
+
painter.fillRect(printable, QtGui.QColor("white"))
|
|
525
|
+
widget.render(
|
|
526
|
+
painter,
|
|
527
|
+
destination,
|
|
528
|
+
source,
|
|
529
|
+
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
|
|
530
|
+
)
|
|
531
|
+
finally:
|
|
532
|
+
print_finished = painter.end()
|
|
533
|
+
|
|
534
|
+
return print_finished
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _ensure_printer_has_no_file_output(
|
|
538
|
+
self,
|
|
539
|
+
printer: QtGui.QPagedPaintDevice,
|
|
540
|
+
) -> None:
|
|
541
|
+
"""
|
|
542
|
+
Rejects direct print-to-file output before the backend opens its path.
|
|
543
|
+
|
|
544
|
+
Concrete PDF output is redirected to a private sibling file before
|
|
545
|
+
this physical-printer entry point is called. Any filename remaining
|
|
546
|
+
here would bypass that atomic staging path.
|
|
547
|
+
|
|
548
|
+
"""
|
|
549
|
+
output_file_name = getattr(printer, "outputFileName", None)
|
|
550
|
+
if not callable(output_file_name):
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
output_path = str(output_file_name() or "")
|
|
554
|
+
if not output_path:
|
|
555
|
+
return
|
|
556
|
+
raise _UnsafePrintDestinationError(
|
|
557
|
+
"Direct printer output bypassed qPlot's PDF staging path: "
|
|
558
|
+
f"{output_path}"
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@staticmethod
|
|
563
|
+
def _fit_print_rect(
|
|
564
|
+
source_size: QtCore.QSize,
|
|
565
|
+
printable: QtCore.QRectF,
|
|
566
|
+
) -> QtCore.QRectF:
|
|
567
|
+
"""
|
|
568
|
+
Fits and centres a plot inside a printable rectangle without cropping.
|
|
569
|
+
|
|
570
|
+
"""
|
|
571
|
+
if (
|
|
572
|
+
source_size.width() < 1
|
|
573
|
+
or source_size.height() < 1
|
|
574
|
+
or printable.isEmpty()
|
|
575
|
+
):
|
|
576
|
+
return QtCore.QRectF()
|
|
577
|
+
|
|
578
|
+
fitted_size = QtCore.QSizeF(source_size)
|
|
579
|
+
fitted_size.scale(
|
|
580
|
+
printable.size(),
|
|
581
|
+
QtCore.Qt.AspectRatioMode.KeepAspectRatio,
|
|
582
|
+
)
|
|
583
|
+
destination = QtCore.QRectF(QtCore.QPointF(), fitted_size)
|
|
584
|
+
destination.moveCenter(printable.center())
|
|
585
|
+
return destination
|
|
586
|
+
|
|
587
|
+
@QtCore.pyqtSlot()
|
|
588
|
+
def open_export_dialog(self) -> None:
|
|
589
|
+
"""
|
|
590
|
+
Opens a fail-closed PyQtGraph format dialog for this plot.
|
|
591
|
+
|
|
592
|
+
"""
|
|
593
|
+
try:
|
|
594
|
+
dialog = self._install_safe_pyqtgraph_export_dialog()
|
|
595
|
+
if dialog is None:
|
|
596
|
+
self.show_status("Plot file export is unavailable.", 5000)
|
|
597
|
+
if hasattr(self, "show_error"):
|
|
598
|
+
self.show_error(
|
|
599
|
+
"Plot Export Unavailable",
|
|
600
|
+
"This PyQtGraph version cannot export files safely.",
|
|
601
|
+
)
|
|
602
|
+
return
|
|
603
|
+
dialog.show(self.plot)
|
|
604
|
+
except Exception as err:
|
|
605
|
+
log_exception("Plot export dialog setup failed", err, __name__)
|
|
606
|
+
self.show_status("Plot file export is unavailable.", 5000)
|
|
607
|
+
if hasattr(self, "show_error"):
|
|
608
|
+
self.show_error(
|
|
609
|
+
"Plot Export Unavailable",
|
|
610
|
+
"This PyQtGraph version cannot export files safely.",
|
|
611
|
+
str(err),
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _install_safe_pyqtgraph_export_dialog(self) -> Any | None:
|
|
616
|
+
"""Eagerly own the scene's generic exporter cache, or disable it."""
|
|
617
|
+
scene = self.widget.scene()
|
|
618
|
+
dialog = getattr(scene, "exportDialog", None)
|
|
619
|
+
if (
|
|
620
|
+
_SafePyqtgraphExportDialog is not None
|
|
621
|
+
and type(dialog) is _SafePyqtgraphExportDialog
|
|
622
|
+
and getattr(dialog, "_qplot_owner", None) is self
|
|
623
|
+
):
|
|
624
|
+
return dialog
|
|
625
|
+
|
|
626
|
+
self._dispose_pyqtgraph_export_dialog(scene, dialog)
|
|
627
|
+
if _SafePyqtgraphExportDialog is None:
|
|
628
|
+
scene.exportDialog = _DisabledPyqtgraphExportDialog()
|
|
629
|
+
return None
|
|
630
|
+
|
|
631
|
+
dialog = _SafePyqtgraphExportDialog(scene, self)
|
|
632
|
+
scene.exportDialog = dialog
|
|
633
|
+
return dialog
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@staticmethod
|
|
637
|
+
def _dispose_pyqtgraph_export_dialog(scene: Any, dialog: Any) -> None:
|
|
638
|
+
"""Dispose a cached stock/safe dialog and its scene selection item."""
|
|
639
|
+
if dialog is None or isinstance(dialog, _DisabledPyqtgraphExportDialog):
|
|
640
|
+
return
|
|
641
|
+
if (
|
|
642
|
+
_SafePyqtgraphExportDialog is not None
|
|
643
|
+
and type(dialog) is _SafePyqtgraphExportDialog
|
|
644
|
+
):
|
|
645
|
+
dialog.dispose()
|
|
646
|
+
return
|
|
647
|
+
try:
|
|
648
|
+
dialog.close()
|
|
649
|
+
except RuntimeError:
|
|
650
|
+
pass
|
|
651
|
+
select_box = getattr(dialog, "selectBox", None)
|
|
652
|
+
if select_box is not None:
|
|
653
|
+
try:
|
|
654
|
+
if select_box.scene() is scene:
|
|
655
|
+
scene.removeItem(select_box)
|
|
656
|
+
except RuntimeError:
|
|
657
|
+
pass
|
|
658
|
+
delete_later = getattr(dialog, "deleteLater", None)
|
|
659
|
+
if callable(delete_later):
|
|
660
|
+
delete_later()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _dispose_plot_export_dialog(self) -> None:
|
|
664
|
+
"""Close the plot's exporter when its owning plot window closes."""
|
|
665
|
+
widget = self.__dict__.get("widget")
|
|
666
|
+
scene = widget.scene() if widget is not None else None
|
|
667
|
+
if scene is None:
|
|
668
|
+
return
|
|
669
|
+
dialog = getattr(scene, "exportDialog", None)
|
|
670
|
+
self._dispose_pyqtgraph_export_dialog(scene, dialog)
|
|
671
|
+
scene.exportDialog = _DisabledPyqtgraphExportDialog()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _export_pyqtgraph_exporter(self, exporter: Any) -> bool:
|
|
675
|
+
"""Route an allowlisted PyQtGraph file exporter through staging."""
|
|
676
|
+
specification = self._pyqtgraph_export_specification(exporter)
|
|
677
|
+
if specification is None:
|
|
678
|
+
self.show_status("That PyQtGraph export format is disabled.", 5000)
|
|
679
|
+
if hasattr(self, "show_error"):
|
|
680
|
+
self.show_error(
|
|
681
|
+
"Plot Export Disabled",
|
|
682
|
+
"qPlot cannot guarantee safe publication for that format.",
|
|
683
|
+
)
|
|
684
|
+
return False
|
|
685
|
+
|
|
686
|
+
suffixes, default_suffix, name_filter, description = specification
|
|
687
|
+
suggested_path = path.splitext(self._default_plot_pdf_filename())[0]
|
|
688
|
+
suggested_path = f"{suggested_path}{default_suffix}"
|
|
689
|
+
try:
|
|
690
|
+
destination = choose_export_path_with_suffixes(
|
|
691
|
+
self,
|
|
692
|
+
caption="Export Plot",
|
|
693
|
+
suggested_path=suggested_path,
|
|
694
|
+
name_filter=name_filter,
|
|
695
|
+
allowed_suffixes=suffixes,
|
|
696
|
+
default_suffix=default_suffix,
|
|
697
|
+
replace_title="Replace Plot Export?",
|
|
698
|
+
file_description=description,
|
|
699
|
+
match_selected_filter=type(exporter) is CSVExporter,
|
|
700
|
+
)
|
|
701
|
+
if destination is None:
|
|
702
|
+
self.show_status("Plot export cancelled.", 3000)
|
|
703
|
+
return False
|
|
704
|
+
csv_separator = None
|
|
705
|
+
if type(exporter) is CSVExporter:
|
|
706
|
+
# Resolve the delimiter from the exact, already-approved target
|
|
707
|
+
# without changing the options in the reused export dialog.
|
|
708
|
+
csv_separator = (
|
|
709
|
+
"tab" if path.splitext(destination.filename)[1].casefold()
|
|
710
|
+
== ".tsv" else "comma"
|
|
711
|
+
)
|
|
712
|
+
writer = lambda staging_path: exporter.export(fileName=staging_path)
|
|
713
|
+
if type(exporter) is SVGExporter:
|
|
714
|
+
writer = lambda staging_path: write_svg_stage(staging_path, exporter)
|
|
715
|
+
elif type(exporter) is CSVExporter:
|
|
716
|
+
if getattr(self, "operation_kind", None) == "plot2d":
|
|
717
|
+
writer = lambda staging_path: self._write_heatmap_csv_stage(
|
|
718
|
+
staging_path, exporter, separator=csv_separator,
|
|
719
|
+
)
|
|
720
|
+
else:
|
|
721
|
+
writer = lambda staging_path: self._write_line_csv_stage(
|
|
722
|
+
staging_path, exporter, separator=csv_separator,
|
|
723
|
+
)
|
|
724
|
+
saved = write_export_atomically(
|
|
725
|
+
destination,
|
|
726
|
+
writer,
|
|
727
|
+
)
|
|
728
|
+
except Exception as err:
|
|
729
|
+
log_exception("PyQtGraph plot export failed", err, __name__)
|
|
730
|
+
self.show_status("Could not export plot.", 5000)
|
|
731
|
+
if hasattr(self, "show_error"):
|
|
732
|
+
self.show_error(
|
|
733
|
+
"Plot Export Failed",
|
|
734
|
+
"Could not safely export the plot.",
|
|
735
|
+
str(err),
|
|
736
|
+
)
|
|
737
|
+
return False
|
|
738
|
+
|
|
739
|
+
if not saved:
|
|
740
|
+
self.show_status("Could not export plot.", 5000)
|
|
741
|
+
return False
|
|
742
|
+
self.show_status(f"Exported plot: {destination.filename}", 5000)
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _write_line_csv_stage(
|
|
747
|
+
self,
|
|
748
|
+
staging_path: str,
|
|
749
|
+
exporter: CSVExporter,
|
|
750
|
+
*,
|
|
751
|
+
separator: str | None = None,
|
|
752
|
+
) -> bool:
|
|
753
|
+
"""Retain original line data with round-trip-safe numeric text.
|
|
754
|
+
|
|
755
|
+
Reuse PyQtGraph's column/header collection, including error bars and
|
|
756
|
+
original (unmapped, non-downsampled) datasets, but never its positional
|
|
757
|
+
decimal-place formatter. Python's float representation round-trips
|
|
758
|
+
QCoDeS's binary64 numbers; integers are written without float coercion.
|
|
759
|
+
Every attempt owns a fresh collector: copy only parameter settings,
|
|
760
|
+
never the dialog exporter's counter, headers or accumulated data. This
|
|
761
|
+
also isolates failures and leaves options intact for a retry or mode
|
|
762
|
+
switch. The earlier round-trip numeric serialization is unchanged.
|
|
763
|
+
"""
|
|
764
|
+
if not isinstance(exporter.item, PlotItem):
|
|
765
|
+
raise TypeError("Must have a PlotItem selected for CSV export.")
|
|
766
|
+
|
|
767
|
+
collector = CSVExporter(exporter.item)
|
|
768
|
+
collector.params.restoreState(exporter.params.saveState())
|
|
769
|
+
if separator is not None:
|
|
770
|
+
collector.params["separator"] = separator
|
|
771
|
+
coordinates = []
|
|
772
|
+
for item in exporter.item.items:
|
|
773
|
+
if isinstance(item, ErrorBarItem):
|
|
774
|
+
collector._exportErrorBarItem(item)
|
|
775
|
+
elif hasattr(item, "implements") and item.implements("plotData"):
|
|
776
|
+
dataset = (
|
|
777
|
+
item.getOriginalDataset() if hasattr(item, "getOriginalDataset")
|
|
778
|
+
else item.getData()
|
|
779
|
+
)
|
|
780
|
+
if dataset[0] is not None:
|
|
781
|
+
coordinates.append(dataset[0])
|
|
782
|
+
collector._exportPlotDataItem(item)
|
|
783
|
+
|
|
784
|
+
if collector.params["columnMode"] == "(x,y,y,y) for all plots" and collector.data:
|
|
785
|
+
reference = collector.data[0][0]
|
|
786
|
+
if any(not _csv_shared_coordinates_match(reference, values) for values in coordinates):
|
|
787
|
+
raise ValueError(
|
|
788
|
+
"The traces have different X coordinates and cannot share one "
|
|
789
|
+
"CSV coordinate column. Select '(x,y) per plot' to retain "
|
|
790
|
+
"each trace's recorded coordinates."
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
columns = [column for dataset in collector.data for column in dataset]
|
|
794
|
+
delimiter = "\t" if collector.params["separator"] == "tab" else ","
|
|
795
|
+
with open(staging_path, "w", encoding="utf-8", newline="") as csv_file:
|
|
796
|
+
csv_writer = csv.writer(
|
|
797
|
+
csv_file, delimiter=delimiter, quoting=csv.QUOTE_MINIMAL,
|
|
798
|
+
)
|
|
799
|
+
csv_writer.writerow(collector.header)
|
|
800
|
+
for row in zip_longest(*columns, fillvalue=""):
|
|
801
|
+
csv_writer.writerow([
|
|
802
|
+
repr(float(value)) if isinstance(value, (float, np.floating))
|
|
803
|
+
else str(value)
|
|
804
|
+
for value in row
|
|
805
|
+
])
|
|
806
|
+
return True
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _write_heatmap_csv_stage(
|
|
810
|
+
self,
|
|
811
|
+
staging_path: str,
|
|
812
|
+
exporter: CSVExporter,
|
|
813
|
+
*,
|
|
814
|
+
separator: str | None = None,
|
|
815
|
+
) -> bool:
|
|
816
|
+
"""Write the canonical, currently plotted heatmap as long-form rows.
|
|
817
|
+
|
|
818
|
+
``dataGrid[row, column]`` is paired with the Y centre at ``row`` and
|
|
819
|
+
the X centre at ``column``. This deliberately snapshots the displayed
|
|
820
|
+
grid, so a viewport-loaded or downsampled large heatmap exports that
|
|
821
|
+
current resolution without initiating another database read.
|
|
822
|
+
"""
|
|
823
|
+
axis_data = self.__dict__.get("axis_data")
|
|
824
|
+
if not isinstance(axis_data, dict):
|
|
825
|
+
raise ValueError("Heatmap coordinate data is not available.")
|
|
826
|
+
|
|
827
|
+
x_centres = np.asarray(axis_data.get("x", []), dtype=float)
|
|
828
|
+
y_centres = np.asarray(axis_data.get("y", []), dtype=float)
|
|
829
|
+
data_grid = np.asarray(self.__dict__.get("dataGrid", []))
|
|
830
|
+
expected_shape = (y_centres.size, x_centres.size)
|
|
831
|
+
if x_centres.ndim != 1 or y_centres.ndim != 1:
|
|
832
|
+
raise ValueError("Heatmap coordinate axes must be one-dimensional.")
|
|
833
|
+
if data_grid.ndim != 2 or data_grid.shape != expected_shape:
|
|
834
|
+
raise ValueError(
|
|
835
|
+
f"Heatmap data shape {data_grid.shape} does not match "
|
|
836
|
+
f"axis shape {expected_shape}."
|
|
837
|
+
)
|
|
838
|
+
if data_grid.size == 0:
|
|
839
|
+
raise ValueError("The currently plotted heatmap is empty.")
|
|
840
|
+
|
|
841
|
+
if separator is None:
|
|
842
|
+
separator = exporter.params["separator"]
|
|
843
|
+
delimiter = "\t" if separator == "tab" else ","
|
|
844
|
+
column_names = self._heatmap_csv_column_names()
|
|
845
|
+
rows_written = 0
|
|
846
|
+
with open(staging_path, "w", encoding="utf-8", newline="") as csv_file:
|
|
847
|
+
csv_writer = csv.writer(
|
|
848
|
+
csv_file,
|
|
849
|
+
delimiter=delimiter,
|
|
850
|
+
quoting=csv.QUOTE_MINIMAL,
|
|
851
|
+
lineterminator="\n",
|
|
852
|
+
)
|
|
853
|
+
csv_writer.writerow(column_names)
|
|
854
|
+
for y_index, y_centre in enumerate(y_centres):
|
|
855
|
+
for x_index, x_centre in enumerate(x_centres):
|
|
856
|
+
csv_writer.writerow((
|
|
857
|
+
float(x_centre),
|
|
858
|
+
float(y_centre),
|
|
859
|
+
data_grid[y_index, x_index],
|
|
860
|
+
))
|
|
861
|
+
rows_written += 1
|
|
862
|
+
|
|
863
|
+
if rows_written != data_grid.size or os.path.getsize(staging_path) == 0:
|
|
864
|
+
raise RuntimeError("Heatmap CSV serialization produced no usable data.")
|
|
865
|
+
return True
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _heatmap_csv_column_names(self) -> tuple[str, str, str]:
|
|
869
|
+
"""Return stable parameter names for heatmap coordinate/value columns."""
|
|
870
|
+
axis_param = self.__dict__.get("axis_param")
|
|
871
|
+
if not isinstance(axis_param, dict):
|
|
872
|
+
axis_param = {}
|
|
873
|
+
value_param = self.__dict__.get(
|
|
874
|
+
"display_param",
|
|
875
|
+
self.__dict__.get("param"),
|
|
876
|
+
)
|
|
877
|
+
return (
|
|
878
|
+
self._export_parameter_name(axis_param.get("x"), "x"),
|
|
879
|
+
self._export_parameter_name(axis_param.get("y"), "y"),
|
|
880
|
+
self._export_parameter_name(value_param, "value"),
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@staticmethod
|
|
885
|
+
def _export_parameter_name(parameter: Any, fallback: str) -> str:
|
|
886
|
+
"""Use a QCoDeS parameter name, with a meaningful safe fallback."""
|
|
887
|
+
name = getattr(parameter, "name", None)
|
|
888
|
+
if name is None or not str(name).strip():
|
|
889
|
+
return fallback
|
|
890
|
+
return str(name)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _copy_pyqtgraph_exporter(self, exporter: Any) -> bool:
|
|
894
|
+
"""Allow only known clipboard-only PyQtGraph exporters."""
|
|
895
|
+
if type(exporter) not in _SAFE_PYQTGRAPH_COPY_EXPORTERS:
|
|
896
|
+
self.show_status("Copy is disabled for that export format.", 5000)
|
|
897
|
+
return False
|
|
898
|
+
try:
|
|
899
|
+
if type(exporter) is SVGExporter:
|
|
900
|
+
clipboard = qtw.QApplication.clipboard()
|
|
901
|
+
if clipboard is None:
|
|
902
|
+
raise RuntimeError("No clipboard available.")
|
|
903
|
+
mime_data = QtCore.QMimeData()
|
|
904
|
+
mime_data.setData("image/svg+xml", QtCore.QByteArray(svg_bytes(exporter)))
|
|
905
|
+
clipboard.setMimeData(mime_data)
|
|
906
|
+
result = None
|
|
907
|
+
else:
|
|
908
|
+
result = exporter.export(copy=True)
|
|
909
|
+
except Exception as err:
|
|
910
|
+
log_exception("PyQtGraph plot copy failed", err, __name__)
|
|
911
|
+
self.show_status("Could not copy plot.", 5000)
|
|
912
|
+
return False
|
|
913
|
+
if result is False:
|
|
914
|
+
self.show_status("Could not copy plot.", 5000)
|
|
915
|
+
return False
|
|
916
|
+
self.show_status("Plot copied to clipboard.", 3000)
|
|
917
|
+
return True
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@staticmethod
|
|
921
|
+
def _pyqtgraph_export_specification(
|
|
922
|
+
exporter: Any,
|
|
923
|
+
) -> tuple[tuple[str, ...], str, str, str] | None:
|
|
924
|
+
"""Describe exporters that accept a private same-suffix filename."""
|
|
925
|
+
if type(exporter) is ImageExporter:
|
|
926
|
+
patterns = tuple(exporter.getSupportedImageFormats())
|
|
927
|
+
suffixes = tuple(
|
|
928
|
+
suffix
|
|
929
|
+
for pattern in patterns
|
|
930
|
+
if (suffix := path.splitext(pattern)[1])
|
|
931
|
+
)
|
|
932
|
+
if not suffixes:
|
|
933
|
+
return None
|
|
934
|
+
return (
|
|
935
|
+
suffixes,
|
|
936
|
+
suffixes[0],
|
|
937
|
+
";;".join(
|
|
938
|
+
f"{suffix[1:].upper()} image (*{suffix})"
|
|
939
|
+
for suffix in suffixes
|
|
940
|
+
),
|
|
941
|
+
"plot image",
|
|
942
|
+
)
|
|
943
|
+
if type(exporter) is CSVExporter:
|
|
944
|
+
return (
|
|
945
|
+
(".csv", ".tsv"),
|
|
946
|
+
".csv",
|
|
947
|
+
"CSV files (*.csv);;TSV files (*.tsv)",
|
|
948
|
+
"plot data file",
|
|
949
|
+
)
|
|
950
|
+
if type(exporter) is SVGExporter:
|
|
951
|
+
return (
|
|
952
|
+
(".svg",),
|
|
953
|
+
".svg",
|
|
954
|
+
"SVG files (*.svg)",
|
|
955
|
+
"SVG file",
|
|
956
|
+
)
|
|
957
|
+
return None
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
@QtCore.pyqtSlot()
|
|
961
|
+
def save_plot_pdf(self) -> bool:
|
|
962
|
+
"""
|
|
963
|
+
Prompts for a filename and saves the visible plot area as a PDF.
|
|
964
|
+
|
|
965
|
+
"""
|
|
966
|
+
try:
|
|
967
|
+
destination = choose_export_path(
|
|
968
|
+
self,
|
|
969
|
+
caption="Save Plot as PDF",
|
|
970
|
+
suggested_path=self._default_plot_pdf_filename(),
|
|
971
|
+
name_filter="PDF files (*.pdf)",
|
|
972
|
+
required_suffix=".pdf",
|
|
973
|
+
replace_title="Replace PDF File?",
|
|
974
|
+
file_description="PDF file",
|
|
975
|
+
)
|
|
976
|
+
except Exception as err:
|
|
977
|
+
log_exception("Plot PDF destination rejected", err, __name__)
|
|
978
|
+
self.show_status("Could not use that PDF destination.", 5000)
|
|
979
|
+
if hasattr(self, "show_error"):
|
|
980
|
+
self.show_error(
|
|
981
|
+
"PDF Export Failed",
|
|
982
|
+
"qPlot could not safely use the selected PDF destination.",
|
|
983
|
+
str(err),
|
|
984
|
+
)
|
|
985
|
+
return False
|
|
986
|
+
if destination is None:
|
|
987
|
+
self.show_status("PDF export cancelled.", 3000)
|
|
988
|
+
return False
|
|
989
|
+
|
|
990
|
+
return self.save_plot_pdf_to_file(destination)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def save_plot_pdf_to_file(
|
|
994
|
+
self,
|
|
995
|
+
filename: str | ExportDestinationTransaction,
|
|
996
|
+
) -> bool:
|
|
997
|
+
"""
|
|
998
|
+
Saves the visible plot area as a PDF at ``filename``.
|
|
999
|
+
|
|
1000
|
+
"""
|
|
1001
|
+
if not filename:
|
|
1002
|
+
self.show_status("PDF export cancelled.", 3000)
|
|
1003
|
+
return False
|
|
1004
|
+
|
|
1005
|
+
try:
|
|
1006
|
+
if isinstance(filename, ExportDestinationTransaction):
|
|
1007
|
+
destination = filename
|
|
1008
|
+
else:
|
|
1009
|
+
destination = prepare_export_destination(
|
|
1010
|
+
self,
|
|
1011
|
+
filename,
|
|
1012
|
+
required_suffix=".pdf",
|
|
1013
|
+
)
|
|
1014
|
+
saved = write_export_atomically(destination, self._write_plot_pdf)
|
|
1015
|
+
except Exception as err:
|
|
1016
|
+
log_exception("Plot PDF export failed", err, __name__)
|
|
1017
|
+
self.show_status("Could not save plot PDF.", 5000)
|
|
1018
|
+
if hasattr(self, "show_error"):
|
|
1019
|
+
self.show_error(
|
|
1020
|
+
"PDF Export Failed",
|
|
1021
|
+
"Could not save the plot PDF.",
|
|
1022
|
+
str(err),
|
|
1023
|
+
)
|
|
1024
|
+
return False
|
|
1025
|
+
|
|
1026
|
+
if not saved:
|
|
1027
|
+
self.show_status("Could not save plot PDF.", 5000)
|
|
1028
|
+
return False
|
|
1029
|
+
|
|
1030
|
+
self.show_status(f"Saved PDF: {destination.filename}", 5000)
|
|
1031
|
+
return True
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _write_plot_pdf(self, filename: str) -> bool:
|
|
1035
|
+
"""
|
|
1036
|
+
Saves the already-laid-out plot widget as a single-page PDF.
|
|
1037
|
+
|
|
1038
|
+
"""
|
|
1039
|
+
pixmap = self._plot_image_pixmap()
|
|
1040
|
+
if pixmap.isNull():
|
|
1041
|
+
return False
|
|
1042
|
+
|
|
1043
|
+
image = pixmap.toImage()
|
|
1044
|
+
if image.isNull():
|
|
1045
|
+
return False
|
|
1046
|
+
|
|
1047
|
+
size = image.size()
|
|
1048
|
+
if size.width() < 1 or size.height() < 1:
|
|
1049
|
+
return False
|
|
1050
|
+
|
|
1051
|
+
writer = QtGui.QPdfWriter(filename)
|
|
1052
|
+
writer.setCreator("qPlot")
|
|
1053
|
+
writer.setTitle("qPlot plot")
|
|
1054
|
+
writer.setResolution(72)
|
|
1055
|
+
page_size = QtGui.QPageSize(
|
|
1056
|
+
QtCore.QSizeF(size.width(), size.height()),
|
|
1057
|
+
QtGui.QPageSize.Unit.Point,
|
|
1058
|
+
"qPlot plot",
|
|
1059
|
+
)
|
|
1060
|
+
page_layout = QtGui.QPageLayout(
|
|
1061
|
+
page_size,
|
|
1062
|
+
QtGui.QPageLayout.Orientation.Portrait,
|
|
1063
|
+
QtCore.QMarginsF(0, 0, 0, 0),
|
|
1064
|
+
QtGui.QPageLayout.Unit.Point,
|
|
1065
|
+
)
|
|
1066
|
+
writer.setPageLayout(page_layout)
|
|
1067
|
+
|
|
1068
|
+
painter = QtGui.QPainter()
|
|
1069
|
+
if not painter.begin(writer):
|
|
1070
|
+
return False
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
target = QtCore.QRectF(0, 0, size.width(), size.height())
|
|
1074
|
+
painter.fillRect(target, QtGui.QColor("white"))
|
|
1075
|
+
painter.drawImage(target, image)
|
|
1076
|
+
finally:
|
|
1077
|
+
painter.end()
|
|
1078
|
+
|
|
1079
|
+
return True
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _default_plot_pdf_filename(self) -> str:
|
|
1083
|
+
"""
|
|
1084
|
+
Returns a suggested PDF export filename for the current plot.
|
|
1085
|
+
|
|
1086
|
+
"""
|
|
1087
|
+
run_id = getattr(self.ds, "run_id", "plot")
|
|
1088
|
+
param_name = getattr(getattr(self, "param", None), "name", "plot")
|
|
1089
|
+
filename = self._safe_plot_export_filename(f"run_{run_id}_{param_name}.pdf")
|
|
1090
|
+
|
|
1091
|
+
try:
|
|
1092
|
+
database_folder = path.dirname(self._dataset_key.database_path)
|
|
1093
|
+
except Exception:
|
|
1094
|
+
database_folder = ""
|
|
1095
|
+
|
|
1096
|
+
if database_folder:
|
|
1097
|
+
return path.join(database_folder, filename)
|
|
1098
|
+
return path.abspath(filename)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@staticmethod
|
|
1102
|
+
def _safe_plot_export_filename(filename: str) -> str:
|
|
1103
|
+
"""
|
|
1104
|
+
Replaces path-hostile characters in a suggested plot export filename.
|
|
1105
|
+
|
|
1106
|
+
"""
|
|
1107
|
+
return "".join(char if char.isalnum() or char in "._-" else "_" for char in filename)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
@QtCore.pyqtSlot()
|
|
1111
|
+
def copy_plot_image(self) -> bool:
|
|
1112
|
+
"""
|
|
1113
|
+
Copies the rendered plot widget to the clipboard as an image.
|
|
1114
|
+
|
|
1115
|
+
Only the pyqtgraph widget is captured, so window menus, toolbars, docks,
|
|
1116
|
+
and any open context menu are excluded.
|
|
1117
|
+
|
|
1118
|
+
"""
|
|
1119
|
+
resolution = self._copy_plot_image_resolution()
|
|
1120
|
+
if resolution == COPY_PLOT_IMAGE_RESOLUTION_300_DPI:
|
|
1121
|
+
return self.copy_plot_image_at_dpi(_HIGH_DPI_COPY_RESOLUTION)
|
|
1122
|
+
if resolution == COPY_PLOT_IMAGE_RESOLUTION_SVG:
|
|
1123
|
+
return self.copy_plot_image_as_svg()
|
|
1124
|
+
|
|
1125
|
+
return self.copy_plot_image_at_screen_resolution()
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def copy_plot_image_at_screen_resolution(self) -> bool:
|
|
1129
|
+
"""
|
|
1130
|
+
Copies the rendered plot widget at the current screen resolution.
|
|
1131
|
+
|
|
1132
|
+
"""
|
|
1133
|
+
clipboard = qtw.QApplication.clipboard()
|
|
1134
|
+
if clipboard is None:
|
|
1135
|
+
self.show_status("No clipboard available.", 5000)
|
|
1136
|
+
return False
|
|
1137
|
+
|
|
1138
|
+
pixmap = self._plot_image_pixmap()
|
|
1139
|
+
if pixmap.isNull():
|
|
1140
|
+
self.show_status("Could not copy plot image.", 5000)
|
|
1141
|
+
return False
|
|
1142
|
+
|
|
1143
|
+
clipboard.setImage(pixmap.toImage())
|
|
1144
|
+
self.show_status("Plot image copied to clipboard.", 3000)
|
|
1145
|
+
return True
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _copy_plot_image_resolution(self) -> str:
|
|
1149
|
+
"""
|
|
1150
|
+
Returns the configured resolution mode for plot-image clipboard copies.
|
|
1151
|
+
|
|
1152
|
+
"""
|
|
1153
|
+
config = self.__dict__.get("config")
|
|
1154
|
+
if config is None:
|
|
1155
|
+
return COPY_PLOT_IMAGE_RESOLUTION_SCREEN
|
|
1156
|
+
return config.get(COPY_PLOT_IMAGE_RESOLUTION_KEY)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _plot_image_pixmap(self) -> QtGui.QPixmap:
|
|
1160
|
+
"""
|
|
1161
|
+
Captures the plot widget without the surrounding QMainWindow chrome.
|
|
1162
|
+
|
|
1163
|
+
"""
|
|
1164
|
+
return self.widget.grab()
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def copy_plot_image_as_svg(self) -> bool:
|
|
1168
|
+
"""
|
|
1169
|
+
Copies the current plot area to the clipboard as SVG.
|
|
1170
|
+
|
|
1171
|
+
"""
|
|
1172
|
+
clipboard = qtw.QApplication.clipboard()
|
|
1173
|
+
if clipboard is None:
|
|
1174
|
+
self.show_status("No clipboard available.", 5000)
|
|
1175
|
+
return False
|
|
1176
|
+
|
|
1177
|
+
try:
|
|
1178
|
+
svg_bytes = self._plot_svg_bytes()
|
|
1179
|
+
except Exception as err:
|
|
1180
|
+
log_exception("Plot SVG copy failed", err, __name__)
|
|
1181
|
+
self.show_status("Could not copy plot SVG.", 5000)
|
|
1182
|
+
return False
|
|
1183
|
+
|
|
1184
|
+
if not svg_bytes:
|
|
1185
|
+
self.show_status("Could not copy plot SVG.", 5000)
|
|
1186
|
+
return False
|
|
1187
|
+
|
|
1188
|
+
mime_data = QtCore.QMimeData()
|
|
1189
|
+
mime_data.setData("image/svg+xml", QtCore.QByteArray(svg_bytes))
|
|
1190
|
+
try:
|
|
1191
|
+
mime_data.setText(svg_bytes.decode("utf-8"))
|
|
1192
|
+
except UnicodeDecodeError:
|
|
1193
|
+
pass
|
|
1194
|
+
clipboard.setMimeData(mime_data)
|
|
1195
|
+
self.show_status("Plot SVG copied to clipboard.", 3000)
|
|
1196
|
+
return True
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _plot_svg_bytes(self) -> bytes:
|
|
1200
|
+
"""
|
|
1201
|
+
Renders the current plot area as SVG bytes.
|
|
1202
|
+
|
|
1203
|
+
"""
|
|
1204
|
+
widget = self.__dict__.get("widget")
|
|
1205
|
+
if widget is None or not hasattr(widget, "scene"):
|
|
1206
|
+
return b""
|
|
1207
|
+
|
|
1208
|
+
size = widget.size()
|
|
1209
|
+
if size.width() < 1 or size.height() < 1:
|
|
1210
|
+
return b""
|
|
1211
|
+
|
|
1212
|
+
scene = widget.scene()
|
|
1213
|
+
if scene is None:
|
|
1214
|
+
return b""
|
|
1215
|
+
|
|
1216
|
+
data = QtCore.QByteArray()
|
|
1217
|
+
buffer = QtCore.QBuffer(data)
|
|
1218
|
+
if not buffer.open(QtCore.QIODevice.OpenModeFlag.WriteOnly):
|
|
1219
|
+
return b""
|
|
1220
|
+
|
|
1221
|
+
# SVG Tiny omits ViewBox clipping; SVG 1.1 preserves zoomed traces
|
|
1222
|
+
# and heatmaps within their axes, as in the file export renderer.
|
|
1223
|
+
generator = QtSvg.QSvgGenerator(QtSvg.QSvgGenerator.SvgVersion.Svg11)
|
|
1224
|
+
generator.setOutputDevice(buffer)
|
|
1225
|
+
generator.setSize(size)
|
|
1226
|
+
generator.setViewBox(QtCore.QRect(0, 0, size.width(), size.height()))
|
|
1227
|
+
generator.setTitle("qPlot plot")
|
|
1228
|
+
generator.setDescription("Copied from qPlot")
|
|
1229
|
+
|
|
1230
|
+
painter = QtGui.QPainter(generator)
|
|
1231
|
+
try:
|
|
1232
|
+
scene.render(
|
|
1233
|
+
painter,
|
|
1234
|
+
QtCore.QRectF(0, 0, size.width(), size.height()),
|
|
1235
|
+
self._plot_svg_source_rect(widget),
|
|
1236
|
+
)
|
|
1237
|
+
finally:
|
|
1238
|
+
painter.end()
|
|
1239
|
+
buffer.close()
|
|
1240
|
+
|
|
1241
|
+
return bytes(data.data())
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _plot_svg_source_rect(self, widget: Any) -> QtCore.QRectF:
|
|
1245
|
+
"""
|
|
1246
|
+
Returns the scene rectangle currently visible in the plot widget.
|
|
1247
|
+
|
|
1248
|
+
"""
|
|
1249
|
+
viewport_transform = getattr(widget, "viewportTransform", None)
|
|
1250
|
+
if viewport_transform is not None:
|
|
1251
|
+
return QtCore.QRectF(
|
|
1252
|
+
viewport_transform().inverted()[0].mapRect(widget.rect())
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
plot = self.__dict__.get("plot")
|
|
1256
|
+
if plot is not None:
|
|
1257
|
+
return QtCore.QRectF(plot.sceneBoundingRect())
|
|
1258
|
+
|
|
1259
|
+
return QtCore.QRectF(widget.scene().sceneRect())
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def copy_plot_image_at_dpi(self, dpi: float) -> bool:
|
|
1263
|
+
"""
|
|
1264
|
+
Renders the plot image at a target DPI and copies it to the clipboard.
|
|
1265
|
+
|
|
1266
|
+
"""
|
|
1267
|
+
try:
|
|
1268
|
+
size = self._plot_image_size_for_dpi(dpi)
|
|
1269
|
+
except Exception as err:
|
|
1270
|
+
log_exception("Plot image DPI sizing failed", err, __name__)
|
|
1271
|
+
self.show_status("Could not copy plot image at requested resolution.", 5000)
|
|
1272
|
+
return False
|
|
1273
|
+
|
|
1274
|
+
if not size.isValid() or size.width() < 1 or size.height() < 1:
|
|
1275
|
+
self.show_status("Could not copy plot image at requested resolution.", 5000)
|
|
1276
|
+
return False
|
|
1277
|
+
|
|
1278
|
+
if not self.copy_plot_image_at_size(size.width(), size.height()):
|
|
1279
|
+
return False
|
|
1280
|
+
|
|
1281
|
+
clipboard = qtw.QApplication.clipboard()
|
|
1282
|
+
if clipboard is not None:
|
|
1283
|
+
image = clipboard.image()
|
|
1284
|
+
if not image.isNull():
|
|
1285
|
+
self._set_image_dpi(image, dpi)
|
|
1286
|
+
clipboard.setImage(image)
|
|
1287
|
+
|
|
1288
|
+
self.show_status(
|
|
1289
|
+
"Plot image copied to clipboard at "
|
|
1290
|
+
f"{dpi:g} dpi ({size.width()} x {size.height()} px).",
|
|
1291
|
+
3000,
|
|
1292
|
+
)
|
|
1293
|
+
return True
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def copy_plot_image_at_size(self, width: int, height: int) -> bool:
|
|
1297
|
+
"""
|
|
1298
|
+
Renders the plot image at a chosen pixel size and copies it to the clipboard.
|
|
1299
|
+
|
|
1300
|
+
"""
|
|
1301
|
+
size = QtCore.QSize(int(width), int(height))
|
|
1302
|
+
if size.width() < 1 or size.height() < 1:
|
|
1303
|
+
self.show_status("Plot image size must be at least 1 x 1 px.", 5000)
|
|
1304
|
+
return False
|
|
1305
|
+
if (
|
|
1306
|
+
size.width() > _MAX_EXPORTED_IMAGE_SIZE
|
|
1307
|
+
or size.height() > _MAX_EXPORTED_IMAGE_SIZE
|
|
1308
|
+
):
|
|
1309
|
+
self.show_status(
|
|
1310
|
+
"Plot image size must be no larger than "
|
|
1311
|
+
f"{_MAX_EXPORTED_IMAGE_SIZE} x {_MAX_EXPORTED_IMAGE_SIZE} px.",
|
|
1312
|
+
5000,
|
|
1313
|
+
)
|
|
1314
|
+
return False
|
|
1315
|
+
|
|
1316
|
+
clipboard = qtw.QApplication.clipboard()
|
|
1317
|
+
if clipboard is None:
|
|
1318
|
+
self.show_status("No clipboard available.", 5000)
|
|
1319
|
+
return False
|
|
1320
|
+
|
|
1321
|
+
try:
|
|
1322
|
+
image = self._plot_image_at_size(size)
|
|
1323
|
+
except Exception as err:
|
|
1324
|
+
log_exception("Plot image copy at size failed", err, __name__)
|
|
1325
|
+
self.show_status("Could not copy plot image at requested size.", 5000)
|
|
1326
|
+
return False
|
|
1327
|
+
|
|
1328
|
+
if image.isNull():
|
|
1329
|
+
self.show_status("Could not copy plot image at requested size.", 5000)
|
|
1330
|
+
return False
|
|
1331
|
+
|
|
1332
|
+
clipboard.setImage(image)
|
|
1333
|
+
self.show_status("Plot image copied to clipboard.", 3000)
|
|
1334
|
+
return True
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _plot_image_at_size(self, size: QtCore.QSize) -> QtGui.QImage:
|
|
1338
|
+
"""
|
|
1339
|
+
Renders the current plot area into a QImage of the requested size.
|
|
1340
|
+
|
|
1341
|
+
"""
|
|
1342
|
+
exporter = self._plot_image_exporter()
|
|
1343
|
+
if exporter is None:
|
|
1344
|
+
return QtGui.QImage()
|
|
1345
|
+
|
|
1346
|
+
return self._export_plot_image(exporter, size)
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def _plot_image_exporter(self) -> ImageExporter | None:
|
|
1350
|
+
"""
|
|
1351
|
+
Returns an image exporter for the visible plot area.
|
|
1352
|
+
|
|
1353
|
+
"""
|
|
1354
|
+
item = self._plot_image_export_item()
|
|
1355
|
+
if item is None:
|
|
1356
|
+
return None
|
|
1357
|
+
return ImageExporter(item)
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _plot_image_export_item(self) -> Any | None:
|
|
1361
|
+
"""
|
|
1362
|
+
Returns the graphics object used for high-resolution image exports.
|
|
1363
|
+
|
|
1364
|
+
"""
|
|
1365
|
+
widget = self.__dict__.get("widget")
|
|
1366
|
+
if widget is not None and hasattr(widget, "scene"):
|
|
1367
|
+
scene = widget.scene()
|
|
1368
|
+
if scene is not None:
|
|
1369
|
+
return scene
|
|
1370
|
+
|
|
1371
|
+
return self.__dict__.get("plot")
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _export_plot_image(self, exporter: ImageExporter, size: QtCore.QSize) -> QtGui.QImage:
|
|
1375
|
+
"""
|
|
1376
|
+
Renders an exporter into a QImage of the requested size.
|
|
1377
|
+
|
|
1378
|
+
"""
|
|
1379
|
+
params = exporter.parameters()
|
|
1380
|
+
params.param("width").setValue(
|
|
1381
|
+
size.width(),
|
|
1382
|
+
blockSignal=exporter.widthChanged,
|
|
1383
|
+
)
|
|
1384
|
+
params.param("height").setValue(
|
|
1385
|
+
size.height(),
|
|
1386
|
+
blockSignal=exporter.heightChanged,
|
|
1387
|
+
)
|
|
1388
|
+
return exporter.export(toBytes=True)
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _plot_image_size_for_dpi(self, dpi: float) -> QtCore.QSize:
|
|
1392
|
+
"""
|
|
1393
|
+
Returns the output pixel size needed to copy the plot at ``dpi``.
|
|
1394
|
+
|
|
1395
|
+
"""
|
|
1396
|
+
exporter = self._plot_image_exporter()
|
|
1397
|
+
if exporter is None:
|
|
1398
|
+
return QtCore.QSize()
|
|
1399
|
+
|
|
1400
|
+
params = exporter.parameters()
|
|
1401
|
+
base_width = int(params["width"])
|
|
1402
|
+
base_height = int(params["height"])
|
|
1403
|
+
width = round(base_width * float(dpi) / self._plot_image_source_dpi("x"))
|
|
1404
|
+
height = round(base_height * float(dpi) / self._plot_image_source_dpi("y"))
|
|
1405
|
+
|
|
1406
|
+
return QtCore.QSize(
|
|
1407
|
+
min(_MAX_EXPORTED_IMAGE_SIZE, max(1, width)),
|
|
1408
|
+
min(_MAX_EXPORTED_IMAGE_SIZE, max(1, height)),
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
def _plot_image_source_dpi(self, axis: _DpiAxis) -> float:
|
|
1413
|
+
"""
|
|
1414
|
+
Returns the logical screen DPI used to convert screen pixels to inches.
|
|
1415
|
+
|
|
1416
|
+
"""
|
|
1417
|
+
widget = self.__dict__.get("widget")
|
|
1418
|
+
method_name = "logicalDpiX" if axis == "x" else "logicalDpiY"
|
|
1419
|
+
method = getattr(widget, method_name, None)
|
|
1420
|
+
if method is not None:
|
|
1421
|
+
dpi = float(method())
|
|
1422
|
+
if dpi > 0:
|
|
1423
|
+
return dpi
|
|
1424
|
+
|
|
1425
|
+
screen = qtw.QApplication.primaryScreen()
|
|
1426
|
+
if screen is not None:
|
|
1427
|
+
dpi = (
|
|
1428
|
+
screen.logicalDotsPerInchX()
|
|
1429
|
+
if axis == "x"
|
|
1430
|
+
else screen.logicalDotsPerInchY()
|
|
1431
|
+
)
|
|
1432
|
+
if dpi > 0:
|
|
1433
|
+
return float(dpi)
|
|
1434
|
+
|
|
1435
|
+
return 96.0
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _set_image_dpi(self, image: QtGui.QImage, dpi: float) -> None:
|
|
1439
|
+
"""
|
|
1440
|
+
Stores DPI metadata on a rendered QImage.
|
|
1441
|
+
|
|
1442
|
+
"""
|
|
1443
|
+
dots_per_meter = round(float(dpi) * _INCHES_PER_METER)
|
|
1444
|
+
image.setDotsPerMeterX(dots_per_meter)
|
|
1445
|
+
image.setDotsPerMeterY(dots_per_meter)
|