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