python-color-math 0.1.0__tar.gz
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.
- python_color_math-0.1.0/LICENSE +21 -0
- python_color_math-0.1.0/PKG-INFO +167 -0
- python_color_math-0.1.0/README.md +116 -0
- python_color_math-0.1.0/color_math/__init__.py +8 -0
- python_color_math-0.1.0/color_math/__main__.py +5 -0
- python_color_math-0.1.0/color_math/adapters.py +288 -0
- python_color_math-0.1.0/color_math/config.py +351 -0
- python_color_math-0.1.0/color_math/converters/__init__.py +27 -0
- python_color_math-0.1.0/color_math/converters/align.py +18 -0
- python_color_math-0.1.0/color_math/converters/block.py +126 -0
- python_color_math-0.1.0/color_math/converters/derivative.py +266 -0
- python_color_math-0.1.0/color_math/converters/equation.py +5 -0
- python_color_math-0.1.0/color_math/converters/generic.py +101 -0
- python_color_math-0.1.0/color_math/converters/integral.py +16 -0
- python_color_math-0.1.0/color_math/converters/limit.py +16 -0
- python_color_math-0.1.0/color_math/converters/matrix.py +143 -0
- python_color_math-0.1.0/color_math/converters/semantic.py +76 -0
- python_color_math-0.1.0/color_math/io.py +53 -0
- python_color_math-0.1.0/color_math/main.py +162 -0
- python_color_math-0.1.0/color_math/parsers/__init__.py +64 -0
- python_color_math-0.1.0/color_math/parsers/braket.py +109 -0
- python_color_math-0.1.0/color_math/parsers/delimiters.py +151 -0
- python_color_math-0.1.0/color_math/parsers/differentials.py +71 -0
- python_color_math-0.1.0/color_math/parsers/dimensionless.py +74 -0
- python_color_math-0.1.0/color_math/parsers/latex_spans.py +1050 -0
- python_color_math-0.1.0/color_math/parsers/markdown_scanner.py +463 -0
- python_color_math-0.1.0/color_math/parsers/math_parser.py +366 -0
- python_color_math-0.1.0/color_math/parsers/scanner.py +351 -0
- python_color_math-0.1.0/color_math/parsers/taxonomy.py +124 -0
- python_color_math-0.1.0/color_math/parsers/units.py +98 -0
- python_color_math-0.1.0/color_math/parsers/variable_hash.py +126 -0
- python_color_math-0.1.0/color_math/self_test.py +224 -0
- python_color_math-0.1.0/color_math/undo.py +63 -0
- python_color_math-0.1.0/color_math/utils/__init__.py +30 -0
- python_color_math-0.1.0/color_math/utils/coloring.py +61 -0
- python_color_math-0.1.0/color_math/utils/latex_helpers.py +232 -0
- python_color_math-0.1.0/color_math/utils/spans.py +77 -0
- python_color_math-0.1.0/pyproject.toml +45 -0
- python_color_math-0.1.0/python_color_math.egg-info/PKG-INFO +167 -0
- python_color_math-0.1.0/python_color_math.egg-info/SOURCES.txt +43 -0
- python_color_math-0.1.0/python_color_math.egg-info/dependency_links.txt +1 -0
- python_color_math-0.1.0/python_color_math.egg-info/entry_points.txt +2 -0
- python_color_math-0.1.0/python_color_math.egg-info/top_level.txt +1 -0
- python_color_math-0.1.0/setup.cfg +4 -0
- python_color_math-0.1.0/tests/test_features.py +168 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aditya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-color-math
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pure Python semantic LaTeX and MathJax colorizer for Obsidian Markdown, KaTeX, and math notes.
|
|
5
|
+
Author: 36ty-blip
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Aditya
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/36ty-blip/python-color-math
|
|
29
|
+
Project-URL: Repository, https://github.com/36ty-blip/python-color-math
|
|
30
|
+
Project-URL: Issues, https://github.com/36ty-blip/python-color-math/issues
|
|
31
|
+
Keywords: obsidian,latex,math,markdown,colorizer,katex,mathjax,cli
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Environment :: Console
|
|
34
|
+
Classifier: Intended Audience :: Science/Research
|
|
35
|
+
Classifier: Intended Audience :: Education
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Operating System :: OS Independent
|
|
38
|
+
Classifier: Programming Language :: Python :: 3
|
|
39
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
44
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
45
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
46
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
47
|
+
Requires-Python: >=3.10
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
License-File: LICENSE
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
# 🎨 color-math (Python)
|
|
53
|
+
|
|
54
|
+
> Pure Python semantic LaTeX and MathJax colorizer for Obsidian Markdown notes, KaTeX documents, and scientific workflows. Zero dependencies.
|
|
55
|
+
|
|
56
|
+
[](https://github.com/36ty-blip/python-color-math/actions/workflows/ci.yml)
|
|
57
|
+
[](https://pypi.org/project/python-color-math/)
|
|
58
|
+
[](LICENSE)
|
|
59
|
+
[](https://www.python.org/)
|
|
60
|
+
|
|
61
|
+
`python-color-math` is a fast, standalone command-line tool and Python library that automatically parses LaTeX and MathJax expressions and wraps elements in semantic `\textcolor{...}{...}` annotations. It works across plain Markdown, Obsidian notes, Quarto documents, Jupyter notebooks, and raw LaTeX files without modifying surrounding prose or code blocks.
|
|
62
|
+
|
|
63
|
+
> [!NOTE]
|
|
64
|
+
> **Looking for the Obsidian Plugin?** Check out [obsidian-color-math](https://github.com/36ty-blip/obsidian-color-math) for real-time live preview math coloring directly inside Obsidian!
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## ✨ Features
|
|
69
|
+
|
|
70
|
+
- **Zero External Dependencies**: Runs entirely on the Python Standard Library (Python 3.10+).
|
|
71
|
+
- **Physical Units & Metric Prefixes**: Distinguishes units (`\mu m`, `m/s`, `nm`, `kg`) from variables, shielding algebraic variables like $m$ in $F = ma$ or $E = mc^2$.
|
|
72
|
+
- **Calculus Differentials & Derivatives**: Recognizes infinitesimal differentials ($dx$, $dt$, $d\theta$) and derivative fractions ($\frac{d}{dx}$, $\frac{\partial \psi}{\partial t}$), while preserving standalone distance $d$ and relations like $d\iff e$.
|
|
73
|
+
- **Dirac Quantum Bra-Ket Notation**: Formats kets ($|\psi\rangle$), bras ($\langle\phi|$), and expectation values ($\langle\phi|\hat{H}|\psi\rangle$) while protecting absolute values ($|x| < 5$).
|
|
74
|
+
- **Dimensionless Numbers**: Identifies contiguous engineering numbers ($Re$, $Ma$, $Pr$, $Nu$) without capturing separated variables ($R\,e$).
|
|
75
|
+
- **Rainbow Delimiters**: Stack-based delimiter matching that colors nested parentheses, brackets, and braces by nesting depth.
|
|
76
|
+
- **Mathematical Symbol Taxonomy**: Categorizes constants ($\pi, \hbar, \infty$), Greek parameters ($\alpha, \theta, \lambda$), functions ($\sin, \cos, \ln$), and bound summation/limit indices.
|
|
77
|
+
- **Variable Data-Flow Hashing**: Deterministically hashes identifiers across an equation so each unique variable maintains a consistent color across terms.
|
|
78
|
+
- **Boxed Equations**: Preserves `\boxed{...}` wrappers while coloring internal mathematical structures.
|
|
79
|
+
- **Signature Tokyo Night Palette**: Muted pastel tones calibrated for readability and reduced eye strain.
|
|
80
|
+
- **Markdown & TeX Safety**: Fenced code blocks, inline code, TeX comments, and `\verb` blocks are protected and left unmodified.
|
|
81
|
+
- **Lossless & Reversible**: Includes a `--undo` command to cleanly strip all injected colors back to original LaTeX notation.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 🚀 Installation
|
|
86
|
+
|
|
87
|
+
Install from PyPI:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pip install python-color-math
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or install from source:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
git clone https://github.com/36ty-blip/python-color-math.git
|
|
97
|
+
cd python-color-math
|
|
98
|
+
pip install .
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## 💻 CLI Usage
|
|
104
|
+
|
|
105
|
+
### Process a Markdown or LaTeX file
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# Colorize equations in a single note
|
|
109
|
+
color-math note.md
|
|
110
|
+
|
|
111
|
+
# Colorize an entire directory of notes recursively
|
|
112
|
+
color-math ./vault/
|
|
113
|
+
|
|
114
|
+
# Preview changes without modifying files (Dry Run)
|
|
115
|
+
color-math --dry-run note.md
|
|
116
|
+
|
|
117
|
+
# Revert and clean colors back to plain LaTeX
|
|
118
|
+
color-math --undo note.md
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Stdin / Pipe Support
|
|
122
|
+
|
|
123
|
+
Pipe equations directly through the CLI:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
echo "$$\frac{d}{dx}f(g(x)) = f'(g(x)) \cdot g'(x)$$" | color-math
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 🐍 Python API
|
|
132
|
+
|
|
133
|
+
Use `color-math` directly as a library in your Python applications, scripts, or data pipelines:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from color_math import colorize_latex, ColorMathOptions
|
|
137
|
+
|
|
138
|
+
latex = r"\frac{d}{dx}f(g(x)) = f'(g(x)) \cdot g'(x)"
|
|
139
|
+
|
|
140
|
+
# Basic coloring with default Tokyo Night palette
|
|
141
|
+
colored = colorize_latex(latex)
|
|
142
|
+
print(colored)
|
|
143
|
+
|
|
144
|
+
# Enable extended features (Rainbow delimiters, Variable hashing, etc.)
|
|
145
|
+
opts = ColorMathOptions.all_enabled()
|
|
146
|
+
extended_colored = colorize_latex(latex, options=opts)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 🧪 Testing
|
|
152
|
+
|
|
153
|
+
Run the comprehensive test suites (51 tests):
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# Run feature test suite
|
|
157
|
+
python -m unittest discover tests
|
|
158
|
+
|
|
159
|
+
# Run regression self-test suite
|
|
160
|
+
python -m tests.self_test
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## 📄 License
|
|
166
|
+
|
|
167
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# 🎨 color-math (Python)
|
|
2
|
+
|
|
3
|
+
> Pure Python semantic LaTeX and MathJax colorizer for Obsidian Markdown notes, KaTeX documents, and scientific workflows. Zero dependencies.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/36ty-blip/python-color-math/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/python-color-math/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://www.python.org/)
|
|
9
|
+
|
|
10
|
+
`python-color-math` is a fast, standalone command-line tool and Python library that automatically parses LaTeX and MathJax expressions and wraps elements in semantic `\textcolor{...}{...}` annotations. It works across plain Markdown, Obsidian notes, Quarto documents, Jupyter notebooks, and raw LaTeX files without modifying surrounding prose or code blocks.
|
|
11
|
+
|
|
12
|
+
> [!NOTE]
|
|
13
|
+
> **Looking for the Obsidian Plugin?** Check out [obsidian-color-math](https://github.com/36ty-blip/obsidian-color-math) for real-time live preview math coloring directly inside Obsidian!
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## ✨ Features
|
|
18
|
+
|
|
19
|
+
- **Zero External Dependencies**: Runs entirely on the Python Standard Library (Python 3.10+).
|
|
20
|
+
- **Physical Units & Metric Prefixes**: Distinguishes units (`\mu m`, `m/s`, `nm`, `kg`) from variables, shielding algebraic variables like $m$ in $F = ma$ or $E = mc^2$.
|
|
21
|
+
- **Calculus Differentials & Derivatives**: Recognizes infinitesimal differentials ($dx$, $dt$, $d\theta$) and derivative fractions ($\frac{d}{dx}$, $\frac{\partial \psi}{\partial t}$), while preserving standalone distance $d$ and relations like $d\iff e$.
|
|
22
|
+
- **Dirac Quantum Bra-Ket Notation**: Formats kets ($|\psi\rangle$), bras ($\langle\phi|$), and expectation values ($\langle\phi|\hat{H}|\psi\rangle$) while protecting absolute values ($|x| < 5$).
|
|
23
|
+
- **Dimensionless Numbers**: Identifies contiguous engineering numbers ($Re$, $Ma$, $Pr$, $Nu$) without capturing separated variables ($R\,e$).
|
|
24
|
+
- **Rainbow Delimiters**: Stack-based delimiter matching that colors nested parentheses, brackets, and braces by nesting depth.
|
|
25
|
+
- **Mathematical Symbol Taxonomy**: Categorizes constants ($\pi, \hbar, \infty$), Greek parameters ($\alpha, \theta, \lambda$), functions ($\sin, \cos, \ln$), and bound summation/limit indices.
|
|
26
|
+
- **Variable Data-Flow Hashing**: Deterministically hashes identifiers across an equation so each unique variable maintains a consistent color across terms.
|
|
27
|
+
- **Boxed Equations**: Preserves `\boxed{...}` wrappers while coloring internal mathematical structures.
|
|
28
|
+
- **Signature Tokyo Night Palette**: Muted pastel tones calibrated for readability and reduced eye strain.
|
|
29
|
+
- **Markdown & TeX Safety**: Fenced code blocks, inline code, TeX comments, and `\verb` blocks are protected and left unmodified.
|
|
30
|
+
- **Lossless & Reversible**: Includes a `--undo` command to cleanly strip all injected colors back to original LaTeX notation.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 🚀 Installation
|
|
35
|
+
|
|
36
|
+
Install from PyPI:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install python-color-math
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Or install from source:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/36ty-blip/python-color-math.git
|
|
46
|
+
cd python-color-math
|
|
47
|
+
pip install .
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 💻 CLI Usage
|
|
53
|
+
|
|
54
|
+
### Process a Markdown or LaTeX file
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Colorize equations in a single note
|
|
58
|
+
color-math note.md
|
|
59
|
+
|
|
60
|
+
# Colorize an entire directory of notes recursively
|
|
61
|
+
color-math ./vault/
|
|
62
|
+
|
|
63
|
+
# Preview changes without modifying files (Dry Run)
|
|
64
|
+
color-math --dry-run note.md
|
|
65
|
+
|
|
66
|
+
# Revert and clean colors back to plain LaTeX
|
|
67
|
+
color-math --undo note.md
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Stdin / Pipe Support
|
|
71
|
+
|
|
72
|
+
Pipe equations directly through the CLI:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
echo "$$\frac{d}{dx}f(g(x)) = f'(g(x)) \cdot g'(x)$$" | color-math
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 🐍 Python API
|
|
81
|
+
|
|
82
|
+
Use `color-math` directly as a library in your Python applications, scripts, or data pipelines:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from color_math import colorize_latex, ColorMathOptions
|
|
86
|
+
|
|
87
|
+
latex = r"\frac{d}{dx}f(g(x)) = f'(g(x)) \cdot g'(x)"
|
|
88
|
+
|
|
89
|
+
# Basic coloring with default Tokyo Night palette
|
|
90
|
+
colored = colorize_latex(latex)
|
|
91
|
+
print(colored)
|
|
92
|
+
|
|
93
|
+
# Enable extended features (Rainbow delimiters, Variable hashing, etc.)
|
|
94
|
+
opts = ColorMathOptions.all_enabled()
|
|
95
|
+
extended_colored = colorize_latex(latex, options=opts)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 🧪 Testing
|
|
101
|
+
|
|
102
|
+
Run the comprehensive test suites (51 tests):
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Run feature test suite
|
|
106
|
+
python -m unittest discover tests
|
|
107
|
+
|
|
108
|
+
# Run regression self-test suite
|
|
109
|
+
python -m tests.self_test
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 📄 License
|
|
115
|
+
|
|
116
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -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"]
|
|
@@ -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))
|