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,76 @@
1
+ """Small shared helpers for lossless semantic formatters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from ..config import COLORS
9
+ from ..parsers.latex_spans import find_top_level_tokens
10
+ from ..utils.spans import ColorSpan
11
+
12
+
13
+ MATH_BLOCK_RE = re.compile(
14
+ r"^(?P<prefix>\s*(?:\#+\s*)?)\$\$(?P<body>.*)\$\$(?P<suffix>\s*)$",
15
+ re.DOTALL,
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class MathBlock:
21
+ prefix: str
22
+ body: str
23
+ suffix: str
24
+
25
+ def render(self, colored_body: str) -> str:
26
+ return f"{self.prefix}$${colored_body}$${self.suffix}"
27
+
28
+
29
+ def parse_math_block(source: str) -> MathBlock | None:
30
+ match = MATH_BLOCK_RE.fullmatch(source)
31
+ if match is None:
32
+ return None
33
+ return MathBlock(
34
+ match.group("prefix"),
35
+ match.group("body"),
36
+ match.group("suffix"),
37
+ )
38
+
39
+
40
+ def trim_range(source: str, start: int, end: int) -> tuple[int, int]:
41
+ while start < end and source[start].isspace():
42
+ start += 1
43
+ while end > start and source[end - 1].isspace():
44
+ end -= 1
45
+ return start, end
46
+
47
+
48
+ def first_equality(source: str) -> tuple[int, int] | None:
49
+ matches = find_top_level_tokens(source, ("=",))
50
+ return matches[0][:2] if matches else None
51
+
52
+
53
+ def relation_spans(
54
+ source: str,
55
+ start: int = 0,
56
+ end: int | None = None,
57
+ ) -> list[ColorSpan]:
58
+ """Color only top-level structural separators."""
59
+ color_by_token = {
60
+ "=": COLORS["relation"],
61
+ "+": COLORS["relation"],
62
+ "-": COLORS["relation"],
63
+ r"\cdot": COLORS["dot"],
64
+ r"\otimes": COLORS["relation"],
65
+ "·": COLORS["dot"],
66
+ "*": COLORS["dot"],
67
+ }
68
+ return [
69
+ ColorSpan(start, end, color_by_token[token], priority=30)
70
+ for start, end, token in find_top_level_tokens(
71
+ source,
72
+ tuple(color_by_token),
73
+ start,
74
+ end,
75
+ )
76
+ ]
color_math/io.py ADDED
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import codecs
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+
10
+ def read_utf8(path: Path) -> tuple[str, bytes]:
11
+ """Read UTF-8 text while retaining its exact original bytes."""
12
+ source = path.read_bytes()
13
+ body = (
14
+ source[len(codecs.BOM_UTF8) :]
15
+ if source.startswith(codecs.BOM_UTF8)
16
+ else source
17
+ )
18
+ return body.decode("utf-8"), source
19
+
20
+
21
+ def encode_utf8(text: str, source: bytes) -> bytes:
22
+ """Encode text with the source file's UTF-8 BOM convention."""
23
+ bom = codecs.BOM_UTF8 if source.startswith(codecs.BOM_UTF8) else b""
24
+ return bom + text.encode("utf-8")
25
+
26
+
27
+ def replace_bytes(path: Path, data: bytes, source: bytes) -> bool:
28
+ """Atomically replace path, returning False when nothing changed."""
29
+ if data == source:
30
+ return False
31
+
32
+ descriptor, temporary_name = tempfile.mkstemp(
33
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
34
+ )
35
+ temporary = Path(temporary_name)
36
+ try:
37
+ with os.fdopen(descriptor, "wb") as stream:
38
+ descriptor = -1
39
+ stream.write(data)
40
+ stream.flush()
41
+ os.fsync(stream.fileno())
42
+ if path.read_bytes() != source:
43
+ raise OSError("file changed while it was being processed; refusing to overwrite")
44
+ shutil.copymode(path, temporary)
45
+ os.replace(temporary, path)
46
+ return True
47
+ finally:
48
+ if descriptor != -1:
49
+ os.close(descriptor)
50
+ try:
51
+ temporary.unlink()
52
+ except FileNotFoundError:
53
+ pass
color_math/main.py ADDED
@@ -0,0 +1,162 @@
1
+ # main.py
2
+
3
+ #!/usr/bin/env python3
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .adapters import AdapterError, FORMATS, detect_format, transform_document
11
+ from .config import COLORS
12
+ from .io import encode_utf8, read_utf8, replace_bytes
13
+ from .parsers.math_parser import describe_math_blocks
14
+ from .self_test import run_self_test
15
+
16
+
17
+ def main() -> int:
18
+ parser = argparse.ArgumentParser(
19
+ description="Colorize LaTeX math expressions."
20
+ )
21
+
22
+ parser.add_argument(
23
+ "input",
24
+ nargs="?",
25
+ help=(
26
+ "Equation text, or file path when --file is used. "
27
+ "Reads stdin if omitted."
28
+ ),
29
+ )
30
+
31
+ parser.add_argument(
32
+ "-f",
33
+ "--file",
34
+ action="store_true",
35
+ help="Treat input as a file path and convert file contents.",
36
+ )
37
+
38
+ parser.add_argument(
39
+ "-i",
40
+ "--in-place",
41
+ action="store_true",
42
+ help="Write converted text back to file. Requires --file.",
43
+ )
44
+
45
+ parser.add_argument(
46
+ "--main-color",
47
+ default=COLORS["main"],
48
+ help="Override main function color.",
49
+ )
50
+
51
+ parser.add_argument(
52
+ "--self-test",
53
+ action="store_true",
54
+ help="Run internal tests and dependency checks.",
55
+ )
56
+
57
+ parser.add_argument(
58
+ "--update-generated",
59
+ action="store_true",
60
+ help="Refresh tests/generated while running --self-test.",
61
+ )
62
+
63
+ parser.add_argument(
64
+ "--parse",
65
+ action="store_true",
66
+ help="Inspect nested function calls without rewriting LaTeX.",
67
+ )
68
+
69
+ parser.add_argument(
70
+ "--undo",
71
+ action="store_true",
72
+ help="Remove LaTeX color wrappers instead of adding them.",
73
+ )
74
+
75
+ parser.add_argument(
76
+ "--format",
77
+ choices=FORMATS,
78
+ default="auto",
79
+ help=(
80
+ "Input format. Auto detects .ipynb and .tex files; use anki "
81
+ "explicitly for text/TSV exports."
82
+ ),
83
+ )
84
+
85
+ args = parser.parse_args()
86
+
87
+ # sanity check
88
+ if args.in_place and not args.file:
89
+ parser.error("--in-place requires --file")
90
+ if args.update_generated and not args.self_test:
91
+ parser.error("--update-generated requires --self-test")
92
+
93
+ # run tests
94
+ if args.self_test:
95
+ return run_self_test(update_generated=args.update_generated)
96
+
97
+ # allow runtime color override
98
+ COLORS["main"] = args.main_color
99
+
100
+ # file mode
101
+ if args.file:
102
+ if not args.input:
103
+ parser.error("--file requires a path")
104
+
105
+ path = Path(args.input)
106
+ try:
107
+ text, source = read_utf8(path)
108
+ except UnicodeDecodeError as error:
109
+ parser.exit(
110
+ 1,
111
+ f"color-math: {path} is not valid UTF-8 "
112
+ f"(byte {error.start})\n",
113
+ )
114
+ except OSError as error:
115
+ parser.exit(1, f"color-math: cannot read {path}: {error}\n")
116
+
117
+ format_name = detect_format(path, args.format)
118
+ if args.parse and format_name != "markdown":
119
+ parser.error("--parse currently supports Markdown input only")
120
+ if args.parse:
121
+ sys.stdout.write(describe_math_blocks(text))
122
+ return 0
123
+
124
+ try:
125
+ converted = transform_document(text, format_name, args.undo)
126
+ except AdapterError as error:
127
+ parser.exit(1, f"color-math: {path}: {error}\n")
128
+ output = encode_utf8(converted, source)
129
+
130
+ if args.in_place:
131
+ try:
132
+ replace_bytes(path, output, source)
133
+ except OSError as error:
134
+ parser.exit(1, f"color-math: cannot replace {path}: {error}\n")
135
+ return 0
136
+
137
+ sys.stdout.buffer.write(output)
138
+ return 0
139
+
140
+ # direct input or stdin
141
+ text = (
142
+ args.input
143
+ if args.input is not None
144
+ else sys.stdin.read()
145
+ )
146
+
147
+ format_name = detect_format(None, args.format)
148
+ if args.parse and format_name != "markdown":
149
+ parser.error("--parse currently supports Markdown input only")
150
+ if args.parse:
151
+ sys.stdout.write(describe_math_blocks(text))
152
+ return 0
153
+
154
+ try:
155
+ sys.stdout.write(transform_document(text, format_name, args.undo))
156
+ except AdapterError as error:
157
+ parser.exit(1, f"color-math: {error}\n")
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ # parsers/__init__.py
2
+
3
+ from .math_parser import (
4
+ ParsedMath,
5
+ SemanticSpan,
6
+ describe_math_blocks,
7
+ find_semantic_spans,
8
+ format_math_structure,
9
+ parse_math_blocks,
10
+ parse_math_body,
11
+ parser_available,
12
+ )
13
+ from .markdown_scanner import MarkdownScan, MarkdownSpan, scan_markdown
14
+
15
+ from .scanner import (
16
+ color_latex_body_with_scanner,
17
+ )
18
+
19
+ from .units import UnitSpan, find_unit_spans, collect_unit_spans
20
+ from .differentials import DifferentialSpan, find_differential_spans, collect_differential_spans
21
+ from .braket import BraKetSpan, find_braket_spans, collect_braket_delimiter_spans
22
+ from .dimensionless import DimensionlessSpan, find_dimensionless_spans, collect_dimensionless_spans
23
+ from .delimiters import DelimiterPair, find_delimiter_pairs, collect_delimiter_spans
24
+ from .taxonomy import collect_taxonomy_spans
25
+ from .variable_hash import collect_variable_spans
26
+
27
+
28
+ __all__ = [
29
+ "ParsedMath",
30
+ "SemanticSpan",
31
+ "MarkdownScan",
32
+ "MarkdownSpan",
33
+
34
+ # math_parser.py
35
+ "describe_math_blocks",
36
+ "find_semantic_spans",
37
+ "format_math_structure",
38
+ "parse_math_blocks",
39
+ "parse_math_body",
40
+ "parser_available",
41
+ "scan_markdown",
42
+
43
+ # scanner.py
44
+ "color_latex_body_with_scanner",
45
+
46
+ # new parsers
47
+ "UnitSpan",
48
+ "find_unit_spans",
49
+ "collect_unit_spans",
50
+ "DifferentialSpan",
51
+ "find_differential_spans",
52
+ "collect_differential_spans",
53
+ "BraKetSpan",
54
+ "find_braket_spans",
55
+ "collect_braket_delimiter_spans",
56
+ "DimensionlessSpan",
57
+ "find_dimensionless_spans",
58
+ "collect_dimensionless_spans",
59
+ "DelimiterPair",
60
+ "find_delimiter_pairs",
61
+ "collect_delimiter_spans",
62
+ "collect_taxonomy_spans",
63
+ "collect_variable_spans",
64
+ ]
@@ -0,0 +1,109 @@
1
+ """Quantum Bra-Ket (Dirac) notation parser."""
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 BraKetSpan:
12
+ start: int
13
+ end: int
14
+ kind: str # "bracket" | "ket" | "bra"
15
+
16
+
17
+ def find_braket_spans(body: str) -> list[BraKetSpan]:
18
+ """Scans LaTeX math body to identify Quantum Bra-Ket (Dirac) notation."""
19
+ spans: list[BraKetSpan] = []
20
+
21
+ def add_span(start: int, end: int, kind: str) -> None:
22
+ if start >= end:
23
+ return
24
+ if not any(start < s.end and end > s.start for s in spans):
25
+ spans.append(BraKetSpan(start, end, kind))
26
+
27
+ # 1. Bracket / Expectation value: \langle ... | ... \rangle
28
+ braket_re = re.compile(
29
+ r"\\langle\s*([^<|>]+?)\s*\|\s*([^<|>]+?)(?:\s*\|\s*([^<|>]+?))?\s*\\rangle"
30
+ )
31
+ for m in braket_re.finditer(body):
32
+ add_span(m.start(), m.end(), "bracket")
33
+
34
+ # 2. Ket: | ... \rangle or \vert ... \rangle or \ket{...}
35
+ ket_re = re.compile(
36
+ r"(?:\||\\vert)\s*([^<|>]+?)\s*\\rangle|\\ket\s*\{([^}]+)\}"
37
+ )
38
+ for m in ket_re.finditer(body):
39
+ add_span(m.start(), m.end(), "ket")
40
+
41
+ # 3. Bra: \langle ... | or \langle ... \vert or \bra{...}
42
+ bra_re = re.compile(
43
+ r"\\langle\s*([^<|>]+?)\s*(?:\||\\vert)|\\bra\s*\{([^}]+)\}"
44
+ )
45
+ for m in bra_re.finditer(body):
46
+ add_span(m.start(), m.end(), "bra")
47
+
48
+ return sorted(spans, key=lambda s: s.start)
49
+
50
+
51
+ def collect_braket_delimiter_spans(
52
+ body: str,
53
+ palette: dict[str, str] | None = None,
54
+ delim_color: str | None = None,
55
+ ) -> list[ColorSpan]:
56
+ r"""Returns color spans for Dirac delimiters (\langle, |, \rangle)."""
57
+ pal = palette or COLORS
58
+ color = delim_color or pal.get("orange", "#e0af68")
59
+ spans: list[ColorSpan] = []
60
+
61
+ # 1. \langle ... | ... \rangle
62
+ braket_re = re.compile(
63
+ r"\\langle\s*([^<|>]+?)\s*\|\s*([^<|>]+?)(?:\s*\|\s*([^<|>]+?))?\s*\\rangle"
64
+ )
65
+ for m in braket_re.finditer(body):
66
+ full = m.group(0)
67
+ langle_idx = m.start()
68
+ langle_end = langle_idx + len(r"\langle")
69
+ rangle_idx = m.start() + full.rfind(r"\rangle")
70
+ rangle_end = rangle_idx + len(r"\rangle")
71
+
72
+ spans.append(ColorSpan(langle_idx, langle_end, color, priority=25))
73
+ spans.append(ColorSpan(rangle_idx, rangle_end, color, priority=25))
74
+
75
+ bar_search = m.start()
76
+ while True:
77
+ bar_search = body.find("|", bar_search)
78
+ if bar_search == -1 or bar_search >= rangle_idx:
79
+ break
80
+ spans.append(ColorSpan(bar_search, bar_search + 1, color, priority=25))
81
+ bar_search += 1
82
+
83
+ # 2. Ket: | ... \rangle or \vert ... \rangle
84
+ ket_re = re.compile(r"(?:\||\\vert)\s*([^<|>]+?)\s*\\rangle")
85
+ for m in ket_re.finditer(body):
86
+ full = m.group(0)
87
+ bar_idx = m.start()
88
+ bar_end = bar_idx + (5 if full.startswith(r"\vert") else 1)
89
+ rangle_idx = m.start() + full.rfind(r"\rangle")
90
+ rangle_end = rangle_idx + 7
91
+
92
+ if not any(s.start == bar_idx for s in spans):
93
+ spans.append(ColorSpan(bar_idx, bar_end, color, priority=25))
94
+ spans.append(ColorSpan(rangle_idx, rangle_end, color, priority=25))
95
+
96
+ # 3. Bra: \langle ... | or \langle ... \vert
97
+ bra_re = re.compile(r"\\langle\s*([^<|>]+?)\s*(?:\||\\vert)")
98
+ for m in bra_re.finditer(body):
99
+ full = m.group(0)
100
+ langle_idx = m.start()
101
+ langle_end = langle_idx + 7
102
+ bar_idx = m.start() + max(full.rfind("|"), full.rfind(r"\vert"))
103
+ bar_end = bar_idx + (5 if full.endswith(r"\vert") else 1)
104
+
105
+ if not any(s.start == langle_idx for s in spans):
106
+ spans.append(ColorSpan(langle_idx, langle_end, color, priority=25))
107
+ spans.append(ColorSpan(bar_idx, bar_end, color, priority=25))
108
+
109
+ return sorted(spans, key=lambda s: s.start)
@@ -0,0 +1,151 @@
1
+ """Rainbow delimiters parser for balanced parentheses, brackets, and braces."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ import re
5
+
6
+ from ..config import RAINBOW_DELIMITER_COLORS
7
+ from ..utils.spans import ColorSpan
8
+
9
+
10
+ @dataclass
11
+ class DelimiterItem:
12
+ delim_type: str
13
+ start: int
14
+ end: int
15
+ is_left_right: bool
16
+
17
+
18
+ @dataclass
19
+ class DelimiterPair:
20
+ open_item: DelimiterItem
21
+ close_item: DelimiterItem
22
+ depth: int
23
+
24
+
25
+ def skip_whitespace(text: str, start: int) -> int:
26
+ while start < len(text) and text[start].isspace():
27
+ start += 1
28
+ return start
29
+
30
+
31
+ def skip_comment(text: str, start: int) -> int:
32
+ idx = start + 1
33
+ while idx < len(text) and text[idx] not in ("\r", "\n"):
34
+ idx += 1
35
+ if idx < len(text) and text[idx] == "\r" and idx + 1 < len(text) and text[idx + 1] == "\n":
36
+ return idx + 2
37
+ return min(idx + 1, len(text))
38
+
39
+
40
+ def get_delimiter_type(delim: str) -> str:
41
+ if delim in ("(", ")"):
42
+ return "paren"
43
+ if delim in ("[", "]"):
44
+ return "bracket"
45
+ if delim in (r"\{", r"\}"):
46
+ return "brace"
47
+ if delim in (r"\langle", r"\rangle"):
48
+ return "angle"
49
+ if delim in ("|", r"\|"):
50
+ return "pipe"
51
+ return "other"
52
+
53
+
54
+ def find_delimiter_pairs(text: str) -> list[DelimiterPair]:
55
+ """Parses all balanced delimiter pairs in a LaTeX string."""
56
+ pairs: list[DelimiterPair] = []
57
+ stack: list[tuple[DelimiterItem, int]] = []
58
+
59
+ idx = 0
60
+ while idx < len(text):
61
+ if text[idx] == "%":
62
+ idx = skip_comment(text, idx)
63
+ continue
64
+
65
+ # Check \left / \right
66
+ if text.startswith(r"\left", idx):
67
+ after_left = skip_whitespace(text, idx + 5)
68
+ m = re.match(r"^(\(|\)|\[|\]|\\\{|\\\}|\\langle|\\rangle|\||\\\||\.)", text[after_left:])
69
+ if m:
70
+ delim_str = m.group(1)
71
+ delim_end = after_left + len(delim_str)
72
+ dtype = get_delimiter_type(delim_str)
73
+ depth = len(stack)
74
+ item = DelimiterItem(dtype, idx, delim_end, is_left_right=True)
75
+ stack.append((item, depth))
76
+ idx = delim_end
77
+ continue
78
+
79
+ if text.startswith(r"\right", idx):
80
+ after_right = skip_whitespace(text, idx + 6)
81
+ m = re.match(r"^(\(|\)|\[|\]|\\\{|\\\}|\\langle|\\rangle|\||\\\||\.)", text[after_right:])
82
+ if m:
83
+ delim_str = m.group(1)
84
+ delim_end = after_right + len(delim_str)
85
+ dtype = get_delimiter_type(delim_str)
86
+
87
+ match_idx = -1
88
+ for i in range(len(stack) - 1, -1, -1):
89
+ if stack[i][0].is_left_right:
90
+ match_idx = i
91
+ break
92
+
93
+ if match_idx != -1:
94
+ matched, matched_depth = stack.pop(match_idx)
95
+ close_item = DelimiterItem(dtype, idx, delim_end, is_left_right=True)
96
+ pairs.append(DelimiterPair(matched, close_item, matched_depth))
97
+ idx = delim_end
98
+ continue
99
+
100
+ # Regular bare delimiters
101
+ ch = text[idx]
102
+ if ch in ("(", "[") or text.startswith(r"\{", idx):
103
+ is_brace = text.startswith(r"\{", idx)
104
+ delim_str = r"\{" if is_brace else ch
105
+ delim_end = idx + (2 if is_brace else 1)
106
+ dtype = get_delimiter_type(delim_str)
107
+ depth = len(stack)
108
+ item = DelimiterItem(dtype, idx, delim_end, is_left_right=False)
109
+ stack.append((item, depth))
110
+ idx = delim_end
111
+ continue
112
+
113
+ if ch in (")", "]") or text.startswith(r"\}", idx):
114
+ is_brace = text.startswith(r"\}", idx)
115
+ delim_str = r"\}" if is_brace else ch
116
+ delim_end = idx + (2 if is_brace else 1)
117
+ dtype = get_delimiter_type(delim_str)
118
+
119
+ match_idx = -1
120
+ for i in range(len(stack) - 1, -1, -1):
121
+ if not stack[i][0].is_left_right and stack[i][0].delim_type == dtype:
122
+ match_idx = i
123
+ break
124
+
125
+ if match_idx != -1:
126
+ matched, matched_depth = stack.pop(match_idx)
127
+ close_item = DelimiterItem(dtype, idx, delim_end, is_left_right=False)
128
+ pairs.append(DelimiterPair(matched, close_item, matched_depth))
129
+ idx = delim_end
130
+ continue
131
+
132
+ idx += 1
133
+
134
+ return pairs
135
+
136
+
137
+ def collect_delimiter_spans(
138
+ body: str,
139
+ palette: list[str] | None = None,
140
+ ) -> list[ColorSpan]:
141
+ """Returns color spans for rainbow delimiters colored by nesting depth."""
142
+ colors = palette or RAINBOW_DELIMITER_COLORS
143
+ pairs = find_delimiter_pairs(body)
144
+ spans: list[ColorSpan] = []
145
+
146
+ for pair in pairs:
147
+ color = colors[pair.depth % len(colors)]
148
+ spans.append(ColorSpan(pair.open_item.start, pair.open_item.end, color, priority=22))
149
+ spans.append(ColorSpan(pair.close_item.start, pair.close_item.end, color, priority=22))
150
+
151
+ return spans
@@ -0,0 +1,71 @@
1
+ """Calculus differentials and derivative operators 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 DifferentialSpan:
12
+ start: int
13
+ end: int
14
+ text: str
15
+ kind: str # "differential" | "derivative_fraction"
16
+
17
+
18
+ GREEK_LETTERS = (
19
+ r"alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|"
20
+ r"lambda|mu|nu|xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega|"
21
+ r"Gamma|Delta|Theta|Lambda|Xi|Pi|Sigma|Upsilon|Phi|Psi|Omega"
22
+ )
23
+ DIFF_VAR = r"(?:\\(?:" + GREEK_LETTERS + r")|[a-zA-Z])"
24
+
25
+
26
+ def find_differential_spans(body: str) -> list[DifferentialSpan]:
27
+ """Scans LaTeX math body to identify infinitesimal differentials and derivative operators."""
28
+ spans: list[DifferentialSpan] = []
29
+
30
+ def add_span(start: int, end: int, text: str, kind: str) -> None:
31
+ if start >= end:
32
+ return
33
+ if not any(start < s.end and end > s.start for s in spans):
34
+ spans.append(DifferentialSpan(start, end, text, kind))
35
+
36
+ # 1. Derivative fractions: \frac{d}{dx}, \frac{df}{dx}, \frac{\partial \psi}{\partial t}, \frac{d^2 y}{dx^2}
37
+ deriv_frac_re = re.compile(
38
+ r"\\frac\s*\{\s*(?:d|\\partial|\\mathrm\{d\})(?:\^\{?\d+\}?)?\s*(?:" + DIFF_VAR + r")?\s*\}\s*\{\s*(?:d|\\partial|\\mathrm\{d\})\s*" + DIFF_VAR + r"(?:\^\{?\d+\}?)?(?:\s*(?:d|\\partial|\\mathrm\{d\})\s*" + DIFF_VAR + r")*\s*\}"
39
+ )
40
+ for m in deriv_frac_re.finditer(body):
41
+ add_span(m.start(), m.end(), m.group(0), "derivative_fraction")
42
+
43
+ # 2. Infinitesimal differentials: dx, dt, dy, dz, dr, d\theta, d\phi, \partial x, \partial t
44
+ diff_re = re.compile(
45
+ r"(?:^|[\s+\-=*({]|\[|\\,|\\:|\\;|\\quad|\\qquad|~)(\s*(?:d|\\partial|\\mathrm\{d\}|\\delta)\s*" + DIFF_VAR + r"(?![a-zA-Z0-9_({])(?:\^\{?\d+\}?)?)"
46
+ )
47
+ for m in diff_re.finditer(body):
48
+ full_match = m.group(0)
49
+ diff_group = m.group(1)
50
+ diff_start = m.start() + (len(full_match) - len(diff_group))
51
+ d_match = re.search(r"(?:d|\\partial|\\mathrm\{d\}|\\delta)", diff_group)
52
+ if d_match:
53
+ d_offset = d_match.start()
54
+ actual_start = diff_start + d_offset
55
+ diff_text = diff_group[d_offset:]
56
+ diff_end = actual_start + len(diff_text)
57
+ add_span(actual_start, diff_end, diff_text, "differential")
58
+
59
+ return sorted(spans, key=lambda s: s.start)
60
+
61
+
62
+ def collect_differential_spans(
63
+ body: str,
64
+ palette: dict[str, str] | None = None,
65
+ diff_spans: list[DifferentialSpan] | None = None,
66
+ ) -> list[ColorSpan]:
67
+ """Returns color spans for differentials and derivative operators using palette.derivative."""
68
+ pal = palette or COLORS
69
+ diffs = diff_spans if diff_spans is not None else find_differential_spans(body)
70
+ deriv_color = pal.get("derivative", "#bb9af7")
71
+ return [ColorSpan(d.start, d.end, deriv_color, priority=24) for d in diffs]