Return to plain LaTeX typing mode
This commit is contained in:
@@ -7,15 +7,14 @@ Application Python qui lit la dernière entrée texte du presse-papier avec un r
|
||||
1. L'utilisateur copie manuellement le texte à envoyer à l'IA.
|
||||
2. Raccourci global par défaut : `Ctrl+Alt+A`.
|
||||
3. L'application lit directement la dernière entrée du presse-papier, sans simuler `Ctrl+C`.
|
||||
4. Le texte capturé est journalisé puis envoyé à Ollama ou Gemini avec le system prompt strict :
|
||||
|
||||
> Réponds directement et de manière ultra-concise. Aucune phrase d'introduction, aucune salutation, aucun formatage superflu. Uniquement la réponse brute.
|
||||
|
||||
4. Le texte capturé est journalisé puis envoyé à Ollama ou Gemini.
|
||||
5. Le temps de génération de la réponse est journalisé.
|
||||
6. Quand la réponse arrive, le premier caractère est écrit automatiquement.
|
||||
7. Le mode dactylographie s'active ensuite : chaque touche physique est interceptée et remplacée par le prochain caractère de la réponse.
|
||||
6. Quand la réponse arrive, le mode dactylographie s'active.
|
||||
7. Chaque touche physique appuyée est interceptée et remplacée par le prochain caractère de la réponse IA.
|
||||
8. Le hook clavier est libéré automatiquement après le dernier caractère.
|
||||
|
||||
L'application n'interprète pas les équations et ne lance pas `Alt+=`. Elle écrit uniquement le texte généré, caractère par caractère. Pour les maths, le modèle peut produire du LaTeX encadré par des marqueurs `[EQ]...[/EQ]`, puis la gestion Word peut être faite ailleurs.
|
||||
|
||||
## Installation depuis les sources
|
||||
|
||||
```bash
|
||||
@@ -69,40 +68,37 @@ 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.
|
||||
|
||||
### Mode mathématique Word
|
||||
### Configuration maths LaTeX
|
||||
|
||||
La configuration générale reste simple (`math_text_format: "plain"`). Pour les réponses mathématiques, utilisez l'exemple dédié :
|
||||
Pour laisser le modèle générer du LaTeX tout en indiquant clairement les débuts/fins d'équations :
|
||||
|
||||
```bash
|
||||
cp config.math-word.template config.json
|
||||
cp config.math-latex.template config.json
|
||||
```
|
||||
|
||||
Ce fichier active :
|
||||
Cette config garde :
|
||||
|
||||
```json
|
||||
"math_text_format": "word_equation"
|
||||
"math_text_format": "plain"
|
||||
```
|
||||
|
||||
Dans ce mode, l'application ne devine plus les maths automatiquement. Elle suit uniquement les balises explicites renvoyées par le modèle :
|
||||
Donc l'application ne transforme rien. Elle tape littéralement la réponse reçue, caractère par caractère.
|
||||
|
||||
Exemple de réponse demandée au modèle :
|
||||
|
||||
```text
|
||||
La solution est [EQ]z_1 = x + iy[/EQ].
|
||||
Les racines sont [EQ]z_1 = x + iy[/EQ] et [EQ]z_2 = x - iy[/EQ].
|
||||
```
|
||||
|
||||
Injection réelle :
|
||||
Pour les fractions, intégrales, sommes, etc., le modèle peut utiliser du LaTeX standard dans les balises :
|
||||
|
||||
1. texte normal : `La solution est ` ;
|
||||
2. à `[EQ]`, l'application envoie `Alt+=` ;
|
||||
3. elle tape `z_1 = x + iy` dans l'éditeur d'équation Word ;
|
||||
4. à `[/EQ]`, elle envoie `Espace` puis `Flèche droite` pour convertir et sortir de l'équation ;
|
||||
5. elle reprend le texte normal : `.`.
|
||||
|
||||
Le prompt de `config.math-word.template` demande aussi au modèle d'éviter le LaTeX non souhaité, notamment `\\frac`, `\\dfrac`, `\\tfrac`, `\\left`, `\\right`, `$`, `$$`, `\\(` et `\\[`.
|
||||
```text
|
||||
On obtient [EQ]\frac{a+b}{c+d}[/EQ] puis [EQ]\int_0^1 f(x)\,dx[/EQ].
|
||||
```
|
||||
|
||||
Modes disponibles :
|
||||
|
||||
- `plain` : injecte la réponse exactement telle que le modèle l'a renvoyée.
|
||||
- `word_equation` : crée des objets Équation Word uniquement pour les blocs `[EQ]...[/EQ]`.
|
||||
- `plain` : mode recommandé ; injecte la réponse exactement telle que le modèle l'a renvoyée.
|
||||
- `unicode` : ancien mode texte Unicode (`z_1` → `z₁`, `x^2` → `x²`) sans objet équation.
|
||||
|
||||
## Compilation
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1",
|
||||
"api_key": "",
|
||||
"server_url": "http://localhost:11434",
|
||||
"hotkey": "ctrl+alt+a",
|
||||
"request_timeout_seconds": 300,
|
||||
"math_text_format": "plain",
|
||||
"type_delay_seconds": 0,
|
||||
"system_prompt": "Tu réponds directement, sans salutation ni introduction. Rédige une réponse claire, correcte et concise. Pour toute expression mathématique, formule, calcul, égalité, fraction, somme, intégrale, matrice ou symbole qui doit être traité comme une équation, encadre exactement le bloc avec [EQ] au début et [/EQ] à la fin. Dans ces blocs, écris du LaTeX standard, car il est plus simple et fiable à générer : \frac{a}{b}, z_1, x^2, \int_0^1, \sum_{k=1}^n, etc. N'utilise pas de délimiteurs LaTeX supplémentaires dans les blocs : pas de $, $$, \\(, \\[. Le texte hors des balises [EQ]...[/EQ] reste du texte normal. Exemple valide : Les racines sont [EQ]z_1 = x + iy[/EQ] et [EQ]z_2 = x - iy[/EQ]."
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"provider": "ollama",
|
||||
"model": "llama3.1",
|
||||
"api_key": "",
|
||||
"server_url": "http://localhost:11434",
|
||||
"hotkey": "ctrl+alt+a",
|
||||
"request_timeout_seconds": 300,
|
||||
"math_text_format": "word_equation",
|
||||
"type_delay_seconds": 0,
|
||||
"system_prompt": "Réponds directement et de manière ultra-concise. Aucune salutation, aucune introduction, aucun Markdown. Pour toute formule mathématique qui doit devenir une équation Microsoft Word, écris exactement [EQ] avant la formule et [/EQ] après la formule. Le texte hors des balises restera du texte normal. Dans les balises [EQ], utilise la syntaxe UnicodeMath de Microsoft Word, pas LaTeX. N'utilise jamais \\frac, \\dfrac, \\tfrac, \\left, \\right, $, $$, \\( ou \\[. Pour les fractions, utilise a/b ou (a+b)/(c+d). Pour les indices et exposants, utilise z_1, x^2, x_(n+1). Pour les racines, utilise sqrt(x). Pour les lettres grecques, utilise \\alpha, \\beta, \\Delta. Exemple de sortie valide : La solution est [EQ]z_1 = x + iy[/EQ]."
|
||||
}
|
||||
@@ -11,22 +11,16 @@ 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, TypeAction
|
||||
from .math_format import format_math_text, split_word_equation_segments
|
||||
from .key_stepper import KeyStepper
|
||||
from .math_format import format_math_text
|
||||
|
||||
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)]
|
||||
def prepare_type_payload(answer: str, mode: str) -> str:
|
||||
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
|
||||
@@ -55,15 +49,12 @@ class AITypewriterApp:
|
||||
started_at = time.perf_counter()
|
||||
answer = ask_ai(selected, self.config)
|
||||
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 unités à injecter en mode %s). Premier caractère/action écrit automatiquement, %d restants au clavier.",
|
||||
"Réponse reçue en %.2f s: %d caractères à écrire en mode %s. Appuyez sur une touche pour écrire chaque caractère.",
|
||||
elapsed,
|
||||
len(answer),
|
||||
typed_units,
|
||||
len(answer_to_type),
|
||||
self.config.math_text_format,
|
||||
max(typed_units - 1, 0),
|
||||
)
|
||||
if self._stepper is not None:
|
||||
self._stepper.stop()
|
||||
@@ -100,13 +91,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
started_at = time.perf_counter()
|
||||
answer = ask_ai(args.ask, config)
|
||||
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 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))
|
||||
LOG.info("Réponse générée en %.2f s: %d caractères en mode %s.", elapsed, len(answer_to_type), config.math_text_format)
|
||||
print(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", "word_equation"]
|
||||
MathTextFormat = Literal["plain", "unicode", "unicode_math"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -44,8 +44,8 @@ def _coerce_timeout(value: Any) -> float | None:
|
||||
|
||||
def _coerce_math_text_format(value: Any) -> MathTextFormat:
|
||||
mode = str(value or "plain").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'")
|
||||
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]
|
||||
|
||||
|
||||
|
||||
@@ -2,63 +2,47 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Lock
|
||||
from typing import Optional, Sequence
|
||||
from typing import Optional
|
||||
|
||||
import keyboard
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TypeAction:
|
||||
kind: str
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeyStepper:
|
||||
text: str | Sequence[TypeAction]
|
||||
text: str
|
||||
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._actions:
|
||||
if not self.text:
|
||||
return
|
||||
if self._hook is not None:
|
||||
return
|
||||
self.type_next_character()
|
||||
if self._index >= len(self._actions):
|
||||
return
|
||||
# suppress=True blocks the physical key while the callback types the next AI character/action.
|
||||
# suppress=True blocks the physical key while the callback types the next AI character.
|
||||
self._hook = keyboard.hook(self._on_event, suppress=True)
|
||||
|
||||
@property
|
||||
def remaining_characters(self) -> int:
|
||||
return max(len(self._actions) - self._index, 0)
|
||||
return max(len(self.text) - self._index, 0)
|
||||
|
||||
def type_next_character(self) -> None:
|
||||
with self._lock:
|
||||
if self._index >= len(self._actions):
|
||||
if self._index >= len(self.text):
|
||||
self.stop()
|
||||
return
|
||||
action = self._actions[self._index]
|
||||
char = self.text[self._index]
|
||||
self._index += 1
|
||||
|
||||
self._injecting = True
|
||||
try:
|
||||
self._type_action(action)
|
||||
self._type_char(char)
|
||||
finally:
|
||||
self._injecting = False
|
||||
|
||||
if self._index >= len(self._actions):
|
||||
if self._index >= len(self.text):
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
@@ -72,20 +56,6 @@ 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")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
GREEK_AND_SYMBOLS = {
|
||||
r"\alpha": "α",
|
||||
@@ -53,14 +52,6 @@ SUBSCRIPT = str.maketrans({
|
||||
})
|
||||
|
||||
_SCRIPT_PATTERN = re.compile(r"([_^])\(([^()]+)\)|([_^])\{([^{}]+)\}|([_^])([A-Za-z0-9+\-=])")
|
||||
EQUATION_OPEN = "[EQ]"
|
||||
EQUATION_CLOSE = "[/EQ]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MathSegment:
|
||||
kind: str
|
||||
value: str
|
||||
|
||||
|
||||
def _translate_script(value: str, marker: str) -> str:
|
||||
@@ -76,11 +67,15 @@ def _replace_script(match: re.Match[str]) -> str:
|
||||
|
||||
|
||||
def format_math_text(text: str, mode: str = "plain") -> str:
|
||||
"""Optional legacy text-only math formatting for non-Word targets."""
|
||||
"""Format the AI answer before key stepping.
|
||||
|
||||
plain keeps the response unchanged, which is the recommended mode for LaTeX
|
||||
markers such as [EQ]\\frac{a}{b}[/EQ].
|
||||
"""
|
||||
if mode == "plain":
|
||||
return text
|
||||
if mode not in {"unicode", "unicode_math"}:
|
||||
raise ValueError("math_text_format doit être 'plain', 'unicode' ou 'word_equation'")
|
||||
raise ValueError("math_text_format doit être 'plain' ou 'unicode'")
|
||||
|
||||
formatted = text
|
||||
for command, replacement in sorted(GREEK_AND_SYMBOLS.items(), key=lambda item: len(item[0]), reverse=True):
|
||||
@@ -91,43 +86,3 @@ def format_math_text(text: str, mode: str = "plain") -> str:
|
||||
previous = formatted
|
||||
formatted = _SCRIPT_PATTERN.sub(_replace_script, formatted)
|
||||
return formatted
|
||||
|
||||
|
||||
def split_word_equation_segments(text: str) -> list[MathSegment]:
|
||||
"""Split explicit [EQ]...[/EQ] blocks into Word equation actions.
|
||||
|
||||
No automatic math guessing is done here: the model decides where an equation
|
||||
starts and ends by returning [EQ] before the expression and [/EQ] after it.
|
||||
"""
|
||||
segments: list[MathSegment] = []
|
||||
cursor = 0
|
||||
|
||||
while cursor < len(text):
|
||||
start = text.find(EQUATION_OPEN, cursor)
|
||||
if start == -1:
|
||||
if cursor < len(text):
|
||||
segments.append(MathSegment("text", text[cursor:]))
|
||||
break
|
||||
|
||||
if start > cursor:
|
||||
segments.append(MathSegment("text", text[cursor:start]))
|
||||
|
||||
expression_start = start + len(EQUATION_OPEN)
|
||||
end = text.find(EQUATION_CLOSE, expression_start)
|
||||
if end == -1:
|
||||
expression = text[expression_start:].strip()
|
||||
cursor = len(text)
|
||||
else:
|
||||
expression = text[expression_start:end].strip()
|
||||
cursor = end + len(EQUATION_CLOSE)
|
||||
|
||||
if expression:
|
||||
segments.append(MathSegment("equation", expression))
|
||||
|
||||
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
|
||||
|
||||
@@ -17,13 +17,13 @@ def test_load_default_config(tmp_path):
|
||||
assert "Réponds directement" in cfg.system_prompt
|
||||
|
||||
|
||||
def test_accept_word_equation_format(tmp_path):
|
||||
def test_accept_unicode_format(tmp_path):
|
||||
path = tmp_path / "config.json"
|
||||
path.write_text(json.dumps({"provider": "ollama", "math_text_format": "word_equation"}), encoding="utf-8")
|
||||
path.write_text(json.dumps({"provider": "ollama", "math_text_format": "unicode"}), encoding="utf-8")
|
||||
|
||||
cfg = load_config(path)
|
||||
|
||||
assert cfg.math_text_format == "word_equation"
|
||||
assert cfg.math_text_format == "unicode"
|
||||
|
||||
|
||||
def test_timeout_zero_disables_timeout(tmp_path):
|
||||
|
||||
@@ -3,7 +3,7 @@ from types import SimpleNamespace
|
||||
from ai_typewriter.key_stepper import KeyStepper
|
||||
|
||||
|
||||
def test_start_types_first_character_immediately_then_hooks_remaining(monkeypatch):
|
||||
def test_start_installs_hook_without_typing_immediately(monkeypatch):
|
||||
typed = []
|
||||
hooked = []
|
||||
|
||||
@@ -13,37 +13,57 @@ def test_start_types_first_character_immediately_then_hooks_remaining(monkeypatc
|
||||
stepper = KeyStepper("abc")
|
||||
stepper.start()
|
||||
|
||||
assert typed == ["a"]
|
||||
assert typed == []
|
||||
assert hooked and hooked[0][1] is True
|
||||
assert stepper.remaining_characters == 2
|
||||
assert stepper.remaining_characters == 3
|
||||
|
||||
|
||||
def test_single_character_response_does_not_install_hook(monkeypatch):
|
||||
def test_single_character_response_waits_for_key_then_unhooks(monkeypatch):
|
||||
typed = []
|
||||
hooks = []
|
||||
hook = {}
|
||||
unhooked = []
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hooks.append(callback))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: unhooked.append(value))
|
||||
|
||||
stepper = KeyStepper("x")
|
||||
stepper.start()
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert typed == ["x"]
|
||||
assert hooks == []
|
||||
assert unhooked == ["hook"]
|
||||
assert stepper.remaining_characters == 0
|
||||
|
||||
|
||||
def test_key_down_types_next_character_after_auto_first(monkeypatch):
|
||||
def test_each_key_down_types_next_character(monkeypatch):
|
||||
typed = []
|
||||
hook = {}
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.setdefault("callback", callback) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None)
|
||||
|
||||
stepper = KeyStepper("ab")
|
||||
stepper.start()
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert typed == ["a", "b"]
|
||||
assert stepper.remaining_characters == 0
|
||||
|
||||
|
||||
def test_latex_markers_are_typed_literally_character_by_character(monkeypatch):
|
||||
typed = []
|
||||
hook = {}
|
||||
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.write", lambda char, delay=0, exact=True: typed.append(char))
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.hook", lambda callback, suppress=True: hook.update({"callback": callback}) or "hook")
|
||||
monkeypatch.setattr("ai_typewriter.key_stepper.keyboard.unhook", lambda value: None)
|
||||
|
||||
stepper = KeyStepper(r"[EQ]\frac{a}{b}[/EQ]")
|
||||
stepper.start()
|
||||
for _ in range(len(stepper.text)):
|
||||
hook["callback"](SimpleNamespace(event_type="down"))
|
||||
|
||||
assert "".join(typed) == r"[EQ]\frac{a}{b}[/EQ]"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import pytest
|
||||
|
||||
from ai_typewriter.math_format import format_math_text, split_word_equation_segments
|
||||
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_plain_mode_keeps_latex_and_markers_literal():
|
||||
assert format_math_text(r"[EQ]\frac{a}{b}[/EQ]", "plain") == r"[EQ]\frac{a}{b}[/EQ]"
|
||||
|
||||
|
||||
def test_unicode_mode_converts_indices_exponents_and_symbols():
|
||||
@@ -13,35 +13,6 @@ def test_unicode_mode_converts_indices_exponents_and_symbols():
|
||||
assert result == "z₁ + x² + xᵢ₊₁ + α + ∞"
|
||||
|
||||
|
||||
def test_marker_mode_only_uses_explicit_equation_blocks():
|
||||
segments = split_word_equation_segments(r"On pose [EQ]z_1 = x + iy[/EQ]. Puis [EQ]\alpha = 2[/EQ].")
|
||||
|
||||
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_marker_mode_does_not_guess_math_without_markers():
|
||||
segments = split_word_equation_segments(r"z_1 reste du texte normal sans marqueur.")
|
||||
|
||||
assert [(segment.kind, segment.value) for segment in segments] == [
|
||||
("text", r"z_1 reste du texte normal sans marqueur."),
|
||||
]
|
||||
|
||||
|
||||
def test_unclosed_equation_marker_consumes_the_rest():
|
||||
segments = split_word_equation_segments(r"Résultat: [EQ]x^2 + 1")
|
||||
|
||||
assert [(segment.kind, segment.value) for segment in segments] == [
|
||||
("text", "Résultat: "),
|
||||
("equation", "x^2 + 1"),
|
||||
]
|
||||
|
||||
|
||||
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