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,126 @@
1
+ # converters/block.py
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ from collections.abc import Callable
6
+
7
+ from ..config import ColorMathOptions
8
+ from ..parsers.markdown_scanner import scan_markdown
9
+ from .align import convert_align_block
10
+ from .derivative import convert_derivative_line
11
+ from .equation import convert_equation_line
12
+ from .generic import color_latex_body, color_generic_math_line
13
+ from .integral import convert_integral_line
14
+ from .limit import convert_limit_line
15
+ from .matrix import convert_matrix_block
16
+
17
+
18
+ MATH_BLOCK_RE = re.compile(
19
+ r"^(?P<prefix>\s*\#+\s*)?\$\$(?P<body>.*)\$\$(?P<suffix>\s*)$",
20
+ re.DOTALL,
21
+ )
22
+ Converter = Callable[[str], str | None]
23
+
24
+ LINE_CONVERTERS: tuple[Converter, ...] = (
25
+ convert_derivative_line,
26
+ convert_integral_line,
27
+ convert_limit_line,
28
+ convert_equation_line,
29
+ )
30
+
31
+ BLOCK_CONVERTERS: tuple[Converter, ...] = (
32
+ convert_matrix_block,
33
+ convert_align_block,
34
+ )
35
+
36
+
37
+ def try_converters(text: str, converters: tuple[Converter, ...]) -> str | None:
38
+ for converter in converters:
39
+ converted = converter(text)
40
+
41
+ if converted is not None:
42
+ return converted
43
+
44
+ return None
45
+
46
+
47
+ def convert_math_block(
48
+ block: str,
49
+ palette: dict[str, str] | None = None,
50
+ options: ColorMathOptions | None = None,
51
+ ) -> str:
52
+ """
53
+ Convert a multiline math block.
54
+
55
+ Specialized converters insert wrappers into the original source. Generic
56
+ coloring is used only when no semantic formatter recognizes the block.
57
+ """
58
+
59
+ match = MATH_BLOCK_RE.match(block)
60
+
61
+ if not match:
62
+ return block
63
+
64
+ prefix = match.group("prefix") or ""
65
+ body = match.group("body")
66
+ suffix = match.group("suffix")
67
+
68
+ line_match = try_converters(block, LINE_CONVERTERS)
69
+
70
+ if line_match is not None:
71
+ return line_match
72
+
73
+ block_match = try_converters(block, BLOCK_CONVERTERS)
74
+
75
+ if block_match is not None:
76
+ return block_match
77
+
78
+ # fallback to generic coloring
79
+ return f"{prefix}$${color_latex_body(body, palette, options)}$${suffix}"
80
+
81
+
82
+ def convert_line(
83
+ line: str,
84
+ palette: dict[str, str] | None = None,
85
+ options: ColorMathOptions | None = None,
86
+ ) -> str:
87
+ """
88
+ Convert a single line.
89
+
90
+ Checks specialized converters first,
91
+ otherwise generic math coloring.
92
+ """
93
+
94
+ converted = try_converters(line, LINE_CONVERTERS)
95
+
96
+ if converted is not None:
97
+ return converted
98
+
99
+ return color_generic_math_line(line, palette, options)
100
+
101
+
102
+ def convert_text(
103
+ text: str,
104
+ palette: dict[str, str] | None = None,
105
+ options: ColorMathOptions | None = None,
106
+ ) -> str:
107
+ """
108
+ Convert an entire document.
109
+
110
+ Handles:
111
+ - single-line and multiline $$ ... $$ math blocks
112
+ - normal text passthrough
113
+ """
114
+
115
+ math_blocks = scan_markdown(text).math_blocks
116
+ if not math_blocks:
117
+ return text
118
+
119
+ converted: list[str] = []
120
+ index = 0
121
+ for span in math_blocks:
122
+ converted.append(text[index:span.start])
123
+ converted.append(convert_math_block(text[span.start:span.end], palette, options))
124
+ index = span.end
125
+ converted.append(text[index:])
126
+ return "".join(converted)
@@ -0,0 +1,266 @@
1
+ """Lossless semantic coloring for chain-rule derivatives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..config import COLORS
8
+ from ..parsers.latex_spans import (
9
+ find_operand_spans,
10
+ read_command,
11
+ read_group_end,
12
+ read_operand,
13
+ skip_ignorable,
14
+ )
15
+ from ..parsers.scanner import collect_operator_spans
16
+ from ..utils.latex_helpers import contains_color_wrapper
17
+ from ..utils.spans import ColorSpan, apply_color_spans
18
+ from .semantic import first_equality, parse_math_block, relation_spans, trim_range
19
+
20
+
21
+ NUMERIC_FRACTION_RE = re.compile(
22
+ r"\\(?:dfrac|tfrac|frac)\{[+-]?\d+(?:\.\d+)?\}"
23
+ r"\{[+-]?\d+(?:\.\d+)?\}"
24
+ )
25
+ PLAIN_COEFFICIENT_RE = re.compile(r"[A-Za-z]")
26
+ ADDITIVE_SEPARATOR_RE = re.compile(
27
+ r"[+\-=<>]|\\(?:pm|mp|leq|geq|neq|approx|sim|equiv)(?![A-Za-z])"
28
+ )
29
+ MULTIPLICATIVE_GAP_RE = re.compile(
30
+ r"(?:\s|[·*]|\\(?:cdot|times|,|:|;|!|quad|qquad)(?![A-Za-z]))*"
31
+ )
32
+
33
+
34
+ def _compact(value: str) -> str:
35
+ return re.sub(r"\s+", "", value)
36
+
37
+
38
+ def _is_derivative_prefix(value: str) -> bool:
39
+ compact = _compact(value)
40
+ return compact.startswith(
41
+ (r"\frac{d}{d", r"\dfrac{d}{d", r"\tfrac{d}{d")
42
+ )
43
+
44
+
45
+ def _is_prime(value: str) -> bool:
46
+ return bool(re.match(r"(?:[A-Za-z]|\\[A-Za-z]+)'", value.lstrip()))
47
+
48
+
49
+ def _is_numeric(value: str) -> bool:
50
+ compact = _compact(value)
51
+ return bool(
52
+ re.fullmatch(r"[+-]?\d+(?:\.\d+)?", compact)
53
+ or NUMERIC_FRACTION_RE.fullmatch(compact)
54
+ )
55
+
56
+
57
+ def _is_outer_derivative(value: str) -> bool:
58
+ compact = _compact(value)
59
+ return (
60
+ compact.startswith(
61
+ (
62
+ r"\cos",
63
+ r"\sin",
64
+ r"\tan",
65
+ r"\sec",
66
+ r"\ln",
67
+ r"\log",
68
+ r"\sqrt",
69
+ r"\frac",
70
+ r"\dfrac",
71
+ r"\tfrac",
72
+ "e^",
73
+ )
74
+ )
75
+ or _is_prime(compact)
76
+ )
77
+
78
+
79
+ def _has_additive_separator(value: str) -> bool:
80
+ return ADDITIVE_SEPARATOR_RE.search(value) is not None
81
+
82
+
83
+ def _is_multiplicative_gap(value: str) -> bool:
84
+ return MULTIPLICATIVE_GAP_RE.fullmatch(value) is not None
85
+
86
+
87
+ def _fraction_arguments(
88
+ body: str,
89
+ operand_start: int,
90
+ end: int,
91
+ ) -> tuple[tuple[int, int], ...]:
92
+ command = read_command(body, operand_start, end)
93
+ if command is None or command[0] not in {"frac", "dfrac", "tfrac"}:
94
+ return ()
95
+ ranges: list[tuple[int, int]] = []
96
+ index = command[1]
97
+ for _ in range(2):
98
+ index = skip_ignorable(body, index, end)
99
+ group_end = read_group_end(body, index, end)
100
+ if group_end is None:
101
+ return ()
102
+ ranges.append((index + 1, group_end - 1))
103
+ index = group_end
104
+ return tuple(ranges)
105
+
106
+
107
+ def _rhs_spans(body: str, start: int, end: int | None = None) -> list[ColorSpan]:
108
+ end = len(body) if end is None else end
109
+ operands = list(find_operand_spans(body, start, end))
110
+ spans: list[ColorSpan] = []
111
+ prime_seen = False
112
+ previous_end = start
113
+
114
+ for index, operand in enumerate(operands):
115
+ if _has_additive_separator(body[previous_end:operand.start]):
116
+ prime_seen = False
117
+ value = body[operand.start:operand.end]
118
+ compact = _compact(value)
119
+ next_exists = index + 1 < len(operands)
120
+
121
+ multiplicative_gap = (
122
+ body[operand.end:operands[index + 1].start]
123
+ if next_exists
124
+ else ""
125
+ )
126
+ is_coefficient = _is_numeric(value) or (
127
+ next_exists
128
+ and PLAIN_COEFFICIENT_RE.fullmatch(compact) is not None
129
+ and _is_multiplicative_gap(multiplicative_gap)
130
+ )
131
+ if is_coefficient:
132
+ color_name = "orange"
133
+ elif _is_prime(value):
134
+ color_name = "chain" if prime_seen else "derivative"
135
+ prime_seen = True
136
+ elif prime_seen:
137
+ color_name = "chain" if operand.kind == "symbol" else "main"
138
+ else:
139
+ color_name = "derivative" if _is_outer_derivative(value) else "main"
140
+
141
+ span_start = operand.start
142
+ if _is_numeric(value):
143
+ sign = operand.start - 1
144
+ while sign >= start and body[sign].isspace():
145
+ sign -= 1
146
+ if sign >= start and body[sign] in "+-":
147
+ before = sign - 1
148
+ while before >= start and body[before].isspace():
149
+ before -= 1
150
+ if before < start or body[before] in "=+-(":
151
+ span_start = sign
152
+
153
+ primed_name = re.match(r"[A-Za-z]+['’]+", value)
154
+ product_group: tuple[int, int] | None = None
155
+ if primed_name is not None:
156
+ group_start = skip_ignorable(
157
+ body,
158
+ operand.start + primed_name.end(),
159
+ operand.end,
160
+ )
161
+ group_end = read_group_end(body, group_start, operand.end)
162
+ if (
163
+ group_end is not None
164
+ and any(token in body[group_start + 1:group_end - 1] for token in "+-")
165
+ ):
166
+ product_group = (group_start, operand.end)
167
+
168
+ if product_group is None:
169
+ spans.append(
170
+ ColorSpan(
171
+ span_start,
172
+ operand.end,
173
+ COLORS[color_name],
174
+ priority=20,
175
+ )
176
+ )
177
+ else:
178
+ spans.extend(
179
+ (
180
+ ColorSpan(
181
+ span_start,
182
+ operand.start + primed_name.end(),
183
+ COLORS[color_name],
184
+ priority=20,
185
+ ),
186
+ ColorSpan(
187
+ product_group[0],
188
+ product_group[1],
189
+ COLORS["main"],
190
+ priority=20,
191
+ ),
192
+ )
193
+ )
194
+
195
+ if (
196
+ not _is_numeric(value)
197
+ and value.lstrip().startswith((r"\frac", r"\dfrac", r"\tfrac"))
198
+ ):
199
+ for inner_start, inner_end in _fraction_arguments(
200
+ body,
201
+ operand.start,
202
+ operand.end,
203
+ ):
204
+ inner_semantic = _rhs_spans(body, inner_start, inner_end)
205
+ inner_relations = relation_spans(body, inner_start, inner_end)
206
+ spans.extend(
207
+ relation
208
+ for relation in inner_relations
209
+ if not any(
210
+ semantic.start <= relation.start
211
+ and relation.end <= semantic.end
212
+ for semantic in inner_semantic
213
+ )
214
+ )
215
+ spans.extend(inner_semantic)
216
+
217
+ previous_end = operand.end
218
+
219
+ return spans
220
+
221
+
222
+ def convert_derivative_line(source: str) -> str | None:
223
+ """Color a derivative block by inserting wrappers into its exact source."""
224
+ block = parse_math_block(source)
225
+ if block is None:
226
+ return None
227
+
228
+ body_start = skip_ignorable(block.body, 0, len(block.body))
229
+ prefix = read_operand(block.body, body_start)
230
+ if prefix is None or not _is_derivative_prefix(prefix.text(block.body)):
231
+ return None
232
+ if contains_color_wrapper(block.body):
233
+ return source
234
+
235
+ equality = first_equality(block.body)
236
+ if equality is None or equality[0] <= prefix.end:
237
+ return None
238
+
239
+ target_start, target_end = trim_range(
240
+ block.body,
241
+ prefix.end,
242
+ equality[0],
243
+ )
244
+ relations = relation_spans(block.body)
245
+ operators = collect_operator_spans(block.body)
246
+ target: ColorSpan | None = None
247
+ if target_start < target_end:
248
+ target = ColorSpan(
249
+ target_start,
250
+ target_end,
251
+ COLORS["main"],
252
+ priority=20,
253
+ )
254
+ semantic_rhs = _rhs_spans(block.body, equality[1])
255
+ relations = [
256
+ span
257
+ for span in relations
258
+ if not any(
259
+ semantic.start <= span.start and span.end <= semantic.end
260
+ for semantic in semantic_rhs
261
+ )
262
+ ]
263
+ spans = [*relations, *operators, *semantic_rhs]
264
+ if target is not None:
265
+ spans.append(target)
266
+ return block.render(apply_color_spans(block.body, spans))
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def convert_equation_line(line: str) -> str | None:
5
+ return None
@@ -0,0 +1,101 @@
1
+ """Generic source-preserving LaTeX coloring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..config import COLORS, ColorMathOptions
8
+ from ..parsers.math_parser import find_semantic_spans
9
+ from ..parsers.scanner import collect_scanner_spans
10
+ from ..parsers.units import find_unit_spans, collect_unit_spans
11
+ from ..parsers.differentials import find_differential_spans, collect_differential_spans
12
+ from ..parsers.braket import collect_braket_delimiter_spans
13
+ from ..parsers.dimensionless import find_dimensionless_spans, collect_dimensionless_spans
14
+ from ..parsers.delimiters import collect_delimiter_spans
15
+ from ..parsers.taxonomy import collect_taxonomy_spans
16
+ from ..parsers.variable_hash import collect_variable_spans
17
+ from ..utils.latex_helpers import contains_color_wrapper
18
+ from ..utils.spans import ColorSpan, apply_color_spans
19
+
20
+
21
+ MATH_LINE_RE = re.compile(
22
+ r"^(?P<prefix>\s*\#+\s*)?\$\$(?P<body>.*)\$\$(?P<suffix>\s*)$"
23
+ )
24
+ FUNCTION_COLOR_NAMES = ("main", "derivative", "chain")
25
+
26
+
27
+ def collect_function_spans(body: str, palette: dict[str, str] | None = None) -> list[ColorSpan]:
28
+ """Color nested call names and recognize literal constants."""
29
+ pal = palette or COLORS
30
+ semantic, _ = find_semantic_spans(body)
31
+ spans: list[ColorSpan] = []
32
+ for item in semantic:
33
+ if item.kind == "function":
34
+ color_name = FUNCTION_COLOR_NAMES[min(item.depth, 2)]
35
+ elif item.kind == "constant":
36
+ color_name = "orange"
37
+ else:
38
+ continue
39
+ spans.append(
40
+ ColorSpan(item.start, item.end, pal[color_name], priority=20)
41
+ )
42
+ return spans
43
+
44
+
45
+ def color_latex_body(
46
+ body: str,
47
+ palette: dict[str, str] | None = None,
48
+ options: ColorMathOptions | None = None,
49
+ ) -> str:
50
+ """Insert scoped colors while preserving every original source character."""
51
+ if contains_color_wrapper(body):
52
+ return body
53
+
54
+ pal = palette or COLORS
55
+ opts = options or ColorMathOptions()
56
+
57
+ unit_spans = find_unit_spans(body)
58
+ diff_spans = find_differential_spans(body)
59
+ dim_spans = find_dimensionless_spans(body)
60
+
61
+ spans: list[ColorSpan] = [
62
+ *collect_function_spans(body, pal),
63
+ *collect_scanner_spans(body),
64
+ ]
65
+
66
+ if opts.color_units:
67
+ spans.extend(collect_unit_spans(body, pal, unit_spans))
68
+
69
+ if opts.color_differentials:
70
+ spans.extend(collect_differential_spans(body, pal, diff_spans))
71
+
72
+ if opts.color_dimensionless:
73
+ spans.extend(collect_dimensionless_spans(body, pal, dim_spans))
74
+
75
+ if opts.color_braket:
76
+ spans.extend(collect_braket_delimiter_spans(body, pal))
77
+
78
+ if opts.rainbow_delimiters:
79
+ spans.extend(collect_delimiter_spans(body))
80
+
81
+ if opts.enable_taxonomy:
82
+ spans.extend(collect_taxonomy_spans(body, pal, unit_spans, diff_spans, dim_spans))
83
+
84
+ if opts.variable_data_flow:
85
+ spans.extend(collect_variable_spans(body, None, unit_spans, diff_spans, dim_spans))
86
+
87
+ return apply_color_spans(body, spans)
88
+
89
+
90
+ def color_generic_math_line(
91
+ line: str,
92
+ palette: dict[str, str] | None = None,
93
+ options: ColorMathOptions | None = None,
94
+ ) -> str:
95
+ """Convert a single-line ``$$...$$`` math block."""
96
+ match = MATH_LINE_RE.match(line)
97
+ if match is None:
98
+ return line
99
+ prefix = match.group("prefix") or ""
100
+ suffix = match.group("suffix")
101
+ return f"{prefix}$${color_latex_body(match.group('body'), palette, options)}$${suffix}"
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .generic import color_latex_body
6
+ from .semantic import parse_math_block
7
+
8
+
9
+ INTEGRAL_RE = re.compile(r"\\(?:i{1,3}nt|oint)(?![A-Za-z])")
10
+
11
+
12
+ def convert_integral_line(line: str) -> str | None:
13
+ block = parse_math_block(line)
14
+ if block is None or INTEGRAL_RE.search(block.body) is None:
15
+ return None
16
+ return block.render(color_latex_body(block.body))
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .generic import color_latex_body
6
+ from .semantic import parse_math_block
7
+
8
+
9
+ LIMIT_RE = re.compile(r"\\(?:lim|liminf|limsup|inf|sup|max|min)(?![A-Za-z])")
10
+
11
+
12
+ def convert_limit_line(line: str) -> str | None:
13
+ block = parse_math_block(line)
14
+ if block is None or LIMIT_RE.search(block.body) is None:
15
+ return None
16
+ return block.render(color_latex_body(block.body))
@@ -0,0 +1,143 @@
1
+ """Lossless semantic coloring for matrix and indexed tensor expressions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ..config import COLORS
8
+ from ..parsers.latex_spans import OperandSpan, find_operand_spans, read_operand
9
+ from ..parsers.scanner import collect_operator_spans, collect_structured_spans
10
+ from ..utils.latex_helpers import contains_color_wrapper
11
+ from ..utils.spans import ColorSpan, apply_color_spans
12
+ from .semantic import first_equality, parse_math_block, relation_spans
13
+
14
+
15
+ MATRIX_COMMAND_RE = re.compile(
16
+ r"\\(?:mathbf|mathcal|nabla|det|tr|Tr|trace|Vert|lVert)"
17
+ r"(?![A-Za-z])|\\\|(?![A-Za-z])|"
18
+ r"\\operatorname\s*\{\s*tr\s*\}"
19
+ )
20
+ MATRIX_ENV_RE = re.compile(
21
+ r"\\begin\s*\{\s*"
22
+ r"(?:Bmatrix|Vmatrix|array|bmatrix|matrix|pmatrix|smallmatrix|vmatrix)"
23
+ r"\s*\}"
24
+ )
25
+ NUMBER_RE = re.compile(r"[+-]?\d+(?:\.\d+)?")
26
+
27
+
28
+ def _structural_source(body: str) -> str:
29
+ """Mask comments and opaque macros before semantic classification."""
30
+ visible = list(body)
31
+ index = 0
32
+ while index < len(body):
33
+ if body[index] == "%":
34
+ end = index + 1
35
+ while end < len(body) and body[end] not in "\r\n":
36
+ visible[end] = " "
37
+ end += 1
38
+ visible[index] = " "
39
+ index = end
40
+ continue
41
+ operand = read_operand(body, index)
42
+ if operand is not None and operand.kind == "opaque":
43
+ for position in range(operand.start, operand.end):
44
+ if visible[position] not in "\r\n":
45
+ visible[position] = " "
46
+ index = operand.end
47
+ continue
48
+ index += 1
49
+ return "".join(visible)
50
+
51
+
52
+ def _is_matrix_expression(body: str) -> bool:
53
+ structural = _structural_source(body)
54
+ operands = find_operand_spans(structural)
55
+ indexed = [
56
+ operand
57
+ for operand in operands
58
+ if operand.kind == "symbol" and "_" in operand.text(structural)
59
+ ]
60
+ return (
61
+ MATRIX_COMMAND_RE.search(structural) is not None
62
+ or MATRIX_ENV_RE.search(structural) is not None
63
+ or any(operand.kind == "matrix" for operand in operands)
64
+ or len(indexed) >= 2
65
+ )
66
+
67
+
68
+ def _operand_color_spans(
69
+ body: str,
70
+ operands: tuple[OperandSpan, ...],
71
+ names: tuple[str, ...],
72
+ ) -> list[ColorSpan]:
73
+ spans: list[ColorSpan] = []
74
+ semantic_index = 0
75
+ for operand in operands:
76
+ value = re.sub(r"\s+", "", operand.text(body))
77
+ if NUMBER_RE.fullmatch(value):
78
+ name = "orange"
79
+ else:
80
+ name = names[min(semantic_index, len(names) - 1)]
81
+ semantic_index += 1
82
+ spans.append(
83
+ ColorSpan(
84
+ operand.start,
85
+ operand.end,
86
+ COLORS[name],
87
+ priority=20,
88
+ )
89
+ )
90
+ return spans
91
+
92
+
93
+ def _operator_spans(body: str) -> list[ColorSpan]:
94
+ return collect_operator_spans(body)
95
+
96
+
97
+ def convert_matrix_block(source: str) -> str | None:
98
+ """Color complete matrix/tensor operands without reconstructing LaTeX."""
99
+ block = parse_math_block(source)
100
+ if block is None or not _is_matrix_expression(block.body):
101
+ return None
102
+ if contains_color_wrapper(block.body):
103
+ return source
104
+
105
+ equality = first_equality(block.body)
106
+ if equality is None:
107
+ return None
108
+
109
+ lhs = find_operand_spans(block.body, 0, equality[0])
110
+ rhs = find_operand_spans(block.body, equality[1])
111
+ if not lhs or not rhs:
112
+ return None
113
+
114
+ lhs_first = re.sub(r"\s+", "", lhs[0].text(block.body))
115
+ if len(lhs) == 1:
116
+ lhs_colors = (
117
+ ("upper",)
118
+ if lhs_first.startswith((r"\det", r"\operatorname{tr}"))
119
+ else ("main",)
120
+ )
121
+ elif lhs_first.startswith((r"\frac{\partial}", r"\nabla")):
122
+ lhs_colors = ("upper", "main")
123
+ else:
124
+ lhs_colors = ("upper", "chain", "orange")
125
+
126
+ lhs_text = re.sub(r"\s+", "", block.body[:equality[0]])
127
+ if lhs_first.startswith(r"\frac{\partial}"):
128
+ rhs_colors = ("chain", "main")
129
+ elif len(lhs) > 1 and len(rhs) == 1:
130
+ rhs_colors = ("main",)
131
+ elif lhs_text.startswith((r"\det", r"\operatorname{tr}")):
132
+ rhs_colors = ("main", "chain")
133
+ elif r"\otimes" in block.body:
134
+ rhs_colors = ("upper", "chain", "orange")
135
+ else:
136
+ rhs_colors = ("upper", "chain")
137
+
138
+ spans = relation_spans(block.body)
139
+ spans.extend(_operand_color_spans(block.body, lhs, lhs_colors))
140
+ spans.extend(_operand_color_spans(block.body, rhs, rhs_colors))
141
+ spans.extend(_operator_spans(block.body))
142
+ spans.extend(collect_structured_spans(block.body))
143
+ return block.render(apply_color_spans(block.body, spans))