feat: referentiel projets dynamique + validation nommage by design

Durcit la convention de nommage des projets (dérive constatée : 'Sliding
Automation', 'code_versioning'... au lieu des formes canoniques).

- trilium_api.py : projets_canoniques() lit le référentiel = valeurs du label
  projet sur les notes de type=projet (source unique, pas de constante en dur).
  Note-projet CodeVersioning créée (manquait).
- mcp_server.py : _valider_projet() branché dans les 6 tools de création
  (add_decision/history/backlog, new_conversation, create_entite, add_skill).
  Refuse un projet non canonique (suggestion si faute) ou inconnu (renvoi au
  processus de création de projet). Ne verrouille pas si référentiel illisible.
- lint_audit.py : VAL-nommage aligné sur le référentiel (attrape casse, espace
  ET snake_case ; l'ancien 'contient un espace' ratait code_versioning).
- Données : 79 notes ré-étiquetées vers les 3 formes canoniques.

Quality by design : l'erreur de nommage devient impossible à l'écriture, le
Lint n'est plus que le filet de sécurité.
This commit is contained in:
2026-07-17 14:59:33 +02:00
parent d1e67431b6
commit 943acbc573
2425 changed files with 710525 additions and 6 deletions
@@ -0,0 +1,3 @@
from importlib import metadata
__version__ = metadata.version("latex2mathml")
@@ -0,0 +1,507 @@
from collections import OrderedDict, defaultdict
from typing import Optional
OPENING_BRACE = "{"
CLOSING_BRACE = "}"
BRACES = "{}"
OPENING_BRACKET = "["
CLOSING_BRACKET = "]"
BRACKETS = "[]"
OPENING_PARENTHESIS = "("
CLOSING_PARENTHESIS = ")"
PARENTHESES = "()"
SUBSUP = "_^"
SUBSCRIPT = "_"
SUPERSCRIPT = "^"
APOSTROPHE = "'"
PRIME = r"\prime"
DPRIME = r"\dprime"
LEFT = r"\left"
MIDDLE = r"\middle"
RIGHT = r"\right"
ABOVE = r"\above"
ABOVEWITHDELIMS = r"\abovewithdelims"
ATOP = r"\atop"
ATOPWITHDELIMS = r"\atopwithdelims"
BINOM = r"\binom"
BRACE = r"\brace"
BRACK = r"\brack"
CFRAC = r"\cfrac"
CHOOSE = r"\choose"
DBINOM = r"\dbinom"
DFRAC = r"\dfrac"
FRAC = r"\frac"
GENFRAC = r"\genfrac"
OVER = r"\over"
TBINOM = r"\tbinom"
TFRAC = r"\tfrac"
ROOT = r"\root"
SQRT = r"\sqrt"
OVERSET = r"\overset"
UNDERSET = r"\underset"
ACUTE = r"\acute"
BAR = r"\bar"
BREVE = r"\breve"
CHECK = r"\check"
DOT = r"\dot"
DDOT = r"\ddot"
DDDOT = r"\dddot"
DDDDOT = r"\ddddot"
GRAVE = r"\grave"
HAT = r"\hat"
MATHRING = r"\mathring"
OVERBRACE = r"\overbrace"
OVERLEFTARROW = r"\overleftarrow"
OVERLEFTRIGHTARROW = r"\overleftrightarrow"
OVERLINE = r"\overline"
OVERPAREN = r"\overparen"
OVERRIGHTARROW = r"\overrightarrow"
TILDE = r"\tilde"
UNDERBRACE = r"\underbrace"
UNDERLEFTARROW = r"\underleftarrow"
UNDERLINE = r"\underline"
UNDERPAREN = r"\underparen"
UNDERRIGHTARROW = r"\underrightarrow"
UNDERLEFTRIGHTARROW = r"\underleftrightarrow"
VEC = r"\vec"
WIDEHAT = r"\widehat"
WIDETILDE = r"\widetilde"
XLEFTARROW = r"\xleftarrow"
XRIGHTARROW = r"\xrightarrow"
HREF = r"\href"
TEXT = r"\text"
TEXTBF = r"\textbf"
TEXTIT = r"\textit"
TEXTRM = r"\textrm"
TEXTSF = r"\textsf"
TEXTTT = r"\texttt"
BEGIN = r"\begin"
END = r"\end"
LIMITS = r"\limits"
INTEGRAL = r"\int"
SUMMATION = r"\sum"
PRODUCT = r"\prod"
LIMIT = (r"\lim", r"\sup", r"\inf", r"\max", r"\min")
OPERATORNAME = r"\operatorname"
LBRACE = r"\{"
FUNCTIONS = (
r"\arccos",
r"\arcsin",
r"\arctan",
r"\cos",
r"\cosh",
r"\cot",
r"\coth",
r"\csc",
r"\deg",
r"\dim",
r"\exp",
r"\hom",
r"\ker",
r"\ln",
r"\lg",
r"\log",
r"\sec",
r"\sin",
r"\sinh",
r"\tan",
r"\tanh",
)
DETERMINANT = r"\det"
GCD = r"\gcd"
INTOP = r"\intop"
INJLIM = r"\injlim"
LIMINF = r"\liminf"
LIMSUP = r"\limsup"
PR = r"\Pr"
PROJLIM = r"\projlim"
MOD = r"\mod"
PMOD = r"\pmod"
BMOD = r"\bmod"
HDASHLINE = r"\hdashline"
HLINE = r"\hline"
HFIL = r"\hfil"
CASES = r"\cases"
DISPLAYLINES = r"\displaylines"
SMALLMATRIX = r"\smallmatrix"
SUBSTACK = r"\substack"
SPLIT = r"\split"
ALIGN = r"\align*"
MATRICES = (
r"\matrix",
r"\matrix*",
r"\pmatrix",
r"\pmatrix*",
r"\bmatrix",
r"\bmatrix*",
r"\Bmatrix",
r"\Bmatrix*",
r"\vmatrix",
r"\vmatrix*",
r"\Vmatrix",
r"\Vmatrix*",
r"\array",
SUBSTACK,
CASES,
DISPLAYLINES,
SMALLMATRIX,
SPLIT,
ALIGN,
)
BACKSLASH = "\\"
CARRIAGE_RETURN = r"\cr"
COLON = r"\:"
COMMA = r"\,"
DOUBLEBACKSLASH = r"\\"
ENSPACE = r"\enspace"
EXCLAMATION = r"\!"
GREATER_THAN = r"\>"
HSKIP = r"\hskip"
HSPACE = r"\hspace"
KERN = r"\kern"
MKERN = r"\mkern"
MSKIP = r"\mskip"
MSPACE = r"\mspace"
NEGTHINSPACE = r"\negthinspace"
NEGMEDSPACE = r"\negmedspace"
NEGTHICKSPACE = r"\negthickspace"
NOBREAKSPACE = r"\nobreakspace"
SPACE = r"\space"
THINSPACE = r"\thinspace"
QQUAD = r"\qquad"
QUAD = r"\quad"
SEMICOLON = r"\;"
BLACKBOARD_BOLD = r"\Bbb"
BOLD_SYMBOL = r"\boldsymbol"
MIT = r"\mit"
OLDSTYLE = r"\oldstyle"
SCR = r"\scr"
TT = r"\tt"
MATH = r"\math"
MATHBB = r"\mathbb"
MATHBF = r"\mathbf"
MATHCAL = r"\mathcal"
MATHFRAK = r"\mathfrak"
MATHIT = r"\mathit"
MATHRM = r"\mathrm"
MATHSCR = r"\mathscr"
MATHSF = r"\mathsf"
MATHTT = r"\mathtt"
BOXED = r"\boxed"
FBOX = r"\fbox"
HBOX = r"\hbox"
MBOX = r"\mbox"
COLOR = r"\color"
DISPLAYSTYLE = r"\displaystyle"
TEXTSTYLE = r"\textstyle"
SCRIPTSTYLE = r"\scriptstyle"
SCRIPTSCRIPTSTYLE = r"\scriptscriptstyle"
STYLE = r"\style"
HPHANTOM = r"\hphantom"
PHANTOM = r"\phantom"
VPHANTOM = r"\vphantom"
IDOTSINT = r"\idotsint"
LATEX = r"\LaTeX"
TEX = r"\TeX"
SIDESET = r"\sideset"
SKEW = r"\skew"
NOT = r"\not"
def font_factory(default: Optional[str], replacement: dict[str, Optional[str]]) -> defaultdict[str, Optional[str]]:
fonts = defaultdict(lambda: default, replacement)
return fonts
LOCAL_FONTS: dict[str, defaultdict[str, Optional[str]]] = {
BLACKBOARD_BOLD: font_factory("double-struck", {"fence": None}),
BOLD_SYMBOL: font_factory("bold", {"mi": "bold-italic", "mtext": None}),
MATHBB: font_factory("double-struck", {"fence": None}),
MATHBF: font_factory("bold", {"fence": None}),
MATHCAL: font_factory("script", {"fence": None}),
MATHFRAK: font_factory("fraktur", {"fence": None}),
MATHIT: font_factory("italic", {"fence": None}),
MATHRM: font_factory(None, {"mi": "normal"}),
MATHSCR: font_factory("script", {"fence": None}),
MATHSF: font_factory(None, {"mi": "sans-serif"}),
MATHTT: font_factory("monospace", {"fence": None}),
MIT: font_factory("italic", {"fence": None, "mi": None}),
OLDSTYLE: font_factory("normal", {"fence": None}),
SCR: font_factory("script", {"fence": None}),
TT: font_factory("monospace", {"fence": None}),
}
OLD_STYLE_FONTS: dict[str, defaultdict[str, Optional[str]]] = {
r"\rm": font_factory(None, {"mi": "normal"}),
r"\bf": font_factory(None, {"mi": "bold"}),
r"\it": font_factory(None, {"mi": "italic"}),
r"\sf": font_factory(None, {"mi": "sans-serif"}),
r"\tt": font_factory(None, {"mi": "monospace"}),
}
GLOBAL_FONTS = {
**OLD_STYLE_FONTS,
r"\cal": font_factory("script", {"fence": None}),
r"\frak": font_factory("fraktur", {"fence": None}),
}
COMMANDS_WITH_ONE_PARAMETER = (
ACUTE,
BAR,
BLACKBOARD_BOLD,
BOLD_SYMBOL,
BOXED,
BREVE,
CHECK,
DOT,
DDOT,
DDDOT,
DDDDOT,
GRAVE,
HAT,
HPHANTOM,
MATHRING,
MIT,
MOD,
OLDSTYLE,
OVERBRACE,
OVERLEFTARROW,
OVERLEFTRIGHTARROW,
OVERLINE,
OVERPAREN,
OVERRIGHTARROW,
PHANTOM,
PMOD,
SCR,
TILDE,
TT,
UNDERBRACE,
UNDERLEFTARROW,
UNDERLINE,
UNDERPAREN,
UNDERRIGHTARROW,
UNDERLEFTRIGHTARROW,
VEC,
VPHANTOM,
WIDEHAT,
WIDETILDE,
)
COMMANDS_WITH_TWO_PARAMETERS = (
BINOM,
CFRAC,
DBINOM,
DFRAC,
FRAC,
OVERSET,
TBINOM,
TFRAC,
UNDERSET,
)
BIG: dict[str, tuple[str, dict]] = {
# command: (mathml_equivalent, attributes)
r"\Bigg": ("mo", OrderedDict([("minsize", "2.470em"), ("maxsize", "2.470em")])),
r"\bigg": ("mo", OrderedDict([("minsize", "2.047em"), ("maxsize", "2.047em")])),
r"\Big": ("mo", OrderedDict([("minsize", "1.623em"), ("maxsize", "1.623em")])),
r"\big": ("mo", OrderedDict([("minsize", "1.2em"), ("maxsize", "1.2em")])),
}
BIG_OPEN_CLOSE = {
command + postfix: (tag, OrderedDict([("stretchy", "true"), ("fence", "true"), *attrib.items()]))
for command, (tag, attrib) in BIG.items()
for postfix in "lmr"
}
MSTYLE_SIZES: dict[str, tuple[str, dict]] = {
# command: (mathml_equivalent, attributes)
r"\Huge": ("mstyle", {"mathsize": "2.49em"}),
r"\huge": ("mstyle", {"mathsize": "2.07em"}),
r"\LARGE": ("mstyle", {"mathsize": "1.73em"}),
r"\Large": ("mstyle", {"mathsize": "1.44em"}),
r"\large": ("mstyle", {"mathsize": "1.2em"}),
r"\normalsize": ("mstyle", {"mathsize": "1em"}),
r"\scriptsize": ("mstyle", {"mathsize": "0.7em"}),
r"\small": ("mstyle", {"mathsize": "0.85em"}),
r"\tiny": ("mstyle", {"mathsize": "0.5em"}),
r"\Tiny": ("mstyle", {"mathsize": "0.6em"}),
}
STYLES: dict[str, tuple[str, dict]] = {
DISPLAYSTYLE: ("mstyle", {"displaystyle": "true", "scriptlevel": "0"}),
TEXTSTYLE: ("mstyle", {"displaystyle": "false", "scriptlevel": "0"}),
SCRIPTSTYLE: ("mstyle", {"displaystyle": "false", "scriptlevel": "1"}),
SCRIPTSCRIPTSTYLE: ("mstyle", {"displaystyle": "false", "scriptlevel": "2"}),
}
CONVERSION_MAP: dict[str, tuple[str, dict]] = {
# command: (mathml_equivalent, attributes)
# tables
**{matrix: ("mtable", {}) for matrix in MATRICES},
DISPLAYLINES: ("mtable", {"rowspacing": "0.5em", "columnspacing": "1em", "displaystyle": "true"}),
SMALLMATRIX: ("mtable", {"rowspacing": "0.1em", "columnspacing": "0.2778em"}),
SPLIT: (
"mtable",
{"displaystyle": "true", "columnspacing": "0em", "rowspacing": "3pt"},
),
ALIGN: (
"mtable",
{"displaystyle": "true", "rowspacing": "3pt"},
),
# subscripts/superscripts
SUBSCRIPT: ("msub", {}),
SUPERSCRIPT: ("msup", {}),
SUBSUP: ("msubsup", {}),
# fractions
BINOM: ("mfrac", {"linethickness": "0"}),
CFRAC: ("mfrac", {}),
DBINOM: ("mfrac", {"linethickness": "0"}),
DFRAC: ("mfrac", {}),
FRAC: ("mfrac", {}),
GENFRAC: ("mfrac", {}),
TBINOM: ("mfrac", {"linethickness": "0"}),
TFRAC: ("mfrac", {}),
# over/under
ACUTE: ("mover", {}),
BAR: ("mover", {}),
BREVE: ("mover", {}),
CHECK: ("mover", {}),
DOT: ("mover", {}),
DDOT: ("mover", {}),
DDDOT: ("mover", {}),
DDDDOT: ("mover", {}),
GRAVE: ("mover", {}),
HAT: ("mover", {}),
LIMITS: ("munderover", {}),
MATHRING: ("mover", {}),
OVERBRACE: ("mover", {}),
OVERLEFTARROW: ("mover", {}),
OVERLEFTRIGHTARROW: ("mover", {}),
OVERLINE: ("mover", {}),
OVERPAREN: ("mover", {}),
OVERRIGHTARROW: ("mover", {}),
TILDE: ("mover", {}),
OVERSET: ("mover", {}),
UNDERBRACE: ("munder", {}),
UNDERLEFTARROW: ("munder", {}),
UNDERLINE: ("munder", {}),
UNDERPAREN: ("munder", {}),
UNDERRIGHTARROW: ("munder", {}),
UNDERLEFTRIGHTARROW: ("munder", {}),
UNDERSET: ("munder", {}),
VEC: ("mover", {}),
WIDEHAT: ("mover", {}),
WIDETILDE: ("mover", {}),
# spaces
COLON: ("mspace", {"width": "0.222em"}),
COMMA: ("mspace", {"width": "0.167em"}),
DOUBLEBACKSLASH: ("mspace", {"linebreak": "newline"}),
ENSPACE: ("mspace", {"width": "0.5em"}),
EXCLAMATION: ("mspace", {"width": "negativethinmathspace"}),
GREATER_THAN: ("mspace", {"width": "0.222em"}),
HSKIP: ("mspace", {}),
HSPACE: ("mspace", {}),
KERN: ("mspace", {}),
MKERN: ("mspace", {}),
MSKIP: ("mspace", {}),
MSPACE: ("mspace", {}),
NEGTHINSPACE: ("mspace", {"width": "negativethinmathspace"}),
NEGMEDSPACE: ("mspace", {"width": "negativemediummathspace"}),
NEGTHICKSPACE: ("mspace", {"width": "negativethickmathspace"}),
THINSPACE: ("mspace", {"width": "thinmathspace"}),
QQUAD: ("mspace", {"width": "2em"}),
QUAD: ("mspace", {"width": "1em"}),
SEMICOLON: ("mspace", {"width": "0.278em"}),
# enclose
BOXED: ("menclose", {"notation": "box"}),
FBOX: ("menclose", {"notation": "box"}),
# operators
**BIG,
**BIG_OPEN_CLOSE,
**MSTYLE_SIZES,
**{limit: ("mo", {}) for limit in LIMIT},
LEFT: ("mo", OrderedDict([("stretchy", "true"), ("fence", "true"), ("form", "prefix")])),
MIDDLE: ("mo", OrderedDict([("stretchy", "true"), ("fence", "true"), ("lspace", "0.05em"), ("rspace", "0.05em")])),
RIGHT: ("mo", OrderedDict([("stretchy", "true"), ("fence", "true"), ("form", "postfix")])),
# styles
COLOR: ("mstyle", {}),
**STYLES,
# others
SQRT: ("msqrt", {}),
ROOT: ("mroot", {}),
HREF: ("mtext", {}),
TEXT: ("mtext", {}),
TEXTBF: ("mtext", {"mathvariant": "bold"}),
TEXTIT: ("mtext", {"mathvariant": "italic"}),
TEXTRM: ("mtext", {}),
TEXTSF: ("mtext", {"mathvariant": "sans-serif"}),
TEXTTT: ("mtext", {"mathvariant": "monospace"}),
HBOX: ("mtext", {}),
MBOX: ("mtext", {}),
HPHANTOM: ("mphantom", {}),
PHANTOM: ("mphantom", {}),
VPHANTOM: ("mphantom", {}),
SIDESET: ("mrow", {}),
SKEW: ("mrow", {}),
MOD: ("mi", {}),
PMOD: ("mi", {}),
BMOD: ("mo", {}),
XLEFTARROW: ("mover", {}),
XRIGHTARROW: ("mover", {}),
}
DIACRITICS: dict[str, tuple[str, dict[str, str]]] = {
ACUTE: ("´", {}),
BAR: ("¯", {"stretchy": "true"}),
BREVE: ("˘", {}),
CHECK: ("ˇ", {}),
DOT: ("˙", {}),
DDOT: ("¨", {}),
DDDOT: ("⃛", {}),
DDDDOT: ("⃜", {}),
GRAVE: ("`", {}),
HAT: ("^", {"stretchy": "false"}),
MATHRING: ("˚", {}),
OVERBRACE: ("⏞", {}),
OVERLEFTARROW: ("←", {}),
OVERLEFTRIGHTARROW: ("↔", {}),
OVERLINE: ("―", {"accent": "true"}),
OVERPAREN: ("⏜", {}),
OVERRIGHTARROW: ("→", {}),
TILDE: ("~", {"stretchy": "false"}),
UNDERBRACE: ("⏟", {}),
UNDERLEFTARROW: ("←", {}),
UNDERLEFTRIGHTARROW: ("↔", {}),
UNDERLINE: ("―", {"accent": "true"}),
UNDERPAREN: ("⏝", {}),
UNDERRIGHTARROW: ("→", {}),
VEC: ("→", {"stretchy": "true"}),
WIDEHAT: ("^", {}),
WIDETILDE: ("~", {}),
}
@@ -0,0 +1,595 @@
import copy
import enum
import re
from collections import OrderedDict
from typing import Iterable, Iterator, Optional
from xml.etree.cElementTree import Element, SubElement, tostring
from xml.sax.saxutils import unescape
from latex2mathml import commands
from latex2mathml.symbols_parser import convert_symbol
from latex2mathml.walker import Node, walk
COLUMN_ALIGNMENT_MAP = {"r": "right", "l": "left", "c": "center"}
OPERATORS = (
"+",
"-",
"*",
"/",
"(",
")",
"=",
",",
"?",
"[",
"]",
"|",
r"\|",
"!",
r"\{",
r"\}",
r">",
r"<",
r".",
r"\bigotimes",
r"\centerdot",
r"\dots",
r"\dotsc",
r"\dotso",
r"\gt",
r"\ldotp",
r"\lt",
r"\lvert",
r"\lVert",
r"\lvertneqq",
r"\ngeqq",
r"\omicron",
r"\rvert",
r"\rVert",
r"\S",
r"\smallfrown",
r"\smallint",
r"\smallsmile",
r"\surd",
r"\varsubsetneqq",
r"\varsupsetneqq",
)
MATH_MODE_PATTERN = re.compile(r"\\\$|\$|\\?[^\\$]+")
class Mode(enum.Enum):
TEXT = enum.auto()
MATH = enum.auto()
def convert(
latex: str,
xmlns: str = "http://www.w3.org/1998/Math/MathML",
display: str = "inline",
parent: Optional[Element] = None,
) -> str:
math = convert_to_element(latex, xmlns, display, parent)
return _convert(math)
def convert_to_element(
latex: str,
xmlns: str = "http://www.w3.org/1998/Math/MathML",
display: str = "inline",
parent: Optional[Element] = None,
) -> Element:
tag = "math"
attrib = {"xmlns": xmlns, "display": display}
math = Element(tag, attrib) if parent is None else SubElement(parent, tag, attrib)
row = SubElement(math, "mrow")
_convert_group(iter(walk(latex, display)), row)
return math
def _convert(tree: Element) -> str:
return unescape(tostring(tree, encoding="unicode"))
def _convert_matrix(nodes: Iterator[Node], parent: Element, command: str, alignment: Optional[str] = None) -> None:
row = None
cell = None
col_index = 0
col_alignment = None
max_col_size = 0
row_index = 0
row_lines = []
hfil_indexes: list[bool] = []
for node in nodes:
if row is None:
row = SubElement(parent, "mtr")
if cell is None:
col_alignment, col_index = _get_column_alignment(alignment, col_alignment, col_index)
cell = _make_matrix_cell(row, col_alignment)
if node.token == commands.BRACES:
_convert_group(iter([node]), cell)
elif node.token == "&":
_set_cell_alignment(cell, hfil_indexes)
hfil_indexes = []
col_alignment, col_index = _get_column_alignment(alignment, col_alignment, col_index)
cell = _make_matrix_cell(row, col_alignment)
if command in (commands.SPLIT, commands.ALIGN) and col_index % 2 == 0:
SubElement(cell, "mi")
elif node.token in (commands.DOUBLEBACKSLASH, commands.CARRIAGE_RETURN):
_set_cell_alignment(cell, hfil_indexes)
hfil_indexes = []
row_index += 1
if col_index > max_col_size:
max_col_size = col_index
col_index = 0
col_alignment, col_index = _get_column_alignment(alignment, col_alignment, col_index)
row = SubElement(parent, "mtr")
cell = _make_matrix_cell(row, col_alignment)
elif node.token == commands.HLINE:
row_lines.append("solid")
elif node.token == commands.HDASHLINE:
row_lines.append("dashed")
elif node.token == commands.HFIL:
hfil_indexes.append(True)
else:
if row_index > len(row_lines):
row_lines.append("none")
hfil_indexes.append(False)
_convert_group(iter([node]), cell)
if col_index > max_col_size:
max_col_size = col_index
if any(r == "solid" for r in row_lines):
parent.set("rowlines", " ".join(row_lines))
if row is not None and cell is not None and len(cell) == 0:
# Remove last row if it does not contain anything
parent.remove(row)
if max_col_size and command == commands.ALIGN:
spacing = ("0em", "2em")
multiplier = max_col_size // len(spacing)
parent.set("columnspacing", " ".join(spacing * multiplier))
def _set_cell_alignment(cell: Element, hfil_indexes: list[bool]) -> None:
if cell is not None and any(hfil_indexes) and len(hfil_indexes) > 1:
if hfil_indexes[0] and not hfil_indexes[-1]:
cell.attrib["columnalign"] = "right"
elif not hfil_indexes[0] and hfil_indexes[-1]:
cell.attrib["columnalign"] = "left"
def _get_column_alignment(
alignment: Optional[str], column_alignment: Optional[str], column_index: int
) -> tuple[Optional[str], int]:
if alignment:
try:
column_alignment = COLUMN_ALIGNMENT_MAP.get(alignment[column_index])
except IndexError:
column_alignment = COLUMN_ALIGNMENT_MAP.get(alignment[column_index % len(alignment)])
column_index += 1
return column_alignment, column_index
def _make_matrix_cell(row: Element, column_alignment: Optional[str]) -> Element:
if column_alignment:
return SubElement(row, "mtd", columnalign=column_alignment)
return SubElement(row, "mtd")
def _convert_group(nodes: Iterable[Node], parent: Element, font: Optional[dict[str, Optional[str]]] = None) -> None:
_font = font
for node in nodes:
token = node.token
if token in (*commands.MSTYLE_SIZES, *commands.STYLES):
node = Node(token=token, children=tuple(n for n in nodes))
_convert_command(node, parent, _font)
elif token in commands.CONVERSION_MAP or token in (commands.MOD, commands.PMOD):
_convert_command(node, parent, _font)
elif token in commands.LOCAL_FONTS and node.children is not None:
_convert_group(iter(node.children), parent, commands.LOCAL_FONTS[token])
elif token.startswith(commands.MATH) and node.children is not None:
_convert_group(iter(node.children), parent, _font)
elif token in commands.GLOBAL_FONTS.keys():
_font = commands.GLOBAL_FONTS.get(token)
elif node.children is None:
_convert_symbol(node, parent, _font)
elif node.children is not None:
attributes = node.attributes or {}
_row = SubElement(parent, "mrow", attrib=attributes)
_convert_group(iter(node.children), _row, _font)
def _get_alignment_and_column_lines(alignment: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
if alignment is None:
return None, None
if "|" not in alignment:
return alignment, None
_alignment = ""
column_lines = []
for c in alignment:
if c == "|":
column_lines.append("solid")
else:
_alignment += c
if len(_alignment) - len(column_lines) == 2:
column_lines.append("none")
return _alignment, " ".join(column_lines)
def separate_by_mode(text: str) -> Iterator[tuple[str, Mode]]:
string = ""
is_math_mode = False
for match in MATH_MODE_PATTERN.findall(text):
if match == "$": # should match both $ and $$
yield string, Mode.MATH if is_math_mode else Mode.TEXT
string = ""
is_math_mode = not is_math_mode
else:
string += match
if len(string):
yield string, Mode.MATH if is_math_mode else Mode.TEXT
# TODO: if stays in math mode, means not terminated properly, raise error
def _convert_command(node: Node, parent: Element, font: Optional[dict[str, Optional[str]]] = None) -> None:
command = node.token
modifier = node.modifier
if command in (commands.SUBSTACK, commands.SMALLMATRIX):
parent = SubElement(parent, "mstyle", scriptlevel="1")
elif command == commands.CASES:
parent = SubElement(parent, "mrow")
lbrace = SubElement(parent, "mo", OrderedDict([("stretchy", "true"), ("fence", "true"), ("form", "prefix")]))
lbrace.text = "&#x{};".format(convert_symbol(commands.LBRACE))
elif command in (commands.DBINOM, commands.DFRAC):
parent = SubElement(parent, "mstyle", displaystyle="true", scriptlevel="0")
elif command == commands.HPHANTOM:
parent = SubElement(parent, "mpadded", height="0", depth="0")
elif command == commands.VPHANTOM:
parent = SubElement(parent, "mpadded", width="0")
elif command in (commands.TBINOM, commands.HBOX, commands.MBOX, commands.TFRAC):
parent = SubElement(parent, "mstyle", displaystyle="false", scriptlevel="0")
elif command in (commands.MOD, commands.PMOD):
SubElement(parent, "mspace", width="1em")
tag, attributes = copy.deepcopy(commands.CONVERSION_MAP[command])
if node.attributes is not None and node.token != commands.SKEW:
attributes.update(node.attributes)
if command == commands.LEFT:
parent = SubElement(parent, "mrow")
_append_prefix_element(node, parent)
alignment, column_lines = _get_alignment_and_column_lines(node.alignment)
if column_lines:
attributes["columnlines"] = column_lines
if command == commands.SUBSUP and node.children is not None and node.children[0].token == commands.GCD:
tag = "munderover"
elif command == commands.SUPERSCRIPT and modifier in (commands.LIMITS, commands.OVERBRACE):
tag = "mover"
elif command == commands.SUBSCRIPT and modifier in (commands.LIMITS, commands.UNDERBRACE):
tag = "munder"
elif command == commands.SUBSUP and modifier in (commands.LIMITS, commands.OVERBRACE, commands.UNDERBRACE):
tag = "munderover"
elif (
command in (commands.XLEFTARROW, commands.XRIGHTARROW) and node.children is not None and len(node.children) == 2
):
tag = "munderover"
element = SubElement(parent, tag, attributes)
if command in commands.LIMIT:
element.text = command[1:]
elif command in (commands.MOD, commands.PMOD):
element.text = "mod"
SubElement(parent, "mspace", width="0.333em")
elif command == commands.BMOD:
element.text = "mod"
elif command in (commands.XLEFTARROW, commands.XRIGHTARROW):
style = SubElement(element, "mstyle", scriptlevel="0")
arrow = SubElement(style, "mo")
if command == commands.XLEFTARROW:
arrow.text = "&#x2190;"
elif command == commands.XRIGHTARROW:
arrow.text = "&#x2192;"
elif node.text is not None:
if command == commands.MIDDLE:
element.text = "&#x{};".format(convert_symbol(node.text))
elif command == commands.HBOX:
mtext: Optional[Element] = element
for text, mode in separate_by_mode(node.text):
if mode == Mode.TEXT:
if mtext is None:
mtext = SubElement(parent, tag, attributes)
mtext.text = text.replace(" ", "&#x000A0;")
_set_font(mtext, "mtext", font)
mtext = None
else:
_row = SubElement(parent, "mrow")
_convert_group(iter(walk(text)), _row)
else:
if command == commands.FBOX:
element = SubElement(element, "mtext")
element.text = node.text.replace(" ", "&#x000A0;")
_set_font(element, "mtext", font)
elif node.delimiter is not None and command not in (commands.FRAC, commands.GENFRAC):
if node.delimiter != ".":
symbol = convert_symbol(node.delimiter)
element.text = node.delimiter if symbol is None else "&#x{};".format(symbol)
if node.children is not None:
_parent = element
if command in (commands.LEFT, commands.MOD, commands.PMOD):
_parent = parent
if command in commands.MATRICES:
if command == commands.CASES:
alignment = "l"
elif command in (commands.SPLIT, commands.ALIGN):
alignment = "rl"
_convert_matrix(iter(node.children), _parent, command, alignment=alignment)
elif command == commands.CFRAC:
for child in node.children:
p = SubElement(_parent, "mstyle", displaystyle="false", scriptlevel="0")
_convert_group(iter([child]), p, font)
elif command == commands.SIDESET:
Node(
r"\style",
children=(Node(r"\mspace", attributes={"width": "-0.167em"}),),
attributes={"scriptlevel": "0"},
),
left, right = node.children
_convert_group(iter([left]), _parent, font)
fill = SubElement(_parent, "mstyle", scriptlevel="0")
SubElement(fill, "mspace", width="-0.167em")
_convert_group(iter([right]), _parent, font)
elif command == commands.SKEW:
child = node.children[0]
new_node = Node(
token=child.token,
children=(
Node(
token=commands.BRACES,
children=(*child.children, Node(token=commands.MKERN, attributes=node.attributes)),
),
),
)
_convert_group(iter([new_node]), _parent, font)
elif command in (commands.XLEFTARROW, commands.XRIGHTARROW):
for child in node.children:
padded = SubElement(
_parent,
"mpadded",
OrderedDict(
[("width", "+0.833em"), ("lspace", "0.556em"), ("voffset", "-.2em"), ("height", "-.2em")]
),
)
_convert_group(iter([child]), padded, font)
SubElement(padded, "mspace", depth=".25em")
else:
_convert_group(iter(node.children), _parent, font)
_add_diacritic(command, element)
_append_postfix_element(node, parent)
def _add_diacritic(command: str, parent: Element) -> None:
if command in commands.DIACRITICS:
text, attributes = copy.deepcopy(commands.DIACRITICS[command])
element = SubElement(parent, "mo", attributes)
element.text = text
def _convert_and_append_command(command: str, parent: Element, attributes: Optional[dict[str, str]] = None) -> None:
code_point = convert_symbol(command)
mo = SubElement(parent, "mo", attributes if attributes is not None else {})
mo.text = "&#x{};".format(code_point) if code_point else command
def _append_prefix_element(node: Node, parent: Element) -> None:
size = "2.047em"
if parent.attrib.get("displaystyle") == "false" or node.token == commands.TBINOM:
size = "1.2em"
if node.token in (r"\pmatrix", commands.PMOD):
_convert_and_append_command(r"\lparen", parent)
elif node.token in (commands.BINOM, commands.DBINOM, commands.TBINOM):
_convert_and_append_command(r"\lparen", parent, {"minsize": size, "maxsize": size})
elif node.token == r"\bmatrix":
_convert_and_append_command(r"\lbrack", parent)
elif node.token == r"\Bmatrix":
_convert_and_append_command(r"\lbrace", parent)
elif node.token == r"\vmatrix":
_convert_and_append_command(r"\vert", parent)
elif node.token == r"\Vmatrix":
_convert_and_append_command(r"\Vert", parent)
elif node.token in (commands.FRAC, commands.GENFRAC) and node.delimiter is not None and node.delimiter[0] != ".":
# TODO: use 1.2em if inline
_convert_and_append_command(node.delimiter[0], parent, {"minsize": size, "maxsize": size})
def _append_postfix_element(node: Node, parent: Element) -> None:
size = "2.047em"
if parent.attrib.get("displaystyle") == "false" or node.token == commands.TBINOM:
size = "1.2em"
if node.token in (r"\pmatrix", commands.PMOD):
_convert_and_append_command(r"\rparen", parent)
elif node.token in (commands.BINOM, commands.DBINOM, commands.TBINOM):
_convert_and_append_command(r"\rparen", parent, {"minsize": size, "maxsize": size})
elif node.token == r"\bmatrix":
_convert_and_append_command(r"\rbrack", parent)
elif node.token == r"\Bmatrix":
_convert_and_append_command(r"\rbrace", parent)
elif node.token == r"\vmatrix":
_convert_and_append_command(r"\vert", parent)
elif node.token == r"\Vmatrix":
_convert_and_append_command(r"\Vert", parent)
elif node.token in (commands.FRAC, commands.GENFRAC) and node.delimiter is not None and node.delimiter[1] != ".":
# TODO: use 1.2em if inline
_convert_and_append_command(node.delimiter[1], parent, {"minsize": size, "maxsize": size})
elif node.token == commands.SKEW and node.attributes is not None:
SubElement(parent, "mspace", width="-" + node.attributes["width"])
def _convert_symbol(node: Node, parent: Element, font: Optional[dict[str, Optional[str]]] = None) -> None:
token = node.token
attributes = node.attributes or {}
symbol = convert_symbol(token)
if re.match(r"\d+(.\d+)?", token):
element = SubElement(parent, "mn", attrib=attributes)
element.text = token
_set_font(element, element.tag, font)
elif token in OPERATORS:
element = SubElement(parent, "mo", attrib=attributes)
element.text = token if symbol is None else "&#x{};".format(symbol)
if token == r"\|":
element.attrib["fence"] = "false"
if token == r"\smallint":
element.attrib["largeop"] = "false"
if token in ("(", ")", "[", "]", "|", r"\|", r"\{", r"\}", r"\surd"):
element.attrib["stretchy"] = "false"
_set_font(element, "fence", font)
else:
_set_font(element, element.tag, font)
elif (
symbol
and (
int(symbol, 16) in range(int("2200", 16), int("22FF", 16) + 1)
or int(symbol, 16) in range(int("2190", 16), int("21FF", 16) + 1)
)
or symbol == "."
):
element = SubElement(parent, "mo", attrib=attributes)
element.text = "&#x{};".format(symbol)
_set_font(element, element.tag, font)
elif token in (r"\ ", "~", commands.NOBREAKSPACE, commands.SPACE):
element = SubElement(parent, "mtext", attrib=attributes)
element.text = "&#x000A0;"
_set_font(element, "mtext", font)
elif token == commands.NOT:
mpadded = SubElement(parent, "mpadded", width="0")
element = SubElement(mpadded, "mtext")
element.text = "&#x029F8;"
elif token in (
commands.DETERMINANT,
commands.GCD,
commands.INTOP,
commands.INJLIM,
commands.LIMINF,
commands.LIMSUP,
commands.PR,
commands.PROJLIM,
):
element = SubElement(parent, "mo", attrib={"movablelimits": "true", **attributes})
texts = {
commands.INJLIM: "inj&#x02006;lim",
commands.INTOP: "&#x0222B;",
commands.LIMINF: "lim&#x02006;inf",
commands.LIMSUP: "lim&#x02006;sup",
commands.PROJLIM: "proj&#x02006;lim",
}
element.text = texts.get(token, token[1:])
_set_font(element, element.tag, font)
elif token == commands.IDOTSINT:
_parent = SubElement(parent, "mrow", attrib=attributes)
for s in ("&#x0222B;", "&#x022EF;", "&#x0222B;"):
element = SubElement(_parent, "mo")
element.text = s
elif token in (commands.LATEX, commands.TEX):
_parent = SubElement(parent, "mrow", attrib=attributes)
if token == commands.LATEX:
mi_l = SubElement(_parent, "mi")
mi_l.text = "L"
SubElement(_parent, "mspace", width="-.325em")
mpadded = SubElement(_parent, "mpadded", height="+.21ex", depth="-.21ex", voffset="+.21ex")
mstyle = SubElement(mpadded, "mstyle", displaystyle="false", scriptlevel="1")
mrow = SubElement(mstyle, "mrow")
mi_a = SubElement(mrow, "mi")
mi_a.text = "A"
SubElement(_parent, "mspace", width="-.17em")
_set_font(mi_l, mi_l.tag, font)
_set_font(mi_a, mi_a.tag, font)
mi_t = SubElement(_parent, "mi")
mi_t.text = "T"
SubElement(_parent, "mspace", width="-.14em")
mpadded = SubElement(_parent, "mpadded", height="-.5ex", depth="+.5ex", voffset="-.5ex")
mrow = SubElement(mpadded, "mrow")
mi_e = SubElement(mrow, "mi")
mi_e.text = "E"
SubElement(_parent, "mspace", width="-.115em")
mi_x = SubElement(_parent, "mi")
mi_x.text = "X"
_set_font(mi_t, mi_t.tag, font)
_set_font(mi_e, mi_e.tag, font)
_set_font(mi_x, mi_x.tag, font)
elif token.startswith(commands.OPERATORNAME):
element = SubElement(parent, "mo", attrib=attributes)
element.text = token[14:-1]
elif token.startswith(commands.BACKSLASH):
element = SubElement(parent, "mi", attrib=attributes)
if symbol:
element.text = "&#x{};".format(symbol)
elif token in commands.FUNCTIONS:
element.text = token[1:]
else:
element.text = token
_set_font(element, element.tag, font)
else:
element = SubElement(parent, "mi", attrib=attributes)
element.text = token
_set_font(element, element.tag, font)
def _set_font(element: Element, key: str, font: Optional[dict[str, Optional[str]]]) -> None:
if font is None:
return
_font = font[key]
if _font is not None:
element.attrib["mathvariant"] = _font
def main() -> None: # pragma: no cover
import argparse
import sys
parser = argparse.ArgumentParser(description="Pure Python library for LaTeX to MathML conversion")
parser.add_argument("-V", "--version", dest="version", action="store_true", required=False, help="Show version")
parser.add_argument("-b", "--block", dest="block", action="store_true", required=False, help="Display block")
required = parser.add_argument_group("required arguments")
group = required.add_mutually_exclusive_group(required=False)
group.add_argument("-t", "--text", dest="text", type=str, required=False, help="Text")
group.add_argument("-f", "--file", dest="file", type=str, required=False, help="File")
group.add_argument("-s", "--stdin", dest="stdin", action="store_true", required=False, help="Stdin")
arguments = parser.parse_args()
display = "block" if arguments.block else "inline"
if arguments.version:
import latex2mathml
print("latex2mathml", latex2mathml.__version__)
elif arguments.text:
print(convert(arguments.text, display=display))
elif arguments.file:
with open(arguments.file) as f:
print(convert(f.read(), display=display))
elif arguments.stdin:
print(convert(sys.stdin.read(), display=display))
if __name__ == "__main__": # pragma: no cover
main()
@@ -0,0 +1,46 @@
class NumeratorNotFoundError(Exception):
pass
class DenominatorNotFoundError(Exception):
pass
class ExtraLeftOrMissingRightError(Exception):
pass
class MissingSuperScriptOrSubscriptError(Exception):
pass
class DoubleSubscriptsError(Exception):
pass
class DoubleSuperscriptsError(Exception):
pass
class NoAvailableTokensError(Exception):
pass
class InvalidStyleForGenfracError(Exception):
pass
class MissingEndError(Exception):
pass
class InvalidAlignmentError(Exception):
pass
class InvalidWidthError(Exception):
pass
class LimitsMustFollowMathOperatorError(Exception):
pass
@@ -0,0 +1 @@
# PEP 581
@@ -0,0 +1,78 @@
import codecs
import os
import re
from typing import Optional, Union
SYMBOLS_FILE: str = os.path.join(os.path.dirname(os.path.realpath(__file__)), "unimathsymbols.txt")
SYMBOLS: Optional[dict[str, str]] = None
def convert_symbol(symbol: str) -> Union[str, None]:
global SYMBOLS
if not SYMBOLS:
SYMBOLS = parse_symbols()
return SYMBOLS.get(symbol, None)
def parse_symbols() -> dict[str, str]:
_symbols: dict[str, str] = {}
with codecs.open(SYMBOLS_FILE, encoding="utf-8") as f:
for line in f:
if line.startswith("#"):
continue
columns = line.strip().split("^")
_unicode = columns[0]
latex = columns[2]
unicode_math = columns[3]
if latex and latex not in _symbols:
_symbols[latex] = _unicode
if unicode_math and unicode_math not in _symbols:
_symbols[unicode_math] = _unicode
for equivalent in re.findall(r"[=#]\s*(\\[^,^ ]+),?", columns[-1]):
if equivalent not in _symbols:
_symbols[equivalent] = _unicode
_symbols.update(
{
r"\And": _symbols[r"\ampersand"],
r"\bigcirc": _symbols[r"\lgwhtcircle"],
r"\Box": _symbols[r"\square"],
r"\circledS": "024C8",
r"\diagdown": "02572",
r"\diagup": "02571",
r"\dots": "02026",
r"\dotsb": _symbols[r"\cdots"],
r"\dotsc": "02026",
r"\dotsi": _symbols[r"\cdots"],
r"\dotsm": _symbols[r"\cdots"],
r"\dotso": "02026",
r"\emptyset": "02205",
r"\gggtr": "022D9",
r"\gvertneqq": "02269",
r"\gt": _symbols[r"\greater"],
r"\ldotp": _symbols[r"\period"],
r"\llless": _symbols[r"\lll"],
r"\lt": _symbols[r"\less"],
r"\lvert": _symbols[r"\vert"],
r"\lVert": _symbols[r"\Vert"],
r"\lvertneqq": _symbols[r"\lneqq"],
r"\ngeqq": _symbols[r"\ngeq"],
r"\nshortmid": _symbols[r"\nmid"],
r"\nshortparallel": _symbols[r"\nparallel"],
r"\nsubseteqq": _symbols[r"\nsubseteq"],
r"\omicron": _symbols[r"\upomicron"],
r"\rvert": _symbols[r"\vert"],
r"\rVert": _symbols[r"\Vert"],
r"\shortmid": _symbols[r"\mid"],
r"\smallfrown": _symbols[r"\frown"],
r"\smallint": "0222B",
r"\smallsmile": _symbols[r"\smile"],
r"\surd": _symbols[r"\sqrt"],
r"\thicksim": "0223C",
r"\thickapprox": _symbols[r"\approx"],
r"\varsubsetneqq": _symbols[r"\subsetneqq"],
r"\varsupsetneq": "0228B",
r"\varsupsetneqq": _symbols[r"\supsetneqq"],
}
)
del _symbols[r"\mathring"] # FIXME: improve tokenizer without removing this
return _symbols
@@ -0,0 +1,55 @@
import re
from typing import Iterator
from latex2mathml import commands
from latex2mathml.symbols_parser import convert_symbol
UNITS = ("in", "mm", "cm", "pt", "em", "ex", "pc", "bp", "dd", "cc", "sp", "mu")
PATTERN = re.compile(
rf"""
(%[^\n]+) | # comment
(a-zA-Z) | # letter
([_^])(\d) | # number succeeding an underscore or a caret
(-?\d+(?:\.\d+)?\s*(?:{'|'.join(UNITS)})) | # dimension
(\d+(?:\.\d+)?) | # integer/decimal
(\.\d*) | # dot (.) or decimal can start with just a dot
(\\[\\\[\]{{}}\s!,:>;|_%#$&]) | # escaped characters
(\\(?:begin|end)\s*{{[a-zA-Z]+\*?}}) | # begin or end
(\\operatorname\s*{{[a-zA-Z\s*]+\*?\s*}}) | # operatorname
# color, fbox, href, hbox, mbox, style, text, textbf, textit, textrm, textsf, texttt
(\\(?:color|fbox|hbox|href|mbox|style|text|textbf|textit|textrm|textsf|texttt))\s*{{([^}}]*)}} |
(\\[cdt]?frac)\s*([.\d])\s*([.\d])? | # fractions
(\\math[a-z]+)({{)([a-zA-Z])(}}) | # commands starting with math
(\\[a-zA-Z]+) | # other commands
(\S) # non-space character
""",
re.VERBOSE,
)
def tokenize(latex_string: str, skip_comments: bool = True) -> Iterator[str]:
"""
Converts Latex string into tokens.
:param latex_string: Latex string.
:param skip_comments: Flag to skip comments (default=True).
"""
for match in PATTERN.finditer(latex_string):
tokens = tuple(filter(lambda x: x is not None, match.groups()))
if tokens[0].startswith(commands.MATH):
full_math = "".join(tokens)
symbol = convert_symbol(full_math)
if symbol:
yield f"&#x{symbol};"
continue
for captured in tokens:
if skip_comments and captured.startswith("%"):
break
if captured.endswith(UNITS):
yield captured.replace(" ", "")
continue
if captured.startswith((commands.BEGIN, commands.END, commands.OPERATORNAME)):
yield "".join(captured.split(" "))
continue
yield captured
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
from typing import Any, Iterator, NamedTuple, Optional
from latex2mathml import commands
from latex2mathml.exceptions import (
DenominatorNotFoundError,
DoubleSubscriptsError,
DoubleSuperscriptsError,
ExtraLeftOrMissingRightError,
InvalidAlignmentError,
InvalidStyleForGenfracError,
InvalidWidthError,
LimitsMustFollowMathOperatorError,
MissingEndError,
MissingSuperScriptOrSubscriptError,
NoAvailableTokensError,
NumeratorNotFoundError,
)
from latex2mathml.symbols_parser import convert_symbol
from latex2mathml.tokenizer import tokenize
class Node(NamedTuple):
token: str
children: Optional[tuple[Any, ...]] = None
delimiter: Optional[str] = None
alignment: Optional[str] = None
text: Optional[str] = None
attributes: Optional[dict[str, str]] = None
modifier: Optional[str] = None
def walk(data: str, display: str = "inline") -> list[Node]:
tokens = tokenize(data)
block = display == "block"
return _walk(tokens, block=block)
def _walk(tokens: Iterator[str], terminator: Optional[str] = None, limit: int = 0, block: bool = False) -> list[Node]:
group: list[Node] = []
token: str
has_available_tokens = False
for token in tokens:
has_available_tokens = True
if token == terminator:
delimiter = None
if terminator == commands.RIGHT:
delimiter = next(tokens)
group.append(Node(token=token, delimiter=delimiter))
break
elif (token == commands.RIGHT != terminator) or (token == commands.MIDDLE and terminator != commands.RIGHT):
raise ExtraLeftOrMissingRightError
elif token == commands.LEFT:
delimiter = next(tokens)
children = tuple(_walk(tokens, terminator=commands.RIGHT)) # make \right as a child of \left
if len(children) == 0 or children[-1].token != commands.RIGHT:
raise ExtraLeftOrMissingRightError
node = Node(token=token, children=children if len(children) else None, delimiter=delimiter)
elif token == commands.OPENING_BRACE:
children = tuple(_walk(tokens, terminator=commands.CLOSING_BRACE))
if len(children) and children[-1].token == commands.CLOSING_BRACE:
children = children[:-1]
node = Node(token=commands.BRACES, children=children)
elif token in (commands.SUBSCRIPT, commands.SUPERSCRIPT):
try:
previous = group.pop()
except IndexError:
previous = Node(token="") # left operand can be empty if not present
if token == previous.token == commands.SUBSCRIPT:
raise DoubleSubscriptsError
if (token == previous.token == commands.SUPERSCRIPT) and (
previous.children is not None
and len(previous.children) >= 2
and previous.children[1].token != commands.PRIME
):
raise DoubleSuperscriptsError
modifier = None
if previous.token == commands.LIMITS:
modifier = commands.LIMITS
try:
previous = group.pop()
if not previous.token.startswith("\\"): # TODO: Complete list of operators
raise LimitsMustFollowMathOperatorError
except IndexError:
raise LimitsMustFollowMathOperatorError
elif block and previous.token in (commands.SUMMATION, commands.PRODUCT):
# block summation and product should result in limited sub/sup
modifier = commands.LIMITS
if token == commands.SUBSCRIPT and previous.token == commands.SUPERSCRIPT and previous.children is not None:
children = tuple(_walk(tokens, terminator=terminator, limit=1))
node = Node(
token=commands.SUBSUP,
children=(previous.children[0], *children, previous.children[1]),
modifier=previous.modifier,
)
elif (
token == commands.SUPERSCRIPT and previous.token == commands.SUBSCRIPT and previous.children is not None
):
children = tuple(_walk(tokens, terminator=terminator, limit=1))
node = Node(token=commands.SUBSUP, children=(*previous.children, *children), modifier=previous.modifier)
elif (
token == commands.SUPERSCRIPT
and previous.token == commands.SUPERSCRIPT
and previous.children is not None
and previous.children[1].token == commands.PRIME
):
children = tuple(_walk(tokens, terminator=terminator, limit=1))
node = Node(
token=commands.SUPERSCRIPT,
children=(
previous.children[0],
Node(token=commands.BRACES, children=(previous.children[1], *children)),
),
modifier=previous.modifier,
)
else:
try:
children = tuple(_walk(tokens, terminator=terminator, limit=1))
except NoAvailableTokensError:
raise MissingSuperScriptOrSubscriptError
if previous.token in (commands.OVERBRACE, commands.UNDERBRACE):
modifier = previous.token
node = Node(token=token, children=(previous, *children), modifier=modifier)
elif token == commands.APOSTROPHE:
try:
previous = group.pop()
except IndexError:
previous = Node(token="") # left operand can be empty if not present
if (
previous.token == commands.SUPERSCRIPT
and previous.children is not None
and len(previous.children) >= 2
and previous.children[1].token != commands.PRIME
):
raise DoubleSuperscriptsError
if (
previous.token == commands.SUPERSCRIPT
and previous.children is not None
and len(previous.children) >= 2
and previous.children[1].token == commands.PRIME
):
node = Node(token=commands.SUPERSCRIPT, children=(previous.children[0], Node(token=commands.DPRIME)))
elif previous.token == commands.SUBSCRIPT and previous.children is not None:
node = Node(
token=commands.SUBSUP,
children=(*previous.children, Node(token=commands.PRIME)),
modifier=previous.modifier,
)
else:
node = Node(token=commands.SUPERSCRIPT, children=(previous, Node(token=commands.PRIME)))
elif token in commands.COMMANDS_WITH_TWO_PARAMETERS:
attributes = None
children = tuple(_walk(tokens, terminator=terminator, limit=2))
if token in (commands.OVERSET, commands.UNDERSET):
children = children[::-1]
node = Node(token=token, children=children, attributes=attributes)
elif token in commands.COMMANDS_WITH_ONE_PARAMETER or token.startswith(commands.MATH):
children = tuple(_walk(tokens, terminator=terminator, limit=1))
node = Node(token=token, children=children)
elif token == commands.NOT:
try:
next_node = tuple(_walk(tokens, terminator=terminator, limit=1))[0]
if next_node.token.startswith("\\"):
negated_symbol = r"\n" + next_node.token[1:]
symbol = convert_symbol(negated_symbol)
if symbol:
node = Node(token=negated_symbol)
group.append(node)
continue
node = Node(token=token)
group.extend((node, next_node))
continue
except NoAvailableTokensError:
node = Node(token=token)
elif token in (commands.XLEFTARROW, commands.XRIGHTARROW):
children = tuple(_walk(tokens, terminator=terminator, limit=1))
if children[0].token == commands.OPENING_BRACKET:
children = (
Node(
token=commands.BRACES, children=tuple(_walk(tokens, terminator=commands.CLOSING_BRACKET))[:-1]
),
*tuple(_walk(tokens, terminator=terminator, limit=1)),
)
node = Node(token=token, children=children)
elif token in (commands.HSKIP, commands.HSPACE, commands.KERN, commands.MKERN, commands.MSKIP, commands.MSPACE):
children = tuple(_walk(tokens, terminator=terminator, limit=1))
if children[0].token == commands.BRACES and children[0].children is not None:
children = children[0].children
node = Node(token=token, attributes={"width": children[0].token})
elif token == commands.COLOR:
attributes = {"mathcolor": next(tokens)}
children = tuple(_walk(tokens, terminator=terminator))
sibling = None
if len(children) and children[-1].token == terminator:
children, sibling = children[:-1], children[-1]
group.append(Node(token=token, children=children, attributes=attributes))
if sibling:
group.append(sibling)
break
elif token == commands.STYLE:
attributes = {"style": next(tokens)}
next_node = tuple(_walk(tokens, terminator=terminator, limit=1))[0]
node = next_node._replace(attributes=attributes)
elif token in (
*commands.BIG.keys(),
*commands.BIG_OPEN_CLOSE.keys(),
commands.FBOX,
commands.HBOX,
commands.MBOX,
commands.MIDDLE,
commands.TEXT,
commands.TEXTBF,
commands.TEXTIT,
commands.TEXTRM,
commands.TEXTSF,
commands.TEXTTT,
):
node = Node(token=token, text=next(tokens))
elif token == commands.HREF:
attributes = {"href": next(tokens)}
children = tuple(_walk(tokens, terminator=terminator, limit=1))
node = Node(token=token, children=children, attributes=attributes)
elif token in (
commands.ABOVE,
commands.ATOP,
commands.ABOVEWITHDELIMS,
commands.ATOPWITHDELIMS,
commands.BRACE,
commands.BRACK,
commands.CHOOSE,
commands.OVER,
):
attributes = None
delimiter = None
if token == commands.ABOVEWITHDELIMS:
delimiter = next(tokens).lstrip("\\") + next(tokens).lstrip("\\")
elif token == commands.ATOPWITHDELIMS:
attributes = {"linethickness": "0"}
delimiter = next(tokens).lstrip("\\") + next(tokens).lstrip("\\")
elif token == commands.BRACE:
delimiter = "{}"
elif token == commands.BRACK:
delimiter = "[]"
elif token == commands.CHOOSE:
delimiter = "()"
if token in (commands.ABOVE, commands.ABOVEWITHDELIMS):
dimension_node = tuple(_walk(tokens, terminator=terminator, limit=1))[0]
dimension = _get_dimension(dimension_node)
attributes = {"linethickness": dimension}
elif token in (commands.ATOP, commands.BRACE, commands.BRACK, commands.CHOOSE):
attributes = {"linethickness": "0"}
denominator = tuple(_walk(tokens, terminator=terminator))
sibling = None
if len(denominator) and denominator[-1].token == terminator:
denominator, sibling = denominator[:-1], denominator[-1]
if len(denominator) == 0:
if token in (commands.BRACE, commands.BRACK):
denominator = (Node(token=commands.BRACES, children=()),)
else:
raise DenominatorNotFoundError
if len(group) == 0:
if token in (commands.BRACE, commands.BRACK):
group = [Node(token=commands.BRACES, children=())]
else:
raise NumeratorNotFoundError
if len(denominator) > 1:
denominator = (Node(token=commands.BRACES, children=denominator),)
if len(group) == 1:
children = (*group, *denominator)
else:
children = (Node(token=commands.BRACES, children=tuple(group)), *denominator)
group = [Node(token=commands.FRAC, children=children, attributes=attributes, delimiter=delimiter)]
if sibling is not None:
group.append(sibling)
break
elif token == commands.SQRT:
root_nodes = None
next_node = tuple(_walk(tokens, limit=1))[0]
if next_node.token == commands.OPENING_BRACKET:
root_nodes = tuple(_walk(tokens, terminator=commands.CLOSING_BRACKET))[:-1]
next_node = tuple(_walk(tokens, limit=1))[0]
if len(root_nodes) > 1:
root_nodes = (Node(token=commands.BRACES, children=root_nodes),)
if root_nodes:
node = Node(token=commands.ROOT, children=(next_node, *root_nodes))
else:
node = Node(token=token, children=(next_node,))
elif token == commands.ROOT:
root_nodes = tuple(_walk(tokens, terminator=r"\of"))[:-1]
next_node = tuple(_walk(tokens, limit=1))[0]
if len(root_nodes) > 1:
root_nodes = (Node(token=commands.BRACES, children=root_nodes),)
if root_nodes:
node = Node(token=token, children=(next_node, *root_nodes))
else:
node = Node(token=token, children=(next_node, Node(token=commands.BRACES, children=())))
elif token in commands.MATRICES:
children = tuple(_walk(tokens, terminator=terminator))
sibling = None
if len(children) and children[-1].token == terminator:
children, sibling = children[:-1], children[-1]
if len(children) == 1 and children[0].token == commands.BRACES and children[0].children:
children = children[0].children
if sibling is not None:
group.extend([Node(token=token, children=children, alignment=""), sibling])
break
else:
node = Node(token=token, children=children, alignment="")
elif token == commands.GENFRAC:
delimiter = next(tokens).lstrip("\\") + next(tokens).lstrip("\\")
dimension_node, style_node = tuple(_walk(tokens, terminator=terminator, limit=2))
dimension = _get_dimension(dimension_node)
style = _get_style(style_node)
attributes = {"linethickness": dimension}
children = tuple(_walk(tokens, terminator=terminator, limit=2))
group.extend(
[Node(token=style), Node(token=token, children=children, delimiter=delimiter, attributes=attributes)]
)
break
elif token == commands.SIDESET:
left, right, operator = tuple(_walk(tokens, terminator=terminator, limit=3))
left_token, left_children = _make_subsup(left)
right_token, right_children = _make_subsup(right)
attributes = {"movablelimits": "false"}
node = Node(
token=token,
children=(
Node(
token=left_token,
children=(
Node(
token=commands.VPHANTOM,
children=(
Node(token=operator.token, children=operator.children, attributes=attributes),
),
),
*left_children,
),
),
Node(
token=right_token,
children=(
Node(token=operator.token, children=operator.children, attributes=attributes),
*right_children,
),
),
),
)
elif token == commands.SKEW:
width_node, child = tuple(_walk(tokens, terminator=terminator, limit=2))
width = width_node.token
if width == commands.BRACES:
if width_node.children is None or len(width_node.children) == 0:
raise InvalidWidthError
width = width_node.children[0].token
if not width.isdigit():
raise InvalidWidthError
node = Node(token=token, children=(child,), attributes={"width": f"{0.0555 * int(width):.3f}em"})
elif token.startswith(commands.BEGIN):
node = _get_environment_node(token, tokens)
else:
node = Node(token=token)
group.append(node)
if limit and len(group) >= limit:
break
if not has_available_tokens:
raise NoAvailableTokensError
return group
def _make_subsup(node: Node) -> tuple[str, tuple[Node, ...]]:
# TODO: raise error instead of assertion
assert node.token == commands.BRACES
try:
assert (
node.children is not None
and 2 <= len(node.children[0].children) <= 3
and node.children[0].token
in (
commands.SUBSUP,
commands.SUBSCRIPT,
commands.SUPERSCRIPT,
)
)
token = node.children[0].token
children = node.children[0].children[1:]
return token, children
except IndexError:
return "", ()
def _get_dimension(node: Node) -> str:
dimension = node.token
if node.token == commands.BRACES and node.children is not None:
dimension = node.children[0].token
return dimension
def _get_style(node: Node) -> str:
style = node.token
if node.token == commands.BRACES and node.children is not None:
style = node.children[0].token
if style == "0":
return commands.DISPLAYSTYLE
if style == "1":
return commands.TEXTSTYLE
if style == "2":
return commands.SCRIPTSTYLE
if style == "3":
return commands.SCRIPTSCRIPTSTYLE
raise InvalidStyleForGenfracError
def _get_environment_node(token: str, tokens: Iterator[str]) -> Node:
# TODO: support non-matrix environments
start_index = token.index("{") + 1
environment = token[start_index:-1]
terminator = rf"{commands.END}{{{environment}}}"
children = tuple(_walk(tokens, terminator=terminator))
if len(children) and children[-1].token != terminator:
raise MissingEndError
children = children[:-1]
alignment = ""
if len(children) and children[0].token == commands.OPENING_BRACKET:
children_iter = iter(children)
next(children_iter) # remove BRACKET
for c in children_iter:
if c.token == commands.CLOSING_BRACKET:
break
elif c.token not in "lcr|":
raise InvalidAlignmentError
alignment += c.token
children = tuple(children_iter)
elif (
len(children)
and children[0].children is not None
and (
children[0].token == commands.BRACES
or (environment.endswith("*") and children[0].token == commands.BRACKETS)
)
and all(c.token in "lcr|" for c in children[0].children)
):
alignment = "".join(c.token for c in children[0].children)
children = children[1:]
return Node(token=rf"\{environment}", children=children, alignment=alignment)