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,124 @@
|
|
|
1
|
+
"""Mathematical taxonomy and semantic role coloring."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..config import COLORS, MATH_CONSTANTS, MATH_PARAMETERS, MATH_FUNCTIONS
|
|
6
|
+
from ..utils.spans import ColorSpan
|
|
7
|
+
from ..utils.latex_helpers import read_color_command, read_braced
|
|
8
|
+
from .units import find_unit_spans, UnitSpan
|
|
9
|
+
from .differentials import find_differential_spans, DifferentialSpan
|
|
10
|
+
from .dimensionless import find_dimensionless_spans, DimensionlessSpan
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
OPAQUE_MACROS = frozenset({
|
|
14
|
+
"text", "mathrm", "mathbf", "mathit", "mathsf", "mathtt", "mathcal",
|
|
15
|
+
"mathbb", "boldsymbol", "operatorname", "textbf", "textit", "textrm", "texttt"
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _skip_comment(text: str, start: int) -> int:
|
|
20
|
+
idx = start + 1
|
|
21
|
+
while idx < len(text) and text[idx] not in ("\r", "\n"):
|
|
22
|
+
idx += 1
|
|
23
|
+
if idx < len(text) and text[idx] == "\r" and idx + 1 < len(text) and text[idx + 1] == "\n":
|
|
24
|
+
return idx + 2
|
|
25
|
+
return min(idx + 1, len(text))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_taxonomy_spans(
|
|
29
|
+
body: str,
|
|
30
|
+
palette: dict[str, str] | None = None,
|
|
31
|
+
unit_spans: list[UnitSpan] | None = None,
|
|
32
|
+
diff_spans: list[DifferentialSpan] | None = None,
|
|
33
|
+
dim_spans: list[DimensionlessSpan] | None = None,
|
|
34
|
+
) -> list[ColorSpan]:
|
|
35
|
+
"""Collects semantic spans for constants, parameters, and bound indices."""
|
|
36
|
+
pal = palette or COLORS
|
|
37
|
+
units = unit_spans if unit_spans is not None else find_unit_spans(body)
|
|
38
|
+
diffs = diff_spans if diff_spans is not None else find_differential_spans(body)
|
|
39
|
+
dims = dim_spans if dim_spans is not None else find_dimensionless_spans(body)
|
|
40
|
+
spans: list[ColorSpan] = []
|
|
41
|
+
|
|
42
|
+
# 1. Bound iteration indices in \sum, \prod, \lim
|
|
43
|
+
index_re = re.compile(
|
|
44
|
+
r"(\\(?:sum|prod|coprod|bigcup|bigcap|lim|inf|sup))_\{?\s*([A-Za-z])\s*(?:=|\\to)"
|
|
45
|
+
)
|
|
46
|
+
for m in index_re.finditer(body):
|
|
47
|
+
var_name = m.group(2)
|
|
48
|
+
var_start = m.start() + m.group(0).rfind(var_name)
|
|
49
|
+
spans.append(ColorSpan(var_start, var_start + len(var_name), pal.get("chain", "#9ece6a"), priority=23))
|
|
50
|
+
|
|
51
|
+
# 2. Token-level scan for constants, functions, parameters, and dot derivatives
|
|
52
|
+
idx = 0
|
|
53
|
+
while idx < len(body):
|
|
54
|
+
if body[idx] == "%":
|
|
55
|
+
idx = _skip_comment(body, idx)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
existing = read_color_command(body, idx)
|
|
59
|
+
if existing is not None:
|
|
60
|
+
idx = existing[1]
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
if any(u.start <= idx < u.end for u in units):
|
|
64
|
+
idx = next(u.end for u in units if u.start <= idx < u.end)
|
|
65
|
+
continue
|
|
66
|
+
if any(d.start <= idx < d.end for d in diffs):
|
|
67
|
+
idx = next(d.end for d in diffs if d.start <= idx < d.end)
|
|
68
|
+
continue
|
|
69
|
+
if any(d.start <= idx < d.end for d in dims):
|
|
70
|
+
idx = next(d.end for d in dims if d.start <= idx < d.end)
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
if body[idx] == "\\":
|
|
74
|
+
m = re.match(r"^(\\[A-Za-z]+|\\.)", body[idx:])
|
|
75
|
+
if m:
|
|
76
|
+
cmd_name = m.group(1)
|
|
77
|
+
cmd_end = idx + len(cmd_name)
|
|
78
|
+
|
|
79
|
+
macro_key = cmd_name[1:]
|
|
80
|
+
if macro_key in OPAQUE_MACROS:
|
|
81
|
+
braced = read_braced(body, cmd_end)
|
|
82
|
+
if braced is not None:
|
|
83
|
+
idx = braced[1]
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if cmd_name in ("\\dot", "\\ddot", "\\dddot"):
|
|
87
|
+
target_start = cmd_end
|
|
88
|
+
while target_start < len(body) and body[target_start].isspace():
|
|
89
|
+
target_start += 1
|
|
90
|
+
if target_start < len(body):
|
|
91
|
+
target_end = target_start + 1
|
|
92
|
+
if body[target_start] == "{":
|
|
93
|
+
braced = read_braced(body, target_start)
|
|
94
|
+
if braced is not None:
|
|
95
|
+
target_end = braced[1]
|
|
96
|
+
else:
|
|
97
|
+
let_m = re.match(r"^[a-zA-Z]('*)*", body[target_start:])
|
|
98
|
+
if let_m:
|
|
99
|
+
target_end = target_start + len(let_m.group(0))
|
|
100
|
+
spans.append(ColorSpan(idx, target_end, pal.get("derivative", "#bb9af7"), priority=22))
|
|
101
|
+
idx = target_end
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if cmd_name in MATH_CONSTANTS:
|
|
105
|
+
spans.append(ColorSpan(idx, cmd_end, pal.get("orange", "#e0af68"), priority=22))
|
|
106
|
+
idx = cmd_end
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if cmd_name in MATH_FUNCTIONS:
|
|
110
|
+
spans.append(ColorSpan(idx, cmd_end, pal.get("main", "#7aa2f7"), priority=22))
|
|
111
|
+
idx = cmd_end
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
if cmd_name in MATH_PARAMETERS:
|
|
115
|
+
spans.append(ColorSpan(idx, cmd_end, pal.get("parameter", pal.get("derivative", "#bb9af7")), priority=20))
|
|
116
|
+
idx = cmd_end
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
idx = cmd_end
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
idx += 1
|
|
123
|
+
|
|
124
|
+
return sorted(spans, key=lambda s: s.start)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Physical units and metric prefixes disambiguation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..config import COLORS
|
|
7
|
+
from ..utils.spans import ColorSpan
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class UnitSpan:
|
|
12
|
+
start: int
|
|
13
|
+
end: int
|
|
14
|
+
text: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SI_UNITS = (
|
|
18
|
+
r"m|s|g|Hz|N|Pa|J|W|C|V|F|T|H|mol|L|l|K|bar|atm|torr|eV|cal|rad|deg|\\Omega|dB|bps|B|Ω"
|
|
19
|
+
)
|
|
20
|
+
PREFIXES = r"k|M|G|T|c|m|n|p|f|d|da|\\mu|µ"
|
|
21
|
+
SAFE_MICRO_UNITS = r"m|s|g|mol|Hz|Pa|bar|rad|\\Omega|L|l"
|
|
22
|
+
AMBIGUOUS_MICRO_UNITS = r"N|A|V|F|H|W|J|C"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_unit_spans(body: str) -> list[UnitSpan]:
|
|
26
|
+
"""Scans LaTeX math body to identify physical unit spans."""
|
|
27
|
+
spans: list[UnitSpan] = []
|
|
28
|
+
|
|
29
|
+
def add_span(start: int, end: int, text: str) -> None:
|
|
30
|
+
if start >= end:
|
|
31
|
+
return
|
|
32
|
+
if not any(start < s.end and end > s.start for s in spans):
|
|
33
|
+
spans.append(UnitSpan(start, end, text))
|
|
34
|
+
|
|
35
|
+
# 1a. \mu\text{...} or \mu\mathrm{...}
|
|
36
|
+
micro_text_re = re.compile(
|
|
37
|
+
r"\\mu\s*(?:\\(?:text|mathrm)\s*\{\s*([A-Za-z°℃%Ωμ/^0-9\s.\-]+?)\s*\})(?:\^\{?-?\d+\}?)?"
|
|
38
|
+
)
|
|
39
|
+
for m in micro_text_re.finditer(body):
|
|
40
|
+
add_span(m.start(), m.end(), m.group(0))
|
|
41
|
+
|
|
42
|
+
# 1b. Bare \mu with safe micro units: \mu m, \mu s, etc.
|
|
43
|
+
safe_micro_re = re.compile(
|
|
44
|
+
r"\\mu\s*(" + SAFE_MICRO_UNITS + r")(?![A-Za-z0-9_])(?:\^\{?-?\d+\}?)?"
|
|
45
|
+
)
|
|
46
|
+
for m in safe_micro_re.finditer(body):
|
|
47
|
+
add_span(m.start(), m.end(), m.group(0))
|
|
48
|
+
|
|
49
|
+
# 2. Degree units: ^\circ C, ^\circ\text{C}, ^\circ F
|
|
50
|
+
deg_re = re.compile(r"\^\s*\\circ\s*(?:\\(?:text|mathrm)\s*\{[A-Za-z]+\}|[A-Za-z]+)")
|
|
51
|
+
for m in deg_re.finditer(body):
|
|
52
|
+
add_span(m.start(), m.end(), m.group(0))
|
|
53
|
+
|
|
54
|
+
# 3. Units preceded by a number (Magnitude + Unit)
|
|
55
|
+
num_unit_pattern = (
|
|
56
|
+
r"(?:^|[^A-Za-z0-9_])(?:\d+(?:\.\d+)?|\.\d+)(?:\s*(?:\\times|\\cdot|·|\*)\s*10\^\{?[+-]?\d+\}?|\s*[eE][+-]?\d+)?(?:\s*|\,|\:|\;|\s*\\quad|\s*\\qquad|~)*"
|
|
57
|
+
r"("
|
|
58
|
+
r"\\(?:text|mathrm)\s*\{[^}]+\}(?:\^\{?-?\d+\}?)?"
|
|
59
|
+
r"|"
|
|
60
|
+
r"\\mu\s*(?:" + SAFE_MICRO_UNITS + r"|" + AMBIGUOUS_MICRO_UNITS + r")(?![A-Za-z0-9_])(?:\^\{?-?\d+\}?)?"
|
|
61
|
+
r"|"
|
|
62
|
+
r"(?:(?:" + PREFIXES + r")?(?:" + SI_UNITS + r"))(?:\/(?:(?:" + PREFIXES + r")?(?:" + SI_UNITS + r")))*(?:\^\{?-?\d+\}?)?(?![A-Za-z0-9_({])"
|
|
63
|
+
r")"
|
|
64
|
+
)
|
|
65
|
+
for m in re.finditer(num_unit_pattern, body):
|
|
66
|
+
full_match = m.group(0)
|
|
67
|
+
unit_part = m.group(1)
|
|
68
|
+
unit_offset = full_match.rfind(unit_part)
|
|
69
|
+
unit_start = m.start() + unit_offset
|
|
70
|
+
unit_end = unit_start + len(unit_part)
|
|
71
|
+
add_span(unit_start, unit_end, unit_part)
|
|
72
|
+
|
|
73
|
+
# 4. Standalone Text / mathrm units with \text{...} or \mathrm{...}
|
|
74
|
+
text_unit_re = re.compile(
|
|
75
|
+
r"\\(?:text|mathrm)\s*\{\s*([A-Za-z°℃%Ωμ/^0-9\s.\-]+?)\s*\}(?:\^\{?-?\d+\}?)?"
|
|
76
|
+
)
|
|
77
|
+
is_unit_re = re.compile(
|
|
78
|
+
r"^(?:(?:" + PREFIXES + r")?(?:" + SI_UNITS + r"))(?:\/(?:(?:" + PREFIXES + r")?(?:" + SI_UNITS + r")))*(?:\^\{?-?\d+\}?)?$",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
for m in text_unit_re.finditer(body):
|
|
82
|
+
inner = m.group(1).strip()
|
|
83
|
+
if is_unit_re.match(inner):
|
|
84
|
+
add_span(m.start(), m.end(), m.group(0))
|
|
85
|
+
|
|
86
|
+
return sorted(spans, key=lambda s: s.start)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def collect_unit_spans(
|
|
90
|
+
body: str,
|
|
91
|
+
palette: dict[str, str] | None = None,
|
|
92
|
+
unit_spans: list[UnitSpan] | None = None,
|
|
93
|
+
) -> list[ColorSpan]:
|
|
94
|
+
"""Returns ColorSpans for units with unit color."""
|
|
95
|
+
pal = palette or COLORS
|
|
96
|
+
units = unit_spans if unit_spans is not None else find_unit_spans(body)
|
|
97
|
+
unit_color = pal.get("unit", "#73daca")
|
|
98
|
+
return [ColorSpan(u.start, u.end, unit_color, priority=25) for u in units]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Variable data-flow hashing parser."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..config import VARIABLE_HASH_PALETTE, hash_string_to_color, MATH_ACCENTS
|
|
6
|
+
from ..utils.spans import ColorSpan
|
|
7
|
+
from ..utils.latex_helpers import read_color_command, read_braced
|
|
8
|
+
from .units import find_unit_spans, UnitSpan
|
|
9
|
+
from .differentials import find_differential_spans, DifferentialSpan
|
|
10
|
+
from .dimensionless import find_dimensionless_spans, DimensionlessSpan
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
OPAQUE_MACROS = frozenset({
|
|
14
|
+
"text", "mathrm", "mathbf", "mathit", "mathsf", "mathtt", "mathcal",
|
|
15
|
+
"mathbb", "boldsymbol", "operatorname", "textbf", "textit", "textrm", "texttt"
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _skip_comment(text: str, start: int) -> int:
|
|
20
|
+
idx = start + 1
|
|
21
|
+
while idx < len(text) and text[idx] not in ("\r", "\n"):
|
|
22
|
+
idx += 1
|
|
23
|
+
if idx < len(text) and text[idx] == "\r" and idx + 1 < len(text) and text[idx + 1] == "\n":
|
|
24
|
+
return idx + 2
|
|
25
|
+
return min(idx + 1, len(text))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def collect_variable_spans(
|
|
29
|
+
body: str,
|
|
30
|
+
palette: list[str] | None = None,
|
|
31
|
+
unit_spans: list[UnitSpan] | None = None,
|
|
32
|
+
diff_spans: list[DifferentialSpan] | None = None,
|
|
33
|
+
dim_spans: list[DimensionlessSpan] | None = None,
|
|
34
|
+
) -> list[ColorSpan]:
|
|
35
|
+
"""Assigns deterministic colors to distinct identifiers across an expression."""
|
|
36
|
+
pal = palette or VARIABLE_HASH_PALETTE
|
|
37
|
+
units = unit_spans if unit_spans is not None else find_unit_spans(body)
|
|
38
|
+
diffs = diff_spans if diff_spans is not None else find_differential_spans(body)
|
|
39
|
+
dims = dim_spans if dim_spans is not None else find_dimensionless_spans(body)
|
|
40
|
+
spans: list[ColorSpan] = []
|
|
41
|
+
|
|
42
|
+
idx = 0
|
|
43
|
+
while idx < len(body):
|
|
44
|
+
if body[idx] == "%":
|
|
45
|
+
idx = _skip_comment(body, idx)
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
existing = read_color_command(body, idx)
|
|
49
|
+
if existing is not None:
|
|
50
|
+
idx = existing[1]
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if any(u.start <= idx < u.end for u in units):
|
|
54
|
+
idx = next(u.end for u in units if u.start <= idx < u.end)
|
|
55
|
+
continue
|
|
56
|
+
if any(d.start <= idx < d.end for d in diffs):
|
|
57
|
+
idx = next(d.end for d in diffs if d.start <= idx < d.end)
|
|
58
|
+
continue
|
|
59
|
+
if any(d.start <= idx < d.end for d in dims):
|
|
60
|
+
idx = next(d.end for d in dims if d.start <= idx < d.end)
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
# Backslash commands
|
|
64
|
+
if body[idx] == "\\":
|
|
65
|
+
m = re.match(r"^(\\[A-Za-z]+|\\.)", body[idx:])
|
|
66
|
+
if m:
|
|
67
|
+
cmd_name = m.group(1)
|
|
68
|
+
cmd_end = idx + len(cmd_name)
|
|
69
|
+
|
|
70
|
+
# Accents like \dot, \ddot, \vec, \hat, \bar, \tilde
|
|
71
|
+
if cmd_name in MATH_ACCENTS:
|
|
72
|
+
target_start = cmd_end
|
|
73
|
+
while target_start < len(body) and body[target_start].isspace():
|
|
74
|
+
target_start += 1
|
|
75
|
+
if target_start < len(body):
|
|
76
|
+
if body[target_start] == "{":
|
|
77
|
+
braced = read_braced(body, target_start)
|
|
78
|
+
if braced is not None:
|
|
79
|
+
inner = body[braced[0]:braced[1]]
|
|
80
|
+
base_m = re.search(r"[a-zA-Z]", inner)
|
|
81
|
+
base_letter = base_m.group(0) if base_m else "x"
|
|
82
|
+
color = hash_string_to_color(base_letter, pal)
|
|
83
|
+
spans.append(ColorSpan(idx, braced[1], color, priority=15))
|
|
84
|
+
idx = braced[1]
|
|
85
|
+
continue
|
|
86
|
+
else:
|
|
87
|
+
let_m = re.match(r"^[a-zA-Z]('*)*", body[target_start:])
|
|
88
|
+
if let_m:
|
|
89
|
+
full_var = let_m.group(0)
|
|
90
|
+
base_letter = full_var.replace("'", "")
|
|
91
|
+
color = hash_string_to_color(base_letter, pal)
|
|
92
|
+
spans.append(ColorSpan(idx, target_start + len(full_var), color, priority=15))
|
|
93
|
+
idx = target_start + len(full_var)
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
macro_key = cmd_name[1:]
|
|
97
|
+
if macro_key in OPAQUE_MACROS:
|
|
98
|
+
braced = read_braced(body, cmd_end)
|
|
99
|
+
if braced is not None:
|
|
100
|
+
idx = braced[1]
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
idx = cmd_end
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Single letter variables (optionally with prime): x, y, z, t, x', y''
|
|
107
|
+
var_m = re.match(r"^[a-zA-Z]('*)*", body[idx:])
|
|
108
|
+
if var_m:
|
|
109
|
+
full_var = var_m.group(0)
|
|
110
|
+
base_letter = full_var.replace("'", "")
|
|
111
|
+
var_end = idx + len(full_var)
|
|
112
|
+
|
|
113
|
+
# Check if followed by ( or \left( -> function call like f(x)
|
|
114
|
+
after_var = body[var_end:].lstrip()
|
|
115
|
+
is_function = after_var.startswith("(") or after_var.startswith(r"\left(")
|
|
116
|
+
|
|
117
|
+
if not is_function:
|
|
118
|
+
color = hash_string_to_color(base_letter, pal)
|
|
119
|
+
spans.append(ColorSpan(idx, var_end, color, priority=15))
|
|
120
|
+
|
|
121
|
+
idx = var_end
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
idx += 1
|
|
125
|
+
|
|
126
|
+
return sorted(spans, key=lambda s: s.start)
|
color_math/self_test.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from .adapters import transform_document
|
|
7
|
+
from .converters.block import convert_text
|
|
8
|
+
from .converters.matrix import convert_matrix_block
|
|
9
|
+
from .parsers.math_parser import (
|
|
10
|
+
format_math_structure,
|
|
11
|
+
parse_math_blocks,
|
|
12
|
+
parse_math_body,
|
|
13
|
+
)
|
|
14
|
+
from .undo import uncolor_fragment, uncolor_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
Check = tuple[str, object, object]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _run_checks(checks: Iterable[Check]) -> int:
|
|
21
|
+
failed = False
|
|
22
|
+
print("Running installed self-tests...\n")
|
|
23
|
+
for name, actual, expected in checks:
|
|
24
|
+
passed = actual == expected
|
|
25
|
+
print(f"{'OK' if passed else 'FAIL'} {name}")
|
|
26
|
+
if not passed:
|
|
27
|
+
print(f"Expected: {expected!r}")
|
|
28
|
+
print(f"Got: {actual!r}\n")
|
|
29
|
+
failed = True
|
|
30
|
+
print("\nAll tests passed." if not failed else "\nSome tests failed.")
|
|
31
|
+
return int(failed)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_self_test(update_generated: bool = False) -> int:
|
|
35
|
+
"""Run installed smoke checks without reading or writing repository files."""
|
|
36
|
+
if update_generated:
|
|
37
|
+
print(
|
|
38
|
+
"--update-generated is repository-only; run "
|
|
39
|
+
"`python -m tests.self_test --update-generated` from a checkout."
|
|
40
|
+
)
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
nested = r"$$y(x(g(3)))$$"
|
|
44
|
+
nested_colored = (
|
|
45
|
+
r"$$\textcolor{#7aa2f7}{y}("
|
|
46
|
+
r"\textcolor{#bb9af7}{x}("
|
|
47
|
+
r"\textcolor{#9ece6a}{g}("
|
|
48
|
+
r"\textcolor{#e0af68}{3})))$$"
|
|
49
|
+
)
|
|
50
|
+
prime = r"$$f'(g(3))$$"
|
|
51
|
+
prime_colored = (
|
|
52
|
+
r"$$\textcolor{#7aa2f7}{f'}("
|
|
53
|
+
r"\textcolor{#bb9af7}{g}("
|
|
54
|
+
r"\textcolor{#e0af68}{3}))$$"
|
|
55
|
+
)
|
|
56
|
+
fenced = "```latex\n$$\\sum_{i=1}^n$$\n```"
|
|
57
|
+
nested_fence = (
|
|
58
|
+
"- item\n"
|
|
59
|
+
" > [!note]\n"
|
|
60
|
+
" > ~~~~latex\n"
|
|
61
|
+
" > $$y(x(g(3)))$$\n"
|
|
62
|
+
" > ~~~~\n"
|
|
63
|
+
)
|
|
64
|
+
commented = "$$f(x)% }\n+g(x)% {\n+h(x)$$"
|
|
65
|
+
commented_colored = (
|
|
66
|
+
"$$\\textcolor{#7aa2f7}{f}(x)% }\n"
|
|
67
|
+
"+\\textcolor{#7aa2f7}{g}(x)% {\n"
|
|
68
|
+
"+\\textcolor{#7aa2f7}{h}(x)$$"
|
|
69
|
+
)
|
|
70
|
+
verb = r"$$\verb|y(x(g(3)))|$$"
|
|
71
|
+
verb_star = r"$$\verb*|y(x(g(3)))|$$"
|
|
72
|
+
scalar_sum = r"$$\sum_{i=1}^{n} x_i$$"
|
|
73
|
+
nested_array = (
|
|
74
|
+
r"$$\mathbf{M}=\left(\begin{array}{cc}a&b\\c&d"
|
|
75
|
+
r"\end{array}\right)$$"
|
|
76
|
+
)
|
|
77
|
+
nested_array_colored = (
|
|
78
|
+
r"$$\textcolor{#7aa2f7}{\mathbf{M}}\textcolor{white}{=}"
|
|
79
|
+
r"\textcolor{#bb9af7}{\left(\begin{array}{cc}"
|
|
80
|
+
r"\textcolor{#7aa2f7}{a}&\textcolor{#bb9af7}{b}\\"
|
|
81
|
+
r"\textcolor{#9ece6a}{c}&\textcolor{#7aa2f7}{d}"
|
|
82
|
+
r"\end{array}\right)}$$"
|
|
83
|
+
)
|
|
84
|
+
grouped_command = r"$$\operatorname*{arg\,max}_{x} f(x)$$"
|
|
85
|
+
grouped_command_colored = (
|
|
86
|
+
r"$$\textcolor{#7aa2f7}{\operatorname*{arg"
|
|
87
|
+
r"\textcolor{white}{\,}max}}_"
|
|
88
|
+
r"{\textcolor{#9ece6a}{x}} \textcolor{#7aa2f7}{f}(x)$$"
|
|
89
|
+
)
|
|
90
|
+
styled = r"$$f(x)+\displaystyle g(x)$$"
|
|
91
|
+
styled_colored = (
|
|
92
|
+
r"$$\textcolor{#7aa2f7}{f}(x)+\displaystyle "
|
|
93
|
+
r"\textcolor{#7aa2f7}{g}(x)$$"
|
|
94
|
+
)
|
|
95
|
+
inline_code = "Prose `$$x=1$$` remains plain."
|
|
96
|
+
prose = "# Original Equations\n\nOrdinary prose stays unchanged.\n"
|
|
97
|
+
anki = r"Front\t\(f(x)\)"
|
|
98
|
+
tex = "\\documentclass{article}\n\\begin{document}\n$f(x)$\n\\end{document}\n"
|
|
99
|
+
notebook = json.dumps(
|
|
100
|
+
{
|
|
101
|
+
"cells": [
|
|
102
|
+
{"cell_type": "markdown", "metadata": {}, "source": "$$f(x)$$"},
|
|
103
|
+
{"cell_type": "code", "metadata": {}, "source": "$$f(x)$$"},
|
|
104
|
+
],
|
|
105
|
+
"metadata": {},
|
|
106
|
+
"nbformat": 4,
|
|
107
|
+
"nbformat_minor": 5,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
notebook_colored = transform_document(notebook, "jupyter")
|
|
111
|
+
|
|
112
|
+
return _run_checks(
|
|
113
|
+
(
|
|
114
|
+
("scoped nested colors", convert_text(nested), nested_colored),
|
|
115
|
+
("exact undo round trip", uncolor_text(nested_colored), nested),
|
|
116
|
+
(
|
|
117
|
+
"conversion idempotence",
|
|
118
|
+
convert_text(nested_colored),
|
|
119
|
+
nested_colored,
|
|
120
|
+
),
|
|
121
|
+
("prime notation", convert_text(prime), prime_colored),
|
|
122
|
+
("prime round trip", uncolor_text(prime_colored), prime),
|
|
123
|
+
(
|
|
124
|
+
"no invented primes",
|
|
125
|
+
"y'" in convert_text(
|
|
126
|
+
r"$$\frac{d}{dx}f(y)^n=nf(y)^{n-1}\cdot f'(y)y$$"
|
|
127
|
+
),
|
|
128
|
+
False,
|
|
129
|
+
),
|
|
130
|
+
("prose preservation", convert_text(prose), prose),
|
|
131
|
+
("fenced code preservation", convert_text(fenced), fenced),
|
|
132
|
+
(
|
|
133
|
+
"comment braces round trip",
|
|
134
|
+
uncolor_text(convert_text(commented)),
|
|
135
|
+
commented,
|
|
136
|
+
),
|
|
137
|
+
(
|
|
138
|
+
"comment braces idempotence",
|
|
139
|
+
convert_text(commented_colored),
|
|
140
|
+
commented_colored,
|
|
141
|
+
),
|
|
142
|
+
(
|
|
143
|
+
"nested tilde fence preservation",
|
|
144
|
+
convert_text(nested_fence),
|
|
145
|
+
nested_fence,
|
|
146
|
+
),
|
|
147
|
+
(
|
|
148
|
+
"nested tilde fence is not parsed",
|
|
149
|
+
parse_math_blocks(nested_fence),
|
|
150
|
+
[],
|
|
151
|
+
),
|
|
152
|
+
("verb payload preservation", convert_text(verb), verb),
|
|
153
|
+
("verb-star payload preservation", convert_text(verb_star), verb_star),
|
|
154
|
+
(
|
|
155
|
+
"verb payload is not inspected",
|
|
156
|
+
format_math_structure(parse_math_body(verb[2:-2])),
|
|
157
|
+
"No nested function calls found.",
|
|
158
|
+
),
|
|
159
|
+
(
|
|
160
|
+
"scalar sum is not a matrix",
|
|
161
|
+
convert_matrix_block(scalar_sum),
|
|
162
|
+
None,
|
|
163
|
+
),
|
|
164
|
+
(
|
|
165
|
+
"nested array matrix recognition",
|
|
166
|
+
convert_matrix_block(nested_array),
|
|
167
|
+
nested_array_colored,
|
|
168
|
+
),
|
|
169
|
+
(
|
|
170
|
+
"operatorname-star stays whole",
|
|
171
|
+
convert_text(grouped_command),
|
|
172
|
+
grouped_command_colored,
|
|
173
|
+
),
|
|
174
|
+
(
|
|
175
|
+
"style declaration stays unwrapped",
|
|
176
|
+
convert_text(styled),
|
|
177
|
+
styled_colored,
|
|
178
|
+
),
|
|
179
|
+
("inline code preservation", convert_text(inline_code), inline_code),
|
|
180
|
+
(
|
|
181
|
+
"text macro preservation",
|
|
182
|
+
convert_text(r"$$f(\text{use x=y literally})$$"),
|
|
183
|
+
r"$$\textcolor{#7aa2f7}{f}(\text{use x=y literally})$$",
|
|
184
|
+
),
|
|
185
|
+
(
|
|
186
|
+
"malformed script preservation",
|
|
187
|
+
convert_text(r"$$x^{abc$$"),
|
|
188
|
+
r"$$x^{abc$$",
|
|
189
|
+
),
|
|
190
|
+
(
|
|
191
|
+
"unclosed call preservation",
|
|
192
|
+
convert_text(r"$$y(x$$"),
|
|
193
|
+
r"$$y(x$$",
|
|
194
|
+
),
|
|
195
|
+
(
|
|
196
|
+
"nested function structure",
|
|
197
|
+
format_math_structure(parse_math_body("y(x(g(3)))")),
|
|
198
|
+
"Function y\n Function x\n Function g\n Constant 3",
|
|
199
|
+
),
|
|
200
|
+
(
|
|
201
|
+
"legacy and scoped undo",
|
|
202
|
+
uncolor_fragment(r"\textcolor{red}{x+\color{blue}{y}}"),
|
|
203
|
+
"x+y",
|
|
204
|
+
),
|
|
205
|
+
(
|
|
206
|
+
"Anki adapter round trip",
|
|
207
|
+
transform_document(transform_document(anki, "anki"), "anki", True),
|
|
208
|
+
anki,
|
|
209
|
+
),
|
|
210
|
+
(
|
|
211
|
+
"native TeX adapter round trip",
|
|
212
|
+
transform_document(transform_document(tex, "tex"), "tex", True),
|
|
213
|
+
tex,
|
|
214
|
+
),
|
|
215
|
+
(
|
|
216
|
+
"Jupyter adapter changes only Markdown cells",
|
|
217
|
+
(
|
|
218
|
+
"textcolor" in json.loads(notebook_colored)["cells"][0]["source"]
|
|
219
|
+
and json.loads(notebook_colored)["cells"][1]["source"] == "$$f(x)$$"
|
|
220
|
+
),
|
|
221
|
+
True,
|
|
222
|
+
),
|
|
223
|
+
)
|
|
224
|
+
)
|
color_math/undo.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .parsers.markdown_scanner import scan_markdown
|
|
4
|
+
from .utils.latex_helpers import (
|
|
5
|
+
COMMAND_RE,
|
|
6
|
+
read_color_wrapper,
|
|
7
|
+
read_comment_end,
|
|
8
|
+
read_verb_end,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def uncolor_fragment(text: str) -> str:
|
|
13
|
+
r"""Remove nested ``\textcolor`` and legacy ``\color`` wrappers."""
|
|
14
|
+
output: list[str] = []
|
|
15
|
+
index = 0
|
|
16
|
+
|
|
17
|
+
while index < len(text):
|
|
18
|
+
if text[index] == "%":
|
|
19
|
+
end = read_comment_end(text, index)
|
|
20
|
+
output.append(text[index:end])
|
|
21
|
+
index = end
|
|
22
|
+
continue
|
|
23
|
+
|
|
24
|
+
if text[index] == "\\":
|
|
25
|
+
wrapper = read_color_wrapper(text, index)
|
|
26
|
+
if wrapper is not None:
|
|
27
|
+
value, index = wrapper
|
|
28
|
+
output.append(uncolor_fragment(value))
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
verb = read_verb_end(text, index)
|
|
32
|
+
if verb is not None:
|
|
33
|
+
end, _ = verb
|
|
34
|
+
output.append(text[index:end])
|
|
35
|
+
index = end
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
command = COMMAND_RE.match(text, index)
|
|
39
|
+
if command is not None:
|
|
40
|
+
output.append(command.group(0))
|
|
41
|
+
index = command.end()
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
output.append(text[index])
|
|
45
|
+
index += 1
|
|
46
|
+
|
|
47
|
+
return "".join(output)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def uncolor_text(text: str) -> str:
|
|
51
|
+
r"""Remove wrappers in display math; leave all other Markdown untouched."""
|
|
52
|
+
math_blocks = scan_markdown(text).math_blocks
|
|
53
|
+
if not math_blocks:
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
output: list[str] = []
|
|
57
|
+
index = 0
|
|
58
|
+
for span in math_blocks:
|
|
59
|
+
output.append(text[index:span.start])
|
|
60
|
+
output.append(uncolor_fragment(text[span.start:span.end]))
|
|
61
|
+
index = span.end
|
|
62
|
+
output.append(text[index:])
|
|
63
|
+
return "".join(output)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# utils/__init__.py
|
|
2
|
+
|
|
3
|
+
from .latex_helpers import (
|
|
4
|
+
read_braced,
|
|
5
|
+
read_script_argument,
|
|
6
|
+
read_script,
|
|
7
|
+
read_color_wrapper,
|
|
8
|
+
read_color_command,
|
|
9
|
+
contains_color_wrapper,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from .coloring import (
|
|
13
|
+
latex_color,
|
|
14
|
+
command_color,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
# latex_helpers.py
|
|
20
|
+
"read_braced",
|
|
21
|
+
"read_script_argument",
|
|
22
|
+
"read_script",
|
|
23
|
+
"read_color_wrapper",
|
|
24
|
+
"read_color_command",
|
|
25
|
+
"contains_color_wrapper",
|
|
26
|
+
|
|
27
|
+
# coloring.py
|
|
28
|
+
"latex_color",
|
|
29
|
+
"command_color",
|
|
30
|
+
]
|