diff -r 4e8b98454baa -r 800c432b34c8 eric7/UI/Previewers/PreviewerHTML.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric7/UI/Previewers/PreviewerHTML.py Sat May 15 18:45:04 2021 +0200 @@ -0,0 +1,809 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2014 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing a previewer widget for HTML, Markdown and ReST files. +""" + +import os +import threading +import re +import shutil +import tempfile +import sys +import io +import contextlib + +from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QThread +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QCheckBox, QSizePolicy, QToolTip +) + +from E5Gui.E5Application import e5App + +import Utilities +import Preferences + + +class PreviewerHTML(QWidget): + """ + Class implementing a previewer widget for HTML, Markdown and ReST files. + """ + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent widget (QWidget) + """ + super().__init__(parent) + + self.__layout = QVBoxLayout(self) + + self.titleLabel = QLabel(self) + self.titleLabel.setWordWrap(True) + self.titleLabel.setTextInteractionFlags( + Qt.TextInteractionFlag.NoTextInteraction) + self.__layout.addWidget(self.titleLabel) + + self.__previewAvailable = True + + try: + from PyQt5.QtWebEngineWidgets import QWebEngineView + self.previewView = QWebEngineView(self) + self.previewView.page().linkHovered.connect(self.__showLink) + except ImportError: + self.__previewAvailable = False + self.titleLabel.setText(self.tr( + "<b>HTML Preview is not available!<br/>" + "Install PyQtWebEngine.</b>")) + self.titleLabel.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.__layout.addStretch() + return + + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, + QSizePolicy.Policy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth( + self.previewView.sizePolicy().hasHeightForWidth()) + self.previewView.setSizePolicy(sizePolicy) + self.previewView.setContextMenuPolicy( + Qt.ContextMenuPolicy.NoContextMenu) + self.previewView.setUrl(QUrl("about:blank")) + self.__layout.addWidget(self.previewView) + + self.jsCheckBox = QCheckBox(self.tr("Enable JavaScript"), self) + self.jsCheckBox.setToolTip(self.tr( + "Select to enable JavaScript for HTML previews")) + self.__layout.addWidget(self.jsCheckBox) + + self.ssiCheckBox = QCheckBox(self.tr("Enable Server Side Includes"), + self) + self.ssiCheckBox.setToolTip(self.tr( + "Select to enable support for Server Side Includes")) + self.__layout.addWidget(self.ssiCheckBox) + + self.jsCheckBox.clicked[bool].connect(self.on_jsCheckBox_clicked) + self.ssiCheckBox.clicked[bool].connect(self.on_ssiCheckBox_clicked) + self.previewView.titleChanged.connect(self.on_previewView_titleChanged) + + self.jsCheckBox.setChecked( + Preferences.getUI("ShowFilePreviewJS")) + self.ssiCheckBox.setChecked( + Preferences.getUI("ShowFilePreviewSSI")) + + self.__scrollBarPositions = {} + self.__vScrollBarAtEnd = {} + self.__hScrollBarAtEnd = {} + + self.__processingThread = PreviewProcessingThread() + self.__processingThread.htmlReady.connect(self.__setHtml) + + self.__previewedPath = None + self.__previewedEditor = None + + def shutdown(self): + """ + Public method to perform shutdown actions. + """ + if self.__previewAvailable: + self.__processingThread.wait() + + @pyqtSlot(bool) + def on_jsCheckBox_clicked(self, checked): + """ + Private slot to enable/disable JavaScript. + + @param checked state of the checkbox (boolean) + """ + Preferences.setUI("ShowFilePreviewJS", checked) + self.__setJavaScriptEnabled(checked) + + def __setJavaScriptEnabled(self, enable): + """ + Private method to enable/disable JavaScript. + + @param enable flag indicating the enable state (boolean) + """ + self.jsCheckBox.setChecked(enable) + + settings = self.previewView.settings() + settings.setAttribute(settings.JavascriptEnabled, enable) + + self.processEditor() + + @pyqtSlot(bool) + def on_ssiCheckBox_clicked(self, checked): + """ + Private slot to enable/disable SSI. + + @param checked state of the checkbox (boolean) + """ + Preferences.setUI("ShowFilePreviewSSI", checked) + self.processEditor() + + @pyqtSlot(str) + def __showLink(self, urlStr): + """ + Private slot to show the hovered link in a tooltip. + + @param urlStr hovered URL + @type str + """ + QToolTip.showText(QCursor.pos(), urlStr, self.previewView) + + def processEditor(self, editor=None): + """ + Public slot to process an editor's text. + + @param editor editor to be processed (Editor) + """ + if not self.__previewAvailable: + return + + if editor is None: + editor = self.__previewedEditor + else: + self.__previewedEditor = editor + + if editor is not None: + fn = editor.getFileName() + + if fn: + extension = os.path.normcase(os.path.splitext(fn)[1][1:]) + else: + extension = "" + if ( + extension in Preferences.getEditor( + "PreviewHtmlFileNameExtensions") or + editor.getLanguage() == "HTML" + ): + language = "HTML" + elif ( + extension in Preferences.getEditor( + "PreviewMarkdownFileNameExtensions") or + editor.getLanguage().lower() == "markdown" + ): + language = "Markdown" + elif ( + extension in Preferences.getEditor( + "PreviewRestFileNameExtensions") or + editor.getLanguage().lower() == "restructuredtext" + ): + language = "ReST" + else: + self.__setHtml(fn, self.tr( + "<p>No preview available for this type of file.</p>")) + return + + if fn: + project = e5App().getObject("Project") + if project.isProjectFile(fn): + rootPath = project.getProjectPath() + else: + rootPath = os.path.dirname(os.path.abspath(fn)) + else: + rootPath = "" + + if bool(editor.text()): + self.__processingThread.process( + fn, language, editor.text(), + self.ssiCheckBox.isChecked(), rootPath, + Preferences.getEditor("PreviewRestUseSphinx"), + Preferences.getEditor("PreviewMarkdownNLtoBR"), + Preferences.getEditor( + "PreviewMarkdownUsePyMdownExtensions"), + Preferences.getEditor("PreviewMarkdownHTMLFormat"), + Preferences.getEditor("PreviewRestDocutilsHTMLFormat")) + + def __setHtml(self, filePath, html, rootPath): + """ + Private method to set the HTML to the view and restore the scroll bars + positions. + + @param filePath file path of the previewed editor + @type str + @param html processed HTML text ready to be shown + @type str + @param rootPath path of the web site root + @type str + """ + self.__previewedPath = Utilities.normcasepath( + Utilities.fromNativeSeparators(filePath)) + self.__saveScrollBarPositions() + self.previewView.page().loadFinished.connect( + self.__restoreScrollBarPositions) + if not filePath: + filePath = "/" + baseUrl = ( + QUrl.fromLocalFile(rootPath + "/index.html") + if rootPath else + QUrl.fromLocalFile(filePath) + ) + self.previewView.setHtml(html, baseUrl=baseUrl) + if self.__previewedEditor: + self.__previewedEditor.setFocus() + + @pyqtSlot(str) + def on_previewView_titleChanged(self, title): + """ + Private slot to handle a change of the title. + + @param title new title (string) + """ + if title: + self.titleLabel.setText(self.tr("Preview - {0}").format(title)) + else: + self.titleLabel.setText(self.tr("Preview")) + + def __saveScrollBarPositions(self): + """ + Private method to save scroll bar positions for a previewed editor. + """ + from PyQt5.QtCore import QPoint + try: + pos = self.previewView.scrollPosition() + except AttributeError: + pos = self.__execJavaScript( + "(function() {" + "var res = {" + " x: 0," + " y: 0," + "};" + "res.x = window.scrollX;" + "res.y = window.scrollY;" + "return res;" + "})()" + ) + pos = QPoint(0, 0) if pos is None else QPoint(pos["x"], pos["y"]) + self.__scrollBarPositions[self.__previewedPath] = pos + self.__hScrollBarAtEnd[self.__previewedPath] = False + self.__vScrollBarAtEnd[self.__previewedPath] = False + + def __restoreScrollBarPositions(self): + """ + Private method to restore scroll bar positions for a previewed editor. + """ + if self.__previewedPath not in self.__scrollBarPositions: + return + + pos = self.__scrollBarPositions[self.__previewedPath] + self.previewView.page().runJavaScript( + "window.scrollTo({0}, {1});".format(pos.x(), pos.y())) + + def __execJavaScript(self, script): + """ + Private function to execute a JavaScript function Synchroneously. + + @param script JavaScript script source to be executed + @type str + @return result of the script + @rtype depending upon script result + """ + from PyQt5.QtCore import QEventLoop + loop = QEventLoop() + resultDict = {"res": None} + + def resultCallback(res, resDict=resultDict): + if loop and loop.isRunning(): + resDict["res"] = res + loop.quit() + + self.previewView.page().runJavaScript( + script, resultCallback) + + loop.exec() + return resultDict["res"] + + +class PreviewProcessingThread(QThread): + """ + Class implementing a thread to process some text into HTML usable by the + previewer view. + + @signal htmlReady(str, str, str) emitted with the file name, the processed + HTML and the web site root path to signal the availability of the + processed HTML + """ + htmlReady = pyqtSignal(str, str, str) + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent object (QObject) + """ + super().__init__() + + self.__lock = threading.Lock() + + def process(self, filePath, language, text, ssiEnabled, rootPath, + useSphinx, convertNewLineToBreak, usePyMdownExtensions, + markdownHtmlFormat, restDocutilsHtmlFormat): + """ + Public method to convert the given text to HTML. + + @param filePath file path of the text + @type str + @param language language of the text + @type str + @param text text to be processed + @type str + @param ssiEnabled flag indicating to do some (limited) SSI processing + @type bool + @param rootPath root path to be used for SSI processing + @type str + @param useSphinx flag indicating to use Sphinx to generate the + ReST preview + @type bool + @param convertNewLineToBreak flag indicating to convert new lines + to HTML break (Markdown only) + @type bool + @param usePyMdownExtensions flag indicating to enable the PyMdown + extensions, if they are available + @type bool + @param markdownHtmlFormat HTML format to be generated by markdown + @type str + @param restDocutilsHtmlFormat HTML format to be generated by docutils + @type str + """ + with self.__lock: + self.__filePath = filePath + self.__language = language + self.__text = text + self.__ssiEnabled = ssiEnabled + self.__rootPath = rootPath + self.__haveData = True + self.__useSphinx = useSphinx + self.__convertNewLineToBreak = convertNewLineToBreak + self.__usePyMdownExtensions = usePyMdownExtensions + self.__markdownHtmlFormat = markdownHtmlFormat + self.__restDocutilsHtmlFormat = restDocutilsHtmlFormat + if not self.isRunning(): + self.start(QThread.Priority.LowPriority) + + def run(self): + """ + Public thread method to convert the stored data. + """ + while True: + # exits with break + with self.__lock: + filePath = self.__filePath + language = self.__language + text = self.__text + ssiEnabled = self.__ssiEnabled + rootPath = self.__rootPath + useSphinx = self.__useSphinx + convertNewLineToBreak = self.__convertNewLineToBreak + usePyMdownExtensions = self.__usePyMdownExtensions + markdownHtmlFormat = self.__markdownHtmlFormat + restDocutilsHtmlFormat = self.__restDocutilsHtmlFormat + + self.__haveData = False + + html = self.__getHtml(language, text, ssiEnabled, filePath, + rootPath, useSphinx, convertNewLineToBreak, + usePyMdownExtensions, markdownHtmlFormat, + restDocutilsHtmlFormat) + + with self.__lock: + if not self.__haveData: + self.htmlReady.emit(filePath, html, rootPath) + break + # else - next iteration + + def __getHtml(self, language, text, ssiEnabled, filePath, rootPath, + useSphinx, convertNewLineToBreak, usePyMdownExtensions, + markdownHtmlFormat, restDocutilsHtmlFormat): + """ + Private method to process the given text depending upon the given + language. + + @param language language of the text + @type str + @param text to be processed + @type str + @param ssiEnabled flag indicating to do some (limited) SSI processing + @type bool + @param filePath file path of the text + @type str + @param rootPath root path to be used for SSI processing + @type str + @param useSphinx flag indicating to use Sphinx to generate the + ReST preview + @type bool + @param convertNewLineToBreak flag indicating to convert new lines + to HTML break (Markdown only) + @type bool + @param usePyMdownExtensions flag indicating to enable the PyMdown + extensions, if they are available + @type bool + @param markdownHtmlFormat HTML format to be generated by markdown + @type str + @param restDocutilsHtmlFormat HTML format to be generated by docutils + @type str + @return processed HTML text + @rtype str + """ + if language == "HTML": + if ssiEnabled: + html = self.__processSSI(text, filePath, rootPath) + else: + html = text + return self.__processRootPath(html, rootPath) + elif language == "Markdown": + return self.__convertMarkdown( + text, convertNewLineToBreak, usePyMdownExtensions, + markdownHtmlFormat) + elif language == "ReST": + return self.__convertReST(text, useSphinx, restDocutilsHtmlFormat) + else: + return self.tr( + "<p>No preview available for this type of file.</p>") + + def __processSSI(self, txt, filename, root): + """ + Private method to process the given text for SSI statements. + + Note: Only a limited subset of SSI statements are supported. + + @param txt text to be processed (string) + @param filename name of the file associated with the given text + (string) + @param root directory of the document root (string) + @return processed HTML (string) + """ + if not filename: + return txt + + # SSI include + incRe = re.compile( + r"""<!--#include[ \t]+(virtual|file)=[\"']([^\"']+)[\"']\s*-->""", + re.IGNORECASE) + baseDir = os.path.dirname(os.path.abspath(filename)) + docRoot = root if root != "" else baseDir + while True: + incMatch = incRe.search(txt) + if incMatch is None: + break + + if incMatch.group(1) == "virtual": + incFile = Utilities.normjoinpath(docRoot, incMatch.group(2)) + elif incMatch.group(1) == "file": + incFile = Utilities.normjoinpath(baseDir, incMatch.group(2)) + else: + incFile = "" + if os.path.exists(incFile): + try: + with open(incFile, "r") as f: + incTxt = f.read() + except OSError: + # remove SSI include + incTxt = "" + else: + # remove SSI include + incTxt = "" + txt = txt[:incMatch.start(0)] + incTxt + txt[incMatch.end(0):] + + return txt + + def __processRootPath(self, txt, root): + """ + Private method to adjust absolute references to the given root path. + + @param txt text to be processed + @type str + @param root directory of the document root + @type str + @return processed HTML + @rtype str + """ + if not root: + return txt + + root = Utilities.fromNativeSeparators(root) + if not root.endswith("/"): + root += "/" + rootLen = len(root) + + refRe = re.compile( + r"""(href|src)=[\\"']/([^\\"']+)[\\"']""", + re.IGNORECASE) + pos = 0 + while True: + refMatch = refRe.search(txt, pos) + if refMatch is None: + break + + txt = (txt[:refMatch.start(0)] + refMatch.group(1) + '="' + root + + refMatch.group(2) + '"' + txt[refMatch.end(0):]) + pos = refMatch.end(0) + rootLen + + return txt + + def __convertReST(self, text, useSphinx, restDocutilsHtmlFormat): + """ + Private method to convert ReST text into HTML. + + @param text text to be processed (string) + @param useSphinx flag indicating to use Sphinx to generate the + ReST preview (boolean) + @param restDocutilsHtmlFormat HTML format to be generated by docutils + (string) + @return processed HTML (string) + """ + if useSphinx: + return self.__convertReSTSphinx(text) + else: + return self.__convertReSTDocutils(text, restDocutilsHtmlFormat) + + def __convertReSTSphinx(self, text): + """ + Private method to convert ReST text into HTML using 'sphinx'. + + @param text text to be processed (string) + @return processed HTML (string) + """ + try: + from sphinx.application import Sphinx # __IGNORE_EXCEPTION__ + except ImportError: + return self.tr( + """<p>ReStructuredText preview requires the""" + """ <b>sphinx</b> package.<br/>Install it with""" + """ your package manager,'pip install Sphinx' or see""" + """ <a href="http://pypi.python.org/pypi/Sphinx">""" + """this page.</a></p>""" + """<p>Alternatively you may disable Sphinx usage""" + """ on the Editor, Filehandling configuration page.</p>""") + + srcTempDir = tempfile.mkdtemp(prefix="eric-rest-src-") + outTempDir = tempfile.mkdtemp(prefix="eric-rest-out-") + doctreeTempDir = tempfile.mkdtemp(prefix="eric-rest-doctree-") + try: + filename = 'sphinx_preview' + basePath = os.path.join(srcTempDir, filename) + with open(basePath + '.rst', 'w', encoding='utf-8') as fh: + fh.write(text) + + overrides = {'html_add_permalinks': False, + 'html_copy_source': False, + 'html_title': 'Sphinx preview', + 'html_use_index': False, + 'html_use_modindex': False, + 'html_use_smartypants': True, + 'master_doc': filename} + app = Sphinx(srcdir=srcTempDir, confdir=None, outdir=outTempDir, + doctreedir=doctreeTempDir, buildername='html', + confoverrides=overrides, status=None, + warning=io.StringIO()) + app.build(force_all=True, filenames=None) + + basePath = os.path.join(outTempDir, filename) + with open(basePath + '.html', 'r', encoding='utf-8') as fh: + html = fh.read() + finally: + shutil.rmtree(srcTempDir) + shutil.rmtree(outTempDir) + shutil.rmtree(doctreeTempDir) + + return html + + def __convertReSTDocutils(self, text, htmlFormat): + """ + Private method to convert ReST text into HTML using 'docutils'. + + @param text text to be processed (string) + @param htmlFormat HTML format to be generated (string) + @return processed HTML (string) + """ + if 'sphinx' in sys.modules: + # Make sure any Sphinx polution of docutils has been removed. + unloadKeys = [k for k in sys.modules.keys() + if k.startswith(('docutils', 'sphinx'))] + for key in unloadKeys: + sys.modules.pop(key) + + try: + import docutils.core # __IGNORE_EXCEPTION__ + import docutils.utils # __IGNORE_EXCEPTION__ + except ImportError: + return self.tr( + """<p>ReStructuredText preview requires the""" + """ <b>python-docutils</b> package.<br/>Install it with""" + """ your package manager, 'pip install docutils' or see""" + """ <a href="http://pypi.python.org/pypi/docutils">""" + """this page.</a></p>""") + + # redirect sys.stderr because we are not interested in it here + origStderr = sys.stderr + sys.stderr = io.StringIO() + try: + html = docutils.core.publish_string( + text, writer_name=htmlFormat.lower()).decode("utf-8") + except docutils.utils.SystemMessage as err: + errStr = str(err).split(":")[-1].replace("\n", "<br/>") + return self.tr( + """<p>Docutils returned an error:</p><p>{0}</p>""" + ).format(errStr) + + sys.stderr = origStderr + return html + + def __convertMarkdown(self, text, convertNewLineToBreak, + usePyMdownExtensions, htmlFormat): + """ + Private method to convert Markdown text into HTML. + + @param text text to be processed + @type str + @param convertNewLineToBreak flag indicating to convert new lines + to HTML break (Markdown only) + @type bool + @param usePyMdownExtensions flag indicating to enable the PyMdown + extensions, if they are available + @type bool + @param htmlFormat HTML format to be generated by markdown + @type str + @return processed HTML + @rtype str + """ + try: + import markdown # __IGNORE_EXCEPTION__ + except ImportError: + return self.tr( + """<p>Markdown preview requires the <b>Markdown</b> """ + """package.<br/>Install it with your package manager,""" + """ 'pip install Markdown' or see """ + """<a href="http://pythonhosted.org/Markdown/install.html">""" + """installation instructions.</a></p>""") + + from . import PreviewerHTMLStyles + from . import MarkdownExtensions + + extensions = [] + + mermaidNeeded = False + if ( + Preferences.getEditor("PreviewMarkdownMermaid") and + MarkdownExtensions.MermaidRegexFullText.search(text) + ): + extensions.append(MarkdownExtensions.MermaidExtension()) + mermaidNeeded = True + + if convertNewLineToBreak: + extensions.append('nl2br') + + pyMdown = False + if usePyMdownExtensions: + with contextlib.suppress(ImportError): + import pymdownx # __IGNORE_EXCEPTION__ __IGNORE_WARNING__ + # PyPI package is 'pymdown-extensions' + + extensions.extend([ + 'toc', + 'pymdownx.extra', 'pymdownx.caret', 'pymdownx.emoji', + 'pymdownx.mark', 'pymdownx.tilde', 'pymdownx.keys', + 'pymdownx.tasklist', 'pymdownx.smartsymbols', + ]) + pyMdown = True + + if not pyMdown: + extensions.extend(['extra', 'toc']) + + # version 2.0 supports only extension names, not instances + if ( + markdown.version_info[0] > 2 or + (markdown.version_info[0] == 2 and + markdown.version_info[1] > 0) + ): + extensions.append(MarkdownExtensions.SimplePatternExtension()) + + if Preferences.getEditor("PreviewMarkdownMathJax"): + mathjax = ( + "<script type='text/javascript' id='MathJax-script' async" + " src='https://cdn.jsdelivr.net/npm/mathjax@3/es5/" + "tex-chtml.js'>\n" + "</script>\n" + ) + # prepare text for mathjax + text = ( + text + .replace(r"\(", r"\\(") + .replace(r"\)", r"\\)") + .replace(r"\[", r"\\[") + .replace(r"\]", r"\\]") + ) + else: + mathjax = "" + + if mermaidNeeded: + mermaid = ( + "<script type='text/javascript' id='Mermaid-script'" + " src='https://unpkg.com/mermaid@8/dist/mermaid.min.js'>\n" + "</script>\n" + ) + if e5App().usesDarkPalette(): + mermaid_initialize = ( + "<script>mermaid.initialize({" + "theme: 'dark', " + "startOnLoad:true" + "});</script>" + ) + else: + mermaid_initialize = ( + "<script>mermaid.initialize({" + "theme: 'default', " + "startOnLoad:true" + "});</script>" + ) + else: + mermaid = "" + mermaid_initialize = "" + + htmlFormat = Preferences.getEditor("PreviewMarkdownHTMLFormat").lower() + body = markdown.markdown(text, extensions=extensions, + output_format=htmlFormat.lower()) + style = ( + (PreviewerHTMLStyles.css_markdown_dark + + PreviewerHTMLStyles.css_pygments_dark) + if e5App().usesDarkPalette() else + (PreviewerHTMLStyles.css_markdown_light + + PreviewerHTMLStyles.css_pygments_light) + ) + + if htmlFormat == "xhtml1": + head = ( + '''<!DOCTYPE html PUBLIC "-//W3C//DTD''' + ''' XHTML 1.0 Transitional//EN"\n''' + ''' "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional''' + '''.dtd">\n''' + '''<html xmlns="http://www.w3.org/1999/xhtml">\n''' + ) + elif htmlFormat == "html5": + head = ( + '''<!DOCTYPE html>\n''' + '''<html lang="EN">\n''' + ) + else: + head = '<html lang="EN">\n' + head += '''<head>\n''' + head += ( + '''<meta name="Generator" content="eric" />\n''' + '''<meta http-equiv="Content-Type" ''' + '''content="text/html; charset=utf-8" />\n''' + '''{0}''' + '''{1}''' + '''<style type="text/css">''' + '''{2}''' + '''</style>\n''' + '''</head>\n''' + '''<body>\n''' + ).format(mathjax, mermaid, style) + + foot = '''\n</body>\n</html>\n''' + + return head + body + mermaid_initialize + foot