"""Lazy download of LaTeX math fonts in Pyodide builds. Desktop wheels bundle the Latin Modern Math fonts under ``imgui_bundle/assets/fonts/latex/`` (876 KB total). Pyodide wheels exclude them via ``IMGUI_BUNDLE_SLIM_PYODIDE_WHEEL=1`` (see ``pyproject.toml``) and instead download them on first use to keep the wheel small. The download is triggered by ``immapp.run(with_latex=True)`` (and the async / notebook variants) via patches installed in ``pyodide_patch_runners.py``. The fetch is gated on ``with_latex`` so that users who do not need LaTeX never pay the cost. Files are written into the same on-disk path the desktop wheel would use (``/assets/fonts/latex/``), so the C++ wrapper's ``HelloImGui::AssetFileFullPath("fonts/latex/...")`` lookup finds them without needing any C++ change. Failure mode: if all download URLs fail, an error is logged to the JS console and the GUI starts anyway. The C++ wrapper (``imgui_md_wrapper.cpp::EnsureMicroTeXInitialized``) then detects the missing assets via ``HelloImGui::AssetExists`` and falls back to rendering the LaTeX source as plain text inside the markdown, instead of crashing on the asset lookup. """ from __future__ import annotations import os # Pin to a stable git tag so jsdelivr serves an immutable, edge-cached # version of the fonts. The tag points at a known-good repo state and # is bumped manually if the font files ever change (essentially never: # Latin Modern Math is from ~2014). Bump to ``latex-fonts-v2`` if you # rename or replace the font files. _FONTS_REPO_REF = "latex-fonts-v1" # Fallback chain. Each entry is a base URL; the file name is appended. # Tried in order until one succeeds. _FONTS_BASE_URLS: tuple[str, ...] = ( f"https://cdn.jsdelivr.net/gh/pthom/imgui_bundle@{_FONTS_REPO_REF}/imgui_bundle_assets/fonts/latex", f"https://raw.githubusercontent.com/pthom/imgui_bundle/{_FONTS_REPO_REF}/imgui_bundle_assets/fonts/latex", ) # Files to fetch. _FONT_FILES: tuple[str, ...] = ( "latinmodern-math.clm1", "latinmodern-math.otf", ) def _fonts_target_dir() -> str: """Return the on-disk directory where the fonts should live. Matches the desktop wheel layout, so the C++ asset lookup ``HelloImGui::AssetFileFullPath("fonts/latex/...")`` finds them without any further configuration. """ here = os.path.dirname(__file__) return os.path.join(here, "assets", "fonts", "latex") def _fonts_present() -> bool: target = _fonts_target_dir() return all(os.path.exists(os.path.join(target, f)) for f in _FONT_FILES) async def _pyfetch_bytes(url: str) -> bytes: """Fetch ``url`` as bytes via Pyodide's ``pyfetch``. Raises on non-200.""" import pyodide.http # type: ignore[import-not-found] resp = await pyodide.http.pyfetch(url) if resp.status != 200: raise RuntimeError(f"HTTP {resp.status} for {url}") result: bytes = await resp.bytes() return result async def _download_one(fname: str, target_dir: str) -> None: """Download a single font file, trying each base URL in order.""" last_error: Exception | None = None for base in _FONTS_BASE_URLS: url = f"{base}/{fname}" try: data = await _pyfetch_bytes(url) except Exception as e: # noqa: BLE001 last_error = e continue out_path = os.path.join(target_dir, fname) with open(out_path, "wb") as f: f.write(data) return assert last_error is not None raise RuntimeError( f"All download URLs failed for {fname}: {last_error}" ) async def ensure_fonts_async() -> None: """Make sure the LaTeX font files exist on disk. No-op if already present. Called from the patched Pyodide runners before the GUI starts when ``with_latex=True``. Safe to call multiple times: subsequent calls return immediately if both font files already exist. Raises ``RuntimeError`` if any font fails to download from every URL in the fallback chain. Callers in ``pyodide_patch_runners.py`` catch this and log to the JS console without aborting the app. """ if _fonts_present(): return target_dir = _fonts_target_dir() os.makedirs(target_dir, exist_ok=True) for fname in _FONT_FILES: await _download_one(fname, target_dir)