diff --git a/README.md b/README.md index bbf1c89..928054b 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ Copiez `config.json.template` vers `config.json` puis adaptez : "api_key": "", "server_url": "http://localhost:11434", "hotkey": "ctrl+alt+a", - "request_timeout_seconds": 300 + "request_timeout_seconds": 300, + "math_text_format": "unicode" } ``` @@ -68,6 +69,22 @@ Copiez `config.json.template` vers `config.json` puis adaptez : `request_timeout_seconds` vaut `300` par défaut. Si Ollama charge un gros modèle ou répond lentement, augmentez cette valeur. Mettez `0` pour désactiver le timeout côté application. +### Formatage mathématique Word + +`math_text_format` vaut `unicode` par défaut. Ce mode transforme les sorties linéaires fréquentes avant l'injection clavier pour éviter que Word reçoive du texte brut comme `z_1` ou `x^2` : + +```text +z_1 + x^2 + \alpha + \infty +``` + +devient : + +```text +z₁ + x² + α + ∞ +``` + +Mettez `"math_text_format": "plain"` si vous voulez injecter la réponse exactement telle que le modèle l'a renvoyée. + ## Compilation ### Windows diff --git a/config.json.template b/config.json.template index fcea5aa..4609a3d 100644 --- a/config.json.template +++ b/config.json.template @@ -5,6 +5,7 @@ "server_url": "http://localhost:11434", "hotkey": "ctrl+alt+a", "request_timeout_seconds": 300, + "math_text_format": "unicode", "type_delay_seconds": 0, "system_prompt": "Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute." } diff --git a/src/ai_typewriter/app.py b/src/ai_typewriter/app.py index 54a97e0..7b0012e 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -12,6 +12,7 @@ from .ai_client import AIClientError, ask_ai from .clipboard_capture import capture_clipboard from .config import AppConfig, load_config from .key_stepper import KeyStepper +from .math_format import format_math_text LOG = logging.getLogger("ai_typewriter") @@ -43,16 +44,19 @@ class AITypewriterApp: LOG.info("Texte capturé: %r", selected) started_at = time.perf_counter() answer = ask_ai(selected, self.config) + answer_to_type = format_math_text(answer, self.config.math_text_format) elapsed = time.perf_counter() - started_at LOG.info( - "Réponse reçue en %.2f s: %d caractères. Premier caractère écrit automatiquement, %d restants au clavier.", + "Réponse reçue en %.2f s: %d caractères (%d après formatage %s). Premier caractère écrit automatiquement, %d restants au clavier.", elapsed, len(answer), - max(len(answer) - 1, 0), + len(answer_to_type), + self.config.math_text_format, + max(len(answer_to_type) - 1, 0), ) if self._stepper is not None: self._stepper.stop() - self._stepper = KeyStepper(answer, delay=self.config.type_delay_seconds) + self._stepper = KeyStepper(answer_to_type, delay=self.config.type_delay_seconds) self._stepper.start() except (AIClientError, FileNotFoundError, ValueError) as exc: LOG.error("%s", exc) @@ -84,9 +88,10 @@ def main(argv: list[str] | None = None) -> int: try: started_at = time.perf_counter() answer = ask_ai(args.ask, config) + answer_to_type = format_math_text(answer, config.math_text_format) elapsed = time.perf_counter() - started_at - LOG.info("Réponse générée en %.2f s: %d caractères.", elapsed, len(answer)) - print(answer) + LOG.info("Réponse générée en %.2f s: %d caractères (%d après formatage %s).", elapsed, len(answer), len(answer_to_type), config.math_text_format) + print(answer_to_type) return 0 except AIClientError as exc: LOG.error("%s", exc) diff --git a/src/ai_typewriter/config.py b/src/ai_typewriter/config.py index f661699..55d7e3f 100644 --- a/src/ai_typewriter/config.py +++ b/src/ai_typewriter/config.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, Literal Provider = Literal["ollama", "gemini"] +MathTextFormat = Literal["plain", "unicode", "unicode_math"] @dataclass(frozen=True) @@ -18,6 +19,7 @@ class AppConfig: request_timeout_seconds: float | None = 300.0 copy_wait_seconds: float = 1.0 type_delay_seconds: float = 0.0 + math_text_format: MathTextFormat = "unicode" restore_clipboard: bool = True system_prompt: str = ( "Réponds directement et de manière ultra-concise. " @@ -40,6 +42,13 @@ def _coerce_timeout(value: Any) -> float | None: return timeout +def _coerce_math_text_format(value: Any) -> MathTextFormat: + mode = str(value or "unicode").lower().strip() + if mode not in {"plain", "unicode", "unicode_math"}: + raise ValueError("config.math_text_format doit être 'plain' ou 'unicode'") + return mode # type: ignore[return-value] + + def load_config(path: str | Path = "config.json") -> AppConfig: cfg_path = Path(path) if not cfg_path.exists(): @@ -56,6 +65,7 @@ def load_config(path: str | Path = "config.json") -> AppConfig: request_timeout_seconds=_coerce_timeout(data.get("request_timeout_seconds", 300)), copy_wait_seconds=float(data.get("copy_wait_seconds", 1)), type_delay_seconds=float(data.get("type_delay_seconds", 0)), + math_text_format=_coerce_math_text_format(data.get("math_text_format", "unicode")), restore_clipboard=bool(data.get("restore_clipboard", True)), system_prompt=str(data.get("system_prompt", AppConfig.system_prompt)), ) diff --git a/src/ai_typewriter/math_format.py b/src/ai_typewriter/math_format.py new file mode 100644 index 0000000..ef3449a --- /dev/null +++ b/src/ai_typewriter/math_format.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import re + +GREEK_AND_SYMBOLS = { + r"\alpha": "α", + r"\beta": "β", + r"\gamma": "γ", + r"\delta": "δ", + r"\epsilon": "ε", + r"\varepsilon": "ε", + r"\zeta": "ζ", + r"\eta": "η", + r"\theta": "θ", + r"\vartheta": "ϑ", + r"\iota": "ι", + r"\kappa": "κ", + r"\lambda": "λ", + r"\mu": "μ", + r"\nu": "ν", + r"\xi": "ξ", + r"\pi": "π", + r"\rho": "ρ", + r"\sigma": "σ", + r"\tau": "τ", + r"\upsilon": "υ", + r"\phi": "φ", + r"\varphi": "φ", + r"\chi": "χ", + r"\psi": "ψ", + r"\omega": "ω", + r"\Gamma": "Γ", + r"\Delta": "Δ", + r"\Theta": "Θ", + r"\Lambda": "Λ", + r"\Xi": "Ξ", + r"\Pi": "Π", + r"\Sigma": "Σ", + r"\Phi": "Φ", + r"\Psi": "Ψ", + r"\Omega": "Ω", + r"\infty": "∞", + r"\leq": "≤", + r"\le": "≤", + r"\geq": "≥", + r"\ge": "≥", + r"\neq": "≠", + r"\ne": "≠", + r"\approx": "≈", + r"\times": "×", + r"\cdot": "·", + r"\pm": "±", + r"\mp": "∓", + r"\to": "→", + r"\rightarrow": "→", + r"\leftarrow": "←", + r"\Rightarrow": "⇒", + r"\Leftrightarrow": "⇔", + r"\forall": "∀", + r"\exists": "∃", + r"\in": "∈", + r"\notin": "∉", + r"\subset": "⊂", + r"\subseteq": "⊆", + r"\cup": "∪", + r"\cap": "∩", + r"\emptyset": "∅", + r"\mathbb{R}": "ℝ", + r"\mathbb{C}": "ℂ", + r"\mathbb{N}": "ℕ", + r"\mathbb{Z}": "ℤ", + r"\mathbb{Q}": "ℚ", + r"\int": "∫", + r"\sum": "∑", + r"\prod": "∏", + r"\partial": "∂", + r"\nabla": "∇", +} + +SUPERSCRIPT = str.maketrans({ + "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴", + "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹", + "+": "⁺", "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾", + "n": "ⁿ", "i": "ⁱ", +}) + +SUBSCRIPT = str.maketrans({ + "0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄", + "5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉", + "+": "₊", "-": "₋", "=": "₌", "(": "₍", ")": "₎", + "a": "ₐ", "e": "ₑ", "h": "ₕ", "i": "ᵢ", "j": "ⱼ", "k": "ₖ", + "l": "ₗ", "m": "ₘ", "n": "ₙ", "o": "ₒ", "p": "ₚ", "r": "ᵣ", + "s": "ₛ", "t": "ₜ", "u": "ᵤ", "v": "ᵥ", "x": "ₓ", +}) + +_GROUP_PATTERN = re.compile(r"([_^])\(([^()]+)\)|([_^])\{([^{}]+)\}|([_^])([A-Za-z0-9+\-=])") + + +def _translate_script(value: str, marker: str) -> str: + table = SUPERSCRIPT if marker == "^" else SUBSCRIPT + converted = value.translate(table) + return converted if converted != value else marker + value + + +def _replace_script(match: re.Match[str]) -> str: + marker = match.group(1) or match.group(3) or match.group(5) + value = match.group(2) or match.group(4) or match.group(6) + return _translate_script(value, marker) + + +def format_math_text(text: str, mode: str = "plain") -> str: + r"""Convert common UnicodeMath/LaTeX-like linear math to Unicode plain text. + + This is meant for Word normal text insertion. It makes expressions such as + z_1, x^2, \alpha and \infty render as z₁, x², α and ∞ without requiring the + user to manually open Word's equation editor for every expression. + """ + if mode == "plain": + return text + if mode not in {"unicode", "unicode_math"}: + raise ValueError("math_text_format doit être 'plain' ou 'unicode'") + + formatted = text + # Replace longer commands first so \subseteq wins before \subset. + for command, replacement in sorted(GREEK_AND_SYMBOLS.items(), key=lambda item: len(item[0]), reverse=True): + formatted = formatted.replace(command, replacement) + + formatted = re.sub(r"\\vec\(([^()]+)\)", lambda match: match.group(1) + "⃗", formatted) + formatted = formatted.replace(r"\sqrt", "√") + previous = None + while previous != formatted: + previous = formatted + formatted = _GROUP_PATTERN.sub(_replace_script, formatted) + return formatted diff --git a/tests/test_config.py b/tests/test_config.py index 4dcf7b4..d664af6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,6 +13,7 @@ def test_load_default_config(tmp_path): assert cfg.provider == "ollama" assert cfg.hotkey == "ctrl+alt+a" + assert cfg.math_text_format == "unicode" assert "Réponds directement" in cfg.system_prompt @@ -25,6 +26,14 @@ def test_timeout_zero_disables_timeout(tmp_path): assert cfg.request_timeout_seconds is None +def test_reject_invalid_math_text_format(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"provider": "ollama", "math_text_format": "bad"}), encoding="utf-8") + + with pytest.raises(ValueError): + load_config(path) + + def test_reject_invalid_provider(tmp_path): path = tmp_path / "config.json" path.write_text(json.dumps({"provider": "bad"}), encoding="utf-8") diff --git a/tests/test_math_format.py b/tests/test_math_format.py new file mode 100644 index 0000000..f02106f --- /dev/null +++ b/tests/test_math_format.py @@ -0,0 +1,24 @@ +import pytest + +from ai_typewriter.math_format import format_math_text + + +def test_plain_mode_keeps_linear_math_literal(): + assert format_math_text("z_1 + x^2", "plain") == "z_1 + x^2" + + +def test_unicode_mode_converts_indices_exponents_and_symbols(): + result = format_math_text(r"z_1 + x^2 + x_(i+1) + \alpha + \infty", "unicode") + + assert result == "z₁ + x² + xᵢ₊₁ + α + ∞" + + +def test_unicode_mode_converts_common_operators(): + result = format_math_text(r"\int_0^1 f(x) dx \leq \sum_(k=1)^n a_k", "unicode") + + assert result == "∫₀¹ f(x) dx ≤ ∑ₖ₌₁ⁿ aₖ" + + +def test_invalid_math_text_format_is_rejected(): + with pytest.raises(ValueError): + format_math_text("x_1", "bad")