Repository: bokeh/bokeh

Issue:
Specifying external URLs for resources
There are many instances where external (JS, CSS) resources are necessary or useful:
- `GMapPlot` needs to load Google APIs
- Custom User Models that wrap 3rd party JS libraries
- Katex+Fonts for LaTeX label support

Currently, the only options are to include explicit code in the BokehJS model to load external resources (as `GMapPlot` does) or to embed Bokeh models in a custom template that provides the necessary script or other tags. The proposal is to make it possible for Bokeh models to specify any required external resources in a self-contained manner. 

The primary tasks seem to be:
- [ ] Add hooks or properties to `Resources` to support specifying external JS, CSS links, etc
- [ ] Modify current templates and embedding to use these resources correctly
- [ ] Create a convention for `Model` to specify any necessary resources declaratively (e.g., perhaps a `__resources__ = [...]` class variable or similar) 
- [ ] Automatically collect any `Model` specific resources to be reported by `Resources` so that they are used during document serialization

Possible considerations: 
- need to load resources before "realizing" a document 
- de-dupe if a resource shows up twice?

@bokeh/dev comments? Is this the right breakdown of work?



Discussion:
My comments from the mailing list:

In order to add LaTeX support (via KaTex) I have to vendor in some font files. While doing this I ran into two issues:

First:

My plan is to add the katex library as a dependency in packages.json, so that'll be pulled in by npm and isn't a problem. I also need to add some style resources (a couple of less files and font files), that get bundled into the bokeh css resources. My initial plan was to add a bokehjs/src/vendor/katex-0.5.1 directory containing the less and font files, then add a src/less/katex.less file that imports the styles and base64 encodes the fonts (similar to how we handle the font-awesome resources).

The problem is that a couple of the necessary font files are over the IE8-safe limit (32kb) when encoded, so gulp-less won't encode them without adding an { ieCompat: false } flag.

I don't have enough domain expertise to know if/how bad this is. Potential solutions are:

Add the { ieCompat: false } flag
Manually base64 encode the fonts and add them to the src/less/katex.less file
Other
Second:

Font files are huge and KaTeX includes a ton of fonts! (https://github.com/Khan/KaTeX/blob/master/static/fonts.less) Assuming we don't want to add 500kb of fonts to our css resources, I commented out all but the four that seem most important and use a single format (assuming that that woff has the same browser support as bokeh per https://github.com/Khan/KaTeX/wiki/Font-Options), but bokeh.css and bokeh.min.css still increase by 180kb (roughly doubling in size). Again, I'm unsure how bad this is. Potential solutions are:

Just go with it, as-is
use another latex implementation (on quick glance, using MathJax would have the same font problems)
don't support latex
load fonts from CDN and don't bundle them?
create another css resource: fonts.css
don't include fonts at all (the latex layout still works, but the font styling doesn't happen)
Other

With my LaTex experience described above, I have a couple of additional considering regarding handling external resources.
- LaTeX supports a ton of fonts (and fonts are relatively big files, ~ 20-40kb). Depending on what LaTeX expression a user hopes to create, different fonts will need to be loaded (bold/italic/script/math characters/etc.). The proposed **resources** `Model` property would have to be configurable in order to be able to download only the desired fonts.
- Another reason for configurable **resources** would be to support loading fonts from a local `statics` directory on a deployed Bokeh plot. 
- Appropriately warning or trying to handle situations where resources are set to "inline" but the document uses external resources (presumably for off-line mode use).


Resolving patch:
diff --git a/bokeh/resources.py b/bokeh/resources.py
--- a/bokeh/resources.py
+++ b/bokeh/resources.py
@@ -19,6 +19,8 @@
 from os.path import basename, join, relpath
 import re
 
+from six import string_types
+
 from . import __version__
 from .core.templates import JS_RESOURCES, CSS_RESOURCES
 from .settings import settings
@@ -274,6 +276,23 @@ def _file_paths(self, kind):
         paths = [ join(bokehjs_dir, kind, file) for file in files ]
         return paths
 
+    def _collect_external_resources(self, resource_attr):
+        """ Collect external resources set on resource_attr attribute of all models."""
+
+        external_resources = []
+
+        for cls in Model.model_class_reverse_map.values():
+            external = getattr(cls, resource_attr, None)
+
+            if isinstance(external, string_types):
+                external_resources.append(external)
+            elif isinstance(external, list):
+                external_resources.extend(external)
+
+        # Return only unique external resources
+        return list(set(external_resources))
+
+
     def _cdn_urls(self):
         return _get_cdn_urls(self.components, self.version, self.minified)
 
@@ -361,6 +380,10 @@ def _autoload_path(self, elementid):
     @property
     def js_files(self):
         files, _ = self._resolve('js')
+
+        external_resources = self._collect_external_resources('__javascript__')
+        files.extend(external_resources)
+
         return files
 
     @property
@@ -513,6 +536,10 @@ class CSSResources(BaseResources):
     @property
     def css_files(self):
         files, _ = self._resolve('css')
+
+        external_resources = self._collect_external_resources("__css__")
+        files.extend(external_resources)
+
         return files
 
     @property
diff --git a/examples/models/external_resources.py b/examples/models/external_resources.py
new file mode 100644
--- /dev/null
+++ b/examples/models/external_resources.py
@@ -0,0 +1,77 @@
+""" The LaTex example was derived from: http://matplotlib.org/users/usetex.html
+"""
+
+from bokeh.core.properties import Override
+from bokeh.models import Label
+from bokeh.plotting import output_file, figure, show
+
+import numpy as np
+
+output_file('external_resources.html')
+
+class LatexLabel(Label):
+    """A subclass of `Label` with all of the same class attributes except
+    canvas mode isn't supported and DOM manipulation happens in the coffeescript
+    superclass implementation that requires setting `render_mode='css'`).
+
+    Only the render method of LabelView is overwritten to perform the
+    text -> latex (via katex) conversion
+    """
+    __javascript__ = ["https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.6.0/katex.min.js"]
+    __css__ = ["https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.6.0/katex.min.css"]
+    __implementation__ = """
+Label = require "models/annotations/label"
+
+class LatexLabelView extends Label.View
+  render: () ->
+    ctx = @plot_view.canvas_view.ctx
+
+    # Here because AngleSpec does units tranform and label doesn't support specs
+    switch @mget('angle_units')
+      when "rad" then angle = -1 * @mget('angle')
+      when "deg" then angle = -1 * @mget('angle') * Math.PI/180.0
+
+    if @mget('x_units') == "data"
+      vx = @xmapper.map_to_target(@mget('x'))
+    else
+      vx = @mget('x')
+    sx = @canvas.vx_to_sx(vx)
+
+    if @mget('y_units') == "data"
+      vy = @ymapper.map_to_target(@mget('y'))
+    else
+      vy = @mget('y')
+    sy = @canvas.vy_to_sy(vy)
+
+    if @model.panel?
+      panel_offset = @_get_panel_offset()
+      sx += panel_offset.x
+      sy += panel_offset.y
+
+    latex = katex.renderToString(@mget('text'), {displayMode: true})
+
+    @_css_text(ctx, latex, sx + @mget('x_offset'), sy - @mget('y_offset'), angle)
+
+class LatexLabel extends Label.Model
+  type: 'LatexLabel'
+  default_view: LatexLabelView
+
+module.exports =
+  Model: LatexLabel
+  View: LatexLabelView
+"""
+
+x = np.arange(0.0, 1.0 + 0.01, 0.01)
+y = np.cos(2*2*np.pi*x) + 2
+
+p = figure(title="LaTex Demonstration", width=500, height=500)
+p.line(x, y)
+
+latex = LatexLabel(text="f = \sum_{n=1}^\infty\\frac{-e^{i\pi}}{2^n}!",
+                   x=35, y=445, x_units='screen', y_units='screen',
+                   render_mode='css', text_font_size='16pt',
+                   background_fill_color='#ffffff')
+
+p.add_layout(latex)
+
+show(p)
diff --git a/sphinx/source/docs/user_guide/source_examples/extensions_example_latex.py b/sphinx/source/docs/user_guide/source_examples/extensions_example_latex.py
new file mode 100644
--- /dev/null
+++ b/sphinx/source/docs/user_guide/source_examples/extensions_example_latex.py
@@ -0,0 +1,89 @@
+""" The LaTex example was derived from: http://matplotlib.org/users/usetex.html
+"""
+
+import numpy as np
+
+from bokeh.core.properties import Override
+from bokeh.models import Label
+from bokeh.plotting import figure, show
+
+JS_CODE = """
+Label = require "models/annotations/label"
+
+class LatexLabelView extends Label.View
+  render: () ->
+
+    #--- Start of copied section from ``Label.render`` implementation
+
+    ctx = @plot_view.canvas_view.ctx
+
+    # Here because AngleSpec does units tranform and label doesn't support specs
+    switch @mget('angle_units')
+      when "rad" then angle = -1 * @mget('angle')
+      when "deg" then angle = -1 * @mget('angle') * Math.PI/180.0
+
+    if @mget('x_units') == "data"
+      vx = @xmapper.map_to_target(@mget('x'))
+    else
+      vx = @mget('x')
+    sx = @canvas.vx_to_sx(vx)
+
+    if @mget('y_units') == "data"
+      vy = @ymapper.map_to_target(@mget('y'))
+    else
+      vy = @mget('y')
+    sy = @canvas.vy_to_sy(vy)
+
+    if @model.panel?
+      panel_offset = @_get_panel_offset()
+      sx += panel_offset.x
+      sy += panel_offset.y
+
+    #--- End of copied section from ``Label.render`` implementation
+
+    # ``katex`` is loaded into the global window at runtime
+    # katex.renderToString returns a html ``span`` element
+    latex = katex.renderToString(@mget('text'), {displayMode: true})
+
+    # Must render as superpositioned div (not on canvas) so that KaTex
+    # css can properly style the text
+    @_css_text(ctx, latex, sx + @mget('x_offset'), sy - @mget('y_offset'), angle)
+
+class LatexLabel extends Label.Model
+  type: 'LatexLabel'
+  default_view: LatexLabelView
+
+module.exports =
+  Model: LatexLabel
+  View: LatexLabelView
+"""
+
+class LatexLabel(Label):
+    """A subclass of the Bokeh built-in `Label` that supports rendering
+    LaTex using the KaTex typesetting library.
+
+    Only the render method of LabelView is overloaded to perform the
+    text -> latex (via katex) conversion. Note: ``render_mode="canvas``
+    isn't supported and certain DOM manipulation happens in the Label
+    superclass implementation that requires explicitly setting
+    `render_mode='css'`).
+    """
+    __javascript__ = ["https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.6.0/katex.min.js"]
+    __css__ = ["https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.6.0/katex.min.css"]
+    __implementation__ = JS_CODE
+
+x = np.arange(0.0, 1.0 + 0.01, 0.01)
+y = np.cos(2*2*np.pi*x) + 2
+
+p = figure(title="LaTex Demonstration", width=500, height=500)
+p.line(x, y)
+
+# Note: must set ``render_mode="css"``
+latex = LatexLabel(text="f = \sum_{n=1}^\infty\\frac{-e^{i\pi}}{2^n}!",
+                   x=35, y=445, x_units='screen', y_units='screen',
+                   render_mode='css', text_font_size='16pt',
+                   background_fill_color='#ffffff')
+
+p.add_layout(latex)
+
+show(p)


Test patch:
diff --git a/bokeh/tests/test_resources.py b/bokeh/tests/test_resources.py
--- a/bokeh/tests/test_resources.py
+++ b/bokeh/tests/test_resources.py
@@ -5,6 +5,7 @@
 import os
 
 import bokeh.resources as resources
+from bokeh.models import Model
 from bokeh.resources import _get_cdn_urls, websocket_url_for_server_url
 
 
@@ -217,3 +218,31 @@ def test_argument_checks(self):
 
         for mode in ("inline", "cdn", "relative", "relative-dev", "absolute", "absolute-dev"):
             self.assertRaises(ValueError, resources.Resources, mode, root_url="foo")
+
+
+## Test external resources
+
+def test_external_js_and_css_resource_embedding():
+    """ This test method has to be at the end of the test modules because
+    subclassing a Model causes the CustomModel to be added as a Viewable and
+    messes up the Resources state for the other tests.
+    """
+
+    # External resources can be defined as a string or list of strings
+    class CustomModel1(Model):
+        __javascript__ = "external_js_1"
+        __css__ = "external_css_1"
+
+    class CustomModel2(Model):
+        __javascript__ =["external_js_2", "external_js_3"]
+        __css__ = ["external_css_2", "external_css_3"]
+
+    r = resources.Resources()
+
+    assert "external_js_1" in r.js_files
+    assert "external_css_1" in r.css_files
+
+    assert "external_js_2" in r.js_files
+    assert "external_js_3" in r.js_files
+    assert "external_css_2" in r.css_files
+    assert "external_css_3" in r.css_files