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
color_math/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Public helpers for converting Obsidian LaTeX color markup."""
2
+
3
+ from .adapters import detect_format, transform_document
4
+ from .config import ColorMathOptions
5
+ from .converters.block import convert_text
6
+ from .undo import uncolor_text
7
+
8
+ __all__ = ["ColorMathOptions", "convert_text", "detect_format", "transform_document", "uncolor_text"]
color_math/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
color_math/adapters.py ADDED
@@ -0,0 +1,288 @@
1
+ """Thin document adapters around the existing Markdown math converter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from .converters.block import convert_math_block, convert_text
10
+ from .undo import uncolor_fragment, uncolor_text
11
+ from .utils.latex_helpers import read_comment_end, read_verb_end
12
+
13
+
14
+ FORMATS = ("auto", "markdown", "jupyter", "anki", "tex")
15
+ ANKI_DELIMITERS = (
16
+ (r"\[", r"\]"),
17
+ (r"\(", r"\)"),
18
+ ("[$$]", "[/$$]"),
19
+ ("[$]", "[/$]"),
20
+ )
21
+ MATH_ENVIRONMENTS = {
22
+ "align", "align*", "alignat", "alignat*", "displaymath",
23
+ "equation", "equation*", "eqnarray", "eqnarray*", "flalign",
24
+ "flalign*", "gather", "gather*", "math", "multline", "multline*",
25
+ }
26
+ VERBATIM_ENVIRONMENTS = {"Verbatim", "lstlisting", "minted", "verbatim", "verbatim*"}
27
+ BEGIN_RE = re.compile(r"\\begin\{([^{}]+)\}")
28
+ HEX_COLOR_RE = re.compile(r"\\textcolor\{#([0-9A-Fa-f]{6})\}")
29
+ NATIVE_COLOR_RE = re.compile(r"\\textcolor\{colormath([0-9A-Fa-f]{6})\}")
30
+ NATIVE_BEGIN = "% color-math: begin generated xcolor support"
31
+ NATIVE_END = "% color-math: end generated xcolor support"
32
+ NATIVE_BLOCK_RE = re.compile(
33
+ rf"(?m)^{re.escape(NATIVE_BEGIN)}(?:\r?\n).*?^{re.escape(NATIVE_END)}(?:\r?\n)?",
34
+ re.DOTALL,
35
+ )
36
+
37
+
38
+ class AdapterError(ValueError):
39
+ """A document cannot be safely handled by its selected adapter."""
40
+
41
+
42
+ def detect_format(path: Path | None, requested: str = "auto") -> str:
43
+ if requested != "auto":
44
+ return requested
45
+ if path is not None and path.suffix.lower() == ".ipynb":
46
+ return "jupyter"
47
+ if path is not None and path.suffix.lower() in {".tex", ".latex"}:
48
+ return "tex"
49
+ return "markdown"
50
+
51
+
52
+ def transform_document(text: str, format_name: str, undo: bool = False) -> str:
53
+ if format_name == "markdown":
54
+ return uncolor_text(text) if undo else convert_text(text)
55
+ if format_name == "jupyter":
56
+ return _transform_notebook(text, undo)
57
+ if format_name == "anki":
58
+ return _transform_delimited(text, ANKI_DELIMITERS, undo)
59
+ if format_name == "tex":
60
+ return _transform_tex(text, undo)
61
+ raise AdapterError(f"unsupported format: {format_name}")
62
+
63
+
64
+ def _transform_fragment(text: str, undo: bool) -> str:
65
+ if undo:
66
+ return uncolor_fragment(text)
67
+ converted = convert_math_block(f"$${text}$$")
68
+ return converted[2:-2]
69
+
70
+
71
+ def _transform_notebook(text: str, undo: bool) -> str:
72
+ try:
73
+ notebook = json.loads(text)
74
+ except json.JSONDecodeError as error:
75
+ raise AdapterError(f"invalid Jupyter JSON at line {error.lineno}: {error.msg}") from error
76
+
77
+ if not isinstance(notebook, dict) or not isinstance(notebook.get("cells"), list):
78
+ raise AdapterError("Jupyter notebook must contain a cells list")
79
+
80
+ changed = False
81
+ for index, cell in enumerate(notebook["cells"]):
82
+ if not isinstance(cell, dict) or cell.get("cell_type") != "markdown":
83
+ continue
84
+ source = cell.get("source", "")
85
+ if isinstance(source, str):
86
+ joined = source
87
+ elif isinstance(source, list) and all(isinstance(part, str) for part in source):
88
+ joined = "".join(source)
89
+ else:
90
+ raise AdapterError(f"markdown cell {index + 1} has an invalid source")
91
+
92
+ converted = uncolor_text(joined) if undo else convert_text(joined)
93
+ if converted == joined:
94
+ continue
95
+ cell["source"] = (
96
+ converted
97
+ if isinstance(source, str)
98
+ else converted.splitlines(keepends=True) or ([""] if source else [])
99
+ )
100
+ changed = True
101
+
102
+ if not changed:
103
+ return text
104
+
105
+ indentation = re.search(r"\n([ \t]+)\"", text)
106
+ if indentation is None:
107
+ output = json.dumps(notebook, ensure_ascii=False, separators=(",", ":"))
108
+ else:
109
+ output = json.dumps(notebook, ensure_ascii=False, indent=indentation.group(1))
110
+ ending = "\r\n" if text.endswith("\r\n") else "\n" if text.endswith("\n") else ""
111
+ return output + ending
112
+
113
+
114
+ def _is_escaped(text: str, index: int) -> bool:
115
+ backslashes = 0
116
+ index -= 1
117
+ while index >= 0 and text[index] == "\\":
118
+ backslashes += 1
119
+ index -= 1
120
+ return backslashes % 2 == 1
121
+
122
+
123
+ def _next_delimiter(
124
+ text: str,
125
+ start: int,
126
+ delimiters: tuple[tuple[str, str], ...],
127
+ ) -> tuple[int, str, str] | None:
128
+ matches = [
129
+ (position, opening, closing)
130
+ for opening, closing in delimiters
131
+ if (position := _find_unescaped(text, opening, start)) >= 0
132
+ ]
133
+ return min(matches, default=None, key=lambda match: match[0])
134
+
135
+
136
+ def _find_unescaped(text: str, token: str, start: int) -> int:
137
+ while (position := text.find(token, start)) >= 0:
138
+ if not _is_escaped(text, position):
139
+ return position
140
+ start = position + len(token)
141
+ return -1
142
+
143
+
144
+ def _transform_delimited(
145
+ text: str,
146
+ delimiters: tuple[tuple[str, str], ...],
147
+ undo: bool,
148
+ ) -> str:
149
+ output: list[str] = []
150
+ index = 0
151
+ while match := _next_delimiter(text, index, delimiters):
152
+ start, opening, closing = match
153
+ end = _find_unescaped(text, closing, start + len(opening))
154
+ if end < 0:
155
+ break
156
+ output.append(text[index:start + len(opening)])
157
+ output.append(_transform_fragment(text[start + len(opening):end], undo))
158
+ output.append(closing)
159
+ index = end + len(closing)
160
+ output.append(text[index:])
161
+ return "".join(output)
162
+
163
+
164
+ def _find_active(text: str, token: str, start: int) -> int:
165
+ index = start
166
+ while index < len(text):
167
+ if text[index] == "%" and not _is_escaped(text, index):
168
+ index = read_comment_end(text, index)
169
+ continue
170
+ if text[index] == "\\":
171
+ verb = read_verb_end(text, index)
172
+ if verb is not None:
173
+ index = verb[0]
174
+ continue
175
+ if text.startswith(token, index) and not _is_escaped(text, index):
176
+ return index
177
+ index += 1
178
+ return -1
179
+
180
+
181
+ def _find_dollar(text: str, start: int, width: int) -> int:
182
+ token = "$" * width
183
+ index = start
184
+ while (index := _find_active(text, token, index)) >= 0:
185
+ before = index > 0 and text[index - 1] == "$"
186
+ after = index + width < len(text) and text[index + width] == "$"
187
+ if not before and not after:
188
+ return index
189
+ index += width
190
+ return -1
191
+
192
+
193
+ def _transform_tex_math(text: str, undo: bool) -> str:
194
+ output: list[str] = []
195
+ index = 0
196
+ while index < len(text):
197
+ if text[index] == "%" and not _is_escaped(text, index):
198
+ end = read_comment_end(text, index)
199
+ output.append(text[index:end])
200
+ index = end
201
+ continue
202
+
203
+ if text[index] == "\\":
204
+ verb = read_verb_end(text, index)
205
+ if verb is not None:
206
+ end = verb[0]
207
+ output.append(text[index:end])
208
+ index = end
209
+ continue
210
+
211
+ environment = BEGIN_RE.match(text, index)
212
+ if environment is not None:
213
+ name = environment.group(1)
214
+ closing = rf"\end{{{name}}}"
215
+ end_start = _find_active(text, closing, environment.end())
216
+ if end_start >= 0 and name in VERBATIM_ENVIRONMENTS:
217
+ end = end_start + len(closing)
218
+ output.append(text[index:end])
219
+ index = end
220
+ continue
221
+ if end_start >= 0 and name in MATH_ENVIRONMENTS:
222
+ end = end_start + len(closing)
223
+ output.append(_transform_fragment(text[index:end], undo))
224
+ index = end
225
+ continue
226
+
227
+ pair = next(
228
+ ((opening, closing) for opening, closing in ((r"\[", r"\]"), (r"\(", r"\)")) if text.startswith(opening, index)),
229
+ None,
230
+ )
231
+ if pair is not None:
232
+ opening, closing = pair
233
+ end_start = _find_active(text, closing, index + len(opening))
234
+ if end_start >= 0:
235
+ output.append(opening)
236
+ output.append(_transform_fragment(text[index + len(opening):end_start], undo))
237
+ output.append(closing)
238
+ index = end_start + len(closing)
239
+ continue
240
+
241
+ if text[index] == "$" and not _is_escaped(text, index):
242
+ width = 2 if text.startswith("$$", index) else 1
243
+ end_start = _find_dollar(text, index + width, width)
244
+ if end_start >= 0:
245
+ delimiter = "$" * width
246
+ output.append(delimiter)
247
+ output.append(_transform_fragment(text[index + width:end_start], undo))
248
+ output.append(delimiter)
249
+ index = end_start + width
250
+ continue
251
+
252
+ output.append(text[index])
253
+ index += 1
254
+ return "".join(output)
255
+
256
+
257
+ def _native_support(text: str) -> str:
258
+ translated = HEX_COLOR_RE.sub(
259
+ lambda match: rf"\textcolor{{colormath{match.group(1).lower()}}}",
260
+ text,
261
+ )
262
+ colors = sorted({match.lower() for match in NATIVE_COLOR_RE.findall(translated)})
263
+ if not colors:
264
+ return translated
265
+
266
+ newline = "\r\n" if "\r\n" in translated else "\n"
267
+ lines = [NATIVE_BEGIN]
268
+ user_document = NATIVE_BLOCK_RE.sub("", translated)
269
+ if not re.search(r"\\usepackage(?:\[[^]]*\])?\{[^}]*\bxcolor\b[^}]*\}", user_document):
270
+ lines.append(r"\usepackage{xcolor}")
271
+ lines.extend(
272
+ rf"\definecolor{{colormath{color}}}{{HTML}}{{{color.upper()}}}"
273
+ for color in colors
274
+ )
275
+ lines.append(NATIVE_END)
276
+ block = newline.join(lines) + newline
277
+
278
+ if NATIVE_BLOCK_RE.search(translated):
279
+ return NATIVE_BLOCK_RE.sub(lambda _: block, translated, count=1)
280
+ document_class = re.search(r"(?m)^\\documentclass[^\r\n]*(?:\r?\n|$)", translated)
281
+ insertion = document_class.end() if document_class else 0
282
+ return translated[:insertion] + block + translated[insertion:]
283
+
284
+
285
+ def _transform_tex(text: str, undo: bool) -> str:
286
+ if undo:
287
+ return _transform_tex_math(NATIVE_BLOCK_RE.sub("", text), True)
288
+ return _native_support(_transform_tex_math(text, False))
color_math/config.py ADDED
@@ -0,0 +1,351 @@
1
+ # config.py
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+
5
+ DEFAULT_COLORS = {
6
+ "main": "#7aa2f7",
7
+ "orange": "#e0af68",
8
+ "dot": "white",
9
+ "derivative": "#bb9af7",
10
+ "chain": "#9ece6a",
11
+ "upper": "#bb9af7",
12
+ "relation": "white",
13
+ "arrow": "#f7768e",
14
+ "set": "#bb9af7",
15
+ "spacing": "white",
16
+ "parameter": "#bb9af7",
17
+ "unit": "#73daca",
18
+ }
19
+
20
+ COLORS = dict(DEFAULT_COLORS)
21
+
22
+
23
+ BIG_OPERATORS = {
24
+ r"\sum",
25
+ r"\prod",
26
+ r"\coprod",
27
+ r"\bigcup",
28
+ r"\bigcap",
29
+ r"\bigsqcup",
30
+ r"\bigvee",
31
+ r"\bigwedge",
32
+ r"\bigoplus",
33
+ r"\bigotimes",
34
+ }
35
+
36
+
37
+ INTEGRALS = {
38
+ r"\int",
39
+ r"\iint",
40
+ r"\iiint",
41
+ r"\oint",
42
+ }
43
+
44
+
45
+ LIMIT_OPERATORS = {
46
+ r"\lim",
47
+ r"\sup",
48
+ r"\inf",
49
+ r"\max",
50
+ r"\min",
51
+ }
52
+
53
+
54
+ RELATIONS = {
55
+ r"\le",
56
+ r"\ge",
57
+ r"\ne",
58
+ r"\neq",
59
+ r"\leq",
60
+ r"\geq",
61
+ r"\approx",
62
+ r"\sim",
63
+ r"\equiv",
64
+ r"\propto",
65
+ r"\simeq",
66
+ r"\cong",
67
+ r"\pm",
68
+ r"\mp",
69
+ r"\div",
70
+ r"\ast",
71
+ r"\star",
72
+ r"\circ",
73
+ r"\bullet",
74
+ "=",
75
+ "<",
76
+ ">",
77
+ }
78
+
79
+
80
+ ARROWS = {
81
+ r"\xrightarrow",
82
+ r"\xleftarrow",
83
+ r"\hookrightarrow",
84
+ r"\hookleftarrow",
85
+ r"\uparrow",
86
+ r"\downarrow",
87
+ r"\implies",
88
+ r"\iff",
89
+ r"\longrightarrow",
90
+ r"\longleftarrow",
91
+ r"\leftrightarrow",
92
+ r"\rightarrow",
93
+ r"\leftarrow",
94
+ r"\Rightarrow",
95
+ r"\Leftarrow",
96
+ r"\Leftrightarrow",
97
+ r"\mapsto",
98
+ r"\to",
99
+ }
100
+
101
+
102
+ SET_SYMBOLS = {
103
+ r"\forall",
104
+ r"\exists",
105
+ r"\land",
106
+ r"\lor",
107
+ r"\ni",
108
+ r"\sqsubset",
109
+ r"\sqsubseteq",
110
+ r"\uplus",
111
+ r"\notin",
112
+ r"\subseteq",
113
+ r"\supseteq",
114
+ r"\subset",
115
+ r"\supset",
116
+ r"\setminus",
117
+ r"\emptyset",
118
+ r"\in",
119
+ r"\cup",
120
+ r"\cap",
121
+ }
122
+
123
+
124
+ SPACING_COMMANDS = {
125
+ r"\,",
126
+ r"\:",
127
+ r"\;",
128
+ r"\quad",
129
+ r"\qquad",
130
+ }
131
+
132
+
133
+ MULTIPLICATION_SYMBOLS = {
134
+ r"\cdot",
135
+ r"\times",
136
+ "·",
137
+ "*",
138
+ }
139
+
140
+
141
+ FUNCTION_COMMANDS = {
142
+ r"\arccos",
143
+ r"\arcsin",
144
+ r"\arctan",
145
+ r"\cos",
146
+ r"\cosh",
147
+ r"\exp",
148
+ r"\ln",
149
+ r"\log",
150
+ r"\sec",
151
+ r"\sin",
152
+ r"\sinh",
153
+ r"\tan",
154
+ r"\tanh",
155
+ }
156
+
157
+
158
+ # Combined commands that should receive special coloring
159
+ COLOR_COMMANDS = (
160
+ BIG_OPERATORS
161
+ | INTEGRALS
162
+ | LIMIT_OPERATORS
163
+ | RELATIONS
164
+ | ARROWS
165
+ | SET_SYMBOLS
166
+ | SPACING_COMMANDS
167
+ | MULTIPLICATION_SYMBOLS
168
+ )
169
+
170
+
171
+ # Longest first so scanner matches \longrightarrow before \to
172
+ SORTED_COLOR_COMMANDS = sorted(
173
+ COLOR_COMMANDS,
174
+ key=len,
175
+ reverse=True,
176
+ )
177
+
178
+
179
+ MATH_CONSTANTS = {
180
+ r"\pi",
181
+ r"\varpi",
182
+ r"\hbar",
183
+ r"\infty",
184
+ r"\ell",
185
+ r"\aleph",
186
+ r"\Re",
187
+ r"\Im",
188
+ r"\top",
189
+ r"\bot",
190
+ }
191
+
192
+
193
+ MATH_ACCENTS = {
194
+ r"\dot",
195
+ r"\ddot",
196
+ r"\dddot",
197
+ r"\ddddot",
198
+ r"\hat",
199
+ r"\widehat",
200
+ r"\tilde",
201
+ r"\widetilde",
202
+ r"\bar",
203
+ r"\vec",
204
+ r"\check",
205
+ r"\breve",
206
+ r"\acute",
207
+ r"\grave",
208
+ r"\mathring",
209
+ }
210
+
211
+
212
+ MATH_PARAMETERS = {
213
+ r"\alpha",
214
+ r"\beta",
215
+ r"\gamma",
216
+ r"\delta",
217
+ r"\epsilon",
218
+ r"\varepsilon",
219
+ r"\zeta",
220
+ r"\eta",
221
+ r"\theta",
222
+ r"\vartheta",
223
+ r"\iota",
224
+ r"\kappa",
225
+ r"\lambda",
226
+ r"\mu",
227
+ r"\nu",
228
+ r"\xi",
229
+ r"\rho",
230
+ r"\varrho",
231
+ r"\sigma",
232
+ r"\varsigma",
233
+ r"\tau",
234
+ r"\upsilon",
235
+ r"\phi",
236
+ r"\varphi",
237
+ r"\chi",
238
+ r"\psi",
239
+ r"\omega",
240
+ r"\Gamma",
241
+ r"\Delta",
242
+ r"\Theta",
243
+ r"\Lambda",
244
+ r"\Xi",
245
+ r"\Pi",
246
+ r"\Sigma",
247
+ r"\Upsilon",
248
+ r"\Phi",
249
+ r"\Psi",
250
+ r"\Omega",
251
+ }
252
+
253
+
254
+ MATH_FUNCTIONS = {
255
+ r"\sin",
256
+ r"\cos",
257
+ r"\tan",
258
+ r"\csc",
259
+ r"\sec",
260
+ r"\cot",
261
+ r"\arcsin",
262
+ r"\arccos",
263
+ r"\arctan",
264
+ r"\sinh",
265
+ r"\cosh",
266
+ r"\tanh",
267
+ r"\coth",
268
+ r"\ln",
269
+ r"\log",
270
+ r"\exp",
271
+ r"\det",
272
+ r"\gcd",
273
+ r"\max",
274
+ r"\min",
275
+ r"\dim",
276
+ r"\ker",
277
+ r"\hom",
278
+ r"\deg",
279
+ r"\arg",
280
+ r"\Pr",
281
+ r"\sup",
282
+ r"\inf",
283
+ }
284
+
285
+
286
+ RAINBOW_DELIMITER_COLORS: list[str] = [
287
+ "#e0af68", # Tier 0: Gold
288
+ "#7aa2f7", # Tier 1: Cyan / Blue
289
+ "#bb9af7", # Tier 2: Purple / Lavender
290
+ "#f7768e", # Tier 3: Coral / Pink
291
+ ]
292
+
293
+
294
+ VARIABLE_HASH_PALETTE: list[str] = [
295
+ "#7aa2f7", # Tokyo Blue
296
+ "#7dcfff", # Tokyo Cyan
297
+ "#bb9af7", # Tokyo Purple
298
+ "#f7768e", # Tokyo Pink
299
+ "#e0af68", # Tokyo Orange/Gold
300
+ "#9ece6a", # Tokyo Green
301
+ "#2ac3de", # Light Cyan
302
+ "#ff9e64", # Peach
303
+ ]
304
+
305
+
306
+ def hash_string_to_color(s: str, palette: list[str] = VARIABLE_HASH_PALETTE) -> str:
307
+ """Deterministic string hashing for variable data-flow coloring."""
308
+ h = 0
309
+ for char in s:
310
+ h = (h * 31 + ord(char)) & 0xFFFFFFFF
311
+ if h >= 0x80000000:
312
+ h -= 0x100000000
313
+ idx = abs(h) % len(palette)
314
+ return palette[idx]
315
+
316
+
317
+ @dataclass
318
+ class ColorMathOptions:
319
+ enable_taxonomy: bool = False
320
+ rainbow_delimiters: bool = False
321
+ variable_data_flow: bool = False
322
+ color_units: bool = False
323
+ color_differentials: bool = False
324
+ color_braket: bool = False
325
+ color_dimensionless: bool = False
326
+
327
+ @classmethod
328
+ def extended(cls) -> ColorMathOptions:
329
+ """Returns standard extended options matching Obsidian plugin defaults."""
330
+ return cls(
331
+ enable_taxonomy=True,
332
+ rainbow_delimiters=True,
333
+ variable_data_flow=False,
334
+ color_units=True,
335
+ color_differentials=True,
336
+ color_braket=True,
337
+ color_dimensionless=True,
338
+ )
339
+
340
+ @classmethod
341
+ def all_enabled(cls) -> ColorMathOptions:
342
+ """Returns options with all features enabled including variable data-flow."""
343
+ return cls(
344
+ enable_taxonomy=True,
345
+ rainbow_delimiters=True,
346
+ variable_data_flow=True,
347
+ color_units=True,
348
+ color_differentials=True,
349
+ color_braket=True,
350
+ color_dimensionless=True,
351
+ )
@@ -0,0 +1,27 @@
1
+ # converters/__init__.py
2
+
3
+ from .align import convert_align_block
4
+ from .derivative import convert_derivative_line
5
+ from .equation import convert_equation_line
6
+ from .generic import color_latex_body, color_generic_math_line
7
+ from .integral import convert_integral_line
8
+ from .limit import convert_limit_line
9
+ from .matrix import convert_matrix_block
10
+ from .block import convert_math_block, convert_line, convert_text
11
+
12
+
13
+ __all__ = [
14
+ "convert_align_block",
15
+ "convert_derivative_line",
16
+ "convert_equation_line",
17
+ "convert_integral_line",
18
+ "convert_limit_line",
19
+ "convert_matrix_block",
20
+
21
+ "color_latex_body",
22
+ "color_generic_math_line",
23
+
24
+ "convert_math_block",
25
+ "convert_line",
26
+ "convert_text",
27
+ ]
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from .generic import color_latex_body
6
+ from .semantic import parse_math_block
7
+
8
+
9
+ ALIGN_ENV_RE = re.compile(
10
+ r"\\begin\s*\{\s*(?:align\*?|aligned|gather\*?|gathered|split)\s*\}"
11
+ )
12
+
13
+
14
+ def convert_align_block(block: str) -> str | None:
15
+ parsed = parse_math_block(block)
16
+ if parsed is None or ALIGN_ENV_RE.search(parsed.body) is None:
17
+ return None
18
+ return parsed.render(color_latex_body(parsed.body))