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,463 @@
|
|
|
1
|
+
"""Locate protected Markdown and display-math spans without rewriting text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
FENCED_CODE = "fenced_code"
|
|
9
|
+
CODE_SPAN = "code_span"
|
|
10
|
+
MATH_BLOCK = "math_block"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class MarkdownSpan:
|
|
15
|
+
"""Half-open source offsets for a delimited Markdown region."""
|
|
16
|
+
|
|
17
|
+
kind: str
|
|
18
|
+
start: int
|
|
19
|
+
content_start: int
|
|
20
|
+
content_end: int
|
|
21
|
+
end: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class MarkdownScan:
|
|
26
|
+
"""Protected code regions and editable ``$$...$$`` regions."""
|
|
27
|
+
|
|
28
|
+
protected: tuple[MarkdownSpan, ...]
|
|
29
|
+
math_blocks: tuple[MarkdownSpan, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class _ListItem:
|
|
34
|
+
marker_indent: int
|
|
35
|
+
content_indent: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class _FenceContainer:
|
|
40
|
+
quote_depth: int
|
|
41
|
+
list_indent: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _line_ranges(text: str) -> list[tuple[int, int, int]]:
|
|
45
|
+
"""Return ``(start, content_end, line_end)`` without normalizing endings."""
|
|
46
|
+
ranges: list[tuple[int, int, int]] = []
|
|
47
|
+
start = 0
|
|
48
|
+
|
|
49
|
+
while start < len(text):
|
|
50
|
+
content_end = start
|
|
51
|
+
while content_end < len(text) and text[content_end] not in "\r\n":
|
|
52
|
+
content_end += 1
|
|
53
|
+
|
|
54
|
+
line_end = content_end
|
|
55
|
+
if line_end < len(text):
|
|
56
|
+
if (
|
|
57
|
+
text[line_end] == "\r"
|
|
58
|
+
and line_end + 1 < len(text)
|
|
59
|
+
and text[line_end + 1] == "\n"
|
|
60
|
+
):
|
|
61
|
+
line_end += 2
|
|
62
|
+
else:
|
|
63
|
+
line_end += 1
|
|
64
|
+
|
|
65
|
+
ranges.append((start, content_end, line_end))
|
|
66
|
+
start = line_end
|
|
67
|
+
|
|
68
|
+
return ranges
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _opening_fence(line: str) -> tuple[str, int] | None:
|
|
72
|
+
index = 0
|
|
73
|
+
while index < len(line) and index < 3 and line[index] == " ":
|
|
74
|
+
index += 1
|
|
75
|
+
|
|
76
|
+
if index >= len(line) or line[index] not in "`~":
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
marker = line[index]
|
|
80
|
+
marker_end = index
|
|
81
|
+
while marker_end < len(line) and line[marker_end] == marker:
|
|
82
|
+
marker_end += 1
|
|
83
|
+
|
|
84
|
+
length = marker_end - index
|
|
85
|
+
if length < 3:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
info = line[marker_end:]
|
|
89
|
+
if marker == "`" and "`" in info:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
return marker, length
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _is_closing_fence(line: str, marker: str, minimum: int) -> bool:
|
|
96
|
+
index = 0
|
|
97
|
+
while index < len(line) and index < 3 and line[index] == " ":
|
|
98
|
+
index += 1
|
|
99
|
+
|
|
100
|
+
marker_end = index
|
|
101
|
+
while marker_end < len(line) and line[marker_end] == marker:
|
|
102
|
+
marker_end += 1
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
marker_end - index >= minimum
|
|
106
|
+
and all(char in " \t" for char in line[marker_end:])
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _strip_blockquotes(line: str) -> tuple[int, int]:
|
|
111
|
+
"""Return the number of leading quote containers and their source end."""
|
|
112
|
+
depth = 0
|
|
113
|
+
index = 0
|
|
114
|
+
|
|
115
|
+
while True:
|
|
116
|
+
marker = index
|
|
117
|
+
spaces = 0
|
|
118
|
+
while marker < len(line) and spaces < 3 and line[marker] == " ":
|
|
119
|
+
marker += 1
|
|
120
|
+
spaces += 1
|
|
121
|
+
if marker >= len(line) or line[marker] != ">":
|
|
122
|
+
return depth, index
|
|
123
|
+
|
|
124
|
+
index = marker + 1
|
|
125
|
+
if index < len(line) and line[index] in " \t":
|
|
126
|
+
index += 1
|
|
127
|
+
depth += 1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _strip_required_blockquotes(line: str, depth: int) -> int | None:
|
|
131
|
+
index = 0
|
|
132
|
+
for _ in range(depth):
|
|
133
|
+
marker = index
|
|
134
|
+
spaces = 0
|
|
135
|
+
while marker < len(line) and spaces < 3 and line[marker] == " ":
|
|
136
|
+
marker += 1
|
|
137
|
+
spaces += 1
|
|
138
|
+
if marker >= len(line) or line[marker] != ">":
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
index = marker + 1
|
|
142
|
+
if index < len(line) and line[index] in " \t":
|
|
143
|
+
index += 1
|
|
144
|
+
return index
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _read_list_marker(line: str, start: int) -> int | None:
|
|
148
|
+
"""Return a list item's content indent, or ``None`` for plain text."""
|
|
149
|
+
if start >= len(line):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
marker_end = start
|
|
153
|
+
if line[start] in "-+*":
|
|
154
|
+
marker_end += 1
|
|
155
|
+
elif line[start].isdigit():
|
|
156
|
+
while marker_end < len(line) and line[marker_end].isdigit():
|
|
157
|
+
marker_end += 1
|
|
158
|
+
if marker_end - start > 9 or marker_end >= len(line):
|
|
159
|
+
return None
|
|
160
|
+
if line[marker_end] not in ".)":
|
|
161
|
+
return None
|
|
162
|
+
marker_end += 1
|
|
163
|
+
else:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
if marker_end == len(line):
|
|
167
|
+
return marker_end + 1
|
|
168
|
+
if line[marker_end] != " ":
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
whitespace_end = marker_end
|
|
172
|
+
while whitespace_end < len(line) and line[whitespace_end] == " ":
|
|
173
|
+
whitespace_end += 1
|
|
174
|
+
padding = whitespace_end - marker_end
|
|
175
|
+
return marker_end + (padding if padding <= 4 else 1)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _list_parent_count(
|
|
179
|
+
stack: list[_ListItem],
|
|
180
|
+
marker_indent: int,
|
|
181
|
+
) -> int | None:
|
|
182
|
+
for level in range(len(stack) - 1, -1, -1):
|
|
183
|
+
item = stack[level]
|
|
184
|
+
if marker_indent == item.marker_indent:
|
|
185
|
+
return level
|
|
186
|
+
if item.content_indent <= marker_indent <= item.content_indent + 3:
|
|
187
|
+
return level + 1
|
|
188
|
+
return 0 if marker_indent <= 3 else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _list_content_start(line: str, stack: list[_ListItem]) -> int:
|
|
192
|
+
"""Strip active list indentation and update the list-container stack."""
|
|
193
|
+
cursor = 0
|
|
194
|
+
parsed_marker = False
|
|
195
|
+
|
|
196
|
+
while cursor < len(line):
|
|
197
|
+
marker = cursor
|
|
198
|
+
while marker < len(line) and line[marker] == " ":
|
|
199
|
+
marker += 1
|
|
200
|
+
|
|
201
|
+
content_indent = _read_list_marker(line, marker)
|
|
202
|
+
if content_indent is None:
|
|
203
|
+
break
|
|
204
|
+
|
|
205
|
+
parent_count = _list_parent_count(stack, marker)
|
|
206
|
+
if parent_count is None:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
stack[:] = stack[:parent_count]
|
|
210
|
+
stack.append(_ListItem(marker, content_indent))
|
|
211
|
+
cursor = min(content_indent, len(line))
|
|
212
|
+
parsed_marker = True
|
|
213
|
+
|
|
214
|
+
if parsed_marker:
|
|
215
|
+
return stack[-1].content_indent
|
|
216
|
+
|
|
217
|
+
if not line.strip(" \t"):
|
|
218
|
+
return stack[-1].content_indent if stack else 0
|
|
219
|
+
|
|
220
|
+
indentation = 0
|
|
221
|
+
while indentation < len(line) and line[indentation] == " ":
|
|
222
|
+
indentation += 1
|
|
223
|
+
|
|
224
|
+
for level in range(len(stack) - 1, -1, -1):
|
|
225
|
+
if indentation >= stack[level].content_indent:
|
|
226
|
+
stack[:] = stack[:level + 1]
|
|
227
|
+
return stack[-1].content_indent
|
|
228
|
+
|
|
229
|
+
stack.clear()
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _opening_container(
|
|
234
|
+
line: str,
|
|
235
|
+
list_stacks: dict[int, list[_ListItem]],
|
|
236
|
+
) -> tuple[_FenceContainer, int]:
|
|
237
|
+
# ponytail: space-indented lists and quote-first containers cover the
|
|
238
|
+
# current Obsidian scope; use a CommonMark parser if tab-expanded or
|
|
239
|
+
# list-before-quote nesting becomes necessary.
|
|
240
|
+
quote_depth, quote_end = _strip_blockquotes(line)
|
|
241
|
+
for depth in tuple(list_stacks):
|
|
242
|
+
if depth > quote_depth:
|
|
243
|
+
del list_stacks[depth]
|
|
244
|
+
|
|
245
|
+
list_stack = list_stacks.setdefault(quote_depth, [])
|
|
246
|
+
list_indent = _list_content_start(line[quote_end:], list_stack)
|
|
247
|
+
return (
|
|
248
|
+
_FenceContainer(quote_depth, list_indent),
|
|
249
|
+
min(quote_end + list_indent, len(line)),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _continuation_start(
|
|
254
|
+
line: str,
|
|
255
|
+
container: _FenceContainer,
|
|
256
|
+
) -> int | None:
|
|
257
|
+
quote_end = _strip_required_blockquotes(line, container.quote_depth)
|
|
258
|
+
if quote_end is None:
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
remainder = line[quote_end:]
|
|
262
|
+
if not remainder.strip(" \t"):
|
|
263
|
+
return len(line)
|
|
264
|
+
if container.list_indent and not remainder.startswith(
|
|
265
|
+
" " * container.list_indent
|
|
266
|
+
):
|
|
267
|
+
return None
|
|
268
|
+
return quote_end + container.list_indent
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _find_fenced_code(text: str) -> tuple[MarkdownSpan, ...]:
|
|
272
|
+
lines = _line_ranges(text)
|
|
273
|
+
spans: list[MarkdownSpan] = []
|
|
274
|
+
list_stacks: dict[int, list[_ListItem]] = {}
|
|
275
|
+
line_index = 0
|
|
276
|
+
|
|
277
|
+
while line_index < len(lines):
|
|
278
|
+
start, content_end, line_end = lines[line_index]
|
|
279
|
+
line = text[start:content_end]
|
|
280
|
+
container, container_end = _opening_container(line, list_stacks)
|
|
281
|
+
opening = _opening_fence(line[container_end:])
|
|
282
|
+
if opening is None:
|
|
283
|
+
line_index += 1
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
marker, minimum = opening
|
|
287
|
+
closing_index = line_index + 1
|
|
288
|
+
while closing_index < len(lines):
|
|
289
|
+
close_start, close_content_end, close_line_end = lines[closing_index]
|
|
290
|
+
close_line = text[close_start:close_content_end]
|
|
291
|
+
close_container_end = _continuation_start(close_line, container)
|
|
292
|
+
if close_container_end is None:
|
|
293
|
+
spans.append(
|
|
294
|
+
MarkdownSpan(
|
|
295
|
+
FENCED_CODE,
|
|
296
|
+
start,
|
|
297
|
+
line_end,
|
|
298
|
+
close_start,
|
|
299
|
+
close_start,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
line_index = closing_index
|
|
303
|
+
break
|
|
304
|
+
if _is_closing_fence(
|
|
305
|
+
close_line[close_container_end:],
|
|
306
|
+
marker,
|
|
307
|
+
minimum,
|
|
308
|
+
):
|
|
309
|
+
spans.append(
|
|
310
|
+
MarkdownSpan(
|
|
311
|
+
FENCED_CODE,
|
|
312
|
+
start,
|
|
313
|
+
line_end,
|
|
314
|
+
close_start,
|
|
315
|
+
close_line_end,
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
line_index = closing_index + 1
|
|
319
|
+
break
|
|
320
|
+
closing_index += 1
|
|
321
|
+
else:
|
|
322
|
+
spans.append(
|
|
323
|
+
MarkdownSpan(
|
|
324
|
+
FENCED_CODE,
|
|
325
|
+
start,
|
|
326
|
+
line_end,
|
|
327
|
+
len(text),
|
|
328
|
+
len(text),
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
line_index = len(lines)
|
|
332
|
+
|
|
333
|
+
return tuple(spans)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _visible_ranges(
|
|
337
|
+
length: int,
|
|
338
|
+
excluded: tuple[MarkdownSpan, ...],
|
|
339
|
+
) -> list[tuple[int, int]]:
|
|
340
|
+
ranges: list[tuple[int, int]] = []
|
|
341
|
+
index = 0
|
|
342
|
+
|
|
343
|
+
for span in excluded:
|
|
344
|
+
if index < span.start:
|
|
345
|
+
ranges.append((index, span.start))
|
|
346
|
+
index = max(index, span.end)
|
|
347
|
+
|
|
348
|
+
if index < length:
|
|
349
|
+
ranges.append((index, length))
|
|
350
|
+
|
|
351
|
+
return ranges
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _is_escaped(text: str, index: int, lower_bound: int) -> bool:
|
|
355
|
+
backslashes = 0
|
|
356
|
+
index -= 1
|
|
357
|
+
while index >= lower_bound and text[index] == "\\":
|
|
358
|
+
backslashes += 1
|
|
359
|
+
index -= 1
|
|
360
|
+
return backslashes % 2 == 1
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _delimiter_runs(
|
|
364
|
+
text: str,
|
|
365
|
+
start: int,
|
|
366
|
+
end: int,
|
|
367
|
+
delimiter: str,
|
|
368
|
+
) -> list[tuple[int, int]]:
|
|
369
|
+
runs: list[tuple[int, int]] = []
|
|
370
|
+
index = start
|
|
371
|
+
|
|
372
|
+
while index < end:
|
|
373
|
+
run_start = text.find(delimiter, index, end)
|
|
374
|
+
if run_start < 0:
|
|
375
|
+
break
|
|
376
|
+
|
|
377
|
+
run_end = run_start + 1
|
|
378
|
+
while run_end < end and text[run_end] == delimiter:
|
|
379
|
+
run_end += 1
|
|
380
|
+
|
|
381
|
+
if not _is_escaped(text, run_start, start):
|
|
382
|
+
runs.append((run_start, run_end))
|
|
383
|
+
index = run_end
|
|
384
|
+
|
|
385
|
+
return runs
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _pair_runs(
|
|
389
|
+
runs: list[tuple[int, int]],
|
|
390
|
+
kind: str,
|
|
391
|
+
exact_length: int | None = None,
|
|
392
|
+
) -> list[MarkdownSpan]:
|
|
393
|
+
if exact_length is not None:
|
|
394
|
+
runs = [run for run in runs if run[1] - run[0] == exact_length]
|
|
395
|
+
|
|
396
|
+
next_same: list[int | None] = [None] * len(runs)
|
|
397
|
+
nearest: dict[int, int] = {}
|
|
398
|
+
for index in range(len(runs) - 1, -1, -1):
|
|
399
|
+
length = runs[index][1] - runs[index][0]
|
|
400
|
+
next_same[index] = nearest.get(length)
|
|
401
|
+
nearest[length] = index
|
|
402
|
+
|
|
403
|
+
spans: list[MarkdownSpan] = []
|
|
404
|
+
index = 0
|
|
405
|
+
while index < len(runs):
|
|
406
|
+
closing_index = next_same[index]
|
|
407
|
+
if closing_index is None:
|
|
408
|
+
index += 1
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
opening = runs[index]
|
|
412
|
+
closing = runs[closing_index]
|
|
413
|
+
spans.append(
|
|
414
|
+
MarkdownSpan(
|
|
415
|
+
kind,
|
|
416
|
+
opening[0],
|
|
417
|
+
opening[1],
|
|
418
|
+
closing[0],
|
|
419
|
+
closing[1],
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
index = closing_index + 1
|
|
423
|
+
|
|
424
|
+
return spans
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _find_code_spans(
|
|
428
|
+
text: str,
|
|
429
|
+
fenced: tuple[MarkdownSpan, ...],
|
|
430
|
+
) -> tuple[MarkdownSpan, ...]:
|
|
431
|
+
spans: list[MarkdownSpan] = []
|
|
432
|
+
for start, end in _visible_ranges(len(text), fenced):
|
|
433
|
+
spans.extend(
|
|
434
|
+
_pair_runs(
|
|
435
|
+
_delimiter_runs(text, start, end, "`"),
|
|
436
|
+
CODE_SPAN,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
return tuple(spans)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _find_math_blocks(
|
|
443
|
+
text: str,
|
|
444
|
+
protected: tuple[MarkdownSpan, ...],
|
|
445
|
+
) -> tuple[MarkdownSpan, ...]:
|
|
446
|
+
spans: list[MarkdownSpan] = []
|
|
447
|
+
for start, end in _visible_ranges(len(text), protected):
|
|
448
|
+
spans.extend(
|
|
449
|
+
_pair_runs(
|
|
450
|
+
_delimiter_runs(text, start, end, "$"),
|
|
451
|
+
MATH_BLOCK,
|
|
452
|
+
exact_length=2,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
return tuple(spans)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def scan_markdown(text: str) -> MarkdownScan:
|
|
459
|
+
"""Return exact protected and display-math offsets in ``text``."""
|
|
460
|
+
fenced = _find_fenced_code(text)
|
|
461
|
+
code_spans = _find_code_spans(text, fenced)
|
|
462
|
+
protected = tuple(sorted((*fenced, *code_spans), key=lambda span: span.start))
|
|
463
|
+
return MarkdownScan(protected, _find_math_blocks(text, protected))
|