Mon, 10 Jul 2017 19:12:12 +0200
Adjustment of the HTML previewer to newer QWebEngine releases.
# -*- coding: utf-8 -*- # Copyright (c) 2014 - 2017 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing a previewer widget for HTML, Markdown and ReST files. """ from __future__ import unicode_literals try: # Only for Py2 basestring import StringIO as io # __IGNORE_EXCEPTION__ except (ImportError, NameError): import io # __IGNORE_WARNING__ import os import threading import re import shutil import tempfile import sys from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QUrl, QSize, QThread from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QCheckBox, \ QSizePolicy 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(PreviewerHTML, self).__init__(parent) self.__layout = QVBoxLayout(self) self.titleLabel = QLabel(self) self.titleLabel.setWordWrap(True) self.titleLabel.setTextInteractionFlags(Qt.NoTextInteraction) self.__layout.addWidget(self.titleLabel) try: from PyQt5.QtWebEngineWidgets import QWebEngineView self.previewView = QWebEngineView(self) self.__usesWebKit = False except ImportError: from PyQt5.QtWebKitWidgets import QWebPage, QWebView self.previewView = QWebView(self) self.previewView.page().setLinkDelegationPolicy( QWebPage.DelegateAllLinks) self.__usesWebKit = True sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) sizePolicy.setHorizontalStretch(0) sizePolicy.setVerticalStretch(0) sizePolicy.setHeightForWidth( self.previewView.sizePolicy().hasHeightForWidth()) self.previewView.setSizePolicy(sizePolicy) self.previewView.setContextMenuPolicy(Qt.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) if self.__usesWebKit: self.previewView.linkClicked.connect( self.on_previewView_linkClicked) 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. """ 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() def processEditor(self, editor=None): """ Public slot to process an editor's text. @param editor editor to be processed (Editor) """ 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")) def __setHtml(self, filePath, html): """ Private method to set the HTML to the view and restore the scroll bars positions. @param filePath file path of the previewed editor (string) @param html processed HTML text ready to be shown (string) """ self.__previewedPath = Utilities.normcasepath( Utilities.fromNativeSeparators(filePath)) self.__saveScrollBarPositions() if self.__usesWebKit: self.previewView.page().mainFrame().contentsSizeChanged.connect( self.__restoreScrollBarPositions) else: self.previewView.page().loadFinished.connect( self.__restoreScrollBarPositions) if not filePath: filePath = "/" self.previewView.setHtml(html, baseUrl=QUrl.fromLocalFile(filePath)) 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. """ if self.__usesWebKit: frame = self.previewView.page().mainFrame() if frame.contentsSize() == QSize(0, 0): return # no valid data, nothing to save pos = frame.scrollPosition() self.__scrollBarPositions[self.__previewedPath] = pos self.__hScrollBarAtEnd[self.__previewedPath] = \ frame.scrollBarMaximum(Qt.Horizontal) == pos.x() self.__vScrollBarAtEnd[self.__previewedPath] = \ frame.scrollBarMaximum(Qt.Vertical) == pos.y() else: 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;" "})()" ) if pos is not None: pos = QPoint(pos["x"], pos["y"]) else: pos = QPoint(0, 0) 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.__usesWebKit: try: self.previewView.page().mainFrame().contentsSizeChanged.\ disconnect(self.__restoreScrollBarPositions) except TypeError: # not connected, simply ignore it pass if self.__previewedPath not in self.__scrollBarPositions: return frame = self.previewView.page().mainFrame() frame.setScrollPosition( self.__scrollBarPositions[self.__previewedPath]) if self.__hScrollBarAtEnd[self.__previewedPath]: frame.setScrollBarValue( Qt.Horizontal, frame.scrollBarMaximum(Qt.Horizontal)) if self.__vScrollBarAtEnd[self.__previewedPath]: frame.setScrollBarValue( Qt.Vertical, frame.scrollBarMaximum(Qt.Vertical)) else: 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())) @pyqtSlot(QUrl) def on_previewView_linkClicked(self, url): """ Private slot handling the clicking of a link. @param url url of the clicked link (QUrl) """ e5App().getObject("UserInterface").launchHelpViewer(url.toString()) 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) emitted with the file name and processed HTML to signal the availability of the processed HTML """ htmlReady = pyqtSignal(str, str) def __init__(self, parent=None): """ Constructor @param parent reference to the parent object (QObject) """ super(PreviewProcessingThread, self).__init__() self.__lock = threading.Lock() def process(self, filePath, language, text, ssiEnabled, rootPath, useSphinx): """ Public method to convert the given text to HTML. @param filePath file path of the text (string) @param language language of the text (string) @param text text to be processed (string) @param ssiEnabled flag indicating to do some (limited) SSI processing (boolean) @param rootPath root path to be used for SSI processing (str) @param useSphinx flag indicating to use Sphinx to generate the ReST preview (boolean) """ with self.__lock: self.__filePath = filePath self.__language = language self.__text = text self.__ssiEnabled = ssiEnabled self.__rootPath = rootPath self.__haveData = True self.__useSphinx = useSphinx if not self.isRunning(): self.start(QThread.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 self.__haveData = False html = self.__getHtml(language, text, ssiEnabled, filePath, rootPath, useSphinx) with self.__lock: if not self.__haveData: self.htmlReady.emit(filePath, html) break # else - next iteration def __getHtml(self, language, text, ssiEnabled, filePath, rootPath, useSphinx): """ Private method to process the given text depending upon the given language. @param language language of the text (string) @param text to be processed (string) @param ssiEnabled flag indicating to do some (limited) SSI processing (boolean) @param filePath file path of the text (string) @param rootPath root path to be used for SSI processing (str) @param useSphinx flag indicating to use Sphinx to generate the ReST preview (boolean) @return processed HTML text (string) """ if language == "HTML": if ssiEnabled: return self.__processSSI(text, filePath, rootPath) else: return text elif language == "Markdown": return self.__convertMarkdown(text) elif language == "ReST": return self.__convertReST(text, useSphinx) 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: f = open(incFile, "r") incTxt = f.read() f.close() except (IOError, OSError): # remove SSI include incTxt = "" else: # remove SSI include incTxt = "" txt = txt[:incMatch.start(0)] + incTxt + txt[incMatch.end(0):] return txt def __convertReST(self, text, useSphinx): """ 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) @return processed HTML (string) """ if useSphinx: return self.__convertReSTSphinx(text) else: return self.__convertReSTDocutils(text) 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>""") tempDir = tempfile.mkdtemp(prefix='eric-rest-') try: filename = 'sphinx_preview' basePath = os.path.join(tempDir, filename) fh = open(basePath + '.rst', 'w', encoding='utf-8') fh.write(text) fh.close() 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=tempDir, confdir=None, outdir=tempDir, doctreedir=tempDir, buildername='html', confoverrides=overrides, status=None, warning=io.StringIO()) app.build(force_all=True, filenames=None) fh = open(basePath + '.html', 'r', encoding='utf-8') html = fh.read() fh.close() finally: shutil.rmtree(tempDir) return html def __convertReSTDocutils(self, text): """ Private method to convert ReST text into HTML using 'docutils'. @param text text to be processed (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__ 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() html = docutils.core.publish_string(text, writer_name='html')\ .decode("utf-8") sys.stderr = origStderr return html def __convertMarkdown(self, text): """ Private method to convert Markdown text into HTML. @param text text to be processed (string) @return processed HTML (string) """ try: import markdown # __IGNORE_EXCEPTION__ except ImportError: return self.tr( """<p>Markdown preview requires the <b>python-markdown</b> """ """package.<br/>Install it with your package manager,""" """ 'pip install docutils' or see """ """<a href="http://pythonhosted.org/Markdown/install.html">""" """installation instructions.</a></p>""") try: import mdx_mathjax # __IGNORE_EXCEPTION__ __IGNORE_WARNING__ except ImportError: #mathjax doesn't require import statement if installed as extension pass extensions = ['fenced_code', 'nl2br', 'extra'] # 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): class _StrikeThroughExtension(markdown.Extension): """ Class is placed here, because it depends on imported markdown, and markdown import is lazy. (see http://achinghead.com/ python-markdown-adding-insert-delete.html this page for details) """ DEL_RE = r'(~~)(.*?)~~' def extendMarkdown(self, md, md_globals): # Create the del pattern del_tag = markdown.inlinepatterns.SimpleTagPattern( self.DEL_RE, 'del') # Insert del pattern into markdown parser md.inlinePatterns.add('del', del_tag, '>not_strong') extensions.append(_StrikeThroughExtension()) try: return markdown.markdown(text, extensions + ['mathjax']) except (ImportError, ValueError): # markdown raises ValueError or ImportError, depends on version # It is not clear, how to distinguish missing mathjax from other # errors. So keep going without mathjax. return markdown.markdown(text, extensions)