Insert Word equation objects for math output
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-1
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user