返回 ppt-master
formula_parser.py
1 #!/usr/bin/env python3
2 r"""
3 PPT Master - Microsoft 365 LaTeX Formula Parser
4
5 Parse the documented Microsoft 365 LaTeX import profile into the internal
6 native-formula AST. Unknown commands remain fail-closed.
7
8 See references/native-formula.md for the owning authoring contract.
9
10 Usage:
11 Import parse_latex_formula() from the native formula compiler facade.
12
13 Examples:
14 parse_latex_formula(r"\binom{n}{k}", display=True)
15
16 Dependencies:
17 None (only uses standard library and local PPT Master modules)
18 """
19
20 from __future__ import annotations
21
22 import re
23 from dataclasses import dataclass
24
25 from .formula_ast import (
26 Accent,
27 AlignmentPoint,
28 Bar,
29 BorderBox,
30 Delimiter,
31 EquationArray,
32 Fraction,
33 Function,
34 GroupChar,
35 Limit,
36 Matrix,
37 Nary,
38 Node,
39 OperatorEmulator,
40 Phantom,
41 Prescript,
42 Radical,
43 RunStyle,
44 Script,
45 Sequence,
46 Styled,
47 Text,
48 append_child,
49 is_empty,
50 merge_run_styles,
51 )
52 from .formula_profile import (
53 ACCENT_COMMANDS,
54 BARE_DELIMITER_COMMAND_PAIRS,
55 COLOR_NAMES,
56 DELIMITER_COMMANDS,
57 DISCARDED_ARGUMENT_COMMANDS,
58 EQUATION_ARRAY_ENVIRONMENTS,
59 GREEK_SYMBOLS,
60 LIMIT_FUNCTIONS,
61 MATRIX_ENVIRONMENTS,
62 NARY_COMMANDS,
63 NEGATED_SYMBOLS,
64 NO_OUTPUT_COMMANDS,
65 OPERATOR_BOUNDARY_COMMANDS,
66 SPACING_COMMANDS,
67 STANDARD_FUNCTIONS,
68 SYMBOL_COMMANDS,
69 UNSUPPORTED_COMMANDS,
70 VARIANT_UPPERCASE_GREEK,
71 )
72
73
74 _MAX_PARSE_DEPTH = 128
75 _MAX_MACRO_EXPANSIONS = 500
76 _MAX_EXPANDED_LENGTH = 1_048_576
77 _ENVIRONMENT_NAME_RE = re.compile(r"[A-Za-z]+\*?")
78 _COLOR_HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}")
79 _LENGTH_RE = re.compile(
80 r"([+-]?(?:\d+(?:\.\d*)?|\.\d+))\s*(mu|em|ex|pt)",
81 re.IGNORECASE,
82 )
83 _BIG_DELIMITER_RE = re.compile(
84 r"\\(?:big|Big|bigg|Bigg)([lrm]?)(?![A-Za-z])\s*"
85 r"(\\[A-Za-z]+|\\[{}|]|[()\[\]{}|.])"
86 )
87 _BIG_LEFT_COMMAND = "pptmasterbigleft"
88 _BIG_RIGHT_COMMAND = "pptmasterbigright"
89 _BIG_MIDDLE_COMMAND = "pptmasterbigmiddle"
90 _BIG_SYMMETRIC_COMMAND = "pptmasterbigsymmetric"
91 _MIDDLE_DELIMITER_COMMANDS = ("middle", _BIG_MIDDLE_COMMAND)
92 _RIGHT_DELIMITER_COMMANDS = ("right", _BIG_RIGHT_COMMAND)
93 _WHITE_OPEN_RE = re.compile(
94 r"(?:(?:\\left)?(?:\[|\\lbrack))\\!"
95 r"(?:(?:\\left)?(?:\[|\\lbrack))"
96 )
97 _WHITE_CLOSE_RE = re.compile(
98 r"(?:(?:\\right)?(?:\]|\\rbrack))\\!"
99 r"(?:(?:\\right)?(?:\]|\\rbrack))"
100 )
101
102 _ROMAN_STYLE = RunStyle(style="p", is_default=True)
103 _TEXT_STYLE = RunStyle(normal=True)
104 _MATH_MODE_RESET_STYLE = RunStyle(
105 normal=False,
106 bold=False,
107 italic=False,
108 typeface="",
109 )
110 _SPACING_STYLE = RunStyle()
111 _PLAIN_OPERATOR_CHARS = frozenset("+−*/=<>±∓×÷⋅,;:!?")
112 _INFIX_FRACTIONS = frozenset({"over", "atop", "choose", "brace", "brack"})
113
114 _STYLE_WRAPPERS = {
115 "mathrm": RunStyle(style="p"),
116 "mathbf": RunStyle(style="b"),
117 "mathit": RunStyle(style="i"),
118 "mathsf": RunStyle(style="p", script="sans-serif"),
119 "mathtt": RunStyle(style="p", script="monospace"),
120 "mathbb": RunStyle(style="p", script="double-struck"),
121 "Bbb": RunStyle(style="p", script="double-struck"),
122 "mathcal": RunStyle(style="p", script="script"),
123 "mathscr": RunStyle(style="p", script="script"),
124 "mathfrak": RunStyle(style="p", script="fraktur"),
125 "boldsymbol": RunStyle(style="bi"),
126 "bm": RunStyle(style="bi"),
127 "text": _TEXT_STYLE,
128 "textrm": _TEXT_STYLE,
129 "textnormal": _TEXT_STYLE,
130 "textbf": RunStyle(normal=True, bold=True),
131 "textit": RunStyle(normal=True, italic=True),
132 "emph": RunStyle(normal=True, italic=True),
133 "textsf": RunStyle(normal=True, typeface="Arial"),
134 "texttt": RunStyle(normal=True, typeface="Courier New"),
135 "mbox": _TEXT_STYLE,
136 "hbox": _TEXT_STYLE,
137 }
138
139 _DECLARATION_STYLES = {
140 "rm": RunStyle(style="p"),
141 "bf": RunStyle(style="b"),
142 "it": RunStyle(style="i"),
143 "cal": RunStyle(style="p", script="script"),
144 "frak": RunStyle(style="p", script="fraktur"),
145 "sf": RunStyle(style="p", script="sans-serif"),
146 "tt": RunStyle(style="p", script="monospace"),
147 }
148
149 _OVER_UNDER_COMMANDS = {
150 "overbrace": ("⏞", "top", "bot"),
151 "underbrace": ("⏟", "bot", "top"),
152 "underrightarrow": ("→", "bot", "top"),
153 "underleftarrow": ("←", "bot", "top"),
154 "underleftrightarrow": ("↔", "bot", "top"),
155 }
156
157 _OVER_ARROW_COMMANDS = {
158 "overrightarrow": "→",
159 "overleftarrow": "←",
160 "overleftrightarrow": "↔",
161 }
162
163
164 class FormulaCompileError(ValueError):
165 """Raised when formula source violates the Microsoft 365 profile."""
166
167
168 @dataclass(frozen=True)
169 class _Macro:
170 parameter_count: int
171 body: str
172
173
174 def _is_command_letter(char: str) -> bool:
175 return char.isascii() and char.isalpha()
176
177
178 def _xml_character_allowed(char: str) -> bool:
179 codepoint = ord(char)
180 return (
181 codepoint in {0x09, 0x0A, 0x0D}
182 or 0x20 <= codepoint <= 0xD7FF
183 or 0xE000 <= codepoint <= 0xFFFD
184 or 0x10000 <= codepoint <= 0x10FFFF
185 )
186
187
188 def _read_control_word(source: str, position: int) -> tuple[str, int]:
189 if position >= len(source) or source[position] != "\\":
190 raise FormulaCompileError("Expected a LaTeX control word")
191 position += 1
192 start = position
193 while position < len(source) and _is_command_letter(source[position]):
194 position += 1
195 if position == start:
196 raise FormulaCompileError("Expected a LaTeX control word")
197 return source[start:position], position
198
199
200 def _skip_whitespace(source: str, position: int) -> int:
201 while position < len(source) and source[position].isspace():
202 position += 1
203 return position
204
205
206 def _read_raw_group(source: str, position: int) -> tuple[str, int]:
207 position = _skip_whitespace(source, position)
208 if position >= len(source) or source[position] != "{":
209 raise FormulaCompileError("Expected a braced LaTeX group")
210 depth = 1
211 cursor = position + 1
212 start = cursor
213 while cursor < len(source):
214 char = source[cursor]
215 if char == "\\":
216 cursor += 2
217 continue
218 if char == "{":
219 depth += 1
220 elif char == "}":
221 depth -= 1
222 if depth == 0:
223 return source[start:cursor], cursor + 1
224 cursor += 1
225 raise FormulaCompileError("Unclosed braced LaTeX group")
226
227
228 def _read_optional_integer(source: str, position: int) -> tuple[int | None, int]:
229 position = _skip_whitespace(source, position)
230 if position >= len(source) or source[position] != "[":
231 return None, position
232 closing = source.find("]", position + 1)
233 if closing < 0:
234 raise FormulaCompileError("Unclosed optional macro parameter count")
235 raw = source[position + 1:closing].strip()
236 if not raw.isdigit() or not 0 <= int(raw) <= 9:
237 raise FormulaCompileError("Macro parameter count must be between 0 and 9")
238 return int(raw), closing + 1
239
240
241 def _collect_macros(source: str) -> tuple[str, dict[str, _Macro]]:
242 macros: dict[str, _Macro] = {}
243 output: list[str] = []
244 position = 0
245 while position < len(source):
246 if source[position] != "\\":
247 output.append(source[position])
248 position += 1
249 continue
250 try:
251 command, command_end = _read_control_word(source, position)
252 except FormulaCompileError:
253 output.append(source[position])
254 position += 1
255 continue
256 if command not in {"newcommand", "renewcommand", "def"}:
257 output.append(source[position:command_end])
258 position = command_end
259 continue
260
261 cursor = _skip_whitespace(source, command_end)
262 if command in {"newcommand", "renewcommand"}:
263 name_group, cursor = _read_raw_group(source, cursor)
264 name_group = name_group.strip()
265 if not re.fullmatch(r"\\[A-Za-z]+", name_group):
266 raise FormulaCompileError(
267 f"\\{command} requires one control-word macro name"
268 )
269 parameter_count, cursor = _read_optional_integer(source, cursor)
270 body, cursor = _read_raw_group(source, cursor)
271 count = parameter_count or 0
272 if re.search(r"#(?:0|[1-9][0-9])", body):
273 raise FormulaCompileError("Macro body uses an invalid parameter number")
274 macros[name_group[1:]] = _Macro(count, body)
275 else:
276 if cursor >= len(source) or source[cursor] != "\\":
277 raise FormulaCompileError("\\def requires one control-word macro name")
278 name, cursor = _read_control_word(source, cursor)
279 parameters: list[int] = []
280 cursor = _skip_whitespace(source, cursor)
281 while cursor < len(source) and source[cursor] == "#":
282 if cursor + 1 >= len(source) or source[cursor + 1] not in "123456789":
283 raise FormulaCompileError("\\def parameters must use #1 through #9")
284 parameters.append(int(source[cursor + 1]))
285 cursor = _skip_whitespace(source, cursor + 2)
286 expected = list(range(1, len(parameters) + 1))
287 if parameters != expected:
288 raise FormulaCompileError("\\def parameters must be consecutive from #1")
289 body, cursor = _read_raw_group(source, cursor)
290 macros[name] = _Macro(len(parameters), body)
291 position = cursor
292 return "".join(output), macros
293
294
295 def _read_macro_argument(source: str, position: int) -> tuple[str, int]:
296 position = _skip_whitespace(source, position)
297 if position >= len(source):
298 raise FormulaCompileError("Macro invocation is missing an argument")
299 if source[position] == "{":
300 return _read_raw_group(source, position)
301 if source[position] == "\\":
302 if position + 1 >= len(source):
303 raise FormulaCompileError("Macro invocation ends with a backslash")
304 if _is_command_letter(source[position + 1]):
305 _, end = _read_control_word(source, position)
306 return source[position:end], end
307 return source[position:position + 2], position + 2
308 return source[position], position + 1
309
310
311 def _expand_macros(source: str, macros: dict[str, _Macro]) -> str:
312 if not macros:
313 return source
314 expansion_count = 0
315 while True:
316 output: list[str] = []
317 position = 0
318 replaced = False
319 while position < len(source):
320 if source[position] != "\\" or position + 1 >= len(source):
321 output.append(source[position])
322 position += 1
323 continue
324 if not _is_command_letter(source[position + 1]):
325 output.append(source[position:position + 2])
326 position += 2
327 continue
328 command, cursor = _read_control_word(source, position)
329 macro = macros.get(command)
330 if macro is None:
331 output.append(source[position:cursor])
332 position = cursor
333 continue
334 arguments: list[str] = []
335 for _ in range(macro.parameter_count):
336 argument, cursor = _read_macro_argument(source, cursor)
337 arguments.append(argument)
338 body = macro.body
339 for index, argument in enumerate(arguments, start=1):
340 body = body.replace(f"#{index}", argument)
341 output.append(body)
342 position = cursor
343 expansion_count += 1
344 if expansion_count > _MAX_MACRO_EXPANSIONS:
345 raise FormulaCompileError(
346 f"Formula exceeds the {_MAX_MACRO_EXPANSIONS}-macro expansion limit"
347 )
348 replaced = True
349 source = "".join(output)
350 if len(source) > _MAX_EXPANDED_LENGTH:
351 raise FormulaCompileError(
352 f"Expanded formula exceeds the {_MAX_EXPANDED_LENGTH}-character limit"
353 )
354 if not replaced:
355 return source
356
357
358 def _strip_outer_delimiters(source: str) -> str:
359 pairs = (("$$", "$$"), ("$", "$"), ("\\(", "\\)"), ("\\[", "\\]"))
360 for opener, closer in pairs:
361 if source.startswith(opener):
362 if not source.endswith(closer) or len(source) <= len(opener) + len(closer):
363 raise FormulaCompileError(f"Unclosed outer LaTeX delimiter {opener!r}")
364 return source[len(opener):-len(closer)].strip()
365 return source
366
367
368 def _normalize_big_delimiters(source: str) -> str:
369 def replace(match: re.Match[str]) -> str:
370 direction = match.group(1)
371 delimiter = match.group(2)
372 if direction:
373 command = {
374 "l": _BIG_LEFT_COMMAND,
375 "r": _BIG_RIGHT_COMMAND,
376 "m": _BIG_MIDDLE_COMMAND,
377 }[direction]
378 return f"\\{command}{delimiter}"
379 openers = {
380 "(", "[", "{", ".", r"\{", r"\lbrace", r"\langle", r"\lfloor",
381 r"\lceil", r"\lvert", r"\lVert", r"\lbrack",
382 }
383 closers = {
384 ")", "]", "}", r"\}", r"\rbrace", r"\rangle", r"\rfloor",
385 r"\rceil", r"\rvert", r"\rVert", r"\rbrack",
386 }
387 symmetric = {"|", r"\|", r"\vert", r"\Vert"}
388 if delimiter in openers:
389 return f"\\{_BIG_LEFT_COMMAND}{delimiter}"
390 if delimiter in closers:
391 return f"\\{_BIG_RIGHT_COMMAND}{delimiter}"
392 if delimiter in symmetric:
393 return f"\\{_BIG_SYMMETRIC_COMMAND}{delimiter}"
394 return delimiter
395
396 return _BIG_DELIMITER_RE.sub(replace, source)
397
398
399 def _normalize_white_brackets(source: str) -> str:
400 source = _WHITE_OPEN_RE.sub(r"\\left⟦", source)
401 return _WHITE_CLOSE_RE.sub(r"\\right⟧", source)
402
403
404 def _prepare_source(source: str) -> str:
405 source = _strip_outer_delimiters(source.strip())
406 if "%" in source:
407 position = 0
408 while position < len(source):
409 if source[position] == "%" and (position == 0 or source[position - 1] != "\\"):
410 raise FormulaCompileError("Unescaped % comments are outside the Office profile")
411 position += 1
412 without_definitions, macros = _collect_macros(source)
413 expanded = _expand_macros(without_definitions, macros)
414 return _normalize_white_brackets(_normalize_big_delimiters(expanded)).strip()
415
416
417 def _space_from_length(raw: str) -> str:
418 match = _LENGTH_RE.fullmatch(raw.strip())
419 if match is None:
420 raise FormulaCompileError(f"Unsupported math spacing length: {raw!r}")
421 amount = float(match.group(1))
422 unit = match.group(2).lower()
423 multiplier = {"mu": 1.0, "em": 18.0, "ex": 9.0, "pt": 1.8}[unit]
424 mu = min(2160.0, amount * multiplier)
425 if mu <= 0:
426 return "\u200b"
427 whole_em = int(mu // 18)
428 remainder = mu - whole_em * 18
429 output = "\u2003" * whole_em
430 if remainder >= 14:
431 output += "\u2003"
432 elif remainder >= 9:
433 output += "\u2002"
434 elif remainder >= 4.5:
435 output += "\u2004"
436 elif remainder >= 3.5:
437 output += "\u205f"
438 elif remainder >= 2:
439 output += "\u2009"
440 elif remainder > 0:
441 output += "\u200b"
442 return output or "\u200b"
443
444
445 class _LatexParser:
446 """Parse one prepared formula source under the Office import profile."""
447
448 def __init__(
449 self,
450 source: str,
451 *,
452 display: bool,
453 text_mode: bool = False,
454 inherited_style: RunStyle | None = None,
455 ) -> None:
456 self.source = source
457 self.position = 0
458 self.depth = 0
459 self.display = display
460 self.text_mode_depth = 1 if text_mode else 0
461 self.current_style = inherited_style
462 self.environment_stack: list[str] = []
463
464 def parse(self) -> Sequence:
465 result = self._parse_sequence()
466 if self.position != len(self.source):
467 self._fail("Unexpected trailing formula source")
468 if is_empty(result):
469 self._fail("Formula is empty")
470 return result
471
472 def _parse_sequence(
473 self,
474 *,
475 terminator: str | None = None,
476 stop_commands: frozenset[str] = frozenset(),
477 stop_at_environment_boundary: bool = False,
478 ) -> Sequence:
479 children: list[Node] = []
480 while self.position < len(self.source):
481 if terminator is not None and self.source[self.position] == terminator:
482 self.position += 1
483 return Sequence(tuple(children))
484 if any(self._at_control_word(command) for command in stop_commands):
485 return Sequence(tuple(children))
486 if stop_at_environment_boundary and self._at_environment_boundary():
487 return Sequence(tuple(children))
488
489 char = self.source[self.position]
490 if char == "}":
491 self._fail("Unexpected closing group")
492 if char == "&":
493 self._fail("Alignment marker '&' is only valid inside an environment")
494 if char in "^_":
495 append_child(children, self._parse_prescript())
496 continue
497 infix = self._current_infix_command()
498 if infix is not None:
499 if not children:
500 self._fail(f"Infix fraction \\{infix} has no numerator")
501 self._consume_control_word(infix)
502 right = self._parse_nested_sequence(
503 terminator=terminator,
504 stop_commands=stop_commands,
505 stop_at_environment_boundary=stop_at_environment_boundary,
506 )
507 if is_empty(right):
508 self._fail(f"Infix fraction \\{infix} has no denominator")
509 left = Sequence(tuple(children))
510 fraction = Fraction(
511 numerator=left,
512 denominator=right,
513 kind="bar" if infix == "over" else "noBar",
514 )
515 if infix in {"choose", "brace", "brack"}:
516 delimiters = {
517 "choose": ("(", ")"),
518 "brace": ("{", "}"),
519 "brack": ("[", "]"),
520 }[infix]
521 return Sequence((Delimiter(*delimiters, (Sequence((fraction,)),)),))
522 return Sequence((fraction,))
523
524 atom = self._parse_complete_atom()
525 append_child(children, atom)
526
527 if terminator is not None:
528 self._fail(f"Unclosed group; expected {terminator!r}")
529 return Sequence(tuple(children))
530
531 def _parse_atom(self) -> Node:
532 char = self.source[self.position]
533 if char.isspace():
534 while self.position < len(self.source) and self.source[self.position].isspace():
535 self.position += 1
536 return self._styled_text(" ") if self.text_mode_depth else Sequence(())
537 if char == "{":
538 return self._parse_group()
539 if char == "\\":
540 return self._parse_command()
541 if char in "([|":
542 return self._parse_or_literal_character_delimiter(char)
543 if char == "$":
544 if not self.text_mode_depth:
545 self._fail("Math delimiters are only allowed around the complete marker source")
546 return self._parse_text_embedded_math("$", "$")
547 if char in "#%":
548 self._fail(f"Unsupported TeX syntax character {char!r}")
549 if ord(char) < 32:
550 self._fail("Unsupported control character")
551
552 self.position += 1
553 if self.text_mode_depth:
554 return self._styled_text(char)
555 if char == "-":
556 char = "−"
557 elif char == "~":
558 return self._styled_text("\u00a0", _SPACING_STYLE)
559 style = _ROMAN_STYLE if char in _PLAIN_OPERATOR_CHARS else None
560 return self._styled_text(char, style)
561
562 def _parse_command(self) -> Node:
563 command_start = self.position
564 self.position += 1
565 if self.position >= len(self.source):
566 self._fail("Trailing backslash", position=command_start)
567
568 char = self.source[self.position]
569 if not _is_command_letter(char):
570 self.position += 1
571 if char == "\\":
572 self._fail(
573 "Row separator '\\\\' is only valid inside an environment",
574 position=command_start,
575 )
576 if char == "|":
577 paired = self._try_symmetric_command_delimiter(command_start)
578 if paired is not None:
579 return paired
580 return self._styled_text("‖", _ROMAN_STYLE)
581 if char == "(" and self.text_mode_depth:
582 return self._parse_text_embedded_math("\\(", "\\)", opened=True)
583 if char == "{":
584 paired = self._try_escaped_brace_delimiter()
585 if paired is not None:
586 return paired
587 escaped = {
588 "{": "{",
589 "}": "}",
590 "_": "_",
591 "%": "%",
592 "#": "#",
593 "$": "$",
594 "&": "&",
595 }
596 if char in escaped:
597 return self._styled_text(
598 escaped[char],
599 literal=char == "&",
600 )
601 if char in SPACING_COMMANDS:
602 return self._styled_text(SPACING_COMMANDS[char], _SPACING_STYLE)
603 self._fail(f"Unsupported control symbol \\{char}", position=command_start)
604
605 command = self._read_control_word_body()
606 self._skip_whitespace()
607 if command in UNSUPPORTED_COMMANDS:
608 self._fail(f"Unsupported LaTeX command \\{command}", position=command_start)
609 if command in {"vert", "Vert"}:
610 paired = self._try_symmetric_named_delimiter(command)
611 if paired is not None:
612 return paired
613 if command in GREEK_SYMBOLS:
614 style = None
615 if command[:1].isupper() and command not in VARIANT_UPPERCASE_GREEK:
616 style = _ROMAN_STYLE
617 return self._styled_text(GREEK_SYMBOLS[command], style)
618 if command in SYMBOL_COMMANDS:
619 return self._styled_text(SYMBOL_COMMANDS[command], _ROMAN_STYLE)
620 if command in DELIMITER_COMMANDS:
621 paired = self._try_command_delimiter(command, command_start)
622 if paired is not None:
623 return paired
624 return self._styled_text(DELIMITER_COMMANDS[command], _ROMAN_STYLE)
625 if command in NARY_COMMANDS:
626 symbol, category = NARY_COMMANDS[command]
627 return Nary(symbol=symbol, category=category)
628 if command in STANDARD_FUNCTIONS:
629 return Function(name=self._roman_name(command))
630 if command in LIMIT_FUNCTIONS:
631 return Function(name=self._roman_name(command), limit_style=True)
632 if command == "operatorname":
633 name = self._parse_required_argument("operator name", text_mode=True)
634 return Function(name=Sequence((Styled(name, _ROMAN_STYLE),)))
635 if command in {"frac", "dfrac", "tfrac", "cfrac", "ifrac"}:
636 if (
637 command == "cfrac"
638 and self.position < len(self.source)
639 and self.source[self.position] == "["
640 ):
641 self._parse_raw_optional_group("continued-fraction alignment")
642 numerator = self._parse_required_argument("fraction numerator")
643 denominator = self._parse_required_argument("fraction denominator")
644 return Fraction(
645 numerator=numerator,
646 denominator=denominator,
647 kind="skw" if command == "ifrac" else "bar",
648 )
649 if command in {"binom", "dbinom", "tbinom"}:
650 numerator = self._parse_required_argument("binomial numerator")
651 denominator = self._parse_required_argument("binomial denominator")
652 fraction = Fraction(numerator, denominator, kind="noBar")
653 return Delimiter("(", ")", (Sequence((fraction,)),))
654 if command == "genfrac":
655 left = self._delimiter_from_raw(self._parse_raw_required_group("left delimiter"))
656 right = self._delimiter_from_raw(self._parse_raw_required_group("right delimiter"))
657 thickness = self._parse_raw_required_group("fraction thickness").strip()
658 self._parse_raw_required_group("fraction style")
659 numerator = self._parse_required_argument("fraction numerator")
660 denominator = self._parse_required_argument("fraction denominator")
661 if not thickness:
662 kind = "bar"
663 elif thickness == "0":
664 kind = "noBar"
665 else:
666 thickness_match = _LENGTH_RE.fullmatch(thickness)
667 if thickness_match is None:
668 self._fail(
669 f"Invalid generalized-fraction thickness {thickness!r}"
670 )
671 kind = (
672 "noBar"
673 if float(thickness_match.group(1)) == 0.0
674 else "bar"
675 )
676 fraction = Fraction(numerator, denominator, kind=kind)
677 if left or right:
678 return Delimiter(left, right, (Sequence((fraction,)),))
679 return fraction
680 if command == "sqrt":
681 degree = self._parse_optional_argument("radical degree")
682 body = self._parse_required_argument("radical body")
683 return Radical(body=body, degree=degree)
684 if command == "root":
685 degree = self._parse_nested_sequence(stop_commands=frozenset({"of"}))
686 if not self._at_control_word("of") or is_empty(degree):
687 self._fail("\\root requires a degree followed by \\of")
688 self._consume_control_word("of")
689 body = self._parse_required_argument("radical body")
690 return Radical(body=body, degree=degree)
691 if command in {"left", _BIG_LEFT_COMMAND}:
692 return self._parse_delimited_expression(command)
693 if command == _BIG_RIGHT_COMMAND:
694 right = self._parse_delimiter_token("explicit big right delimiter")
695 return Delimiter("", right, (Sequence(()),))
696 if command == _BIG_MIDDLE_COMMAND:
697 separator = self._parse_delimiter_token("explicit big middle delimiter")
698 return Delimiter("", "", (Sequence(()), Sequence(())), separator)
699 if command == _BIG_SYMMETRIC_COMMAND:
700 return self._parse_big_symmetric_delimiter()
701 if command in {"right", "middle"}:
702 self._fail(f"Unexpected \\{command} without matching \\left", position=command_start)
703 if command == "begin":
704 return self._parse_environment()
705 if command == "end":
706 self._fail("Unexpected \\end without matching \\begin", position=command_start)
707 if command in _STYLE_WRAPPERS:
708 text_mode = command in {
709 "text", "textrm", "textnormal", "textbf", "textit", "emph",
710 "textsf", "texttt", "mbox", "hbox",
711 }
712 body = self._parse_required_argument(f"\\{command} body", text_mode=text_mode)
713 return Styled(body=body, style=_STYLE_WRAPPERS[command])
714 if command in _DECLARATION_STYLES:
715 self.current_style = merge_run_styles(
716 self.current_style,
717 _DECLARATION_STYLES[command],
718 )
719 return Sequence(())
720 if command in ACCENT_COMMANDS:
721 body = self._parse_required_argument(f"\\{command} body")
722 return Accent(character=ACCENT_COMMANDS[command], body=body)
723 if command == "overline":
724 return Bar("top", self._parse_required_argument("overline body"))
725 if command == "underline":
726 return Bar("bot", self._parse_required_argument("underline body"))
727 if command in _OVER_UNDER_COMMANDS:
728 character, position, vertical = _OVER_UNDER_COMMANDS[command]
729 body = self._parse_required_argument(f"\\{command} body")
730 return GroupChar(character, position, vertical, body)
731 if command in _OVER_ARROW_COMMANDS:
732 body = self._parse_required_argument(f"\\{command} body")
733 return GroupChar(_OVER_ARROW_COMMANDS[command], "top", "bot", body)
734 if command in {"xrightarrow", "xleftarrow"}:
735 lower = self._parse_optional_argument("arrow lower label", allow_empty=True)
736 upper = self._parse_required_argument("arrow upper label", allow_empty=True)
737 arrow = GroupChar(
738 "→" if command == "xrightarrow" else "←",
739 "bot",
740 "top",
741 upper,
742 )
743 return Limit(arrow, lower=lower) if lower is not None else arrow
744 if command in {"overset", "stackrel"}:
745 upper = self._parse_required_argument("upper limit")
746 base = self._single_node(self._parse_required_argument("limit base"))
747 return Limit(base=base, upper=upper)
748 if command == "underset":
749 lower = self._parse_required_argument("lower limit")
750 base = self._single_node(self._parse_required_argument("limit base"))
751 return Limit(base=base, lower=lower)
752 if command == "buildrel":
753 upper = self._parse_nested_sequence(stop_commands=frozenset({"over"}))
754 if not self._at_control_word("over"):
755 self._fail("\\buildrel requires \\over")
756 if is_empty(upper):
757 self._fail("\\buildrel requires an upper value")
758 self._consume_control_word("over")
759 base = self._single_node(self._parse_required_argument("buildrel base"))
760 return Limit(base=base, upper=upper)
761 if command == "substack":
762 raw = self._parse_raw_required_group("substack body")
763 return self._parse_synthetic_environment("aligned", raw)
764 if command == "bmod":
765 return Function(name=self._roman_name("mod"))
766 if command in {"pmod", "mod"}:
767 body = self._parse_required_argument(f"\\{command} body")
768 content = Sequence((
769 Text("mod", _ROMAN_STYLE),
770 Text(" ", _SPACING_STYLE),
771 *body.children,
772 ))
773 if command == "pmod":
774 return Delimiter("(", ")", (content,))
775 return content
776 if command in _STYLE_COLOR_COMMANDS:
777 color = self._parse_color()
778 body = self._parse_required_argument(f"\\{command} body")
779 return Styled(body, RunStyle(color=color))
780 if command in {"boxed", "fbox", "framebox", "cancel", "bcancel", "xcancel"}:
781 body = self._parse_required_argument(
782 f"\\{command} body",
783 text_mode=command in {"fbox", "framebox"},
784 )
785 return BorderBox(body=body, kind=command)
786 if command in {"phantom", "hphantom", "vphantom"}:
787 return Phantom(
788 body=self._parse_required_argument(f"\\{command} body"),
789 kind=command,
790 )
791 if command == "not":
792 target = self._parse_required_argument("negated relation")
793 node = self._single_node(target)
794 if isinstance(node, Text) and len(node.value) == 1:
795 negated = NEGATED_SYMBOLS.get(node.value)
796 if negated is not None:
797 return self._styled_text(negated, _ROMAN_STYLE)
798 return Accent("̸", target)
799 if command == "eqalign":
800 raw = self._parse_raw_required_group("eqalign body")
801 return self._parse_synthetic_environment("aligned", raw.replace("\\cr", "\\\\"))
802 if command == "ce":
803 raw = self._parse_raw_required_group("chemical expression")
804 return _ChemParser(raw, display=self.display).parse()
805 if command == "bra":
806 return Delimiter("⟨", "|", (self._parse_required_argument("bra body"),))
807 if command == "ket":
808 return Delimiter("|", "⟩", (self._parse_required_argument("ket body"),))
809 if command in SPACING_COMMANDS:
810 return self._styled_text(SPACING_COMMANDS[command], _SPACING_STYLE)
811 if command in {"mkern", "mskip"}:
812 return self._styled_text(self._parse_spacing_length(command), _SPACING_STYLE)
813 if command == "hspace":
814 return self._styled_text(
815 _space_from_length(self._parse_raw_required_group("horizontal space")),
816 _SPACING_STYLE,
817 )
818 if command in NO_OUTPUT_COMMANDS:
819 return Sequence(())
820 if command in DISCARDED_ARGUMENT_COMMANDS:
821 self._parse_required_argument(f"\\{command} body", allow_empty=True)
822 return Sequence(())
823 if command == "mathrel":
824 return OperatorEmulator(
825 self._parse_required_argument("math relation body")
826 )
827 if command == "mathop":
828 return Function(
829 name=self._parse_required_argument("math operator body"),
830 limit_style=True,
831 )
832
833 self._fail(f"Unsupported LaTeX command \\{command}", position=command_start)
834
835 def _parse_group(self) -> Sequence:
836 self.position += 1
837 saved_style = self.current_style
838 try:
839 return self._parse_nested_sequence(terminator="}")
840 finally:
841 self.current_style = saved_style
842
843 def _parse_required_argument(
844 self,
845 description: str,
846 *,
847 allow_empty: bool = False,
848 text_mode: bool = False,
849 ) -> Sequence:
850 self._skip_whitespace()
851 saved_text_depth = self.text_mode_depth
852 if text_mode:
853 self.text_mode_depth += 1
854 try:
855 if self.position >= len(self.source):
856 self._fail(f"{description.capitalize()} is missing")
857 if self.source[self.position] == "{":
858 result = self._parse_group()
859 else:
860 result = Sequence((self._parse_nested_bare_atom(),))
861 finally:
862 self.text_mode_depth = saved_text_depth
863 if not allow_empty and is_empty(result):
864 self._fail(f"{description.capitalize()} cannot be empty")
865 return result
866
867 def _parse_optional_argument(
868 self,
869 description: str,
870 *,
871 allow_empty: bool = False,
872 ) -> Sequence | None:
873 self._skip_whitespace()
874 if self.position >= len(self.source) or self.source[self.position] != "[":
875 return None
876 self.position += 1
877 result = self._parse_nested_sequence(terminator="]")
878 if not allow_empty and is_empty(result):
879 self._fail(f"{description.capitalize()} cannot be empty")
880 return result
881
882 def _parse_raw_required_group(self, description: str) -> str:
883 try:
884 raw, self.position = _read_raw_group(self.source, self.position)
885 except FormulaCompileError as exc:
886 self._fail(f"{description.capitalize()} must use a braced group")
887 raise AssertionError from exc
888 return raw
889
890 def _parse_raw_optional_group(self, description: str) -> str | None:
891 self._skip_whitespace()
892 if self.position >= len(self.source) or self.source[self.position] != "[":
893 return None
894 closing = self.source.find("]", self.position + 1)
895 if closing < 0:
896 self._fail(f"Unclosed {description}")
897 raw = self.source[self.position + 1:closing]
898 self.position = closing + 1
899 self._skip_whitespace()
900 return raw
901
902 def _parse_nested_sequence(self, **kwargs: object) -> Sequence:
903 self.depth += 1
904 if self.depth > _MAX_PARSE_DEPTH:
905 self._fail(f"Formula nesting exceeds {_MAX_PARSE_DEPTH} levels")
906 try:
907 return self._parse_sequence(**kwargs)
908 finally:
909 self.depth -= 1
910
911 def _parse_nested_atom(self) -> Node:
912 self.depth += 1
913 if self.depth > _MAX_PARSE_DEPTH:
914 self._fail(f"Formula nesting exceeds {_MAX_PARSE_DEPTH} levels")
915 try:
916 return self._parse_complete_atom()
917 finally:
918 self.depth -= 1
919
920 def _parse_nested_bare_atom(self) -> Node:
921 self.depth += 1
922 if self.depth > _MAX_PARSE_DEPTH:
923 self._fail(f"Formula nesting exceeds {_MAX_PARSE_DEPTH} levels")
924 try:
925 return self._parse_atom()
926 finally:
927 self.depth -= 1
928
929 def _parse_complete_atom(self) -> Node:
930 atom = self._parse_atom()
931 if isinstance(atom, (Nary, Function)):
932 atom = self._parse_limit_modifier(atom)
933 atom = self._parse_scripts(atom)
934 if isinstance(atom, (Nary, Function)):
935 atom = self._parse_limit_modifier(atom)
936 atom = self._parse_owned_body(atom)
937 return atom
938
939 def _parse_scripts(self, base: Node) -> Node:
940 subscript: Sequence | None = None
941 superscript: Sequence | None = None
942 while True:
943 saved_position = self.position
944 self._skip_whitespace()
945 if self.position >= len(self.source) or self.source[self.position] not in "^_":
946 self.position = saved_position
947 break
948 marker = self.source[self.position]
949 self.position += 1
950 argument = self._parse_required_argument(f"script marker {marker!r}")
951 if marker == "_":
952 if subscript is not None:
953 self._fail("Duplicate subscript for one base")
954 subscript = argument
955 else:
956 if superscript is not None:
957 self._fail("Duplicate superscript for one base")
958 superscript = argument
959 if subscript is None and superscript is None:
960 return base
961 if isinstance(base, Nary):
962 return Nary(
963 base.symbol,
964 base.category,
965 subscript,
966 superscript,
967 base.body,
968 base.limit_modifier,
969 )
970 if isinstance(base, Function):
971 return Function(
972 base.name,
973 base.body,
974 subscript,
975 superscript,
976 base.limit_style,
977 base.limit_modifier,
978 )
979 if isinstance(base, GroupChar):
980 return Limit(base, lower=subscript, upper=superscript)
981 if isinstance(base, Limit):
982 return Limit(
983 base.base,
984 lower=subscript or base.lower,
985 upper=superscript or base.upper,
986 )
987 return Script(base, subscript=subscript, superscript=superscript)
988
989 def _parse_prescript(self) -> Prescript:
990 subscript: Sequence | None = None
991 superscript: Sequence | None = None
992 while self.position < len(self.source) and self.source[self.position] in "^_":
993 marker = self.source[self.position]
994 self.position += 1
995 argument = self._parse_required_argument(f"pre-script marker {marker!r}")
996 if marker == "_":
997 if subscript is not None:
998 self._fail("Duplicate pre-subscript")
999 subscript = argument
1000 else:
1001 if superscript is not None:
1002 self._fail("Duplicate pre-superscript")
1003 superscript = argument
1004 self._skip_whitespace()
1005 if self.position >= len(self.source):
1006 self._fail("Pre-script has no base")
1007 base = self._parse_nested_atom()
1008 return Prescript(base, subscript=subscript, superscript=superscript)
1009
1010 def _parse_limit_modifier(self, node: Nary | Function) -> Nary | Function:
1011 saved = self.position
1012 self._skip_whitespace()
1013 command: str | None = None
1014 if self._at_control_word("limits"):
1015 command = "limits"
1016 elif self._at_control_word("nolimits"):
1017 command = "nolimits"
1018 if command is None:
1019 self.position = saved
1020 return node
1021 if node.limit_modifier is not None:
1022 self._fail("Operator has more than one limit modifier")
1023 self._consume_control_word(command)
1024 if isinstance(node, Nary):
1025 return Nary(
1026 node.symbol,
1027 node.category,
1028 node.subscript,
1029 node.superscript,
1030 node.body,
1031 command,
1032 )
1033 return Function(
1034 node.name,
1035 node.body,
1036 node.subscript,
1037 node.superscript,
1038 node.limit_style,
1039 command,
1040 )
1041
1042 def _parse_owned_body(self, node: Nary | Function) -> Nary | Function:
1043 if node.body is not None:
1044 return node
1045 saved = self.position
1046 self._skip_whitespace()
1047 leading_spacing: list[Node] = []
1048 while self._transparent_prefix_follows():
1049 append_child(leading_spacing, self._parse_nested_atom())
1050 self._skip_whitespace()
1051 if self.position < len(self.source) and self.source[self.position] in "+-":
1052 append_child(leading_spacing, self._parse_nested_atom())
1053 self._skip_whitespace()
1054 while self._transparent_prefix_follows():
1055 append_child(leading_spacing, self._parse_nested_atom())
1056 self._skip_whitespace()
1057 if not self._owned_body_follows():
1058 self.position = saved
1059 return node
1060 if self.source[self.position] == "{":
1061 body = self._parse_group()
1062 else:
1063 body = Sequence((self._parse_nested_atom(),))
1064 if leading_spacing:
1065 body = Sequence((*leading_spacing, *body.children))
1066 if isinstance(node, Nary):
1067 children = list(body.children)
1068 while True:
1069 saved_tail = self.position
1070 self._skip_whitespace()
1071 tail_prefix: list[Node] = []
1072 while self._transparent_prefix_follows():
1073 append_child(tail_prefix, self._parse_nested_atom())
1074 self._skip_whitespace()
1075 if not self._owned_body_follows():
1076 self.position = saved_tail
1077 break
1078 for prefix_node in tail_prefix:
1079 append_child(children, prefix_node)
1080 append_child(children, self._parse_nested_atom())
1081 body = Sequence(tuple(children))
1082 elif isinstance(node, Function):
1083 children = list(body.children)
1084 while self._call_delimiter_follows():
1085 append_child(children, self._parse_nested_atom())
1086 body = Sequence(tuple(children))
1087 if isinstance(node, Nary):
1088 return Nary(
1089 node.symbol,
1090 node.category,
1091 node.subscript,
1092 node.superscript,
1093 body,
1094 node.limit_modifier,
1095 )
1096 return Function(
1097 node.name,
1098 body,
1099 node.subscript,
1100 node.superscript,
1101 node.limit_style,
1102 node.limit_modifier,
1103 )
1104
1105 def _transparent_prefix_follows(self) -> bool:
1106 if self.position >= len(self.source):
1107 return False
1108 if self.source[self.position] == "~":
1109 return True
1110 if self.source[self.position] != "\\" or self.position + 1 >= len(self.source):
1111 return False
1112 following = self.source[self.position + 1]
1113 if not _is_command_letter(following):
1114 return following in SPACING_COMMANDS
1115 command, _ = _read_control_word(self.source, self.position)
1116 return (
1117 command in SPACING_COMMANDS
1118 or command in NO_OUTPUT_COMMANDS
1119 or command in {"mkern", "mskip", "hspace"}
1120 )
1121
1122 def _call_delimiter_follows(self) -> bool:
1123 self._skip_whitespace()
1124 if self.position >= len(self.source):
1125 return False
1126 if self.source[self.position] in "([|":
1127 return True
1128 if self.source.startswith((r"\|", r"\{"), self.position):
1129 return True
1130 if self.source[self.position] != "\\":
1131 return False
1132 if self.position + 1 >= len(self.source) or not _is_command_letter(
1133 self.source[self.position + 1]
1134 ):
1135 return False
1136 command, _ = _read_control_word(self.source, self.position)
1137 return command in BARE_DELIMITER_COMMAND_PAIRS
1138
1139 def _owned_body_follows(self) -> bool:
1140 if self.position >= len(self.source) or self._at_environment_boundary():
1141 return False
1142 if any(
1143 self._at_control_word(command)
1144 for command in {"right", "middle", "end"}
1145 ):
1146 return False
1147 char = self.source[self.position]
1148 if char in "}])&^_+-=<>/,;:!?":
1149 return False
1150 if char != "\\":
1151 return True
1152 if (
1153 self.position + 1 < len(self.source)
1154 and not _is_command_letter(self.source[self.position + 1])
1155 ):
1156 return self.source[self.position + 1] not in {"\\", ",", ";", ":", "!", " "}
1157 command, _ = _read_control_word(self.source, self.position)
1158 return command not in OPERATOR_BOUNDARY_COMMANDS
1159
1160 def _parse_delimited_expression(self, command: str = "left") -> Delimiter:
1161 left = self._parse_delimiter_token(f"\\{command}")
1162 segments: list[Sequence] = []
1163 separator = ""
1164 stop_commands = frozenset({
1165 *_MIDDLE_DELIMITER_COMMANDS,
1166 *_RIGHT_DELIMITER_COMMANDS,
1167 })
1168 while True:
1169 segments.append(
1170 self._parse_nested_sequence(stop_commands=stop_commands)
1171 )
1172 middle_command = next(
1173 (
1174 candidate
1175 for candidate in _MIDDLE_DELIMITER_COMMANDS
1176 if self._at_control_word(candidate)
1177 ),
1178 None,
1179 )
1180 if middle_command is not None:
1181 self._consume_control_word(middle_command)
1182 current = self._parse_delimiter_token(f"\\{middle_command}")
1183 if separator and separator != current:
1184 self._fail("One delimiter expression cannot mix middle characters")
1185 separator = current
1186 continue
1187 right = ""
1188 right_command = next(
1189 (
1190 candidate
1191 for candidate in _RIGHT_DELIMITER_COMMANDS
1192 if self._at_control_word(candidate)
1193 ),
1194 None,
1195 )
1196 if right_command is not None:
1197 self._consume_control_word(right_command)
1198 right = self._parse_delimiter_token(f"\\{right_command}")
1199 return Delimiter(left, right, tuple(segments), separator)
1200
1201 def _parse_big_symmetric_delimiter(self) -> Delimiter:
1202 delimiter = self._parse_delimiter_token("explicit big symmetric delimiter")
1203 body_start = self.position
1204 closing = self._find_matching_big_symmetric(delimiter, body_start)
1205 if closing is None:
1206 return Delimiter("", "", (Sequence(()), Sequence(())), delimiter)
1207 closing_start, after_closing = closing
1208 body = self._parse_fragment(self.source[body_start:closing_start])
1209 self.position = after_closing
1210 return Delimiter(delimiter, delimiter, (body,))
1211
1212 def _find_matching_big_symmetric(
1213 self,
1214 delimiter: str,
1215 start: int,
1216 ) -> tuple[int, int] | None:
1217 group_depth = 0
1218 cursor = start
1219 while cursor < len(self.source):
1220 char = self.source[cursor]
1221 if char == "{":
1222 group_depth += 1
1223 cursor += 1
1224 continue
1225 if char == "}":
1226 group_depth = max(0, group_depth - 1)
1227 cursor += 1
1228 continue
1229 if char != "\\" or cursor + 1 >= len(self.source):
1230 cursor += 1
1231 continue
1232 if not _is_command_letter(self.source[cursor + 1]):
1233 cursor += 2
1234 continue
1235 command, after_command = _read_control_word(self.source, cursor)
1236 if group_depth != 0 or command != _BIG_SYMMETRIC_COMMAND:
1237 cursor = after_command
1238 continue
1239 saved_position = self.position
1240 try:
1241 self.position = _skip_whitespace(self.source, after_command)
1242 candidate = self._parse_delimiter_token(
1243 "explicit big symmetric delimiter"
1244 )
1245 after_delimiter = self.position
1246 finally:
1247 self.position = saved_position
1248 if candidate == delimiter:
1249 return cursor, after_delimiter
1250 cursor = after_delimiter
1251 return None
1252
1253 def _parse_delimiter_token(self, owner: str) -> str:
1254 self._skip_whitespace()
1255 if self.position >= len(self.source):
1256 self._fail(f"{owner} requires a delimiter")
1257 char = self.source[self.position]
1258 if char != "\\":
1259 self.position += 1
1260 if char in "()[]|.⟦⟧":
1261 return "" if char == "." else char
1262 self._fail(f"Unsupported delimiter {char!r} after {owner}")
1263 command_start = self.position
1264 self.position += 1
1265 if self.position >= len(self.source):
1266 self._fail(f"{owner} requires a delimiter", position=command_start)
1267 if not _is_command_letter(self.source[self.position]):
1268 delimiter = self.source[self.position]
1269 self.position += 1
1270 if delimiter in "{}|":
1271 return "‖" if delimiter == "|" else delimiter
1272 self._fail(f"Unsupported delimiter command \\{delimiter}", position=command_start)
1273 command = self._read_control_word_body()
1274 self._skip_whitespace()
1275 if command not in DELIMITER_COMMANDS:
1276 self._fail(f"Unsupported delimiter command \\{command}", position=command_start)
1277 return DELIMITER_COMMANDS[command]
1278
1279 def _parse_or_literal_character_delimiter(self, opener: str) -> Node:
1280 closer = {"(": ")", "[": "]", "|": "|"}[opener]
1281 start = self.position
1282 if opener == closer:
1283 closing = self._find_symmetric_character(start, opener)
1284 else:
1285 closing = self._find_matching_character(start, opener, closer)
1286 if closing is None:
1287 self.position += 1
1288 return self._styled_text(opener, _ROMAN_STYLE)
1289 body_source = self.source[start + 1:closing]
1290 body = self._parse_fragment(body_source)
1291 self.position = closing + 1
1292 return Delimiter(opener, closer, (body,))
1293
1294 def _find_symmetric_character(self, start: int, delimiter: str) -> int | None:
1295 group_depth = 0
1296 cursor = start + 1
1297 while cursor < len(self.source):
1298 char = self.source[cursor]
1299 if char == "\\":
1300 cursor += 2
1301 continue
1302 if char == "{":
1303 group_depth += 1
1304 elif char == "}" and group_depth:
1305 group_depth -= 1
1306 elif char == delimiter and group_depth == 0:
1307 return cursor
1308 cursor += 1
1309 return None
1310
1311 def _try_escaped_brace_delimiter(self) -> Node | None:
1312 closing = self._find_matching_control_symbol("{", "}", self.position)
1313 if closing is None:
1314 return None
1315 body = self._parse_fragment(self.source[self.position:closing])
1316 self.position = closing + 2
1317 return Delimiter("{", "}", (body,))
1318
1319 def _try_command_delimiter(self, command: str, command_start: int) -> Node | None:
1320 pair = BARE_DELIMITER_COMMAND_PAIRS.get(command)
1321 if pair is None:
1322 return None
1323 closing_command, left, right = pair
1324 closing = self._find_matching_control_word(command, closing_command, self.position)
1325 if closing is None:
1326 return None
1327 body = self._parse_fragment(self.source[self.position:closing])
1328 _, after = _read_control_word(self.source, closing)
1329 self.position = _skip_whitespace(self.source, after)
1330 return Delimiter(left, right, (body,))
1331
1332 def _try_symmetric_command_delimiter(self, command_start: int) -> Node | None:
1333 closing = self.source.find("\\|", self.position)
1334 if closing < 0:
1335 return None
1336 body = self._parse_fragment(self.source[self.position:closing])
1337 self.position = closing + 2
1338 return Delimiter("‖", "‖", (body,))
1339
1340 def _try_symmetric_named_delimiter(self, command: str) -> Node | None:
1341 closing = self._find_symmetric_control_word(command, self.position)
1342 if closing is None:
1343 return None
1344 body = self._parse_fragment(self.source[self.position:closing])
1345 _, after = _read_control_word(self.source, closing)
1346 self.position = _skip_whitespace(self.source, after)
1347 delimiter = "‖" if command == "Vert" else "|"
1348 return Delimiter(delimiter, delimiter, (body,))
1349
1350 def _find_matching_character(self, start: int, opener: str, closer: str) -> int | None:
1351 expected_closers = [closer]
1352 group_depth = 0
1353 cursor = start + 1
1354 explicit_delimiter_commands = {
1355 "left",
1356 "right",
1357 "middle",
1358 _BIG_LEFT_COMMAND,
1359 _BIG_RIGHT_COMMAND,
1360 _BIG_MIDDLE_COMMAND,
1361 _BIG_SYMMETRIC_COMMAND,
1362 }
1363 while cursor < len(self.source):
1364 char = self.source[cursor]
1365 if char == "\\":
1366 if cursor + 1 >= len(self.source):
1367 return None
1368 if not _is_command_letter(self.source[cursor + 1]):
1369 cursor += 2
1370 continue
1371 command, cursor = _read_control_word(self.source, cursor)
1372 if group_depth == 0 and command in explicit_delimiter_commands:
1373 cursor = self._skip_scanned_delimiter_token(cursor)
1374 continue
1375 if char == "{":
1376 group_depth += 1
1377 cursor += 1
1378 continue
1379 if char == "}":
1380 if group_depth == 0:
1381 return None
1382 group_depth -= 1
1383 cursor += 1
1384 continue
1385 if group_depth == 0:
1386 if char in "([":
1387 expected_closers.append(")" if char == "(" else "]")
1388 elif char in ")]":
1389 if char != expected_closers[-1]:
1390 return None
1391 expected_closers.pop()
1392 if not expected_closers:
1393 return cursor
1394 cursor += 1
1395 return None
1396
1397 def _skip_scanned_delimiter_token(self, position: int) -> int:
1398 position = _skip_whitespace(self.source, position)
1399 if position >= len(self.source):
1400 return position
1401 if self.source[position] != "\\":
1402 return position + 1
1403 if position + 1 >= len(self.source):
1404 return len(self.source)
1405 if not _is_command_letter(self.source[position + 1]):
1406 return position + 2
1407 _, position = _read_control_word(self.source, position)
1408 return position
1409
1410 def _find_matching_control_symbol(
1411 self,
1412 opener: str,
1413 closer: str,
1414 start: int,
1415 ) -> int | None:
1416 depth = 1
1417 cursor = start
1418 while cursor + 1 < len(self.source):
1419 if self.source[cursor] != "\\":
1420 cursor += 1
1421 continue
1422 symbol = self.source[cursor + 1]
1423 if symbol == opener:
1424 depth += 1
1425 elif symbol == closer:
1426 depth -= 1
1427 if depth == 0:
1428 return cursor
1429 cursor += 2
1430 return None
1431
1432 def _find_matching_control_word(
1433 self,
1434 opener: str,
1435 closer: str,
1436 start: int,
1437 ) -> int | None:
1438 depth = 1
1439 cursor = start
1440 while cursor < len(self.source):
1441 if self.source[cursor] != "\\" or cursor + 1 >= len(self.source):
1442 cursor += 1
1443 continue
1444 if not _is_command_letter(self.source[cursor + 1]):
1445 cursor += 2
1446 continue
1447 command, end = _read_control_word(self.source, cursor)
1448 if command == opener:
1449 depth += 1
1450 elif command == closer:
1451 depth -= 1
1452 if depth == 0:
1453 return cursor
1454 cursor = end
1455 return None
1456
1457 def _find_symmetric_control_word(
1458 self,
1459 command: str,
1460 start: int,
1461 ) -> int | None:
1462 group_depth = 0
1463 cursor = start
1464 while cursor < len(self.source):
1465 char = self.source[cursor]
1466 if char == "{":
1467 group_depth += 1
1468 cursor += 1
1469 continue
1470 if char == "}":
1471 group_depth = max(0, group_depth - 1)
1472 cursor += 1
1473 continue
1474 if char != "\\" or cursor + 1 >= len(self.source):
1475 cursor += 1
1476 continue
1477 if not _is_command_letter(self.source[cursor + 1]):
1478 cursor += 2
1479 continue
1480 current, end = _read_control_word(self.source, cursor)
1481 if group_depth == 0 and current == command:
1482 return cursor
1483 cursor = end
1484 return None
1485
1486 def _parse_environment(self) -> Node:
1487 environment = self._parse_environment_name("\\begin")
1488 if (
1489 environment not in MATRIX_ENVIRONMENTS
1490 and environment not in EQUATION_ARRAY_ENVIRONMENTS
1491 ):
1492 self._fail(f"Unsupported formula environment {environment!r}")
1493
1494 if environment == "CD":
1495 return self._parse_cd_environment()
1496
1497 column_alignments: tuple[str, ...] = ()
1498 if environment in {"array", "subarray"}:
1499 raw = self._parse_raw_required_group(f"{environment} column specification")
1500 filtered = tuple(char for char in raw if char in "lcr")
1501 if not filtered:
1502 self._fail(f"Environment {environment!r} requires l/c/r columns")
1503 column_alignments = tuple("center" for _ in filtered)
1504 elif environment in {"alignat", "alignat*", "alignedat"}:
1505 raw = self._parse_raw_required_group(f"{environment} column count").strip()
1506 if not raw.isdigit() or int(raw) <= 0:
1507 self._fail(f"Environment {environment!r} requires a positive column count")
1508
1509 self.environment_stack.append(environment)
1510 try:
1511 rows = self._parse_environment_rows(environment)
1512 finally:
1513 self.environment_stack.pop()
1514
1515 if environment in MATRIX_ENVIRONMENTS:
1516 if not column_alignments and rows:
1517 column_alignments = tuple("center" for _ in rows[0])
1518 matrix = Matrix(environment, rows, column_alignments)
1519 delimiters = MATRIX_ENVIRONMENTS[environment]
1520 if delimiters is not None:
1521 return Delimiter(delimiters[0], delimiters[1], (Sequence((matrix,)),))
1522 return matrix
1523
1524 eq_rows: list[Sequence] = []
1525 for row in rows:
1526 children: list[Node] = []
1527 for index, cell in enumerate(row):
1528 if index:
1529 children.append(AlignmentPoint())
1530 children.extend(cell.children)
1531 eq_rows.append(Sequence(tuple(children)))
1532 equation_array = EquationArray(tuple(eq_rows))
1533 if environment == "cases":
1534 return Delimiter("{", "", (Sequence((equation_array,)),))
1535 if environment == "rcases":
1536 return Delimiter("", "}", (Sequence((equation_array,)),))
1537 return equation_array
1538
1539 def _parse_cd_environment(self) -> Matrix:
1540 ending = r"\end{CD}"
1541 closing = self.source.find(ending, self.position)
1542 if closing < 0:
1543 self._fail("Unclosed formula environment 'CD'")
1544 raw = self.source[self.position:closing]
1545 self.position = closing + len(ending)
1546 raw_rows = re.split(r"\\\\|\\cr(?![A-Za-z])", raw)
1547 if raw_rows and not raw_rows[-1].strip():
1548 raw_rows.pop()
1549 rows: list[tuple[Sequence, ...]] = []
1550 for raw_row in raw_rows:
1551 row = raw_row.strip()
1552 if not row:
1553 self._fail("Environment 'CD' cannot contain an empty row")
1554 horizontal_parts = re.split(r"@(>>>|<<<)", row)
1555 if len(horizontal_parts) > 1:
1556 cells: list[Sequence] = []
1557 for index, part in enumerate(horizontal_parts):
1558 if index % 2:
1559 cells.append(Sequence((Text(
1560 "→" if part == ">>>" else "←",
1561 _ROMAN_STYLE,
1562 ),)))
1563 else:
1564 if "@" in part:
1565 self._fail(
1566 "Environment 'CD' supports only @>>>, @<<<, @VVV, and @AAA"
1567 )
1568 cells.append(self._parse_fragment(part.strip()))
1569 rows.append(tuple(cells))
1570 continue
1571 vertical_tokens = re.findall(r"@(VVV|AAA)", row)
1572 vertical_remainder = re.sub(r"@(VVV|AAA)|[&\s]", "", row)
1573 if vertical_tokens and not vertical_remainder:
1574 cells: list[Sequence] = []
1575 for index, token in enumerate(vertical_tokens):
1576 if index:
1577 cells.append(Sequence(()))
1578 cells.append(Sequence((Text(
1579 "↓" if token == "VVV" else "↑",
1580 _ROMAN_STYLE,
1581 ),)))
1582 rows.append(tuple(cells))
1583 continue
1584 if "@" in row:
1585 self._fail("Environment 'CD' supports only @>>>, @<<<, @VVV, and @AAA")
1586 rows.append(tuple(self._parse_fragment(cell.strip()) for cell in row.split("&")))
1587 if not rows:
1588 self._fail("Environment 'CD' cannot be empty")
1589 column_count = max(len(row) for row in rows)
1590 padded_rows = tuple(
1591 (*row, *(Sequence(()) for _ in range(column_count - len(row))))
1592 for row in rows
1593 )
1594 return Matrix(
1595 "CD",
1596 padded_rows,
1597 tuple("center" for _ in range(column_count)),
1598 )
1599
1600 def _parse_environment_rows(
1601 self,
1602 environment: str,
1603 ) -> tuple[tuple[Sequence, ...], ...]:
1604 rows: list[tuple[Sequence, ...]] = []
1605 row: list[Sequence] = []
1606 while True:
1607 cell = self._parse_nested_sequence(stop_at_environment_boundary=True)
1608 row.append(cell)
1609 if self.position < len(self.source) and self.source[self.position] == "&":
1610 self.position += 1
1611 continue
1612 if self.source.startswith("\\\\", self.position):
1613 self.position += 2
1614 rows.append(tuple(row))
1615 row = []
1616 self._skip_whitespace()
1617 if self._at_control_word("end"):
1618 self._consume_environment_end(environment)
1619 break
1620 continue
1621 if self._at_control_word("cr"):
1622 self._consume_control_word("cr")
1623 rows.append(tuple(row))
1624 row = []
1625 if self._at_control_word("end"):
1626 self._consume_environment_end(environment)
1627 break
1628 continue
1629 if self._at_control_word("end"):
1630 rows.append(tuple(row))
1631 self._consume_environment_end(environment)
1632 break
1633 self._fail(f"Malformed environment {environment!r}")
1634 if not rows or not rows[0]:
1635 self._fail(f"Environment {environment!r} cannot be empty")
1636 if environment in MATRIX_ENVIRONMENTS:
1637 column_count = len(rows[0])
1638 if any(len(current) != column_count for current in rows):
1639 self._fail(f"Environment {environment!r} has inconsistent column counts")
1640 return tuple(rows)
1641
1642 def _parse_synthetic_environment(self, environment: str, raw: str) -> Node:
1643 parser = _LatexParser(
1644 f"\\begin{{{environment}}}{raw}\\end{{{environment}}}",
1645 display=self.display,
1646 inherited_style=self.current_style,
1647 )
1648 result = parser.parse()
1649 return self._single_node(result)
1650
1651 def _consume_environment_end(self, expected: str) -> None:
1652 self._consume_control_word("end")
1653 actual = self._parse_environment_name("\\end")
1654 if actual != expected:
1655 self._fail(
1656 f"Mismatched environment ending: expected {expected!r}, got {actual!r}"
1657 )
1658
1659 def _parse_environment_name(self, owner: str) -> str:
1660 raw = self._parse_raw_required_group(f"{owner} environment name")
1661 if _ENVIRONMENT_NAME_RE.fullmatch(raw) is None:
1662 self._fail(f"Invalid environment name {raw!r}")
1663 return raw
1664
1665 def _at_environment_boundary(self) -> bool:
1666 return (
1667 (self.position < len(self.source) and self.source[self.position] == "&")
1668 or self.source.startswith("\\\\", self.position)
1669 or self._at_control_word("cr")
1670 or self._at_control_word("end")
1671 )
1672
1673 def _parse_text_embedded_math(
1674 self,
1675 opener: str,
1676 closer: str,
1677 *,
1678 opened: bool = False,
1679 ) -> Sequence:
1680 start = self.position if opened else self.position + len(opener)
1681 if not opened:
1682 self.position += len(opener)
1683 closing = self.source.find(closer, self.position)
1684 if closing < 0:
1685 self._fail(f"Unclosed embedded math delimiter {opener!r}", position=start)
1686 raw = self.source[self.position:closing]
1687 self.position = closing + len(closer)
1688 embedded = _LatexParser(
1689 raw,
1690 display=False,
1691 inherited_style=self.current_style,
1692 ).parse()
1693 return Sequence((Styled(embedded, _MATH_MODE_RESET_STYLE),))
1694
1695 def _parse_fragment(self, raw: str) -> Sequence:
1696 if not raw:
1697 return Sequence(())
1698 return _LatexParser(
1699 raw,
1700 display=self.display,
1701 text_mode=bool(self.text_mode_depth),
1702 inherited_style=self.current_style,
1703 ).parse()
1704
1705 def _parse_spacing_length(self, command: str) -> str:
1706 self._skip_whitespace()
1707 match = _LENGTH_RE.match(self.source, self.position)
1708 if match is None:
1709 self._fail(f"\\{command} requires a numeric length with mu/em/ex/pt")
1710 self.position = match.end()
1711 self._skip_whitespace()
1712 return _space_from_length(match.group(0))
1713
1714 def _parse_color(self) -> str:
1715 raw = self._parse_raw_required_group("formula color").strip()
1716 if _COLOR_HEX_RE.fullmatch(raw):
1717 return raw[1:].upper()
1718 color = COLOR_NAMES.get(raw.lower())
1719 if color is None:
1720 self._fail(f"Unsupported formula color {raw!r}")
1721 return color
1722
1723 def _delimiter_from_raw(self, raw: str) -> str:
1724 raw = raw.strip()
1725 if raw in {"", "."}:
1726 return ""
1727 if raw in "()[]|{}⟨⟩⌊⌋⌈⌉⟦⟧":
1728 return raw
1729 control_symbols = {r"\{": "{", r"\}": "}", r"\|": "‖"}
1730 if raw in control_symbols:
1731 return control_symbols[raw]
1732 if raw.startswith("\\"):
1733 command = raw[1:]
1734 if command in DELIMITER_COMMANDS:
1735 return DELIMITER_COMMANDS[command]
1736 self._fail(f"Unsupported generalized-fraction delimiter {raw!r}")
1737
1738 def _roman_name(self, name: str) -> Sequence:
1739 return Sequence((Text(name, _ROMAN_STYLE),))
1740
1741 def _single_node(self, sequence: Sequence) -> Node:
1742 if len(sequence.children) == 1:
1743 return sequence.children[0]
1744 return sequence
1745
1746 def _styled_text(
1747 self,
1748 value: str,
1749 style: RunStyle | None = None,
1750 *,
1751 literal: bool = False,
1752 ) -> Text:
1753 return Text(
1754 value,
1755 merge_run_styles(self.current_style, style),
1756 literal=literal,
1757 )
1758
1759 def _current_infix_command(self) -> str | None:
1760 for command in _INFIX_FRACTIONS:
1761 if self._at_control_word(command):
1762 return command
1763 return None
1764
1765 def _at_control_word(self, expected: str) -> bool:
1766 prefix = f"\\{expected}"
1767 if not self.source.startswith(prefix, self.position):
1768 return False
1769 following = self.position + len(prefix)
1770 return following >= len(self.source) or not _is_command_letter(self.source[following])
1771
1772 def _consume_control_word(self, expected: str) -> None:
1773 if not self._at_control_word(expected):
1774 self._fail(f"Expected \\{expected}")
1775 self.position += len(expected) + 1
1776 self._skip_whitespace()
1777
1778 def _read_control_word_body(self) -> str:
1779 start = self.position
1780 while self.position < len(self.source) and _is_command_letter(self.source[self.position]):
1781 self.position += 1
1782 return self.source[start:self.position]
1783
1784 def _skip_whitespace(self) -> None:
1785 self.position = _skip_whitespace(self.source, self.position)
1786
1787 def _fail(self, message: str, *, position: int | None = None) -> None:
1788 offset = self.position if position is None else position
1789 start = max(0, offset - 16)
1790 end = min(len(self.source), offset + 16)
1791 context = self.source[start:end].replace("\n", " ")
1792 raise FormulaCompileError(f"{message} at offset {offset}: {context!r}")
1793
1794
1795 _STYLE_COLOR_COMMANDS = frozenset({"color", "textcolor"})
1796
1797
1798 class _ChemParser:
1799 """Parse the documented Microsoft 365 mhchem subset into shared AST nodes."""
1800
1801 _ARROWS = {
1802 "<-->": "⟷",
1803 "<=>": "⇌",
1804 "<->": "↔",
1805 "->": "→",
1806 "<-": "←",
1807 }
1808 _UNSUPPORTED_AT_LABEL_RE = re.compile(r"@[<>=-]+\{")
1809
1810 def __init__(self, source: str, *, display: bool) -> None:
1811 self.source = source
1812 self.position = 0
1813 self.display = display
1814
1815 def parse(self) -> Sequence:
1816 children: list[Node] = []
1817 while self.position < len(self.source):
1818 if self._UNSUPPORTED_AT_LABEL_RE.match(self.source, self.position):
1819 raise FormulaCompileError(
1820 "mhchem @{} arrow labels are unsupported; use [above][below]"
1821 )
1822 self._reject_unsupported_arrow()
1823 arrow = self._match_arrow()
1824 if arrow is not None:
1825 append_child(children, arrow)
1826 continue
1827 char = self.source[self.position]
1828 if char.isspace():
1829 while self.position < len(self.source) and self.source[self.position].isspace():
1830 self.position += 1
1831 append_child(children, Text(" ", _TEXT_STYLE))
1832 continue
1833 if char == "\\":
1834 parser = _LatexParser(self.source[self.position:], display=self.display)
1835 node = parser._parse_complete_atom()
1836 self.position += parser.position
1837 append_child(children, node)
1838 continue
1839 if char == "{":
1840 raw, self.position = _read_raw_group(self.source, self.position)
1841 if raw in {"(", ")", "[", "]"}:
1842 append_child(children, Text(raw, _ROMAN_STYLE))
1843 continue
1844 body = _ChemParser(raw, display=self.display).parse()
1845 append_child(children, body)
1846 continue
1847 if char in "([":
1848 append_child(children, self._parse_grouped_formula(char))
1849 continue
1850 if char.isupper():
1851 append_child(children, self._parse_element())
1852 continue
1853 if char.isdigit():
1854 append_child(children, self._parse_number(children))
1855 continue
1856 if char == "^" and self._standalone_marker():
1857 self.position += 1
1858 append_child(children, Text("↑", _ROMAN_STYLE))
1859 continue
1860 if char in "^_":
1861 if self._has_right_script_base(children):
1862 base = children.pop()
1863 append_child(children, self._parse_explicit_scripts(base))
1864 else:
1865 append_child(children, self._parse_isotope())
1866 continue
1867 if char in "+-":
1868 if self._is_bond_dash():
1869 self.position += 1
1870 append_child(children, Text("‒", _ROMAN_STYLE))
1871 elif (
1872 children
1873 and self.position > 0
1874 and not self.source[self.position - 1].isspace()
1875 ):
1876 self._attach_charge(children)
1877 else:
1878 self.position += 1
1879 append_child(children, Text("+" if char == "+" else "−", _ROMAN_STYLE))
1880 continue
1881 if char == "*":
1882 self.position += 1
1883 append_child(children, Text("·", _ROMAN_STYLE))
1884 continue
1885 if char == "=":
1886 self.position += 1
1887 append_child(children, Text("=", _ROMAN_STYLE))
1888 continue
1889 if char == "v" and self._standalone_marker():
1890 self.position += 1
1891 append_child(children, Text("↓", _ROMAN_STYLE))
1892 continue
1893 if char.islower():
1894 start = self.position
1895 while self.position < len(self.source) and self.source[self.position].islower():
1896 self.position += 1
1897 word = self.source[start:self.position]
1898 following = self.position
1899 while following < len(self.source) and self.source[following].isspace():
1900 following += 1
1901 is_variable = (
1902 len(word) == 1
1903 and following < len(self.source)
1904 and self.source[following].isupper()
1905 )
1906 style = None if is_variable else _ROMAN_STYLE
1907 append_child(children, Text(word, style))
1908 continue
1909 self.position += 1
1910 append_child(children, Text("−" if char == "-" else char, _ROMAN_STYLE))
1911 return Sequence(tuple(children))
1912
1913 def _parse_element(self) -> Node:
1914 start = self.position
1915 self.position += 1
1916 while self.position < len(self.source) and self.source[self.position].islower():
1917 self.position += 1
1918 base: Node = Text(self.source[start:self.position], _ROMAN_STYLE)
1919 if self.position < len(self.source) and self.source[self.position].isdigit():
1920 digits = self._read_digits()
1921 base = Script(base, subscript=Sequence((Text(digits, _ROMAN_STYLE),)))
1922 if self.position < len(self.source) and self.source[self.position] in "^_":
1923 base = self._parse_explicit_scripts(base)
1924 if (
1925 self.position < len(self.source)
1926 and self.source[self.position] in "+-"
1927 and not self._is_bond_dash()
1928 ):
1929 charge = self._read_charge()
1930 base = self._merge_script(
1931 base,
1932 superscript=Sequence((Text(charge, _ROMAN_STYLE),)),
1933 )
1934 return base
1935
1936 def _parse_number(self, children: list[Node]) -> Node:
1937 start = self.position
1938 numerator = self._read_digits()
1939 number: Node = Text(numerator, _ROMAN_STYLE)
1940 if self.position < len(self.source) and self.source[self.position] == "/":
1941 saved = self.position
1942 self.position += 1
1943 if self.position < len(self.source) and self.source[self.position].isdigit():
1944 denominator = self._read_digits()
1945 number = Fraction(
1946 Sequence((Text(numerator, _ROMAN_STYLE),)),
1947 Sequence((Text(denominator, _ROMAN_STYLE),)),
1948 )
1949 else:
1950 self.position = saved
1951 if self._is_coefficient_position(start):
1952 following = self.position
1953 while following < len(self.source) and self.source[following].isspace():
1954 following += 1
1955 if following < len(self.source) and self.source[following].isupper():
1956 self.position = following
1957 return Sequence((number, Text("\u2009", _SPACING_STYLE)))
1958 return number
1959
1960 def _parse_grouped_formula(self, opener: str) -> Node:
1961 closer = ")" if opener == "(" else "]"
1962 start = self.position + 1
1963 depth = 1
1964 cursor = start
1965 while cursor < len(self.source):
1966 if self.source[cursor] == opener:
1967 depth += 1
1968 elif self.source[cursor] == closer:
1969 depth -= 1
1970 if depth == 0:
1971 break
1972 cursor += 1
1973 if cursor >= len(self.source):
1974 raise FormulaCompileError(f"Unclosed chemical delimiter {opener!r}")
1975 body = _ChemParser(self.source[start:cursor], display=self.display).parse()
1976 self.position = cursor + 1
1977 base: Node = Sequence((
1978 Text(opener, _ROMAN_STYLE),
1979 *body.children,
1980 Text(closer, _ROMAN_STYLE),
1981 ))
1982 if self.position < len(self.source) and self.source[self.position].isdigit():
1983 digits = self._read_digits()
1984 if self.position < len(self.source) and self.source[self.position] in "+-":
1985 charge = digits + self._read_charge()
1986 base = Script(
1987 base,
1988 superscript=Sequence((Text(charge, _ROMAN_STYLE),)),
1989 )
1990 else:
1991 base = Script(base, subscript=Sequence((Text(digits, _ROMAN_STYLE),)))
1992 if self.position < len(self.source) and self.source[self.position] in "+-":
1993 charge = self._read_charge()
1994 base = self._merge_script(
1995 base,
1996 superscript=Sequence((Text(charge, _ROMAN_STYLE),)),
1997 )
1998 return base
1999
2000 def _parse_isotope(self) -> Prescript:
2001 subscript: Sequence | None = None
2002 superscript: Sequence | None = None
2003 while self.position < len(self.source) and self.source[self.position] in "^_":
2004 marker = self.source[self.position]
2005 self.position += 1
2006 raw = self._read_chem_script()
2007 value = self._chem_script_sequence(raw)
2008 if marker == "_":
2009 subscript = value
2010 else:
2011 superscript = value
2012 if self.position >= len(self.source) or not self.source[self.position].isupper():
2013 raise FormulaCompileError("Chemical pre-script requires an element base")
2014 base = self._parse_element()
2015 return Prescript(base, subscript=subscript, superscript=superscript)
2016
2017 def _parse_explicit_scripts(self, base: Node) -> Script:
2018 subscript: Sequence | None = None
2019 superscript: Sequence | None = None
2020 while self.position < len(self.source) and self.source[self.position] in "^_":
2021 marker = self.source[self.position]
2022 self.position += 1
2023 raw = self._read_chem_script()
2024 if (
2025 marker == "^"
2026 and self.position < len(self.source)
2027 and self.source[self.position] in "+-"
2028 ):
2029 raw += self._read_charge()
2030 value = self._chem_script_sequence(raw)
2031 if marker == "_":
2032 subscript = value
2033 else:
2034 superscript = value
2035 return self._merge_script(
2036 base,
2037 subscript=subscript,
2038 superscript=superscript,
2039 )
2040
2041 def _read_chem_script(self) -> str:
2042 if self.position >= len(self.source):
2043 raise FormulaCompileError("Chemical script is missing an argument")
2044 if self.source[self.position] == "{":
2045 raw, self.position = _read_raw_group(self.source, self.position)
2046 return raw
2047 char = self.source[self.position]
2048 self.position += 1
2049 return char
2050
2051 def _attach_charge(self, children: list[Node]) -> None:
2052 charge = self._read_charge()
2053 base = children.pop()
2054 children.append(self._merge_script(
2055 base,
2056 superscript=Sequence((Text(charge, _ROMAN_STYLE),)),
2057 ))
2058
2059 def _merge_script(
2060 self,
2061 base: Node,
2062 *,
2063 subscript: Sequence | None = None,
2064 superscript: Sequence | None = None,
2065 ) -> Script:
2066 if isinstance(base, Script):
2067 return Script(
2068 base.base,
2069 subscript or base.subscript,
2070 superscript or base.superscript,
2071 )
2072 return Script(base, subscript=subscript, superscript=superscript)
2073
2074 def _chem_script_sequence(self, raw: str) -> Sequence:
2075 if "\\" in raw:
2076 return _LatexParser(raw, display=self.display).parse()
2077 normalized = raw.replace("-", "−")
2078 style = None if len(normalized) == 1 and normalized.islower() else _ROMAN_STYLE
2079 return Sequence((Text(normalized, style),))
2080
2081 def _has_right_script_base(self, children: list[Node]) -> bool:
2082 if not children or self.position == 0:
2083 return False
2084 previous = self.source[self.position - 1]
2085 if previous.isspace() or previous in "+-=<>*/([{,;:":
2086 return False
2087 return not isinstance(children[-1], Limit)
2088
2089 def _is_coefficient_position(self, start: int) -> bool:
2090 cursor = start - 1
2091 while cursor >= 0 and self.source[cursor].isspace():
2092 cursor -= 1
2093 return cursor < 0 or self.source[cursor] in "+<>=-"
2094
2095 def _is_bond_dash(self) -> bool:
2096 return (
2097 self.position < len(self.source)
2098 and self.source[self.position] == "-"
2099 and self.position + 1 < len(self.source)
2100 and self.source[self.position + 1].isupper()
2101 )
2102
2103 def _read_charge(self) -> str:
2104 start = self.position
2105 while self.position < len(self.source) and self.source[self.position] in "+-":
2106 self.position += 1
2107 return self.source[start:self.position].replace("-", "−")
2108
2109 def _read_digits(self) -> str:
2110 start = self.position
2111 while self.position < len(self.source) and self.source[self.position].isdigit():
2112 self.position += 1
2113 return self.source[start:self.position]
2114
2115 def _match_arrow(self) -> Node | None:
2116 for token, symbol in self._ARROWS.items():
2117 if not self.source.startswith(token, self.position):
2118 continue
2119 self.position += len(token)
2120 upper = self._read_arrow_label()
2121 lower = self._read_arrow_label()
2122 return Limit(Text(symbol, _ROMAN_STYLE), lower=lower, upper=upper)
2123 return None
2124
2125 def _reject_unsupported_arrow(self) -> None:
2126 for token in ("<<=>>", "<=>>", "<<=>", "->>"):
2127 if self.source.startswith(token, self.position):
2128 raise FormulaCompileError(
2129 f"Unsupported chemical reaction arrow {token!r}"
2130 )
2131
2132 def _read_arrow_label(self) -> Sequence | None:
2133 if self.position >= len(self.source) or self.source[self.position] != "[":
2134 return None
2135 start = self.position + 1
2136 depth = 1
2137 brace_depth = 0
2138 cursor = start
2139 while cursor < len(self.source):
2140 char = self.source[cursor]
2141 if char == "\\":
2142 cursor += 2
2143 continue
2144 if char == "{":
2145 brace_depth += 1
2146 elif char == "}" and brace_depth:
2147 brace_depth -= 1
2148 elif brace_depth == 0 and char == "[":
2149 depth += 1
2150 elif brace_depth == 0 and char == "]":
2151 depth -= 1
2152 if depth == 0:
2153 raw = self.source[start:cursor]
2154 self.position = cursor + 1
2155 return _ChemParser(raw, display=self.display).parse()
2156 cursor += 1
2157 raise FormulaCompileError("Unclosed chemical arrow label")
2158
2159 def _standalone_marker(self) -> bool:
2160 before = self.position == 0 or self.source[self.position - 1].isspace()
2161 after_index = self.position + 1
2162 after = after_index >= len(self.source) or self.source[after_index].isspace()
2163 return before and after
2164
2165
2166 def parse_latex_formula(latex: str, *, display: bool) -> Sequence:
2167 """Validate, normalize, and parse one Microsoft 365 LaTeX expression."""
2168 if not isinstance(latex, str):
2169 raise FormulaCompileError("LaTeX formula must be a string")
2170 source = latex.strip()
2171 if not source:
2172 raise FormulaCompileError("LaTeX formula is empty")
2173 if any(not _xml_character_allowed(char) for char in source):
2174 raise FormulaCompileError("LaTeX formula contains an invalid XML character")
2175 prepared = _prepare_source(source)
2176 if not prepared:
2177 raise FormulaCompileError("LaTeX formula is empty after preprocessing")
2178 return _LatexParser(prepared, display=display).parse()
2179
2180
2181 __all__ = ["FormulaCompileError", "parse_latex_formula"]
2182
2182 lines PYTHON