diff --git a/DataPlotly/gui/plot_settings_widget.py b/DataPlotly/gui/plot_settings_widget.py index 8530c00b..e8ee9a37 100644 --- a/DataPlotly/gui/plot_settings_widget.py +++ b/DataPlotly/gui/plot_settings_widget.py @@ -37,8 +37,6 @@ from qgis.PyQt.QtGui import ( QFont, - QImage, - QPainter, QColor ) from qgis.PyQt.QtCore import ( @@ -1591,15 +1589,7 @@ def save_plot_as_image(self): plot_file = QgsFileUtils.ensureFileNameHasExtension(plot_file, ['png']) - frame = self.plot_view.page().mainFrame() - self.plot_view.page().setViewportSize(frame.contentsSize()) - # render image - image = QImage(self.plot_view.page().viewportSize(), - QImage.Format.Format_ARGB32) - painter = QPainter(image) - frame.render(painter) - painter.end() - image.save(plot_file) + self.plot_view.grab().save(plot_file) if self.message_bar: self.message_bar.pushSuccess(self.tr('DataPlotly'), self.tr('Plot saved to {}').format( diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index f4819ea7..e859dc65 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -9,27 +9,22 @@ from qgis.PyQt.QtCore import ( Qt, QCoreApplication, - QRectF, QSize, + QTimer, QUrl, - QEventLoop, - QTimer ) -from qgis.PyQt.QtGui import QPalette from qgis.PyQt.QtWidgets import QGraphicsItem from qgis.core import ( QgsLayoutItem, QgsLayoutItemRegistry, QgsLayoutItemAbstractMetadata, - QgsNetworkAccessManager, QgsMessageLog, QgsGeometry, QgsPropertyCollection ) -# from qgis.PyQt.QtWebKitWidgets import QWebPage from qgis.PyQt.QtWebEngineWidgets import QWebEngineView -from qgis.PyQt.QtWebEngineCore import QWebEngineSettings +from qgis.PyQt.QtWebEngineCore import QWebEnginePage from DataPlotly.core.plot_settings import PlotSettings from DataPlotly.core.plot_factory import PlotFactory, FilterRegion @@ -38,12 +33,12 @@ ITEM_TYPE = QgsLayoutItemRegistry.ItemType.PluginItem + 1337 -class LoggingWebPage(QWebEngineView): +class LoggingWebPage(QWebEnginePage): def __init__(self, parent=None): super().__init__(parent) - def javaScriptConsoleMessage(self, message, lineNumber, source): + def javaScriptConsoleMessage(self, level, message, lineNumber, source): QgsMessageLog.logMessage(f'{source}:{lineNumber} {message}', 'DataPlotly') @@ -58,18 +53,18 @@ def __init__(self, layout): self.linked_map = None self.web_page = LoggingWebPage(self) - self.web_page.setNetworkAccessManager(QgsNetworkAccessManager.instance()) + self.web_page.setBackgroundColor(Qt.GlobalColor.transparent) - # This makes the background transparent. (copied from QgsLayoutItemLabel) - palette = self.web_page.palette() - palette.setBrush(QPalette.ColorRole.Base, Qt.GlobalColor.transparent) - self.web_page.setPalette(palette) - self.web_page.mainFrame().setZoomFactor(10.0) - self.web_page.mainFrame().setScrollBarPolicy(Qt.Orientation.Horizontal, Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.web_page.mainFrame().setScrollBarPolicy(Qt.Orientation.Vertical, Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.web_view = QWebEngineView() + self.web_view.setPage(self.web_page) + self.web_view.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen) + self.web_view.setZoomFactor(10.0) + self.web_view.show() self.web_page.loadFinished.connect(self.loading_html_finished) self.html_loaded = False + self._loading = False + self._captured_pixmap = None self.html_units_to_layout_units = self.calculate_html_units_to_layout_units() self.sizePositionChanged.connect(self.refresh) @@ -87,7 +82,7 @@ def calculate_html_units_to_layout_units(self): # Hm - why is this? Something internal in Plotly which is auto-scaling the html content? # we may need to expose this as a "scaling" setting - return 72 + return 8 def set_linked_map(self, map): """ @@ -155,7 +150,7 @@ def set_plot_settings(self, plot_id, settings): self.invalidateCache() def draw(self, context): - if not self.html_loaded: + if not self.html_loaded and not self._loading: self.load_content() if not self.layout().renderContext().isPreviewRender(): @@ -164,14 +159,14 @@ def draw(self, context): while not self.html_loaded: QCoreApplication.processEvents() - # almost a direct copy from QgsLayoutItemLabel! painter = context.renderContext().painter() painter.save() - # painter is scaled to dots, so scale back to layout units - painter.scale(context.renderContext().scaleFactor() / self.html_units_to_layout_units, - context.renderContext().scaleFactor() / self.html_units_to_layout_units) - self.web_page.mainFrame().render(painter) + if self._captured_pixmap and not self._captured_pixmap.isNull(): + sx = self.rect().width() * context.renderContext().scaleFactor() / self._captured_pixmap.width() + sy = self.rect().height() * context.renderContext().scaleFactor() / self._captured_pixmap.height() + painter.scale(sx, sy) + painter.drawPixmap(0, 0, self._captured_pixmap) painter.restore() def create_plot(self): @@ -218,11 +213,15 @@ def get_polygon_filter(self, index=0): return polygon_filter, visible_features_only def load_content(self): + import tempfile + self._loading = True self.html_loaded = False - base_url = QUrl.fromLocalFile(self.layout().project().absoluteFilePath()) - self.web_page.setViewportSize(QSize(int(self.rect().width()) * self.html_units_to_layout_units, - int(self.rect().height()) * self.html_units_to_layout_units)) - self.web_page.mainFrame().setHtml(self.create_plot(), base_url) + self.web_view.resize(QSize(int(self.rect().width()) * self.html_units_to_layout_units, + int(self.rect().height()) * self.html_units_to_layout_units)) + self._tmp_file = tempfile.NamedTemporaryFile(suffix='.html', delete=False) + self._tmp_file.write(self.create_plot().encode('utf-8')) + self._tmp_file.close() + self.web_page.load(QUrl.fromLocalFile(self._tmp_file.name)) def writePropertiesToElement(self, element, document, _) -> bool: for plot_setting in self.plot_settings: @@ -262,6 +261,49 @@ def finalizeRestoreFromXml(self): self.set_linked_map(map) def loading_html_finished(self): + self.web_page.runJavaScript("document.documentElement.style.overflow='hidden'") + self._render_retries = 0 + js = """(function() { + var plot = document.querySelector('.js-plotly-plot'); + if (plot && typeof Plotly !== 'undefined') { + Plotly.toImage(plot, {format: 'png', scale: 2}).then(function(dataUrl) { + window._capturedImage = dataUrl; + }).catch(function() { + window._capturedImage = ''; + }); + } else { + window._capturedImage = ''; + } + })()""" + self.web_page.runJavaScript(js) + self._wait_for_image_capture() + + def _wait_for_image_capture(self): + """Poll until Plotly.toImage() has produced the image.""" + self.web_page.runJavaScript( + 'typeof window._capturedImage === "string"', + self._on_image_capture_check) + + def _on_image_capture_check(self, ready): + self._render_retries += 1 + if ready or self._render_retries >= 100: + self.web_page.runJavaScript( + 'window._capturedImage || ""', + self._on_image_data_received) + else: + QTimer.singleShot(50, self._wait_for_image_capture) + + def _on_image_data_received(self, data_url): + import base64 + from qgis.PyQt.QtGui import QPixmap + if data_url and data_url.startswith('data:image'): + base64_data = data_url.split(',', 1)[1] + image_bytes = base64.b64decode(base64_data) + self._captured_pixmap = QPixmap() + self._captured_pixmap.loadFromData(image_bytes) + else: + self._captured_pixmap = None + self._loading = False self.html_loaded = True self.invalidateCache() self.update()