diff --git a/nbconvert_a11y/exporter.py b/nbconvert_a11y/exporter.py index 9f5da633..69144668 100644 --- a/nbconvert_a11y/exporter.py +++ b/nbconvert_a11y/exporter.py @@ -4,6 +4,7 @@ """ import builtins +from copy import copy, deepcopy import json from contextlib import suppress from datetime import datetime @@ -73,7 +74,7 @@ class Table: } -class PostProcess(Exporter): +class PostProcess(HTMLExporter): """an exporter that allows post processing after the templating step this class introduces the `post_process_html` protocol that can be used to modify @@ -88,7 +89,7 @@ def from_notebook_node(self, nb, resources=None, **kw): def post_process_html(self, body): ... -class A11yExporter(PostProcess, HTMLExporter): +class A11yExporter(PostProcess): """an accessible reference implementation for computational notebooks implemented for ipynb files. this template provides a flexible screen reader experience with settings to control and customize the reading experience. @@ -99,7 +100,7 @@ class A11yExporter(PostProcess, HTMLExporter): config=True ) axe_url = CUnicode(AXE, help="the remote source for the axe resources.").tag(config=True) - include_sa11y = Bool(False, help="include sa11y accessibility authoring tool").tag(config=True) + include_sa11y = Bool(True, help="include sa11y accessibility authoring tool").tag(config=True) include_settings = Bool(False, help="include configurable accessibility settings dialog.").tag( config=True ) @@ -126,7 +127,7 @@ class A11yExporter(PostProcess, HTMLExporter): include_upload = Bool(False, help="include template for uploading new content").tag(config=True) allow_run_mode = Bool(False, help="enable buttons for a run mode").tag(config=True) hide_anchor_links = Bool(False).tag(config=True) - exclude_anchor_links = Bool(False).tag(config=True) + hidden_anchor_links = Bool(False).tag(config=True) code_theme = Enum(list(THEMES), "gh-high", help="an accessible pygments dark/light theme").tag( config=True ) @@ -140,6 +141,7 @@ class A11yExporter(PostProcess, HTMLExporter): prompt_out = CUnicode("Out").tag(config=True) prompt_left = CUnicode("[").tag(config=True) prompt_right = CUnicode("]").tag(config=True) + validate_nb = Bool(False).tag(config=True) def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -177,9 +179,7 @@ def default_config(self): return c def init_resources(self, resources=None): - if resources is None: - resources = {} - resources = resources or {} + resources = self._init_resources(resources) resources["include_axe"] = self.include_axe resources["include_settings"] = self.include_settings resources["include_help"] = self.include_help @@ -196,7 +196,7 @@ def init_resources(self, resources=None): resources["prompt_out"] = self.prompt_out resources["prompt_left"] = self.prompt_left resources["prompt_right"] = self.prompt_right - resources["exclude_anchor_links"] = self.exclude_anchor_links + resources["hidden_anchor_links"] = self.hidden_anchor_links resources["hide_anchor_links"] = self.hide_anchor_links resources["table_pattern"] = getattr(Roles, self.table_pattern) resources["allow_run_mode"] = self.allow_run_mode @@ -218,6 +218,18 @@ def post_process_html(self, body): details.append(toc(soup)) return soup.prettify(formatter="html5") + def _preprocess(self, nb, resources): + nbc = deepcopy(nb) + resc = deepcopy(resources) + + for preprocessor in self._preprocessors: + nbc, resc = preprocessor(nbc, resc) + + if self.validate_nb: + self._validate_preprocessor(nbc, preprocessor) + + return nbc, resc + class SectionExporter(A11yExporter): template_file = Unicode("a11y/section.html.j2").tag(config=True) @@ -303,7 +315,7 @@ def toc(html): level = int(header.name[-1]) if last_level > level: for l in range(level, last_level): - last_level -= 1 + last_level -= 1 ol = ol.parent.parent elif last_level < level: for l in range(last_level, level): diff --git a/nbconvert_a11y/outputs.css b/nbconvert_a11y/outputs.css new file mode 100644 index 00000000..75eac8d1 --- /dev/null +++ b/nbconvert_a11y/outputs.css @@ -0,0 +1,164 @@ +/* css to accompany the semantic outputs.py + +these styles make the semantic html appear like python styled reprs. +prettifying python combines content and style, but with html +we can seperate these concerns. */ + +:root { + --quote: '"'; +} + +data[value], +[itemscope]:not([itemtype$=DataFrame]), +[itemscope]:not([itemtype$=Series]) { + font-family: monospace; +} + +.jp-RenderedHTMLCommon kbd { + font-size: unset; +} + +samp[itemscope]::before, +samp[itemscope]::after { + content: var(--quote); +} + +.jp-RenderedHTMLCommon [itemscope] { + color: var(--jp-mirror-editor-number-color); +} + +.jp-RenderedHTMLCommon data[value] { + color: var(--jp-mirror-editor-keyword-color); + font-weight: bold; +} + +.jp-RenderedHTMLCommon samp[itemscope] { + color: var(--jp-mirror-editor-string-color); +} + +ol[itemscope], +ul[itemscope] { + li { + display: inline; + + &::after { + content: ", "; + } + + &:first-child::before { + content: '['; + color: var(--jp-mirror-editor-bracket-color); + } + + &:last-child::after { + content: ']'; + color: var(--jp-mirror-editor-bracket-color); + } + } +} + +ol[itemtype$=tuple] li { + &:first-child::before { + content: '('; + } + + &:last-child::after { + content: ')'; + } +} + +ul[itemtype$=set] li { + &:first-child::before { + content: '{'; + } + + &:last-child::after { + content: '}'; + } +} + +.jp-OutputArea-output table caption, +table caption { + + dl, + dd, + dt { + padding-left: .5em; + padding-right: .5em; + } + +} + + +.jp-OutputArea-output dd, +.jp-OutputArea-output dt { + float: unset; + display: unset; +} + +dl[itemscope] dd, +dl[itemscope] dt { + display: inline; +} + +dl[itemtype] { + + dt:first-child::before { + content: "{"; + color: var(--jp-mirror-editor-bracket-color); + } + + dd:last-child::after { + content: "}"; + color: var(--jp-mirror-editor-bracket-color); + + } + + dt::after { + content: ": "; + } + + dd::after { + content: ", "; + } +} + +.visually-hidden:not(:active):not(:focus-within), +.non-visual, +.nv { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +table[itemtype$=ndarray] { + text-align: right; + + td:first-child::before { + content: "["; + float: left; + } + + td::after { + content: ","; + } + + td:last-child::after { + content: "]"; + } +} + +:not(.jp-RenderedMarkdown).jp-RenderedHTMLCommon td, +:not(.jp-RenderedMarkdown).jp-RenderedHTMLCommon th, +:not(.jp-RenderedMarkdown).jp-RenderedHTMLCommon tr { + vertical-align: unset; + text-align: unset; +} + +.jp-RenderedHTMLCommon tr { + padding: unset; +} \ No newline at end of file diff --git a/nbconvert_a11y/outputs.py b/nbconvert_a11y/outputs.py new file mode 100644 index 00000000..56fa828a --- /dev/null +++ b/nbconvert_a11y/outputs.py @@ -0,0 +1,193 @@ +"""semantic html representations for common python values. +requires complementary css to be included.""" + +from gc import get_referrers +from pathlib import Path +import bs4 +from types import FunctionType, ModuleType +import functools +import bs4 +import json +from IPython import get_ipython +from numpy import float_, int_ + +HERE = Path(__file__).parent +CSS = HERE / "outputs.css" +BeautifulSoup = functools.partial(bs4.BeautifulSoup, features="lxml") + + +def new(tag, *children, soup=BeautifulSoup(), **attrs): + """create a new beautiful soup tag""" + element = soup.new_tag(tag, attrs=attrs) + element.extend(children) + return element + + +def _recursion(value): + """handling of recursive values""" + return new("a", repr_default(value), href=f"#{id(value)}") + + +def load_ipython_extension(shell): + from IPython.display import display + + # shell.display_formatter.formatters["text/html"].for_type(bs4.Tag, bs4.Tag.prettify) + # shell.display_formatter.formatters["text/html"].for_type( + # bs4.BeautifulSoup, bs4.BeautifulSoup.prettify + # ) + repr_semantic_update(shell) + display({"text/html": f""}, raw=True) + + +def _prettify(func): + def wrapped(*args, **kwargs): + return str(func(*args, **kwargs)) + + return wrapped + + +def repr_semantic_update(shell=None): + # this dispatches on the type of the value, including value types. + # the mimebundle and ipython display are hit first then the display formatters. + # which these formatters installed we'll never hit the text/plain formatter + # and every display will be included in the output. + + (shell or get_ipython()).display_formatter.formatters["text/html"].type_printers.update( + {k: _prettify(v) for k, v in repr_semantic.registry.items() if k is not object} + ) + + +def get_type(value): + "construct a URI or URN for the type" + return get_id(type(value)) + + +def get_id(value): + "construct a URI or URN for the value" + if isinstance(value, ModuleType): + return value.__name__ + elif isinstance(value, (FunctionType, type)): + return f"{value.__module__}:{value.__qualname__}" + return str(id(value)) + + +def get_from_garbage(object): + get_referrers(object) + + +def repr_html(value, caption=None): + html = repr_semantic(value) + if caption is not None: + figure = new("figure", html) + figure.append(new("figcaption", caption)) + return figure + return html + + +def get_markdown(value): + import mistune + + return mistune.markdown(value) + + +def repr_default(value, **attrs): + return new( + "samp", + new("kbd", object.__repr__(value), itemscope=None, itemtype=get_type(value), **attrs), + ) + + +@functools.singledispatch +def repr_semantic(value, level=0, maxlevels=6, context=None): + """default representation""" + from IPython.display import display + + if f := getattr(value, "_repr_markdown_", None): + return BeautifulSoup(f(value)) + if f := getattr(value, "_repr_html_", None): + return BeautifulSoup(f(value)) + return repr_default(value) + + +@repr_semantic.register(str) +def repr_string(value, level=0, maxlevels=6, context=None): + pre, sep, rest = value.partition(":") + if rest.startswith(("//",)): + if pre in {"file", "http", "https"}: + return new("a", value, href=value) + elif pre in {"data"}: + "handle data uri" + return new( + "samp", value, itemscope=None, itemtype=get_type(value), **{"class": "s2"} + ) # s1 is single quote + + +@repr_semantic.register(bool) +@repr_semantic.register(type(None)) +def repr_const(value, level=0, maxlevels=6, context=None): + return new("data", str(value), value=json.dumps(value), **{"class": "kc"}) + + +@repr_semantic.register(int) +@repr_semantic.register(int_) +@repr_semantic.register(float_) +@repr_semantic.register(float) +def repr_number(value, level=0, maxlevels=6, context=None): + return new( + "data", + str(value), + itemscope=None, + itemtype=get_type(value), + style=f"--val: {value};", + **{"class": "m" + "if"[isinstance(value, float)]}, + ) + + +@repr_semantic.register(bytes) +def repr_bytes(value, level=0, maxlevels=6, context=None): + return new("samp", object.__repr__(value)[2:-1], itemscope=None, itemtype=get_type(value)) + + +@repr_semantic.register(list) +@repr_semantic.register(tuple) +def repr_list(value, level=0, maxlevels=6, context=None, tag="ol"): + context = set() if context is None else context + if maxlevels and level >= maxlevels: + return new("span", "collapsed") + ID = id(value) + if ID in context: + return _recursion(value) + list = new(tag, id=ID, itemscope=None, itemtype=get_type(value)) + context.add(ID) + level and list.attrs.update(role="presentation") + level += 1 + for item in value: + list.append(new("li", repr_semantic(item, level, maxlevels, context))) + return list + + +@repr_semantic.register(set) +def repr_tuple(value, level=0, maxlevels=6, context=None): + return repr_list(value, level, maxlevels, context, tag="ul") + + +@repr_semantic.register(dict) +def repr_dict(value, level=0, maxlevels=6, context=None): + context = set() if context is None else context + if maxlevels and level >= maxlevels: + return new("span", "collapsed") + ID = id(value) + if ID in context: + return _recursion(value) + context.add(ID) + dl = new("dl", itemscope=None, itemtype=get_type(value), id=get_id(value)) + level and dl.attrs.update(role="presentation") + level += 1 + for k, v in value.items(): + dl.append(new("dt", repr_semantic(k, level, maxlevels))) + if id(v) in context: + dl.append(new("dd", _recursion(v))) + else: + dl.append(new("dd", repr_semantic(v))) + context.remove(ID) + return dl diff --git a/nbconvert_a11y/tables.py b/nbconvert_a11y/tables.py new file mode 100644 index 00000000..589c5b4e --- /dev/null +++ b/nbconvert_a11y/tables.py @@ -0,0 +1,263 @@ +from numpy import ndarray +import pandas, bs4, functools +from .outputs import get_type, repr_html, repr_semantic, repr_semantic_update + + +def load_ipython_extension(shell): + repr_semantic_update() + + +def get_caption(df, ROW_INDEX=True, COL_INDEX=True): + dl = new("dl", role="presentation") + dl.append(new("dt", "rows")), dl.append(new("dd", str(len(df)))) + dl.append(new("dt", "columns")), dl.append(new("dd", str(len(df.columns)))) + dl.append(new("dt", "indexes")), dl.append(new("dd", indexes := new("dl", role="presentation"))) + indexes.append(new("dt", "rows")), indexes.append(new("dd", str(df.index.nlevels * ROW_INDEX))) + indexes.append(new("dt", "columns")), indexes.append( + new("dd", str(df.columns.nlevels * COL_INDEX)) + ) + return dl + + +def row_major_at_rows(df, COL_INDEX=True): + return df.columns.nlevels * COL_INDEX + len(df) + + +def row_major_at_cols(df, ROW_INDEX=True): + return df.index.nlevels * ROW_INDEX + int(any(df.columns.names)) + len(df.columns) + + +@repr_semantic.register(pandas.DataFrame) +@repr_semantic.register(ndarray) +def get_table(df, caption=None, ARIA=True, ROW_INDEX=True, COL_INDEX=True, SEMANTIC=True): + soup = bs4.BeautifulSoup(features="lxml") + WIDE = (df.shape[1] + 1) > pandas.options.display.max_columns + LONG = (df.shape[0] + 1) > pandas.options.display.max_rows + soup.append( + table := new( + "table", + itemscope=None, + itemtype=get_type(df), + ) + ) + if isinstance(df, ndarray): + ROW_INDEX = COL_INDEX = False + df = pandas.DataFrame(df) + + table.attrs.update( + colcount=row_major_at_cols(df, ROW_INDEX) if ARIA or WIDE else None, + rowcount=row_major_at_rows(df, COL_INDEX) if ARIA or LONG else None, + ) + col_ranges, row_ranges = get_ranges(df, WIDE, LONG) + table.append(cap := new("caption", caption)) + if caption is None: + cap.attrs["class"] = "nv" + cap.append(get_caption(df, ROW_INDEX, COL_INDEX)) + if COL_INDEX: + get_thead(df, table, col_ranges, WIDE, ARIA, LONG, ROW_INDEX) + get_tbody(df, table, col_ranges, row_ranges, WIDE, ARIA, LONG, ROW_INDEX, COL_INDEX, SEMANTIC) + return soup + + +def get_thead(df, table, col_ranges, WIDE=False, ARIA=False, LONG=False, ROW_INDEX=True): + ROWS, COLS = any(df.index.names), any(df.columns.names) + col_center = col_ranges[1].start - col_ranges[0].stop + for col_level, col_name in enumerate(df.columns.names): + table.append(tr := trow(rowindex=col_level + 1 if ARIA or LONG else None)) + if ROW_INDEX: + if not col_level: + # on the first pass write column or index names + if ROWS or not COLS: + for row_level, row_name in enumerate(df.index.names): + tr.append( + th := theading( + str( + row_name + or ("index" if df.index.nlevels == 1 else f"index {row_level}") + ), + scope="col", + rowspan=df.columns.nlevels if df.columns.nlevels > 1 else None, + colindex=row_level + 1 if ARIA else None, + ) + ) + if COLS: + tr.append( + theading( + str(col_name or ("column" if len(df.index) == 1 else f"index {col_level}")), + scope="row", + colindex=df.index.nlevels + 1 if ARIA else None, + ) + ) + + for col_part, col_range in enumerate(col_ranges): + if col_part and col_range: + tr.append( + theading( + HIDDEN, + colindex=( + col_index + 2 + df.index.nlevels + bool(LONG and WIDE) if ARIA else None + ), + **{"aria-colspan": col_center}, + ) + ) + for col_index in col_range: + col_value = df.columns.get_level_values(col_level)[col_index] + tr.append( + theading( + str(col_value), + scope="col", + colindex=( + df.index.nlevels + int(ROWS and COLS) + col_index + 1 + if ARIA or WIDE and col_part + else None + ), + ) + ) + + +def get_tbody( + df, + table, + col_ranges, + row_ranges, + WIDE=False, + ARIA=False, + LONG=False, + ROW_INDEX=True, + COL_INDEX=True, + SEMANTIC=False, +): + ROWS, COLS = any(df.index.names), any(df.columns.names) + row_center = row_ranges[1].start - row_ranges[0].stop + col_center = col_ranges[1].start - col_ranges[0].stop + for row_part, row_range in enumerate(row_ranges): + if row_part and row_range: + # handle hidden data in between dataframe regions + table.append( + tr := trow( + rowindex=row_index + 2 + df.columns.nlevels * COL_INDEX, + **{"aria-rowspan": row_center}, + ) + ) + if ROW_INDEX: + # shw the row headers + for row_level in range(df.index.nlevels): + tr.append(theading(HIDDEN, colindex=row_level + 1)) + if ROWS and COLS: + tr.append(tdata(EMPTY, colindex=row_level + 2)) + else: + row_level = 0 + + for col_part, col_range in enumerate(col_ranges): + # write the column values + if col_part and col_range: + # write the hidden columns + tr.append( + tdata( + HIDDEN, + colindex=col_index + + 2 + + df.index.nlevels * ROW_INDEX + + int(ROWS and COLS), + **{"aria-rowspan": row_center, "aria-colspan": col_center}, + ), + ) + for col_index in col_range: + # write the column values + tr.append( + tdata( + HIDDEN, + colindex=col_index + + 1 + + df.index.nlevels * ROW_INDEX + + int(ROWS and COLS), + ) + ) + for row_index in row_range: + table.append(tr := trow(rowindex=row_index + 1 + df.columns.nlevels * COL_INDEX)) + if ROW_INDEX: + for row_level in range(df.index.nlevels): + tr.append( + theading( + str(df.index.get_level_values(row_level)[row_index]), + colindex=row_level + 1 if ARIA else None, + scope="row", + ) + ) + if ROWS and COLS: + tr.append(tdata(EMPTY, colindex=row_level + 2)) + for col_part, col_range in enumerate(col_ranges): + if col_part and col_range: + tr.append( + tdata( + HIDDEN, + colindex=col_index + + 2 + + df.index.nlevels * ROW_INDEX + + int(ROWS and COLS), + **{"aria-colspan": col_center}, + ) + ) + for col_index in col_range: + tr.append( + tdata( + (SEMANTIC and repr_html or str)(df.iloc[row_index, col_index]), + colindex=col_index + + 1 + + df.index.nlevels * ROW_INDEX + + int(ROWS and COLS), + ) + ) + + +def get_frame_bounds(df, WIDE=False, LONG=False): + a, b, c, d = len(df.columns), len(df.columns), len(df), len(df) + if WIDE: + a = pandas.options.display.max_columns // 2 + b -= a + if LONG: + c = pandas.options.display.max_rows // 2 + d -= c + return a, b, c, d + + +def get_ranges(df, WIDE=False, LONG=False): + a, b, c, d = get_frame_bounds(df, WIDE=WIDE, LONG=LONG) + return (range(a), range(b, df.shape[1])), (range(c), range(d, df.shape[0])) + + +def new( + tag, + string=None, + rowindex=None, + colindex=None, + rowcount=None, + colcount=None, + rowspan=None, + colspan=None, + scope=None, + *, + soup=bs4.BeautifulSoup(features="lxml"), + **attrs, +): + """create a new beautiful soup with table and aria properties""" + data = locals() + attrs.update( + { + f"aria-{k}": data.get(k) + for k in ["rowindex", "colindex", "rowcount", "colcount"] + if data.get(k) + } + ) + attrs.update({k: data.get(k) for k in ["rowspan", "colspan", "scope"] if data.get(k)}) + tag = soup.new_tag(tag, attrs=attrs) + if string: + tag.append(string) + return tag + + +trow = functools.partial(new, "tr") +theading = functools.partial(new, "th") +tdata = functools.partial(new, "td") + +HIDDEN, EMPTY = "hidden", "empty" diff --git a/nbconvert_a11y/templates/a11y/base.html.j2 b/nbconvert_a11y/templates/a11y/base.html.j2 index c7ac7280..e072938a 100644 --- a/nbconvert_a11y/templates/a11y/base.html.j2 +++ b/nbconvert_a11y/templates/a11y/base.html.j2 @@ -37,7 +37,7 @@ the notebook experiennce from browse to edit/focus mode. {% block body_header %} {%- set title = nb.metadata.get("title") -%} {%- set description = nb.metadata.get("description") -%} -{% set describedby = "nb-ct-total nb-cells-label nb-ct-code nb-state nb-ct-loc nb-loc-label +{% set describedby = "nb-ct-total nb-cells-label nb-ct-code nb-lang nb-state nb-ct-loc nb-loc-label nb-ct-outputs nb-outputs-label"%} diff --git a/nbconvert_a11y/templates/a11y/components/cell.html.j2 b/nbconvert_a11y/templates/a11y/components/cell.html.j2 index 4e3ffd88..eb0e92f7 100644 --- a/nbconvert_a11y/templates/a11y/components/cell.html.j2 +++ b/nbconvert_a11y/templates/a11y/components/cell.html.j2 @@ -2,11 +2,14 @@ {% from "a11y/components/displays.html.j2" import cell_display_priority with context %} {% macro cell_anchor(i, cell_type, execution_count=None, outputs=None, hidden=False)%} +{% set CODE = cell_type==" code" .strip() %} +{% set MD = cell_type==" markdown" .strip() %} +{% set EXEC = execution_count %} {{i}} + %}accesskey="{{i}}" {% endif %} + aria-describedby="{% if CODE %}cell-{{i}}-executed {% endif %}nb-{{cell_type}}-label nb-cell-label{% if not MD %} cell-{{i}}-loc nb-loc-label{% endif %}{% if CODE%} cell-{{i}}-outputs-len{% endif %}" + {{hide(hidden or resources.hidden_anchor_links)}}>{{i}} {% endmacro %} {% macro cell_form(i, cell_type, hidden=True) %} @@ -37,6 +40,7 @@ that would include talking to the kernel. #} {% macro cell_execution_count(i, execution_count, hidden=False) %}