Source code for spacr.qt.widgets.save_figure_dialog
"""Preview and save a figure styled for the file rather than for the screen.
The figure displayed in spaCR is never changed. A Matplotlib figure is copied
before it is styled; a pyqtgraph plot cannot be copied safely, so it is styled
only for the length of one offscreen render and put back afterwards. Figures
that can be neither copied nor rendered still use the ordinary save path
without a styled preview.
The dialog offers what belongs to the FILE -- its background, the colour and
width of its lines, the colour of its text, the shape of its page, and what
kind of file it is. Everything else a figure can be told belongs to the PLOT
and lives on the plot's own right-click menu, where it reaches the screen and
every export at once; a value inherited from there is shown here rather than
offered a second time. Matplotlib figures have no such plot menu, so their
dialog also offers an export-only text scale to keep type proportional when
the output page is resized.
"""
import logging
from typing import Optional
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import (QComboBox, QDialog, QDialogButtonBox,
QDoubleSpinBox, QFileDialog, QFormLayout,
QHBoxLayout, QLabel, QPushButton,
QSpinBox, QVBoxLayout, QWidget)
LOG = logging.getLogger("spacr.qt.save_figure")
_integrity_notices = set()
def _show_integrity_notice(parent, report, chosen: str, sidecar):
"""Present saved-output findings without a modal wait or another write.
Open notices are held in a module-level set because a notice outlives the
save dialog, including a parentless dialog run with exec(); it is released
when closed or when its owning window is destroyed.
"""
from pathlib import Path
from PySide6.QtCore import Qt, QUrl
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import QPlainTextEdit
from ..i18n import tr
warnings = [finding["message"]
for finding in report.get("integrity", {}).get("findings", [])
if finding.get("severity") == "warning"]
if not warnings and sidecar:
return None
notice = QDialog(parent)
notice.setObjectName("FigureIntegrityNotice")
notice.setWindowTitle(tr("Figure integrity"))
notice.setWindowModality(Qt.WindowModality.NonModal)
notice.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
layout = QVBoxLayout(notice)
summary = QLabel(tr("Figure saved. Review these warnings before sharing it."))
summary.setWordWrap(True)
layout.addWidget(summary)
details = QPlainTextEdit()
details.setObjectName("FigureIntegrityFindings")
details.setReadOnly(True)
paths = [tr("Saved figure: {path}").format(path=str(Path(chosen).resolve()))]
if sidecar:
paths.append(tr("Provenance: {path}").format(path=str(Path(sidecar).resolve())))
else:
warnings.append(tr(
"The provenance sidecar could not be written. "
"Check folder permissions and save again."))
details.setPlainText("\n".join(paths) + "\n\n" + "\n\n".join(warnings))
layout.addWidget(details)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
folder = buttons.addButton(tr("Open output folder"), QDialogButtonBox.ButtonRole.ActionRole)
folder.setObjectName("FigureIntegrityOpenFolder")
destination = QUrl.fromLocalFile(str(Path(chosen).resolve().parent))
folder.clicked.connect(lambda: QDesktopServices.openUrl(destination))
buttons.rejected.connect(notice.reject)
layout.addWidget(buttons)
notice.resize(660, 380)
_integrity_notices.add(notice)
notice.finished.connect(lambda _result: _integrity_notices.discard(notice))
notice.destroyed.connect(lambda: _integrity_notices.discard(notice))
notice.show()
return notice
#: Why the page size is not editable here for a pyqtgraph plot.
_SIZE_REASON = ("set on the plot's right-click menu, under Canvas: it is one "
"page size, read by every export this plot makes")
#: Why a vector file has no resolution to set. PDF and SVG carry paths and
#: text rather than pixels, so the page size is the whole answer.
_RESOLUTION_REASON = ("PDF and SVG are true vector: the page size is the "
"whole answer and there is no resolution to set")
#: What the resolution does when the file IS pixels, in one sentence. The
#: resolution decides the count and the graph shape decides the proportion,
#: which is the order a journal asking for 300 dpi expects.
_PIXELS_REASON = ("pixels across = page width in inches × resolution; the "
"graph shape decides the height")
#: Said when the plot really has nothing on it. Reserved for that case: a
#: render that RAISED is a different sentence and gets one.
EMPTY_PLOT = ("This plot has nothing drawn on it yet, so there is nothing "
"to preview or to save.")
#: Said when the render raised. The figure already in the preview is left
#: where it is, so this says what it is rather than letting it be read as
#: the answer to the settings just chosen.
PREVIEW_FAILED = ("These settings could not be drawn, so nothing would be "
"written either. The figure shown is the last one that "
"could be drawn, not what these settings would give —")
#: The same, with no earlier figure to keep.
PREVIEW_FAILED_ALONE = ("These settings could not be drawn, so nothing "
"would be written either —")
#: Said when the write itself raised, rather than the preview.
SAVE_FAILED = "The file could not be written —"
def _with_reason(text: str, reason: str = "") -> str:
"""Compose a value or a name with the sentence that explains it.
One composer, so a label and the note beside it are punctuated the same
way and a reader meets one form rather than two.
"""
if not reason:
return text
return f"{text} — {reason}" if text else reason
def _reason_label(text: str, reason: str = ""):
"""Return a form label that displays why its control is disabled.
The reason appears directly in the label and as a tooltip, so it remains
available when a disabled widget does not receive hover events.
"""
from PySide6.QtWidgets import QLabel
if not reason:
return QLabel(text)
label = QLabel(_with_reason(text, reason))
label.setEnabled(False)
label.setToolTip(reason)
label.setWordWrap(True)
return label
def _quiet_note(text: str = "", reason: str = ""):
"""A dimmed note that carries a VALUE first and its explanation after.
The row's label names the setting and the control shows what it is set
to; this is where the sentence about where that value comes from goes,
so the number is what a reader about to save sees first. Dimmed rather
than hidden, because the explanation is worth reading once and is not
worth competing with the number for attention every time after that.
"""
label = _reason_label(text, reason)
label.setEnabled(False)
label.setWordWrap(True)
return label
#: What the file can be written as.
FORMATS = (("png", "PNG image"), ("pdf", "PDF document"),
("svg", "SVG image"), ("tiff", "TIFF image"))
#: What a pyqtgraph plot can be written as. TIFF is absent because
#: `FastPlot.export` routes anything that is not .pdf or .svg through
#: pyqtgraph's ImageExporter, which writes PNG -- offering TIFF would produce
#: a file whose name and contents disagreed.
FAST_PLOT_FORMATS = (("png", "PNG image"), ("pdf", "PDF document"),
("svg", "SVG image"))
#: The formats that hold paths rather than pixels, and so have no resolution.
VECTOR_FORMATS = ("pdf", "svg")
#: Ink for the TEXT in the file: the title, the axis labels, the tick
#: numbers and the legend. "As drawn" is first because it is what the
#: previous behaviour did, and a dialog that silently changed the default
#: would restyle every save a user made out of habit.
INKS = (("", "as drawn"),
("#231F20", "black — for paper"),
("#FFFFFF", "white — for a dark slide"))
#: Ink for the LINES: the plotted curves, the reference lines, the axis
#: spines and the tick marks. The same three answers as the text, because a
#: figure going onto paper wants both and asking for them in two different
#: vocabularies is how one of them gets forgotten.
LINE_INKS = (("", "as drawn"),
("#231F20", "black — for paper"),
("#FFFFFF", "white — for a dark slide"))
#: The page behind everything.
BACKGROUNDS = (("", "transparent"), ("#FFFFFF", "white"), ("#000000", "black"))
#: Marks the entry that opens a colour chooser rather than being a colour.
#: Not a valid colour string, so a stale selection cannot be mistaken for one.
_CHOOSE = "\0choose"
#: What that entry is called.
_CHOOSE_LABEL = "choose a colour…"
[docs]
def is_fast_plot(figure) -> bool:
"""Return whether a plot supports spaCR's fast styled-export protocol.
Parameters
----------
figure : object
Candidate Matplotlib or pyqtgraph figure.
Returns
-------
bool
``True`` when the object provides callable ``styled_snapshot`` and
``export_styled`` methods.
Notes
-----
Capability detection allows new fast-plot classes to support this dialog
without requiring a class registry.
"""
return (figure is not None
and callable(getattr(figure, "styled_snapshot", None))
and callable(getattr(figure, "export_styled", None)))
[docs]
def copy_figure(figure):
"""Create a detached figure for export preview.
Parameters
----------
figure : object
Figure to copy through Python's pickle protocol.
Returns
-------
object or None
Independent copy of ``figure``. ``None`` is returned when the input is
absent or contains state that cannot be serialized, such as a live
canvas or closure.
Notes
-----
The serialization round trip matches the one used by the figure queue.
Returning ``None`` lets callers fall back to an ordinary save without
altering the on-screen figure.
"""
import io
import pickle
if figure is None:
return None
try:
buffer = io.BytesIO()
pickle.dump(figure, buffer)
buffer.seek(0)
return pickle.load(buffer)
except Exception: # noqa: BLE001
LOG.debug("figure could not be copied for preview", exc_info=True)
return None
[docs]
def style_for_file(figure, *, ink: str = "", background: str = "",
grid: Optional[bool] = None, width: float = 0.0,
height: float = 0.0, dpi: int = 0,
font_scale: float = 0.0, text_colour: str = "",
line_colour: str = ""):
"""Apply export-only styling to a Matplotlib figure.
Parameters
----------
figure : matplotlib.figure.Figure or None
Figure copy to modify. ``None`` is accepted for preview fallbacks.
ink : str, optional
Color applied to titles, labels, ticks, spines, legends, and text --
text and lines together, which is what a paper-or-slide preset
means. An empty string preserves the existing colors.
text_colour : str, optional
Color for TEXT only: the title, the axis labels, the tick numbers
and the legend. Overrides ``ink`` for that half.
line_colour : str, optional
Color for LINES only: the spines and the tick marks. Overrides
``ink`` for that half.
background : str, optional
Figure and axes background color. An empty string makes both
backgrounds transparent.
grid : bool or None, default None
Draw major grid lines when ``True`` and disable them when ``False``.
``None`` leaves the figure's own grid alone, which is what a caller
with no grid control of its own wants: a default of ``False`` turns
off a grid the figure was drawn with and nobody asked about.
width, height : float, default 0
Output dimensions in inches. The size changes only when both values
are positive.
dpi : int, default 0
Output resolution. Zero preserves the figure's current resolution.
font_scale : float, default 0
Multiplier applied to every text artist. Values at or below zero
preserve the current text sizes.
Returns
-------
matplotlib.figure.Figure or None
The same figure object after styling, or ``None`` when ``figure`` was
``None``.
Notes
-----
This function is intended for detached export copies. It does not read or
update live figure preferences.
"""
if figure is None:
return None
if width and height:
figure.set_size_inches(float(width), float(height))
if dpi:
figure.set_dpi(int(dpi))
if font_scale and font_scale > 0:
for text in figure.findobj(match=lambda o: hasattr(o, "get_fontsize")):
try:
text.set_fontsize(text.get_fontsize() * float(font_scale))
except Exception: # noqa: BLE001
continue
if background:
figure.patch.set_facecolor(background)
figure.patch.set_alpha(1.0)
else:
figure.patch.set_alpha(0.0)
text_ink = text_colour or ink
line_ink = line_colour or ink
for axes in figure.axes:
if background:
axes.set_facecolor(background)
else:
axes.patch.set_alpha(0.0)
if text_ink:
axes.title.set_color(text_ink)
axes.xaxis.label.set_color(text_ink)
axes.yaxis.label.set_color(text_ink)
axes.tick_params(labelcolor=text_ink, which="both")
legend = axes.get_legend()
if legend is not None:
for text in legend.get_texts():
text.set_color(text_ink)
for text in axes.texts:
text.set_color(text_ink)
if line_ink:
axes.tick_params(color=line_ink, which="both")
for spine in axes.spines.values():
spine.set_edgecolor(line_ink)
if grid:
axes.grid(True, which="major", linewidth=0.4, alpha=0.35)
elif grid is not None:
axes.grid(False)
return figure
[docs]
class SaveFigureDialog(QDialog):
"""Preview and save an independently styled figure copy.
Parameters
----------
figure : matplotlib.figure.Figure or fast plot
Source figure. Matplotlib figures are copied before preview styling;
fast plots provide their own styled snapshot and export methods.
parent : QWidget, optional
Parent widget for the modal dialog.
Notes
-----
Export settings never modify the source displayed in the application.
WHAT IT OFFERS, AND WHY IT IS SHORT. For a fast plot, four setting groups
change how the FILE looks -- the background, the lines' colour and width,
the text colour, and the shape of the page -- and three more say what the
file IS: its format, resolution and page size. Everything else belongs on
the plot's own right-click menu, where it applies to the screen and every
export at once. A Matplotlib figure has no equivalent live menu, so its
dialog also offers an export-only text scale.
A SETTING INHERITED FROM THE PLOT IS SHOWN, NOT EXPLAINED. The page size
a pyqtgraph plot writes onto is set on that menu; this dialog displays
the size it will get and keeps the sentence about where it comes from as
a quieter note beside the number.
"""
#: How wide the preview is rendered, in pixels.
PREVIEW_PX = 760
def __init__(self, figure, parent: Optional[QWidget] = None):
"""Build the save dialog: the four style choices, the shape and the page.
Whether the figure is a fast plot is decided once here, because the two
kinds are styled and written through different methods and every branch
below reads the flag rather than re-sniffing the object.
The text-scale control is offered only for a matplotlib figure: reducing
a wide on-screen figure to a journal column leaves its labels full size
and crowding the axes, and a fast plot already owns one font-size
setting on its own menu -- a second answer here would contradict it.
:param figure: the figure to save.
:param parent: parent widget, or ``None``.
"""
super().__init__(parent)
self.setWindowTitle("Save figure")
self._source = figure
self._preview = None
self._canvas = None
#: A pyqtgraph plot is styled and written through its own two methods
#: rather than through matplotlib's. Decided once, here, so every
#: branch below reads a flag instead of re-sniffing the object.
self._fast = is_fast_plot(figure)
#: Guards the colour combos while a cancelled chooser is put back.
self._picking = False
#: Height over width of the figure as it was handed in, for a
#: Matplotlib figure whose page this dialog reshapes directly.
self._drawn_ratio: Optional[float] = None
layout = QVBoxLayout(self)
form = QFormLayout()
self.background = self._colour_box(
BACKGROUNDS,
"The page behind the figure. Transparent takes whatever the "
"figure is placed on, which is what a slide usually wants.")
form.addRow("background colour", self.background)
self.line_colour = self._colour_box(
LINE_INKS,
"Every line in the file: the plotted curves, the reference "
"lines, the axis spines and the tick marks. The numbers beside "
"the ticks are text and follow the text colour.")
form.addRow("line colour", self.line_colour)
self.line_width = QDoubleSpinBox()
self.line_width.setRange(0.0, 20.0)
self.line_width.setDecimals(1)
self.line_width.setSingleStep(0.5)
self.line_width.setSpecialValueText("as drawn")
self.line_width.setToolTip(
"Pen width in pixels for every line. A line that reads well on "
"screen is often too thin once the figure is a column wide.")
self.line_width.valueChanged.connect(self.refresh)
form.addRow("line width", self.line_width)
self.ink = self._colour_box(
INKS,
"Every piece of text in the file: the title, the axis labels, "
"the numbers beside the ticks and the legend.")
form.addRow("text colour", self.ink)
if not self._fast:
self.font_scale = QDoubleSpinBox()
self.font_scale.setRange(0.25, 4.0)
self.font_scale.setSingleStep(0.05)
self.font_scale.setDecimals(2)
self.font_scale.setValue(1.0)
self.font_scale.setToolTip(
"Scale labels, tick text, legends, and titles in the saved "
"file. Use a value below 1 when reducing the page size; 1 "
"keeps the current text size.")
self.font_scale.valueChanged.connect(self.refresh)
form.addRow("text scale", self.font_scale)
try:
from .fast_plots import CANVAS_SHAPE_LABELS, CANVAS_SHAPES
except Exception: # noqa: BLE001
CANVAS_SHAPES, CANVAS_SHAPE_LABELS = (), {}
self._shape_ratios = {name: ratio for name, ratio in CANVAS_SHAPES}
self.graph_shape = QComboBox()
self.graph_shape.addItem("as drawn", "")
for name, _ratio in CANVAS_SHAPES:
if name == "free":
continue
self.graph_shape.addItem(CANVAS_SHAPE_LABELS[name], name)
self.graph_shape.setToolTip(
"The proportions of the saved figure. 'as drawn' keeps what is "
"on screen. This is the shape of the PAGE; the plot's own menu "
"has the axis lock, which is a statement about the data.")
self.graph_shape.currentIndexChanged.connect(self._shape_changed)
form.addRow("graph shape", self.graph_shape)
self.format = QComboBox()
for value, label in (FAST_PLOT_FORMATS if self._fast else FORMATS):
self.format.addItem(label, value)
self.format.currentIndexChanged.connect(self._format_changed)
form.addRow("format", self.format)
self.dpi = QSpinBox()
self.dpi.setRange(72, 1200)
self.dpi.setValue(300)
self.dpi.valueChanged.connect(self._page_changed)
self._resolution_note = _quiet_note()
resolution = QHBoxLayout()
resolution.addWidget(self.dpi)
resolution.addWidget(self._resolution_note, 1)
form.addRow("resolution", resolution)
size = QHBoxLayout()
self.width = QDoubleSpinBox()
self.width.setRange(1.0, 40.0)
self.width.setSuffix(" in")
self.height = QDoubleSpinBox()
self.height.setRange(1.0, 40.0)
self.height.setSuffix(" in")
if self._fast:
for box in (self.width, self.height):
box.setEnabled(False)
box.setToolTip(_SIZE_REASON)
elif figure is not None:
w, h = figure.get_size_inches()
self.width.setValue(float(w))
self.height.setValue(float(h))
#: The proportion the figure was drawn at, so "as drawn" has
#: something to go back to after a shape has been chosen.
self._drawn_ratio = float(h) / float(w) if w else None
self.width.valueChanged.connect(self.refresh)
self.height.valueChanged.connect(self.refresh)
self._size_note = _quiet_note()
size.addWidget(self.width)
size.addWidget(QLabel("×"))
size.addWidget(self.height)
size.addWidget(self._size_note, 1)
form.addRow("size", size)
layout.addLayout(form)
self._page_notes = (self._resolution_note, self._size_note)
for page_note in self._page_notes:
page_note.installEventFilter(self)
self._holder = QVBoxLayout()
layout.addLayout(self._holder, 1)
self._trouble = QLabel()
self._trouble.setWordWrap(True)
self._trouble.setVisible(False)
layout.addWidget(self._trouble)
note = QLabel("The figure on screen is not changed — these settings "
"apply to the file only.")
note.setWordWrap(True)
layout.addWidget(note)
buttons = QDialogButtonBox(QDialogButtonBox.Cancel)
self._save = QPushButton("Save…")
self._save.clicked.connect(self.save)
buttons.addButton(self._save, QDialogButtonBox.AcceptRole)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self.resize(760, 760)
self._format_changed()
[docs]
def eventFilter(self, watched, event) -> bool:
"""Reserve the actual wrapped height of the page explanation labels.
Parameters
----------
watched : PySide6.QtCore.QObject
Label receiving a width, font or style change.
event : PySide6.QtCore.QEvent
Qt notification forwarded unchanged to the parent implementation.
Returns
-------
bool
Parent event-filter result; resizing a note never consumes input.
"""
if watched in self._page_notes and event.type() in (
QEvent.Resize, QEvent.FontChange, QEvent.StyleChange):
self._fit_page_notes()
return super().eventFilter(watched, event)
def _fit_page_notes(self) -> None:
"""Fit both raster/vector explanations to their current text and width."""
for note in self._page_notes:
height = max(0, note.heightForWidth(note.width())) if note.text() else 0
if note.minimumHeight() != height:
note.setMinimumHeight(height)
def _colour_box(self, choices, tooltip: str) -> QComboBox:
"""A colour combo: the presets, then a chooser for anything else.
The presets are the two answers a figure leaving the application
usually needs -- black on paper, white on a dark slide -- and the
chooser is there so the dialog is not weaker than the plot's own
menu, which has had a full picker all along.
"""
box = QComboBox()
for value, label in choices:
box.addItem(label, value)
box.addItem(_CHOOSE_LABEL, _CHOOSE)
box.setToolTip(tooltip)
box.currentIndexChanged.connect(
lambda _index, which=box: self._resolve_choice(which))
box.currentIndexChanged.connect(self.refresh)
return box
def _resolve_choice(self, box: QComboBox) -> None:
"""Turn a "choose a colour…" selection into a colour, or undo it."""
if self._picking or box.currentData() != _CHOOSE:
return
from .colour_picker import pick_colour
self._picking = True
try:
chosen = pick_colour(self, "#FFFFFF", "Colour")
index = 0
if chosen.isValid():
name = chosen.name()
index = box.findData(name)
if index < 0:
lowered = name.lower()
for position in range(box.count()):
data = box.itemData(position)
if isinstance(data, str) and data.lower() == lowered:
index = position
break
if index < 0:
index = box.count() - 1
box.insertItem(index, name, name)
box.setCurrentIndex(index)
finally:
self._picking = False
@staticmethod
def _colour_of(box: QComboBox) -> str:
"""The colour a combo holds, or an empty string for "as drawn"."""
value = box.currentData()
return "" if not value or value == _CHOOSE else str(value)
def _format_changed(self, *_args) -> None:
"""Light the resolution for a raster format and grey it for vector.
THE FORMAT DECIDES, NOT THE KIND OF PLOT. Vector output has no
resolution to set; a PNG's resolution is the number that decides how
big the file really is, and that is true of a pyqtgraph plot exactly
as it is of a Matplotlib one.
"""
vector = self._suffix() in VECTOR_FORMATS
self.dpi.setEnabled(not vector)
self.dpi.setToolTip(
_RESOLUTION_REASON if vector else
"Dots per inch in the written file. 300 is the usual journal "
"minimum; the screen never needs more than about 150.")
self._page_changed()
def _suffix(self) -> str:
"""Return the chosen file extension.
:returns: the format's suffix, defaulting to ``"png"``.
"""
return str(self.format.currentData() or "png")
def _shape_ratio(self) -> Optional[float]:
"""Height over width for the chosen shape, or None for "as drawn"."""
name = str(self.graph_shape.currentData() or "")
return self._shape_ratios.get(name) if name else None
def _page_mm(self) -> tuple:
"""``(width, height)`` of the page in millimetres, or ``(None, None)``.
Only a pyqtgraph plot has a page measured in millimetres. The graph
shape chosen here is applied to it, because that is the shape the
file is about to be written at and showing the unshaped height would
be showing a number the save will not use.
"""
if not self._fast:
return None, None
try:
width_mm, height_mm = self._source.export_size()
except Exception: # noqa: BLE001
LOG.debug("could not read the plot's page size", exc_info=True)
return None, None
ratio = self._shape_ratio()
if ratio is not None:
height_mm = float(width_mm) * float(ratio)
return float(width_mm), (None if height_mm is None
else float(height_mm))
def _shape_changed(self, *_args) -> None:
"""Put the chosen shape onto the page, then rebuild everything.
A pyqtgraph plot's page is millimetres and :meth:`_show_the_page`
recomputes it. A Matplotlib figure's page is the inches in the size
row, so the shape is applied by writing the height -- which also
keeps rule 3, because the number shown is then the number used.
"""
ratio = self._shape_ratio()
if not self._fast and self._drawn_ratio is not None:
proportion = self._drawn_ratio if ratio is None else float(ratio)
blocked = self.height.blockSignals(True)
try:
self.height.setValue(
round(float(self.width.value()) * proportion, 2))
finally:
self.height.blockSignals(blocked)
self._page_changed()
def _page_changed(self, *_args) -> None:
"""Re-read the page and the pixel count, then rebuild the preview."""
self._show_the_page()
self.refresh()
def _show_the_page(self) -> None:
"""Put the page size and the pixel count on the rows that show them.
RULE 3: the row shows the VALUE. "Set on the plot's right-click menu"
explains where a number lives and is no substitute for the number,
so the millimetres and the pixels are written out and the sentence
about where they come from is the quieter note beside them.
"""
width_mm, height_mm = self._page_mm()
if width_mm is not None:
for box, inches in ((self.width, width_mm / 25.4),
(self.height,
(height_mm if height_mm else width_mm) / 25.4)):
blocked = box.blockSignals(True)
try:
box.setValue(round(inches, 2))
finally:
box.blockSignals(blocked)
shown = (f"{width_mm:g} × {height_mm:g} mm" if height_mm
else f"{width_mm:g} mm wide, height follows the plot")
self._size_note.setText(_with_reason(shown, _SIZE_REASON))
self._size_note.setToolTip(_SIZE_REASON)
else:
self._size_note.setText("")
if self._suffix() in VECTOR_FORMATS:
self._resolution_note.setText(_RESOLUTION_REASON)
self._resolution_note.setToolTip(_RESOLUTION_REASON)
self._fit_page_notes()
return
pixels = self._raster_pixels()
if not pixels:
shown = ""
elif pixels[1] is None:
shown = f"{pixels[0]} pixels across, height follows the plot"
else:
shown = f"{pixels[0]} × {pixels[1]} pixels"
self._resolution_note.setText(_with_reason(shown, _PIXELS_REASON))
self._resolution_note.setToolTip(_PIXELS_REASON)
self._fit_page_notes()
def _raster_pixels(self) -> Optional[tuple]:
"""``(width, height)`` in pixels, with height None when it follows.
ONLY FOR A PLOT WHOSE PAGE IS A KNOWN SIZE. A Matplotlib figure is
written with ``bbox_inches="tight"``, which crops the page to the
ink -- so a count printed for one would be an upper bound presented
as a measurement, and a wrong number shown confidently is what rule
3 is trying to get away from.
The WIDTH is always known once the page is: it is the resolution
times the page width, and it is the number that says how big the
file will be. The height is known only once a shape has been chosen,
so an unshaped page says so rather than guessing one.
"""
width_mm, height_mm = self._page_mm()
if width_mm is None:
return None
width = max(1, int(round(width_mm / 25.4 * int(self.dpi.value()))))
ratio = self._shape_ratio()
if ratio is None and width_mm and height_mm:
ratio = float(height_mm) / float(width_mm)
if ratio is None:
return width, None
return width, max(1, int(round(width * float(ratio))))
[docs]
def preview(self):
"""Return the current detached preview.
Returns
-------
matplotlib.figure.Figure, QPixmap, or None
Matplotlib copy, fast-plot snapshot, or ``None`` when no preview
can be rendered.
"""
return self._preview
def _say(self, trouble: str) -> None:
"""Show a refusal, or clear the line when there is nothing to say."""
self._trouble.setText(trouble)
self._trouble.setToolTip(trouble)
self._trouble.setVisible(bool(trouble))
@staticmethod
def _why(exc: BaseException) -> str:
"""The reason a render or a write refused, in a readable line."""
reason = str(exc).strip()
return f"{type(exc).__name__}: {reason}" if reason else type(exc).__name__
[docs]
def refresh(self, *_args):
"""Rebuild and return the preview from the original figure.
Each refresh starts from a new copy so repeated text scaling or color
changes do not accumulate. Fast plots delegate to
:meth:`_refresh_fast_plot`.
"""
from .graph_builder import _canvas_class
if self._fast:
return self._refresh_fast_plot()
self._preview = style_for_file(
copy_figure(self._source),
text_colour=self._colour_of(self.ink),
line_colour=self._colour_of(self.line_colour),
background=self._colour_of(self.background),
width=float(self.width.value()), height=float(self.height.value()),
dpi=int(self.dpi.value()),
font_scale=float(self.font_scale.value()))
self._clear_holder()
if self._preview is None:
self._holder.addWidget(QLabel(
"This figure cannot be previewed, so it will be written "
"exactly as it appears on screen."))
self._canvas = None
return None
self._canvas = _canvas_class()(self._preview)
self._holder.addWidget(self._canvas)
return self._preview
def _extra_styling(self) -> dict:
"""The styling knobs that are only for the file, as keywords.
A control left at its special value is left OUT rather than passed
as zero: the render treats "no value" as "keep what the plot has",
and passing zero would mean "make it zero".
"""
out: dict = {}
shape = self.graph_shape.currentData()
if shape:
out["canvas_shape"] = str(shape)
if self.line_width.value() > 0:
out["line_width"] = float(self.line_width.value())
line_ink = self._colour_of(self.line_colour)
if line_ink:
out["line_colour"] = line_ink
text_ink = self._colour_of(self.ink)
if text_ink:
out["text_colour"] = text_ink
return out
def _for_the_file(self) -> dict:
"""The styling, plus what the FILE is: its page and its resolution.
The resolution is not styling -- it decides how many pixels the file
has, not how the figure looks -- so it is kept out of
:meth:`_extra_styling` and added here, where the render and the
write both read it. It is passed only for a format that has one:
vector output ignores a resolution, and sending one anyway would
make a PDF look as though it had been given a choice it cannot use.
"""
out = self._extra_styling()
if self.dpi.isEnabled():
out["dpi"] = int(self.dpi.value())
return out
def _refresh_fast_plot(self):
"""Build a fast-plot preview through its styled snapshot protocol.
The snapshot and file export use the same styling path, keeping the
preview consistent with the saved output.
A PREVIEW THAT FAILS SAYS WHY, and never calls a drawn plot empty.
"Nothing drawn" is one reason a preview can be absent; a render that
RAISED is another, and the two are not the same sentence. The
exception used to go to a debug log nobody reads and the dialog then
blamed the plot for being empty -- a full volcano reported as a blank
one, with the real reason discarded on the way.
"""
from PySide6.QtCore import Qt
pixmap, failure = None, ""
try:
pixmap = self._source.styled_snapshot(
self.PREVIEW_PX,
background=self._colour_of(self.background),
**self._for_the_file())
except Exception as exc: # noqa: BLE001
failure = self._why(exc)
LOG.debug("could not preview the plot", exc_info=True)
if pixmap is not None:
self._clear_holder()
label = QLabel()
label.setAlignment(Qt.AlignCenter)
label.setPixmap(pixmap)
self._holder.addWidget(label)
self._preview = pixmap
self._save.setEnabled(True)
self._say("")
return pixmap
self._save.setEnabled(False)
if failure:
self._say(f"{PREVIEW_FAILED} {failure}" if self._preview is not None
else f"{PREVIEW_FAILED_ALONE} {failure}")
return None
self._clear_holder()
self._preview = None
self._holder.addWidget(QLabel(EMPTY_PLOT))
self._say("")
return None
def _clear_holder(self) -> None:
"""Empty the preview holder, deleting whatever it held.
The widgets are deleted rather than only unparented: a preview is
rebuilt on every change, and orphans would accumulate for the life of
the dialog.
"""
while self._holder.count():
item = self._holder.takeAt(0)
widget = item.widget()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
[docs]
def save(self, path: str = "") -> str:
"""Write the figure using the current export settings.
Parameters
----------
path : str, optional
Destination path. When omitted, a file chooser is displayed.
Returns
-------
str
Written path, or an empty string when the chooser is cancelled or
no figure can be saved.
"""
chosen = str(path or "")
suffix = self._suffix()
if not chosen:
chosen, _filter = QFileDialog.getSaveFileName(
self, "Save figure", f"figure.{suffix}",
f"{dict(FORMATS).get(suffix, suffix)} (*.{suffix})")
if not chosen:
return ""
if self._fast:
try:
written = self._source.export_styled(
chosen,
background=self._colour_of(self.background),
**self._for_the_file())
except Exception as exc: # noqa: BLE001
LOG.debug("could not save the plot", exc_info=True)
self._say(f"{SAVE_FAILED} {self._why(exc)}")
return ""
if not written:
self._say(f"{SAVE_FAILED} the plot wrote no file.")
return ""
self.accept()
return str(written)
target = self._preview if self._preview is not None else self._source
if target is None:
self._say(f"{SAVE_FAILED} there is no figure to write.")
return ""
stamp, report = self._integrity_stamp(target, chosen, suffix)
try:
target.savefig(chosen, dpi=int(self.dpi.value()),
bbox_inches="tight",
facecolor=target.patch.get_facecolor(),
transparent=not self._colour_of(self.background),
**stamp)
except Exception as exc: # noqa: BLE001
LOG.debug("could not save the figure", exc_info=True)
self._say(f"{SAVE_FAILED} {self._why(exc)}")
return ""
sidecar = None
if report is not None:
try:
from ...plot import _finish_integrity
sidecar = _finish_integrity(report, chosen)
except Exception:
LOG.debug("could not write the provenance", exc_info=True)
self.accept()
if report is not None:
try:
_show_integrity_notice(self.parentWidget(), report, chosen, sidecar)
except Exception:
LOG.debug("could not present figure integrity findings", exc_info=True)
return chosen
def _integrity_stamp(self, figure, chosen: str, suffix: str):
"""Check ``figure`` and build its provenance stamp, when switched on.
The same check :func:`spacr.plot.save_figure` runs, so a figure
saved from this dialog is held to the rule a pipeline figure is.
Returns
-------
tuple
``(savefig keyword arguments, report)``; both empty when the
check is off, finds no image panel, or cannot run.
"""
import os
try:
from ...plot import (_figure_integrity_enabled,
_integrity_metadata, _integrity_report)
if not _figure_integrity_enabled():
return {}, None
written = (os.path.splitext(chosen)[1].lstrip(".").lower()
or suffix)
report = _integrity_report(figure, fmt=written,
dpi=int(self.dpi.value()),
destination=chosen)
if report is None:
return {}, None
stamp = _integrity_metadata(report, written)
return ({"metadata": stamp} if stamp else {}), report
except Exception:
LOG.debug("the figure integrity check could not run",
exc_info=True)
return {}, None