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/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