about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/docutils/utils
diff options
context:
space:
mode:
authorS. Solomon Darnell2025-03-28 21:52:21 -0500
committerS. Solomon Darnell2025-03-28 21:52:21 -0500
commit4a52a71956a8d46fcb7294ac71734504bb09bcc2 (patch)
treeee3dc5af3b6313e921cd920906356f5d4febc4ed /.venv/lib/python3.12/site-packages/docutils/utils
parentcc961e04ba734dd72309fb548a2f97d67d578813 (diff)
downloadgn-ai-master.tar.gz
two version of R2R are here HEAD master
Diffstat (limited to '.venv/lib/python3.12/site-packages/docutils/utils')
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/__init__.py861
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/code_analyzer.py136
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/error_reporting.py222
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/__init__.py73
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/latex2mathml.py1252
-rwxr-xr-x.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py3165
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/mathalphabet2unichar.py892
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/mathml_elements.py478
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/tex2mathml_extern.py261
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/tex2unichar.py730
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/math/unichar2tex.py808
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/punctuation_chars.py123
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/roman.py154
-rwxr-xr-x.venv/lib/python3.12/site-packages/docutils/utils/smartquotes.py1004
-rw-r--r--.venv/lib/python3.12/site-packages/docutils/utils/urischemes.py138
15 files changed, 10297 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/__init__.py b/.venv/lib/python3.12/site-packages/docutils/utils/__init__.py
new file mode 100644
index 00000000..777f8013
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/__init__.py
@@ -0,0 +1,861 @@
+# $Id: __init__.py 9544 2024-02-17 10:37:45Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+Miscellaneous utilities for the documentation utilities.
+"""
+
+__docformat__ = 'reStructuredText'
+
+import sys
+import os
+import os.path
+from pathlib import PurePath, Path
+import re
+import itertools
+import warnings
+import unicodedata
+
+from docutils import ApplicationError, DataError, __version_info__
+from docutils import io, nodes
+# for backwards compatibility
+from docutils.nodes import unescape  # noqa: F401
+
+
+class SystemMessage(ApplicationError):
+
+    def __init__(self, system_message, level):
+        Exception.__init__(self, system_message.astext())
+        self.level = level
+
+
+class SystemMessagePropagation(ApplicationError):
+    pass
+
+
+class Reporter:
+
+    """
+    Info/warning/error reporter and ``system_message`` element generator.
+
+    Five levels of system messages are defined, along with corresponding
+    methods: `debug()`, `info()`, `warning()`, `error()`, and `severe()`.
+
+    There is typically one Reporter object per process.  A Reporter object is
+    instantiated with thresholds for reporting (generating warnings) and
+    halting processing (raising exceptions), a switch to turn debug output on
+    or off, and an I/O stream for warnings.  These are stored as instance
+    attributes.
+
+    When a system message is generated, its level is compared to the stored
+    thresholds, and a warning or error is generated as appropriate.  Debug
+    messages are produced if the stored debug switch is on, independently of
+    other thresholds.  Message output is sent to the stored warning stream if
+    not set to ''.
+
+    The Reporter class also employs a modified form of the "Observer" pattern
+    [GoF95]_ to track system messages generated.  The `attach_observer` method
+    should be called before parsing, with a bound method or function which
+    accepts system messages.  The observer can be removed with
+    `detach_observer`, and another added in its place.
+
+    .. [GoF95] Gamma, Helm, Johnson, Vlissides. *Design Patterns: Elements of
+       Reusable Object-Oriented Software*. Addison-Wesley, Reading, MA, USA,
+       1995.
+    """
+
+    levels = 'DEBUG INFO WARNING ERROR SEVERE'.split()
+    """List of names for system message levels, indexed by level."""
+
+    # system message level constants:
+    (DEBUG_LEVEL,
+     INFO_LEVEL,
+     WARNING_LEVEL,
+     ERROR_LEVEL,
+     SEVERE_LEVEL) = range(5)
+
+    def __init__(self, source, report_level, halt_level, stream=None,
+                 debug=False, encoding=None, error_handler='backslashreplace'):
+        """
+        :Parameters:
+            - `source`: The path to or description of the source data.
+            - `report_level`: The level at or above which warning output will
+              be sent to `stream`.
+            - `halt_level`: The level at or above which `SystemMessage`
+              exceptions will be raised, halting execution.
+            - `debug`: Show debug (level=0) system messages?
+            - `stream`: Where warning output is sent.  Can be file-like (has a
+              ``.write`` method), a string (file name, opened for writing),
+              '' (empty string) or `False` (for discarding all stream messages)
+              or `None` (implies `sys.stderr`; default).
+            - `encoding`: The output encoding.
+            - `error_handler`: The error handler for stderr output encoding.
+        """
+
+        self.source = source
+        """The path to or description of the source data."""
+
+        self.error_handler = error_handler
+        """The character encoding error handler."""
+
+        self.debug_flag = debug
+        """Show debug (level=0) system messages?"""
+
+        self.report_level = report_level
+        """The level at or above which warning output will be sent
+        to `self.stream`."""
+
+        self.halt_level = halt_level
+        """The level at or above which `SystemMessage` exceptions
+        will be raised, halting execution."""
+
+        if not isinstance(stream, io.ErrorOutput):
+            stream = io.ErrorOutput(stream, encoding, error_handler)
+
+        self.stream = stream
+        """Where warning output is sent."""
+
+        self.encoding = encoding or getattr(stream, 'encoding', 'ascii')
+        """The output character encoding."""
+
+        self.observers = []
+        """List of bound methods or functions to call with each system_message
+        created."""
+
+        self.max_level = -1
+        """The highest level system message generated so far."""
+
+    def set_conditions(self, category, report_level, halt_level,
+                       stream=None, debug=False):
+        warnings.warn('docutils.utils.Reporter.set_conditions() deprecated; '
+                      'Will be removed in Docutils 0.21 or later. '
+                      'Set attributes via configuration settings or directly.',
+                      DeprecationWarning, stacklevel=2)
+        self.report_level = report_level
+        self.halt_level = halt_level
+        if not isinstance(stream, io.ErrorOutput):
+            stream = io.ErrorOutput(stream, self.encoding, self.error_handler)
+        self.stream = stream
+        self.debug_flag = debug
+
+    def attach_observer(self, observer):
+        """
+        The `observer` parameter is a function or bound method which takes one
+        argument, a `nodes.system_message` instance.
+        """
+        self.observers.append(observer)
+
+    def detach_observer(self, observer):
+        self.observers.remove(observer)
+
+    def notify_observers(self, message):
+        for observer in self.observers:
+            observer(message)
+
+    def system_message(self, level, message, *children, **kwargs):
+        """
+        Return a system_message object.
+
+        Raise an exception or generate a warning if appropriate.
+        """
+        # `message` can be a `str` or `Exception` instance.
+        if isinstance(message, Exception):
+            message = str(message)
+
+        attributes = kwargs.copy()
+        if 'base_node' in kwargs:
+            source, line = get_source_line(kwargs['base_node'])
+            del attributes['base_node']
+            if source is not None:
+                attributes.setdefault('source', source)
+            if line is not None:
+                attributes.setdefault('line', line)
+                # assert source is not None, "line- but no source-argument"
+        if 'source' not in attributes:
+            # 'line' is absolute line number
+            try:
+                source, line = self.get_source_and_line(attributes.get('line'))
+            except AttributeError:
+                source, line = None, None
+            if source is not None:
+                attributes['source'] = source
+            if line is not None:
+                attributes['line'] = line
+        # assert attributes['line'] is not None, (message, kwargs)
+        # assert attributes['source'] is not None, (message, kwargs)
+        attributes.setdefault('source', self.source)
+
+        msg = nodes.system_message(message, level=level,
+                                   type=self.levels[level],
+                                   *children, **attributes)
+        if self.stream and (level >= self.report_level
+                            or self.debug_flag and level == self.DEBUG_LEVEL
+                            or level >= self.halt_level):
+            self.stream.write(msg.astext() + '\n')
+        if level >= self.halt_level:
+            raise SystemMessage(msg, level)
+        if level > self.DEBUG_LEVEL or self.debug_flag:
+            self.notify_observers(msg)
+        self.max_level = max(level, self.max_level)
+        return msg
+
+    def debug(self, *args, **kwargs):
+        """
+        Level-0, "DEBUG": an internal reporting issue. Typically, there is no
+        effect on the processing. Level-0 system messages are handled
+        separately from the others.
+        """
+        if self.debug_flag:
+            return self.system_message(self.DEBUG_LEVEL, *args, **kwargs)
+
+    def info(self, *args, **kwargs):
+        """
+        Level-1, "INFO": a minor issue that can be ignored. Typically there is
+        no effect on processing, and level-1 system messages are not reported.
+        """
+        return self.system_message(self.INFO_LEVEL, *args, **kwargs)
+
+    def warning(self, *args, **kwargs):
+        """
+        Level-2, "WARNING": an issue that should be addressed. If ignored,
+        there may be unpredictable problems with the output.
+        """
+        return self.system_message(self.WARNING_LEVEL, *args, **kwargs)
+
+    def error(self, *args, **kwargs):
+        """
+        Level-3, "ERROR": an error that should be addressed. If ignored, the
+        output will contain errors.
+        """
+        return self.system_message(self.ERROR_LEVEL, *args, **kwargs)
+
+    def severe(self, *args, **kwargs):
+        """
+        Level-4, "SEVERE": a severe error that must be addressed. If ignored,
+        the output will contain severe errors. Typically level-4 system
+        messages are turned into exceptions which halt processing.
+        """
+        return self.system_message(self.SEVERE_LEVEL, *args, **kwargs)
+
+
+class ExtensionOptionError(DataError): pass
+class BadOptionError(ExtensionOptionError): pass
+class BadOptionDataError(ExtensionOptionError): pass
+class DuplicateOptionError(ExtensionOptionError): pass
+
+
+def extract_extension_options(field_list, options_spec):
+    """
+    Return a dictionary mapping extension option names to converted values.
+
+    :Parameters:
+        - `field_list`: A flat field list without field arguments, where each
+          field body consists of a single paragraph only.
+        - `options_spec`: Dictionary mapping known option names to a
+          conversion function such as `int` or `float`.
+
+    :Exceptions:
+        - `KeyError` for unknown option names.
+        - `ValueError` for invalid option values (raised by the conversion
+           function).
+        - `TypeError` for invalid option value types (raised by conversion
+           function).
+        - `DuplicateOptionError` for duplicate options.
+        - `BadOptionError` for invalid fields.
+        - `BadOptionDataError` for invalid option data (missing name,
+          missing data, bad quotes, etc.).
+    """
+    option_list = extract_options(field_list)
+    return assemble_option_dict(option_list, options_spec)
+
+
+def extract_options(field_list):
+    """
+    Return a list of option (name, value) pairs from field names & bodies.
+
+    :Parameter:
+        `field_list`: A flat field list, where each field name is a single
+        word and each field body consists of a single paragraph only.
+
+    :Exceptions:
+        - `BadOptionError` for invalid fields.
+        - `BadOptionDataError` for invalid option data (missing name,
+          missing data, bad quotes, etc.).
+    """
+    option_list = []
+    for field in field_list:
+        if len(field[0].astext().split()) != 1:
+            raise BadOptionError(
+                'extension option field name may not contain multiple words')
+        name = str(field[0].astext().lower())
+        body = field[1]
+        if len(body) == 0:
+            data = None
+        elif (len(body) > 1
+              or not isinstance(body[0], nodes.paragraph)
+              or len(body[0]) != 1
+              or not isinstance(body[0][0], nodes.Text)):
+            raise BadOptionDataError(
+                  'extension option field body may contain\n'
+                  'a single paragraph only (option "%s")' % name)
+        else:
+            data = body[0][0].astext()
+        option_list.append((name, data))
+    return option_list
+
+
+def assemble_option_dict(option_list, options_spec):
+    """
+    Return a mapping of option names to values.
+
+    :Parameters:
+        - `option_list`: A list of (name, value) pairs (the output of
+          `extract_options()`).
+        - `options_spec`: Dictionary mapping known option names to a
+          conversion function such as `int` or `float`.
+
+    :Exceptions:
+        - `KeyError` for unknown option names.
+        - `DuplicateOptionError` for duplicate options.
+        - `ValueError` for invalid option values (raised by conversion
+           function).
+        - `TypeError` for invalid option value types (raised by conversion
+           function).
+    """
+    options = {}
+    for name, value in option_list:
+        convertor = options_spec[name]  # raises KeyError if unknown
+        if convertor is None:
+            raise KeyError(name)        # or if explicitly disabled
+        if name in options:
+            raise DuplicateOptionError('duplicate option "%s"' % name)
+        try:
+            options[name] = convertor(value)
+        except (ValueError, TypeError) as detail:
+            raise detail.__class__('(option: "%s"; value: %r)\n%s'
+                                   % (name, value, ' '.join(detail.args)))
+    return options
+
+
+class NameValueError(DataError): pass
+
+
+def decode_path(path):
+    """
+    Ensure `path` is Unicode. Return `str` instance.
+
+    Decode file/path string in a failsafe manner if not already done.
+    """
+    # TODO: is this still required with Python 3?
+    if isinstance(path, str):
+        return path
+    try:
+        path = path.decode(sys.getfilesystemencoding(), 'strict')
+    except AttributeError:  # default value None has no decode method
+        if not path:
+            return ''
+        raise ValueError('`path` value must be a String or ``None``, '
+                         f'not {path!r}')
+    except UnicodeDecodeError:
+        try:
+            path = path.decode('utf-8', 'strict')
+        except UnicodeDecodeError:
+            path = path.decode('ascii', 'replace')
+    return path
+
+
+def extract_name_value(line):
+    """
+    Return a list of (name, value) from a line of the form "name=value ...".
+
+    :Exception:
+        `NameValueError` for invalid input (missing name, missing data, bad
+        quotes, etc.).
+    """
+    attlist = []
+    while line:
+        equals = line.find('=')
+        if equals == -1:
+            raise NameValueError('missing "="')
+        attname = line[:equals].strip()
+        if equals == 0 or not attname:
+            raise NameValueError(
+                  'missing attribute name before "="')
+        line = line[equals+1:].lstrip()
+        if not line:
+            raise NameValueError(
+                  'missing value after "%s="' % attname)
+        if line[0] in '\'"':
+            endquote = line.find(line[0], 1)
+            if endquote == -1:
+                raise NameValueError(
+                      'attribute "%s" missing end quote (%s)'
+                      % (attname, line[0]))
+            if len(line) > endquote + 1 and line[endquote + 1].strip():
+                raise NameValueError(
+                      'attribute "%s" end quote (%s) not followed by '
+                      'whitespace' % (attname, line[0]))
+            data = line[1:endquote]
+            line = line[endquote+1:].lstrip()
+        else:
+            space = line.find(' ')
+            if space == -1:
+                data = line
+                line = ''
+            else:
+                data = line[:space]
+                line = line[space+1:].lstrip()
+        attlist.append((attname.lower(), data))
+    return attlist
+
+
+def new_reporter(source_path, settings):
+    """
+    Return a new Reporter object.
+
+    :Parameters:
+        `source` : string
+            The path to or description of the source text of the document.
+        `settings` : optparse.Values object
+            Runtime settings.
+    """
+    reporter = Reporter(
+        source_path, settings.report_level, settings.halt_level,
+        stream=settings.warning_stream, debug=settings.debug,
+        encoding=settings.error_encoding,
+        error_handler=settings.error_encoding_error_handler)
+    return reporter
+
+
+def new_document(source_path, settings=None):
+    """
+    Return a new empty document object.
+
+    :Parameters:
+        `source_path` : string
+            The path to or description of the source text of the document.
+        `settings` : optparse.Values object
+            Runtime settings.  If none are provided, a default core set will
+            be used.  If you will use the document object with any Docutils
+            components, you must provide their default settings as well.
+
+            For example, if parsing rST, at least provide the rst-parser
+            settings, obtainable as follows:
+
+            Defaults for parser component::
+
+                settings = docutils.frontend.get_default_settings(
+                               docutils.parsers.rst.Parser)
+
+            Defaults and configuration file customizations::
+
+                settings = docutils.core.Publisher(
+                    parser=docutils.parsers.rst.Parser).get_settings()
+
+    """
+    # Import at top of module would lead to circular dependency!
+    from docutils import frontend
+    if settings is None:
+        settings = frontend.get_default_settings()
+    source_path = decode_path(source_path)
+    reporter = new_reporter(source_path, settings)
+    document = nodes.document(settings, reporter, source=source_path)
+    document.note_source(source_path, -1)
+    return document
+
+
+def clean_rcs_keywords(paragraph, keyword_substitutions):
+    if len(paragraph) == 1 and isinstance(paragraph[0], nodes.Text):
+        textnode = paragraph[0]
+        for pattern, substitution in keyword_substitutions:
+            match = pattern.search(textnode)
+            if match:
+                paragraph[0] = nodes.Text(pattern.sub(substitution, textnode))
+                return
+
+
+def relative_path(source, target):
+    """
+    Build and return a path to `target`, relative to `source` (both files).
+
+    The return value is a `str` suitable to be included in `source`
+    as a reference to `target`.
+
+    :Parameters:
+        `source` : path-like object or None
+            Path of a file in the start directory for the relative path
+            (the file does not need to exist).
+            The value ``None`` is replaced with "<cwd>/dummy_file".
+        `target` : path-like object
+            End point of the returned relative path.
+
+    Differences to `os.path.relpath()`:
+
+    * Inverse argument order.
+    * `source` is assumed to be a FILE in the start directory (add a "dummy"
+      file name to obtain the path relative from a directory)
+      while `os.path.relpath()` expects a DIRECTORY as `start` argument.
+    * Always use Posix path separator ("/") for the output.
+    * Use `os.sep` for parsing the input
+      (changing the value of `os.sep` is ignored by `os.relpath()`).
+    * If there is no common prefix, return the absolute path to `target`.
+
+    Differences to `pathlib.PurePath.relative_to(other)`:
+
+    * pathlib offers an object oriented interface.
+    * `source` expects path to a FILE while `other` expects a DIRECTORY.
+    * `target` defaults to the cwd, no default value for `other`.
+    * `relative_path()` always returns a path (relative or absolute),
+      while `PurePath.relative_to()` raises a ValueError
+      if `target` is not a subpath of `other` (no ".." inserted).
+    """
+    source_parts = os.path.abspath(source or type(target)('dummy_file')
+                                   ).split(os.sep)
+    target_parts = os.path.abspath(target).split(os.sep)
+    # Check first 2 parts because '/dir'.split('/') == ['', 'dir']:
+    if source_parts[:2] != target_parts[:2]:
+        # Nothing in common between paths.
+        # Return absolute path, using '/' for URLs:
+        return '/'.join(target_parts)
+    source_parts.reverse()
+    target_parts.reverse()
+    while (source_parts and target_parts
+           and source_parts[-1] == target_parts[-1]):
+        # Remove path components in common:
+        source_parts.pop()
+        target_parts.pop()
+    target_parts.reverse()
+    parts = ['..'] * (len(source_parts) - 1) + target_parts
+    return '/'.join(parts)
+
+
+def get_stylesheet_reference(settings, relative_to=None):
+    """
+    Retrieve a stylesheet reference from the settings object.
+
+    Deprecated. Use get_stylesheet_list() instead to
+    enable specification of multiple stylesheets as a comma-separated
+    list.
+    """
+    warnings.warn('utils.get_stylesheet_reference()'
+                  ' is obsoleted by utils.get_stylesheet_list()'
+                  ' and will be removed in Docutils 2.0.',
+                  DeprecationWarning, stacklevel=2)
+    if settings.stylesheet_path:
+        assert not settings.stylesheet, (
+            'stylesheet and stylesheet_path are mutually exclusive.')
+        if relative_to is None:
+            relative_to = settings._destination
+        return relative_path(relative_to, settings.stylesheet_path)
+    else:
+        return settings.stylesheet
+
+
+# Return 'stylesheet' or 'stylesheet_path' arguments as list.
+#
+# The original settings arguments are kept unchanged: you can test
+# with e.g. ``if settings.stylesheet_path: ...``.
+#
+# Differences to the depracated `get_stylesheet_reference()`:
+# * return value is a list
+# * no re-writing of the path (and therefore no optional argument)
+#   (if required, use ``utils.relative_path(source, target)``
+#   in the calling script)
+def get_stylesheet_list(settings):
+    """
+    Retrieve list of stylesheet references from the settings object.
+    """
+    assert not (settings.stylesheet and settings.stylesheet_path), (
+            'stylesheet and stylesheet_path are mutually exclusive.')
+    stylesheets = settings.stylesheet_path or settings.stylesheet or []
+    # programmatically set default may be string with comma separated list:
+    if not isinstance(stylesheets, list):
+        stylesheets = [path.strip() for path in stylesheets.split(',')]
+    if settings.stylesheet_path:
+        # expand relative paths if found in stylesheet-dirs:
+        stylesheets = [find_file_in_dirs(path, settings.stylesheet_dirs)
+                       for path in stylesheets]
+    return stylesheets
+
+
+def find_file_in_dirs(path, dirs):
+    """
+    Search for `path` in the list of directories `dirs`.
+
+    Return the first expansion that matches an existing file.
+    """
+    path = Path(path)
+    if path.is_absolute():
+        return path.as_posix()
+    for d in dirs:
+        f = Path(d).expanduser() / path
+        if f.exists():
+            return f.as_posix()
+    return path.as_posix()
+
+
+def get_trim_footnote_ref_space(settings):
+    """
+    Return whether or not to trim footnote space.
+
+    If trim_footnote_reference_space is not None, return it.
+
+    If trim_footnote_reference_space is None, return False unless the
+    footnote reference style is 'superscript'.
+    """
+    if settings.setdefault('trim_footnote_reference_space', None) is None:
+        return getattr(settings, 'footnote_references', None) == 'superscript'
+    else:
+        return settings.trim_footnote_reference_space
+
+
+def get_source_line(node):
+    """
+    Return the "source" and "line" attributes from the `node` given or from
+    its closest ancestor.
+    """
+    while node:
+        if node.source or node.line:
+            return node.source, node.line
+        node = node.parent
+    return None, None
+
+
+def escape2null(text):
+    """Return a string with escape-backslashes converted to nulls."""
+    parts = []
+    start = 0
+    while True:
+        found = text.find('\\', start)
+        if found == -1:
+            parts.append(text[start:])
+            return ''.join(parts)
+        parts.append(text[start:found])
+        parts.append('\x00' + text[found+1:found+2])
+        start = found + 2               # skip character after escape
+
+
+def split_escaped_whitespace(text):
+    """
+    Split `text` on escaped whitespace (null+space or null+newline).
+    Return a list of strings.
+    """
+    strings = text.split('\x00 ')
+    strings = [string.split('\x00\n') for string in strings]
+    # flatten list of lists of strings to list of strings:
+    return list(itertools.chain(*strings))
+
+
+def strip_combining_chars(text):
+    return ''.join(c for c in text if not unicodedata.combining(c))
+
+
+def find_combining_chars(text):
+    """Return indices of all combining chars in  Unicode string `text`.
+
+    >>> from docutils.utils import find_combining_chars
+    >>> find_combining_chars('A t̆ab̆lĕ')
+    [3, 6, 9]
+
+    """
+    return [i for i, c in enumerate(text) if unicodedata.combining(c)]
+
+
+def column_indices(text):
+    """Indices of Unicode string `text` when skipping combining characters.
+
+    >>> from docutils.utils import column_indices
+    >>> column_indices('A t̆ab̆lĕ')
+    [0, 1, 2, 4, 5, 7, 8]
+
+    """
+    # TODO: account for asian wide chars here instead of using dummy
+    # replacements in the tableparser?
+    string_indices = list(range(len(text)))
+    for index in find_combining_chars(text):
+        string_indices[index] = None
+    return [i for i in string_indices if i is not None]
+
+
+east_asian_widths = {'W': 2,   # Wide
+                     'F': 2,   # Full-width (wide)
+                     'Na': 1,  # Narrow
+                     'H': 1,   # Half-width (narrow)
+                     'N': 1,   # Neutral (not East Asian, treated as narrow)
+                     'A': 1,   # Ambiguous (s/b wide in East Asian context,
+                     }         # narrow otherwise, but that doesn't work)
+"""Mapping of result codes from `unicodedata.east_asian_widt()` to character
+column widths."""
+
+
+def column_width(text):
+    """Return the column width of text.
+
+    Correct ``len(text)`` for wide East Asian and combining Unicode chars.
+    """
+    width = sum(east_asian_widths[unicodedata.east_asian_width(c)]
+                for c in text)
+    # correction for combining chars:
+    width -= len(find_combining_chars(text))
+    return width
+
+
+def uniq(L):
+    r = []
+    for item in L:
+        if item not in r:
+            r.append(item)
+    return r
+
+
+def normalize_language_tag(tag):
+    """Return a list of normalized combinations for a `BCP 47` language tag.
+
+    Example:
+
+    >>> from docutils.utils import normalize_language_tag
+    >>> normalize_language_tag('de_AT-1901')
+    ['de-at-1901', 'de-at', 'de-1901', 'de']
+    >>> normalize_language_tag('de-CH-x_altquot')
+    ['de-ch-x-altquot', 'de-ch', 'de-x-altquot', 'de']
+
+    """
+    # normalize:
+    tag = tag.lower().replace('-', '_')
+    # split (except singletons, which mark the following tag as non-standard):
+    tag = re.sub(r'_([a-zA-Z0-9])_', r'_\1-', tag)
+    subtags = [subtag for subtag in tag.split('_')]
+    base_tag = (subtags.pop(0),)
+    # find all combinations of subtags
+    taglist = []
+    for n in range(len(subtags), 0, -1):
+        for tags in itertools.combinations(subtags, n):
+            taglist.append('-'.join(base_tag+tags))
+    taglist += base_tag
+    return taglist
+
+
+def xml_declaration(encoding=None):
+    """Return an XML text declaration.
+
+    Include an encoding declaration, if `encoding`
+    is not 'unicode', '', or None.
+    """
+    if encoding and encoding.lower() != 'unicode':
+        encoding_declaration = f' encoding="{encoding}"'
+    else:
+        encoding_declaration = ''
+    return f'<?xml version="1.0"{encoding_declaration}?>\n'
+
+
+class DependencyList:
+
+    """
+    List of dependencies, with file recording support.
+
+    Note that the output file is not automatically closed.  You have
+    to explicitly call the close() method.
+    """
+
+    def __init__(self, output_file=None, dependencies=()):
+        """
+        Initialize the dependency list, automatically setting the
+        output file to `output_file` (see `set_output()`) and adding
+        all supplied dependencies.
+
+        If output_file is None, no file output is done when calling add().
+        """
+        self.list = []
+        self.file = None
+        if output_file:
+            self.set_output(output_file)
+        self.add(*dependencies)
+
+    def set_output(self, output_file):
+        """
+        Set the output file and clear the list of already added
+        dependencies.
+
+        `output_file` must be a string.  The specified file is
+        immediately overwritten.
+
+        If output_file is '-', the output will be written to stdout.
+        """
+        if output_file:
+            if output_file == '-':
+                self.file = sys.stdout
+            else:
+                self.file = open(output_file, 'w', encoding='utf-8')
+
+    def add(self, *paths):
+        """
+        Append `path` to `self.list` unless it is already there.
+
+        Also append to `self.file` unless it is already there
+        or `self.file is `None`.
+        """
+        for path in paths:
+            if isinstance(path, PurePath):
+                path = path.as_posix()  # use '/' as separator
+            if path not in self.list:
+                self.list.append(path)
+                if self.file is not None:
+                    self.file.write(path+'\n')
+
+    def close(self):
+        """
+        Close the output file.
+        """
+        if self.file is not sys.stdout:
+            self.file.close()
+        self.file = None
+
+    def __repr__(self):
+        try:
+            output_file = self.file.name
+        except AttributeError:
+            output_file = None
+        return '%s(%r, %s)' % (self.__class__.__name__, output_file, self.list)
+
+
+release_level_abbreviations = {
+    'alpha': 'a',
+    'beta': 'b',
+    'candidate': 'rc',
+    'final': ''}
+
+
+def version_identifier(version_info=None):
+    """
+    Return a version identifier string built from `version_info`, a
+    `docutils.VersionInfo` namedtuple instance or compatible tuple. If
+    `version_info` is not provided, by default return a version identifier
+    string based on `docutils.__version_info__` (i.e. the current Docutils
+    version).
+    """
+    if version_info is None:
+        version_info = __version_info__
+    if version_info.micro:
+        micro = '.%s' % version_info.micro
+    else:
+        # 0 is omitted:
+        micro = ''
+    releaselevel = release_level_abbreviations[version_info.releaselevel]
+    if version_info.serial:
+        serial = version_info.serial
+    else:
+        # 0 is omitted:
+        serial = ''
+    if version_info.release:
+        dev = ''
+    else:
+        dev = '.dev'
+    version = '%s.%s%s%s%s%s' % (
+        version_info.major,
+        version_info.minor,
+        micro,
+        releaselevel,
+        serial,
+        dev)
+    return version
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/code_analyzer.py b/.venv/lib/python3.12/site-packages/docutils/utils/code_analyzer.py
new file mode 100644
index 00000000..cd020bb1
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/code_analyzer.py
@@ -0,0 +1,136 @@
+# :Author: Georg Brandl; Lea Wiemann; Günter Milde
+# :Date: $Date: 2022-11-16 15:01:31 +0100 (Mi, 16. Nov 2022) $
+# :Copyright: This module has been placed in the public domain.
+
+"""Lexical analysis of formal languages (i.e. code) using Pygments."""
+
+from docutils import ApplicationError
+try:
+    import pygments
+    from pygments.lexers import get_lexer_by_name
+    from pygments.formatters.html import _get_ttype_class
+    with_pygments = True
+except ImportError:
+    with_pygments = False
+
+# Filter the following token types from the list of class arguments:
+unstyled_tokens = ['token',  # Token (base token type)
+                   'text',   # Token.Text
+                   '']       # short name for Token and Text
+# (Add, e.g., Token.Punctuation with ``unstyled_tokens += 'punctuation'``.)
+
+
+class LexerError(ApplicationError):
+    pass
+
+
+class Lexer:
+    """Parse `code` lines and yield "classified" tokens.
+
+    Arguments
+
+      code       -- string of source code to parse,
+      language   -- formal language the code is written in,
+      tokennames -- either 'long', 'short', or 'none' (see below).
+
+    Merge subsequent tokens of the same token-type.
+
+    Iterating over an instance yields the tokens as ``(tokentype, value)``
+    tuples. The value of `tokennames` configures the naming of the tokentype:
+
+      'long':  downcased full token type name,
+      'short': short name defined by pygments.token.STANDARD_TYPES
+               (= class argument used in pygments html output),
+      'none':  skip lexical analysis.
+    """
+
+    def __init__(self, code, language, tokennames='short'):
+        """
+        Set up a lexical analyzer for `code` in `language`.
+        """
+        self.code = code
+        self.language = language
+        self.tokennames = tokennames
+        self.lexer = None
+        # get lexical analyzer for `language`:
+        if language in ('', 'text') or tokennames == 'none':
+            return
+        if not with_pygments:
+            raise LexerError('Cannot analyze code. '
+                             'Pygments package not found.')
+        try:
+            self.lexer = get_lexer_by_name(self.language)
+        except pygments.util.ClassNotFound:
+            raise LexerError('Cannot analyze code. '
+                             'No Pygments lexer found for "%s".' % language)
+        # self.lexer.add_filter('tokenmerge')
+        # Since version 1.2. (released Jan 01, 2010) Pygments has a
+        # TokenMergeFilter. # ``self.merge(tokens)`` in __iter__ could
+        # be replaced by ``self.lexer.add_filter('tokenmerge')`` in __init__.
+        # However, `merge` below also strips a final newline added by pygments.
+        #
+        # self.lexer.add_filter('tokenmerge')
+
+    def merge(self, tokens):
+        """Merge subsequent tokens of same token-type.
+
+           Also strip the final newline (added by pygments).
+        """
+        tokens = iter(tokens)
+        (lasttype, lastval) = next(tokens)
+        for ttype, value in tokens:
+            if ttype is lasttype:
+                lastval += value
+            else:
+                yield lasttype, lastval
+                (lasttype, lastval) = (ttype, value)
+        if lastval.endswith('\n'):
+            lastval = lastval[:-1]
+        if lastval:
+            yield lasttype, lastval
+
+    def __iter__(self):
+        """Parse self.code and yield "classified" tokens.
+        """
+        if self.lexer is None:
+            yield [], self.code
+            return
+        tokens = pygments.lex(self.code, self.lexer)
+        for tokentype, value in self.merge(tokens):
+            if self.tokennames == 'long':  # long CSS class args
+                classes = str(tokentype).lower().split('.')
+            else:  # short CSS class args
+                classes = [_get_ttype_class(tokentype)]
+            classes = [cls for cls in classes if cls not in unstyled_tokens]
+            yield classes, value
+
+
+class NumberLines:
+    """Insert linenumber-tokens at the start of every code line.
+
+    Arguments
+
+       tokens    -- iterable of ``(classes, value)`` tuples
+       startline -- first line number
+       endline   -- last line number
+
+    Iterating over an instance yields the tokens with a
+    ``(['ln'], '<the line number>')`` token added for every code line.
+    Multi-line tokens are split."""
+
+    def __init__(self, tokens, startline, endline):
+        self.tokens = tokens
+        self.startline = startline
+        # pad linenumbers, e.g. endline == 100 -> fmt_str = '%3d '
+        self.fmt_str = '%%%dd ' % len(str(endline))
+
+    def __iter__(self):
+        lineno = self.startline
+        yield ['ln'], self.fmt_str % lineno
+        for ttype, value in self.tokens:
+            lines = value.split('\n')
+            for line in lines[:-1]:
+                yield ttype, line + '\n'
+                lineno += 1
+                yield ['ln'], self.fmt_str % lineno
+            yield ttype, lines[-1]
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/error_reporting.py b/.venv/lib/python3.12/site-packages/docutils/utils/error_reporting.py
new file mode 100644
index 00000000..805c95bb
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/error_reporting.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+# :Id: $Id: error_reporting.py 9078 2022-06-17 11:31:40Z milde $
+# :Copyright: © 2011 Günter Milde.
+# :License: Released under the terms of the `2-Clause BSD license`_, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+
+"""
+Deprecated module to handle Exceptions across Python versions.
+
+.. warning::
+   This module is deprecated with the end of support for Python 2.7
+   and will be removed in Docutils 0.21 or later.
+
+   Replacements:
+     | SafeString  -> str
+     | ErrorString -> docutils.io.error_string()
+     | ErrorOutput -> docutils.io.ErrorOutput
+
+Error reporting should be safe from encoding/decoding errors.
+However, implicit conversions of strings and exceptions like
+
+>>> u'%s world: %s' % ('H\xe4llo', Exception(u'H\xe4llo'))
+
+fail in some Python versions:
+
+* In Python <= 2.6, ``unicode(<exception instance>)`` uses
+  `__str__` and fails with non-ASCII chars in`unicode` arguments.
+  (work around http://bugs.python.org/issue2517):
+
+* In Python 2, unicode(<exception instance>) fails, with non-ASCII
+  chars in arguments. (Use case: in some locales, the errstr
+  argument of IOError contains non-ASCII chars.)
+
+* In Python 2, str(<exception instance>) fails, with non-ASCII chars
+  in `unicode` arguments.
+
+The `SafeString`, `ErrorString` and `ErrorOutput` classes handle
+common exceptions.
+"""
+
+import sys
+import warnings
+
+from docutils.io import _locale_encoding as locale_encoding  # noqa
+
+warnings.warn('The `docutils.utils.error_reporting` module is deprecated '
+              'and will be removed in Docutils 0.21 or later.\n'
+              'Details with help("docutils.utils.error_reporting").',
+              DeprecationWarning, stacklevel=2)
+
+
+if sys.version_info >= (3, 0):
+    unicode = str  # noqa
+
+
+class SafeString:
+    """
+    A wrapper providing robust conversion to `str` and `unicode`.
+    """
+
+    def __init__(self, data, encoding=None, encoding_errors='backslashreplace',
+                 decoding_errors='replace'):
+        self.data = data
+        self.encoding = (encoding or getattr(data, 'encoding', None)
+                         or locale_encoding or 'ascii')
+        self.encoding_errors = encoding_errors
+        self.decoding_errors = decoding_errors
+
+    def __str__(self):
+        try:
+            return str(self.data)
+        except UnicodeEncodeError:
+            if isinstance(self.data, Exception):
+                args = [str(SafeString(arg, self.encoding,
+                                       self.encoding_errors))
+                        for arg in self.data.args]
+                return ', '.join(args)
+            if isinstance(self.data, unicode):
+                if sys.version_info > (3, 0):
+                    return self.data
+                else:
+                    return self.data.encode(self.encoding,
+                                            self.encoding_errors)
+            raise
+
+    def __unicode__(self):
+        """
+        Return unicode representation of `self.data`.
+
+        Try ``unicode(self.data)``, catch `UnicodeError` and
+
+        * if `self.data` is an Exception instance, work around
+          http://bugs.python.org/issue2517 with an emulation of
+          Exception.__unicode__,
+
+        * else decode with `self.encoding` and `self.decoding_errors`.
+        """
+        try:
+            u = unicode(self.data)
+            if isinstance(self.data, EnvironmentError):
+                u = u.replace(": u'", ": '") # normalize filename quoting
+            return u
+        except UnicodeError as error: # catch ..Encode.. and ..Decode.. errors
+            if isinstance(self.data, EnvironmentError):
+                return "[Errno %s] %s: '%s'" % (
+                    self.data.errno,
+                    SafeString(self.data.strerror, self.encoding,
+                               self.decoding_errors),
+                    SafeString(self.data.filename, self.encoding,
+                               self.decoding_errors))
+            if isinstance(self.data, Exception):
+                args = [unicode(SafeString(
+                                    arg, self.encoding,
+                                    decoding_errors=self.decoding_errors))
+                        for arg in self.data.args]
+                return u', '.join(args)
+            if isinstance(error, UnicodeDecodeError):
+                return unicode(self.data, self.encoding, self.decoding_errors)
+            raise
+
+
+class ErrorString(SafeString):
+    """
+    Safely report exception type and message.
+    """
+    def __str__(self):
+        return '%s: %s' % (self.data.__class__.__name__,
+                           super(ErrorString, self).__str__())
+
+    def __unicode__(self):
+        return u'%s: %s' % (self.data.__class__.__name__,
+                            super(ErrorString, self).__unicode__())
+
+
+class ErrorOutput:
+    """
+    Wrapper class for file-like error streams with
+    failsafe de- and encoding of `str`, `bytes`, `unicode` and
+    `Exception` instances.
+    """
+
+    def __init__(self, stream=None, encoding=None,
+                 encoding_errors='backslashreplace',
+                 decoding_errors='replace'):
+        """
+        :Parameters:
+            - `stream`: a file-like object,
+                        a string (path to a file),
+                        `None` (write to `sys.stderr`, default), or
+                        evaluating to `False` (write() requests are ignored).
+            - `encoding`: `stream` text encoding. Guessed if None.
+            - `encoding_errors`: how to treat encoding errors.
+        """
+        if stream is None:
+            stream = sys.stderr
+        elif not stream:
+            stream = False
+        # if `stream` is a file name, open it
+        elif isinstance(stream, str):
+            stream = open(stream, 'w')
+        elif isinstance(stream, unicode):
+            stream = open(stream.encode(sys.getfilesystemencoding()), 'w')
+
+        self.stream = stream
+        """Where warning output is sent."""
+
+        self.encoding = (encoding or getattr(stream, 'encoding', None)
+                         or locale_encoding or 'ascii')
+        """The output character encoding."""
+
+        self.encoding_errors = encoding_errors
+        """Encoding error handler."""
+
+        self.decoding_errors = decoding_errors
+        """Decoding error handler."""
+
+    def write(self, data):
+        """
+        Write `data` to self.stream. Ignore, if self.stream is False.
+
+        `data` can be a `string`, `unicode`, or `Exception` instance.
+        """
+        if self.stream is False:
+            return
+        if isinstance(data, Exception):
+            data = unicode(SafeString(data, self.encoding,
+                                      self.encoding_errors,
+                                      self.decoding_errors))
+        try:
+            self.stream.write(data)
+        except UnicodeEncodeError:
+            self.stream.write(data.encode(self.encoding, self.encoding_errors))
+        except TypeError:
+            if isinstance(data, unicode): # passed stream may expect bytes
+                self.stream.write(data.encode(self.encoding,
+                                              self.encoding_errors))
+                return
+            if self.stream in (sys.stderr, sys.stdout):
+                self.stream.buffer.write(data) # write bytes to raw stream
+            else:
+                self.stream.write(unicode(data, self.encoding,
+                                          self.decoding_errors))
+
+    def close(self):
+        """
+        Close the error-output stream.
+
+        Ignored if the stream is` sys.stderr` or `sys.stdout` or has no
+        close() method.
+        """
+        if self.stream in (sys.stdout, sys.stderr):
+            return
+        try:
+            self.stream.close()
+        except AttributeError:
+            pass
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/__init__.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/__init__.py
new file mode 100644
index 00000000..2ad43b42
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/__init__.py
@@ -0,0 +1,73 @@
+# :Id: $Id: __init__.py 9516 2024-01-15 16:11:08Z milde $
+# :Author: Guenter Milde.
+# :License: Released under the terms of the `2-Clause BSD license`_, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+
+"""
+This is the Docutils (Python Documentation Utilities) "math" sub-package.
+
+It contains various modules for conversion between different math formats
+(LaTeX, MathML, HTML).
+
+:math2html:    LaTeX math -> HTML conversion from eLyXer
+:latex2mathml: LaTeX math -> presentational MathML
+:unichar2tex:  Unicode character to LaTeX math translation table
+:tex2unichar:  LaTeX math to Unicode character translation dictionaries
+:mathalphabet2unichar:  LaTeX math alphabets to Unicode character translation
+:tex2mathml_extern: Wrapper for 3rd party TeX -> MathML converters
+"""
+
+# helpers for Docutils math support
+# =================================
+
+
+class MathError(ValueError):
+    """Exception for math syntax and math conversion errors.
+
+    The additional attribute `details` may hold a list of Docutils
+    nodes suitable as children for a ``<system_message>``.
+    """
+    def __init__(self, msg, details=[]):
+        super().__init__(msg)
+        self.details = details
+
+
+def toplevel_code(code):
+    """Return string (LaTeX math) `code` with environments stripped out."""
+    chunks = code.split(r'\begin{')
+    return r'\begin{'.join(chunk.split(r'\end{')[-1]
+                           for chunk in chunks)
+
+
+def pick_math_environment(code, numbered=False):
+    """Return the right math environment to display `code`.
+
+    The test simply looks for line-breaks (``\\``) outside environments.
+    Multi-line formulae are set with ``align``, one-liners with
+    ``equation``.
+
+    If `numbered` evaluates to ``False``, the "starred" versions are used
+    to suppress numbering.
+    """
+    if toplevel_code(code).find(r'\\') >= 0:
+        env = 'align'
+    else:
+        env = 'equation'
+    if not numbered:
+        env += '*'
+    return env
+
+
+def wrap_math_code(code, as_block):
+    # Wrap math-code in mode-switching TeX command/environment.
+    # If `as_block` is True, use environment for displayed equation(s).
+    if as_block:
+        env = pick_math_environment(code)
+        return '\\begin{%s}\n%s\n\\end{%s}' % (env, code, env)
+    return '$%s$' % code
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/latex2mathml.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/latex2mathml.py
new file mode 100644
index 00000000..b6ca3934
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/latex2mathml.py
@@ -0,0 +1,1252 @@
+# :Id: $Id: latex2mathml.py 9536 2024-02-01 13:04:22Z milde $
+# :Copyright: © 2005 Jens Jørgen Mortensen [1]_
+#             © 2010, 2021, 2024 Günter Milde.
+#
+# :License: Released under the terms of the `2-Clause BSD license`_, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+#
+# .. [1] the original `rst2mathml.py` in `sandbox/jensj/latex_math`
+
+"""Convert LaTex maths code into presentational MathML.
+
+This module is provisional:
+the API is not settled and may change with any minor Docutils version.
+"""
+
+# Usage:
+#
+# >>> from latex2mathml import *
+
+import re
+import unicodedata
+
+from docutils.utils.math import (MathError, mathalphabet2unichar,
+                                 tex2unichar, toplevel_code)
+from docutils.utils.math.mathml_elements import (
+    math, mtable, mrow, mtr, mtd, menclose, mphantom, msqrt, mi, mn, mo,
+    mtext, msub, msup, msubsup, munder, mover, munderover, mroot, mfrac,
+    mspace, MathRow)
+
+
+# Character data
+# --------------
+
+# LaTeX math macro to Unicode mappings.
+# Character categories.
+
+# identifiers -> <mi>
+
+letters = {'hbar': 'ℏ'}  # Compatibility mapping: \hbar resembles italic ħ
+#                          "unicode-math" unifies \hbar and \hslash to ℏ.
+letters.update(tex2unichar.mathalpha)
+
+ordinary = tex2unichar.mathord  # Miscellaneous symbols
+
+# special case: Capital Greek letters: (upright in TeX style)
+greek_capitals = {
+    'Phi': '\u03a6', 'Xi': '\u039e', 'Sigma': '\u03a3',
+    'Psi': '\u03a8', 'Delta': '\u0394', 'Theta': '\u0398',
+    'Upsilon': '\u03d2', 'Pi': '\u03a0', 'Omega': '\u03a9',
+    'Gamma': '\u0393', 'Lambda': '\u039b'}
+
+# functions -> <mi>
+functions = {
+    # functions with a space in the name
+    'liminf': 'lim\u202finf',
+    'limsup': 'lim\u202fsup',
+    'injlim': 'inj\u202flim',
+    'projlim': 'proj\u202flim',
+    # embellished function names (see handle_cmd() below)
+    'varlimsup': 'lim',
+    'varliminf': 'lim',
+    'varprojlim': 'lim',
+    'varinjlim': 'lim',
+    # custom function name
+    'operatorname': None,
+}
+functions.update((name, name) for name in
+                 ('arccos', 'arcsin', 'arctan', 'arg',  'cos',
+                  'cosh',   'cot',    'coth',   'csc',  'deg',
+                  'det',    'dim',    'exp',    'gcd',  'hom',
+                  'ker',    'lg',     'ln',     'log',  'Pr',
+                  'sec',    'sin',    'sinh',   'tan',  'tanh'))
+# Function with limits: 'lim', 'sup', 'inf', 'max', 'min':
+# use <mo> to allow "movablelimits" attribute (see below).
+
+# modulo operator/arithmetic
+modulo_functions = {
+    # cmdname: (binary, named, parentheses, padding)
+    'bmod': (True,  True,  False, '0.278em'),  # a mod n
+    'pmod': (False, True,  True,  '0.444em'),  # a  (mod n)
+    'mod':  (False, True,  False, '0.667em'),  # a  mod n
+    'pod':  (False, False, True,  '0.444em'),  # a  (n)
+    }
+
+
+# "mathematical alphabets": map identifiers to the corresponding
+# characters from the "Mathematical Alphanumeric Symbols" block
+math_alphabets = {
+    # 'cmdname':  'mathvariant value'        # package
+    'mathbb':     'double-struck',           # amssymb
+    'mathbf':     'bold',
+    'mathbfit':   'bold-italic',             # isomath
+    'mathcal':    'script',
+    'mathfrak':   'fraktur',                 # amssymb
+    'mathit':     'italic',
+    'mathrm':     'normal',
+    'mathscr':    'script',                  # mathrsfs et al
+    'mathsf':     'sans-serif',
+    'mathbfsfit': 'sans-serif-bold-italic',  # unicode-math
+    'mathsfbfit': 'sans-serif-bold-italic',  # isomath
+    'mathsfit':   'sans-serif-italic',       # isomath
+    'mathtt':     'monospace',
+    # unsupported: bold-fraktur
+    #              bold-script
+    #              bold-sans-serif
+}
+
+# operator, fence, or separator -> <mo>
+
+stretchables = {
+    # extensible delimiters allowed in left/right cmds
+    'backslash':   '\\',
+    'uparrow':     '\u2191',  # ↑ UPWARDS ARROW
+    'downarrow':   '\u2193',  # ↓ DOWNWARDS ARROW
+    'updownarrow': '\u2195',  # ↕ UP DOWN ARROW
+    'Uparrow':     '\u21d1',  # ⇑ UPWARDS DOUBLE ARROW
+    'Downarrow':   '\u21d3',  # ⇓ DOWNWARDS DOUBLE ARROW
+    'Updownarrow': '\u21d5',  # ⇕ UP DOWN DOUBLE ARROW
+    'lmoustache':  '\u23b0',  # ⎰ … CURLY BRACKET SECTION
+    'rmoustache':  '\u23b1',  # ⎱ … LEFT CURLY BRACKET SECTION
+    'arrowvert':   '\u23d0',  # ⏐ VERTICAL LINE EXTENSION
+    'bracevert':   '\u23aa',  # ⎪ CURLY BRACKET EXTENSION
+    'lvert':      '|',        # left  |
+    'lVert':      '\u2016',   # left  ‖
+    'rvert':      '|',        # right |
+    'rVert':      '\u2016',   # right ‖
+    'Arrowvert':  '\u2016',   # ‖
+}
+stretchables.update(tex2unichar.mathfence)
+stretchables.update(tex2unichar.mathopen)   # Braces
+stretchables.update(tex2unichar.mathclose)  # Braces
+
+# >>> print(' '.join(sorted(set(stretchables.values()))))
+# [ \ ] { | } ‖ ↑ ↓ ↕ ⇑ ⇓ ⇕ ⌈ ⌉ ⌊ ⌋ ⌜ ⌝ ⌞ ⌟ ⎪ ⎰ ⎱ ⏐ ⟅ ⟆ ⟦ ⟧ ⟨ ⟩ ⟮ ⟯ ⦇ ⦈
+
+operators = {
+    # negated symbols without pre-composed Unicode character
+    'nleqq':      '\u2266\u0338',  # ≦̸
+    'ngeqq':      '\u2267\u0338',  # ≧̸
+    'nleqslant':  '\u2a7d\u0338',  # ⩽̸
+    'ngeqslant':  '\u2a7e\u0338',  # ⩾̸
+    'ngtrless':   '\u2277\u0338',  # txfonts
+    'nlessgtr':   '\u2276\u0338',  # txfonts
+    'nsubseteqq': '\u2AC5\u0338',  # ⫅̸
+    'nsupseteqq': '\u2AC6\u0338',  # ⫆̸
+    # compatibility definitions:
+    'centerdot': '\u2B1D',  # BLACK VERY SMALL SQUARE | mathbin
+    'varnothing': '\u2300',  # ⌀ DIAMETER SIGN | empty set
+    'varpropto': '\u221d',  # ∝ PROPORTIONAL TO | sans serif
+    'triangle': '\u25B3',  # WHITE UP-POINTING TRIANGLE | mathord
+    'triangledown': '\u25BD',  # WHITE DOWN-POINTING TRIANGLE | mathord
+    # alias commands:
+    'dotsb': '\u22ef',  # ⋯ with binary operators/relations
+    'dotsc': '\u2026',  # … with commas
+    'dotsi': '\u22ef',  # ⋯ with integrals
+    'dotsm': '\u22ef',  # ⋯ multiplication dots
+    'dotso': '\u2026',  # … other dots
+    # functions with movable limits (requires <mo>)
+    'lim': 'lim',
+    'sup': 'sup',
+    'inf': 'inf',
+    'max': 'max',
+    'min': 'min',
+}
+operators.update(tex2unichar.mathbin)    # Binary symbols
+operators.update(tex2unichar.mathrel)    # Relation symbols, arrow symbols
+operators.update(tex2unichar.mathpunct)  # Punctuation
+operators.update(tex2unichar.mathop)     # Variable-sized symbols
+operators.update(stretchables)
+
+
+# special cases
+
+thick_operators = {
+    # style='font-weight: bold;'
+    'thicksim':       '\u223C',  # ∼
+    'thickapprox':    '\u2248',  # ≈
+}
+
+small_operators = {
+    # mathsize='75%'
+    'shortmid':       '\u2223',  # ∣
+    'shortparallel':  '\u2225',  # ∥
+    'nshortmid':      '\u2224',  # ∤
+    'nshortparallel': '\u2226',  # ∦
+    'smallfrown':     '\u2322',  # ⌢ FROWN
+    'smallsmile':     '\u2323',  # ⌣ SMILE
+    'smallint':       '\u222b',  # ∫ INTEGRAL
+}
+
+# Operators and functions with limits above/below in display formulas
+# and in index position inline (movablelimits=True)
+movablelimits = ('bigcap', 'bigcup', 'bigodot', 'bigoplus', 'bigotimes',
+                 'bigsqcup', 'biguplus', 'bigvee', 'bigwedge',
+                 'coprod', 'intop', 'ointop', 'prod', 'sum',
+                 'lim', 'max', 'min', 'sup', 'inf')
+# Depending on settings, integrals may also be in this category.
+# (e.g. if "amsmath" is loaded with option "intlimits", see
+#  http://mirror.ctan.org/macros/latex/required/amsmath/amsldoc.pdf)
+# movablelimits.extend(('fint', 'iiiint', 'iiint', 'iint', 'int', 'oiint',
+#                       'oint', 'ointctrclockwise', 'sqint',
+#                       'varointclockwise',))
+
+# horizontal space -> <mspace>
+
+spaces = {'qquad':         '2em',        # two \quad
+          'quad':          '1em',        # 18 mu
+          'thickspace':    '0.2778em',   # 5mu = 5/18em
+          ';':             '0.2778em',   # 5mu thickspace
+          ' ':             '0.25em',     # inter word space
+          '\n':            '0.25em',     # inter word space
+          'medspace':      '0.2222em',   # 4mu = 2/9em
+          ':':             '0.2222em',   # 4mu medspace
+          'thinspace':     '0.1667em',   # 3mu = 1/6em
+          ',':             '0.1667em',   # 3mu thinspace
+          'negthinspace':  '-0.1667em',  # -3mu = -1/6em
+          '!':             '-0.1667em',  # negthinspace
+          'negmedspace':   '-0.2222em',  # -4mu = -2/9em
+          'negthickspace': '-0.2778em',  # -5mu = -5/18em
+          }
+
+# accents: -> <mo stretchy="false"> in <mover>
+accents = {
+    # TeX:      spacing    combining
+    'acute':    '´',     # '\u0301'
+    'bar':      'ˉ',     # '\u0304'
+    'breve':    '˘',     # '\u0306'
+    'check':    'ˇ',     # '\u030C'
+    'dot':      '˙',     # '\u0307'
+    'ddot':     '¨',     # '\u0308'
+    'dddot':    '˙˙˙',   # '\u20DB'  # or … ?
+    'ddddot':   '˙˙˙˙',  # '\u20DC'  # or ¨¨ ?
+    'grave':    '`',     # '\u0300'
+    'hat':      'ˆ',     # '\u0302'
+    'mathring': '˚',     # '\u030A'
+    'tilde':    '~',     # '\u0303'  # tilde ~ or small tilde ˜?
+    'vec':      '→',     # '\u20d7'  # → too heavy, use scriptlevel="+1"
+}
+
+# limits etc. -> <mo> in <mover> or <munder>
+over = {
+    # TeX:                  (char,     offset-correction/em)
+    'overbrace':            ('\u23DE', -0.2),  # DejaVu Math -0.6
+    'overleftarrow':        ('\u2190', -0.2),
+    'overleftrightarrow':   ('\u2194', -0.2),
+    'overline':             ('_',      -0.2),  # \u2012 does not stretch
+    'overrightarrow':       ('\u2192', -0.2),
+    'widehat':              ('^',      -0.5),
+    'widetilde':            ('~',      -0.3),
+}
+under = {'underbrace':          ('\u23DF',  0.1),  # DejaVu Math -0.7
+         'underleftarrow':      ('\u2190', -0.2),
+         'underleftrightarrow': ('\u2194', -0.2),
+         'underline':           ('_',      -0.8),
+         'underrightarrow':     ('\u2192', -0.2),
+         }
+
+# Character translations
+# ----------------------
+# characters with preferred alternative in mathematical use
+# cf. https://www.w3.org/TR/MathML3/chapter7.html#chars.anomalous
+anomalous_chars = {'-': '\u2212',  # HYPHEN-MINUS -> MINUS SIGN
+                   ':': '\u2236',  # COLON -> RATIO
+                   '~': '\u00a0',  # NO-BREAK SPACE
+                   }
+
+# blackboard bold (Greek characters not working with "mathvariant" (Firefox 78)
+mathbb = {'Γ': '\u213E',    # ℾ
+          'Π': '\u213F',    # ℿ
+          'Σ': '\u2140',    # ⅀
+          'γ': '\u213D',    # ℽ
+          'π': '\u213C',    # ℼ
+          }
+
+# Matrix environments
+matrices = {
+    # name:    fences
+    'matrix':  ('', ''),
+    'smallmatrix':  ('', ''),  # smaller, see begin_environment()!
+    'pmatrix': ('(', ')'),
+    'bmatrix': ('[', ']'),
+    'Bmatrix': ('{', '}'),
+    'vmatrix': ('|', '|'),
+    'Vmatrix': ('\u2016', '\u2016'),  # ‖
+    'aligned': ('', ''),
+    'cases':   ('{', ''),
+}
+
+layout_styles = {
+    'displaystyle':      {'displaystyle': True,  'scriptlevel': 0},
+    'textstyle':         {'displaystyle': False, 'scriptlevel': 0},
+    'scriptstyle':       {'displaystyle': False, 'scriptlevel': 1},
+    'scriptscriptstyle': {'displaystyle': False, 'scriptlevel': 2},
+    }
+# See also https://www.w3.org/TR/MathML3/chapter3.html#presm.scriptlevel
+
+fractions = {
+    # name:   attributes
+    'frac':   {},
+    'cfrac':  {'displaystyle': True,  'scriptlevel': 0,
+               'class': 'cfrac'},  # in LaTeX with padding
+    'dfrac':  layout_styles['displaystyle'],
+    'tfrac':  layout_styles['textstyle'],
+    'binom':  {'linethickness': 0},
+    'dbinom': layout_styles['displaystyle'] | {'linethickness': 0},
+    'tbinom': layout_styles['textstyle'] | {'linethickness': 0},
+}
+
+delimiter_sizes = ['', '1.2em', '1.623em', '2.047em', '2.470em']
+bigdelimiters = {'left':  0,
+                 'right': 0,
+                 'bigl':  1,
+                 'bigr':  1,
+                 'Bigl':  2,
+                 'Bigr':  2,
+                 'biggl': 3,
+                 'biggr': 3,
+                 'Biggl': 4,
+                 'Biggr': 4,
+                 }
+
+
+# LaTeX to MathML translation
+# ---------------------------
+
+# auxiliary functions
+# ~~~~~~~~~~~~~~~~~~~
+
+def tex_cmdname(string):
+    """Return leading TeX command name and remainder of `string`.
+
+    >>> tex_cmdname('mymacro2') # up to first non-letter
+    ('mymacro', '2')
+    >>> tex_cmdname('name 2') # strip trailing whitespace
+    ('name', '2')
+    >>> tex_cmdname('_2') # single non-letter character
+    ('_', '2')
+
+    """
+    m = re.match(r'([a-zA-Z]+)[ \n]*(.*)', string, re.DOTALL)
+    if m is None:
+        m = re.match(r'(.?)(.*)', string, re.DOTALL)
+    return m.group(1), m.group(2)
+
+
+# Test:
+#
+# >>> tex_cmdname('name\nnext') # strip trailing whitespace, also newlines
+# ('name', 'next')
+# >>> tex_cmdname('name_2') # first non-letter terminates
+# ('name', '_2')
+# >>> tex_cmdname('name_2\nnext line') # line-break allowed
+# ('name', '_2\nnext line')
+# >>> tex_cmdname(' next') # leading whitespace is returned
+# (' ', 'next')
+# >>> tex_cmdname('1 2') # whitespace after non-letter is kept
+# ('1', ' 2')
+# >>> tex_cmdname('1\n2\t3') # whitespace after non-letter is kept
+# ('1', '\n2\t3')
+# >>> tex_cmdname('') # empty string
+# ('', '')
+
+
+def tex_number(string):
+    """Return leading number literal and remainder of `string`.
+
+    >>> tex_number('123.4')
+    ('123.4', '')
+
+    """
+    m = re.match(r'([0-9.,]*[0-9]+)(.*)', string, re.DOTALL)
+    if m is None:
+        return '', string
+    return m.group(1), m.group(2)
+
+
+# Test:
+#
+# >>> tex_number(' 23.4b') # leading whitespace -> no number
+# ('', ' 23.4b')
+# >>> tex_number('23,400/2') # comma separator included
+# ('23,400', '/2')
+# >>> tex_number('23. 4/2') # trailing separator not included
+# ('23', '. 4/2')
+# >>> tex_number('4, 2') # trailing separator not included
+# ('4', ', 2')
+# >>> tex_number('1 000.4')
+# ('1', ' 000.4')
+
+
+def tex_token(string):
+    """Return first simple TeX token and remainder of `string`.
+
+    >>> tex_token('\\command{without argument}')
+    ('\\command', '{without argument}')
+    >>> tex_token('or first character')
+    ('o', 'r first character')
+
+    """
+    m = re.match(r"""((?P<cmd>\\[a-zA-Z]+)\s* # TeX command, skip whitespace
+                      |(?P<chcmd>\\.)          # one-character TeX command
+                      |(?P<ch>.?))            # first character (or empty)
+                     (?P<remainder>.*$)    # remaining part of string
+                 """, string, re.VERBOSE | re.DOTALL)
+    cmd, chcmd, ch, remainder = m.group('cmd', 'chcmd', 'ch', 'remainder')
+    return cmd or chcmd or ch, remainder
+
+# Test:
+#
+# >>> tex_token('{opening bracket of group}')
+# ('{', 'opening bracket of group}')
+# >>> tex_token('\\skip whitespace after macro name')
+# ('\\skip', 'whitespace after macro name')
+# >>> tex_token('. but not after single char')
+# ('.', ' but not after single char')
+# >>> tex_token('') # empty string.
+# ('', '')
+# >>> tex_token('\{escaped bracket')
+# ('\\{', 'escaped bracket')
+
+
+def tex_group(string):
+    """Return first TeX group or token and remainder of `string`.
+
+    >>> tex_group('{first group} returned without brackets')
+    ('first group', ' returned without brackets')
+
+    """
+    split_index = 0
+    nest_level = 0   # level of {{nested} groups}
+    escape = False   # the next character is escaped (\)
+
+    if not string.startswith('{'):
+        # special case: there is no group, return first token and remainder
+        return string[:1], string[1:]
+    for c in string:
+        split_index += 1
+        if escape:
+            escape = False
+        elif c == '\\':
+            escape = True
+        elif c == '{':
+            nest_level += 1
+        elif c == '}':
+            nest_level -= 1
+        if nest_level == 0:
+            break
+    else:
+        raise MathError('Group without closing bracket!')
+    return string[1:split_index-1], string[split_index:]
+
+
+# >>> tex_group('{} empty group')
+# ('', ' empty group')
+# >>> tex_group('{group with {nested} group} ')
+# ('group with {nested} group', ' ')
+# >>> tex_group('{group with {nested group}} at the end')
+# ('group with {nested group}', ' at the end')
+# >>> tex_group('{{group} {with {{complex }nesting}} constructs}')
+# ('{group} {with {{complex }nesting}} constructs', '')
+# >>> tex_group('{group with \\{escaped\\} brackets}')
+# ('group with \\{escaped\\} brackets', '')
+# >>> tex_group('{group followed by closing bracket}} from outer group')
+# ('group followed by closing bracket', '} from outer group')
+# >>> tex_group('No group? Return first character.')
+# ('N', 'o group? Return first character.')
+# >>> tex_group(' {also whitespace}')
+# (' ', '{also whitespace}')
+
+
+def tex_token_or_group(string):
+    """Return first TeX group or token and remainder of `string`.
+
+    >>> tex_token_or_group('\\command{without argument}')
+    ('\\command', '{without argument}')
+    >>> tex_token_or_group('first character')
+    ('f', 'irst character')
+    >>> tex_token_or_group(' also whitespace')
+    (' ', 'also whitespace')
+    >>> tex_token_or_group('{first group} keep rest')
+    ('first group', ' keep rest')
+
+    """
+    arg, remainder = tex_token(string)
+    if arg == '{':
+        arg, remainder = tex_group(string.lstrip())
+    return arg, remainder
+
+# >>> tex_token_or_group('\{no group but left bracket')
+# ('\\{', 'no group but left bracket')
+
+
+def tex_optarg(string):
+    """Return optional argument and remainder.
+
+    >>> tex_optarg('[optional argument] returned without brackets')
+    ('optional argument', ' returned without brackets')
+    >>> tex_optarg('{empty string, if there is no optional arg}')
+    ('', '{empty string, if there is no optional arg}')
+
+    """
+    m = re.match(r"""\s*                            # leading whitespace
+                 \[(?P<optarg>(\\]|[^\[\]]|\\])*)\] # [group] without nested groups
+                 (?P<remainder>.*$)
+                 """, string, re.VERBOSE | re.DOTALL)
+    if m is None and not string.startswith('['):
+        return '', string
+    try:
+        return m.group('optarg'), m.group('remainder')
+    except AttributeError:
+        raise MathError(f'Could not extract optional argument from "{string}"!')
+
+# Test:
+# >>> tex_optarg(' [optional argument] after whitespace')
+# ('optional argument', ' after whitespace')
+# >>> tex_optarg('[missing right bracket')
+# Traceback (most recent call last):
+#     ...
+# docutils.utils.math.MathError: Could not extract optional argument from "[missing right bracket"!
+# >>> tex_optarg('[group with [nested group]]')
+# Traceback (most recent call last):
+#     ...
+# docutils.utils.math.MathError: Could not extract optional argument from "[group with [nested group]]"!
+
+
+def parse_latex_math(root, source):
+    """Append MathML conversion of `string` to `node` and return it.
+
+    >>> parse_latex_math(math(), r'\alpha')
+    math(mi('α'))
+    >>> parse_latex_math(mrow(), r'x_{n}')
+    mrow(msub(mi('x'), mi('n')))
+
+    """
+    # Normalize white-space:
+    string = source  # not-yet handled part of source
+    node = root  # the current "insertion point"
+
+    # Loop over `string` while changing it.
+    while len(string) > 0:
+        # Take off first character:
+        c, string = string[0], string[1:]
+
+        if c in ' \n':
+            continue  # whitespace is ignored in LaTeX math mode
+        if c == '\\':  # start of a LaTeX macro
+            cmdname, string = tex_cmdname(string)
+            node, string = handle_cmd(cmdname, node, string)
+        elif c in "_^":
+            node = handle_script_or_limit(node, c)
+        elif c == '{':
+            if isinstance(node, MathRow) and node.nchildren == 1:
+                # LaTeX takes one arg, MathML node accepts a group
+                node.nchildren = None  # allow appending until closed by '}'
+            else:  # wrap group in an <mrow>
+                new_node = mrow()
+                node.append(new_node)
+                node = new_node
+        elif c == '}':
+            node = node.close()
+        elif c == '&':
+            new_node = mtd()
+            node.close().append(new_node)
+            node = new_node
+        elif c.isalpha():
+            node = node.append(mi(c))
+        elif c.isdigit():
+            number, string = tex_number(string)
+            node = node.append(mn(c+number))
+        elif c in anomalous_chars:
+            # characters with a special meaning in LaTeX math mode
+            # fix spacing before "unary" minus.
+            attributes = {}
+            if c == '-' and len(node):
+                previous_node = node[-1]
+                if (previous_node.text and previous_node.text in '([='
+                    or previous_node.get('class') == 'mathopen'):
+                    attributes['form'] = 'prefix'
+            node = node.append(mo(anomalous_chars[c], **attributes))
+        elif c in "/()[]|":
+            node = node.append(mo(c, stretchy=False))
+        elif c in "+*=<>,.!?`';@":
+            node = node.append(mo(c))
+        else:
+            raise MathError(f'Unsupported character: "{c}"!')
+            # TODO: append as <mi>?
+        if node is None:
+            if not string:
+                return root  # ignore unbalanced braces
+            raise MathError(f'No insertion point for "{string}". '
+                            f'Unbalanced braces in "{source[:-len(string)]}"?')
+    if node.nchildren and len(node) < node.nchildren:
+        raise MathError('Last node missing children. Source incomplete?')
+    return root
+
+# Test:
+
+# >>> parse_latex_math(math(), '')
+# math()
+# >>> parse_latex_math(math(), ' \\sqrt{ \\alpha}')
+# math(msqrt(mi('α')))
+# >>> parse_latex_math(math(), '23.4x')
+# math(mn('23.4'), mi('x'))
+# >>> parse_latex_math(math(), '\\sqrt 2 \\ne 3')
+# math(msqrt(mn('2')), mo('≠'), mn('3'))
+# >>> parse_latex_math(math(), '\\sqrt{2 + 3} < 10')
+# math(msqrt(mn('2'), mo('+'), mn('3'), nchildren=3), mo('<'), mn('10'))
+# >>> parse_latex_math(math(), '\\sqrt[3]{2 + 3}')
+# math(mroot(mrow(mn('2'), mo('+'), mn('3'), nchildren=3), mn('3')))
+# >>> parse_latex_math(math(), '\max_x') # function takes limits
+# math(munder(mo('max', movablelimits='true'), mi('x')))
+# >>> parse_latex_math(math(), 'x^j_i') # ensure correct order: base, sub, sup
+# math(msubsup(mi('x'), mi('i'), mi('j')))
+# >>> parse_latex_math(math(), '\int^j_i') # ensure correct order
+# math(msubsup(mo('∫'), mi('i'), mi('j')))
+# >>> parse_latex_math(math(), 'x_{\\alpha}')
+# math(msub(mi('x'), mi('α')))
+# >>> parse_latex_math(math(), 'x_\\text{in}')
+# math(msub(mi('x'), mtext('in')))
+# >>> parse_latex_math(math(), '2⌘')
+# Traceback (most recent call last):
+# docutils.utils.math.MathError: Unsupported character: "⌘"!
+# >>> parse_latex_math(math(), '23}x')  # doctest: +ELLIPSIS
+# Traceback (most recent call last):
+# ...
+# docutils.utils.math.MathError: ... Unbalanced braces in "23}"?
+# >>> parse_latex_math(math(), '\\frac{2}')
+# Traceback (most recent call last):
+# ...
+# docutils.utils.math.MathError: Last node missing children. Source incomplete?
+
+
+def handle_cmd(name, node, string):  # noqa: C901 TODO make this less complex
+    """Process LaTeX command `name` followed by `string`.
+
+    Append result to `node`.
+    If needed, parse `string` for command argument.
+    Return new current node and remainder of `string`:
+
+    >>> handle_cmd('hbar', math(), r' \frac')
+    (math(mi('ℏ')), ' \\frac')
+    >>> handle_cmd('hspace', math(), r'{1ex} (x)')
+    (math(mspace(width='1ex')), ' (x)')
+
+    """
+
+    # Token elements
+    # ==============
+
+    # identifier  ->  <mi>
+
+    if name in letters:
+        new_node = mi(letters[name])
+        if name in greek_capitals:
+            # upright in "TeX style" but MathML sets them italic ("ISO style").
+            # CSS styling does not change the font style in Firefox 78.
+            # Use 'mathvariant="normal"'?
+            new_node.set('class', 'capital-greek')
+        node = node.append(new_node)
+        return node, string
+
+    if name in ordinary:
+        # <mi mathvariant="normal"> well supported by Chromium but
+        # Firefox 115.5.0 puts additional space around the symbol, e.g.
+        # <mi mathvariant="normal">∂</mi><mi>t</mi> looks like ∂ t, not ∂t
+        # return node.append(mi(ordinary[name], mathvariant='normal')), string
+        return node.append(mi(ordinary[name])), string
+
+    if name in functions:
+        # use <mi> followed by invisible function applicator character
+        # (see https://www.w3.org/TR/MathML3/chapter3.html#presm.mi)
+        if name == 'operatorname':
+            # custom function name, e.g. ``\operatorname{abs}(x)``
+            # TODO: \operatorname* -> with limits
+            arg, string = tex_token_or_group(string)
+            new_node = mi(arg, mathvariant='normal')
+        else:
+            new_node = mi(functions[name])
+        # embellished function names:
+        if name == 'varliminf':    # \underline\lim
+            new_node = munder(new_node, mo('_'))
+        elif name == 'varlimsup':  # \overline\lim
+            new_node = mover(new_node, mo('¯'), accent=False)
+        elif name == 'varprojlim':  # \underleftarrow\lim
+            new_node = munder(new_node, mo('\u2190'))
+        elif name == 'varinjlim':  # \underrightarrow\lim
+            new_node = munder(new_node, mo('\u2192'))
+
+        node = node.append(new_node)
+        # add ApplyFunction when appropriate (not \sin^2(x), say)
+        # cf. https://www.w3.org/TR/MathML3/chapter3.html#presm.mi
+        if string and string[0] not in ('^', '_'):
+            node = node.append(mo('\u2061'))  # &ApplyFunction;
+        return node, string
+
+    if name in modulo_functions:
+        (binary, named, parentheses, padding) = modulo_functions[name]
+        if binary:
+            node = node.append(mo('mod', lspace=padding, rspace=padding))
+            return node, string
+        # left padding
+        if node.in_block():
+            padding = '1em'
+        node = node.append(mspace(width=padding))
+        if parentheses:
+            node = node.append(mo('(', stretchy=False))
+        if named:
+            node = node.append(mi('mod'))
+            node = node.append(mspace(width='0.333em'))
+        arg, string = tex_token_or_group(string)
+        node = parse_latex_math(node, arg)
+        if parentheses:
+            node = node.append(mo(')', stretchy=False))
+        return node, string
+
+    # font changes or mathematical alphanumeric characters
+
+    if name in ('boldsymbol', 'pmb'):  # \pmb is "poor mans bold"
+        new_node = mrow(CLASS='boldsymbol')
+        node.append(new_node)
+        return new_node, string
+
+    if name in math_alphabets:
+        return handle_math_alphabet(name, node, string)
+
+    # operator, fence, or separator  ->  <mo>
+
+    if name == 'colon':  # trailing punctuation, not binary relation
+        node = node.append(mo(':', form='postfix', lspace='0', rspace='0.28em'))
+        return node, string
+
+    if name == 'idotsint':  # AMS shortcut for ∫︀···∫︀
+        node = parse_latex_math(node, r'\int\dotsi\int')
+        return node, string
+
+    if name in thick_operators:
+        node = node.append(mo(thick_operators[name], style='font-weight: bold'))
+        return node, string
+
+    if name in small_operators:
+        node = node.append(mo(small_operators[name], mathsize='75%'))
+        return node, string
+
+    if name in operators:
+        attributes = {}
+        if name in movablelimits and string and string[0] in ' _^':
+            attributes['movablelimits'] = True
+        elif name in ('lvert', 'lVert'):
+            attributes['class'] = 'mathopen'
+        node = node.append(mo(operators[name], **attributes))
+        return node, string
+
+    if name in bigdelimiters:
+        delimiter_attributes = {}
+        size = delimiter_sizes[bigdelimiters[name]]
+        delimiter, string = tex_token_or_group(string)
+        if delimiter not in '()[]/|.':
+            try:
+                delimiter = stretchables[delimiter.lstrip('\\')]
+            except KeyError:
+                raise MathError(f'Unsupported "\\{name}" delimiter '
+                                f'"{delimiter}"!')
+        if size:
+            delimiter_attributes['maxsize'] = size
+            delimiter_attributes['minsize'] = size
+            delimiter_attributes['symmetric'] = True
+        if name == 'left' or name.endswith('l'):
+            row = mrow()
+            node.append(row)
+            node = row
+        if delimiter != '.':  # '.' stands for "empty delimiter"
+            node.append(mo(delimiter, **delimiter_attributes))
+        if name == 'right' or name.endswith('r'):
+            node = node.close()
+        return node, string
+
+    if name == 'not':
+        # negation: LaTeX just overlays next symbol with "/".
+        arg, string = tex_token(string)
+        if arg == '{':
+            return node, '{\\not ' + string
+        if arg.startswith('\\'):  # LaTeX macro
+            try:
+                arg = operators[arg[1:]]
+            except KeyError:
+                raise MathError(rf'"\not" cannot negate: "{arg}"!')
+        arg = unicodedata.normalize('NFC', arg+'\u0338')
+        node = node.append(mo(arg))
+        return node, string
+
+    # arbitrary text (usually comments)  ->  <mtext>
+    if name in ('text', 'mbox', 'textrm'):
+        arg, string = tex_token_or_group(string)
+        parts = arg.split('$')  # extract inline math
+        for i, part in enumerate(parts):
+            if i % 2 == 0:  # i is even
+                # LaTeX keeps whitespace in, e.g., ``\text{ foo }``,
+                # <mtext> displays only internal whitespace.
+                # → replace marginal whitespace with NBSP
+                part = re.sub('(^[ \n]|[ \n]$)', '\u00a0', part)
+                node = node.append(mtext(part))
+            else:
+                parse_latex_math(node, part)
+        return node, string
+
+    # horizontal space -> <mspace>
+    if name in spaces:
+        node = node.append(mspace(width='%s'%spaces[name]))
+        return node, string
+
+    if name in ('hspace', 'mspace'):
+        arg, string = tex_group(string)
+        if arg.endswith('mu'):
+            # unit "mu" (1mu=1/18em) not supported by MathML
+            arg = '%sem' % (float(arg[:-2])/18)
+        node = node.append(mspace(width='%s'%arg))
+        return node, string
+
+    if name == 'phantom':
+        new_node = mphantom()
+        node.append(new_node)
+        return new_node, string
+
+    if name == 'boxed':
+        # CSS padding is broken in Firefox 115.6.0esr
+        # therefore we still need the deprecated <menclose> element
+        new_node = menclose(notation='box', CLASS='boxed')
+        node.append(new_node)
+        return new_node, string
+
+    # Complex elements (Layout schemata)
+    # ==================================
+
+    if name == 'sqrt':
+        radix, string = tex_optarg(string)
+        if radix:
+            indexnode = mrow()
+            new_node = mroot(indexnode, switch=True)
+            parse_latex_math(indexnode, radix)
+            indexnode.close()
+        else:
+            new_node = msqrt()
+        node.append(new_node)
+        return new_node, string
+
+    if name in fractions:
+        attributes = fractions[name]
+        if name == 'cfrac':
+            optarg, string = tex_optarg(string)
+            optargs = {'l': 'left', 'r': 'right'}
+            if optarg in optargs:
+                attributes = attributes.copy()
+                attributes['numalign'] = optargs[optarg]  # "numalign" is deprecated
+                attributes['class'] += ' numalign-' + optargs[optarg]
+        new_node = frac = mfrac(**attributes)
+        if name.endswith('binom'):
+            new_node = mrow(mo('('), new_node, mo(')'), CLASS='binom')
+            new_node.nchildren = 3
+        node.append(new_node)
+        return frac, string
+
+    if name == '\\':  # end of a row
+        entry = mtd()
+        new_node = mtr(entry)
+        node.close().close().append(new_node)
+        return entry, string
+
+    if name in accents:
+        accent_node = mo(accents[name], stretchy=False)
+        # mi() would be simpler, but semantically wrong
+        # --- https://w3c.github.io/mathml-core/#operator-fence-separator-or-accent-mo
+        if name == 'vec':
+            accent_node.set('scriptlevel', '+1')  # scale down arrow
+        new_node = mover(accent_node, accent=True, switch=True)
+        node.append(new_node)
+        return new_node, string
+
+    if name in over:
+        # set "accent" to False (otherwise dots on i and j are dropped)
+        # but to True on accent node get "textstyle" (full size) symbols on top
+        new_node = mover(mo(over[name][0], accent=True),
+                         switch=True, accent=False)
+        node.append(new_node)
+        return new_node, string
+
+    if name == 'overset':
+        new_node = mover(switch=True)
+        node.append(new_node)
+        return new_node, string
+
+    if name in under:
+        new_node = munder(mo(under[name][0]), switch=True)
+        node.append(new_node)
+        return new_node, string
+
+    if name == 'underset':
+        new_node = munder(switch=True)
+        node.append(new_node)
+        return new_node, string
+
+    if name in ('xleftarrow', 'xrightarrow'):
+        subscript, string = tex_optarg(string)
+        base = mo(operators['long'+name[1:]])
+        if subscript:
+            new_node = munderover(base)
+            sub_node = parse_latex_math(mrow(), subscript)
+            if len(sub_node) == 1:
+                sub_node = sub_node[0]
+            new_node.append(sub_node)
+        else:
+            new_node = mover(base)
+        node.append(new_node)
+        return new_node, string
+
+    if name in layout_styles:  # 'displaystyle', 'textstyle', ...
+        if len(node) > 0:
+            raise MathError(rf'Declaration "\{name}" must be first command '
+                            'in a group!')
+        for k, v in layout_styles[name].items():
+            node.set(k, v)
+        return node, string
+
+    if name.endswith('limits'):
+        arg, remainder = tex_token(string)
+        if arg in '_^':  # else ignore
+            string = remainder
+            node = handle_script_or_limit(node, arg, limits=name)
+        return node, string
+
+    # Environments
+
+    if name == 'begin':
+        return begin_environment(node, string)
+
+    if name == 'end':
+        return end_environment(node, string)
+
+    raise MathError(rf'Unknown LaTeX command "\{name}".')
+
+# >>> handle_cmd('left', math(), '[a\\right]')
+# (mrow(mo('[')), 'a\\right]')
+# >>> handle_cmd('left', math(), '. a)') # empty \left
+# (mrow(), ' a)')
+# >>> handle_cmd('left', math(), '\\uparrow a)') # cmd
+# (mrow(mo('↑')), 'a)')
+# >>> handle_cmd('not', math(), '\\equiv \\alpha)') # cmd
+# (math(mo('≢')), '\\alpha)')
+# >>> handle_cmd('text', math(), '{ for } i>0') # group
+# (math(mtext('\xa0for\xa0')), ' i>0')
+# >>> handle_cmd('text', math(), '{B}T') # group
+# (math(mtext('B')), 'T')
+# >>> handle_cmd('text', math(), '{number of apples}}') # group
+# (math(mtext('number of apples')), '}')
+# >>> handle_cmd('text', math(), 'i \\sin(x)') # single char
+# (math(mtext('i')), ' \\sin(x)')
+# >>> handle_cmd(' ', math(), '  next') # inter word space
+# (math(mspace(width='0.25em')), '  next')
+# >>> handle_cmd('\n', math(), '\nnext') # inter word space
+# (math(mspace(width='0.25em')), '\nnext')
+# >>> handle_cmd('sin', math(), '(\\alpha)')
+# (math(mi('sin'), mo('\u2061')), '(\\alpha)')
+# >>> handle_cmd('sin', math(), ' \\alpha')
+# (math(mi('sin'), mo('\u2061')), ' \\alpha')
+# >>> handle_cmd('operatorname', math(), '{abs}(x)')
+# (math(mi('abs', mathvariant='normal'), mo('\u2061')), '(x)')
+# >>> handle_cmd('overline', math(), '{981}')
+# (mover(mo('_', accent='true'), switch=True, accent='false'), '{981}')
+# >>> handle_cmd('bar', math(), '{x}')
+# (mover(mo('ˉ', stretchy='false'), switch=True, accent='true'), '{x}')
+# >>> handle_cmd('xleftarrow', math(), r'[\alpha]{10}')
+# (munderover(mo('⟵'), mi('α')), '{10}')
+# >>> handle_cmd('xleftarrow', math(), r'[\alpha=5]{10}')
+# (munderover(mo('⟵'), mrow(mi('α'), mo('='), mn('5'))), '{10}')
+# >>> handle_cmd('left', math(), '< a)')
+# Traceback (most recent call last):
+# docutils.utils.math.MathError: Unsupported "\left" delimiter "<"!
+# >>> handle_cmd('not', math(), '{< b} c') #  LaTeX ignores the braces, too.
+# (math(), '{\\not < b} c')
+
+
+def handle_math_alphabet(name, node, string):
+    attributes = {}
+    if name == 'mathscr':
+        attributes['class'] = 'mathscr'
+    arg, string = tex_token_or_group(string)
+    # Shortcut for text arg like \mathrm{out} with more than one letter:
+    if name == 'mathrm' and arg.isalpha() and len(arg) > 1:
+        node = node.append(mi(arg))  # <mi> defaults to "normal" font
+        return node, string
+    # Parse into an <mrow>
+    container = mrow(**attributes)
+    node.append(container)
+    parse_latex_math(container, arg)
+    key = name.replace('mathscr', 'mathcal').replace('mathbfsfit', 'mathsfbfit')
+    a2ch = getattr(mathalphabet2unichar, key, {})
+    for subnode in container.iter():
+        if isinstance(subnode, mn):
+            # a number may consist of more than one digit
+            subnode.text = ''.join(a2ch.get(ch, ch) for ch in subnode.text)
+        elif isinstance(subnode, mi):
+            # don't convert multi-letter identifiers (functions)
+            subnode.text = a2ch.get(subnode.text, subnode.text)
+            if name == 'mathrm' and subnode.text.isalpha():
+                subnode.set('mathvariant', 'normal')
+    return container.close(), string
+
+# >>> handle_math_alphabet('mathrm', math(), '\\alpha')
+# (math(mi('α', mathvariant='normal')), '')
+# >>> handle_math_alphabet('mathbb', math(), '{R} = 3')
+# (math(mi('ℝ')), ' = 3')
+# >>> handle_math_alphabet('mathcal', math(), '{F = 3}')
+# (math(mrow(mi('ℱ'), mo('='), mn('3'), nchildren=3)), '')
+# >>> handle_math_alphabet('mathrm', math(), '{out} = 3')  # drop <mrow>
+# (math(mi('out')), ' = 3')
+#
+# Single letters in \mathrm require "mathvariant='normal'":
+# >>> handle_math_alphabet('mathrm', math(), '{V = 3}')  # doctest: +ELLIPSIS
+# (math(mrow(mi('V', mathvariant='normal'), mo('='), mn('3'), ...)), '')
+
+
+def handle_script_or_limit(node, c, limits=''):
+    """Append script or limit element to `node`."""
+    child = node.pop()
+    if limits == 'limits':
+        child.set('movablelimits', 'false')
+    elif (limits == 'movablelimits'
+          or getattr(child, 'text', '') in movablelimits):
+        child.set('movablelimits', 'true')
+
+    if c == '_':
+        if isinstance(child, mover):
+            new_node = munderover(*child, switch=True)
+        elif isinstance(child, msup):
+            new_node = msubsup(*child, switch=True)
+        elif (limits in ('limits', 'movablelimits')
+              or limits == '' and child.get('movablelimits', None)):
+            new_node = munder(child)
+        else:
+            new_node = msub(child)
+    elif c == '^':
+        if isinstance(child, munder):
+            new_node = munderover(*child)
+        elif isinstance(child, msub):
+            new_node = msubsup(*child)
+        elif (limits in ('limits', 'movablelimits')
+              or limits == '' and child.get('movablelimits', None)):
+            new_node = mover(child)
+        else:
+            new_node = msup(child)
+    node.append(new_node)
+    return new_node
+
+
+def begin_environment(node, string):
+    name, string = tex_group(string)
+    if name in matrices:
+        left_delimiter = matrices[name][0]
+        attributes = {}
+        if left_delimiter:
+            wrapper = mrow(mo(left_delimiter))
+            if name == 'cases':
+                wrapper = mrow(mo(left_delimiter, rspace='0.17em'))
+                attributes['columnalign'] = 'left'
+                attributes['class'] = 'cases'
+            node.append(wrapper)
+            node = wrapper
+        elif name == 'smallmatrix':
+            attributes['rowspacing'] = '0.02em'
+            attributes['columnspacing'] = '0.333em'
+            attributes['scriptlevel'] = '1'
+        elif name == 'aligned':
+            attributes['class'] = 'ams-align'
+        # TODO: array, aligned & alignedat take an optional [t], [b], or [c].
+        entry = mtd()
+        node.append(mtable(mtr(entry), **attributes))
+        node = entry
+    else:
+        raise MathError(f'Environment "{name}" not supported!')
+    return node, string
+
+
+def end_environment(node, string):
+    name, string = tex_group(string)
+    if name in matrices:
+        node = node.close().close().close()  # close: mtd, mdr, mtable
+        right_delimiter = matrices[name][1]
+        if right_delimiter:
+            node = node.append(mo(right_delimiter))
+            node = node.close()
+        elif name == 'cases':
+            node = node.close()
+    else:
+        raise MathError(f'Environment "{name}" not supported!')
+    return node, string
+
+
+# Return the number of "equation_columns" in `code_lines`. cf. "alignat"
+# in http://mirror.ctan.org/macros/latex/required/amsmath/amsldoc.pdf
+def tex_equation_columns(rows):
+    tabs = max(row.count('&') - row.count(r'\&') for row in rows)
+    if tabs == 0:
+        return 0
+    return int(tabs/2 + 1)
+
+# >>> tex_equation_columns(['a = b'])
+# 0
+# >>> tex_equation_columns(['a &= b'])
+# 1
+# >>> tex_equation_columns(['a &= b & a \in S'])
+# 2
+# >>> tex_equation_columns(['a &= b & c &= d'])
+# 2
+
+
+# Return dictionary with attributes to style an <mtable> as align environment:
+# Not used with HTML. Replaced by CSS rule for "mtable.ams-align" in
+# "minimal.css" as "columnalign" is disregarded by Chromium and webkit.
+def align_attributes(rows):
+    atts = {'class': 'ams-align',
+            'displaystyle': True}
+    # get maximal number of non-escaped "next column" markup characters:
+    tabs = max(row.count('&') - row.count(r'\&') for row in rows)
+    if tabs:
+        aligns = ['right', 'left'] * tabs
+        spacing = ['0', '2em'] * tabs
+        atts['columnalign'] = ' '.join(aligns[:tabs+1])
+        atts['columnspacing'] = ' '.join(spacing[:tabs])
+    return atts
+
+# >>> align_attributes(['a = b'])
+# {'class': 'ams-align', 'displaystyle': True}
+# >>> align_attributes(['a &= b'])
+# {'class': 'ams-align', 'displaystyle': True, 'columnalign': 'right left', 'columnspacing': '0'}
+# >>> align_attributes(['a &= b & a \in S'])
+# {'class': 'ams-align', 'displaystyle': True, 'columnalign': 'right left right', 'columnspacing': '0 2em'}
+# >>> align_attributes(['a &= b & c &= d'])
+# {'class': 'ams-align', 'displaystyle': True, 'columnalign': 'right left right left', 'columnspacing': '0 2em 0'}
+# >>> align_attributes([r'a &= b & c &= d \& e'])
+# {'class': 'ams-align', 'displaystyle': True, 'columnalign': 'right left right left', 'columnspacing': '0 2em 0'}
+# >>> align_attributes([r'a &= b & c &= d & e'])
+# {'class': 'ams-align', 'displaystyle': True, 'columnalign': 'right left right left right', 'columnspacing': '0 2em 0 2em'}
+
+
+def tex2mathml(tex_math, as_block=False):
+    """Return string with MathML code corresponding to `tex_math`.
+
+    Set `as_block` to ``True`` for displayed formulas.
+    """
+    # Set up tree
+    math_tree = math(xmlns='http://www.w3.org/1998/Math/MathML')
+    node = math_tree
+    if as_block:
+        math_tree.set('display', 'block')
+        rows = toplevel_code(tex_math).split(r'\\')
+        if len(rows) > 1:
+            # emulate "align*" environment with a math table
+            node = mtd()
+            math_tree.append(mtable(mtr(node), CLASS='ams-align',
+                                    displaystyle=True))
+    parse_latex_math(node, tex_math)
+    math_tree.indent_xml()
+    return math_tree.toxml()
+
+# >>> print(tex2mathml('3'))
+# <math xmlns="http://www.w3.org/1998/Math/MathML">
+#   <mn>3</mn>
+# </math>
+# >>> print(tex2mathml('3', as_block=True))
+# <math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
+#   <mn>3</mn>
+# </math>
+# >>> print(tex2mathml(r'a & b \\ c & d', as_block=True))
+# <math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
+#   <mtable class="ams-align" displaystyle="true">
+#     <mtr>
+#       <mtd>
+#         <mi>a</mi>
+#       </mtd>
+#       <mtd>
+#         <mi>b</mi>
+#       </mtd>
+#     </mtr>
+#     <mtr>
+#       <mtd>
+#         <mi>c</mi>
+#       </mtd>
+#       <mtd>
+#         <mi>d</mi>
+#       </mtd>
+#     </mtr>
+#   </mtable>
+# </math>
+# >>> print(tex2mathml(r'a \\ b', as_block=True))
+# <math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
+#   <mtable class="ams-align" displaystyle="true">
+#     <mtr>
+#       <mtd>
+#         <mi>a</mi>
+#       </mtd>
+#     </mtr>
+#     <mtr>
+#       <mtd>
+#         <mi>b</mi>
+#       </mtd>
+#     </mtr>
+#   </mtable>
+# </math>
+
+
+# TODO: look up more symbols from tr25, e.g.
+#
+#
+# Table 2.8 Using Vertical Line or Solidus Overlay
+#   some of the negated forms of mathematical relations that can only be
+#   encoded by using either U+0338 COMBINING LONG SOLIDUS OVERLAY or U+20D2
+#   COMBINING LONG VERTICAL LINE OVERLAY . (For issues with using 0338 in
+#   MathML, see Section 3.2.7, Combining Marks.
+#
+# Table 2.9 Variants of Mathematical Symbols using VS1?
+#
+# Sequence      Description
+# 0030 + VS1    DIGIT ZERO - short diagonal stroke form
+# 2205 + VS1    EMPTY SET - zero with long diagonal stroke overlay form
+# 2229 + VS1    INTERSECTION - with serifs
+# 222A + VS1    UNION - with serifs
+# 2268 + VS1    LESS-THAN BUT NOT EQUAL TO - with vertical stroke
+# 2269 + VS1    GREATER-THAN BUT NOT EQUAL TO - with vertical stroke
+# 2272 + VS1    LESS-THAN OR EQUIVALENT TO - following the slant of the lower leg
+# 2273 + VS1    GREATER-THAN OR EQUIVALENT TO - following the slant of the lower leg
+# 228A + VS1    SUBSET OF WITH NOT EQUAL TO - variant with stroke through bottom members
+# 228B + VS1    SUPERSET OF WITH NOT EQUAL TO - variant with stroke through bottom members
+# 2293 + VS1    SQUARE CAP - with serifs
+# 2294 + VS1    SQUARE CUP - with serifs
+# 2295 + VS1    CIRCLED PLUS - with white rim
+# 2297 + VS1    CIRCLED TIMES - with white rim
+# 229C + VS1    CIRCLED EQUALS - equal sign inside and touching the circle
+# 22DA + VS1    LESS-THAN slanted EQUAL TO OR GREATER-THAN
+# 22DB + VS1    GREATER-THAN slanted EQUAL TO OR LESS-THAN
+# 2A3C + VS1    INTERIOR PRODUCT - tall variant with narrow foot
+# 2A3D + VS1    RIGHTHAND INTERIOR PRODUCT - tall variant with narrow foot
+# 2A9D + VS1    SIMILAR OR LESS-THAN - following the slant of the upper leg
+# 2A9E + VS1    SIMILAR OR GREATER-THAN - following the slant of the upper leg
+# 2AAC + VS1    SMALLER THAN OR slanted EQUAL
+# 2AAD + VS1    LARGER THAN OR slanted EQUAL
+# 2ACB + VS1    SUBSET OF ABOVE NOT EQUAL TO - variant with stroke through bottom members
+# 2ACC + VS1    SUPERSET OF ABOVE NOT EQUAL TO - variant with stroke through bottom members
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py
new file mode 100755
index 00000000..dc94cff7
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py
@@ -0,0 +1,3165 @@
+#! /usr/bin/env python3
+#   math2html: convert LaTeX equations to HTML output.
+#
+#   Copyright (C) 2009-2011 Alex Fernández, 2021 Günter Milde
+#
+#   Released under the terms of the `2-Clause BSD license'_, in short:
+#   Copying and distribution of this file, with or without modification,
+#   are permitted in any medium without royalty provided the copyright
+#   notice and this notice are preserved.
+#   This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+
+#   Based on eLyXer: convert LyX source files to HTML output.
+#   http://alexfernandez.github.io/elyxer/
+
+# Versions:
+# 1.2.5  2015-02-26  eLyXer standalone formula conversion to HTML.
+# 1.3    2021-06-02  Removed code for conversion of LyX files not
+#                    required for LaTeX math.
+#                    Support for more math commands from the AMS "math-guide".
+# 2.0    2021-12-31  Drop 2.7 compatibility code.
+
+import pathlib
+import sys
+import unicodedata
+
+from docutils.utils.math import tex2unichar
+
+
+__version__ = '1.3 (2021-06-02)'
+
+
+class Trace:
+    "A tracing class"
+
+    debugmode = False
+    quietmode = False
+    showlinesmode = False
+
+    prefix = None
+
+    def debug(cls, message):
+        "Show a debug message"
+        if not Trace.debugmode or Trace.quietmode:
+            return
+        Trace.show(message, sys.stdout)
+
+    def message(cls, message):
+        "Show a trace message"
+        if Trace.quietmode:
+            return
+        if Trace.prefix and Trace.showlinesmode:
+            message = Trace.prefix + message
+        Trace.show(message, sys.stdout)
+
+    def error(cls, message):
+        "Show an error message"
+        message = '* ' + message
+        if Trace.prefix and Trace.showlinesmode:
+            message = Trace.prefix + message
+        Trace.show(message, sys.stderr)
+
+    def show(cls, message, channel):
+        "Show a message out of a channel"
+        channel.write(message + '\n')
+
+    debug = classmethod(debug)
+    message = classmethod(message)
+    error = classmethod(error)
+    show = classmethod(show)
+
+
+class ContainerConfig:
+    "Configuration class from elyxer.config file"
+
+    extracttext = {
+        'allowed': ['FormulaConstant'],
+        'extracted': ['AlphaCommand',
+                      'Bracket',
+                      'BracketCommand',
+                      'CombiningFunction',
+                      'EmptyCommand',
+                      'FontFunction',
+                      'Formula',
+                      'FormulaNumber',
+                      'FormulaSymbol',
+                      'OneParamFunction',
+                      'OversetFunction',
+                      'RawText',
+                      'SpacedCommand',
+                      'SymbolFunction',
+                      'TextFunction',
+                      'UndersetFunction',
+                      ],
+    }
+
+
+class EscapeConfig:
+    "Configuration class from elyxer.config file"
+
+    chars = {
+        '\n': '',
+        "'": '’',
+        '`': '‘',
+    }
+
+    entities = {
+        '&': '&amp;',
+        '<': '&lt;',
+        '>': '&gt;',
+    }
+
+
+class FormulaConfig:
+    "Configuration class from elyxer.config file"
+
+    alphacommands = {
+        '\\AmS': '<span class="textsc">AmS</span>',
+        '\\AA':        'Å',
+        '\\AE':        'Æ',
+        '\\DH':        'Ð',
+        '\\L':         'Ł',
+        '\\O':         'Ø',
+        '\\OE':        'Œ',
+        '\\TH':        'Þ',
+        '\\aa':        'å',
+        '\\ae':        'æ',
+        '\\dh':        'ð',
+        '\\i':         'ı',
+        '\\j':         'ȷ',
+        '\\l':         'ł',
+        '\\o':         'ø',
+        '\\oe':        'œ',
+        '\\ss':        'ß',
+        '\\th':        'þ',
+        '\\hbar':      'ħ',  # cf. \hslash: ℏ in tex2unichar
+    }
+    for key, value in tex2unichar.mathalpha.items():
+        alphacommands['\\'+key] = value
+
+    array = {
+        'begin': r'\begin',
+        'cellseparator': '&',
+        'end': r'\end',
+        'rowseparator': r'\\',
+    }
+
+    bigbrackets = {'(': ['⎛', '⎜', '⎝'],
+                   ')': ['⎞', '⎟', '⎠'],
+                   '[': ['⎡', '⎢', '⎣'],
+                   ']': ['⎤', '⎥', '⎦'],
+                   '{': ['⎧', '⎪', '⎨', '⎩'],
+                   '}': ['⎫', '⎪', '⎬', '⎭'],
+                   # TODO: 2-row brackets with ⎰⎱ (\lmoustache \rmoustache)
+                   '|': ['|'],  # 007C VERTICAL LINE
+                   # '|': ['⎮'],  # 23AE INTEGRAL EXTENSION
+                   # '|': ['⎪'],  # 23AA CURLY BRACKET EXTENSION
+                   '‖': ['‖'],  # 2016 DOUBLE VERTICAL LINE
+                   # '∥': ['∥'],  # 2225 PARALLEL TO
+                   }
+
+    bracketcommands = {
+        '\\left': 'span class="stretchy"',
+        '\\left.': '<span class="leftdot"></span>',
+        '\\middle': 'span class="stretchy"',
+        '\\right': 'span class="stretchy"',
+        '\\right.': '<span class="rightdot"></span>',
+    }
+
+    combiningfunctions = {
+        "\\'":           '\u0301',  # x́
+        '\\"':           '\u0308',  # ẍ
+        '\\^':           '\u0302',  # x̂
+        '\\`':           '\u0300',  # x̀
+        '\\~':           '\u0303',  # x̃
+        '\\c':           '\u0327',  # x̧
+        '\\r':           '\u030a',  # x̊
+        '\\s':           '\u0329',  # x̩
+        '\\textcircled': '\u20dd',  # x⃝
+        '\\textsubring': '\u0325',  # x̥
+        '\\v':           '\u030c',  # x̌
+    }
+    for key, value in tex2unichar.mathaccent.items():
+        combiningfunctions['\\'+key] = value
+
+    commands = {
+        '\\\\': '<br/>',
+        '\\\n': ' ',  # escaped whitespace
+        '\\\t': ' ',  # escaped whitespace
+        '\\centerdot': '\u2B1D',  # BLACK VERY SMALL SQUARE, mathbin
+        '\\colon': ': ',
+        '\\copyright': '©',
+        '\\dotminus': '∸',
+        '\\dots': '…',
+        '\\dotsb': '⋯',
+        '\\dotsc': '…',
+        '\\dotsi': '⋯',
+        '\\dotsm': '⋯',
+        '\\dotso': '…',
+        '\\euro': '€',
+        '\\guillemotleft': '«',
+        '\\guillemotright': '»',
+        '\\lVert': '‖',
+        '\\Arrowvert':  '‖',
+        '\\lvert': '|',
+        '\\newline': '<br/>',
+        '\\nobreakspace': ' ',
+        '\\nolimits': '',
+        '\\nonumber': '',
+        '\\qquad': '  ',
+        '\\rVert': '‖',
+        '\\rvert': '|',
+        '\\textasciicircum': '^',
+        '\\textasciitilde': '~',
+        '\\textbackslash': '\\',
+        '\\textcopyright': '©',
+        '\\textdegree': '°',
+        '\\textellipsis': '…',
+        '\\textemdash': '—',
+        '\\textendash': '—',
+        '\\texteuro': '€',
+        '\\textgreater': '>',
+        '\\textless': '<',
+        '\\textordfeminine': 'ª',
+        '\\textordmasculine': 'º',
+        '\\textquotedblleft': '“',
+        '\\textquotedblright': '”',
+        '\\textquoteright': '’',
+        '\\textregistered': '®',
+        '\\textrightarrow': '→',
+        '\\textsection': '§',
+        '\\texttrademark': '™',
+        '\\texttwosuperior': '²',
+        '\\textvisiblespace': ' ',
+        '\\thickspace': '<span class="thickspace"> </span>',  # 5/13 em
+        '\\;': '<span class="thickspace"> </span>',  # 5/13 em
+        '\\triangle': '\u25B3',  # WHITE UP-POINTING TRIANGLE, mathord
+        '\\triangledown': '\u25BD',  # WHITE DOWN-POINTING TRIANGLE, mathord
+        '\\varnothing': '\u2300',  # ⌀ DIAMETER SIGN
+        # functions
+        '\\Pr': 'Pr',
+        '\\arccos': 'arccos',
+        '\\arcsin': 'arcsin',
+        '\\arctan': 'arctan',
+        '\\arg': 'arg',
+        '\\cos': 'cos',
+        '\\cosh': 'cosh',
+        '\\cot': 'cot',
+        '\\coth': 'coth',
+        '\\csc': 'csc',
+        '\\deg': 'deg',
+        '\\det': 'det',
+        '\\dim': 'dim',
+        '\\exp': 'exp',
+        '\\gcd': 'gcd',
+        '\\hom': 'hom',
+        '\\injlim': 'inj lim',
+        '\\ker': 'ker',
+        '\\lg': 'lg',
+        '\\liminf': 'lim inf',
+        '\\limsup': 'lim sup',
+        '\\ln': 'ln',
+        '\\log': 'log',
+        '\\projlim': 'proj lim',
+        '\\sec': 'sec',
+        '\\sin': 'sin',
+        '\\sinh': 'sinh',
+        '\\tan': 'tan',
+        '\\tanh': 'tanh',
+    }
+    cmddict = {}
+    cmddict.update(tex2unichar.mathbin)  # TODO: spacing around binary operators
+    cmddict.update(tex2unichar.mathopen)
+    cmddict.update(tex2unichar.mathclose)
+    cmddict.update(tex2unichar.mathfence)
+    cmddict.update(tex2unichar.mathord)
+    cmddict.update(tex2unichar.mathpunct)
+    cmddict.update(tex2unichar.space)
+    commands.update(('\\' + key, value) for key, value in cmddict.items())
+
+    oversetfunctions = {
+        # math accents (cf. combiningfunctions)
+        # '\\acute':    '´',
+        '\\bar':      '‒',  # FIGURE DASH
+        # '\\breve':    '˘',
+        # '\\check':    'ˇ',
+        '\\dddot':    '<span class="smallsymbol">⋯</span>',
+        # '\\ddot':     '··', # ¨ too high
+        # '\\dot':      '·',
+        # '\\grave':    '`',
+        # '\\hat':      '^',
+        # '\\mathring': '˚',
+        # '\\tilde':    '~',
+        '\\vec':      '<span class="smallsymbol">→</span>',
+        # embellishments
+        '\\overleftarrow': '⟵',
+        '\\overleftrightarrow': '⟷',
+        '\\overrightarrow': '⟶',
+        '\\widehat': '^',
+        '\\widetilde': '~',
+    }
+
+    undersetfunctions = {
+        '\\underleftarrow': '⟵',
+        '\\underleftrightarrow': '⟷',
+        '\\underrightarrow': '⟶',
+    }
+
+    endings = {
+        'bracket': '}',
+        'complex': '\\]',
+        'endafter': '}',
+        'endbefore': '\\end{',
+        'squarebracket': ']',
+    }
+
+    environments = {
+        'align': ['r', 'l'],
+        'eqnarray': ['r', 'c', 'l'],
+        'gathered': ['l', 'l'],
+        'smallmatrix': ['c', 'c'],
+    }
+
+    fontfunctions = {
+        '\\boldsymbol': 'b', '\\mathbb': 'span class="blackboard"',
+        '\\mathbb{A}': '𝔸', '\\mathbb{B}': '𝔹', '\\mathbb{C}': 'ℂ',
+        '\\mathbb{D}': '𝔻', '\\mathbb{E}': '𝔼', '\\mathbb{F}': '𝔽',
+        '\\mathbb{G}': '𝔾', '\\mathbb{H}': 'ℍ', '\\mathbb{J}': '𝕁',
+        '\\mathbb{K}': '𝕂', '\\mathbb{L}': '𝕃', '\\mathbb{N}': 'ℕ',
+        '\\mathbb{O}': '𝕆', '\\mathbb{P}': 'ℙ', '\\mathbb{Q}': 'ℚ',
+        '\\mathbb{R}': 'ℝ', '\\mathbb{S}': '𝕊', '\\mathbb{T}': '𝕋',
+        '\\mathbb{W}': '𝕎', '\\mathbb{Z}': 'ℤ', '\\mathbf': 'b',
+        '\\mathcal': 'span class="scriptfont"',
+        '\\mathcal{B}': 'ℬ', '\\mathcal{E}': 'ℰ', '\\mathcal{F}':
+        'ℱ', '\\mathcal{H}': 'ℋ', '\\mathcal{I}': 'ℐ',
+        '\\mathcal{L}': 'ℒ', '\\mathcal{M}': 'ℳ', '\\mathcal{R}': 'ℛ',
+        '\\mathfrak': 'span class="fraktur"',
+        '\\mathfrak{C}': 'ℭ', '\\mathfrak{F}': '𝔉', '\\mathfrak{H}': 'ℌ',
+        '\\mathfrak{I}': 'ℑ', '\\mathfrak{R}': 'ℜ', '\\mathfrak{Z}': 'ℨ',
+        '\\mathit': 'i',
+        '\\mathring{A}': 'Å', '\\mathring{U}': 'Ů',
+        '\\mathring{a}': 'å', '\\mathring{u}': 'ů', '\\mathring{w}': 'ẘ',
+        '\\mathring{y}': 'ẙ',
+        '\\mathrm': 'span class="mathrm"',
+        '\\mathscr': 'span class="mathscr"',
+        '\\mathscr{B}': 'ℬ', '\\mathscr{E}': 'ℰ', '\\mathscr{F}': 'ℱ',
+        '\\mathscr{H}': 'ℋ', '\\mathscr{I}': 'ℐ', '\\mathscr{L}': 'ℒ',
+        '\\mathscr{M}': 'ℳ', '\\mathscr{R}': 'ℛ',
+        '\\mathsf': 'span class="mathsf"',
+        '\\mathtt': 'span class="mathtt"',
+        '\\operatorname': 'span class="mathrm"',
+    }
+
+    hybridfunctions = {
+        '\\addcontentsline': ['{$p!}{$q!}{$r!}', 'f0{}', 'ignored'],
+        '\\addtocontents': ['{$p!}{$q!}', 'f0{}', 'ignored'],
+        '\\backmatter': ['', 'f0{}', 'ignored'],
+        '\\binom': ['{$1}{$2}', 'f2{(}f0{f1{$1}f1{$2}}f2{)}', 'span class="binom"', 'span class="binomstack"', 'span class="bigdelimiter size2"'],
+        '\\boxed': ['{$1}', 'f0{$1}', 'span class="boxed"'],
+        '\\cfrac': ['[$p!]{$1}{$2}', 'f0{f3{(}f1{$1}f3{)/(}f2{$2}f3{)}}', 'span class="fullfraction"', 'span class="numerator align-$p"', 'span class="denominator"', 'span class="ignored"'],
+        '\\color': ['{$p!}{$1}', 'f0{$1}', 'span style="color: $p;"'],
+        '\\colorbox': ['{$p!}{$1}', 'f0{$1}', 'span class="colorbox" style="background: $p;"'],
+        '\\dbinom': ['{$1}{$2}', '(f0{f1{f2{$1}}f1{f2{ }}f1{f2{$2}}})', 'span class="binomial"', 'span class="binomrow"', 'span class="binomcell"'],
+        '\\dfrac': ['{$1}{$2}', 'f0{f3{(}f1{$1}f3{)/(}f2{$2}f3{)}}', 'span class="fullfraction"', 'span class="numerator"', 'span class="denominator"', 'span class="ignored"'],
+        '\\displaystyle': ['{$1}', 'f0{$1}', 'span class="displaystyle"'],
+        '\\fancyfoot': ['[$p!]{$q!}', 'f0{}', 'ignored'],
+        '\\fancyhead': ['[$p!]{$q!}', 'f0{}', 'ignored'],
+        '\\fbox': ['{$1}', 'f0{$1}', 'span class="fbox"'],
+        '\\fboxrule': ['{$p!}', 'f0{}', 'ignored'],
+        '\\fboxsep': ['{$p!}', 'f0{}', 'ignored'],
+        '\\fcolorbox': ['{$p!}{$q!}{$1}', 'f0{$1}', 'span class="boxed" style="border-color: $p; background: $q;"'],
+        '\\frac': ['{$1}{$2}', 'f0{f3{(}f1{$1}f3{)/(}f2{$2}f3{)}}', 'span class="fraction"', 'span class="numerator"', 'span class="denominator"', 'span class="ignored"'],
+        '\\framebox': ['[$p!][$q!]{$1}', 'f0{$1}', 'span class="framebox align-$q" style="width: $p;"'],
+        '\\frontmatter': ['', 'f0{}', 'ignored'],
+        '\\href': ['[$o]{$u!}{$t!}', 'f0{$t}', 'a href="$u"'],
+        '\\hspace': ['{$p!}', 'f0{ }', 'span class="hspace" style="width: $p;"'],
+        '\\leftroot': ['{$p!}', 'f0{ }', 'span class="leftroot" style="width: $p;px"'],
+        # TODO: convert 1 mu to 1/18 em
+        # '\\mspace': ['{$p!}', 'f0{ }', 'span class="hspace" style="width: $p;"'],
+        '\\nicefrac': ['{$1}{$2}', 'f0{f1{$1}⁄f2{$2}}', 'span class="fraction"', 'sup class="numerator"', 'sub class="denominator"', 'span class="ignored"'],
+        '\\parbox': ['[$p!]{$w!}{$1}', 'f0{1}', 'div class="Boxed" style="width: $w;"'],
+        '\\raisebox': ['{$p!}{$1}', 'f0{$1.font}', 'span class="raisebox" style="vertical-align: $p;"'],
+        '\\renewenvironment': ['{$1!}{$2!}{$3!}', ''],
+        '\\rule': ['[$v!]{$w!}{$h!}', 'f0/', 'hr class="line" style="width: $w; height: $h;"'],
+        '\\scriptscriptstyle': ['{$1}', 'f0{$1}', 'span class="scriptscriptstyle"'],
+        '\\scriptstyle': ['{$1}', 'f0{$1}', 'span class="scriptstyle"'],
+        # TODO: increase √-size with argument (\frac in display mode, ...)
+        '\\sqrt': ['[$0]{$1}', 'f0{f1{$0}f2{√}f4{(}f3{$1}f4{)}}', 'span class="sqrt"', 'sup class="root"', 'span class="radical"', 'span class="root"', 'span class="ignored"'],
+        '\\stackrel': ['{$1}{$2}', 'f0{f1{$1}f2{$2}}', 'span class="stackrel"', 'span class="upstackrel"', 'span class="downstackrel"'],
+        '\\tbinom': ['{$1}{$2}', '(f0{f1{f2{$1}}f1{f2{ }}f1{f2{$2}}})', 'span class="binomial"', 'span class="binomrow"', 'span class="binomcell"'],
+        '\\tfrac':  ['{$1}{$2}', 'f0{f3{(}f1{$1}f3{)/(}f2{$2}f3{)}}', 'span class="textfraction"', 'span class="numerator"', 'span class="denominator"', 'span class="ignored"'],
+        '\\textcolor': ['{$p!}{$1}', 'f0{$1}', 'span style="color: $p;"'],
+        '\\textstyle': ['{$1}', 'f0{$1}', 'span class="textstyle"'],
+        '\\thispagestyle': ['{$p!}', 'f0{}', 'ignored'],
+        '\\unit': ['[$0]{$1}', '$0f0{$1.font}', 'span class="unit"'],
+        '\\unitfrac': ['[$0]{$1}{$2}', '$0f0{f1{$1.font}⁄f2{$2.font}}', 'span class="fraction"', 'sup class="unit"', 'sub class="unit"'],
+        '\\uproot': ['{$p!}', 'f0{ }', 'span class="uproot" style="width: $p;px"'],
+        '\\url': ['{$u!}', 'f0{$u}', 'a href="$u"'],
+        '\\vspace': ['{$p!}', 'f0{ }', 'span class="vspace" style="height: $p;"'],
+    }
+
+    hybridsizes = {
+        '\\binom': '$1+$2', '\\cfrac': '$1+$2', '\\dbinom': '$1+$2+1',
+        '\\dfrac': '$1+$2', '\\frac': '$1+$2', '\\tbinom': '$1+$2+1',
+    }
+
+    labelfunctions = {
+        '\\label': 'a name="#"',
+    }
+
+    limitcommands = {
+        '\\biginterleave': '⫼',
+        '\\inf': 'inf',
+        '\\lim': 'lim',
+        '\\max': 'max',
+        '\\min': 'min',
+        '\\sup': 'sup',
+        '\\ointop':    '<span class="bigoperator integral">∮</span>',
+        '\\bigcap':    '<span class="bigoperator">⋂</span>',
+        '\\bigcup':    '<span class="bigoperator">⋃</span>',
+        '\\bigodot':   '<span class="bigoperator">⨀</span>',
+        '\\bigoplus':  '<span class="bigoperator">⨁</span>',
+        '\\bigotimes': '<span class="bigoperator">⨂</span>',
+        '\\bigsqcap':  '<span class="bigoperator">⨅</span>',
+        '\\bigsqcup':  '<span class="bigoperator">⨆</span>',
+        '\\biguplus':  '<span class="bigoperator">⨄</span>',
+        '\\bigvee':    '<span class="bigoperator">⋁</span>',
+        '\\bigwedge':  '<span class="bigoperator">⋀</span>',
+        '\\coprod':    '<span class="bigoperator">∐</span>',
+        '\\intop':     '<span class="bigoperator integral">∫</span>',
+        '\\prod':      '<span class="bigoperator">∏</span>',
+        '\\sum':       '<span class="bigoperator">∑</span>',
+        '\\varprod':   '<span class="bigoperator">⨉</span>',
+        '\\zcmp': '⨟', '\\zhide': '⧹', '\\zpipe': '⨠', '\\zproject': '⨡',
+        # integrals have limits in index position with LaTeX default settings
+        # TODO: move to commands?
+        '\\int': '<span class="bigoperator integral">∫</span>',
+        '\\iint': '<span class="bigoperator integral">∬</span>',
+        '\\iiint': '<span class="bigoperator integral">∭</span>',
+        '\\iiiint': '<span class="bigoperator integral">⨌</span>',
+        '\\fint': '<span class="bigoperator integral">⨏</span>',
+        '\\idotsint': '<span class="bigoperator integral">∫⋯∫</span>',
+        '\\oint': '<span class="bigoperator integral">∮</span>',
+        '\\oiint': '<span class="bigoperator integral">∯</span>',
+        '\\oiiint': '<span class="bigoperator integral">∰</span>',
+        '\\ointclockwise': '<span class="bigoperator integral">∲</span>',
+        '\\ointctrclockwise': '<span class="bigoperator integral">∳</span>',
+        '\\smallint': '<span class="smallsymbol integral">∫</span>',
+        '\\sqint': '<span class="bigoperator integral">⨖</span>',
+        '\\varointclockwise': '<span class="bigoperator integral">∲</span>',
+    }
+
+    modified = {
+        '\n': '', ' ': '', '$': '', '&': '	', '\'': '’', '+': '\u2009+\u2009',
+        ',': ',\u2009', '-': '\u2009−\u2009', '/': '\u2009⁄\u2009', ':': ' : ', '<': '\u2009&lt;\u2009',
+        '=': '\u2009=\u2009', '>': '\u2009&gt;\u2009', '@': '', '~': '\u00a0',
+    }
+
+    onefunctions = {
+        '\\big': 'span class="bigdelimiter size1"',
+        '\\bigl': 'span class="bigdelimiter size1"',
+        '\\bigr': 'span class="bigdelimiter size1"',
+        '\\Big': 'span class="bigdelimiter size2"',
+        '\\Bigl': 'span class="bigdelimiter size2"',
+        '\\Bigr': 'span class="bigdelimiter size2"',
+        '\\bigg': 'span class="bigdelimiter size3"',
+        '\\biggl': 'span class="bigdelimiter size3"',
+        '\\biggr': 'span class="bigdelimiter size3"',
+        '\\Bigg': 'span class="bigdelimiter size4"',
+        '\\Biggl': 'span class="bigdelimiter size4"',
+        '\\Biggr': 'span class="bigdelimiter size4"',
+        # '\\bar': 'span class="bar"',
+        '\\begin{array}': 'span class="arraydef"',
+        '\\centering': 'span class="align-center"',
+        '\\ensuremath': 'span class="ensuremath"',
+        '\\hphantom': 'span class="phantom"',
+        '\\noindent': 'span class="noindent"',
+        '\\overbrace': 'span class="overbrace"',
+        '\\overline': 'span class="overline"',
+        '\\phantom': 'span class="phantom"',
+        '\\underbrace': 'span class="underbrace"',
+        '\\underline': '',
+        '\\vphantom': 'span class="phantom"',
+    }
+
+    # relations (put additional space before and after the symbol)
+    spacedcommands = {
+        # negated symbols without pre-composed Unicode character
+        '\\nleqq':      '\u2266\u0338',  # ≦̸
+        '\\ngeqq':      '\u2267\u0338',  # ≧̸
+        '\\nleqslant':  '\u2a7d\u0338',  # ⩽̸
+        '\\ngeqslant':  '\u2a7e\u0338',  # ⩾̸
+        '\\nsubseteqq': '\u2AC5\u0338',  # ⫅̸
+        '\\nsupseteqq': '\u2AC6\u0338',  # ⫆̸
+        '\\nsqsubset':  '\u2276\u228F',  # ⊏̸
+        # modified glyphs
+        '\\shortmid': '<span class="smallsymbol">∣</span>',
+        '\\shortparallel': '<span class="smallsymbol">∥</span>',
+        '\\nshortmid': '<span class="smallsymbol">∤</span>',
+        '\\nshortparallel': '<span class="smallsymbol">∦</span>',
+        '\\smallfrown': '<span class="smallsymbol">⌢</span>',
+        '\\smallsmile': '<span class="smallsymbol">⌣</span>',
+        '\\thickapprox': '<span class="boldsymbol">≈</span>',
+        '\\thicksim': '<span class="boldsymbol">∼</span>',
+        '\\varpropto': '<span class="mathsf">\u221d</span>',  # ∝ PROPORTIONAL TO
+    }
+    for key, value in tex2unichar.mathrel.items():
+        spacedcommands['\\'+key] = value
+    starts = {
+        'beginafter': '}', 'beginbefore': '\\begin{', 'bracket': '{',
+        'command': '\\', 'comment': '%', 'complex': '\\[', 'simple': '$',
+        'squarebracket': '[', 'unnumbered': '*',
+    }
+
+    symbolfunctions = {
+        '^': 'sup', '_': 'sub',
+    }
+
+    textfunctions = {
+        '\\mbox': 'span class="mbox"',
+        '\\text': 'span class="text"',
+        '\\textbf': 'span class="textbf"',
+        '\\textit': 'span class="textit"',
+        '\\textnormal': 'span class="textnormal"',
+        '\\textrm': 'span class="textrm"',
+        '\\textsc': 'span class="textsc"',
+        '\\textsf': 'span class="textsf"',
+        '\\textsl': 'span class="textsl"',
+        '\\texttt': 'span class="texttt"',
+        '\\textup': 'span class="normal"',
+    }
+
+    unmodified = {
+        'characters': ['.', '*', '€', '(', ')', '[', ']',
+                       '·', '!', ';', '|', '§', '"', '?'],
+        }
+
+
+class CommandLineParser:
+    "A parser for runtime options"
+
+    def __init__(self, options):
+        self.options = options
+
+    def parseoptions(self, args):
+        "Parse command line options"
+        if len(args) == 0:
+            return None
+        while len(args) > 0 and args[0].startswith('--'):
+            key, value = self.readoption(args)
+            if not key:
+                return 'Option ' + value + ' not recognized'
+            if not value:
+                return 'Option ' + key + ' needs a value'
+            setattr(self.options, key, value)
+        return None
+
+    def readoption(self, args):
+        "Read the key and value for an option"
+        arg = args[0][2:]
+        del args[0]
+        if '=' in arg:
+            key = self.readequalskey(arg, args)
+        else:
+            key = arg.replace('-', '')
+        if not hasattr(self.options, key):
+            return None, key
+        current = getattr(self.options, key)
+        if isinstance(current, bool):
+            return key, True
+        # read value
+        if len(args) == 0:
+            return key, None
+        if args[0].startswith('"'):
+            initial = args[0]
+            del args[0]
+            return key, self.readquoted(args, initial)
+        value = args[0].decode('utf-8')
+        del args[0]
+        if isinstance(current, list):
+            current.append(value)
+            return key, current
+        return key, value
+
+    def readquoted(self, args, initial):
+        "Read a value between quotes"
+        Trace.error('Oops')
+        value = initial[1:]
+        while len(args) > 0 and not args[0].endswith('"') and not args[0].startswith('--'):
+            Trace.error('Appending ' + args[0])
+            value += ' ' + args[0]
+            del args[0]
+        if len(args) == 0 or args[0].startswith('--'):
+            return None
+        value += ' ' + args[0:-1]
+        return value
+
+    def readequalskey(self, arg, args):
+        "Read a key using equals"
+        split = arg.split('=', 1)
+        key = split[0]
+        value = split[1]
+        args.insert(0, value)
+        return key
+
+
+class Options:
+    "A set of runtime options"
+
+    location = None
+
+    debug = False
+    quiet = False
+    version = False
+    help = False
+    simplemath = False
+    showlines = True
+
+    branches = {}
+
+    def parseoptions(self, args):
+        "Parse command line options"
+        Options.location = args[0]
+        del args[0]
+        parser = CommandLineParser(Options)
+        result = parser.parseoptions(args)
+        if result:
+            Trace.error(result)
+            self.usage()
+        self.processoptions()
+
+    def processoptions(self):
+        "Process all options parsed."
+        if Options.help:
+            self.usage()
+        if Options.version:
+            self.showversion()
+        # set in Trace if necessary
+        for param in dir(Trace):
+            if param.endswith('mode'):
+                setattr(Trace, param, getattr(self, param[:-4]))
+
+    def usage(self):
+        "Show correct usage"
+        Trace.error(f'Usage: {pathlib.Path(Options.location).parent}'
+                    ' [options] "input string"')
+        Trace.error('Convert input string with LaTeX math to MathML')
+        self.showoptions()
+
+    def showoptions(self):
+        "Show all possible options"
+        Trace.error('    --help:                 show this online help')
+        Trace.error('    --quiet:                disables all runtime messages')
+        Trace.error('    --debug:                enable debugging messages (for developers)')
+        Trace.error('    --version:              show version number and release date')
+        Trace.error('    --simplemath:           do not generate fancy math constructions')
+        sys.exit()
+
+    def showversion(self):
+        "Return the current eLyXer version string"
+        Trace.error('math2html '+__version__)
+        sys.exit()
+
+
+class Cloner:
+    "An object used to clone other objects."
+
+    def clone(cls, original):
+        "Return an exact copy of an object."
+        "The original object must have an empty constructor."
+        return cls.create(original.__class__)
+
+    def create(cls, type):
+        "Create an object of a given class."
+        clone = type.__new__(type)
+        clone.__init__()
+        return clone
+
+    clone = classmethod(clone)
+    create = classmethod(create)
+
+
+class ContainerExtractor:
+    """A class to extract certain containers.
+
+    The config parameter is a map containing three lists:
+    allowed, copied and extracted.
+    Each of the three is a list of class names for containers.
+    Allowed containers are included as is into the result.
+    Cloned containers are cloned and placed into the result.
+    Extracted containers are looked into.
+    All other containers are silently ignored.
+    """
+
+    def __init__(self, config):
+        self.allowed = config['allowed']
+        self.extracted = config['extracted']
+
+    def extract(self, container):
+        "Extract a group of selected containers from a container."
+        list = []
+        locate = lambda c: c.__class__.__name__ in self.allowed
+        recursive = lambda c: c.__class__.__name__ in self.extracted
+        process = lambda c: self.process(c, list)
+        container.recursivesearch(locate, recursive, process)
+        return list
+
+    def process(self, container, list):
+        "Add allowed containers."
+        name = container.__class__.__name__
+        if name in self.allowed:
+            list.append(container)
+        else:
+            Trace.error('Unknown container class ' + name)
+
+    def safeclone(self, container):
+        "Return a new container with contents only in a safe list, recursively."
+        clone = Cloner.clone(container)
+        clone.output = container.output
+        clone.contents = self.extract(container)
+        return clone
+
+
+class Parser:
+    "A generic parser"
+
+    def __init__(self):
+        self.begin = 0
+        self.parameters = {}
+
+    def parseheader(self, reader):
+        "Parse the header"
+        header = reader.currentline().split()
+        reader.nextline()
+        self.begin = reader.linenumber
+        return header
+
+    def parseparameter(self, reader):
+        "Parse a parameter"
+        split = reader.currentline().strip().split(' ', 1)
+        reader.nextline()
+        if len(split) == 0:
+            return
+        key = split[0]
+        if len(split) == 1:
+            self.parameters[key] = True
+            return
+        if '"' not in split[1]:
+            self.parameters[key] = split[1].strip()
+            return
+        doublesplit = split[1].split('"')
+        self.parameters[key] = doublesplit[1]
+
+    def parseending(self, reader, process):
+        "Parse until the current ending is found"
+        if not self.ending:
+            Trace.error('No ending for ' + str(self))
+            return
+        while not reader.currentline().startswith(self.ending):
+            process()
+
+    def parsecontainer(self, reader, contents):
+        container = self.factory.createcontainer(reader)
+        if container:
+            container.parent = self.parent
+            contents.append(container)
+
+    def __str__(self):
+        "Return a description"
+        return self.__class__.__name__ + ' (' + str(self.begin) + ')'
+
+
+class LoneCommand(Parser):
+    "A parser for just one command line"
+
+    def parse(self, reader):
+        "Read nothing"
+        return []
+
+
+class TextParser(Parser):
+    "A parser for a command and a bit of text"
+
+    stack = []
+
+    def __init__(self, container):
+        Parser.__init__(self)
+        self.ending = None
+        if container.__class__.__name__ in ContainerConfig.endings:
+            self.ending = ContainerConfig.endings[container.__class__.__name__]
+        self.endings = []
+
+    def parse(self, reader):
+        "Parse lines as long as they are text"
+        TextParser.stack.append(self.ending)
+        self.endings = TextParser.stack + [ContainerConfig.endings['Layout'],
+                                           ContainerConfig.endings['Inset'],
+                                           self.ending]
+        contents = []
+        while not self.isending(reader):
+            self.parsecontainer(reader, contents)
+        return contents
+
+    def isending(self, reader):
+        "Check if text is ending"
+        current = reader.currentline().split()
+        if len(current) == 0:
+            return False
+        if current[0] in self.endings:
+            if current[0] in TextParser.stack:
+                TextParser.stack.remove(current[0])
+            else:
+                TextParser.stack = []
+            return True
+        return False
+
+
+class ExcludingParser(Parser):
+    "A parser that excludes the final line"
+
+    def parse(self, reader):
+        "Parse everything up to (and excluding) the final line"
+        contents = []
+        self.parseending(reader, lambda: self.parsecontainer(reader, contents))
+        return contents
+
+
+class BoundedParser(ExcludingParser):
+    "A parser bound by a final line"
+
+    def parse(self, reader):
+        "Parse everything, including the final line"
+        contents = ExcludingParser.parse(self, reader)
+        # skip last line
+        reader.nextline()
+        return contents
+
+
+class BoundedDummy(Parser):
+    "A bound parser that ignores everything"
+
+    def parse(self, reader):
+        "Parse the contents of the container"
+        self.parseending(reader, lambda: reader.nextline())
+        # skip last line
+        reader.nextline()
+        return []
+
+
+class StringParser(Parser):
+    "Parses just a string"
+
+    def parseheader(self, reader):
+        "Do nothing, just take note"
+        self.begin = reader.linenumber + 1
+        return []
+
+    def parse(self, reader):
+        "Parse a single line"
+        contents = reader.currentline()
+        reader.nextline()
+        return contents
+
+
+class ContainerOutput:
+    "The generic HTML output for a container."
+
+    def gethtml(self, container):
+        "Show an error."
+        Trace.error('gethtml() not implemented for ' + str(self))
+
+    def isempty(self):
+        "Decide if the output is empty: by default, not empty."
+        return False
+
+
+class EmptyOutput(ContainerOutput):
+
+    def gethtml(self, container):
+        "Return empty HTML code."
+        return []
+
+    def isempty(self):
+        "This output is particularly empty."
+        return True
+
+
+class FixedOutput(ContainerOutput):
+    "Fixed output"
+
+    def gethtml(self, container):
+        "Return constant HTML code"
+        return container.html
+
+
+class ContentsOutput(ContainerOutput):
+    "Outputs the contents converted to HTML"
+
+    def gethtml(self, container):
+        "Return the HTML code"
+        html = []
+        if container.contents is None:
+            return html
+        for element in container.contents:
+            if not hasattr(element, 'gethtml'):
+                Trace.error('No html in ' + element.__class__.__name__ + ': ' + str(element))
+                return html
+            html += element.gethtml()
+        return html
+
+
+class TaggedOutput(ContentsOutput):
+    "Outputs an HTML tag surrounding the contents."
+
+    tag = None
+    breaklines = False
+    empty = False
+
+    def settag(self, tag, breaklines=False, empty=False):
+        "Set the value for the tag and other attributes."
+        self.tag = tag
+        if breaklines:
+            self.breaklines = breaklines
+        if empty:
+            self.empty = empty
+        return self
+
+    def setbreaklines(self, breaklines):
+        "Set the value for breaklines."
+        self.breaklines = breaklines
+        return self
+
+    def gethtml(self, container):
+        "Return the HTML code."
+        if self.empty:
+            return [self.selfclosing(container)]
+        html = [self.open(container)]
+        html += ContentsOutput.gethtml(self, container)
+        html.append(self.close(container))
+        return html
+
+    def open(self, container):
+        "Get opening line."
+        if not self.checktag(container):
+            return ''
+        open = '<' + self.tag + '>'
+        if self.breaklines:
+            return open + '\n'
+        return open
+
+    def close(self, container):
+        "Get closing line."
+        if not self.checktag(container):
+            return ''
+        close = '</' + self.tag.split()[0] + '>'
+        if self.breaklines:
+            return '\n' + close + '\n'
+        return close
+
+    def selfclosing(self, container):
+        "Get self-closing line."
+        if not self.checktag(container):
+            return ''
+        selfclosing = '<' + self.tag + '/>'
+        if self.breaklines:
+            return selfclosing + '\n'
+        return selfclosing
+
+    def checktag(self, container):
+        "Check that the tag is valid."
+        if not self.tag:
+            Trace.error('No tag in ' + str(container))
+            return False
+        if self.tag == '':
+            return False
+        return True
+
+
+class FilteredOutput(ContentsOutput):
+    "Returns the output in the contents, but filtered:"
+    "some strings are replaced by others."
+
+    def __init__(self):
+        "Initialize the filters."
+        self.filters = []
+
+    def addfilter(self, original, replacement):
+        "Add a new filter: replace the original by the replacement."
+        self.filters.append((original, replacement))
+
+    def gethtml(self, container):
+        "Return the HTML code"
+        result = []
+        html = ContentsOutput.gethtml(self, container)
+        for line in html:
+            result.append(self.filter(line))
+        return result
+
+    def filter(self, line):
+        "Filter a single line with all available filters."
+        for original, replacement in self.filters:
+            if original in line:
+                line = line.replace(original, replacement)
+        return line
+
+
+class StringOutput(ContainerOutput):
+    "Returns a bare string as output"
+
+    def gethtml(self, container):
+        "Return a bare string"
+        return [container.string]
+
+
+class Globable:
+    """A bit of text which can be globbed (lumped together in bits).
+    Methods current(), skipcurrent(), checkfor() and isout() have to be
+    implemented by subclasses."""
+
+    leavepending = False
+
+    def __init__(self):
+        self.endinglist = EndingList()
+
+    def checkbytemark(self):
+        "Check for a Unicode byte mark and skip it."
+        if self.finished():
+            return
+        if ord(self.current()) == 0xfeff:
+            self.skipcurrent()
+
+    def isout(self):
+        "Find out if we are out of the position yet."
+        Trace.error('Unimplemented isout()')
+        return True
+
+    def current(self):
+        "Return the current character."
+        Trace.error('Unimplemented current()')
+        return ''
+
+    def checkfor(self, string):
+        "Check for the given string in the current position."
+        Trace.error('Unimplemented checkfor()')
+        return False
+
+    def finished(self):
+        "Find out if the current text has finished."
+        if self.isout():
+            if not self.leavepending:
+                self.endinglist.checkpending()
+            return True
+        return self.endinglist.checkin(self)
+
+    def skipcurrent(self):
+        "Return the current character and skip it."
+        Trace.error('Unimplemented skipcurrent()')
+        return ''
+
+    def glob(self, currentcheck):
+        "Glob a bit of text that satisfies a check on the current char."
+        glob = ''
+        while not self.finished() and currentcheck():
+            glob += self.skipcurrent()
+        return glob
+
+    def globalpha(self):
+        "Glob a bit of alpha text"
+        return self.glob(lambda: self.current().isalpha())
+
+    def globnumber(self):
+        "Glob a row of digits."
+        return self.glob(lambda: self.current().isdigit())
+
+    def isidentifier(self):
+        "Return if the current character is alphanumeric or _."
+        if self.current().isalnum() or self.current() == '_':
+            return True
+        return False
+
+    def globidentifier(self):
+        "Glob alphanumeric and _ symbols."
+        return self.glob(self.isidentifier)
+
+    def isvalue(self):
+        "Return if the current character is a value character:"
+        "not a bracket or a space."
+        if self.current().isspace():
+            return False
+        if self.current() in '{}()':
+            return False
+        return True
+
+    def globvalue(self):
+        "Glob a value: any symbols but brackets."
+        return self.glob(self.isvalue)
+
+    def skipspace(self):
+        "Skip all whitespace at current position."
+        return self.glob(lambda: self.current().isspace())
+
+    def globincluding(self, magicchar):
+        "Glob a bit of text up to (including) the magic char."
+        glob = self.glob(lambda: self.current() != magicchar) + magicchar
+        self.skip(magicchar)
+        return glob
+
+    def globexcluding(self, excluded):
+        "Glob a bit of text up until (excluding) any excluded character."
+        return self.glob(lambda: self.current() not in excluded)
+
+    def pushending(self, ending, optional=False):
+        "Push a new ending to the bottom"
+        self.endinglist.add(ending, optional)
+
+    def popending(self, expected=None):
+        "Pop the ending found at the current position"
+        if self.isout() and self.leavepending:
+            return expected
+        ending = self.endinglist.pop(self)
+        if expected and expected != ending:
+            Trace.error('Expected ending ' + expected + ', got ' + ending)
+        self.skip(ending)
+        return ending
+
+    def nextending(self):
+        "Return the next ending in the queue."
+        nextending = self.endinglist.findending(self)
+        if not nextending:
+            return None
+        return nextending.ending
+
+
+class EndingList:
+    "A list of position endings"
+
+    def __init__(self):
+        self.endings = []
+
+    def add(self, ending, optional=False):
+        "Add a new ending to the list"
+        self.endings.append(PositionEnding(ending, optional))
+
+    def pickpending(self, pos):
+        "Pick any pending endings from a parse position."
+        self.endings += pos.endinglist.endings
+
+    def checkin(self, pos):
+        "Search for an ending"
+        if self.findending(pos):
+            return True
+        return False
+
+    def pop(self, pos):
+        "Remove the ending at the current position"
+        if pos.isout():
+            Trace.error('No ending out of bounds')
+            return ''
+        ending = self.findending(pos)
+        if not ending:
+            Trace.error('No ending at ' + pos.current())
+            return ''
+        for each in reversed(self.endings):
+            self.endings.remove(each)
+            if each == ending:
+                return each.ending
+            elif not each.optional:
+                Trace.error('Removed non-optional ending ' + each)
+        Trace.error('No endings left')
+        return ''
+
+    def findending(self, pos):
+        "Find the ending at the current position"
+        if len(self.endings) == 0:
+            return None
+        for index, ending in enumerate(reversed(self.endings)):
+            if ending.checkin(pos):
+                return ending
+            if not ending.optional:
+                return None
+        return None
+
+    def checkpending(self):
+        "Check if there are any pending endings"
+        if len(self.endings) != 0:
+            Trace.error('Pending ' + str(self) + ' left open')
+
+    def __str__(self):
+        "Printable representation"
+        string = 'endings ['
+        for ending in self.endings:
+            string += str(ending) + ','
+        if len(self.endings) > 0:
+            string = string[:-1]
+        return string + ']'
+
+
+class PositionEnding:
+    "An ending for a parsing position"
+
+    def __init__(self, ending, optional):
+        self.ending = ending
+        self.optional = optional
+
+    def checkin(self, pos):
+        "Check for the ending"
+        return pos.checkfor(self.ending)
+
+    def __str__(self):
+        "Printable representation"
+        string = 'Ending ' + self.ending
+        if self.optional:
+            string += ' (optional)'
+        return string
+
+
+class Position(Globable):
+    """A position in a text to parse.
+    Including those in Globable, functions to implement by subclasses are:
+    skip(), identifier(), extract(), isout() and current()."""
+
+    def __init__(self):
+        Globable.__init__(self)
+
+    def skip(self, string):
+        "Skip a string"
+        Trace.error('Unimplemented skip()')
+
+    def identifier(self):
+        "Return an identifier for the current position."
+        Trace.error('Unimplemented identifier()')
+        return 'Error'
+
+    def extract(self, length):
+        "Extract the next string of the given length, or None if not enough text,"
+        "without advancing the parse position."
+        Trace.error('Unimplemented extract()')
+        return None
+
+    def checkfor(self, string):
+        "Check for a string at the given position."
+        return string == self.extract(len(string))
+
+    def checkforlower(self, string):
+        "Check for a string in lower case."
+        extracted = self.extract(len(string))
+        if not extracted:
+            return False
+        return string.lower() == self.extract(len(string)).lower()
+
+    def skipcurrent(self):
+        "Return the current character and skip it."
+        current = self.current()
+        self.skip(current)
+        return current
+
+    def __next__(self):
+        "Advance the position and return the next character."
+        self.skipcurrent()
+        return self.current()
+
+    def checkskip(self, string):
+        "Check for a string at the given position; if there, skip it"
+        if not self.checkfor(string):
+            return False
+        self.skip(string)
+        return True
+
+    def error(self, message):
+        "Show an error message and the position identifier."
+        Trace.error(message + ': ' + self.identifier())
+
+
+class TextPosition(Position):
+    "A parse position based on a raw text."
+
+    def __init__(self, text):
+        "Create the position from some text."
+        Position.__init__(self)
+        self.pos = 0
+        self.text = text
+        self.checkbytemark()
+
+    def skip(self, string):
+        "Skip a string of characters."
+        self.pos += len(string)
+
+    def identifier(self):
+        "Return a sample of the remaining text."
+        length = 30
+        if self.pos + length > len(self.text):
+            length = len(self.text) - self.pos
+        return '*' + self.text[self.pos:self.pos + length] + '*'
+
+    def isout(self):
+        "Find out if we are out of the text yet."
+        return self.pos >= len(self.text)
+
+    def current(self):
+        "Return the current character, assuming we are not out."
+        return self.text[self.pos]
+
+    def extract(self, length):
+        "Extract the next string of the given length, or None if not enough text."
+        if self.pos + length > len(self.text):
+            return None
+        return self.text[self.pos : self.pos + length]                 # noqa: E203
+
+
+class Container:
+    "A container for text and objects in a lyx file"
+
+    partkey = None
+    parent = None
+    begin = None
+
+    def __init__(self):
+        self.contents = list()
+
+    def process(self):
+        "Process contents"
+        pass
+
+    def gethtml(self):
+        "Get the resulting HTML"
+        html = self.output.gethtml(self)
+        if isinstance(html, str):
+            Trace.error('Raw string ' + html)
+            html = [html]
+        return html
+
+    def escape(self, line, replacements=EscapeConfig.entities):
+        "Escape a line with replacements from a map"
+        pieces = sorted(replacements.keys())
+        # do them in order
+        for piece in pieces:
+            if piece in line:
+                line = line.replace(piece, replacements[piece])
+        return line
+
+    def escapeentities(self, line):
+        "Escape all Unicode characters to HTML entities."
+        result = ''
+        pos = TextPosition(line)
+        while not pos.finished():
+            if ord(pos.current()) > 128:
+                codepoint = hex(ord(pos.current()))
+                if codepoint == '0xd835':
+                    codepoint = hex(ord(next(pos)) + 0xf800)
+                result += '&#' + codepoint[1:] + ';'
+            else:
+                result += pos.current()
+            pos.skipcurrent()
+        return result
+
+    def searchall(self, type):
+        "Search for all embedded containers of a given type"
+        list = []
+        self.searchprocess(type, lambda container: list.append(container))
+        return list
+
+    def searchremove(self, type):
+        "Search for all containers of a type and remove them"
+        list = self.searchall(type)
+        for container in list:
+            container.parent.contents.remove(container)
+        return list
+
+    def searchprocess(self, type, process):
+        "Search for elements of a given type and process them"
+        self.locateprocess(lambda container: isinstance(container, type), process)
+
+    def locateprocess(self, locate, process):
+        "Search for all embedded containers and process them"
+        for container in self.contents:
+            container.locateprocess(locate, process)
+            if locate(container):
+                process(container)
+
+    def recursivesearch(self, locate, recursive, process):
+        "Perform a recursive search in the container."
+        for container in self.contents:
+            if recursive(container):
+                container.recursivesearch(locate, recursive, process)
+            if locate(container):
+                process(container)
+
+    def extracttext(self):
+        "Extract all text from allowed containers."
+        constants = ContainerExtractor(ContainerConfig.extracttext).extract(self)
+        return ''.join(constant.string for constant in constants)
+
+    def group(self, index, group, isingroup):
+        "Group some adjoining elements into a group"
+        if index >= len(self.contents):
+            return
+        if hasattr(self.contents[index], 'grouped'):
+            return
+        while index < len(self.contents) and isingroup(self.contents[index]):
+            self.contents[index].grouped = True
+            group.contents.append(self.contents[index])
+            self.contents.pop(index)
+        self.contents.insert(index, group)
+
+    def remove(self, index):
+        "Remove a container but leave its contents"
+        container = self.contents[index]
+        self.contents.pop(index)
+        while len(container.contents) > 0:
+            self.contents.insert(index, container.contents.pop())
+
+    def tree(self, level=0):
+        "Show in a tree"
+        Trace.debug("  " * level + str(self))
+        for container in self.contents:
+            container.tree(level + 1)
+
+    def getparameter(self, name):
+        "Get the value of a parameter, if present."
+        if name not in self.parameters:
+            return None
+        return self.parameters[name]
+
+    def getparameterlist(self, name):
+        "Get the value of a comma-separated parameter as a list."
+        paramtext = self.getparameter(name)
+        if not paramtext:
+            return []
+        return paramtext.split(',')
+
+    def hasemptyoutput(self):
+        "Check if the parent's output is empty."
+        current = self.parent
+        while current:
+            if current.output.isempty():
+                return True
+            current = current.parent
+        return False
+
+    def __str__(self):
+        "Get a description"
+        if not self.begin:
+            return self.__class__.__name__
+        return self.__class__.__name__ + '@' + str(self.begin)
+
+
+class BlackBox(Container):
+    "A container that does not output anything"
+
+    def __init__(self):
+        self.parser = LoneCommand()
+        self.output = EmptyOutput()
+        self.contents = []
+
+
+class StringContainer(Container):
+    "A container for a single string"
+
+    parsed = None
+
+    def __init__(self):
+        self.parser = StringParser()
+        self.output = StringOutput()
+        self.string = ''
+
+    def process(self):
+        "Replace special chars from the contents."
+        if self.parsed:
+            self.string = self.replacespecial(self.parsed)
+            self.parsed = None
+
+    def replacespecial(self, line):
+        "Replace all special chars from a line"
+        replaced = self.escape(line, EscapeConfig.entities)
+        replaced = self.changeline(replaced)
+        if ContainerConfig.string['startcommand'] in replaced and len(replaced) > 1:
+            # unprocessed commands
+            if self.begin:
+                message = 'Unknown command at ' + str(self.begin) + ': '
+            else:
+                message = 'Unknown command: '
+            Trace.error(message + replaced.strip())
+        return replaced
+
+    def changeline(self, line):
+        return self.escape(line, EscapeConfig.chars)
+
+    def extracttext(self):
+        "Return all text."
+        return self.string
+
+    def __str__(self):
+        "Return a printable representation."
+        result = 'StringContainer'
+        if self.begin:
+            result += '@' + str(self.begin)
+        ellipsis = '...'
+        if len(self.string.strip()) <= 15:
+            ellipsis = ''
+        return result + ' (' + self.string.strip()[:15] + ellipsis + ')'
+
+
+class Constant(StringContainer):
+    "A constant string"
+
+    def __init__(self, text):
+        self.contents = []
+        self.string = text
+        self.output = StringOutput()
+
+    def __str__(self):
+        return 'Constant: ' + self.string
+
+
+class DocumentParameters:
+    "Global parameters for the document."
+
+    displaymode = False
+
+
+class FormulaParser(Parser):
+    "Parses a formula"
+
+    def parseheader(self, reader):
+        "See if the formula is inlined"
+        self.begin = reader.linenumber + 1
+        type = self.parsetype(reader)
+        if not type:
+            reader.nextline()
+            type = self.parsetype(reader)
+            if not type:
+                Trace.error('Unknown formula type in ' + reader.currentline().strip())
+                return ['unknown']
+        return [type]
+
+    def parsetype(self, reader):
+        "Get the formula type from the first line."
+        if reader.currentline().find(FormulaConfig.starts['simple']) >= 0:
+            return 'inline'
+        if reader.currentline().find(FormulaConfig.starts['complex']) >= 0:
+            return 'block'
+        if reader.currentline().find(FormulaConfig.starts['unnumbered']) >= 0:
+            return 'block'
+        if reader.currentline().find(FormulaConfig.starts['beginbefore']) >= 0:
+            return 'numbered'
+        return None
+
+    def parse(self, reader):
+        "Parse the formula until the end"
+        formula = self.parseformula(reader)
+        while not reader.currentline().startswith(self.ending):
+            stripped = reader.currentline().strip()
+            if len(stripped) > 0:
+                Trace.error('Unparsed formula line ' + stripped)
+            reader.nextline()
+        reader.nextline()
+        return formula
+
+    def parseformula(self, reader):
+        "Parse the formula contents"
+        simple = FormulaConfig.starts['simple']
+        if simple in reader.currentline():
+            rest = reader.currentline().split(simple, 1)[1]
+            if simple in rest:
+                # formula is $...$
+                return self.parsesingleliner(reader, simple, simple)
+            # formula is multiline $...$
+            return self.parsemultiliner(reader, simple, simple)
+        if FormulaConfig.starts['complex'] in reader.currentline():
+            # formula of the form \[...\]
+            return self.parsemultiliner(reader, FormulaConfig.starts['complex'],
+                                        FormulaConfig.endings['complex'])
+        beginbefore = FormulaConfig.starts['beginbefore']
+        beginafter = FormulaConfig.starts['beginafter']
+        if beginbefore in reader.currentline():
+            if reader.currentline().strip().endswith(beginafter):
+                current = reader.currentline().strip()
+                endsplit = current.split(beginbefore)[1].split(beginafter)
+                startpiece = beginbefore + endsplit[0] + beginafter
+                endbefore = FormulaConfig.endings['endbefore']
+                endafter = FormulaConfig.endings['endafter']
+                endpiece = endbefore + endsplit[0] + endafter
+                return startpiece + self.parsemultiliner(reader, startpiece, endpiece) + endpiece
+            Trace.error('Missing ' + beginafter + ' in ' + reader.currentline())
+            return ''
+        begincommand = FormulaConfig.starts['command']
+        beginbracket = FormulaConfig.starts['bracket']
+        if begincommand in reader.currentline() and beginbracket in reader.currentline():
+            endbracket = FormulaConfig.endings['bracket']
+            return self.parsemultiliner(reader, beginbracket, endbracket)
+        Trace.error('Formula beginning ' + reader.currentline() + ' is unknown')
+        return ''
+
+    def parsesingleliner(self, reader, start, ending):
+        "Parse a formula in one line"
+        line = reader.currentline().strip()
+        if start not in line:
+            Trace.error('Line ' + line + ' does not contain formula start ' + start)
+            return ''
+        if not line.endswith(ending):
+            Trace.error('Formula ' + line + ' does not end with ' + ending)
+            return ''
+        index = line.index(start)
+        rest = line[index + len(start):-len(ending)]
+        reader.nextline()
+        return rest
+
+    def parsemultiliner(self, reader, start, ending):
+        "Parse a formula in multiple lines"
+        formula = ''
+        line = reader.currentline()
+        if start not in line:
+            Trace.error('Line ' + line.strip() + ' does not contain formula start ' + start)
+            return ''
+        index = line.index(start)
+        line = line[index + len(start):].strip()
+        while not line.endswith(ending):
+            formula += line + '\n'
+            reader.nextline()
+            line = reader.currentline()
+        formula += line[:-len(ending)]
+        reader.nextline()
+        return formula
+
+
+class FormulaBit(Container):
+    "A bit of a formula"
+
+    type = None
+    size = 1
+    original = ''
+
+    def __init__(self):
+        "The formula bit type can be 'alpha', 'number', 'font'."
+        self.contents = []
+        self.output = ContentsOutput()
+
+    def setfactory(self, factory):
+        "Set the internal formula factory."
+        self.factory = factory
+        return self
+
+    def add(self, bit):
+        "Add any kind of formula bit already processed"
+        self.contents.append(bit)
+        self.original += bit.original
+        bit.parent = self
+
+    def skiporiginal(self, string, pos):
+        "Skip a string and add it to the original formula"
+        self.original += string
+        if not pos.checkskip(string):
+            Trace.error('String ' + string + ' not at ' + pos.identifier())
+
+    def computesize(self):
+        "Compute the size of the bit as the max of the sizes of all contents."
+        if len(self.contents) == 0:
+            return 1
+        self.size = max(element.size for element in self.contents)
+        return self.size
+
+    def clone(self):
+        "Return a copy of itself."
+        return self.factory.parseformula(self.original)
+
+    def __str__(self):
+        "Get a string representation"
+        return self.__class__.__name__ + ' read in ' + self.original
+
+
+class TaggedBit(FormulaBit):
+    "A tagged string in a formula"
+
+    def constant(self, constant, tag):
+        "Set the constant and the tag"
+        self.output = TaggedOutput().settag(tag)
+        self.add(FormulaConstant(constant))
+        return self
+
+    def complete(self, contents, tag, breaklines=False):
+        "Set the constant and the tag"
+        self.contents = contents
+        self.output = TaggedOutput().settag(tag, breaklines)
+        return self
+
+    def selfcomplete(self, tag):
+        "Set the self-closing tag, no contents (as in <hr/>)."
+        self.output = TaggedOutput().settag(tag, empty=True)
+        return self
+
+
+class FormulaConstant(Constant):
+    "A constant string in a formula"
+
+    def __init__(self, string):
+        "Set the constant string"
+        Constant.__init__(self, string)
+        self.original = string
+        self.size = 1
+        self.type = None
+
+    def computesize(self):
+        "Compute the size of the constant: always 1."
+        return self.size
+
+    def clone(self):
+        "Return a copy of itself."
+        return FormulaConstant(self.original)
+
+    def __str__(self):
+        "Return a printable representation."
+        return 'Formula constant: ' + self.string
+
+
+class RawText(FormulaBit):
+    "A bit of text inside a formula"
+
+    def detect(self, pos):
+        "Detect a bit of raw text"
+        return pos.current().isalpha()
+
+    def parsebit(self, pos):
+        "Parse alphabetic text"
+        alpha = pos.globalpha()
+        self.add(FormulaConstant(alpha))
+        self.type = 'alpha'
+
+
+class FormulaSymbol(FormulaBit):
+    "A symbol inside a formula"
+
+    modified = FormulaConfig.modified
+    unmodified = FormulaConfig.unmodified['characters']
+
+    def detect(self, pos):
+        "Detect a symbol"
+        if pos.current() in FormulaSymbol.unmodified:
+            return True
+        if pos.current() in FormulaSymbol.modified:
+            return True
+        return False
+
+    def parsebit(self, pos):
+        "Parse the symbol"
+        if pos.current() in FormulaSymbol.unmodified:
+            self.addsymbol(pos.current(), pos)
+            return
+        if pos.current() in FormulaSymbol.modified:
+            self.addsymbol(FormulaSymbol.modified[pos.current()], pos)
+            return
+        Trace.error('Symbol ' + pos.current() + ' not found')
+
+    def addsymbol(self, symbol, pos):
+        "Add a symbol"
+        self.skiporiginal(pos.current(), pos)
+        self.contents.append(FormulaConstant(symbol))
+
+
+class FormulaNumber(FormulaBit):
+    "A string of digits in a formula"
+
+    def detect(self, pos):
+        "Detect a digit"
+        return pos.current().isdigit()
+
+    def parsebit(self, pos):
+        "Parse a bunch of digits"
+        digits = pos.glob(lambda: pos.current().isdigit())
+        self.add(FormulaConstant(digits))
+        self.type = 'number'
+
+
+class Comment(FormulaBit):
+    "A LaTeX comment: % to the end of the line."
+
+    start = FormulaConfig.starts['comment']
+
+    def detect(self, pos):
+        "Detect the %."
+        return pos.current() == self.start
+
+    def parsebit(self, pos):
+        "Parse to the end of the line."
+        self.original += pos.globincluding('\n')
+
+
+class WhiteSpace(FormulaBit):
+    "Some white space inside a formula."
+
+    def detect(self, pos):
+        "Detect the white space."
+        return pos.current().isspace()
+
+    def parsebit(self, pos):
+        "Parse all whitespace."
+        self.original += pos.skipspace()
+
+    def __str__(self):
+        "Return a printable representation."
+        return 'Whitespace: *' + self.original + '*'
+
+
+class Bracket(FormulaBit):
+    "A {} bracket inside a formula"
+
+    start = FormulaConfig.starts['bracket']
+    ending = FormulaConfig.endings['bracket']
+
+    def __init__(self):
+        "Create a (possibly literal) new bracket"
+        FormulaBit.__init__(self)
+        self.inner = None
+
+    def detect(self, pos):
+        "Detect the start of a bracket"
+        return pos.checkfor(self.start)
+
+    def parsebit(self, pos):
+        "Parse the bracket"
+        self.parsecomplete(pos, self.innerformula)
+        return self
+
+    def parsetext(self, pos):
+        "Parse a text bracket"
+        self.parsecomplete(pos, self.innertext)
+        return self
+
+    def parseliteral(self, pos):
+        "Parse a literal bracket"
+        self.parsecomplete(pos, self.innerliteral)
+        return self
+
+    def parsecomplete(self, pos, innerparser):
+        "Parse the start and end marks"
+        if not pos.checkfor(self.start):
+            Trace.error('Bracket should start with ' + self.start + ' at ' + pos.identifier())
+            return None
+        self.skiporiginal(self.start, pos)
+        pos.pushending(self.ending)
+        innerparser(pos)
+        self.original += pos.popending(self.ending)
+        self.computesize()
+
+    def innerformula(self, pos):
+        "Parse a whole formula inside the bracket"
+        while not pos.finished():
+            self.add(self.factory.parseany(pos))
+
+    def innertext(self, pos):
+        "Parse some text inside the bracket, following textual rules."
+        specialchars = list(FormulaConfig.symbolfunctions.keys())
+        specialchars.append(FormulaConfig.starts['command'])
+        specialchars.append(FormulaConfig.starts['bracket'])
+        specialchars.append(Comment.start)
+        while not pos.finished():
+            if pos.current() in specialchars:
+                self.add(self.factory.parseany(pos))
+                if pos.checkskip(' '):
+                    self.original += ' '
+            else:
+                self.add(FormulaConstant(pos.skipcurrent()))
+
+    def innerliteral(self, pos):
+        "Parse a literal inside the bracket, which does not generate HTML."
+        self.literal = ''
+        while not pos.finished() and not pos.current() == self.ending:
+            if pos.current() == self.start:
+                self.parseliteral(pos)
+            else:
+                self.literal += pos.skipcurrent()
+        self.original += self.literal
+
+
+class SquareBracket(Bracket):
+    "A [] bracket inside a formula"
+
+    start = FormulaConfig.starts['squarebracket']
+    ending = FormulaConfig.endings['squarebracket']
+
+    def clone(self):
+        "Return a new square bracket with the same contents."
+        bracket = SquareBracket()
+        bracket.contents = self.contents
+        return bracket
+
+
+class MathsProcessor:
+    "A processor for a maths construction inside the FormulaProcessor."
+
+    def process(self, contents, index):
+        "Process an element inside a formula."
+        Trace.error('Unimplemented process() in ' + str(self))
+
+    def __str__(self):
+        "Return a printable description."
+        return 'Maths processor ' + self.__class__.__name__
+
+
+class FormulaProcessor:
+    "A processor specifically for formulas."
+
+    processors = []
+
+    def process(self, bit):
+        "Process the contents of every formula bit, recursively."
+        self.processcontents(bit)
+        self.processinsides(bit)
+        self.traversewhole(bit)
+
+    def processcontents(self, bit):
+        "Process the contents of a formula bit."
+        if not isinstance(bit, FormulaBit):
+            return
+        bit.process()
+        for element in bit.contents:
+            self.processcontents(element)
+
+    def processinsides(self, bit):
+        "Process the insides (limits, brackets) in a formula bit."
+        if not isinstance(bit, FormulaBit):
+            return
+        for index, element in enumerate(bit.contents):
+            for processor in self.processors:
+                processor.process(bit.contents, index)
+            # continue with recursive processing
+            self.processinsides(element)
+
+    def traversewhole(self, formula):
+        "Traverse over the contents to alter variables and space units."
+        last = None
+        for bit, contents in self.traverse(formula):
+            if bit.type == 'alpha':
+                self.italicize(bit, contents)
+            elif bit.type == 'font' and last and last.type == 'number':
+                bit.contents.insert(0, FormulaConstant('\u2009'))
+            last = bit
+
+    def traverse(self, bit):
+        "Traverse a formula and yield a flattened structure of (bit, list) pairs."
+        for element in bit.contents:
+            if hasattr(element, 'type') and element.type:
+                yield element, bit.contents
+            elif isinstance(element, FormulaBit):
+                yield from self.traverse(element)
+
+    def italicize(self, bit, contents):
+        "Italicize the given bit of text."
+        index = contents.index(bit)
+        contents[index] = TaggedBit().complete([bit], 'i')
+
+
+class Formula(Container):
+    "A LaTeX formula"
+
+    def __init__(self):
+        self.parser = FormulaParser()
+        self.output = TaggedOutput().settag('span class="formula"')
+
+    def process(self):
+        "Convert the formula to tags"
+        if self.header[0] == 'inline':
+            DocumentParameters.displaymode = False
+        else:
+            DocumentParameters.displaymode = True
+            self.output.settag('div class="formula"', True)
+        self.classic()
+
+    def classic(self):
+        "Make the contents using classic output generation with XHTML and CSS."
+        whole = FormulaFactory().parseformula(self.parsed)
+        FormulaProcessor().process(whole)
+        whole.parent = self
+        self.contents = [whole]
+
+    def parse(self, pos):
+        "Parse using a parse position instead of self.parser."
+        if pos.checkskip('$$'):
+            self.parsedollarblock(pos)
+        elif pos.checkskip('$'):
+            self.parsedollarinline(pos)
+        elif pos.checkskip('\\('):
+            self.parseinlineto(pos, '\\)')
+        elif pos.checkskip('\\['):
+            self.parseblockto(pos, '\\]')
+        else:
+            pos.error('Unparseable formula')
+        self.process()
+        return self
+
+    def parsedollarinline(self, pos):
+        "Parse a $...$ formula."
+        self.header = ['inline']
+        self.parsedollar(pos)
+
+    def parsedollarblock(self, pos):
+        "Parse a $$...$$ formula."
+        self.header = ['block']
+        self.parsedollar(pos)
+        if not pos.checkskip('$'):
+            pos.error('Formula should be $$...$$, but last $ is missing.')
+
+    def parsedollar(self, pos):
+        "Parse to the next $."
+        pos.pushending('$')
+        self.parsed = pos.globexcluding('$')
+        pos.popending('$')
+
+    def parseinlineto(self, pos, limit):
+        "Parse a \\(...\\) formula."
+        self.header = ['inline']
+        self.parseupto(pos, limit)
+
+    def parseblockto(self, pos, limit):
+        "Parse a \\[...\\] formula."
+        self.header = ['block']
+        self.parseupto(pos, limit)
+
+    def parseupto(self, pos, limit):
+        "Parse a formula that ends with the given command."
+        pos.pushending(limit)
+        self.parsed = pos.glob(lambda: True)
+        pos.popending(limit)
+
+    def __str__(self):
+        "Return a printable representation."
+        if self.partkey and self.partkey.number:
+            return 'Formula (' + self.partkey.number + ')'
+        return 'Unnumbered formula'
+
+
+class WholeFormula(FormulaBit):
+    "Parse a whole formula"
+
+    def detect(self, pos):
+        "Not outside the formula is enough."
+        return not pos.finished()
+
+    def parsebit(self, pos):
+        "Parse with any formula bit"
+        while not pos.finished():
+            self.add(self.factory.parseany(pos))
+
+
+class FormulaFactory:
+    "Construct bits of formula"
+
+    # bit types will be appended later
+    types = [FormulaSymbol, RawText, FormulaNumber, Bracket, Comment, WhiteSpace]
+    skippedtypes = [Comment, WhiteSpace]
+    defining = False
+
+    def __init__(self):
+        "Initialize the map of instances."
+        self.instances = {}
+
+    def detecttype(self, type, pos):
+        "Detect a bit of a given type."
+        if pos.finished():
+            return False
+        return self.instance(type).detect(pos)
+
+    def instance(self, type):
+        "Get an instance of the given type."
+        if type not in self.instances or not self.instances[type]:
+            self.instances[type] = self.create(type)
+        return self.instances[type]
+
+    def create(self, type):
+        "Create a new formula bit of the given type."
+        return Cloner.create(type).setfactory(self)
+
+    def clearskipped(self, pos):
+        "Clear any skipped types."
+        while not pos.finished():
+            if not self.skipany(pos):
+                return
+        return
+
+    def skipany(self, pos):
+        "Skip any skipped types."
+        for type in self.skippedtypes:
+            if self.instance(type).detect(pos):
+                return self.parsetype(type, pos)
+        return None
+
+    def parseany(self, pos):
+        "Parse any formula bit at the current location."
+        for type in self.types + self.skippedtypes:
+            if self.detecttype(type, pos):
+                return self.parsetype(type, pos)
+        Trace.error('Unrecognized formula at ' + pos.identifier())
+        return FormulaConstant(pos.skipcurrent())
+
+    def parsetype(self, type, pos):
+        "Parse the given type and return it."
+        bit = self.instance(type)
+        self.instances[type] = None
+        returnedbit = bit.parsebit(pos)
+        if returnedbit:
+            return returnedbit.setfactory(self)
+        return bit
+
+    def parseformula(self, formula):
+        "Parse a string of text that contains a whole formula."
+        pos = TextPosition(formula)
+        whole = self.create(WholeFormula)
+        if whole.detect(pos):
+            whole.parsebit(pos)
+            return whole
+        # no formula found
+        if not pos.finished():
+            Trace.error('Unknown formula at: ' + pos.identifier())
+            whole.add(TaggedBit().constant(formula, 'span class="unknown"'))
+        return whole
+
+
+class FormulaCommand(FormulaBit):
+    "A LaTeX command inside a formula"
+
+    types = []
+    start = FormulaConfig.starts['command']
+    commandmap = None
+
+    def detect(self, pos):
+        "Find the current command."
+        return pos.checkfor(FormulaCommand.start)
+
+    def parsebit(self, pos):
+        "Parse the command."
+        command = self.extractcommand(pos)
+        bit = self.parsewithcommand(command, pos)
+        if bit:
+            return bit
+        if command.startswith('\\up') or command.startswith('\\Up'):
+            upgreek = self.parseupgreek(command, pos)
+            if upgreek:
+                return upgreek
+        if not self.factory.defining:
+            Trace.error('Unknown command ' + command)
+        self.output = TaggedOutput().settag('span class="unknown"')
+        self.add(FormulaConstant(command))
+        return None
+
+    def parsewithcommand(self, command, pos):
+        "Parse the command type once we have the command."
+        for type in FormulaCommand.types:
+            if command in type.commandmap:
+                return self.parsecommandtype(command, type, pos)
+        return None
+
+    def parsecommandtype(self, command, type, pos):
+        "Parse a given command type."
+        bit = self.factory.create(type)
+        bit.setcommand(command)
+        returned = bit.parsebit(pos)
+        if returned:
+            return returned
+        return bit
+
+    def extractcommand(self, pos):
+        "Extract the command from the current position."
+        if not pos.checkskip(FormulaCommand.start):
+            pos.error('Missing command start ' + FormulaCommand.start)
+            return
+        if pos.finished():
+            return self.emptycommand(pos)
+        if pos.current().isalpha():
+            # alpha command
+            command = FormulaCommand.start + pos.globalpha()
+            # skip mark of short command
+            pos.checkskip('*')
+            return command
+        # symbol command
+        return FormulaCommand.start + pos.skipcurrent()
+
+    def emptycommand(self, pos):
+        """Check for an empty command: look for command disguised as ending.
+        Special case against '{ \\{ \\} }' situation."""
+        command = ''
+        if not pos.isout():
+            ending = pos.nextending()
+            if ending and pos.checkskip(ending):
+                command = ending
+        return FormulaCommand.start + command
+
+    def parseupgreek(self, command, pos):
+        "Parse the Greek \\up command.."
+        if len(command) < 4:
+            return None
+        if command.startswith('\\up'):
+            upcommand = '\\' + command[3:]
+        elif pos.checkskip('\\Up'):
+            upcommand = '\\' + command[3:4].upper() + command[4:]
+        else:
+            Trace.error('Impossible upgreek command: ' + command)
+            return
+        upgreek = self.parsewithcommand(upcommand, pos)
+        if upgreek:
+            upgreek.type = 'font'
+        return upgreek
+
+
+class CommandBit(FormulaCommand):
+    "A formula bit that includes a command"
+
+    def setcommand(self, command):
+        "Set the command in the bit"
+        self.command = command
+        if self.commandmap:
+            self.original += command
+            self.translated = self.commandmap[self.command]
+
+    def parseparameter(self, pos):
+        "Parse a parameter at the current position"
+        self.factory.clearskipped(pos)
+        if pos.finished():
+            return None
+        parameter = self.factory.parseany(pos)
+        self.add(parameter)
+        return parameter
+
+    def parsesquare(self, pos):
+        "Parse a square bracket"
+        self.factory.clearskipped(pos)
+        if not self.factory.detecttype(SquareBracket, pos):
+            return None
+        bracket = self.factory.parsetype(SquareBracket, pos)
+        self.add(bracket)
+        return bracket
+
+    def parseliteral(self, pos):
+        "Parse a literal bracket."
+        self.factory.clearskipped(pos)
+        if not self.factory.detecttype(Bracket, pos):
+            if not pos.isvalue():
+                Trace.error('No literal parameter found at: ' + pos.identifier())
+                return None
+            return pos.globvalue()
+        bracket = Bracket().setfactory(self.factory)
+        self.add(bracket.parseliteral(pos))
+        return bracket.literal
+
+    def parsesquareliteral(self, pos):
+        "Parse a square bracket literally."
+        self.factory.clearskipped(pos)
+        if not self.factory.detecttype(SquareBracket, pos):
+            return None
+        bracket = SquareBracket().setfactory(self.factory)
+        self.add(bracket.parseliteral(pos))
+        return bracket.literal
+
+    def parsetext(self, pos):
+        "Parse a text parameter."
+        self.factory.clearskipped(pos)
+        if not self.factory.detecttype(Bracket, pos):
+            Trace.error('No text parameter for ' + self.command)
+            return None
+        bracket = Bracket().setfactory(self.factory).parsetext(pos)
+        self.add(bracket)
+        return bracket
+
+
+class EmptyCommand(CommandBit):
+    "An empty command (without parameters)"
+
+    commandmap = FormulaConfig.commands
+
+    def parsebit(self, pos):
+        "Parse a command without parameters"
+        self.contents = [FormulaConstant(self.translated)]
+
+
+class SpacedCommand(CommandBit):
+    """An empty command which should have math spacing in formulas."""
+
+    commandmap = FormulaConfig.spacedcommands
+
+    def parsebit(self, pos):
+        "Place as contents the command translated and spaced."
+        # pad with MEDIUM MATHEMATICAL SPACE (4/18 em): too wide in STIX fonts :(
+        # self.contents = [FormulaConstant('\u205f' + self.translated + '\u205f')]
+        # pad with THIN SPACE (1/5 em)
+        self.contents = [FormulaConstant('\u2009' + self.translated + '\u2009')]
+
+
+class AlphaCommand(EmptyCommand):
+    """A command without parameters whose result is alphabetical."""
+
+    commandmap = FormulaConfig.alphacommands
+    greek_capitals = ('\\Xi', '\\Theta', '\\Pi', '\\Sigma', '\\Gamma',
+                      '\\Lambda', '\\Phi', '\\Psi', '\\Delta',
+                      '\\Upsilon', '\\Omega')
+
+    def parsebit(self, pos):
+        "Parse the command and set type to alpha"
+        EmptyCommand.parsebit(self, pos)
+        if self.command not in self.greek_capitals:
+            # Greek Capital letters are upright in LaTeX default math-style.
+            # TODO: use italic, like in MathML and "iso" math-style?
+            self.type = 'alpha'
+
+
+class OneParamFunction(CommandBit):
+    "A function of one parameter"
+
+    commandmap = FormulaConfig.onefunctions
+    simplified = False
+
+    def parsebit(self, pos):
+        "Parse a function with one parameter"
+        self.output = TaggedOutput().settag(self.translated)
+        self.parseparameter(pos)
+        self.simplifyifpossible()
+
+    def simplifyifpossible(self):
+        "Try to simplify to a single character."
+        if self.original in self.commandmap:
+            self.output = FixedOutput()
+            self.html = [self.commandmap[self.original]]
+            self.simplified = True
+
+
+class SymbolFunction(CommandBit):
+    "Find a function which is represented by a symbol (like _ or ^)"
+
+    commandmap = FormulaConfig.symbolfunctions
+
+    def detect(self, pos):
+        "Find the symbol"
+        return pos.current() in SymbolFunction.commandmap
+
+    def parsebit(self, pos):
+        "Parse the symbol"
+        self.setcommand(pos.current())
+        pos.skip(self.command)
+        self.output = TaggedOutput().settag(self.translated)
+        self.parseparameter(pos)
+
+
+class TextFunction(CommandBit):
+    "A function where parameters are read as text."
+
+    commandmap = FormulaConfig.textfunctions
+
+    def parsebit(self, pos):
+        "Parse a text parameter"
+        self.output = TaggedOutput().settag(self.translated)
+        self.parsetext(pos)
+
+    def process(self):
+        "Set the type to font"
+        self.type = 'font'
+
+
+class FontFunction(OneParamFunction):
+    """A function of one parameter that changes the font."""
+    # TODO: keep letters italic with \boldsymbol.
+
+    commandmap = FormulaConfig.fontfunctions
+
+    def process(self):
+        "Simplify if possible using a single character."
+        self.type = 'font'
+        self.simplifyifpossible()
+
+
+FormulaFactory.types += [FormulaCommand, SymbolFunction]
+FormulaCommand.types = [
+    AlphaCommand, EmptyCommand, OneParamFunction, FontFunction,
+    TextFunction, SpacedCommand]
+
+
+class BigBracket:
+    "A big bracket generator."
+
+    def __init__(self, size, bracket, alignment='l'):
+        "Set the size and symbol for the bracket."
+        self.size = size
+        self.original = bracket
+        self.alignment = alignment
+        self.pieces = None
+        if bracket in FormulaConfig.bigbrackets:
+            self.pieces = FormulaConfig.bigbrackets[bracket]
+
+    def getpiece(self, index):
+        "Return the nth piece for the bracket."
+        function = getattr(self, 'getpiece' + str(len(self.pieces)))
+        return function(index)
+
+    def getpiece1(self, index):
+        "Return the only piece for a single-piece bracket."
+        return self.pieces[0]
+
+    def getpiece3(self, index):
+        "Get the nth piece for a 3-piece bracket: parenthesis or square bracket."
+        if index == 0:
+            return self.pieces[0]
+        if index == self.size - 1:
+            return self.pieces[-1]
+        return self.pieces[1]
+
+    def getpiece4(self, index):
+        "Get the nth piece for a 4-piece bracket: curly bracket."
+        if index == 0:
+            return self.pieces[0]
+        if index == self.size - 1:
+            return self.pieces[3]
+        if index == (self.size - 1)/2:
+            return self.pieces[2]
+        return self.pieces[1]
+
+    def getcell(self, index):
+        "Get the bracket piece as an array cell."
+        piece = self.getpiece(index)
+        span = 'span class="bracket align-' + self.alignment + '"'
+        return TaggedBit().constant(piece, span)
+
+    def getcontents(self):
+        "Get the bracket as an array or as a single bracket."
+        if self.size == 1 or not self.pieces:
+            return self.getsinglebracket()
+        rows = []
+        for index in range(self.size):
+            cell = self.getcell(index)
+            rows.append(TaggedBit().complete([cell], 'span class="arrayrow"'))
+        return [TaggedBit().complete(rows, 'span class="array"')]
+
+    def getsinglebracket(self):
+        "Return the bracket as a single sign."
+        if self.original == '.':
+            return [TaggedBit().constant('', 'span class="emptydot"')]
+        return [TaggedBit().constant(self.original, 'span class="stretchy"')]
+
+
+class FormulaEquation(CommandBit):
+    "A simple numbered equation."
+
+    piece = 'equation'
+
+    def parsebit(self, pos):
+        "Parse the array"
+        self.output = ContentsOutput()
+        self.add(self.factory.parsetype(WholeFormula, pos))
+
+
+class FormulaCell(FormulaCommand):
+    "An array cell inside a row"
+
+    def setalignment(self, alignment):
+        self.alignment = alignment
+        self.output = TaggedOutput().settag('span class="arraycell align-'
+                                            + alignment + '"', True)
+        return self
+
+    def parsebit(self, pos):
+        self.factory.clearskipped(pos)
+        if pos.finished():
+            return
+        self.add(self.factory.parsetype(WholeFormula, pos))
+
+
+class FormulaRow(FormulaCommand):
+    "An array row inside an array"
+
+    cellseparator = FormulaConfig.array['cellseparator']
+
+    def setalignments(self, alignments):
+        self.alignments = alignments
+        self.output = TaggedOutput().settag('span class="arrayrow"', True)
+        return self
+
+    def parsebit(self, pos):
+        "Parse a whole row"
+        index = 0
+        pos.pushending(self.cellseparator, optional=True)
+        while not pos.finished():
+            cell = self.createcell(index)
+            cell.parsebit(pos)
+            self.add(cell)
+            index += 1
+            pos.checkskip(self.cellseparator)
+        if len(self.contents) == 0:
+            self.output = EmptyOutput()
+
+    def createcell(self, index):
+        "Create the cell that corresponds to the given index."
+        alignment = self.alignments[index % len(self.alignments)]
+        return self.factory.create(FormulaCell).setalignment(alignment)
+
+
+class MultiRowFormula(CommandBit):
+    "A formula with multiple rows."
+
+    def parserows(self, pos):
+        "Parse all rows, finish when no more row ends"
+        self.rows = []
+        first = True
+        for row in self.iteraterows(pos):
+            if first:
+                first = False
+            else:
+                # intersparse empty rows
+                self.addempty()
+            row.parsebit(pos)
+            self.addrow(row)
+        self.size = len(self.rows)
+
+    def iteraterows(self, pos):
+        "Iterate over all rows, end when no more row ends"
+        rowseparator = FormulaConfig.array['rowseparator']
+        while True:
+            pos.pushending(rowseparator, True)
+            row = self.factory.create(FormulaRow)
+            yield row.setalignments(self.alignments)
+            if pos.checkfor(rowseparator):
+                self.original += pos.popending(rowseparator)
+            else:
+                return
+
+    def addempty(self):
+        "Add an empty row."
+        row = self.factory.create(FormulaRow).setalignments(self.alignments)
+        for index, originalcell in enumerate(self.rows[-1].contents):
+            cell = row.createcell(index)
+            cell.add(FormulaConstant(' '))
+            row.add(cell)
+        self.addrow(row)
+
+    def addrow(self, row):
+        "Add a row to the contents and to the list of rows."
+        self.rows.append(row)
+        self.add(row)
+
+
+class FormulaArray(MultiRowFormula):
+    "An array within a formula"
+
+    piece = 'array'
+
+    def parsebit(self, pos):
+        "Parse the array"
+        self.output = TaggedOutput().settag('span class="array"', False)
+        self.parsealignments(pos)
+        self.parserows(pos)
+
+    def parsealignments(self, pos):
+        "Parse the different alignments"
+        # vertical
+        self.valign = 'c'
+        literal = self.parsesquareliteral(pos)
+        if literal:
+            self.valign = literal
+        # horizontal
+        literal = self.parseliteral(pos)
+        self.alignments = []
+        for s in literal:
+            self.alignments.append(s)
+
+
+class FormulaMatrix(MultiRowFormula):
+    "A matrix (array with center alignment)."
+
+    piece = 'matrix'
+
+    def parsebit(self, pos):
+        "Parse the matrix, set alignments to 'c'."
+        self.output = TaggedOutput().settag('span class="array"', False)
+        self.valign = 'c'
+        self.alignments = ['c']
+        self.parserows(pos)
+
+
+class FormulaCases(MultiRowFormula):
+    "A cases statement"
+
+    piece = 'cases'
+
+    def parsebit(self, pos):
+        "Parse the cases"
+        self.output = ContentsOutput()
+        self.alignments = ['l', 'l']
+        self.parserows(pos)
+        for row in self.contents:
+            for cell in row.contents:
+                cell.output.settag('span class="case align-l"', True)
+                cell.contents.append(FormulaConstant(' '))
+        array = TaggedBit().complete(self.contents, 'span class="bracketcases"', True)
+        brace = BigBracket(len(self.contents), '{', 'l')
+        self.contents = brace.getcontents() + [array]
+
+
+class EquationEnvironment(MultiRowFormula):
+    "A \\begin{}...\\end equation environment with rows and cells."
+
+    def parsebit(self, pos):
+        "Parse the whole environment."
+        environment = self.piece.replace('*', '')
+        self.output = TaggedOutput().settag(
+                        'span class="environment %s"'%environment, False)
+        if environment in FormulaConfig.environments:
+            self.alignments = FormulaConfig.environments[environment]
+        else:
+            Trace.error('Unknown equation environment ' + self.piece)
+            # print in red
+            self.output = TaggedOutput().settag('span class="unknown"')
+            self.add(FormulaConstant('\\begin{%s} '%environment))
+
+            self.alignments = ['l']
+        self.parserows(pos)
+
+
+class BeginCommand(CommandBit):
+    "A \\begin{}...\\end command and what it entails (array, cases, aligned)"
+
+    commandmap = {FormulaConfig.array['begin']: ''}
+
+    types = [FormulaEquation, FormulaArray, FormulaCases, FormulaMatrix]
+
+    def parsebit(self, pos):
+        "Parse the begin command"
+        command = self.parseliteral(pos)
+        bit = self.findbit(command)
+        ending = FormulaConfig.array['end'] + '{' + command + '}'
+        pos.pushending(ending)
+        bit.parsebit(pos)
+        self.add(bit)
+        self.original += pos.popending(ending)
+        self.size = bit.size
+
+    def findbit(self, piece):
+        "Find the command bit corresponding to the \\begin{piece}"
+        for type in BeginCommand.types:
+            if piece.replace('*', '') == type.piece:
+                return self.factory.create(type)
+        bit = self.factory.create(EquationEnvironment)
+        bit.piece = piece
+        return bit
+
+
+FormulaCommand.types += [BeginCommand]
+
+
+class CombiningFunction(OneParamFunction):
+
+    commandmap = FormulaConfig.combiningfunctions
+
+    def parsebit(self, pos):
+        "Parse a combining function."
+        combining = self.translated
+        parameter = self.parsesingleparameter(pos)
+        if not parameter:
+            Trace.error('Missing parameter for combining function ' + self.command)
+            return
+        # Trace.message('apply %s to %r'%(self.command, parameter.extracttext()))
+        # parameter.tree()
+        if not isinstance(parameter, FormulaConstant):
+            try:
+                extractor = ContainerExtractor(ContainerConfig.extracttext)
+                parameter = extractor.extract(parameter)[0]
+            except IndexError:
+                Trace.error('No base character found for "%s".' % self.command)
+                return
+        # Trace.message('  basechar: %r' % parameter.string)
+        # Insert combining character after the first character:
+        if parameter.string.startswith('\u2009'):
+            i = 2  # skip padding by SpacedCommand and FormulaConfig.modified
+        else:
+            i = 1
+        parameter.string = parameter.string[:i] + combining + parameter.string[i:]
+        # Use pre-composed characters if possible: \not{=} -> ≠, say.
+        parameter.string = unicodedata.normalize('NFC', parameter.string)
+
+    def parsesingleparameter(self, pos):
+        "Parse a parameter, or a single letter."
+        self.factory.clearskipped(pos)
+        if pos.finished():
+            return None
+        return self.parseparameter(pos)
+
+
+class OversetFunction(OneParamFunction):
+    "A function that decorates some bit of text with an overset."
+
+    commandmap = FormulaConfig.oversetfunctions
+
+    def parsebit(self, pos):
+        "Parse an overset-function"
+        symbol = self.translated
+        self.symbol = TaggedBit().constant(symbol, 'sup')
+        self.parameter = self.parseparameter(pos)
+        self.output = TaggedOutput().settag('span class="embellished"')
+        self.contents.insert(0, self.symbol)
+        self.parameter.output = TaggedOutput().settag('span class="base"')
+        self.simplifyifpossible()
+
+
+class UndersetFunction(OneParamFunction):
+    "A function that decorates some bit of text with an underset."
+
+    commandmap = FormulaConfig.undersetfunctions
+
+    def parsebit(self, pos):
+        "Parse an underset-function"
+        symbol = self.translated
+        self.symbol = TaggedBit().constant(symbol, 'sub')
+        self.parameter = self.parseparameter(pos)
+        self.output = TaggedOutput().settag('span class="embellished"')
+        self.contents.insert(0, self.symbol)
+        self.parameter.output = TaggedOutput().settag('span class="base"')
+        self.simplifyifpossible()
+
+
+class LimitCommand(EmptyCommand):
+    "A command which accepts limits above and below, in display mode."
+
+    commandmap = FormulaConfig.limitcommands
+
+    def parsebit(self, pos):
+        "Parse a limit command."
+        self.output = TaggedOutput().settag('span class="limits"')
+        symbol = self.translated
+        self.contents.append(TaggedBit().constant(symbol, 'span class="limit"'))
+
+
+class LimitPreviousCommand(LimitCommand):
+    "A command to limit the previous command."
+
+    commandmap = None
+
+    def parsebit(self, pos):
+        "Do nothing."
+        self.output = TaggedOutput().settag('span class="limits"')
+        self.factory.clearskipped(pos)
+
+    def __str__(self):
+        "Return a printable representation."
+        return 'Limit previous command'
+
+
+class LimitsProcessor(MathsProcessor):
+    "A processor for limits inside an element."
+
+    def process(self, contents, index):
+        "Process the limits for an element."
+        if Options.simplemath:
+            return
+        if self.checklimits(contents, index):
+            self.modifylimits(contents, index)
+        if self.checkscript(contents, index) and self.checkscript(contents, index + 1):
+            self.modifyscripts(contents, index)
+
+    def checklimits(self, contents, index):
+        "Check if the current position has a limits command."
+        # TODO: check for \limits macro
+        if not DocumentParameters.displaymode:
+            return False
+        if self.checkcommand(contents, index + 1, LimitPreviousCommand):
+            self.limitsahead(contents, index)
+            return False
+        if not isinstance(contents[index], LimitCommand):
+            return False
+        return self.checkscript(contents, index + 1)
+
+    def limitsahead(self, contents, index):
+        "Limit the current element based on the next."
+        contents[index + 1].add(contents[index].clone())
+        contents[index].output = EmptyOutput()
+
+    def modifylimits(self, contents, index):
+        "Modify a limits commands so that the limits appear above and below."
+        limited = contents[index]
+        subscript = self.getlimit(contents, index + 1)
+        if self.checkscript(contents, index + 1):
+            superscript = self.getlimit(contents, index + 1)
+        else:
+            superscript = TaggedBit().constant('\u2009', 'sup class="limit"')
+        # fix order if source is x^i
+        if subscript.command == '^':
+            superscript, subscript = subscript, superscript
+        limited.contents.append(subscript)
+        limited.contents.insert(0, superscript)
+
+    def getlimit(self, contents, index):
+        "Get the limit for a limits command."
+        limit = self.getscript(contents, index)
+        limit.output.tag = limit.output.tag.replace('script', 'limit')
+        return limit
+
+    def modifyscripts(self, contents, index):
+        "Modify the super- and subscript to appear vertically aligned."
+        subscript = self.getscript(contents, index)
+        # subscript removed so instead of index + 1 we get index again
+        superscript = self.getscript(contents, index)
+        # super-/subscript are reversed if source is x^i_j
+        if subscript.command == '^':
+            superscript, subscript = subscript, superscript
+        scripts = TaggedBit().complete([superscript, subscript], 'span class="scripts"')
+        contents.insert(index, scripts)
+
+    def checkscript(self, contents, index):
+        "Check if the current element is a sub- or superscript."
+        return self.checkcommand(contents, index, SymbolFunction)
+
+    def checkcommand(self, contents, index, type):
+        "Check for the given type as the current element."
+        if len(contents) <= index:
+            return False
+        return isinstance(contents[index], type)
+
+    def getscript(self, contents, index):
+        "Get the sub- or superscript."
+        bit = contents[index]
+        bit.output.tag += ' class="script"'
+        del contents[index]
+        return bit
+
+
+class BracketCommand(OneParamFunction):
+    "A command which defines a bracket."
+
+    commandmap = FormulaConfig.bracketcommands
+
+    def parsebit(self, pos):
+        "Parse the bracket."
+        OneParamFunction.parsebit(self, pos)
+
+    def create(self, direction, character):
+        "Create the bracket for the given character."
+        self.original = character
+        self.command = '\\' + direction
+        self.contents = [FormulaConstant(character)]
+        return self
+
+
+class BracketProcessor(MathsProcessor):
+    "A processor for bracket commands."
+
+    def process(self, contents, index):
+        "Convert the bracket using Unicode pieces, if possible."
+        if Options.simplemath:
+            return
+        if self.checkleft(contents, index):
+            return self.processleft(contents, index)
+
+    def processleft(self, contents, index):
+        "Process a left bracket."
+        rightindex = self.findright(contents, index + 1)
+        if not rightindex:
+            return
+        size = self.findmax(contents, index, rightindex)
+        self.resize(contents[index], size)
+        self.resize(contents[rightindex], size)
+
+    def checkleft(self, contents, index):
+        "Check if the command at the given index is left."
+        return self.checkdirection(contents[index], '\\left')
+
+    def checkright(self, contents, index):
+        "Check if the command at the given index is right."
+        return self.checkdirection(contents[index], '\\right')
+
+    def checkdirection(self, bit, command):
+        "Check if the given bit is the desired bracket command."
+        if not isinstance(bit, BracketCommand):
+            return False
+        return bit.command == command
+
+    def findright(self, contents, index):
+        "Find the right bracket starting at the given index, or 0."
+        depth = 1
+        while index < len(contents):
+            if self.checkleft(contents, index):
+                depth += 1
+            if self.checkright(contents, index):
+                depth -= 1
+            if depth == 0:
+                return index
+            index += 1
+        return None
+
+    def findmax(self, contents, leftindex, rightindex):
+        "Find the max size of the contents between the two given indices."
+        sliced = contents[leftindex:rightindex]
+        return max(element.size for element in sliced)
+
+    def resize(self, command, size):
+        "Resize a bracket command to the given size."
+        character = command.extracttext()
+        alignment = command.command.replace('\\', '')
+        bracket = BigBracket(size, character, alignment)
+        command.output = ContentsOutput()
+        command.contents = bracket.getcontents()
+
+
+FormulaCommand.types += [OversetFunction, UndersetFunction,
+                         CombiningFunction, LimitCommand, BracketCommand]
+
+FormulaProcessor.processors += [
+    LimitsProcessor(), BracketProcessor(),
+]
+
+
+class ParameterDefinition:
+    "The definition of a parameter in a hybrid function."
+    "[] parameters are optional, {} parameters are mandatory."
+    "Each parameter has a one-character name, like {$1} or {$p}."
+    "A parameter that ends in ! like {$p!} is a literal."
+    "Example: [$1]{$p!} reads an optional parameter $1 and a literal mandatory parameter p."
+
+    parambrackets = [('[', ']'), ('{', '}')]
+
+    def __init__(self):
+        self.name = None
+        self.literal = False
+        self.optional = False
+        self.value = None
+        self.literalvalue = None
+
+    def parse(self, pos):
+        "Parse a parameter definition: [$0], {$x}, {$1!}..."
+        for (opening, closing) in ParameterDefinition.parambrackets:
+            if pos.checkskip(opening):
+                if opening == '[':
+                    self.optional = True
+                if not pos.checkskip('$'):
+                    Trace.error('Wrong parameter name, did you mean $' + pos.current() + '?')
+                    return None
+                self.name = pos.skipcurrent()
+                if pos.checkskip('!'):
+                    self.literal = True
+                if not pos.checkskip(closing):
+                    Trace.error('Wrong parameter closing ' + pos.skipcurrent())
+                    return None
+                return self
+        Trace.error('Wrong character in parameter template: ' + pos.skipcurrent())
+        return None
+
+    def read(self, pos, function):
+        "Read the parameter itself using the definition."
+        if self.literal:
+            if self.optional:
+                self.literalvalue = function.parsesquareliteral(pos)
+            else:
+                self.literalvalue = function.parseliteral(pos)
+            if self.literalvalue:
+                self.value = FormulaConstant(self.literalvalue)
+        elif self.optional:
+            self.value = function.parsesquare(pos)
+        else:
+            self.value = function.parseparameter(pos)
+
+    def __str__(self):
+        "Return a printable representation."
+        result = 'param ' + self.name
+        if self.value:
+            result += ': ' + str(self.value)
+        else:
+            result += ' (empty)'
+        return result
+
+
+class ParameterFunction(CommandBit):
+    "A function with a variable number of parameters defined in a template."
+    "The parameters are defined as a parameter definition."
+
+    def readparams(self, readtemplate, pos):
+        "Read the params according to the template."
+        self.params = {}
+        for paramdef in self.paramdefs(readtemplate):
+            paramdef.read(pos, self)
+            self.params['$' + paramdef.name] = paramdef
+
+    def paramdefs(self, readtemplate):
+        "Read each param definition in the template"
+        pos = TextPosition(readtemplate)
+        while not pos.finished():
+            paramdef = ParameterDefinition().parse(pos)
+            if paramdef:
+                yield paramdef
+
+    def getparam(self, name):
+        "Get a parameter as parsed."
+        if name not in self.params:
+            return None
+        return self.params[name]
+
+    def getvalue(self, name):
+        "Get the value of a parameter."
+        return self.getparam(name).value
+
+    def getliteralvalue(self, name):
+        "Get the literal value of a parameter."
+        param = self.getparam(name)
+        if not param or not param.literalvalue:
+            return None
+        return param.literalvalue
+
+
+class HybridFunction(ParameterFunction):
+    """
+    A parameter function where the output is also defined using a template.
+    The template can use a number of functions; each function has an associated
+    tag.
+    Example: [f0{$1},span class="fbox"] defines a function f0 which corresponds
+    to a span of class fbox, yielding <span class="fbox">$1</span>.
+    Literal parameters can be used in tags definitions:
+      [f0{$1},span style="color: $p;"]
+    yields <span style="color: $p;">$1</span>, where $p is a literal parameter.
+    Sizes can be specified in hybridsizes, e.g. adding parameter sizes. By
+    default the resulting size is the max of all arguments. Sizes are used
+    to generate the right parameters.
+    A function followed by a single / is output as a self-closing XHTML tag:
+      [f0/,hr]
+    will generate <hr/>.
+    """
+
+    commandmap = FormulaConfig.hybridfunctions
+
+    def parsebit(self, pos):
+        "Parse a function with [] and {} parameters"
+        readtemplate = self.translated[0]
+        writetemplate = self.translated[1]
+        self.readparams(readtemplate, pos)
+        self.contents = self.writeparams(writetemplate)
+        self.computehybridsize()
+
+    def writeparams(self, writetemplate):
+        "Write all params according to the template"
+        return self.writepos(TextPosition(writetemplate))
+
+    def writepos(self, pos):
+        "Write all params as read in the parse position."
+        result = []
+        while not pos.finished():
+            if pos.checkskip('$'):
+                param = self.writeparam(pos)
+                if param:
+                    result.append(param)
+            elif pos.checkskip('f'):
+                function = self.writefunction(pos)
+                if function:
+                    function.type = None
+                    result.append(function)
+            elif pos.checkskip('('):
+                result.append(self.writebracket('left', '('))
+            elif pos.checkskip(')'):
+                result.append(self.writebracket('right', ')'))
+            else:
+                result.append(FormulaConstant(pos.skipcurrent()))
+        return result
+
+    def writeparam(self, pos):
+        "Write a single param of the form $0, $x..."
+        name = '$' + pos.skipcurrent()
+        if name not in self.params:
+            Trace.error('Unknown parameter ' + name)
+            return None
+        if not self.params[name]:
+            return None
+        if pos.checkskip('.'):
+            self.params[name].value.type = pos.globalpha()
+        return self.params[name].value
+
+    def writefunction(self, pos):
+        "Write a single function f0,...,fn."
+        tag = self.readtag(pos)
+        if not tag:
+            return None
+        if pos.checkskip('/'):
+            # self-closing XHTML tag, such as <hr/>
+            return TaggedBit().selfcomplete(tag)
+        if not pos.checkskip('{'):
+            Trace.error('Function should be defined in {}')
+            return None
+        pos.pushending('}')
+        contents = self.writepos(pos)
+        pos.popending()
+        if len(contents) == 0:
+            return None
+        return TaggedBit().complete(contents, tag)
+
+    def readtag(self, pos):
+        "Get the tag corresponding to the given index. Does parameter substitution."
+        if not pos.current().isdigit():
+            Trace.error('Function should be f0,...,f9: f' + pos.current())
+            return None
+        index = int(pos.skipcurrent())
+        if 2 + index > len(self.translated):
+            Trace.error('Function f' + str(index) + ' is not defined')
+            return None
+        tag = self.translated[2 + index]
+        if '$' not in tag:
+            return tag
+        for variable in self.params:
+            if variable in tag:
+                param = self.params[variable]
+                if not param.literal:
+                    Trace.error('Parameters in tag ' + tag + ' should be literal: {' + variable + '!}')
+                    continue
+                if param.literalvalue:
+                    value = param.literalvalue
+                else:
+                    value = ''
+                tag = tag.replace(variable, value)
+        return tag
+
+    def writebracket(self, direction, character):
+        "Return a new bracket looking at the given direction."
+        return self.factory.create(BracketCommand).create(direction, character)
+
+    def computehybridsize(self):
+        "Compute the size of the hybrid function."
+        if self.command not in HybridSize.configsizes:
+            self.computesize()
+            return
+        self.size = HybridSize().getsize(self)
+        # set the size in all elements at first level
+        for element in self.contents:
+            element.size = self.size
+
+
+class HybridSize:
+    "The size associated with a hybrid function."
+
+    configsizes = FormulaConfig.hybridsizes
+
+    def getsize(self, function):
+        "Read the size for a function and parse it."
+        sizestring = self.configsizes[function.command]
+        for name in function.params:
+            if name in sizestring:
+                size = function.params[name].value.computesize()
+                sizestring = sizestring.replace(name, str(size))
+        if '$' in sizestring:
+            Trace.error('Unconverted variable in hybrid size: ' + sizestring)
+            return 1
+        return eval(sizestring)
+
+
+FormulaCommand.types += [HybridFunction]
+
+
+def math2html(formula):
+    "Convert some TeX math to HTML."
+    factory = FormulaFactory()
+    whole = factory.parseformula(formula)
+    FormulaProcessor().process(whole)
+    whole.process()
+    return ''.join(whole.gethtml())
+
+
+def main():
+    "Main function, called if invoked from the command line"
+    args = sys.argv
+    Options().parseoptions(args)
+    if len(args) != 1:
+        Trace.error('Usage: math2html.py escaped_string')
+        exit()
+    result = math2html(args[0])
+    Trace.message(result)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/mathalphabet2unichar.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/mathalphabet2unichar.py
new file mode 100644
index 00000000..876cea47
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/mathalphabet2unichar.py
@@ -0,0 +1,892 @@
+#!/usr/bin/env python3
+#
+# LaTeX math to Unicode symbols translation dictionaries for
+# the content of math alphabet commands (\mathtt, \mathbf, ...).
+# Generated with ``write_mathalphabet2unichar.py`` from the data in
+# http://milde.users.sourceforge.net/LUCR/Math/
+#
+# :Copyright: © 2024 Günter Milde.
+# :License: Released under the terms of the `2-Clause BSD license`__, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# __ https://opensource.org/licenses/BSD-2-Clause
+
+mathbb = {
+    '0': '\U0001d7d8',  # 𝟘 MATHEMATICAL DOUBLE-STRUCK DIGIT ZERO
+    '1': '\U0001d7d9',  # 𝟙 MATHEMATICAL DOUBLE-STRUCK DIGIT ONE
+    '2': '\U0001d7da',  # 𝟚 MATHEMATICAL DOUBLE-STRUCK DIGIT TWO
+    '3': '\U0001d7db',  # 𝟛 MATHEMATICAL DOUBLE-STRUCK DIGIT THREE
+    '4': '\U0001d7dc',  # 𝟜 MATHEMATICAL DOUBLE-STRUCK DIGIT FOUR
+    '5': '\U0001d7dd',  # 𝟝 MATHEMATICAL DOUBLE-STRUCK DIGIT FIVE
+    '6': '\U0001d7de',  # 𝟞 MATHEMATICAL DOUBLE-STRUCK DIGIT SIX
+    '7': '\U0001d7df',  # 𝟟 MATHEMATICAL DOUBLE-STRUCK DIGIT SEVEN
+    '8': '\U0001d7e0',  # 𝟠 MATHEMATICAL DOUBLE-STRUCK DIGIT EIGHT
+    '9': '\U0001d7e1',  # 𝟡 MATHEMATICAL DOUBLE-STRUCK DIGIT NINE
+    'A': '\U0001d538',  # 𝔸 MATHEMATICAL DOUBLE-STRUCK CAPITAL A
+    'B': '\U0001d539',  # 𝔹 MATHEMATICAL DOUBLE-STRUCK CAPITAL B
+    'C': '\u2102',  # ℂ DOUBLE-STRUCK CAPITAL C
+    'D': '\U0001d53b',  # 𝔻 MATHEMATICAL DOUBLE-STRUCK CAPITAL D
+    'E': '\U0001d53c',  # 𝔼 MATHEMATICAL DOUBLE-STRUCK CAPITAL E
+    'F': '\U0001d53d',  # 𝔽 MATHEMATICAL DOUBLE-STRUCK CAPITAL F
+    'G': '\U0001d53e',  # 𝔾 MATHEMATICAL DOUBLE-STRUCK CAPITAL G
+    'H': '\u210d',  # ℍ DOUBLE-STRUCK CAPITAL H
+    'I': '\U0001d540',  # 𝕀 MATHEMATICAL DOUBLE-STRUCK CAPITAL I
+    'J': '\U0001d541',  # 𝕁 MATHEMATICAL DOUBLE-STRUCK CAPITAL J
+    'K': '\U0001d542',  # 𝕂 MATHEMATICAL DOUBLE-STRUCK CAPITAL K
+    'L': '\U0001d543',  # 𝕃 MATHEMATICAL DOUBLE-STRUCK CAPITAL L
+    'M': '\U0001d544',  # 𝕄 MATHEMATICAL DOUBLE-STRUCK CAPITAL M
+    'N': '\u2115',  # ℕ DOUBLE-STRUCK CAPITAL N
+    'O': '\U0001d546',  # 𝕆 MATHEMATICAL DOUBLE-STRUCK CAPITAL O
+    'P': '\u2119',  # ℙ DOUBLE-STRUCK CAPITAL P
+    'Q': '\u211a',  # ℚ DOUBLE-STRUCK CAPITAL Q
+    'R': '\u211d',  # ℝ DOUBLE-STRUCK CAPITAL R
+    'S': '\U0001d54a',  # 𝕊 MATHEMATICAL DOUBLE-STRUCK CAPITAL S
+    'T': '\U0001d54b',  # 𝕋 MATHEMATICAL DOUBLE-STRUCK CAPITAL T
+    'U': '\U0001d54c',  # 𝕌 MATHEMATICAL DOUBLE-STRUCK CAPITAL U
+    'V': '\U0001d54d',  # 𝕍 MATHEMATICAL DOUBLE-STRUCK CAPITAL V
+    'W': '\U0001d54e',  # 𝕎 MATHEMATICAL DOUBLE-STRUCK CAPITAL W
+    'X': '\U0001d54f',  # 𝕏 MATHEMATICAL DOUBLE-STRUCK CAPITAL X
+    'Y': '\U0001d550',  # 𝕐 MATHEMATICAL DOUBLE-STRUCK CAPITAL Y
+    'Z': '\u2124',  # ℤ DOUBLE-STRUCK CAPITAL Z
+    'a': '\U0001d552',  # 𝕒 MATHEMATICAL DOUBLE-STRUCK SMALL A
+    'b': '\U0001d553',  # 𝕓 MATHEMATICAL DOUBLE-STRUCK SMALL B
+    'c': '\U0001d554',  # 𝕔 MATHEMATICAL DOUBLE-STRUCK SMALL C
+    'd': '\U0001d555',  # 𝕕 MATHEMATICAL DOUBLE-STRUCK SMALL D
+    'e': '\U0001d556',  # 𝕖 MATHEMATICAL DOUBLE-STRUCK SMALL E
+    'f': '\U0001d557',  # 𝕗 MATHEMATICAL DOUBLE-STRUCK SMALL F
+    'g': '\U0001d558',  # 𝕘 MATHEMATICAL DOUBLE-STRUCK SMALL G
+    'h': '\U0001d559',  # 𝕙 MATHEMATICAL DOUBLE-STRUCK SMALL H
+    'i': '\U0001d55a',  # 𝕚 MATHEMATICAL DOUBLE-STRUCK SMALL I
+    'j': '\U0001d55b',  # 𝕛 MATHEMATICAL DOUBLE-STRUCK SMALL J
+    'k': '\U0001d55c',  # 𝕜 MATHEMATICAL DOUBLE-STRUCK SMALL K
+    'l': '\U0001d55d',  # 𝕝 MATHEMATICAL DOUBLE-STRUCK SMALL L
+    'm': '\U0001d55e',  # 𝕞 MATHEMATICAL DOUBLE-STRUCK SMALL M
+    'n': '\U0001d55f',  # 𝕟 MATHEMATICAL DOUBLE-STRUCK SMALL N
+    'o': '\U0001d560',  # 𝕠 MATHEMATICAL DOUBLE-STRUCK SMALL O
+    'p': '\U0001d561',  # 𝕡 MATHEMATICAL DOUBLE-STRUCK SMALL P
+    'q': '\U0001d562',  # 𝕢 MATHEMATICAL DOUBLE-STRUCK SMALL Q
+    'r': '\U0001d563',  # 𝕣 MATHEMATICAL DOUBLE-STRUCK SMALL R
+    's': '\U0001d564',  # 𝕤 MATHEMATICAL DOUBLE-STRUCK SMALL S
+    't': '\U0001d565',  # 𝕥 MATHEMATICAL DOUBLE-STRUCK SMALL T
+    'u': '\U0001d566',  # 𝕦 MATHEMATICAL DOUBLE-STRUCK SMALL U
+    'v': '\U0001d567',  # 𝕧 MATHEMATICAL DOUBLE-STRUCK SMALL V
+    'w': '\U0001d568',  # 𝕨 MATHEMATICAL DOUBLE-STRUCK SMALL W
+    'x': '\U0001d569',  # 𝕩 MATHEMATICAL DOUBLE-STRUCK SMALL X
+    'y': '\U0001d56a',  # 𝕪 MATHEMATICAL DOUBLE-STRUCK SMALL Y
+    'z': '\U0001d56b',  # 𝕫 MATHEMATICAL DOUBLE-STRUCK SMALL Z
+    'Γ': '\u213e',  # ℾ DOUBLE-STRUCK CAPITAL GAMMA
+    'Π': '\u213f',  # ℿ DOUBLE-STRUCK CAPITAL PI
+    'Σ': '\u2140',  # ⅀ DOUBLE-STRUCK N-ARY SUMMATION
+    'γ': '\u213d',  # ℽ DOUBLE-STRUCK SMALL GAMMA
+    'π': '\u213c',  # ℼ DOUBLE-STRUCK SMALL PI
+    }
+
+mathbf = {
+    '0': '\U0001d7ce',  # 𝟎 MATHEMATICAL BOLD DIGIT ZERO
+    '1': '\U0001d7cf',  # 𝟏 MATHEMATICAL BOLD DIGIT ONE
+    '2': '\U0001d7d0',  # 𝟐 MATHEMATICAL BOLD DIGIT TWO
+    '3': '\U0001d7d1',  # 𝟑 MATHEMATICAL BOLD DIGIT THREE
+    '4': '\U0001d7d2',  # 𝟒 MATHEMATICAL BOLD DIGIT FOUR
+    '5': '\U0001d7d3',  # 𝟓 MATHEMATICAL BOLD DIGIT FIVE
+    '6': '\U0001d7d4',  # 𝟔 MATHEMATICAL BOLD DIGIT SIX
+    '7': '\U0001d7d5',  # 𝟕 MATHEMATICAL BOLD DIGIT SEVEN
+    '8': '\U0001d7d6',  # 𝟖 MATHEMATICAL BOLD DIGIT EIGHT
+    '9': '\U0001d7d7',  # 𝟗 MATHEMATICAL BOLD DIGIT NINE
+    'A': '\U0001d400',  # 𝐀 MATHEMATICAL BOLD CAPITAL A
+    'B': '\U0001d401',  # 𝐁 MATHEMATICAL BOLD CAPITAL B
+    'C': '\U0001d402',  # 𝐂 MATHEMATICAL BOLD CAPITAL C
+    'D': '\U0001d403',  # 𝐃 MATHEMATICAL BOLD CAPITAL D
+    'E': '\U0001d404',  # 𝐄 MATHEMATICAL BOLD CAPITAL E
+    'F': '\U0001d405',  # 𝐅 MATHEMATICAL BOLD CAPITAL F
+    'G': '\U0001d406',  # 𝐆 MATHEMATICAL BOLD CAPITAL G
+    'H': '\U0001d407',  # 𝐇 MATHEMATICAL BOLD CAPITAL H
+    'I': '\U0001d408',  # 𝐈 MATHEMATICAL BOLD CAPITAL I
+    'J': '\U0001d409',  # 𝐉 MATHEMATICAL BOLD CAPITAL J
+    'K': '\U0001d40a',  # 𝐊 MATHEMATICAL BOLD CAPITAL K
+    'L': '\U0001d40b',  # 𝐋 MATHEMATICAL BOLD CAPITAL L
+    'M': '\U0001d40c',  # 𝐌 MATHEMATICAL BOLD CAPITAL M
+    'N': '\U0001d40d',  # 𝐍 MATHEMATICAL BOLD CAPITAL N
+    'O': '\U0001d40e',  # 𝐎 MATHEMATICAL BOLD CAPITAL O
+    'P': '\U0001d40f',  # 𝐏 MATHEMATICAL BOLD CAPITAL P
+    'Q': '\U0001d410',  # 𝐐 MATHEMATICAL BOLD CAPITAL Q
+    'R': '\U0001d411',  # 𝐑 MATHEMATICAL BOLD CAPITAL R
+    'S': '\U0001d412',  # 𝐒 MATHEMATICAL BOLD CAPITAL S
+    'T': '\U0001d413',  # 𝐓 MATHEMATICAL BOLD CAPITAL T
+    'U': '\U0001d414',  # 𝐔 MATHEMATICAL BOLD CAPITAL U
+    'V': '\U0001d415',  # 𝐕 MATHEMATICAL BOLD CAPITAL V
+    'W': '\U0001d416',  # 𝐖 MATHEMATICAL BOLD CAPITAL W
+    'X': '\U0001d417',  # 𝐗 MATHEMATICAL BOLD CAPITAL X
+    'Y': '\U0001d418',  # 𝐘 MATHEMATICAL BOLD CAPITAL Y
+    'Z': '\U0001d419',  # 𝐙 MATHEMATICAL BOLD CAPITAL Z
+    'a': '\U0001d41a',  # 𝐚 MATHEMATICAL BOLD SMALL A
+    'b': '\U0001d41b',  # 𝐛 MATHEMATICAL BOLD SMALL B
+    'c': '\U0001d41c',  # 𝐜 MATHEMATICAL BOLD SMALL C
+    'd': '\U0001d41d',  # 𝐝 MATHEMATICAL BOLD SMALL D
+    'e': '\U0001d41e',  # 𝐞 MATHEMATICAL BOLD SMALL E
+    'f': '\U0001d41f',  # 𝐟 MATHEMATICAL BOLD SMALL F
+    'g': '\U0001d420',  # 𝐠 MATHEMATICAL BOLD SMALL G
+    'h': '\U0001d421',  # 𝐡 MATHEMATICAL BOLD SMALL H
+    'i': '\U0001d422',  # 𝐢 MATHEMATICAL BOLD SMALL I
+    'j': '\U0001d423',  # 𝐣 MATHEMATICAL BOLD SMALL J
+    'k': '\U0001d424',  # 𝐤 MATHEMATICAL BOLD SMALL K
+    'l': '\U0001d425',  # 𝐥 MATHEMATICAL BOLD SMALL L
+    'm': '\U0001d426',  # 𝐦 MATHEMATICAL BOLD SMALL M
+    'n': '\U0001d427',  # 𝐧 MATHEMATICAL BOLD SMALL N
+    'o': '\U0001d428',  # 𝐨 MATHEMATICAL BOLD SMALL O
+    'p': '\U0001d429',  # 𝐩 MATHEMATICAL BOLD SMALL P
+    'q': '\U0001d42a',  # 𝐪 MATHEMATICAL BOLD SMALL Q
+    'r': '\U0001d42b',  # 𝐫 MATHEMATICAL BOLD SMALL R
+    's': '\U0001d42c',  # 𝐬 MATHEMATICAL BOLD SMALL S
+    't': '\U0001d42d',  # 𝐭 MATHEMATICAL BOLD SMALL T
+    'u': '\U0001d42e',  # 𝐮 MATHEMATICAL BOLD SMALL U
+    'v': '\U0001d42f',  # 𝐯 MATHEMATICAL BOLD SMALL V
+    'w': '\U0001d430',  # 𝐰 MATHEMATICAL BOLD SMALL W
+    'x': '\U0001d431',  # 𝐱 MATHEMATICAL BOLD SMALL X
+    'y': '\U0001d432',  # 𝐲 MATHEMATICAL BOLD SMALL Y
+    'z': '\U0001d433',  # 𝐳 MATHEMATICAL BOLD SMALL Z
+    'Γ': '\U0001d6aa',  # 𝚪 MATHEMATICAL BOLD CAPITAL GAMMA
+    'Δ': '\U0001d6ab',  # 𝚫 MATHEMATICAL BOLD CAPITAL DELTA
+    'Θ': '\U0001d6af',  # 𝚯 MATHEMATICAL BOLD CAPITAL THETA
+    'Λ': '\U0001d6b2',  # 𝚲 MATHEMATICAL BOLD CAPITAL LAMDA
+    'Ξ': '\U0001d6b5',  # 𝚵 MATHEMATICAL BOLD CAPITAL XI
+    'Π': '\U0001d6b7',  # 𝚷 MATHEMATICAL BOLD CAPITAL PI
+    'Σ': '\U0001d6ba',  # 𝚺 MATHEMATICAL BOLD CAPITAL SIGMA
+    'Υ': '\U0001d6bc',  # 𝚼 MATHEMATICAL BOLD CAPITAL UPSILON
+    'Φ': '\U0001d6bd',  # 𝚽 MATHEMATICAL BOLD CAPITAL PHI
+    'Ψ': '\U0001d6bf',  # 𝚿 MATHEMATICAL BOLD CAPITAL PSI
+    'Ω': '\U0001d6c0',  # 𝛀 MATHEMATICAL BOLD CAPITAL OMEGA
+    'α': '\U0001d6c2',  # 𝛂 MATHEMATICAL BOLD SMALL ALPHA
+    'β': '\U0001d6c3',  # 𝛃 MATHEMATICAL BOLD SMALL BETA
+    'γ': '\U0001d6c4',  # 𝛄 MATHEMATICAL BOLD SMALL GAMMA
+    'δ': '\U0001d6c5',  # 𝛅 MATHEMATICAL BOLD SMALL DELTA
+    'ε': '\U0001d6c6',  # 𝛆 MATHEMATICAL BOLD SMALL EPSILON
+    'ζ': '\U0001d6c7',  # 𝛇 MATHEMATICAL BOLD SMALL ZETA
+    'η': '\U0001d6c8',  # 𝛈 MATHEMATICAL BOLD SMALL ETA
+    'θ': '\U0001d6c9',  # 𝛉 MATHEMATICAL BOLD SMALL THETA
+    'ι': '\U0001d6ca',  # 𝛊 MATHEMATICAL BOLD SMALL IOTA
+    'κ': '\U0001d6cb',  # 𝛋 MATHEMATICAL BOLD SMALL KAPPA
+    'λ': '\U0001d6cc',  # 𝛌 MATHEMATICAL BOLD SMALL LAMDA
+    'μ': '\U0001d6cd',  # 𝛍 MATHEMATICAL BOLD SMALL MU
+    'ν': '\U0001d6ce',  # 𝛎 MATHEMATICAL BOLD SMALL NU
+    'ξ': '\U0001d6cf',  # 𝛏 MATHEMATICAL BOLD SMALL XI
+    'π': '\U0001d6d1',  # 𝛑 MATHEMATICAL BOLD SMALL PI
+    'ρ': '\U0001d6d2',  # 𝛒 MATHEMATICAL BOLD SMALL RHO
+    'ς': '\U0001d6d3',  # 𝛓 MATHEMATICAL BOLD SMALL FINAL SIGMA
+    'σ': '\U0001d6d4',  # 𝛔 MATHEMATICAL BOLD SMALL SIGMA
+    'τ': '\U0001d6d5',  # 𝛕 MATHEMATICAL BOLD SMALL TAU
+    'υ': '\U0001d6d6',  # 𝛖 MATHEMATICAL BOLD SMALL UPSILON
+    'φ': '\U0001d6d7',  # 𝛗 MATHEMATICAL BOLD SMALL PHI
+    'χ': '\U0001d6d8',  # 𝛘 MATHEMATICAL BOLD SMALL CHI
+    'ψ': '\U0001d6d9',  # 𝛙 MATHEMATICAL BOLD SMALL PSI
+    'ω': '\U0001d6da',  # 𝛚 MATHEMATICAL BOLD SMALL OMEGA
+    'ϑ': '\U0001d6dd',  # 𝛝 MATHEMATICAL BOLD THETA SYMBOL
+    'ϕ': '\U0001d6df',  # 𝛟 MATHEMATICAL BOLD PHI SYMBOL
+    'ϖ': '\U0001d6e1',  # 𝛡 MATHEMATICAL BOLD PI SYMBOL
+    'Ϝ': '\U0001d7ca',  # 𝟊 MATHEMATICAL BOLD CAPITAL DIGAMMA
+    'ϝ': '\U0001d7cb',  # 𝟋 MATHEMATICAL BOLD SMALL DIGAMMA
+    'ϰ': '\U0001d6de',  # 𝛞 MATHEMATICAL BOLD KAPPA SYMBOL
+    'ϱ': '\U0001d6e0',  # 𝛠 MATHEMATICAL BOLD RHO SYMBOL
+    'ϵ': '\U0001d6dc',  # 𝛜 MATHEMATICAL BOLD EPSILON SYMBOL
+    '∂': '\U0001d6db',  # 𝛛 MATHEMATICAL BOLD PARTIAL DIFFERENTIAL
+    '∇': '\U0001d6c1',  # 𝛁 MATHEMATICAL BOLD NABLA
+    }
+
+mathbfit = {
+    'A': '\U0001d468',  # 𝑨 MATHEMATICAL BOLD ITALIC CAPITAL A
+    'B': '\U0001d469',  # 𝑩 MATHEMATICAL BOLD ITALIC CAPITAL B
+    'C': '\U0001d46a',  # 𝑪 MATHEMATICAL BOLD ITALIC CAPITAL C
+    'D': '\U0001d46b',  # 𝑫 MATHEMATICAL BOLD ITALIC CAPITAL D
+    'E': '\U0001d46c',  # 𝑬 MATHEMATICAL BOLD ITALIC CAPITAL E
+    'F': '\U0001d46d',  # 𝑭 MATHEMATICAL BOLD ITALIC CAPITAL F
+    'G': '\U0001d46e',  # 𝑮 MATHEMATICAL BOLD ITALIC CAPITAL G
+    'H': '\U0001d46f',  # 𝑯 MATHEMATICAL BOLD ITALIC CAPITAL H
+    'I': '\U0001d470',  # 𝑰 MATHEMATICAL BOLD ITALIC CAPITAL I
+    'J': '\U0001d471',  # 𝑱 MATHEMATICAL BOLD ITALIC CAPITAL J
+    'K': '\U0001d472',  # 𝑲 MATHEMATICAL BOLD ITALIC CAPITAL K
+    'L': '\U0001d473',  # 𝑳 MATHEMATICAL BOLD ITALIC CAPITAL L
+    'M': '\U0001d474',  # 𝑴 MATHEMATICAL BOLD ITALIC CAPITAL M
+    'N': '\U0001d475',  # 𝑵 MATHEMATICAL BOLD ITALIC CAPITAL N
+    'O': '\U0001d476',  # 𝑶 MATHEMATICAL BOLD ITALIC CAPITAL O
+    'P': '\U0001d477',  # 𝑷 MATHEMATICAL BOLD ITALIC CAPITAL P
+    'Q': '\U0001d478',  # 𝑸 MATHEMATICAL BOLD ITALIC CAPITAL Q
+    'R': '\U0001d479',  # 𝑹 MATHEMATICAL BOLD ITALIC CAPITAL R
+    'S': '\U0001d47a',  # 𝑺 MATHEMATICAL BOLD ITALIC CAPITAL S
+    'T': '\U0001d47b',  # 𝑻 MATHEMATICAL BOLD ITALIC CAPITAL T
+    'U': '\U0001d47c',  # 𝑼 MATHEMATICAL BOLD ITALIC CAPITAL U
+    'V': '\U0001d47d',  # 𝑽 MATHEMATICAL BOLD ITALIC CAPITAL V
+    'W': '\U0001d47e',  # 𝑾 MATHEMATICAL BOLD ITALIC CAPITAL W
+    'X': '\U0001d47f',  # 𝑿 MATHEMATICAL BOLD ITALIC CAPITAL X
+    'Y': '\U0001d480',  # 𝒀 MATHEMATICAL BOLD ITALIC CAPITAL Y
+    'Z': '\U0001d481',  # 𝒁 MATHEMATICAL BOLD ITALIC CAPITAL Z
+    'a': '\U0001d482',  # 𝒂 MATHEMATICAL BOLD ITALIC SMALL A
+    'b': '\U0001d483',  # 𝒃 MATHEMATICAL BOLD ITALIC SMALL B
+    'c': '\U0001d484',  # 𝒄 MATHEMATICAL BOLD ITALIC SMALL C
+    'd': '\U0001d485',  # 𝒅 MATHEMATICAL BOLD ITALIC SMALL D
+    'e': '\U0001d486',  # 𝒆 MATHEMATICAL BOLD ITALIC SMALL E
+    'f': '\U0001d487',  # 𝒇 MATHEMATICAL BOLD ITALIC SMALL F
+    'g': '\U0001d488',  # 𝒈 MATHEMATICAL BOLD ITALIC SMALL G
+    'h': '\U0001d489',  # 𝒉 MATHEMATICAL BOLD ITALIC SMALL H
+    'i': '\U0001d48a',  # 𝒊 MATHEMATICAL BOLD ITALIC SMALL I
+    'j': '\U0001d48b',  # 𝒋 MATHEMATICAL BOLD ITALIC SMALL J
+    'k': '\U0001d48c',  # 𝒌 MATHEMATICAL BOLD ITALIC SMALL K
+    'l': '\U0001d48d',  # 𝒍 MATHEMATICAL BOLD ITALIC SMALL L
+    'm': '\U0001d48e',  # 𝒎 MATHEMATICAL BOLD ITALIC SMALL M
+    'n': '\U0001d48f',  # 𝒏 MATHEMATICAL BOLD ITALIC SMALL N
+    'o': '\U0001d490',  # 𝒐 MATHEMATICAL BOLD ITALIC SMALL O
+    'p': '\U0001d491',  # 𝒑 MATHEMATICAL BOLD ITALIC SMALL P
+    'q': '\U0001d492',  # 𝒒 MATHEMATICAL BOLD ITALIC SMALL Q
+    'r': '\U0001d493',  # 𝒓 MATHEMATICAL BOLD ITALIC SMALL R
+    's': '\U0001d494',  # 𝒔 MATHEMATICAL BOLD ITALIC SMALL S
+    't': '\U0001d495',  # 𝒕 MATHEMATICAL BOLD ITALIC SMALL T
+    'u': '\U0001d496',  # 𝒖 MATHEMATICAL BOLD ITALIC SMALL U
+    'v': '\U0001d497',  # 𝒗 MATHEMATICAL BOLD ITALIC SMALL V
+    'w': '\U0001d498',  # 𝒘 MATHEMATICAL BOLD ITALIC SMALL W
+    'x': '\U0001d499',  # 𝒙 MATHEMATICAL BOLD ITALIC SMALL X
+    'y': '\U0001d49a',  # 𝒚 MATHEMATICAL BOLD ITALIC SMALL Y
+    'z': '\U0001d49b',  # 𝒛 MATHEMATICAL BOLD ITALIC SMALL Z
+    'Γ': '\U0001d71e',  # 𝜞 MATHEMATICAL BOLD ITALIC CAPITAL GAMMA
+    'Δ': '\U0001d71f',  # 𝜟 MATHEMATICAL BOLD ITALIC CAPITAL DELTA
+    'Θ': '\U0001d723',  # 𝜣 MATHEMATICAL BOLD ITALIC CAPITAL THETA
+    'Λ': '\U0001d726',  # 𝜦 MATHEMATICAL BOLD ITALIC CAPITAL LAMDA
+    'Ξ': '\U0001d729',  # 𝜩 MATHEMATICAL BOLD ITALIC CAPITAL XI
+    'Π': '\U0001d72b',  # 𝜫 MATHEMATICAL BOLD ITALIC CAPITAL PI
+    'Σ': '\U0001d72e',  # 𝜮 MATHEMATICAL BOLD ITALIC CAPITAL SIGMA
+    'Υ': '\U0001d730',  # 𝜰 MATHEMATICAL BOLD ITALIC CAPITAL UPSILON
+    'Φ': '\U0001d731',  # 𝜱 MATHEMATICAL BOLD ITALIC CAPITAL PHI
+    'Ψ': '\U0001d733',  # 𝜳 MATHEMATICAL BOLD ITALIC CAPITAL PSI
+    'Ω': '\U0001d734',  # 𝜴 MATHEMATICAL BOLD ITALIC CAPITAL OMEGA
+    'α': '\U0001d736',  # 𝜶 MATHEMATICAL BOLD ITALIC SMALL ALPHA
+    'β': '\U0001d737',  # 𝜷 MATHEMATICAL BOLD ITALIC SMALL BETA
+    'γ': '\U0001d738',  # 𝜸 MATHEMATICAL BOLD ITALIC SMALL GAMMA
+    'δ': '\U0001d739',  # 𝜹 MATHEMATICAL BOLD ITALIC SMALL DELTA
+    'ε': '\U0001d73a',  # 𝜺 MATHEMATICAL BOLD ITALIC SMALL EPSILON
+    'ζ': '\U0001d73b',  # 𝜻 MATHEMATICAL BOLD ITALIC SMALL ZETA
+    'η': '\U0001d73c',  # 𝜼 MATHEMATICAL BOLD ITALIC SMALL ETA
+    'θ': '\U0001d73d',  # 𝜽 MATHEMATICAL BOLD ITALIC SMALL THETA
+    'ι': '\U0001d73e',  # 𝜾 MATHEMATICAL BOLD ITALIC SMALL IOTA
+    'κ': '\U0001d73f',  # 𝜿 MATHEMATICAL BOLD ITALIC SMALL KAPPA
+    'λ': '\U0001d740',  # 𝝀 MATHEMATICAL BOLD ITALIC SMALL LAMDA
+    'μ': '\U0001d741',  # 𝝁 MATHEMATICAL BOLD ITALIC SMALL MU
+    'ν': '\U0001d742',  # 𝝂 MATHEMATICAL BOLD ITALIC SMALL NU
+    'ξ': '\U0001d743',  # 𝝃 MATHEMATICAL BOLD ITALIC SMALL XI
+    'π': '\U0001d745',  # 𝝅 MATHEMATICAL BOLD ITALIC SMALL PI
+    'ρ': '\U0001d746',  # 𝝆 MATHEMATICAL BOLD ITALIC SMALL RHO
+    'ς': '\U0001d747',  # 𝝇 MATHEMATICAL BOLD ITALIC SMALL FINAL SIGMA
+    'σ': '\U0001d748',  # 𝝈 MATHEMATICAL BOLD ITALIC SMALL SIGMA
+    'τ': '\U0001d749',  # 𝝉 MATHEMATICAL BOLD ITALIC SMALL TAU
+    'υ': '\U0001d74a',  # 𝝊 MATHEMATICAL BOLD ITALIC SMALL UPSILON
+    'φ': '\U0001d74b',  # 𝝋 MATHEMATICAL BOLD ITALIC SMALL PHI
+    'χ': '\U0001d74c',  # 𝝌 MATHEMATICAL BOLD ITALIC SMALL CHI
+    'ψ': '\U0001d74d',  # 𝝍 MATHEMATICAL BOLD ITALIC SMALL PSI
+    'ω': '\U0001d74e',  # 𝝎 MATHEMATICAL BOLD ITALIC SMALL OMEGA
+    'ϑ': '\U0001d751',  # 𝝑 MATHEMATICAL BOLD ITALIC THETA SYMBOL
+    'ϕ': '\U0001d753',  # 𝝓 MATHEMATICAL BOLD ITALIC PHI SYMBOL
+    'ϖ': '\U0001d755',  # 𝝕 MATHEMATICAL BOLD ITALIC PI SYMBOL
+    'ϰ': '\U0001d752',  # 𝝒 MATHEMATICAL BOLD ITALIC KAPPA SYMBOL
+    'ϱ': '\U0001d754',  # 𝝔 MATHEMATICAL BOLD ITALIC RHO SYMBOL
+    'ϵ': '\U0001d750',  # 𝝐 MATHEMATICAL BOLD ITALIC EPSILON SYMBOL
+    '∂': '\U0001d74f',  # 𝝏 MATHEMATICAL BOLD ITALIC PARTIAL DIFFERENTIAL
+    '∇': '\U0001d735',  # 𝜵 MATHEMATICAL BOLD ITALIC NABLA
+    }
+
+mathcal = {
+    'A': '\U0001d49c',  # 𝒜 MATHEMATICAL SCRIPT CAPITAL A
+    'B': '\u212c',  # ℬ SCRIPT CAPITAL B
+    'C': '\U0001d49e',  # 𝒞 MATHEMATICAL SCRIPT CAPITAL C
+    'D': '\U0001d49f',  # 𝒟 MATHEMATICAL SCRIPT CAPITAL D
+    'E': '\u2130',  # ℰ SCRIPT CAPITAL E
+    'F': '\u2131',  # ℱ SCRIPT CAPITAL F
+    'G': '\U0001d4a2',  # 𝒢 MATHEMATICAL SCRIPT CAPITAL G
+    'H': '\u210b',  # ℋ SCRIPT CAPITAL H
+    'I': '\u2110',  # ℐ SCRIPT CAPITAL I
+    'J': '\U0001d4a5',  # 𝒥 MATHEMATICAL SCRIPT CAPITAL J
+    'K': '\U0001d4a6',  # 𝒦 MATHEMATICAL SCRIPT CAPITAL K
+    'L': '\u2112',  # ℒ SCRIPT CAPITAL L
+    'M': '\u2133',  # ℳ SCRIPT CAPITAL M
+    'N': '\U0001d4a9',  # 𝒩 MATHEMATICAL SCRIPT CAPITAL N
+    'O': '\U0001d4aa',  # 𝒪 MATHEMATICAL SCRIPT CAPITAL O
+    'P': '\U0001d4ab',  # 𝒫 MATHEMATICAL SCRIPT CAPITAL P
+    'Q': '\U0001d4ac',  # 𝒬 MATHEMATICAL SCRIPT CAPITAL Q
+    'R': '\u211b',  # ℛ SCRIPT CAPITAL R
+    'S': '\U0001d4ae',  # 𝒮 MATHEMATICAL SCRIPT CAPITAL S
+    'T': '\U0001d4af',  # 𝒯 MATHEMATICAL SCRIPT CAPITAL T
+    'U': '\U0001d4b0',  # 𝒰 MATHEMATICAL SCRIPT CAPITAL U
+    'V': '\U0001d4b1',  # 𝒱 MATHEMATICAL SCRIPT CAPITAL V
+    'W': '\U0001d4b2',  # 𝒲 MATHEMATICAL SCRIPT CAPITAL W
+    'X': '\U0001d4b3',  # 𝒳 MATHEMATICAL SCRIPT CAPITAL X
+    'Y': '\U0001d4b4',  # 𝒴 MATHEMATICAL SCRIPT CAPITAL Y
+    'Z': '\U0001d4b5',  # 𝒵 MATHEMATICAL SCRIPT CAPITAL Z
+    'a': '\U0001d4b6',  # 𝒶 MATHEMATICAL SCRIPT SMALL A
+    'b': '\U0001d4b7',  # 𝒷 MATHEMATICAL SCRIPT SMALL B
+    'c': '\U0001d4b8',  # 𝒸 MATHEMATICAL SCRIPT SMALL C
+    'd': '\U0001d4b9',  # 𝒹 MATHEMATICAL SCRIPT SMALL D
+    'e': '\u212f',  # ℯ SCRIPT SMALL E
+    'f': '\U0001d4bb',  # 𝒻 MATHEMATICAL SCRIPT SMALL F
+    'g': '\u210a',  # ℊ SCRIPT SMALL G
+    'h': '\U0001d4bd',  # 𝒽 MATHEMATICAL SCRIPT SMALL H
+    'i': '\U0001d4be',  # 𝒾 MATHEMATICAL SCRIPT SMALL I
+    'j': '\U0001d4bf',  # 𝒿 MATHEMATICAL SCRIPT SMALL J
+    'k': '\U0001d4c0',  # 𝓀 MATHEMATICAL SCRIPT SMALL K
+    'l': '\U0001d4c1',  # 𝓁 MATHEMATICAL SCRIPT SMALL L
+    'm': '\U0001d4c2',  # 𝓂 MATHEMATICAL SCRIPT SMALL M
+    'n': '\U0001d4c3',  # 𝓃 MATHEMATICAL SCRIPT SMALL N
+    'o': '\u2134',  # ℴ SCRIPT SMALL O
+    'p': '\U0001d4c5',  # 𝓅 MATHEMATICAL SCRIPT SMALL P
+    'q': '\U0001d4c6',  # 𝓆 MATHEMATICAL SCRIPT SMALL Q
+    'r': '\U0001d4c7',  # 𝓇 MATHEMATICAL SCRIPT SMALL R
+    's': '\U0001d4c8',  # 𝓈 MATHEMATICAL SCRIPT SMALL S
+    't': '\U0001d4c9',  # 𝓉 MATHEMATICAL SCRIPT SMALL T
+    'u': '\U0001d4ca',  # 𝓊 MATHEMATICAL SCRIPT SMALL U
+    'v': '\U0001d4cb',  # 𝓋 MATHEMATICAL SCRIPT SMALL V
+    'w': '\U0001d4cc',  # 𝓌 MATHEMATICAL SCRIPT SMALL W
+    'x': '\U0001d4cd',  # 𝓍 MATHEMATICAL SCRIPT SMALL X
+    'y': '\U0001d4ce',  # 𝓎 MATHEMATICAL SCRIPT SMALL Y
+    'z': '\U0001d4cf',  # 𝓏 MATHEMATICAL SCRIPT SMALL Z
+    }
+
+mathfrak = {
+    'A': '\U0001d504',  # 𝔄 MATHEMATICAL FRAKTUR CAPITAL A
+    'B': '\U0001d505',  # 𝔅 MATHEMATICAL FRAKTUR CAPITAL B
+    'C': '\u212d',  # ℭ BLACK-LETTER CAPITAL C
+    'D': '\U0001d507',  # 𝔇 MATHEMATICAL FRAKTUR CAPITAL D
+    'E': '\U0001d508',  # 𝔈 MATHEMATICAL FRAKTUR CAPITAL E
+    'F': '\U0001d509',  # 𝔉 MATHEMATICAL FRAKTUR CAPITAL F
+    'G': '\U0001d50a',  # 𝔊 MATHEMATICAL FRAKTUR CAPITAL G
+    'H': '\u210c',  # ℌ BLACK-LETTER CAPITAL H
+    'I': '\u2111',  # ℑ BLACK-LETTER CAPITAL I
+    'J': '\U0001d50d',  # 𝔍 MATHEMATICAL FRAKTUR CAPITAL J
+    'K': '\U0001d50e',  # 𝔎 MATHEMATICAL FRAKTUR CAPITAL K
+    'L': '\U0001d50f',  # 𝔏 MATHEMATICAL FRAKTUR CAPITAL L
+    'M': '\U0001d510',  # 𝔐 MATHEMATICAL FRAKTUR CAPITAL M
+    'N': '\U0001d511',  # 𝔑 MATHEMATICAL FRAKTUR CAPITAL N
+    'O': '\U0001d512',  # 𝔒 MATHEMATICAL FRAKTUR CAPITAL O
+    'P': '\U0001d513',  # 𝔓 MATHEMATICAL FRAKTUR CAPITAL P
+    'Q': '\U0001d514',  # 𝔔 MATHEMATICAL FRAKTUR CAPITAL Q
+    'R': '\u211c',  # ℜ BLACK-LETTER CAPITAL R
+    'S': '\U0001d516',  # 𝔖 MATHEMATICAL FRAKTUR CAPITAL S
+    'T': '\U0001d517',  # 𝔗 MATHEMATICAL FRAKTUR CAPITAL T
+    'U': '\U0001d518',  # 𝔘 MATHEMATICAL FRAKTUR CAPITAL U
+    'V': '\U0001d519',  # 𝔙 MATHEMATICAL FRAKTUR CAPITAL V
+    'W': '\U0001d51a',  # 𝔚 MATHEMATICAL FRAKTUR CAPITAL W
+    'X': '\U0001d51b',  # 𝔛 MATHEMATICAL FRAKTUR CAPITAL X
+    'Y': '\U0001d51c',  # 𝔜 MATHEMATICAL FRAKTUR CAPITAL Y
+    'Z': '\u2128',  # ℨ BLACK-LETTER CAPITAL Z
+    'a': '\U0001d51e',  # 𝔞 MATHEMATICAL FRAKTUR SMALL A
+    'b': '\U0001d51f',  # 𝔟 MATHEMATICAL FRAKTUR SMALL B
+    'c': '\U0001d520',  # 𝔠 MATHEMATICAL FRAKTUR SMALL C
+    'd': '\U0001d521',  # 𝔡 MATHEMATICAL FRAKTUR SMALL D
+    'e': '\U0001d522',  # 𝔢 MATHEMATICAL FRAKTUR SMALL E
+    'f': '\U0001d523',  # 𝔣 MATHEMATICAL FRAKTUR SMALL F
+    'g': '\U0001d524',  # 𝔤 MATHEMATICAL FRAKTUR SMALL G
+    'h': '\U0001d525',  # 𝔥 MATHEMATICAL FRAKTUR SMALL H
+    'i': '\U0001d526',  # 𝔦 MATHEMATICAL FRAKTUR SMALL I
+    'j': '\U0001d527',  # 𝔧 MATHEMATICAL FRAKTUR SMALL J
+    'k': '\U0001d528',  # 𝔨 MATHEMATICAL FRAKTUR SMALL K
+    'l': '\U0001d529',  # 𝔩 MATHEMATICAL FRAKTUR SMALL L
+    'm': '\U0001d52a',  # 𝔪 MATHEMATICAL FRAKTUR SMALL M
+    'n': '\U0001d52b',  # 𝔫 MATHEMATICAL FRAKTUR SMALL N
+    'o': '\U0001d52c',  # 𝔬 MATHEMATICAL FRAKTUR SMALL O
+    'p': '\U0001d52d',  # 𝔭 MATHEMATICAL FRAKTUR SMALL P
+    'q': '\U0001d52e',  # 𝔮 MATHEMATICAL FRAKTUR SMALL Q
+    'r': '\U0001d52f',  # 𝔯 MATHEMATICAL FRAKTUR SMALL R
+    's': '\U0001d530',  # 𝔰 MATHEMATICAL FRAKTUR SMALL S
+    't': '\U0001d531',  # 𝔱 MATHEMATICAL FRAKTUR SMALL T
+    'u': '\U0001d532',  # 𝔲 MATHEMATICAL FRAKTUR SMALL U
+    'v': '\U0001d533',  # 𝔳 MATHEMATICAL FRAKTUR SMALL V
+    'w': '\U0001d534',  # 𝔴 MATHEMATICAL FRAKTUR SMALL W
+    'x': '\U0001d535',  # 𝔵 MATHEMATICAL FRAKTUR SMALL X
+    'y': '\U0001d536',  # 𝔶 MATHEMATICAL FRAKTUR SMALL Y
+    'z': '\U0001d537',  # 𝔷 MATHEMATICAL FRAKTUR SMALL Z
+    }
+
+mathit = {
+    'A': '\U0001d434',  # 𝐴 MATHEMATICAL ITALIC CAPITAL A
+    'B': '\U0001d435',  # 𝐵 MATHEMATICAL ITALIC CAPITAL B
+    'C': '\U0001d436',  # 𝐶 MATHEMATICAL ITALIC CAPITAL C
+    'D': '\U0001d437',  # 𝐷 MATHEMATICAL ITALIC CAPITAL D
+    'E': '\U0001d438',  # 𝐸 MATHEMATICAL ITALIC CAPITAL E
+    'F': '\U0001d439',  # 𝐹 MATHEMATICAL ITALIC CAPITAL F
+    'G': '\U0001d43a',  # 𝐺 MATHEMATICAL ITALIC CAPITAL G
+    'H': '\U0001d43b',  # 𝐻 MATHEMATICAL ITALIC CAPITAL H
+    'I': '\U0001d43c',  # 𝐼 MATHEMATICAL ITALIC CAPITAL I
+    'J': '\U0001d43d',  # 𝐽 MATHEMATICAL ITALIC CAPITAL J
+    'K': '\U0001d43e',  # 𝐾 MATHEMATICAL ITALIC CAPITAL K
+    'L': '\U0001d43f',  # 𝐿 MATHEMATICAL ITALIC CAPITAL L
+    'M': '\U0001d440',  # 𝑀 MATHEMATICAL ITALIC CAPITAL M
+    'N': '\U0001d441',  # 𝑁 MATHEMATICAL ITALIC CAPITAL N
+    'O': '\U0001d442',  # 𝑂 MATHEMATICAL ITALIC CAPITAL O
+    'P': '\U0001d443',  # 𝑃 MATHEMATICAL ITALIC CAPITAL P
+    'Q': '\U0001d444',  # 𝑄 MATHEMATICAL ITALIC CAPITAL Q
+    'R': '\U0001d445',  # 𝑅 MATHEMATICAL ITALIC CAPITAL R
+    'S': '\U0001d446',  # 𝑆 MATHEMATICAL ITALIC CAPITAL S
+    'T': '\U0001d447',  # 𝑇 MATHEMATICAL ITALIC CAPITAL T
+    'U': '\U0001d448',  # 𝑈 MATHEMATICAL ITALIC CAPITAL U
+    'V': '\U0001d449',  # 𝑉 MATHEMATICAL ITALIC CAPITAL V
+    'W': '\U0001d44a',  # 𝑊 MATHEMATICAL ITALIC CAPITAL W
+    'X': '\U0001d44b',  # 𝑋 MATHEMATICAL ITALIC CAPITAL X
+    'Y': '\U0001d44c',  # 𝑌 MATHEMATICAL ITALIC CAPITAL Y
+    'Z': '\U0001d44d',  # 𝑍 MATHEMATICAL ITALIC CAPITAL Z
+    'a': '\U0001d44e',  # 𝑎 MATHEMATICAL ITALIC SMALL A
+    'b': '\U0001d44f',  # 𝑏 MATHEMATICAL ITALIC SMALL B
+    'c': '\U0001d450',  # 𝑐 MATHEMATICAL ITALIC SMALL C
+    'd': '\U0001d451',  # 𝑑 MATHEMATICAL ITALIC SMALL D
+    'e': '\U0001d452',  # 𝑒 MATHEMATICAL ITALIC SMALL E
+    'f': '\U0001d453',  # 𝑓 MATHEMATICAL ITALIC SMALL F
+    'g': '\U0001d454',  # 𝑔 MATHEMATICAL ITALIC SMALL G
+    'h': '\u210e',  # ℎ PLANCK CONSTANT
+    'i': '\U0001d456',  # 𝑖 MATHEMATICAL ITALIC SMALL I
+    'j': '\U0001d457',  # 𝑗 MATHEMATICAL ITALIC SMALL J
+    'k': '\U0001d458',  # 𝑘 MATHEMATICAL ITALIC SMALL K
+    'l': '\U0001d459',  # 𝑙 MATHEMATICAL ITALIC SMALL L
+    'm': '\U0001d45a',  # 𝑚 MATHEMATICAL ITALIC SMALL M
+    'n': '\U0001d45b',  # 𝑛 MATHEMATICAL ITALIC SMALL N
+    'o': '\U0001d45c',  # 𝑜 MATHEMATICAL ITALIC SMALL O
+    'p': '\U0001d45d',  # 𝑝 MATHEMATICAL ITALIC SMALL P
+    'q': '\U0001d45e',  # 𝑞 MATHEMATICAL ITALIC SMALL Q
+    'r': '\U0001d45f',  # 𝑟 MATHEMATICAL ITALIC SMALL R
+    's': '\U0001d460',  # 𝑠 MATHEMATICAL ITALIC SMALL S
+    't': '\U0001d461',  # 𝑡 MATHEMATICAL ITALIC SMALL T
+    'u': '\U0001d462',  # 𝑢 MATHEMATICAL ITALIC SMALL U
+    'v': '\U0001d463',  # 𝑣 MATHEMATICAL ITALIC SMALL V
+    'w': '\U0001d464',  # 𝑤 MATHEMATICAL ITALIC SMALL W
+    'x': '\U0001d465',  # 𝑥 MATHEMATICAL ITALIC SMALL X
+    'y': '\U0001d466',  # 𝑦 MATHEMATICAL ITALIC SMALL Y
+    'z': '\U0001d467',  # 𝑧 MATHEMATICAL ITALIC SMALL Z
+    'ı': '\U0001d6a4',  # 𝚤 MATHEMATICAL ITALIC SMALL DOTLESS I
+    'ȷ': '\U0001d6a5',  # 𝚥 MATHEMATICAL ITALIC SMALL DOTLESS J
+    'Γ': '\U0001d6e4',  # 𝛤 MATHEMATICAL ITALIC CAPITAL GAMMA
+    'Δ': '\U0001d6e5',  # 𝛥 MATHEMATICAL ITALIC CAPITAL DELTA
+    'Θ': '\U0001d6e9',  # 𝛩 MATHEMATICAL ITALIC CAPITAL THETA
+    'Λ': '\U0001d6ec',  # 𝛬 MATHEMATICAL ITALIC CAPITAL LAMDA
+    'Ξ': '\U0001d6ef',  # 𝛯 MATHEMATICAL ITALIC CAPITAL XI
+    'Π': '\U0001d6f1',  # 𝛱 MATHEMATICAL ITALIC CAPITAL PI
+    'Σ': '\U0001d6f4',  # 𝛴 MATHEMATICAL ITALIC CAPITAL SIGMA
+    'Υ': '\U0001d6f6',  # 𝛶 MATHEMATICAL ITALIC CAPITAL UPSILON
+    'Φ': '\U0001d6f7',  # 𝛷 MATHEMATICAL ITALIC CAPITAL PHI
+    'Ψ': '\U0001d6f9',  # 𝛹 MATHEMATICAL ITALIC CAPITAL PSI
+    'Ω': '\U0001d6fa',  # 𝛺 MATHEMATICAL ITALIC CAPITAL OMEGA
+    'α': '\U0001d6fc',  # 𝛼 MATHEMATICAL ITALIC SMALL ALPHA
+    'β': '\U0001d6fd',  # 𝛽 MATHEMATICAL ITALIC SMALL BETA
+    'γ': '\U0001d6fe',  # 𝛾 MATHEMATICAL ITALIC SMALL GAMMA
+    'δ': '\U0001d6ff',  # 𝛿 MATHEMATICAL ITALIC SMALL DELTA
+    'ε': '\U0001d700',  # 𝜀 MATHEMATICAL ITALIC SMALL EPSILON
+    'ζ': '\U0001d701',  # 𝜁 MATHEMATICAL ITALIC SMALL ZETA
+    'η': '\U0001d702',  # 𝜂 MATHEMATICAL ITALIC SMALL ETA
+    'θ': '\U0001d703',  # 𝜃 MATHEMATICAL ITALIC SMALL THETA
+    'ι': '\U0001d704',  # 𝜄 MATHEMATICAL ITALIC SMALL IOTA
+    'κ': '\U0001d705',  # 𝜅 MATHEMATICAL ITALIC SMALL KAPPA
+    'λ': '\U0001d706',  # 𝜆 MATHEMATICAL ITALIC SMALL LAMDA
+    'μ': '\U0001d707',  # 𝜇 MATHEMATICAL ITALIC SMALL MU
+    'ν': '\U0001d708',  # 𝜈 MATHEMATICAL ITALIC SMALL NU
+    'ξ': '\U0001d709',  # 𝜉 MATHEMATICAL ITALIC SMALL XI
+    'π': '\U0001d70b',  # 𝜋 MATHEMATICAL ITALIC SMALL PI
+    'ρ': '\U0001d70c',  # 𝜌 MATHEMATICAL ITALIC SMALL RHO
+    'ς': '\U0001d70d',  # 𝜍 MATHEMATICAL ITALIC SMALL FINAL SIGMA
+    'σ': '\U0001d70e',  # 𝜎 MATHEMATICAL ITALIC SMALL SIGMA
+    'τ': '\U0001d70f',  # 𝜏 MATHEMATICAL ITALIC SMALL TAU
+    'υ': '\U0001d710',  # 𝜐 MATHEMATICAL ITALIC SMALL UPSILON
+    'φ': '\U0001d711',  # 𝜑 MATHEMATICAL ITALIC SMALL PHI
+    'χ': '\U0001d712',  # 𝜒 MATHEMATICAL ITALIC SMALL CHI
+    'ψ': '\U0001d713',  # 𝜓 MATHEMATICAL ITALIC SMALL PSI
+    'ω': '\U0001d714',  # 𝜔 MATHEMATICAL ITALIC SMALL OMEGA
+    'ϑ': '\U0001d717',  # 𝜗 MATHEMATICAL ITALIC THETA SYMBOL
+    'ϕ': '\U0001d719',  # 𝜙 MATHEMATICAL ITALIC PHI SYMBOL
+    'ϖ': '\U0001d71b',  # 𝜛 MATHEMATICAL ITALIC PI SYMBOL
+    'ϱ': '\U0001d71a',  # 𝜚 MATHEMATICAL ITALIC RHO SYMBOL
+    'ϵ': '\U0001d716',  # 𝜖 MATHEMATICAL ITALIC EPSILON SYMBOL
+    '∂': '\U0001d715',  # 𝜕 MATHEMATICAL ITALIC PARTIAL DIFFERENTIAL
+    '∇': '\U0001d6fb',  # 𝛻 MATHEMATICAL ITALIC NABLA
+    }
+
+mathsf = {
+    '0': '\U0001d7e2',  # 𝟢 MATHEMATICAL SANS-SERIF DIGIT ZERO
+    '1': '\U0001d7e3',  # 𝟣 MATHEMATICAL SANS-SERIF DIGIT ONE
+    '2': '\U0001d7e4',  # 𝟤 MATHEMATICAL SANS-SERIF DIGIT TWO
+    '3': '\U0001d7e5',  # 𝟥 MATHEMATICAL SANS-SERIF DIGIT THREE
+    '4': '\U0001d7e6',  # 𝟦 MATHEMATICAL SANS-SERIF DIGIT FOUR
+    '5': '\U0001d7e7',  # 𝟧 MATHEMATICAL SANS-SERIF DIGIT FIVE
+    '6': '\U0001d7e8',  # 𝟨 MATHEMATICAL SANS-SERIF DIGIT SIX
+    '7': '\U0001d7e9',  # 𝟩 MATHEMATICAL SANS-SERIF DIGIT SEVEN
+    '8': '\U0001d7ea',  # 𝟪 MATHEMATICAL SANS-SERIF DIGIT EIGHT
+    '9': '\U0001d7eb',  # 𝟫 MATHEMATICAL SANS-SERIF DIGIT NINE
+    'A': '\U0001d5a0',  # 𝖠 MATHEMATICAL SANS-SERIF CAPITAL A
+    'B': '\U0001d5a1',  # 𝖡 MATHEMATICAL SANS-SERIF CAPITAL B
+    'C': '\U0001d5a2',  # 𝖢 MATHEMATICAL SANS-SERIF CAPITAL C
+    'D': '\U0001d5a3',  # 𝖣 MATHEMATICAL SANS-SERIF CAPITAL D
+    'E': '\U0001d5a4',  # 𝖤 MATHEMATICAL SANS-SERIF CAPITAL E
+    'F': '\U0001d5a5',  # 𝖥 MATHEMATICAL SANS-SERIF CAPITAL F
+    'G': '\U0001d5a6',  # 𝖦 MATHEMATICAL SANS-SERIF CAPITAL G
+    'H': '\U0001d5a7',  # 𝖧 MATHEMATICAL SANS-SERIF CAPITAL H
+    'I': '\U0001d5a8',  # 𝖨 MATHEMATICAL SANS-SERIF CAPITAL I
+    'J': '\U0001d5a9',  # 𝖩 MATHEMATICAL SANS-SERIF CAPITAL J
+    'K': '\U0001d5aa',  # 𝖪 MATHEMATICAL SANS-SERIF CAPITAL K
+    'L': '\U0001d5ab',  # 𝖫 MATHEMATICAL SANS-SERIF CAPITAL L
+    'M': '\U0001d5ac',  # 𝖬 MATHEMATICAL SANS-SERIF CAPITAL M
+    'N': '\U0001d5ad',  # 𝖭 MATHEMATICAL SANS-SERIF CAPITAL N
+    'O': '\U0001d5ae',  # 𝖮 MATHEMATICAL SANS-SERIF CAPITAL O
+    'P': '\U0001d5af',  # 𝖯 MATHEMATICAL SANS-SERIF CAPITAL P
+    'Q': '\U0001d5b0',  # 𝖰 MATHEMATICAL SANS-SERIF CAPITAL Q
+    'R': '\U0001d5b1',  # 𝖱 MATHEMATICAL SANS-SERIF CAPITAL R
+    'S': '\U0001d5b2',  # 𝖲 MATHEMATICAL SANS-SERIF CAPITAL S
+    'T': '\U0001d5b3',  # 𝖳 MATHEMATICAL SANS-SERIF CAPITAL T
+    'U': '\U0001d5b4',  # 𝖴 MATHEMATICAL SANS-SERIF CAPITAL U
+    'V': '\U0001d5b5',  # 𝖵 MATHEMATICAL SANS-SERIF CAPITAL V
+    'W': '\U0001d5b6',  # 𝖶 MATHEMATICAL SANS-SERIF CAPITAL W
+    'X': '\U0001d5b7',  # 𝖷 MATHEMATICAL SANS-SERIF CAPITAL X
+    'Y': '\U0001d5b8',  # 𝖸 MATHEMATICAL SANS-SERIF CAPITAL Y
+    'Z': '\U0001d5b9',  # 𝖹 MATHEMATICAL SANS-SERIF CAPITAL Z
+    'a': '\U0001d5ba',  # 𝖺 MATHEMATICAL SANS-SERIF SMALL A
+    'b': '\U0001d5bb',  # 𝖻 MATHEMATICAL SANS-SERIF SMALL B
+    'c': '\U0001d5bc',  # 𝖼 MATHEMATICAL SANS-SERIF SMALL C
+    'd': '\U0001d5bd',  # 𝖽 MATHEMATICAL SANS-SERIF SMALL D
+    'e': '\U0001d5be',  # 𝖾 MATHEMATICAL SANS-SERIF SMALL E
+    'f': '\U0001d5bf',  # 𝖿 MATHEMATICAL SANS-SERIF SMALL F
+    'g': '\U0001d5c0',  # 𝗀 MATHEMATICAL SANS-SERIF SMALL G
+    'h': '\U0001d5c1',  # 𝗁 MATHEMATICAL SANS-SERIF SMALL H
+    'i': '\U0001d5c2',  # 𝗂 MATHEMATICAL SANS-SERIF SMALL I
+    'j': '\U0001d5c3',  # 𝗃 MATHEMATICAL SANS-SERIF SMALL J
+    'k': '\U0001d5c4',  # 𝗄 MATHEMATICAL SANS-SERIF SMALL K
+    'l': '\U0001d5c5',  # 𝗅 MATHEMATICAL SANS-SERIF SMALL L
+    'm': '\U0001d5c6',  # 𝗆 MATHEMATICAL SANS-SERIF SMALL M
+    'n': '\U0001d5c7',  # 𝗇 MATHEMATICAL SANS-SERIF SMALL N
+    'o': '\U0001d5c8',  # 𝗈 MATHEMATICAL SANS-SERIF SMALL O
+    'p': '\U0001d5c9',  # 𝗉 MATHEMATICAL SANS-SERIF SMALL P
+    'q': '\U0001d5ca',  # 𝗊 MATHEMATICAL SANS-SERIF SMALL Q
+    'r': '\U0001d5cb',  # 𝗋 MATHEMATICAL SANS-SERIF SMALL R
+    's': '\U0001d5cc',  # 𝗌 MATHEMATICAL SANS-SERIF SMALL S
+    't': '\U0001d5cd',  # 𝗍 MATHEMATICAL SANS-SERIF SMALL T
+    'u': '\U0001d5ce',  # 𝗎 MATHEMATICAL SANS-SERIF SMALL U
+    'v': '\U0001d5cf',  # 𝗏 MATHEMATICAL SANS-SERIF SMALL V
+    'w': '\U0001d5d0',  # 𝗐 MATHEMATICAL SANS-SERIF SMALL W
+    'x': '\U0001d5d1',  # 𝗑 MATHEMATICAL SANS-SERIF SMALL X
+    'y': '\U0001d5d2',  # 𝗒 MATHEMATICAL SANS-SERIF SMALL Y
+    'z': '\U0001d5d3',  # 𝗓 MATHEMATICAL SANS-SERIF SMALL Z
+    }
+
+mathsfbf = {
+    '0': '\U0001d7ec',  # 𝟬 MATHEMATICAL SANS-SERIF BOLD DIGIT ZERO
+    '1': '\U0001d7ed',  # 𝟭 MATHEMATICAL SANS-SERIF BOLD DIGIT ONE
+    '2': '\U0001d7ee',  # 𝟮 MATHEMATICAL SANS-SERIF BOLD DIGIT TWO
+    '3': '\U0001d7ef',  # 𝟯 MATHEMATICAL SANS-SERIF BOLD DIGIT THREE
+    '4': '\U0001d7f0',  # 𝟰 MATHEMATICAL SANS-SERIF BOLD DIGIT FOUR
+    '5': '\U0001d7f1',  # 𝟱 MATHEMATICAL SANS-SERIF BOLD DIGIT FIVE
+    '6': '\U0001d7f2',  # 𝟲 MATHEMATICAL SANS-SERIF BOLD DIGIT SIX
+    '7': '\U0001d7f3',  # 𝟳 MATHEMATICAL SANS-SERIF BOLD DIGIT SEVEN
+    '8': '\U0001d7f4',  # 𝟴 MATHEMATICAL SANS-SERIF BOLD DIGIT EIGHT
+    '9': '\U0001d7f5',  # 𝟵 MATHEMATICAL SANS-SERIF BOLD DIGIT NINE
+    'A': '\U0001d5d4',  # 𝗔 MATHEMATICAL SANS-SERIF BOLD CAPITAL A
+    'B': '\U0001d5d5',  # 𝗕 MATHEMATICAL SANS-SERIF BOLD CAPITAL B
+    'C': '\U0001d5d6',  # 𝗖 MATHEMATICAL SANS-SERIF BOLD CAPITAL C
+    'D': '\U0001d5d7',  # 𝗗 MATHEMATICAL SANS-SERIF BOLD CAPITAL D
+    'E': '\U0001d5d8',  # 𝗘 MATHEMATICAL SANS-SERIF BOLD CAPITAL E
+    'F': '\U0001d5d9',  # 𝗙 MATHEMATICAL SANS-SERIF BOLD CAPITAL F
+    'G': '\U0001d5da',  # 𝗚 MATHEMATICAL SANS-SERIF BOLD CAPITAL G
+    'H': '\U0001d5db',  # 𝗛 MATHEMATICAL SANS-SERIF BOLD CAPITAL H
+    'I': '\U0001d5dc',  # 𝗜 MATHEMATICAL SANS-SERIF BOLD CAPITAL I
+    'J': '\U0001d5dd',  # 𝗝 MATHEMATICAL SANS-SERIF BOLD CAPITAL J
+    'K': '\U0001d5de',  # 𝗞 MATHEMATICAL SANS-SERIF BOLD CAPITAL K
+    'L': '\U0001d5df',  # 𝗟 MATHEMATICAL SANS-SERIF BOLD CAPITAL L
+    'M': '\U0001d5e0',  # 𝗠 MATHEMATICAL SANS-SERIF BOLD CAPITAL M
+    'N': '\U0001d5e1',  # 𝗡 MATHEMATICAL SANS-SERIF BOLD CAPITAL N
+    'O': '\U0001d5e2',  # 𝗢 MATHEMATICAL SANS-SERIF BOLD CAPITAL O
+    'P': '\U0001d5e3',  # 𝗣 MATHEMATICAL SANS-SERIF BOLD CAPITAL P
+    'Q': '\U0001d5e4',  # 𝗤 MATHEMATICAL SANS-SERIF BOLD CAPITAL Q
+    'R': '\U0001d5e5',  # 𝗥 MATHEMATICAL SANS-SERIF BOLD CAPITAL R
+    'S': '\U0001d5e6',  # 𝗦 MATHEMATICAL SANS-SERIF BOLD CAPITAL S
+    'T': '\U0001d5e7',  # 𝗧 MATHEMATICAL SANS-SERIF BOLD CAPITAL T
+    'U': '\U0001d5e8',  # 𝗨 MATHEMATICAL SANS-SERIF BOLD CAPITAL U
+    'V': '\U0001d5e9',  # 𝗩 MATHEMATICAL SANS-SERIF BOLD CAPITAL V
+    'W': '\U0001d5ea',  # 𝗪 MATHEMATICAL SANS-SERIF BOLD CAPITAL W
+    'X': '\U0001d5eb',  # 𝗫 MATHEMATICAL SANS-SERIF BOLD CAPITAL X
+    'Y': '\U0001d5ec',  # 𝗬 MATHEMATICAL SANS-SERIF BOLD CAPITAL Y
+    'Z': '\U0001d5ed',  # 𝗭 MATHEMATICAL SANS-SERIF BOLD CAPITAL Z
+    'a': '\U0001d5ee',  # 𝗮 MATHEMATICAL SANS-SERIF BOLD SMALL A
+    'b': '\U0001d5ef',  # 𝗯 MATHEMATICAL SANS-SERIF BOLD SMALL B
+    'c': '\U0001d5f0',  # 𝗰 MATHEMATICAL SANS-SERIF BOLD SMALL C
+    'd': '\U0001d5f1',  # 𝗱 MATHEMATICAL SANS-SERIF BOLD SMALL D
+    'e': '\U0001d5f2',  # 𝗲 MATHEMATICAL SANS-SERIF BOLD SMALL E
+    'f': '\U0001d5f3',  # 𝗳 MATHEMATICAL SANS-SERIF BOLD SMALL F
+    'g': '\U0001d5f4',  # 𝗴 MATHEMATICAL SANS-SERIF BOLD SMALL G
+    'h': '\U0001d5f5',  # 𝗵 MATHEMATICAL SANS-SERIF BOLD SMALL H
+    'i': '\U0001d5f6',  # 𝗶 MATHEMATICAL SANS-SERIF BOLD SMALL I
+    'j': '\U0001d5f7',  # 𝗷 MATHEMATICAL SANS-SERIF BOLD SMALL J
+    'k': '\U0001d5f8',  # 𝗸 MATHEMATICAL SANS-SERIF BOLD SMALL K
+    'l': '\U0001d5f9',  # 𝗹 MATHEMATICAL SANS-SERIF BOLD SMALL L
+    'm': '\U0001d5fa',  # 𝗺 MATHEMATICAL SANS-SERIF BOLD SMALL M
+    'n': '\U0001d5fb',  # 𝗻 MATHEMATICAL SANS-SERIF BOLD SMALL N
+    'o': '\U0001d5fc',  # 𝗼 MATHEMATICAL SANS-SERIF BOLD SMALL O
+    'p': '\U0001d5fd',  # 𝗽 MATHEMATICAL SANS-SERIF BOLD SMALL P
+    'q': '\U0001d5fe',  # 𝗾 MATHEMATICAL SANS-SERIF BOLD SMALL Q
+    'r': '\U0001d5ff',  # 𝗿 MATHEMATICAL SANS-SERIF BOLD SMALL R
+    's': '\U0001d600',  # 𝘀 MATHEMATICAL SANS-SERIF BOLD SMALL S
+    't': '\U0001d601',  # 𝘁 MATHEMATICAL SANS-SERIF BOLD SMALL T
+    'u': '\U0001d602',  # 𝘂 MATHEMATICAL SANS-SERIF BOLD SMALL U
+    'v': '\U0001d603',  # 𝘃 MATHEMATICAL SANS-SERIF BOLD SMALL V
+    'w': '\U0001d604',  # 𝘄 MATHEMATICAL SANS-SERIF BOLD SMALL W
+    'x': '\U0001d605',  # 𝘅 MATHEMATICAL SANS-SERIF BOLD SMALL X
+    'y': '\U0001d606',  # 𝘆 MATHEMATICAL SANS-SERIF BOLD SMALL Y
+    'z': '\U0001d607',  # 𝘇 MATHEMATICAL SANS-SERIF BOLD SMALL Z
+    'Γ': '\U0001d758',  # 𝝘 MATHEMATICAL SANS-SERIF BOLD CAPITAL GAMMA
+    'Δ': '\U0001d759',  # 𝝙 MATHEMATICAL SANS-SERIF BOLD CAPITAL DELTA
+    'Θ': '\U0001d75d',  # 𝝝 MATHEMATICAL SANS-SERIF BOLD CAPITAL THETA
+    'Λ': '\U0001d760',  # 𝝠 MATHEMATICAL SANS-SERIF BOLD CAPITAL LAMDA
+    'Ξ': '\U0001d763',  # 𝝣 MATHEMATICAL SANS-SERIF BOLD CAPITAL XI
+    'Π': '\U0001d765',  # 𝝥 MATHEMATICAL SANS-SERIF BOLD CAPITAL PI
+    'Σ': '\U0001d768',  # 𝝨 MATHEMATICAL SANS-SERIF BOLD CAPITAL SIGMA
+    'Υ': '\U0001d76a',  # 𝝪 MATHEMATICAL SANS-SERIF BOLD CAPITAL UPSILON
+    'Φ': '\U0001d76b',  # 𝝫 MATHEMATICAL SANS-SERIF BOLD CAPITAL PHI
+    'Ψ': '\U0001d76d',  # 𝝭 MATHEMATICAL SANS-SERIF BOLD CAPITAL PSI
+    'Ω': '\U0001d76e',  # 𝝮 MATHEMATICAL SANS-SERIF BOLD CAPITAL OMEGA
+    'α': '\U0001d770',  # 𝝰 MATHEMATICAL SANS-SERIF BOLD SMALL ALPHA
+    'β': '\U0001d771',  # 𝝱 MATHEMATICAL SANS-SERIF BOLD SMALL BETA
+    'γ': '\U0001d772',  # 𝝲 MATHEMATICAL SANS-SERIF BOLD SMALL GAMMA
+    'δ': '\U0001d773',  # 𝝳 MATHEMATICAL SANS-SERIF BOLD SMALL DELTA
+    'ε': '\U0001d774',  # 𝝴 MATHEMATICAL SANS-SERIF BOLD SMALL EPSILON
+    'ζ': '\U0001d775',  # 𝝵 MATHEMATICAL SANS-SERIF BOLD SMALL ZETA
+    'η': '\U0001d776',  # 𝝶 MATHEMATICAL SANS-SERIF BOLD SMALL ETA
+    'θ': '\U0001d777',  # 𝝷 MATHEMATICAL SANS-SERIF BOLD SMALL THETA
+    'ι': '\U0001d778',  # 𝝸 MATHEMATICAL SANS-SERIF BOLD SMALL IOTA
+    'κ': '\U0001d779',  # 𝝹 MATHEMATICAL SANS-SERIF BOLD SMALL KAPPA
+    'λ': '\U0001d77a',  # 𝝺 MATHEMATICAL SANS-SERIF BOLD SMALL LAMDA
+    'μ': '\U0001d77b',  # 𝝻 MATHEMATICAL SANS-SERIF BOLD SMALL MU
+    'ν': '\U0001d77c',  # 𝝼 MATHEMATICAL SANS-SERIF BOLD SMALL NU
+    'ξ': '\U0001d77d',  # 𝝽 MATHEMATICAL SANS-SERIF BOLD SMALL XI
+    'π': '\U0001d77f',  # 𝝿 MATHEMATICAL SANS-SERIF BOLD SMALL PI
+    'ρ': '\U0001d780',  # 𝞀 MATHEMATICAL SANS-SERIF BOLD SMALL RHO
+    'ς': '\U0001d781',  # 𝞁 MATHEMATICAL SANS-SERIF BOLD SMALL FINAL SIGMA
+    'σ': '\U0001d782',  # 𝞂 MATHEMATICAL SANS-SERIF BOLD SMALL SIGMA
+    'τ': '\U0001d783',  # 𝞃 MATHEMATICAL SANS-SERIF BOLD SMALL TAU
+    'υ': '\U0001d784',  # 𝞄 MATHEMATICAL SANS-SERIF BOLD SMALL UPSILON
+    'φ': '\U0001d785',  # 𝞅 MATHEMATICAL SANS-SERIF BOLD SMALL PHI
+    'χ': '\U0001d786',  # 𝞆 MATHEMATICAL SANS-SERIF BOLD SMALL CHI
+    'ψ': '\U0001d787',  # 𝞇 MATHEMATICAL SANS-SERIF BOLD SMALL PSI
+    'ω': '\U0001d788',  # 𝞈 MATHEMATICAL SANS-SERIF BOLD SMALL OMEGA
+    'ϑ': '\U0001d78b',  # 𝞋 MATHEMATICAL SANS-SERIF BOLD THETA SYMBOL
+    'ϕ': '\U0001d78d',  # 𝞍 MATHEMATICAL SANS-SERIF BOLD PHI SYMBOL
+    'ϖ': '\U0001d78f',  # 𝞏 MATHEMATICAL SANS-SERIF BOLD PI SYMBOL
+    'ϱ': '\U0001d78e',  # 𝞎 MATHEMATICAL SANS-SERIF BOLD RHO SYMBOL
+    'ϵ': '\U0001d78a',  # 𝞊 MATHEMATICAL SANS-SERIF BOLD EPSILON SYMBOL
+    '∇': '\U0001d76f',  # 𝝯 MATHEMATICAL SANS-SERIF BOLD NABLA
+    }
+
+mathsfbfit = {
+    'A': '\U0001d63c',  # 𝘼 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL A
+    'B': '\U0001d63d',  # 𝘽 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL B
+    'C': '\U0001d63e',  # 𝘾 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL C
+    'D': '\U0001d63f',  # 𝘿 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL D
+    'E': '\U0001d640',  # 𝙀 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL E
+    'F': '\U0001d641',  # 𝙁 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL F
+    'G': '\U0001d642',  # 𝙂 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL G
+    'H': '\U0001d643',  # 𝙃 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL H
+    'I': '\U0001d644',  # 𝙄 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL I
+    'J': '\U0001d645',  # 𝙅 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL J
+    'K': '\U0001d646',  # 𝙆 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL K
+    'L': '\U0001d647',  # 𝙇 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL L
+    'M': '\U0001d648',  # 𝙈 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL M
+    'N': '\U0001d649',  # 𝙉 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL N
+    'O': '\U0001d64a',  # 𝙊 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL O
+    'P': '\U0001d64b',  # 𝙋 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL P
+    'Q': '\U0001d64c',  # 𝙌 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL Q
+    'R': '\U0001d64d',  # 𝙍 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL R
+    'S': '\U0001d64e',  # 𝙎 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL S
+    'T': '\U0001d64f',  # 𝙏 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL T
+    'U': '\U0001d650',  # 𝙐 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL U
+    'V': '\U0001d651',  # 𝙑 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL V
+    'W': '\U0001d652',  # 𝙒 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL W
+    'X': '\U0001d653',  # 𝙓 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL X
+    'Y': '\U0001d654',  # 𝙔 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL Y
+    'Z': '\U0001d655',  # 𝙕 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL Z
+    'a': '\U0001d656',  # 𝙖 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL A
+    'b': '\U0001d657',  # 𝙗 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL B
+    'c': '\U0001d658',  # 𝙘 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL C
+    'd': '\U0001d659',  # 𝙙 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL D
+    'e': '\U0001d65a',  # 𝙚 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL E
+    'f': '\U0001d65b',  # 𝙛 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL F
+    'g': '\U0001d65c',  # 𝙜 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL G
+    'h': '\U0001d65d',  # 𝙝 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL H
+    'i': '\U0001d65e',  # 𝙞 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL I
+    'j': '\U0001d65f',  # 𝙟 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL J
+    'k': '\U0001d660',  # 𝙠 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL K
+    'l': '\U0001d661',  # 𝙡 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL L
+    'm': '\U0001d662',  # 𝙢 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL M
+    'n': '\U0001d663',  # 𝙣 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL N
+    'o': '\U0001d664',  # 𝙤 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL O
+    'p': '\U0001d665',  # 𝙥 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL P
+    'q': '\U0001d666',  # 𝙦 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL Q
+    'r': '\U0001d667',  # 𝙧 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL R
+    's': '\U0001d668',  # 𝙨 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL S
+    't': '\U0001d669',  # 𝙩 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL T
+    'u': '\U0001d66a',  # 𝙪 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL U
+    'v': '\U0001d66b',  # 𝙫 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL V
+    'w': '\U0001d66c',  # 𝙬 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL W
+    'x': '\U0001d66d',  # 𝙭 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL X
+    'y': '\U0001d66e',  # 𝙮 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL Y
+    'z': '\U0001d66f',  # 𝙯 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL Z
+    'Γ': '\U0001d792',  # 𝞒 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL GAMMA
+    'Δ': '\U0001d793',  # 𝞓 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL DELTA
+    'Θ': '\U0001d797',  # 𝞗 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL THETA
+    'Λ': '\U0001d79a',  # 𝞚 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL LAMDA
+    'Ξ': '\U0001d79d',  # 𝞝 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL XI
+    'Π': '\U0001d79f',  # 𝞟 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL PI
+    'Σ': '\U0001d7a2',  # 𝞢 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL SIGMA
+    'Υ': '\U0001d7a4',  # 𝞤 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL UPSILON
+    'Φ': '\U0001d7a5',  # 𝞥 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL PHI
+    'Ψ': '\U0001d7a7',  # 𝞧 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL PSI
+    'Ω': '\U0001d7a8',  # 𝞨 MATHEMATICAL SANS-SERIF BOLD ITALIC CAPITAL OMEGA
+    'α': '\U0001d7aa',  # 𝞪 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL ALPHA
+    'β': '\U0001d7ab',  # 𝞫 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL BETA
+    'γ': '\U0001d7ac',  # 𝞬 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL GAMMA
+    'δ': '\U0001d7ad',  # 𝞭 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL DELTA
+    'ε': '\U0001d7ae',  # 𝞮 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL EPSILON
+    'ζ': '\U0001d7af',  # 𝞯 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL ZETA
+    'η': '\U0001d7b0',  # 𝞰 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL ETA
+    'θ': '\U0001d7b1',  # 𝞱 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL THETA
+    'ι': '\U0001d7b2',  # 𝞲 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL IOTA
+    'κ': '\U0001d7b3',  # 𝞳 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL KAPPA
+    'λ': '\U0001d7b4',  # 𝞴 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL LAMDA
+    'μ': '\U0001d7b5',  # 𝞵 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL MU
+    'ν': '\U0001d7b6',  # 𝞶 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL NU
+    'ξ': '\U0001d7b7',  # 𝞷 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL XI
+    'π': '\U0001d7b9',  # 𝞹 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL PI
+    'ρ': '\U0001d7ba',  # 𝞺 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL RHO
+    'ς': '\U0001d7bb',  # 𝞻 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL FINAL SIGMA
+    'σ': '\U0001d7bc',  # 𝞼 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL SIGMA
+    'τ': '\U0001d7bd',  # 𝞽 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL TAU
+    'υ': '\U0001d7be',  # 𝞾 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL UPSILON
+    'φ': '\U0001d7bf',  # 𝞿 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL PHI
+    'χ': '\U0001d7c0',  # 𝟀 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL CHI
+    'ψ': '\U0001d7c1',  # 𝟁 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL PSI
+    'ω': '\U0001d7c2',  # 𝟂 MATHEMATICAL SANS-SERIF BOLD ITALIC SMALL OMEGA
+    'ϑ': '\U0001d7c5',  # 𝟅 MATHEMATICAL SANS-SERIF BOLD ITALIC THETA SYMBOL
+    'ϕ': '\U0001d7c7',  # 𝟇 MATHEMATICAL SANS-SERIF BOLD ITALIC PHI SYMBOL
+    'ϖ': '\U0001d7c9',  # 𝟉 MATHEMATICAL SANS-SERIF BOLD ITALIC PI SYMBOL
+    'ϰ': '\U0001d7c6',  # 𝟆 MATHEMATICAL SANS-SERIF BOLD ITALIC KAPPA SYMBOL
+    'ϱ': '\U0001d7c8',  # 𝟈 MATHEMATICAL SANS-SERIF BOLD ITALIC RHO SYMBOL
+    'ϵ': '\U0001d7c4',  # 𝟄 MATHEMATICAL SANS-SERIF BOLD ITALIC EPSILON SYMBOL
+    '∂': '\U0001d7c3',  # 𝟃 MATHEMATICAL SANS-SERIF BOLD ITALIC PARTIAL DIFFERENTIAL
+    '∇': '\U0001d7a9',  # 𝞩 MATHEMATICAL SANS-SERIF BOLD ITALIC NABLA
+    }
+
+mathsfit = {
+    'A': '\U0001d608',  # 𝘈 MATHEMATICAL SANS-SERIF ITALIC CAPITAL A
+    'B': '\U0001d609',  # 𝘉 MATHEMATICAL SANS-SERIF ITALIC CAPITAL B
+    'C': '\U0001d60a',  # 𝘊 MATHEMATICAL SANS-SERIF ITALIC CAPITAL C
+    'D': '\U0001d60b',  # 𝘋 MATHEMATICAL SANS-SERIF ITALIC CAPITAL D
+    'E': '\U0001d60c',  # 𝘌 MATHEMATICAL SANS-SERIF ITALIC CAPITAL E
+    'F': '\U0001d60d',  # 𝘍 MATHEMATICAL SANS-SERIF ITALIC CAPITAL F
+    'G': '\U0001d60e',  # 𝘎 MATHEMATICAL SANS-SERIF ITALIC CAPITAL G
+    'H': '\U0001d60f',  # 𝘏 MATHEMATICAL SANS-SERIF ITALIC CAPITAL H
+    'I': '\U0001d610',  # 𝘐 MATHEMATICAL SANS-SERIF ITALIC CAPITAL I
+    'J': '\U0001d611',  # 𝘑 MATHEMATICAL SANS-SERIF ITALIC CAPITAL J
+    'K': '\U0001d612',  # 𝘒 MATHEMATICAL SANS-SERIF ITALIC CAPITAL K
+    'L': '\U0001d613',  # 𝘓 MATHEMATICAL SANS-SERIF ITALIC CAPITAL L
+    'M': '\U0001d614',  # 𝘔 MATHEMATICAL SANS-SERIF ITALIC CAPITAL M
+    'N': '\U0001d615',  # 𝘕 MATHEMATICAL SANS-SERIF ITALIC CAPITAL N
+    'O': '\U0001d616',  # 𝘖 MATHEMATICAL SANS-SERIF ITALIC CAPITAL O
+    'P': '\U0001d617',  # 𝘗 MATHEMATICAL SANS-SERIF ITALIC CAPITAL P
+    'Q': '\U0001d618',  # 𝘘 MATHEMATICAL SANS-SERIF ITALIC CAPITAL Q
+    'R': '\U0001d619',  # 𝘙 MATHEMATICAL SANS-SERIF ITALIC CAPITAL R
+    'S': '\U0001d61a',  # 𝘚 MATHEMATICAL SANS-SERIF ITALIC CAPITAL S
+    'T': '\U0001d61b',  # 𝘛 MATHEMATICAL SANS-SERIF ITALIC CAPITAL T
+    'U': '\U0001d61c',  # 𝘜 MATHEMATICAL SANS-SERIF ITALIC CAPITAL U
+    'V': '\U0001d61d',  # 𝘝 MATHEMATICAL SANS-SERIF ITALIC CAPITAL V
+    'W': '\U0001d61e',  # 𝘞 MATHEMATICAL SANS-SERIF ITALIC CAPITAL W
+    'X': '\U0001d61f',  # 𝘟 MATHEMATICAL SANS-SERIF ITALIC CAPITAL X
+    'Y': '\U0001d620',  # 𝘠 MATHEMATICAL SANS-SERIF ITALIC CAPITAL Y
+    'Z': '\U0001d621',  # 𝘡 MATHEMATICAL SANS-SERIF ITALIC CAPITAL Z
+    'a': '\U0001d622',  # 𝘢 MATHEMATICAL SANS-SERIF ITALIC SMALL A
+    'b': '\U0001d623',  # 𝘣 MATHEMATICAL SANS-SERIF ITALIC SMALL B
+    'c': '\U0001d624',  # 𝘤 MATHEMATICAL SANS-SERIF ITALIC SMALL C
+    'd': '\U0001d625',  # 𝘥 MATHEMATICAL SANS-SERIF ITALIC SMALL D
+    'e': '\U0001d626',  # 𝘦 MATHEMATICAL SANS-SERIF ITALIC SMALL E
+    'f': '\U0001d627',  # 𝘧 MATHEMATICAL SANS-SERIF ITALIC SMALL F
+    'g': '\U0001d628',  # 𝘨 MATHEMATICAL SANS-SERIF ITALIC SMALL G
+    'h': '\U0001d629',  # 𝘩 MATHEMATICAL SANS-SERIF ITALIC SMALL H
+    'i': '\U0001d62a',  # 𝘪 MATHEMATICAL SANS-SERIF ITALIC SMALL I
+    'j': '\U0001d62b',  # 𝘫 MATHEMATICAL SANS-SERIF ITALIC SMALL J
+    'k': '\U0001d62c',  # 𝘬 MATHEMATICAL SANS-SERIF ITALIC SMALL K
+    'l': '\U0001d62d',  # 𝘭 MATHEMATICAL SANS-SERIF ITALIC SMALL L
+    'm': '\U0001d62e',  # 𝘮 MATHEMATICAL SANS-SERIF ITALIC SMALL M
+    'n': '\U0001d62f',  # 𝘯 MATHEMATICAL SANS-SERIF ITALIC SMALL N
+    'o': '\U0001d630',  # 𝘰 MATHEMATICAL SANS-SERIF ITALIC SMALL O
+    'p': '\U0001d631',  # 𝘱 MATHEMATICAL SANS-SERIF ITALIC SMALL P
+    'q': '\U0001d632',  # 𝘲 MATHEMATICAL SANS-SERIF ITALIC SMALL Q
+    'r': '\U0001d633',  # 𝘳 MATHEMATICAL SANS-SERIF ITALIC SMALL R
+    's': '\U0001d634',  # 𝘴 MATHEMATICAL SANS-SERIF ITALIC SMALL S
+    't': '\U0001d635',  # 𝘵 MATHEMATICAL SANS-SERIF ITALIC SMALL T
+    'u': '\U0001d636',  # 𝘶 MATHEMATICAL SANS-SERIF ITALIC SMALL U
+    'v': '\U0001d637',  # 𝘷 MATHEMATICAL SANS-SERIF ITALIC SMALL V
+    'w': '\U0001d638',  # 𝘸 MATHEMATICAL SANS-SERIF ITALIC SMALL W
+    'x': '\U0001d639',  # 𝘹 MATHEMATICAL SANS-SERIF ITALIC SMALL X
+    'y': '\U0001d63a',  # 𝘺 MATHEMATICAL SANS-SERIF ITALIC SMALL Y
+    'z': '\U0001d63b',  # 𝘻 MATHEMATICAL SANS-SERIF ITALIC SMALL Z
+    }
+
+mathtt = {
+    '0': '\U0001d7f6',  # 𝟶 MATHEMATICAL MONOSPACE DIGIT ZERO
+    '1': '\U0001d7f7',  # 𝟷 MATHEMATICAL MONOSPACE DIGIT ONE
+    '2': '\U0001d7f8',  # 𝟸 MATHEMATICAL MONOSPACE DIGIT TWO
+    '3': '\U0001d7f9',  # 𝟹 MATHEMATICAL MONOSPACE DIGIT THREE
+    '4': '\U0001d7fa',  # 𝟺 MATHEMATICAL MONOSPACE DIGIT FOUR
+    '5': '\U0001d7fb',  # 𝟻 MATHEMATICAL MONOSPACE DIGIT FIVE
+    '6': '\U0001d7fc',  # 𝟼 MATHEMATICAL MONOSPACE DIGIT SIX
+    '7': '\U0001d7fd',  # 𝟽 MATHEMATICAL MONOSPACE DIGIT SEVEN
+    '8': '\U0001d7fe',  # 𝟾 MATHEMATICAL MONOSPACE DIGIT EIGHT
+    '9': '\U0001d7ff',  # 𝟿 MATHEMATICAL MONOSPACE DIGIT NINE
+    'A': '\U0001d670',  # 𝙰 MATHEMATICAL MONOSPACE CAPITAL A
+    'B': '\U0001d671',  # 𝙱 MATHEMATICAL MONOSPACE CAPITAL B
+    'C': '\U0001d672',  # 𝙲 MATHEMATICAL MONOSPACE CAPITAL C
+    'D': '\U0001d673',  # 𝙳 MATHEMATICAL MONOSPACE CAPITAL D
+    'E': '\U0001d674',  # 𝙴 MATHEMATICAL MONOSPACE CAPITAL E
+    'F': '\U0001d675',  # 𝙵 MATHEMATICAL MONOSPACE CAPITAL F
+    'G': '\U0001d676',  # 𝙶 MATHEMATICAL MONOSPACE CAPITAL G
+    'H': '\U0001d677',  # 𝙷 MATHEMATICAL MONOSPACE CAPITAL H
+    'I': '\U0001d678',  # 𝙸 MATHEMATICAL MONOSPACE CAPITAL I
+    'J': '\U0001d679',  # 𝙹 MATHEMATICAL MONOSPACE CAPITAL J
+    'K': '\U0001d67a',  # 𝙺 MATHEMATICAL MONOSPACE CAPITAL K
+    'L': '\U0001d67b',  # 𝙻 MATHEMATICAL MONOSPACE CAPITAL L
+    'M': '\U0001d67c',  # 𝙼 MATHEMATICAL MONOSPACE CAPITAL M
+    'N': '\U0001d67d',  # 𝙽 MATHEMATICAL MONOSPACE CAPITAL N
+    'O': '\U0001d67e',  # 𝙾 MATHEMATICAL MONOSPACE CAPITAL O
+    'P': '\U0001d67f',  # 𝙿 MATHEMATICAL MONOSPACE CAPITAL P
+    'Q': '\U0001d680',  # 𝚀 MATHEMATICAL MONOSPACE CAPITAL Q
+    'R': '\U0001d681',  # 𝚁 MATHEMATICAL MONOSPACE CAPITAL R
+    'S': '\U0001d682',  # 𝚂 MATHEMATICAL MONOSPACE CAPITAL S
+    'T': '\U0001d683',  # 𝚃 MATHEMATICAL MONOSPACE CAPITAL T
+    'U': '\U0001d684',  # 𝚄 MATHEMATICAL MONOSPACE CAPITAL U
+    'V': '\U0001d685',  # 𝚅 MATHEMATICAL MONOSPACE CAPITAL V
+    'W': '\U0001d686',  # 𝚆 MATHEMATICAL MONOSPACE CAPITAL W
+    'X': '\U0001d687',  # 𝚇 MATHEMATICAL MONOSPACE CAPITAL X
+    'Y': '\U0001d688',  # 𝚈 MATHEMATICAL MONOSPACE CAPITAL Y
+    'Z': '\U0001d689',  # 𝚉 MATHEMATICAL MONOSPACE CAPITAL Z
+    'a': '\U0001d68a',  # 𝚊 MATHEMATICAL MONOSPACE SMALL A
+    'b': '\U0001d68b',  # 𝚋 MATHEMATICAL MONOSPACE SMALL B
+    'c': '\U0001d68c',  # 𝚌 MATHEMATICAL MONOSPACE SMALL C
+    'd': '\U0001d68d',  # 𝚍 MATHEMATICAL MONOSPACE SMALL D
+    'e': '\U0001d68e',  # 𝚎 MATHEMATICAL MONOSPACE SMALL E
+    'f': '\U0001d68f',  # 𝚏 MATHEMATICAL MONOSPACE SMALL F
+    'g': '\U0001d690',  # 𝚐 MATHEMATICAL MONOSPACE SMALL G
+    'h': '\U0001d691',  # 𝚑 MATHEMATICAL MONOSPACE SMALL H
+    'i': '\U0001d692',  # 𝚒 MATHEMATICAL MONOSPACE SMALL I
+    'j': '\U0001d693',  # 𝚓 MATHEMATICAL MONOSPACE SMALL J
+    'k': '\U0001d694',  # 𝚔 MATHEMATICAL MONOSPACE SMALL K
+    'l': '\U0001d695',  # 𝚕 MATHEMATICAL MONOSPACE SMALL L
+    'm': '\U0001d696',  # 𝚖 MATHEMATICAL MONOSPACE SMALL M
+    'n': '\U0001d697',  # 𝚗 MATHEMATICAL MONOSPACE SMALL N
+    'o': '\U0001d698',  # 𝚘 MATHEMATICAL MONOSPACE SMALL O
+    'p': '\U0001d699',  # 𝚙 MATHEMATICAL MONOSPACE SMALL P
+    'q': '\U0001d69a',  # 𝚚 MATHEMATICAL MONOSPACE SMALL Q
+    'r': '\U0001d69b',  # 𝚛 MATHEMATICAL MONOSPACE SMALL R
+    's': '\U0001d69c',  # 𝚜 MATHEMATICAL MONOSPACE SMALL S
+    't': '\U0001d69d',  # 𝚝 MATHEMATICAL MONOSPACE SMALL T
+    'u': '\U0001d69e',  # 𝚞 MATHEMATICAL MONOSPACE SMALL U
+    'v': '\U0001d69f',  # 𝚟 MATHEMATICAL MONOSPACE SMALL V
+    'w': '\U0001d6a0',  # 𝚠 MATHEMATICAL MONOSPACE SMALL W
+    'x': '\U0001d6a1',  # 𝚡 MATHEMATICAL MONOSPACE SMALL X
+    'y': '\U0001d6a2',  # 𝚢 MATHEMATICAL MONOSPACE SMALL Y
+    'z': '\U0001d6a3',  # 𝚣 MATHEMATICAL MONOSPACE SMALL Z
+    }
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/mathml_elements.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/mathml_elements.py
new file mode 100644
index 00000000..f2059c9f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/mathml_elements.py
@@ -0,0 +1,478 @@
+# :Id: $Id: mathml_elements.py 9561 2024-03-14 16:34:48Z milde $
+# :Copyright: 2024 Günter Milde.
+#
+# :License: Released under the terms of the `2-Clause BSD license`_, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+
+"""MathML element classes based on `xml.etree`.
+
+The module is intended for programmatic generation of MathML
+and covers the part of `MathML Core`_ that is required by
+Docutil's *TeX math to MathML* converter.
+
+This module is PROVISIONAL:
+the API is not settled and may change with any minor Docutils version.
+
+.. _MathML Core: https://www.w3.org/TR/mathml-core/
+"""
+
+# Usage:
+#
+# >>> from mathml_elements import *
+
+import numbers
+import xml.etree.ElementTree as ET
+
+
+GLOBAL_ATTRIBUTES = (
+    'class',  # space-separated list of element classes
+    # 'data-*',  # custom data attributes (see HTML)
+    'dir',  # directionality ('ltr', 'rtl')
+    'displaystyle',  # True: normal, False: compact
+    'id',  # unique identifier
+    # 'mathbackground',  # color definition, deprecated
+    # 'mathcolor',  # color definition, deprecated
+    # 'mathsize',  # font-size, deprecated
+    'nonce',  # cryptographic nonce ("number used once")
+    'scriptlevel',  # math-depth for the element
+    'style',  # CSS styling declarations
+    'tabindex',  # indicate if the element takes input focus
+    )
+"""Global MathML attributes
+
+https://w3c.github.io/mathml-core/#global-attributes
+"""
+
+
+# Base classes
+# ------------
+
+class MathElement(ET.Element):
+    """Base class for MathML elements."""
+
+    nchildren = None
+    """Expected number of children or None"""
+    # cf. https://www.w3.org/TR/MathML3/chapter3.html#id.3.1.3.2
+    parent = None
+    """Parent node in MathML element tree."""
+
+    def __init__(self, *children, **attributes):
+        """Set up node with `children` and `attributes`.
+
+        Attribute names are normalised to lowercase.
+        You may use "CLASS" to set a "class" attribute.
+        Attribute values are converted to strings
+        (with True -> "true" and False -> "false").
+
+        >>> math(CLASS='test', level=3, split=True)
+        math(class='test', level='3', split='true')
+        >>> math(CLASS='test', level=3, split=True).toxml()
+        '<math class="test" level="3" split="true"></math>'
+
+        """
+        attrib = {k.lower(): self.a_str(v) for k, v in attributes.items()}
+        super().__init__(self.__class__.__name__, **attrib)
+        self.extend(children)
+
+    @staticmethod
+    def a_str(v):
+        # Return string representation for attribute value `v`.
+        if isinstance(v, bool):
+            return str(v).lower()
+        return str(v)
+
+    def __repr__(self):
+        """Return full string representation."""
+        args = [repr(child) for child in self]
+        if self.text:
+            args.append(repr(self.text))
+        if self.nchildren != self.__class__.nchildren:
+            args.append(f'nchildren={self.nchildren}')
+        if getattr(self, 'switch', None):
+            args.append('switch=True')
+        args += [f'{k}={v!r}' for k, v in self.items() if v is not None]
+        return f'{self.tag}({", ".join(args)})'
+
+    def __str__(self):
+        """Return concise, informal string representation."""
+        if self.text:
+            args = repr(self.text)
+        else:
+            args = ', '.join(f'{child}' for child in self)
+        return f'{self.tag}({args})'
+
+    def set(self, key, value):
+        super().set(key, self.a_str(value))
+
+    def __setitem__(self, key, value):
+        if self.nchildren == 0:
+            raise TypeError(f'Element "{self}" does not take children.')
+        if isinstance(value, MathElement):
+            value.parent = self
+        else:  # value may be an iterable
+            if self.nchildren and len(self) + len(value) > self.nchildren:
+                raise TypeError(f'Element "{self}" takes only {self.nchildren}'
+                                ' children')
+            for e in value:
+                e.parent = self
+        super().__setitem__(key, value)
+
+    def is_full(self):
+        """Return boolean indicating whether children may be appended."""
+        return self.nchildren is not None and len(self) >= self.nchildren
+
+    def close(self):
+        """Close element and return first non-full anchestor or None."""
+        self.nchildren = len(self)  # mark node as full
+        parent = self.parent
+        while parent is not None and parent.is_full():
+            parent = parent.parent
+        return parent
+
+    def append(self, element):
+        """Append `element` and return new "current node" (insertion point).
+
+        Append as child element and set the internal `parent` attribute.
+
+        If self is already full, raise TypeError.
+
+        If self is full after appending, call `self.close()`
+        (returns first non-full anchestor or None) else return `self`.
+        """
+        if self.is_full():
+            if self.nchildren:
+                status = f'takes only {self.nchildren} children'
+            else:
+                status = 'does not take children'
+            raise TypeError(f'Element "{self}" {status}.')
+        super().append(element)
+        element.parent = self
+        if self.is_full():
+            return self.close()
+        return self
+
+    def extend(self, elements):
+        """Sequentially append `elements`. Return new "current node".
+
+        Raise TypeError if overfull.
+        """
+        current_node = self
+        for element in elements:
+            current_node = self.append(element)
+        return current_node
+
+    def pop(self, index=-1):
+        element = self[index]
+        del self[index]
+        return element
+
+    def in_block(self):
+        """Return True, if `self` or an ancestor has ``display='block'``.
+
+        Used to find out whether we are in inline vs. displayed maths.
+        """
+        if self.get('display') is None:
+            try:
+                return self.parent.in_block()
+            except AttributeError:
+                return False
+        return self.get('display') == 'block'
+
+    # XML output:
+
+    def indent_xml(self, space='  ', level=0):
+        """Format XML output with indents.
+
+        Use with care:
+          Formatting whitespace is permanently added to the
+          `text` and `tail` attributes of `self` and anchestors!
+        """
+        ET.indent(self, space, level)
+
+    def unindent_xml(self):
+        """Strip whitespace at the end of `text` and `tail` attributes...
+
+        to revert changes made by the `indent_xml()` method.
+        Use with care, trailing whitespace from the original may be lost.
+        """
+        for e in self.iter():
+            if not isinstance(e, MathToken) and e.text:
+                e.text = e.text.rstrip()
+            if e.tail:
+                e.tail = e.tail.rstrip()
+
+    def toxml(self, encoding=None):
+        """Return an XML representation of the element.
+
+        By default, the return value is a `str` instance. With an explicit
+        `encoding` argument, the result is a `bytes` instance in the
+        specified encoding. The XML default encoding is UTF-8, any other
+        encoding must be specified in an XML document header.
+
+        Name and encoding handling match `xml.dom.minidom.Node.toxml()`
+        while `etree.Element.tostring()` returns `bytes` by default.
+        """
+        xml = ET.tostring(self, encoding or 'unicode',
+                          short_empty_elements=False)
+        # Visible representation for "Apply Function" character:
+        try:
+            xml = xml.replace('\u2061', '&ApplyFunction;')
+        except TypeError:
+            xml = xml.replace('\u2061'.encode(encoding), b'&ApplyFunction;')
+        return xml
+
+
+# Group sub-expressions in a horizontal row
+#
+# The elements <msqrt>, <mstyle>, <merror>, <mpadded>, <mphantom>,
+# <menclose>, <mtd>, <mscarry>, and <math> treat their contents
+# as a single inferred mrow formed from all their children.
+# (https://www.w3.org/TR/mathml4/#presm_inferredmrow)
+#
+# MathML Core uses the term "anonymous mrow element".
+
+class MathRow(MathElement):
+    """Base class for elements treating content as a single mrow."""
+
+
+# 2d Schemata
+
+class MathSchema(MathElement):
+    """Base class for schemata expecting 2 or more children.
+
+    The special attribute `switch` indicates that the last two child
+    elements are in reversed order and must be switched before XML-export.
+    See `msub` for an example.
+    """
+    nchildren = 2
+
+    def __init__(self, *children, **kwargs):
+        self.switch = kwargs.pop('switch', False)
+        super().__init__(*children, **kwargs)
+
+    def append(self, element):
+        """Append element. Normalize order and close if full."""
+        current_node = super().append(element)
+        if self.switch and self.is_full():
+            self[-1], self[-2] = self[-2], self[-1]
+            self.switch = False
+        return current_node
+
+
+# Token elements represent the smallest units of mathematical notation which
+# carry meaning.
+
+class MathToken(MathElement):
+    """Token Element: contains textual data instead of children.
+
+    Expect text data on initialisation.
+    """
+    nchildren = 0
+
+    def __init__(self, text, **attributes):
+        super().__init__(**attributes)
+        if not isinstance(text, (str, numbers.Number)):
+            raise ValueError('MathToken element expects `str` or number,'
+                             f' not "{text}".')
+        self.text = str(text)
+
+
+# MathML element classes
+# ----------------------
+
+class math(MathRow):
+    """Top-level MathML element, a single mathematical formula."""
+
+
+# Token elements
+# ~~~~~~~~~~~~~~
+
+class mtext(MathToken):
+    """Arbitrary text with no notational meaning."""
+
+
+class mi(MathToken):
+    """Identifier, such as a function name, variable or symbolic constant."""
+
+
+class mn(MathToken):
+    """Numeric literal.
+
+    >>> mn(3.41).toxml()
+    '<mn>3.41</mn>'
+
+    Normally a sequence of digits with a possible separator (a dot or a comma).
+    (Values with comma must be specified as `str`.)
+    """
+
+
+class mo(MathToken):
+    """Operator, Fence, Separator, or Accent.
+
+    >>> mo('<').toxml()
+    '<mo>&lt;</mo>'
+
+    Besides operators in strict mathematical meaning, this element also
+    includes "operators" like parentheses, separators like comma and
+    semicolon, or "absolute value" bars.
+    """
+
+
+class mspace(MathElement):
+    """Blank space, whose size is set by its attributes.
+
+    Takes additional attributes `depth`, `height`, `width`.
+    Takes no children and no text.
+
+    See also `mphantom`.
+    """
+    nchildren = 0
+
+
+# General Layout Schemata
+# ~~~~~~~~~~~~~~~~~~~~~~~
+
+class mrow(MathRow):
+    """Generic element to group children as a horizontal row.
+
+    Removed on closing if not required (see `mrow.close()`).
+    """
+
+    def transfer_attributes(self, other):
+        """Transfer attributes from self to other.
+
+        "List values" (class, style) are appended to existing values,
+        other values replace existing values.
+        """
+        delimiters = {'class': ' ', 'style': '; '}
+        for k, v in self.items():
+            if k in ('class', 'style') and v:
+                if other.get(k):
+                    v = delimiters[k].join(
+                        (other.get(k).rstrip(delimiters[k]), v))
+            other.set(k, v)
+
+    def close(self):
+        """Close element and return first non-full anchestor or None.
+
+        Remove <mrow> if it has only one child element.
+        """
+        parent = self.parent
+        # replace `self` with single child
+        if parent is not None and len(self) == 1:
+            child = self[0]
+            try:
+                parent[list(parent).index(self)] = child
+                child.parent = parent
+            except (AttributeError, ValueError):
+                return None
+            self.transfer_attributes(child)
+        return super().close()
+
+
+class mfrac(MathSchema):
+    """Fractions or fraction-like objects such as binomial coefficients."""
+
+
+class msqrt(MathRow):
+    """Square root. See also `mroot`."""
+    nchildren = 1  # \sqrt expects one argument or a group
+
+
+class mroot(MathSchema):
+    """Roots with an explicit index. See also `msqrt`."""
+
+
+class mstyle(MathRow):
+    """Style Change.
+
+    In modern browsers, <mstyle> is equivalent to an <mrow> element.
+    However, <mstyle> may still be relevant for compatibility with
+    MathML implementations outside browsers.
+    """
+
+
+class merror(MathRow):
+    """Display contents as error messages."""
+
+
+class menclose(MathRow):
+    """Renders content inside an enclosing notation...
+
+    ... specified by the notation attribute.
+
+    Non-standard but still required by Firefox for boxed expressions.
+    """
+    nchildren = 1  # \boxed expects one argument or a group
+
+
+class mpadded(MathRow):
+    """Adjust space around content."""
+    # nchildren = 1  # currently not used by latex2mathml
+
+
+class mphantom(MathRow):
+    """Placeholder: Rendered invisibly but dimensions are kept."""
+    nchildren = 1  # \phantom expects one argument or a group
+
+
+# Script and Limit Schemata
+# ~~~~~~~~~~~~~~~~~~~~~~~~~
+
+class msub(MathSchema):
+    """Attach a subscript to an expression."""
+
+
+class msup(MathSchema):
+    """Attach a superscript to an expression."""
+
+
+class msubsup(MathSchema):
+    """Attach both a subscript and a superscript to an expression."""
+    nchildren = 3
+
+# Examples:
+#
+# The `switch` attribute reverses the order of the last two children:
+# >>> msub(mn(1), mn(2)).toxml()
+# '<msub><mn>1</mn><mn>2</mn></msub>'
+# >>> msub(mn(1), mn(2), switch=True).toxml()
+# '<msub><mn>2</mn><mn>1</mn></msub>'
+#
+# >>> msubsup(mi('base'), mn(1), mn(2)).toxml()
+# '<msubsup><mi>base</mi><mn>1</mn><mn>2</mn></msubsup>'
+# >>> msubsup(mi('base'), mn(1), mn(2), switch=True).toxml()
+# '<msubsup><mi>base</mi><mn>2</mn><mn>1</mn></msubsup>'
+
+
+class munder(msub):
+    """Attach an accent or a limit under an expression."""
+
+
+class mover(msup):
+    """Attach an accent or a limit over an expression."""
+
+
+class munderover(msubsup):
+    """Attach accents or limits both under and over an expression."""
+
+
+# Tabular Math
+# ~~~~~~~~~~~~
+
+class mtable(MathElement):
+    """Table or matrix element."""
+
+
+class mtr(MathRow):
+    """Row in a table or a matrix."""
+
+
+class mtd(MathRow):
+    """Cell in a table or a matrix"""
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/tex2mathml_extern.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/tex2mathml_extern.py
new file mode 100644
index 00000000..11f9ab3e
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/tex2mathml_extern.py
@@ -0,0 +1,261 @@
+# :Id: $Id: tex2mathml_extern.py 9536 2024-02-01 13:04:22Z milde $
+# :Copyright: © 2015 Günter Milde.
+# :License: Released under the terms of the `2-Clause BSD license`__, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# __ https://opensource.org/licenses/BSD-2-Clause
+
+"""Wrappers for TeX->MathML conversion by external tools
+
+This module is provisional:
+the API is not settled and may change with any minor Docutils version.
+"""
+
+import subprocess
+
+from docutils import nodes
+from docutils.utils.math import MathError, wrap_math_code
+
+# `latexml` expects a complete document:
+document_template = r"""\documentclass{article}
+\begin{document}
+%s
+\end{document}
+"""
+
+
+def _check_result(result, details=[]):
+    # raise MathError if the conversion went wrong
+    # :details: list of doctree nodes with additional info
+    msg = ''
+    if not details and result.stderr:
+        details = [nodes.paragraph('', result.stderr, classes=['pre-wrap'])]
+    if details:
+        msg = f'TeX to MathML converter `{result.args[0]}` failed:'
+    elif result.returncode:
+        msg = (f'TeX to MathMl converter `{result.args[0]}` '
+               f'exited with Errno {result.returncode}.')
+    elif not result.stdout:
+        msg = f'TeX to MathML converter `{result.args[0]}` returned no MathML.'
+    if msg:
+        raise MathError(msg, details=details)
+
+
+def blahtexml(math_code, as_block=False):
+    """Convert LaTeX math code to MathML with blahtexml__.
+
+    __ http://gva.noekeon.org/blahtexml/
+    """
+    args = ['blahtexml',
+            '--mathml',
+            '--indented',
+            '--spacing', 'moderate',
+            '--mathml-encoding', 'raw',
+            '--other-encoding', 'raw',
+            '--doctype-xhtml+mathml',
+            '--annotate-TeX',
+            ]
+    # "blahtexml" expects LaTeX code without math-mode-switch.
+    # We still need to tell it about displayed equation(s).
+    mathml_args = ' display="block"' if as_block else ''
+    _wrapped = wrap_math_code(math_code, as_block)
+    if '{align*}' in _wrapped:
+        math_code = _wrapped.replace('{align*}', '{aligned}')
+
+    result = subprocess.run(args, input=math_code,
+                            capture_output=True, text=True)
+
+    # blahtexml writes <error> messages to stdout
+    if '<error>' in result.stdout:
+        result.stderr = result.stdout[result.stdout.find('<message>')+9:
+                                      result.stdout.find('</message>')]
+    else:
+        result.stdout = result.stdout[result.stdout.find('<markup>')+9:
+                                      result.stdout.find('</markup>')]
+    _check_result(result)
+    return (f'<math xmlns="http://www.w3.org/1998/Math/MathML"{mathml_args}>'
+            f'\n{result.stdout}</math>')
+
+
+def latexml(math_code, as_block=False):
+    """Convert LaTeX math code to MathML with LaTeXML__.
+
+    Comprehensive macro support but **very** slow.
+
+    __ http://dlmf.nist.gov/LaTeXML/
+    """
+
+    # LaTeXML works in 2 stages, expects complete documents.
+    #
+    # The `latexmlmath`__ convenience wrapper does not support block-level
+    # (displayed) equations.
+    #
+    # __ https://metacpan.org/dist/LaTeXML/view/bin/latexmlmath
+    args1 = ['latexml',
+             '-',  # read from stdin
+             '--preload=amsmath',
+             '--preload=amssymb',  # also loads amsfonts
+             '--inputencoding=utf8',
+             '--',
+             ]
+    math_code = document_template % wrap_math_code(math_code, as_block)
+
+    result1 = subprocess.run(args1, input=math_code,
+                             capture_output=True, text=True)
+    if result1.stderr:
+        result1.stderr = '\n'.join(line for line in result1.stderr.splitlines()
+                                   if line.startswith('Error:')
+                                   or line.startswith('Warning:')
+                                   or line.startswith('Fatal:'))
+    _check_result(result1)
+
+    args2 = ['latexmlpost',
+             '-',
+             '--nonumbersections',
+             '--format=html5',  # maths included as MathML
+             '--omitdoctype',   # Make it simple, we only need the maths.
+             '--noscan',        # ...
+             '--nocrossref',
+             '--nographicimages',
+             '--nopictureimages',
+             '--nodefaultresources',  # do not copy *.css files to output dir
+             '--'
+             ]
+    result2 = subprocess.run(args2, input=result1.stdout,
+                             capture_output=True, text=True)
+    # Extract MathML from HTML document:
+    # <table> with <math> in cells for "align", <math> element else.
+    start = result2.stdout.find('<table class="ltx_equationgroup')
+    if start != -1:
+        stop = result2.stdout.find('</table>', start)+8
+        result2.stdout = result2.stdout[start:stop].replace(
+            'ltx_equationgroup', 'borderless align-center')
+    else:
+        result2.stdout = result2.stdout[result2.stdout.find('<math'):
+                                        result2.stdout.find('</math>')+7]
+    # Search for error messages
+    if result2.stdout:
+        _msg_source = result2.stdout  # latexmlpost reports errors in output
+    else:
+        _msg_source = result2.stderr  # just in case
+    result2.stderr = '\n'.join(line for line in _msg_source.splitlines()
+                               if line.startswith('Error:')
+                               or line.startswith('Warning:')
+                               or line.startswith('Fatal:'))
+    _check_result(result2)
+    return result2.stdout
+
+
+def pandoc(math_code, as_block=False):
+    """Convert LaTeX math code to MathML with pandoc__.
+
+    __ https://pandoc.org/
+    """
+    args = ['pandoc',
+            '--mathml',
+            '--from=latex',
+            ]
+    result = subprocess.run(args, input=wrap_math_code(math_code, as_block),
+                            capture_output=True, text=True)
+
+    result.stdout = result.stdout[result.stdout.find('<math'):
+                                  result.stdout.find('</math>')+7]
+    # Pandoc (2.9.2.1) messages are pre-formatted for the terminal:
+    #   1. summary
+    #   2. math source (part)
+    #   3. error spot indicator '^' (works only in a literal block)
+    #   4. assumed problem
+    #   5. assumed solution (may be wrong or confusing)
+    # Construct a "details" list:
+    details = []
+    if result.stderr:
+        lines = result.stderr.splitlines()
+        details.append(nodes.paragraph('', lines[0]))
+        details.append(nodes.literal_block('', '\n'.join(lines[1:3])))
+        details.append(nodes.paragraph('', '\n'.join(lines[3:]),
+                                       classes=['pre-wrap']))
+    _check_result(result, details=details)
+    return result.stdout
+
+
+def ttm(math_code, as_block=False):
+    """Convert LaTeX math code to MathML with TtM__.
+
+    Aged, limited, but fast.
+
+    __ http://silas.psfc.mit.edu/tth/mml/
+    """
+    args = ['ttm',
+            '-L',  # source is LaTeX snippet
+            '-r']  # output MathML snippet
+    math_code = wrap_math_code(math_code, as_block)
+
+    # "ttm" does not support UTF-8 input. (Docutils converts most math
+    # characters to LaTeX commands before calling this function.)
+    try:
+        result = subprocess.run(args, input=math_code,
+                                capture_output=True, text=True,
+                                encoding='ISO-8859-1')
+    except UnicodeEncodeError as err:
+        raise MathError(err)
+
+    result.stdout = result.stdout[result.stdout.find('<math'):
+                                  result.stdout.find('</math>')+7]
+    if as_block:
+        result.stdout = result.stdout.replace('<math xmlns=',
+                                              '<math display="block" xmlns=')
+    result.stderr = '\n'.join(line[5:] + '.'
+                              for line in result.stderr.splitlines()
+                              if line.startswith('**** '))
+    _check_result(result)
+    return result.stdout
+
+
+# self-test
+
+if __name__ == "__main__":
+    example = (r'\frac{\partial \sin^2(\alpha)}{\partial \vec r}'
+               r'\varpi \mathbb{R} \, \text{Grüße}')
+
+    print("""<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
+<head>
+<title>test external mathml converters</title>
+</head>
+<body>
+<p>Test external converters</p>
+<p>
+""")
+    print(f'latexml: {latexml(example)},')
+    print(f'ttm: {ttm(example.replace("mathbb", "mathbf"))},')
+    print(f'blahtexml: {blahtexml(example)},')
+    print(f'pandoc: {pandoc(example)}.')
+    print('</p>')
+
+    print('<p>latexml:</p>')
+    print(latexml(example, as_block=True))
+    print('<p>ttm:</p>')
+    print(ttm(example.replace('mathbb', 'mathbf'), as_block=True))
+    print('<p>blahtexml:</p>')
+    print(blahtexml(example, as_block=True))
+    print('<p>pandoc:</p>')
+    print(pandoc(example, as_block=True))
+
+    print('</main>\n</body>\n</html>')
+
+    buggy = r'\sinc \phy'
+    # buggy = '\sqrt[e]'
+    try:
+        # print(blahtexml(buggy))
+        # print(latexml(f'${buggy}$'))
+        print(pandoc(f'${buggy}$'))
+        # print(ttm(f'${buggy}$'))
+    except MathError as err:
+        print(err)
+        print(err.details)
+        for node in err.details:
+            print(node.astext())
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/tex2unichar.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/tex2unichar.py
new file mode 100644
index 00000000..c84e8a6f
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/tex2unichar.py
@@ -0,0 +1,730 @@
+#!/usr/bin/env python3
+
+# LaTeX math to Unicode symbols translation dictionaries.
+# Generated with ``write_tex2unichar.py`` from the data in
+# http://milde.users.sourceforge.net/LUCR/Math/
+
+# Includes commands from:
+#   standard LaTeX
+#   amssymb
+#   amsmath
+#   amsxtra
+#   bbold
+#   esint
+#   mathabx
+#   mathdots
+#   txfonts
+#   stmaryrd
+#   wasysym
+
+mathaccent = {
+    'acute': '\u0301',  #  ́ COMBINING ACUTE ACCENT
+    'bar': '\u0304',  #  ̄ COMBINING MACRON
+    'breve': '\u0306',  #  ̆ COMBINING BREVE
+    'check': '\u030c',  #  ̌ COMBINING CARON
+    'ddddot': '\u20dc',  #  ⃜ COMBINING FOUR DOTS ABOVE
+    'dddot': '\u20db',  #  ⃛ COMBINING THREE DOTS ABOVE
+    'ddot': '\u0308',  #  ̈ COMBINING DIAERESIS
+    'dot': '\u0307',  #  ̇ COMBINING DOT ABOVE
+    'grave': '\u0300',  #  ̀ COMBINING GRAVE ACCENT
+    'hat': '\u0302',  #  ̂ COMBINING CIRCUMFLEX ACCENT
+    'mathring': '\u030a',  #  ̊ COMBINING RING ABOVE
+    'not': '\u0338',  #  ̸ COMBINING LONG SOLIDUS OVERLAY
+    'overleftrightarrow': '\u20e1',  #  ⃡ COMBINING LEFT RIGHT ARROW ABOVE
+    'overline': '\u0305',  #  ̅ COMBINING OVERLINE
+    'tilde': '\u0303',  #  ̃ COMBINING TILDE
+    'underbar': '\u0331',  #  ̱ COMBINING MACRON BELOW
+    'underleftarrow': '\u20ee',  #  ⃮ COMBINING LEFT ARROW BELOW
+    'underline': '\u0332',  #  ̲ COMBINING LOW LINE
+    'underrightarrow': '\u20ef',  #  ⃯ COMBINING RIGHT ARROW BELOW
+    'vec': '\u20d7',  #  ⃗ COMBINING RIGHT ARROW ABOVE
+    }
+
+mathalpha = {
+    'Bbbk': '\U0001d55c',  # 𝕜 MATHEMATICAL DOUBLE-STRUCK SMALL K
+    'Delta': '\u0394',  # Δ GREEK CAPITAL LETTER DELTA
+    'Gamma': '\u0393',  # Γ GREEK CAPITAL LETTER GAMMA
+    'Im': '\u2111',  # ℑ BLACK-LETTER CAPITAL I
+    'Lambda': '\u039b',  # Λ GREEK CAPITAL LETTER LAMDA
+    'Omega': '\u03a9',  # Ω GREEK CAPITAL LETTER OMEGA
+    'Phi': '\u03a6',  # Φ GREEK CAPITAL LETTER PHI
+    'Pi': '\u03a0',  # Π GREEK CAPITAL LETTER PI
+    'Psi': '\u03a8',  # Ψ GREEK CAPITAL LETTER PSI
+    'Re': '\u211c',  # ℜ BLACK-LETTER CAPITAL R
+    'Sigma': '\u03a3',  # Σ GREEK CAPITAL LETTER SIGMA
+    'Theta': '\u0398',  # Θ GREEK CAPITAL LETTER THETA
+    'Upsilon': '\u03a5',  # Υ GREEK CAPITAL LETTER UPSILON
+    'Xi': '\u039e',  # Ξ GREEK CAPITAL LETTER XI
+    'aleph': '\u2135',  # ℵ ALEF SYMBOL
+    'alpha': '\u03b1',  # α GREEK SMALL LETTER ALPHA
+    'beta': '\u03b2',  # β GREEK SMALL LETTER BETA
+    'beth': '\u2136',  # ℶ BET SYMBOL
+    'chi': '\u03c7',  # χ GREEK SMALL LETTER CHI
+    'daleth': '\u2138',  # ℸ DALET SYMBOL
+    'delta': '\u03b4',  # δ GREEK SMALL LETTER DELTA
+    'digamma': '\u03dd',  # ϝ GREEK SMALL LETTER DIGAMMA
+    'ell': '\u2113',  # ℓ SCRIPT SMALL L
+    'epsilon': '\u03f5',  # ϵ GREEK LUNATE EPSILON SYMBOL
+    'eta': '\u03b7',  # η GREEK SMALL LETTER ETA
+    'eth': '\xf0',  # ð LATIN SMALL LETTER ETH
+    'gamma': '\u03b3',  # γ GREEK SMALL LETTER GAMMA
+    'gimel': '\u2137',  # ℷ GIMEL SYMBOL
+    'imath': '\u0131',  # ı LATIN SMALL LETTER DOTLESS I
+    'iota': '\u03b9',  # ι GREEK SMALL LETTER IOTA
+    'jmath': '\u0237',  # ȷ LATIN SMALL LETTER DOTLESS J
+    'kappa': '\u03ba',  # κ GREEK SMALL LETTER KAPPA
+    'lambda': '\u03bb',  # λ GREEK SMALL LETTER LAMDA
+    'mu': '\u03bc',  # μ GREEK SMALL LETTER MU
+    'nu': '\u03bd',  # ν GREEK SMALL LETTER NU
+    'omega': '\u03c9',  # ω GREEK SMALL LETTER OMEGA
+    'phi': '\u03d5',  # ϕ GREEK PHI SYMBOL
+    'pi': '\u03c0',  # π GREEK SMALL LETTER PI
+    'psi': '\u03c8',  # ψ GREEK SMALL LETTER PSI
+    'rho': '\u03c1',  # ρ GREEK SMALL LETTER RHO
+    'sigma': '\u03c3',  # σ GREEK SMALL LETTER SIGMA
+    'tau': '\u03c4',  # τ GREEK SMALL LETTER TAU
+    'theta': '\u03b8',  # θ GREEK SMALL LETTER THETA
+    'upsilon': '\u03c5',  # υ GREEK SMALL LETTER UPSILON
+    'varDelta': '\U0001d6e5',  # 𝛥 MATHEMATICAL ITALIC CAPITAL DELTA
+    'varGamma': '\U0001d6e4',  # 𝛤 MATHEMATICAL ITALIC CAPITAL GAMMA
+    'varLambda': '\U0001d6ec',  # 𝛬 MATHEMATICAL ITALIC CAPITAL LAMDA
+    'varOmega': '\U0001d6fa',  # 𝛺 MATHEMATICAL ITALIC CAPITAL OMEGA
+    'varPhi': '\U0001d6f7',  # 𝛷 MATHEMATICAL ITALIC CAPITAL PHI
+    'varPi': '\U0001d6f1',  # 𝛱 MATHEMATICAL ITALIC CAPITAL PI
+    'varPsi': '\U0001d6f9',  # 𝛹 MATHEMATICAL ITALIC CAPITAL PSI
+    'varSigma': '\U0001d6f4',  # 𝛴 MATHEMATICAL ITALIC CAPITAL SIGMA
+    'varTheta': '\U0001d6e9',  # 𝛩 MATHEMATICAL ITALIC CAPITAL THETA
+    'varUpsilon': '\U0001d6f6',  # 𝛶 MATHEMATICAL ITALIC CAPITAL UPSILON
+    'varXi': '\U0001d6ef',  # 𝛯 MATHEMATICAL ITALIC CAPITAL XI
+    'varepsilon': '\u03b5',  # ε GREEK SMALL LETTER EPSILON
+    'varkappa': '\u03f0',  # ϰ GREEK KAPPA SYMBOL
+    'varphi': '\u03c6',  # φ GREEK SMALL LETTER PHI
+    'varpi': '\u03d6',  # ϖ GREEK PI SYMBOL
+    'varrho': '\u03f1',  # ϱ GREEK RHO SYMBOL
+    'varsigma': '\u03c2',  # ς GREEK SMALL LETTER FINAL SIGMA
+    'vartheta': '\u03d1',  # ϑ GREEK THETA SYMBOL
+    'wp': '\u2118',  # ℘ SCRIPT CAPITAL P
+    'xi': '\u03be',  # ξ GREEK SMALL LETTER XI
+    'zeta': '\u03b6',  # ζ GREEK SMALL LETTER ZETA
+    }
+
+mathbin = {
+    'Cap': '\u22d2',  # ⋒ DOUBLE INTERSECTION
+    'Circle': '\u25cb',  # ○ WHITE CIRCLE
+    'Cup': '\u22d3',  # ⋓ DOUBLE UNION
+    'LHD': '\u25c0',  # ◀ BLACK LEFT-POINTING TRIANGLE
+    'RHD': '\u25b6',  # ▶ BLACK RIGHT-POINTING TRIANGLE
+    'amalg': '\u2a3f',  # ⨿ AMALGAMATION OR COPRODUCT
+    'ast': '\u2217',  # ∗ ASTERISK OPERATOR
+    'barwedge': '\u22bc',  # ⊼ NAND
+    'bigcirc': '\u25ef',  # ◯ LARGE CIRCLE
+    'bigtriangledown': '\u25bd',  # ▽ WHITE DOWN-POINTING TRIANGLE
+    'bigtriangleup': '\u25b3',  # △ WHITE UP-POINTING TRIANGLE
+    'bindnasrepma': '\u214b',  # ⅋ TURNED AMPERSAND
+    'blacklozenge': '\u29eb',  # ⧫ BLACK LOZENGE
+    'boxast': '\u29c6',  # ⧆ SQUARED ASTERISK
+    'boxbar': '\u25eb',  # ◫ WHITE SQUARE WITH VERTICAL BISECTING LINE
+    'boxbox': '\u29c8',  # ⧈ SQUARED SQUARE
+    'boxbslash': '\u29c5',  # ⧅ SQUARED FALLING DIAGONAL SLASH
+    'boxcircle': '\u29c7',  # ⧇ SQUARED SMALL CIRCLE
+    'boxdot': '\u22a1',  # ⊡ SQUARED DOT OPERATOR
+    'boxminus': '\u229f',  # ⊟ SQUARED MINUS
+    'boxplus': '\u229e',  # ⊞ SQUARED PLUS
+    'boxslash': '\u29c4',  # ⧄ SQUARED RISING DIAGONAL SLASH
+    'boxtimes': '\u22a0',  # ⊠ SQUARED TIMES
+    'bullet': '\u2022',  # • BULLET
+    'cap': '\u2229',  # ∩ INTERSECTION
+    'cdot': '\u22c5',  # ⋅ DOT OPERATOR
+    'circ': '\u2218',  # ∘ RING OPERATOR
+    'circledast': '\u229b',  # ⊛ CIRCLED ASTERISK OPERATOR
+    'circledbslash': '\u29b8',  # ⦸ CIRCLED REVERSE SOLIDUS
+    'circledcirc': '\u229a',  # ⊚ CIRCLED RING OPERATOR
+    'circleddash': '\u229d',  # ⊝ CIRCLED DASH
+    'circledgtr': '\u29c1',  # ⧁ CIRCLED GREATER-THAN
+    'circledless': '\u29c0',  # ⧀ CIRCLED LESS-THAN
+    'cup': '\u222a',  # ∪ UNION
+    'curlyvee': '\u22ce',  # ⋎ CURLY LOGICAL OR
+    'curlywedge': '\u22cf',  # ⋏ CURLY LOGICAL AND
+    'dagger': '\u2020',  # † DAGGER
+    'ddagger': '\u2021',  # ‡ DOUBLE DAGGER
+    'diamond': '\u22c4',  # ⋄ DIAMOND OPERATOR
+    'div': '\xf7',  # ÷ DIVISION SIGN
+    'divideontimes': '\u22c7',  # ⋇ DIVISION TIMES
+    'dotplus': '\u2214',  # ∔ DOT PLUS
+    'doublebarwedge': '\u2a5e',  # ⩞ LOGICAL AND WITH DOUBLE OVERBAR
+    'gtrdot': '\u22d7',  # ⋗ GREATER-THAN WITH DOT
+    'intercal': '\u22ba',  # ⊺ INTERCALATE
+    'interleave': '\u2af4',  # ⫴ TRIPLE VERTICAL BAR BINARY RELATION
+    'invamp': '\u214b',  # ⅋ TURNED AMPERSAND
+    'land': '\u2227',  # ∧ LOGICAL AND
+    'leftthreetimes': '\u22cb',  # ⋋ LEFT SEMIDIRECT PRODUCT
+    'lessdot': '\u22d6',  # ⋖ LESS-THAN WITH DOT
+    'lor': '\u2228',  # ∨ LOGICAL OR
+    'ltimes': '\u22c9',  # ⋉ LEFT NORMAL FACTOR SEMIDIRECT PRODUCT
+    'mp': '\u2213',  # ∓ MINUS-OR-PLUS SIGN
+    'odot': '\u2299',  # ⊙ CIRCLED DOT OPERATOR
+    'ominus': '\u2296',  # ⊖ CIRCLED MINUS
+    'oplus': '\u2295',  # ⊕ CIRCLED PLUS
+    'oslash': '\u2298',  # ⊘ CIRCLED DIVISION SLASH
+    'otimes': '\u2297',  # ⊗ CIRCLED TIMES
+    'pm': '\xb1',  # ± PLUS-MINUS SIGN
+    'rightthreetimes': '\u22cc',  # ⋌ RIGHT SEMIDIRECT PRODUCT
+    'rtimes': '\u22ca',  # ⋊ RIGHT NORMAL FACTOR SEMIDIRECT PRODUCT
+    'setminus': '\u29f5',  # ⧵ REVERSE SOLIDUS OPERATOR
+    'slash': '\u2215',  # ∕ DIVISION SLASH
+    'smallsetminus': '\u2216',  # ∖ SET MINUS
+    'smalltriangledown': '\u25bf',  # ▿ WHITE DOWN-POINTING SMALL TRIANGLE
+    'smalltriangleleft': '\u25c3',  # ◃ WHITE LEFT-POINTING SMALL TRIANGLE
+    'smalltriangleright': '\u25b9',  # ▹ WHITE RIGHT-POINTING SMALL TRIANGLE
+    'sqcap': '\u2293',  # ⊓ SQUARE CAP
+    'sqcup': '\u2294',  # ⊔ SQUARE CUP
+    'sslash': '\u2afd',  # ⫽ DOUBLE SOLIDUS OPERATOR
+    'star': '\u22c6',  # ⋆ STAR OPERATOR
+    'talloblong': '\u2afe',  # ⫾ WHITE VERTICAL BAR
+    'times': '\xd7',  # × MULTIPLICATION SIGN
+    'triangleleft': '\u25c3',  # ◃ WHITE LEFT-POINTING SMALL TRIANGLE
+    'triangleright': '\u25b9',  # ▹ WHITE RIGHT-POINTING SMALL TRIANGLE
+    'uplus': '\u228e',  # ⊎ MULTISET UNION
+    'vee': '\u2228',  # ∨ LOGICAL OR
+    'veebar': '\u22bb',  # ⊻ XOR
+    'wedge': '\u2227',  # ∧ LOGICAL AND
+    'wr': '\u2240',  # ≀ WREATH PRODUCT
+    }
+
+mathclose = {
+    'Rbag': '\u27c6',  # ⟆ RIGHT S-SHAPED BAG DELIMITER
+    'lrcorner': '\u231f',  # ⌟ BOTTOM RIGHT CORNER
+    'rangle': '\u27e9',  # ⟩ MATHEMATICAL RIGHT ANGLE BRACKET
+    'rbag': '\u27c6',  # ⟆ RIGHT S-SHAPED BAG DELIMITER
+    'rbrace': '}',  # } RIGHT CURLY BRACKET
+    'rbrack': ']',  # ] RIGHT SQUARE BRACKET
+    'rceil': '\u2309',  # ⌉ RIGHT CEILING
+    'rfloor': '\u230b',  # ⌋ RIGHT FLOOR
+    'rgroup': '\u27ef',  # ⟯ MATHEMATICAL RIGHT FLATTENED PARENTHESIS
+    'rrbracket': '\u27e7',  # ⟧ MATHEMATICAL RIGHT WHITE SQUARE BRACKET
+    'rrparenthesis': '\u2988',  # ⦈ Z NOTATION RIGHT IMAGE BRACKET
+    'urcorner': '\u231d',  # ⌝ TOP RIGHT CORNER
+    '}': '}',  # } RIGHT CURLY BRACKET
+    }
+
+mathfence = {
+    'Vert': '\u2016',  # ‖ DOUBLE VERTICAL LINE
+    'vert': '|',  # | VERTICAL LINE
+    '|': '\u2016',  # ‖ DOUBLE VERTICAL LINE
+    }
+
+mathop = {
+    'bigcap': '\u22c2',  # ⋂ N-ARY INTERSECTION
+    'bigcup': '\u22c3',  # ⋃ N-ARY UNION
+    'biginterleave': '\u2afc',  # ⫼ LARGE TRIPLE VERTICAL BAR OPERATOR
+    'bigodot': '\u2a00',  # ⨀ N-ARY CIRCLED DOT OPERATOR
+    'bigoplus': '\u2a01',  # ⨁ N-ARY CIRCLED PLUS OPERATOR
+    'bigotimes': '\u2a02',  # ⨂ N-ARY CIRCLED TIMES OPERATOR
+    'bigsqcap': '\u2a05',  # ⨅ N-ARY SQUARE INTERSECTION OPERATOR
+    'bigsqcup': '\u2a06',  # ⨆ N-ARY SQUARE UNION OPERATOR
+    'biguplus': '\u2a04',  # ⨄ N-ARY UNION OPERATOR WITH PLUS
+    'bigvee': '\u22c1',  # ⋁ N-ARY LOGICAL OR
+    'bigwedge': '\u22c0',  # ⋀ N-ARY LOGICAL AND
+    'coprod': '\u2210',  # ∐ N-ARY COPRODUCT
+    'fatsemi': '\u2a1f',  # ⨟ Z NOTATION SCHEMA COMPOSITION
+    'fint': '\u2a0f',  # ⨏ INTEGRAL AVERAGE WITH SLASH
+    'iiiint': '\u2a0c',  # ⨌ QUADRUPLE INTEGRAL OPERATOR
+    'iiint': '\u222d',  # ∭ TRIPLE INTEGRAL
+    'iint': '\u222c',  # ∬ DOUBLE INTEGRAL
+    'int': '\u222b',  # ∫ INTEGRAL
+    'intop': '\u222b',  # ∫ INTEGRAL
+    'oiiint': '\u2230',  # ∰ VOLUME INTEGRAL
+    'oiint': '\u222f',  # ∯ SURFACE INTEGRAL
+    'oint': '\u222e',  # ∮ CONTOUR INTEGRAL
+    'ointctrclockwise': '\u2233',  # ∳ ANTICLOCKWISE CONTOUR INTEGRAL
+    'ointop': '\u222e',  # ∮ CONTOUR INTEGRAL
+    'prod': '\u220f',  # ∏ N-ARY PRODUCT
+    'sqint': '\u2a16',  # ⨖ QUATERNION INTEGRAL OPERATOR
+    'sum': '\u2211',  # ∑ N-ARY SUMMATION
+    'varointclockwise': '\u2232',  # ∲ CLOCKWISE CONTOUR INTEGRAL
+    'varprod': '\u2a09',  # ⨉ N-ARY TIMES OPERATOR
+    }
+
+mathopen = {
+    'Lbag': '\u27c5',  # ⟅ LEFT S-SHAPED BAG DELIMITER
+    'langle': '\u27e8',  # ⟨ MATHEMATICAL LEFT ANGLE BRACKET
+    'lbag': '\u27c5',  # ⟅ LEFT S-SHAPED BAG DELIMITER
+    'lbrace': '{',  # { LEFT CURLY BRACKET
+    'lbrack': '[',  # [ LEFT SQUARE BRACKET
+    'lceil': '\u2308',  # ⌈ LEFT CEILING
+    'lfloor': '\u230a',  # ⌊ LEFT FLOOR
+    'lgroup': '\u27ee',  # ⟮ MATHEMATICAL LEFT FLATTENED PARENTHESIS
+    'llbracket': '\u27e6',  # ⟦ MATHEMATICAL LEFT WHITE SQUARE BRACKET
+    'llcorner': '\u231e',  # ⌞ BOTTOM LEFT CORNER
+    'llparenthesis': '\u2987',  # ⦇ Z NOTATION LEFT IMAGE BRACKET
+    'ulcorner': '\u231c',  # ⌜ TOP LEFT CORNER
+    '{': '{',  # { LEFT CURLY BRACKET
+    }
+
+mathord = {
+    '#': '#',  # # NUMBER SIGN
+    '$': '$',  # $ DOLLAR SIGN
+    '%': '%',  # % PERCENT SIGN
+    '&': '&',  # & AMPERSAND
+    'AC': '\u223f',  # ∿ SINE WAVE
+    'APLcomment': '\u235d',  # ⍝ APL FUNCTIONAL SYMBOL UP SHOE JOT
+    'APLdownarrowbox': '\u2357',  # ⍗ APL FUNCTIONAL SYMBOL QUAD DOWNWARDS ARROW
+    'APLinput': '\u235e',  # ⍞ APL FUNCTIONAL SYMBOL QUOTE QUAD
+    'APLinv': '\u2339',  # ⌹ APL FUNCTIONAL SYMBOL QUAD DIVIDE
+    'APLleftarrowbox': '\u2347',  # ⍇ APL FUNCTIONAL SYMBOL QUAD LEFTWARDS ARROW
+    'APLlog': '\u235f',  # ⍟ APL FUNCTIONAL SYMBOL CIRCLE STAR
+    'APLrightarrowbox': '\u2348',  # ⍈ APL FUNCTIONAL SYMBOL QUAD RIGHTWARDS ARROW
+    'APLuparrowbox': '\u2350',  # ⍐ APL FUNCTIONAL SYMBOL QUAD UPWARDS ARROW
+    'Aries': '\u2648',  # ♈ ARIES
+    'Box': '\u2b1c',  # ⬜ WHITE LARGE SQUARE
+    'CIRCLE': '\u25cf',  # ● BLACK CIRCLE
+    'CheckedBox': '\u2611',  # ☑ BALLOT BOX WITH CHECK
+    'Diamond': '\u25c7',  # ◇ WHITE DIAMOND
+    'Diamondblack': '\u25c6',  # ◆ BLACK DIAMOND
+    'Diamonddot': '\u27d0',  # ⟐ WHITE DIAMOND WITH CENTRED DOT
+    'Finv': '\u2132',  # Ⅎ TURNED CAPITAL F
+    'Game': '\u2141',  # ⅁ TURNED SANS-SERIF CAPITAL G
+    'Gemini': '\u264a',  # ♊ GEMINI
+    'Jupiter': '\u2643',  # ♃ JUPITER
+    'LEFTCIRCLE': '\u25d6',  # ◖ LEFT HALF BLACK CIRCLE
+    'LEFTcircle': '\u25d0',  # ◐ CIRCLE WITH LEFT HALF BLACK
+    'Leo': '\u264c',  # ♌ LEO
+    'Libra': '\u264e',  # ♎ LIBRA
+    'Mars': '\u2642',  # ♂ MALE SIGN
+    'Mercury': '\u263f',  # ☿ MERCURY
+    'Neptune': '\u2646',  # ♆ NEPTUNE
+    'P': '\xb6',  # ¶ PILCROW SIGN
+    'Pluto': '\u2647',  # ♇ PLUTO
+    'RIGHTCIRCLE': '\u25d7',  # ◗ RIGHT HALF BLACK CIRCLE
+    'RIGHTcircle': '\u25d1',  # ◑ CIRCLE WITH RIGHT HALF BLACK
+    'S': '\xa7',  # § SECTION SIGN
+    'Saturn': '\u2644',  # ♄ SATURN
+    'Scorpio': '\u264f',  # ♏ SCORPIUS
+    'Square': '\u2610',  # ☐ BALLOT BOX
+    'Sun': '\u2609',  # ☉ SUN
+    'Taurus': '\u2649',  # ♉ TAURUS
+    'Uranus': '\u2645',  # ♅ URANUS
+    'Venus': '\u2640',  # ♀ FEMALE SIGN
+    'XBox': '\u2612',  # ☒ BALLOT BOX WITH X
+    'Yup': '\u2144',  # ⅄ TURNED SANS-SERIF CAPITAL Y
+    '_': '_',  # _ LOW LINE
+    'angle': '\u2220',  # ∠ ANGLE
+    'aquarius': '\u2652',  # ♒ AQUARIUS
+    'aries': '\u2648',  # ♈ ARIES
+    'arrowvert': '\u23d0',  # ⏐ VERTICAL LINE EXTENSION
+    'backprime': '\u2035',  # ‵ REVERSED PRIME
+    'backslash': '\\',  # \ REVERSE SOLIDUS
+    'bigstar': '\u2605',  # ★ BLACK STAR
+    'blacksmiley': '\u263b',  # ☻ BLACK SMILING FACE
+    'blacksquare': '\u25fc',  # ◼ BLACK MEDIUM SQUARE
+    'blacktriangle': '\u25b4',  # ▴ BLACK UP-POINTING SMALL TRIANGLE
+    'blacktriangledown': '\u25be',  # ▾ BLACK DOWN-POINTING SMALL TRIANGLE
+    'blacktriangleup': '\u25b4',  # ▴ BLACK UP-POINTING SMALL TRIANGLE
+    'bot': '\u22a5',  # ⊥ UP TACK
+    'boy': '\u2642',  # ♂ MALE SIGN
+    'bracevert': '\u23aa',  # ⎪ CURLY BRACKET EXTENSION
+    'cancer': '\u264b',  # ♋ CANCER
+    'capricornus': '\u2651',  # ♑ CAPRICORN
+    'cdots': '\u22ef',  # ⋯ MIDLINE HORIZONTAL ELLIPSIS
+    'cent': '\xa2',  # ¢ CENT SIGN
+    'checkmark': '\u2713',  # ✓ CHECK MARK
+    'circledR': '\u24c7',  # Ⓡ CIRCLED LATIN CAPITAL LETTER R
+    'circledS': '\u24c8',  # Ⓢ CIRCLED LATIN CAPITAL LETTER S
+    'clubsuit': '\u2663',  # ♣ BLACK CLUB SUIT
+    'complement': '\u2201',  # ∁ COMPLEMENT
+    'diagdown': '\u27cd',  # ⟍ MATHEMATICAL FALLING DIAGONAL
+    'diagup': '\u27cb',  # ⟋ MATHEMATICAL RISING DIAGONAL
+    'diameter': '\u2300',  # ⌀ DIAMETER SIGN
+    'diamondsuit': '\u2662',  # ♢ WHITE DIAMOND SUIT
+    'earth': '\u2641',  # ♁ EARTH
+    'emptyset': '\u2205',  # ∅ EMPTY SET
+    'exists': '\u2203',  # ∃ THERE EXISTS
+    'female': '\u2640',  # ♀ FEMALE SIGN
+    'flat': '\u266d',  # ♭ MUSIC FLAT SIGN
+    'forall': '\u2200',  # ∀ FOR ALL
+    'fourth': '\u2057',  # ⁗ QUADRUPLE PRIME
+    'frownie': '\u2639',  # ☹ WHITE FROWNING FACE
+    'gemini': '\u264a',  # ♊ GEMINI
+    'girl': '\u2640',  # ♀ FEMALE SIGN
+    'heartsuit': '\u2661',  # ♡ WHITE HEART SUIT
+    'hslash': '\u210f',  # ℏ PLANCK CONSTANT OVER TWO PI
+    'infty': '\u221e',  # ∞ INFINITY
+    'invdiameter': '\u2349',  # ⍉ APL FUNCTIONAL SYMBOL CIRCLE BACKSLASH
+    'invneg': '\u2310',  # ⌐ REVERSED NOT SIGN
+    'jupiter': '\u2643',  # ♃ JUPITER
+    'ldots': '\u2026',  # … HORIZONTAL ELLIPSIS
+    'leftmoon': '\u263e',  # ☾ LAST QUARTER MOON
+    'leo': '\u264c',  # ♌ LEO
+    'libra': '\u264e',  # ♎ LIBRA
+    'lmoustache': '\u23b0',  # ⎰ UPPER LEFT OR LOWER RIGHT CURLY BRACKET SECTION
+    'lnot': '\xac',  # ¬ NOT SIGN
+    'lozenge': '\u25ca',  # ◊ LOZENGE
+    'male': '\u2642',  # ♂ MALE SIGN
+    'maltese': '\u2720',  # ✠ MALTESE CROSS
+    'mathcent': '\xa2',  # ¢ CENT SIGN
+    'mathdollar': '$',  # $ DOLLAR SIGN
+    'mathsterling': '\xa3',  # £ POUND SIGN
+    'measuredangle': '\u2221',  # ∡ MEASURED ANGLE
+    'medbullet': '\u26ab',  # ⚫ MEDIUM BLACK CIRCLE
+    'medcirc': '\u26aa',  # ⚪ MEDIUM WHITE CIRCLE
+    'mercury': '\u263f',  # ☿ MERCURY
+    'mho': '\u2127',  # ℧ INVERTED OHM SIGN
+    'nabla': '\u2207',  # ∇ NABLA
+    'natural': '\u266e',  # ♮ MUSIC NATURAL SIGN
+    'neg': '\xac',  # ¬ NOT SIGN
+    'neptune': '\u2646',  # ♆ NEPTUNE
+    'nexists': '\u2204',  # ∄ THERE DOES NOT EXIST
+    'notbackslash': '\u2340',  # ⍀ APL FUNCTIONAL SYMBOL BACKSLASH BAR
+    'partial': '\u2202',  # ∂ PARTIAL DIFFERENTIAL
+    'pisces': '\u2653',  # ♓ PISCES
+    'pluto': '\u2647',  # ♇ PLUTO
+    'pounds': '\xa3',  # £ POUND SIGN
+    'prime': '\u2032',  # ′ PRIME
+    'quarternote': '\u2669',  # ♩ QUARTER NOTE
+    'rightmoon': '\u263d',  # ☽ FIRST QUARTER MOON
+    'rmoustache': '\u23b1',  # ⎱ UPPER RIGHT OR LOWER LEFT CURLY BRACKET SECTION
+    'sagittarius': '\u2650',  # ♐ SAGITTARIUS
+    'saturn': '\u2644',  # ♄ SATURN
+    'scorpio': '\u264f',  # ♏ SCORPIUS
+    'second': '\u2033',  # ″ DOUBLE PRIME
+    'sharp': '\u266f',  # ♯ MUSIC SHARP SIGN
+    'smiley': '\u263a',  # ☺ WHITE SMILING FACE
+    'spadesuit': '\u2660',  # ♠ BLACK SPADE SUIT
+    'spddot': '\xa8',  # ¨ DIAERESIS
+    'sphat': '^',  # ^ CIRCUMFLEX ACCENT
+    'sphericalangle': '\u2222',  # ∢ SPHERICAL ANGLE
+    'sptilde': '~',  # ~ TILDE
+    'square': '\u25fb',  # ◻ WHITE MEDIUM SQUARE
+    'sun': '\u263c',  # ☼ WHITE SUN WITH RAYS
+    'surd': '\u221a',  # √ SQUARE ROOT
+    'taurus': '\u2649',  # ♉ TAURUS
+    'third': '\u2034',  # ‴ TRIPLE PRIME
+    'top': '\u22a4',  # ⊤ DOWN TACK
+    'twonotes': '\u266b',  # ♫ BEAMED EIGHTH NOTES
+    'uranus': '\u2645',  # ♅ URANUS
+    'varEarth': '\u2641',  # ♁ EARTH
+    'varclubsuit': '\u2667',  # ♧ WHITE CLUB SUIT
+    'vardiamondsuit': '\u2666',  # ♦ BLACK DIAMOND SUIT
+    'varheartsuit': '\u2665',  # ♥ BLACK HEART SUIT
+    'varspadesuit': '\u2664',  # ♤ WHITE SPADE SUIT
+    'virgo': '\u264d',  # ♍ VIRGO
+    'wasylozenge': '\u2311',  # ⌑ SQUARE LOZENGE
+    'yen': '\xa5',  # ¥ YEN SIGN
+    }
+
+mathover = {
+    'overbrace': '\u23de',  # ⏞ TOP CURLY BRACKET
+    'wideparen': '\u23dc',  # ⏜ TOP PARENTHESIS
+    }
+
+mathpunct = {
+    'ddots': '\u22f1',  # ⋱ DOWN RIGHT DIAGONAL ELLIPSIS
+    'vdots': '\u22ee',  # ⋮ VERTICAL ELLIPSIS
+    }
+
+mathradical = {
+    'sqrt[3]': '\u221b',  # ∛ CUBE ROOT
+    'sqrt[4]': '\u221c',  # ∜ FOURTH ROOT
+    }
+
+mathrel = {
+    'Bot': '\u2aeb',  # ⫫ DOUBLE UP TACK
+    'Bumpeq': '\u224e',  # ≎ GEOMETRICALLY EQUIVALENT TO
+    'Coloneqq': '\u2a74',  # ⩴ DOUBLE COLON EQUAL
+    'Doteq': '\u2251',  # ≑ GEOMETRICALLY EQUAL TO
+    'Downarrow': '\u21d3',  # ⇓ DOWNWARDS DOUBLE ARROW
+    'Leftarrow': '\u21d0',  # ⇐ LEFTWARDS DOUBLE ARROW
+    'Leftrightarrow': '\u21d4',  # ⇔ LEFT RIGHT DOUBLE ARROW
+    'Lleftarrow': '\u21da',  # ⇚ LEFTWARDS TRIPLE ARROW
+    'Longleftarrow': '\u27f8',  # ⟸ LONG LEFTWARDS DOUBLE ARROW
+    'Longleftrightarrow': '\u27fa',  # ⟺ LONG LEFT RIGHT DOUBLE ARROW
+    'Longmapsfrom': '\u27fd',  # ⟽ LONG LEFTWARDS DOUBLE ARROW FROM BAR
+    'Longmapsto': '\u27fe',  # ⟾ LONG RIGHTWARDS DOUBLE ARROW FROM BAR
+    'Longrightarrow': '\u27f9',  # ⟹ LONG RIGHTWARDS DOUBLE ARROW
+    'Lsh': '\u21b0',  # ↰ UPWARDS ARROW WITH TIP LEFTWARDS
+    'Mapsfrom': '\u2906',  # ⤆ LEFTWARDS DOUBLE ARROW FROM BAR
+    'Mapsto': '\u2907',  # ⤇ RIGHTWARDS DOUBLE ARROW FROM BAR
+    'Nearrow': '\u21d7',  # ⇗ NORTH EAST DOUBLE ARROW
+    'Nwarrow': '\u21d6',  # ⇖ NORTH WEST DOUBLE ARROW
+    'Perp': '\u2aeb',  # ⫫ DOUBLE UP TACK
+    'Rightarrow': '\u21d2',  # ⇒ RIGHTWARDS DOUBLE ARROW
+    'Rrightarrow': '\u21db',  # ⇛ RIGHTWARDS TRIPLE ARROW
+    'Rsh': '\u21b1',  # ↱ UPWARDS ARROW WITH TIP RIGHTWARDS
+    'Searrow': '\u21d8',  # ⇘ SOUTH EAST DOUBLE ARROW
+    'Subset': '\u22d0',  # ⋐ DOUBLE SUBSET
+    'Supset': '\u22d1',  # ⋑ DOUBLE SUPERSET
+    'Swarrow': '\u21d9',  # ⇙ SOUTH WEST DOUBLE ARROW
+    'Top': '\u2aea',  # ⫪ DOUBLE DOWN TACK
+    'Uparrow': '\u21d1',  # ⇑ UPWARDS DOUBLE ARROW
+    'Updownarrow': '\u21d5',  # ⇕ UP DOWN DOUBLE ARROW
+    'VDash': '\u22ab',  # ⊫ DOUBLE VERTICAL BAR DOUBLE RIGHT TURNSTILE
+    'Vdash': '\u22a9',  # ⊩ FORCES
+    'Vvdash': '\u22aa',  # ⊪ TRIPLE VERTICAL BAR RIGHT TURNSTILE
+    'apprge': '\u2273',  # ≳ GREATER-THAN OR EQUIVALENT TO
+    'apprle': '\u2272',  # ≲ LESS-THAN OR EQUIVALENT TO
+    'approx': '\u2248',  # ≈ ALMOST EQUAL TO
+    'approxeq': '\u224a',  # ≊ ALMOST EQUAL OR EQUAL TO
+    'asymp': '\u224d',  # ≍ EQUIVALENT TO
+    'backepsilon': '\u220d',  # ∍ SMALL CONTAINS AS MEMBER
+    'backsim': '\u223d',  # ∽ REVERSED TILDE
+    'backsimeq': '\u22cd',  # ⋍ REVERSED TILDE EQUALS
+    'barin': '\u22f6',  # ⋶ ELEMENT OF WITH OVERBAR
+    'barleftharpoon': '\u296b',  # ⥫ LEFTWARDS HARPOON WITH BARB DOWN BELOW LONG DASH
+    'barrightharpoon': '\u296d',  # ⥭ RIGHTWARDS HARPOON WITH BARB DOWN BELOW LONG DASH
+    'because': '\u2235',  # ∵ BECAUSE
+    'between': '\u226c',  # ≬ BETWEEN
+    'blacktriangleleft': '\u25c2',  # ◂ BLACK LEFT-POINTING SMALL TRIANGLE
+    'blacktriangleright': '\u25b8',  # ▸ BLACK RIGHT-POINTING SMALL TRIANGLE
+    'bowtie': '\u22c8',  # ⋈ BOWTIE
+    'bumpeq': '\u224f',  # ≏ DIFFERENCE BETWEEN
+    'circeq': '\u2257',  # ≗ RING EQUAL TO
+    'circlearrowleft': '\u21ba',  # ↺ ANTICLOCKWISE OPEN CIRCLE ARROW
+    'circlearrowright': '\u21bb',  # ↻ CLOCKWISE OPEN CIRCLE ARROW
+    'coloneq': '\u2254',  # ≔ COLON EQUALS
+    'coloneqq': '\u2254',  # ≔ COLON EQUALS
+    'cong': '\u2245',  # ≅ APPROXIMATELY EQUAL TO
+    'corresponds': '\u2259',  # ≙ ESTIMATES
+    'curlyeqprec': '\u22de',  # ⋞ EQUAL TO OR PRECEDES
+    'curlyeqsucc': '\u22df',  # ⋟ EQUAL TO OR SUCCEEDS
+    'curvearrowleft': '\u21b6',  # ↶ ANTICLOCKWISE TOP SEMICIRCLE ARROW
+    'curvearrowright': '\u21b7',  # ↷ CLOCKWISE TOP SEMICIRCLE ARROW
+    'dasharrow': '\u21e2',  # ⇢ RIGHTWARDS DASHED ARROW
+    'dashleftarrow': '\u21e0',  # ⇠ LEFTWARDS DASHED ARROW
+    'dashrightarrow': '\u21e2',  # ⇢ RIGHTWARDS DASHED ARROW
+    'dashv': '\u22a3',  # ⊣ LEFT TACK
+    'dlsh': '\u21b2',  # ↲ DOWNWARDS ARROW WITH TIP LEFTWARDS
+    'doteq': '\u2250',  # ≐ APPROACHES THE LIMIT
+    'doteqdot': '\u2251',  # ≑ GEOMETRICALLY EQUAL TO
+    'downarrow': '\u2193',  # ↓ DOWNWARDS ARROW
+    'downdownarrows': '\u21ca',  # ⇊ DOWNWARDS PAIRED ARROWS
+    'downdownharpoons': '\u2965',  # ⥥ DOWNWARDS HARPOON WITH BARB LEFT BESIDE DOWNWARDS HARPOON WITH BARB RIGHT
+    'downharpoonleft': '\u21c3',  # ⇃ DOWNWARDS HARPOON WITH BARB LEFTWARDS
+    'downharpoonright': '\u21c2',  # ⇂ DOWNWARDS HARPOON WITH BARB RIGHTWARDS
+    'downuparrows': '\u21f5',  # ⇵ DOWNWARDS ARROW LEFTWARDS OF UPWARDS ARROW
+    'downupharpoons': '\u296f',  # ⥯ DOWNWARDS HARPOON WITH BARB LEFT BESIDE UPWARDS HARPOON WITH BARB RIGHT
+    'drsh': '\u21b3',  # ↳ DOWNWARDS ARROW WITH TIP RIGHTWARDS
+    'eqcirc': '\u2256',  # ≖ RING IN EQUAL TO
+    'eqcolon': '\u2255',  # ≕ EQUALS COLON
+    'eqqcolon': '\u2255',  # ≕ EQUALS COLON
+    'eqsim': '\u2242',  # ≂ MINUS TILDE
+    'eqslantgtr': '\u2a96',  # ⪖ SLANTED EQUAL TO OR GREATER-THAN
+    'eqslantless': '\u2a95',  # ⪕ SLANTED EQUAL TO OR LESS-THAN
+    'equiv': '\u2261',  # ≡ IDENTICAL TO
+    'fallingdotseq': '\u2252',  # ≒ APPROXIMATELY EQUAL TO OR THE IMAGE OF
+    'frown': '\u2322',  # ⌢ FROWN
+    'ge': '\u2265',  # ≥ GREATER-THAN OR EQUAL TO
+    'geq': '\u2265',  # ≥ GREATER-THAN OR EQUAL TO
+    'geqq': '\u2267',  # ≧ GREATER-THAN OVER EQUAL TO
+    'geqslant': '\u2a7e',  # ⩾ GREATER-THAN OR SLANTED EQUAL TO
+    'gets': '\u2190',  # ← LEFTWARDS ARROW
+    'gg': '\u226b',  # ≫ MUCH GREATER-THAN
+    'ggcurly': '\u2abc',  # ⪼ DOUBLE SUCCEEDS
+    'ggg': '\u22d9',  # ⋙ VERY MUCH GREATER-THAN
+    'gggtr': '\u22d9',  # ⋙ VERY MUCH GREATER-THAN
+    'gnapprox': '\u2a8a',  # ⪊ GREATER-THAN AND NOT APPROXIMATE
+    'gneq': '\u2a88',  # ⪈ GREATER-THAN AND SINGLE-LINE NOT EQUAL TO
+    'gneqq': '\u2269',  # ≩ GREATER-THAN BUT NOT EQUAL TO
+    'gnsim': '\u22e7',  # ⋧ GREATER-THAN BUT NOT EQUIVALENT TO
+    'gtrapprox': '\u2a86',  # ⪆ GREATER-THAN OR APPROXIMATE
+    'gtreqless': '\u22db',  # ⋛ GREATER-THAN EQUAL TO OR LESS-THAN
+    'gtreqqless': '\u2a8c',  # ⪌ GREATER-THAN ABOVE DOUBLE-LINE EQUAL ABOVE LESS-THAN
+    'gtrless': '\u2277',  # ≷ GREATER-THAN OR LESS-THAN
+    'gtrsim': '\u2273',  # ≳ GREATER-THAN OR EQUIVALENT TO
+    'hash': '\u22d5',  # ⋕ EQUAL AND PARALLEL TO
+    'hookleftarrow': '\u21a9',  # ↩ LEFTWARDS ARROW WITH HOOK
+    'hookrightarrow': '\u21aa',  # ↪ RIGHTWARDS ARROW WITH HOOK
+    'iddots': '\u22f0',  # ⋰ UP RIGHT DIAGONAL ELLIPSIS
+    'impliedby': '\u27f8',  # ⟸ LONG LEFTWARDS DOUBLE ARROW
+    'implies': '\u27f9',  # ⟹ LONG RIGHTWARDS DOUBLE ARROW
+    'in': '\u2208',  # ∈ ELEMENT OF
+    'le': '\u2264',  # ≤ LESS-THAN OR EQUAL TO
+    'leadsto': '\u2933',  # ⤳ WAVE ARROW POINTING DIRECTLY RIGHT
+    'leftarrow': '\u2190',  # ← LEFTWARDS ARROW
+    'leftarrowtail': '\u21a2',  # ↢ LEFTWARDS ARROW WITH TAIL
+    'leftarrowtriangle': '\u21fd',  # ⇽ LEFTWARDS OPEN-HEADED ARROW
+    'leftbarharpoon': '\u296a',  # ⥪ LEFTWARDS HARPOON WITH BARB UP ABOVE LONG DASH
+    'leftharpoondown': '\u21bd',  # ↽ LEFTWARDS HARPOON WITH BARB DOWNWARDS
+    'leftharpoonup': '\u21bc',  # ↼ LEFTWARDS HARPOON WITH BARB UPWARDS
+    'leftleftarrows': '\u21c7',  # ⇇ LEFTWARDS PAIRED ARROWS
+    'leftleftharpoons': '\u2962',  # ⥢ LEFTWARDS HARPOON WITH BARB UP ABOVE LEFTWARDS HARPOON WITH BARB DOWN
+    'leftrightarrow': '\u2194',  # ↔ LEFT RIGHT ARROW
+    'leftrightarrows': '\u21c6',  # ⇆ LEFTWARDS ARROW OVER RIGHTWARDS ARROW
+    'leftrightarrowtriangle': '\u21ff',  # ⇿ LEFT RIGHT OPEN-HEADED ARROW
+    'leftrightharpoon': '\u294a',  # ⥊ LEFT BARB UP RIGHT BARB DOWN HARPOON
+    'leftrightharpoons': '\u21cb',  # ⇋ LEFTWARDS HARPOON OVER RIGHTWARDS HARPOON
+    'leftrightsquigarrow': '\u21ad',  # ↭ LEFT RIGHT WAVE ARROW
+    'leftslice': '\u2aa6',  # ⪦ LESS-THAN CLOSED BY CURVE
+    'leftsquigarrow': '\u21dc',  # ⇜ LEFTWARDS SQUIGGLE ARROW
+    'leftturn': '\u21ba',  # ↺ ANTICLOCKWISE OPEN CIRCLE ARROW
+    'leq': '\u2264',  # ≤ LESS-THAN OR EQUAL TO
+    'leqq': '\u2266',  # ≦ LESS-THAN OVER EQUAL TO
+    'leqslant': '\u2a7d',  # ⩽ LESS-THAN OR SLANTED EQUAL TO
+    'lessapprox': '\u2a85',  # ⪅ LESS-THAN OR APPROXIMATE
+    'lesseqgtr': '\u22da',  # ⋚ LESS-THAN EQUAL TO OR GREATER-THAN
+    'lesseqqgtr': '\u2a8b',  # ⪋ LESS-THAN ABOVE DOUBLE-LINE EQUAL ABOVE GREATER-THAN
+    'lessgtr': '\u2276',  # ≶ LESS-THAN OR GREATER-THAN
+    'lesssim': '\u2272',  # ≲ LESS-THAN OR EQUIVALENT TO
+    'lhd': '\u22b2',  # ⊲ NORMAL SUBGROUP OF
+    'lightning': '\u21af',  # ↯ DOWNWARDS ZIGZAG ARROW
+    'll': '\u226a',  # ≪ MUCH LESS-THAN
+    'llcurly': '\u2abb',  # ⪻ DOUBLE PRECEDES
+    'lll': '\u22d8',  # ⋘ VERY MUCH LESS-THAN
+    'llless': '\u22d8',  # ⋘ VERY MUCH LESS-THAN
+    'lnapprox': '\u2a89',  # ⪉ LESS-THAN AND NOT APPROXIMATE
+    'lneq': '\u2a87',  # ⪇ LESS-THAN AND SINGLE-LINE NOT EQUAL TO
+    'lneqq': '\u2268',  # ≨ LESS-THAN BUT NOT EQUAL TO
+    'lnsim': '\u22e6',  # ⋦ LESS-THAN BUT NOT EQUIVALENT TO
+    'longleftarrow': '\u27f5',  # ⟵ LONG LEFTWARDS ARROW
+    'longleftrightarrow': '\u27f7',  # ⟷ LONG LEFT RIGHT ARROW
+    'longmapsfrom': '\u27fb',  # ⟻ LONG LEFTWARDS ARROW FROM BAR
+    'longmapsto': '\u27fc',  # ⟼ LONG RIGHTWARDS ARROW FROM BAR
+    'longrightarrow': '\u27f6',  # ⟶ LONG RIGHTWARDS ARROW
+    'looparrowleft': '\u21ab',  # ↫ LEFTWARDS ARROW WITH LOOP
+    'looparrowright': '\u21ac',  # ↬ RIGHTWARDS ARROW WITH LOOP
+    'lrtimes': '\u22c8',  # ⋈ BOWTIE
+    'mapsfrom': '\u21a4',  # ↤ LEFTWARDS ARROW FROM BAR
+    'mapsto': '\u21a6',  # ↦ RIGHTWARDS ARROW FROM BAR
+    'mid': '\u2223',  # ∣ DIVIDES
+    'models': '\u22a7',  # ⊧ MODELS
+    'multimap': '\u22b8',  # ⊸ MULTIMAP
+    'multimapboth': '\u29df',  # ⧟ DOUBLE-ENDED MULTIMAP
+    'multimapdotbothA': '\u22b6',  # ⊶ ORIGINAL OF
+    'multimapdotbothB': '\u22b7',  # ⊷ IMAGE OF
+    'multimapinv': '\u27dc',  # ⟜ LEFT MULTIMAP
+    'nLeftarrow': '\u21cd',  # ⇍ LEFTWARDS DOUBLE ARROW WITH STROKE
+    'nLeftrightarrow': '\u21ce',  # ⇎ LEFT RIGHT DOUBLE ARROW WITH STROKE
+    'nRightarrow': '\u21cf',  # ⇏ RIGHTWARDS DOUBLE ARROW WITH STROKE
+    'nVDash': '\u22af',  # ⊯ NEGATED DOUBLE VERTICAL BAR DOUBLE RIGHT TURNSTILE
+    'nVdash': '\u22ae',  # ⊮ DOES NOT FORCE
+    'ncong': '\u2247',  # ≇ NEITHER APPROXIMATELY NOR ACTUALLY EQUAL TO
+    'ne': '\u2260',  # ≠ NOT EQUAL TO
+    'nearrow': '\u2197',  # ↗ NORTH EAST ARROW
+    'neq': '\u2260',  # ≠ NOT EQUAL TO
+    'ngeq': '\u2271',  # ≱ NEITHER GREATER-THAN NOR EQUAL TO
+    'ngtr': '\u226f',  # ≯ NOT GREATER-THAN
+    'ngtrless': '\u2279',  # ≹ NEITHER GREATER-THAN NOR LESS-THAN
+    'ni': '\u220b',  # ∋ CONTAINS AS MEMBER
+    'nleftarrow': '\u219a',  # ↚ LEFTWARDS ARROW WITH STROKE
+    'nleftrightarrow': '\u21ae',  # ↮ LEFT RIGHT ARROW WITH STROKE
+    'nleq': '\u2270',  # ≰ NEITHER LESS-THAN NOR EQUAL TO
+    'nless': '\u226e',  # ≮ NOT LESS-THAN
+    'nlessgtr': '\u2278',  # ≸ NEITHER LESS-THAN NOR GREATER-THAN
+    'nmid': '\u2224',  # ∤ DOES NOT DIVIDE
+    'notasymp': '\u226d',  # ≭ NOT EQUIVALENT TO
+    'notin': '\u2209',  # ∉ NOT AN ELEMENT OF
+    'notni': '\u220c',  # ∌ DOES NOT CONTAIN AS MEMBER
+    'notowner': '\u220c',  # ∌ DOES NOT CONTAIN AS MEMBER
+    'notslash': '\u233f',  # ⌿ APL FUNCTIONAL SYMBOL SLASH BAR
+    'nparallel': '\u2226',  # ∦ NOT PARALLEL TO
+    'nprec': '\u2280',  # ⊀ DOES NOT PRECEDE
+    'npreceq': '\u22e0',  # ⋠ DOES NOT PRECEDE OR EQUAL
+    'nrightarrow': '\u219b',  # ↛ RIGHTWARDS ARROW WITH STROKE
+    'nsim': '\u2241',  # ≁ NOT TILDE
+    'nsimeq': '\u2244',  # ≄ NOT ASYMPTOTICALLY EQUAL TO
+    'nsubseteq': '\u2288',  # ⊈ NEITHER A SUBSET OF NOR EQUAL TO
+    'nsucc': '\u2281',  # ⊁ DOES NOT SUCCEED
+    'nsucceq': '\u22e1',  # ⋡ DOES NOT SUCCEED OR EQUAL
+    'nsupseteq': '\u2289',  # ⊉ NEITHER A SUPERSET OF NOR EQUAL TO
+    'ntriangleleft': '\u22ea',  # ⋪ NOT NORMAL SUBGROUP OF
+    'ntrianglelefteq': '\u22ec',  # ⋬ NOT NORMAL SUBGROUP OF OR EQUAL TO
+    'ntriangleright': '\u22eb',  # ⋫ DOES NOT CONTAIN AS NORMAL SUBGROUP
+    'ntrianglerighteq': '\u22ed',  # ⋭ DOES NOT CONTAIN AS NORMAL SUBGROUP OR EQUAL
+    'nvDash': '\u22ad',  # ⊭ NOT TRUE
+    'nvdash': '\u22ac',  # ⊬ DOES NOT PROVE
+    'nwarrow': '\u2196',  # ↖ NORTH WEST ARROW
+    'owns': '\u220b',  # ∋ CONTAINS AS MEMBER
+    'parallel': '\u2225',  # ∥ PARALLEL TO
+    'perp': '\u27c2',  # ⟂ PERPENDICULAR
+    'pitchfork': '\u22d4',  # ⋔ PITCHFORK
+    'prec': '\u227a',  # ≺ PRECEDES
+    'precapprox': '\u2ab7',  # ⪷ PRECEDES ABOVE ALMOST EQUAL TO
+    'preccurlyeq': '\u227c',  # ≼ PRECEDES OR EQUAL TO
+    'preceq': '\u2aaf',  # ⪯ PRECEDES ABOVE SINGLE-LINE EQUALS SIGN
+    'preceqq': '\u2ab3',  # ⪳ PRECEDES ABOVE EQUALS SIGN
+    'precnapprox': '\u2ab9',  # ⪹ PRECEDES ABOVE NOT ALMOST EQUAL TO
+    'precneqq': '\u2ab5',  # ⪵ PRECEDES ABOVE NOT EQUAL TO
+    'precnsim': '\u22e8',  # ⋨ PRECEDES BUT NOT EQUIVALENT TO
+    'precsim': '\u227e',  # ≾ PRECEDES OR EQUIVALENT TO
+    'propto': '\u221d',  # ∝ PROPORTIONAL TO
+    'restriction': '\u21be',  # ↾ UPWARDS HARPOON WITH BARB RIGHTWARDS
+    'rhd': '\u22b3',  # ⊳ CONTAINS AS NORMAL SUBGROUP
+    'rightarrow': '\u2192',  # → RIGHTWARDS ARROW
+    'rightarrowtail': '\u21a3',  # ↣ RIGHTWARDS ARROW WITH TAIL
+    'rightarrowtriangle': '\u21fe',  # ⇾ RIGHTWARDS OPEN-HEADED ARROW
+    'rightbarharpoon': '\u296c',  # ⥬ RIGHTWARDS HARPOON WITH BARB UP ABOVE LONG DASH
+    'rightharpoondown': '\u21c1',  # ⇁ RIGHTWARDS HARPOON WITH BARB DOWNWARDS
+    'rightharpoonup': '\u21c0',  # ⇀ RIGHTWARDS HARPOON WITH BARB UPWARDS
+    'rightleftarrows': '\u21c4',  # ⇄ RIGHTWARDS ARROW OVER LEFTWARDS ARROW
+    'rightleftharpoon': '\u294b',  # ⥋ LEFT BARB DOWN RIGHT BARB UP HARPOON
+    'rightleftharpoons': '\u21cc',  # ⇌ RIGHTWARDS HARPOON OVER LEFTWARDS HARPOON
+    'rightrightarrows': '\u21c9',  # ⇉ RIGHTWARDS PAIRED ARROWS
+    'rightrightharpoons': '\u2964',  # ⥤ RIGHTWARDS HARPOON WITH BARB UP ABOVE RIGHTWARDS HARPOON WITH BARB DOWN
+    'rightslice': '\u2aa7',  # ⪧ GREATER-THAN CLOSED BY CURVE
+    'rightsquigarrow': '\u21dd',  # ⇝ RIGHTWARDS SQUIGGLE ARROW
+    'rightturn': '\u21bb',  # ↻ CLOCKWISE OPEN CIRCLE ARROW
+    'risingdotseq': '\u2253',  # ≓ IMAGE OF OR APPROXIMATELY EQUAL TO
+    'searrow': '\u2198',  # ↘ SOUTH EAST ARROW
+    'sim': '\u223c',  # ∼ TILDE OPERATOR
+    'simeq': '\u2243',  # ≃ ASYMPTOTICALLY EQUAL TO
+    'smile': '\u2323',  # ⌣ SMILE
+    'sqsubset': '\u228f',  # ⊏ SQUARE IMAGE OF
+    'sqsubseteq': '\u2291',  # ⊑ SQUARE IMAGE OF OR EQUAL TO
+    'sqsupset': '\u2290',  # ⊐ SQUARE ORIGINAL OF
+    'sqsupseteq': '\u2292',  # ⊒ SQUARE ORIGINAL OF OR EQUAL TO
+    'strictfi': '\u297c',  # ⥼ LEFT FISH TAIL
+    'strictif': '\u297d',  # ⥽ RIGHT FISH TAIL
+    'subset': '\u2282',  # ⊂ SUBSET OF
+    'subseteq': '\u2286',  # ⊆ SUBSET OF OR EQUAL TO
+    'subseteqq': '\u2ac5',  # ⫅ SUBSET OF ABOVE EQUALS SIGN
+    'subsetneq': '\u228a',  # ⊊ SUBSET OF WITH NOT EQUAL TO
+    'subsetneqq': '\u2acb',  # ⫋ SUBSET OF ABOVE NOT EQUAL TO
+    'succ': '\u227b',  # ≻ SUCCEEDS
+    'succapprox': '\u2ab8',  # ⪸ SUCCEEDS ABOVE ALMOST EQUAL TO
+    'succcurlyeq': '\u227d',  # ≽ SUCCEEDS OR EQUAL TO
+    'succeq': '\u2ab0',  # ⪰ SUCCEEDS ABOVE SINGLE-LINE EQUALS SIGN
+    'succeqq': '\u2ab4',  # ⪴ SUCCEEDS ABOVE EQUALS SIGN
+    'succnapprox': '\u2aba',  # ⪺ SUCCEEDS ABOVE NOT ALMOST EQUAL TO
+    'succneqq': '\u2ab6',  # ⪶ SUCCEEDS ABOVE NOT EQUAL TO
+    'succnsim': '\u22e9',  # ⋩ SUCCEEDS BUT NOT EQUIVALENT TO
+    'succsim': '\u227f',  # ≿ SUCCEEDS OR EQUIVALENT TO
+    'supset': '\u2283',  # ⊃ SUPERSET OF
+    'supseteq': '\u2287',  # ⊇ SUPERSET OF OR EQUAL TO
+    'supseteqq': '\u2ac6',  # ⫆ SUPERSET OF ABOVE EQUALS SIGN
+    'supsetneq': '\u228b',  # ⊋ SUPERSET OF WITH NOT EQUAL TO
+    'supsetneqq': '\u2acc',  # ⫌ SUPERSET OF ABOVE NOT EQUAL TO
+    'swarrow': '\u2199',  # ↙ SOUTH WEST ARROW
+    'therefore': '\u2234',  # ∴ THEREFORE
+    'to': '\u2192',  # → RIGHTWARDS ARROW
+    'trianglelefteq': '\u22b4',  # ⊴ NORMAL SUBGROUP OF OR EQUAL TO
+    'triangleq': '\u225c',  # ≜ DELTA EQUAL TO
+    'trianglerighteq': '\u22b5',  # ⊵ CONTAINS AS NORMAL SUBGROUP OR EQUAL TO
+    'twoheadleftarrow': '\u219e',  # ↞ LEFTWARDS TWO HEADED ARROW
+    'twoheadrightarrow': '\u21a0',  # ↠ RIGHTWARDS TWO HEADED ARROW
+    'uparrow': '\u2191',  # ↑ UPWARDS ARROW
+    'updownarrow': '\u2195',  # ↕ UP DOWN ARROW
+    'updownarrows': '\u21c5',  # ⇅ UPWARDS ARROW LEFTWARDS OF DOWNWARDS ARROW
+    'updownharpoons': '\u296e',  # ⥮ UPWARDS HARPOON WITH BARB LEFT BESIDE DOWNWARDS HARPOON WITH BARB RIGHT
+    'upharpoonleft': '\u21bf',  # ↿ UPWARDS HARPOON WITH BARB LEFTWARDS
+    'upharpoonright': '\u21be',  # ↾ UPWARDS HARPOON WITH BARB RIGHTWARDS
+    'upuparrows': '\u21c8',  # ⇈ UPWARDS PAIRED ARROWS
+    'upupharpoons': '\u2963',  # ⥣ UPWARDS HARPOON WITH BARB LEFT BESIDE UPWARDS HARPOON WITH BARB RIGHT
+    'vDash': '\u22a8',  # ⊨ TRUE
+    'vartriangle': '\u25b5',  # ▵ WHITE UP-POINTING SMALL TRIANGLE
+    'vartriangleleft': '\u22b2',  # ⊲ NORMAL SUBGROUP OF
+    'vartriangleright': '\u22b3',  # ⊳ CONTAINS AS NORMAL SUBGROUP
+    'vdash': '\u22a2',  # ⊢ RIGHT TACK
+    'wasytherefore': '\u2234',  # ∴ THEREFORE
+    }
+
+mathunder = {
+    'underbrace': '\u23df',  # ⏟ BOTTOM CURLY BRACKET
+    }
+
+space = {
+    ' ': ' ',  #   SPACE
+    ',': '\u2006',  #   SIX-PER-EM SPACE
+    ':': '\u205f',  #   MEDIUM MATHEMATICAL SPACE
+    'medspace': '\u205f',  #   MEDIUM MATHEMATICAL SPACE
+    'quad': '\u2001',  #   EM QUAD
+    'thinspace': '\u2006',  #   SIX-PER-EM SPACE
+    }
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/math/unichar2tex.py b/.venv/lib/python3.12/site-packages/docutils/utils/math/unichar2tex.py
new file mode 100644
index 00000000..da1f828a
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/math/unichar2tex.py
@@ -0,0 +1,808 @@
+# LaTeX math to Unicode symbols translation table
+# for use with the translate() method of unicode objects.
+# Generated with ``write_unichar2tex.py`` from the data in
+# http://milde.users.sourceforge.net/LUCR/Math/
+
+# Includes commands from: standard LaTeX, amssymb, amsmath
+
+uni2tex_table = {
+0xa0: '~',
+0xa3: '\\pounds ',
+0xa5: '\\yen ',
+0xa7: '\\S ',
+0xac: '\\neg ',
+0xb1: '\\pm ',
+0xb6: '\\P ',
+0xd7: '\\times ',
+0xf0: '\\eth ',
+0xf7: '\\div ',
+0x131: '\\imath ',
+0x237: '\\jmath ',
+0x393: '\\Gamma ',
+0x394: '\\Delta ',
+0x398: '\\Theta ',
+0x39b: '\\Lambda ',
+0x39e: '\\Xi ',
+0x3a0: '\\Pi ',
+0x3a3: '\\Sigma ',
+0x3a5: '\\Upsilon ',
+0x3a6: '\\Phi ',
+0x3a8: '\\Psi ',
+0x3a9: '\\Omega ',
+0x3b1: '\\alpha ',
+0x3b2: '\\beta ',
+0x3b3: '\\gamma ',
+0x3b4: '\\delta ',
+0x3b5: '\\varepsilon ',
+0x3b6: '\\zeta ',
+0x3b7: '\\eta ',
+0x3b8: '\\theta ',
+0x3b9: '\\iota ',
+0x3ba: '\\kappa ',
+0x3bb: '\\lambda ',
+0x3bc: '\\mu ',
+0x3bd: '\\nu ',
+0x3be: '\\xi ',
+0x3c0: '\\pi ',
+0x3c1: '\\rho ',
+0x3c2: '\\varsigma ',
+0x3c3: '\\sigma ',
+0x3c4: '\\tau ',
+0x3c5: '\\upsilon ',
+0x3c6: '\\varphi ',
+0x3c7: '\\chi ',
+0x3c8: '\\psi ',
+0x3c9: '\\omega ',
+0x3d1: '\\vartheta ',
+0x3d5: '\\phi ',
+0x3d6: '\\varpi ',
+0x3dd: '\\digamma ',
+0x3f0: '\\varkappa ',
+0x3f1: '\\varrho ',
+0x3f5: '\\epsilon ',
+0x3f6: '\\backepsilon ',
+0x2001: '\\quad ',
+0x2003: '\\quad ',
+0x2006: '\\, ',
+0x2016: '\\| ',
+0x2020: '\\dagger ',
+0x2021: '\\ddagger ',
+0x2022: '\\bullet ',
+0x2026: '\\ldots ',
+0x2032: '\\prime ',
+0x2035: '\\backprime ',
+0x205f: '\\: ',
+0x2102: '\\mathbb{C}',
+0x210b: '\\mathcal{H}',
+0x210c: '\\mathfrak{H}',
+0x210d: '\\mathbb{H}',
+0x210f: '\\hslash ',
+0x2110: '\\mathcal{I}',
+0x2111: '\\Im ',
+0x2112: '\\mathcal{L}',
+0x2113: '\\ell ',
+0x2115: '\\mathbb{N}',
+0x2118: '\\wp ',
+0x2119: '\\mathbb{P}',
+0x211a: '\\mathbb{Q}',
+0x211b: '\\mathcal{R}',
+0x211c: '\\Re ',
+0x211d: '\\mathbb{R}',
+0x2124: '\\mathbb{Z}',
+0x2127: '\\mho ',
+0x2128: '\\mathfrak{Z}',
+0x212c: '\\mathcal{B}',
+0x212d: '\\mathfrak{C}',
+0x2130: '\\mathcal{E}',
+0x2131: '\\mathcal{F}',
+0x2132: '\\Finv ',
+0x2133: '\\mathcal{M}',
+0x2135: '\\aleph ',
+0x2136: '\\beth ',
+0x2137: '\\gimel ',
+0x2138: '\\daleth ',
+0x2190: '\\leftarrow ',
+0x2191: '\\uparrow ',
+0x2192: '\\rightarrow ',
+0x2193: '\\downarrow ',
+0x2194: '\\leftrightarrow ',
+0x2195: '\\updownarrow ',
+0x2196: '\\nwarrow ',
+0x2197: '\\nearrow ',
+0x2198: '\\searrow ',
+0x2199: '\\swarrow ',
+0x219a: '\\nleftarrow ',
+0x219b: '\\nrightarrow ',
+0x219e: '\\twoheadleftarrow ',
+0x21a0: '\\twoheadrightarrow ',
+0x21a2: '\\leftarrowtail ',
+0x21a3: '\\rightarrowtail ',
+0x21a6: '\\mapsto ',
+0x21a9: '\\hookleftarrow ',
+0x21aa: '\\hookrightarrow ',
+0x21ab: '\\looparrowleft ',
+0x21ac: '\\looparrowright ',
+0x21ad: '\\leftrightsquigarrow ',
+0x21ae: '\\nleftrightarrow ',
+0x21b0: '\\Lsh ',
+0x21b1: '\\Rsh ',
+0x21b6: '\\curvearrowleft ',
+0x21b7: '\\curvearrowright ',
+0x21ba: '\\circlearrowleft ',
+0x21bb: '\\circlearrowright ',
+0x21bc: '\\leftharpoonup ',
+0x21bd: '\\leftharpoondown ',
+0x21be: '\\upharpoonright ',
+0x21bf: '\\upharpoonleft ',
+0x21c0: '\\rightharpoonup ',
+0x21c1: '\\rightharpoondown ',
+0x21c2: '\\downharpoonright ',
+0x21c3: '\\downharpoonleft ',
+0x21c4: '\\rightleftarrows ',
+0x21c6: '\\leftrightarrows ',
+0x21c7: '\\leftleftarrows ',
+0x21c8: '\\upuparrows ',
+0x21c9: '\\rightrightarrows ',
+0x21ca: '\\downdownarrows ',
+0x21cb: '\\leftrightharpoons ',
+0x21cc: '\\rightleftharpoons ',
+0x21cd: '\\nLeftarrow ',
+0x21ce: '\\nLeftrightarrow ',
+0x21cf: '\\nRightarrow ',
+0x21d0: '\\Leftarrow ',
+0x21d1: '\\Uparrow ',
+0x21d2: '\\Rightarrow ',
+0x21d3: '\\Downarrow ',
+0x21d4: '\\Leftrightarrow ',
+0x21d5: '\\Updownarrow ',
+0x21da: '\\Lleftarrow ',
+0x21db: '\\Rrightarrow ',
+0x21dd: '\\rightsquigarrow ',
+0x21e0: '\\dashleftarrow ',
+0x21e2: '\\dashrightarrow ',
+0x2200: '\\forall ',
+0x2201: '\\complement ',
+0x2202: '\\partial ',
+0x2203: '\\exists ',
+0x2204: '\\nexists ',
+0x2205: '\\emptyset ',
+0x2207: '\\nabla ',
+0x2208: '\\in ',
+0x2209: '\\notin ',
+0x220b: '\\ni ',
+0x220f: '\\prod ',
+0x2210: '\\coprod ',
+0x2211: '\\sum ',
+0x2212: '-',
+0x2213: '\\mp ',
+0x2214: '\\dotplus ',
+0x2215: '\\slash ',
+0x2216: '\\smallsetminus ',
+0x2217: '\\ast ',
+0x2218: '\\circ ',
+0x2219: '\\bullet ',
+0x221a: '\\surd ',
+0x221b: '\\sqrt[3] ',
+0x221c: '\\sqrt[4] ',
+0x221d: '\\propto ',
+0x221e: '\\infty ',
+0x2220: '\\angle ',
+0x2221: '\\measuredangle ',
+0x2222: '\\sphericalangle ',
+0x2223: '\\mid ',
+0x2224: '\\nmid ',
+0x2225: '\\parallel ',
+0x2226: '\\nparallel ',
+0x2227: '\\wedge ',
+0x2228: '\\vee ',
+0x2229: '\\cap ',
+0x222a: '\\cup ',
+0x222b: '\\int ',
+0x222c: '\\iint ',
+0x222d: '\\iiint ',
+0x222e: '\\oint ',
+0x2234: '\\therefore ',
+0x2235: '\\because ',
+0x2236: ':',
+0x223c: '\\sim ',
+0x223d: '\\backsim ',
+0x2240: '\\wr ',
+0x2241: '\\nsim ',
+0x2242: '\\eqsim ',
+0x2243: '\\simeq ',
+0x2245: '\\cong ',
+0x2247: '\\ncong ',
+0x2248: '\\approx ',
+0x224a: '\\approxeq ',
+0x224d: '\\asymp ',
+0x224e: '\\Bumpeq ',
+0x224f: '\\bumpeq ',
+0x2250: '\\doteq ',
+0x2251: '\\Doteq ',
+0x2252: '\\fallingdotseq ',
+0x2253: '\\risingdotseq ',
+0x2256: '\\eqcirc ',
+0x2257: '\\circeq ',
+0x225c: '\\triangleq ',
+0x2260: '\\neq ',
+0x2261: '\\equiv ',
+0x2264: '\\leq ',
+0x2265: '\\geq ',
+0x2266: '\\leqq ',
+0x2267: '\\geqq ',
+0x2268: '\\lneqq ',
+0x2269: '\\gneqq ',
+0x226a: '\\ll ',
+0x226b: '\\gg ',
+0x226c: '\\between ',
+0x226e: '\\nless ',
+0x226f: '\\ngtr ',
+0x2270: '\\nleq ',
+0x2271: '\\ngeq ',
+0x2272: '\\lesssim ',
+0x2273: '\\gtrsim ',
+0x2276: '\\lessgtr ',
+0x2277: '\\gtrless ',
+0x227a: '\\prec ',
+0x227b: '\\succ ',
+0x227c: '\\preccurlyeq ',
+0x227d: '\\succcurlyeq ',
+0x227e: '\\precsim ',
+0x227f: '\\succsim ',
+0x2280: '\\nprec ',
+0x2281: '\\nsucc ',
+0x2282: '\\subset ',
+0x2283: '\\supset ',
+0x2286: '\\subseteq ',
+0x2287: '\\supseteq ',
+0x2288: '\\nsubseteq ',
+0x2289: '\\nsupseteq ',
+0x228a: '\\subsetneq ',
+0x228b: '\\supsetneq ',
+0x228e: '\\uplus ',
+0x228f: '\\sqsubset ',
+0x2290: '\\sqsupset ',
+0x2291: '\\sqsubseteq ',
+0x2292: '\\sqsupseteq ',
+0x2293: '\\sqcap ',
+0x2294: '\\sqcup ',
+0x2295: '\\oplus ',
+0x2296: '\\ominus ',
+0x2297: '\\otimes ',
+0x2298: '\\oslash ',
+0x2299: '\\odot ',
+0x229a: '\\circledcirc ',
+0x229b: '\\circledast ',
+0x229d: '\\circleddash ',
+0x229e: '\\boxplus ',
+0x229f: '\\boxminus ',
+0x22a0: '\\boxtimes ',
+0x22a1: '\\boxdot ',
+0x22a2: '\\vdash ',
+0x22a3: '\\dashv ',
+0x22a4: '\\top ',
+0x22a5: '\\bot ',
+0x22a7: '\\models ',
+0x22a8: '\\vDash ',
+0x22a9: '\\Vdash ',
+0x22aa: '\\Vvdash ',
+0x22ac: '\\nvdash ',
+0x22ad: '\\nvDash ',
+0x22ae: '\\nVdash ',
+0x22af: '\\nVDash ',
+0x22b2: '\\vartriangleleft ',
+0x22b3: '\\vartriangleright ',
+0x22b4: '\\trianglelefteq ',
+0x22b5: '\\trianglerighteq ',
+0x22b8: '\\multimap ',
+0x22ba: '\\intercal ',
+0x22bb: '\\veebar ',
+0x22bc: '\\barwedge ',
+0x22c0: '\\bigwedge ',
+0x22c1: '\\bigvee ',
+0x22c2: '\\bigcap ',
+0x22c3: '\\bigcup ',
+0x22c4: '\\diamond ',
+0x22c5: '\\cdot ',
+0x22c6: '\\star ',
+0x22c7: '\\divideontimes ',
+0x22c8: '\\bowtie ',
+0x22c9: '\\ltimes ',
+0x22ca: '\\rtimes ',
+0x22cb: '\\leftthreetimes ',
+0x22cc: '\\rightthreetimes ',
+0x22cd: '\\backsimeq ',
+0x22ce: '\\curlyvee ',
+0x22cf: '\\curlywedge ',
+0x22d0: '\\Subset ',
+0x22d1: '\\Supset ',
+0x22d2: '\\Cap ',
+0x22d3: '\\Cup ',
+0x22d4: '\\pitchfork ',
+0x22d6: '\\lessdot ',
+0x22d7: '\\gtrdot ',
+0x22d8: '\\lll ',
+0x22d9: '\\ggg ',
+0x22da: '\\lesseqgtr ',
+0x22db: '\\gtreqless ',
+0x22de: '\\curlyeqprec ',
+0x22df: '\\curlyeqsucc ',
+0x22e0: '\\npreceq ',
+0x22e1: '\\nsucceq ',
+0x22e6: '\\lnsim ',
+0x22e7: '\\gnsim ',
+0x22e8: '\\precnsim ',
+0x22e9: '\\succnsim ',
+0x22ea: '\\ntriangleleft ',
+0x22eb: '\\ntriangleright ',
+0x22ec: '\\ntrianglelefteq ',
+0x22ed: '\\ntrianglerighteq ',
+0x22ee: '\\vdots ',
+0x22ef: '\\cdots ',
+0x22f1: '\\ddots ',
+0x2308: '\\lceil ',
+0x2309: '\\rceil ',
+0x230a: '\\lfloor ',
+0x230b: '\\rfloor ',
+0x231c: '\\ulcorner ',
+0x231d: '\\urcorner ',
+0x231e: '\\llcorner ',
+0x231f: '\\lrcorner ',
+0x2322: '\\frown ',
+0x2323: '\\smile ',
+0x23aa: '\\bracevert ',
+0x23b0: '\\lmoustache ',
+0x23b1: '\\rmoustache ',
+0x23d0: '\\arrowvert ',
+0x23de: '\\overbrace ',
+0x23df: '\\underbrace ',
+0x24c7: '\\circledR ',
+0x24c8: '\\circledS ',
+0x25b2: '\\blacktriangle ',
+0x25b3: '\\bigtriangleup ',
+0x25b7: '\\triangleright ',
+0x25bc: '\\blacktriangledown ',
+0x25bd: '\\bigtriangledown ',
+0x25c1: '\\triangleleft ',
+0x25c7: '\\Diamond ',
+0x25ca: '\\lozenge ',
+0x25ef: '\\bigcirc ',
+0x25fb: '\\square ',
+0x25fc: '\\blacksquare ',
+0x2605: '\\bigstar ',
+0x2660: '\\spadesuit ',
+0x2661: '\\heartsuit ',
+0x2662: '\\diamondsuit ',
+0x2663: '\\clubsuit ',
+0x266d: '\\flat ',
+0x266e: '\\natural ',
+0x266f: '\\sharp ',
+0x2713: '\\checkmark ',
+0x2720: '\\maltese ',
+0x27c2: '\\perp ',
+0x27cb: '\\diagup ',
+0x27cd: '\\diagdown ',
+0x27e8: '\\langle ',
+0x27e9: '\\rangle ',
+0x27ee: '\\lgroup ',
+0x27ef: '\\rgroup ',
+0x27f5: '\\longleftarrow ',
+0x27f6: '\\longrightarrow ',
+0x27f7: '\\longleftrightarrow ',
+0x27f8: '\\Longleftarrow ',
+0x27f9: '\\Longrightarrow ',
+0x27fa: '\\Longleftrightarrow ',
+0x27fc: '\\longmapsto ',
+0x29eb: '\\blacklozenge ',
+0x29f5: '\\setminus ',
+0x2a00: '\\bigodot ',
+0x2a01: '\\bigoplus ',
+0x2a02: '\\bigotimes ',
+0x2a04: '\\biguplus ',
+0x2a06: '\\bigsqcup ',
+0x2a0c: '\\iiiint ',
+0x2a3f: '\\amalg ',
+0x2a5e: '\\doublebarwedge ',
+0x2a7d: '\\leqslant ',
+0x2a7e: '\\geqslant ',
+0x2a85: '\\lessapprox ',
+0x2a86: '\\gtrapprox ',
+0x2a87: '\\lneq ',
+0x2a88: '\\gneq ',
+0x2a89: '\\lnapprox ',
+0x2a8a: '\\gnapprox ',
+0x2a8b: '\\lesseqqgtr ',
+0x2a8c: '\\gtreqqless ',
+0x2a95: '\\eqslantless ',
+0x2a96: '\\eqslantgtr ',
+0x2aaf: '\\preceq ',
+0x2ab0: '\\succeq ',
+0x2ab5: '\\precneqq ',
+0x2ab6: '\\succneqq ',
+0x2ab7: '\\precapprox ',
+0x2ab8: '\\succapprox ',
+0x2ab9: '\\precnapprox ',
+0x2aba: '\\succnapprox ',
+0x2ac5: '\\subseteqq ',
+0x2ac6: '\\supseteqq ',
+0x2acb: '\\subsetneqq ',
+0x2acc: '\\supsetneqq ',
+0x2b1c: '\\Box ',
+0x1d400: '\\mathbf{A}',
+0x1d401: '\\mathbf{B}',
+0x1d402: '\\mathbf{C}',
+0x1d403: '\\mathbf{D}',
+0x1d404: '\\mathbf{E}',
+0x1d405: '\\mathbf{F}',
+0x1d406: '\\mathbf{G}',
+0x1d407: '\\mathbf{H}',
+0x1d408: '\\mathbf{I}',
+0x1d409: '\\mathbf{J}',
+0x1d40a: '\\mathbf{K}',
+0x1d40b: '\\mathbf{L}',
+0x1d40c: '\\mathbf{M}',
+0x1d40d: '\\mathbf{N}',
+0x1d40e: '\\mathbf{O}',
+0x1d40f: '\\mathbf{P}',
+0x1d410: '\\mathbf{Q}',
+0x1d411: '\\mathbf{R}',
+0x1d412: '\\mathbf{S}',
+0x1d413: '\\mathbf{T}',
+0x1d414: '\\mathbf{U}',
+0x1d415: '\\mathbf{V}',
+0x1d416: '\\mathbf{W}',
+0x1d417: '\\mathbf{X}',
+0x1d418: '\\mathbf{Y}',
+0x1d419: '\\mathbf{Z}',
+0x1d41a: '\\mathbf{a}',
+0x1d41b: '\\mathbf{b}',
+0x1d41c: '\\mathbf{c}',
+0x1d41d: '\\mathbf{d}',
+0x1d41e: '\\mathbf{e}',
+0x1d41f: '\\mathbf{f}',
+0x1d420: '\\mathbf{g}',
+0x1d421: '\\mathbf{h}',
+0x1d422: '\\mathbf{i}',
+0x1d423: '\\mathbf{j}',
+0x1d424: '\\mathbf{k}',
+0x1d425: '\\mathbf{l}',
+0x1d426: '\\mathbf{m}',
+0x1d427: '\\mathbf{n}',
+0x1d428: '\\mathbf{o}',
+0x1d429: '\\mathbf{p}',
+0x1d42a: '\\mathbf{q}',
+0x1d42b: '\\mathbf{r}',
+0x1d42c: '\\mathbf{s}',
+0x1d42d: '\\mathbf{t}',
+0x1d42e: '\\mathbf{u}',
+0x1d42f: '\\mathbf{v}',
+0x1d430: '\\mathbf{w}',
+0x1d431: '\\mathbf{x}',
+0x1d432: '\\mathbf{y}',
+0x1d433: '\\mathbf{z}',
+0x1d434: 'A',
+0x1d435: 'B',
+0x1d436: 'C',
+0x1d437: 'D',
+0x1d438: 'E',
+0x1d439: 'F',
+0x1d43a: 'G',
+0x1d43b: 'H',
+0x1d43c: 'I',
+0x1d43d: 'J',
+0x1d43e: 'K',
+0x1d43f: 'L',
+0x1d440: 'M',
+0x1d441: 'N',
+0x1d442: 'O',
+0x1d443: 'P',
+0x1d444: 'Q',
+0x1d445: 'R',
+0x1d446: 'S',
+0x1d447: 'T',
+0x1d448: 'U',
+0x1d449: 'V',
+0x1d44a: 'W',
+0x1d44b: 'X',
+0x1d44c: 'Y',
+0x1d44d: 'Z',
+0x1d44e: 'a',
+0x1d44f: 'b',
+0x1d450: 'c',
+0x1d451: 'd',
+0x1d452: 'e',
+0x1d453: 'f',
+0x1d454: 'g',
+0x1d456: 'i',
+0x1d457: 'j',
+0x1d458: 'k',
+0x1d459: 'l',
+0x1d45a: 'm',
+0x1d45b: 'n',
+0x1d45c: 'o',
+0x1d45d: 'p',
+0x1d45e: 'q',
+0x1d45f: 'r',
+0x1d460: 's',
+0x1d461: 't',
+0x1d462: 'u',
+0x1d463: 'v',
+0x1d464: 'w',
+0x1d465: 'x',
+0x1d466: 'y',
+0x1d467: 'z',
+0x1d49c: '\\mathcal{A}',
+0x1d49e: '\\mathcal{C}',
+0x1d49f: '\\mathcal{D}',
+0x1d4a2: '\\mathcal{G}',
+0x1d4a5: '\\mathcal{J}',
+0x1d4a6: '\\mathcal{K}',
+0x1d4a9: '\\mathcal{N}',
+0x1d4aa: '\\mathcal{O}',
+0x1d4ab: '\\mathcal{P}',
+0x1d4ac: '\\mathcal{Q}',
+0x1d4ae: '\\mathcal{S}',
+0x1d4af: '\\mathcal{T}',
+0x1d4b0: '\\mathcal{U}',
+0x1d4b1: '\\mathcal{V}',
+0x1d4b2: '\\mathcal{W}',
+0x1d4b3: '\\mathcal{X}',
+0x1d4b4: '\\mathcal{Y}',
+0x1d4b5: '\\mathcal{Z}',
+0x1d504: '\\mathfrak{A}',
+0x1d505: '\\mathfrak{B}',
+0x1d507: '\\mathfrak{D}',
+0x1d508: '\\mathfrak{E}',
+0x1d509: '\\mathfrak{F}',
+0x1d50a: '\\mathfrak{G}',
+0x1d50d: '\\mathfrak{J}',
+0x1d50e: '\\mathfrak{K}',
+0x1d50f: '\\mathfrak{L}',
+0x1d510: '\\mathfrak{M}',
+0x1d511: '\\mathfrak{N}',
+0x1d512: '\\mathfrak{O}',
+0x1d513: '\\mathfrak{P}',
+0x1d514: '\\mathfrak{Q}',
+0x1d516: '\\mathfrak{S}',
+0x1d517: '\\mathfrak{T}',
+0x1d518: '\\mathfrak{U}',
+0x1d519: '\\mathfrak{V}',
+0x1d51a: '\\mathfrak{W}',
+0x1d51b: '\\mathfrak{X}',
+0x1d51c: '\\mathfrak{Y}',
+0x1d51e: '\\mathfrak{a}',
+0x1d51f: '\\mathfrak{b}',
+0x1d520: '\\mathfrak{c}',
+0x1d521: '\\mathfrak{d}',
+0x1d522: '\\mathfrak{e}',
+0x1d523: '\\mathfrak{f}',
+0x1d524: '\\mathfrak{g}',
+0x1d525: '\\mathfrak{h}',
+0x1d526: '\\mathfrak{i}',
+0x1d527: '\\mathfrak{j}',
+0x1d528: '\\mathfrak{k}',
+0x1d529: '\\mathfrak{l}',
+0x1d52a: '\\mathfrak{m}',
+0x1d52b: '\\mathfrak{n}',
+0x1d52c: '\\mathfrak{o}',
+0x1d52d: '\\mathfrak{p}',
+0x1d52e: '\\mathfrak{q}',
+0x1d52f: '\\mathfrak{r}',
+0x1d530: '\\mathfrak{s}',
+0x1d531: '\\mathfrak{t}',
+0x1d532: '\\mathfrak{u}',
+0x1d533: '\\mathfrak{v}',
+0x1d534: '\\mathfrak{w}',
+0x1d535: '\\mathfrak{x}',
+0x1d536: '\\mathfrak{y}',
+0x1d537: '\\mathfrak{z}',
+0x1d538: '\\mathbb{A}',
+0x1d539: '\\mathbb{B}',
+0x1d53b: '\\mathbb{D}',
+0x1d53c: '\\mathbb{E}',
+0x1d53d: '\\mathbb{F}',
+0x1d53e: '\\mathbb{G}',
+0x1d540: '\\mathbb{I}',
+0x1d541: '\\mathbb{J}',
+0x1d542: '\\mathbb{K}',
+0x1d543: '\\mathbb{L}',
+0x1d544: '\\mathbb{M}',
+0x1d546: '\\mathbb{O}',
+0x1d54a: '\\mathbb{S}',
+0x1d54b: '\\mathbb{T}',
+0x1d54c: '\\mathbb{U}',
+0x1d54d: '\\mathbb{V}',
+0x1d54e: '\\mathbb{W}',
+0x1d54f: '\\mathbb{X}',
+0x1d550: '\\mathbb{Y}',
+0x1d55c: '\\Bbbk ',
+0x1d5a0: '\\mathsf{A}',
+0x1d5a1: '\\mathsf{B}',
+0x1d5a2: '\\mathsf{C}',
+0x1d5a3: '\\mathsf{D}',
+0x1d5a4: '\\mathsf{E}',
+0x1d5a5: '\\mathsf{F}',
+0x1d5a6: '\\mathsf{G}',
+0x1d5a7: '\\mathsf{H}',
+0x1d5a8: '\\mathsf{I}',
+0x1d5a9: '\\mathsf{J}',
+0x1d5aa: '\\mathsf{K}',
+0x1d5ab: '\\mathsf{L}',
+0x1d5ac: '\\mathsf{M}',
+0x1d5ad: '\\mathsf{N}',
+0x1d5ae: '\\mathsf{O}',
+0x1d5af: '\\mathsf{P}',
+0x1d5b0: '\\mathsf{Q}',
+0x1d5b1: '\\mathsf{R}',
+0x1d5b2: '\\mathsf{S}',
+0x1d5b3: '\\mathsf{T}',
+0x1d5b4: '\\mathsf{U}',
+0x1d5b5: '\\mathsf{V}',
+0x1d5b6: '\\mathsf{W}',
+0x1d5b7: '\\mathsf{X}',
+0x1d5b8: '\\mathsf{Y}',
+0x1d5b9: '\\mathsf{Z}',
+0x1d5ba: '\\mathsf{a}',
+0x1d5bb: '\\mathsf{b}',
+0x1d5bc: '\\mathsf{c}',
+0x1d5bd: '\\mathsf{d}',
+0x1d5be: '\\mathsf{e}',
+0x1d5bf: '\\mathsf{f}',
+0x1d5c0: '\\mathsf{g}',
+0x1d5c1: '\\mathsf{h}',
+0x1d5c2: '\\mathsf{i}',
+0x1d5c3: '\\mathsf{j}',
+0x1d5c4: '\\mathsf{k}',
+0x1d5c5: '\\mathsf{l}',
+0x1d5c6: '\\mathsf{m}',
+0x1d5c7: '\\mathsf{n}',
+0x1d5c8: '\\mathsf{o}',
+0x1d5c9: '\\mathsf{p}',
+0x1d5ca: '\\mathsf{q}',
+0x1d5cb: '\\mathsf{r}',
+0x1d5cc: '\\mathsf{s}',
+0x1d5cd: '\\mathsf{t}',
+0x1d5ce: '\\mathsf{u}',
+0x1d5cf: '\\mathsf{v}',
+0x1d5d0: '\\mathsf{w}',
+0x1d5d1: '\\mathsf{x}',
+0x1d5d2: '\\mathsf{y}',
+0x1d5d3: '\\mathsf{z}',
+0x1d670: '\\mathtt{A}',
+0x1d671: '\\mathtt{B}',
+0x1d672: '\\mathtt{C}',
+0x1d673: '\\mathtt{D}',
+0x1d674: '\\mathtt{E}',
+0x1d675: '\\mathtt{F}',
+0x1d676: '\\mathtt{G}',
+0x1d677: '\\mathtt{H}',
+0x1d678: '\\mathtt{I}',
+0x1d679: '\\mathtt{J}',
+0x1d67a: '\\mathtt{K}',
+0x1d67b: '\\mathtt{L}',
+0x1d67c: '\\mathtt{M}',
+0x1d67d: '\\mathtt{N}',
+0x1d67e: '\\mathtt{O}',
+0x1d67f: '\\mathtt{P}',
+0x1d680: '\\mathtt{Q}',
+0x1d681: '\\mathtt{R}',
+0x1d682: '\\mathtt{S}',
+0x1d683: '\\mathtt{T}',
+0x1d684: '\\mathtt{U}',
+0x1d685: '\\mathtt{V}',
+0x1d686: '\\mathtt{W}',
+0x1d687: '\\mathtt{X}',
+0x1d688: '\\mathtt{Y}',
+0x1d689: '\\mathtt{Z}',
+0x1d68a: '\\mathtt{a}',
+0x1d68b: '\\mathtt{b}',
+0x1d68c: '\\mathtt{c}',
+0x1d68d: '\\mathtt{d}',
+0x1d68e: '\\mathtt{e}',
+0x1d68f: '\\mathtt{f}',
+0x1d690: '\\mathtt{g}',
+0x1d691: '\\mathtt{h}',
+0x1d692: '\\mathtt{i}',
+0x1d693: '\\mathtt{j}',
+0x1d694: '\\mathtt{k}',
+0x1d695: '\\mathtt{l}',
+0x1d696: '\\mathtt{m}',
+0x1d697: '\\mathtt{n}',
+0x1d698: '\\mathtt{o}',
+0x1d699: '\\mathtt{p}',
+0x1d69a: '\\mathtt{q}',
+0x1d69b: '\\mathtt{r}',
+0x1d69c: '\\mathtt{s}',
+0x1d69d: '\\mathtt{t}',
+0x1d69e: '\\mathtt{u}',
+0x1d69f: '\\mathtt{v}',
+0x1d6a0: '\\mathtt{w}',
+0x1d6a1: '\\mathtt{x}',
+0x1d6a2: '\\mathtt{y}',
+0x1d6a3: '\\mathtt{z}',
+0x1d6a4: '\\imath ',
+0x1d6a5: '\\jmath ',
+0x1d6aa: '\\mathbf{\\Gamma}',
+0x1d6ab: '\\mathbf{\\Delta}',
+0x1d6af: '\\mathbf{\\Theta}',
+0x1d6b2: '\\mathbf{\\Lambda}',
+0x1d6b5: '\\mathbf{\\Xi}',
+0x1d6b7: '\\mathbf{\\Pi}',
+0x1d6ba: '\\mathbf{\\Sigma}',
+0x1d6bc: '\\mathbf{\\Upsilon}',
+0x1d6bd: '\\mathbf{\\Phi}',
+0x1d6bf: '\\mathbf{\\Psi}',
+0x1d6c0: '\\mathbf{\\Omega}',
+0x1d6e4: '\\mathit{\\Gamma}',
+0x1d6e5: '\\mathit{\\Delta}',
+0x1d6e9: '\\mathit{\\Theta}',
+0x1d6ec: '\\mathit{\\Lambda}',
+0x1d6ef: '\\mathit{\\Xi}',
+0x1d6f1: '\\mathit{\\Pi}',
+0x1d6f4: '\\mathit{\\Sigma}',
+0x1d6f6: '\\mathit{\\Upsilon}',
+0x1d6f7: '\\mathit{\\Phi}',
+0x1d6f9: '\\mathit{\\Psi}',
+0x1d6fa: '\\mathit{\\Omega}',
+0x1d6fc: '\\alpha ',
+0x1d6fd: '\\beta ',
+0x1d6fe: '\\gamma ',
+0x1d6ff: '\\delta ',
+0x1d700: '\\varepsilon ',
+0x1d701: '\\zeta ',
+0x1d702: '\\eta ',
+0x1d703: '\\theta ',
+0x1d704: '\\iota ',
+0x1d705: '\\kappa ',
+0x1d706: '\\lambda ',
+0x1d707: '\\mu ',
+0x1d708: '\\nu ',
+0x1d709: '\\xi ',
+0x1d70b: '\\pi ',
+0x1d70c: '\\rho ',
+0x1d70d: '\\varsigma ',
+0x1d70e: '\\sigma ',
+0x1d70f: '\\tau ',
+0x1d710: '\\upsilon ',
+0x1d711: '\\varphi ',
+0x1d712: '\\chi ',
+0x1d713: '\\psi ',
+0x1d714: '\\omega ',
+0x1d715: '\\partial ',
+0x1d716: '\\epsilon ',
+0x1d717: '\\vartheta ',
+0x1d718: '\\varkappa ',
+0x1d719: '\\phi ',
+0x1d71a: '\\varrho ',
+0x1d71b: '\\varpi ',
+0x1d7ce: '\\mathbf{0}',
+0x1d7cf: '\\mathbf{1}',
+0x1d7d0: '\\mathbf{2}',
+0x1d7d1: '\\mathbf{3}',
+0x1d7d2: '\\mathbf{4}',
+0x1d7d3: '\\mathbf{5}',
+0x1d7d4: '\\mathbf{6}',
+0x1d7d5: '\\mathbf{7}',
+0x1d7d6: '\\mathbf{8}',
+0x1d7d7: '\\mathbf{9}',
+0x1d7e2: '\\mathsf{0}',
+0x1d7e3: '\\mathsf{1}',
+0x1d7e4: '\\mathsf{2}',
+0x1d7e5: '\\mathsf{3}',
+0x1d7e6: '\\mathsf{4}',
+0x1d7e7: '\\mathsf{5}',
+0x1d7e8: '\\mathsf{6}',
+0x1d7e9: '\\mathsf{7}',
+0x1d7ea: '\\mathsf{8}',
+0x1d7eb: '\\mathsf{9}',
+0x1d7f6: '\\mathtt{0}',
+0x1d7f7: '\\mathtt{1}',
+0x1d7f8: '\\mathtt{2}',
+0x1d7f9: '\\mathtt{3}',
+0x1d7fa: '\\mathtt{4}',
+0x1d7fb: '\\mathtt{5}',
+0x1d7fc: '\\mathtt{6}',
+0x1d7fd: '\\mathtt{7}',
+0x1d7fe: '\\mathtt{8}',
+0x1d7ff: '\\mathtt{9}',
+}
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/punctuation_chars.py b/.venv/lib/python3.12/site-packages/docutils/utils/punctuation_chars.py
new file mode 100644
index 00000000..9dd21404
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/punctuation_chars.py
@@ -0,0 +1,123 @@
+# :Id: $Id: punctuation_chars.py 9270 2022-11-24 20:28:03Z milde $
+# :Copyright: © 2011, 2017 Günter Milde.
+# :License: Released under the terms of the `2-Clause BSD license`_, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notice and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+#
+# This file is generated by
+# ``docutils/tools/dev/generate_punctuation_chars.py``.
+# ::
+
+"""Docutils character category patterns.
+
+   Patterns for the implementation of the `inline markup recognition rules`_
+   in the reStructuredText parser `docutils.parsers.rst.states.py` based
+   on Unicode character categories.
+   The patterns are used inside ``[ ]`` in regular expressions.
+
+   Rule (5) requires determination of matching open/close pairs. However, the
+   pairing of open/close quotes is ambiguous due to  different typographic
+   conventions in different languages. The ``quote_pairs`` function tests
+   whether two characters form an open/close pair.
+
+   The patterns are generated by
+   ``docutils/tools/dev/generate_punctuation_chars.py`` to  prevent dependence
+   on the Python version and avoid the time-consuming generation with every
+   Docutils run. See there for motives and implementation details.
+
+   The category of some characters changed with the development of the
+   Unicode standard. The current lists are generated with the help of the
+   "unicodedata" module of Python 2.7.13 (based on Unicode version 5.2.0).
+
+   .. _inline markup recognition rules:
+      https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html
+      #inline-markup-recognition-rules
+"""
+
+openers = (
+    '"\'(<\\[{\u0f3a\u0f3c\u169b\u2045\u207d\u208d\u2329\u2768'
+    '\u276a\u276c\u276e\u2770\u2772\u2774\u27c5\u27e6\u27e8\u27ea'
+    '\u27ec\u27ee\u2983\u2985\u2987\u2989\u298b\u298d\u298f\u2991'
+    '\u2993\u2995\u2997\u29d8\u29da\u29fc\u2e22\u2e24\u2e26\u2e28'
+    '\u3008\u300a\u300c\u300e\u3010\u3014\u3016\u3018\u301a\u301d'
+    '\u301d\ufd3e\ufe17\ufe35\ufe37\ufe39\ufe3b\ufe3d\ufe3f\ufe41'
+    '\ufe43\ufe47\ufe59\ufe5b\ufe5d\uff08\uff3b\uff5b\uff5f\uff62'
+    '\xab\u2018\u201c\u2039\u2e02\u2e04\u2e09\u2e0c\u2e1c\u2e20'
+    '\u201a\u201e\xbb\u2019\u201d\u203a\u2e03\u2e05\u2e0a\u2e0d'
+    '\u2e1d\u2e21\u201b\u201f'
+    )
+closers = (
+    '"\')>\\]}\u0f3b\u0f3d\u169c\u2046\u207e\u208e\u232a\u2769'
+    '\u276b\u276d\u276f\u2771\u2773\u2775\u27c6\u27e7\u27e9\u27eb'
+    '\u27ed\u27ef\u2984\u2986\u2988\u298a\u298c\u298e\u2990\u2992'
+    '\u2994\u2996\u2998\u29d9\u29db\u29fd\u2e23\u2e25\u2e27\u2e29'
+    '\u3009\u300b\u300d\u300f\u3011\u3015\u3017\u3019\u301b\u301e'
+    '\u301f\ufd3f\ufe18\ufe36\ufe38\ufe3a\ufe3c\ufe3e\ufe40\ufe42'
+    '\ufe44\ufe48\ufe5a\ufe5c\ufe5e\uff09\uff3d\uff5d\uff60\uff63'
+    '\xbb\u2019\u201d\u203a\u2e03\u2e05\u2e0a\u2e0d\u2e1d\u2e21'
+    '\u201b\u201f\xab\u2018\u201c\u2039\u2e02\u2e04\u2e09\u2e0c'
+    '\u2e1c\u2e20\u201a\u201e'
+    )
+delimiters = (
+    '\\-/:\u058a\xa1\xb7\xbf\u037e\u0387\u055a-\u055f\u0589'
+    '\u05be\u05c0\u05c3\u05c6\u05f3\u05f4\u0609\u060a\u060c'
+    '\u060d\u061b\u061e\u061f\u066a-\u066d\u06d4\u0700-\u070d'
+    '\u07f7-\u07f9\u0830-\u083e\u0964\u0965\u0970\u0df4\u0e4f'
+    '\u0e5a\u0e5b\u0f04-\u0f12\u0f85\u0fd0-\u0fd4\u104a-\u104f'
+    '\u10fb\u1361-\u1368\u1400\u166d\u166e\u16eb-\u16ed\u1735'
+    '\u1736\u17d4-\u17d6\u17d8-\u17da\u1800-\u180a\u1944\u1945'
+    '\u19de\u19df\u1a1e\u1a1f\u1aa0-\u1aa6\u1aa8-\u1aad\u1b5a-'
+    '\u1b60\u1c3b-\u1c3f\u1c7e\u1c7f\u1cd3\u2010-\u2017\u2020-'
+    '\u2027\u2030-\u2038\u203b-\u203e\u2041-\u2043\u2047-'
+    '\u2051\u2053\u2055-\u205e\u2cf9-\u2cfc\u2cfe\u2cff\u2e00'
+    '\u2e01\u2e06-\u2e08\u2e0b\u2e0e-\u2e1b\u2e1e\u2e1f\u2e2a-'
+    '\u2e2e\u2e30\u2e31\u3001-\u3003\u301c\u3030\u303d\u30a0'
+    '\u30fb\ua4fe\ua4ff\ua60d-\ua60f\ua673\ua67e\ua6f2-\ua6f7'
+    '\ua874-\ua877\ua8ce\ua8cf\ua8f8-\ua8fa\ua92e\ua92f\ua95f'
+    '\ua9c1-\ua9cd\ua9de\ua9df\uaa5c-\uaa5f\uaade\uaadf\uabeb'
+    '\ufe10-\ufe16\ufe19\ufe30-\ufe32\ufe45\ufe46\ufe49-\ufe4c'
+    '\ufe50-\ufe52\ufe54-\ufe58\ufe5f-\ufe61\ufe63\ufe68\ufe6a'
+    '\ufe6b\uff01-\uff03\uff05-\uff07\uff0a\uff0c-\uff0f\uff1a'
+    '\uff1b\uff1f\uff20\uff3c\uff61\uff64\uff65'
+    '\U00010100\U00010101\U0001039f\U000103d0\U00010857'
+    '\U0001091f\U0001093f\U00010a50-\U00010a58\U00010a7f'
+    '\U00010b39-\U00010b3f\U000110bb\U000110bc\U000110be-'
+    '\U000110c1\U00012470-\U00012473'
+    )
+closing_delimiters = r'\\.,;!?'
+
+
+# Matching open/close quotes
+# --------------------------
+
+# Matching open/close pairs are at the same position in
+# `punctuation_chars.openers` and `punctuation_chars.closers`.
+# Additional matches (due to different typographic conventions
+# in different languages) are stored in `quote_pairs`.
+
+quote_pairs = {
+    # open char: matching closing characters # use case
+    '\xbb': '\xbb',            # » » Swedish
+    '\u2018': '\u201a',        # ‘ ‚ Albanian/Greek/Turkish
+    '\u2019': '\u2019',        # ’ ’ Swedish
+    '\u201a': '\u2018\u2019',  # ‚ ‘ German, ‚ ’ Polish
+    '\u201c': '\u201e',        # “ „ Albanian/Greek/Turkish
+    '\u201e': '\u201c\u201d',  # „ “ German, „ ” Polish
+    '\u201d': '\u201d',        # ” ” Swedish
+    '\u203a': '\u203a',        # › › Swedish
+    }
+"""Additional open/close quote pairs."""
+
+
+def match_chars(c1, c2):
+    """Test whether `c1` and `c2` are a matching open/close character pair."""
+    try:
+        i = openers.index(c1)
+    except ValueError:  # c1 not in openers
+        return False
+    return c2 == closers[i] or c2 in quote_pairs.get(c1, '')
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/roman.py b/.venv/lib/python3.12/site-packages/docutils/utils/roman.py
new file mode 100644
index 00000000..df0c5b33
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/roman.py
@@ -0,0 +1,154 @@
+##############################################################################
+#
+# Copyright (c) 2001 Mark Pilgrim and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Convert to and from Roman numerals"""
+
+__author__ = "Mark Pilgrim (f8dy@diveintopython.org)"
+__version__ = "1.4"
+__date__ = "8 August 2001"
+__copyright__ = """Copyright (c) 2001 Mark Pilgrim
+
+This program is part of "Dive Into Python", a free Python tutorial for
+experienced programmers.  Visit http://diveintopython.org/ for the
+latest version.
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the Python 2.1.1 license, available at
+http://www.python.org/2.1.1/license.html
+"""
+
+import argparse
+import re
+import sys
+
+
+# Define exceptions
+class RomanError(Exception):
+    pass
+
+
+class OutOfRangeError(RomanError):
+    pass
+
+
+class NotIntegerError(RomanError):
+    pass
+
+
+class InvalidRomanNumeralError(RomanError):
+    pass
+
+
+# Define digit mapping
+romanNumeralMap = (('M', 1000),
+                   ('CM', 900),
+                   ('D', 500),
+                   ('CD', 400),
+                   ('C', 100),
+                   ('XC', 90),
+                   ('L', 50),
+                   ('XL', 40),
+                   ('X', 10),
+                   ('IX', 9),
+                   ('V', 5),
+                   ('IV', 4),
+                   ('I', 1))
+
+
+def toRoman(n):
+    """convert integer to Roman numeral"""
+    if not isinstance(n, int):
+        raise NotIntegerError("decimals can not be converted")
+    if not (-1 < n < 5000):
+        raise OutOfRangeError("number out of range (must be 0..4999)")
+
+    # special case
+    if n == 0:
+        return 'N'
+
+    result = ""
+    for numeral, integer in romanNumeralMap:
+        while n >= integer:
+            result += numeral
+            n -= integer
+    return result
+
+
+# Define pattern to detect valid Roman numerals
+romanNumeralPattern = re.compile("""
+    ^                   # beginning of string
+    M{0,4}              # thousands - 0 to 4 M's
+    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's),
+                        #            or 500-800 (D, followed by 0 to 3 C's)
+    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's),
+                        #        or 50-80 (L, followed by 0 to 3 X's)
+    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's),
+                        #        or 5-8 (V, followed by 0 to 3 I's)
+    $                   # end of string
+    """, re.VERBOSE)
+
+
+def fromRoman(s):
+    """convert Roman numeral to integer"""
+    if not s:
+        raise InvalidRomanNumeralError('Input can not be blank')
+
+    # special case
+    if s == 'N':
+        return 0
+
+    if not romanNumeralPattern.search(s):
+        raise InvalidRomanNumeralError('Invalid Roman numeral: %s' % s)
+
+    result = 0
+    index = 0
+    for numeral, integer in romanNumeralMap:
+        while s[index:index + len(numeral)] == numeral:
+            result += integer
+            index += len(numeral)
+    return result
+
+
+def parse_args():
+    parser = argparse.ArgumentParser(
+        prog='roman',
+        description='convert between roman and arabic numerals'
+    )
+    parser.add_argument('number', help='the value to convert')
+    parser.add_argument(
+        '-r', '--reverse',
+        action='store_true',
+        default=False,
+        help='convert roman to numeral (case insensitive) [default: False]')
+
+    args = parser.parse_args()
+    args.number = args.number
+    return args
+
+
+def main():
+    args = parse_args()
+    if args.reverse:
+        u = args.number.upper()
+        r = fromRoman(u)
+        print(r)
+    else:
+        i = int(args.number)
+        n = toRoman(i)
+        print(n)
+
+    return 0
+
+
+if __name__ == "__main__":  # pragma: no cover
+    sys.exit(main())  # pragma: no cover
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/smartquotes.py b/.venv/lib/python3.12/site-packages/docutils/utils/smartquotes.py
new file mode 100755
index 00000000..b8766db2
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/smartquotes.py
@@ -0,0 +1,1004 @@
+#!/usr/bin/python3
+# :Id: $Id: smartquotes.py 9481 2023-11-19 21:19:20Z milde $
+# :Copyright: © 2010-2023 Günter Milde,
+#             original `SmartyPants`_: © 2003 John Gruber
+#             smartypants.py:          © 2004, 2007 Chad Miller
+# :Maintainer: docutils-develop@lists.sourceforge.net
+# :License: Released under the terms of the `2-Clause BSD license`_, in short:
+#
+#    Copying and distribution of this file, with or without modification,
+#    are permitted in any medium without royalty provided the copyright
+#    notices and this notice are preserved.
+#    This file is offered as-is, without any warranty.
+#
+# .. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+
+
+r"""
+=========================
+Smart Quotes for Docutils
+=========================
+
+Synopsis
+========
+
+"SmartyPants" is a free web publishing plug-in for Movable Type, Blosxom, and
+BBEdit that easily translates plain ASCII punctuation characters into "smart"
+typographic punctuation characters.
+
+``smartquotes.py`` is an adaption of "SmartyPants" to Docutils_.
+
+* Using Unicode instead of HTML entities for typographic punctuation
+  characters, it works for any output format that supports Unicode.
+* Supports `language specific quote characters`__.
+
+__ https://en.wikipedia.org/wiki/Non-English_usage_of_quotation_marks
+
+
+Authors
+=======
+
+`John Gruber`_ did all of the hard work of writing this software in Perl for
+`Movable Type`_ and almost all of this useful documentation.  `Chad Miller`_
+ported it to Python to use with Pyblosxom_.
+Adapted to Docutils_ by Günter Milde.
+
+Additional Credits
+==================
+
+Portions of the SmartyPants original work are based on Brad Choate's nifty
+MTRegex plug-in.  `Brad Choate`_ also contributed a few bits of source code to
+this plug-in.  Brad Choate is a fine hacker indeed.
+
+`Jeremy Hedley`_ and `Charles Wiltgen`_ deserve mention for exemplary beta
+testing of the original SmartyPants.
+
+`Rael Dornfest`_ ported SmartyPants to Blosxom.
+
+.. _Brad Choate: http://bradchoate.com/
+.. _Jeremy Hedley: http://antipixel.com/
+.. _Charles Wiltgen: http://playbacktime.com/
+.. _Rael Dornfest: http://raelity.org/
+
+
+Copyright and License
+=====================
+
+SmartyPants_ license (3-Clause BSD license):
+
+  Copyright (c) 2003 John Gruber (http://daringfireball.net/)
+  All rights reserved.
+
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are
+  met:
+
+  * Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+
+  * Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in
+    the documentation and/or other materials provided with the
+    distribution.
+
+  * Neither the name "SmartyPants" nor the names of its contributors
+    may be used to endorse or promote products derived from this
+    software without specific prior written permission.
+
+  This software is provided by the copyright holders and contributors
+  "as is" and any express or implied warranties, including, but not
+  limited to, the implied warranties of merchantability and fitness for
+  a particular purpose are disclaimed. In no event shall the copyright
+  owner or contributors be liable for any direct, indirect, incidental,
+  special, exemplary, or consequential damages (including, but not
+  limited to, procurement of substitute goods or services; loss of use,
+  data, or profits; or business interruption) however caused and on any
+  theory of liability, whether in contract, strict liability, or tort
+  (including negligence or otherwise) arising in any way out of the use
+  of this software, even if advised of the possibility of such damage.
+
+smartypants.py license (2-Clause BSD license):
+
+  smartypants.py is a derivative work of SmartyPants.
+
+  Redistribution and use in source and binary forms, with or without
+  modification, are permitted provided that the following conditions are
+  met:
+
+  * Redistributions of source code must retain the above copyright
+    notice, this list of conditions and the following disclaimer.
+
+  * Redistributions in binary form must reproduce the above copyright
+    notice, this list of conditions and the following disclaimer in
+    the documentation and/or other materials provided with the
+    distribution.
+
+  This software is provided by the copyright holders and contributors
+  "as is" and any express or implied warranties, including, but not
+  limited to, the implied warranties of merchantability and fitness for
+  a particular purpose are disclaimed. In no event shall the copyright
+  owner or contributors be liable for any direct, indirect, incidental,
+  special, exemplary, or consequential damages (including, but not
+  limited to, procurement of substitute goods or services; loss of use,
+  data, or profits; or business interruption) however caused and on any
+  theory of liability, whether in contract, strict liability, or tort
+  (including negligence or otherwise) arising in any way out of the use
+  of this software, even if advised of the possibility of such damage.
+
+.. _John Gruber: http://daringfireball.net/
+.. _Chad Miller: http://web.chad.org/
+
+.. _Pyblosxom: http://pyblosxom.bluesock.org/
+.. _SmartyPants: http://daringfireball.net/projects/smartypants/
+.. _Movable Type: http://www.movabletype.org/
+.. _2-Clause BSD license: https://opensource.org/licenses/BSD-2-Clause
+.. _Docutils: https://docutils.sourceforge.io/
+
+Description
+===========
+
+SmartyPants can perform the following transformations:
+
+- Straight quotes ( " and ' ) into "curly" quote characters
+- Backticks-style quotes (\`\`like this'') into "curly" quote characters
+- Dashes (``--`` and ``---``) into en- and em-dash entities
+- Three consecutive dots (``...`` or ``. . .``) into an ellipsis ``…``.
+
+This means you can write, edit, and save your posts using plain old
+ASCII straight quotes, plain dashes, and plain dots, but your published
+posts (and final HTML output) will appear with smart quotes, em-dashes,
+and proper ellipses.
+
+Backslash Escapes
+=================
+
+If you need to use literal straight quotes (or plain hyphens and periods),
+`smartquotes` accepts the following backslash escape sequences to force
+ASCII-punctuation. Mind, that you need two backslashes in "docstrings", as
+Python expands them, too.
+
+========  =========
+Escape    Character
+========  =========
+``\\``    \\
+``\\"``   \\"
+``\\'``   \\'
+``\\.``   \\.
+``\\-``   \\-
+``\\```   \\`
+========  =========
+
+This is useful, for example, when you want to use straight quotes as
+foot and inch marks: 6\\'2\\" tall; a 17\\" iMac.
+
+
+Caveats
+=======
+
+Why You Might Not Want to Use Smart Quotes in Your Weblog
+---------------------------------------------------------
+
+For one thing, you might not care.
+
+Most normal, mentally stable individuals do not take notice of proper
+typographic punctuation. Many design and typography nerds, however, break
+out in a nasty rash when they encounter, say, a restaurant sign that uses
+a straight apostrophe to spell "Joe's".
+
+If you're the sort of person who just doesn't care, you might well want to
+continue not caring. Using straight quotes -- and sticking to the 7-bit
+ASCII character set in general -- is certainly a simpler way to live.
+
+Even if you *do* care about accurate typography, you still might want to
+think twice before educating the quote characters in your weblog. One side
+effect of publishing curly quote characters is that it makes your
+weblog a bit harder for others to quote from using copy-and-paste. What
+happens is that when someone copies text from your blog, the copied text
+contains the 8-bit curly quote characters (as well as the 8-bit characters
+for em-dashes and ellipses, if you use these options). These characters
+are not standard across different text encoding methods, which is why they
+need to be encoded as characters.
+
+People copying text from your weblog, however, may not notice that you're
+using curly quotes, and they'll go ahead and paste the unencoded 8-bit
+characters copied from their browser into an email message or their own
+weblog. When pasted as raw "smart quotes", these characters are likely to
+get mangled beyond recognition.
+
+That said, my own opinion is that any decent text editor or email client
+makes it easy to stupefy smart quote characters into their 7-bit
+equivalents, and I don't consider it my problem if you're using an
+indecent text editor or email client.
+
+
+Algorithmic Shortcomings
+------------------------
+
+One situation in which quotes will get curled the wrong way is when
+apostrophes are used at the start of leading contractions. For example::
+
+  'Twas the night before Christmas.
+
+In the case above, SmartyPants will turn the apostrophe into an opening
+secondary quote, when in fact it should be the `RIGHT SINGLE QUOTATION MARK`
+character which is also "the preferred character to use for apostrophe"
+(Unicode). I don't think this problem can be solved in the general case --
+every word processor I've tried gets this wrong as well. In such cases, it's
+best to inset the `RIGHT SINGLE QUOTATION MARK` (’) by hand.
+
+In English, the same character is used for apostrophe and  closing secondary
+quote (both plain and "smart" ones). For other locales (French, Italean,
+Swiss, ...) "smart" secondary closing quotes differ from the curly apostrophe.
+
+   .. class:: language-fr
+
+   Il dit : "C'est 'super' !"
+
+If the apostrophe is used at the end of a word, it cannot be distinguished
+from a secondary quote by the algorithm. Therefore, a text like::
+
+   .. class:: language-de-CH
+
+   "Er sagt: 'Ich fass' es nicht.'"
+
+will get a single closing guillemet instead of an apostrophe.
+
+This can be prevented by use use of the `RIGHT SINGLE QUOTATION MARK` in
+the source::
+
+   -  "Er sagt: 'Ich fass' es nicht.'"
+   +  "Er sagt: 'Ich fass’ es nicht.'"
+
+
+Version History
+===============
+
+1.10    2023-11-18
+        - Pre-compile regexps once, not with every call of `educateQuotes()`
+          (patch #206 by Chris Sewell). Simplify regexps.
+
+1.9     2022-03-04
+        - Code cleanup. Require Python 3.
+
+1.8.1   2017-10-25
+        - Use open quote after Unicode whitespace, ZWSP, and ZWNJ.
+        - Code cleanup.
+
+1.8:    2017-04-24
+        - Command line front-end.
+
+1.7.1:  2017-03-19
+        - Update and extend language-dependent quotes.
+        - Differentiate apostrophe from single quote.
+
+1.7:    2012-11-19
+        - Internationalization: language-dependent quotes.
+
+1.6.1:  2012-11-06
+        - Refactor code, code cleanup,
+        - `educate_tokens()` generator as interface for Docutils.
+
+1.6:    2010-08-26
+        - Adaption to Docutils:
+          - Use Unicode instead of HTML entities,
+          - Remove code special to pyblosxom.
+
+1.5_1.6: Fri, 27 Jul 2007 07:06:40 -0400
+        - Fixed bug where blocks of precious unalterable text was instead
+          interpreted.  Thanks to Le Roux and Dirk van Oosterbosch.
+
+1.5_1.5: Sat, 13 Aug 2005 15:50:24 -0400
+        - Fix bogus magical quotation when there is no hint that the
+          user wants it, e.g., in "21st century".  Thanks to Nathan Hamblen.
+        - Be smarter about quotes before terminating numbers in an en-dash'ed
+          range.
+
+1.5_1.4: Thu, 10 Feb 2005 20:24:36 -0500
+        - Fix a date-processing bug, as reported by jacob childress.
+        - Begin a test-suite for ensuring correct output.
+        - Removed import of "string", since I didn't really need it.
+          (This was my first every Python program.  Sue me!)
+
+1.5_1.3: Wed, 15 Sep 2004 18:25:58 -0400
+        - Abort processing if the flavour is in forbidden-list.  Default of
+          [ "rss" ]   (Idea of Wolfgang SCHNERRING.)
+        - Remove stray virgules from en-dashes.  Patch by Wolfgang SCHNERRING.
+
+1.5_1.2: Mon, 24 May 2004 08:14:54 -0400
+        - Some single quotes weren't replaced properly.  Diff-tesuji played
+          by Benjamin GEIGER.
+
+1.5_1.1: Sun, 14 Mar 2004 14:38:28 -0500
+        - Support upcoming pyblosxom 0.9 plugin verification feature.
+
+1.5_1.0: Tue, 09 Mar 2004 08:08:35 -0500
+        - Initial release
+"""
+
+import re
+import sys
+
+
+options = r"""
+Options
+=======
+
+Numeric values are the easiest way to configure SmartyPants' behavior:
+
+:0:     Suppress all transformations. (Do nothing.)
+
+:1:     Performs default SmartyPants transformations: quotes (including
+        \`\`backticks'' -style), em-dashes, and ellipses. "``--``" (dash dash)
+        is used to signify an em-dash; there is no support for en-dashes
+
+:2:     Same as smarty_pants="1", except that it uses the old-school typewriter
+        shorthand for dashes:  "``--``" (dash dash) for en-dashes, "``---``"
+        (dash dash dash)
+        for em-dashes.
+
+:3:     Same as smarty_pants="2", but inverts the shorthand for dashes:
+        "``--``" (dash dash) for em-dashes, and "``---``" (dash dash dash) for
+        en-dashes.
+
+:-1:    Stupefy mode. Reverses the SmartyPants transformation process, turning
+        the characters produced by SmartyPants into their ASCII equivalents.
+        E.g. the LEFT DOUBLE QUOTATION MARK (“) is turned into a simple
+        double-quote (\"), "—" is turned into two dashes, etc.
+
+
+The following single-character attribute values can be combined to toggle
+individual transformations from within the smarty_pants attribute. For
+example, ``"1"`` is equivalent to ``"qBde"``.
+
+:q:     Educates normal quote characters: (") and (').
+
+:b:     Educates \`\`backticks'' -style double quotes.
+
+:B:     Educates \`\`backticks'' -style double quotes and \`single' quotes.
+
+:d:     Educates em-dashes.
+
+:D:     Educates em-dashes and en-dashes, using old-school typewriter
+        shorthand: (dash dash) for en-dashes, (dash dash dash) for em-dashes.
+
+:i:     Educates em-dashes and en-dashes, using inverted old-school typewriter
+        shorthand: (dash dash) for em-dashes, (dash dash dash) for en-dashes.
+
+:e:     Educates ellipses.
+
+:w:     Translates any instance of ``&quot;`` into a normal double-quote
+        character. This should be of no interest to most people, but
+        of particular interest to anyone who writes their posts using
+        Dreamweaver, as Dreamweaver inexplicably uses this entity to represent
+        a literal double-quote character. SmartyPants only educates normal
+        quotes, not entities (because ordinarily, entities are used for
+        the explicit purpose of representing the specific character they
+        represent). The "w" option must be used in conjunction with one (or
+        both) of the other quote options ("q" or "b"). Thus, if you wish to
+        apply all SmartyPants transformations (quotes, en- and em-dashes, and
+        ellipses) and also translate ``&quot;`` entities into regular quotes
+        so SmartyPants can educate them, you should pass the following to the
+        smarty_pants attribute:
+"""
+
+
+class smartchars:
+    """Smart quotes and dashes"""
+
+    endash = '–'      # EN DASH
+    emdash = '—'      # EM DASH
+    ellipsis = '…'    # HORIZONTAL ELLIPSIS
+    apostrophe = '’'  # RIGHT SINGLE QUOTATION MARK
+
+    # quote characters (language-specific, set in __init__())
+    # https://en.wikipedia.org/wiki/Non-English_usage_of_quotation_marks
+    # https://de.wikipedia.org/wiki/Anf%C3%BChrungszeichen#Andere_Sprachen
+    # https://fr.wikipedia.org/wiki/Guillemet
+    # https://typographisme.net/post/Les-espaces-typographiques-et-le-web
+    # https://www.btb.termiumplus.gc.ca/tpv2guides/guides/redac/index-fra.html
+    # https://en.wikipedia.org/wiki/Hebrew_punctuation#Quotation_marks
+    # [7] https://www.tustep.uni-tuebingen.de/bi/bi00/bi001t1-anfuehrung.pdf
+    # [8] https://www.korrekturavdelingen.no/anforselstegn.htm
+    # [9] Typografisk håndbok. Oslo: Spartacus. 2000. s. 67. ISBN 8243001530.
+    # [10] https://www.typografi.org/sitat/sitatart.html
+    # [11] https://mk.wikipedia.org/wiki/Правопис_и_правоговор_на_македонскиот_јазик  # noqa:E501
+    # [12] https://hrvatska-tipografija.com/polunavodnici/
+    # [13] https://pl.wikipedia.org/wiki/Cudzys%C5%82%C3%B3w
+    #
+    # See also configuration option "smartquote-locales".
+    quotes = {
+        'af':           '“”‘’',
+        'af-x-altquot': '„”‚’',
+        'bg':           '„“‚‘',  # https://bg.wikipedia.org/wiki/Кавички
+        'ca':           '«»“”',
+        'ca-x-altquot': '“”‘’',
+        'cs':           '„“‚‘',
+        'cs-x-altquot': '»«›‹',
+        'da':           '»«›‹',
+        'da-x-altquot': '„“‚‘',
+        # 'da-x-altquot2': '””’’',
+        'de':           '„“‚‘',
+        'de-x-altquot': '»«›‹',
+        'de-ch':        '«»‹›',
+        'el':           '«»“”',  # '«»‟”' https://hal.science/hal-02101618
+        'en':           '“”‘’',
+        'en-uk-x-altquot': '‘’“”',  # Attention: " → ‘ and ' → “ !
+        'eo':           '“”‘’',
+        'es':           '«»“”',
+        'es-x-altquot': '“”‘’',
+        'et':           '„“‚‘',  # no secondary quote listed in
+        'et-x-altquot': '«»‹›',  # the sources above (wikipedia.org)
+        'eu':           '«»‹›',
+        'fi':           '””’’',
+        'fi-x-altquot': '»»››',
+        'fr':           ('« ', ' »', '“', '”'),  # full no-break space
+        'fr-x-altquot': ('« ', ' »', '“', '”'),  # narrow no-break space
+        'fr-ch':        '«»‹›',                  # https://typoguide.ch/
+        'fr-ch-x-altquot': ('« ',  ' »', '‹ ', ' ›'),  # narrow no-break space  # noqa:E501
+        'gl':           '«»“”',
+        'he':           '”“»«',  # Hebrew is RTL, test position:
+        'he-x-altquot': '„”‚’',  # low quotation marks are opening.
+        # 'he-x-altquot': '“„‘‚',  # RTL: low quotation marks opening
+        'hr':           '„”‘’',  # Croatian [12]
+        'hr-x-altquot': '»«›‹',
+        'hsb':          '„“‚‘',
+        'hsb-x-altquot': '»«›‹',
+        'hu':           '„”«»',
+        'is':           '„“‚‘',
+        'it':           '«»“”',
+        'it-ch':        '«»‹›',
+        'it-x-altquot': '“”‘’',
+        # 'it-x-altquot2': '“„‘‚',  # [7] in headlines
+        'ja':           '「」『』',
+        'ko':           '“”‘’',
+        'lt':           '„“‚‘',
+        'lv':           '„“‚‘',
+        'mk':           '„“‚‘',  # Macedonian [11]
+        'nl':           '“”‘’',
+        'nl-x-altquot': '„”‚’',
+        # 'nl-x-altquot2': '””’’',
+        'nb':           '«»’’',     # Norsk bokmål (canonical form 'no')
+        'nn':           '«»’’',     # Nynorsk [10]
+        'nn-x-altquot': '«»‘’',     # [8], [10]
+        # 'nn-x-altquot2': '«»«»',  # [9], [10]
+        # 'nn-x-altquot3': '„“‚‘',  # [10]
+        'no':           '«»’’',     # Norsk bokmål [10]
+        'no-x-altquot': '«»‘’',     # [8], [10]
+        # 'no-x-altquot2': '«»«»',  # [9], [10
+        # 'no-x-altquot3': '„“‚‘',  # [10]
+        'pl':           '„”«»',
+        'pl-x-altquot': '«»‚’',
+        # 'pl-x-altquot2': '„”‚’',  # [13]
+        'pt':           '«»“”',
+        'pt-br':        '“”‘’',
+        'ro':           '„”«»',
+        'ru':           '«»„“',
+        'sh':           '„”‚’',  # Serbo-Croatian
+        'sh-x-altquot': '»«›‹',
+        'sk':           '„“‚‘',  # Slovak
+        'sk-x-altquot': '»«›‹',
+        'sl':           '„“‚‘',  # Slovenian
+        'sl-x-altquot': '»«›‹',
+        'sq':           '«»‹›',  # Albanian
+        'sq-x-altquot': '“„‘‚',
+        'sr':           '„”’’',
+        'sr-x-altquot': '»«›‹',
+        'sv':           '””’’',
+        'sv-x-altquot': '»»››',
+        'tr':           '“”‘’',
+        'tr-x-altquot': '«»‹›',
+        # 'tr-x-altquot2': '“„‘‚',  # [7] antiquated?
+        'uk':           '«»„“',
+        'uk-x-altquot': '„“‚‘',
+        'zh-cn':        '“”‘’',
+        'zh-tw':        '「」『』',
+        }
+
+    def __init__(self, language='en'):
+        self.language = language
+        try:
+            (self.opquote, self.cpquote,
+             self.osquote, self.csquote) = self.quotes[language.lower()]
+        except KeyError:
+            self.opquote, self.cpquote, self.osquote, self.csquote = '""\'\''
+
+
+class RegularExpressions:
+    # character classes:
+    _CH_CLASSES = {'open': '[([{]',    # opening braces
+                   'close': r'[^\s]',  # everything except whitespace
+                   'punct': r"""[-!"  #\$\%'()*+,.\/:;<=>?\@\[\\\]\^_`{|}~]""",
+                   'dash': r'[-–—]',
+                   'sep': '[\\s\u200B\u200C]',  # Whitespace, ZWSP, ZWNJ
+                   }
+    START_SINGLE = re.compile(r"^'(?=%s\\B)" % _CH_CLASSES['punct'])
+    START_DOUBLE = re.compile(r'^"(?=%s\\B)' % _CH_CLASSES['punct'])
+    ADJACENT_1 = re.compile('"\'(?=\\w)')
+    ADJACENT_2 = re.compile('\'"(?=\\w)')
+    OPEN_SINGLE = re.compile(r"(%(open)s|%(dash)s)'(?=%(punct)s? )"
+                             % _CH_CLASSES)
+    OPEN_DOUBLE = re.compile(r'(%(open)s|%(dash)s)"(?=%(punct)s? )'
+                             % _CH_CLASSES)
+    DECADE = re.compile(r"'(?=\d{2}s)")
+    APOSTROPHE = re.compile(r"(?<=(\w|\d))'(?=\w)")
+    OPENING_SECONDARY = re.compile("""
+                    (# ?<=  # look behind fails: requires fixed-width pattern
+                        %(sep)s     |  # a whitespace char, or
+                        %(open)s    |  # opening brace, or
+                        %(dash)s       # em/en-dash
+                    )
+                    '                  # the quote
+                    (?=\\w|%(punct)s)  # word character or punctuation
+                    """ % _CH_CLASSES, re.VERBOSE)
+    CLOSING_SECONDARY = re.compile(r"(?<!\s)'")
+    OPENING_PRIMARY = re.compile("""
+                    (
+                        %(sep)s     |  # a whitespace char, or
+                        %(open)s    |  # zero width separating char, or
+                        %(dash)s       # em/en-dash
+                    )
+                    "                 # the quote, followed by
+                    (?=\\w|%(punct)s) # a word character or punctuation
+                    """ % _CH_CLASSES, re.VERBOSE)
+    CLOSING_PRIMARY = re.compile(r"""
+                    (
+                    (?<!\s)" | # no whitespace before
+                    "(?=\s)    # whitespace behind
+                    )
+                    """, re.VERBOSE)
+
+
+regexes = RegularExpressions()
+
+
+default_smartypants_attr = '1'
+
+
+def smartyPants(text, attr=default_smartypants_attr, language='en'):
+    """Main function for "traditional" use."""
+
+    return "".join(t for t in educate_tokens(tokenize(text), attr, language))
+
+
+def educate_tokens(text_tokens, attr=default_smartypants_attr, language='en'):
+    """Return iterator that "educates" the items of `text_tokens`."""
+    # Parse attributes:
+    # 0 : do nothing
+    # 1 : set all
+    # 2 : set all, using old school en- and em- dash shortcuts
+    # 3 : set all, using inverted old school en and em- dash shortcuts
+    #
+    # q : quotes
+    # b : backtick quotes (``double'' only)
+    # B : backtick quotes (``double'' and `single')
+    # d : dashes
+    # D : old school dashes
+    # i : inverted old school dashes
+    # e : ellipses
+    # w : convert &quot; entities to " for Dreamweaver users
+
+    convert_quot = False  # translate &quot; entities into normal quotes?
+    do_dashes = False
+    do_backticks = False
+    do_quotes = False
+    do_ellipses = False
+    do_stupefy = False
+
+    # if attr == "0":  # pass tokens unchanged (see below).
+    if attr == '1':  # Do everything, turn all options on.
+        do_quotes = True
+        do_backticks = True
+        do_dashes = 1
+        do_ellipses = True
+    elif attr == '2':
+        # Do everything, turn all options on, use old school dash shorthand.
+        do_quotes = True
+        do_backticks = True
+        do_dashes = 2
+        do_ellipses = True
+    elif attr == '3':
+        # Do everything, use inverted old school dash shorthand.
+        do_quotes = True
+        do_backticks = True
+        do_dashes = 3
+        do_ellipses = True
+    elif attr == '-1':  # Special "stupefy" mode.
+        do_stupefy = True
+    else:
+        if 'q' in attr: do_quotes = True     # noqa: E701
+        if 'b' in attr: do_backticks = True  # noqa: E701
+        if 'B' in attr: do_backticks = 2     # noqa: E701
+        if 'd' in attr: do_dashes = 1        # noqa: E701
+        if 'D' in attr: do_dashes = 2        # noqa: E701
+        if 'i' in attr: do_dashes = 3        # noqa: E701
+        if 'e' in attr: do_ellipses = True   # noqa: E701
+        if 'w' in attr: convert_quot = True  # noqa: E701
+
+    prev_token_last_char = ' '
+    # Last character of the previous text token. Used as
+    # context to curl leading quote characters correctly.
+
+    for (ttype, text) in text_tokens:
+
+        # skip HTML and/or XML tags as well as empty text tokens
+        # without updating the last character
+        if ttype == 'tag' or not text:
+            yield text
+            continue
+
+        # skip literal text (math, literal, raw, ...)
+        if ttype == 'literal':
+            prev_token_last_char = text[-1:]
+            yield text
+            continue
+
+        last_char = text[-1:]  # Remember last char before processing.
+
+        text = processEscapes(text)
+
+        if convert_quot:
+            text = text.replace('&quot;', '"')
+
+        if do_dashes == 1:
+            text = educateDashes(text)
+        elif do_dashes == 2:
+            text = educateDashesOldSchool(text)
+        elif do_dashes == 3:
+            text = educateDashesOldSchoolInverted(text)
+
+        if do_ellipses:
+            text = educateEllipses(text)
+
+        # Note: backticks need to be processed before quotes.
+        if do_backticks:
+            text = educateBackticks(text, language)
+
+        if do_backticks == 2:
+            text = educateSingleBackticks(text, language)
+
+        if do_quotes:
+            # Replace plain quotes in context to prevent conversion to
+            # 2-character sequence in French.
+            context = prev_token_last_char.replace('"', ';').replace("'", ';')
+            text = educateQuotes(context+text, language)[1:]
+
+        if do_stupefy:
+            text = stupefyEntities(text, language)
+
+        # Remember last char as context for the next token
+        prev_token_last_char = last_char
+
+        text = processEscapes(text, restore=True)
+
+        yield text
+
+
+def educateQuotes(text, language='en'):
+    """
+    Parameter:  - text string (unicode or bytes).
+                - language (`BCP 47` language tag.)
+    Returns:    The `text`, with "educated" curly quote characters.
+
+    Example input:  "Isn't this fun?"
+    Example output: “Isn’t this fun?“
+    """
+    smart = smartchars(language)
+
+    if not re.search('[-"\']', text):
+        return text
+
+    # Special case if the very first character is a quote
+    # followed by punctuation at a non-word-break. Use closing quotes.
+    # TODO: example (when does this match?)
+    text = regexes.START_SINGLE.sub(smart.csquote, text)
+    text = regexes.START_DOUBLE.sub(smart.cpquote, text)
+
+    # Special case for adjacent quotes
+    # like "'Quoted' words in a larger quote."
+    text = regexes.ADJACENT_1.sub(smart.opquote+smart.osquote, text)
+    text = regexes.ADJACENT_2.sub(smart.osquote+smart.opquote, text)
+
+    # Special case: "opening character" followed by quote,
+    # optional punctuation and space like "[", '(', or '-'.
+    text = regexes.OPEN_SINGLE.sub(r'\1%s'%smart.csquote, text)
+    text = regexes.OPEN_DOUBLE.sub(r'\1%s'%smart.cpquote, text)
+
+    # Special case for decade abbreviations (the '80s):
+    if language.startswith('en'):  # TODO similar cases in other languages?
+        text = regexes.DECADE.sub(smart.apostrophe, text)
+
+    # Get most opening secondary quotes:
+    text = regexes.OPENING_SECONDARY.sub(r'\1'+smart.osquote, text)
+
+    # In many locales, secondary closing quotes are different from apostrophe:
+    if smart.csquote != smart.apostrophe:
+        text = regexes.APOSTROPHE.sub(smart.apostrophe, text)
+    # TODO: keep track of quoting level to recognize apostrophe in, e.g.,
+    # "Ich fass' es nicht."
+
+    text = regexes.CLOSING_SECONDARY.sub(smart.csquote, text)
+
+    # Any remaining secondary quotes should be opening ones:
+    text = text.replace(r"'", smart.osquote)
+
+    # Get most opening primary quotes:
+    text = regexes.OPENING_PRIMARY.sub(r'\1'+smart.opquote, text)
+
+    # primary closing quotes:
+    text = regexes.CLOSING_PRIMARY.sub(smart.cpquote, text)
+
+    # Any remaining quotes should be opening ones.
+    text = text.replace(r'"', smart.opquote)
+
+    return text
+
+
+def educateBackticks(text, language='en'):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with ``backticks'' -style double quotes
+                translated into HTML curly quote entities.
+    Example input:  ``Isn't this fun?''
+    Example output: “Isn't this fun?“
+    """
+    smart = smartchars(language)
+
+    text = text.replace(r'``', smart.opquote)
+    text = text.replace(r"''", smart.cpquote)
+    return text
+
+
+def educateSingleBackticks(text, language='en'):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with `backticks' -style single quotes
+                translated into HTML curly quote entities.
+
+    Example input:  `Isn't this fun?'
+    Example output: ‘Isn’t this fun?’
+    """
+    smart = smartchars(language)
+
+    text = text.replace(r'`', smart.osquote)
+    text = text.replace(r"'", smart.csquote)
+    return text
+
+
+def educateDashes(text):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with each instance of "--" translated to
+                an em-dash character.
+    """
+
+    text = text.replace(r'---', smartchars.endash)  # en (yes, backwards)
+    text = text.replace(r'--', smartchars.emdash)   # em (yes, backwards)
+    return text
+
+
+def educateDashesOldSchool(text):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with each instance of "--" translated to
+                an en-dash character, and each "---" translated to
+                an em-dash character.
+    """
+
+    text = text.replace(r'---', smartchars.emdash)
+    text = text.replace(r'--', smartchars.endash)
+    return text
+
+
+def educateDashesOldSchoolInverted(text):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with each instance of "--" translated to
+                an em-dash character, and each "---" translated to
+                an en-dash character. Two reasons why: First, unlike the
+                en- and em-dash syntax supported by
+                EducateDashesOldSchool(), it's compatible with existing
+                entries written before SmartyPants 1.1, back when "--" was
+                only used for em-dashes.  Second, em-dashes are more
+                common than en-dashes, and so it sort of makes sense that
+                the shortcut should be shorter to type. (Thanks to Aaron
+                Swartz for the idea.)
+    """
+    text = text.replace(r'---', smartchars.endash)    # em
+    text = text.replace(r'--', smartchars.emdash)    # en
+    return text
+
+
+def educateEllipses(text):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with each instance of "..." translated to
+                an ellipsis character.
+
+    Example input:  Huh...?
+    Example output: Huh…?
+    """
+
+    text = text.replace(r'...', smartchars.ellipsis)
+    text = text.replace(r'. . .', smartchars.ellipsis)
+    return text
+
+
+def stupefyEntities(text, language='en'):
+    """
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with each SmartyPants character translated to
+                its ASCII counterpart.
+
+    Example input:  “Hello — world.”
+    Example output: "Hello -- world."
+    """
+    smart = smartchars(language)
+
+    text = text.replace(smart.endash, "-")
+    text = text.replace(smart.emdash, "--")
+    text = text.replace(smart.osquote, "'")  # open secondary quote
+    text = text.replace(smart.csquote, "'")  # close secondary quote
+    text = text.replace(smart.opquote, '"')  # open primary quote
+    text = text.replace(smart.cpquote, '"')  # close primary quote
+    text = text.replace(smart.ellipsis, '...')
+
+    return text
+
+
+def processEscapes(text, restore=False):
+    r"""
+    Parameter:  String (unicode or bytes).
+    Returns:    The `text`, with after processing the following backslash
+                escape sequences. This is useful if you want to force a "dumb"
+                quote or other character to appear.
+
+                Escape  Value
+                ------  -----
+                \\      &#92;
+                \"      &#34;
+                \'      &#39;
+                \.      &#46;
+                \-      &#45;
+                \`      &#96;
+    """
+    replacements = ((r'\\', r'&#92;'),
+                    (r'\"', r'&#34;'),
+                    (r"\'", r'&#39;'),
+                    (r'\.', r'&#46;'),
+                    (r'\-', r'&#45;'),
+                    (r'\`', r'&#96;'))
+    if restore:
+        for (ch, rep) in replacements:
+            text = text.replace(rep, ch[1])
+    else:
+        for (ch, rep) in replacements:
+            text = text.replace(ch, rep)
+
+    return text
+
+
+def tokenize(text):
+    """
+    Parameter:  String containing HTML markup.
+    Returns:    An iterator that yields the tokens comprising the input
+                string. Each token is either a tag (possibly with nested,
+                tags contained therein, such as <a href="<MTFoo>">, or a
+                run of text between tags. Each yielded element is a
+                two-element tuple; the first is either 'tag' or 'text';
+                the second is the actual value.
+
+    Based on the _tokenize() subroutine from Brad Choate's MTRegex plugin.
+    """
+    tag_soup = re.compile(r'([^<]*)(<[^>]*>)')
+    token_match = tag_soup.search(text)
+    previous_end = 0
+
+    while token_match is not None:
+        if token_match.group(1):
+            yield 'text', token_match.group(1)
+        yield 'tag', token_match.group(2)
+        previous_end = token_match.end()
+        token_match = tag_soup.search(text, token_match.end())
+
+    if previous_end < len(text):
+        yield 'text', text[previous_end:]
+
+
+if __name__ == "__main__":
+
+    import itertools
+    import locale
+    try:
+        locale.setlocale(locale.LC_ALL, '')  # set to user defaults
+        defaultlanguage = locale.getlocale()[0]
+    except:  # noqa  catchall
+        defaultlanguage = 'en'
+
+    # Normalize and drop unsupported subtags:
+    defaultlanguage = defaultlanguage.lower().replace('-', '_')
+    # split (except singletons, which mark the following tag as non-standard):
+    defaultlanguage = re.sub(r'_([a-zA-Z0-9])_', r'_\1-', defaultlanguage)
+    _subtags = [subtag for subtag in defaultlanguage.split('_')]
+    _basetag = _subtags.pop(0)
+    # find all combinations of subtags
+    for n in range(len(_subtags), 0, -1):
+        for tags in itertools.combinations(_subtags, n):
+            _tag = '-'.join((_basetag, *tags))
+            if _tag in smartchars.quotes:
+                defaultlanguage = _tag
+                break
+        else:
+            if _basetag in smartchars.quotes:
+                defaultlanguage = _basetag
+            else:
+                defaultlanguage = 'en'
+
+    import argparse
+    parser = argparse.ArgumentParser(
+                description='Filter <input> making ASCII punctuation "smart".')
+    # TODO: require input arg or other means to print USAGE instead of waiting.
+    # parser.add_argument("input", help="Input stream, use '-' for stdin.")
+    parser.add_argument("-a", "--action", default="1",
+                        help="what to do with the input (see --actionhelp)")
+    parser.add_argument("-e", "--encoding", default="utf-8",
+                        help="text encoding")
+    parser.add_argument("-l", "--language", default=defaultlanguage,
+                        help="text language (BCP47 tag), "
+                             f"Default: {defaultlanguage}")
+    parser.add_argument("-q", "--alternative-quotes", action="store_true",
+                        help="use alternative quote style")
+    parser.add_argument("--doc", action="store_true",
+                        help="print documentation")
+    parser.add_argument("--actionhelp", action="store_true",
+                        help="list available actions")
+    parser.add_argument("--stylehelp", action="store_true",
+                        help="list available quote styles")
+    parser.add_argument("--test", action="store_true",
+                        help="perform short self-test")
+    args = parser.parse_args()
+
+    if args.doc:
+        print(__doc__)
+    elif args.actionhelp:
+        print(options)
+    elif args.stylehelp:
+        print()
+        print("Available styles (primary open/close, secondary open/close)")
+        print("language tag   quotes")
+        print("============   ======")
+        for key in sorted(smartchars.quotes.keys()):
+            print("%-14s %s" % (key, smartchars.quotes[key]))
+    elif args.test:
+        # Unit test output goes to stderr.
+        import unittest
+
+        class TestSmartypantsAllAttributes(unittest.TestCase):
+            # the default attribute is "1", which means "all".
+            def test_dates(self):
+                self.assertEqual(smartyPants("1440-80's"), "1440-80’s")
+                self.assertEqual(smartyPants("1440-'80s"), "1440-’80s")
+                self.assertEqual(smartyPants("1440---'80s"), "1440–’80s")
+                self.assertEqual(smartyPants("1960's"), "1960’s")
+                self.assertEqual(smartyPants("one two '60s"), "one two ’60s")
+                self.assertEqual(smartyPants("'60s"), "’60s")
+
+            def test_educated_quotes(self):
+                self.assertEqual(smartyPants('"Isn\'t this fun?"'),
+                                 '“Isn’t this fun?”')
+
+            def test_html_tags(self):
+                text = '<a src="foo">more</a>'
+                self.assertEqual(smartyPants(text), text)
+
+        suite = unittest.TestLoader().loadTestsFromTestCase(
+                                            TestSmartypantsAllAttributes)
+        unittest.TextTestRunner().run(suite)
+
+    else:
+        if args.alternative_quotes:
+            if '-x-altquot' in args.language:
+                args.language = args.language.replace('-x-altquot', '')
+            else:
+                args.language += '-x-altquot'
+        text = sys.stdin.read()
+        print(smartyPants(text, attr=args.action, language=args.language))
diff --git a/.venv/lib/python3.12/site-packages/docutils/utils/urischemes.py b/.venv/lib/python3.12/site-packages/docutils/utils/urischemes.py
new file mode 100644
index 00000000..a0435c02
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/docutils/utils/urischemes.py
@@ -0,0 +1,138 @@
+# $Id: urischemes.py 9315 2023-01-15 19:27:55Z milde $
+# Author: David Goodger <goodger@python.org>
+# Copyright: This module has been placed in the public domain.
+
+"""
+`schemes` is a dictionary with lowercase URI addressing schemes as
+keys and descriptions as values. It was compiled from the index at
+http://www.iana.org/assignments/uri-schemes (revised 2005-11-28)
+and an older list at https://www.w3.org/Addressing/schemes.html.
+"""
+
+# Many values are blank and should be filled in with useful descriptions.
+
+schemes = {
+      'about': 'provides information on Navigator',
+      'acap': 'Application Configuration Access Protocol; RFC 2244',
+      'addbook': "To add vCard entries to Communicator's Address Book",
+      'afp': 'Apple Filing Protocol',
+      'afs': 'Andrew File System global file names',
+      'aim': 'AOL Instant Messenger',
+      'callto': 'for NetMeeting links',
+      'castanet': 'Castanet Tuner URLs for Netcaster',
+      'chttp': 'cached HTTP supported by RealPlayer',
+      'cid': 'content identifier; RFC 2392',
+      'crid': 'TV-Anytime Content Reference Identifier; RFC 4078',
+      'data': 'allows inclusion of small data items as "immediate" data; '
+              'RFC 2397',
+      'dav': 'Distributed Authoring and Versioning Protocol; RFC 2518',
+      'dict': 'dictionary service protocol; RFC 2229',
+      'dns': 'Domain Name System resources',
+      'eid': 'External ID; non-URL data; general escape mechanism to allow '
+             'access to information for applications that are too '
+             'specialized to justify their own schemes',
+      'fax': 'a connection to a terminal that can handle telefaxes '
+             '(facsimiles); RFC 2806',
+      'feed': 'NetNewsWire feed',
+      'file': 'Host-specific file names; RFC 1738',
+      'finger': '',
+      'freenet': '',
+      'ftp': 'File Transfer Protocol; RFC 1738',
+      'go': 'go; RFC 3368',
+      'gopher': 'The Gopher Protocol',
+      'gsm-sms': 'Global System for Mobile Communications Short Message '
+                 'Service',
+      'h323': 'video (audiovisual) communication on local area networks; '
+              'RFC 3508',
+      'h324': 'video and audio communications over low bitrate connections '
+              'such as POTS modem connections',
+      'hdl': 'CNRI handle system',
+      'hnews': 'an HTTP-tunneling variant of the NNTP news protocol',
+      'http': 'Hypertext Transfer Protocol; RFC 2616',
+      'https': 'HTTP over SSL; RFC 2818',
+      'hydra': 'SubEthaEdit URI. '
+               'See http://www.codingmonkeys.de/subethaedit.',
+      'iioploc': 'Internet Inter-ORB Protocol Location?',
+      'ilu': 'Inter-Language Unification',
+      'im': 'Instant Messaging; RFC 3860',
+      'imap': 'Internet Message Access Protocol; RFC 2192',
+      'info': 'Information Assets with Identifiers in Public Namespaces',
+      'ior': 'CORBA interoperable object reference',
+      'ipp': 'Internet Printing Protocol; RFC 3510',
+      'irc': 'Internet Relay Chat',
+      'iris.beep': 'iris.beep; RFC 3983',
+      'iseek': 'See www.ambrosiasw.com;  a little util for OS X.',
+      'jar': 'Java archive',
+      'javascript': 'JavaScript code; '
+                    'evaluates the expression after the colon',
+      'jdbc': 'JDBC connection URI.',
+      'ldap': 'Lightweight Directory Access Protocol',
+      'lifn': '',
+      'livescript': '',
+      'lrq': '',
+      'mailbox': 'Mail folder access',
+      'mailserver': 'Access to data available from mail servers',
+      'mailto': 'Electronic mail address; RFC 2368',
+      'md5': '',
+      'mid': 'message identifier; RFC 2392',
+      'mocha': '',
+      'modem': 'a connection to a terminal that can handle incoming data '
+               'calls; RFC 2806',
+      'mtqp': 'Message Tracking Query Protocol; RFC 3887',
+      'mupdate': 'Mailbox Update (MUPDATE) Protocol; RFC 3656',
+      'news': 'USENET news; RFC 1738',
+      'nfs': 'Network File System protocol; RFC 2224',
+      'nntp': 'USENET news using NNTP access; RFC 1738',
+      'opaquelocktoken': 'RFC 2518',
+      'phone': '',
+      'pop': 'Post Office Protocol; RFC 2384',
+      'pop3': 'Post Office Protocol v3',
+      'pres': 'Presence; RFC 3859',
+      'printer': '',
+      'prospero': 'Prospero Directory Service; RFC 4157',
+      'rdar': 'URLs found in Darwin source '
+              '(http://www.opensource.apple.com/darwinsource/).',
+      'res': '',
+      'rtsp': 'real time streaming protocol; RFC 2326',
+      'rvp': '',
+      'rwhois': '',
+      'rx': 'Remote Execution',
+      'sdp': '',
+      'service': 'service location; RFC 2609',
+      'shttp': 'secure hypertext transfer protocol',
+      'sip': 'Session Initiation Protocol; RFC 3261',
+      'sips': 'secure session intitiaion protocol; RFC 3261',
+      'smb': 'SAMBA filesystems.',
+      'snews': 'For NNTP postings via SSL',
+      'snmp': 'Simple Network Management Protocol; RFC 4088',
+      'soap.beep': 'RFC 3288',
+      'soap.beeps': 'RFC 3288',
+      'ssh': 'Reference to interactive sessions via ssh.',
+      't120': 'real time data conferencing (audiographics)',
+      'tag': 'RFC 4151',
+      'tcp': '',
+      'tel': 'a connection to a terminal that handles normal voice '
+             'telephone calls, a voice mailbox or another voice messaging '
+             'system or a service that can be operated using DTMF tones; '
+             'RFC 3966.',
+      'telephone': 'telephone',
+      'telnet': 'Reference to interactive sessions; RFC 4248',
+      'tftp': 'Trivial File Transfer Protocol; RFC 3617',
+      'tip': 'Transaction Internet Protocol; RFC 2371',
+      'tn3270': 'Interactive 3270 emulation sessions',
+      'tv': '',
+      'urn': 'Uniform Resource Name; RFC 2141',
+      'uuid': '',
+      'vemmi': 'versatile multimedia interface; RFC 2122',
+      'videotex': '',
+      'view-source': 'displays HTML code that was generated with JavaScript',
+      'wais': 'Wide Area Information Servers; RFC 4156',
+      'whodp': '',
+      'whois++': 'Distributed directory service.',
+      'x-man-page': 'Opens man page in Terminal.app on OS X '
+                    '(see macosxhints.com)',
+      'xmlrpc.beep': 'RFC 3529',
+      'xmlrpc.beeps': 'RFC 3529',
+      'z39.50r': 'Z39.50 Retrieval; RFC 2056',
+      'z39.50s': 'Z39.50 Session; RFC 2056',
+      }