--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/QScintilla/Exporters/ExporterHTML.py Thu Jul 07 11:23:56 2022 +0200 @@ -0,0 +1,696 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2007 - 2022 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing an exporter for HTML. +""" + +# This code is a port of the C++ code found in SciTE 1.74 +# Original code: Copyright 1998-2006 by Neil Hodgson <neilh@scintilla.org> + +import os +import sys +import io +import contextlib + +from PyQt6.QtGui import QFontInfo +from PyQt6.QtWidgets import QInputDialog +from PyQt6.Qsci import QsciScintilla + +from EricWidgets import EricMessageBox +from EricGui.EricOverrideCursor import EricOverrideCursor + +from .ExporterBase import ExporterBase + +import Preferences +import Utilities + + +class HTMLGenerator: + """ + Class implementing an HTML generator for exporting source code. + """ + def __init__(self, editor): + """ + Constructor + + @param editor reference to the editor object (QScintilla.Editor.Editor) + """ + self.editor = editor + + def generate(self, tabSize=4, useTabs=False, wysiwyg=True, folding=False, + onlyStylesUsed=False, titleFullPath=False): + """ + Public method to generate HTML for the source editor. + + @param tabSize size of tabs (integer) + @param useTabs flag indicating the use of tab characters (boolean) + @param wysiwyg flag indicating colorization (boolean) + @param folding flag indicating usage of fold markers + @param onlyStylesUsed flag indicating to include only style + definitions for styles used in the source (boolean) + @param titleFullPath flag indicating to include the full file path + in the title tag (boolean) + @return generated HTML text (string) + """ + self.editor.recolor(0, -1) + + lengthDoc = self.editor.length() + styleIsUsed = {} + if onlyStylesUsed: + for index in range(QsciScintilla.STYLE_MAX + 1): + styleIsUsed[index] = False + # check the used styles + pos = 0 + while pos < lengthDoc: + styleIsUsed[self.editor.styleAt(pos) & 0x7F] = True + pos += 1 + else: + for index in range(QsciScintilla.STYLE_MAX + 1): + styleIsUsed[index] = True + styleIsUsed[QsciScintilla.STYLE_DEFAULT] = True + + html = ( + '''<!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''' + '''<head>\n''' + ) + if titleFullPath: + html += '''<title>{0}</title>\n'''.format( + self.editor.getFileName()) + else: + html += '''<title>{0}</title>\n'''.format( + os.path.basename(self.editor.getFileName())) + html += ( + '''<meta name="Generator" content="eric" />\n''' + '''<meta http-equiv="Content-Type" ''' + '''content="text/html; charset=utf-8" />\n''' + ) + if folding: + html += ( + '''<script language="JavaScript" type="text/javascript">\n''' + '''<!--\n''' + '''function symbol(id, sym) {\n''' + ''' if (id.textContent == undefined) {\n''' + ''' id.innerText = sym;\n''' + ''' } else {\n''' + ''' id.textContent = sym;\n''' + ''' }\n''' + '''}\n''' + '''function toggle(id) {\n''' + ''' var thislayer = document.getElementById('ln' + id);\n''' + ''' id -= 1;\n''' + ''' var togline = document.getElementById('hd' + id);\n''' + ''' var togsym = document.getElementById('bt' + id);\n''' + ''' if (thislayer.style.display == 'none') {\n''' + ''' thislayer.style.display = 'block';\n''' + ''' togline.style.textDecoration = 'none';\n''' + ''' symbol(togsym, '- ');\n''' + ''' } else {\n''' + ''' thislayer.style.display = 'none';\n''' + ''' togline.style.textDecoration = 'underline';\n''' + ''' symbol(togsym, '+ ');\n''' + ''' }\n''' + '''}\n''' + '''//-->\n''' + '''</script>\n''' + ) + + lex = self.editor.getLexer() + bgColour = ( + lex.paper(QsciScintilla.STYLE_DEFAULT).name() + if lex else + self.editor.paper().name() + ) + + html += '''<style type="text/css">\n''' + if lex: + istyle = 0 + while istyle <= QsciScintilla.STYLE_MAX: + if ( + (istyle <= QsciScintilla.STYLE_DEFAULT or + istyle > QsciScintilla.STYLE_LASTPREDEFINED) and + styleIsUsed[istyle] + ): + if ( + lex.description(istyle) or + istyle == QsciScintilla.STYLE_DEFAULT + ): + font = lex.font(istyle) + colour = lex.color(istyle) + paper = lex.paper(istyle) + if istyle == QsciScintilla.STYLE_DEFAULT: + html += '''span {\n''' + else: + html += '''.S{0:d} {{\n'''.format(istyle) + if font.italic(): + html += ''' font-style: italic;\n''' + if font.bold(): + html += ''' font-weight: bold;\n''' + if wysiwyg: + html += ''' font-family: '{0}';\n'''.format( + font.family()) + html += ''' color: {0};\n'''.format(colour.name()) + if ( + istyle != QsciScintilla.STYLE_DEFAULT and + bgColour != paper.name() + ): + html += ''' background: {0};\n'''.format( + paper.name()) + html += ''' text-decoration: inherit;\n''' + if wysiwyg: + html += ''' font-size: {0:d}pt;\n'''.format( + QFontInfo(font).pointSize()) + html += '''}\n''' + + # get substyles + subs_start, subs_count = self.editor.getSubStyleRange( + istyle) + for subs_idx in range(subs_count): + styleIsUsed[subs_idx - subs_start] = True + font = lex.font(subs_start + subs_idx) + colour = lex.color(subs_start + subs_idx) + paper = lex.paper(subs_start + subs_idx) + html += '.S{0:d} {{\n'.format( + subs_idx - subs_start) + if font.italic(): + html += ' font-style: italic;\n' + if font.bold(): + html += ' font-weight: bold;\n' + if wysiwyg: + html += " font-family: '{0}';\n".format( + font.family()) + html += ' color: {0};\n'.format(colour.name()) + if wysiwyg: + html += ' font-size: {0:d}pt;\n'.format( + QFontInfo(font).pointSize()) + html += '}\n' + # __IGNORE_WARNING_Y113__ + else: + styleIsUsed[istyle] = False + istyle += 1 + else: + colour = self.editor.color() + paper = self.editor.paper() + font = Preferences.getEditorOtherFonts("DefaultFont") + html += '''.S0 {\n''' + if font.italic(): + html += ''' font-style: italic;\n''' + if font.bold(): + html += ''' font-weight: bold;\n''' + if wysiwyg: + html += ''' font-family: '{0}';\n'''.format(font.family()) + html += ''' color: {0};\n'''.format(colour.name()) + if bgColour != paper.name(): + html += ''' background: {0};\n'''.format(paper.name()) + html += ''' text-decoration: inherit;\n''' + if wysiwyg: + html += ''' font-size: {0:d}pt;\n'''.format( + QFontInfo(font).pointSize()) + html += '''}\n''' + html += '''</style>\n''' + html += '''</head>\n''' + + html += '''<body bgcolor="{0}">\n'''.format(bgColour) + line = self.editor.lineAt(0) + level = self.editor.foldLevelAt(line) - QsciScintilla.SC_FOLDLEVELBASE + levelStack = [level] + styleCurrent = self.editor.styleAt(0) + inStyleSpan = False + inFoldSpan = False + # Global span for default attributes + if wysiwyg: + html += '''<span>''' + else: + html += '''<pre>''' + + if folding: + if ( + self.editor.foldFlagsAt(line) & + QsciScintilla.SC_FOLDLEVELHEADERFLAG + ): + html += ( + '''<span id="hd{0:d}" onclick="toggle('{1:d}')">''' + ).format(line, line + 1) + html += '''<span id="bt{0:d}">- </span>'''.format(line) + inFoldSpan = True + else: + html += ''' ''' + + if styleIsUsed[styleCurrent]: + html += '''<span class="S{0:0d}">'''.format(styleCurrent) + inStyleSpan = True + + column = 0 + pos = 0 + utf8 = self.editor.isUtf8() + utf8Ch = b"" + utf8Len = 0 + + while pos < lengthDoc: + ch = self.editor.byteAt(pos) + style = self.editor.styleAt(pos) + if style != styleCurrent: + if inStyleSpan: + html += '''</span>''' + inStyleSpan = False + if ch not in [b'\r', b'\n']: # no need of a span for the EOL + if styleIsUsed[style]: + html += '''<span class="S{0:d}">'''.format(style) + inStyleSpan = True + styleCurrent = style + + if ch == b' ': + if wysiwyg: + prevCh = b'' + if column == 0: + # at start of line, must put a + # because regular space will be collapsed + prevCh = b' ' + while pos < lengthDoc and self.editor.byteAt(pos) == b' ': + if prevCh != b' ': + html += ' ' + else: + html += ''' ''' + prevCh = self.editor.byteAt(pos) + pos += 1 + column += 1 + pos -= 1 + # the last incrementation will be done by the outer loop + else: + html += ' ' + column += 1 + elif ch == b'\t': + ts = tabSize - (column % tabSize) + if wysiwyg: + html += ''' ''' * ts + column += ts + else: + if useTabs: + html += '\t' + column += 1 + else: + html += ' ' * ts + column += ts + elif ch in [b'\r', b'\n']: + if inStyleSpan: + html += '''</span>''' + inStyleSpan = False + if inFoldSpan: + html += '''</span>''' + inFoldSpan = False + if ch == b'\r' and self.editor.byteAt(pos + 1) == b'\n': + pos += 1 # CR+LF line ending, skip the "extra" EOL char + column = 0 + if wysiwyg: + html += '''<br />''' + + styleCurrent = self.editor.styleAt(pos + 1) + if folding: + line = self.editor.lineAt(pos + 1) + newLevel = self.editor.foldLevelAt(line) + + if newLevel < level: + while levelStack[-1] > newLevel: + html += '''</span>''' + levelStack.pop() + html += '\n' # here to get clean code + if newLevel > level: + html += '''<span id="ln{0:d}">'''.format(line) + levelStack.append(newLevel) + if ( + self.editor.foldFlagsAt(line) & + QsciScintilla.SC_FOLDLEVELHEADERFLAG + ): + html += ( + '''<span id="hd{0:d}"''' + ''' onclick="toggle('{1:d}')">''' + ).format(line, line + 1) + html += '''<span id="bt{0:d}">- </span>'''.format(line) + inFoldSpan = True + else: + html += ''' ''' + level = newLevel + else: + html += '\n' + + if ( + styleIsUsed[styleCurrent] and + self.editor.byteAt(pos + 1) not in [b'\r', b'\n'] + ): + # We know it's the correct next style, + # but no (empty) span for an empty line + html += '''<span class="S{0:0d}">'''.format(styleCurrent) + inStyleSpan = True + else: + if ch == b'<': + html += '''<''' + elif ch == b'>': + html += '''>''' + elif ch == b'&': + html += '''&''' + else: + if ord(ch) > 127 and utf8: + utf8Ch += ch + if utf8Len == 0: + if (utf8Ch[0] & 0xF0) == 0xF0: + utf8Len = 4 + elif (utf8Ch[0] & 0xE0) == 0xE0: + utf8Len = 3 + elif (utf8Ch[0] & 0xC0) == 0xC0: + utf8Len = 2 + column -= 1 # will be incremented again later + elif len(utf8Ch) == utf8Len: + ch = utf8Ch.decode('utf8') + html += Utilities.html_encode(ch) + utf8Ch = b"" + utf8Len = 0 + else: + column -= 1 # will be incremented again later + else: + html += ch.decode() + column += 1 + + pos += 1 + + if inStyleSpan: + html += '''</span>''' + + if folding: + while levelStack: + html += '''</span>''' + levelStack.pop() + + if wysiwyg: + html += '''</span>''' + else: + html += '''</pre>''' + + html += '''</body>\n</html>\n''' + + return html + + +class ExporterHTML(ExporterBase): + """ + Class implementing an exporter for HTML. + """ + def __init__(self, editor, parent=None): + """ + Constructor + + @param editor reference to the editor object (QScintilla.Editor.Editor) + @param parent parent object of the exporter (QObject) + """ + ExporterBase.__init__(self, editor, parent) + + def exportSource(self): + """ + Public method performing the export. + """ + filename = self._getFileName(self.tr("HTML Files (*.html)")) + if not filename: + return + + fn = self.editor.getFileName() + extension = os.path.normcase(os.path.splitext(fn)[1][1:]) if fn else "" + + if ( + extension in Preferences.getEditor( + "PreviewMarkdownFileNameExtensions") or + self.editor.getLanguage().lower() == "markdown" + ): + # export markdown to HTML + colorSchemes = [ + self.tr("Light Background Color"), + self.tr("Dark Background Color"), + ] + colorScheme, ok = QInputDialog.getItem( + None, + self.tr("Markdown Export"), + self.tr("Select color scheme:"), + colorSchemes, + 0, False) + if ok: + colorSchemeIndex = colorSchemes.index(colorScheme) + else: + # light background as default + colorSchemeIndex = 0 + with EricOverrideCursor(): + html = self.__generateFromMarkdown(colorSchemeIndex == 1) + elif ( + extension in Preferences.getEditor( + "PreviewRestFileNameExtensions") or + self.editor.getLanguage().lower() == "restructuredtext" + ): + # export ReST to HTML + with EricOverrideCursor(): + html = self.__generateFromReSTDocutils() + else: + tabSize = self.editor.getEditorConfig("TabWidth") + if tabSize == 0: + tabSize = 4 + wysiwyg = Preferences.getEditorExporter("HTML/WYSIWYG") + folding = Preferences.getEditorExporter("HTML/Folding") + onlyStylesUsed = Preferences.getEditorExporter( + "HTML/OnlyStylesUsed") + titleFullPath = Preferences.getEditorExporter( + "HTML/FullPathAsTitle") + tabs = Preferences.getEditorExporter("HTML/UseTabs") + + with EricOverrideCursor(): + generator = HTMLGenerator(self.editor) + html = generator.generate( + tabSize=tabSize, + useTabs=tabs, + wysiwyg=wysiwyg, + folding=folding, + onlyStylesUsed=onlyStylesUsed, + titleFullPath=titleFullPath + ) + + if html: + with EricOverrideCursor(), open(filename, "w", encoding="utf-8" + ) as f: + try: + f.write(html) + except OSError as err: + EricMessageBox.critical( + self.editor, + self.tr("Export source"), + self.tr( + """<p>The source could not be exported to""" + """ <b>{0}</b>.</p><p>Reason: {1}</p>""") + .format(filename, str(err))) + else: + EricMessageBox.critical( + self.editor, + self.tr("Export source"), + self.tr( + """<p>The source could not be exported to""" + """ <b>{0}</b>.</p><p>Reason: No HTML code""" + """ generated.</p>""") + .format(filename)) + + def __generateFromReSTDocutils(self): + """ + Private method to convert ReST text into HTML using 'docutils'. + + @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: + EricMessageBox.critical( + self.editor, + self.tr("Export source"), + self.tr( + """<p>ReStructuredText export 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>""") + ) + return "" + + htmlFormat = Preferences.getEditor( + "PreviewRestDocutilsHTMLFormat").lower() + # redirect sys.stderr because we are not interested in it here + origStderr = sys.stderr + sys.stderr = io.StringIO() + html = docutils.core.publish_string( + self.editor.text(), writer_name=htmlFormat).decode("utf-8") + sys.stderr = origStderr + return html + + def __generateFromMarkdown(self, useDarkScheme): + """ + Private method to convert Markdown text into HTML. + + @param useDarkScheme flag indicating to export using a dark color + scheme + @type bool + @return processed HTML + @rtype str + """ + try: + import markdown # __IGNORE_EXCEPTION__ + except ImportError: + EricMessageBox.critical( + self.editor, + self.tr("Export source"), + self.tr( + """<p>Markdown export 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>""") + ) + return "" + + from UI.Previewers import PreviewerHTMLStyles + from UI.Previewers import MarkdownExtensions + + extensions = [] + + text = self.editor.text() + + mermaidNeeded = False + if ( + Preferences.getEditor("PreviewMarkdownMermaid") and + MarkdownExtensions.MermaidRegexFullText.search(text) + ): + extensions.append(MarkdownExtensions.MermaidExtension()) + mermaidNeeded = True + + if Preferences.getEditor("PreviewMarkdownNLtoBR"): + extensions.append('nl2br') + + pyMdown = False + if Preferences.getEditor("PreviewMarkdownUsePyMdownExtensions"): + 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 useDarkScheme: + 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) + style = ( + (PreviewerHTMLStyles.css_markdown_dark + + PreviewerHTMLStyles.css_pygments_dark) + if useDarkScheme 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''' + if Preferences.getEditorExporter("HTML/FullPathAsTitle"): + head += '''<title>{0}</title>\n'''.format( + self.editor.getFileName()) + else: + head += '''<title>{0}</title>\n'''.format( + os.path.basename(self.editor.getFileName())) + 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