latex2code 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
latex2code/parsing.py ADDED
@@ -0,0 +1,597 @@
1
+ import ast
2
+ import difflib
3
+ import re
4
+
5
+ import sympy as sp
6
+ from sympy.core.function import UndefinedFunction
7
+ from sympy.parsing.latex import parse_latex
8
+ from sympy.parsing.latex.errors import LaTeXParsingError
9
+
10
+ from .errors import (
11
+ InvalidLaTeXSyntaxError,
12
+ UnsupportedLaTeXFeatureError,
13
+ _line_and_column,
14
+ _syntax_location,
15
+ )
16
+ from .normalize import VALID_LATEX_COMMANDS, _normalize_latex_string
17
+
18
+ _LATEX_PARSE_ERRORS = (LaTeXParsingError,)
19
+ _LATEX_PARSE_ERRORS = (LaTeXParsingError,)
20
+
21
+
22
+ class _StableSigmoid(sp.Function):
23
+ nargs = 1
24
+
25
+
26
+ class _StableSoftplus(sp.Function):
27
+ nargs = 1
28
+
29
+
30
+ class _StableSwish(sp.Function):
31
+ nargs = 1
32
+
33
+
34
+ class _StableBeta(sp.Function):
35
+ nargs = 2
36
+
37
+
38
+ OPERATORNAME_FUNCTIONS = {
39
+ "erf": sp.erf,
40
+ "erfc": sp.erfc,
41
+ "gamma": sp.gamma,
42
+ "beta": _StableBeta,
43
+ "abs": sp.Abs,
44
+ "sign": sp.sign,
45
+ "floor": sp.floor,
46
+ "ceiling": sp.ceiling,
47
+ "acot": sp.acot,
48
+ "asec": sp.asec,
49
+ "acsc": sp.acsc,
50
+ "coth": sp.coth,
51
+ "sech": sp.sech,
52
+ "csch": sp.csch,
53
+ "hypot": lambda *args: sp.sqrt(sum(arg**2 for arg in args)),
54
+ "sigmoid": _StableSigmoid,
55
+ "softplus": _StableSoftplus,
56
+ "relu": lambda value: sp.Max(value, 0),
57
+ "logit": lambda value: sp.log(value / (1 - value)),
58
+ "softsign": lambda value: value / (1 + sp.Abs(value)),
59
+ "swish": _StableSwish,
60
+ }
61
+
62
+
63
+ def _find_matching_parenthesis(text: str, start_index: int) -> int | None:
64
+ """Finds the closing parenthesis matching the opening one at start_index."""
65
+ depth = 0
66
+ for idx in range(start_index, len(text)):
67
+ char = text[idx]
68
+ if char == "(":
69
+ depth += 1
70
+ elif char == ")":
71
+ depth -= 1
72
+ if depth == 0:
73
+ return idx
74
+ return None
75
+
76
+
77
+ def _split_top_level_commas(text: str) -> list[str]:
78
+ """Split a comma-separated argument list while preserving nested parentheses/brackets."""
79
+ parts: list[str] = []
80
+ current: list[str] = []
81
+ depth = 0
82
+ bracket_depth = 0
83
+ brace_depth = 0
84
+ for char in text:
85
+ if char == "(":
86
+ depth += 1
87
+ elif char == ")":
88
+ depth = max(0, depth - 1)
89
+ elif char == "[":
90
+ bracket_depth += 1
91
+ elif char == "]":
92
+ bracket_depth = max(0, bracket_depth - 1)
93
+ elif char == "{":
94
+ brace_depth += 1
95
+ elif char == "}":
96
+ brace_depth = max(0, brace_depth - 1)
97
+ if char == "," and depth == 0 and bracket_depth == 0 and brace_depth == 0:
98
+ parts.append("".join(current).strip())
99
+ current = []
100
+ continue
101
+ current.append(char)
102
+ if current:
103
+ parts.append("".join(current).strip())
104
+ return [part for part in parts if part != ""]
105
+
106
+
107
+ def _parse_latex(expression: str) -> sp.Expr:
108
+ return parse_latex(expression, strict=True, backend="antlr")
109
+
110
+
111
+ def _parse_operatorname_expression(latex_str: str) -> sp.Expr:
112
+ r"""Parses operatorname-style functions such as \operatorname{erf}(x)."""
113
+ expression = _normalize_latex_string(latex_str)
114
+ replacements: dict[str, sp.Expr] = {}
115
+
116
+ while True:
117
+ match = re.search(r"\\operatorname\s*\{([^}]+)\}", expression)
118
+ if not match:
119
+ break
120
+
121
+ func_name = match.group(1)
122
+ open_index = match.end()
123
+ while open_index < len(expression) and expression[open_index].isspace():
124
+ open_index += 1
125
+ if open_index >= len(expression) or expression[open_index] != "(":
126
+ break
127
+
128
+ close_index = _find_matching_parenthesis(expression, open_index)
129
+ if close_index is None:
130
+ break
131
+
132
+ args_text = expression[open_index + 1 : close_index]
133
+ if args_text:
134
+ arg_exprs = [
135
+ _parse_operatorname_expression(_normalize_latex_string(arg.strip()))
136
+ for arg in _split_top_level_commas(args_text)
137
+ ]
138
+ else:
139
+ arg_exprs = [sp.Symbol("x")]
140
+
141
+ if func_name == "hypot":
142
+ value = (
143
+ sp.sqrt(sum(arg**2 for arg in arg_exprs))
144
+ if arg_exprs
145
+ else sp.Symbol("x")
146
+ )
147
+ elif (
148
+ func_name in {"sigmoid", "softplus", "relu", "logit", "softsign", "swish"}
149
+ and len(arg_exprs) == 1
150
+ ):
151
+ value = OPERATORNAME_FUNCTIONS[func_name](arg_exprs[0])
152
+ else:
153
+ factory = OPERATORNAME_FUNCTIONS.get(func_name, sp.Function(func_name))
154
+ if len(arg_exprs) == 1:
155
+ value = factory(arg_exprs[0])
156
+ else:
157
+ value = factory(*arg_exprs)
158
+
159
+ replacement_name = next(
160
+ (
161
+ chr(codepoint)
162
+ for codepoint in range(ord("A"), ord("Z") + 1)
163
+ if chr(codepoint) not in expression
164
+ ),
165
+ None,
166
+ )
167
+ if replacement_name is None:
168
+ raise UnsupportedLaTeXFeatureError(
169
+ latex_str, "too many uppercase symbols to safely normalize operatorname"
170
+ )
171
+ replacements[replacement_name] = value
172
+ expression = (
173
+ expression[: match.start()]
174
+ + replacement_name
175
+ + expression[close_index + 1 :]
176
+ )
177
+ parsed = _parse_latex(expression)
178
+ for symbol_name, value in replacements.items():
179
+ parsed = parsed.subs(sp.Symbol(symbol_name), value)
180
+ return parsed
181
+
182
+
183
+ def _split_environment_rows(content: str, content_offset: int) -> list[tuple[str, int]]:
184
+ rows = []
185
+ start = 0
186
+ for separator in re.finditer(r"\\\\{1,2}", content):
187
+ segment = content[start : separator.start()]
188
+ leading = len(segment) - len(segment.lstrip())
189
+ row = segment.strip()
190
+ if row:
191
+ rows.append((row, content_offset + start + leading))
192
+ start = separator.end()
193
+ segment = content[start:]
194
+ leading = len(segment) - len(segment.lstrip())
195
+ row = segment.strip()
196
+ if row:
197
+ rows.append((row, content_offset + start + leading))
198
+ return rows
199
+
200
+
201
+ def _parse_latex_segment(segment: str, raw_expression: str, offset: int) -> sp.Expr:
202
+ try:
203
+ return _parse_latex(segment)
204
+ except _LATEX_PARSE_ERRORS as exc:
205
+ relative_location = _syntax_location(segment, exc)
206
+ if relative_location[0] is None or relative_location[1] is None:
207
+ relative_location = (1, 1)
208
+ assert relative_location[0] is not None
209
+ assert relative_location[1] is not None
210
+ relative_index = (
211
+ sum(
212
+ len(line) + 1
213
+ for line in segment.splitlines()[: relative_location[0] - 1]
214
+ )
215
+ + relative_location[1]
216
+ - 1
217
+ )
218
+ raise InvalidLaTeXSyntaxError(
219
+ raw_expression,
220
+ original_error=exc,
221
+ location=_line_and_column(raw_expression, offset + relative_index),
222
+ ) from exc
223
+
224
+
225
+ def _parse_matrix_environment(latex_str: str) -> sp.Matrix | None:
226
+ """Detects and parses matrix-style environments directly into a SymPy Matrix."""
227
+ pattern = (
228
+ r"\\begin\{(?P<environment>pmatrix|matrix|bmatrix|Bmatrix|vmatrix|Vmatrix|array|aligned|align|align\*|gathered|eqnarray|eqnarray\*)\}"
229
+ r"(?:\{.*?\})?"
230
+ r"(?P<content>.*?)\\end\{(?P=environment)\}"
231
+ )
232
+ stripped_expression = latex_str.strip()
233
+ expression_offset = len(latex_str) - len(latex_str.lstrip())
234
+ match = re.fullmatch(pattern, stripped_expression, re.DOTALL)
235
+ if not match:
236
+ return None
237
+
238
+ content = match.group("content")
239
+ content_offset = expression_offset + match.start("content")
240
+ rows = _split_environment_rows(content, content_offset)
241
+ matrix_rows = []
242
+ row_offsets = []
243
+ try:
244
+ for row, row_offset in rows:
245
+ elements = []
246
+ segment_start = 0
247
+ for segment in row.split("&"):
248
+ raw_start = row.find(segment, segment_start)
249
+ segment_start = raw_start + len(segment) + 1
250
+ leading = len(segment) - len(segment.lstrip())
251
+ elem = segment.strip()
252
+ if not elem:
253
+ continue
254
+ elem_offset = row_offset + raw_start + leading
255
+ if elem.startswith("="):
256
+ elem = elem[1:].strip()
257
+ elem_offset += 1
258
+ if elem.startswith("=="):
259
+ elem = elem[2:].strip()
260
+ elem_offset += 2
261
+ elements.append((elem, elem_offset))
262
+ if not elements:
263
+ continue
264
+ parsed_elements = [
265
+ _parse_latex_segment(elem, latex_str, elem_offset)
266
+ for elem, elem_offset in elements
267
+ if elem
268
+ ]
269
+ if not parsed_elements:
270
+ continue
271
+ matrix_rows.append(parsed_elements)
272
+ row_offsets.append(row_offset)
273
+ if not matrix_rows:
274
+ return None
275
+ expected_width = len(matrix_rows[0])
276
+ inconsistent_row = next(
277
+ (
278
+ index
279
+ for index, row in enumerate(matrix_rows[1:], start=1)
280
+ if len(row) != expected_width
281
+ ),
282
+ None,
283
+ )
284
+ if inconsistent_row is not None:
285
+ raise InvalidLaTeXSyntaxError(
286
+ latex_str,
287
+ hint="Every row in a matrix or array must contain the same number of entries.",
288
+ location=_line_and_column(latex_str, row_offsets[inconsistent_row]),
289
+ )
290
+ return sp.Matrix(matrix_rows)
291
+ except InvalidLaTeXSyntaxError:
292
+ raise
293
+
294
+
295
+ def _parse_cases_environment(latex_str: str) -> sp.Expr | None:
296
+ r"""Parses piecewise expressions from \begin{cases} ... \end{cases} into SymPy Piecewise."""
297
+ pattern = r"\\begin\{cases\}(.*?)\\end\{cases\}"
298
+ stripped_expression = latex_str.strip()
299
+ expression_offset = len(latex_str) - len(latex_str.lstrip())
300
+ match = re.fullmatch(pattern, stripped_expression, re.DOTALL)
301
+ if not match:
302
+ return None
303
+
304
+ content = match.group(1)
305
+ rows = _split_environment_rows(
306
+ content,
307
+ expression_offset + match.start(1),
308
+ )
309
+ if not rows:
310
+ return None
311
+
312
+ pieces = []
313
+ try:
314
+ for row, row_offset in rows:
315
+ if "&" not in row:
316
+ raise InvalidLaTeXSyntaxError(
317
+ latex_str,
318
+ hint="Each cases row must separate its value and condition with '&'.",
319
+ location=_line_and_column(latex_str, row_offset),
320
+ )
321
+ separator = row.index("&")
322
+ expression_segment = row[:separator]
323
+ condition_segment = row[separator + 1 :]
324
+ expression_leading = len(expression_segment) - len(
325
+ expression_segment.lstrip()
326
+ )
327
+ condition_leading = len(condition_segment) - len(condition_segment.lstrip())
328
+ expr_part = expression_segment.strip()
329
+ cond_part = condition_segment.strip()
330
+ expr = _parse_latex_segment(
331
+ expr_part,
332
+ latex_str,
333
+ row_offset + expression_leading,
334
+ )
335
+ cond = (
336
+ True
337
+ if cond_part.lower() in {"otherwise", "else"}
338
+ else _parse_latex_segment(
339
+ cond_part,
340
+ latex_str,
341
+ row_offset + separator + 1 + condition_leading,
342
+ )
343
+ )
344
+ pieces.append((expr, cond))
345
+ if not pieces:
346
+ return None
347
+
348
+ return sp.Piecewise(*pieces, evaluate=False)
349
+ except InvalidLaTeXSyntaxError:
350
+ raise
351
+
352
+
353
+ PYTHON_FUNCTIONS = {
354
+ "Abs": sp.Abs,
355
+ "abs": sp.Abs,
356
+ "max": sp.Max,
357
+ "min": sp.Min,
358
+ "erf": sp.erf,
359
+ "erfc": sp.erfc,
360
+ "gamma": sp.gamma,
361
+ "Gamma": sp.gamma,
362
+ "beta": _StableBeta,
363
+ "floor": sp.floor,
364
+ "ceiling": sp.ceiling,
365
+ "sin": sp.sin,
366
+ "cos": sp.cos,
367
+ "tan": sp.tan,
368
+ "cot": sp.cot,
369
+ "sec": sp.sec,
370
+ "csc": sp.csc,
371
+ "asin": sp.asin,
372
+ "acos": sp.acos,
373
+ "atan": sp.atan,
374
+ "acot": sp.acot,
375
+ "asec": sp.asec,
376
+ "acsc": sp.acsc,
377
+ "sinh": sp.sinh,
378
+ "cosh": sp.cosh,
379
+ "tanh": sp.tanh,
380
+ "coth": sp.coth,
381
+ "sech": sp.sech,
382
+ "csch": sp.csch,
383
+ "asinh": sp.asinh,
384
+ "acosh": sp.acosh,
385
+ "atanh": sp.atanh,
386
+ "exp": sp.exp,
387
+ "log": sp.log,
388
+ "ln": sp.log,
389
+ "sqrt": sp.sqrt,
390
+ "sigmoid": _StableSigmoid,
391
+ "softplus": _StableSoftplus,
392
+ "relu": lambda value: sp.Max(value, 0),
393
+ "logit": lambda value: sp.log(value / (1 - value)),
394
+ "softsign": lambda value: value / (1 + sp.Abs(value)),
395
+ "swish": _StableSwish,
396
+ }
397
+
398
+
399
+ def _python_ast_to_sympy(node: ast.AST) -> sp.Expr:
400
+ if isinstance(node, ast.Expression):
401
+ return _python_ast_to_sympy(node.body)
402
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
403
+ return sp.sympify(node.value)
404
+ if isinstance(node, ast.Name):
405
+ if node.id in {"oo", "infty"}:
406
+ return sp.oo
407
+ return sp.Symbol(node.id)
408
+ if isinstance(node, ast.BinOp):
409
+ left = _python_ast_to_sympy(node.left)
410
+ right = _python_ast_to_sympy(node.right)
411
+ operations = {
412
+ ast.Add: lambda: left + right,
413
+ ast.Sub: lambda: left - right,
414
+ ast.Mult: lambda: left * right,
415
+ ast.Div: lambda: left / right,
416
+ ast.Pow: lambda: left**right,
417
+ ast.BitXor: lambda: left**right,
418
+ ast.Mod: lambda: left % right,
419
+ }
420
+ operation = operations.get(type(node.op))
421
+ if operation is not None:
422
+ return operation()
423
+ if isinstance(node, ast.UnaryOp):
424
+ operand = _python_ast_to_sympy(node.operand)
425
+ if isinstance(node.op, ast.UAdd):
426
+ return operand
427
+ if isinstance(node.op, ast.USub):
428
+ return -operand
429
+ if isinstance(node.op, ast.Not):
430
+ return sp.Not(operand)
431
+ if isinstance(node, ast.BoolOp):
432
+ values = [_python_ast_to_sympy(value) for value in node.values]
433
+ if isinstance(node.op, ast.And):
434
+ return sp.And(*values)
435
+ if isinstance(node.op, ast.Or):
436
+ return sp.Or(*values)
437
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
438
+ function = PYTHON_FUNCTIONS.get(node.func.id)
439
+ if function is not None and not node.keywords:
440
+ return function(*[_python_ast_to_sympy(arg) for arg in node.args])
441
+ if (
442
+ isinstance(node, ast.Compare)
443
+ and len(node.ops) == 1
444
+ and len(node.comparators) == 1
445
+ ):
446
+ left = _python_ast_to_sympy(node.left)
447
+ right = _python_ast_to_sympy(node.comparators[0])
448
+ comparisons = {
449
+ ast.Eq: sp.Eq,
450
+ ast.NotEq: sp.Ne,
451
+ ast.Lt: sp.Lt,
452
+ ast.LtE: sp.Le,
453
+ ast.Gt: sp.Gt,
454
+ ast.GtE: sp.Ge,
455
+ }
456
+ comparison = comparisons.get(type(node.ops[0]))
457
+ if comparison is not None:
458
+ return comparison(left, right)
459
+ raise ValueError(
460
+ "Expression contains syntax unsupported by the safe fallback parser."
461
+ )
462
+
463
+
464
+ def _parse_nth_root_expression(latex_str: str) -> sp.Expr | None:
465
+ """Parses expressions such as \\sqrt[n]{x} into x**(1/n)."""
466
+ pattern = r"\\sqrt\s*\[(?P<index>.+?)\]\s*\{(?P<argument>.+)\}"
467
+ match = re.fullmatch(pattern, latex_str.strip(), re.DOTALL)
468
+ if not match:
469
+ return None
470
+
471
+ index_expr = _parse_latex(match.group("index").strip())
472
+ argument_expr = _parse_latex(match.group("argument").strip())
473
+ return argument_expr ** (1 / index_expr)
474
+
475
+
476
+ def _replace_extrema_calls(expression: str) -> tuple[str, dict[str, sp.Expr]]:
477
+ replacements: dict[str, sp.Expr] = {}
478
+ while match := re.search(r"\\(max|min)(?![A-Za-z])\s*\(", expression):
479
+ open_index = expression.find("(", match.start())
480
+ close_index = _find_matching_parenthesis(expression, open_index)
481
+ if close_index is None:
482
+ break
483
+ arguments = _split_top_level_commas(expression[open_index + 1 : close_index])
484
+ parsed_arguments = [_parse_expression(argument) for argument in arguments]
485
+ function = sp.Max if match.group(1) == "max" else sp.Min
486
+ value = function(*parsed_arguments)
487
+ replacement_name = next(
488
+ (
489
+ chr(codepoint)
490
+ for codepoint in range(ord("A"), ord("Z") + 1)
491
+ if chr(codepoint) not in expression
492
+ ),
493
+ None,
494
+ )
495
+ if replacement_name is None:
496
+ raise UnsupportedLaTeXFeatureError(
497
+ expression, "too many uppercase symbols to safely normalize extrema"
498
+ )
499
+ replacements[replacement_name] = value
500
+ expression = (
501
+ expression[: match.start()]
502
+ + replacement_name
503
+ + expression[close_index + 1 :]
504
+ )
505
+ return expression, replacements
506
+
507
+
508
+ def _parse_expression(latex_str: str) -> sp.Expr:
509
+ """Use the restricted Python fallback only for structurally plain call syntax."""
510
+ normalized = _normalize_latex_string(latex_str)
511
+ if normalized in {"oo", "infty"}:
512
+ return sp.oo
513
+
514
+ nth_root_expr = _parse_nth_root_expression(normalized)
515
+ if nth_root_expr is not None:
516
+ return nth_root_expr
517
+ normalized, extrema_replacements = _replace_extrema_calls(normalized)
518
+
519
+ plain_python_calls = (
520
+ "\\" not in normalized
521
+ and re.search(r"\b[A-Za-z_]\w*\s*\(", normalized) is not None
522
+ )
523
+ if plain_python_calls:
524
+ try:
525
+ parsed = ast.parse(normalized, mode="eval")
526
+ except SyntaxError:
527
+ pass
528
+ else:
529
+ try:
530
+ expression = _python_ast_to_sympy(parsed)
531
+ except ValueError as exc:
532
+ raise InvalidLaTeXSyntaxError(
533
+ latex_str,
534
+ original_error=exc,
535
+ ) from exc
536
+ return expression.xreplace(
537
+ {sp.Symbol(name): value for name, value in extrema_replacements.items()}
538
+ )
539
+ try:
540
+ expression = _parse_latex(normalized)
541
+ except _LATEX_PARSE_ERRORS as parser_error:
542
+ if not plain_python_calls:
543
+ raise
544
+ try:
545
+ parsed = ast.parse(normalized, mode="eval")
546
+ except SyntaxError:
547
+ raise parser_error
548
+ try:
549
+ expression = _python_ast_to_sympy(parsed)
550
+ except ValueError as exc:
551
+ raise InvalidLaTeXSyntaxError(
552
+ latex_str,
553
+ original_error=exc,
554
+ ) from exc
555
+ return expression.xreplace(
556
+ {sp.Symbol(name): value for name, value in extrema_replacements.items()}
557
+ )
558
+
559
+
560
+ def _ordered_free_symbols(expr: sp.Expr, raw_latex: str) -> list[str]:
561
+ """Select arguments in their first-appearance order in the source."""
562
+ free_symbols = [str(symbol) for symbol in expr.free_symbols]
563
+ ordered = []
564
+ seen = set()
565
+ for token in re.findall(r"[A-Za-z_]\w*", raw_latex):
566
+ if token in free_symbols and token not in seen:
567
+ ordered.append(token)
568
+ seen.add(token)
569
+ ordered.extend(symbol for symbol in free_symbols if symbol not in seen)
570
+ return ordered
571
+
572
+
573
+ def _check_for_undefined_commands(expr: sp.Expr, raw_str: str) -> None:
574
+ """Check if the raw LaTeX string or parsed expression contains unsupported commands."""
575
+ unhandled_commands = re.findall(r"\\[A-Za-z]+", raw_str)
576
+
577
+ for cmd in unhandled_commands:
578
+ if cmd not in VALID_LATEX_COMMANDS:
579
+ match = difflib.get_close_matches(
580
+ cmd, VALID_LATEX_COMMANDS, n=1, cutoff=0.65
581
+ )
582
+ suggestion = repr(match[0]) if match else None
583
+ raise UnsupportedLaTeXFeatureError(raw_str, cmd, suggestion=suggestion)
584
+
585
+ for func in expr.atoms(sp.Function):
586
+ if isinstance(func.func, UndefinedFunction):
587
+ name = func.func.__name__
588
+ known_functions = set(PYTHON_FUNCTIONS) | set(OPERATORNAME_FUNCTIONS)
589
+ match = difflib.get_close_matches(name, known_functions, n=1, cutoff=0.65)
590
+ suggestion = (
591
+ f"use {match[0]!r} or add support for {name!r}"
592
+ if match
593
+ else f"{name!r} is not in the supported function set"
594
+ )
595
+ raise UnsupportedLaTeXFeatureError(
596
+ raw_str, name, "function", suggestion=suggestion
597
+ )