"""Reusable full-image preview dialog for desktop image workflows.""" from __future__ import annotations import math import os from PySide6.QtCore import QSize, Qt, QTimer from PySide6.QtGui import QImageReader, QPixmap from PySide6.QtWidgets import ( QApplication, QDialog, QHBoxLayout, QLabel, QPushButton, QScrollArea, QToolButton, QVBoxLayout, ) class ImagePreviewDialog(QDialog): """Preview a local image with bounded zoom and fit-to-window behavior.""" MIN_ZOOM = 0.25 MAX_ZOOM = 4.0 ZOOM_STEP = 0.25 def __init__(self, path, title="图片预览", parent=None): super().__init__(parent) self._base_title = str(title or "图片预览") self._source = self._load_source(path) self.fit_to_window = True self.zoom_factor = 1.0 self._effective_zoom = 1.0 self._display_size = QSize() self._resize_timer = QTimer(self) self._resize_timer.setSingleShot(True) self._resize_timer.setInterval(50) self._resize_timer.timeout.connect(self._render) self.setWindowTitle(self._window_title()) layout = QVBoxLayout(self) self.scroll = QScrollArea() self.scroll.setObjectName("imagePreviewScrollArea") self.scroll.setWidgetResizable(False) self.scroll.setAlignment(Qt.AlignCenter) self.image_label = QLabel() self.image_label.setObjectName("imagePreviewLabel") self.image_label.setAlignment(Qt.AlignCenter) self.scroll.setWidget(self.image_label) layout.addWidget(self.scroll, 1) controls = QHBoxLayout() self.zoom_out_button = QToolButton() self.zoom_out_button.setObjectName("imagePreviewZoomOutButton") self.zoom_out_button.setText("-") self.zoom_out_button.setToolTip("缩小图片") self.zoom_out_button.setAccessibleName("缩小图片") self.zoom_out_button.setMinimumSize(32, 32) self.zoom_out_button.clicked.connect(self.zoom_out) controls.addWidget(self.zoom_out_button) self.zoom_label = QLabel("适应窗口") self.zoom_label.setObjectName("imagePreviewZoomLabel") self.zoom_label.setAlignment(Qt.AlignCenter) self.zoom_label.setMinimumWidth(72) controls.addWidget(self.zoom_label) self.zoom_in_button = QToolButton() self.zoom_in_button.setObjectName("imagePreviewZoomInButton") self.zoom_in_button.setText("+") self.zoom_in_button.setToolTip("放大图片") self.zoom_in_button.setAccessibleName("放大图片") self.zoom_in_button.setMinimumSize(32, 32) self.zoom_in_button.clicked.connect(self.zoom_in) controls.addWidget(self.zoom_in_button) self.fit_button = QPushButton("适应窗口") self.fit_button.setObjectName("imagePreviewFitButton") self.fit_button.setCheckable(True) self.fit_button.setChecked(True) self.fit_button.setToolTip("完整显示图片且不放大超过原始尺寸") self.fit_button.clicked.connect(self.show_fit) controls.addWidget(self.fit_button) self.actual_size_button = QPushButton("100%") self.actual_size_button.setObjectName("imagePreviewActualSizeButton") self.actual_size_button.setToolTip("按图片原始像素查看") self.actual_size_button.clicked.connect(self.show_actual_size) controls.addWidget(self.actual_size_button) controls.addStretch(1) close_button = QPushButton("关闭") close_button.clicked.connect(self.reject) controls.addWidget(close_button) layout.addLayout(controls) self._fit_to_screen() self._render() QTimer.singleShot(0, self._render) @staticmethod def _load_source(path): normalized = str(path or "") if not normalized or not os.path.isfile(normalized): return QPixmap() reader = QImageReader(normalized) reader.setAutoTransform(True) image = reader.read() return QPixmap.fromImage(image) if not image.isNull() else QPixmap() def _window_title(self): if self._source.isNull(): return self._base_title return "%s · %dx%d" % ( self._base_title, self._source.width(), self._source.height(), ) def resizeEvent(self, event): super().resizeEvent(event) if self.fit_to_window: self._resize_timer.start() def showEvent(self, event): super().showEvent(event) QTimer.singleShot(0, self._render) def show_fit(self, checked=False): self.fit_to_window = True self._display_size = QSize() self._render() def show_actual_size(self, checked=False): self.fit_to_window = False self.zoom_factor = 1.0 self._display_size = QSize() self._render() def zoom_in(self, checked=False): self._step_zoom(1) def zoom_out(self, checked=False): self._step_zoom(-1) def _step_zoom(self, direction): if self._source.isNull(): return current = self._effective_zoom if self.fit_to_window else self.zoom_factor if direction > 0: stepped = (math.floor(current / self.ZOOM_STEP + 1e-9) + 1) * self.ZOOM_STEP else: stepped = (math.ceil(current / self.ZOOM_STEP - 1e-9) - 1) * self.ZOOM_STEP self.fit_to_window = False self.zoom_factor = max(self.MIN_ZOOM, min(self.MAX_ZOOM, stepped)) self._display_size = QSize() self._render() def _fit_scale(self): viewport = self.scroll.viewport().size() if self._source.isNull() or viewport.width() <= 0 or viewport.height() <= 0: return 1.0 return min( 1.0, viewport.width() / self._source.width(), viewport.height() / self._source.height(), ) def _render(self): viewport = self.scroll.viewport().size() viewport_width = max(1, viewport.width()) viewport_height = max(1, viewport.height()) if self._source.isNull(): self.image_label.clear() self.image_label.setText("图片文件不存在或无法读取") self.image_label.resize(max(320, viewport_width), max(240, viewport_height)) self.zoom_label.setText("无法预览") self._set_controls_enabled(False) return if self.fit_to_window: scale = max(1e-6, min(1.0, self._fit_scale())) else: scale = max(self.MIN_ZOOM, min(self.MAX_ZOOM, self.zoom_factor)) self._effective_zoom = scale display_size = QSize( max(1, int(round(self._source.width() * scale))), max(1, int(round(self._source.height() * scale))), ) if display_size == self._source.size(): pixmap = self._source else: pixmap = self._source.scaled( display_size, Qt.KeepAspectRatio, Qt.SmoothTransformation, ) if display_size != self._display_size or self.image_label.pixmap() is None: self.image_label.setPixmap(pixmap) self.image_label.resize(pixmap.size()) self._display_size = QSize(pixmap.size()) self.zoom_label.setText( "适应窗口" if self.fit_to_window else "%d%%" % int(round(scale * 100)) ) self.fit_button.setChecked(self.fit_to_window) self._set_controls_enabled(True) def _set_controls_enabled(self, available): self.fit_button.setEnabled(available) self.actual_size_button.setEnabled(available) self.zoom_out_button.setEnabled( available and self._effective_zoom > self.MIN_ZOOM + 1e-9 ) self.zoom_in_button.setEnabled( available and self._effective_zoom < self.MAX_ZOOM - 1e-9 ) def _fit_to_screen(self): screen = self.parentWidget().screen() if self.parentWidget() is not None else None screen = screen or QApplication.primaryScreen() if screen is None: self.resize(720, 520) return available = screen.availableGeometry() max_width = max(1, int(available.width() * 0.9)) max_height = max(1, int(available.height() * 0.9)) min_width = min(560, max_width) min_height = min(420, max_height) source_width = self._source.width() if not self._source.isNull() else 640 source_height = self._source.height() if not self._source.isNull() else 480 self.resize( min(max_width, max(min_width, source_width + 48)), min(max_height, max(min_height, source_height + 104)), ) frame = self.frameGeometry() frame.moveCenter(available.center()) self.move(frame.topLeft())