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,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)
@@ -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
+ ]