commit 54fd27f205edbde15f090faeab6df8bd63c321ec from: ale date: Thu Jul 30 23:55:42 2026 UTC LaTeX inline: aproxima a texto Unicode en vez de romper el párrafo en imágenes El problema real no era el tamaño de la imagen sino que CADA $...$ inline generaba un widget de imagen separado, partiendo una oración en varios bloques apilados (texto/imagen/texto/imagen) — se perdía la lectura fluida sin importar qué tan chica fuera la imagen. Ahora el $...$ inline se reemplaza en el lugar por su aproximación en texto Unicode (griego, sub/superíndices, ·, ×, →, √, fracciones simples, etc.) vía flatlatex, así el párrafo sigue siendo un solo bloque de Markdown que fluye normal. Se agrega preprocesamiento para los casos que flatlatex no cubre (\sin/\cos/\log/..., \text{}/\mathrm{}, \left/\right, \to). El bloque $$...$$ (que ya vive aislado en su propio párrafo en el Markdown fuente) sigue renderizándose como imagen real vía matplotlib — ahí sí tiene sentido, es una ecuación destacada, no texto corrido. tokenize() ahora devuelve (texto_mostrado, segmentos) en vez de solo segmentos, porque los offsets de línea para el sync del scroll deben calcularse sobre el texto YA sustituido, no el original (la sustitución cambia longitudes de línea pero nunca la cantidad de saltos de línea). 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]