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,366 @@
1
+ """Lossless structural inspection for the small math subset Color Math uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from ..utils.latex_helpers import read_braced
9
+ from .latex_spans import read_operand
10
+ from .markdown_scanner import scan_markdown
11
+
12
+
13
+ COMMAND_RE = re.compile(r"\\[A-Za-z]+|\\.")
14
+ NAME_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:')*")
15
+ NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
16
+ OPAQUE_MACROS = frozenset({
17
+ "mathbb",
18
+ "mathbf",
19
+ "mathcal",
20
+ "mathit",
21
+ "mathrm",
22
+ "operatorname",
23
+ "text",
24
+ "textbf",
25
+ "textit",
26
+ "textrm",
27
+ "texttt",
28
+ "verb",
29
+ })
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SemanticSpan:
34
+ """A recognized source range, kept without rewriting its LaTeX."""
35
+
36
+ kind: str
37
+ value: str
38
+ start: int
39
+ end: int
40
+ depth: int
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ParsedMath:
45
+ source: str
46
+ normalized: str
47
+ expression: tuple[SemanticSpan, ...]
48
+ error: str | None = None
49
+
50
+ @property
51
+ def ok(self) -> bool:
52
+ return self.error is None
53
+
54
+
55
+ def _read_command(text: str, start: int, end: int) -> tuple[str, int] | None:
56
+ match = COMMAND_RE.match(text, start, end)
57
+ if match is None:
58
+ return None
59
+ return match.group(0), match.end()
60
+
61
+
62
+ def _skip_whitespace(text: str, start: int, end: int) -> int:
63
+ while start < end and text[start].isspace():
64
+ start += 1
65
+ return start
66
+
67
+
68
+ def _skip_comment(text: str, start: int, end: int) -> int:
69
+ index = start + 1
70
+ while index < end and text[index] not in "\r\n":
71
+ index += 1
72
+ if index < end and text[index] == "\r" and index + 1 < end and text[index + 1] == "\n":
73
+ return index + 2
74
+ return min(index + 1, end)
75
+
76
+
77
+ def _read_delimiter(text: str, start: int, end: int) -> tuple[str, int] | None:
78
+ if start >= end:
79
+ return None
80
+ if text[start] != "\\":
81
+ return text[start], start + 1
82
+ return _read_command(text, start, end)
83
+
84
+
85
+ def _skip_opaque_argument(text: str, start: int, end: int) -> int:
86
+ start = _skip_whitespace(text, start, end)
87
+ group = read_braced(text, start)
88
+ return group[1] if group is not None and group[1] <= end else start
89
+
90
+
91
+ def _read_left_right_group(
92
+ text: str,
93
+ start: int,
94
+ end: int,
95
+ ) -> tuple[str, int, int, int] | None:
96
+ command = _read_command(text, start, end)
97
+ if command is None or command[0] != r"\left":
98
+ return None
99
+
100
+ delimiter_data = _read_delimiter(
101
+ text,
102
+ _skip_whitespace(text, command[1], end),
103
+ end,
104
+ )
105
+ if delimiter_data is None:
106
+ return None
107
+
108
+ opening, content_start = delimiter_data
109
+ depth = 1
110
+ index = content_start
111
+
112
+ while index < end:
113
+ if text[index] == "%":
114
+ index = _skip_comment(text, index, end)
115
+ continue
116
+ if text[index] == "{":
117
+ group = read_braced(text, index)
118
+ if group is None or group[1] > end:
119
+ return None
120
+ index = group[1]
121
+ continue
122
+
123
+ if text[index] != "\\":
124
+ index += 1
125
+ continue
126
+
127
+ nested_command = _read_command(text, index, end)
128
+ if nested_command is None:
129
+ index += 1
130
+ continue
131
+
132
+ name, command_end = nested_command
133
+ if name == r"\left":
134
+ delimiter = _read_delimiter(
135
+ text,
136
+ _skip_whitespace(text, command_end, end),
137
+ end,
138
+ )
139
+ if delimiter is not None:
140
+ depth += 1
141
+ index = delimiter[1]
142
+ continue
143
+ elif name == r"\right":
144
+ delimiter = _read_delimiter(
145
+ text,
146
+ _skip_whitespace(text, command_end, end),
147
+ end,
148
+ )
149
+ if delimiter is not None:
150
+ depth -= 1
151
+ if depth == 0:
152
+ return opening, content_start, index, delimiter[1]
153
+ index = delimiter[1]
154
+ continue
155
+ else:
156
+ operand = read_operand(text, index, end)
157
+ if operand is not None and operand.kind == "opaque":
158
+ index = operand.end
159
+ continue
160
+
161
+ if name[1:] in OPAQUE_MACROS:
162
+ opaque_end = _skip_opaque_argument(text, command_end, end)
163
+ if opaque_end != command_end:
164
+ index = opaque_end
165
+ continue
166
+
167
+ index = command_end
168
+
169
+ return None
170
+
171
+
172
+ def _read_plain_parentheses(
173
+ text: str,
174
+ start: int,
175
+ end: int,
176
+ ) -> tuple[int, int, int] | None:
177
+ depth = 1
178
+ index = start + 1
179
+
180
+ while index < end:
181
+ if text[index] == "%":
182
+ index = _skip_comment(text, index, end)
183
+ continue
184
+ if text[index] == "{":
185
+ group = read_braced(text, index)
186
+ if group is None or group[1] > end:
187
+ return None
188
+ index = group[1]
189
+ continue
190
+
191
+ if text[index] == "\\":
192
+ operand = read_operand(text, index, end)
193
+ if operand is not None and operand.kind == "opaque":
194
+ index = operand.end
195
+ continue
196
+ command = _read_command(text, index, end)
197
+ if command is None:
198
+ index += 1
199
+ continue
200
+
201
+ name, command_end = command
202
+ if name == r"\left":
203
+ group = _read_left_right_group(text, index, end)
204
+ if group is not None:
205
+ index = group[3]
206
+ continue
207
+ elif name[1:] in OPAQUE_MACROS:
208
+ opaque_end = _skip_opaque_argument(text, command_end, end)
209
+ if opaque_end != command_end:
210
+ index = opaque_end
211
+ continue
212
+
213
+ index = command_end
214
+ continue
215
+
216
+ if text[index] == "(":
217
+ depth += 1
218
+ elif text[index] == ")":
219
+ depth -= 1
220
+ if depth == 0:
221
+ return start + 1, index, index + 1
222
+
223
+ index += 1
224
+
225
+ return None
226
+
227
+
228
+ def _read_function_arguments(
229
+ text: str,
230
+ start: int,
231
+ end: int,
232
+ ) -> tuple[int, int, int] | None:
233
+ if start >= end:
234
+ return None
235
+ if text[start] == "(":
236
+ return _read_plain_parentheses(text, start, end)
237
+
238
+ group = _read_left_right_group(text, start, end)
239
+ if group is None or group[0] not in {"(", r"\("}:
240
+ return None
241
+ return group[1:]
242
+
243
+
244
+ def _collect_semantic_spans(
245
+ text: str,
246
+ start: int,
247
+ end: int,
248
+ depth: int,
249
+ spans: list[SemanticSpan],
250
+ errors: list[str],
251
+ ) -> None:
252
+ index = start
253
+ while index < end:
254
+ if text[index] == "%":
255
+ index = _skip_comment(text, index, end)
256
+ continue
257
+ if text[index] == "\\":
258
+ operand = read_operand(text, index, end)
259
+ if operand is not None and operand.kind == "opaque":
260
+ index = operand.end
261
+ continue
262
+ command = _read_command(text, index, end)
263
+ if command is not None:
264
+ name, command_end = command
265
+ if name[1:] in OPAQUE_MACROS:
266
+ opaque_end = _skip_opaque_argument(text, command_end, end)
267
+ if opaque_end != command_end:
268
+ index = opaque_end
269
+ continue
270
+ index = command_end
271
+ continue
272
+
273
+ name_match = NAME_RE.match(text, index, end)
274
+ if name_match is not None:
275
+ name_end = name_match.end()
276
+ arguments = _read_function_arguments(text, name_end, end)
277
+ if arguments is not None:
278
+ argument_start, argument_end, call_end = arguments
279
+ spans.append(
280
+ SemanticSpan(
281
+ "function",
282
+ name_match.group(0),
283
+ index,
284
+ name_end,
285
+ depth,
286
+ )
287
+ )
288
+ _collect_semantic_spans(
289
+ text,
290
+ argument_start,
291
+ argument_end,
292
+ depth + 1,
293
+ spans,
294
+ errors,
295
+ )
296
+ index = call_end
297
+ continue
298
+
299
+ if name_end < end and text[name_end] == "(":
300
+ errors.append(f"unclosed function call after {name_match.group(0)!r}")
301
+ index = name_end
302
+ continue
303
+
304
+ number_match = NUMBER_RE.match(text, index, end) if depth else None
305
+ if number_match is not None:
306
+ spans.append(
307
+ SemanticSpan(
308
+ "constant",
309
+ number_match.group(0),
310
+ index,
311
+ number_match.end(),
312
+ depth,
313
+ )
314
+ )
315
+ index = number_match.end()
316
+ continue
317
+
318
+ index += 1
319
+
320
+
321
+ def find_semantic_spans(source: str) -> tuple[tuple[SemanticSpan, ...], str | None]:
322
+ """Recognize plain nested calls and constants without rewriting LaTeX."""
323
+ spans: list[SemanticSpan] = []
324
+ errors: list[str] = []
325
+ _collect_semantic_spans(source, 0, len(source), 0, spans, errors)
326
+ return tuple(spans), errors[0] if errors else None
327
+
328
+
329
+ def format_math_structure(parsed: ParsedMath) -> str:
330
+ if not parsed.expression:
331
+ return "No nested function calls found."
332
+ return "\n".join(
333
+ f"{' ' * span.depth}{span.kind.title()} {span.value}"
334
+ for span in parsed.expression
335
+ )
336
+
337
+
338
+ def parser_available() -> bool:
339
+ """The built-in structural inspector has no optional dependencies."""
340
+ return True
341
+
342
+
343
+ def parse_math_body(body: str) -> ParsedMath:
344
+ spans, error = find_semantic_spans(body)
345
+ return ParsedMath(body, body, spans, error)
346
+
347
+
348
+ def parse_math_blocks(text: str) -> list[ParsedMath]:
349
+ return [
350
+ parse_math_body(text[span.content_start:span.content_end])
351
+ for span in scan_markdown(text).math_blocks
352
+ ]
353
+
354
+
355
+ def describe_math_blocks(text: str) -> str:
356
+ blocks = parse_math_blocks(text)
357
+ if not blocks:
358
+ return "No $$...$$ math blocks found."
359
+
360
+ lines: list[str] = []
361
+ for index, parsed in enumerate(blocks, 1):
362
+ lines.append(f"Block {index}: {'OK' if parsed.ok else 'FAIL'}")
363
+ lines.append(format_math_structure(parsed) if parsed.ok else parsed.error or "unknown parse error")
364
+ lines.append("")
365
+
366
+ return "\n".join(lines).rstrip()
@@ -0,0 +1,351 @@
1
+ """Collect generic, source-preserving LaTeX color spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..config import COLORS, FUNCTION_COMMANDS, SORTED_COLOR_COMMANDS
8
+ from ..utils.coloring import command_color
9
+ from ..utils.latex_helpers import read_color_command
10
+ from ..utils.spans import ColorSpan, apply_color_spans
11
+ from .latex_spans import (
12
+ MATRIX_ENVIRONMENTS,
13
+ UNARY_MACROS,
14
+ find_all_operator_spans,
15
+ find_operand_spans,
16
+ find_script_argument_spans,
17
+ read_command,
18
+ read_environment_end,
19
+ read_group_end,
20
+ read_operand,
21
+ skip_ignorable,
22
+ )
23
+
24
+
25
+ COMMAND_RE = re.compile(r"\\[A-Za-z]+|\\.")
26
+ LAYOUT_ENVIRONMENTS = MATRIX_ENVIRONMENTS | frozenset({
27
+ "align",
28
+ "align*",
29
+ "aligned",
30
+ "cases",
31
+ "gather",
32
+ "gather*",
33
+ "gathered",
34
+ "split",
35
+ })
36
+ STRUCTURED_COMMANDS = {
37
+ "binom": 2,
38
+ "boxed": 1,
39
+ "cancel": 1,
40
+ "dfrac": 2,
41
+ "frac": 2,
42
+ "overbrace": 1,
43
+ "phantom": 1,
44
+ "tfrac": 2,
45
+ "underbrace": 1,
46
+ }
47
+ SEMANTIC_COLOR_NAMES = ("main", "derivative", "chain")
48
+
49
+
50
+ def _argument_range(
51
+ source: str,
52
+ start: int,
53
+ end: int,
54
+ ) -> tuple[int, int, int] | None:
55
+ start = skip_ignorable(source, start, end)
56
+ if start >= end:
57
+ return None
58
+ if source[start] in "{([":
59
+ group_end = read_group_end(source, start, end)
60
+ if group_end is None:
61
+ return None
62
+ return start + 1, group_end - 1, group_end
63
+ if source[start] != "\\":
64
+ return start, start + 1, start + 1
65
+ operand = read_operand(source, start, end)
66
+ if operand is None:
67
+ return start, start + 1, start + 1
68
+ return operand.start, operand.end, operand.end
69
+
70
+
71
+ def _operand_color_spans(
72
+ source: str,
73
+ start: int,
74
+ end: int,
75
+ offset: int,
76
+ ) -> list[ColorSpan]:
77
+ spans: list[ColorSpan] = []
78
+ semantic_index = 0
79
+ for operand in find_operand_spans(source, start, end):
80
+ if operand.kind == "number":
81
+ color = COLORS["orange"]
82
+ else:
83
+ color = COLORS[
84
+ SEMANTIC_COLOR_NAMES[
85
+ (offset + semantic_index) % len(SEMANTIC_COLOR_NAMES)
86
+ ]
87
+ ]
88
+ semantic_index += 1
89
+ spans.append(ColorSpan(operand.start, operand.end, color, priority=5))
90
+ return spans
91
+
92
+
93
+ def _environment_content(
94
+ source: str,
95
+ start: int,
96
+ end: int,
97
+ ) -> tuple[str, int, int, int] | None:
98
+ command = read_command(source, start, end)
99
+ if command is None or command[0] != "begin":
100
+ return None
101
+ name_start = skip_ignorable(source, command[1], end)
102
+ name_end = read_group_end(source, name_start, end)
103
+ environment = read_environment_end(source, start, end)
104
+ if name_end is None or environment is None:
105
+ return None
106
+ name, environment_end = environment
107
+ content_start = name_end
108
+ if name == "array":
109
+ preamble_start = skip_ignorable(source, content_start, environment_end)
110
+ preamble_end = read_group_end(source, preamble_start, environment_end)
111
+ if preamble_end is not None:
112
+ content_start = preamble_end
113
+ closing_start = source.rfind(r"\end", content_start, environment_end)
114
+ if closing_start < content_start:
115
+ return None
116
+ return name, content_start, closing_start, environment_end
117
+
118
+
119
+ def _layout_cells(source: str, start: int, end: int) -> list[tuple[int, int]]:
120
+ cells: list[tuple[int, int]] = []
121
+ cell_start = start
122
+ index = start
123
+ while index < end:
124
+ if source[index] == "%":
125
+ newline = source.find("\n", index + 1, end)
126
+ index = end if newline < 0 else newline + 1
127
+ continue
128
+ if source[index] == "&":
129
+ cells.append((cell_start, index))
130
+ cell_start = index + 1
131
+ index += 1
132
+ continue
133
+ if source[index] == "\\":
134
+ command = read_command(source, index, end)
135
+ if command is not None and command[0] == "\\":
136
+ cells.append((cell_start, index))
137
+ cell_start = command[1]
138
+ index = command[1]
139
+ continue
140
+ operand = read_operand(source, index, end)
141
+ if operand is not None:
142
+ index = max(index + 1, operand.end)
143
+ continue
144
+ index += 1
145
+ cells.append((cell_start, end))
146
+ return cells
147
+
148
+
149
+ def collect_structured_spans(body: str) -> list[ColorSpan]:
150
+ """Color operands inside structured commands and layout environments."""
151
+ spans: list[ColorSpan] = []
152
+ index = 0
153
+ while index < len(body):
154
+ if body[index] != "\\":
155
+ index += 1
156
+ continue
157
+ command = read_command(body, index, len(body))
158
+ if command is None:
159
+ index += 1
160
+ continue
161
+ name, command_end = command
162
+
163
+ if name == "begin":
164
+ environment = _environment_content(body, index, len(body))
165
+ if environment is not None and environment[0] in LAYOUT_ENVIRONMENTS:
166
+ _, content_start, content_end, _ = environment
167
+ for offset, (start, end) in enumerate(
168
+ _layout_cells(body, content_start, content_end)
169
+ ):
170
+ spans.extend(_operand_color_spans(body, start, end, offset))
171
+
172
+ argument_count = STRUCTURED_COMMANDS.get(name)
173
+ if name in UNARY_MACROS:
174
+ argument_count = 1
175
+ if name == "sqrt":
176
+ optional_start = skip_ignorable(body, command_end, len(body))
177
+ if optional_start < len(body) and body[optional_start] == "[":
178
+ optional_end = read_group_end(body, optional_start, len(body))
179
+ if optional_end is not None:
180
+ spans.extend(
181
+ _operand_color_spans(
182
+ body,
183
+ optional_start + 1,
184
+ optional_end - 1,
185
+ 1,
186
+ )
187
+ )
188
+ command_end = optional_end
189
+ argument_count = 1
190
+
191
+ argument_start = command_end
192
+ for offset in range(argument_count or 0):
193
+ argument = _argument_range(body, argument_start, len(body))
194
+ if argument is None:
195
+ break
196
+ start, end, argument_start = argument
197
+ spans.extend(_operand_color_spans(body, start, end, offset))
198
+ index = command[1]
199
+ return spans
200
+
201
+
202
+ def collect_operator_spans(
203
+ body: str,
204
+ start: int = 0,
205
+ end: int | None = None,
206
+ ) -> list[ColorSpan]:
207
+ """Color complete operators, including attached limits and scripts."""
208
+ spans: list[ColorSpan] = []
209
+ for operator in find_all_operator_spans(body, start, end):
210
+ command = COMMAND_RE.match(body, operator.start, operator.end)
211
+ if command is not None:
212
+ spans.append(
213
+ ColorSpan(
214
+ operator.start,
215
+ operator.end,
216
+ command_color(command.group(0)),
217
+ priority=30,
218
+ )
219
+ )
220
+ return spans
221
+
222
+
223
+ def collect_scanner_spans(body: str) -> list[ColorSpan]:
224
+ """Find generic operators and script arguments without rewriting LaTeX."""
225
+ scripts = find_script_argument_spans(body)
226
+ operators = find_all_operator_spans(body)
227
+ spans = [
228
+ ColorSpan(
229
+ item.start,
230
+ item.end,
231
+ COLORS["chain" if item.kind == "subscript" else "upper"],
232
+ priority=10,
233
+ )
234
+ for item in scripts
235
+ ]
236
+ spans.extend(collect_operator_spans(body))
237
+ spans.extend(collect_structured_spans(body))
238
+ script_ranges = tuple((item.start, item.end) for item in scripts)
239
+ operator_ranges = tuple((item.start, item.end) for item in operators)
240
+
241
+ index = 0
242
+ while index < len(body):
243
+ if body[index] == "%":
244
+ line_end = index + 1
245
+ while line_end < len(body) and body[line_end] not in "\r\n":
246
+ line_end += 1
247
+ if (
248
+ line_end < len(body)
249
+ and body[line_end] == "\r"
250
+ and line_end + 1 < len(body)
251
+ and body[line_end + 1] == "\n"
252
+ ):
253
+ line_end += 2
254
+ elif line_end < len(body):
255
+ line_end += 1
256
+ index = line_end
257
+ continue
258
+
259
+ existing = read_color_command(body, index)
260
+ if existing is not None:
261
+ index = existing[1]
262
+ continue
263
+
264
+ operand = read_operand(body, index)
265
+ if operand is not None and operand.kind == "opaque":
266
+ index = operand.end
267
+ continue
268
+
269
+ containing_operator = next(
270
+ (
271
+ (start, end)
272
+ for start, end in operator_ranges
273
+ if start <= index < end
274
+ ),
275
+ None,
276
+ )
277
+ if containing_operator is not None:
278
+ index = containing_operator[1]
279
+ continue
280
+
281
+ containing_script = next(
282
+ (
283
+ (start, end)
284
+ for start, end in script_ranges
285
+ if start <= index < end
286
+ ),
287
+ None,
288
+ )
289
+ if containing_script is not None:
290
+ index = containing_script[1]
291
+ continue
292
+
293
+ command_match = COMMAND_RE.match(body, index)
294
+ if command_match is not None:
295
+ command = command_match.group(0)
296
+ if command in SORTED_COLOR_COMMANDS:
297
+ spans.append(
298
+ ColorSpan(
299
+ index,
300
+ command_match.end(),
301
+ command_color(command),
302
+ )
303
+ )
304
+ elif command in FUNCTION_COMMANDS:
305
+ spans.append(
306
+ ColorSpan(
307
+ index,
308
+ command_match.end(),
309
+ COLORS["main"],
310
+ priority=20,
311
+ )
312
+ )
313
+ elif command == r"\operatorname":
314
+ argument_start = command_match.end()
315
+ if argument_start < len(body) and body[argument_start] == "*":
316
+ argument_start += 1
317
+ argument_start = skip_ignorable(body, argument_start, len(body))
318
+ argument_end = read_group_end(body, argument_start, len(body))
319
+ if argument_end is not None:
320
+ spans.append(
321
+ ColorSpan(index, argument_end, COLORS["main"], priority=20)
322
+ )
323
+ index = command_match.end()
324
+ if index < len(body) and body[index] == "*":
325
+ index += 1
326
+ continue
327
+
328
+ command = next(
329
+ (
330
+ candidate
331
+ for candidate in SORTED_COLOR_COMMANDS
332
+ if not candidate.startswith("\\")
333
+ and body.startswith(candidate, index)
334
+ ),
335
+ None,
336
+ )
337
+ if command is not None:
338
+ spans.append(
339
+ ColorSpan(index, index + len(command), command_color(command))
340
+ )
341
+ index += len(command)
342
+ continue
343
+
344
+ index += 1
345
+
346
+ return spans
347
+
348
+
349
+ def color_latex_body_with_scanner(body: str) -> str:
350
+ """Color generic LaTeX tokens by inserting scoped wrappers."""
351
+ return apply_color_spans(body, collect_scanner_spans(body))