python-color-math 0.2.2__py3-none-any.whl → 0.2.13__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 (34) hide show
  1. color_math/__init__.py +7 -1
  2. color_math/adapters.py +35 -2
  3. color_math/colormath.default.json +57 -0
  4. color_math/config.py +184 -40
  5. color_math/converters/block.py +5 -0
  6. color_math/converters/derivative.py +21 -4
  7. color_math/converters/generic.py +15 -1
  8. color_math/converters/matrix.py +3 -3
  9. color_math/converters/unicode_converter.py +279 -0
  10. color_math/custom_definitions.py +100 -0
  11. color_math/ipython.py +183 -0
  12. color_math/jupyter.py +99 -0
  13. color_math/main.py +114 -25
  14. color_math/parsers/__init__.py +11 -1
  15. color_math/parsers/braket.py +43 -9
  16. color_math/parsers/constants.py +2 -2
  17. color_math/parsers/delimiters.py +225 -26
  18. color_math/parsers/differentials.py +4 -4
  19. color_math/parsers/frontmatter.py +168 -0
  20. color_math/parsers/markdown_scanner.py +16 -4
  21. color_math/parsers/math_parser.py +2 -2
  22. color_math/parsers/physics.py +44 -0
  23. color_math/parsers/taxonomy.py +13 -0
  24. color_math/parsers/units.py +15 -9
  25. color_math/parsers/variable_hash.py +4 -4
  26. color_math/tutorial.py +247 -10
  27. color_math/unicode.py +217 -0
  28. {python_color_math-0.2.2.dist-info → python_color_math-0.2.13.dist-info}/METADATA +104 -5
  29. python_color_math-0.2.13.dist-info/RECORD +58 -0
  30. python_color_math-0.2.2.dist-info/RECORD +0 -50
  31. {python_color_math-0.2.2.dist-info → python_color_math-0.2.13.dist-info}/WHEEL +0 -0
  32. {python_color_math-0.2.2.dist-info → python_color_math-0.2.13.dist-info}/entry_points.txt +0 -0
  33. {python_color_math-0.2.2.dist-info → python_color_math-0.2.13.dist-info}/licenses/LICENSE +0 -0
  34. {python_color_math-0.2.2.dist-info → python_color_math-0.2.13.dist-info}/top_level.txt +0 -0
color_math/__init__.py CHANGED
@@ -5,13 +5,19 @@ from .config import ColorMathOptions
5
5
  from .converters.block import convert_text
6
6
  from .undo import uncolor_text
7
7
 
8
- __version__ = "0.2.2"
8
+ from .ipython import ColorMath, colormath, load_ipython_extension, unload_ipython_extension
9
+
10
+ __version__ = "0.2.13"
9
11
 
10
12
  __all__ = [
11
13
  "__version__",
14
+ "ColorMath",
12
15
  "ColorMathOptions",
16
+ "colormath",
13
17
  "convert_text",
14
18
  "detect_format",
19
+ "load_ipython_extension",
15
20
  "transform_document",
16
21
  "uncolor_text",
22
+ "unload_ipython_extension",
17
23
  ]
color_math/adapters.py CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import copy
5
6
  import json
6
7
  import re
7
8
  from pathlib import Path
8
9
 
9
10
  from .config import ColorMathOptions
10
11
  from .converters.block import convert_math_block, convert_text
12
+ from .parsers.frontmatter import detect_note_field
11
13
  from .undo import uncolor_fragment, uncolor_text
12
14
  from .utils.latex_helpers import read_comment_end, read_verb_end
13
15
 
@@ -50,6 +52,19 @@ def detect_format(path: Path | None, requested: str = "auto") -> str:
50
52
  return "markdown"
51
53
 
52
54
 
55
+ def has_potential_math(text: str, format_name: str = "auto") -> bool:
56
+ """Ultra-fast probe to bypass documents that cannot possibly contain math."""
57
+ if "$" in text:
58
+ return True
59
+ if r"\[" in text or r"\(" in text:
60
+ return True
61
+ if format_name in ("tex", "auto") and r"\begin{" in text:
62
+ return True
63
+ if format_name in ("anki", "auto") and ("[$]" in text or "[$$]" in text):
64
+ return True
65
+ return False
66
+
67
+
53
68
  def transform_document(
54
69
  text: str,
55
70
  format_name: str,
@@ -57,10 +72,28 @@ def transform_document(
57
72
  palette: dict[str, str] | None = None,
58
73
  options: ColorMathOptions | None = None,
59
74
  ) -> str:
60
- if format_name == "markdown":
61
- return uncolor_text(text) if undo else convert_text(text, palette=palette, options=options)
62
75
  if format_name == "jupyter":
63
76
  return _transform_notebook(text, undo, palette=palette, options=options)
77
+
78
+ if undo:
79
+ if r"\textcolor" not in text:
80
+ return text
81
+ elif not has_potential_math(text, format_name):
82
+ return text
83
+
84
+ if format_name == "markdown":
85
+ if undo:
86
+ return uncolor_text(text)
87
+ opts = options
88
+ detection = detect_note_field(text)
89
+ if detection.is_quantum:
90
+ if opts is None:
91
+ opts = ColorMathOptions(color_quantum_operators=True, field="quantum")
92
+ else:
93
+ opts = copy.copy(opts)
94
+ opts.color_quantum_operators = True
95
+ opts.field = "quantum"
96
+ return convert_text(text, palette=palette, options=opts)
64
97
  if format_name == "anki":
65
98
  return _transform_delimited(text, ANKI_DELIMITERS, undo, palette=palette, options=options)
66
99
  if format_name == "tex":
@@ -0,0 +1,57 @@
1
+ {
2
+ "_comment": "Python Color Math factory default configuration. This file serves as an immutable reference. To customize settings, edit '.colormath.json'. If '.colormath.json' has syntax errors or missing keys, Color Math safely falls back to these defaults.",
3
+ "theme": "default",
4
+ "colors": {
5
+ "main": "#7aa2f7",
6
+ "orange": "#e0af68",
7
+ "dot": "white",
8
+ "derivative": "#bb9af7",
9
+ "chain": "#9ece6a",
10
+ "upper": "#bb9af7",
11
+ "relation": "white",
12
+ "arrow": "#f7768e",
13
+ "set": "#bb9af7",
14
+ "spacing": "white",
15
+ "parameter": "#bb9af7",
16
+ "unit": "#73daca"
17
+ },
18
+ "options": {
19
+ "enable_taxonomy": true,
20
+ "rainbow_delimiters": true,
21
+ "variable_data_flow": false,
22
+ "color_units": true,
23
+ "color_differentials": true,
24
+ "color_braket": true,
25
+ "color_dimensionless": true,
26
+ "rainbow_bare_braces": false,
27
+ "highlight_unmatched_braces": false
28
+ },
29
+ "unicode": {
30
+ "greek_style": "plane1",
31
+ "convert_definite_integrals": false,
32
+ "convert_bounded_operators": false,
33
+ "convert_prose_to_unicode": false,
34
+ "convert_prose_to_latex": false
35
+ },
36
+ "_role_descriptions": {
37
+ "main": "Primary functions, terms, and outermost operations (e.g. f(x))",
38
+ "derivative": "Derivative marks and differentiated functions (e.g. f'(x))",
39
+ "chain": "Chain rule factors, inner differential stages (e.g. g'(x), y')",
40
+ "orange": "Big operators, sums, integrals, and limits (e.g. \\sum, \\int, \\lim)",
41
+ "dot": "Multiplication symbols, dots, and cross products (e.g. \\cdot, \\times)",
42
+ "relation": "Equals, inequalities, and comparison symbols (e.g. =, <, \\le)",
43
+ "arrow": "Implication and mapping arrows (e.g. \\to, \\implies)",
44
+ "set": "Set theory relations and operators (e.g. \\in, \\subset)",
45
+ "spacing": "LaTeX spacing and alignment commands (e.g. \\quad, \\,)",
46
+ "upper": "Matrix exponents, transposes, and top indices (e.g. A^T, M^{-1})",
47
+ "parameter": "Inner function parameters and indexed variables",
48
+ "unit": "Physical units and dimensions (e.g. m/s, \\mu m, ^\\circ C)"
49
+ },
50
+ "_unicode_descriptions": {
51
+ "greek_style": "Greek letter typography: 'plane1' (Mathematical Italic: 𝝍, 𝝰) or 'standard' (ψ, α)",
52
+ "convert_definite_integrals": "Convert bounded integrals like \\int_a^b to ∫_a^b (default: false to preserve vertical TeX limits)",
53
+ "convert_bounded_operators": "Convert bounded summations/products like \\sum_{i=1}^n to ∑_{i=1}^n (default: false)",
54
+ "convert_prose_to_unicode": "Convert LaTeX commands like \\psi to 𝜓 in regular prose outside math blocks (default: false to protect prose)",
55
+ "convert_prose_to_latex": "Convert Unicode symbols like 𝝍 back to \\psi in regular prose outside math blocks (default: false)"
56
+ }
57
+ }
color_math/config.py CHANGED
@@ -18,6 +18,7 @@ DEFAULT_COLORS = {
18
18
  "spacing": "white",
19
19
  "parameter": "#bb9af7",
20
20
  "unit": "#73daca",
21
+ "energy_operator": "#2ac3de",
21
22
  }
22
23
 
23
24
  THEMES: dict[str, dict[str, str]] = {
@@ -84,6 +85,39 @@ ROLE_DESCRIPTIONS: dict[str, str] = {
84
85
  COLORS = dict(DEFAULT_COLORS)
85
86
 
86
87
 
88
+ from .unicode import (
89
+ UNICODE_BIG_OPERATORS,
90
+ UNICODE_INTEGRALS,
91
+ UNICODE_RELATIONS,
92
+ UNICODE_ARROWS,
93
+ UNICODE_SETS,
94
+ UNICODE_MULTIPLICATION,
95
+ UNICODE_CONSTANTS,
96
+ UNICODE_VECTORS,
97
+ UNICODE_GREEK_LOWER_STANDARD,
98
+ UNICODE_GREEK_LOWER_PLANE1,
99
+ UNICODE_GREEK_UPPER_STANDARD,
100
+ UNICODE_GREEK_UPPER_PLANE1,
101
+ )
102
+ from .custom_definitions import (
103
+ CUSTOM_CONSTANTS,
104
+ CUSTOM_FUNCTIONS,
105
+ CUSTOM_OPERATORS,
106
+ CUSTOM_PARAMETERS,
107
+ CUSTOM_QUANTUM_OPERATORS,
108
+ CUSTOM_RELATIONS,
109
+ sanitize_definition,
110
+ )
111
+
112
+ CUSTOM_BARE_FUNCTIONS: set[str] = set()
113
+ CUSTOM_MACRO_FUNCTIONS: set[str] = set()
114
+ for _raw_fn in CUSTOM_FUNCTIONS:
115
+ _macro, _bare = sanitize_definition(_raw_fn)
116
+ if _bare:
117
+ CUSTOM_BARE_FUNCTIONS.add(_bare)
118
+ if _macro:
119
+ CUSTOM_MACRO_FUNCTIONS.add(_macro)
120
+
87
121
  BIG_OPERATORS = {
88
122
  r"\sum",
89
123
  r"\prod",
@@ -95,6 +129,8 @@ BIG_OPERATORS = {
95
129
  r"\bigwedge",
96
130
  r"\bigoplus",
97
131
  r"\bigotimes",
132
+ *UNICODE_BIG_OPERATORS.values(),
133
+ *CUSTOM_OPERATORS,
98
134
  }
99
135
 
100
136
 
@@ -103,6 +139,7 @@ INTEGRALS = {
103
139
  r"\iint",
104
140
  r"\iiint",
105
141
  r"\oint",
142
+ *UNICODE_INTEGRALS.values(),
106
143
  }
107
144
 
108
145
 
@@ -138,6 +175,8 @@ RELATIONS = {
138
175
  "=",
139
176
  "<",
140
177
  ">",
178
+ *UNICODE_RELATIONS.values(),
179
+ *CUSTOM_RELATIONS,
141
180
  }
142
181
 
143
182
 
@@ -160,6 +199,7 @@ ARROWS = {
160
199
  r"\Leftrightarrow",
161
200
  r"\mapsto",
162
201
  r"\to",
202
+ *UNICODE_ARROWS.values(),
163
203
  }
164
204
 
165
205
 
@@ -182,6 +222,7 @@ SET_SYMBOLS = {
182
222
  r"\in",
183
223
  r"\cup",
184
224
  r"\cap",
225
+ *UNICODE_SETS.values(),
185
226
  }
186
227
 
187
228
 
@@ -199,6 +240,8 @@ MULTIPLICATION_SYMBOLS = {
199
240
  r"\times",
200
241
  "·",
201
242
  "*",
243
+ "×",
244
+ "✕",
202
245
  }
203
246
 
204
247
 
@@ -216,6 +259,7 @@ FUNCTION_COMMANDS = {
216
259
  r"\sinh",
217
260
  r"\tan",
218
261
  r"\tanh",
262
+ *CUSTOM_MACRO_FUNCTIONS,
219
263
  }
220
264
 
221
265
 
@@ -229,6 +273,7 @@ COLOR_COMMANDS = (
229
273
  | SET_SYMBOLS
230
274
  | SPACING_COMMANDS
231
275
  | MULTIPLICATION_SYMBOLS
276
+ | set(CUSTOM_QUANTUM_OPERATORS)
232
277
  )
233
278
 
234
279
 
@@ -256,6 +301,8 @@ MATH_CONSTANTS = {
256
301
  r"\mathrm{e}",
257
302
  r"\mathrm{i}",
258
303
  r"\mathrm{j}",
304
+ *UNICODE_CONSTANTS.values(),
305
+ *CUSTOM_CONSTANTS,
259
306
  }
260
307
 
261
308
 
@@ -377,14 +424,13 @@ EXTENDED_BARE_FUNCTIONS = frozenset({
377
424
  })
378
425
 
379
426
  FULL_BARE_FUNCTIONS = STANDARD_BARE_FUNCTIONS | EXTENDED_BARE_FUNCTIONS
380
- ALL_BARE_FUNCTIONS = FULL_BARE_FUNCTIONS
381
- BARE_FUNCTIONS = FULL_BARE_FUNCTIONS
427
+ ALL_BARE_FUNCTIONS = FULL_BARE_FUNCTIONS | CUSTOM_BARE_FUNCTIONS
428
+ BARE_FUNCTIONS = ALL_BARE_FUNCTIONS
382
429
 
383
430
 
384
431
  def get_bare_functions(options: ColorMathOptions | None = None) -> frozenset[str]:
385
- if options is not None and not options.extended_functions:
386
- return STANDARD_BARE_FUNCTIONS
387
- return FULL_BARE_FUNCTIONS
432
+ base = FULL_BARE_FUNCTIONS if (options is None or options.extended_functions) else STANDARD_BARE_FUNCTIONS
433
+ return frozenset(base | CUSTOM_BARE_FUNCTIONS)
388
434
 
389
435
 
390
436
  MATH_ACCENTS = {
@@ -445,8 +491,18 @@ MATH_PARAMETERS = {
445
491
  r"\Phi",
446
492
  r"\Psi",
447
493
  r"\Omega",
494
+ *UNICODE_GREEK_LOWER_STANDARD.values(),
495
+ *UNICODE_GREEK_LOWER_PLANE1.values(),
496
+ *UNICODE_GREEK_UPPER_STANDARD.values(),
497
+ *UNICODE_GREEK_UPPER_PLANE1.values(),
498
+ "𝜓",
499
+ "𝝍",
500
+ *CUSTOM_PARAMETERS,
448
501
  }
449
502
 
503
+ NON_SLASH_MATH_CONSTANTS = {c for c in MATH_CONSTANTS if not c.startswith("\\")}
504
+ NON_SLASH_MATH_PARAMETERS = {c for c in MATH_PARAMETERS if not c.startswith("\\")}
505
+
450
506
 
451
507
  MATH_FUNCTIONS = {
452
508
  r"\sin",
@@ -541,6 +597,7 @@ MATH_FUNCTIONS = {
541
597
  r"\dn",
542
598
  r"\avg",
543
599
  r"\len",
600
+ *CUSTOM_MACRO_FUNCTIONS,
544
601
  }
545
602
 
546
603
 
@@ -588,6 +645,10 @@ class ColorMathOptions:
588
645
  color_single_constants: bool = False
589
646
  normalize_braces: bool = False
590
647
  extended_functions: bool = True
648
+ color_quantum_operators: bool = False
649
+ rainbow_bare_braces: bool = False
650
+ highlight_unmatched_braces: bool = False
651
+ field: str = "math"
591
652
 
592
653
  @classmethod
593
654
  def extended(cls) -> ColorMathOptions:
@@ -604,6 +665,8 @@ class ColorMathOptions:
604
665
  color_single_constants=True,
605
666
  normalize_braces=False,
606
667
  extended_functions=True,
668
+ rainbow_bare_braces=False,
669
+ highlight_unmatched_braces=True,
607
670
  )
608
671
 
609
672
  @classmethod
@@ -621,6 +684,8 @@ class ColorMathOptions:
621
684
  color_single_constants=True,
622
685
  normalize_braces=False,
623
686
  extended_functions=True,
687
+ rainbow_bare_braces=True,
688
+ highlight_unmatched_braces=True,
624
689
  )
625
690
 
626
691
 
@@ -665,11 +730,51 @@ def get_global_config_path() -> Path | None:
665
730
  return None
666
731
 
667
732
 
668
- def load_config(path: Path | None = None) -> tuple[dict[str, str], ColorMathOptions]:
733
+ DEFAULT_UNICODE_CONFIG: dict[str, object] = {
734
+ "greek_style": "plane1",
735
+ "convert_definite_integrals": False,
736
+ "convert_bounded_operators": False,
737
+ "convert_prose_to_unicode": False,
738
+ "convert_prose_to_latex": False,
739
+ }
740
+
741
+
742
+ def get_bundled_default_config_path() -> Path:
743
+ """Return path to bundled colormath.default.json."""
744
+ return Path(__file__).parent / "colormath.default.json"
745
+
746
+
747
+ def get_bundled_default_config() -> dict[str, object]:
748
+ """Load the factory defaults configuration object."""
749
+ default_path = get_bundled_default_config_path()
750
+ if default_path.exists():
751
+ try:
752
+ return json.loads(default_path.read_text(encoding="utf-8"))
753
+ except Exception:
754
+ pass
755
+ return {
756
+ "_comment": "Python Color Math factory default configuration",
757
+ "theme": "default",
758
+ "colors": dict(DEFAULT_COLORS),
759
+ "options": asdict(ColorMathOptions()),
760
+ "unicode": dict(DEFAULT_UNICODE_CONFIG),
761
+ "_role_descriptions": ROLE_DESCRIPTIONS,
762
+ }
763
+
764
+
765
+ def load_config(
766
+ path: Path | None = None,
767
+ ) -> tuple[dict[str, str], ColorMathOptions, dict[str, object]]:
669
768
  """
670
769
  Load configuration from path, local '.colormath.json', or global user config.
671
- Returns (palette_dict, ColorMathOptions).
770
+ Fault-tolerant: If the user file contains JSON syntax errors or invalid types,
771
+ a warning is emitted and it safely falls back to factory defaults without crashing.
772
+ Returns (palette_dict, ColorMathOptions, unicode_config_dict).
672
773
  """
774
+ palette = dict(DEFAULT_COLORS)
775
+ options = ColorMathOptions()
776
+ unicode_config = dict(DEFAULT_UNICODE_CONFIG)
777
+
673
778
  target = path
674
779
  if target is None:
675
780
  local_candidate = Path(".colormath.json")
@@ -681,48 +786,87 @@ def load_config(path: Path | None = None) -> tuple[dict[str, str], ColorMathOpti
681
786
  target = global_candidate
682
787
 
683
788
  if target is None or not target.exists():
684
- return dict(DEFAULT_COLORS), ColorMathOptions()
789
+ return palette, options, unicode_config
685
790
 
686
791
  try:
687
792
  data = json.loads(target.read_text(encoding="utf-8"))
793
+ if not isinstance(data, dict):
794
+ sys.stderr.write(
795
+ f"color-math warning: config file '{target}' must contain a JSON object. "
796
+ "Safely falling back to default configuration.\n"
797
+ )
798
+ return palette, options, unicode_config
688
799
  except Exception as err:
689
- raise ValueError(f"Failed to parse config file {target}: {err}") from err
800
+ sys.stderr.write(
801
+ f"color-math warning: failed to parse config file '{target}': {err}. "
802
+ "Safely falling back to default configuration.\n"
803
+ )
804
+ return palette, options, unicode_config
805
+
806
+ # 1. Apply theme
807
+ if "theme" in data and isinstance(data["theme"], str):
808
+ try:
809
+ palette.update(get_theme(data["theme"]))
810
+ except ValueError:
811
+ pass
812
+
813
+ # 2. Apply color overrides
814
+ if "colors" in data and isinstance(data["colors"], dict):
815
+ for k, v in data["colors"].items():
816
+ if isinstance(k, str) and isinstance(v, str) and k in DEFAULT_COLORS:
817
+ palette[k] = v
818
+
819
+ # 3. Apply engine options
820
+ if "options" in data and isinstance(data["options"], dict):
821
+ raw_options = data["options"]
822
+ options = ColorMathOptions(
823
+ enable_taxonomy=bool(raw_options.get("enable_taxonomy", options.enable_taxonomy)),
824
+ rainbow_delimiters=bool(raw_options.get("rainbow_delimiters", options.rainbow_delimiters)),
825
+ variable_data_flow=bool(raw_options.get("variable_data_flow", options.variable_data_flow)),
826
+ color_units=bool(raw_options.get("color_units", options.color_units)),
827
+ color_differentials=bool(raw_options.get("color_differentials", options.color_differentials)),
828
+ color_braket=bool(raw_options.get("color_braket", options.color_braket)),
829
+ color_dimensionless=bool(raw_options.get("color_dimensionless", options.color_dimensionless)),
830
+ color_alignment=bool(raw_options.get("color_alignment", options.color_alignment)),
831
+ color_single_constants=bool(raw_options.get("color_single_constants", options.color_single_constants)),
832
+ normalize_braces=bool(raw_options.get("normalize_braces", options.normalize_braces)),
833
+ extended_functions=bool(raw_options.get("extended_functions", options.extended_functions)),
834
+ color_quantum_operators=bool(raw_options.get("color_quantum_operators", options.color_quantum_operators)),
835
+ rainbow_bare_braces=bool(raw_options.get("rainbow_bare_braces", options.rainbow_bare_braces)),
836
+ highlight_unmatched_braces=bool(raw_options.get("highlight_unmatched_braces", options.highlight_unmatched_braces)),
837
+ field=str(raw_options.get("field", options.field)),
838
+ )
690
839
 
691
- palette = dict(DEFAULT_COLORS)
692
- if isinstance(data, dict):
693
- if "theme" in data and isinstance(data["theme"], str):
694
- try:
695
- palette.update(get_theme(data["theme"]))
696
- except ValueError:
697
- pass
698
- if "colors" in data and isinstance(data["colors"], dict):
699
- for k, v in data["colors"].items():
700
- if isinstance(k, str) and isinstance(v, str):
701
- palette[k] = v
702
-
703
- raw_options = data.get("options", {}) if isinstance(data, dict) else {}
704
- options = ColorMathOptions(
705
- enable_taxonomy=bool(raw_options.get("enable_taxonomy", False)),
706
- rainbow_delimiters=bool(raw_options.get("rainbow_delimiters", False)),
707
- variable_data_flow=bool(raw_options.get("variable_data_flow", False)),
708
- color_units=bool(raw_options.get("color_units", False)),
709
- color_differentials=bool(raw_options.get("color_differentials", False)),
710
- color_braket=bool(raw_options.get("color_braket", False)),
711
- color_dimensionless=bool(raw_options.get("color_dimensionless", False)),
712
- )
713
- return palette, options
840
+ # 4. Apply unicode options
841
+ if "unicode" in data and isinstance(data["unicode"], dict):
842
+ raw_unicode = data["unicode"]
843
+ if "greek_style" in raw_unicode and raw_unicode["greek_style"] in ("plane1", "standard"):
844
+ unicode_config["greek_style"] = raw_unicode["greek_style"]
845
+ if "convert_definite_integrals" in raw_unicode:
846
+ unicode_config["convert_definite_integrals"] = bool(raw_unicode["convert_definite_integrals"])
847
+ if "convert_bounded_operators" in raw_unicode:
848
+ unicode_config["convert_bounded_operators"] = bool(raw_unicode["convert_bounded_operators"])
849
+ if "convert_prose_to_unicode" in raw_unicode:
850
+ unicode_config["convert_prose_to_unicode"] = bool(raw_unicode["convert_prose_to_unicode"])
851
+ if "convert_prose_to_latex" in raw_unicode:
852
+ unicode_config["convert_prose_to_latex"] = bool(raw_unicode["convert_prose_to_latex"])
853
+
854
+ return palette, options, unicode_config
714
855
 
715
856
 
716
857
  def save_default_config(path: Path) -> Path:
717
- """Save the clean default configuration to path."""
718
- content = {
719
- "_comment": "Python Color Math configuration file",
720
- "theme": "default",
721
- "colors": dict(DEFAULT_COLORS),
722
- "options": asdict(ColorMathOptions()),
723
- "_role_descriptions": ROLE_DESCRIPTIONS,
724
- }
858
+ """Save the clean default configuration to path, and copy the reference default file."""
859
+ content = get_bundled_default_config()
725
860
  path.write_text(json.dumps(content, indent=2), encoding="utf-8")
861
+
862
+ # Also save .colormath.default.json alongside .colormath.json if appropriate
863
+ target_dir = path.parent
864
+ default_ref_path = target_dir / ".colormath.default.json"
865
+ try:
866
+ default_ref_path.write_text(json.dumps(content, indent=2), encoding="utf-8")
867
+ except OSError:
868
+ pass
869
+
726
870
  return path
727
871
 
728
872
 
@@ -55,6 +55,8 @@ def _has_semantic_options(options: ColorMathOptions | None) -> bool:
55
55
  or options.color_differentials
56
56
  or options.color_braket
57
57
  or options.color_dimensionless
58
+ or options.color_quantum_operators
59
+ or options.field in ("quantum", "physics")
58
60
  )
59
61
  )
60
62
 
@@ -133,6 +135,9 @@ def convert_text(
133
135
  - inline $ ... $ math expressions
134
136
  - normal text passthrough
135
137
  """
138
+ if "$" not in text:
139
+ return text
140
+
136
141
  scan = scan_markdown(text)
137
142
  all_spans = sorted(
138
143
  [*scan.math_blocks, *scan.math_inlines],
@@ -25,10 +25,10 @@ NUMERIC_FRACTION_RE = re.compile(
25
25
  )
26
26
  PLAIN_COEFFICIENT_RE = re.compile(r"[A-Za-z]")
27
27
  ADDITIVE_SEPARATOR_RE = re.compile(
28
- r"[+\-=<>]|\\(?:pm|mp|leq|geq|neq|approx|sim|equiv)(?![A-Za-z])"
28
+ r"[+\-=<>]|\\(?:pm|mp|leq|geq|neq|approx|sim|equiv)(?![A-Za-z])|[±∓≤≥≠≈∼≡]"
29
29
  )
30
30
  MULTIPLICATIVE_GAP_RE = re.compile(
31
- r"(?:\s|[·*]|\\(?:cdot|times|,|:|;|!|quad|qquad)(?![A-Za-z]))*"
31
+ r"(?:\s|[·*×✕]|\\(?:cdot|times|,|:|;|!|quad|qquad)(?![A-Za-z]))*"
32
32
  )
33
33
 
34
34
 
@@ -39,12 +39,25 @@ def _compact(value: str) -> str:
39
39
  def _is_derivative_prefix(value: str) -> bool:
40
40
  compact = _compact(value)
41
41
  return compact.startswith(
42
- (r"\frac{d}{d", r"\dfrac{d}{d", r"\tfrac{d}{d")
42
+ (
43
+ r"\frac{d}{d",
44
+ r"\dfrac{d}{d",
45
+ r"\tfrac{d}{d",
46
+ r"\frac{\mathrm{d}}{\mathrm{d",
47
+ r"\dfrac{\mathrm{d}}{\mathrm{d",
48
+ r"\tfrac{\mathrm{d}}{\mathrm{d",
49
+ r"\frac{\partial}{\partial",
50
+ r"\dfrac{\partial}{\partial",
51
+ r"\tfrac{\partial}{\partial",
52
+ r"\frac{∂}{∂",
53
+ r"\dfrac{∂}{∂",
54
+ r"\tfrac{∂}{∂",
55
+ )
43
56
  )
44
57
 
45
58
 
46
59
  def _is_prime(value: str) -> bool:
47
- return bool(re.match(r"(?:[A-Za-z]|\\[A-Za-z]+)'", value.lstrip()))
60
+ return bool(re.match(r"(?:[A-Za-z]|\\[A-Za-z]+|[\u0370-\u03FF\U0001D400-\U0001D7FF])'", value.lstrip()))
48
61
 
49
62
 
50
63
  def _is_numeric(value: str) -> bool:
@@ -227,6 +240,10 @@ def convert_derivative_line(source: str) -> str | None:
227
240
  if block is None:
228
241
  return None
229
242
 
243
+ # Delegate matrix/tensor equations to convert_matrix_block
244
+ if re.search(r"\\(?:mathbf|mathcal|begin\s*\{(?:Bmatrix|Vmatrix|array|bmatrix|matrix|pmatrix|smallmatrix|vmatrix)\})", block.body):
245
+ return None
246
+
230
247
  body_start = skip_ignorable(block.body, 0, len(block.body))
231
248
  prefix = read_operand(block.body, body_start)
232
249
  if prefix is None or not _is_derivative_prefix(prefix.text(block.body)):
@@ -15,6 +15,7 @@ from ..parsers.dimensionless import find_dimensionless_spans, collect_dimensionl
15
15
  from ..parsers.delimiters import collect_delimiter_spans
16
16
  from ..parsers.taxonomy import collect_taxonomy_spans
17
17
  from ..parsers.variable_hash import collect_variable_spans
18
+ from ..parsers.physics import collect_quantum_operator_spans
18
19
  from ..utils.latex_helpers import contains_color_wrapper, normalize_latex_braces
19
20
  from ..utils.spans import ColorSpan, apply_color_spans
20
21
  from ..undo import uncolor_fragment
@@ -104,7 +105,14 @@ def color_latex_body(
104
105
  spans.extend(collect_single_constant_spans(normalized, pal))
105
106
 
106
107
  if opts.rainbow_delimiters:
107
- spans.extend(collect_delimiter_spans(normalized))
108
+ spans.extend(
109
+ collect_delimiter_spans(
110
+ normalized,
111
+ for_latex_wrap=True,
112
+ include_bare_braces=opts.rainbow_bare_braces,
113
+ highlight_unmatched=opts.highlight_unmatched_braces,
114
+ )
115
+ )
108
116
 
109
117
  if opts.enable_taxonomy:
110
118
  spans.extend(collect_taxonomy_spans(normalized, pal, unit_spans, diff_spans, dim_spans))
@@ -112,6 +120,12 @@ def color_latex_body(
112
120
  if opts.variable_data_flow:
113
121
  spans.extend(collect_variable_spans(normalized, None, unit_spans, diff_spans, dim_spans, bare_functions))
114
122
 
123
+ if opts.color_quantum_operators or opts.field in ("quantum", "physics"):
124
+ quantum_spans = collect_quantum_operator_spans(normalized, pal, opts)
125
+ if quantum_spans:
126
+ spans = [s for s in spans if not any(q.start <= s.start and s.end <= q.end for q in quantum_spans)]
127
+ spans.extend(quantum_spans)
128
+
115
129
  return apply_color_spans(normalized, spans)
116
130
 
117
131
 
@@ -15,7 +15,7 @@ from .semantic import first_equality, parse_math_block, relation_spans
15
15
 
16
16
  MATRIX_COMMAND_RE = re.compile(
17
17
  r"\\(?:mathbf|mathcal|nabla|det|tr|Tr|trace|Vert|lVert)"
18
- r"(?![A-Za-z])|\\\|(?![A-Za-z])|"
18
+ r"(?![A-Za-z])|∇|\\\|(?![A-Za-z])|"
19
19
  r"\\operatorname\s*\{\s*tr\s*\}"
20
20
  )
21
21
  MATRIX_ENV_RE = re.compile(
@@ -126,13 +126,13 @@ def convert_matrix_block(source: str) -> str | None:
126
126
  if lhs_first.startswith((r"\det", r"\operatorname{tr}"))
127
127
  else ("main",)
128
128
  )
129
- elif lhs_first.startswith((r"\frac{\partial}", r"\nabla")):
129
+ elif lhs_first.startswith((r"\frac{\partial}", r"\dfrac{\partial}", r"\tfrac{\partial}", r"\frac{∂}", r"\dfrac{∂}", r"\tfrac{∂}", r"\nabla", "∇")):
130
130
  lhs_colors = ("upper", "main")
131
131
  else:
132
132
  lhs_colors = ("upper", "chain", "orange")
133
133
 
134
134
  lhs_text = re.sub(r"\s+", "", block.body[:equality[0]])
135
- if lhs_first.startswith(r"\frac{\partial}"):
135
+ if lhs_first.startswith((r"\frac{\partial}", r"\dfrac{\partial}", r"\tfrac{\partial}", r"\frac{∂}", r"\dfrac{∂}", r"\tfrac{∂}")):
136
136
  rhs_colors = ("chain", "main")
137
137
  elif len(lhs) > 1 and len(rhs) == 1:
138
138
  rhs_colors = ("main",)