commit eabf2e720ed1f9f352c93c56ace8614e9237a0c0 from: ale date: Thu Jul 30 23:05:29 2026 UTC Preview del editor: renderiza LaTeX e imágenes (locales/remotas), fija libs de NixOS El preview de Markdown ahora tokeniza fórmulas $...$/$$...$$ (renderizadas a PNG vía matplotlib.mathtext) e imágenes referenciadas por markdown estándar o por los shortcodes de Zola (photo/gallery/image), mostrándolas con textual-image (Kitty/Sixel/Unicode). El rebuild del preview corre debounced y en un worker para no bloquear la UI con el render/descarga. flake.nix agrega libstdc++/zlib al devShell — los wheels compilados de numpy (dependencia de matplotlib) los buscan en rutas FHS que no existen en NixOS. commit - 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0 commit + eabf2e720ed1f9f352c93c56ace8614e9237a0c0 blob - 4f8dd7a0d1455b9bb9a8d6959c55c7b3ed419651 blob + 66e57bc2c10ac49bd82035c108a8988c79bc7773 --- README.md +++ README.md @@ -34,7 +34,14 @@ Validada de punta a punta contra un clone local descar real): las 4 pantallas (post, page, album, add-photos), edición con preview de Markdown en vivo, commit+push real, y confirmado que las fotos quedan como punteros git-lfs reales (no blobs crudos). Pantalla de configuración -(animal de cowsay, ruta del repo) también probada. +(animal de cowsay, ruta del repo) también probada. Editar entradas/álbumes +existentes también probado. +El preview del editor, además de Markdown, renderiza fórmulas LaTeX (subset +mathtext de matplotlib) como imagen y muestra las imágenes referenciadas +—locales del repo (`![]()`, shortcodes `photo()`/`gallery()` de Zola) o +remotas por URL (shortcode `image()`)— usando el protocolo de gráficos de +la terminal (requiere Kitty, iTerm2 o soporte Sixel; si no, cae a texto). + Todavía no reemplaza a `website/scripts/ctl.py` en el uso diario — eso queda a decisión de Ale, una vez que la pruebe él mismo contra el repo real. blob - 8a11d497d5c1f1169a6a3475579031de503e3ddb blob + 4164dc12c58412d437c1704a8d0317575b8e7642 --- flake.nix +++ flake.nix @@ -12,11 +12,17 @@ in { devShells.${system}.default = pkgs.mkShell { - # Deps del proyecto (textual/pyfiglet/cowsay) van por pip/pyproject.toml, - # no por nixpkgs: cowsay no está empaquetado ahí, y mezclar deps de nix - # con deps de pip para el mismo proyecto es frágil. Nix solo fija la - # versión de Python. - packages = [ pkgs.python313 ]; + # Deps del proyecto (textual/pyfiglet/cowsay/matplotlib/textual-image) + # van por pip/pyproject.toml, no por nixpkgs: cowsay no está + # empaquetado ahí, y mezclar deps de nix con deps de pip para el mismo + # proyecto es frágil. Nix solo fija la versión de Python + las libs de + # sistema que los wheels compilados (numpy, vía matplotlib) esperan + # encontrar en rutas estándar que en NixOS no existen. + packages = [ + pkgs.python313 + pkgs.stdenv.cc.cc.lib # libstdc++.so.6 — lo pide numpy al importar + pkgs.zlib + ]; shellHook = '' if [ ! -d .venv ]; then @@ -24,6 +30,7 @@ python3 -m venv .venv fi source .venv/bin/activate + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib pkgs.zlib ]}:$LD_LIBRARY_PATH" pip install -q -e . --disable-pip-version-check echo "nezzontli-ctl listo. Corré 'ctl' o 'python3 -m nezzontli_ctl'." echo "Apunta a: ''${NEZZONTLI_REPO_PATH:-$HOME/projects/website}" blob - 77afa72c4c380a08898428d2c7d5b293c68b7161 blob + c904fa90e277c1465f58d003acc673ab88e4b3bd --- nezzontli_ctl/screens/editor.py +++ nezzontli_ctl/screens/editor.py @@ -1,11 +1,26 @@ +import asyncio + from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen from textual.widgets import Footer, Markdown, Static, TextArea +from nezzontli_ctl import preview as preview_mod +try: + from textual_image.widget import AutoImage +except ImportError: # pragma: no cover — el preview cae a texto sin esto + AutoImage = None + +# Debounce del rebuild del preview: correr el tokenizer + renderizar +# LaTeX/imágenes en cada tecla sería carísimo, así que se espera una pausa +# corta en el tipeo antes de reconstruirlo. +_PREVIEW_DEBOUNCE = 0.5 + + class EditorScreen(ModalScreen[str]): - """Editor de cuerpo Markdown con preview en vivo. Devuelve el texto final - al llamador (ctrl+s / botón Listo) o None si se cancela (escape).""" + """Editor de cuerpo Markdown con preview en vivo (Markdown + LaTeX + renderizado + imágenes locales/remotas). Devuelve el texto final al + llamador (ctrl+s / botón Listo) o None si se cancela (escape).""" BINDINGS = [ ("ctrl+s", "submit", "Listo"), @@ -16,6 +31,7 @@ class EditorScreen(ModalScreen[str]): super().__init__() self._initial_text = initial_text self._title = title + self._debounce_timer = None def compose(self): with Vertical(): @@ -28,8 +44,7 @@ class EditorScreen(ModalScreen[str]): id="editor-textarea", classes="editor-pane", ) - with VerticalScroll(id="editor-preview-scroll", classes="editor-pane"): - yield Markdown(self._initial_text, id="editor-preview") + yield VerticalScroll(id="editor-preview-scroll", classes="editor-pane") yield Footer() def on_mount(self) -> None: @@ -37,6 +52,9 @@ class EditorScreen(ModalScreen[str]): text_area.focus() text_area.cursor_location = text_area.document.end self.watch(text_area, "scroll_y", self._sync_preview_scroll) + self.run_worker( + self._rebuild_preview(self._initial_text), exclusive=True, group="preview" + ) def _sync_preview_scroll(self, old_value: float, new_value: float) -> None: text_area = self.query_one("#editor-textarea", TextArea) @@ -48,9 +66,48 @@ class EditorScreen(ModalScreen[str]): preview_scroll.scroll_y = ratio * preview_scroll.max_scroll_y def on_text_area_changed(self, event: TextArea.Changed) -> None: - preview = self.query_one("#editor-preview", Markdown) - preview.update(event.text_area.text) + if self._debounce_timer is not None: + self._debounce_timer.stop() + text = event.text_area.text + self._debounce_timer = self.set_timer( + _PREVIEW_DEBOUNCE, lambda: self._start_rebuild(text) + ) + def _start_rebuild(self, text: str) -> None: + self.run_worker(self._rebuild_preview(text), exclusive=True, group="preview") + + 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, + imágenes locales del repo o remotas por URL).""" + segments = await asyncio.to_thread(preview_mod.tokenize, text) + widgets = [] + for segment in segments: + kind = segment[0] + if kind == "text": + if segment[1].strip(): + widgets.append(Markdown(segment[1])) + elif kind == "math": + _, tex, is_display = segment + png_path = await asyncio.to_thread(preview_mod.render_math, tex, is_display) + if png_path is not None and AutoImage is not None: + widgets.append(AutoImage(str(png_path), classes="preview-math")) + else: + widgets.append(Static(f"[$] {tex}", classes="preview-fallback")) + elif kind == "image": + _, ref, alt = segment + img_path = await asyncio.to_thread(preview_mod.resolve_image, ref) + if img_path is not None and AutoImage is not None: + widgets.append(AutoImage(str(img_path), classes="preview-image")) + else: + widgets.append(Static(f"[img no encontrada: {ref}]", classes="preview-fallback")) + if alt: + widgets.append(Static(alt, classes="preview-caption")) + + preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll) + await preview_scroll.remove_children() + await preview_scroll.mount_all(widgets or [Markdown("")]) + def action_submit(self) -> None: text = self.query_one("#editor-textarea", TextArea).text self.dismiss(text) blob - /dev/null blob + 57cc5600720bd7c08d03c9adbcce665ab366bf00 (mode 644) --- /dev/null +++ nezzontli_ctl/preview.py @@ -0,0 +1,145 @@ +"""Preview enriquecido para EditorScreen: parte el Markdown/Zola en +segmentos (texto, fórmulas LaTeX, imágenes locales o remotas) para poder +mezclar widgets Markdown + Image en la pantalla de edición. + +Nada de esto lo usa el build real del sitio (Zola) — es solo para que la +TUI muestre una aproximación visual de lo que va a salir. +""" + +import hashlib +import re +import urllib.request +from pathlib import Path + +from nezzontli_ctl.config import REPO_ROOT + +CACHE_DIR = Path.home() / ".cache" / "nezzontli-ctl" / "preview" + +_DISPLAY_MATH_RE = re.compile(r"\$\$(.+?)\$\$", re.DOTALL) +_INLINE_MATH_RE = re.compile(r"(? Path bajo REPO_ROOT/static, + o None si el archivo no existe.""" + candidate = REPO_ROOT / "static" / path_str.lstrip("/") + return candidate if candidate.is_file() else None + + +def _cache_path_for_url(url, prefix="remote"): + CACHE_DIR.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(url.encode("utf-8")).hexdigest()[:24] + suffix = Path(url.split("?")[0]).suffix or ".img" + return CACHE_DIR / f"{prefix}-{digest}{suffix}" + + +def _fetch_remote_image(url, timeout=5): + cached = _cache_path_for_url(url) + if cached.is_file(): + return cached + # Sin User-Agent, varios hosts (ej. Wikimedia) devuelven 403. + req = urllib.request.Request(url, headers={"User-Agent": "nezzontli-ctl-preview/1.0"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + data = resp.read() + except Exception: + return None + cached.write_bytes(data) + return cached + + +def resolve_image(path_or_url): + """Devuelve un Path local a la imagen (descargándola a caché si es + remota) o None si no se pudo resolver.""" + if path_or_url.startswith("http://") or path_or_url.startswith("https://"): + return _fetch_remote_image(path_or_url) + return _resolve_local_image(path_or_url) + + +def render_math(tex, display=False): + """Renderiza una fórmula LaTeX-ish (sintaxis mathtext de matplotlib, + subconjunto de LaTeX) a PNG, cacheado por contenido. Devuelve el Path + del PNG o None si matplotlib no pudo parsearla.""" + from matplotlib import mathtext + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + key = f"{'display' if display else 'inline'}:{tex}" + digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:24] + cached = CACHE_DIR / f"math-{digest}.png" + if cached.is_file(): + return cached + try: + mathtext.math_to_image( + f"${tex}$", str(cached), dpi=200 if display else 150, color="#ebdbb2" + ) + except Exception: + return None + return cached + + +def tokenize(text): + """Parte `text` en segmentos ordenados: + ("text", markdown), ("math", tex, es_display), ("image", ruta_o_url, alt) + """ + matches = [] + for m in _GALLERY_RE.finditer(text): + matches.append((m.start(), m.end(), "gallery", m)) + for m in _PHOTO_RE.finditer(text): + matches.append((m.start(), m.end(), "photo", m)) + for m in _IMAGE_SHORTCODE_RE.finditer(text): + matches.append((m.start(), m.end(), "image_shortcode", m)) + for m in _MD_IMAGE_RE.finditer(text): + matches.append((m.start(), m.end(), "md_image", m)) + 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]) + + segments = [] + cursor = 0 + for start, end, kind, m in matches: + if start < cursor: + continue + if start > cursor: + segments.append(("text", text[cursor:start])) + + if kind == "gallery": + for qm in _QUOTED_RE.finditer(m.group(1)): + path, _, alt = qm.group(1).partition("::") + segments.append(("image", path, alt)) + elif kind == "photo": + kv = _parse_kv_args(m.group(1)) + if "path" in kv: + segments.append(("image", kv["path"], kv.get("alt", ""))) + elif kind == "image_shortcode": + kv = _parse_kv_args(m.group(1)) + if "url" in kv: + segments.append(("image", kv["url"], kv.get("alt", ""))) + elif kind == "md_image": + segments.append(("image", m.group(2), m.group(1))) + elif kind == "display_math": + segments.append(("math", m.group(1).strip(), True)) + elif kind == "inline_math": + segments.append(("math", m.group(1).strip(), False)) + + cursor = end + + if cursor < len(text): + segments.append(("text", text[cursor:])) + return segments blob - 60f5416419df679aa787d2359d83a41de5734327 blob + 5d85569a77942f99e135bf07b56a067f914c0561 --- nezzontli_ctl/styles.tcss +++ nezzontli_ctl/styles.tcss @@ -49,6 +49,29 @@ overflow-y: auto; } +.preview-image { + height: 18; + width: auto; + margin: 1 0; +} + +.preview-math { + height: 3; + width: auto; + margin: 1 0; +} + +.preview-caption { + color: $text-muted; + text-style: italic; + margin: 0 0 1 0; +} + +.preview-fallback { + color: $warning; + margin: 1 0; +} + #confirm-diff { height: 1fr; border: solid $panel; blob - 226e4bd680918eddfb44ed2d44479ded5788a298 blob + c8284457787f04b8f369ee95704cf71ff6128bec --- pyproject.toml +++ pyproject.toml @@ -7,6 +7,8 @@ dependencies = [ "textual>=0.80", "pyfiglet>=1.0", "cowsay>=6.1", + "textual-image>=0.8", + "matplotlib>=3.8", ] [project.scripts]