diff --git a/.travis.yml b/.travis.yml index d806bedec..9e34a8f20 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,8 @@ # needs these two lines: sudo: required dist: trusty +addons: + chrome: stable language: python python: @@ -32,6 +34,8 @@ install: - pip install -f travis-wheels/wheelhouse . codecov coverage - pip install nbconvert[execute,serve,test] - pip install check-manifest + - npm install + - npm run build - python -m ipykernel.kernelspec --user script: - check-manifest diff --git a/nbconvert/exporters/exporter.py b/nbconvert/exporters/exporter.py index 3cc864563..b7f09ee1a 100644 --- a/nbconvert/exporters/exporter.py +++ b/nbconvert/exporters/exporter.py @@ -82,6 +82,7 @@ class Exporter(LoggingConfigurable): 'nbconvert.preprocessors.CSSHTMLHeaderPreprocessor', 'nbconvert.preprocessors.LatexPreprocessor', 'nbconvert.preprocessors.HighlightMagicsPreprocessor', + 'nbconvert.preprocessors.SnapshotPreProcessor', 'nbconvert.preprocessors.ExtractOutputPreprocessor', ], help="""List of preprocessors available by default, by name, namespace, diff --git a/nbconvert/nbconvertapp.py b/nbconvert/nbconvertapp.py index 032c1b6f6..a74bc498f 100755 --- a/nbconvert/nbconvertapp.py +++ b/nbconvert/nbconvertapp.py @@ -65,6 +65,10 @@ def validate(self, obj, value): {'ExecutePreprocessor' : {'enabled' : True}}, "Execute the notebook prior to export." ), + 'snapshot' : ( + {'SnapshotPreProcessor' : {'enabled' : True}}, + "Create snapshots using a real browser" + ), 'allow-errors' : ( {'ExecutePreprocessor' : {'allow_errors' : True}}, ("Continue notebook execution even if one of the cells throws " @@ -244,7 +248,8 @@ def _writer_class_changed(self, change): help="""PostProcessor class used to write the results of the conversion""" ).tag(config=True) - postprocessor_aliases = {'serve': 'nbconvert.postprocessors.serve.ServePostProcessor'} + postprocessor_aliases = {'serve': 'nbconvert.postprocessors.serve.ServePostProcessor', + 'render': 'nbconvert.postprocessors.render.RenderPostProcessor'} postprocessor_factory = Type(None, allow_none=True) @observe('postprocessor_class') diff --git a/nbconvert/preprocessors/__init__.py b/nbconvert/preprocessors/__init__.py index 6e4ca5422..50915a325 100755 --- a/nbconvert/preprocessors/__init__.py +++ b/nbconvert/preprocessors/__init__.py @@ -10,6 +10,7 @@ from .execute import ExecutePreprocessor, CellExecutionError from .regexremove import RegexRemovePreprocessor from .tagremove import TagRemovePreprocessor +from .snapshot import SnapshotPreProcessor # decorated function Preprocessors from .coalescestreams import coalesce_streams diff --git a/nbconvert/preprocessors/execute.py b/nbconvert/preprocessors/execute.py index fe80c5d66..d204dc7dc 100644 --- a/nbconvert/preprocessors/execute.py +++ b/nbconvert/preprocessors/execute.py @@ -3,7 +3,7 @@ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. - +import base64 from textwrap import dedent from contextlib import contextmanager @@ -299,6 +299,8 @@ def setup_preprocessor(self, nb, resources, km=None): self.nb = nb # clear display_id map self._display_id_map = {} + self.widget_state = {} + self.widget_buffers = {} if km is None: self.km, self.kc = self.start_new_kernel(cwd=path) @@ -361,9 +363,27 @@ def preprocess(self, nb, resources, km=None): nb, resources = super(ExecutePreprocessor, self).preprocess(nb, resources) info_msg = self._wait_for_reply(self.kc.kernel_info()) nb.metadata['language_info'] = info_msg['content']['language_info'] + self.set_widgets_metadata() return nb, resources + def set_widgets_metadata(self): + if self.widget_state: + self.nb.metadata.widgets = { + 'application/vnd.jupyter.widget-state+json': { + 'state': { + model_id: _serialize_widget_state(state) + for model_id, state in self.widget_state.items() if '_model_name' in state + }, + 'version_major': 2, + 'version_minor': 0, + } + } + for key, widget in self.nb.metadata.widgets['application/vnd.jupyter.widget-state+json']['state'].items(): + buffers = self.widget_buffers.get(key) + if buffers: + widget['buffers'] = buffers + def preprocess_cell(self, cell, resources, cell_index): """ Executes a single code cell. See base.py for details. @@ -489,6 +509,11 @@ def run_cell(self, cell, cell_index=0): cell_map[cell_index] = [] continue elif msg_type.startswith('comm'): + data = content['data'] + if 'state' in data: # ignore custom msg'es + self.widget_state.setdefault(content['comm_id'], {}).update(data['state']) + if 'buffer_paths' in data and data['buffer_paths']: + self.widget_buffers[content['comm_id']] = _get_buffer_data(msg) continue display_id = None @@ -539,3 +564,26 @@ def executenb(nb, cwd=None, km=None, **kwargs): resources['metadata'] = {'path': cwd} ep = ExecutePreprocessor(**kwargs) return ep.preprocess(nb, resources, km=km)[0] + + +def _serialize_widget_state(state): + """Serialize a widget state, following format in @jupyter-widgets/schema.""" + return { + 'model_name': state.get('_model_name'), + 'model_module': state.get('_model_module'), + 'model_module_version': state.get('_model_module_version'), + 'state': state, + } + + +def _get_buffer_data(msg): + encoded_buffers = [] + paths = msg['content']['data']['buffer_paths'] + buffers = msg['buffers'] + for path, buffer in zip(paths, buffers): + encoded_buffers.append({ + 'data': base64.b64encode(buffer).decode('utf-8'), + 'encoding': 'base64', + 'path': path + }) + return encoded_buffers diff --git a/nbconvert/preprocessors/snapshot.py b/nbconvert/preprocessors/snapshot.py new file mode 100644 index 000000000..e5b497c19 --- /dev/null +++ b/nbconvert/preprocessors/snapshot.py @@ -0,0 +1,234 @@ +"""PreProcessor for rendering """ + +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. + +from __future__ import print_function + +import os +import copy +import tempfile +import webbrowser +import threading +import tornado.escape +import nbformat + +from traitlets.config.configurable import LoggingConfigurable +from tornado import web, ioloop, httpserver, log +from tornado.httpclient import AsyncHTTPClient +from traitlets import Bool, Unicode, Int, DottedObjectName, Type, observe, Instance +from traitlets.utils.importstring import import_item + + +from .base import Preprocessor +from ..exporters.html import HTMLExporter +from ..writers import FilesWriter + + +class SnapshotHandler(web.RequestHandler): + def initialize(self, snapshot_dict, callback): + self.snapshot_dict = snapshot_dict + self.callback = callback + + # @web.asynchronous + def post(self, view_id=None, image_data=None): + #, view_id=None, image_data=None + #view_id = self.get_parameter('view_id') + data = tornado.escape.json_decode(self.request.body) + #print('hi', data['cell_index'], data['output_index']) + i, j = data['cell_index'], data['output_index'] + key = i, j + image_data = data['image_data'] + header = 'data:image/png;base64,' + assert image_data.startswith(header), 'not a png image?' + self.snapshot_dict[key]['data'][MIME_TYPE_PNG] = image_data[len(header):] + self.callback() + + +MIME_TYPE_JUPYTER_WIDGET_VIEW = 'application/vnd.jupyter.widget-view+json' +MIME_TYPE_PNG = 'image/png' +MIME_TYPE_HTML = 'text/html' +DIRNAME_STATIC = os.path.abspath(os.path.join(os.path.dirname(__file__), '../resources')) +def next_port(): + i = 8009 + while 1: + yield i + i += 1 + +next_port = next_port() + +class PageOpener(LoggingConfigurable): + pass + +class PageOpenerDefault(PageOpener): + browser = Unicode(u'', + help="""Specify what browser should be used to open slides. See + https://docs.python.org/3/library/webbrowser.html#webbrowser.register + to see how keys are mapped to browser executables. If + not specified, the default browser will be determined + by the `webbrowser` + standard library module, which allows setting of the BROWSER + environment variable to override it. + """).tag(config=True) + + def open(self, url): + browser = webbrowser.get(self.browser or None) + b = lambda: browser.open(url, new=2) + threading.Thread(target=b).start() + +chrome_binary = 'echo "not found"' +import platform +if platform.system().lower() == 'darwin': + chrome_binary = r"/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome" +elif platform.system().lower() == 'linux': + chrome_binary = 'google-chrome' + + + + +class PageOpenerChromeHeadless(PageOpener): + start_command = Unicode('%s --remote-debugging-port=9222 --headless &' % chrome_binary).tag(config=True) + def open(self, url): + import PyChromeDevTools + import requests.exceptions + import time + try: + chrome = PyChromeDevTools.ChromeInterface() + except requests.exceptions.ConnectionError: + print('could not connect, try starting with', self.start_command) + ret = os.system(self.start_command) + if ret != 0: + raise ValueError('could not start chrome headless with command: ' + self.start_command) + for i in range(4): + time.sleep(1) + print('try connecting to chrome') + try: + chrome = PyChromeDevTools.ChromeInterface() + except requests.exceptions.ConnectionError: + if i == 3: + raise + chrome.Network.enable() + chrome.Page.enable() + chrome.Page.navigate(url=url) + + +# chrome caches the js files, bad for development +class StaticFileHandlerNoCache(tornado.web.StaticFileHandler): + def set_extra_headers(self, path): + self.set_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + + +class SnapshotPreProcessor(Preprocessor): + """Pre processor that will make snapshots of widgets and html + """ + + open_in_browser = Bool(True, + help="""Should the browser be opened automatically?""" + ).tag(config=True) + devmode = Bool(True, + help="""In dev mode, the static files will not be cached by the browser""" + ).tag(config=True) + keep_running = Bool(False, help="Keep server running when done").tag(config=True) + page_opener = Instance(PageOpener).tag(config=True) + page_opener_class = DottedObjectName('nbconvert.preprocessors.snapshot.PageOpenerChromeHeadless', + help="""How to open a page for rendering""").tag(config=True) + page_opener_aliases = {'headless': 'nbconvert.preprocessors.snapshot.PageOpenerChromeHeadless', + 'default': 'nbconvert.preprocessors.snapshot.PageOpenerDefault'} + page_opener_factory = Type(allow_none=True) + + @observe('page_opener_class') + def _page_opener_class_changed(self, change): + new = change['new'] + if new.lower() in self.page_opener_aliases: + new = self.page_opener_aliases[new.lower()] + self.page_opener_factory = import_item(new) + + ip = Unicode("127.0.0.1", + help="The IP address to listen on.").tag(config=True) + port = Int(8000, help="port for the server to listen on.").tag(config=True) + + def callback(self): + done = True + for key, value in self.snapshot_dict.items(): + if value['data'][MIME_TYPE_PNG] is None: + done = False + + if done and not self.keep_running: + self.stop_server() + + # see https://github.com/tornadoweb/tornado/issues/2523 + def stop_server(self): + self.http_server.stop() + self.main_ioloop.add_future(self.http_server.close_all_connections(), + lambda x: self.main_ioloop.stop()) + + def preprocess(self, nb, resources): + """Serve the build directory with a webserver.""" + self.snapshot_dict = {} + self.nb = nb + for cell_index, cell in enumerate(self.nb.cells): + if 'outputs' in cell: + for output_index, output in enumerate(cell.outputs): + if 'data' in output: + if MIME_TYPE_JUPYTER_WIDGET_VIEW in output['data'] or MIME_TYPE_HTML in output['data']: + # clear the existing png data, we may consider skipping these cells + output['data'][MIME_TYPE_PNG] = None + self.snapshot_dict[(cell_index, output_index)] = output + if self.snapshot_dict.keys(): + with tempfile.TemporaryDirectory() as dirname: + html_exporter = HTMLExporter(template_file='snapshot', default_preprocessors=[ + 'nbconvert.preprocessors.SVG2PDFPreprocessor', + 'nbconvert.preprocessors.CSSHTMLHeaderPreprocessor', + 'nbconvert.preprocessors.HighlightMagicsPreprocessor', + ]) + nbc = copy.deepcopy(nb) + resc = copy.deepcopy(resources) + output, resources_html = html_exporter.from_notebook_node(nbc, resources=resc) + writer = FilesWriter(build_directory=dirname) + filename_base = 'index' + filename = filename_base + '.html' + writer.write(output, resources_html, notebook_name=filename_base) + + # dirname, filename = os.path.split(input) + handlers = [ + (r"/send_snapshot", SnapshotHandler, dict(snapshot_dict=self.snapshot_dict, callback=self.callback)), + (r"/resources/(.+)", StaticFileHandlerNoCache if self.devmode else web.StaticFileHandler, + {'path' : DIRNAME_STATIC}), + (r"/(.+)", web.StaticFileHandler, {'path' : dirname}), + (r"/", web.RedirectHandler, {"url": "/%s" % filename}) + ] + app = web.Application(handlers, + client=AsyncHTTPClient(), + ) + + # hook up tornado logger to our logger + log.app_log = self.log + + self.http_server = httpserver.HTTPServer(app) + self.http_server.listen(self.port, address=self.ip) + url = "http://%s:%i/%s" % (self.ip, self.port, filename) + print("Serving your slides at %s" % url) + print("Use Control-C to stop this server") + self.main_ioloop = ioloop.IOLoop.instance() + if self.open_in_browser: + self._page_opener_class_changed({ 'new': self.page_opener_class }) + self.page_opener = self.page_opener_factory() + self.page_opener.open(url) + try: + self.main_ioloop.start() + except KeyboardInterrupt: + print("\nInterrupted") + self.http_server.stop() + # TODO: maybe we need to wait for this to finish + self.http_server.close_all_connections() + # nbformat.write(self.nb, input.replace('.html', '.ipynb')) + return nb, resources + +def main(path): + """allow running this module to serve the slides""" + server = SnapshotPreProcessor() + server(path) + +if __name__ == '__main__': + import sys + main(sys.argv[1]) diff --git a/nbconvert/preprocessors/tests/files/JupyterWidgets.ipynb b/nbconvert/preprocessors/tests/files/JupyterWidgets.ipynb new file mode 100644 index 000000000..fccfeb4e8 --- /dev/null +++ b/nbconvert/preprocessors/tests/files/JupyterWidgets.ipynb @@ -0,0 +1,94 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "f46f26da84b54255bccc3a69d7eb08de", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Label(value='Hello World')" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import ipywidgets\n", + "label = ipywidgets.Label('Hello World')\n", + "label" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# it should also handle custom msg'es\n", + "label.send({'msg': 'Hello'})" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": { + "8273e8fe9d9941a4a63c062158e0a630": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.4.0", + "model_name": "DescriptionStyleModel", + "state": { + "description_width": "" + } + }, + "a72770a4f541425f8fe85833a3dc2a8e": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "1.4.0", + "model_name": "LabelModel", + "state": { + "context_menu": null, + "layout": "IPY_MODEL_dec20f599109458ca607b1df5959469b", + "style": "IPY_MODEL_8273e8fe9d9941a4a63c062158e0a630", + "value": "Hello World" + } + }, + "dec20f599109458ca607b1df5959469b": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "1.1.0", + "model_name": "LayoutModel", + "state": {} + } + }, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/nbconvert/preprocessors/tests/files/single-pixel.ipynb b/nbconvert/preprocessors/tests/files/single-pixel.ipynb new file mode 100644 index 000000000..e73740c09 --- /dev/null +++ b/nbconvert/preprocessors/tests/files/single-pixel.ipynb @@ -0,0 +1,110 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "9aa0769fd66e42fcb83d86241d228062", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Image(value=b'\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x01\\x00\\x00\\x00\\x01\\x08\\x06\\x00\\x00\\x00\\x1f\\x15\\…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# this generates a 1 pixel image with the rgba values (100, 200, 250, 255)\n", + "# generated with the code in the next cells (kept for debugging purposes)\n", + "import base64\n", + "import ipywidgets\n", + "image_data = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNIOfHrPwAG4AMmv//laQAAAABJRU5ErkJggg==')\n", + "ipywidgets.Image(value=image_data)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# try:\n", + "# from io import BytesIO as StringIO\n", + "# except:\n", + "# from cStringIO import StringIO\n", + "# import PIL.Image\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# f = StringIO(image_data)\n", + "# image = PIL.Image.new('RGBA', (1,1))\n", + "# image.putpixel((0, 0), (100, 200, 250, 255))\n", + "# image.save(f, format='png')\n", + "# base64.b64encode(f.getvalue())" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# base64.b64encode(f.getvalue())" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "# image = PIL.Image.open(f)\n", + "# pixel = image.getpixel((0, 0))\n", + "\n", + "# #assert pixel == (100, 200, 250)\n", + "\n", + "# pixel" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/nbconvert/preprocessors/tests/test_execute.py b/nbconvert/preprocessors/tests/test_execute.py index ce40849fb..27dac6643 100644 --- a/nbconvert/preprocessors/tests/test_execute.py +++ b/nbconvert/preprocessors/tests/test_execute.py @@ -55,8 +55,13 @@ def normalize_output(output): if 'text/plain' in output.get('data', {}): output['data']['text/plain'] = \ re.sub(addr_pat, '', output['data']['text/plain']) + if 'application/vnd.jupyter.widget-view+json' in output.get('data', {}): + output['data']['application/vnd.jupyter.widget-view+json'] \ + ['model_id'] = '' for key, value in output.get('data', {}).items(): if isinstance(value, string_types): + if sys.version_info.major == 2: + value = value.replace('u\'', '\'') output['data'][key] = _normalize_base64(value) if 'traceback' in output: tb = [ @@ -288,3 +293,33 @@ def test_execute_function(self): original = copy.deepcopy(input_nb) executed = executenb(original, os.path.dirname(filename)) self.assert_notebooks_equal(original, executed) + + def test_widgets(self): + """Runs a test notebook with widgets and checks the widget state is saved.""" + input_file = os.path.join(current_dir, 'files', 'JupyterWidgets.ipynb') + opts = dict(kernel_name="python") + res = self.build_resources() + res['metadata']['path'] = os.path.dirname(input_file) + input_nb, output_nb = self.run_notebook(input_file, opts, res) + + output_data = [ + output.get('data', {}) + for cell in output_nb['cells'] + for output in cell['outputs'] + ] + + model_ids = [ + data['application/vnd.jupyter.widget-view+json']['model_id'] + for data in output_data + if 'application/vnd.jupyter.widget-view+json' in data + ] + + wdata = output_nb['metadata']['widgets'] \ + ['application/vnd.jupyter.widget-state+json'] + for k in model_ids: + d = wdata['state'][k] + assert 'model_name' in d + assert 'model_module' in d + assert 'state' in d + assert 'version_major' in wdata + assert 'version_minor' in wdata diff --git a/nbconvert/preprocessors/tests/test_snapshot.py b/nbconvert/preprocessors/tests/test_snapshot.py new file mode 100644 index 000000000..2f604e6da --- /dev/null +++ b/nbconvert/preprocessors/tests/test_snapshot.py @@ -0,0 +1,32 @@ +import os + +import nbformat +from ..snapshot import SnapshotPreProcessor +from ..execute import ExecutePreprocessor +import PIL.Image +try: + from io import BytesIO as StringIO +except: + from cStringIO import StringIO +import base64 + +current_dir = os.path.dirname(__file__) + +def test_red_pixel(): + execute_preprocessor = ExecutePreprocessor(enabled=True) + + input_file = os.path.join(current_dir, 'files', 'single-pixel.ipynb') + spp = SnapshotPreProcessor(page_opener_class='headless') + nb = nbformat.read(input_file, 4) + resources = {} + nb, resources = execute_preprocessor.preprocess(nb, resources) + assert 'image/png' not in nb.cells[0].outputs[0].data + nb, resources = spp.preprocess(nb, resources) + assert 'image/png' in nb.cells[0].outputs[0].data + # we cannot be sure the browser encodes it the same way + # assert nb.cells[0].outputs[0].data['image/png'] == 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNIOfHrPwAG4AMmv//laQAAAABJRU5ErkJggg==' + # instead, we read the magic pixel values + f = StringIO(base64.b64decode(nb.cells[0].outputs[0].data['image/png'])) + image = PIL.Image.open(f) + pixel = image.getpixel((0, 0)) + assert pixel == (100, 200, 250, 255) diff --git a/nbconvert/templates/html/snapshot.tpl b/nbconvert/templates/html/snapshot.tpl new file mode 100644 index 000000000..519785c62 --- /dev/null +++ b/nbconvert/templates/html/snapshot.tpl @@ -0,0 +1,41 @@ +{%- extends 'full.tpl' -%} + +{% block ipywidgets %} + +{% endblock ipywidgets %} + + +{%- block data_widget_view scoped %} +{% set div_id = uuid4() %} +{% set datatype_list = output.data | filter_data_type %} +{% set datatype = datatype_list[0]%} +
+
+ + +
+{%- endblock data_widget_view -%} + +{% block data_html scoped -%} +
+{{ output.data['text/html'] }} +
+{%- endblock data_html %} \ No newline at end of file diff --git a/nbconvert/templates/skeleton/null.tpl b/nbconvert/templates/skeleton/null.tpl index 32886a867..d7fde7262 100644 --- a/nbconvert/templates/skeleton/null.tpl +++ b/nbconvert/templates/skeleton/null.tpl @@ -20,11 +20,14 @@ calling super. consider calling super even if it is a leave block, we might insert more blocks later. + {%- if cell.outputs and resources.global_content_filter.include_output and 'voila:output:table' in cell.metadata.tags -%} + #} {%- block header -%} {%- endblock header -%} {%- block body -%} {%- for cell in nb.cells -%} + {% set cell_index = loop.index0 %} {%- block any_cell scoped -%} {%- if cell.cell_type == 'code'-%} {%- if resources.global_content_filter.include_code -%} @@ -44,6 +47,7 @@ consider calling super even if it is a leave block, we might insert more blocks {%- endif -%} {%- block outputs scoped -%} {%- for output in cell.outputs -%} + {% set output_index = loop.index0 %} {%- block output scoped -%} {%- if output.output_type == 'execute_result' -%} {%- block execute_result scoped -%}{%- endblock execute_result -%} diff --git a/package.json b/package.json new file mode 100644 index 000000000..3e4bc7816 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "nbconvert-snapshot", + "version": "0.1.0", + "description": "Create snapshots for nbconvert", + "keywords": [ + "jupyter", + "nbconvert", + "jupyterlab-extension" + ], + "homepage": "https://github.com/jupyter/nbconvert", + "bugs": { + "url": "https://github.com/jupyter/nbconvert/issues" + }, + "license": "BSD-3-Clause", + "author": "Maarten Breddels", + "files": [ + "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", + "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/jupyter/nbconvert.git" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf lib", + "watch": "npm-run-all -p watch:*", + "watch:lib": "tsc -w --project .", + "watch:dist": "webpack --watch --mode=development", + "prepare": "npm run clean && npm run build && webpack --mode=production" + }, + "dependencies": { + "dom-to-image": "^2.6.0", + "html2canvas": "^1.0.0-alpha.12", + "font-awesome": "^4.7.0" + }, + "peerDependencies": { + "@jupyterlab/application": "^0.18.0", + "@jupyterlab/notebook": "^0.18.0", + "@jupyterlab/apputils": "^0.18.0", + "@jupyter-widgets/jupyterlab-manager": "^0.37.0" + }, + "devDependencies": { + "@jupyter-widgets/html-manager": "^0.15.0", + "@jupyter-widgets/jupyterlab-manager": "^0.37.0", + "@jupyterlab/application": "^0.18.0", + "@jupyterlab/apputils": "^0.18.0", + "@jupyterlab/notebook": "^0.18.0", + "@types/requirejs": "^2.1.28", + "css-loader": "^1.0.0", + "file-loader": "^1.1.5", + "url-loader": "^0.6.2", + "postcss": "^6.0.11", + "postcss-cssnext": "^3.0.2", + "postcss-import": "^10.0.0", + "postcss-loader": "^2.0.6", + "npm-run-all": "^4.1.3", + "rimraf": "^2.6.1", + "style-loader": "^0.23.1", + "typescript": "~3.0.0", + "webpack": "^4.16.1", + "webpack-cli": "^3.0.8" + }, + "jupyterlab": { + "extension": true + } +} diff --git a/setup.py b/setup.py index 6aa8bd9d6..0d867b93a 100644 --- a/setup.py +++ b/setup.py @@ -30,6 +30,7 @@ import os import setuptools import io +import platform from setuptools.command.bdist_egg import bdist_egg @@ -39,10 +40,13 @@ from urllib.request import urlopen except ImportError: from urllib import urlopen +from subprocess import check_call + from distutils.core import setup from distutils.cmd import Command from distutils.command.build import build +from distutils.command.build_py import build_py from distutils.command.sdist import sdist pjoin = os.path.join @@ -56,7 +60,7 @@ package_data = { 'nbconvert.filters' : ['marked.js'], - 'nbconvert.resources' : ['style.min.css'], + 'nbconvert.resources' : ['style.min.css', '*.js', '*.js.map', '*.eot', '*.svg', '*.woff2', '*.ttf', '*.woff'], 'nbconvert' : [ 'tests/files/*.*', 'tests/exporter_entrypoint/*.py', @@ -126,7 +130,81 @@ def run(self): f.write(css) print("Downloaded Notebook CSS to %s" % dest) -cmdclass = {'css': FetchCSS} +def update_package_data(distribution): + """update package_data to catch changes during setup""" + build_py = distribution.get_command_obj('build_py') + # distribution.package_data = find_package_data() + # re-init build_py options which load package_data + build_py.finalize_options() + + +node_root = os.path.join(here, '.') +npm_path = os.pathsep.join([ + os.path.join(node_root, 'node_modules', '.bin'), + os.environ.get('PATH', os.defpath), +]) + +class NPM(Command): + description = 'install package.json dependencies using npm' + + user_options = [] + + node_modules = os.path.join(node_root, 'node_modules') + + targets = [ + os.path.join(here, 'nbconvert', 'resources', 'snapshot.js'), + ] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def get_npm_name(self): + npmName = 'npm' + if platform.system() == 'Windows': + npmName = 'npm.cmd' + return npmName + + def has_npm(self): + npmName = self.get_npm_name() + try: + check_call([npmName, '--version']) + return True + except: + return False + + def should_run_npm_install(self): + package_json = os.path.join(node_root, 'package.json') + node_modules_exists = os.path.exists(self.node_modules) + return self.has_npm() + + def run(self): + has_npm = self.has_npm() + if not has_npm: + raise OSError("`npm` unavailable. If you're running this command using sudo, make sure `npm` is available to sudo") + + env = os.environ.copy() + env['PATH'] = npm_path + + if self.should_run_npm_install(): + print("Installing build dependencies with npm. This may take a while...") + npmName = self.get_npm_name(); + check_call([npmName, 'install'], cwd=node_root, stdout=sys.stdout, stderr=sys.stderr) + os.utime(self.node_modules, None) + + for t in self.targets: + if not os.path.exists(t): + msg = 'Missing file: %s' % t + if not has_npm: + msg += '\nnpm is required to build a development version of widgetsnbextension' + raise ValueError(msg) + + # update package data in case this created new files + update_package_data(self.distribution) + +cmdclass = {'css': FetchCSS, 'js': NPM} class bdist_egg_disabled(bdist_egg): @@ -145,10 +223,40 @@ def run(self): return command.run(self) return CSSFirst -cmdclass['build'] = css_first(build) -cmdclass['sdist'] = css_first(sdist) +is_repo = os.path.exists(os.path.join(here, '.git')) + +def js_first(command, strict=False): + """decorator for building minified js/css prior to another command""" + class JSFirst(command): + def run(self): + jsdeps = self.distribution.get_command_obj('js') + if not is_repo and all(os.path.exists(t) for t in jsdeps.targets): + # sdist, nothing to do + command.run(self) + return + + try: + self.distribution.run_command('js') + except Exception as e: + missing = [t for t in jsdeps.targets if not os.path.exists(t)] + if strict or missing: + print('rebuilding js and css failed') + if missing: + print('missing files: %s' % missing) + raise e + else: + print('rebuilding js and css failed (not a problem)') + print(str(e)) + command.run(self) + update_package_data(self.distribution) + return JSFirst + +cmdclass['build'] = js_first(css_first(build)) +cmdclass['sdist'] = js_first(css_first(sdist), strict=True) cmdclass['bdist_egg'] = bdist_egg if 'bdist_egg' in sys.argv else bdist_egg_disabled + + for d, _, _ in os.walk(pjoin(pkg_root, 'templates')): g = pjoin(d[len(pkg_root)+1:], '*.*') package_data['nbconvert'].append(g) @@ -228,8 +336,9 @@ def run(self): jupyter_client_req = 'jupyter_client>=4.2' extra_requirements = { - 'test': ['pytest', 'pytest-cov', 'ipykernel', jupyter_client_req], + 'test': ['pytest', 'pytest-cov', 'ipykernel', jupyter_client_req, 'ipywidgets>=7', 'PyChromeDevTools', 'pillow'], 'serve': ['tornado>=4.0'], + 'snapshot': ['PyChromeDevTools'], 'execute': [jupyter_client_req], 'docs': ['sphinx>=1.5.1', 'sphinx_rtd_theme', diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..7703e9c3c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,45 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +// import * as libembed from './libembed'; +declare var __webpack_public_path__:string; +// needed to make fontawesome work +__webpack_public_path__ = 'resources/' + +import {Manager} from './manager'; +import 'font-awesome/css/font-awesome.css'; +import '@phosphor/widgets/style/index.css'; +import '@jupyter-widgets/controls/css/widgets.built.css'; +/** + * Render widgets in a given element. + * + * @param element (default document.documentElement) The element containing widget state and views. + * @param loader (default requireLoader) The function used to look up the modules containing + * the widgets' models and views classes. (The default loader looks them up on unpkg.com) + */ +// export +// function renderWidgets(element = document.documentElement, loader: (moduleName: string, moduleVersion: string) => Promise = requireLoader) { +// requirePromise(['@jupyter-widgets/html-manager']).then((htmlmanager) => { +// let managerFactory = () => { +// return new htmlmanager.HTMLManager({loader: loader}); +// } +// libembed.renderWidgets(managerFactory, element); +// }); +// } + +export +function renderWidgets() { + console.log('rendering widgets') + let manager = new Manager(); + manager.load(); + +} + +// (window as any).require(['@jupyter-widgets/html-manager/dist/libembed-amd'], function(embed) { +// if (document.readyState === "complete") { +// renderWidgets(); +// } else { +// window.addEventListener('load', function() {renderWidgets();}); +// } +// }); + diff --git a/src/loader.ts b/src/loader.ts new file mode 100644 index 000000000..358c0fc1b --- /dev/null +++ b/src/loader.ts @@ -0,0 +1,64 @@ + + +let cdn = 'https://unpkg.com/'; + +// find the data-cdn for any script tag, assuming it is only used for embed-amd.js +const scripts = document.getElementsByTagName('script'); +Array.prototype.forEach.call(scripts, (script) => { + cdn = script.getAttribute('data-jupyter-widgets-cdn') || cdn; +}); + +/** + * Load a package using requirejs and return a promise + * + * @param pkg Package name or names to load + */ +let requirePromise = function(pkg: string | string[]): Promise { + return new Promise((resolve, reject) => { + let require = (window as any).requirejs; + if (require === undefined) { + reject("Requirejs is needed, please ensure it is loaded on the page."); + } else { + require(pkg, resolve, reject); + } + }); +} + +function moduleNameToCDNUrl(moduleName: string, moduleVersion: string) { + let packageName = moduleName; + let fileName = 'index'; // default filename + // if a '/' is present, like 'foo/bar', packageName is changed to 'foo', and path to 'bar' + // We first find the first '/' + let index = moduleName.indexOf('/'); + if ((index != -1) && (moduleName[0] == '@')) { + // if we have a namespace, it's a different story + // @foo/bar/baz should translate to @foo/bar and baz + // so we find the 2nd '/' + index = moduleName.indexOf('/', index+1); + } + if (index != -1) { + fileName = moduleName.substr(index+1); + packageName = moduleName.substr(0, index); + } + return `${cdn}${packageName}@${moduleVersion}/dist/${fileName}`; +} + +export +function requireLoader(moduleName: string, moduleVersion: string) { + return requirePromise([`${moduleName}`]).catch((err) => { + let failedId = err.requireModules && err.requireModules[0]; + if (failedId) { + console.log(`Falling back to ${cdn} for ${moduleName}@${moduleVersion}`); + let require = (window as any).requirejs; + if (require === undefined) { + throw new Error("Requirejs is needed, please ensure it is loaded on the page."); + } + const conf = {paths: {}}; + conf.paths[moduleName] = moduleNameToCDNUrl(moduleName, moduleVersion); + require.undef(failedId); + require.config(conf); + + return requirePromise([`${moduleName}`]); + } + }); +} diff --git a/src/manager.ts b/src/manager.ts new file mode 100644 index 000000000..7c9419a61 --- /dev/null +++ b/src/manager.ts @@ -0,0 +1,367 @@ + +import * as base from '@jupyter-widgets/base' +import * as controls from '@jupyter-widgets/controls'; +import * as pWidget from '@phosphor/widgets'; +import { Signal } from '@phosphor/signaling'; + +import { HTMLManager } from '@jupyter-widgets/html-manager'; + +import * as outputWidgets from './output'; +import {requireLoader as loader} from './loader' +import { ShimmedComm } from './services-shim'; +import { createRenderMimeRegistryWithWidgets } from './renderMime'; + +import * as domtoimage from 'dom-to-image'; +import * as html2canvas from 'html2canvas' + +// let (window as any) +if (typeof window !== "undefined" && typeof (window as any).define !== "undefined") { + (window as any).define("@jupyter-widgets/base", base); + (window as any).define("@jupyter-widgets/controls", controls); +} + +let viewIdSnapshots = {} + +// http://lea.verou.me/2016/12/resolve-promises-externally-with-this-one-weird-trick/ +class defer { + constructor() { + this.promise = new Promise((resolve, reject) => { + this.res = resolve; + this.rej = reject; + }); + } + + + promise: any; + rej: any; + res: any; +} + +async function getViewSnapshot(viewId) { + if(viewIdSnapshots[viewId] === undefined) { + viewIdSnapshots[viewId] = new defer(); + } else{ + return viewIdSnapshots[viewId] + } +} + +export class Manager extends HTMLManager { + constructor() { + console.log('manager') + super({loader: loader}); + + // for (let i=0; i!=tags.length; ++i) { + // renderManager(element, JSON.parse(tags[i].innerHTML), managerFactory); + // } + // this.kernel = kernel; + // this.registerWithKernel(kernel) + // this.loader = loader; + // this.renderMime = createRenderMimeRegistryWithWidgets(this); + // this._onError = new Signal(this) + // this.build_widgets() + } + async load() { + let htmlElements = document.body.querySelectorAll('div.output_html'); + htmlElements.forEach((el) => { + setTimeout(() => { + html2canvas(el, {useCORS: true}).then(canvas => { + // document.body.appendChild(canvas) + el.appendChild(canvas) + let imageData = canvas.toDataURL('image/png'); + let cell_index = Number(el.getAttribute('data-nb-cell-index')); + let output_index = Number(el.getAttribute('data-nb-output-index')); + var xmlHttp = new XMLHttpRequest(); + xmlHttp.open("post", "/send_snapshot"); + xmlHttp.send(JSON.stringify({cell_index: cell_index, output_index: output_index, image_data: imageData})); + }); + }, 1000); + + }) + + let tags = document.body.querySelectorAll('script[type="application/vnd.jupyter.widget-state+json"]'); + if(tags.length == 0) { + console.error('no state found') + } else if(tags.length > 1) { + console.error('no state found') + } else { + let state = JSON.parse(tags[0].innerHTML); + let models = await this.set_state(state); + this.models = models; + let viewTags = document.body.querySelectorAll('script[type="application/vnd.jupyter.widget-view+json"]'); + let views = []; + for (let i=0; i!=viewTags.length; ++i) { + let viewtag = viewTags[i]; + let widgetViewObject = JSON.parse(viewtag.innerHTML); + let model_id: string = widgetViewObject.model_id; + let model = models.filter( (item) => { + return item.model_id == model_id; + })[0]; + if (model !== undefined) { + let prev = viewtag.previousElementSibling; + if (prev && prev.tagName === 'img' && prev.classList.contains('jupyter-widget')) { + viewtag.parentElement.removeChild(prev); + } + let widgetTag = document.createElement('div'); + widgetTag.className = 'widget-subarea'; + viewtag.parentElement.insertBefore(widgetTag, viewtag); + let options = { el : widgetTag }; + // await this.display_model(undefined, model, { el : widgetTag }); + let view = await this.create_view(model, options) ;//.then( + views.push(view); + this.display_view(undefined, view, options);//.catch(utils.reject('Could not create view', true)); + } + } + let all_view_promises = []; + models.forEach((model) => { + for(let id in model.views) { + all_view_promises.push(model.views[id]) + } + }) + console.log('views', all_view_promises); + let all_views = await Promise.all(all_view_promises); + let bqplot_figure_views = all_views.filter((view) => view.model.name == 'FigureModel'); + console.log('bqplot_figure_views', bqplot_figure_views); + await Promise.all(bqplot_figure_views.map(async (view) => { + while(!view.mark_views) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + let marks = await Promise.all(view.mark_views.views); + await new Promise((resolve) => { + setTimeout(async () => { + // this is code from bqplot, make bqplot such that we can rely on a method + // of figure + var xml = view.get_svg(); + // Render a SVG data into a canvas and download as PNG. + var image = new Image(); + var that = this; + let image_loaded_promise = new Promise((resolve) => image.onload=resolve); + image.src = "data:image/svg+xml;base64," + btoa(xml); + await image_loaded_promise; + var canvas = document.createElement("canvas"); + canvas.classList.add('bqplot'); + canvas.width = view.width * window.devicePixelRatio;; + canvas.height = view.height * window.devicePixelRatio; + canvas.style.width = view.width; + canvas.style.height = view.height; + canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio); + var context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + view.el.parentElement.replaceChild(canvas, view.el) + view.el = canvas; // dirty, but then html2canvas can find it + console.log('bqplot figure replaced by snapshot') + resolve() + }, 1000); + }); + })); + /*await views.map((view) => { + return new Promise(async (resolve_view) => { + // convert bqplot figures by a static png before we do the html2canvas + if(view.model.name == 'FigureModel') { + console.log(view) + // TODO: change bqplot such that we have a promise to wait for + // instead of polling it + resolve_view(); + } else { + // await Promise.resolve(1); + resolve_view(); + await Promise.resolve(1); + } + }); + })) + })*/ + console.log('All views converted') + views.forEach(async (view) => { + let callbacks = (window as any)._webgl_update_callbacks; + console.log('callbacks', callbacks) + if(callbacks) { + callbacks.forEach((callback) => callback()) + } + // let dependencies = []; + if(view.model.name == 'LeafletMapModel') { + // TODO: change jupyter-leaflet such that we have a promise to wait for + // instead of polling it + while(!view.obj) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + for(var i=0; i < view.layer_views.views.length; i++) { + let layer = await view.layer_views.views[i]; + // wait for max 10 seconds + let timeout = new Promise((resolve) => {setTimeout(() => {console.log('timeout'); resolve()}, 10*1000)}); + let loaded = new Promise((resolve) => {console.log('loaded'); layer.obj.on('load', resolve)}) + layer.obj.on('load', () => console.log('loaded!!')) + await Promise.race([timeout, loaded]); + console.log('done') + + } + } + setTimeout(() => { + console.log('make screenshot') + html2canvas(view.el, {useCORS: true}).then(canvas => { + // document.body.appendChild(canvas) + view.el.parentElement.appendChild(canvas) + let imageData = canvas.toDataURL('image/png'); + let outputElement = view.el.parentElement.parentElement;'' + let cell_index = Number(outputElement.getAttribute('data-nb-cell-index')); + let output_index = Number(outputElement.getAttribute('data-nb-output-index')); + var xmlHttp = new XMLHttpRequest(); + xmlHttp.open("post", "/send_snapshot"); + xmlHttp.send(JSON.stringify({cell_index: cell_index, output_index: output_index, image_data: imageData})); + }); + }, 1000); + }) + + + } + + } + _display_view(msg, view, options) { + console.log(msg, view, options) + let result = super.display_view(msg, view, options) + // let promises = []; + // this.models.forEach((model) => { + // // for(id in model.views) { + + // // } + // if(model._real_update) { + // console.log('force update of ipyvolume') + // model._real_update() + // } + // }) + result.then((view) => { + let viewId = view.cid; + if(viewIdSnapshots[viewId] == undefined) { + viewIdSnapshots[viewId] = new defer() + } + let d = viewIdSnapshots[viewId]; + /*domtoimage.toPng(view.el).then(function (dataUrl) { + var img = new Image(); + img.src = dataUrl; + document.body.appendChild(img); + }).catch(function (error) { + console.error('oops, something went wrong!', error); + }); + */ + view.on('displayed', () => { + console.log('callbacks', (window as any)._webgl_update_callbacks) + }) + + + }) + return result; + } + models: any; + + // async build_widgets() { + // let models = await this.build_models() + // window.models = models + // let element = document.body; + // let tags = element.querySelectorAll('script[type="application/vnd.jupyter.widget-view+json"]'); + // for (let i=0; i!=tags.length; ++i) { + // let viewtag = tags[i]; + // let widgetViewObject = JSON.parse(viewtag.innerHTML); + // let model_id = widgetViewObject.model_id; + // let model = models[model_id] + // let prev = viewtag.previousElementSibling; + // let widgetTag = document.createElement('div'); + // widgetTag.className = 'widget-subarea'; + // viewtag.parentElement.insertBefore(widgetTag, viewtag); + // this.display_model(undefined, model, { el : widgetTag }); + // } + // } + // async build_models() { + // let comm_ids = await this._get_comm_info() + // let models = {}; + // let widgets_info = await Promise.all(Object.keys(comm_ids).map(async (comm_id) => { + // var comm = await this._create_comm(this.comm_target_name, comm_id); + // return this._update_comm(comm); + // })); + // // do the creation of the widgets in parallel + // await Promise.all(widgets_info.map(async (widget_info) => { + // let promise = this.new_model({ + // model_name: widget_info.msg.content.data.state._model_name, + // model_module: widget_info.msg.content.data.state._model_module, + // model_module_version: widget_info.msg.content.data.state._model_module_version, + // comm: widget_info.comm, + // }, widget_info.msg.content.data.state); + // let model = await promise; + // models[model.model_id] = model; + // return promise; + // })); + // return models + // } + + // async _update_comm(comm) { + // return new Promise(function(resolve, reject) { + // comm.on_msg(async (msg) => { + // base.put_buffers(msg.content.data.state, msg.content.data.buffer_paths, msg.buffers); + // if (msg.content.data.method === 'update') { + // resolve({comm: comm, msg: msg}) + // } + // }); + // comm.send({method: 'request_state'}, {}) + // }) + // } + + // get onError() { + // return this._onError + // } + + // registerWithKernel(kernel) { + // if (this._commRegistration) { + // this._commRegistration.dispose(); + // } + // this._commRegistration = kernel.registerCommTarget( + // this.comm_target_name, + // (comm, message) => + // this.handle_comm_open(new ShimmedComm(comm), message) + // ); + // } + + // display_view(msg, view, options) { + // const el = options.el || this.el; + // return Promise.resolve(view).then(view => { + // pWidget.Widget.attach(view.pWidget, el); + // view.on('remove', function() { + // console.log('view removed', view); + // }); + // return view; + // }); + // } + + // loadClass(className, moduleName, moduleVersion) { + // if (moduleName === '@jupyter-widgets/output') { + // return Promise.resolve(outputWidgets).then(module => { + // if (module[className]) { + // return module[className]; + // } else { + // return Promise.reject( + // `Class ${className} not found in module ${moduleName}` + // ); + // } + // }) + // } else { + // return super.loadClass(className, moduleName, moduleVersion) + // } + // } + + // callbacks(view) { + // const baseCallbacks = super.callbacks(view) + // return { + // ...baseCallbacks, + // iopub: { output: (msg) => this._onError.emit(msg) } + // } + // } + + // _create_comm(target_name, model_id, data, metadata) { + // const comm = this.kernel.connectToComm(target_name, model_id) + // if (data || metadata ) { + // comm.open(data, metadata) + // } + // return Promise.resolve(new ShimmedComm(comm)) + // } + + // _get_comm_info() { + // return this.kernel.requestCommInfo({ target: this.comm_target_name}) + // .then(reply => reply.content.comms) + // } +} diff --git a/src/output.js b/src/output.js new file mode 100644 index 000000000..66f06673a --- /dev/null +++ b/src/output.js @@ -0,0 +1,148 @@ +// This is mostly copied from +// https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/jupyterlab-manager/src/output.ts + +import * as outputBase from '@jupyter-widgets/output'; + +import { DOMWidgetView, JupyterPhosphorWidget } from '@jupyter-widgets/base'; + +import { Panel } from '@phosphor/widgets'; + +import { OutputAreaModel, OutputArea } from '@jupyterlab/outputarea'; + +const OUTPUT_WIDGET_VERSION = outputBase.OUTPUT_WIDGET_VERSION; + +export +class OutputModel extends outputBase.OutputModel { + defaults() { + return { + ...super.defaults(), + msg_id: '' + }; + } + + initialize(attributes, options) { + super.initialize(attributes, options) + // The output area model is trusted since widgets are + // only rendered in trusted contexts. + this._outputs = new OutputAreaModel({trusted: true}); + this.listenTo(this, 'change:msg_id', this.onMsgIdChange); + this.onMsgIdChange(); + } + + onMsgIdChange() { + if (this._msgHook) { + this._msgHook.dispose(); + } + this._msgHook = null; + + const kernel = this.widget_manager.kernel; + const msgId = this.get('msg_id'); + if (kernel && msgId) { + this._msgHook = kernel.registerMessageHook(msgId, msg => { + this.add(msg); + return false; + }); + } + } + + add(msg) { + const msgType = msg.header.msg_type; + switch (msgType) { + case 'execute_result': + case 'display_data': + case 'stream': + case 'error': + const model = msg.content; + model.output_type = msgType; + this._outputs.add(model); + break; + case 'clear_output': + this.clear_output(msg.content.wait); + break; + default: + break; + } + } + + clear_output(wait = false) { + this._outputs.clear(wait); + } + + get outputs() { + return this._outputs; + } +} + +class JupyterPhosphorPanelWidget extends Panel { + constructor(options) { + const { view } = options; + delete options.view; + super(options); + this._view = view; + } + + /** + * Process the phosphor message. + * + * Any custom phosphor widget used inside a Jupyter widget should override + * the processMessage function like this. + */ + processMessage(msg) { + super.processMessage(msg); + this._view.processPhosphorMessage(msg); + } + + /** + * Dispose the widget. + * + * This causes the view to be destroyed as well with 'remove' + */ + dispose() { + if (this.isDisposed) { + return; + } + super.dispose(); + if (this._view) { + this._view.remove(); + } + this._view = null; + } +} + +export class OutputView extends outputBase.OutputView { + + _createElement(tagName) { + this.pWidget = new Panel() + return this.pWidget.node; + } + + _setElement(el) { + if (this.el || el !== this.pWidget.node) { + // Boxes don't allow setting the element beyond the initial creation. + throw new Error('Cannot reset the DOM element.'); + } + + this.el = this.pWidget.node; + } + + /** + * Called when view is rendered. + */ + render() { + super.render(); + this._outputView = new OutputArea({ + rendermime: this.model.widget_manager.renderMime, + contentFactory: OutputArea.defaultContentFactory, + model: this.model.outputs + }); + this.pWidget.insertWidget(0, this._outputView); + this.pWidget.addClass('jupyter-widgets'); + this.pWidget.addClass('widget-output'); + this.update(); // Set defaults. + } + + remove() { + this._outputView.dispose(); + return super.remove(); + } +} diff --git a/src/renderMime.ts b/src/renderMime.ts new file mode 100644 index 000000000..38ecd76e2 --- /dev/null +++ b/src/renderMime.ts @@ -0,0 +1,23 @@ + +import { RenderMimeRegistry, standardRendererFactories } from '@jupyterlab/rendermime'; + +import { WIDGET_MIMETYPE, WidgetRenderer } from '@jupyter-widgets/html-manager/lib/output_renderers'; + +export function createSimpleRenderMimeRegistry() { + const renderMime = new RenderMimeRegistry({ + initialFactories: standardRendererFactories + }); + return renderMime +} + +export function createRenderMimeRegistryWithWidgets(manager) { + const renderMime = createSimpleRenderMimeRegistry() + + renderMime.addFactory({ + safe: false, + mimeTypes: [WIDGET_MIMETYPE], + createRenderer: options => new WidgetRenderer(options, manager) + }, 1) + + return renderMime; +} diff --git a/src/services-shim.js b/src/services-shim.js new file mode 100644 index 000000000..bbe1098f4 --- /dev/null +++ b/src/services-shim.js @@ -0,0 +1,104 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +// Copied from https://github.com/jupyter-widgets/ipywidgets/blob/master/packages/base/src/services-shim.ts + +/** + * This module defines shims for @jupyterlab/services that allows you to use the + * old comm API. Use this, @jupyterlab/services, and the widget base manager to + * embed live widgets in a context outside of the notebook. + */ + +// Modified this slightly to also intercept error messages from the +// kernel so they can be displayed in the error area. + +import { Kernel } from '@jupyterlab/services'; + + +export class ShimmedComm { + constructor(jsServicesComm) { + this.jsServicesComm = jsServicesComm; + this.comm_id = this.jsServicesComm.commId + this.target_name = this.jsServicesComm.targetName + } + + + /** + * Opens a sibling comm in the backend + */ + open(data, callbacks, metadata, buffers) { + const future = this.jsServicesComm.open(data, metadata, buffers); + this._hookupCallbacks(future, callbacks); + return future.msg.header.msg_id; + } + + /** + * Sends a message to the sibling comm in the backend + */ + send(data, callbacks, metadata, buffers) { + let future = this.jsServicesComm.send(data, metadata, buffers); + this._hookupCallbacks(future, callbacks); + return future.msg.header.msg_id; + } + + /** + * Closes the sibling comm in the backend + */ + close(data, callbacks, metadata, buffers) { + let future = this.jsServicesComm.close(data, metadata, buffers); + this._hookupCallbacks(future, callbacks); + return future.msg.header.msg_id; + } + + /** + * Register a message handler + */ + on_msg(callback) { + this.jsServicesComm.onMsg = callback.bind(this); + } + + /** + * Register a handler for when the comm is closed by the backend + */ + on_close(callback) { + this.jsServicesComm.onClose = callback.bind(this); + } + + /** + * Hooks callback object up with @jupyterlab/services IKernelFuture + */ + _hookupCallbacks(future, callbacks) { + if (callbacks) { + future.onReply = function(msg) { + if (callbacks.shell && callbacks.shell.reply) { + callbacks.shell.reply(msg); + } + // TODO: Handle payloads. See https://github.com/jupyter/notebook/blob/master/notebook/static/services/kernels/kernel.js#L923-L947 + }; + + future.onStdin = function(msg) { + if (callbacks.input) { + callbacks.input(msg); + } + }; + + future.onIOPub = function(msg) { + if (callbacks.iopub) { + if (callbacks.iopub.status && msg.header.msg_type === 'status') { + callbacks.iopub.status(msg); + } else if (callbacks.iopub.clear_output && msg.header.msg_type === 'clear_output') { + callbacks.iopub.clear_output(msg); + } else if (callbacks.iopub.output) { + switch (msg.header.msg_type) { + case 'display_data': + case 'execute_result': + case 'error': + callbacks.iopub.output(msg); + break; + } + } + } + }; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..94bf3a02c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declaration": true, + "lib": ["es2015", "dom"], + "module": "commonjs", + "moduleResolution": "node", + // "noEmitOnError": true, + // "noUnusedLocals": true, + "outDir": "./lib", + "target": "es2015", + // "strict": false, + // "strictNullChecks": false, + "types": ["requirejs"], + }, + "include": ["src/*"] +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 000000000..8c7c3a532 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,70 @@ +var version = require('./package.json').version; +const path = require('path'); +var pyname = 'jupyter-widgets-render' +var postcss = require('postcss'); + +// Custom webpack loaders are generally the same for all webpack bundles, hence +// stored in a separate local variable. +var rules = [ + // { test: /\.css$/, use: ['style-loader', 'css-loader']}, + {test: /\.png$/,use: 'url-loader?limit=10000000'}, + { test: /\.css$/, use: [ + 'style-loader', + 'css-loader', + { + loader: 'postcss-loader', + options: { + plugins: [ + postcss.plugin('delete-tilde', function() { + return function (css) { + css.walkAtRules('import', function(rule) { + rule.params = rule.params.replace('~', ''); + }); + }; + }), + require('postcss-import')(), + ] + } + } + ]}, + { test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?limit=10000&mimetype=application/font-woff' }, + { test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?limit=10000&mimetype=application/font-woff' }, + { test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?limit=10000&mimetype=application/octet-stream' }, + { test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, use: 'file-loader' }, + { test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, use: 'url-loader?limit=10000&mimetype=image/svg+xml' } + + // { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/}, + // { test: /\.(ts|js)?$/, use: [ + // { loader: 'cache-loader' }, + // { + // loader: 'thread-loader', + // options: { + // // there should be 1 cpu for the fork-ts-checker-webpack-plugin + // workers: require('os').cpus().length - 1, + // }, + // }, + // { loader: "ts-loader", options: {transpileOnly: true,happyPackMode: true} } + // ]} +]; + +var resolve = { + extensions: ['.ts', '.js'] +}; + + +module.exports = [ + { entry: './lib/index.js', + devtool: 'inline-source-map', + output: { + filename: 'snapshot.js', + path: path.resolve(__dirname, `nbconvert/resources`), + libraryTarget: 'amd' + }, + devtool: 'source-map', + module: { + rules: rules + }, + // externals: ['@jupyter-widgets/base', '@jupyter-widgets/controls'], + resolve: resolve + }, +];