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.
Files changed (40) hide show
  1. color_math/__init__.py +8 -0
  2. color_math/__main__.py +5 -0
  3. color_math/adapters.py +288 -0
  4. color_math/config.py +351 -0
  5. color_math/converters/__init__.py +27 -0
  6. color_math/converters/align.py +18 -0
  7. color_math/converters/block.py +126 -0
  8. color_math/converters/derivative.py +266 -0
  9. color_math/converters/equation.py +5 -0
  10. color_math/converters/generic.py +101 -0
  11. color_math/converters/integral.py +16 -0
  12. color_math/converters/limit.py +16 -0
  13. color_math/converters/matrix.py +143 -0
  14. color_math/converters/semantic.py +76 -0
  15. color_math/io.py +53 -0
  16. color_math/main.py +162 -0
  17. color_math/parsers/__init__.py +64 -0
  18. color_math/parsers/braket.py +109 -0
  19. color_math/parsers/delimiters.py +151 -0
  20. color_math/parsers/differentials.py +71 -0
  21. color_math/parsers/dimensionless.py +74 -0
  22. color_math/parsers/latex_spans.py +1050 -0
  23. color_math/parsers/markdown_scanner.py +463 -0
  24. color_math/parsers/math_parser.py +366 -0
  25. color_math/parsers/scanner.py +351 -0
  26. color_math/parsers/taxonomy.py +124 -0
  27. color_math/parsers/units.py +98 -0
  28. color_math/parsers/variable_hash.py +126 -0
  29. color_math/self_test.py +224 -0
  30. color_math/undo.py +63 -0
  31. color_math/utils/__init__.py +30 -0
  32. color_math/utils/coloring.py +61 -0
  33. color_math/utils/latex_helpers.py +232 -0
  34. color_math/utils/spans.py +77 -0
  35. python_color_math-0.1.0.dist-info/METADATA +167 -0
  36. python_color_math-0.1.0.dist-info/RECORD +40 -0
  37. python_color_math-0.1.0.dist-info/WHEEL +5 -0
  38. python_color_math-0.1.0.dist-info/entry_points.txt +2 -0
  39. python_color_math-0.1.0.dist-info/licenses/LICENSE +21 -0
  40. 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)