From ee1265cafdc23dc9a874b5b0810704059afb16d1 2011-03-12 00:58:23 From: epatters Date: 2011-03-12 00:58:23 Subject: [PATCH] Refactored ConsoleWidget's HTML exportaton code + other minor code cleanup. --- diff --git a/IPython/frontend/qt/console/console_widget.py b/IPython/frontend/qt/console/console_widget.py index 2a1ce29..490baab 100644 --- a/IPython/frontend/qt/console/console_widget.py +++ b/IPython/frontend/qt/console/console_widget.py @@ -17,6 +17,7 @@ from IPython.external.qt import QtCore, QtGui # Local imports from IPython.config.configurable import Configurable +from IPython.frontend.qt.rich_text import HtmlExporter from IPython.frontend.qt.util import MetaQObjectHasTraits, get_font from IPython.utils.traitlets import Bool, Enum, Int from ansi_code_processor import QtAnsiCodeProcessor @@ -170,6 +171,7 @@ class ConsoleWidget(Configurable, QtGui.QWidget): self._executing = False self._filter_drag = False self._filter_resize = False + self._html_exporter = HtmlExporter(self._control) self._prompt = '' self._prompt_html = None self._prompt_pos = 0 @@ -178,8 +180,6 @@ class ConsoleWidget(Configurable, QtGui.QWidget): self._reading_callback = None self._tab_width = 8 self._text_completing_pos = 0 - self._filename = 'ipython.html' - self._png_mode=None # Set a monospaced font. self.reset_font() @@ -190,7 +190,7 @@ class ConsoleWidget(Configurable, QtGui.QWidget): printkey = QtGui.QKeySequence(QtGui.QKeySequence.Print) if printkey.matches("Ctrl+P") and sys.platform != 'darwin': # Only override the default if there is a collision. - # Qt ctrl = cmd on OSX, so the match gets a false positive on darwin. + # Qt ctrl = cmd on OSX, so the match gets a false positive on OSX. printkey = "Ctrl+Shift+P" action.setShortcut(printkey) action.triggered.connect(self.print_) @@ -198,9 +198,8 @@ class ConsoleWidget(Configurable, QtGui.QWidget): self._print_action = action action = QtGui.QAction('Save as HTML/XML', None) - action.setEnabled(self.can_export()) action.setShortcut(QtGui.QKeySequence.Save) - action.triggered.connect(self.export) + action.triggered.connect(self._html_exporter.export) self.addAction(action) self._export_action = action @@ -210,7 +209,6 @@ class ConsoleWidget(Configurable, QtGui.QWidget): action.triggered.connect(self.select_all) self.addAction(action) self._select_all_action = action - def eventFilter(self, obj, event): """ Reimplemented to ensure a console-like behavior in the underlying @@ -348,12 +346,6 @@ class ConsoleWidget(Configurable, QtGui.QWidget): return bool(QtGui.QApplication.clipboard().text()) return False - def can_export(self): - """Returns whether we can export. Currently only rich widgets - can export html. - """ - return self.kind == "rich" - def clear(self, keep_input=True): """ Clear the console. @@ -561,194 +553,6 @@ class ConsoleWidget(Configurable, QtGui.QWidget): return self._control.print_(printer) - def export(self, parent = None): - """Export HTML/XML in various modes from one Dialog.""" - parent = parent or None # sometimes parent is False - dialog = QtGui.QFileDialog(parent, 'Save Console as...') - dialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) - filters = [ - 'HTML with PNG figures (*.html *.htm)', - 'XHTML with inline SVG figures (*.xhtml *.xml)' - ] - dialog.setNameFilters(filters) - if self._filename: - dialog.selectFile(self._filename) - root,ext = os.path.splitext(self._filename) - if ext.lower() in ('.xml', '.xhtml'): - dialog.selectNameFilter(filters[-1]) - if dialog.exec_(): - filename = str(dialog.selectedFiles()[0]) - self._filename = filename - choice = str(dialog.selectedNameFilter()) - - if choice.startswith('XHTML'): - exporter = self.export_xhtml - else: - exporter = self.export_html - - try: - return exporter(filename) - except Exception, e: - title = self.window().windowTitle() - msg = "Error while saving to: %s\n"%filename+str(e) - reply = QtGui.QMessageBox.warning(self, title, msg, - QtGui.QMessageBox.Ok, QtGui.QMessageBox.Ok) - return None - - def export_html(self, filename): - """ Export the contents of the ConsoleWidget as HTML. - - Parameters: - ----------- - filename : str - The file to be saved. - inline : bool, optional [default True] - If True, include images as inline PNGs. Otherwise, - include them as links to external PNG files, mimicking - web browsers' "Web Page, Complete" behavior. - """ - # N.B. this is overly restrictive, but Qt's output is - # predictable... - img_re = re.compile(r'') - html = self.fix_html_encoding( - str(self._control.toHtml().toUtf8())) - if self._png_mode: - # preference saved, don't ask again - if img_re.search(html): - inline = (self._png_mode == 'inline') - else: - inline = True - elif img_re.search(html): - # there are images - widget = QtGui.QWidget() - layout = QtGui.QVBoxLayout(widget) - title = self.window().windowTitle() - msg = "Exporting HTML with PNGs" - info = "Would you like inline PNGs (single large html file) or "+\ - "external image files?" - checkbox = QtGui.QCheckBox("&Don't ask again") - checkbox.setShortcut('D') - ib = QtGui.QPushButton("&Inline", self) - ib.setShortcut('I') - eb = QtGui.QPushButton("&External", self) - eb.setShortcut('E') - box = QtGui.QMessageBox(QtGui.QMessageBox.Question, title, msg) - box.setInformativeText(info) - box.addButton(ib,QtGui.QMessageBox.NoRole) - box.addButton(eb,QtGui.QMessageBox.YesRole) - box.setDefaultButton(ib) - layout.setSpacing(0) - layout.addWidget(box) - layout.addWidget(checkbox) - widget.setLayout(layout) - widget.show() - reply = box.exec_() - inline = (reply == 0) - if checkbox.checkState(): - # don't ask anymore, always use this choice - if inline: - self._png_mode='inline' - else: - self._png_mode='external' - else: - # no images - inline = True - - if inline: - path = None - else: - root,ext = os.path.splitext(filename) - path = root+"_files" - if os.path.isfile(path): - raise OSError("%s exists, but is not a directory."%path) - - f = open(filename, 'w') - try: - f.write(img_re.sub( - lambda x: self.image_tag(x, path = path, format = "png"), - html)) - except Exception, e: - f.close() - raise e - else: - f.close() - return filename - - - def export_xhtml(self, filename): - """ Export the contents of the ConsoleWidget as XHTML with inline SVGs. - """ - f = open(filename, 'w') - try: - # N.B. this is overly restrictive, but Qt's output is - # predictable... - img_re = re.compile(r'') - html = str(self._control.toHtml().toUtf8()) - # Hack to make xhtml header -- note that we are not doing - # any check for valid xml - offset = html.find("") - assert(offset > -1) - html = ('\n'+ - html[offset+6:]) - # And now declare UTF-8 encoding - html = self.fix_html_encoding(html) - f.write(img_re.sub( - lambda x: self.image_tag(x, path = None, format = "svg"), - html)) - except Exception, e: - f.close() - raise e - else: - f.close() - return filename - - def fix_html_encoding(self, html): - """ Return html string, with a UTF-8 declaration added to . - - Assumes that html is Qt generated and has already been UTF-8 encoded - and coerced to a python string. If the expected head element is - not found, the given object is returned unmodified. - - This patching is needed for proper rendering of some characters - (e.g., indented commands) when viewing exported HTML on a local - system (i.e., without seeing an encoding declaration in an HTTP - header). - - C.f. http://www.w3.org/International/O-charset for details. - """ - offset = html.find("") - if(offset > -1): - html = (html[:offset+6]+ - '\n\n'+ - html[offset+6:]) - - return html - - def image_tag(self, match, path = None, format = "png"): - """ Return (X)HTML mark-up for the image-tag given by match. - - Parameters - ---------- - match : re.SRE_Match - A match to an HTML image tag as exported by Qt, with - match.group("Name") containing the matched image ID. - - path : string|None, optional [default None] - If not None, specifies a path to which supporting files - may be written (e.g., for linked images). - If None, all images are to be included inline. - - format : "png"|"svg", optional [default "png"] - Format for returned or referenced images. - - Subclasses supporting image display should override this - method. - """ - - # Default case -- not enough information to generate tag - return "" - def prompt_to_top(self): """ Moves the prompt to the top of the viewport. """ @@ -1722,10 +1526,6 @@ class ConsoleWidget(Configurable, QtGui.QWidget): """ Called immediately after a prompt is finished, i.e. when some input will be processed and a new prompt displayed. """ - # Flush all state from the input splitter so the next round of - # reading input starts with a clean buffer. - self._input_splitter.reset() - self._control.setReadOnly(True) self._prompt_finished_hook() @@ -1882,6 +1682,7 @@ class ConsoleWidget(Configurable, QtGui.QWidget): diff = maximum - scrollbar.maximum() scrollbar.setRange(0, maximum) scrollbar.setPageStep(step) + # Compensate for undesirable scrolling that occurs automatically due to # maximumBlockCount() text truncation. if diff < 0 and document.blockCount() == document.maximumBlockCount(): diff --git a/IPython/frontend/qt/console/frontend_widget.py b/IPython/frontend/qt/console/frontend_widget.py index 79cf0c1..79a627f 100644 --- a/IPython/frontend/qt/console/frontend_widget.py +++ b/IPython/frontend/qt/console/frontend_widget.py @@ -143,8 +143,9 @@ class FrontendWidget(HistoryConsoleWidget, BaseFrontendMixin): document = self._control.document() document.contentsChange.connect(self._document_contents_change) - # set flag for whether we are connected via localhost - self._local_kernel = kw.get('local_kernel', FrontendWidget._local_kernel) + # Set flag for whether we are connected via localhost. + self._local_kernel = kw.get('local_kernel', + FrontendWidget._local_kernel) #--------------------------------------------------------------------------- # 'ConsoleWidget' public interface @@ -192,6 +193,10 @@ class FrontendWidget(HistoryConsoleWidget, BaseFrontendMixin): """ Called immediately after a prompt is finished, i.e. when some input will be processed and a new prompt displayed. """ + # Flush all state from the input splitter so the next round of + # reading input starts with a clean buffer. + self._input_splitter.reset() + if not self._reading: self._highlighter.highlighting_on = False @@ -383,7 +388,8 @@ class FrontendWidget(HistoryConsoleWidget, BaseFrontendMixin): title = self.window().windowTitle() if not msg['content']['restart']: reply = QtGui.QMessageBox.question(self, title, - "Kernel has been shutdown permanently. Close the Console?", + "Kernel has been shutdown permanently. " + "Close the Console?", QtGui.QMessageBox.Yes,QtGui.QMessageBox.No) if reply == QtGui.QMessageBox.Yes: sys.exit(0) diff --git a/IPython/frontend/qt/console/rich_ipython_widget.py b/IPython/frontend/qt/console/rich_ipython_widget.py index 1338a42..f289cc8 100644 --- a/IPython/frontend/qt/console/rich_ipython_widget.py +++ b/IPython/frontend/qt/console/rich_ipython_widget.py @@ -30,8 +30,12 @@ class RichIPythonWidget(IPythonWidget): """ kw['kind'] = 'rich' super(RichIPythonWidget, self).__init__(*args, **kw) - # Dictionary for resolving Qt names to images when - # generating XHTML output + + # Configure the ConsoleWidget HTML exporter for our formats. + self._html_exporter.image_tag = self._get_image_tag + + # Dictionary for resolving Qt names to images when generating XHTML + # output self._name_to_svg = {} #--------------------------------------------------------------------------- @@ -194,51 +198,35 @@ class RichIPythonWidget(IPythonWidget): QtCore.QUrl(name)) return variant.toPyObject() - def _save_image(self, name, format='PNG'): - """ Shows a save dialog for the ImageResource with 'name'. - """ - dialog = QtGui.QFileDialog(self._control, 'Save Image') - dialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) - dialog.setDefaultSuffix(format.lower()) - dialog.setNameFilter('%s file (*.%s)' % (format, format.lower())) - if dialog.exec_(): - filename = dialog.selectedFiles()[0] - image = self._get_image(name) - image.save(filename, format) - - def image_tag(self, match, path = None, format = "png"): + def _get_image_tag(self, match, path = None, format = "png"): """ Return (X)HTML mark-up for the image-tag given by match. Parameters ---------- - match : re.SRE_Match + match : re.SRE_Match A match to an HTML image tag as exported by Qt, with match.group("Name") containing the matched image ID. path : string|None, optional [default None] - If not None, specifies a path to which supporting files - may be written (e.g., for linked images). - If None, all images are to be included inline. + If not None, specifies a path to which supporting files may be + written (e.g., for linked images). If None, all images are to be + included inline. format : "png"|"svg", optional [default "png"] Format for returned or referenced images. - - Subclasses supporting image display should override this - method. """ - - if(format == "png"): + if format == "png": try: image = self._get_image(match.group("name")) except KeyError: return "Couldn't find image %s" % match.group("name") - if(path is not None): + if path is not None: if not os.path.exists(path): os.mkdir(path) relpath = os.path.basename(path) - if(image.save("%s/qt_img%s.png" % (path,match.group("name")), - "PNG")): + if image.save("%s/qt_img%s.png" % (path,match.group("name")), + "PNG"): return '' % (relpath, match.group("name")) else: @@ -252,7 +240,7 @@ class RichIPythonWidget(IPythonWidget): return '' % ( re.sub(r'(.{60})',r'\1\n',str(ba.toBase64()))) - elif(format == "svg"): + elif format == "svg": try: svg = str(self._name_to_svg[match.group("name")]) except KeyError: @@ -271,3 +259,14 @@ class RichIPythonWidget(IPythonWidget): else: return 'Unrecognized image format' + def _save_image(self, name, format='PNG'): + """ Shows a save dialog for the ImageResource with 'name'. + """ + dialog = QtGui.QFileDialog(self._control, 'Save Image') + dialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) + dialog.setDefaultSuffix(format.lower()) + dialog.setNameFilter('%s file (*.%s)' % (format, format.lower())) + if dialog.exec_(): + filename = dialog.selectedFiles()[0] + image = self._get_image(name) + image.save(filename, format) diff --git a/IPython/frontend/qt/rich_text.py b/IPython/frontend/qt/rich_text.py new file mode 100644 index 0000000..6f17dab --- /dev/null +++ b/IPython/frontend/qt/rich_text.py @@ -0,0 +1,233 @@ +""" Defines classes and functions for working with Qt's rich text system. +""" +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +from __future__ import with_statement + +# Standard library imports. +import os +import re + +# System library imports. +from IPython.external.qt import QtGui + +#----------------------------------------------------------------------------- +# Constants +#----------------------------------------------------------------------------- + +# A regular expression for matching images in rich text HTML. +# Note that this is overly restrictive, but Qt's output is predictable... +IMG_RE = re.compile(r'') + +#----------------------------------------------------------------------------- +# Classes +#----------------------------------------------------------------------------- + +class HtmlExporter(object): + """ A stateful HTML exporter for a Q(Plain)TextEdit. + + This class is designed for convenient user interaction. + """ + + def __init__(self, control): + """ Creates an HtmlExporter for the given Q(Plain)TextEdit. + """ + assert isinstance(control, (QtGui.QPlainTextEdit, QtGui.QTextEdit)) + self.control = control + self.filename = 'ipython.html' + self.image_tag = None + self.inline_png = None + + def export(self): + """ Displays a dialog for exporting HTML generated by Qt's rich text + system. + + Returns + ------- + The name of the file that was saved, or None if no file was saved. + """ + parent = self.control.window() + dialog = QtGui.QFileDialog(parent, 'Save as...') + dialog.setAcceptMode(QtGui.QFileDialog.AcceptSave) + filters = [ + 'HTML with PNG figures (*.html *.htm)', + 'XHTML with inline SVG figures (*.xhtml *.xml)' + ] + dialog.setNameFilters(filters) + if self.filename: + dialog.selectFile(self.filename) + root,ext = os.path.splitext(self.filename) + if ext.lower() in ('.xml', '.xhtml'): + dialog.selectNameFilter(filters[-1]) + + if dialog.exec_(): + self.filename = dialog.selectedFiles()[0] + choice = dialog.selectedNameFilter() + html = self.control.document().toHtml().encode('utf-8') + + # Configure the exporter. + if choice.startswith('XHTML'): + exporter = export_xhtml + else: + # If there are PNGs, decide how to export them. + inline = self.inline_png + if inline is None and IMG_RE.search(html): + dialog = QtGui.QDialog(parent) + dialog.setWindowTitle('Save as...') + layout = QtGui.QVBoxLayout(dialog) + msg = "Exporting HTML with PNGs" + info = "Would you like inline PNGs (single large html " \ + "file) or external image files?" + checkbox = QtGui.QCheckBox("&Don't ask again") + checkbox.setShortcut('D') + ib = QtGui.QPushButton("&Inline") + ib.setShortcut('I') + eb = QtGui.QPushButton("&External") + eb.setShortcut('E') + box = QtGui.QMessageBox(QtGui.QMessageBox.Question, + dialog.windowTitle(), msg) + box.setInformativeText(info) + box.addButton(ib, QtGui.QMessageBox.NoRole) + box.addButton(eb, QtGui.QMessageBox.YesRole) + box.setDefaultButton(ib) + layout.setSpacing(0) + layout.addWidget(box) + layout.addWidget(checkbox) + dialog.setLayout(layout) + dialog.show() + reply = box.exec_() + dialog.hide() + inline = (reply == 0) + if checkbox.checkState(): + # Don't ask anymore; always use this choice. + self.inline_png = inline + exporter = lambda h, f, i: export_html(h, f, i, inline) + + # Perform the export! + try: + return exporter(html, self.filename, self.image_tag) + except Exception, e: + title = self.window().windowTitle() + msg = "Error while saving to: %s\n" % filename + str(e) + reply = QtGui.QMessageBox.warning(parent, title, msg, + QtGui.QMessageBox.Ok, QtGui.QMessageBox.Ok) + + return None + +#----------------------------------------------------------------------------- +# Functions +#----------------------------------------------------------------------------- + +def export_html(html, filename, image_tag = None, inline = True): + """ Export the contents of the ConsoleWidget as HTML. + + Parameters: + ----------- + html : str, + A utf-8 encoded Python string containing the Qt HTML to export. + + filename : str + The file to be saved. + + image_tag : callable, optional (default None) + Used to convert images. See ``default_image_tag()`` for information. + + inline : bool, optional [default True] + If True, include images as inline PNGs. Otherwise, include them as + links to external PNG files, mimicking web browsers' "Web Page, + Complete" behavior. + """ + if image_tag is None: + image_tag = default_image_tag + + if inline: + path = None + else: + root,ext = os.path.splitext(filename) + path = root + "_files" + if os.path.isfile(path): + raise OSError("%s exists, but is not a directory." % path) + + with open(filename, 'w') as f: + html = fix_html_encoding(html) + f.write(IMG_RE.sub(lambda x: image_tag(x, path = path, format = "png"), + html)) + + +def export_xhtml(html, filename, image_tag=None): + """ Export the contents of the ConsoleWidget as XHTML with inline SVGs. + + Parameters: + ----------- + html : str, + A utf-8 encoded Python string containing the Qt HTML to export. + + filename : str + The file to be saved. + + image_tag : callable, optional (default None) + Used to convert images. See ``default_image_tag()`` for information. + """ + if image_tag is None: + image_tag = default_image_tag + + with open(filename, 'w') as f: + # Hack to make xhtml header -- note that we are not doing any check for + # valid XML. + offset = html.find("") + assert(offset > -1) + html = ('\n'+ + html[offset+6:]) + + html = fix_html_encoding(html) + f.write(IMG_RE.sub(lambda x: image_tag(x, path = None, format = "svg"), + html)) + + +def default_image_tag(match, path = None, format = "png"): + """ Return (X)HTML mark-up for the image-tag given by match. + + This default implementation merely removes the image, and exists mostly + for documentation purposes. More information than is present in the Qt + HTML is required to supply the images. + + Parameters + ---------- + match : re.SRE_Match + A match to an HTML image tag as exported by Qt, with match.group("Name") + containing the matched image ID. + + path : string|None, optional [default None] + If not None, specifies a path to which supporting files may be written + (e.g., for linked images). If None, all images are to be included + inline. + + format : "png"|"svg", optional [default "png"] + Format for returned or referenced images. + """ + return '' + + +def fix_html_encoding(html): + """ Return html string, with a UTF-8 declaration added to . + + Assumes that html is Qt generated and has already been UTF-8 encoded + and coerced to a python string. If the expected head element is + not found, the given object is returned unmodified. + + This patching is needed for proper rendering of some characters + (e.g., indented commands) when viewing exported HTML on a local + system (i.e., without seeing an encoding declaration in an HTTP + header). + + C.f. http://www.w3.org/International/O-charset for details. + """ + offset = html.find('') + if offset > -1: + html = (html[:offset+6]+ + '\n\n'+ + html[offset+6:]) + return html