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/__init__.py +38 -0
- latex2code/__main__.py +4 -0
- latex2code/api.py +461 -0
- latex2code/backends.py +88 -0
- latex2code/cli.py +230 -0
- latex2code/core.py +35 -0
- latex2code/errors.py +210 -0
- latex2code/normalize.py +207 -0
- latex2code/parsing.py +597 -0
- latex2code/printers.py +205 -0
- latex2code/py.typed +0 -0
- latex2code-0.2.0.dist-info/METADATA +481 -0
- latex2code-0.2.0.dist-info/RECORD +17 -0
- latex2code-0.2.0.dist-info/WHEEL +5 -0
- latex2code-0.2.0.dist-info/entry_points.txt +2 -0
- latex2code-0.2.0.dist-info/licenses/LICENSE +19 -0
- latex2code-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
)
|