##// END OF EJS Templates
SVG scoping must be explicitly enabled by the user...
Pablo de Oliveira -
Show More
@@ -1,676 +1,708 b''
1 1 # -*- coding: utf-8 -*-
2 2 """Top-level display functions for displaying object in different formats.
3 3
4 4 Authors:
5 5
6 6 * Brian Granger
7 7 """
8 8
9 9 #-----------------------------------------------------------------------------
10 10 # Copyright (C) 2013 The IPython Development Team
11 11 #
12 12 # Distributed under the terms of the BSD License. The full license is in
13 13 # the file COPYING, distributed as part of this software.
14 14 #-----------------------------------------------------------------------------
15 15
16 16 #-----------------------------------------------------------------------------
17 17 # Imports
18 18 #-----------------------------------------------------------------------------
19 19
20 20 from __future__ import print_function
21 21
22 22 import os
23 23 import struct
24 24
25 25 from IPython.utils.py3compat import (string_types, cast_bytes_py2, cast_unicode,
26 26 unicode_type)
27 27
28 28 from .displaypub import publish_display_data
29 29
30 30 #-----------------------------------------------------------------------------
31 31 # utility functions
32 32 #-----------------------------------------------------------------------------
33 33
34 34 def _safe_exists(path):
35 35 """Check path, but don't let exceptions raise"""
36 36 try:
37 37 return os.path.exists(path)
38 38 except Exception:
39 39 return False
40 40
41 41 def _merge(d1, d2):
42 42 """Like update, but merges sub-dicts instead of clobbering at the top level.
43 43
44 44 Updates d1 in-place
45 45 """
46 46
47 47 if not isinstance(d2, dict) or not isinstance(d1, dict):
48 48 return d2
49 49 for key, value in d2.items():
50 50 d1[key] = _merge(d1.get(key), value)
51 51 return d1
52 52
53 53 def _display_mimetype(mimetype, objs, raw=False, metadata=None):
54 54 """internal implementation of all display_foo methods
55 55
56 56 Parameters
57 57 ----------
58 58 mimetype : str
59 59 The mimetype to be published (e.g. 'image/png')
60 60 objs : tuple of objects
61 61 The Python objects to display, or if raw=True raw text data to
62 62 display.
63 63 raw : bool
64 64 Are the data objects raw data or Python objects that need to be
65 65 formatted before display? [default: False]
66 66 metadata : dict (optional)
67 67 Metadata to be associated with the specific mimetype output.
68 68 """
69 69 if metadata:
70 70 metadata = {mimetype: metadata}
71 71 if raw:
72 72 # turn list of pngdata into list of { 'image/png': pngdata }
73 73 objs = [ {mimetype: obj} for obj in objs ]
74 74 display(*objs, raw=raw, metadata=metadata, include=[mimetype])
75 75
76 76 #-----------------------------------------------------------------------------
77 77 # Main functions
78 78 #-----------------------------------------------------------------------------
79 79
80 80 def display(*objs, **kwargs):
81 81 """Display a Python object in all frontends.
82 82
83 83 By default all representations will be computed and sent to the frontends.
84 84 Frontends can decide which representation is used and how.
85 85
86 86 Parameters
87 87 ----------
88 88 objs : tuple of objects
89 89 The Python objects to display.
90 90 raw : bool, optional
91 91 Are the objects to be displayed already mimetype-keyed dicts of raw display data,
92 92 or Python objects that need to be formatted before display? [default: False]
93 93 include : list or tuple, optional
94 94 A list of format type strings (MIME types) to include in the
95 95 format data dict. If this is set *only* the format types included
96 96 in this list will be computed.
97 97 exclude : list or tuple, optional
98 98 A list of format type strings (MIME types) to exclude in the format
99 99 data dict. If this is set all format types will be computed,
100 100 except for those included in this argument.
101 101 metadata : dict, optional
102 102 A dictionary of metadata to associate with the output.
103 103 mime-type keys in this dictionary will be associated with the individual
104 104 representation formats, if they exist.
105 105 """
106 106 raw = kwargs.get('raw', False)
107 107 include = kwargs.get('include')
108 108 exclude = kwargs.get('exclude')
109 109 metadata = kwargs.get('metadata')
110 110
111 111 from IPython.core.interactiveshell import InteractiveShell
112 112
113 113 if raw:
114 114 for obj in objs:
115 115 publish_display_data('display', obj, metadata)
116 116 else:
117 117 format = InteractiveShell.instance().display_formatter.format
118 118 for obj in objs:
119 119 format_dict, md_dict = format(obj, include=include, exclude=exclude)
120 120 if metadata:
121 121 # kwarg-specified metadata gets precedence
122 122 _merge(md_dict, metadata)
123 123 publish_display_data('display', format_dict, md_dict)
124 124
125 125
126 126 def display_pretty(*objs, **kwargs):
127 127 """Display the pretty (default) representation of an object.
128 128
129 129 Parameters
130 130 ----------
131 131 objs : tuple of objects
132 132 The Python objects to display, or if raw=True raw text data to
133 133 display.
134 134 raw : bool
135 135 Are the data objects raw data or Python objects that need to be
136 136 formatted before display? [default: False]
137 137 metadata : dict (optional)
138 138 Metadata to be associated with the specific mimetype output.
139 139 """
140 140 _display_mimetype('text/plain', objs, **kwargs)
141 141
142 142
143 143 def display_html(*objs, **kwargs):
144 144 """Display the HTML representation of an object.
145 145
146 146 Parameters
147 147 ----------
148 148 objs : tuple of objects
149 149 The Python objects to display, or if raw=True raw HTML data to
150 150 display.
151 151 raw : bool
152 152 Are the data objects raw data or Python objects that need to be
153 153 formatted before display? [default: False]
154 154 metadata : dict (optional)
155 155 Metadata to be associated with the specific mimetype output.
156 156 """
157 157 _display_mimetype('text/html', objs, **kwargs)
158 158
159 159
160 160 def display_svg(*objs, **kwargs):
161 161 """Display the SVG representation of an object.
162 162
163 163 Parameters
164 164 ----------
165 165 objs : tuple of objects
166 166 The Python objects to display, or if raw=True raw svg data to
167 167 display.
168 168 raw : bool
169 169 Are the data objects raw data or Python objects that need to be
170 170 formatted before display? [default: False]
171 171 metadata : dict (optional)
172 172 Metadata to be associated with the specific mimetype output.
173 173 """
174 174 _display_mimetype('image/svg+xml', objs, **kwargs)
175 175
176 176
177 177 def display_png(*objs, **kwargs):
178 178 """Display the PNG representation of an object.
179 179
180 180 Parameters
181 181 ----------
182 182 objs : tuple of objects
183 183 The Python objects to display, or if raw=True raw png data to
184 184 display.
185 185 raw : bool
186 186 Are the data objects raw data or Python objects that need to be
187 187 formatted before display? [default: False]
188 188 metadata : dict (optional)
189 189 Metadata to be associated with the specific mimetype output.
190 190 """
191 191 _display_mimetype('image/png', objs, **kwargs)
192 192
193 193
194 194 def display_jpeg(*objs, **kwargs):
195 195 """Display the JPEG representation of an object.
196 196
197 197 Parameters
198 198 ----------
199 199 objs : tuple of objects
200 200 The Python objects to display, or if raw=True raw JPEG data to
201 201 display.
202 202 raw : bool
203 203 Are the data objects raw data or Python objects that need to be
204 204 formatted before display? [default: False]
205 205 metadata : dict (optional)
206 206 Metadata to be associated with the specific mimetype output.
207 207 """
208 208 _display_mimetype('image/jpeg', objs, **kwargs)
209 209
210 210
211 211 def display_latex(*objs, **kwargs):
212 212 """Display the LaTeX representation of an object.
213 213
214 214 Parameters
215 215 ----------
216 216 objs : tuple of objects
217 217 The Python objects to display, or if raw=True raw latex data to
218 218 display.
219 219 raw : bool
220 220 Are the data objects raw data or Python objects that need to be
221 221 formatted before display? [default: False]
222 222 metadata : dict (optional)
223 223 Metadata to be associated with the specific mimetype output.
224 224 """
225 225 _display_mimetype('text/latex', objs, **kwargs)
226 226
227 227
228 228 def display_json(*objs, **kwargs):
229 229 """Display the JSON representation of an object.
230 230
231 231 Note that not many frontends support displaying JSON.
232 232
233 233 Parameters
234 234 ----------
235 235 objs : tuple of objects
236 236 The Python objects to display, or if raw=True raw json data to
237 237 display.
238 238 raw : bool
239 239 Are the data objects raw data or Python objects that need to be
240 240 formatted before display? [default: False]
241 241 metadata : dict (optional)
242 242 Metadata to be associated with the specific mimetype output.
243 243 """
244 244 _display_mimetype('application/json', objs, **kwargs)
245 245
246 246
247 247 def display_javascript(*objs, **kwargs):
248 248 """Display the Javascript representation of an object.
249 249
250 250 Parameters
251 251 ----------
252 252 objs : tuple of objects
253 253 The Python objects to display, or if raw=True raw javascript data to
254 254 display.
255 255 raw : bool
256 256 Are the data objects raw data or Python objects that need to be
257 257 formatted before display? [default: False]
258 258 metadata : dict (optional)
259 259 Metadata to be associated with the specific mimetype output.
260 260 """
261 261 _display_mimetype('application/javascript', objs, **kwargs)
262 262
263 263 #-----------------------------------------------------------------------------
264 264 # Smart classes
265 265 #-----------------------------------------------------------------------------
266 266
267 267
268 268 class DisplayObject(object):
269 269 """An object that wraps data to be displayed."""
270 270
271 271 _read_flags = 'r'
272 272
273 273 def __init__(self, data=None, url=None, filename=None):
274 274 """Create a display object given raw data.
275 275
276 276 When this object is returned by an expression or passed to the
277 277 display function, it will result in the data being displayed
278 278 in the frontend. The MIME type of the data should match the
279 279 subclasses used, so the Png subclass should be used for 'image/png'
280 280 data. If the data is a URL, the data will first be downloaded
281 281 and then displayed. If
282 282
283 283 Parameters
284 284 ----------
285 285 data : unicode, str or bytes
286 286 The raw data or a URL or file to load the data from
287 287 url : unicode
288 288 A URL to download the data from.
289 289 filename : unicode
290 290 Path to a local file to load the data from.
291 291 """
292 292 if data is not None and isinstance(data, string_types):
293 293 if data.startswith('http') and url is None:
294 294 url = data
295 295 filename = None
296 296 data = None
297 297 elif _safe_exists(data) and filename is None:
298 298 url = None
299 299 filename = data
300 300 data = None
301 301
302 302 self.data = data
303 303 self.url = url
304 304 self.filename = None if filename is None else unicode_type(filename)
305 305
306 306 self.reload()
307 307
308 308 def reload(self):
309 309 """Reload the raw data from file or URL."""
310 310 if self.filename is not None:
311 311 with open(self.filename, self._read_flags) as f:
312 312 self.data = f.read()
313 313 elif self.url is not None:
314 314 try:
315 315 import urllib2
316 316 response = urllib2.urlopen(self.url)
317 317 self.data = response.read()
318 318 # extract encoding from header, if there is one:
319 319 encoding = None
320 320 for sub in response.headers['content-type'].split(';'):
321 321 sub = sub.strip()
322 322 if sub.startswith('charset'):
323 323 encoding = sub.split('=')[-1].strip()
324 324 break
325 325 # decode data, if an encoding was specified
326 326 if encoding:
327 327 self.data = self.data.decode(encoding, 'replace')
328 328 except:
329 329 self.data = None
330 330
331 331 class Pretty(DisplayObject):
332 332
333 333 def _repr_pretty_(self):
334 334 return self.data
335 335
336 336
337 337 class HTML(DisplayObject):
338 338
339 339 def _repr_html_(self):
340 340 return self.data
341 341
342 342 def __html__(self):
343 343 """
344 344 This method exists to inform other HTML-using modules (e.g. Markupsafe,
345 345 htmltag, etc) that this object is HTML and does not need things like
346 346 special characters (<>&) escaped.
347 347 """
348 348 return self._repr_html_()
349 349
350 350
351 351 class Math(DisplayObject):
352 352
353 353 def _repr_latex_(self):
354 354 s = self.data.strip('$')
355 355 return "$$%s$$" % s
356 356
357 357
358 358 class Latex(DisplayObject):
359 359
360 360 def _repr_latex_(self):
361 361 return self.data
362 362
363 363
364 364 class SVG(DisplayObject):
365 365
366 def __init__(self, data=None, url=None, filename=None, scoped=False):
367 """Create a SVG display object given raw data.
368
369 When this object is returned by an expression or passed to the
370 display function, it will result in the data being displayed
371 in the frontend. If the data is a URL, the data will first be
372 downloaded and then displayed.
373
374 Parameters
375 ----------
376 data : unicode, str or bytes
377 The Javascript source code or a URL to download it from.
378 url : unicode
379 A URL to download the data from.
380 filename : unicode
381 Path to a local file to load the data from.
382 scoped : bool
383 Should the SVG declarations be scoped.
384 """
385 if not isinstance(scoped, (bool)):
386 raise TypeError('expected bool, got: %r' % scoped)
387 self.scoped = scoped
388 super(SVG, self).__init__(data=data, url=url, filename=filename)
389
366 390 # wrap data in a property, which extracts the <svg> tag, discarding
367 391 # document headers
368 392 _data = None
369 393
370 394 @property
371 395 def data(self):
372 396 return self._data
373 397
398 _scoped_class = "ipython-scoped"
399
374 400 @data.setter
375 401 def data(self, svg):
376 402 if svg is None:
377 403 self._data = None
378 404 return
379 405 # parse into dom object
380 406 from xml.dom import minidom
381 407 svg = cast_bytes_py2(svg)
382 408 x = minidom.parseString(svg)
383 409 # get svg tag (should be 1)
384 410 found_svg = x.getElementsByTagName('svg')
385 411 if found_svg:
412 # If the user request scoping, tag the svg with the
413 # ipython-scoped class
414 if self.scoped:
415 classes = (found_svg[0].getAttribute('class') +
416 " " + self._scoped_class)
417 found_svg[0].setAttribute('class', classes)
386 418 svg = found_svg[0].toxml()
387 419 else:
388 420 # fallback on the input, trust the user
389 421 # but this is probably an error.
390 422 pass
391 423 svg = cast_unicode(svg)
392 424 self._data = svg
393 425
394 426 def _repr_svg_(self):
395 427 return self.data
396 428
397 429
398 430 class JSON(DisplayObject):
399 431
400 432 def _repr_json_(self):
401 433 return self.data
402 434
403 435 css_t = """$("head").append($("<link/>").attr({
404 436 rel: "stylesheet",
405 437 type: "text/css",
406 438 href: "%s"
407 439 }));
408 440 """
409 441
410 442 lib_t1 = """$.getScript("%s", function () {
411 443 """
412 444 lib_t2 = """});
413 445 """
414 446
415 447 class Javascript(DisplayObject):
416 448
417 449 def __init__(self, data=None, url=None, filename=None, lib=None, css=None):
418 450 """Create a Javascript display object given raw data.
419 451
420 452 When this object is returned by an expression or passed to the
421 453 display function, it will result in the data being displayed
422 454 in the frontend. If the data is a URL, the data will first be
423 455 downloaded and then displayed.
424 456
425 457 In the Notebook, the containing element will be available as `element`,
426 458 and jQuery will be available. The output area starts hidden, so if
427 459 the js appends content to `element` that should be visible, then
428 460 it must call `container.show()` to unhide the area.
429 461
430 462 Parameters
431 463 ----------
432 464 data : unicode, str or bytes
433 465 The Javascript source code or a URL to download it from.
434 466 url : unicode
435 467 A URL to download the data from.
436 468 filename : unicode
437 469 Path to a local file to load the data from.
438 470 lib : list or str
439 471 A sequence of Javascript library URLs to load asynchronously before
440 472 running the source code. The full URLs of the libraries should
441 473 be given. A single Javascript library URL can also be given as a
442 474 string.
443 475 css: : list or str
444 476 A sequence of css files to load before running the source code.
445 477 The full URLs of the css files should be given. A single css URL
446 478 can also be given as a string.
447 479 """
448 480 if isinstance(lib, string_types):
449 481 lib = [lib]
450 482 elif lib is None:
451 483 lib = []
452 484 if isinstance(css, string_types):
453 485 css = [css]
454 486 elif css is None:
455 487 css = []
456 488 if not isinstance(lib, (list,tuple)):
457 489 raise TypeError('expected sequence, got: %r' % lib)
458 490 if not isinstance(css, (list,tuple)):
459 491 raise TypeError('expected sequence, got: %r' % css)
460 492 self.lib = lib
461 493 self.css = css
462 494 super(Javascript, self).__init__(data=data, url=url, filename=filename)
463 495
464 496 def _repr_javascript_(self):
465 497 r = ''
466 498 for c in self.css:
467 499 r += css_t % c
468 500 for l in self.lib:
469 501 r += lib_t1 % l
470 502 r += self.data
471 503 r += lib_t2*len(self.lib)
472 504 return r
473 505
474 506 # constants for identifying png/jpeg data
475 507 _PNG = b'\x89PNG\r\n\x1a\n'
476 508 _JPEG = b'\xff\xd8'
477 509
478 510 def _pngxy(data):
479 511 """read the (width, height) from a PNG header"""
480 512 ihdr = data.index(b'IHDR')
481 513 # next 8 bytes are width/height
482 514 w4h4 = data[ihdr+4:ihdr+12]
483 515 return struct.unpack('>ii', w4h4)
484 516
485 517 def _jpegxy(data):
486 518 """read the (width, height) from a JPEG header"""
487 519 # adapted from http://www.64lines.com/jpeg-width-height
488 520
489 521 idx = 4
490 522 while True:
491 523 block_size = struct.unpack('>H', data[idx:idx+2])[0]
492 524 idx = idx + block_size
493 525 if data[idx:idx+2] == b'\xFF\xC0':
494 526 # found Start of Frame
495 527 iSOF = idx
496 528 break
497 529 else:
498 530 # read another block
499 531 idx += 2
500 532
501 533 h, w = struct.unpack('>HH', data[iSOF+5:iSOF+9])
502 534 return w, h
503 535
504 536 class Image(DisplayObject):
505 537
506 538 _read_flags = 'rb'
507 539 _FMT_JPEG = u'jpeg'
508 540 _FMT_PNG = u'png'
509 541 _ACCEPTABLE_EMBEDDINGS = [_FMT_JPEG, _FMT_PNG]
510 542
511 543 def __init__(self, data=None, url=None, filename=None, format=u'png', embed=None, width=None, height=None, retina=False):
512 544 """Create a PNG/JPEG image object given raw data.
513 545
514 546 When this object is returned by an input cell or passed to the
515 547 display function, it will result in the image being displayed
516 548 in the frontend.
517 549
518 550 Parameters
519 551 ----------
520 552 data : unicode, str or bytes
521 553 The raw image data or a URL or filename to load the data from.
522 554 This always results in embedded image data.
523 555 url : unicode
524 556 A URL to download the data from. If you specify `url=`,
525 557 the image data will not be embedded unless you also specify `embed=True`.
526 558 filename : unicode
527 559 Path to a local file to load the data from.
528 560 Images from a file are always embedded.
529 561 format : unicode
530 562 The format of the image data (png/jpeg/jpg). If a filename or URL is given
531 563 for format will be inferred from the filename extension.
532 564 embed : bool
533 565 Should the image data be embedded using a data URI (True) or be
534 566 loaded using an <img> tag. Set this to True if you want the image
535 567 to be viewable later with no internet connection in the notebook.
536 568
537 569 Default is `True`, unless the keyword argument `url` is set, then
538 570 default value is `False`.
539 571
540 572 Note that QtConsole is not able to display images if `embed` is set to `False`
541 573 width : int
542 574 Width to which to constrain the image in html
543 575 height : int
544 576 Height to which to constrain the image in html
545 577 retina : bool
546 578 Automatically set the width and height to half of the measured
547 579 width and height.
548 580 This only works for embedded images because it reads the width/height
549 581 from image data.
550 582 For non-embedded images, you can just set the desired display width
551 583 and height directly.
552 584
553 585 Examples
554 586 --------
555 587 # embedded image data, works in qtconsole and notebook
556 588 # when passed positionally, the first arg can be any of raw image data,
557 589 # a URL, or a filename from which to load image data.
558 590 # The result is always embedding image data for inline images.
559 591 Image('http://www.google.fr/images/srpr/logo3w.png')
560 592 Image('/path/to/image.jpg')
561 593 Image(b'RAW_PNG_DATA...')
562 594
563 595 # Specifying Image(url=...) does not embed the image data,
564 596 # it only generates `<img>` tag with a link to the source.
565 597 # This will not work in the qtconsole or offline.
566 598 Image(url='http://www.google.fr/images/srpr/logo3w.png')
567 599
568 600 """
569 601 if filename is not None:
570 602 ext = self._find_ext(filename)
571 603 elif url is not None:
572 604 ext = self._find_ext(url)
573 605 elif data is None:
574 606 raise ValueError("No image data found. Expecting filename, url, or data.")
575 607 elif isinstance(data, string_types) and (
576 608 data.startswith('http') or _safe_exists(data)
577 609 ):
578 610 ext = self._find_ext(data)
579 611 else:
580 612 ext = None
581 613
582 614 if ext is not None:
583 615 format = ext.lower()
584 616 if ext == u'jpg' or ext == u'jpeg':
585 617 format = self._FMT_JPEG
586 618 if ext == u'png':
587 619 format = self._FMT_PNG
588 620 elif isinstance(data, bytes) and format == 'png':
589 621 # infer image type from image data header,
590 622 # only if format might not have been specified.
591 623 if data[:2] == _JPEG:
592 624 format = 'jpeg'
593 625
594 626 self.format = unicode_type(format).lower()
595 627 self.embed = embed if embed is not None else (url is None)
596 628
597 629 if self.embed and self.format not in self._ACCEPTABLE_EMBEDDINGS:
598 630 raise ValueError("Cannot embed the '%s' image format" % (self.format))
599 631 self.width = width
600 632 self.height = height
601 633 self.retina = retina
602 634 super(Image, self).__init__(data=data, url=url, filename=filename)
603 635
604 636 if retina:
605 637 self._retina_shape()
606 638
607 639 def _retina_shape(self):
608 640 """load pixel-doubled width and height from image data"""
609 641 if not self.embed:
610 642 return
611 643 if self.format == 'png':
612 644 w, h = _pngxy(self.data)
613 645 elif self.format == 'jpeg':
614 646 w, h = _jpegxy(self.data)
615 647 else:
616 648 # retina only supports png
617 649 return
618 650 self.width = w // 2
619 651 self.height = h // 2
620 652
621 653 def reload(self):
622 654 """Reload the raw data from file or URL."""
623 655 if self.embed:
624 656 super(Image,self).reload()
625 657 if self.retina:
626 658 self._retina_shape()
627 659
628 660 def _repr_html_(self):
629 661 if not self.embed:
630 662 width = height = ''
631 663 if self.width:
632 664 width = ' width="%d"' % self.width
633 665 if self.height:
634 666 height = ' height="%d"' % self.height
635 667 return u'<img src="%s"%s%s/>' % (self.url, width, height)
636 668
637 669 def _data_and_metadata(self):
638 670 """shortcut for returning metadata with shape information, if defined"""
639 671 md = {}
640 672 if self.width:
641 673 md['width'] = self.width
642 674 if self.height:
643 675 md['height'] = self.height
644 676 if md:
645 677 return self.data, md
646 678 else:
647 679 return self.data
648 680
649 681 def _repr_png_(self):
650 682 if self.embed and self.format == u'png':
651 683 return self._data_and_metadata()
652 684
653 685 def _repr_jpeg_(self):
654 686 if self.embed and (self.format == u'jpeg' or self.format == u'jpg'):
655 687 return self._data_and_metadata()
656 688
657 689 def _find_ext(self, s):
658 690 return unicode_type(s.split('.')[-1].lower())
659 691
660 692
661 693 def clear_output(wait=False):
662 694 """Clear the output of the current cell receiving output.
663 695
664 696 Parameters
665 697 ----------
666 698 wait : bool [default: false]
667 699 Wait to clear the output until new output is available to replace it."""
668 700 from IPython.core.interactiveshell import InteractiveShell
669 701 if InteractiveShell.initialized():
670 702 InteractiveShell.instance().display_pub.clear_output(wait)
671 703 else:
672 704 from IPython.utils import io
673 705 print('\033[2K\r', file=io.stdout, end='')
674 706 io.stdout.flush()
675 707 print('\033[2K\r', file=io.stderr, end='')
676 708 io.stderr.flush()
@@ -1,708 +1,712 b''
1 1 //----------------------------------------------------------------------------
2 2 // Copyright (C) 2008 The IPython Development Team
3 3 //
4 4 // Distributed under the terms of the BSD License. The full license is in
5 5 // the file COPYING, distributed as part of this software.
6 6 //----------------------------------------------------------------------------
7 7
8 8 //============================================================================
9 9 // OutputArea
10 10 //============================================================================
11 11
12 12 /**
13 13 * @module IPython
14 14 * @namespace IPython
15 15 * @submodule OutputArea
16 16 */
17 17 var IPython = (function (IPython) {
18 18 "use strict";
19 19
20 20 var utils = IPython.utils;
21 21
22 22 /**
23 23 * @class OutputArea
24 24 *
25 25 * @constructor
26 26 */
27 27
28 28 var OutputArea = function (selector, prompt_area) {
29 29 this.selector = selector;
30 30 this.wrapper = $(selector);
31 31 this.outputs = [];
32 32 this.collapsed = false;
33 33 this.scrolled = false;
34 34 this.clear_queued = null;
35 35 if (prompt_area === undefined) {
36 36 this.prompt_area = true;
37 37 } else {
38 38 this.prompt_area = prompt_area;
39 39 }
40 40 this.create_elements();
41 41 this.style();
42 42 this.bind_events();
43 43 };
44 44
45 45 OutputArea.prototype.create_elements = function () {
46 46 this.element = $("<div/>");
47 47 this.collapse_button = $("<div/>");
48 48 this.prompt_overlay = $("<div/>");
49 49 this.wrapper.append(this.prompt_overlay);
50 50 this.wrapper.append(this.element);
51 51 this.wrapper.append(this.collapse_button);
52 52 };
53 53
54 54
55 55 OutputArea.prototype.style = function () {
56 56 this.collapse_button.hide();
57 57 this.prompt_overlay.hide();
58 58
59 59 this.wrapper.addClass('output_wrapper');
60 60 this.element.addClass('output vbox');
61 61
62 62 this.collapse_button.addClass("btn output_collapsed");
63 63 this.collapse_button.attr('title', 'click to expand output');
64 64 this.collapse_button.html('. . .');
65 65
66 66 this.prompt_overlay.addClass('out_prompt_overlay prompt');
67 67 this.prompt_overlay.attr('title', 'click to expand output; double click to hide output');
68 68
69 69 this.collapse();
70 70 };
71 71
72 72 /**
73 73 * Should the OutputArea scroll?
74 74 * Returns whether the height (in lines) exceeds a threshold.
75 75 *
76 76 * @private
77 77 * @method _should_scroll
78 78 * @param [lines=100]{Integer}
79 79 * @return {Bool}
80 80 *
81 81 */
82 82 OutputArea.prototype._should_scroll = function (lines) {
83 83 if (lines <=0 ){ return }
84 84 if (!lines) {
85 85 lines = 100;
86 86 }
87 87 // line-height from http://stackoverflow.com/questions/1185151
88 88 var fontSize = this.element.css('font-size');
89 89 var lineHeight = Math.floor(parseInt(fontSize.replace('px','')) * 1.5);
90 90
91 91 return (this.element.height() > lines * lineHeight);
92 92 };
93 93
94 94
95 95 OutputArea.prototype.bind_events = function () {
96 96 var that = this;
97 97 this.prompt_overlay.dblclick(function () { that.toggle_output(); });
98 98 this.prompt_overlay.click(function () { that.toggle_scroll(); });
99 99
100 100 this.element.resize(function () {
101 101 // FIXME: Firefox on Linux misbehaves, so automatic scrolling is disabled
102 102 if ( IPython.utils.browser[0] === "Firefox" ) {
103 103 return;
104 104 }
105 105 // maybe scroll output,
106 106 // if it's grown large enough and hasn't already been scrolled.
107 107 if ( !that.scrolled && that._should_scroll(OutputArea.auto_scroll_threshold)) {
108 108 that.scroll_area();
109 109 }
110 110 });
111 111 this.collapse_button.click(function () {
112 112 that.expand();
113 113 });
114 114 };
115 115
116 116
117 117 OutputArea.prototype.collapse = function () {
118 118 if (!this.collapsed) {
119 119 this.element.hide();
120 120 this.prompt_overlay.hide();
121 121 if (this.element.html()){
122 122 this.collapse_button.show();
123 123 }
124 124 this.collapsed = true;
125 125 }
126 126 };
127 127
128 128
129 129 OutputArea.prototype.expand = function () {
130 130 if (this.collapsed) {
131 131 this.collapse_button.hide();
132 132 this.element.show();
133 133 this.prompt_overlay.show();
134 134 this.collapsed = false;
135 135 }
136 136 };
137 137
138 138
139 139 OutputArea.prototype.toggle_output = function () {
140 140 if (this.collapsed) {
141 141 this.expand();
142 142 } else {
143 143 this.collapse();
144 144 }
145 145 };
146 146
147 147
148 148 OutputArea.prototype.scroll_area = function () {
149 149 this.element.addClass('output_scroll');
150 150 this.prompt_overlay.attr('title', 'click to unscroll output; double click to hide');
151 151 this.scrolled = true;
152 152 };
153 153
154 154
155 155 OutputArea.prototype.unscroll_area = function () {
156 156 this.element.removeClass('output_scroll');
157 157 this.prompt_overlay.attr('title', 'click to scroll output; double click to hide');
158 158 this.scrolled = false;
159 159 };
160 160
161 161 /**
162 162 * Threshold to trigger autoscroll when the OutputArea is resized,
163 163 * typically when new outputs are added.
164 164 *
165 165 * Behavior is undefined if autoscroll is lower than minimum_scroll_threshold,
166 166 * unless it is < 0, in which case autoscroll will never be triggered
167 167 *
168 168 * @property auto_scroll_threshold
169 169 * @type Number
170 170 * @default 100
171 171 *
172 172 **/
173 173 OutputArea.auto_scroll_threshold = 100;
174 174
175 175
176 176 /**
177 177 * Lower limit (in lines) for OutputArea to be made scrollable. OutputAreas
178 178 * shorter than this are never scrolled.
179 179 *
180 180 * @property minimum_scroll_threshold
181 181 * @type Number
182 182 * @default 20
183 183 *
184 184 **/
185 185 OutputArea.minimum_scroll_threshold = 20;
186 186
187 187
188 188 /**
189 189 *
190 190 * Scroll OutputArea if height supperior than a threshold (in lines).
191 191 *
192 192 * Threshold is a maximum number of lines. If unspecified, defaults to
193 193 * OutputArea.minimum_scroll_threshold.
194 194 *
195 195 * Negative threshold will prevent the OutputArea from ever scrolling.
196 196 *
197 197 * @method scroll_if_long
198 198 *
199 199 * @param [lines=20]{Number} Default to 20 if not set,
200 200 * behavior undefined for value of `0`.
201 201 *
202 202 **/
203 203 OutputArea.prototype.scroll_if_long = function (lines) {
204 204 var n = lines | OutputArea.minimum_scroll_threshold;
205 205 if(n <= 0){
206 206 return
207 207 }
208 208
209 209 if (this._should_scroll(n)) {
210 210 // only allow scrolling long-enough output
211 211 this.scroll_area();
212 212 }
213 213 };
214 214
215 215
216 216 OutputArea.prototype.toggle_scroll = function () {
217 217 if (this.scrolled) {
218 218 this.unscroll_area();
219 219 } else {
220 220 // only allow scrolling long-enough output
221 221 this.scroll_if_long();
222 222 }
223 223 };
224 224
225 225
226 226 // typeset with MathJax if MathJax is available
227 227 OutputArea.prototype.typeset = function () {
228 228 if (window.MathJax){
229 229 MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
230 230 }
231 231 };
232 232
233 233
234 234 OutputArea.prototype.handle_output = function (msg) {
235 235 var json = {};
236 236 var msg_type = json.output_type = msg.header.msg_type;
237 237 var content = msg.content;
238 238 if (msg_type === "stream") {
239 239 json.text = content.data;
240 240 json.stream = content.name;
241 241 } else if (msg_type === "display_data") {
242 242 json = this.convert_mime_types(json, content.data);
243 243 json.metadata = this.convert_mime_types({}, content.metadata);
244 244 } else if (msg_type === "pyout") {
245 245 json.prompt_number = content.execution_count;
246 246 json = this.convert_mime_types(json, content.data);
247 247 json.metadata = this.convert_mime_types({}, content.metadata);
248 248 } else if (msg_type === "pyerr") {
249 249 json.ename = content.ename;
250 250 json.evalue = content.evalue;
251 251 json.traceback = content.traceback;
252 252 }
253 253 // append with dynamic=true
254 254 this.append_output(json, true);
255 255 };
256 256
257 257
258 258 OutputArea.prototype.convert_mime_types = function (json, data) {
259 259 if (data === undefined) {
260 260 return json;
261 261 }
262 262 if (data['text/plain'] !== undefined) {
263 263 json.text = data['text/plain'];
264 264 }
265 265 if (data['text/html'] !== undefined) {
266 266 json.html = data['text/html'];
267 267 }
268 268 if (data['image/svg+xml'] !== undefined) {
269 269 json.svg = data['image/svg+xml'];
270 270 }
271 271 if (data['image/png'] !== undefined) {
272 272 json.png = data['image/png'];
273 273 }
274 274 if (data['image/jpeg'] !== undefined) {
275 275 json.jpeg = data['image/jpeg'];
276 276 }
277 277 if (data['text/latex'] !== undefined) {
278 278 json.latex = data['text/latex'];
279 279 }
280 280 if (data['application/json'] !== undefined) {
281 281 json.json = data['application/json'];
282 282 }
283 283 if (data['application/javascript'] !== undefined) {
284 284 json.javascript = data['application/javascript'];
285 285 }
286 286 return json;
287 287 };
288 288
289 289
290 290 OutputArea.prototype.append_output = function (json, dynamic) {
291 291 // If dynamic is true, javascript output will be eval'd.
292 292 this.expand();
293 293
294 294 // Clear the output if clear is queued.
295 295 var needs_height_reset = false;
296 296 if (this.clear_queued) {
297 297 this.clear_output(false);
298 298 needs_height_reset = true;
299 299 }
300 300
301 301 if (json.output_type === 'pyout') {
302 302 this.append_pyout(json, dynamic);
303 303 } else if (json.output_type === 'pyerr') {
304 304 this.append_pyerr(json);
305 305 } else if (json.output_type === 'display_data') {
306 306 this.append_display_data(json, dynamic);
307 307 } else if (json.output_type === 'stream') {
308 308 this.append_stream(json);
309 309 }
310 310 this.outputs.push(json);
311 311
312 312 // Only reset the height to automatic if the height is currently
313 313 // fixed (done by wait=True flag on clear_output).
314 314 if (needs_height_reset) {
315 315 this.element.height('');
316 316 }
317 317
318 318 var that = this;
319 319 setTimeout(function(){that.element.trigger('resize');}, 100);
320 320 };
321 321
322 322
323 323 OutputArea.prototype.create_output_area = function () {
324 324 var oa = $("<div/>").addClass("output_area");
325 325 if (this.prompt_area) {
326 326 oa.append($('<div/>').addClass('prompt'));
327 327 }
328 328 return oa;
329 329 };
330 330
331 331 OutputArea.prototype._append_javascript_error = function (err, container) {
332 332 // display a message when a javascript error occurs in display output
333 333 var msg = "Javascript error adding output!"
334 334 console.log(msg, err);
335 335 if ( container === undefined ) return;
336 336 container.append(
337 337 $('<div/>').html(msg + "<br/>" +
338 338 err.toString() +
339 339 '<br/>See your browser Javascript console for more details.'
340 340 ).addClass('js-error')
341 341 );
342 342 container.show();
343 343 };
344 344
345 345 OutputArea.prototype._safe_append = function (toinsert) {
346 346 // safely append an item to the document
347 347 // this is an object created by user code,
348 348 // and may have errors, which should not be raised
349 349 // under any circumstances.
350 350 try {
351 351 this.element.append(toinsert);
352 352 } catch(err) {
353 353 console.log(err);
354 354 this._append_javascript_error(err, this.element);
355 355 }
356 356 };
357 357
358 358
359 359 OutputArea.prototype.append_pyout = function (json, dynamic) {
360 360 var n = json.prompt_number || ' ';
361 361 var toinsert = this.create_output_area();
362 362 if (this.prompt_area) {
363 363 toinsert.find('div.prompt').addClass('output_prompt').html('Out[' + n + ']:');
364 364 }
365 365 this.append_mime_type(json, toinsert, dynamic);
366 366 this._safe_append(toinsert);
367 367 // If we just output latex, typeset it.
368 368 if ((json.latex !== undefined) || (json.html !== undefined)) {
369 369 this.typeset();
370 370 }
371 371 };
372 372
373 373
374 374 OutputArea.prototype.append_pyerr = function (json) {
375 375 var tb = json.traceback;
376 376 if (tb !== undefined && tb.length > 0) {
377 377 var s = '';
378 378 var len = tb.length;
379 379 for (var i=0; i<len; i++) {
380 380 s = s + tb[i] + '\n';
381 381 }
382 382 s = s + '\n';
383 383 var toinsert = this.create_output_area();
384 384 this.append_text(s, {}, toinsert);
385 385 this._safe_append(toinsert);
386 386 }
387 387 };
388 388
389 389
390 390 OutputArea.prototype.append_stream = function (json) {
391 391 // temporary fix: if stream undefined (json file written prior to this patch),
392 392 // default to most likely stdout:
393 393 if (json.stream == undefined){
394 394 json.stream = 'stdout';
395 395 }
396 396 var text = json.text;
397 397 var subclass = "output_"+json.stream;
398 398 if (this.outputs.length > 0){
399 399 // have at least one output to consider
400 400 var last = this.outputs[this.outputs.length-1];
401 401 if (last.output_type == 'stream' && json.stream == last.stream){
402 402 // latest output was in the same stream,
403 403 // so append directly into its pre tag
404 404 // escape ANSI & HTML specials:
405 405 var pre = this.element.find('div.'+subclass).last().find('pre');
406 406 var html = utils.fixCarriageReturn(
407 407 pre.html() + utils.fixConsole(text));
408 408 pre.html(html);
409 409 return;
410 410 }
411 411 }
412 412
413 413 if (!text.replace("\r", "")) {
414 414 // text is nothing (empty string, \r, etc.)
415 415 // so don't append any elements, which might add undesirable space
416 416 return;
417 417 }
418 418
419 419 // If we got here, attach a new div
420 420 var toinsert = this.create_output_area();
421 421 this.append_text(text, {}, toinsert, "output_stream "+subclass);
422 422 this._safe_append(toinsert);
423 423 };
424 424
425 425
426 426 OutputArea.prototype.append_display_data = function (json, dynamic) {
427 427 var toinsert = this.create_output_area();
428 428 this.append_mime_type(json, toinsert, dynamic);
429 429 this._safe_append(toinsert);
430 430 // If we just output latex, typeset it.
431 431 if ( (json.latex !== undefined) || (json.html !== undefined) ) {
432 432 this.typeset();
433 433 }
434 434 };
435 435
436 436 OutputArea.display_order = ['javascript','html','latex','svg','png','jpeg','text'];
437 437
438 438 OutputArea.prototype.append_mime_type = function (json, element, dynamic) {
439 439 for(var type_i in OutputArea.display_order){
440 440 var type = OutputArea.display_order[type_i];
441 441 if(json[type] != undefined ){
442 442 var md = {};
443 443 if (json.metadata && json.metadata[type]) {
444 444 md = json.metadata[type];
445 445 };
446 446 if(type == 'javascript'){
447 447 if (dynamic) {
448 448 this.append_javascript(json.javascript, md, element, dynamic);
449 449 }
450 450 } else {
451 451 this['append_'+type](json[type], md, element);
452 452 }
453 453 return;
454 454 }
455 455 }
456 456 };
457 457
458 458
459 459 OutputArea.prototype.append_html = function (html, md, element) {
460 460 var toinsert = $("<div/>").addClass("output_subarea output_html rendered_html");
461 461 toinsert.append(html);
462 462 element.append(toinsert);
463 463 };
464 464
465 465
466 466 OutputArea.prototype.append_javascript = function (js, md, container) {
467 467 // We just eval the JS code, element appears in the local scope.
468 468 var element = $("<div/>").addClass("output_subarea");
469 469 container.append(element);
470 470 // Div for js shouldn't be drawn, as it will add empty height to the area.
471 471 container.hide();
472 472 // If the Javascript appends content to `element` that should be drawn, then
473 473 // it must also call `container.show()`.
474 474 try {
475 475 eval(js);
476 476 } catch(err) {
477 477 this._append_javascript_error(err, container);
478 478 }
479 479 };
480 480
481 481
482 482 OutputArea.prototype.append_text = function (data, md, element, extra_class) {
483 483 var toinsert = $("<div/>").addClass("output_subarea output_text");
484 484 // escape ANSI & HTML specials in plaintext:
485 485 data = utils.fixConsole(data);
486 486 data = utils.fixCarriageReturn(data);
487 487 data = utils.autoLinkUrls(data);
488 488 if (extra_class){
489 489 toinsert.addClass(extra_class);
490 490 }
491 491 toinsert.append($("<pre/>").html(data));
492 492 element.append(toinsert);
493 493 };
494 494
495 495
496 496 OutputArea.prototype.append_svg = function (svg, md, element) {
497 var wrapper = $('<div/>').addClass('output_subarea output_svg');
498 wrapper.append(svg);
499 var svg_element = wrapper.children()[0];
500
501 if (svg_element.classList.contains('ipython-scoped')) {
497 502 // To avoid style or use collisions between multiple svg figures,
498 503 // svg figures are wrapped inside an iframe.
499
500 504 var iframe = $('<iframe/>')
501 505 iframe.attr('frameborder', 0);
502 506 iframe.attr('scrolling', 'no');
503 507
504 var wrapper = $("<div/>").addClass("output_subarea output_svg");
505 wrapper.append(svg);
506
507 508 // Once the iframe is loaded, the svg is dynamically inserted
508 509 iframe.on('load', function() {
509 510 // Set the iframe height and width to fit the svg
510 511 // (the +10 pixel offset handles the default body margins
511 512 // in Chrome)
512 var svg = wrapper.children()[0];
513 iframe.width(svg.width.baseVal.value + 10);
514 iframe.height(svg.height.baseVal.value + 10);
513 iframe.width(svg_element.width.baseVal.value + 10);
514 iframe.height(svg_element.height.baseVal.value + 10);
515 515
516 // Workaround needed by Firefox, to properly render svg inside iframes,
517 // see http://stackoverflow.com/questions/10177190/svg-dynamically-added-to-iframe-does-not-render-correctly
516 // Workaround needed by Firefox, to properly render svg inside
517 // iframes, see http://stackoverflow.com/questions/10177190/
518 // svg-dynamically-added-to-iframe-does-not-render-correctly
518 519 iframe.contents()[0].open();
519 520 iframe.contents()[0].close();
520 521
521 522 // Insert the svg inside the iframe
522 523 var body = iframe.contents().find('body');
523 524 body.html(wrapper.html());
524 525 });
525 526
526 527 element.append(iframe);
528 } else {
529 element.append(wrapper);
530 }
527 531 };
528 532
529 533
530 534 OutputArea.prototype._dblclick_to_reset_size = function (img) {
531 535 // schedule wrapping image in resizable after a delay,
532 536 // so we don't end up calling resize on a zero-size object
533 537 var that = this;
534 538 setTimeout(function () {
535 539 var h0 = img.height();
536 540 var w0 = img.width();
537 541 if (!(h0 && w0)) {
538 542 // zero size, schedule another timeout
539 543 that._dblclick_to_reset_size(img);
540 544 return;
541 545 }
542 546 img.resizable({
543 547 aspectRatio: true,
544 548 autoHide: true
545 549 });
546 550 img.dblclick(function () {
547 551 // resize wrapper & image together for some reason:
548 552 img.parent().height(h0);
549 553 img.height(h0);
550 554 img.parent().width(w0);
551 555 img.width(w0);
552 556 });
553 557 }, 250);
554 558 };
555 559
556 560
557 561 OutputArea.prototype.append_png = function (png, md, element) {
558 562 var toinsert = $("<div/>").addClass("output_subarea output_png");
559 563 var img = $("<img/>").attr('src','data:image/png;base64,'+png);
560 564 if (md['height']) {
561 565 img.attr('height', md['height']);
562 566 }
563 567 if (md['width']) {
564 568 img.attr('width', md['width']);
565 569 }
566 570 this._dblclick_to_reset_size(img);
567 571 toinsert.append(img);
568 572 element.append(toinsert);
569 573 };
570 574
571 575
572 576 OutputArea.prototype.append_jpeg = function (jpeg, md, element) {
573 577 var toinsert = $("<div/>").addClass("output_subarea output_jpeg");
574 578 var img = $("<img/>").attr('src','data:image/jpeg;base64,'+jpeg);
575 579 if (md['height']) {
576 580 img.attr('height', md['height']);
577 581 }
578 582 if (md['width']) {
579 583 img.attr('width', md['width']);
580 584 }
581 585 this._dblclick_to_reset_size(img);
582 586 toinsert.append(img);
583 587 element.append(toinsert);
584 588 };
585 589
586 590
587 591 OutputArea.prototype.append_latex = function (latex, md, element) {
588 592 // This method cannot do the typesetting because the latex first has to
589 593 // be on the page.
590 594 var toinsert = $("<div/>").addClass("output_subarea output_latex");
591 595 toinsert.append(latex);
592 596 element.append(toinsert);
593 597 };
594 598
595 599 OutputArea.prototype.append_raw_input = function (msg) {
596 600 var that = this;
597 601 this.expand();
598 602 var content = msg.content;
599 603 var area = this.create_output_area();
600 604
601 605 // disable any other raw_inputs, if they are left around
602 606 $("div.output_subarea.raw_input").remove();
603 607
604 608 area.append(
605 609 $("<div/>")
606 610 .addClass("box-flex1 output_subarea raw_input")
607 611 .append(
608 612 $("<span/>")
609 613 .addClass("input_prompt")
610 614 .text(content.prompt)
611 615 )
612 616 .append(
613 617 $("<input/>")
614 618 .addClass("raw_input")
615 619 .attr('type', 'text')
616 620 .attr("size", 47)
617 621 .keydown(function (event, ui) {
618 622 // make sure we submit on enter,
619 623 // and don't re-execute the *cell* on shift-enter
620 624 if (event.which === utils.keycodes.ENTER) {
621 625 that._submit_raw_input();
622 626 return false;
623 627 }
624 628 })
625 629 )
626 630 );
627 631 this.element.append(area);
628 632 // weirdly need double-focus now,
629 633 // otherwise only the cell will be focused
630 634 area.find("input.raw_input").focus().focus();
631 635 }
632 636 OutputArea.prototype._submit_raw_input = function (evt) {
633 637 var container = this.element.find("div.raw_input");
634 638 var theprompt = container.find("span.input_prompt");
635 639 var theinput = container.find("input.raw_input");
636 640 var value = theinput.val();
637 641 var content = {
638 642 output_type : 'stream',
639 643 name : 'stdout',
640 644 text : theprompt.text() + value + '\n'
641 645 }
642 646 // remove form container
643 647 container.parent().remove();
644 648 // replace with plaintext version in stdout
645 649 this.append_output(content, false);
646 650 $([IPython.events]).trigger('send_input_reply.Kernel', value);
647 651 }
648 652
649 653
650 654 OutputArea.prototype.handle_clear_output = function (msg) {
651 655 this.clear_output(msg.content.wait);
652 656 };
653 657
654 658
655 659 OutputArea.prototype.clear_output = function(wait) {
656 660 if (wait) {
657 661
658 662 // If a clear is queued, clear before adding another to the queue.
659 663 if (this.clear_queued) {
660 664 this.clear_output(false);
661 665 };
662 666
663 667 this.clear_queued = true;
664 668 } else {
665 669
666 670 // Fix the output div's height if the clear_output is waiting for
667 671 // new output (it is being used in an animation).
668 672 if (this.clear_queued) {
669 673 var height = this.element.height();
670 674 this.element.height(height);
671 675 this.clear_queued = false;
672 676 }
673 677
674 678 // clear all, no need for logic
675 679 this.element.html("");
676 680 this.outputs = [];
677 681 this.unscroll_area();
678 682 return;
679 683 };
680 684 };
681 685
682 686
683 687 // JSON serialization
684 688
685 689 OutputArea.prototype.fromJSON = function (outputs) {
686 690 var len = outputs.length;
687 691 for (var i=0; i<len; i++) {
688 692 // append with dynamic=false.
689 693 this.append_output(outputs[i], false);
690 694 }
691 695 };
692 696
693 697
694 698 OutputArea.prototype.toJSON = function () {
695 699 var outputs = [];
696 700 var len = this.outputs.length;
697 701 for (var i=0; i<len; i++) {
698 702 outputs[i] = this.outputs[i];
699 703 }
700 704 return outputs;
701 705 };
702 706
703 707
704 708 IPython.OutputArea = OutputArea;
705 709
706 710 return IPython;
707 711
708 712 }(IPython));
General Comments 0
You need to be logged in to leave comments. Login now