Insert Word equation objects for math output

This commit is contained in:
Hermes Agent
2026-09-11 14:04:51 +02:00
parent 6bb572f9e4
commit 3bccabe4ec
8 changed files with 179 additions and 38 deletions
+15 -8
View File
@@ -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
+1 -1
View File
@@ -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."
}
+24 -9
View File
@@ -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 -6
View File
@@ -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)),
)
+38 -11
View File
@@ -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)
+72 -1
View File
@@ -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
View File
@@ -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")
+13 -1
View File
@@ -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")