From 872c83c81e4ce477b5cbc90eb587919ccd000a44 Mon Sep 17 00:00:00 2001 From: Stefan Binder Date: Mon, 9 Sep 2024 17:11:57 -0400 Subject: [PATCH] feat: Add 'olli' renderer to generate accessible text structures for screen reader users (#3580) --- altair/utils/html.py | 45 ++++++++++++++++++++++++++-- altair/vegalite/v5/display.py | 10 +++++++ doc/user_guide/display_frontends.rst | 2 ++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/altair/utils/html.py b/altair/utils/html.py index 7b5f4dcf5..ea5269034 100644 --- a/altair/utils/html.py +++ b/altair/utils/html.py @@ -7,7 +7,7 @@ from altair.utils._importers import import_vl_convert, vl_version_for_vl_convert -TemplateName = Literal["standard", "universal", "inline"] +TemplateName = Literal["standard", "universal", "inline", "olli"] RenderMode = Literal["vega", "vega-lite"] HTML_TEMPLATE = jinja2.Template( @@ -116,11 +116,22 @@ if (outputDiv.id !== "{{ output_div }}") { outputDiv = document.getElementById("{{ output_div }}"); } + {%- if use_olli %} + const olliDiv = document.createElement("div"); + const vegaDiv = document.createElement("div"); + outputDiv.appendChild(vegaDiv); + outputDiv.appendChild(olliDiv); + outputDiv = vegaDiv; + {%- endif %} const paths = { "vega": "{{ base_url }}/vega@{{ vega_version }}?noext", "vega-lib": "{{ base_url }}/vega-lib?noext", "vega-lite": "{{ base_url }}/vega-lite@{{ vegalite_version }}?noext", "vega-embed": "{{ base_url }}/vega-embed@{{ vegaembed_version }}?noext", + {%- if use_olli %} + "olli": "{{ base_url }}/olli@{{ olli_version }}?noext", + "olli-adapters": "{{ base_url }}/olli-adapters@{{ olli_adapters_version }}?noext", + {%- endif %} }; function maybeLoadScript(lib, version) { @@ -145,20 +156,41 @@ throw err; } - function displayChart(vegaEmbed) { + function displayChart(vegaEmbed, olli, olliAdapters) { vegaEmbed(outputDiv, spec, embedOpt) .catch(err => showError(`Javascript Error: ${err.message}
This usually means there's a typo in your chart specification. See the javascript console for the full traceback.`)); + {%- if use_olli %} + olliAdapters.VegaLiteAdapter(spec).then(olliVisSpec => { + // It's a function if it was loaded via maybeLoadScript below. + // If it comes from require, it's a module and we access olli.olli + const olliFunc = typeof olli === 'function' ? olli : olli.olli; + const olliRender = olliFunc(olliVisSpec); + olliDiv.append(olliRender); + }); + {%- endif %} } if(typeof define === "function" && define.amd) { requirejs.config({paths}); - require(["vega-embed"], displayChart, err => showError(`Error loading script: ${err.message}`)); + let deps = ["vega-embed"]; + {%- if use_olli %} + deps.push("olli", "olli-adapters"); + {%- endif %} + require(deps, displayChart, err => showError(`Error loading script: ${err.message}`)); } else { maybeLoadScript("vega", "{{vega_version}}") .then(() => maybeLoadScript("vega-lite", "{{vegalite_version}}")) .then(() => maybeLoadScript("vega-embed", "{{vegaembed_version}}")) + {%- if use_olli %} + .then(() => maybeLoadScript("olli", "{{olli_version}}")) + .then(() => maybeLoadScript("olli-adapters", "{{olli_adapters_version}}")) + {%- endif %} .catch(showError) + {%- if use_olli %} + .then(() => displayChart(vegaEmbed, olli, OlliAdapters)); + {%- else %} .then(() => displayChart(vegaEmbed)); + {%- endif %} } })({{ spec }}, {{ embed_options }}); @@ -209,6 +241,7 @@ "standard": HTML_TEMPLATE, "universal": HTML_TEMPLATE_UNIVERSAL, "inline": INLINE_HTML_TEMPLATE, + "olli": HTML_TEMPLATE_UNIVERSAL, } @@ -293,6 +326,12 @@ def spec_to_html( vlc = import_vl_convert() vl_version = vl_version_for_vl_convert() render_kwargs["vegaembed_script"] = vlc.javascript_bundle(vl_version=vl_version) + elif template == "olli": + OLLI_VERSION = "2" + OLLI_ADAPTERS_VERSION = "2" + render_kwargs["olli_version"] = OLLI_VERSION + render_kwargs["olli_adapters_version"] = OLLI_ADAPTERS_VERSION + render_kwargs["use_olli"] = True jinja_template = TEMPLATES.get(template, template) # type: ignore[arg-type] if not hasattr(jinja_template, "render"): diff --git a/altair/vegalite/v5/display.py b/altair/vegalite/v5/display.py index aead52a4d..8e6cb39bf 100644 --- a/altair/vegalite/v5/display.py +++ b/altair/vegalite/v5/display.py @@ -142,6 +142,15 @@ def browser_renderer( vegalite_version=VEGALITE_VERSION, ) + +olli_renderer = HTMLRenderer( + mode="vega-lite", + template="olli", + vega_version=VEGA_VERSION, + vegaembed_version=VEGAEMBED_VERSION, + vegalite_version=VEGALITE_VERSION, +) + renderers.register("default", html_renderer) renderers.register("html", html_renderer) renderers.register("colab", html_renderer) @@ -155,6 +164,7 @@ def browser_renderer( renderers.register("svg", svg_renderer) renderers.register("jupyter", jupyter_renderer) renderers.register("browser", browser_renderer) +renderers.register("olli", olli_renderer) renderers.enable("default") diff --git a/doc/user_guide/display_frontends.rst b/doc/user_guide/display_frontends.rst index b8ecbe67e..12e224f04 100644 --- a/doc/user_guide/display_frontends.rst +++ b/doc/user_guide/display_frontends.rst @@ -68,6 +68,7 @@ In addition, Altair includes the following renderers: using the ``"image/png"`` MIME type. - ``"svg"``: renderer that renders and converts the chart to an SVG image, outputting it using the ``"image/svg+xml"`` MIME type. +- ``"olli"``: renderer that uses `Olli`_ to generate accessible text structures for screen reader users. - ``"json"``: renderer that outputs the raw JSON chart specification, using the ``"application/json"`` MIME type. @@ -713,3 +714,4 @@ see :ref:`display-general`. .. _Spyder: https://www.spyder-ide.org/ .. _IPython QtConsole: https://qtconsole.readthedocs.io/en/stable/ .. _webbrowser module: https://docs.python.org/3/library/webbrowser.html#webbrowser.register +.. _Olli: https://mitvis.github.io/olli/