From ec545b4cecee1561ef7f677cdb04f2afe5b74c65 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 24 Jan 2024 21:14:45 -0600 Subject: [PATCH] Addon Manager: Improve macro readme rendering --- .../addonmanager_readme_viewer.py | 159 ++++++++++++++++-- src/Mod/AddonManager/package_details.py | 4 +- 2 files changed, 150 insertions(+), 13 deletions(-) diff --git a/src/Mod/AddonManager/addonmanager_readme_viewer.py b/src/Mod/AddonManager/addonmanager_readme_viewer.py index 5e8ec50464..af2a5d0752 100644 --- a/src/Mod/AddonManager/addonmanager_readme_viewer.py +++ b/src/Mod/AddonManager/addonmanager_readme_viewer.py @@ -25,6 +25,8 @@ import Addon from PySide import QtCore, QtGui, QtWidgets +from enum import Enum, auto +from html.parser import HTMLParser import addonmanager_freecad_interface as fci import addonmanager_utilities as utils @@ -43,18 +45,21 @@ class ReadmeViewer(QtWidgets.QTextBrowser): super().__init__(parent) NetworkManager.InitializeNetworkManager() NetworkManager.AM_NETWORK_MANAGER.completed.connect(self._download_completed) - self.request_index = 0 + self.readme_request_index = 0 + self.resource_requests = {} self.url = "" self.repo: Addon.Addon = None self.setOpenExternalLinks(True) self.setOpenLinks(True) self.image_map = {} + self.stop = True def set_addon(self, repo: Addon): """Set which Addon's information is displayed""" self.setPlainText(translate("AddonsInstaller", "Loading README data...")) self.repo = repo + self.stop = False if self.repo.repo_type == Addon.Addon.Kind.MACRO: self.url = self.repo.macro.wiki if not self.url: @@ -62,11 +67,13 @@ class ReadmeViewer(QtWidgets.QTextBrowser): else: self.url = utils.get_readme_url(repo) - self.request_index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(self.url) + self.readme_request_index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get( + self.url + ) def _download_completed(self, index: int, code: int, data: QtCore.QByteArray) -> None: """Callback for handling a completed README file download.""" - if index == self.request_index: + if index == self.readme_request_index: if code == 200: # HTTP success self._process_package_download(data.data().decode("utf-8")) else: @@ -76,34 +83,56 @@ class ReadmeViewer(QtWidgets.QTextBrowser): "Failed to download data from {} -- received response code {}.", ).format(self.url, code) ) + elif index in self.resource_requests: + if code == 200: + self._process_resource_download(self.resource_requests[index], data.data()) + else: + self.image_map[self.resource_requests[index]] = None + del self.resource_requests[index] + if not self.resource_requests: + self.set_addon(self.repo) # Trigger a reload of the page now with resources def _process_package_download(self, data: str): if self.repo.repo_type == Addon.Addon.Kind.MACRO: - self.setHtml(data) + parser = WikiCleaner() + parser.feed(data) + self.setHtml(parser.final_html) else: + # Check for recent Qt (e.g. Qt5.15 or later). Check can be removed when + # we no longer support Ubuntu 20.04LTS for compiling. if hasattr(self, "setMarkdown"): self.setMarkdown(data) else: self.setPlainText(data) + def _process_resource_download(self, resource_name: str, resource_data: bytes): + image = QtGui.QImage.fromData(resource_data) + if image: + self.image_map[resource_name] = self._ensure_appropriate_width(image) + else: + self.image_map[resource_name] = None + def loadResource(self, resource_type: int, name: QtCore.QUrl) -> object: """Callback for resource loading. Called automatically by underlying Qt code when external resources are needed for rendering. In particular, here it is used to download and cache (in RAM) the images needed for the README and Wiki pages.""" - if resource_type == QtGui.QTextDocument.ImageResource: + if resource_type == QtGui.QTextDocument.ImageResource and not self.stop: full_url = self._create_full_url(name.toString()) if full_url not in self.image_map: self.image_map[full_url] = None fci.Console.PrintMessage(f"Downloading image from {full_url}...\n") - data = NetworkManager.AM_NETWORK_MANAGER.blocking_get(full_url) - if data and data.data(): - image = QtGui.QImage.fromData(data.data()) - if image: - self.image_map[full_url] = self._ensure_appropriate_width(image) + index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(full_url) + self.resource_requests[index] = full_url return self.image_map[full_url] return super().loadResource(resource_type, name) + def hideEvent(self, event: QtGui.QHideEvent): + self.stop = True + for request in self.resource_requests: + NetworkManager.AM_NETWORK_MANAGER.abort(request) + self.resource_requests.clear() + def _create_full_url(self, url: str) -> str: if url.startswith("http"): return url @@ -117,3 +146,113 @@ class ReadmeViewer(QtWidgets.QTextBrowser): if image.width() < ninety_seven_percent: return image return image.scaledToWidth(ninety_seven_percent) + + +class WikiCleaner(HTMLParser): + """This HTML parser cleans up FreeCAD Macro Wiki Page for display in a + QTextBrowser widget (which does not deal will with tables used as formatting, + etc.) It strips out any tables, and extracts the mw-parser-output div as the only + thing that actually gets displayed. It also discards anything inside the [edit] + spans that litter wiki output.""" + + class State(Enum): + BeforeMacroContent = auto() + InMacroContent = auto() + InTable = auto() + InEditSpan = auto() + AfterMacroContent = auto() + + def __init__(self): + super().__init__() + self.depth_in_div = 0 + self.depth_in_span = 0 + self.depth_in_table = 0 + self.final_html = "" + self.previous_state = WikiCleaner.State.BeforeMacroContent + self.state = WikiCleaner.State.BeforeMacroContent + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]): + if tag == "div": + self.handle_div_start(attrs) + elif tag == "span": + self.handle_span_start(attrs) + elif tag == "table": + self.handle_table_start(attrs) + else: + if self.state == WikiCleaner.State.InMacroContent: + self.add_tag_to_html(tag, attrs) + + def handle_div_start(self, attrs: list[tuple[str, str]]): + for name, value in attrs: + if name == "class" and value == "mw-parser-output": + self.previous_state = self.state + self.state = WikiCleaner.State.InMacroContent + if self.state == WikiCleaner.State.InMacroContent: + self.depth_in_div += 1 + self.add_tag_to_html("div", attrs) + + def handle_span_start(self, attrs: list[tuple[str, str]]): + for name, value in attrs: + if name == "class" and value == "mw-editsection": + self.previous_state = self.state + self.state = WikiCleaner.State.InEditSpan + break + if self.state == WikiCleaner.State.InEditSpan: + self.depth_in_span += 1 + elif WikiCleaner.State.InMacroContent: + self.add_tag_to_html("span", attrs) + + def handle_table_start(self, attrs: list[tuple[str, str]]): + if self.state != WikiCleaner.State.InTable: + self.previous_state = self.state + self.state = WikiCleaner.State.InTable + self.depth_in_table += 1 + + def add_tag_to_html(self, tag, attrs=None): + self.final_html += f"<{tag}" + if attrs: + self.final_html += " " + for attr, value in attrs: + self.final_html += f"{attr}='{value}'" + self.final_html += ">\n" + + def handle_endtag(self, tag): + if tag == "table": + self.handle_table_end() + elif tag == "span": + self.handle_span_end() + elif tag == "div": + self.handle_div_end() + else: + if self.state == WikiCleaner.State.InMacroContent: + self.add_tag_to_html(f"/{tag}") + + def handle_span_end(self): + if self.state == WikiCleaner.State.InEditSpan: + self.depth_in_span -= 1 + if self.depth_in_span <= 0: + self.depth_in_span = 0 + self.state = self.previous_state + else: + self.add_tag_to_html(f"/span") + + def handle_div_end(self): + if self.state == WikiCleaner.State.InMacroContent: + self.depth_in_div -= 1 + if self.depth_in_div <= 0: + self.depth_in_div = 0 + self.state = WikiCleaner.State.AfterMacroContent + self.final_html += "" + else: + self.add_tag_to_html(f"/div") + + def handle_table_end(self): + if self.state == WikiCleaner.State.InTable: + self.depth_in_table -= 1 + if self.depth_in_table <= 0: + self.depth_in_table = 0 + self.state = self.previous_state + + def handle_data(self, data): + if self.state == WikiCleaner.State.InMacroContent: + self.final_html += data diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index 8310b5e903..a9e73a42c7 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -49,8 +49,6 @@ except ImportError: translate = fci.translate -show_javascript_console_output = False - class PackageDetails(QtWidgets.QWidget): """The PackageDetails QWidget shows package README information and provides @@ -90,7 +88,7 @@ class PackageDetails(QtWidgets.QWidget): # If this is the same repo we were already showing, we do not have to do the # expensive refetch unless reload is true - if self.repo != repo or reload: + if True or self.repo != repo or reload: self.repo = repo if self.worker is not None: