|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2013 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing a previewer widget for HTML, Markdown and ReST files. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import threading |
|
12 import re |
|
13 |
|
14 from PyQt4.QtCore import pyqtSlot, pyqtSignal, Qt, QTimer, QSize, QUrl, QThread |
|
15 from PyQt4.QtGui import QWidget |
|
16 from PyQt4.QtWebKit import QWebPage |
|
17 |
|
18 from E5Gui.E5Application import e5App |
|
19 |
|
20 from .Ui_Previewer import Ui_Previewer |
|
21 |
|
22 import Preferences |
|
23 import Utilities |
|
24 |
|
25 |
|
26 class Previewer(QWidget, Ui_Previewer): |
|
27 """ |
|
28 Class implementing a previewer widget for HTML, Markdown and ReST files. |
|
29 """ |
|
30 def __init__(self, viewmanager, splitter, parent=None): |
|
31 """ |
|
32 Constructor |
|
33 |
|
34 @param viewmanager reference to the viewmanager object (ViewManager) |
|
35 @param splitter reference to the embedding splitter (QSplitter) |
|
36 @param parent reference to the parent widget (QWidget) |
|
37 """ |
|
38 super().__init__(parent) |
|
39 self.setupUi(self) |
|
40 |
|
41 self.__vm = viewmanager |
|
42 self.__splitter = splitter |
|
43 |
|
44 self.__firstShow = True |
|
45 |
|
46 self.previewView.page().setLinkDelegationPolicy(QWebPage.DelegateAllLinks) |
|
47 |
|
48 # Don't update too often because the UI might become sluggish |
|
49 self.__typingTimer = QTimer() |
|
50 self.__typingTimer.setInterval(500) # 500ms |
|
51 self.__typingTimer.timeout.connect(self.__runProcessingThread) |
|
52 |
|
53 self.__scrollBarPositions = {} |
|
54 self.__vScrollBarAtEnd = {} |
|
55 self.__hScrollBarAtEnd = {} |
|
56 |
|
57 self.__processingThread = PreviewProcessingThread() |
|
58 self.__processingThread.htmlReady.connect(self.__setHtml) |
|
59 |
|
60 self.__previewedPath = None |
|
61 |
|
62 self.__vm.editorChangedEd.connect(self.__editorChanged) |
|
63 self.__vm.editorLanguageChanged.connect(self.__editorLanguageChanged) |
|
64 self.__vm.editorTextChanged.connect(self.__editorTextChanged) |
|
65 |
|
66 self.__vm.previewStateChanged.connect(self.__previewStateChanged) |
|
67 |
|
68 self.__splitter.splitterMoved.connect(self.__splitterMoved) |
|
69 |
|
70 self.hide() |
|
71 |
|
72 def show(self): |
|
73 """ |
|
74 Public method to show the preview widget. |
|
75 """ |
|
76 super().show() |
|
77 if self.__firstShow: |
|
78 self.__splitter.restoreState(Preferences.getUI("PreviewSplitterState")) |
|
79 self.jsCheckBox.setChecked(Preferences.getUI("ShowFilePreviewJS")) |
|
80 self.ssiCheckBox.setChecked(Preferences.getUI("ShowFilePreviewSSI")) |
|
81 self.__firstShow = False |
|
82 self.__typingTimer.start() |
|
83 |
|
84 def hide(self): |
|
85 """ |
|
86 Public method to hide the preview widget. |
|
87 """ |
|
88 super().hide() |
|
89 self.__typingTimer.stop() |
|
90 |
|
91 def shutdown(self): |
|
92 """ |
|
93 Public method to perform shutdown actions. |
|
94 """ |
|
95 self.__typingTimer.stop() |
|
96 self.__processingThread.wait() |
|
97 |
|
98 def __splitterMoved(self): |
|
99 """ |
|
100 Private slot to handle the movement of the embedding splitter's handle. |
|
101 """ |
|
102 state = self.__splitter.saveState() |
|
103 Preferences.setUI("PreviewSplitterState", state) |
|
104 |
|
105 @pyqtSlot(bool) |
|
106 def on_jsCheckBox_clicked(self, checked): |
|
107 """ |
|
108 Private slot to enable/disable JavaScript. |
|
109 |
|
110 @param checked state of the checkbox (boolean) |
|
111 """ |
|
112 Preferences.setUI("ShowFilePreviewJS", checked) |
|
113 self.__setJavaScriptEnabled(checked) |
|
114 |
|
115 def __setJavaScriptEnabled(self, enable): |
|
116 """ |
|
117 Private method to enable/disable JavaScript. |
|
118 |
|
119 @param enable flag indicating the enable state (boolean) |
|
120 """ |
|
121 self.jsCheckBox.setChecked(enable) |
|
122 |
|
123 settings = self.previewView.settings() |
|
124 settings.setAttribute(settings.JavascriptEnabled, enable) |
|
125 |
|
126 self.__runProcessingThread() |
|
127 |
|
128 @pyqtSlot(bool) |
|
129 def on_ssiCheckBox_clicked(self, checked): |
|
130 """ |
|
131 Private slot to enable/disable SSI. |
|
132 |
|
133 @param checked state of the checkbox (boolean) |
|
134 """ |
|
135 Preferences.setUI("ShowFilePreviewSSI", checked) |
|
136 self.__runProcessingThread() |
|
137 |
|
138 def __editorChanged(self, editor): |
|
139 """ |
|
140 Private slot to handle a change of the current editor. |
|
141 |
|
142 @param editor reference to the editor (Editor) |
|
143 """ |
|
144 if editor is None: |
|
145 self.hide() |
|
146 return |
|
147 |
|
148 if Preferences.getUI("ShowFilePreview") and self.__isPreviewable(editor): |
|
149 self.show() |
|
150 self.__runProcessingThread() |
|
151 else: |
|
152 self.hide() |
|
153 |
|
154 def __editorLanguageChanged(self, editor): |
|
155 """ |
|
156 Private slot to handle a change of the current editor's language. |
|
157 |
|
158 @param editor reference to the editor (Editor) |
|
159 """ |
|
160 self.__editorChanged(editor) |
|
161 |
|
162 def __editorTextChanged(self, editor): |
|
163 """ |
|
164 Private slot to handle changes of an editor's text. |
|
165 |
|
166 @param editor reference to the editor (Editor) |
|
167 """ |
|
168 if self.isVisible(): |
|
169 self.__typingTimer.stop() |
|
170 self.__typingTimer.start() |
|
171 |
|
172 def __previewStateChanged(self, on): |
|
173 """ |
|
174 Public slot to toggle the display of the preview. |
|
175 |
|
176 @param on flag indicating to show a preview (boolean) |
|
177 """ |
|
178 editor = self.__vm.activeWindow() |
|
179 if on and self.__isPreviewable(editor): |
|
180 self.show() |
|
181 else: |
|
182 self.hide() |
|
183 |
|
184 def __isPreviewable(self, editor): |
|
185 """ |
|
186 Private method to check, if a preview can be shown for the given editor. |
|
187 |
|
188 @param editor reference to an editor (Editor) |
|
189 @return flag indicating if a preview can be shown (boolean) |
|
190 """ |
|
191 if editor.getFileName() is not None: |
|
192 extension = os.path.normcase(os.path.splitext(editor.getFileName())[1][1:]) |
|
193 return extension in \ |
|
194 Preferences.getEditor("PreviewHtmlFileNameExtensions") + \ |
|
195 Preferences.getEditor("PreviewMarkdownFileNameExtensions") + \ |
|
196 Preferences.getEditor("PreviewRestFileNameExtensions") |
|
197 elif editor.getLanguage() == "HTML": |
|
198 return True |
|
199 else: |
|
200 return False |
|
201 |
|
202 def __runProcessingThread(self): |
|
203 """ |
|
204 Private slot to schedule the processing of the current editor's text. |
|
205 """ |
|
206 self.__typingTimer.stop() |
|
207 |
|
208 editor = self.__vm.activeWindow() |
|
209 if editor is not None: |
|
210 fn = editor.getFileName() |
|
211 |
|
212 if fn: |
|
213 extension = os.path.normcase(os.path.splitext(fn)[1][1:]) |
|
214 else: |
|
215 extension = "" |
|
216 if extension in \ |
|
217 Preferences.getEditor("PreviewHtmlFileNameExtensions") or \ |
|
218 editor.getLanguage() == "HTML": |
|
219 language = "HTML" |
|
220 elif extension in \ |
|
221 Preferences.getEditor("PreviewMarkdownFileNameExtensions"): |
|
222 language = "Markdown" |
|
223 elif extension in \ |
|
224 Preferences.getEditor("PreviewRestFileNameExtensions"): |
|
225 language = "ReST" |
|
226 else: |
|
227 self.__setHtml(fn, |
|
228 self.trUtf8("<p>No preview available for this type of file.</p>")) |
|
229 return |
|
230 |
|
231 if fn: |
|
232 project = e5App().getObject("Project") |
|
233 if project.isProjectFile(fn): |
|
234 rootPath = project.getProjectPath() |
|
235 else: |
|
236 rootPath = os.path.dirname(os.path.abspath(fn)) |
|
237 else: |
|
238 rootPath = "" |
|
239 |
|
240 self.__processingThread.process( |
|
241 fn, language, editor.text(), |
|
242 self.ssiCheckBox.isChecked(), rootPath) |
|
243 |
|
244 def __setHtml(self, filePath, html): |
|
245 """ |
|
246 Private method to set the HTML to the view and restore the scroll bars positions. |
|
247 |
|
248 @param filePath file path of the previewed editor (string) |
|
249 @param html processed HTML text ready to be shown (string) |
|
250 """ |
|
251 self.__saveScrollBarPositions() |
|
252 self.__previewedPath = Utilities.normcasepath( |
|
253 Utilities.fromNativeSeparators(filePath)) |
|
254 self.previewView.page().mainFrame().contentsSizeChanged.connect( |
|
255 self.__restoreScrollBarPositions) |
|
256 self.previewView.setHtml(html, baseUrl=QUrl.fromLocalFile(filePath)) |
|
257 |
|
258 @pyqtSlot(str) |
|
259 def on_previewView_titleChanged(self, title): |
|
260 """ |
|
261 Private slot to handle a change of the title. |
|
262 |
|
263 @param title new title (string) |
|
264 """ |
|
265 if title: |
|
266 self.titleLabel.setText(self.trUtf8("Preview - {0}").format(title)) |
|
267 else: |
|
268 self.titleLabel.setText(self.trUtf8("Preview")) |
|
269 |
|
270 def __saveScrollBarPositions(self): |
|
271 """ |
|
272 Private method to save scroll bar positions for a previewed editor. |
|
273 """ |
|
274 frame = self.previewView.page().mainFrame() |
|
275 if frame.contentsSize() == QSize(0, 0): |
|
276 return # no valid data, nothing to save |
|
277 |
|
278 pos = frame.scrollPosition() |
|
279 self.__scrollBarPositions[self.__previewedPath] = pos |
|
280 self.__hScrollBarAtEnd[self.__previewedPath] = \ |
|
281 frame.scrollBarMaximum(Qt.Horizontal) == pos.x() |
|
282 self.__vScrollBarAtEnd[self.__previewedPath] = \ |
|
283 frame.scrollBarMaximum(Qt.Vertical) == pos.y() |
|
284 |
|
285 def __restoreScrollBarPositions(self): |
|
286 """ |
|
287 Private method to restore scroll bar positions for a previewed editor. |
|
288 """ |
|
289 try: |
|
290 self.previewView.page().mainFrame().contentsSizeChanged.disconnect( |
|
291 self.__restoreScrollBarPositions) |
|
292 except TypeError: |
|
293 # not connected, simply ignore it |
|
294 pass |
|
295 |
|
296 if self.__previewedPath not in self.__scrollBarPositions: |
|
297 return |
|
298 |
|
299 frame = self.previewView.page().mainFrame() |
|
300 frame.setScrollPosition(self.__scrollBarPositions[self.__previewedPath]) |
|
301 |
|
302 if self.__hScrollBarAtEnd[self.__previewedPath]: |
|
303 frame.setScrollBarValue(Qt.Horizontal, frame.scrollBarMaximum(Qt.Horizontal)) |
|
304 |
|
305 if self.__vScrollBarAtEnd[self.__previewedPath]: |
|
306 frame.setScrollBarValue(Qt.Vertical, frame.scrollBarMaximum(Qt.Vertical)) |
|
307 |
|
308 @pyqtSlot(QUrl) |
|
309 def on_previewView_linkClicked(self, url): |
|
310 """ |
|
311 Private slot handling the clicking of a link. |
|
312 |
|
313 @param url url of the clicked link (QUrl) |
|
314 """ |
|
315 e5App().getObject("UserInterface").launchHelpViewer(url.toString()) |
|
316 |
|
317 |
|
318 class PreviewProcessingThread(QThread): |
|
319 """ |
|
320 Class implementing a thread to process some text into HTML usable by the |
|
321 previewer view. |
|
322 |
|
323 @signal htmlReady(str,str) emitted with the file name and processed HTML to signal |
|
324 the availability of the processed HTML |
|
325 """ |
|
326 htmlReady = pyqtSignal(str, str) |
|
327 |
|
328 def __init__(self, parent=None): |
|
329 """ |
|
330 Constructor |
|
331 |
|
332 @param parent reference to the parent object (QObject) |
|
333 """ |
|
334 super().__init__() |
|
335 |
|
336 self.__lock = threading.Lock() |
|
337 |
|
338 def process(self, filePath, language, text, ssiEnabled, rootPath): |
|
339 """ |
|
340 Convert the given text to HTML. |
|
341 |
|
342 @param filePath file path of the text (string) |
|
343 @param language language of the text (string) |
|
344 @param text text to be processed (string) |
|
345 @param ssiEnabled flag indicating to do some (limited) SSI processing (boolean) |
|
346 @param rootPath root path to be used for SSI processing (str) |
|
347 """ |
|
348 with self.__lock: |
|
349 self.__filePath = filePath |
|
350 self.__language = language |
|
351 self.__text = text |
|
352 self.__ssiEnabled = ssiEnabled |
|
353 self.__rootPath = rootPath |
|
354 self.__haveData = True |
|
355 if not self.isRunning(): |
|
356 self.start(QThread.LowPriority) |
|
357 |
|
358 def run(self): |
|
359 """ |
|
360 Thread function to convert the stored data. |
|
361 """ |
|
362 while True: |
|
363 # exits with break |
|
364 with self.__lock: |
|
365 filePath = self.__filePath |
|
366 language = self.__language |
|
367 text = self.__text |
|
368 ssiEnabled = self.__ssiEnabled |
|
369 rootPath = self.__rootPath |
|
370 self.__haveData = False |
|
371 |
|
372 html = self.__getHtml(language, text, ssiEnabled, filePath, rootPath) |
|
373 |
|
374 with self.__lock: |
|
375 if not self.__haveData: |
|
376 self.htmlReady.emit(filePath, html) |
|
377 break |
|
378 # else - next iteration |
|
379 |
|
380 def __getHtml(self, language, text, ssiEnabled, filePath, rootPath): |
|
381 """ |
|
382 Private method to process the given text depending upon the given language. |
|
383 |
|
384 @param language language of the text (string) |
|
385 @param text to be processed (string) |
|
386 @param ssiEnabled flag indicating to do some (limited) SSI processing (boolean) |
|
387 @param filePath file path of the text (string) |
|
388 @param rootPath root path to be used for SSI processing (str) |
|
389 """ |
|
390 if language == "HTML": |
|
391 if ssiEnabled: |
|
392 return self.__processSSI(text, filePath, rootPath) |
|
393 else: |
|
394 return text |
|
395 elif language == "Markdown": |
|
396 return self.__convertMarkdown(text) |
|
397 elif language == "ReST": |
|
398 return self.__convertReST(text) |
|
399 else: |
|
400 return self.trUtf8("<p>No preview available for this type of file.</p>") |
|
401 |
|
402 def __processSSI(self, txt, filename, root): |
|
403 """ |
|
404 Private method to process the given text for SSI statements. |
|
405 |
|
406 Note: Only a limited subset of SSI statements are supported. |
|
407 |
|
408 @param txt text to be processed (string) |
|
409 @param filename name of the file associated with the given text (string) |
|
410 @param root directory of the document root (string) |
|
411 @return processed HTML (string) |
|
412 """ |
|
413 if not filename: |
|
414 return txt |
|
415 |
|
416 # SSI include |
|
417 incRe = re.compile( |
|
418 r"""<!--#include[ \t]+(virtual|file)=[\"']([^\"']+)[\"']\s*-->""", |
|
419 re.IGNORECASE) |
|
420 baseDir = os.path.dirname(os.path.abspath(filename)) |
|
421 docRoot = root if root != "" else baseDir |
|
422 while True: |
|
423 incMatch = incRe.search(txt) |
|
424 if incMatch is None: |
|
425 break |
|
426 |
|
427 if incMatch.group(1) == "virtual": |
|
428 incFile = Utilities.normjoinpath(docRoot, incMatch.group(2)) |
|
429 elif incMatch.group(1) == "file": |
|
430 incFile = Utilities.normjoinpath(baseDir, incMatch.group(2)) |
|
431 else: |
|
432 incFile = "" |
|
433 if os.path.exists(incFile): |
|
434 try: |
|
435 f = open(incFile, "r") |
|
436 incTxt = f.read() |
|
437 f.close() |
|
438 except (IOError, OSError): |
|
439 # remove SSI include |
|
440 incTxt = "" |
|
441 else: |
|
442 # remove SSI include |
|
443 incTxt = "" |
|
444 txt = txt[:incMatch.start(0)] + incTxt + txt[incMatch.end(0):] |
|
445 |
|
446 return txt |
|
447 |
|
448 def __convertReST(self, text): |
|
449 """ |
|
450 Private method to convert ReST text into HTML. |
|
451 |
|
452 @param text text to be processed (string) |
|
453 @return processed HTML (string) |
|
454 """ |
|
455 try: |
|
456 import docutils.core # __IGNORE_EXCEPTION__ __IGNORE_WARNING__ |
|
457 except ImportError: |
|
458 return self.trUtf8( |
|
459 """<p>ReStructuredText preview requires the <b>python-docutils</b> """ |
|
460 """package.<br/>Install it with your package manager or see """ |
|
461 """<a href="http://pypi.python.org/pypi/docutils">this page.</a></p>""") |
|
462 |
|
463 return docutils.core.publish_string(text, writer_name='html').decode("utf-8") |
|
464 |
|
465 def __convertMarkdown(self, text): |
|
466 """ |
|
467 Private method to convert Markdown text into HTML. |
|
468 |
|
469 @param text text to be processed (string) |
|
470 @return processed HTML (string) |
|
471 """ |
|
472 try: |
|
473 import markdown # __IGNORE_EXCEPTION__ __IGNORE_WARNING__ |
|
474 except ImportError: |
|
475 return self.trUtf8( |
|
476 """<p>Markdown preview requires the <b>python-markdown</b> """ |
|
477 """package.<br/>Install it with your package manager or see """ |
|
478 """<a href="http://pythonhosted.org/Markdown/install.html">""" |
|
479 """installation instructions.</a></p>""") |
|
480 |
|
481 try: |
|
482 import mdx_mathjax # __IGNORE_EXCEPTION__ __IGNORE_WARNING__ |
|
483 except ImportError: |
|
484 #mathjax doesn't require import statement if installed as extension |
|
485 pass |
|
486 |
|
487 extensions = ['fenced_code', 'nl2br'] |
|
488 |
|
489 # version 2.0 supports only extension names, not instances |
|
490 if markdown.version_info[0] > 2 or \ |
|
491 (markdown.version_info[0] == 2 and markdown.version_info[1] > 0): |
|
492 |
|
493 class _StrikeThroughExtension(markdown.Extension): |
|
494 """ |
|
495 Class is placed here, because it depends on imported markdown, |
|
496 and markdown import is lazy. |
|
497 |
|
498 (see |
|
499 <a href="http://achinghead.com/python-markdown-adding-insert-delete.html"> |
|
500 this page for details</a>) |
|
501 """ |
|
502 DEL_RE = r'(~~)(.*?)~~' |
|
503 def extendMarkdown(self, md, md_globals): |
|
504 # Create the del pattern |
|
505 del_tag = markdown.inlinepatterns.SimpleTagPattern(self.DEL_RE, 'del') |
|
506 # Insert del pattern into markdown parser |
|
507 md.inlinePatterns.add('del', del_tag, '>not_strong') |
|
508 |
|
509 extensions.append(_StrikeThroughExtension()) |
|
510 |
|
511 try: |
|
512 return markdown.markdown(text, extensions + ['mathjax']) |
|
513 except (ImportError, ValueError): |
|
514 # markdown raises ValueError or ImportError, depends on version |
|
515 # It is not clear, how to distinguish missing mathjax from other errors. |
|
516 # So keep going without mathjax. |
|
517 return markdown.markdown(text, extensions) |