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