_imgui_md_image_loader.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. # Part of ImGui Bundle - MIT License - Copyright (c) 2022-2026 Pascal Thomet - https://github.com/pthom/imgui_bundle
  2. """Provides URL image download support for imgui_md.
  3. When enabled, markdown images with http:// or https:// URLs are downloaded
  4. and rendered as textures.
  5. On desktop, downloads are asynchronous (background thread via ThreadPoolExecutor).
  6. The callback returns Downloading on first call, then Ready/Failed once the download completes.
  7. This avoids blocking the UI while images are being fetched.
  8. On Pyodide, synchronous XMLHttpRequest is used (no threads available).
  9. """
  10. import logging
  11. from concurrent.futures import Future, ThreadPoolExecutor
  12. from typing import Any, Dict, TYPE_CHECKING
  13. if TYPE_CHECKING:
  14. from imgui_bundle import imgui_md
  15. # Enable logging with:
  16. # logging.getLogger("imgui_md_image_loader").setLevel(logging.DEBUG)
  17. log = logging.getLogger("imgui_md_image_loader")
  18. # Artificial delay for testing async downloads (set to 0 for production)
  19. _DEBUG_DELAY_SECONDS = 0.0
  20. # Background thread pool for async downloads (desktop only)
  21. _executor = ThreadPoolExecutor(max_workers=2)
  22. _pending: Dict[str, "Future[Any]"] = {}
  23. def _do_download_desktop(url: str) -> bytes:
  24. """Blocking download (runs in background thread).
  25. Returns bytes on success, empty bytes on failure."""
  26. import urllib.request
  27. import time
  28. if _DEBUG_DELAY_SECONDS > 0:
  29. log.debug("Artificial delay of %.1fs for %s", _DEBUG_DELAY_SECONDS, url)
  30. time.sleep(_DEBUG_DELAY_SECONDS)
  31. try:
  32. req = urllib.request.Request(url, headers={"User-Agent": "imgui_bundle/1.0"})
  33. with urllib.request.urlopen(req, timeout=10) as resp:
  34. data: bytes = resp.read()
  35. log.debug("Downloaded %d bytes from %s", len(data), url)
  36. return data
  37. except Exception as e:
  38. log.warning("Failed to download %s: %s", url, e)
  39. return b""
  40. def _download_desktop_async(url: str) -> "imgui_md.MarkdownDownloadResult":
  41. """Async download callback for desktop (Option B).
  42. Returns Downloading on first call, Ready/Failed once done.
  43. Manages pending downloads internally."""
  44. from imgui_bundle import imgui_md
  45. result = imgui_md.MarkdownDownloadResult()
  46. # Already in progress: check if done
  47. if url in _pending:
  48. future = _pending[url]
  49. if not future.done():
  50. result.status = imgui_md.MarkdownDownloadStatus.downloading
  51. return result
  52. # Done - get result
  53. del _pending[url]
  54. data = future.result()
  55. if data:
  56. result.fill_from_bytes(data)
  57. result.status = imgui_md.MarkdownDownloadStatus.ready
  58. else:
  59. result.status = imgui_md.MarkdownDownloadStatus.failed
  60. result.error_message = f"Download failed for {url}"
  61. return result
  62. # First call: start background download
  63. log.debug("Starting async download for %s", url)
  64. _pending[url] = _executor.submit(_do_download_desktop, url)
  65. result.status = imgui_md.MarkdownDownloadStatus.downloading
  66. return result
  67. _pyodide_js_initialized = False
  68. def _ensure_pyodide_js() -> None:
  69. """Initialize the JS-side download infrastructure (once)."""
  70. global _pyodide_js_initialized
  71. if _pyodide_js_initialized:
  72. return
  73. from pyodide.code import run_js # type: ignore
  74. run_js("""
  75. window._imgui_downloads = {};
  76. window._imgui_download_debug_delay_ms = 0; // set to e.g. 3000 to simulate slow downloads
  77. window._imgui_start_download = function(url) {
  78. if (window._imgui_downloads[url]) return; // already in progress
  79. window._imgui_downloads[url] = {status: 'downloading'};
  80. var doFetch = function() { fetch(url)
  81. .then(function(response) {
  82. if (!response.ok) throw new Error('HTTP ' + response.status);
  83. return response.arrayBuffer();
  84. })
  85. .then(function(buf) {
  86. window._imgui_downloads[url] = {status: 'ready', data: new Uint8Array(buf)};
  87. })
  88. .catch(function(e) {
  89. window._imgui_downloads[url] = {status: 'failed', error: e.toString()};
  90. }); };
  91. if (window._imgui_download_debug_delay_ms > 0)
  92. setTimeout(doFetch, window._imgui_download_debug_delay_ms);
  93. else
  94. doFetch();
  95. };
  96. window._imgui_poll_download = function(url) {
  97. return window._imgui_downloads[url] || {status: 'not_started'};
  98. };
  99. window._imgui_clear_download = function(url) {
  100. delete window._imgui_downloads[url];
  101. };
  102. """)
  103. _pyodide_js_initialized = True
  104. def _download_pyodide_async(url: str) -> "imgui_md.MarkdownDownloadResult":
  105. """Async download using JS fetch() in Pyodide.
  106. Returns Downloading on first call, Ready/Failed once done."""
  107. from imgui_bundle import imgui_md
  108. from pyodide.code import run_js
  109. result = imgui_md.MarkdownDownloadResult()
  110. try:
  111. _ensure_pyodide_js()
  112. # Start download if not already in progress
  113. run_js(f"window._imgui_start_download({repr(url)})")
  114. # Poll status
  115. js_result = run_js(f"window._imgui_poll_download({repr(url)})")
  116. status = js_result.status
  117. if status == 'downloading':
  118. result.status = imgui_md.MarkdownDownloadStatus.downloading
  119. elif status == 'ready':
  120. data = bytes(js_result.data)
  121. log.debug("Downloaded %d bytes from %s (Pyodide)", len(data), url)
  122. result.fill_from_bytes(data)
  123. result.status = imgui_md.MarkdownDownloadStatus.ready
  124. # Clean up JS-side storage
  125. run_js(f"window._imgui_clear_download({repr(url)})")
  126. elif status == 'failed':
  127. error_msg = str(js_result.error) if hasattr(js_result, 'error') else "Unknown error"
  128. log.warning("Failed to download %s (Pyodide): %s", url, error_msg)
  129. result.status = imgui_md.MarkdownDownloadStatus.failed
  130. result.error_message = error_msg
  131. run_js(f"window._imgui_clear_download({repr(url)})")
  132. else:
  133. result.status = imgui_md.MarkdownDownloadStatus.downloading
  134. except Exception as e:
  135. log.warning("Failed to download %s (Pyodide): %s", url, e)
  136. result.status = imgui_md.MarkdownDownloadStatus.failed
  137. result.error_message = str(e)
  138. return result
  139. def _get_download_function() -> Any:
  140. """Return the appropriate download function for the current platform."""
  141. from imgui_bundle import __bundle_pyodide__
  142. if __bundle_pyodide__:
  143. return _download_pyodide_async
  144. else:
  145. return _download_desktop_async
  146. def md_options_with_url_images() -> "imgui_md.MarkdownOptions":
  147. """Create MarkdownOptions with URL image download support enabled."""
  148. from imgui_bundle import imgui_md
  149. opts = imgui_md.MarkdownOptions()
  150. opts.callbacks.on_download_data = _get_download_function()
  151. return opts