You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.16 19:35:21