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/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from dataclasses import asdict
5
+ from pathlib import Path
6
+
7
+ from latex2code import __version__
8
+ from latex2code.core import (
9
+ Backend,
10
+ LaTeXTranspilerError,
11
+ inspect_latex,
12
+ transpile_latex,
13
+ )
14
+
15
+
16
+ def main():
17
+ parser = argparse.ArgumentParser(
18
+ description="Transpile LaTeX mathematical expressions into executable Python functions."
19
+ )
20
+ parser.add_argument(
21
+ "latex",
22
+ nargs="?",
23
+ type=str,
24
+ help="The LaTeX expression wrapped in quotes, or '-' to read from stdin",
25
+ )
26
+ parser.add_argument(
27
+ "--version",
28
+ action="version",
29
+ version=f"%(prog)s {__version__}",
30
+ )
31
+ parser.add_argument(
32
+ "-n",
33
+ "--name",
34
+ type=str,
35
+ default="formula",
36
+ help="Name of the generated Python function (default: 'formula')",
37
+ )
38
+ parser.add_argument(
39
+ "--no-types",
40
+ action="store_true",
41
+ help="Disable type hints in the generated function definition",
42
+ )
43
+ parser.add_argument(
44
+ "--backend",
45
+ choices=("python", "numpy", "torch", "jax"),
46
+ help="Generated code target (default: python)",
47
+ )
48
+ parser.add_argument(
49
+ "-o",
50
+ "--output",
51
+ type=Path,
52
+ help="Write generated Python source to this file instead of stdout",
53
+ )
54
+ parser.add_argument(
55
+ "--inspect",
56
+ action="store_true",
57
+ help="Print a JSON report with the parsed expression, variables, warnings, and code",
58
+ )
59
+ parser.add_argument(
60
+ "--map-variable",
61
+ action="append",
62
+ metavar="SYMBOL=NAME",
63
+ help="Rename a parsed variable to a Python argument name (repeatable)",
64
+ )
65
+ parser.add_argument(
66
+ "-i",
67
+ "--interactive",
68
+ action="store_true",
69
+ help="Read expressions interactively until 'quit' or end-of-file",
70
+ )
71
+
72
+ args = parser.parse_args()
73
+
74
+ if args.latex is None and not args.interactive:
75
+ parser.print_help()
76
+ return
77
+ if args.interactive and args.latex is not None:
78
+ parser.error("--interactive cannot be combined with a positional expression")
79
+ if args.inspect and args.output:
80
+ parser.error("--inspect cannot be combined with --output")
81
+ if args.interactive and args.output:
82
+ parser.error("--interactive cannot be combined with --output")
83
+
84
+ variable_map = {}
85
+ for item in args.map_variable or ():
86
+ symbol, separator, name = item.partition("=")
87
+ if not separator or not symbol or not name:
88
+ parser.error(f"invalid --map-variable value {item!r}; expected SYMBOL=NAME")
89
+ if symbol in variable_map:
90
+ parser.error(f"variable {symbol!r} was mapped more than once")
91
+ variable_map[symbol] = name
92
+
93
+ latex_input = args.latex
94
+ if args.interactive:
95
+ _run_interactive(
96
+ function_name=args.name,
97
+ type_hints=not args.no_types,
98
+ backend=args.backend,
99
+ inspect=args.inspect,
100
+ variable_map=variable_map,
101
+ )
102
+ return
103
+
104
+ if latex_input == "-":
105
+ latex_input = sys.stdin.read().strip()
106
+
107
+ try:
108
+ if args.inspect:
109
+ details = inspect_latex(
110
+ latex_input,
111
+ function_name=args.name,
112
+ type_hints=not args.no_types,
113
+ backend=args.backend,
114
+ variable_map=variable_map,
115
+ )
116
+ print(json.dumps(asdict(details), indent=2))
117
+ else:
118
+ source = transpile_latex(
119
+ latex_input,
120
+ function_name=args.name,
121
+ type_hints=not args.no_types,
122
+ backend=args.backend,
123
+ variable_map=variable_map,
124
+ )
125
+ if args.output:
126
+ args.output.parent.mkdir(parents=True, exist_ok=True)
127
+ args.output.write_text(source, encoding="utf-8")
128
+ else:
129
+ print(source)
130
+ except LaTeXTranspilerError as err:
131
+ print(f"\n[Error] {err}", file=sys.stderr)
132
+ sys.exit(1)
133
+ except OSError as err:
134
+ print(f"\n[Error] Could not write output: {err}", file=sys.stderr)
135
+ sys.exit(1)
136
+ except Exception as err:
137
+ print(f"\n[Unexpected Error] {err}", file=sys.stderr)
138
+ sys.exit(1)
139
+
140
+
141
+ def _run_interactive(
142
+ *,
143
+ function_name: str,
144
+ type_hints: bool,
145
+ backend: Backend | None,
146
+ inspect: bool,
147
+ variable_map: dict[str, str],
148
+ ) -> None:
149
+ mode = "inspection" if inspect else "code generation"
150
+ print(
151
+ f"latex2code interactive ({mode}); enter 'quit' to exit, "
152
+ "or ':begin' / ':end' for a multiline expression."
153
+ )
154
+ multiline: list[str] | None = None
155
+ while True:
156
+ try:
157
+ expression = input("latex2code> " if multiline is None else "...> ").strip()
158
+ except EOFError:
159
+ if multiline is not None:
160
+ print("Discarded unfinished multiline expression.", file=sys.stderr)
161
+ print()
162
+ return
163
+ if multiline is not None:
164
+ if expression == ":end":
165
+ _print_interactive_expression(
166
+ "\n".join(multiline),
167
+ function_name=function_name,
168
+ type_hints=type_hints,
169
+ backend=backend,
170
+ inspect=inspect,
171
+ variable_map=variable_map,
172
+ )
173
+ multiline = None
174
+ elif expression == ":cancel":
175
+ multiline = None
176
+ print("Cancelled.")
177
+ else:
178
+ multiline.append(expression)
179
+ continue
180
+ if expression == ":begin":
181
+ multiline = []
182
+ continue
183
+ if expression.lower() in {"quit", "exit"}:
184
+ return
185
+ if not expression:
186
+ continue
187
+ _print_interactive_expression(
188
+ expression,
189
+ function_name=function_name,
190
+ type_hints=type_hints,
191
+ backend=backend,
192
+ inspect=inspect,
193
+ variable_map=variable_map,
194
+ )
195
+
196
+
197
+ def _print_interactive_expression(
198
+ expression: str,
199
+ *,
200
+ function_name: str,
201
+ type_hints: bool,
202
+ backend: Backend | None,
203
+ inspect: bool,
204
+ variable_map: dict[str, str],
205
+ ) -> None:
206
+ try:
207
+ if inspect:
208
+ details = inspect_latex(
209
+ expression,
210
+ function_name=function_name,
211
+ type_hints=type_hints,
212
+ backend=backend,
213
+ variable_map=variable_map,
214
+ )
215
+ print(json.dumps(asdict(details), indent=2))
216
+ else:
217
+ source = transpile_latex(
218
+ expression,
219
+ function_name=function_name,
220
+ type_hints=type_hints,
221
+ backend=backend,
222
+ variable_map=variable_map,
223
+ )
224
+ print(source, end="" if source.endswith("\n") else "\n")
225
+ except LaTeXTranspilerError as err:
226
+ print(f"[Error] {err}", file=sys.stderr)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
latex2code/core.py ADDED
@@ -0,0 +1,35 @@
1
+ from .api import (
2
+ CompiledFormula,
3
+ TranspilationInfo,
4
+ compile_latex,
5
+ inspect_latex,
6
+ latex_to_code,
7
+ transpile_latex,
8
+ )
9
+ from .backends import Backend
10
+ from .errors import (
11
+ CodeGenerationError,
12
+ InvalidLaTeXSyntaxError,
13
+ InvalidPythonIdentifierError,
14
+ LaTeXTranspilerError,
15
+ PiecewiseEvaluationWarning,
16
+ UnsupportedLaTeXFeatureError,
17
+ )
18
+ from .normalize import VALID_LATEX_COMMANDS
19
+
20
+ __all__ = [
21
+ "Backend",
22
+ "CompiledFormula",
23
+ "TranspilationInfo",
24
+ "LaTeXTranspilerError",
25
+ "CodeGenerationError",
26
+ "InvalidLaTeXSyntaxError",
27
+ "UnsupportedLaTeXFeatureError",
28
+ "InvalidPythonIdentifierError",
29
+ "PiecewiseEvaluationWarning",
30
+ "VALID_LATEX_COMMANDS",
31
+ "transpile_latex",
32
+ "compile_latex",
33
+ "inspect_latex",
34
+ "latex_to_code",
35
+ ]
latex2code/errors.py ADDED
@@ -0,0 +1,210 @@
1
+ import keyword
2
+ import re
3
+
4
+ RESERVED_MODULE_NAMES = {
5
+ "builtins",
6
+ "functools",
7
+ "jax",
8
+ "jnp",
9
+ "math",
10
+ "np",
11
+ "numpy",
12
+ "torch",
13
+ }
14
+
15
+
16
+ class LaTeXTranspilerError(Exception):
17
+ """Base exception raised for errors during LaTeX transpilation."""
18
+
19
+ pass
20
+
21
+
22
+ class InvalidPythonIdentifierError(LaTeXTranspilerError):
23
+ """Raised when generated Python would contain an invalid identifier."""
24
+
25
+ def __init__(self, identifier: object, kind: str):
26
+ super().__init__(
27
+ f"{kind} must be a valid non-keyword Python identifier: {identifier!r}."
28
+ )
29
+
30
+
31
+ class _LaTeXSyntaxError(LaTeXTranspilerError):
32
+ """Shared base for errors caused by LaTeX syntax or unsupported features."""
33
+
34
+ line: int | None
35
+ column: int | None
36
+
37
+
38
+ class InvalidLaTeXSyntaxError(_LaTeXSyntaxError):
39
+ """Raised when the provided LaTeX string cannot be parsed."""
40
+
41
+ def __init__(
42
+ self,
43
+ raw_expression: str,
44
+ original_error: Exception | None = None,
45
+ hint: str | None = None,
46
+ location: tuple[int | None, int | None] | None = None,
47
+ ):
48
+ self.raw_expression = raw_expression
49
+ self.original_error = original_error
50
+ hint = hint or _syntax_hint(raw_expression)
51
+ resolved_location = (
52
+ location
53
+ if location is not None
54
+ else _syntax_location(raw_expression, original_error)
55
+ )
56
+ self.line, self.column = resolved_location
57
+ message = (
58
+ f"Failed to parse LaTeX expression: '{raw_expression}'. "
59
+ "Please verify the expression syntax and supported LaTeX subset."
60
+ )
61
+ if self.line is not None and self.column is not None:
62
+ message += (
63
+ f" Location: line {self.line}, column {self.column}."
64
+ f"\n{_source_context(raw_expression, self.line, self.column)}"
65
+ )
66
+ if hint:
67
+ message += f" Hint: {hint}"
68
+ super().__init__(message)
69
+
70
+
71
+ class UnsupportedLaTeXFeatureError(_LaTeXSyntaxError):
72
+ """Raised when syntax is valid-looking but uses an unsupported command or function."""
73
+
74
+ def __init__(
75
+ self,
76
+ raw_expression: str,
77
+ feature: str,
78
+ kind: str = "LaTeX command",
79
+ suggestion: str | None = None,
80
+ ):
81
+ self.raw_expression = raw_expression
82
+ self.original_error = None
83
+ self.feature = feature
84
+ self.kind = kind
85
+ location_index = raw_expression.find(feature)
86
+ if location_index < 0 and feature.startswith("\\"):
87
+ location_index = raw_expression.find(feature[1:])
88
+ self.line, self.column = (
89
+ _line_and_column(raw_expression, location_index)
90
+ if location_index >= 0
91
+ else (None, None)
92
+ )
93
+ message = f"Unsupported {kind}: {feature}."
94
+ if self.line is not None and self.column is not None:
95
+ message += (
96
+ f" Location: line {self.line}, column {self.column}."
97
+ f"\n{_source_context(raw_expression, self.line, self.column)}"
98
+ )
99
+ if suggestion:
100
+ message += f" Did you mean {suggestion}?"
101
+ super().__init__(message)
102
+
103
+
104
+ class CodeGenerationError(LaTeXTranspilerError):
105
+ """Raised when a parsed expression cannot be represented as executable Python."""
106
+
107
+
108
+ class PiecewiseEvaluationWarning(UserWarning):
109
+ """Warns when array backends eagerly evaluate all branches of a piecewise expression."""
110
+
111
+
112
+ def _line_and_column(expression: str, offset: int) -> tuple[int, int]:
113
+ line = expression.count("\n", 0, offset) + 1
114
+ line_start = expression.rfind("\n", 0, offset) + 1
115
+ return line, offset - line_start + 1
116
+
117
+
118
+ def _source_context(expression: str, line: int, column: int) -> str:
119
+ lines = expression.splitlines() or [expression]
120
+ source_line = lines[line - 1] if line <= len(lines) else ""
121
+ start = max(0, column - 61)
122
+ end = min(len(source_line), start + 120)
123
+ prefix = "..." if start else ""
124
+ suffix = "..." if end < len(source_line) else ""
125
+ line_prefix = source_line[start : column - 1]
126
+ caret = " " * (len(prefix) + len(line_prefix.expandtabs())) + "^"
127
+ return f" {prefix}{source_line[start:end]}{suffix}\n {caret}"
128
+
129
+
130
+ def _syntax_location(
131
+ expression: str,
132
+ original_error: Exception | None,
133
+ ) -> tuple[int | None, int | None]:
134
+ delimiters = {"(": ")", "[": "]", "{": "}"}
135
+ stack: list[tuple[str, int]] = []
136
+ for index, character in enumerate(expression):
137
+ if character in delimiters or character in delimiters.values():
138
+ backslashes = 0
139
+ previous = index - 1
140
+ while previous >= 0 and expression[previous] == "\\":
141
+ backslashes += 1
142
+ previous -= 1
143
+ if backslashes % 2:
144
+ continue
145
+ if character in delimiters:
146
+ stack.append((character, index))
147
+ elif not stack or delimiters[stack[-1][0]] != character:
148
+ return _line_and_column(expression, index)
149
+ else:
150
+ stack.pop()
151
+ if stack:
152
+ return _line_and_column(expression, stack[0][1])
153
+
154
+ begins = list(re.finditer(r"\\begin\{([^}]+)\}", expression))
155
+ endings = list(re.finditer(r"\\end\{([^}]+)\}", expression))
156
+ begin_names = [match.group(1) for match in begins]
157
+ ending_names = [match.group(1) for match in endings]
158
+ if begin_names != ending_names:
159
+ for begin, ending in zip(begins, endings):
160
+ if begin.group(1) != ending.group(1):
161
+ return _line_and_column(expression, begin.start())
162
+ if len(begins) > len(endings):
163
+ return _line_and_column(expression, begins[len(endings)].start())
164
+ if endings:
165
+ return _line_and_column(expression, endings[0].start())
166
+
167
+ if original_error is not None:
168
+ match = re.search(r"\n([^\n]*)\n([~ ]*)\^", str(original_error))
169
+ if match:
170
+ excerpt = match.group(1)
171
+ for line_number, source_line in enumerate(expression.splitlines(), start=1):
172
+ if source_line.strip() == excerpt.strip():
173
+ return line_number, len(match.group(2)) + 1
174
+ trailing_operator = re.search(r"[+*/^=<>-]\s*$", expression)
175
+ if trailing_operator:
176
+ return _line_and_column(expression, len(expression.rstrip()))
177
+ repeated_operator = re.search(r"[+*/^=<>]\s*([*/^=<>])", expression)
178
+ if repeated_operator:
179
+ return _line_and_column(expression, repeated_operator.start(1))
180
+ return None, None
181
+
182
+
183
+ def _syntax_hint(expression: str) -> str | None:
184
+ for opening, closing, label in (
185
+ ("{", "}", "curly braces"),
186
+ ("(", ")", "parentheses"),
187
+ ("[", "]", "square brackets"),
188
+ ):
189
+ if expression.count(opening) != expression.count(closing):
190
+ return f"Check that {label} are balanced."
191
+
192
+ environments = re.findall(r"\\begin\{([^}]+)\}", expression)
193
+ endings = re.findall(r"\\end\{([^}]+)\}", expression)
194
+ if environments != endings:
195
+ return "Make sure each \\begin{...} has a matching \\end{...} of the same environment."
196
+ if re.search(r"[+*/^=<>-]\s*$", expression):
197
+ return "The expression ends with an operator; add the missing operand or remove the operator."
198
+ if re.search(r"[+*/^=<>]\s*[*/^=<>]", expression):
199
+ return "Check for consecutive operators that do not form a valid expression."
200
+ return None
201
+
202
+
203
+ def _validate_python_identifier(identifier: object, kind: str) -> None:
204
+ if (
205
+ not isinstance(identifier, str)
206
+ or not identifier.isidentifier()
207
+ or keyword.iskeyword(identifier)
208
+ or identifier in RESERVED_MODULE_NAMES
209
+ ):
210
+ raise InvalidPythonIdentifierError(identifier, kind)
@@ -0,0 +1,207 @@
1
+ import re
2
+
3
+ LATEX_COMMAND_TRANSLATIONS = {
4
+ r"\coth": "coth",
5
+ r"\sech": "sech",
6
+ r"\csch": "csch",
7
+ r"\infty": "oo",
8
+ r"\equiv": "==",
9
+ r"\propto": "~",
10
+ r"\sim": "~",
11
+ r"\land": " and ",
12
+ r"\lor": " or ",
13
+ r"\not": " not ",
14
+ r"\asinh": "asinh",
15
+ r"\acosh": "acosh",
16
+ r"\atanh": "atanh",
17
+ }
18
+
19
+ LATEX_SYMBOL_COMMANDS = {
20
+ r"\frac",
21
+ r"\sin",
22
+ r"\cos",
23
+ r"\tan",
24
+ r"\cot",
25
+ r"\sec",
26
+ r"\csc",
27
+ r"\max",
28
+ r"\min",
29
+ r"\sinh",
30
+ r"\cosh",
31
+ r"\tanh",
32
+ r"\asin",
33
+ r"\acos",
34
+ r"\atan",
35
+ r"\arcsin",
36
+ r"\arccos",
37
+ r"\arctan",
38
+ r"\arccot",
39
+ r"\arcsec",
40
+ r"\arccsc",
41
+ r"\asinh",
42
+ r"\acosh",
43
+ r"\atanh",
44
+ r"\coth",
45
+ r"\sech",
46
+ r"\csch",
47
+ r"\sqrt",
48
+ r"\sum",
49
+ r"\prod",
50
+ r"\lim",
51
+ r"\binom",
52
+ r"\Gamma",
53
+ r"\beta",
54
+ r"\left",
55
+ r"\right",
56
+ r"\exp",
57
+ r"\log",
58
+ r"\ln",
59
+ r"\int",
60
+ r"\partial",
61
+ r"\pm",
62
+ r"\mp",
63
+ r"\times",
64
+ r"\cdot",
65
+ r"\div",
66
+ r"\ge",
67
+ r"\le",
68
+ r"\neq",
69
+ r"\neg",
70
+ r"\infty",
71
+ r"\pi",
72
+ r"\theta",
73
+ r"\alpha",
74
+ r"\gamma",
75
+ r"\delta",
76
+ r"\epsilon",
77
+ r"\varepsilon",
78
+ r"\zeta",
79
+ r"\eta",
80
+ r"\kappa",
81
+ r"\lambda",
82
+ r"\mu",
83
+ r"\nu",
84
+ r"\xi",
85
+ r"\rho",
86
+ r"\sigma",
87
+ r"\tau",
88
+ r"\upsilon",
89
+ r"\phi",
90
+ r"\varphi",
91
+ r"\chi",
92
+ r"\psi",
93
+ r"\omega",
94
+ r"\Delta",
95
+ r"\Lambda",
96
+ r"\Sigma",
97
+ r"\Theta",
98
+ r"\Omega",
99
+ r"\Phi",
100
+ r"\Pi",
101
+ r"\Psi",
102
+ r"\Xi",
103
+ r"\Upsilon",
104
+ r"\operatorname",
105
+ r"\mathrm",
106
+ r"\text",
107
+ r"\textbf",
108
+ r"\begin",
109
+ r"\end",
110
+ r"\vert",
111
+ r"\mid",
112
+ r"\lvert",
113
+ r"\rvert",
114
+ r"\langle",
115
+ r"\rangle",
116
+ r"\lfloor",
117
+ r"\rfloor",
118
+ r"\lceil",
119
+ r"\rceil",
120
+ r"\to",
121
+ r"\quad",
122
+ r"\qquad",
123
+ r"\vspace",
124
+ r"\hspace",
125
+ }
126
+
127
+ LATEX_ENVIRONMENTS = {
128
+ "cases",
129
+ "matrix",
130
+ "pmatrix",
131
+ "bmatrix",
132
+ "Bmatrix",
133
+ "vmatrix",
134
+ "Vmatrix",
135
+ "array",
136
+ "aligned",
137
+ "align",
138
+ "align*",
139
+ "gathered",
140
+ "eqnarray",
141
+ "eqnarray*",
142
+ }
143
+
144
+ VALID_LATEX_COMMANDS = LATEX_SYMBOL_COMMANDS | set(LATEX_COMMAND_TRANSLATIONS)
145
+
146
+
147
+ def _normalize_latex_string(latex_str: str) -> str:
148
+ """Normalizes known LaTeX wrappers and function-style operators into SymPy-friendly syntax."""
149
+ normalized = latex_str.strip()
150
+
151
+ normalized = re.sub(r"\\mathrm\s*\{([A-Za-z]+)\}", r"\1", normalized)
152
+ command_pattern = re.compile(
153
+ "(?:"
154
+ + "|".join(
155
+ re.escape(command)
156
+ for command in sorted(LATEX_COMMAND_TRANSLATIONS, key=len, reverse=True)
157
+ )
158
+ + ")"
159
+ + r"(?![A-Za-z])"
160
+ )
161
+ normalized = command_pattern.sub(
162
+ lambda match: LATEX_COMMAND_TRANSLATIONS[match.group(0)],
163
+ normalized,
164
+ )
165
+
166
+ spacing_commands = (
167
+ r"\\(?:,|;|!|:|quad\b|qquad\b|enspace\b|thinspace\b|medspace\b|"
168
+ r"thickspace\b|negthinspace\b|hfill\b|vfill\b|allowbreak\b)"
169
+ )
170
+ normalized = re.sub(
171
+ spacing_commands,
172
+ lambda match: "".join(
173
+ "\n" if character == "\n" else " " for character in match.group(0)
174
+ ),
175
+ normalized,
176
+ )
177
+ sized_spacing_commands = r"\\(?:hspace|vspace)\*?\s*(?:\{[^{}]*\}|\[[^\[\]]*\])"
178
+ normalized = re.sub(
179
+ sized_spacing_commands,
180
+ lambda match: "".join(
181
+ "\n" if character == "\n" else " " for character in match.group(0)
182
+ ),
183
+ normalized,
184
+ )
185
+ style_commands = r"\\(?:displaystyle|textstyle|scriptstyle|scriptscriptstyle)\b"
186
+ normalized = re.sub(
187
+ style_commands,
188
+ lambda match: " " * len(match.group(0)),
189
+ normalized,
190
+ )
191
+
192
+ normalized = re.sub(
193
+ r"\\left\s*\|\s*(.+?)\s*\\right\s*\|", r"Abs(\1)", normalized, flags=re.DOTALL
194
+ )
195
+ normalized = re.sub(
196
+ r"\\left\s*\\lvert\s*(.+?)\s*\\right\s*\\rvert",
197
+ r"Abs(\1)",
198
+ normalized,
199
+ flags=re.DOTALL,
200
+ )
201
+ normalized = re.sub(r"\\left\s*[\[(]\s*", "(", normalized)
202
+ normalized = re.sub(r"\\right\s*[\])]\s*", ")", normalized)
203
+ normalized = re.sub(r"\\left\s*\\\{\s*", "(", normalized)
204
+ normalized = re.sub(r"\\right\s*\\\}\s*", ")", normalized)
205
+ normalized = re.sub(r"\\(?:left|right)\b", "", normalized)
206
+ normalized = re.sub(r"\\(?:vert|mid|lvert|rvert)\b", "|", normalized)
207
+ return normalized