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