qplotter 1.6.0b3__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.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.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)