# Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. from base64 import b64decode import os import re from warnings import warn from qtpy import QtCore, QtGui, QtWidgets from ipython_genutils.path import ensure_dir_exists from traitlets import Bool from qtconsole.svg import save_svg, svg_to_clipboard, svg_to_image from .jupyter_widget import JupyterWidget try: from IPython.lib.latextools import latex_to_png except ImportError: latex_to_png = None class LatexError(Exception): """Exception for Latex errors""" class RichIPythonWidget(JupyterWidget): """Dummy class for config inheritance. Destroyed below.""" class RichJupyterWidget(RichIPythonWidget): """ An JupyterWidget that supports rich text, including lists, images, and tables. Note that raw performance will be reduced compared to the plain text version. """ # RichJupyterWidget protected class variables. _payload_source_plot = 'ipykernel.pylab.backend_payload.add_plot_payload' _jpg_supported = Bool(False) # Used to determine whether a given html export attempt has already # displayed a warning about being unable to convert a png to svg. _svg_warning_displayed = False #--------------------------------------------------------------------------- # 'object' interface #--------------------------------------------------------------------------- def __init__(self, *args, **kw): """ Create a RichJupyterWidget. """ kw['kind'] = 'rich' super(RichJupyterWidget, self).__init__(*args, **kw) # Configure the ConsoleWidget HTML exporter for our formats. self._html_exporter.image_tag = self._get_image_tag # Dictionary for resolving document resource names to SVG data. self._name_to_svg_map = {} # Do we support jpg ? # it seems that sometime jpg support is a plugin of QT, so try to assume # it is not always supported. self._jpg_supported = 'jpeg' in QtGui.QImageReader.supportedImageFormats() #--------------------------------------------------------------------------- # 'ConsoleWidget' public interface overides #--------------------------------------------------------------------------- def export_html(self): """ Shows a dialog to export HTML/XML in various formats. Overridden in order to reset the _svg_warning_displayed flag prior to the export running. """ self._svg_warning_displayed = False super(RichJupyterWidget, self).export_html() #--------------------------------------------------------------------------- # 'ConsoleWidget' protected interface #--------------------------------------------------------------------------- def _context_menu_make(self, pos): """ Reimplemented to return a custom context menu for images. """ format = self._control.cursorForPosition(pos).charFormat() name = format.stringProperty(QtGui.QTextFormat.ImageName) if name: menu = QtWidgets.QMenu(self) menu.addAction('Copy Image', lambda: self._copy_image(name)) menu.addAction('Save Image As...', lambda: self._save_image(name)) menu.addSeparator() svg = self._name_to_svg_map.get(name, None) if svg is not None: menu.addSeparator() menu.addAction('Copy SVG', lambda: svg_to_clipboard(svg)) menu.addAction('Save SVG As...', lambda: save_svg(svg, self._control)) else: menu = super(RichJupyterWidget, self)._context_menu_make(pos) return menu #--------------------------------------------------------------------------- # 'BaseFrontendMixin' abstract interface #--------------------------------------------------------------------------- def _pre_image_append(self, msg, prompt_number): """Append the Out[] prompt and make the output nicer 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) def _handle_execute_result(self, msg): """Overridden to handle rich data types, like SVG.""" self.log.debug("execute_result: %s", msg.get('content', '')) if self.include_output(msg): self.flush_clearoutput() content = msg['content'] prompt_number = content.get('execution_count', 0) data = content['data'] metadata = msg['content']['metadata'] if 'image/svg+xml' in data: self._pre_image_append(msg, prompt_number) self._append_svg(data['image/svg+xml'], True) self._append_html(self.output_sep2, True) elif 'image/png' in data: self._pre_image_append(msg, prompt_number) png = b64decode(data['image/png'].encode('ascii')) self._append_png(png, True, metadata=metadata.get('image/png', None)) self._append_html(self.output_sep2, True) elif 'image/jpeg' in data and self._jpg_supported: self._pre_image_append(msg, prompt_number) jpg = b64decode(data['image/jpeg'].encode('ascii')) self._append_jpg(jpg, True, metadata=metadata.get('image/jpeg', None)) self._append_html(self.output_sep2, True) elif 'text/latex' in data: self._pre_image_append(msg, prompt_number) try: self._append_latex(data['text/latex'], True) except LatexError: return super(RichJupyterWidget, self)._handle_display_data(msg) self._append_html(self.output_sep2, True) else: # Default back to the plain text representation. return super(RichJupyterWidget, self)._handle_execute_result(msg) def _handle_display_data(self, msg): """Overridden to handle rich data types, like SVG.""" self.log.debug("display_data: %s", msg.get('content', '')) if self.include_output(msg): self.flush_clearoutput() 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? self.log.debug("display: %s", msg.get('content', '')) if 'image/svg+xml' in data: svg = data['image/svg+xml'] self._append_svg(svg, True) elif 'image/png' in data: # PNG data is base64 encoded as it passes over the network # in a JSON structure so we decode it. png = b64decode(data['image/png'].encode('ascii')) self._append_png(png, True, metadata=metadata.get('image/png', None)) elif 'image/jpeg' in data and self._jpg_supported: jpg = b64decode(data['image/jpeg'].encode('ascii')) self._append_jpg(jpg, True, metadata=metadata.get('image/jpeg', None)) elif 'text/latex' in data and latex_to_png: try: self._append_latex(data['text/latex'], True) except LatexError: return super(RichJupyterWidget, self)._handle_display_data(msg) else: # Default back to the plain text representation. return super(RichJupyterWidget, self)._handle_display_data(msg) #--------------------------------------------------------------------------- # 'RichJupyterWidget' protected interface #--------------------------------------------------------------------------- def _is_latex_math(self, latex): """ Determine if a Latex string is in math mode This is the only mode supported by qtconsole """ basic_envs = ['math', 'displaymath'] starable_envs = ['equation', 'eqnarray' 'multline', 'gather', 'align', 'flalign', 'alignat'] star_envs = [env + '*' for env in starable_envs] envs = basic_envs + starable_envs + star_envs env_syntax = [r'\begin{{{0}}} \end{{{0}}}'.format(env).split() for env in envs] math_syntax = [ (r'\[', r'\]'), (r'\(', r'\)'), ('$$', '$$'), ('$', '$'), ] for start, end in math_syntax + env_syntax: inner = latex[len(start):-len(end)] if start in inner or end in inner: return False if latex.startswith(start) and latex.endswith(end): return True return False def _append_latex(self, latex, before_prompt=False, metadata=None): """ Append latex data to the widget.""" png = None if self._is_latex_math(latex): png = latex_to_png(latex, wrap=False, backend='dvipng') # Matplotlib only supports strings enclosed in dollar signs if png is None and latex.startswith('$') and latex.endswith('$'): # To avoid long and ugly errors, like the one reported in # spyder-ide/spyder#7619 try: png = latex_to_png(latex, wrap=False, backend='matplotlib') except Exception: pass if png: self._append_png(png, before_prompt, metadata) else: raise LatexError def _append_jpg(self, jpg, before_prompt=False, metadata=None): """ Append raw JPG data to the widget.""" self._append_custom(self._insert_jpg, jpg, before_prompt, metadata=metadata) def _append_png(self, png, before_prompt=False, metadata=None): """ Append raw PNG data to the widget. """ self._append_custom(self._insert_png, png, before_prompt, metadata=metadata) def _append_svg(self, svg, before_prompt=False): """ Append raw SVG data to the widget. """ self._append_custom(self._insert_svg, svg, before_prompt) def _add_image(self, image): """ Adds the specified QImage to the document and returns a QTextImageFormat that references it. """ document = self._control.document() name = str(image.cacheKey()) 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) QtWidgets.QApplication.clipboard().setImage(image) def _get_image(self, name): """ Returns the QImage stored as the ImageResource with 'name'. """ document = self._control.document() image = document.resource(QtGui.QTextDocument.ImageResource, QtCore.QUrl(name)) return image 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 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"|"jpg", optional [default "png"] Format for returned or referenced images. """ if format in ("png","jpg"): try: image = self._get_image(match.group("name")) except KeyError: return "Couldn't find image %s" % match.group("name") if path is not None: ensure_dir_exists(path) relpath = os.path.basename(path) if image.save("%s/qt_img%s.%s" % (path, match.group("name"), format), "PNG"): return '' % (relpath, match.group("name"),format) else: return "Couldn't save image!" else: ba = QtCore.QByteArray() buffer_ = QtCore.QBuffer(ba) buffer_.open(QtCore.QIODevice.WriteOnly) image.save(buffer_, format.upper()) buffer_.close() return '' % ( format,re.sub(r'(.{60})',r'\1\n', str(ba.toBase64().data().decode()))) elif format == "svg": try: svg = str(self._name_to_svg_map[match.group("name")]) except KeyError: if not self._svg_warning_displayed: QtWidgets.QMessageBox.warning(self, 'Error converting PNG to SVG.', 'Cannot convert PNG images to SVG, export with PNG figures instead. ' 'If you want to export matplotlib figures as SVG, add ' 'to your ipython config:\n\n' '\tc.InlineBackend.figure_format = \'svg\'\n\n' 'And regenerate the figures.', QtWidgets.QMessageBox.Ok) self._svg_warning_displayed = True return ("Cannot convert PNG images to SVG. " "You must export this session with PNG images. " "If you want to export matplotlib figures as SVG, add to your config " "c.InlineBackend.figure_format = 'svg' " "and regenerate the figures.") # 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(" -1) return svg[offset:] else: return 'Unrecognized image format' def _insert_jpg(self, cursor, jpg, metadata=None): """ Insert raw PNG data into the widget.""" self._insert_img(cursor, jpg, 'jpg', metadata=metadata) def _insert_png(self, cursor, png, metadata=None): """ Insert raw PNG data into the widget. """ self._insert_img(cursor, png, 'png', metadata=metadata) def _insert_img(self, cursor, img, fmt, metadata=None): """ insert a raw image, jpg or png """ if metadata: width = metadata.get('width', None) height = metadata.get('height', None) else: width = height = None try: image = QtGui.QImage() image.loadFromData(img, fmt.upper()) if width and height: image = image.scaled(width, height, QtCore.Qt.IgnoreAspectRatio, QtCore.Qt.SmoothTransformation) elif width and not height: image = image.scaledToWidth(width, QtCore.Qt.SmoothTransformation) elif height and not width: image = image.scaledToHeight(height, QtCore.Qt.SmoothTransformation) except ValueError: self._insert_plain_text(cursor, 'Received invalid %s data.'%fmt) 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() def _save_image(self, name, format='PNG'): """ Shows a save dialog for the ImageResource with 'name'. """ dialog = QtWidgets.QFileDialog(self._control, 'Save Image') dialog.setAcceptMode(QtWidgets.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) # Clobber RichIPythonWidget above: class RichIPythonWidget(RichJupyterWidget): """Deprecated class. Use RichJupyterWidget.""" def __init__(self, *a, **kw): warn("RichIPythonWidget is deprecated, use RichJupyterWidget", DeprecationWarning) super(RichIPythonWidget, self).__init__(*a, **kw)