Add Unicode math output formatting
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user