diff --git a/README.md b/README.md index 928054b..6880e13 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Copiez `config.json.template` vers `config.json` puis adaptez : "server_url": "http://localhost:11434", "hotkey": "ctrl+alt+a", "request_timeout_seconds": 300, - "math_text_format": "unicode" + "math_text_format": "word_equation" } ``` @@ -71,19 +71,26 @@ Copiez `config.json.template` vers `config.json` puis adaptez : ### 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` : +`math_text_format` vaut `word_equation` par défaut. Dans ce mode, les segments mathématiques comme `z_1`, `x^2`, `\alpha` ou `z_1 = x + iy` sont injectés dans Word comme de vraies équations : + +1. l'application envoie `Alt+=` ; +2. elle tape l'expression en syntaxe UnicodeMath Word ; +3. elle envoie `Espace` pour laisser Word convertir ; +4. elle sort de l'objet équation avec `Flèche droite`. + +Exemple : ```text -z_1 + x^2 + \alpha + \infty +On pose z_1 = x + iy. ``` -devient : +est injecté comme texte normal `On pose `, puis une équation Word `z_1 = x + iy`, puis le point final. -```text -z₁ + x² + α + ∞ -``` +Modes disponibles : -Mettez `"math_text_format": "plain"` si vous voulez injecter la réponse exactement telle que le modèle l'a renvoyée. +- `word_equation` : crée des objets Équation Word via `Alt+=` pour les segments mathématiques détectés. +- `unicode` : transforme `z_1`, `x^2`, `\alpha`, etc. en texte Unicode (`z₁`, `x²`, `α`) sans objet équation. +- `plain` : injecte la réponse exactement telle que le modèle l'a renvoyée. ## Compilation diff --git a/config.json.template b/config.json.template index 4609a3d..ce123f8 100644 --- a/config.json.template +++ b/config.json.template @@ -5,7 +5,7 @@ "server_url": "http://localhost:11434", "hotkey": "ctrl+alt+a", "request_timeout_seconds": 300, - "math_text_format": "unicode", + "math_text_format": "word_equation", "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 7b0012e..aaadbea 100644 --- a/src/ai_typewriter/app.py +++ b/src/ai_typewriter/app.py @@ -11,12 +11,22 @@ import keyboard 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 +from .key_stepper import KeyStepper, TypeAction +from .math_format import format_math_text, split_word_equation_segments LOG = logging.getLogger("ai_typewriter") +def prepare_type_payload(answer: str, mode: str) -> str | list[TypeAction]: + if mode == "word_equation": + return [TypeAction(segment.kind, segment.value) for segment in split_word_equation_segments(answer)] + return format_math_text(answer, mode) + + +def payload_length(payload: str | list[TypeAction]) -> int: + return len(payload) + + class AITypewriterApp: def __init__(self, config: AppConfig) -> None: self.config = config @@ -44,15 +54,16 @@ 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) + answer_to_type = prepare_type_payload(answer, self.config.math_text_format) + typed_units = payload_length(answer_to_type) elapsed = time.perf_counter() - started_at LOG.info( - "Réponse reçue en %.2f s: %d caractères (%d après formatage %s). Premier caractère écrit automatiquement, %d restants au clavier.", + "Réponse reçue en %.2f s: %d caractères (%d unités à injecter en mode %s). Premier caractère/action écrit automatiquement, %d restants au clavier.", elapsed, len(answer), - len(answer_to_type), + typed_units, self.config.math_text_format, - max(len(answer_to_type) - 1, 0), + max(typed_units - 1, 0), ) if self._stepper is not None: self._stepper.stop() @@ -88,10 +99,14 @@ 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) + answer_to_type = prepare_type_payload(answer, config.math_text_format) + typed_units = payload_length(answer_to_type) elapsed = time.perf_counter() - started_at - 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) + LOG.info("Réponse générée en %.2f s: %d caractères (%d unités en mode %s).", elapsed, len(answer), typed_units, config.math_text_format) + if isinstance(answer_to_type, str): + print(answer_to_type) + else: + print("".join(f"[Alt+= {action.value}]" if action.kind == "equation" else action.value for action in 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 55d7e3f..fbe283b 100644 --- a/src/ai_typewriter/config.py +++ b/src/ai_typewriter/config.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Literal Provider = Literal["ollama", "gemini"] -MathTextFormat = Literal["plain", "unicode", "unicode_math"] +MathTextFormat = Literal["plain", "unicode", "unicode_math", "word_equation"] @dataclass(frozen=True) @@ -19,7 +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" + math_text_format: MathTextFormat = "word_equation" restore_clipboard: bool = True system_prompt: str = ( "Réponds directement et de manière ultra-concise. " @@ -43,9 +43,9 @@ def _coerce_timeout(value: Any) -> float | None: 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'") + mode = str(value or "word_equation").lower().strip() + if mode not in {"plain", "unicode", "unicode_math", "word_equation"}: + raise ValueError("config.math_text_format doit être 'plain', 'unicode' ou 'word_equation'") return mode # type: ignore[return-value] @@ -65,7 +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")), + math_text_format=_coerce_math_text_format(data.get("math_text_format", "word_equation")), restore_clipboard=bool(data.get("restore_clipboard", True)), system_prompt=str(data.get("system_prompt", AppConfig.system_prompt)), ) diff --git a/src/ai_typewriter/key_stepper.py b/src/ai_typewriter/key_stepper.py index 6d8668f..4ad69b5 100644 --- a/src/ai_typewriter/key_stepper.py +++ b/src/ai_typewriter/key_stepper.py @@ -2,50 +2,63 @@ from __future__ import annotations from dataclasses import dataclass, field from threading import Lock -from typing import Optional +from typing import Optional, Sequence import keyboard +@dataclass(frozen=True) +class TypeAction: + kind: str + value: str + + @dataclass class KeyStepper: - text: str + text: str | Sequence[TypeAction] delay: float = 0.0 _index: int = 0 _hook: Optional[object] = None _lock: Lock = field(default_factory=Lock) _injecting: bool = False + _actions: list[TypeAction] = field(init=False) + + def __post_init__(self) -> None: + if isinstance(self.text, str): + self._actions = [TypeAction("text", char) for char in self.text] + else: + self._actions = list(self.text) def start(self) -> None: - if not self.text: + if not self._actions: return if self._hook is not None: return self.type_next_character() - if self._index >= len(self.text): + if self._index >= len(self._actions): return - # suppress=True blocks the physical key while the callback types the next AI character. + # suppress=True blocks the physical key while the callback types the next AI character/action. self._hook = keyboard.hook(self._on_event, suppress=True) @property def remaining_characters(self) -> int: - return max(len(self.text) - self._index, 0) + return max(len(self._actions) - self._index, 0) def type_next_character(self) -> None: with self._lock: - if self._index >= len(self.text): + if self._index >= len(self._actions): self.stop() return - char = self.text[self._index] + action = self._actions[self._index] self._index += 1 self._injecting = True try: - self._type_char(char) + self._type_action(action) finally: self._injecting = False - if self._index >= len(self.text): + if self._index >= len(self._actions): self.stop() def stop(self) -> None: @@ -59,10 +72,24 @@ class KeyStepper: return self.type_next_character() + def _type_action(self, action: TypeAction) -> None: + if action.kind == "equation": + self._type_word_equation(action.value) + return + for char in action.value: + self._type_char(char) + + def _type_word_equation(self, expression: str) -> None: + keyboard.send("alt+=") + keyboard.write(expression, delay=self.delay, exact=True) + # Word converts much of UnicodeMath after Space; Right exits the equation object. + keyboard.send("space") + keyboard.send("right") + def _type_char(self, char: str) -> None: if char == "\n": keyboard.send("enter") - elif char == " ": + elif char == "\t": keyboard.send("tab") else: keyboard.write(char, delay=self.delay, exact=True) diff --git a/src/ai_typewriter/math_format.py b/src/ai_typewriter/math_format.py index ef3449a..2d4c9c2 100644 --- a/src/ai_typewriter/math_format.py +++ b/src/ai_typewriter/math_format.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from dataclasses import dataclass GREEK_AND_SYMBOLS = { r"\alpha": "α", @@ -94,6 +95,23 @@ SUBSCRIPT = str.maketrans({ }) _GROUP_PATTERN = re.compile(r"([_^])\(([^()]+)\)|([_^])\{([^{}]+)\}|([_^])([A-Za-z0-9+\-=])") +_MATH_START_PATTERN = re.compile( + r"(\\[A-Za-z]+|[A-Za-zΑ-Ωα-ω]+(?:[_^](?:\([^()]+\)|\{[^{}]+\}|[A-Za-z0-9+\-=]))+|[∫∑∞≤≥≠≈α-ωΑ-Ω]|=)" +) +_MATH_TOKEN_PATTERN = re.compile( + r"\s*(?:" + r"\\[A-Za-z]+(?:\([^()]+\)|\{[^{}]+\})?" + r"|[A-Za-zΑ-Ωα-ω0-9]+(?:[_^](?:\([^()]+\)|\{[^{}]+\}|[A-Za-z0-9+\-=]))*" + r"|[=+\-*/×·≤≥≠≈<>]" + r"|[(){}]" + r")" +) + + +@dataclass(frozen=True) +class MathSegment: + kind: str + value: str def _translate_script(value: str, marker: str) -> str: @@ -118,7 +136,7 @@ def format_math_text(text: str, mode: str = "plain") -> str: if mode == "plain": return text if mode not in {"unicode", "unicode_math"}: - raise ValueError("math_text_format doit être 'plain' ou 'unicode'") + raise ValueError("math_text_format doit être 'plain', 'unicode' ou 'word_equation'") formatted = text # Replace longer commands first so \subseteq wins before \subset. @@ -132,3 +150,56 @@ def format_math_text(text: str, mode: str = "plain") -> str: previous = formatted formatted = _GROUP_PATTERN.sub(_replace_script, formatted) return formatted + + +def _consume_math_sequence(text: str, start: int) -> int: + end = start + while end < len(text): + if text[end] in "\n.,;:!?": + break + match = _MATH_TOKEN_PATTERN.match(text, end) + if match is None: + break + end = match.end() + return end + + +def split_word_equation_segments(text: str) -> list[MathSegment]: + r"""Split text into normal text and Word UnicodeMath equation insertions. + + Equation segments keep Word's linear UnicodeMath syntax (z_1, x^2, \alpha), + because Word converts that syntax inside the Alt+= equation editor. + """ + segments: list[MathSegment] = [] + cursor = 0 + while cursor < len(text): + match = _MATH_START_PATTERN.search(text, cursor) + if match is None: + if cursor < len(text): + segments.append(MathSegment("text", text[cursor:])) + break + + start = match.start() + if start > cursor: + segments.append(MathSegment("text", text[cursor:start])) + + end = _consume_math_sequence(text, start) + expression = text[start:end].strip() + leading = text[start : start + len(text[start:end]) - len(text[start:end].lstrip())] + trailing = text[start:end][len(text[start:end].rstrip()):] + if leading: + segments.append(MathSegment("text", leading)) + if expression: + segments.append(MathSegment("equation", expression)) + if trailing: + segments.append(MathSegment("text", trailing)) + cursor = end + + # Merge adjacent text segments for cleaner action counts. + merged: list[MathSegment] = [] + for segment in segments: + if merged and segment.kind == "text" and merged[-1].kind == "text": + merged[-1] = MathSegment("text", merged[-1].value + segment.value) + else: + merged.append(segment) + return merged diff --git a/tests/test_config.py b/tests/test_config.py index d664af6..a78819a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,10 +13,19 @@ def test_load_default_config(tmp_path): assert cfg.provider == "ollama" assert cfg.hotkey == "ctrl+alt+a" - assert cfg.math_text_format == "unicode" + assert cfg.math_text_format == "word_equation" assert "Réponds directement" in cfg.system_prompt +def test_accept_word_equation_format(tmp_path): + path = tmp_path / "config.json" + path.write_text(json.dumps({"provider": "ollama", "math_text_format": "word_equation"}), encoding="utf-8") + + cfg = load_config(path) + + assert cfg.math_text_format == "word_equation" + + def test_timeout_zero_disables_timeout(tmp_path): path = tmp_path / "config.json" path.write_text(json.dumps({"provider": "ollama", "request_timeout_seconds": 0}), encoding="utf-8") diff --git a/tests/test_math_format.py b/tests/test_math_format.py index f02106f..f885a3b 100644 --- a/tests/test_math_format.py +++ b/tests/test_math_format.py @@ -1,6 +1,6 @@ import pytest -from ai_typewriter.math_format import format_math_text +from ai_typewriter.math_format import format_math_text, split_word_equation_segments def test_plain_mode_keeps_linear_math_literal(): @@ -19,6 +19,18 @@ def test_unicode_mode_converts_common_operators(): assert result == "∫₀¹ f(x) dx ≤ ∑ₖ₌₁ⁿ aₖ" +def test_word_equation_segments_keep_word_unicodemath_syntax(): + segments = split_word_equation_segments(r"On pose z_1 = x + iy. Puis \alpha = 2.") + + assert [(segment.kind, segment.value) for segment in segments] == [ + ("text", "On pose "), + ("equation", "z_1 = x + iy"), + ("text", ". Puis "), + ("equation", r"\alpha = 2"), + ("text", "."), + ] + + def test_invalid_math_text_format_is_rejected(): with pytest.raises(ValueError): format_math_text("x_1", "bad")