"""Save a rendered picture as a PNG or a one-page PDF.
Exports use the supplied ``QImage`` or ``QPixmap`` rather than capturing
the surrounding widget. PNG preserves the image's pixel dimensions and
records the selected resolution as dots-per-metre metadata. PDF embeds
the raster image on a page; it does not convert pixels into vector artwork.
PDF page dimensions are computed from the image's pixel dimensions and
export DPI, with a minimum of one millimetre for each dimension. The image
fills the page with zero margins. This sets the printed size without using
microscope pixel calibration.
Default resolution and filename suffix come from
:func:`spacr.qt.preferences.get_figure_png_dpi` and
:func:`spacr.qt.preferences.get_figure_format`. The right-click menu's
explicit PNG or PDF choice determines the final export format.
"""
from __future__ import annotations
import logging
import os
from typing import Callable, Optional
from PySide6.QtCore import QMarginsF, QRectF, QSizeF, Qt
from PySide6.QtGui import QImage, QPageLayout, QPageSize, QPainter, QPixmap
LOG = logging.getLogger(__name__)
#: Millimetres per inch. Named because the conversion appears three times
#: and a wrong one is a page that is silently the wrong size.
MM_PER_INCH = 25.4
#: What the save dialog offers, in the order it offers it. PNG first: it is
#: what a picture of pixels usually wants to be, and the PDF is there for
#: the person putting it in a figure.
FILTERS = "PNG image (*.png);;PDF document (*.pdf)"
#: Fallback resolution when the preference cannot be read. The same 300 the
#: Figures preference defaults to.
FALLBACK_DPI = 300
[docs]
def picture_dpi() -> int:
"""The resolution a saved picture is written at.
:returns: the Figures preference, or :data:`FALLBACK_DPI`.
"""
try:
from ..preferences import get_figure_png_dpi
dpi = int(get_figure_png_dpi())
except Exception: # noqa: BLE001
LOG.debug("could not read the figure DPI", exc_info=True)
return FALLBACK_DPI
return dpi if dpi > 0 else FALLBACK_DPI
[docs]
def preferred_suffix() -> str:
"""``".pdf"`` or ``".png"``, following the Figures format preference."""
try:
from ..preferences import get_figure_format
chosen = str(get_figure_format() or "").strip().lower()
except Exception: # noqa: BLE001
LOG.debug("could not read the figure format", exc_info=True)
return ".png"
return ".pdf" if chosen == "pdf" else ".png"
[docs]
def as_image(picture) -> Optional[QImage]:
"""Whatever was handed over, as a :class:`QImage`, or ``None``.
A caller may hold a ``QPixmap`` (what a view shows), a ``QImage`` (what
a renderer produced) or nothing at all yet.
:param picture: a ``QPixmap``, a ``QImage`` or ``None``; a null image or
any other type also gives ``None``.
"""
if picture is None:
return None
if isinstance(picture, QImage):
return picture if not picture.isNull() else None
if isinstance(picture, QPixmap):
if picture.isNull():
return None
return picture.toImage()
return None
[docs]
def save_picture(picture, path, dpi: Optional[int] = None) -> bool:
"""Write ``picture`` to ``path``, as a PNG or a PDF by its suffix.
:param picture: a ``QPixmap`` or ``QImage``.
:param path: where to write. ``.pdf`` writes a page; anything else
writes a PNG.
:param dpi: resolution; :func:`picture_dpi` when omitted.
:returns: whether a file was written.
"""
image = as_image(picture)
if image is None:
return False
resolution = int(dpi or picture_dpi())
if resolution <= 0:
resolution = FALLBACK_DPI
path = str(path)
fmt = "pdf" if path.lower().endswith(".pdf") else "png"
report = None
try:
flag = os.environ.get("SPACR_FIGURE_INTEGRITY", "").strip().lower()
if flag in ("1", "true", "yes", "on"):
enabled = True
elif flag in ("0", "false", "no", "off"):
enabled = False
else:
from ..preferences import _get_figure_integrity
enabled = bool(_get_figure_integrity())
if enabled:
import numpy as np
from matplotlib.figure import Figure
from ...plot import _PNG_PROVENANCE_KEY, _integrity_report
rgba = image.convertToFormat(QImage.Format.Format_RGBA8888)
pixels = np.frombuffer(bytes(rgba.constBits()), dtype=np.uint8)
pixels = pixels.reshape(rgba.height(), rgba.bytesPerLine())
pixels = pixels[:, :rgba.width() * 4].reshape(
rgba.height(), rgba.width(), 4).copy()
figure = Figure(figsize=(rgba.width() / resolution,
rgba.height() / resolution),
dpi=resolution)
axes = figure.add_axes([0, 0, 1, 1])
axes.imshow(pixels, interpolation="nearest")
axes.set_axis_off()
report = _integrity_report(figure, fmt=fmt, dpi=resolution,
destination=path)
if report is not None:
report["panels"][0]["capture"] = "rendered QImage"
report["panels"][0]["reproducible"] = False
if fmt == "png":
import json
image = QImage(image)
image.setText(_PNG_PROVENANCE_KEY, json.dumps(report))
except Exception:
LOG.debug("could not check the rendered picture", exc_info=True)
report = None
written = (_save_pdf(image, path, resolution) if fmt == "pdf"
else _save_png(image, path, resolution))
if written and report is not None:
try:
from ...plot import _finish_integrity
_finish_integrity(report, path)
except Exception:
LOG.debug("could not save picture provenance", exc_info=True)
return written
def _save_png(image: QImage, path: str, dpi: int) -> bool:
"""Write PNG pixels with horizontal and vertical resolution metadata.
A copy receives the requested DPI converted to integer dots per metre.
If setting the metadata fails, writing the pixel image is still attempted.
"""
stamped = QImage(image)
try:
per_metre = int(round(dpi / MM_PER_INCH * 1000.0))
stamped.setDotsPerMeterX(per_metre)
stamped.setDotsPerMeterY(per_metre)
except Exception: # noqa: BLE001
LOG.debug("could not stamp the resolution", exc_info=True)
try:
return bool(stamped.save(path, "PNG"))
except Exception: # noqa: BLE001
LOG.debug("could not write %s", path, exc_info=True)
return False
def _save_pdf(image: QImage, path: str, dpi: int) -> bool:
"""Draw a raster image across a one-page PDF at the selected DPI.
Width and height are computed from pixels divided by DPI, converted to
millimetres, and independently clamped to at least one millimetre. The
image is drawn over the resulting page with zero margins.
"""
from PySide6.QtGui import QPdfWriter
width_mm = max(1.0, image.width() / float(dpi) * MM_PER_INCH)
height_mm = max(1.0, image.height() / float(dpi) * MM_PER_INCH)
painter = None
try:
writer = QPdfWriter(path)
writer.setResolution(int(dpi))
writer.setPageSize(QPageSize(QSizeF(width_mm, height_mm),
QPageSize.Millimeter))
writer.setPageMargins(QMarginsF(0, 0, 0, 0), QPageLayout.Millimeter)
painter = QPainter(writer)
target = QRectF(0, 0, writer.width(), writer.height())
painter.drawImage(target, image, QRectF(image.rect()))
except Exception: # noqa: BLE001
LOG.debug("could not write %s", path, exc_info=True)
return False
finally:
if painter is not None:
try:
painter.end()
except Exception: # noqa: BLE001
LOG.debug("the PDF painter would not close", exc_info=True)
return os.path.isfile(path)
[docs]
def suggested_name(stem: str) -> str:
"""A file name for ``stem``, with the preferred suffix on it.
:param stem: the base name; whitespace runs become underscores, and an
empty result (or ``None``) becomes ``picture``.
"""
cleaned = "_".join(str(stem or "picture").split()).strip("_")
return f"{cleaned or 'picture'}{preferred_suffix()}"
[docs]
def ask_where_to_save(parent, stem: str) -> str:
"""Ask for an output path; return ``""`` when the dialog is cancelled.
If the chosen name has no suffix, append ``.pdf`` or ``.png`` according
to the selected file filter. An existing suffix is retained here;
:func:`save_as` subsequently enforces the format chosen in the menu.
:param parent: the widget the save dialog is parented to.
:param stem: the file name offered, without a suffix; cleaned by
:func:`suggested_name`.
"""
from PySide6.QtWidgets import QFileDialog
start = suggested_name(stem)
chosen, selected = QFileDialog.getSaveFileName(
parent, "Save picture", start, FILTERS)
if not chosen:
return ""
if not os.path.splitext(chosen)[1]:
chosen += ".pdf" if "pdf" in str(selected).lower() else ".png"
return chosen
[docs]
def save_as(view, picture, stem: str, want: str) -> str:
"""Ask for a path and write ``picture`` there. ``""`` if nothing was.
:param view: the widget the save dialog and any warning are parented to.
:param picture: the ``QPixmap`` or ``QImage`` to write.
:param stem: the file name offered, without a suffix.
:param want: the suffix the user picked in the menu. It WINS over what
the file dialog came back with, so "save as PDF" followed by a name
ending in ``.png`` still writes a PDF rather than quietly changing
format because of a typed extension.
:returns: the path written, or ``""``.
"""
from PySide6.QtWidgets import QMessageBox
path = ask_where_to_save(view, stem)
if not path:
return ""
if os.path.splitext(path)[1].lower() != want:
path = os.path.splitext(path)[0] + want
if save_picture(picture, path):
return path
QMessageBox.warning(view, "Not saved",
f"{os.path.basename(path)} could not be written.")
return ""
[docs]
def install_picture_save(view, picture: Callable[[], object],
stem: str = "picture",
unless: Optional[Callable[[], bool]] = None) -> bool:
"""Give ``view`` a right-click menu that saves what it is showing.
:param view: the widget the user right-clicks.
:param picture: called before the context menu opens; returns the
``QPixmap`` or ``QImage`` to write. The returned image is used after
the format and path dialogs; the callback is not called again
immediately before writing.
:param stem: the file name offered, without a suffix. A callable is
called after the format is selected and before the path dialog opens.
:param unless: asked first; ``True`` means the right button belongs to
something else at this moment and no menu is shown. The live
preview's ruler is cleared with a right-click while it is active,
and a menu appearing over that would take a tool away to add a file
dialog nobody asked for.
:returns: whether the menu was installed.
"""
def _menu(point) -> str:
"""Offer the save menu at ``point``; the path written, or ``""``.
:param point: where the right-click landed, in ``view`` coordinates.
"""
if unless is not None:
try:
if unless():
return ""
except Exception: # noqa: BLE001
LOG.debug("a picture menu guard raised", exc_info=True)
image = as_image(picture())
want = choose_format(view, point, image is not None)
if not want or image is None:
return ""
name = stem() if callable(stem) else stem
return save_as(view, image, name, want)
try:
view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
view.customContextMenuRequested.connect(_menu)
view._spacr_picture_menu = _menu
return True
except Exception: # noqa: BLE001
LOG.debug("could not install the picture menu", exc_info=True)
return False