_pyodide_latex_fonts.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """Lazy download of LaTeX math fonts in Pyodide builds.
  2. Desktop wheels bundle the Latin Modern Math fonts under
  3. ``imgui_bundle/assets/fonts/latex/`` (876 KB total). Pyodide wheels
  4. exclude them via ``IMGUI_BUNDLE_SLIM_PYODIDE_WHEEL=1`` (see
  5. ``pyproject.toml``) and instead download them on first use to keep
  6. the wheel small.
  7. The download is triggered by ``immapp.run(with_latex=True)`` (and the
  8. async / notebook variants) via patches installed in
  9. ``pyodide_patch_runners.py``. The fetch is gated on ``with_latex`` so
  10. that users who do not need LaTeX never pay the cost.
  11. Files are written into the same on-disk path the desktop wheel would
  12. use (``<package>/assets/fonts/latex/``), so the C++ wrapper's
  13. ``HelloImGui::AssetFileFullPath("fonts/latex/...")`` lookup finds them
  14. without needing any C++ change.
  15. Failure mode: if all download URLs fail, an error is logged to the
  16. JS console and the GUI starts anyway. The C++ wrapper
  17. (``imgui_md_wrapper.cpp::EnsureMicroTeXInitialized``) then detects the
  18. missing assets via ``HelloImGui::AssetExists`` and falls back to
  19. rendering the LaTeX source as plain text inside the markdown, instead
  20. of crashing on the asset lookup.
  21. """
  22. from __future__ import annotations
  23. import os
  24. # Pin to a stable git tag so jsdelivr serves an immutable, edge-cached
  25. # version of the fonts. The tag points at a known-good repo state and
  26. # is bumped manually if the font files ever change (essentially never:
  27. # Latin Modern Math is from ~2014). Bump to ``latex-fonts-v2`` if you
  28. # rename or replace the font files.
  29. _FONTS_REPO_REF = "latex-fonts-v1"
  30. # Fallback chain. Each entry is a base URL; the file name is appended.
  31. # Tried in order until one succeeds.
  32. _FONTS_BASE_URLS: tuple[str, ...] = (
  33. f"https://cdn.jsdelivr.net/gh/pthom/imgui_bundle@{_FONTS_REPO_REF}/imgui_bundle_assets/fonts/latex",
  34. f"https://raw.githubusercontent.com/pthom/imgui_bundle/{_FONTS_REPO_REF}/imgui_bundle_assets/fonts/latex",
  35. )
  36. # Files to fetch.
  37. _FONT_FILES: tuple[str, ...] = (
  38. "latinmodern-math.clm1",
  39. "latinmodern-math.otf",
  40. )
  41. def _fonts_target_dir() -> str:
  42. """Return the on-disk directory where the fonts should live.
  43. Matches the desktop wheel layout, so the C++ asset lookup
  44. ``HelloImGui::AssetFileFullPath("fonts/latex/...")`` finds them
  45. without any further configuration.
  46. """
  47. here = os.path.dirname(__file__)
  48. return os.path.join(here, "assets", "fonts", "latex")
  49. def _fonts_present() -> bool:
  50. target = _fonts_target_dir()
  51. return all(os.path.exists(os.path.join(target, f)) for f in _FONT_FILES)
  52. async def _pyfetch_bytes(url: str) -> bytes:
  53. """Fetch ``url`` as bytes via Pyodide's ``pyfetch``. Raises on non-200."""
  54. import pyodide.http # type: ignore[import-not-found]
  55. resp = await pyodide.http.pyfetch(url)
  56. if resp.status != 200:
  57. raise RuntimeError(f"HTTP {resp.status} for {url}")
  58. result: bytes = await resp.bytes()
  59. return result
  60. async def _download_one(fname: str, target_dir: str) -> None:
  61. """Download a single font file, trying each base URL in order."""
  62. last_error: Exception | None = None
  63. for base in _FONTS_BASE_URLS:
  64. url = f"{base}/{fname}"
  65. try:
  66. data = await _pyfetch_bytes(url)
  67. except Exception as e: # noqa: BLE001
  68. last_error = e
  69. continue
  70. out_path = os.path.join(target_dir, fname)
  71. with open(out_path, "wb") as f:
  72. f.write(data)
  73. return
  74. assert last_error is not None
  75. raise RuntimeError(
  76. f"All download URLs failed for {fname}: {last_error}"
  77. )
  78. async def ensure_fonts_async() -> None:
  79. """Make sure the LaTeX font files exist on disk. No-op if already present.
  80. Called from the patched Pyodide runners before the GUI starts when
  81. ``with_latex=True``. Safe to call multiple times: subsequent calls
  82. return immediately if both font files already exist.
  83. Raises ``RuntimeError`` if any font fails to download from every
  84. URL in the fallback chain. Callers in ``pyodide_patch_runners.py``
  85. catch this and log to the JS console without aborting the app.
  86. """
  87. if _fonts_present():
  88. return
  89. target_dir = _fonts_target_dir()
  90. os.makedirs(target_dir, exist_ok=True)
  91. for fname in _FONT_FILES:
  92. await _download_one(fname, target_dir)