about summary refs log tree commit diff
path: root/.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py
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/math/math2html.py
parentcc961e04ba734dd72309fb548a2f97d67d578813 (diff)
downloadgn-ai-4a52a71956a8d46fcb7294ac71734504bb09bcc2.tar.gz
two version of R2R are here HEAD master
Diffstat (limited to '.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py')
-rwxr-xr-x.venv/lib/python3.12/site-packages/docutils/utils/math/math2html.py3165
1 files changed, 3165 insertions, 0 deletions
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 = {
+        '&': '&',
+        '<': '&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()