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