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/mathtext.py ADDED
@@ -0,0 +1,842 @@
1
+ """LaTeX and Unicode for a parsed formula.
2
+
3
+ The walker reads the syntax tree (``^``, implicit ``*``, and grouping already
4
+ resolved) and does not combine like terms. ``2x^3 + 3x^3`` stays two terms.
5
+ A substituted constant such as ``(a - 1)`` with ``a = 2`` does fold to a number.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import math
12
+ from dataclasses import dataclass
13
+
14
+ # Higher binds tighter. Parentheses follow the same cuts as ast.unparse.
15
+ _ADD = 10
16
+ _MUL = 20
17
+ _UNARY = 30
18
+ _POW = 40
19
+ _ATOM = 50
20
+
21
+ _GREEK = {
22
+ "alpha": ("\\alpha", "α"),
23
+ "beta": ("\\beta", "β"),
24
+ "gamma": ("\\gamma", "γ"),
25
+ "delta": ("\\delta", "δ"),
26
+ "epsilon": ("\\epsilon", "ε"),
27
+ "zeta": ("\\zeta", "ζ"),
28
+ "eta": ("\\eta", "η"),
29
+ "theta": ("\\theta", "θ"),
30
+ "iota": ("\\iota", "ι"),
31
+ "kappa": ("\\kappa", "κ"),
32
+ "lambda": ("\\lambda", "λ"),
33
+ "mu": ("\\mu", "μ"),
34
+ "nu": ("\\nu", "ν"),
35
+ "xi": ("\\xi", "ξ"),
36
+ "pi": ("\\pi", "π"),
37
+ "rho": ("\\rho", "ρ"),
38
+ "sigma": ("\\sigma", "σ"),
39
+ "tau": ("\\tau", "τ"),
40
+ "phi": ("\\phi", "φ"),
41
+ "chi": ("\\chi", "χ"),
42
+ "psi": ("\\psi", "ψ"),
43
+ "omega": ("\\omega", "ω"),
44
+ }
45
+
46
+ # Textbook operators. The power sits on the name: sin(x)^2 -> \sin^{2} x.
47
+ _OPS = {
48
+ "sin": ("\\sin", "sin"),
49
+ "cos": ("\\cos", "cos"),
50
+ "tan": ("\\tan", "tan"),
51
+ "asin": ("\\arcsin", "arcsin"),
52
+ "acos": ("\\arccos", "arccos"),
53
+ "atan": ("\\arctan", "arctan"),
54
+ "arcsin": ("\\arcsin", "arcsin"),
55
+ "arccos": ("\\arccos", "arccos"),
56
+ "arctan": ("\\arctan", "arctan"),
57
+ "sinh": ("\\sinh", "sinh"),
58
+ "cosh": ("\\cosh", "cosh"),
59
+ "tanh": ("\\tanh", "tanh"),
60
+ "log": ("\\ln", "ln"),
61
+ "ln": ("\\ln", "ln"),
62
+ "log10": ("\\log_{10}", "log₁₀"),
63
+ }
64
+
65
+ _SUP = str.maketrans(
66
+ {
67
+ "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
68
+ "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
69
+ "+": "⁺", "-": "⁻", "−": "⁻", "(": "⁽", ")": "⁾",
70
+ "a": "ᵃ", "b": "ᵇ", "c": "ᶜ", "d": "ᵈ", "e": "ᵉ",
71
+ "f": "ᶠ", "g": "ᵍ", "h": "ʰ", "i": "ⁱ", "j": "ʲ",
72
+ "k": "ᵏ", "l": "ˡ", "m": "ᵐ", "n": "ⁿ", "o": "ᵒ",
73
+ "p": "ᵖ", "r": "ʳ", "s": "ˢ", "t": "ᵗ", "u": "ᵘ",
74
+ "v": "ᵛ", "w": "ʷ", "x": "ˣ", "y": "ʸ", "z": "ᶻ",
75
+ }
76
+ )
77
+ _SUB = str.maketrans(
78
+ {
79
+ "0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄",
80
+ "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉",
81
+ "+": "₊", "-": "₋", "−": "₋", "(": "₍", ")": "₎",
82
+ "a": "ₐ", "e": "ₑ", "h": "ₕ", "i": "ᵢ", "j": "ⱼ",
83
+ "k": "ₖ", "l": "ₗ", "m": "ₘ", "n": "ₙ", "o": "ₒ",
84
+ "p": "ₚ", "r": "ᵣ", "s": "ₛ", "t": "ₜ", "u": "ᵤ",
85
+ "v": "ᵥ", "x": "ₓ",
86
+ }
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class _Piece:
92
+ latex: str
93
+ pretty: str
94
+ prec: int
95
+ kind: str
96
+
97
+
98
+ def formula_texts(
99
+ lhs: ast.AST | None,
100
+ rhs: ast.AST,
101
+ *,
102
+ mode: str,
103
+ dependent: str | None,
104
+ parameters: dict[str, float] | None = None,
105
+ ) -> dict[str, str]:
106
+ """Symbolic and value-substituted LaTeX and Unicode for one formula."""
107
+ params = parameters or {}
108
+ latex, pretty = _equation(lhs, rhs, mode, dependent, None)
109
+ if params:
110
+ legend_latex, legend_pretty = _equation(lhs, rhs, mode, dependent, params)
111
+ else:
112
+ legend_latex, legend_pretty = latex, pretty
113
+ caption_latex, caption_pretty = _caption(latex, pretty, lhs, rhs, params)
114
+ return {
115
+ "latex": latex,
116
+ "pretty": pretty,
117
+ "legend_latex": legend_latex,
118
+ "legend_pretty": legend_pretty,
119
+ "caption_latex": caption_latex,
120
+ "caption_pretty": caption_pretty,
121
+ }
122
+
123
+
124
+ def split_math(text: str) -> tuple[str, list[dict[str, str]] | None]:
125
+ """Split a label on ``$...$``. Plain text is the Unicode fallback.
126
+
127
+ An unmatched ``$`` stays as text. Segments are ``{"text": ...}`` or
128
+ ``{"text": pretty, "latex": source}``.
129
+ """
130
+ if "$" not in text:
131
+ return text, None
132
+ parts: list[dict[str, str]] = []
133
+ plain: list[str] = []
134
+ index = 0
135
+ while index < len(text):
136
+ start = text.find("$", index)
137
+ if start < 0:
138
+ parts.append({"text": text[index:]})
139
+ plain.append(text[index:])
140
+ break
141
+ end = text.find("$", start + 1)
142
+ if end < 0:
143
+ parts.append({"text": text[index:]})
144
+ plain.append(text[index:])
145
+ break
146
+ if start > index:
147
+ parts.append({"text": text[index:start]})
148
+ plain.append(text[index:start])
149
+ latex = text[start + 1 : end]
150
+ pretty = latex_to_pretty(latex)
151
+ parts.append({"text": pretty, "latex": latex})
152
+ plain.append(pretty)
153
+ index = end + 1
154
+ if not any("latex" in part for part in parts):
155
+ return text, None
156
+ return "".join(plain), parts
157
+
158
+
159
+ def latex_to_pretty(src: str) -> str:
160
+ """A small LaTeX subset for canvas labels when KaTeX cannot draw."""
161
+ out: list[str] = []
162
+ index = 0
163
+ length = len(src)
164
+ while index < length:
165
+ char = src[index]
166
+ if char == "\\":
167
+ name, index = _command_name(src, index)
168
+ text, index = _command_pretty(name, src, index)
169
+ out.append(text)
170
+ continue
171
+ if char == "^":
172
+ body, index = _script_body(src, index + 1)
173
+ # ^\circ is the degree sign. Superscripting the ring draws ᶜⁱʳᶜ.
174
+ if body in {"∘", "°"}:
175
+ out.append("°")
176
+ else:
177
+ out.append(_raised(body))
178
+ continue
179
+ if char == "_":
180
+ body, index = _script_body(src, index + 1)
181
+ out.append(_translate(body, _SUB) or f"_{body}")
182
+ continue
183
+ if char in "{}":
184
+ index += 1
185
+ continue
186
+ out.append(char)
187
+ index += 1
188
+ return " ".join("".join(out).split())
189
+
190
+
191
+ def _equation(lhs, rhs, mode, dependent, substitute):
192
+ if mode == "implicit" and lhs is not None:
193
+ left = render(lhs, 0, substitute)
194
+ else:
195
+ left = _name(dependent or "y", None)
196
+ right = render(rhs, 0, substitute)
197
+ return f"{left.latex} = {right.latex}", f"{left.pretty} = {right.pretty}"
198
+
199
+
200
+ def _caption(latex, pretty, lhs, rhs, parameters):
201
+ names = _param_names(lhs, rhs, parameters)
202
+ if not names:
203
+ return latex, pretty
204
+ bits_l = []
205
+ bits_p = []
206
+ for name in names:
207
+ shown = _name(name, None)
208
+ value = _number(parameters[name])
209
+ bits_l.append(f"{shown.latex} = {value.latex}")
210
+ bits_p.append(f"{shown.pretty} = {value.pretty}")
211
+ return (
212
+ latex + " \\quad (" + ",\\ ".join(bits_l) + ")",
213
+ pretty + " (" + ", ".join(bits_p) + ")",
214
+ )
215
+
216
+
217
+ def _param_names(lhs, rhs, parameters) -> list[str]:
218
+ """Parameter names in source order. ``ast.walk`` is breadth-first."""
219
+ if not parameters:
220
+ return []
221
+ found: list[str] = []
222
+
223
+ def walk(node: ast.AST) -> None:
224
+ if isinstance(node, ast.Call):
225
+ for arg in node.args:
226
+ walk(arg)
227
+ return
228
+ if isinstance(node, ast.Name):
229
+ if node.id in parameters and node.id not in found:
230
+ found.append(node.id)
231
+ return
232
+ for child in ast.iter_child_nodes(node):
233
+ walk(child)
234
+
235
+ for tree in (rhs, lhs):
236
+ if tree is not None:
237
+ walk(tree)
238
+ return found
239
+
240
+
241
+ def render(node: ast.AST, min_prec: int, substitute: dict[str, float] | None) -> _Piece:
242
+ piece = _render(node, substitute)
243
+ if piece.prec < min_prec:
244
+ return _Piece(f"({piece.latex})", f"({piece.pretty})", _ATOM, "group")
245
+ return piece
246
+
247
+
248
+ def _render(node: ast.AST, substitute: dict[str, float] | None) -> _Piece:
249
+ # (a − 1) with a = 2 is 1, not the expanded difference. Names and
250
+ # literals already print as themselves.
251
+ if substitute and not isinstance(node, (ast.Name, ast.Constant)):
252
+ folded = _const_value(node, substitute)
253
+ if folded is not None:
254
+ return _number(folded)
255
+ if isinstance(node, ast.Name):
256
+ return _name(node.id, substitute)
257
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
258
+ return _number(float(node.value))
259
+ if isinstance(node, ast.UnaryOp):
260
+ return _unary(node, substitute)
261
+ if isinstance(node, ast.BinOp):
262
+ return _binop(node, substitute)
263
+ if isinstance(node, ast.Compare):
264
+ return _compare(node, substitute)
265
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
266
+ return _call(node, substitute)
267
+ return _Piece("?", "?", _ATOM, "other")
268
+
269
+
270
+ def _unary(node: ast.UnaryOp, substitute) -> _Piece:
271
+ sign = -1.0 if isinstance(node.op, ast.USub) else 1.0
272
+ if isinstance(node.operand, ast.Constant) and isinstance(node.operand.value, (int, float)):
273
+ return _number(sign * float(node.operand.value))
274
+ # -x^2 is one power under the minus. LaTeX needs the parentheses that
275
+ # Python's printer drops, because -x^{2} is easy to misread.
276
+ if (
277
+ isinstance(node.op, ast.USub)
278
+ and isinstance(node.operand, ast.BinOp)
279
+ and isinstance(node.operand.op, ast.Pow)
280
+ ):
281
+ inner = render(node.operand, 0, substitute)
282
+ return _Piece(f"-({inner.latex})", f"−({inner.pretty})", _UNARY, "other")
283
+ mark_l = "-" if isinstance(node.op, ast.USub) else "+"
284
+ mark_p = "−" if isinstance(node.op, ast.USub) else "+"
285
+ inner = render(node.operand, _UNARY + 1, substitute)
286
+ return _Piece(mark_l + inner.latex, mark_p + inner.pretty, _UNARY, "other")
287
+
288
+
289
+ def _const_value(node: ast.AST, substitute: dict[str, float] | None) -> float | None:
290
+ """The number ``node`` stands for after substitution, if it is constant."""
291
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
292
+ return float(node.value)
293
+ if isinstance(node, ast.Name) and substitute and node.id in substitute:
294
+ try:
295
+ return float(substitute[node.id])
296
+ except (TypeError, ValueError):
297
+ return None
298
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
299
+ inner = _const_value(node.operand, substitute)
300
+ if inner is None:
301
+ return None
302
+ return inner if isinstance(node.op, ast.UAdd) else -inner
303
+ if not isinstance(node, ast.BinOp):
304
+ return None
305
+ left = _const_value(node.left, substitute)
306
+ right = _const_value(node.right, substitute)
307
+ if left is None or right is None:
308
+ return None
309
+ if isinstance(node.op, ast.Add):
310
+ value = left + right
311
+ elif isinstance(node.op, ast.Sub):
312
+ value = left - right
313
+ elif isinstance(node.op, ast.Mult):
314
+ value = left * right
315
+ elif isinstance(node.op, ast.Div):
316
+ if right == 0:
317
+ return None
318
+ value = left / right
319
+ elif isinstance(node.op, ast.Pow):
320
+ try:
321
+ value = left ** right
322
+ except (OverflowError, ValueError, ZeroDivisionError):
323
+ return None
324
+ if isinstance(value, complex):
325
+ return None
326
+ else:
327
+ return None
328
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value):
329
+ return None
330
+ return float(value)
331
+
332
+
333
+ def _binop(node: ast.BinOp, substitute) -> _Piece:
334
+ if isinstance(node.op, ast.Pow):
335
+ exponent = _const_value(node.right, substitute) if substitute else None
336
+ # x^(a − 1) with a = 2 is x, not x¹.
337
+ if exponent == 1:
338
+ return render(node.left, _POW + 1, substitute)
339
+ if (
340
+ isinstance(node.left, ast.Call)
341
+ and isinstance(node.left.func, ast.Name)
342
+ and node.left.func.id in _OPS
343
+ ):
344
+ return _op_call(
345
+ node.left.func.id, node.left.args, substitute, power=node.right
346
+ )
347
+ base = render(node.left, _POW + 1, substitute)
348
+ exp = render(node.right, 0, substitute)
349
+ pretty = base.pretty + _raised(exp.pretty)
350
+ return _Piece(f"{base.latex}^{{{exp.latex}}}", pretty, _POW, "pow")
351
+ if isinstance(node.op, ast.Div):
352
+ left = render(node.left, 0, substitute)
353
+ right = render(node.right, 0, substitute)
354
+ return _Piece(
355
+ f"\\frac{{{left.latex}}}{{{right.latex}}}",
356
+ f"{_call_parens(left)}/{_frac(right)}",
357
+ _MUL,
358
+ "frac",
359
+ )
360
+ if isinstance(node.op, (ast.Add, ast.Sub)):
361
+ return _add(node, substitute)
362
+ if isinstance(node.op, ast.Mult):
363
+ return _mul(node.left, node.right, substitute)
364
+ if isinstance(node.op, ast.FloorDiv):
365
+ return _spaced(node, "//", "//", substitute)
366
+ if isinstance(node.op, ast.Mod):
367
+ return _spaced(node, "\\bmod", "%", substitute)
368
+ return _Piece("?", "?", _ATOM, "other")
369
+
370
+
371
+ def _add(node: ast.BinOp, substitute) -> _Piece:
372
+ left = render(node.left, _ADD, substitute)
373
+ right = render(node.right, _ADD + 1, substitute)
374
+ if isinstance(node.op, ast.Add) and right.kind == "num" and right.latex.startswith("-"):
375
+ return _Piece(
376
+ f"{left.latex} - {right.latex[1:]}",
377
+ f"{left.pretty} − {right.pretty[1:]}",
378
+ _ADD,
379
+ "other",
380
+ )
381
+ mark_l = "+" if isinstance(node.op, ast.Add) else "-"
382
+ mark_p = "+" if isinstance(node.op, ast.Add) else "−"
383
+ return _Piece(
384
+ f"{left.latex} {mark_l} {right.latex}",
385
+ f"{left.pretty} {mark_p} {right.pretty}",
386
+ _ADD,
387
+ "other",
388
+ )
389
+
390
+
391
+ def _mul(left_node, right_node, substitute) -> _Piece:
392
+ left = render(left_node, _MUL, substitute)
393
+ right = render(right_node, _MUL + 1, substitute)
394
+ if _juxtapose(left, right):
395
+ pretty = left.pretty + right.pretty
396
+ if left.kind == "call" or right.kind == "call":
397
+ pretty = f"{left.pretty} {right.pretty}"
398
+ kind = right.kind if right.kind in {"name", "pow", "call"} else "other"
399
+ return _Piece(_join_command(left.latex, right.latex), pretty, _MUL, kind)
400
+ if right.kind == "num" and right.latex.startswith("-"):
401
+ right = _Piece(f"({right.latex})", f"({right.pretty})", _ATOM, "group")
402
+ return _Piece(
403
+ f"{left.latex} \\cdot {right.latex}",
404
+ f"{left.pretty} · {right.pretty}",
405
+ _MUL,
406
+ "other",
407
+ )
408
+
409
+
410
+ def _join_command(left: str, right: str) -> str:
411
+ """``\\pi`` beside ``x`` is ``\\pi x``. ``\\pix`` would be a different command."""
412
+ if right and right[0].isalpha() and _command_tail(left):
413
+ return left + " " + right
414
+ return left + right
415
+
416
+
417
+ def _command_tail(text: str) -> bool:
418
+ if len(text) < 2 or not text[-1].isalpha():
419
+ return False
420
+ index = len(text) - 1
421
+ while index >= 0 and text[index].isalpha():
422
+ index -= 1
423
+ return index >= 0 and text[index] == "\\"
424
+
425
+
426
+ def _juxtapose(left: _Piece, right: _Piece) -> bool:
427
+ """Number or name beside a factor needs no dot. Number beside number does."""
428
+ if right.kind == "num":
429
+ return False
430
+ if left.kind == "num":
431
+ return True
432
+ if left.kind in {"name", "pow"} and right.kind in {"name", "call", "group", "frac", "pow"}:
433
+ return True
434
+ if left.kind == "call" and right.kind in {"call", "group", "frac", "pow"}:
435
+ return True
436
+ if left.kind == "group" and right.kind in {"name", "call", "group", "frac", "pow"}:
437
+ return True
438
+ return False
439
+
440
+
441
+ def _spaced(node: ast.BinOp, latex_op: str, pretty_op: str, substitute) -> _Piece:
442
+ left = render(node.left, _MUL, substitute)
443
+ right = render(node.right, _MUL + 1, substitute)
444
+ return _Piece(
445
+ f"{left.latex} {latex_op} {right.latex}",
446
+ f"{left.pretty} {pretty_op} {right.pretty}",
447
+ _MUL,
448
+ "other",
449
+ )
450
+
451
+
452
+ _CMP_MARK = {
453
+ ast.Lt: ("<", "<"),
454
+ ast.LtE: ("\\le", "≤"),
455
+ ast.Gt: (">", ">"),
456
+ ast.GtE: ("\\ge", "≥"),
457
+ ast.Eq: ("=", "="),
458
+ ast.NotEq: ("\\ne", "≠"),
459
+ }
460
+
461
+
462
+ def _compare(node: ast.Compare, substitute) -> _Piece:
463
+ if len(node.ops) != 1 or type(node.ops[0]) not in _CMP_MARK:
464
+ return _Piece("?", "?", _ATOM, "other")
465
+ mark_l, mark_p = _CMP_MARK[type(node.ops[0])]
466
+ left = render(node.left, _ADD, substitute)
467
+ right = render(node.comparators[0], _ADD + 1, substitute)
468
+ return _Piece(
469
+ f"{left.latex} {mark_l} {right.latex}",
470
+ f"{left.pretty} {mark_p} {right.pretty}",
471
+ _ADD,
472
+ "other",
473
+ )
474
+
475
+
476
+ def _where(node: ast.Call, substitute) -> _Piece:
477
+ cond = render(node.args[0], 0, substitute)
478
+ yes = render(node.args[1], 0, substitute)
479
+ no = render(node.args[2], 0, substitute)
480
+ if isinstance(node.args[2], ast.Call) and getattr(node.args[2].func, "id", "") == "where":
481
+ latex = (
482
+ f"\\begin{{cases}} {yes.latex} & {cond.latex} \\\\ "
483
+ f"{no.latex} \\end{{cases}}"
484
+ )
485
+ else:
486
+ latex = (
487
+ f"\\begin{{cases}} {yes.latex} & {cond.latex} \\\\ "
488
+ f"{no.latex} & \\text{{otherwise}} \\end{{cases}}"
489
+ )
490
+ pretty = f"({yes.pretty} if {cond.pretty}, else {no.pretty})"
491
+ return _Piece(latex, pretty, _ATOM, "call")
492
+
493
+
494
+ def _call(node: ast.Call, substitute) -> _Piece:
495
+ name = node.func.id
496
+ if name == "where" and len(node.args) == 3:
497
+ return _where(node, substitute)
498
+ if name == "sqrt":
499
+ return _sqrt(node.args, substitute)
500
+ if name == "cbrt":
501
+ return _cbrt(node.args, substitute)
502
+ if name == "abs":
503
+ return _abs(node.args, substitute)
504
+ if name == "exp" and len(node.args) == 1:
505
+ return _exp(node.args[0], substitute)
506
+ if name == "floor":
507
+ return _wrap_call(node.args, substitute, "\\lfloor ", " \\rfloor", "⌊", "⌋")
508
+ if name == "ceil":
509
+ return _wrap_call(node.args, substitute, "\\lceil ", " \\rceil", "⌈", "⌉")
510
+ if name in _OPS:
511
+ return _op_call(name, node.args, substitute)
512
+ if name in _NAMED:
513
+ return _named_call(name, node.args, substitute)
514
+ return _user_call(name, node.args, substitute)
515
+
516
+
517
+ # Special functions with a textbook symbol: gamma(x) -> Γ(x), beta(a, b) -> B(a, b).
518
+ _NAMED = {
519
+ "gamma": ("\\Gamma", "Γ"),
520
+ "lgamma": ("\\ln\\Gamma", "ln Γ"),
521
+ "beta": ("\\mathrm{B}", "B"),
522
+ }
523
+
524
+
525
+ def _named_call(name, args, substitute) -> _Piece:
526
+ latex_op, pretty_op = _NAMED[name]
527
+ rendered = [render(arg, 0, substitute) for arg in args]
528
+ inner_l = ", ".join(piece.latex for piece in rendered)
529
+ inner_p = ", ".join(piece.pretty for piece in rendered)
530
+ return _Piece(
531
+ f"{latex_op}\\left({inner_l}\\right)", f"{pretty_op}({inner_p})", _POW, "call"
532
+ )
533
+
534
+
535
+ def _exp(arg_node, substitute) -> _Piece:
536
+ arg = render(arg_node, 0, substitute)
537
+ long = (
538
+ " + " in arg.latex
539
+ or " - " in arg.latex
540
+ or "\\cdot" in arg.latex
541
+ or "\\frac" in arg.latex
542
+ or "\\left" in arg.latex
543
+ or len(arg.latex) > 12
544
+ )
545
+ if long:
546
+ return _Piece(
547
+ f"\\exp\\left({arg.latex}\\right)",
548
+ f"exp({arg.pretty})",
549
+ _ATOM,
550
+ "call",
551
+ )
552
+ return _Piece(
553
+ f"e^{{{arg.latex}}}",
554
+ "e" + _raised(arg.pretty),
555
+ _ATOM,
556
+ "pow",
557
+ )
558
+
559
+
560
+ def _sqrt(args, substitute) -> _Piece:
561
+ arg = render(args[0], 0, substitute) if args else _Piece("", "", _ATOM, "name")
562
+ if _bare(arg) and arg.kind in {"name", "num"}:
563
+ pretty = "√" + arg.pretty
564
+ else:
565
+ pretty = f"√({arg.pretty})"
566
+ return _Piece(f"\\sqrt{{{arg.latex}}}", pretty, _ATOM, "call")
567
+
568
+
569
+ def _cbrt(args, substitute) -> _Piece:
570
+ arg = render(args[0], 0, substitute) if args else _Piece("", "", _ATOM, "name")
571
+ if _bare(arg) and arg.kind in {"name", "num"}:
572
+ pretty = "∛" + arg.pretty
573
+ else:
574
+ pretty = f"∛({arg.pretty})"
575
+ return _Piece(f"\\sqrt[3]{{{arg.latex}}}", pretty, _ATOM, "call")
576
+
577
+
578
+ def _abs(args, substitute) -> _Piece:
579
+ arg = render(args[0], 0, substitute) if args else _Piece("", "", _ATOM, "name")
580
+ return _Piece(
581
+ f"\\left\\lvert {arg.latex} \\right\\rvert",
582
+ f"|{arg.pretty}|",
583
+ _ATOM,
584
+ "call",
585
+ )
586
+
587
+
588
+ def _wrap_call(args, substitute, latex_l, latex_r, pretty_l, pretty_r) -> _Piece:
589
+ arg = render(args[0], 0, substitute) if args else _Piece("", "", _ATOM, "name")
590
+ return _Piece(
591
+ f"{latex_l}{arg.latex}{latex_r}",
592
+ f"{pretty_l}{arg.pretty}{pretty_r}",
593
+ _ATOM,
594
+ "call",
595
+ )
596
+
597
+
598
+ def _op_call(name, args, substitute, power=None) -> _Piece:
599
+ latex_op, pretty_op = _OPS[name]
600
+ rendered = [render(arg, 0, substitute) for arg in args]
601
+ if len(rendered) == 1 and _bare(rendered[0]):
602
+ suffix_l, suffix_p = f" {rendered[0].latex}", f" {rendered[0].pretty}"
603
+ else:
604
+ inner_l = ", ".join(piece.latex for piece in rendered)
605
+ inner_p = ", ".join(piece.pretty for piece in rendered)
606
+ suffix_l, suffix_p = f"\\left({inner_l}\\right)", f"({inner_p})"
607
+ if power is not None:
608
+ exp = render(power, 0, substitute)
609
+ sup = _raised(exp.pretty)
610
+ return _Piece(
611
+ f"{latex_op}^{{{exp.latex}}}{suffix_l}",
612
+ f"{pretty_op}{sup}{suffix_p}",
613
+ _ATOM,
614
+ "call",
615
+ )
616
+ return _Piece(latex_op + suffix_l, pretty_op + suffix_p, _ATOM, "call")
617
+
618
+
619
+ def _user_call(name, args, substitute) -> _Piece:
620
+ rendered = [render(arg, 0, substitute) for arg in args]
621
+ inner_l = ", ".join(piece.latex for piece in rendered)
622
+ inner_p = ", ".join(piece.pretty for piece in rendered)
623
+ # Below an atom so damp(x)^2 gains parentheses: the power must cover the call.
624
+ return _Piece(
625
+ f"\\operatorname{{{name}}}({inner_l})",
626
+ f"{name}({inner_p})",
627
+ _POW,
628
+ "call",
629
+ )
630
+
631
+
632
+ def _bare(piece: _Piece) -> bool:
633
+ if piece.kind in {"group", "frac"}:
634
+ return False
635
+ return not any(token in piece.latex for token in (" ", "\\frac", "\\left", "\\cdot"))
636
+
637
+
638
+ def _frac(piece: _Piece) -> str:
639
+ if piece.prec <= _ADD or piece.kind == "frac" or "/" in piece.pretty:
640
+ return f"({piece.pretty})"
641
+ return piece.pretty
642
+
643
+
644
+ def _call_parens(piece: _Piece) -> str:
645
+ """Keep parentheses when a function is divided: sin(x)/x, not sin x/x."""
646
+ text = _frac(piece)
647
+ if piece.kind != "call" or "(" in piece.pretty or " " not in piece.pretty:
648
+ return text
649
+ head, arg = piece.pretty.split(" ", 1)
650
+ return f"{head}({arg})"
651
+
652
+
653
+ def _name(name: str, substitute: dict[str, float] | None) -> _Piece:
654
+ if substitute and name in substitute:
655
+ return _number(substitute[name])
656
+ base, sub = _split_ident(name)
657
+ if base in _GREEK:
658
+ latex_base, pretty_base = _GREEK[base]
659
+ else:
660
+ latex_base, pretty_base = base, base
661
+ if not sub:
662
+ return _Piece(latex_base, pretty_base, _ATOM, "name")
663
+ mapped = _translate(sub, _SUB)
664
+ pretty = pretty_base + (mapped if mapped else f"_{sub}")
665
+ return _Piece(f"{latex_base}_{{{sub}}}", pretty, _ATOM, "name")
666
+
667
+
668
+ def _split_ident(name: str) -> tuple[str, str | None]:
669
+ if "_" in name:
670
+ base, sub = name.split("_", 1)
671
+ if base and sub:
672
+ return base, sub
673
+ index = len(name)
674
+ while index > 0 and name[index - 1].isdigit():
675
+ index -= 1
676
+ if 0 < index < len(name) and name[:index].isalpha():
677
+ return name[:index], name[index:]
678
+ return name, None
679
+
680
+
681
+ def _number(value: float) -> _Piece:
682
+ number = float(value)
683
+ if not math.isfinite(number):
684
+ text = str(number)
685
+ else:
686
+ nearest = round(number)
687
+ if abs(number - nearest) <= 1e-8 * max(1.0, abs(number)) and abs(nearest) < 1e15:
688
+ text = str(int(nearest))
689
+ else:
690
+ # Four significant figures. 1/30 is 0.03333, not 0.0333333333333.
691
+ text = f"{number:.4g}"
692
+ pretty = text.replace("-", "−")
693
+ prec = _UNARY if text.startswith("-") else _ATOM
694
+ return _Piece(text, pretty, prec, "num")
695
+
696
+
697
+ def _raised(text: str) -> str:
698
+ """Exponent in plain text: ``²``, ``ᵃ⁻¹``, or ``^q`` when no raised form exists."""
699
+ sup = _translate(text, _SUP)
700
+ if sup:
701
+ return sup
702
+ if text and (len(text) == 1 or text.isdigit()):
703
+ return f"^{text}" # one symbol: x^q; e^qx would be ambiguous
704
+ return f"^({text})"
705
+
706
+
707
+ def _translate(text: str, table) -> str | None:
708
+ # Spacing around operators has no raised form; drop it so x^(a − 1)
709
+ # becomes xᵃ⁻¹ instead of falling back to a caret.
710
+ text = text.replace(" ", "") if text else text
711
+ # maketrans keys are code points, not characters.
712
+ if not text or any(ord(char) not in table for char in text):
713
+ return None
714
+ return text.translate(table)
715
+
716
+
717
+ def _command_name(src: str, index: int) -> tuple[str, int]:
718
+ cursor = index + 1
719
+ if cursor < len(src) and not src[cursor].isalpha():
720
+ return src[cursor], cursor + 1
721
+ while cursor < len(src) and src[cursor].isalpha():
722
+ cursor += 1
723
+ return src[index + 1 : cursor], cursor
724
+
725
+
726
+ _SYMBOLS = {
727
+ "pm": "±",
728
+ "mp": "∓",
729
+ "times": "×",
730
+ "cdot": "·",
731
+ "leq": "≤",
732
+ "le": "≤",
733
+ "geq": "≥",
734
+ "ge": "≥",
735
+ "neq": "≠",
736
+ "ne": "≠",
737
+ "approx": "≈",
738
+ "circ": "∘",
739
+ "infty": "∞",
740
+ "partial": "∂",
741
+ "degree": "°",
742
+ }
743
+ # Uppercase Greek have no lowercase command of the same spelling.
744
+ _GREEK_UPPER = {
745
+ "Gamma": "Γ",
746
+ "Delta": "Δ",
747
+ "Theta": "Θ",
748
+ "Lambda": "Λ",
749
+ "Xi": "Ξ",
750
+ "Pi": "Π",
751
+ "Sigma": "Σ",
752
+ "Upsilon": "Υ",
753
+ "Phi": "Φ",
754
+ "Psi": "Ψ",
755
+ "Omega": "Ω",
756
+ }
757
+ _TEXT_COMMANDS = {
758
+ "mathrm", "text", "operatorname", "textbf", "textit",
759
+ "textrm", "mathbf", "mathit", "hbox",
760
+ }
761
+
762
+
763
+ def _pretty_over(text: str) -> str:
764
+ """A fraction part. Add parentheses only when the piece itself is a sum or product."""
765
+ if text and any(ch in text for ch in "+-−±×/·∘ "):
766
+ return f"({text})"
767
+ return text
768
+
769
+
770
+ def _command_pretty(name: str, src: str, index: int) -> tuple[str, int]:
771
+ if name in {"left", "right"}:
772
+ return "", index
773
+ if name in {"lvert", "rvert", "vert", "|"}:
774
+ return "|", index
775
+ if name in _SYMBOLS:
776
+ return _SYMBOLS[name], index
777
+ if name in {"quad", ",,", ","}:
778
+ return " ", index
779
+ if name == "frac":
780
+ num, index = _read_group(src, index)
781
+ den, index = _read_group(src, index)
782
+ return (
783
+ f"{_pretty_over(latex_to_pretty(num))}/{_pretty_over(latex_to_pretty(den))}",
784
+ index,
785
+ )
786
+ if name == "sqrt":
787
+ if index < len(src) and src[index] == "[":
788
+ end = src.find("]", index)
789
+ index = end + 1 if end >= 0 else index
790
+ body, index = _read_group(src, index)
791
+ inner = latex_to_pretty(body)
792
+ shown = inner if inner.isalnum() else f"({inner})"
793
+ return "√" + shown, index
794
+ if name in _TEXT_COMMANDS:
795
+ body, index = _read_group(src, index)
796
+ return latex_to_pretty(body), index
797
+ greek = {key: pretty for key, (_latex, pretty) in _GREEK.items()}
798
+ greek.update(_GREEK_UPPER)
799
+ if name in greek:
800
+ return greek[name], index
801
+ # User LaTeX keeps \log as log. The formula walker maps log() to \ln.
802
+ words = {
803
+ "sin": "sin", "cos": "cos", "tan": "tan",
804
+ "ln": "ln", "log": "log", "exp": "exp",
805
+ "sinh": "sinh", "cosh": "cosh", "tanh": "tanh",
806
+ "arcsin": "arcsin", "arccos": "arccos", "arctan": "arctan",
807
+ }
808
+ if name in words:
809
+ return words[name], index
810
+ return name, index
811
+
812
+
813
+ def _read_group(src: str, index: int) -> tuple[str, int]:
814
+ while index < len(src) and src[index].isspace():
815
+ index += 1
816
+ if index >= len(src):
817
+ return "", index
818
+ if src[index] != "{":
819
+ return src[index], index + 1
820
+ depth = 0
821
+ for cursor in range(index, len(src)):
822
+ if src[cursor] == "{":
823
+ depth += 1
824
+ elif src[cursor] == "}":
825
+ depth -= 1
826
+ if depth == 0:
827
+ return src[index + 1 : cursor], cursor + 1
828
+ return src[index + 1 :], len(src)
829
+
830
+
831
+ def _script_body(src: str, index: int) -> tuple[str, int]:
832
+ while index < len(src) and src[index].isspace():
833
+ index += 1
834
+ if index < len(src) and src[index] == "{":
835
+ body, index = _read_group(src, index)
836
+ return latex_to_pretty(body), index
837
+ if index < len(src) and src[index] == "\\":
838
+ name, index = _command_name(src, index)
839
+ return _command_pretty(name, src, index)
840
+ if index < len(src):
841
+ return src[index], index + 1
842
+ return "", index