Add Unicode math output formatting

This commit is contained in:
Hermes Agent
2026-09-11 13:54:01 +02:00
parent b2d2ba58fc
commit 6bb572f9e4
7 changed files with 206 additions and 6 deletions
+18 -1
View File
@@ -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
+1
View File
@@ -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."
}
+10 -5
View File
@@ -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)
+10
View File
@@ -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)),
)
+134
View File
@@ -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
+9
View File
@@ -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")
+24
View File
@@ -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")