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,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))
|