Source code for spacr.qt.widgets.picture_export

"""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 build_menu(parent, enabled: bool): """The right-click menu: save as PNG, save as PDF. Each action carries its suffix as its ``data``, so the caller reads the choice off the action rather than comparing it against two references it had to keep. :param parent: the widget that owns the menu. :param enabled: ``False`` when there is no picture yet. The actions are SHOWN AND GREYED rather than left out, and a line underneath says why: a menu that is empty on one field and full on the next reads as a bug, not as a state. """ from PySide6.QtWidgets import QMenu menu = QMenu(parent) png = menu.addAction("Save as PNG…") png.setData(".png") pdf = menu.addAction("Save as PDF…") pdf.setData(".pdf") for action in (png, pdf): action.setEnabled(bool(enabled)) if not enabled: menu.addSeparator() menu.addAction("There is no picture here yet").setEnabled(False) return menu
[docs] def choose_format(view, point, enabled: bool) -> str: """Pop the menu at ``point`` and return the chosen suffix, or ``""``. THE MODAL CALL IS ALONE IN HERE so everything around it can be driven by a test. ``QMenu.exec`` spins an event loop of its own, and a test that reached it would hang rather than fail; substituting this one function is how the rest of the gesture is checked at all. :param view: the widget the menu belongs to; ``point`` is mapped to global coordinates through it. :param point: where the right-click happened, in ``view`` coordinates. :param enabled: ``False`` greys out the save actions, as in :func:`build_menu`. """ menu = build_menu(view, enabled) chosen = menu.exec(view.mapToGlobal(point)) if chosen is None: return "" return str(chosen.data() or "")
[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