python-color-math 0.1.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.
- color_math/__init__.py +8 -0
- color_math/__main__.py +5 -0
- color_math/adapters.py +288 -0
- color_math/config.py +351 -0
- color_math/converters/__init__.py +27 -0
- color_math/converters/align.py +18 -0
- color_math/converters/block.py +126 -0
- color_math/converters/derivative.py +266 -0
- color_math/converters/equation.py +5 -0
- color_math/converters/generic.py +101 -0
- color_math/converters/integral.py +16 -0
- color_math/converters/limit.py +16 -0
- color_math/converters/matrix.py +143 -0
- color_math/converters/semantic.py +76 -0
- color_math/io.py +53 -0
- color_math/main.py +162 -0
- color_math/parsers/__init__.py +64 -0
- color_math/parsers/braket.py +109 -0
- color_math/parsers/delimiters.py +151 -0
- color_math/parsers/differentials.py +71 -0
- color_math/parsers/dimensionless.py +74 -0
- color_math/parsers/latex_spans.py +1050 -0
- color_math/parsers/markdown_scanner.py +463 -0
- color_math/parsers/math_parser.py +366 -0
- color_math/parsers/scanner.py +351 -0
- color_math/parsers/taxonomy.py +124 -0
- color_math/parsers/units.py +98 -0
- color_math/parsers/variable_hash.py +126 -0
- color_math/self_test.py +224 -0
- color_math/undo.py +63 -0
- color_math/utils/__init__.py +30 -0
- color_math/utils/coloring.py +61 -0
- color_math/utils/latex_helpers.py +232 -0
- color_math/utils/spans.py +77 -0
- python_color_math-0.1.0.dist-info/METADATA +167 -0
- python_color_math-0.1.0.dist-info/RECORD +40 -0
- python_color_math-0.1.0.dist-info/WHEEL +5 -0
- python_color_math-0.1.0.dist-info/entry_points.txt +2 -0
- python_color_math-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_color_math-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
"""Small lossless LaTeX scanner for operands and structural tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
COMMAND_RE = re.compile(r"\\[A-Za-z]+|\\.")
|
|
10
|
+
NUMBER_RE = re.compile(r"(?:\d+(?:\.\d*)?|\.\d+)")
|
|
11
|
+
|
|
12
|
+
STYLE_MACROS = frozenset({
|
|
13
|
+
"mathbf",
|
|
14
|
+
"mathcal",
|
|
15
|
+
"mathbb",
|
|
16
|
+
"mathrm",
|
|
17
|
+
"mathit",
|
|
18
|
+
"mathsf",
|
|
19
|
+
"mathtt",
|
|
20
|
+
"boldsymbol",
|
|
21
|
+
"operatorname",
|
|
22
|
+
"text",
|
|
23
|
+
"textbf",
|
|
24
|
+
"textit",
|
|
25
|
+
"textrm",
|
|
26
|
+
"texttt",
|
|
27
|
+
})
|
|
28
|
+
FUNCTION_MACROS = frozenset({
|
|
29
|
+
"Tr",
|
|
30
|
+
"arccos",
|
|
31
|
+
"arcsin",
|
|
32
|
+
"arctan",
|
|
33
|
+
"cos",
|
|
34
|
+
"cosh",
|
|
35
|
+
"det",
|
|
36
|
+
"exp",
|
|
37
|
+
"ln",
|
|
38
|
+
"log",
|
|
39
|
+
"max",
|
|
40
|
+
"min",
|
|
41
|
+
"sec",
|
|
42
|
+
"sin",
|
|
43
|
+
"sinh",
|
|
44
|
+
"sqrt",
|
|
45
|
+
"sup",
|
|
46
|
+
"tan",
|
|
47
|
+
"tanh",
|
|
48
|
+
"tr",
|
|
49
|
+
"trace",
|
|
50
|
+
"operatorname",
|
|
51
|
+
})
|
|
52
|
+
OPERATOR_COMMANDS = frozenset({
|
|
53
|
+
"bigcap",
|
|
54
|
+
"bigcup",
|
|
55
|
+
"bigoplus",
|
|
56
|
+
"bigotimes",
|
|
57
|
+
"bigsqcup",
|
|
58
|
+
"bigvee",
|
|
59
|
+
"bigwedge",
|
|
60
|
+
"cdot",
|
|
61
|
+
"coprod",
|
|
62
|
+
"int",
|
|
63
|
+
"iint",
|
|
64
|
+
"iiint",
|
|
65
|
+
"inf",
|
|
66
|
+
"lim",
|
|
67
|
+
"max",
|
|
68
|
+
"min",
|
|
69
|
+
"oint",
|
|
70
|
+
"otimes",
|
|
71
|
+
"prod",
|
|
72
|
+
"sum",
|
|
73
|
+
"sup",
|
|
74
|
+
"times",
|
|
75
|
+
})
|
|
76
|
+
NON_OPERAND_COMMANDS = frozenset({
|
|
77
|
+
"!",
|
|
78
|
+
",",
|
|
79
|
+
":",
|
|
80
|
+
";",
|
|
81
|
+
"\\",
|
|
82
|
+
"approx",
|
|
83
|
+
"atop",
|
|
84
|
+
"choose",
|
|
85
|
+
"cap",
|
|
86
|
+
"displaystyle",
|
|
87
|
+
"displaylimits",
|
|
88
|
+
"emptyset",
|
|
89
|
+
"end",
|
|
90
|
+
"equiv",
|
|
91
|
+
"Leftarrow",
|
|
92
|
+
"Leftrightarrow",
|
|
93
|
+
"Rightarrow",
|
|
94
|
+
"geq",
|
|
95
|
+
"in",
|
|
96
|
+
"leq",
|
|
97
|
+
"leftarrow",
|
|
98
|
+
"leftrightarrow",
|
|
99
|
+
"limits",
|
|
100
|
+
"longleftarrow",
|
|
101
|
+
"longrightarrow",
|
|
102
|
+
"mapsto",
|
|
103
|
+
"middle",
|
|
104
|
+
"mp",
|
|
105
|
+
"neq",
|
|
106
|
+
"nolimits",
|
|
107
|
+
"notin",
|
|
108
|
+
"over",
|
|
109
|
+
"pm",
|
|
110
|
+
"propto",
|
|
111
|
+
"quad",
|
|
112
|
+
"qquad",
|
|
113
|
+
"right",
|
|
114
|
+
"rVert",
|
|
115
|
+
"scriptstyle",
|
|
116
|
+
"scriptscriptstyle",
|
|
117
|
+
"sim",
|
|
118
|
+
"setminus",
|
|
119
|
+
"subset",
|
|
120
|
+
"subseteq",
|
|
121
|
+
"supset",
|
|
122
|
+
"supseteq",
|
|
123
|
+
"to",
|
|
124
|
+
"textstyle",
|
|
125
|
+
"cup",
|
|
126
|
+
"rightarrow",
|
|
127
|
+
})
|
|
128
|
+
UNARY_MACROS = frozenset({
|
|
129
|
+
"acute",
|
|
130
|
+
"bar",
|
|
131
|
+
"breve",
|
|
132
|
+
"check",
|
|
133
|
+
"ddot",
|
|
134
|
+
"dot",
|
|
135
|
+
"grave",
|
|
136
|
+
"hat",
|
|
137
|
+
"mathring",
|
|
138
|
+
"overline",
|
|
139
|
+
"tilde",
|
|
140
|
+
"underline",
|
|
141
|
+
"vec",
|
|
142
|
+
"widehat",
|
|
143
|
+
"widetilde",
|
|
144
|
+
})
|
|
145
|
+
SYMBOL_MACROS = frozenset({
|
|
146
|
+
"Delta",
|
|
147
|
+
"Gamma",
|
|
148
|
+
"Im",
|
|
149
|
+
"Lambda",
|
|
150
|
+
"Omega",
|
|
151
|
+
"Phi",
|
|
152
|
+
"Pi",
|
|
153
|
+
"Psi",
|
|
154
|
+
"Re",
|
|
155
|
+
"Sigma",
|
|
156
|
+
"Theta",
|
|
157
|
+
"Upsilon",
|
|
158
|
+
"Xi",
|
|
159
|
+
"aleph",
|
|
160
|
+
"alpha",
|
|
161
|
+
"beta",
|
|
162
|
+
"bot",
|
|
163
|
+
"chi",
|
|
164
|
+
"delta",
|
|
165
|
+
"ell",
|
|
166
|
+
"epsilon",
|
|
167
|
+
"eta",
|
|
168
|
+
"gamma",
|
|
169
|
+
"hbar",
|
|
170
|
+
"imath",
|
|
171
|
+
"infty",
|
|
172
|
+
"iota",
|
|
173
|
+
"jmath",
|
|
174
|
+
"kappa",
|
|
175
|
+
"lambda",
|
|
176
|
+
"mu",
|
|
177
|
+
"nabla",
|
|
178
|
+
"nu",
|
|
179
|
+
"omega",
|
|
180
|
+
"partial",
|
|
181
|
+
"perp",
|
|
182
|
+
"phi",
|
|
183
|
+
"pi",
|
|
184
|
+
"psi",
|
|
185
|
+
"rho",
|
|
186
|
+
"sigma",
|
|
187
|
+
"tau",
|
|
188
|
+
"theta",
|
|
189
|
+
"top",
|
|
190
|
+
"upsilon",
|
|
191
|
+
"varepsilon",
|
|
192
|
+
"varphi",
|
|
193
|
+
"varpi",
|
|
194
|
+
"varrho",
|
|
195
|
+
"varsigma",
|
|
196
|
+
"vartheta",
|
|
197
|
+
"xi",
|
|
198
|
+
"zeta",
|
|
199
|
+
})
|
|
200
|
+
DELIMITER_SIZE_COMMANDS = frozenset({
|
|
201
|
+
"Big",
|
|
202
|
+
"Bigg",
|
|
203
|
+
"Biggl",
|
|
204
|
+
"Biggm",
|
|
205
|
+
"Biggr",
|
|
206
|
+
"Bigl",
|
|
207
|
+
"Bigm",
|
|
208
|
+
"Bigr",
|
|
209
|
+
"big",
|
|
210
|
+
"bigg",
|
|
211
|
+
"biggl",
|
|
212
|
+
"biggm",
|
|
213
|
+
"biggr",
|
|
214
|
+
"bigl",
|
|
215
|
+
"bigm",
|
|
216
|
+
"bigr",
|
|
217
|
+
})
|
|
218
|
+
OPAQUE_MACROS = frozenset({
|
|
219
|
+
"color",
|
|
220
|
+
"colorbox",
|
|
221
|
+
"fcolorbox",
|
|
222
|
+
"text",
|
|
223
|
+
"textbf",
|
|
224
|
+
"textcolor",
|
|
225
|
+
"textit",
|
|
226
|
+
"textrm",
|
|
227
|
+
"texttt",
|
|
228
|
+
"verb",
|
|
229
|
+
})
|
|
230
|
+
MATRIX_ENVIRONMENTS = frozenset({
|
|
231
|
+
"Bmatrix",
|
|
232
|
+
"Vmatrix",
|
|
233
|
+
"array",
|
|
234
|
+
"bmatrix",
|
|
235
|
+
"matrix",
|
|
236
|
+
"pmatrix",
|
|
237
|
+
"smallmatrix",
|
|
238
|
+
"vmatrix",
|
|
239
|
+
})
|
|
240
|
+
NEGATABLE_RELATIONS = frozenset({
|
|
241
|
+
"approx",
|
|
242
|
+
"equiv",
|
|
243
|
+
"geq",
|
|
244
|
+
"in",
|
|
245
|
+
"leq",
|
|
246
|
+
"sim",
|
|
247
|
+
"subset",
|
|
248
|
+
"subseteq",
|
|
249
|
+
"supset",
|
|
250
|
+
"supseteq",
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass(frozen=True)
|
|
255
|
+
class OperandSpan:
|
|
256
|
+
"""A half-open range containing one complete LaTeX operand."""
|
|
257
|
+
|
|
258
|
+
kind: str
|
|
259
|
+
start: int
|
|
260
|
+
end: int
|
|
261
|
+
|
|
262
|
+
def text(self, source: str) -> str:
|
|
263
|
+
return source[self.start:self.end]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def skip_whitespace(source: str, index: int, end: int) -> int:
|
|
267
|
+
while index < end and source[index].isspace():
|
|
268
|
+
index += 1
|
|
269
|
+
return index
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def skip_ignorable(source: str, index: int, end: int) -> int:
|
|
273
|
+
"""Skip TeX whitespace and comments without changing the source."""
|
|
274
|
+
while True:
|
|
275
|
+
index = skip_whitespace(source, index, end)
|
|
276
|
+
if index >= end or source[index] != "%":
|
|
277
|
+
return index
|
|
278
|
+
index = _skip_comment(source, index, end)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def read_command(source: str, start: int, end: int) -> tuple[str, int] | None:
|
|
282
|
+
match = COMMAND_RE.match(source, start, end)
|
|
283
|
+
if match is None:
|
|
284
|
+
return None
|
|
285
|
+
return match.group(0)[1:], match.end()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _skip_comment(source: str, start: int, end: int) -> int:
|
|
289
|
+
index = start + 1
|
|
290
|
+
while index < end and source[index] not in "\r\n":
|
|
291
|
+
index += 1
|
|
292
|
+
if index < end and source[index] == "\r" and index + 1 < end and source[index + 1] == "\n":
|
|
293
|
+
return index + 2
|
|
294
|
+
return min(index + 1, end)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def read_group_end(
|
|
298
|
+
source: str,
|
|
299
|
+
start: int,
|
|
300
|
+
end: int,
|
|
301
|
+
opening: str | None = None,
|
|
302
|
+
) -> int | None:
|
|
303
|
+
"""Return the index after one balanced character-delimited group."""
|
|
304
|
+
|
|
305
|
+
if start >= end:
|
|
306
|
+
return None
|
|
307
|
+
opening = source[start] if opening is None else opening
|
|
308
|
+
closing = {"{": "}", "(": ")", "[": "]"}.get(opening)
|
|
309
|
+
if closing is None or source[start] != opening:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
depth = 1
|
|
313
|
+
index = start + 1
|
|
314
|
+
while index < end:
|
|
315
|
+
if source[index] == "%":
|
|
316
|
+
index = _skip_comment(source, index, end)
|
|
317
|
+
continue
|
|
318
|
+
if source[index] == "\\":
|
|
319
|
+
command = read_command(source, index, end)
|
|
320
|
+
if command is not None and command[0] == "verb":
|
|
321
|
+
verb_end = _read_verb_end(source, command[1], end)
|
|
322
|
+
if verb_end >= end:
|
|
323
|
+
return None
|
|
324
|
+
index = verb_end
|
|
325
|
+
continue
|
|
326
|
+
if command is not None and command[0] == "left":
|
|
327
|
+
nested = read_left_right_end(source, index, end)
|
|
328
|
+
if nested is not None:
|
|
329
|
+
index = nested
|
|
330
|
+
continue
|
|
331
|
+
index = command[1] if command is not None else index + 1
|
|
332
|
+
continue
|
|
333
|
+
if source[index] == opening:
|
|
334
|
+
depth += 1
|
|
335
|
+
elif source[index] == closing:
|
|
336
|
+
depth -= 1
|
|
337
|
+
if depth == 0:
|
|
338
|
+
return index + 1
|
|
339
|
+
index += 1
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _read_delimiter_end(source: str, start: int, end: int) -> int | None:
|
|
344
|
+
start = skip_ignorable(source, start, end)
|
|
345
|
+
if start >= end:
|
|
346
|
+
return None
|
|
347
|
+
if source[start] == "\\":
|
|
348
|
+
command = read_command(source, start, end)
|
|
349
|
+
return command[1] if command is not None else None
|
|
350
|
+
return start + 1
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _left_delimiter(source: str, start: int, end: int) -> str | None:
|
|
354
|
+
r"""Return the delimiter following an exact ``\left`` command."""
|
|
355
|
+
command = read_command(source, start, end)
|
|
356
|
+
if command is None or command[0] != "left":
|
|
357
|
+
return None
|
|
358
|
+
delimiter_start = skip_ignorable(source, command[1], end)
|
|
359
|
+
if delimiter_start >= end:
|
|
360
|
+
return None
|
|
361
|
+
if source[delimiter_start] != "\\":
|
|
362
|
+
return source[delimiter_start]
|
|
363
|
+
delimiter = read_command(source, delimiter_start, end)
|
|
364
|
+
return delimiter[0] if delimiter is not None else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def read_left_right_end(source: str, start: int, end: int) -> int | None:
|
|
368
|
+
command = read_command(source, start, end)
|
|
369
|
+
if command is None or command[0] != "left":
|
|
370
|
+
return None
|
|
371
|
+
index = _read_delimiter_end(source, command[1], end)
|
|
372
|
+
if index is None:
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
depth = 1
|
|
376
|
+
while index < end:
|
|
377
|
+
if source[index] == "%":
|
|
378
|
+
index = _skip_comment(source, index, end)
|
|
379
|
+
continue
|
|
380
|
+
if source[index] == "{":
|
|
381
|
+
group_end = read_group_end(source, index, end)
|
|
382
|
+
if group_end is not None:
|
|
383
|
+
index = group_end
|
|
384
|
+
continue
|
|
385
|
+
if source[index] != "\\":
|
|
386
|
+
index += 1
|
|
387
|
+
continue
|
|
388
|
+
nested = read_command(source, index, end)
|
|
389
|
+
if nested is None:
|
|
390
|
+
index += 1
|
|
391
|
+
continue
|
|
392
|
+
name, command_end = nested
|
|
393
|
+
if name == "verb":
|
|
394
|
+
verb_end = _read_verb_end(source, command_end, end)
|
|
395
|
+
if verb_end >= end:
|
|
396
|
+
return None
|
|
397
|
+
index = verb_end
|
|
398
|
+
continue
|
|
399
|
+
if name == "left":
|
|
400
|
+
delimiter_end = _read_delimiter_end(source, command_end, end)
|
|
401
|
+
if delimiter_end is not None:
|
|
402
|
+
depth += 1
|
|
403
|
+
index = delimiter_end
|
|
404
|
+
continue
|
|
405
|
+
elif name == "right":
|
|
406
|
+
delimiter_end = _read_delimiter_end(source, command_end, end)
|
|
407
|
+
if delimiter_end is not None:
|
|
408
|
+
depth -= 1
|
|
409
|
+
if depth == 0:
|
|
410
|
+
return delimiter_end
|
|
411
|
+
index = delimiter_end
|
|
412
|
+
continue
|
|
413
|
+
index = command_end
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _read_environment_marker(
|
|
418
|
+
source: str,
|
|
419
|
+
start: int,
|
|
420
|
+
end: int,
|
|
421
|
+
) -> tuple[str, str, int] | None:
|
|
422
|
+
command = read_command(source, start, end)
|
|
423
|
+
if command is None or command[0] not in {"begin", "end"}:
|
|
424
|
+
return None
|
|
425
|
+
marker, index = command
|
|
426
|
+
group_start = skip_ignorable(source, index, end)
|
|
427
|
+
group_end = read_group_end(source, group_start, end)
|
|
428
|
+
if group_end is None:
|
|
429
|
+
return None
|
|
430
|
+
name = source[group_start + 1:group_end - 1].strip()
|
|
431
|
+
if not name:
|
|
432
|
+
return None
|
|
433
|
+
return marker, name, group_end
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def read_environment_end(source: str, start: int, end: int) -> tuple[str, int] | None:
|
|
437
|
+
"""Return the environment name and index after its matching ``\\end``."""
|
|
438
|
+
|
|
439
|
+
opening = _read_environment_marker(source, start, end)
|
|
440
|
+
if opening is None or opening[0] != "begin":
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
stack = [opening[1]]
|
|
444
|
+
index = opening[2]
|
|
445
|
+
while index < end:
|
|
446
|
+
if source[index] == "%":
|
|
447
|
+
index = _skip_comment(source, index, end)
|
|
448
|
+
continue
|
|
449
|
+
if source[index] == "{":
|
|
450
|
+
group_end = read_group_end(source, index, end)
|
|
451
|
+
if group_end is not None:
|
|
452
|
+
index = group_end
|
|
453
|
+
continue
|
|
454
|
+
if source[index] != "\\":
|
|
455
|
+
index += 1
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
marker = _read_environment_marker(source, index, end)
|
|
459
|
+
if marker is None:
|
|
460
|
+
command = read_command(source, index, end)
|
|
461
|
+
if command is not None and command[0] == "verb":
|
|
462
|
+
verb_end = _read_verb_end(source, command[1], end)
|
|
463
|
+
if verb_end >= end:
|
|
464
|
+
return None
|
|
465
|
+
index = verb_end
|
|
466
|
+
continue
|
|
467
|
+
index = command[1] if command is not None else index + 1
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
marker_kind, name, marker_end = marker
|
|
471
|
+
if marker_kind == "begin":
|
|
472
|
+
stack.append(name)
|
|
473
|
+
elif name != stack[-1]:
|
|
474
|
+
return None
|
|
475
|
+
else:
|
|
476
|
+
stack.pop()
|
|
477
|
+
if not stack:
|
|
478
|
+
return opening[1], marker_end
|
|
479
|
+
index = marker_end
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _read_argument_end(source: str, start: int, end: int) -> int | None:
|
|
484
|
+
start = skip_ignorable(source, start, end)
|
|
485
|
+
if start >= end:
|
|
486
|
+
return None
|
|
487
|
+
if source[start] in "{([":
|
|
488
|
+
return read_group_end(source, start, end)
|
|
489
|
+
if source[start] == "\\":
|
|
490
|
+
operand = read_operand(source, start, end)
|
|
491
|
+
return (
|
|
492
|
+
operand.end
|
|
493
|
+
if operand is not None and operand.kind != "opaque"
|
|
494
|
+
else None
|
|
495
|
+
)
|
|
496
|
+
return start + 1
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _consume_scripts(source: str, start: int, end: int) -> int:
|
|
500
|
+
current = start
|
|
501
|
+
while True:
|
|
502
|
+
marker = skip_ignorable(source, current, end)
|
|
503
|
+
if marker >= end or source[marker] not in "_^":
|
|
504
|
+
return current
|
|
505
|
+
argument_end = _read_argument_end(source, marker + 1, end)
|
|
506
|
+
if argument_end is None:
|
|
507
|
+
return current
|
|
508
|
+
current = argument_end
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _consume_postfix(source: str, start: int, end: int) -> int:
|
|
512
|
+
"""Consume attached scripts and transpose/derivative prime marks."""
|
|
513
|
+
current = start
|
|
514
|
+
while True:
|
|
515
|
+
previous = current
|
|
516
|
+
current = _consume_scripts(source, current, end)
|
|
517
|
+
prime_start = skip_ignorable(source, current, end)
|
|
518
|
+
if prime_start < end and source[prime_start] in "'’":
|
|
519
|
+
current = prime_start
|
|
520
|
+
while current < end and source[current] in "'’":
|
|
521
|
+
current += 1
|
|
522
|
+
if current == previous:
|
|
523
|
+
return current
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _consume_operator_scripts(source: str, start: int, end: int) -> int:
|
|
527
|
+
modifier_start = skip_ignorable(source, start, end)
|
|
528
|
+
modifier = read_command(source, modifier_start, end)
|
|
529
|
+
if modifier is not None and modifier[0] in {"displaylimits", "limits", "nolimits"}:
|
|
530
|
+
start = modifier[1]
|
|
531
|
+
return _consume_scripts(source, start, end)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _read_norm_end(source: str, start: int, end: int) -> int | None:
|
|
535
|
+
opening = read_command(source, start, end)
|
|
536
|
+
if opening is None:
|
|
537
|
+
return None
|
|
538
|
+
closing_name = {"|": "|", "Vert": "Vert", "lVert": "rVert"}.get(opening[0])
|
|
539
|
+
if closing_name is None:
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
index = opening[1]
|
|
543
|
+
brace_depth = 0
|
|
544
|
+
while index < end:
|
|
545
|
+
if source[index] == "%":
|
|
546
|
+
index = _skip_comment(source, index, end)
|
|
547
|
+
continue
|
|
548
|
+
if source[index] == "{":
|
|
549
|
+
brace_depth += 1
|
|
550
|
+
elif source[index] == "}" and brace_depth:
|
|
551
|
+
brace_depth -= 1
|
|
552
|
+
elif source[index] == "\\":
|
|
553
|
+
command = read_command(source, index, end)
|
|
554
|
+
if command is not None:
|
|
555
|
+
if command[0] == "verb":
|
|
556
|
+
verb_end = _read_verb_end(source, command[1], end)
|
|
557
|
+
if verb_end >= end:
|
|
558
|
+
return None
|
|
559
|
+
index = verb_end
|
|
560
|
+
continue
|
|
561
|
+
if command[0] == "left":
|
|
562
|
+
group_end = read_left_right_end(source, index, end)
|
|
563
|
+
if group_end is not None:
|
|
564
|
+
index = group_end
|
|
565
|
+
continue
|
|
566
|
+
if brace_depth == 0 and command[0] == closing_name:
|
|
567
|
+
return _consume_postfix(source, command[1], end)
|
|
568
|
+
index = command[1]
|
|
569
|
+
continue
|
|
570
|
+
index += 1
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _consume_arguments(
|
|
575
|
+
source: str,
|
|
576
|
+
start: int,
|
|
577
|
+
end: int,
|
|
578
|
+
count: int,
|
|
579
|
+
) -> int | None:
|
|
580
|
+
index = start
|
|
581
|
+
for _ in range(count):
|
|
582
|
+
argument_end = _read_argument_end(source, index, end)
|
|
583
|
+
if argument_end is None:
|
|
584
|
+
return None
|
|
585
|
+
index = argument_end
|
|
586
|
+
return index
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _consume_optional_bracket(source: str, start: int, end: int) -> int | None:
|
|
590
|
+
index = skip_ignorable(source, start, end)
|
|
591
|
+
if index >= end or source[index] != "[":
|
|
592
|
+
return start
|
|
593
|
+
return read_group_end(source, index, end)
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _read_verb_end(source: str, start: int, end: int) -> int:
|
|
597
|
+
index = start + 1 if start < end and source[start] == "*" else start
|
|
598
|
+
if index >= end or source[index].isspace():
|
|
599
|
+
return end
|
|
600
|
+
closing = source.find(source[index], index + 1, end)
|
|
601
|
+
return end if closing < 0 else closing + 1
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _contains_verb_command(source: str, start: int, end: int) -> bool:
|
|
605
|
+
"""Return whether a range contains active verbatim math source."""
|
|
606
|
+
index = start
|
|
607
|
+
while index < end:
|
|
608
|
+
if source[index] == "%":
|
|
609
|
+
index = _skip_comment(source, index, end)
|
|
610
|
+
continue
|
|
611
|
+
if source[index] != "\\":
|
|
612
|
+
index += 1
|
|
613
|
+
continue
|
|
614
|
+
command = read_command(source, index, end)
|
|
615
|
+
if command is None:
|
|
616
|
+
index += 1
|
|
617
|
+
continue
|
|
618
|
+
if command[0] == "verb":
|
|
619
|
+
return True
|
|
620
|
+
index = command[1]
|
|
621
|
+
return False
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _read_negated_relation_end(source: str, start: int, end: int) -> int | None:
|
|
625
|
+
index = skip_ignorable(source, start, end)
|
|
626
|
+
if index < end and source[index] in "=<>":
|
|
627
|
+
return index + 1
|
|
628
|
+
command = read_command(source, index, end)
|
|
629
|
+
if command is not None and command[0] in NEGATABLE_RELATIONS:
|
|
630
|
+
return command[1]
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def read_operand(source: str, start: int, end: int | None = None) -> OperandSpan | None:
|
|
635
|
+
"""Read one operand without normalizing or reconstructing its source."""
|
|
636
|
+
|
|
637
|
+
end = len(source) if end is None else end
|
|
638
|
+
if start >= end or source[start].isspace():
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
if source[start] == "\\":
|
|
642
|
+
command = read_command(source, start, end)
|
|
643
|
+
if command is None:
|
|
644
|
+
return None
|
|
645
|
+
name, command_end = command
|
|
646
|
+
if name == "operatorname" and command_end < end and source[command_end] == "*":
|
|
647
|
+
command_end += 1
|
|
648
|
+
if name in {"|", "Vert", "lVert"}:
|
|
649
|
+
norm_end = _read_norm_end(source, start, end)
|
|
650
|
+
if norm_end is not None:
|
|
651
|
+
return OperandSpan("norm", start, norm_end)
|
|
652
|
+
return OperandSpan(
|
|
653
|
+
"opaque" if name == "lVert" else "structural",
|
|
654
|
+
start,
|
|
655
|
+
command_end if name != "lVert" else end,
|
|
656
|
+
)
|
|
657
|
+
if name == "verb":
|
|
658
|
+
return OperandSpan("opaque", start, _read_verb_end(source, command_end, end))
|
|
659
|
+
if name == "not":
|
|
660
|
+
relation_end = _read_negated_relation_end(source, command_end, end)
|
|
661
|
+
return OperandSpan(
|
|
662
|
+
"opaque",
|
|
663
|
+
start,
|
|
664
|
+
relation_end if relation_end is not None else end,
|
|
665
|
+
)
|
|
666
|
+
if name == "above":
|
|
667
|
+
return OperandSpan("opaque", start, end)
|
|
668
|
+
if name == "begin":
|
|
669
|
+
environment = read_environment_end(source, start, end)
|
|
670
|
+
if environment is not None:
|
|
671
|
+
environment_name, environment_end = environment
|
|
672
|
+
if _contains_verb_command(source, start, environment_end):
|
|
673
|
+
return OperandSpan("opaque", start, environment_end)
|
|
674
|
+
kind = "matrix" if environment_name in MATRIX_ENVIRONMENTS else "environment"
|
|
675
|
+
return OperandSpan(
|
|
676
|
+
kind,
|
|
677
|
+
start,
|
|
678
|
+
_consume_postfix(source, environment_end, end),
|
|
679
|
+
)
|
|
680
|
+
return OperandSpan("opaque", start, end)
|
|
681
|
+
if name == "left":
|
|
682
|
+
group_end = read_left_right_end(source, start, end)
|
|
683
|
+
if group_end is None:
|
|
684
|
+
return OperandSpan("opaque", start, end)
|
|
685
|
+
if _contains_verb_command(source, start, group_end):
|
|
686
|
+
return OperandSpan("opaque", start, group_end)
|
|
687
|
+
return OperandSpan(
|
|
688
|
+
"group",
|
|
689
|
+
start,
|
|
690
|
+
_consume_postfix(source, group_end, end),
|
|
691
|
+
)
|
|
692
|
+
if name in OPERATOR_COMMANDS:
|
|
693
|
+
return OperandSpan(
|
|
694
|
+
"operator",
|
|
695
|
+
start,
|
|
696
|
+
_consume_operator_scripts(source, command_end, end),
|
|
697
|
+
)
|
|
698
|
+
if name == "\\":
|
|
699
|
+
layout_end = command_end
|
|
700
|
+
if layout_end < end and source[layout_end] == "*":
|
|
701
|
+
layout_end += 1
|
|
702
|
+
optional_start = skip_ignorable(source, layout_end, end)
|
|
703
|
+
if optional_start < end and source[optional_start] == "[":
|
|
704
|
+
optional_end = read_group_end(source, optional_start, end)
|
|
705
|
+
if optional_end is None:
|
|
706
|
+
return OperandSpan("opaque", start, end)
|
|
707
|
+
layout_end = optional_end
|
|
708
|
+
return OperandSpan("structural", start, layout_end)
|
|
709
|
+
if name in NON_OPERAND_COMMANDS:
|
|
710
|
+
return OperandSpan("structural", start, command_end)
|
|
711
|
+
if name in DELIMITER_SIZE_COMMANDS:
|
|
712
|
+
delimiter_end = _read_delimiter_end(source, command_end, end)
|
|
713
|
+
return OperandSpan(
|
|
714
|
+
"structural",
|
|
715
|
+
start,
|
|
716
|
+
delimiter_end if delimiter_end is not None else command_end,
|
|
717
|
+
)
|
|
718
|
+
if name in SYMBOL_MACROS:
|
|
719
|
+
return OperandSpan(
|
|
720
|
+
"symbol",
|
|
721
|
+
start,
|
|
722
|
+
_consume_postfix(source, command_end, end),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
if name in {"color", "colorbox", "textcolor"}:
|
|
726
|
+
optional_end = _consume_optional_bracket(source, command_end, end)
|
|
727
|
+
arguments_end = (
|
|
728
|
+
_consume_arguments(source, optional_end, end, 2)
|
|
729
|
+
if optional_end is not None
|
|
730
|
+
else None
|
|
731
|
+
)
|
|
732
|
+
return OperandSpan(
|
|
733
|
+
"opaque",
|
|
734
|
+
start,
|
|
735
|
+
arguments_end if arguments_end is not None else end,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
if name == "fcolorbox":
|
|
739
|
+
optional_end = _consume_optional_bracket(source, command_end, end)
|
|
740
|
+
frame_end = (
|
|
741
|
+
_consume_arguments(source, optional_end, end, 1)
|
|
742
|
+
if optional_end is not None
|
|
743
|
+
else None
|
|
744
|
+
)
|
|
745
|
+
background_model_end = (
|
|
746
|
+
_consume_optional_bracket(source, frame_end, end)
|
|
747
|
+
if frame_end is not None
|
|
748
|
+
else None
|
|
749
|
+
)
|
|
750
|
+
arguments_end = (
|
|
751
|
+
_consume_arguments(source, background_model_end, end, 2)
|
|
752
|
+
if background_model_end is not None
|
|
753
|
+
else None
|
|
754
|
+
)
|
|
755
|
+
return OperandSpan(
|
|
756
|
+
"opaque",
|
|
757
|
+
start,
|
|
758
|
+
arguments_end if arguments_end is not None else end,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
argument_count = 0
|
|
762
|
+
if name in {"frac", "dfrac", "tfrac"}:
|
|
763
|
+
argument_count = 2
|
|
764
|
+
elif name in STYLE_MACROS:
|
|
765
|
+
argument_count = 1
|
|
766
|
+
elif name == "sqrt":
|
|
767
|
+
optional = skip_ignorable(source, command_end, end)
|
|
768
|
+
if optional < end and source[optional] == "[":
|
|
769
|
+
optional_end = read_group_end(source, optional, end)
|
|
770
|
+
if optional_end is None:
|
|
771
|
+
return None
|
|
772
|
+
command_end = optional_end
|
|
773
|
+
argument_count = 1
|
|
774
|
+
elif name in UNARY_MACROS:
|
|
775
|
+
argument_count = 1
|
|
776
|
+
elif name in {"overset", "stackrel", "underset"}:
|
|
777
|
+
argument_count = 2
|
|
778
|
+
|
|
779
|
+
atom_end = command_end
|
|
780
|
+
if argument_count:
|
|
781
|
+
arguments_end = _consume_arguments(
|
|
782
|
+
source,
|
|
783
|
+
command_end,
|
|
784
|
+
end,
|
|
785
|
+
argument_count,
|
|
786
|
+
)
|
|
787
|
+
if arguments_end is None:
|
|
788
|
+
return OperandSpan("opaque", start, end)
|
|
789
|
+
atom_end = arguments_end
|
|
790
|
+
|
|
791
|
+
if not argument_count and name not in FUNCTION_MACROS:
|
|
792
|
+
# Unknown commands have unknown arity, but hiding the remaining
|
|
793
|
+
# equation is worse than leaving their arguments unclassified.
|
|
794
|
+
return OperandSpan("structural", start, command_end)
|
|
795
|
+
|
|
796
|
+
scripted_end = _consume_scripts(source, atom_end, end)
|
|
797
|
+
if name in FUNCTION_MACROS:
|
|
798
|
+
group_start = skip_ignorable(source, scripted_end, end)
|
|
799
|
+
if group_start < end and source[group_start] in "([":
|
|
800
|
+
group_end = read_group_end(source, group_start, end)
|
|
801
|
+
if group_end is not None:
|
|
802
|
+
atom_end = group_end
|
|
803
|
+
elif (
|
|
804
|
+
source.startswith(r"\left", group_start)
|
|
805
|
+
and _left_delimiter(source, group_start, end)
|
|
806
|
+
in {"(", "[", "lparen", "lbrack"}
|
|
807
|
+
):
|
|
808
|
+
group_end = read_left_right_end(source, group_start, end)
|
|
809
|
+
if group_end is not None:
|
|
810
|
+
atom_end = group_end
|
|
811
|
+
else:
|
|
812
|
+
atom_end = scripted_end
|
|
813
|
+
|
|
814
|
+
kind = "opaque" if name in OPAQUE_MACROS else "function" if name in FUNCTION_MACROS else "operand"
|
|
815
|
+
return OperandSpan(kind, start, _consume_postfix(source, atom_end, end))
|
|
816
|
+
|
|
817
|
+
if source[start] in "({[":
|
|
818
|
+
group_end = read_group_end(source, start, end)
|
|
819
|
+
if group_end is None:
|
|
820
|
+
return OperandSpan("opaque", start, end)
|
|
821
|
+
if _contains_verb_command(source, start, group_end):
|
|
822
|
+
return OperandSpan("opaque", start, group_end)
|
|
823
|
+
inner_start = skip_ignorable(source, start + 1, group_end - 1)
|
|
824
|
+
inner_command = read_command(source, inner_start, group_end - 1)
|
|
825
|
+
kind = (
|
|
826
|
+
"opaque"
|
|
827
|
+
if source[start] == "{"
|
|
828
|
+
and inner_command is not None
|
|
829
|
+
and inner_command[0] == "color"
|
|
830
|
+
else "group"
|
|
831
|
+
)
|
|
832
|
+
return OperandSpan(kind, start, _consume_postfix(source, group_end, end))
|
|
833
|
+
|
|
834
|
+
number = NUMBER_RE.match(source, start, end)
|
|
835
|
+
if number is not None:
|
|
836
|
+
return OperandSpan(
|
|
837
|
+
"number",
|
|
838
|
+
start,
|
|
839
|
+
_consume_postfix(source, number.end(), end),
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
if source[start].isalpha():
|
|
843
|
+
name_end = start + 1
|
|
844
|
+
while name_end < end and source[name_end] == "'":
|
|
845
|
+
name_end += 1
|
|
846
|
+
group_start = skip_ignorable(source, name_end, end)
|
|
847
|
+
atom_end = name_end
|
|
848
|
+
kind = "symbol"
|
|
849
|
+
if group_start < end and source[group_start] == "(":
|
|
850
|
+
group_end = read_group_end(source, group_start, end)
|
|
851
|
+
if group_end is not None:
|
|
852
|
+
atom_end = group_end
|
|
853
|
+
kind = "function"
|
|
854
|
+
elif (
|
|
855
|
+
source.startswith(r"\left", group_start)
|
|
856
|
+
and _left_delimiter(source, group_start, end) in {"(", "lparen"}
|
|
857
|
+
):
|
|
858
|
+
group_end = read_left_right_end(source, group_start, end)
|
|
859
|
+
if group_end is not None:
|
|
860
|
+
atom_end = group_end
|
|
861
|
+
kind = "function"
|
|
862
|
+
return OperandSpan(kind, start, _consume_postfix(source, atom_end, end))
|
|
863
|
+
|
|
864
|
+
return None
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def find_operand_spans(
|
|
868
|
+
source: str,
|
|
869
|
+
start: int = 0,
|
|
870
|
+
end: int | None = None,
|
|
871
|
+
) -> tuple[OperandSpan, ...]:
|
|
872
|
+
"""Return top-level operands in a source range."""
|
|
873
|
+
|
|
874
|
+
end = len(source) if end is None else end
|
|
875
|
+
operands: list[OperandSpan] = []
|
|
876
|
+
index = start
|
|
877
|
+
while index < end:
|
|
878
|
+
index = skip_whitespace(source, index, end)
|
|
879
|
+
if index >= end:
|
|
880
|
+
break
|
|
881
|
+
if source[index] == "%":
|
|
882
|
+
index = _skip_comment(source, index, end)
|
|
883
|
+
continue
|
|
884
|
+
operand = read_operand(source, index, end)
|
|
885
|
+
if operand is None:
|
|
886
|
+
index += 1
|
|
887
|
+
continue
|
|
888
|
+
if operand.kind not in {"operator", "opaque", "structural"}:
|
|
889
|
+
operands.append(operand)
|
|
890
|
+
index = max(index + 1, operand.end)
|
|
891
|
+
return tuple(operands)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def find_operator_spans(
|
|
895
|
+
source: str,
|
|
896
|
+
start: int = 0,
|
|
897
|
+
end: int | None = None,
|
|
898
|
+
) -> tuple[OperandSpan, ...]:
|
|
899
|
+
"""Return top-level operators together with attached limits/scripts."""
|
|
900
|
+
|
|
901
|
+
end = len(source) if end is None else end
|
|
902
|
+
operators: list[OperandSpan] = []
|
|
903
|
+
index = start
|
|
904
|
+
while index < end:
|
|
905
|
+
index = skip_whitespace(source, index, end)
|
|
906
|
+
if index >= end:
|
|
907
|
+
break
|
|
908
|
+
if source[index] == "%":
|
|
909
|
+
index = _skip_comment(source, index, end)
|
|
910
|
+
continue
|
|
911
|
+
operand = read_operand(source, index, end)
|
|
912
|
+
if operand is None:
|
|
913
|
+
index += 1
|
|
914
|
+
continue
|
|
915
|
+
if operand.kind == "operator":
|
|
916
|
+
operators.append(operand)
|
|
917
|
+
index = max(index + 1, operand.end)
|
|
918
|
+
return tuple(operators)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def find_all_operator_spans(
|
|
922
|
+
source: str,
|
|
923
|
+
start: int = 0,
|
|
924
|
+
end: int | None = None,
|
|
925
|
+
) -> tuple[OperandSpan, ...]:
|
|
926
|
+
"""Return operators at any structural depth, excluding opaque source."""
|
|
927
|
+
end = len(source) if end is None else end
|
|
928
|
+
operators: list[OperandSpan] = []
|
|
929
|
+
index = start
|
|
930
|
+
while index < end:
|
|
931
|
+
if source[index] == "%":
|
|
932
|
+
index = _skip_comment(source, index, end)
|
|
933
|
+
continue
|
|
934
|
+
|
|
935
|
+
operand = read_operand(source, index, end)
|
|
936
|
+
if operand is not None:
|
|
937
|
+
if operand.kind == "operator":
|
|
938
|
+
operators.append(operand)
|
|
939
|
+
index = operand.end
|
|
940
|
+
continue
|
|
941
|
+
if operand.kind == "opaque":
|
|
942
|
+
index = operand.end
|
|
943
|
+
continue
|
|
944
|
+
|
|
945
|
+
if source[index] == "\\":
|
|
946
|
+
command = read_command(source, index, end)
|
|
947
|
+
if command is not None:
|
|
948
|
+
index = command[1]
|
|
949
|
+
continue
|
|
950
|
+
index += 1
|
|
951
|
+
|
|
952
|
+
return tuple(operators)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def find_top_level_tokens(
|
|
956
|
+
source: str,
|
|
957
|
+
tokens: tuple[str, ...],
|
|
958
|
+
start: int = 0,
|
|
959
|
+
end: int | None = None,
|
|
960
|
+
) -> tuple[tuple[int, int, str], ...]:
|
|
961
|
+
"""Find tokens outside complete operands/groups."""
|
|
962
|
+
|
|
963
|
+
end = len(source) if end is None else end
|
|
964
|
+
found: list[tuple[int, int, str]] = []
|
|
965
|
+
index = start
|
|
966
|
+
ordered = tuple(sorted(tokens, key=len, reverse=True))
|
|
967
|
+
while index < end:
|
|
968
|
+
index = skip_whitespace(source, index, end)
|
|
969
|
+
if index >= end:
|
|
970
|
+
break
|
|
971
|
+
if source[index] == "%":
|
|
972
|
+
index = _skip_comment(source, index, end)
|
|
973
|
+
continue
|
|
974
|
+
token = next(
|
|
975
|
+
(
|
|
976
|
+
item
|
|
977
|
+
for item in ordered
|
|
978
|
+
if source.startswith(item, index)
|
|
979
|
+
and not (
|
|
980
|
+
item.startswith("\\")
|
|
981
|
+
and item[-1:].isalpha()
|
|
982
|
+
and index + len(item) < end
|
|
983
|
+
and source[index + len(item)].isalpha()
|
|
984
|
+
)
|
|
985
|
+
),
|
|
986
|
+
None,
|
|
987
|
+
)
|
|
988
|
+
if token is not None:
|
|
989
|
+
found.append((index, index + len(token), token))
|
|
990
|
+
index += len(token)
|
|
991
|
+
continue
|
|
992
|
+
operand = read_operand(source, index, end)
|
|
993
|
+
if operand is not None:
|
|
994
|
+
index = max(index + 1, operand.end)
|
|
995
|
+
continue
|
|
996
|
+
index += 1
|
|
997
|
+
return tuple(found)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def find_script_argument_spans(source: str) -> tuple[OperandSpan, ...]:
|
|
1001
|
+
"""Find only script arguments, leaving ``_``/``^`` source markers intact."""
|
|
1002
|
+
|
|
1003
|
+
spans: list[OperandSpan] = []
|
|
1004
|
+
index = 0
|
|
1005
|
+
while index < len(source):
|
|
1006
|
+
if source[index] == "%":
|
|
1007
|
+
index = _skip_comment(source, index, len(source))
|
|
1008
|
+
continue
|
|
1009
|
+
if source[index] not in "_^":
|
|
1010
|
+
operand = read_operand(source, index)
|
|
1011
|
+
if operand is not None and operand.kind == "opaque":
|
|
1012
|
+
index = operand.end
|
|
1013
|
+
continue
|
|
1014
|
+
if source[index] == "\\":
|
|
1015
|
+
command = read_command(source, index, len(source))
|
|
1016
|
+
if command is not None:
|
|
1017
|
+
index = command[1]
|
|
1018
|
+
continue
|
|
1019
|
+
index += 1
|
|
1020
|
+
continue
|
|
1021
|
+
|
|
1022
|
+
argument_start = skip_ignorable(source, index + 1, len(source))
|
|
1023
|
+
argument_end = _read_argument_end(source, argument_start, len(source))
|
|
1024
|
+
if argument_end is None:
|
|
1025
|
+
index += 1
|
|
1026
|
+
continue
|
|
1027
|
+
if source[argument_start:argument_start + 1] == "{":
|
|
1028
|
+
inner_start = argument_start + 1
|
|
1029
|
+
inner_end = argument_end - 1
|
|
1030
|
+
else:
|
|
1031
|
+
inner_start = argument_start
|
|
1032
|
+
inner_end = argument_end
|
|
1033
|
+
if inner_start < inner_end:
|
|
1034
|
+
existing = read_command(source, inner_start, inner_end)
|
|
1035
|
+
existing_operand = (
|
|
1036
|
+
read_operand(source, inner_start, inner_end)
|
|
1037
|
+
if existing is not None
|
|
1038
|
+
and existing[0] in {"color", "textcolor"}
|
|
1039
|
+
else None
|
|
1040
|
+
)
|
|
1041
|
+
if existing_operand is None or existing_operand.end != inner_end:
|
|
1042
|
+
spans.append(
|
|
1043
|
+
OperandSpan(
|
|
1044
|
+
"subscript" if source[index] == "_" else "superscript",
|
|
1045
|
+
inner_start,
|
|
1046
|
+
inner_end,
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
index = argument_end
|
|
1050
|
+
return tuple(spans)
|