plot3 0.4.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.
- plot3/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
plot3/latexin.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"""Turn a LaTeX formula subset into plot3's plain formula string.
|
|
2
|
+
|
|
3
|
+
The string then goes through the same normaliser and whitelist as typed
|
|
4
|
+
math. LaTeX does not define the meaning, so a few conventions are fixed
|
|
5
|
+
here: ``\\sin x^2`` is ``sin(x**2)``, ``\\sin^2 x`` is ``(sin x)**2``,
|
|
6
|
+
``\\sin^{-1}`` is ``arcsin``, and ``e^{x}`` is ``exp(x)``. Letters written
|
|
7
|
+
side by side are separate variables (``xy`` is ``x*y``). A call ``f(x)``
|
|
8
|
+
is left for the normaliser: it stays a call only when ``f`` was passed in.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
_EATEN_MSG = (
|
|
14
|
+
'This looks like LaTeX with backslashes eaten by Python. '
|
|
15
|
+
'Use a raw string: r"\\frac{1}{x}".'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# Python already turned the backslash into a control character.
|
|
19
|
+
# Form feed, bell, backspace, and vertical tab are never indentation.
|
|
20
|
+
_ALWAYS_EATEN = set("\a\b\f\v")
|
|
21
|
+
# Tab, newline, and carriage return also start real commands (\theta, \nu, \right).
|
|
22
|
+
# A suffix match keeps an ordinary line break such as a triple-quoted "y = x".
|
|
23
|
+
_EATEN_SUFFIX = {
|
|
24
|
+
"\t": ("heta", "imes", "frac", "riangle", "ext", "au", "an", "o"),
|
|
25
|
+
"\n": ("abla", "otin", "eq", "u", "e"),
|
|
26
|
+
"\r": ("ight", "angle", "ho"),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_GREEK = {
|
|
30
|
+
"alpha": "alpha", "beta": "beta", "gamma": "gamma", "delta": "delta",
|
|
31
|
+
"epsilon": "epsilon", "zeta": "zeta", "eta": "eta", "theta": "theta",
|
|
32
|
+
"iota": "iota", "kappa": "kappa", "lambda": "lambda", "mu": "mu",
|
|
33
|
+
"nu": "nu", "xi": "xi", "pi": "pi", "rho": "rho", "sigma": "sigma",
|
|
34
|
+
"tau": "tau", "phi": "phi", "chi": "chi", "psi": "psi", "omega": "omega",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Command -> the name the plain parser already knows. \ln is natural log.
|
|
38
|
+
_FUNCS = {
|
|
39
|
+
"sin": "sin", "cos": "cos", "tan": "tan",
|
|
40
|
+
"sinh": "sinh", "cosh": "cosh", "tanh": "tanh",
|
|
41
|
+
"arcsin": "arcsin", "arccos": "arccos", "arctan": "arctan",
|
|
42
|
+
"asin": "arcsin", "acos": "arccos", "atan": "arctan",
|
|
43
|
+
"ln": "log", "log": "log", "exp": "exp",
|
|
44
|
+
}
|
|
45
|
+
_INVERSE = {"sin": "arcsin", "cos": "arccos", "tan": "arctan"}
|
|
46
|
+
_SPACING = {",", ";", ":", "!", " ", "quad", "qquad", "\\", "thinspace", "medspace", "thickspace"}
|
|
47
|
+
_VALUE_CMDS = {
|
|
48
|
+
"frac", "dfrac", "tfrac", "sqrt", "left", "lvert", "vert", "|",
|
|
49
|
+
"lfloor", "lceil", "operatorname", "mathrm", "{",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reject_eaten_backslashes(source: str) -> None:
|
|
54
|
+
"""Raise when a normal Python string has already eaten ``\\frac`` or ``\\theta``."""
|
|
55
|
+
for index, char in enumerate(source):
|
|
56
|
+
if char in _ALWAYS_EATEN:
|
|
57
|
+
raise ValueError(_EATEN_MSG)
|
|
58
|
+
suffixes = _EATEN_SUFFIX.get(char)
|
|
59
|
+
if not suffixes:
|
|
60
|
+
continue
|
|
61
|
+
rest = source[index + 1 :]
|
|
62
|
+
for suffix in suffixes:
|
|
63
|
+
if not rest.startswith(suffix):
|
|
64
|
+
continue
|
|
65
|
+
after = rest[len(suffix) : len(suffix) + 1]
|
|
66
|
+
if after.isalpha():
|
|
67
|
+
continue
|
|
68
|
+
# "\ny = x" is a line break. "\nu" is a newline plus the letter u.
|
|
69
|
+
if char == "\n" and (index == 0 or source[index - 1] == "\n"):
|
|
70
|
+
if source.strip() != suffix:
|
|
71
|
+
continue
|
|
72
|
+
raise ValueError(_EATEN_MSG)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_latex(text: str) -> bool:
|
|
76
|
+
"""True for ``$...$`` or any backslash command. Plain ``x^{2}`` is not."""
|
|
77
|
+
body = text.strip()
|
|
78
|
+
if "\\" in body:
|
|
79
|
+
return True
|
|
80
|
+
return len(body) >= 2 and body[0] == "$" and body[-1] == "$"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def unwrap_latex(text: str) -> str:
|
|
84
|
+
"""Drop one layer of ``$`` or ``$$`` wrappers."""
|
|
85
|
+
body = text.strip()
|
|
86
|
+
if body.startswith("$$") and body.endswith("$$") and len(body) >= 4:
|
|
87
|
+
return body[2:-2].strip()
|
|
88
|
+
if len(body) >= 2 and body[0] == "$" and body[-1] == "$":
|
|
89
|
+
return body[1:-1].strip()
|
|
90
|
+
return body
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def latex_to_source(source: str, *, e_is_constant: bool = True) -> str:
|
|
94
|
+
"""Translate a LaTeX subset into ``2*x``, ``sin(x)``, ``x**(2)``."""
|
|
95
|
+
parser = _Parser(_tokenize(source), e_is_constant=e_is_constant)
|
|
96
|
+
return parser.parse()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _tokenize(source: str) -> list[tuple[str, str]]:
|
|
100
|
+
tokens: list[tuple[str, str]] = []
|
|
101
|
+
index = 0
|
|
102
|
+
length = len(source)
|
|
103
|
+
while index < length:
|
|
104
|
+
char = source[index]
|
|
105
|
+
if char.isspace() or char == "~":
|
|
106
|
+
index += 1
|
|
107
|
+
continue
|
|
108
|
+
if char == "\\":
|
|
109
|
+
name, index = _command_at(source, index)
|
|
110
|
+
if name in _SPACING:
|
|
111
|
+
# A row break in \begin{cases}. Other spacing commands stay blank.
|
|
112
|
+
if name == "\\":
|
|
113
|
+
tokens.append(("op", "\\\\"))
|
|
114
|
+
continue
|
|
115
|
+
tokens.append(("cmd", name))
|
|
116
|
+
continue
|
|
117
|
+
if char.isdigit() or (char == "." and index + 1 < length and source[index + 1].isdigit()):
|
|
118
|
+
end = index + 1
|
|
119
|
+
seen_dot = char == "."
|
|
120
|
+
while end < length and (source[end].isdigit() or (source[end] == "." and not seen_dot)):
|
|
121
|
+
if source[end] == ".":
|
|
122
|
+
seen_dot = True
|
|
123
|
+
end += 1
|
|
124
|
+
tokens.append(("num", source[index:end]))
|
|
125
|
+
index = end
|
|
126
|
+
continue
|
|
127
|
+
if char.isalpha():
|
|
128
|
+
# One letter per variable. \theta and \operatorname carry longer names.
|
|
129
|
+
tokens.append(("name", char))
|
|
130
|
+
index += 1
|
|
131
|
+
continue
|
|
132
|
+
if source.startswith("//", index):
|
|
133
|
+
tokens.append(("op", "//"))
|
|
134
|
+
index += 2
|
|
135
|
+
continue
|
|
136
|
+
if char in "+-*/^=_,()[]{}|&<>":
|
|
137
|
+
tokens.append(("op", char))
|
|
138
|
+
index += 1
|
|
139
|
+
continue
|
|
140
|
+
raise ValueError(f"syntax error at {char!r}")
|
|
141
|
+
return tokens
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _command_at(source: str, index: int) -> tuple[str, int]:
|
|
145
|
+
cursor = index + 1
|
|
146
|
+
if cursor >= len(source):
|
|
147
|
+
raise ValueError("syntax error at '\\\\'")
|
|
148
|
+
if not source[cursor].isalpha():
|
|
149
|
+
return source[cursor], cursor + 1
|
|
150
|
+
end = cursor + 1
|
|
151
|
+
while end < len(source) and source[end].isalpha():
|
|
152
|
+
end += 1
|
|
153
|
+
return source[cursor:end], end
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _neg_one(text: str) -> bool:
|
|
157
|
+
return text.replace(" ", "") in {"-1", "-(1)", "(-1)"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class _Parser:
|
|
161
|
+
def __init__(self, tokens: list[tuple[str, str]], *, e_is_constant: bool):
|
|
162
|
+
self.tokens = tokens
|
|
163
|
+
self.index = 0
|
|
164
|
+
self.e_is_constant = e_is_constant
|
|
165
|
+
self.bar_depth = 0
|
|
166
|
+
|
|
167
|
+
def parse(self) -> str:
|
|
168
|
+
if not self.tokens:
|
|
169
|
+
raise ValueError("syntax error at end of formula")
|
|
170
|
+
left = self._relation()
|
|
171
|
+
if self._eat_op("="):
|
|
172
|
+
right = self._relation()
|
|
173
|
+
self._finish()
|
|
174
|
+
return f"{left} = {right}"
|
|
175
|
+
self._finish()
|
|
176
|
+
return left
|
|
177
|
+
|
|
178
|
+
def _relation(self) -> str:
|
|
179
|
+
left = self._expr()
|
|
180
|
+
rel = self._eat_relation()
|
|
181
|
+
if rel is None:
|
|
182
|
+
return left
|
|
183
|
+
right = self._expr()
|
|
184
|
+
return f"({left}){rel}({right})"
|
|
185
|
+
|
|
186
|
+
def _eat_relation(self) -> str | None:
|
|
187
|
+
if self._eat_op("<"):
|
|
188
|
+
if self._eat_op("="):
|
|
189
|
+
return "<="
|
|
190
|
+
return "<"
|
|
191
|
+
if self._eat_op(">"):
|
|
192
|
+
if self._eat_op("="):
|
|
193
|
+
return ">="
|
|
194
|
+
return ">"
|
|
195
|
+
mapping = {
|
|
196
|
+
"le": "<=",
|
|
197
|
+
"leq": "<=",
|
|
198
|
+
"ge": ">=",
|
|
199
|
+
"geq": ">=",
|
|
200
|
+
"lt": "<",
|
|
201
|
+
"gt": ">",
|
|
202
|
+
"ne": "!=",
|
|
203
|
+
"neq": "!=",
|
|
204
|
+
}
|
|
205
|
+
tok = self._peek()
|
|
206
|
+
if tok is not None and tok[0] == "cmd" and tok[1] in mapping:
|
|
207
|
+
self.index += 1
|
|
208
|
+
return mapping[tok[1]]
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def _finish(self) -> None:
|
|
212
|
+
tok = self._peek()
|
|
213
|
+
if tok is not None:
|
|
214
|
+
self._bad(tok)
|
|
215
|
+
|
|
216
|
+
def _peek(self) -> tuple[str, str] | None:
|
|
217
|
+
if self.index >= len(self.tokens):
|
|
218
|
+
return None
|
|
219
|
+
return self.tokens[self.index]
|
|
220
|
+
|
|
221
|
+
def _eat_op(self, text: str) -> bool:
|
|
222
|
+
if self._peek() == ("op", text):
|
|
223
|
+
self.index += 1
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def _eat_cmd(self, name: str) -> bool:
|
|
228
|
+
if self._peek() == ("cmd", name):
|
|
229
|
+
self.index += 1
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def _expect_op(self, text: str) -> None:
|
|
234
|
+
if not self._eat_op(text):
|
|
235
|
+
tok = self._peek()
|
|
236
|
+
if tok is None:
|
|
237
|
+
raise ValueError(f"syntax error at end of formula, expected {text!r}")
|
|
238
|
+
self._bad(tok)
|
|
239
|
+
|
|
240
|
+
def _bad(self, tok: tuple[str, str]) -> None:
|
|
241
|
+
kind, val = tok
|
|
242
|
+
if kind == "cmd":
|
|
243
|
+
raise ValueError(f"\\{val} isn't supported in geom_function")
|
|
244
|
+
raise ValueError(f"syntax error at {val!r}")
|
|
245
|
+
|
|
246
|
+
def _starts(self, *, functions: bool) -> bool:
|
|
247
|
+
tok = self._peek()
|
|
248
|
+
if tok is None:
|
|
249
|
+
return False
|
|
250
|
+
kind, val = tok
|
|
251
|
+
if kind in {"num", "name"}:
|
|
252
|
+
return True
|
|
253
|
+
if kind == "op":
|
|
254
|
+
if val in "({[":
|
|
255
|
+
return True
|
|
256
|
+
return val == "|" and self.bar_depth == 0
|
|
257
|
+
if kind != "cmd":
|
|
258
|
+
return False
|
|
259
|
+
if val in _FUNCS or val in {"operatorname", "mathrm"}:
|
|
260
|
+
return functions
|
|
261
|
+
if val in _VALUE_CMDS or val in _GREEK:
|
|
262
|
+
return True
|
|
263
|
+
# An unknown command is a factor so the error can name it.
|
|
264
|
+
if val in {
|
|
265
|
+
"cdot", "times", "bmod", "right", "rvert", "rfloor", "rceil", "end",
|
|
266
|
+
"le", "leq", "ge", "geq", "lt", "gt", "ne", "neq",
|
|
267
|
+
}:
|
|
268
|
+
return False
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def _expr(self) -> str:
|
|
272
|
+
left = self._term()
|
|
273
|
+
while True:
|
|
274
|
+
if self._eat_op("+"):
|
|
275
|
+
left = f"({left})+({self._term()})"
|
|
276
|
+
elif self._eat_op("-"):
|
|
277
|
+
left = f"({left})-({self._term()})"
|
|
278
|
+
else:
|
|
279
|
+
break
|
|
280
|
+
return left
|
|
281
|
+
|
|
282
|
+
def _term(self) -> str:
|
|
283
|
+
left = self._unary()
|
|
284
|
+
while True:
|
|
285
|
+
if self._eat_op("*") or self._eat_cmd("cdot") or self._eat_cmd("times"):
|
|
286
|
+
left = f"({left})*({self._unary()})"
|
|
287
|
+
elif self._eat_op("/"):
|
|
288
|
+
left = f"({left})/({self._unary()})"
|
|
289
|
+
elif self._eat_op("//"):
|
|
290
|
+
left = f"({left})//({self._unary()})"
|
|
291
|
+
elif self._eat_op("%") or self._eat_cmd("bmod"):
|
|
292
|
+
left = f"({left})%({self._unary()})"
|
|
293
|
+
elif self._starts(functions=True):
|
|
294
|
+
left = f"({left})*({self._unary()})"
|
|
295
|
+
else:
|
|
296
|
+
break
|
|
297
|
+
return left
|
|
298
|
+
|
|
299
|
+
def _unary(self) -> str:
|
|
300
|
+
if self._eat_op("+"):
|
|
301
|
+
return self._unary()
|
|
302
|
+
if self._eat_op("-"):
|
|
303
|
+
return f"-({self._unary()})"
|
|
304
|
+
return self._power()
|
|
305
|
+
|
|
306
|
+
def _power(self) -> str:
|
|
307
|
+
base = self._atom()
|
|
308
|
+
if not self._eat_op("^"):
|
|
309
|
+
return base
|
|
310
|
+
exp = self._script()
|
|
311
|
+
if base == "e" and self.e_is_constant:
|
|
312
|
+
return f"exp({exp})"
|
|
313
|
+
return f"({base})**({exp})"
|
|
314
|
+
|
|
315
|
+
def _script(self) -> str:
|
|
316
|
+
if self._eat_op("{"):
|
|
317
|
+
inner = self._expr()
|
|
318
|
+
self._expect_op("}")
|
|
319
|
+
return inner
|
|
320
|
+
return self._unary()
|
|
321
|
+
|
|
322
|
+
def _atom(self) -> str:
|
|
323
|
+
tok = self._peek()
|
|
324
|
+
if tok is None:
|
|
325
|
+
raise ValueError("syntax error at end of formula")
|
|
326
|
+
kind, val = tok
|
|
327
|
+
if kind == "num":
|
|
328
|
+
self.index += 1
|
|
329
|
+
return val
|
|
330
|
+
if kind == "name":
|
|
331
|
+
self.index += 1
|
|
332
|
+
base = self._subscript(val)
|
|
333
|
+
if self._peek() == ("op", "("):
|
|
334
|
+
return self._call(base)
|
|
335
|
+
return base
|
|
336
|
+
if kind == "op":
|
|
337
|
+
if val == "(":
|
|
338
|
+
return self._group(")")
|
|
339
|
+
if val == "[":
|
|
340
|
+
return self._group("]")
|
|
341
|
+
if val == "{":
|
|
342
|
+
return self._group("}")
|
|
343
|
+
if val == "|":
|
|
344
|
+
return self._bars()
|
|
345
|
+
self._bad(tok)
|
|
346
|
+
if val in _FUNCS:
|
|
347
|
+
return self._func(val)
|
|
348
|
+
if val in {"frac", "dfrac", "tfrac"}:
|
|
349
|
+
return self._frac()
|
|
350
|
+
if val == "sqrt":
|
|
351
|
+
return self._sqrt()
|
|
352
|
+
if val in {"operatorname", "mathrm"}:
|
|
353
|
+
return self._word()
|
|
354
|
+
if val in _GREEK:
|
|
355
|
+
self.index += 1
|
|
356
|
+
return self._subscript(_GREEK[val])
|
|
357
|
+
if val == "begin":
|
|
358
|
+
return self._cases()
|
|
359
|
+
if val == "left":
|
|
360
|
+
return self._left()
|
|
361
|
+
if val in {"lvert", "vert", "|"}:
|
|
362
|
+
return self._abs_until({"rvert", "vert", "|"})
|
|
363
|
+
if val == "lfloor":
|
|
364
|
+
return self._abs_until({"rfloor"}, wrapper="floor")
|
|
365
|
+
if val == "lceil":
|
|
366
|
+
return self._abs_until({"rceil"}, wrapper="ceil")
|
|
367
|
+
if val == "{":
|
|
368
|
+
return self._group("}")
|
|
369
|
+
self._bad(tok)
|
|
370
|
+
|
|
371
|
+
def _env_name(self) -> str:
|
|
372
|
+
chars: list[str] = []
|
|
373
|
+
while True:
|
|
374
|
+
tok = self._peek()
|
|
375
|
+
if tok is None or tok[0] != "name":
|
|
376
|
+
break
|
|
377
|
+
chars.append(tok[1])
|
|
378
|
+
self.index += 1
|
|
379
|
+
if not chars:
|
|
380
|
+
raise ValueError("syntax error at '\\begin'")
|
|
381
|
+
return "".join(chars)
|
|
382
|
+
|
|
383
|
+
def _cases(self) -> str:
|
|
384
|
+
"""\\begin{cases} value & condition \\\\ ... \\end{cases} → where()."""
|
|
385
|
+
self.index += 1
|
|
386
|
+
self._expect_op("{")
|
|
387
|
+
env = self._env_name()
|
|
388
|
+
self._expect_op("}")
|
|
389
|
+
if env != "cases":
|
|
390
|
+
raise ValueError(
|
|
391
|
+
f"\\begin{{{env}}} isn't supported in geom_function"
|
|
392
|
+
)
|
|
393
|
+
rows: list[tuple[str, str]] = []
|
|
394
|
+
if self._peek() == ("cmd", "end"):
|
|
395
|
+
raise ValueError("\\begin{cases} needs at least one row")
|
|
396
|
+
while self._peek() != ("cmd", "end"):
|
|
397
|
+
if self._peek() is None:
|
|
398
|
+
raise ValueError("\\begin{cases} is missing \\end{cases}")
|
|
399
|
+
value = self._expr()
|
|
400
|
+
if not self._eat_op("&"):
|
|
401
|
+
raise ValueError("syntax error in \\begin{cases}: expected &")
|
|
402
|
+
cond = self._relation()
|
|
403
|
+
rows.append((cond, value))
|
|
404
|
+
if self._eat_op("\\\\"):
|
|
405
|
+
continue
|
|
406
|
+
if self._peek() == ("cmd", "end"):
|
|
407
|
+
break
|
|
408
|
+
raise ValueError(
|
|
409
|
+
"syntax error in \\begin{cases}: expected \\\\ or \\end{cases}"
|
|
410
|
+
)
|
|
411
|
+
if not rows:
|
|
412
|
+
raise ValueError("\\begin{cases} needs at least one row")
|
|
413
|
+
if not self._eat_cmd("end"):
|
|
414
|
+
raise ValueError("\\begin{cases} is missing \\end{cases}")
|
|
415
|
+
self._expect_op("{")
|
|
416
|
+
end_env = self._env_name()
|
|
417
|
+
self._expect_op("}")
|
|
418
|
+
if end_env != env:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
f"\\end{{{end_env}}} doesn't match \\begin{{{env}}}"
|
|
421
|
+
)
|
|
422
|
+
expr = "nan"
|
|
423
|
+
for cond, value in reversed(rows):
|
|
424
|
+
expr = f"where({cond}, {value}, {expr})"
|
|
425
|
+
return expr
|
|
426
|
+
|
|
427
|
+
def _group(self, closer: str) -> str:
|
|
428
|
+
self.index += 1
|
|
429
|
+
inner = self._expr()
|
|
430
|
+
if closer == "}" and self._eat_cmd("}"):
|
|
431
|
+
return f"({inner})"
|
|
432
|
+
self._expect_op(closer)
|
|
433
|
+
return f"({inner})"
|
|
434
|
+
|
|
435
|
+
def _bars(self) -> str:
|
|
436
|
+
self.index += 1
|
|
437
|
+
self.bar_depth += 1
|
|
438
|
+
try:
|
|
439
|
+
inner = self._expr()
|
|
440
|
+
finally:
|
|
441
|
+
self.bar_depth -= 1
|
|
442
|
+
self._expect_op("|")
|
|
443
|
+
return f"abs({inner})"
|
|
444
|
+
|
|
445
|
+
def _subscript(self, base: str) -> str:
|
|
446
|
+
if not self._eat_op("_"):
|
|
447
|
+
return base
|
|
448
|
+
if self._eat_op("{"):
|
|
449
|
+
sub = self._expr()
|
|
450
|
+
self._expect_op("}")
|
|
451
|
+
else:
|
|
452
|
+
sub = self._script_atom()
|
|
453
|
+
if not sub or any(not (ch.isalnum() or ch == "_") for ch in sub):
|
|
454
|
+
raise ValueError(f"syntax error at subscript {sub!r}")
|
|
455
|
+
return f"{base}_{sub}"
|
|
456
|
+
|
|
457
|
+
def _script_atom(self) -> str:
|
|
458
|
+
tok = self._peek()
|
|
459
|
+
if tok is None:
|
|
460
|
+
raise ValueError("syntax error at end of formula")
|
|
461
|
+
kind, val = tok
|
|
462
|
+
if kind in {"num", "name"}:
|
|
463
|
+
self.index += 1
|
|
464
|
+
return val
|
|
465
|
+
if kind == "cmd" and val in _GREEK:
|
|
466
|
+
self.index += 1
|
|
467
|
+
return _GREEK[val]
|
|
468
|
+
self._bad(tok)
|
|
469
|
+
|
|
470
|
+
def _call(self, name: str) -> str:
|
|
471
|
+
self._expect_op("(")
|
|
472
|
+
args: list[str] = []
|
|
473
|
+
if self._peek() != ("op", ")"):
|
|
474
|
+
args.append(self._expr())
|
|
475
|
+
while self._eat_op(","):
|
|
476
|
+
args.append(self._expr())
|
|
477
|
+
self._expect_op(")")
|
|
478
|
+
return f"{name}({', '.join(args)})"
|
|
479
|
+
|
|
480
|
+
def _func(self, cmd: str) -> str:
|
|
481
|
+
self.index += 1
|
|
482
|
+
name = _FUNCS[cmd]
|
|
483
|
+
power = None
|
|
484
|
+
inverse = False
|
|
485
|
+
for _ in range(2):
|
|
486
|
+
if power is None and not inverse and self._eat_op("^"):
|
|
487
|
+
exp = self._script()
|
|
488
|
+
if _neg_one(exp):
|
|
489
|
+
inverse = True
|
|
490
|
+
else:
|
|
491
|
+
power = exp
|
|
492
|
+
elif cmd == "log" and name == "log" and self._eat_op("_"):
|
|
493
|
+
sub = self._script()
|
|
494
|
+
if sub != "10":
|
|
495
|
+
raise ValueError(
|
|
496
|
+
"\\log with a subscript other than 10 isn't supported "
|
|
497
|
+
"in geom_function"
|
|
498
|
+
)
|
|
499
|
+
name = "log10"
|
|
500
|
+
else:
|
|
501
|
+
break
|
|
502
|
+
if inverse:
|
|
503
|
+
mapped = _INVERSE.get(name)
|
|
504
|
+
if mapped:
|
|
505
|
+
name = mapped
|
|
506
|
+
else:
|
|
507
|
+
power = "-(1)"
|
|
508
|
+
if self._eat_op("{"):
|
|
509
|
+
arg = self._expr()
|
|
510
|
+
self._expect_op("}")
|
|
511
|
+
elif self._peek_sign() or self._starts(functions=True):
|
|
512
|
+
arg = self._unary()
|
|
513
|
+
while self._starts(functions=False):
|
|
514
|
+
arg = f"({arg})*({self._unary()})"
|
|
515
|
+
else:
|
|
516
|
+
raise ValueError(f"syntax error at '\\{cmd}', expected an argument")
|
|
517
|
+
call = f"{name}({arg})"
|
|
518
|
+
if power is not None:
|
|
519
|
+
return f"({call})**({power})"
|
|
520
|
+
return call
|
|
521
|
+
|
|
522
|
+
def _peek_sign(self) -> bool:
|
|
523
|
+
tok = self._peek()
|
|
524
|
+
return tok is not None and tok[0] == "op" and tok[1] in "+-"
|
|
525
|
+
|
|
526
|
+
def _frac(self) -> str:
|
|
527
|
+
self.index += 1
|
|
528
|
+
num = self._braced()
|
|
529
|
+
den = self._braced()
|
|
530
|
+
return f"({num})/({den})"
|
|
531
|
+
|
|
532
|
+
def _braced(self) -> str:
|
|
533
|
+
if not self._eat_op("{"):
|
|
534
|
+
tok = self._peek()
|
|
535
|
+
if tok is None:
|
|
536
|
+
raise ValueError("syntax error at end of formula")
|
|
537
|
+
self._bad(tok)
|
|
538
|
+
inner = self._expr()
|
|
539
|
+
self._expect_op("}")
|
|
540
|
+
return inner
|
|
541
|
+
|
|
542
|
+
def _sqrt(self) -> str:
|
|
543
|
+
self.index += 1
|
|
544
|
+
index = None
|
|
545
|
+
if self._eat_op("["):
|
|
546
|
+
index = self._expr()
|
|
547
|
+
self._expect_op("]")
|
|
548
|
+
body = self._braced()
|
|
549
|
+
if index is None or index in {"2", "(2)"}:
|
|
550
|
+
return f"sqrt({body})"
|
|
551
|
+
if index in {"3", "(3)"}:
|
|
552
|
+
return f"cbrt({body})"
|
|
553
|
+
return f"({body})**(1/({index}))"
|
|
554
|
+
|
|
555
|
+
def _word(self) -> str:
|
|
556
|
+
self.index += 1
|
|
557
|
+
if not self._eat_op("{"):
|
|
558
|
+
raise ValueError("syntax error at '\\mathrm', expected a name")
|
|
559
|
+
parts: list[str] = []
|
|
560
|
+
while self._peek() not in {None, ("op", "}")}:
|
|
561
|
+
tok = self._peek()
|
|
562
|
+
assert tok is not None
|
|
563
|
+
kind, val = tok
|
|
564
|
+
if kind in {"name", "num"} or (kind == "op" and val == "_"):
|
|
565
|
+
parts.append(val)
|
|
566
|
+
self.index += 1
|
|
567
|
+
continue
|
|
568
|
+
self._bad(tok)
|
|
569
|
+
self._expect_op("}")
|
|
570
|
+
word = "".join(parts)
|
|
571
|
+
if not word.isidentifier():
|
|
572
|
+
raise ValueError(f"syntax error at '\\mathrm{{{word}}}'")
|
|
573
|
+
if self._peek() == ("op", "("):
|
|
574
|
+
return self._call(word)
|
|
575
|
+
return word
|
|
576
|
+
|
|
577
|
+
def _left(self) -> str:
|
|
578
|
+
self.index += 1
|
|
579
|
+
tok = self._peek()
|
|
580
|
+
if tok is None:
|
|
581
|
+
raise ValueError("syntax error at '\\left'")
|
|
582
|
+
kind, val = tok
|
|
583
|
+
self.index += 1
|
|
584
|
+
if val in {"|", "lvert", "vert"}:
|
|
585
|
+
inner = self._expr()
|
|
586
|
+
self._right({"|", "rvert", "vert"})
|
|
587
|
+
return f"abs({inner})"
|
|
588
|
+
if val in {"(", "[", "{", "lbrace"}:
|
|
589
|
+
inner = self._expr()
|
|
590
|
+
self._right({")", "]", "}", "rbrace"})
|
|
591
|
+
return f"({inner})"
|
|
592
|
+
if kind == "cmd":
|
|
593
|
+
raise ValueError(f"\\{val} isn't supported in geom_function")
|
|
594
|
+
raise ValueError(f"syntax error at {val!r}")
|
|
595
|
+
|
|
596
|
+
def _right(self, closers: set[str]) -> None:
|
|
597
|
+
if not self._eat_cmd("right"):
|
|
598
|
+
tok = self._peek()
|
|
599
|
+
if tok is None:
|
|
600
|
+
raise ValueError("syntax error at end of formula, expected \\right")
|
|
601
|
+
self._bad(tok)
|
|
602
|
+
tok = self._peek()
|
|
603
|
+
if tok is None or tok[1] not in closers:
|
|
604
|
+
raise ValueError("syntax error at '\\right'")
|
|
605
|
+
self.index += 1
|
|
606
|
+
|
|
607
|
+
def _abs_until(self, closers: set[str], wrapper: str = "abs") -> str:
|
|
608
|
+
self.index += 1
|
|
609
|
+
inner = self._expr()
|
|
610
|
+
tok = self._peek()
|
|
611
|
+
if tok is not None and tok[1] in closers and (tok[0] == "cmd" or tok == ("op", "|")):
|
|
612
|
+
self.index += 1
|
|
613
|
+
return f"{wrapper}({inner})"
|
|
614
|
+
if tok is None:
|
|
615
|
+
raise ValueError("syntax error at end of formula")
|
|
616
|
+
self._bad(tok)
|