From 207d3a0ff9292cdf650f0ab94979dfdbe951f7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Sat, 28 Mar 2026 06:23:11 -0300 Subject: [PATCH 01/10] fix: migrate QtWebKit API to QtWebEngine for Qt6/QGIS 4 compatibility --- DataPlotly/gui/plot_settings_widget.py | 12 +------- DataPlotly/layouts/plot_layout_item.py | 42 +++++++++++--------------- 2 files changed, 18 insertions(+), 36 deletions(-) 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..b64d98bd 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -9,27 +9,21 @@ from qgis.PyQt.QtCore import ( Qt, QCoreApplication, - QRectF, QSize, 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 +32,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,15 +52,13 @@ 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 @@ -164,14 +156,13 @@ 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) + pixmap = self.web_view.grab() + scale = context.renderContext().scaleFactor() / self.html_units_to_layout_units + painter.scale(scale, scale) + painter.drawPixmap(0, 0, pixmap) painter.restore() def create_plot(self): @@ -220,9 +211,9 @@ def get_polygon_filter(self, index=0): def load_content(self): 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.web_page.setHtml(self.create_plot(), base_url) def writePropertiesToElement(self, element, document, _) -> bool: for plot_setting in self.plot_settings: @@ -262,6 +253,7 @@ def finalizeRestoreFromXml(self): self.set_linked_map(map) def loading_html_finished(self): + self.web_page.runJavaScript("document.documentElement.style.overflow='hidden'") self.html_loaded = True self.invalidateCache() self.update() From 514b24467d8f89b0dcfa8b8b6714da743481fe3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Mon, 30 Mar 2026 09:14:50 -0300 Subject: [PATCH 02/10] fix: wait for Plotly render before grab() in print layout --- DataPlotly/layouts/plot_layout_item.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index b64d98bd..e9c80800 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -10,6 +10,7 @@ Qt, QCoreApplication, QSize, + QTimer, QUrl, ) from qgis.PyQt.QtWidgets import QGraphicsItem @@ -254,6 +255,28 @@ def finalizeRestoreFromXml(self): def loading_html_finished(self): self.web_page.runJavaScript("document.documentElement.style.overflow='hidden'") + self._render_retries = 0 + self._wait_for_plotly_render() + + def _wait_for_plotly_render(self): + """Poll until Plotly has finished rendering the plot.""" + js = """(function() { + var plot = document.querySelector('.js-plotly-plot'); + if (!plot) return true; + return plot.querySelector('.plot-container') !== null; + })()""" + self.web_page.runJavaScript(js, self._on_plotly_render_check) + + def _on_plotly_render_check(self, ready): + self._render_retries += 1 + if ready or self._render_retries >= 100: + # Plotly DOM is ready, but the WebEngine compositor still needs + # a short delay to rasterize the frame before grab() can capture it + QTimer.singleShot(100, self._on_compositor_ready) + else: + QTimer.singleShot(50, self._wait_for_plotly_render) + + def _on_compositor_ready(self): self.html_loaded = True self.invalidateCache() self.update() From 24ce3098f06da47a7a3603a07d69231d2a285b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Mon, 30 Mar 2026 12:03:09 -0300 Subject: [PATCH 03/10] fix: use temp file for WebEngine rendering in print layout setHtml() creates an opaque Chromium origin that silently blocks file:// script loading (Plotly.js). --- DataPlotly/layouts/plot_layout_item.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index e9c80800..cd6e6407 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -210,11 +210,14 @@ def get_polygon_filter(self, index=0): return polygon_filter, visible_features_only def load_content(self): + import tempfile self.html_loaded = False - base_url = QUrl.fromLocalFile(self.layout().project().absoluteFilePath()) 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.web_page.setHtml(self.create_plot(), base_url) + 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,7 +265,7 @@ def _wait_for_plotly_render(self): """Poll until Plotly has finished rendering the plot.""" js = """(function() { var plot = document.querySelector('.js-plotly-plot'); - if (!plot) return true; + if (!plot) return false; return plot.querySelector('.plot-container') !== null; })()""" self.web_page.runJavaScript(js, self._on_plotly_render_check) From 2612c2564c3513041ebb6d8b8df0156f4368a9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Mon, 30 Mar 2026 13:10:37 -0300 Subject: [PATCH 04/10] fix: prevent reload loop during WebEngine async loading in print layout --- DataPlotly/layouts/plot_layout_item.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index cd6e6407..ed17e98b 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -63,6 +63,7 @@ def __init__(self, layout): self.web_page.loadFinished.connect(self.loading_html_finished) self.html_loaded = False + self._loading = False self.html_units_to_layout_units = self.calculate_html_units_to_layout_units() self.sizePositionChanged.connect(self.refresh) @@ -148,7 +149,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(): @@ -211,6 +212,7 @@ def get_polygon_filter(self, index=0): def load_content(self): import tempfile + self._loading = True self.html_loaded = False 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)) @@ -280,6 +282,7 @@ def _on_plotly_render_check(self, ready): QTimer.singleShot(50, self._wait_for_plotly_render) def _on_compositor_ready(self): + self._loading = False self.html_loaded = True self.invalidateCache() self.update() From f9bebf05082933c460d01afabfd9b4e31a3d790b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Mon, 30 Mar 2026 15:57:04 -0300 Subject: [PATCH 05/10] fix: use Plotly.react() promise to detect render completion --- DataPlotly/layouts/plot_layout_item.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index ed17e98b..4172147a 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -261,16 +261,24 @@ def finalizeRestoreFromXml(self): 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' && plot.data) { + Plotly.react(plot, plot.data, plot.layout).then(function() { + window._plotlyRenderComplete = true; + }); + } else { + window._plotlyRenderComplete = true; + } + })()""" + self.web_page.runJavaScript(js) self._wait_for_plotly_render() def _wait_for_plotly_render(self): """Poll until Plotly has finished rendering the plot.""" - js = """(function() { - var plot = document.querySelector('.js-plotly-plot'); - if (!plot) return false; - return plot.querySelector('.plot-container') !== null; - })()""" - self.web_page.runJavaScript(js, self._on_plotly_render_check) + self.web_page.runJavaScript( + 'window._plotlyRenderComplete === true', + self._on_plotly_render_check) def _on_plotly_render_check(self, ready): self._render_retries += 1 From 8e6b48a7338c65fb1f69f8d98be1968791ec4899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Tue, 31 Mar 2026 06:04:57 -0300 Subject: [PATCH 06/10] fix: use Plotly.toImage() to bypass unreliable WebEngine compositor grab() --- DataPlotly/layouts/plot_layout_item.py | 52 ++++++++++++++++---------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index 4172147a..40b7c2d5 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -64,6 +64,7 @@ def __init__(self, layout): 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) @@ -161,10 +162,10 @@ def draw(self, context): painter = context.renderContext().painter() painter.save() - pixmap = self.web_view.grab() - scale = context.renderContext().scaleFactor() / self.html_units_to_layout_units - painter.scale(scale, scale) - painter.drawPixmap(0, 0, pixmap) + if self._captured_pixmap and not self._captured_pixmap.isNull(): + scale = context.renderContext().scaleFactor() / self.html_units_to_layout_units + painter.scale(scale, scale) + painter.drawPixmap(0, 0, self._captured_pixmap) painter.restore() def create_plot(self): @@ -263,33 +264,44 @@ def loading_html_finished(self): self._render_retries = 0 js = """(function() { var plot = document.querySelector('.js-plotly-plot'); - if (plot && typeof Plotly !== 'undefined' && plot.data) { - Plotly.react(plot, plot.data, plot.layout).then(function() { - window._plotlyRenderComplete = true; + if (plot && typeof Plotly !== 'undefined') { + Plotly.toImage(plot, {format: 'png', scale: 10}).then(function(dataUrl) { + window._capturedImage = dataUrl; + }).catch(function() { + window._capturedImage = ''; }); } else { - window._plotlyRenderComplete = true; + window._capturedImage = ''; } })()""" self.web_page.runJavaScript(js) - self._wait_for_plotly_render() + self._wait_for_image_capture() - def _wait_for_plotly_render(self): - """Poll until Plotly has finished rendering the plot.""" + def _wait_for_image_capture(self): + """Poll until Plotly.toImage() has produced the image.""" self.web_page.runJavaScript( - 'window._plotlyRenderComplete === true', - self._on_plotly_render_check) + 'typeof window._capturedImage === "string"', + self._on_image_capture_check) - def _on_plotly_render_check(self, ready): + def _on_image_capture_check(self, ready): self._render_retries += 1 if ready or self._render_retries >= 100: - # Plotly DOM is ready, but the WebEngine compositor still needs - # a short delay to rasterize the frame before grab() can capture it - QTimer.singleShot(100, self._on_compositor_ready) + self.web_page.runJavaScript( + 'window._capturedImage || ""', + self._on_image_data_received) else: - QTimer.singleShot(50, self._wait_for_plotly_render) - - def _on_compositor_ready(self): + 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() From 1f5caf2a5b29426d6d4d60e352f76dee9d7c1d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Tue, 31 Mar 2026 06:22:34 -0300 Subject: [PATCH 07/10] fix: pass explicit viewport dimensions to Plotly.toImage() --- DataPlotly/layouts/plot_layout_item.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index 40b7c2d5..3656d768 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -264,8 +264,10 @@ def loading_html_finished(self): self._render_retries = 0 js = """(function() { var plot = document.querySelector('.js-plotly-plot'); + var w = document.documentElement.clientWidth; + var h = document.documentElement.clientHeight; if (plot && typeof Plotly !== 'undefined') { - Plotly.toImage(plot, {format: 'png', scale: 10}).then(function(dataUrl) { + Plotly.toImage(plot, {format: 'png', width: w, height: h, scale: 10}).then(function(dataUrl) { window._capturedImage = dataUrl; }).catch(function() { window._capturedImage = ''; From 24a191514e7f5fa028728d694a91f6bbaa978bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Tue, 31 Mar 2026 06:33:22 -0300 Subject: [PATCH 08/10] fix: scale captured pixmap to fill layout item rect --- DataPlotly/layouts/plot_layout_item.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index 3656d768..942118e1 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -163,8 +163,9 @@ def draw(self, context): painter.save() if self._captured_pixmap and not self._captured_pixmap.isNull(): - scale = context.renderContext().scaleFactor() / self.html_units_to_layout_units - painter.scale(scale, scale) + 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() @@ -264,10 +265,8 @@ def loading_html_finished(self): self._render_retries = 0 js = """(function() { var plot = document.querySelector('.js-plotly-plot'); - var w = document.documentElement.clientWidth; - var h = document.documentElement.clientHeight; if (plot && typeof Plotly !== 'undefined') { - Plotly.toImage(plot, {format: 'png', width: w, height: h, scale: 10}).then(function(dataUrl) { + Plotly.toImage(plot, {format: 'png', scale: 10}).then(function(dataUrl) { window._capturedImage = dataUrl; }).catch(function() { window._capturedImage = ''; From fda1048c77860c01474159cd233082c576d4773d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Tue, 31 Mar 2026 06:42:08 -0300 Subject: [PATCH 09/10] fix: reduce Plotly.toImage scale to 1 (viewport is already 13K+ px) --- DataPlotly/layouts/plot_layout_item.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index 942118e1..19fa16b6 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -266,7 +266,7 @@ def loading_html_finished(self): js = """(function() { var plot = document.querySelector('.js-plotly-plot'); if (plot && typeof Plotly !== 'undefined') { - Plotly.toImage(plot, {format: 'png', scale: 10}).then(function(dataUrl) { + Plotly.toImage(plot, {format: 'png', scale: 1}).then(function(dataUrl) { window._capturedImage = dataUrl; }).catch(function() { window._capturedImage = ''; From 5d3357b794118902ac539242fda9f07a08b2fadc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Escalh=C3=A3o?= Date: Tue, 31 Mar 2026 07:00:10 -0300 Subject: [PATCH 10/10] fix: reduce widget viewport size and rebalance toImage scale for sharper rendering --- DataPlotly/layouts/plot_layout_item.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DataPlotly/layouts/plot_layout_item.py b/DataPlotly/layouts/plot_layout_item.py index 19fa16b6..e859dc65 100644 --- a/DataPlotly/layouts/plot_layout_item.py +++ b/DataPlotly/layouts/plot_layout_item.py @@ -82,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): """ @@ -266,7 +266,7 @@ def loading_html_finished(self): js = """(function() { var plot = document.querySelector('.js-plotly-plot'); if (plot && typeof Plotly !== 'undefined') { - Plotly.toImage(plot, {format: 'png', scale: 1}).then(function(dataUrl) { + Plotly.toImage(plot, {format: 'png', scale: 2}).then(function(dataUrl) { window._capturedImage = dataUrl; }).catch(function() { window._capturedImage = '';