240 lines
8.7 KiB
Python
240 lines
8.7 KiB
Python
"""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())
|