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