PyQt5 Plotly Viewer保存PNG时弹出多个文件对话框问题
问题:多Plotly窗口保存时弹出多个保存对话框
基于PyQt5的QWebEngineView开发了一款轻量浏览器,用于查看Plotly生成的.html文件。当打开多个图表窗口后,尝试将其中一个图表保存为.png时,会弹出多个保存文件对话框(数量等于程序启动后打开过的窗口数)。虽然最终仅能保存一个图表,但多余的对话框严重影响使用体验。
复现步骤:
- 运行下方代码
- 点击「plot」按钮2次及以上,创建多个图表窗口
- 任意打开一个窗口,使用Plotly自带的「download plot as png」选项保存图表,会出现多个保存对话框
原代码:
import os import sys from pathlib import Path import plotly import plotly.express as px from PyQt5 import QtCore, QtWidgets from PyQt5 import QtWebEngineWidgets, QtGui user_profile = Path(os.environ.get("USERPROFILE")) APP_DATA_FOLDER = user_profile / "AppData" / "Local" / "program" APP_DATA_FOLDER.mkdir(parents=True, exist_ok=True) class PlotlyViewer(QtWebEngineWidgets.QWebEngineView): """A lightweight browser used to view Plotly figures without relying on an external browser. """ def __init__( self, fig, title="Plot Viewer", count=0, download_directory=None ): super().__init__() self.windows = [] # Create a temporary html file containing the plot self.file_path = str(APP_DATA_FOLDER / f"temp{count}.html") plotly.offline.plot( fig, filename=self.file_path, auto_open=False, ) # Open the html file with the PlotlyViewer self.load(QtCore.QUrl.fromLocalFile(self.file_path)) self.setWindowTitle(title) self.resize(1000, 600) # When a downloadRequest is received, run the download_file method self.page().profile().downloadRequested.connect(self.download_file) def closeEvent(self, event): # When the plot is closed, delete the temporary file os.remove(self.file_path) @QtCore.pyqtSlot(QtWebEngineWidgets.QWebEngineDownloadItem) def download_file(self, download): # Get a save_file_dialog... For some reason this happens twice! plot_path, _ = QtWidgets.QFileDialog.getSaveFileName( None, "Save Plot As...", str(user_profile), "Image (*.png)" ) if plot_path: download.setPath(plot_path) download.accept() @staticmethod def save_file_dialog(export_dir): file_path, _ = QtWidgets.QFileDialog.getSaveFileName( None, "Save Plot As...", export_dir, "Image (*.png)" ) return file_path class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") MainWindow.setFixedSize(150, 100) MainWindow.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") self.btn_plot = QtWidgets.QPushButton(self.centralwidget) self.btn_plot.setGeometry(QtCore.QRect(0, 0, 70, 23)) self.btn_plot.setObjectName("btn_plot") self.btn_plot.setText("plot") self.connect_slots() def connect_slots(self): self.btn_plot.clicked.connect(self.create_plot) def create_plot(self): fig = px.scatter(x=[0, 1, 2, 3, 4], y=[0, 1, 4, 9, 16]) browser_title = "a window title" plot_window = PlotlyViewer(fig, browser_title, download_directory=user_profile) plot_window.windows.append(plot_window) plot_window.show() if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) MainWindow = QtWidgets.QMainWindow() ui = Ui_MainWindow() ui.setupUi(MainWindow) MainWindow.show() sys.exit(app.exec_())
解决方案
问题根源
QWebEngineProfile是全局共享实例,每个PlotlyViewer对象都调用self.page().profile().downloadRequested.connect(self.download_file),导致所有实例都会监听同一个下载请求信号。当触发一次下载时,所有已创建的PlotlyViewer实例都会执行download_file方法,弹出多个对话框。
修改后的代码
import os import sys from pathlib import Path import plotly import plotly.express as px from PyQt5 import QtCore, QtWidgets from PyQt5 import QtWebEngineWidgets, QtGui user_profile = Path(os.environ.get("USERPROFILE")) APP_DATA_FOLDER = user_profile / "AppData" / "Local" / "program" APP_DATA_FOLDER.mkdir(parents=True, exist_ok=True) class PlotlyViewer(QtWebEngineWidgets.QWebEngineView): """A lightweight browser used to view Plotly figures without relying on an external browser. """ def __init__( self, fig, title="Plot Viewer", count=0, download_directory=None ): # 为每个窗口创建独立的QWebEngineProfile,避免信号共享 self.profile = QtWebEngineWidgets.QWebEngineProfile(f"plot_profile_{count}", self) page = QtWebEngineWidgets.QWebEnginePage(self.profile, self) super().__init__(page) self.windows = [] # 创建包含图表的临时html文件 self.file_path = str(APP_DATA_FOLDER / f"temp{count}.html") plotly.offline.plot( fig, filename=self.file_path, auto_open=False, ) # 加载本地html文件 self.load(QtCore.QUrl.fromLocalFile(self.file_path)) self.setWindowTitle(title) self.resize(1000, 600) # 连接独立profile的下载请求信号 self.profile.downloadRequested.connect(self.download_file) def closeEvent(self, event): # 关闭窗口时删除临时文件 os.remove(self.file_path) # 清理profile资源 self.profile.deleteLater() super().closeEvent(event) @QtCore.pyqtSlot(QtWebEngineWidgets.QWebEngineDownloadItem) def download_file(self, download): # 绑定当前窗口为对话框父对象,确保对话框与窗口关联 plot_path, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Save Plot As...", str(user_profile), "Image (*.png)" ) if plot_path: download.setPath(plot_path) download.accept() @staticmethod def save_file_dialog(export_dir): file_path, _ = QtWidgets.QFileDialog.getSaveFileName( None, "Save Plot As...", export_dir, "Image (*.png)" ) return file_path class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") MainWindow.setFixedSize(150, 100) MainWindow.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setObjectName("centralwidget") self.btn_plot = QtWidgets.QPushButton(self.centralwidget) self.btn_plot.setGeometry(QtCore.QRect(0, 0, 70, 23)) self.btn_plot.setObjectName("btn_plot") self.btn_plot.setText("plot") self.connect_slots() # 添加窗口计数器,确保每个profile名称唯一 self.window_count = 0 def connect_slots(self): self.btn_plot.clicked.connect(self.create_plot) def create_plot(self): fig = px.scatter(x=[0, 1, 2, 3, 4], y=[0, 1, 4, 9, 16]) browser_title = "a window title" plot_window = PlotlyViewer(fig, browser_title, count=self.window_count, download_directory=user_profile) plot_window.windows.append(plot_window) plot_window.show() self.window_count += 1 if __name__ == "__main__": app = QtWidgets.QApplication(sys.argv) MainWindow = QtWidgets.QMainWindow() ui = Ui_MainWindow() ui.setupUi(MainWindow) MainWindow.show() sys.exit(app.exec_())
关键改动点
- 为每个
PlotlyViewer实例创建独立的QWebEngineProfile,避免多个窗口共享同一个profile的下载信号 - 将保存对话框的父对象设置为当前
PlotlyViewer窗口,确保对话框与窗口关联,不会出现游离状态 - 添加窗口计数器,确保每个profile的名称唯一,避免资源冲突
- 窗口关闭时调用
profile.deleteLater()清理资源,防止内存泄漏
内容的提问来源于stack exchange,提问作者lamdoug
相关产品推荐
相关产品推荐

