rich_ipython_widget.py
348 lines
| 14.9 KiB
| text/x-python
|
PythonLexer
MinRK
|
r16486 | # Copyright (c) IPython Development Team. | ||
Matthias BUSSONNIER
|
r6636 | # Distributed under the terms of the Modified BSD License. | ||
Evan Patterson
|
r3304 | from base64 import decodestring | ||
MinRK
|
r3154 | import os | ||
import re | ||||
Evan Patterson
|
r3304 | |||
from IPython.external.qt import QtCore, QtGui | ||||
epatters
|
r2758 | |||
Carlos Cordoba
|
r17671 | from IPython.lib.latextools import latex_to_png | ||
MinRK
|
r16486 | from IPython.utils.path import ensure_dir_exists | ||
Matthias BUSSONNIER
|
r6638 | from IPython.utils.traitlets import Bool | ||
Fernando Perez
|
r11022 | from IPython.qt.svg import save_svg, svg_to_clipboard, svg_to_image | ||
Thomas Kluyver
|
r13347 | from .ipython_widget import IPythonWidget | ||
epatters
|
r2758 | |||
class RichIPythonWidget(IPythonWidget): | ||||
""" An IPythonWidget that supports rich text, including lists, images, and | ||||
tables. Note that raw performance will be reduced compared to the plain | ||||
text version. | ||||
""" | ||||
epatters
|
r2835 | # RichIPythonWidget protected class variables. | ||
MinRK
|
r9372 | _payload_source_plot = 'IPython.kernel.zmq.pylab.backend_payload.add_plot_payload' | ||
Matthias BUSSONNIER
|
r6638 | _jpg_supported = Bool(False) | ||
Ian Murray
|
r6866 | |||
# Used to determine whether a given html export attempt has already | ||||
# displayed a warning about being unable to convert a png to svg. | ||||
Ian Murray
|
r6865 | _svg_warning_displayed = False | ||
epatters
|
r2758 | #--------------------------------------------------------------------------- | ||
epatters
|
r2776 | # 'object' interface | ||
epatters
|
r2758 | #--------------------------------------------------------------------------- | ||
epatters
|
r2776 | def __init__(self, *args, **kw): | ||
epatters
|
r2758 | """ Create a RichIPythonWidget. | ||
""" | ||||
epatters
|
r2776 | kw['kind'] = 'rich' | ||
super(RichIPythonWidget, self).__init__(*args, **kw) | ||||
epatters
|
r3361 | |||
# Configure the ConsoleWidget HTML exporter for our formats. | ||||
self._html_exporter.image_tag = self._get_image_tag | ||||
epatters
|
r3364 | # Dictionary for resolving document resource names to SVG data. | ||
self._name_to_svg_map = {} | ||||
epatters
|
r2765 | |||
Matthias BUSSONNIER
|
r6636 | # Do we support jpg ? | ||
# it seems that sometime jpg support is a plugin of QT, so try to assume | ||||
# it is not always supported. | ||||
Matthias BUSSONNIER
|
r6638 | _supported_format = map(str, QtGui.QImageReader.supportedImageFormats()) | ||
self._jpg_supported = 'jpeg' in _supported_format | ||||
Matthias BUSSONNIER
|
r6636 | |||
epatters
|
r2765 | #--------------------------------------------------------------------------- | ||
Ian Murray
|
r6865 | # 'ConsoleWidget' public interface overides | ||
#--------------------------------------------------------------------------- | ||||
def export_html(self): | ||||
""" Shows a dialog to export HTML/XML in various formats. | ||||
Ian Murray
|
r6866 | Overridden in order to reset the _svg_warning_displayed flag prior | ||
to the export running. | ||||
Ian Murray
|
r6865 | """ | ||
self._svg_warning_displayed = False | ||||
super(RichIPythonWidget, self).export_html() | ||||
#--------------------------------------------------------------------------- | ||||
epatters
|
r2765 | # 'ConsoleWidget' protected interface | ||
#--------------------------------------------------------------------------- | ||||
epatters
|
r2990 | def _context_menu_make(self, pos): | ||
""" Reimplemented to return a custom context menu for images. | ||||
epatters
|
r2765 | """ | ||
format = self._control.cursorForPosition(pos).charFormat() | ||||
name = format.stringProperty(QtGui.QTextFormat.ImageName) | ||||
epatters
|
r3307 | if name: | ||
epatters
|
r2765 | menu = QtGui.QMenu() | ||
menu.addAction('Copy Image', lambda: self._copy_image(name)) | ||||
menu.addAction('Save Image As...', lambda: self._save_image(name)) | ||||
menu.addSeparator() | ||||
epatters
|
r3364 | svg = self._name_to_svg_map.get(name, None) | ||
if svg is not None: | ||||
epatters
|
r2765 | menu.addSeparator() | ||
menu.addAction('Copy SVG', lambda: svg_to_clipboard(svg)) | ||||
Bernardo B. Marques
|
r4872 | menu.addAction('Save SVG As...', | ||
epatters
|
r2765 | lambda: save_svg(svg, self._control)) | ||
epatters
|
r3307 | else: | ||
menu = super(RichIPythonWidget, self)._context_menu_make(pos) | ||||
epatters
|
r2990 | return menu | ||
Brian Granger
|
r3277 | |||
#--------------------------------------------------------------------------- | ||||
# 'BaseFrontendMixin' abstract interface | ||||
#--------------------------------------------------------------------------- | ||||
Matthias BUSSONNIER
|
r6638 | def _pre_image_append(self, msg, prompt_number): | ||
Min RK
|
r19736 | """Append the Out[] prompt and make the output nicer | ||
Matthias BUSSONNIER
|
r6638 | |||
Shared code for some the following if statement | ||||
""" | ||||
self._append_plain_text(self.output_sep, True) | ||||
self._append_html(self._make_out_prompt(prompt_number), True) | ||||
self._append_plain_text('\n', True) | ||||
Brian Granger
|
r3277 | |||
MinRK
|
r16568 | def _handle_execute_result(self, msg): | ||
Min RK
|
r19736 | """Overridden to handle rich data types, like SVG.""" | ||
self.log.debug("execute_result: %s", msg.get('content', '')) | ||||
MinRK
|
r18374 | if self.include_output(msg): | ||
Jonathan Frederic
|
r16194 | self.flush_clearoutput() | ||
Brian Granger
|
r3278 | content = msg['content'] | ||
Toby Gilham
|
r6153 | prompt_number = content.get('execution_count', 0) | ||
Brian Granger
|
r3278 | data = content['data'] | ||
MinRK
|
r11169 | metadata = msg['content']['metadata'] | ||
Bradley M. Froehle
|
r7859 | if 'image/svg+xml' in data: | ||
Matthias BUSSONNIER
|
r6638 | self._pre_image_append(msg, prompt_number) | ||
epatters
|
r4057 | self._append_svg(data['image/svg+xml'], True) | ||
self._append_html(self.output_sep2, True) | ||||
Bradley M. Froehle
|
r7859 | elif 'image/png' in data: | ||
Matthias BUSSONNIER
|
r6638 | self._pre_image_append(msg, prompt_number) | ||
MinRK
|
r11169 | png = decodestring(data['image/png'].encode('ascii')) | ||
self._append_png(png, True, metadata=metadata.get('image/png', None)) | ||||
epatters
|
r4057 | self._append_html(self.output_sep2, True) | ||
Bradley M. Froehle
|
r7859 | elif 'image/jpeg' in data and self._jpg_supported: | ||
Matthias BUSSONNIER
|
r6638 | self._pre_image_append(msg, prompt_number) | ||
MinRK
|
r11169 | jpg = decodestring(data['image/jpeg'].encode('ascii')) | ||
self._append_jpg(jpg, True, metadata=metadata.get('image/jpeg', None)) | ||||
Matthias BUSSONNIER
|
r6636 | self._append_html(self.output_sep2, True) | ||
Carlos Cordoba
|
r17671 | elif 'text/latex' in data: | ||
self._pre_image_append(msg, prompt_number) | ||||
Min RK
|
r19736 | self._append_latex(data['text/latex'], True) | ||
self._append_html(self.output_sep2, True) | ||||
Brian Granger
|
r3278 | else: | ||
# Default back to the plain text representation. | ||||
MinRK
|
r16568 | return super(RichIPythonWidget, self)._handle_execute_result(msg) | ||
Brian Granger
|
r3278 | |||
Brian Granger
|
r3277 | def _handle_display_data(self, msg): | ||
Min RK
|
r19736 | """Overridden to handle rich data types, like SVG.""" | ||
self.log.debug("display_data: %s", msg.get('content', '')) | ||||
MinRK
|
r18374 | if self.include_output(msg): | ||
Jonathan Frederic
|
r16194 | self.flush_clearoutput() | ||
Brian Granger
|
r3277 | data = msg['content']['data'] | ||
metadata = msg['content']['metadata'] | ||||
# Try to use the svg or html representations. | ||||
# FIXME: Is this the right ordering of things to try? | ||||
Min RK
|
r19730 | self.log.debug("display: %s", msg.get('content', '')) | ||
Bradley M. Froehle
|
r7859 | if 'image/svg+xml' in data: | ||
Brian Granger
|
r3277 | svg = data['image/svg+xml'] | ||
epatters
|
r4057 | self._append_svg(svg, True) | ||
Bradley M. Froehle
|
r7859 | elif 'image/png' in data: | ||
Brian Granger
|
r3279 | # PNG data is base64 encoded as it passes over the network | ||
# in a JSON structure so we decode it. | ||||
Grahame Bowland
|
r4773 | png = decodestring(data['image/png'].encode('ascii')) | ||
MinRK
|
r11169 | self._append_png(png, True, metadata=metadata.get('image/png', None)) | ||
Bradley M. Froehle
|
r7859 | elif 'image/jpeg' in data and self._jpg_supported: | ||
Matthias BUSSONNIER
|
r6636 | jpg = decodestring(data['image/jpeg'].encode('ascii')) | ||
MinRK
|
r11169 | self._append_jpg(jpg, True, metadata=metadata.get('image/jpeg', None)) | ||
Min RK
|
r19730 | elif 'text/latex' in data: | ||
Min RK
|
r19736 | self._append_latex(data['text/latex'], True) | ||
Brian Granger
|
r3277 | else: | ||
# Default back to the plain text representation. | ||||
return super(RichIPythonWidget, self)._handle_display_data(msg) | ||||
epatters
|
r2758 | #--------------------------------------------------------------------------- | ||
epatters
|
r2765 | # 'RichIPythonWidget' protected interface | ||
#--------------------------------------------------------------------------- | ||||
Min RK
|
r19736 | def _append_latex(self, latex, before_prompt=False, metadata=None): | ||
""" Append latex data to the widget.""" | ||||
try: | ||||
png = latex_to_png(latex, wrap=False) | ||||
except Exception as e: | ||||
self.log.error("Failed to render latex: '%s'", latex, exc_info=True) | ||||
Min RK
|
r19738 | self._append_plain_text("Failed to render latex: %s" % e, before_prompt) | ||
Min RK
|
r19736 | else: | ||
self._append_png(png, before_prompt, metadata) | ||||
MinRK
|
r11169 | def _append_jpg(self, jpg, before_prompt=False, metadata=None): | ||
Matthias BUSSONNIER
|
r6636 | """ Append raw JPG data to the widget.""" | ||
MinRK
|
r11169 | self._append_custom(self._insert_jpg, jpg, before_prompt, metadata=metadata) | ||
Matthias BUSSONNIER
|
r6636 | |||
MinRK
|
r11169 | def _append_png(self, png, before_prompt=False, metadata=None): | ||
epatters
|
r4057 | """ Append raw PNG data to the widget. | ||
Brian Granger
|
r3277 | """ | ||
MinRK
|
r11169 | self._append_custom(self._insert_png, png, before_prompt, metadata=metadata) | ||
Brian Granger
|
r3279 | |||
epatters
|
r4057 | def _append_svg(self, svg, before_prompt=False): | ||
""" Append raw SVG data to the widget. | ||||
Brian Granger
|
r3279 | """ | ||
epatters
|
r4057 | self._append_custom(self._insert_svg, svg, before_prompt) | ||
Brian Granger
|
r3277 | |||
epatters
|
r2765 | def _add_image(self, image): | ||
""" Adds the specified QImage to the document and returns a | ||||
QTextImageFormat that references it. | ||||
""" | ||||
document = self._control.document() | ||||
Evan Patterson
|
r3304 | name = str(image.cacheKey()) | ||
epatters
|
r2765 | document.addResource(QtGui.QTextDocument.ImageResource, | ||
QtCore.QUrl(name), image) | ||||
format = QtGui.QTextImageFormat() | ||||
format.setName(name) | ||||
return format | ||||
def _copy_image(self, name): | ||||
""" Copies the ImageResource with 'name' to the clipboard. | ||||
""" | ||||
image = self._get_image(name) | ||||
QtGui.QApplication.clipboard().setImage(image) | ||||
def _get_image(self, name): | ||||
""" Returns the QImage stored as the ImageResource with 'name'. | ||||
""" | ||||
document = self._control.document() | ||||
epatters
|
r3364 | image = document.resource(QtGui.QTextDocument.ImageResource, | ||
QtCore.QUrl(name)) | ||||
return image | ||||
epatters
|
r2765 | |||
epatters
|
r3361 | def _get_image_tag(self, match, path = None, format = "png"): | ||
Mark Voorhies
|
r3132 | """ Return (X)HTML mark-up for the image-tag given by match. | ||
Parameters | ||||
---------- | ||||
epatters
|
r3361 | match : re.SRE_Match | ||
Mark Voorhies
|
r3132 | 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] | ||||
epatters
|
r3361 | 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. | ||||
Mark Voorhies
|
r3132 | |||
Matthias BUSSONNIER
|
r6636 | format : "png"|"svg"|"jpg", optional [default "png"] | ||
Mark Voorhies
|
r3132 | Format for returned or referenced images. | ||
""" | ||||
Matthias BUSSONNIER
|
r6636 | if format in ("png","jpg"): | ||
Mark Voorhies
|
r3125 | try: | ||
image = self._get_image(match.group("name")) | ||||
except KeyError: | ||||
return "<b>Couldn't find image %s</b>" % match.group("name") | ||||
epatters
|
r3361 | if path is not None: | ||
MinRK
|
r16486 | ensure_dir_exists(path) | ||
MinRK
|
r3154 | relpath = os.path.basename(path) | ||
Matthias BUSSONNIER
|
r6637 | if image.save("%s/qt_img%s.%s" % (path, match.group("name"), format), | ||
epatters
|
r3361 | "PNG"): | ||
Matthias BUSSONNIER
|
r6636 | return '<img src="%s/qt_img%s.%s">' % (relpath, | ||
match.group("name"),format) | ||||
Mark Voorhies
|
r3125 | else: | ||
return "<b>Couldn't save image!</b>" | ||||
else: | ||||
ba = QtCore.QByteArray() | ||||
buffer_ = QtCore.QBuffer(ba) | ||||
buffer_.open(QtCore.QIODevice.WriteOnly) | ||||
Matthias BUSSONNIER
|
r6636 | image.save(buffer_, format.upper()) | ||
Mark Voorhies
|
r3125 | buffer_.close() | ||
Matthias BUSSONNIER
|
r6636 | return '<img src="data:image/%s;base64,\n%s\n" />' % ( | ||
format,re.sub(r'(.{60})',r'\1\n',str(ba.toBase64()))) | ||||
Mark Voorhies
|
r3125 | |||
epatters
|
r3361 | elif format == "svg": | ||
Mark Voorhies
|
r3125 | try: | ||
epatters
|
r3364 | svg = str(self._name_to_svg_map[match.group("name")]) | ||
Mark Voorhies
|
r3125 | except KeyError: | ||
Ian Murray
|
r6865 | if not self._svg_warning_displayed: | ||
Ian Murray
|
r6864 | QtGui.QMessageBox.warning(self, 'Error converting PNG to SVG.', | ||
MinRK
|
r12236 | 'Cannot convert PNG images to SVG, export with PNG figures instead. ' | ||
MinRK
|
r12293 | 'If you want to export matplotlib figures as SVG, add ' | ||
Ian Murray
|
r6866 | 'to your ipython config:\n\n' | ||
MinRK
|
r12236 | '\tc.InlineBackend.figure_format = \'svg\'\n\n' | ||
Ian Murray
|
r6864 | 'And regenerate the figures.', | ||
QtGui.QMessageBox.Ok) | ||||
self._svg_warning_displayed = True | ||||
MinRK
|
r12236 | return ("<b>Cannot convert PNG images to SVG.</b> " | ||
"You must export this session with PNG images. " | ||||
MinRK
|
r12293 | "If you want to export matplotlib figures as SVG, add to your config " | ||
MinRK
|
r12236 | "<span>c.InlineBackend.figure_format = 'svg'</span> " | ||
Ian Murray
|
r6866 | "and regenerate the figures.") | ||
Mark Voorhies
|
r3125 | |||
# Not currently checking path, because it's tricky to find a | ||||
# cross-browser way to embed external SVG images (e.g., via | ||||
# object or embed tags). | ||||
# Chop stand-alone header from matplotlib SVG | ||||
offset = svg.find("<svg") | ||||
assert(offset > -1) | ||||
Bernardo B. Marques
|
r4872 | |||
Mark Voorhies
|
r3125 | return svg[offset:] | ||
else: | ||||
return '<b>Unrecognized image format</b>' | ||||
Brian Granger
|
r3277 | |||
MinRK
|
r11169 | def _insert_jpg(self, cursor, jpg, metadata=None): | ||
Matthias BUSSONNIER
|
r6636 | """ Insert raw PNG data into the widget.""" | ||
MinRK
|
r11169 | self._insert_img(cursor, jpg, 'jpg', metadata=metadata) | ||
Matthias BUSSONNIER
|
r6636 | |||
MinRK
|
r11169 | def _insert_png(self, cursor, png, metadata=None): | ||
epatters
|
r4057 | """ Insert raw PNG data into the widget. | ||
""" | ||||
MinRK
|
r11169 | self._insert_img(cursor, png, 'png', metadata=metadata) | ||
Matthias BUSSONNIER
|
r6636 | |||
MinRK
|
r11169 | def _insert_img(self, cursor, img, fmt, metadata=None): | ||
Matthias BUSSONNIER
|
r6636 | """ insert a raw image, jpg or png """ | ||
MinRK
|
r11169 | if metadata: | ||
width = metadata.get('width', None) | ||||
height = metadata.get('height', None) | ||||
else: | ||||
width = height = None | ||||
epatters
|
r4057 | try: | ||
image = QtGui.QImage() | ||||
Matthias BUSSONNIER
|
r6636 | image.loadFromData(img, fmt.upper()) | ||
MinRK
|
r11169 | if width and height: | ||
MinRK
|
r11170 | image = image.scaled(width, height, transformMode=QtCore.Qt.SmoothTransformation) | ||
MinRK
|
r11169 | elif width and not height: | ||
MinRK
|
r11170 | image = image.scaledToWidth(width, transformMode=QtCore.Qt.SmoothTransformation) | ||
MinRK
|
r11169 | elif height and not width: | ||
MinRK
|
r11170 | image = image.scaledToHeight(height, transformMode=QtCore.Qt.SmoothTransformation) | ||
epatters
|
r4057 | except ValueError: | ||
Matthias BUSSONNIER
|
r6636 | self._insert_plain_text(cursor, 'Received invalid %s data.'%fmt) | ||
epatters
|
r4057 | else: | ||
format = self._add_image(image) | ||||
cursor.insertBlock() | ||||
cursor.insertImage(format) | ||||
cursor.insertBlock() | ||||
def _insert_svg(self, cursor, svg): | ||||
""" Insert raw SVG data into the widet. | ||||
""" | ||||
try: | ||||
image = svg_to_image(svg) | ||||
except ValueError: | ||||
self._insert_plain_text(cursor, 'Received invalid SVG data.') | ||||
else: | ||||
format = self._add_image(image) | ||||
self._name_to_svg_map[format.name()] = svg | ||||
cursor.insertBlock() | ||||
cursor.insertImage(format) | ||||
cursor.insertBlock() | ||||
epatters
|
r3361 | 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) | ||||