math2tex 1.0.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.
- math2tex/__init__.py +84 -0
- math2tex/ast_nodes.py +205 -0
- math2tex/cli.py +60 -0
- math2tex/config.py +213 -0
- math2tex/formatter.py +504 -0
- math2tex/lexer.py +160 -0
- math2tex/macros.py +408 -0
- math2tex/parser.py +967 -0
- math2tex/tests_legacy.py +195 -0
- math2tex-1.0.0.dist-info/METADATA +135 -0
- math2tex-1.0.0.dist-info/RECORD +14 -0
- math2tex-1.0.0.dist-info/WHEEL +4 -0
- math2tex-1.0.0.dist-info/entry_points.txt +2 -0
- math2tex-1.0.0.dist-info/licenses/LICENSE +21 -0
math2tex/formatter.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
math2tex.formatter — LaTeX code generation via AST visitor.
|
|
3
|
+
|
|
4
|
+
Converts a typed AST tree into a publication-ready LaTeX string.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .config import (
|
|
11
|
+
ACCENT_FUNCTIONS, BUILTIN_LATEX_FUNCS, Config, FONT_FUNCTIONS,
|
|
12
|
+
GREEK_LETTERS, Prec, SPECIAL_CONSTANTS, WORD_ADD_OPS, WORD_MULT_OPS,
|
|
13
|
+
WORD_RELATIONS, _REL_OPS,
|
|
14
|
+
)
|
|
15
|
+
from .ast_nodes import (
|
|
16
|
+
Abs, BigOp, BinOp, Derivative, Factorial, Fraction, Func, Group,
|
|
17
|
+
InnerProduct, Integral, IntervalNode, MatrixNode, Node, Norm, Num,
|
|
18
|
+
PiecewiseNode, Power, Prime, SetBuilder, Subscript, TextLiteral,
|
|
19
|
+
UnOp, Var,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LatexFormatter:
|
|
24
|
+
def __init__(self, text_map: Optional[dict] = None,
|
|
25
|
+
align_mode: bool = False):
|
|
26
|
+
self.text_map = text_map or {}
|
|
27
|
+
self.align_mode = align_mode
|
|
28
|
+
self.in_integral = False
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# Helpers
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def get_vars(self, node: Node) -> List[str]:
|
|
35
|
+
vars_list: List[str] = []
|
|
36
|
+
|
|
37
|
+
def visit(n: Node) -> None:
|
|
38
|
+
if isinstance(n, Var) and len(n.name) == 1 and n.name.isalpha():
|
|
39
|
+
if n.name not in vars_list:
|
|
40
|
+
vars_list.append(n.name)
|
|
41
|
+
elif isinstance(n, BinOp):
|
|
42
|
+
visit(n.left); visit(n.right)
|
|
43
|
+
elif isinstance(n, UnOp):
|
|
44
|
+
visit(n.expr)
|
|
45
|
+
elif isinstance(n, Fraction):
|
|
46
|
+
visit(n.num); visit(n.den)
|
|
47
|
+
elif isinstance(n, Power):
|
|
48
|
+
visit(n.base); visit(n.exp)
|
|
49
|
+
elif isinstance(n, Subscript):
|
|
50
|
+
visit(n.base); visit(n.sub)
|
|
51
|
+
elif isinstance(n, Func):
|
|
52
|
+
for a in n.args:
|
|
53
|
+
visit(a)
|
|
54
|
+
|
|
55
|
+
visit(node)
|
|
56
|
+
return vars_list
|
|
57
|
+
|
|
58
|
+
def _is_complex(self, node: Node) -> bool:
|
|
59
|
+
if isinstance(node, Fraction):
|
|
60
|
+
return True
|
|
61
|
+
if isinstance(node, (Power, Subscript)):
|
|
62
|
+
return self._is_complex(node.base)
|
|
63
|
+
if isinstance(node, (BigOp, Integral)):
|
|
64
|
+
return True
|
|
65
|
+
if isinstance(node, BinOp) and node.op in ('+', '-', '+-', '-+'):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def format_child(self, parent_prec: int, child: Node) -> str:
|
|
70
|
+
child_str = self.format(child)
|
|
71
|
+
if child.precedence < parent_prec:
|
|
72
|
+
if self._is_complex(child):
|
|
73
|
+
return f'\\left({child_str}\\right)'
|
|
74
|
+
return f'({child_str})'
|
|
75
|
+
return child_str
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Entry point
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def format(self, node: Node, is_root: bool = False) -> str:
|
|
82
|
+
if isinstance(node, BinOp):
|
|
83
|
+
return self.visit_BinOp(node, is_root=is_root)
|
|
84
|
+
method = getattr(self, f'visit_{type(node).__name__}',
|
|
85
|
+
self.generic_visit)
|
|
86
|
+
return method(node)
|
|
87
|
+
|
|
88
|
+
def generic_visit(self, node: Node) -> str:
|
|
89
|
+
raise NotImplementedError(
|
|
90
|
+
f'No formatter for {type(node).__name__}'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
# Atoms
|
|
95
|
+
# ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def visit_Num(self, node: Num) -> str:
|
|
98
|
+
val = node.val.lower()
|
|
99
|
+
if 'e' in val:
|
|
100
|
+
base, exp = val.split('e')
|
|
101
|
+
if exp.startswith('+'):
|
|
102
|
+
exp = exp[1:]
|
|
103
|
+
return f'{base} \\times 10^{{{exp}}}'
|
|
104
|
+
if '.' not in val and len(val) >= 5:
|
|
105
|
+
parts: List[str] = []
|
|
106
|
+
v = val
|
|
107
|
+
while len(v) > 3:
|
|
108
|
+
parts.insert(0, v[-3:])
|
|
109
|
+
v = v[:-3]
|
|
110
|
+
if v:
|
|
111
|
+
parts.insert(0, v)
|
|
112
|
+
return '\\,'.join(parts)
|
|
113
|
+
return node.val
|
|
114
|
+
|
|
115
|
+
def visit_Var(self, node: Var) -> str:
|
|
116
|
+
v = node.name
|
|
117
|
+
if not v:
|
|
118
|
+
return ''
|
|
119
|
+
if v == '...':
|
|
120
|
+
return r'\ldots'
|
|
121
|
+
if v in self.text_map:
|
|
122
|
+
return f'\\text{{{self.text_map[v]}}}'
|
|
123
|
+
if v in SPECIAL_CONSTANTS:
|
|
124
|
+
return SPECIAL_CONSTANTS[v]
|
|
125
|
+
if v in GREEK_LETTERS:
|
|
126
|
+
return f'\\{v}'
|
|
127
|
+
if v in BUILTIN_LATEX_FUNCS:
|
|
128
|
+
return f'\\{v}'
|
|
129
|
+
if len(v) >= 2 and v[0] == 'd':
|
|
130
|
+
rest = v[1:]
|
|
131
|
+
if rest in GREEK_LETTERS or (len(rest) == 1 and rest.isalpha()):
|
|
132
|
+
if self.in_integral:
|
|
133
|
+
return (f'\\dd{{\\{rest}}}'
|
|
134
|
+
if rest in GREEK_LETTERS
|
|
135
|
+
else f'\\dd{{{rest}}}')
|
|
136
|
+
return (f'd \\{rest}'
|
|
137
|
+
if rest in GREEK_LETTERS
|
|
138
|
+
else f'd {rest}')
|
|
139
|
+
return v
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Operations
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def visit_BinOp(self, node: BinOp,
|
|
146
|
+
is_root: bool = False) -> str:
|
|
147
|
+
if node.op == 'choose':
|
|
148
|
+
return (f'\\binom{{{self.format(node.left)}}}'
|
|
149
|
+
f'{{{self.format(node.right)}}}')
|
|
150
|
+
|
|
151
|
+
left = self.format_child(node.precedence, node.left)
|
|
152
|
+
right = self.format_child(node.precedence, node.right)
|
|
153
|
+
|
|
154
|
+
# ---- Implicit / explicit multiplication ----
|
|
155
|
+
if node.op in ('*', ' '):
|
|
156
|
+
left_s = left.strip()
|
|
157
|
+
right_s = right.strip()
|
|
158
|
+
if not left_s or not right_s:
|
|
159
|
+
return f'{left_s}{right_s}'
|
|
160
|
+
if left_s == r'\exp':
|
|
161
|
+
return f'e^{{{right_s}}}'
|
|
162
|
+
|
|
163
|
+
if left_s[-1].isdigit() and right_s[0].isdigit():
|
|
164
|
+
return f'{left_s} \\cdot {right_s}'
|
|
165
|
+
|
|
166
|
+
if node.op == '*':
|
|
167
|
+
if left_s[-1].isdigit() and (
|
|
168
|
+
right_s[0].isalpha() or right_s.startswith('\\')):
|
|
169
|
+
return f'{left_s}{right_s}'
|
|
170
|
+
if left_s.endswith(')') or left_s.endswith('}') or right_s.startswith('(') or right_s.startswith('\\left('):
|
|
171
|
+
return f'{left_s}{right_s}'
|
|
172
|
+
return f'{left_s} \\cdot {right_s}'
|
|
173
|
+
|
|
174
|
+
if right_s.startswith('\\dd{'):
|
|
175
|
+
return f'{left_s}{right_s}'
|
|
176
|
+
if right_s.startswith('\\ldots'):
|
|
177
|
+
return f'{left_s}{right_s}'
|
|
178
|
+
if left_s.endswith('!') and right_s[0].isalpha():
|
|
179
|
+
return f'{left_s} {right_s}'
|
|
180
|
+
if left_s.endswith('}') and (right_s[0].isalpha() or right_s.startswith('\\')) and not right_s.startswith('\\left('):
|
|
181
|
+
return f'{left_s} {right_s}'
|
|
182
|
+
if left_s.endswith(')') and right_s.startswith('\\') and not right_s.startswith('\\left('):
|
|
183
|
+
return f'{left_s} {right_s}'
|
|
184
|
+
if (left_s.endswith(')') or left_s.endswith('}')) and right_s.startswith('('):
|
|
185
|
+
return f'{left_s}{right_s}'
|
|
186
|
+
if right_s.startswith('\\'):
|
|
187
|
+
if left_s[-1].isalpha():
|
|
188
|
+
return f'{left_s} {right_s}'
|
|
189
|
+
return f'{left_s}{right_s}'
|
|
190
|
+
if right_s.startswith('('):
|
|
191
|
+
return f'{left_s}{right_s}'
|
|
192
|
+
if left_s.endswith(')'):
|
|
193
|
+
return f'{left_s}{right_s}'
|
|
194
|
+
if left_s[-1].isalpha() and right_s[0].isalnum():
|
|
195
|
+
return f'{left_s} {right_s}'
|
|
196
|
+
return f'{left_s}{right_s}'
|
|
197
|
+
|
|
198
|
+
# ---- Plus-minus / minus-plus ----
|
|
199
|
+
if node.op in ('+-', '-+'):
|
|
200
|
+
op_str = '\\pm' if node.op == '+-' else '\\mp'
|
|
201
|
+
if isinstance(node.left, Var) and node.left.name == '':
|
|
202
|
+
return f'{op_str} {right}'
|
|
203
|
+
return f'{left} {op_str} {right}'
|
|
204
|
+
|
|
205
|
+
# ---- Word additive / multiplicative ops ----
|
|
206
|
+
if node.op in WORD_ADD_OPS:
|
|
207
|
+
return f'{left} {WORD_ADD_OPS[node.op]} {right}'
|
|
208
|
+
if node.op in WORD_MULT_OPS:
|
|
209
|
+
return f'{left} {WORD_MULT_OPS[node.op]} {right}'
|
|
210
|
+
|
|
211
|
+
# ---- Relational ops ----
|
|
212
|
+
rel_map = {
|
|
213
|
+
':': '\\colon', '->': '\\to', '>=': '\\ge',
|
|
214
|
+
'<=': '\\le', '!=': '\\neq', '~=': '\\approx', '==': '=',
|
|
215
|
+
}
|
|
216
|
+
if node.op in _REL_OPS or node.op in WORD_RELATIONS:
|
|
217
|
+
op_str = rel_map.get(node.op, node.op)
|
|
218
|
+
if node.op in WORD_RELATIONS:
|
|
219
|
+
op_str = WORD_RELATIONS[node.op]
|
|
220
|
+
|
|
221
|
+
if (is_root and self.align_mode
|
|
222
|
+
and Config.AUTO_ALIGN_EQUALS):
|
|
223
|
+
return f'{left} & {op_str} {right}'
|
|
224
|
+
|
|
225
|
+
if node.op in WORD_RELATIONS:
|
|
226
|
+
return f'{left} {op_str} {right}'
|
|
227
|
+
if node.op in ('->', '>=', '<=', '!=', '~='):
|
|
228
|
+
return f'{left}{op_str} {right}'
|
|
229
|
+
if node.op == ':':
|
|
230
|
+
return f'{left}\\colon {right}'
|
|
231
|
+
|
|
232
|
+
return f'{left}{op_str}{right}'
|
|
233
|
+
|
|
234
|
+
if node.op == 'if':
|
|
235
|
+
return f'{left} \\text{{if}} {right}'
|
|
236
|
+
if node.op == 'otherwise':
|
|
237
|
+
return f'{left} \\text{{otherwise}}'
|
|
238
|
+
|
|
239
|
+
# ---- Standard binary operators ----
|
|
240
|
+
if node.op in ('+', '-') and right.startswith('\\left'):
|
|
241
|
+
return f'{left}{node.op}{right}'
|
|
242
|
+
|
|
243
|
+
return f'{left}{node.op}{right}'
|
|
244
|
+
|
|
245
|
+
def visit_UnOp(self, node: UnOp) -> str:
|
|
246
|
+
if isinstance(node.expr, Power):
|
|
247
|
+
return f'{node.op}{self.format(node.expr)}'
|
|
248
|
+
return f'{node.op}{self.format_child(node.precedence, node.expr)}'
|
|
249
|
+
|
|
250
|
+
def visit_Group(self, node: Group) -> str:
|
|
251
|
+
"""Emit user-written parentheses."""
|
|
252
|
+
inner = self.format(node.expr)
|
|
253
|
+
if self._is_complex(node.expr):
|
|
254
|
+
return f'\\left({inner}\\right)'
|
|
255
|
+
return f'({inner})'
|
|
256
|
+
|
|
257
|
+
def visit_Fraction(self, node: Fraction) -> str:
|
|
258
|
+
return (f'\\frac{{{self.format(node.num)}}}'
|
|
259
|
+
f'{{{self.format(node.den)}}}')
|
|
260
|
+
|
|
261
|
+
def visit_Power(self, node: Power) -> str:
|
|
262
|
+
base = self.format_child(node.precedence, node.base)
|
|
263
|
+
if isinstance(node.base, Fraction):
|
|
264
|
+
base = f'\\left({self.format(node.base)}\\right)'
|
|
265
|
+
elif isinstance(node.base, Power):
|
|
266
|
+
base = f'\\left({self.format(node.base)}\\right)'
|
|
267
|
+
elif isinstance(node.base, UnOp):
|
|
268
|
+
base = f'({self.format(node.base)})'
|
|
269
|
+
return f'{base}^{{{self.format(node.exp)}}}'
|
|
270
|
+
|
|
271
|
+
def visit_Subscript(self, node: Subscript) -> str:
|
|
272
|
+
base = self.format_child(node.precedence, node.base)
|
|
273
|
+
if isinstance(node.base, Fraction):
|
|
274
|
+
base = f'\\left({self.format(node.base)}\\right)'
|
|
275
|
+
return f'{base}_{{{self.format(node.sub)}}}'
|
|
276
|
+
|
|
277
|
+
def visit_Factorial(self, node: Factorial) -> str:
|
|
278
|
+
expr = self.format_child(node.precedence, node.expr)
|
|
279
|
+
if isinstance(node.expr, BinOp):
|
|
280
|
+
expr = f'\\left({self.format(node.expr)}\\right)'
|
|
281
|
+
return f'{expr}!'
|
|
282
|
+
|
|
283
|
+
def visit_Prime(self, node: Prime) -> str:
|
|
284
|
+
inner = self.format_child(Prec.POSTFIX, node.expr)
|
|
285
|
+
primes = "'" * node.order
|
|
286
|
+
return f'{inner}{primes}'
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------------
|
|
289
|
+
# Functions
|
|
290
|
+
# ------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def visit_Func(self, node: Func) -> str:
|
|
293
|
+
name = node.name
|
|
294
|
+
|
|
295
|
+
if name == 'sqrt' and node.args:
|
|
296
|
+
return f'\\sqrt{{{self.format(node.args[0])}}}'
|
|
297
|
+
if name == 'root' and len(node.args) >= 2:
|
|
298
|
+
return (f'\\sqrt[{self.format(node.args[0])}]'
|
|
299
|
+
f'{{{self.format(node.args[1])}}}')
|
|
300
|
+
if name == 'exp' and node.args:
|
|
301
|
+
return f'e^{{{self.format(node.args[0])}}}'
|
|
302
|
+
if name == 'floor' and node.args:
|
|
303
|
+
return (f'\\left\\lfloor '
|
|
304
|
+
f'{self.format(node.args[0])}\\right\\rfloor')
|
|
305
|
+
if name == 'ceil' and node.args:
|
|
306
|
+
return (f'\\left\\lceil '
|
|
307
|
+
f'{self.format(node.args[0])}\\right\\rceil')
|
|
308
|
+
if name == 'abs' and node.args:
|
|
309
|
+
return f'\\abs{{{self.format(node.args[0])}}}'
|
|
310
|
+
if name == 'norm' and node.args:
|
|
311
|
+
return f'\\norm{{{self.format(node.args[0])}}}'
|
|
312
|
+
if name == 'binom' and len(node.args) >= 2:
|
|
313
|
+
return (f'\\binom{{{self.format(node.args[0])}}}'
|
|
314
|
+
f'{{{self.format(node.args[1])}}}')
|
|
315
|
+
if name == 'boxed' and node.args:
|
|
316
|
+
return f'\\boxed{{{self.format(node.args[0])}}}'
|
|
317
|
+
if name == 'cancel' and node.args:
|
|
318
|
+
return f'\\cancel{{{self.format(node.args[0])}}}'
|
|
319
|
+
if name in ACCENT_FUNCTIONS and node.args:
|
|
320
|
+
cmd = ACCENT_FUNCTIONS[name]
|
|
321
|
+
base = f'\\{cmd}{{{self.format(node.args[0])}}}'
|
|
322
|
+
if node.call_args is not None:
|
|
323
|
+
inner = ','.join(self.format(a) for a in node.call_args)
|
|
324
|
+
if any(self._is_complex(a) for a in node.call_args):
|
|
325
|
+
return f'{base}\\left({inner}\\right)'
|
|
326
|
+
return f'{base}({inner})'
|
|
327
|
+
return base
|
|
328
|
+
if name in FONT_FUNCTIONS and node.args:
|
|
329
|
+
cmd = FONT_FUNCTIONS[name]
|
|
330
|
+
base = f'\\{cmd}{{{self.format(node.args[0])}}}'
|
|
331
|
+
if node.call_args is not None:
|
|
332
|
+
inner = ','.join(self.format(a) for a in node.call_args)
|
|
333
|
+
if any(self._is_complex(a) for a in node.call_args):
|
|
334
|
+
return f'{base}\\left({inner}\\right)'
|
|
335
|
+
return f'{base}({inner})'
|
|
336
|
+
return base
|
|
337
|
+
|
|
338
|
+
if name in self.text_map:
|
|
339
|
+
return f'\\text{{{self.text_map[name]}}}'
|
|
340
|
+
|
|
341
|
+
if name in BUILTIN_LATEX_FUNCS:
|
|
342
|
+
fmt_name = f'\\{name}'
|
|
343
|
+
elif name in GREEK_LETTERS:
|
|
344
|
+
fmt_name = f'\\{name}'
|
|
345
|
+
elif len(name) > 1:
|
|
346
|
+
fmt_name = f'\\operatorname{{{name}}}'
|
|
347
|
+
else:
|
|
348
|
+
fmt_name = name
|
|
349
|
+
|
|
350
|
+
if node.primes:
|
|
351
|
+
fmt_name = fmt_name + "'" * node.primes
|
|
352
|
+
|
|
353
|
+
if node.sub is not None:
|
|
354
|
+
fmt_name = f'{fmt_name}_{{{self.format(node.sub)}}}'
|
|
355
|
+
if node.power is not None:
|
|
356
|
+
fmt_name = f'{fmt_name}^{{{self.format(node.power)}}}'
|
|
357
|
+
|
|
358
|
+
if not node.args:
|
|
359
|
+
if fmt_name.startswith('\\') and fmt_name[-1].isalpha():
|
|
360
|
+
return fmt_name + ' '
|
|
361
|
+
return fmt_name
|
|
362
|
+
|
|
363
|
+
inner = ','.join(self.format(a) for a in node.args)
|
|
364
|
+
if any(self._is_complex(a) for a in node.args):
|
|
365
|
+
return f'{fmt_name}\\left({inner}\\right)'
|
|
366
|
+
return f'{fmt_name}({inner})'
|
|
367
|
+
|
|
368
|
+
# ------------------------------------------------------------------
|
|
369
|
+
# Calculus
|
|
370
|
+
# ------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def visit_Derivative(self, node: Derivative) -> str:
|
|
373
|
+
expr = self.format(node.expr)
|
|
374
|
+
order = f'[{self.format(node.order)}]' if node.order else ''
|
|
375
|
+
var_fmt = (f'\\{node.var}'
|
|
376
|
+
if node.var in GREEK_LETTERS else node.var)
|
|
377
|
+
|
|
378
|
+
if not expr:
|
|
379
|
+
if node.is_partial:
|
|
380
|
+
if node.var2:
|
|
381
|
+
var2_fmt = (f'\\{node.var2}'
|
|
382
|
+
if node.var2 in GREEK_LETTERS
|
|
383
|
+
else node.var2)
|
|
384
|
+
return f'\\pdv{{{var_fmt}}}{{{var2_fmt}}}'
|
|
385
|
+
return f'\\pdv{order}{{{var_fmt}}}'
|
|
386
|
+
return f'\\dv{order}{{{var_fmt}}}'
|
|
387
|
+
|
|
388
|
+
if node.is_partial:
|
|
389
|
+
if node.var2:
|
|
390
|
+
var2_fmt = (f'\\{node.var2}'
|
|
391
|
+
if node.var2 in GREEK_LETTERS
|
|
392
|
+
else node.var2)
|
|
393
|
+
return f'\\pdv{{{expr}}}{{{var_fmt}}}{{{var2_fmt}}}'
|
|
394
|
+
return f'\\pdv{order}{{{expr}}}{{{var_fmt}}}'
|
|
395
|
+
return f'\\dv{order}{{{expr}}}{{{var_fmt}}}'
|
|
396
|
+
|
|
397
|
+
def visit_Integral(self, node: Integral) -> str:
|
|
398
|
+
op = {
|
|
399
|
+
'single': '\\int', 'double': '\\iint', 'triple': '\\iiint',
|
|
400
|
+
'contour': '\\oint', 'double_contour': '\\oiint',
|
|
401
|
+
}[node.kind]
|
|
402
|
+
bounds = ''
|
|
403
|
+
if node.lower is not None and node.upper is not None:
|
|
404
|
+
bounds = (f'_{{{self.format(node.lower)}}}'
|
|
405
|
+
f'^{{{self.format(node.upper)}}}')
|
|
406
|
+
elif node.lower is not None:
|
|
407
|
+
bounds = f'_{{{self.format(node.lower)}}}'
|
|
408
|
+
elif node.upper is not None:
|
|
409
|
+
bounds = f'^{{{self.format(node.upper)}}}'
|
|
410
|
+
|
|
411
|
+
old_int = self.in_integral
|
|
412
|
+
self.in_integral = True
|
|
413
|
+
expr = self.format(node.expr)
|
|
414
|
+
if isinstance(node.expr, BinOp) and node.expr.op in ('+', '-', '+-', '-+'):
|
|
415
|
+
expr = f'\\left({expr}\\right)'
|
|
416
|
+
self.in_integral = old_int
|
|
417
|
+
|
|
418
|
+
if Config.ADD_DX_TO_INTEGRALS:
|
|
419
|
+
if not re.search(r'\\dd\{[a-zA-Z]\}|d\s*[a-zA-Z]', expr):
|
|
420
|
+
vars_found = self.get_vars(node.expr)
|
|
421
|
+
vars_found = [v for v in vars_found if v not in ('e', 'i', 'f', 'g', 'h')]
|
|
422
|
+
|
|
423
|
+
num_vars = 1
|
|
424
|
+
if node.kind in ('double', 'double_contour'):
|
|
425
|
+
num_vars = 2
|
|
426
|
+
elif node.kind == 'triple':
|
|
427
|
+
num_vars = 3
|
|
428
|
+
|
|
429
|
+
vars_to_add = vars_found[:num_vars]
|
|
430
|
+
defaults = ['x', 'y', 'z', 'w', 't']
|
|
431
|
+
while len(vars_to_add) < num_vars:
|
|
432
|
+
for d in defaults:
|
|
433
|
+
if d not in vars_to_add:
|
|
434
|
+
vars_to_add.append(d)
|
|
435
|
+
break
|
|
436
|
+
|
|
437
|
+
dd_str = ''.join(f'\\dd{{{v}}}' for v in vars_to_add)
|
|
438
|
+
expr = f"{expr.strip()}{dd_str}"
|
|
439
|
+
|
|
440
|
+
if expr.startswith('\\frac') or expr.startswith('\\left'):
|
|
441
|
+
return f'{op}{bounds}{expr}'
|
|
442
|
+
return f'{op}{bounds} {expr}'
|
|
443
|
+
|
|
444
|
+
def visit_BigOp(self, node: BigOp) -> str:
|
|
445
|
+
op = f'\\{node.op_name}'
|
|
446
|
+
bounds = ''
|
|
447
|
+
if node.lower is not None:
|
|
448
|
+
bounds += f'_{{{self.format(node.lower)}}}'
|
|
449
|
+
if node.upper is not None:
|
|
450
|
+
bounds += f'^{{{self.format(node.upper)}}}'
|
|
451
|
+
expr_str = self.format(node.expr)
|
|
452
|
+
if isinstance(node.expr, BinOp) and node.expr.op in ('+', '-', '+-', '-+'):
|
|
453
|
+
expr_str = f'\\left({expr_str}\\right)'
|
|
454
|
+
if expr_str:
|
|
455
|
+
if expr_str.startswith('\\frac') or expr_str.startswith('\\left'):
|
|
456
|
+
return f'{op}{bounds}{expr_str}'
|
|
457
|
+
return f'{op}{bounds} {expr_str}'.strip()
|
|
458
|
+
return f'{op}{bounds}'
|
|
459
|
+
|
|
460
|
+
# ------------------------------------------------------------------
|
|
461
|
+
# Delimiters & Structures
|
|
462
|
+
# ------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
def visit_Abs(self, node: Abs) -> str:
|
|
465
|
+
return f'\\abs{{{self.format(node.expr)}}}'
|
|
466
|
+
|
|
467
|
+
def visit_Norm(self, node: Norm) -> str:
|
|
468
|
+
return f'\\norm{{{self.format(node.expr)}}}'
|
|
469
|
+
|
|
470
|
+
def visit_InnerProduct(self, node: InnerProduct) -> str:
|
|
471
|
+
return (f'\\langle {self.format(node.left)},'
|
|
472
|
+
f'{self.format(node.right)}\\rangle')
|
|
473
|
+
|
|
474
|
+
def visit_SetBuilder(self, node: SetBuilder) -> str:
|
|
475
|
+
expr = self.format(node.expr)
|
|
476
|
+
cond = self.format(node.condition)
|
|
477
|
+
return f'\\left\\{{{expr}\\mid {cond}\\right\\}}'
|
|
478
|
+
|
|
479
|
+
def visit_MatrixNode(self, node: MatrixNode) -> str:
|
|
480
|
+
lines = []
|
|
481
|
+
for row in node.rows:
|
|
482
|
+
lines.append(' & '.join(self.format(col) for col in row))
|
|
483
|
+
content = ' \\\\\n'.join(lines)
|
|
484
|
+
return f'\\begin{{{node.kind}}}\n{content}\n\\end{{{node.kind}}}'
|
|
485
|
+
|
|
486
|
+
def visit_TextLiteral(self, node: TextLiteral) -> str:
|
|
487
|
+
return f'\\text{{{node.val}}}'
|
|
488
|
+
|
|
489
|
+
def visit_IntervalNode(self, node: IntervalNode) -> str:
|
|
490
|
+
lb = '\\left[' if node.left_delim == '[' else '\\left('
|
|
491
|
+
rb = '\\right]' if node.right_delim == ']' else '\\right)'
|
|
492
|
+
return (f'{lb} {self.format(node.left_expr)}, '
|
|
493
|
+
f'{self.format(node.right_expr)} {rb}')
|
|
494
|
+
|
|
495
|
+
def visit_PiecewiseNode(self, node: PiecewiseNode) -> str:
|
|
496
|
+
lines = []
|
|
497
|
+
for expr, cond in node.conditions:
|
|
498
|
+
formatted = self.format(expr)
|
|
499
|
+
if cond:
|
|
500
|
+
lines.append(f'{formatted}, & {self.format(cond)}')
|
|
501
|
+
else:
|
|
502
|
+
lines.append(formatted)
|
|
503
|
+
content = ' \\\\\n'.join(lines)
|
|
504
|
+
return f'\\begin{{cases}}\n{content}\n\\end{{cases}}'
|
math2tex/lexer.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
math2tex.lexer — Lexical analysis: preprocessing and tokenization.
|
|
3
|
+
|
|
4
|
+
Responsible for converting raw plaintext input into a flat token stream.
|
|
5
|
+
This module also houses the two exception types shared across the compiler.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import List, Tuple
|
|
11
|
+
|
|
12
|
+
from .config import SPLITTABLE_FUNCS, UNICODE_MAP
|
|
13
|
+
|
|
14
|
+
# =============================================================================
|
|
15
|
+
# Exception Types
|
|
16
|
+
# =============================================================================
|
|
17
|
+
|
|
18
|
+
class ParseError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Backtrack(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
# =============================================================================
|
|
26
|
+
# Token
|
|
27
|
+
# =============================================================================
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Token:
|
|
31
|
+
type: str
|
|
32
|
+
value: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# Text-literal placeholder helpers (ISSUE 5 FIX)
|
|
37
|
+
# =============================================================================
|
|
38
|
+
|
|
39
|
+
_TEXT_PH_PREFIX = 'TEXTPLACEHOLDER'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _make_text_placeholder(idx: int) -> str:
|
|
43
|
+
return _TEXT_PH_PREFIX + chr(ord('A') + idx // 26) + chr(ord('A') + idx % 26)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_text_placeholder(value: str) -> bool:
|
|
47
|
+
return value.startswith(_TEXT_PH_PREFIX) and len(value) == len(_TEXT_PH_PREFIX) + 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# =============================================================================
|
|
51
|
+
# Preprocessing
|
|
52
|
+
# =============================================================================
|
|
53
|
+
|
|
54
|
+
def _preprocess(text: str) -> Tuple[str, dict]:
|
|
55
|
+
text_map: dict = {}
|
|
56
|
+
idx = 0
|
|
57
|
+
pattern = re.compile(r'\btext\s*\(')
|
|
58
|
+
s = text
|
|
59
|
+
while True:
|
|
60
|
+
m = pattern.search(s)
|
|
61
|
+
if not m:
|
|
62
|
+
break
|
|
63
|
+
start = m.start()
|
|
64
|
+
depth = 1
|
|
65
|
+
i = m.end()
|
|
66
|
+
while i < len(s) and depth > 0:
|
|
67
|
+
if s[i] == '(':
|
|
68
|
+
depth += 1
|
|
69
|
+
elif s[i] == ')':
|
|
70
|
+
depth -= 1
|
|
71
|
+
i += 1
|
|
72
|
+
if depth != 0:
|
|
73
|
+
break
|
|
74
|
+
content = s[m.end():i - 1]
|
|
75
|
+
placeholder = _make_text_placeholder(idx)
|
|
76
|
+
text_map[placeholder] = content
|
|
77
|
+
s = s[:start] + placeholder + s[i:]
|
|
78
|
+
idx += 1
|
|
79
|
+
|
|
80
|
+
for uni_char, ascii_rep in UNICODE_MAP.items():
|
|
81
|
+
if uni_char in s:
|
|
82
|
+
if ascii_rep.isalpha():
|
|
83
|
+
s = s.replace(uni_char, f' {ascii_rep} ')
|
|
84
|
+
else:
|
|
85
|
+
s = s.replace(uni_char, ascii_rep)
|
|
86
|
+
|
|
87
|
+
s = re.sub(r'\bdouble\s+integral\b', 'iint', s)
|
|
88
|
+
s = re.sub(r'\btriple\s+integral\b', 'iiint', s)
|
|
89
|
+
s = re.sub(r'\bcontour\s+integral\b', 'oint', s)
|
|
90
|
+
s = re.sub(r'\bsurface\s+integral\b', 'iint', s)
|
|
91
|
+
s = re.sub(r'\bvolume\s+integral\b', 'iiint', s)
|
|
92
|
+
s = re.sub(r'\bline\s+integral\b', 'oint', s)
|
|
93
|
+
|
|
94
|
+
s = re.sub(r'\bapproaches\b', '->', s)
|
|
95
|
+
s = re.sub(r'\bover\b', '/', s)
|
|
96
|
+
s = re.sub(r'\bsquare\s+root\b', 'sqrt', s)
|
|
97
|
+
s = re.sub(r'\bintegrate\b', 'integral', s)
|
|
98
|
+
s = re.sub(r'\blimit\b', 'lim', s)
|
|
99
|
+
|
|
100
|
+
return s, text_map
|
|
101
|
+
|
|
102
|
+
# =============================================================================
|
|
103
|
+
# Tokenizer
|
|
104
|
+
# =============================================================================
|
|
105
|
+
|
|
106
|
+
def tokenize(text: str) -> List[Token]:
|
|
107
|
+
spec = [
|
|
108
|
+
('NUMBER', r'(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?'),
|
|
109
|
+
('ELLIPSIS', r'\.\.\.'),
|
|
110
|
+
('ARROW', r'->'),
|
|
111
|
+
('COLON', r':'),
|
|
112
|
+
('REL', r'!=|<=|>=|~=|<|>|==|='),
|
|
113
|
+
('DBLBAR', r'\|\|'),
|
|
114
|
+
('BAR', r'\|'),
|
|
115
|
+
('PM', r'\+-|-\+'),
|
|
116
|
+
('OP', r'[+\-*/^_!]'),
|
|
117
|
+
('PRIME', r"'+"),
|
|
118
|
+
('ID', r'[A-Za-z]+'),
|
|
119
|
+
('LPAREN', r'\('),
|
|
120
|
+
('RPAREN', r'\)'),
|
|
121
|
+
('LBRACK', r'\['),
|
|
122
|
+
('RBRACK', r'\]'),
|
|
123
|
+
('LBRACE', r'\{'),
|
|
124
|
+
('RBRACE', r'\}'),
|
|
125
|
+
('COMMA', r','),
|
|
126
|
+
('SEMI', r';'),
|
|
127
|
+
('SKIP', r'[ \t\n\r]+'),
|
|
128
|
+
('MISMATCH', r'.'),
|
|
129
|
+
]
|
|
130
|
+
tok_regex = '|'.join(f'(?P<{name}>{pattern})' for name, pattern in spec)
|
|
131
|
+
tokens: List[Token] = []
|
|
132
|
+
for mo in re.finditer(tok_regex, text):
|
|
133
|
+
kind = mo.lastgroup
|
|
134
|
+
value = mo.group()
|
|
135
|
+
if kind == 'SKIP':
|
|
136
|
+
continue
|
|
137
|
+
if kind == 'MISMATCH':
|
|
138
|
+
raise ParseError(f"Unexpected character: {value!r}")
|
|
139
|
+
|
|
140
|
+
if kind == 'ID':
|
|
141
|
+
split = False
|
|
142
|
+
|
|
143
|
+
if value not in SPLITTABLE_FUNCS:
|
|
144
|
+
for pfx in SPLITTABLE_FUNCS:
|
|
145
|
+
if value.startswith(pfx) and len(value) > len(pfx):
|
|
146
|
+
rest = value[len(pfx):]
|
|
147
|
+
|
|
148
|
+
if '_' not in value:
|
|
149
|
+
tokens.append(Token('ID', pfx))
|
|
150
|
+
tokens.append(Token('ID', rest))
|
|
151
|
+
split = True
|
|
152
|
+
break
|
|
153
|
+
|
|
154
|
+
if not split:
|
|
155
|
+
tokens.append(Token(kind, value))
|
|
156
|
+
else:
|
|
157
|
+
tokens.append(Token(kind, value))
|
|
158
|
+
|
|
159
|
+
tokens.append(Token('EOF', ''))
|
|
160
|
+
return tokens
|