Commit Diff


commit - 998bc9b164edf78f09d9b800f3e31f4ffba2b63a
commit + 54fd27f205edbde15f090faeab6df8bd63c321ec
blob - c30745905821456fffab5ef54627fd5e3131ea0f
blob + bc9e19614b8f5e059f11d5fdd957c0e251747b72
--- nezzontli_ctl/preview.py
+++ nezzontli_ctl/preview.py
@@ -24,11 +24,76 @@ _IMAGE_SHORTCODE_RE = re.compile(r"\{\{\s*image\(([^)]
 _QUOTED_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
 _KV_RE = re.compile(r'(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"')
 
+# flatlatex no reconoce estos nombres de función/operador (los deja con la
+# barra literal), así que se pasan a texto plano antes de convertir.
+_FUNC_NAMES = [
+    "varlimsup", "varliminf", "limsup", "liminf", "sinh", "cosh", "tanh",
+    "coth", "arcsin", "arccos", "arctan", "sin", "cos", "tan", "cot", "sec",
+    "csc", "log", "ln", "exp", "lim", "max", "min", "det", "gcd", "sup",
+    "inf", "arg", "mod", "deg", "dim", "hom", "ker",
+]
+_WRAPPER_MACROS = ("text", "mathrm", "textrm", "operatorname")
 
+
 def _parse_kv_args(raw):
     return {m.group(1): m.group(2) for m in _KV_RE.finditer(raw)}
 
 
+def _strip_wrapper_macro(tex, name):
+    pattern = re.compile(r"\\" + name + r"\{([^{}]*)\}")
+    while pattern.search(tex):
+        tex = pattern.sub(r"\1", tex)
+    return tex
+
+
+def _preprocess_for_flatlatex(tex):
+    """flatlatex traduce símbolos/griego/sub-superíndices bastante bien,
+    pero no conoce nombres de función (\\sin, \\log, ...), \\text{}/\\mathrm{}
+    (los deja con la barra literal pegada), ni \\left/\\right ni \\to."""
+    for name in _WRAPPER_MACROS:
+        tex = _strip_wrapper_macro(tex, name)
+    for name in _FUNC_NAMES:
+        tex = re.sub(r"\\" + name + r"(?![a-zA-Z])", name + " ", tex)
+    tex = tex.replace(r"\left", "").replace(r"\right", "")
+    tex = tex.replace(r"\to", "→").replace(r"\gets", "←")
+    tex = tex.replace(r"\{", "{").replace(r"\}", "}")
+    return tex
+
+
+_flatlatex_converter = None
+
+
+def latex_to_unicode(tex):
+    """Aproxima una fórmula LaTeX-ish a texto Unicode plano (griego,
+    sub/superíndices, operadores) para que se pueda mostrar flotando en
+    medio de una oración sin partirla en un bloque de imagen separado.
+    Si no se puede convertir, devuelve la fórmula tal cual (con los \\ )."""
+    global _flatlatex_converter
+    if _flatlatex_converter is None:
+        import flatlatex
+
+        _flatlatex_converter = flatlatex.converter()
+    try:
+        return _flatlatex_converter.convert(_preprocess_for_flatlatex(tex))
+    except Exception:
+        return tex
+
+
+def _substitute_inline_math(text):
+    """Reemplaza cada $...$ inline (fuera de bloques $$...$$, que sí se
+    renderizan como imagen más abajo) por su aproximación Unicode, en el
+    mismo lugar del texto — así la oración sigue siendo un solo párrafo de
+    Markdown en el preview en vez de partirse en texto/imagen/texto/imagen."""
+    display_spans = [(m.start(), m.end()) for m in _DISPLAY_MATH_RE.finditer(text)]
+
+    def replace(m):
+        if any(s <= m.start() < e for s, e in display_spans):
+            return m.group(0)
+        return latex_to_unicode(m.group(1).strip())
+
+    return _INLINE_MATH_RE.sub(replace, text)
+
+
 def _resolve_local_image(path_str):
     """'/images/x/y.jpg' o 'images/x/y.jpg' -> Path bajo REPO_ROOT/static,
     o None si el archivo no existe."""
@@ -103,8 +168,17 @@ def render_math(tex, display=False):
 
 def tokenize(text):
     """Parte `text` en segmentos ordenados:
-    ("text", markdown), ("math", tex, es_display), ("image", ruta_o_url, alt)
+    ("text", markdown, offset), ("math", tex, es_display, offset),
+    ("image", ruta_o_url, alt, offset)
+
+    Devuelve (texto_mostrado, segmentos) — el texto inline $...$ ya viene
+    reemplazado por su aproximación Unicode (ver _substitute_inline_math),
+    así que los offsets son relativos a ESE texto, no al original. Solo
+    $$...$$ (bloque) sigue generando un segmento "math" con imagen — el
+    inline nunca rompe el párrafo en pedazos.
     """
+    text = _substitute_inline_math(text)
+
     matches = []
     for m in _GALLERY_RE.finditer(text):
         matches.append((m.start(), m.end(), "gallery", m))
@@ -117,17 +191,11 @@ def tokenize(text):
     for m in _DISPLAY_MATH_RE.finditer(text):
         matches.append((m.start(), m.end(), "display_math", m))
 
-    display_spans = [(s, e) for s, e, k, _ in matches if k == "display_math"]
-    for m in _INLINE_MATH_RE.finditer(text):
-        if any(s <= m.start() < e for s, e in display_spans):
-            continue
-        matches.append((m.start(), m.end(), "inline_math", m))
-
     matches.sort(key=lambda t: t[0])
 
     # El 4to campo (o 3ro para "text") es el offset de carácter donde
-    # arranca el segmento en `text` — lo usa EditorScreen para anclar el
-    # scroll del preview a la línea del cursor en el editor.
+    # arranca el segmento en el texto devuelto — lo usa EditorScreen para
+    # anclar el scroll del preview a la línea del cursor en el editor.
     segments = []
     cursor = 0
     for start, end, kind, m in matches:
@@ -152,11 +220,9 @@ def tokenize(text):
             segments.append(("image", m.group(2), m.group(1), start))
         elif kind == "display_math":
             segments.append(("math", m.group(1).strip(), True, start))
-        elif kind == "inline_math":
-            segments.append(("math", m.group(1).strip(), False, start))
 
         cursor = end
 
     if cursor < len(text):
         segments.append(("text", text[cursor:], cursor))
-    return segments
+    return text, segments
blob - 68aa889c0a839498cee0eb8f084e70fa7f370646
blob + 24e0084bfb18c150e9d7dc44e56fd6fffbf38a2a
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
@@ -110,7 +110,7 @@ class EditorScreen(ModalScreen[str]):
             png_path = await asyncio.to_thread(preview_mod.render_math, tex, is_display)
             if png_path is not None and AutoImage is not None:
                 return [AutoImage(str(png_path), classes="preview-math")]
-            return [Static(f"[$] {tex}", classes="preview-fallback")]
+            return [Static(f"$$ {tex} $$", classes="preview-fallback")]
 
         if kind == "image":
             _, ref, alt, _start = segment
@@ -127,10 +127,11 @@ class EditorScreen(ModalScreen[str]):
 
     async def _rebuild_preview(self, text: str) -> None:
         """Tokeniza `text` y remonta el preview como una mezcla de widgets
-        Markdown (texto normal) e Image (fórmulas LaTeX renderizadas a PNG,
+        Markdown (texto normal, con el LaTeX inline ya aproximado a Unicode
+        en el lugar) e Image (fórmulas en bloque $$...$$ renderizadas a PNG,
         imágenes locales del repo o remotas por URL)."""
         preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
-        segments = await asyncio.to_thread(preview_mod.tokenize, text)
+        display_text, segments = await asyncio.to_thread(preview_mod.tokenize, text)
 
         if any(segment[0] in ("math", "image") for segment in segments):
             await preview_scroll.remove_children()
@@ -145,7 +146,7 @@ class EditorScreen(ModalScreen[str]):
         widgets = []
         anchors = []
         for segment, group in zip(segments, rendered_groups):
-            line = text.count("\n", 0, segment[-1])
+            line = display_text.count("\n", 0, segment[-1])
             for widget in group:
                 widgets.append(widget)
                 anchors.append((line, widget))
blob - c8284457787f04b8f369ee95704cf71ff6128bec
blob + e51d1494c3316fc2d585bc91e8ae55ba620b6590
--- pyproject.toml
+++ pyproject.toml
@@ -9,6 +9,7 @@ dependencies = [
     "cowsay>=6.1",
     "textual-image>=0.8",
     "matplotlib>=3.8",
+    "flatlatex>=0.15",
 ]
 
 [project.scripts]