| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- # Part of ImGui Bundle - MIT License - Copyright (c) 2022-2026 Pascal Thomet - https://github.com/pthom/imgui_bundle
- """Provides URL image download support for imgui_md.
- When enabled, markdown images with http:// or https:// URLs are downloaded
- and rendered as textures.
- On desktop, downloads are asynchronous (background thread via ThreadPoolExecutor).
- The callback returns Downloading on first call, then Ready/Failed once the download completes.
- This avoids blocking the UI while images are being fetched.
- On Pyodide, synchronous XMLHttpRequest is used (no threads available).
- """
- import logging
- from concurrent.futures import Future, ThreadPoolExecutor
- from typing import Any, Dict, TYPE_CHECKING
- if TYPE_CHECKING:
- from imgui_bundle import imgui_md
- # Enable logging with:
- # logging.getLogger("imgui_md_image_loader").setLevel(logging.DEBUG)
- log = logging.getLogger("imgui_md_image_loader")
- # Artificial delay for testing async downloads (set to 0 for production)
- _DEBUG_DELAY_SECONDS = 0.0
- # Background thread pool for async downloads (desktop only)
- _executor = ThreadPoolExecutor(max_workers=2)
- _pending: Dict[str, "Future[Any]"] = {}
- def _do_download_desktop(url: str) -> bytes:
- """Blocking download (runs in background thread).
- Returns bytes on success, empty bytes on failure."""
- import urllib.request
- import time
- if _DEBUG_DELAY_SECONDS > 0:
- log.debug("Artificial delay of %.1fs for %s", _DEBUG_DELAY_SECONDS, url)
- time.sleep(_DEBUG_DELAY_SECONDS)
- try:
- req = urllib.request.Request(url, headers={"User-Agent": "imgui_bundle/1.0"})
- with urllib.request.urlopen(req, timeout=10) as resp:
- data: bytes = resp.read()
- log.debug("Downloaded %d bytes from %s", len(data), url)
- return data
- except Exception as e:
- log.warning("Failed to download %s: %s", url, e)
- return b""
- def _download_desktop_async(url: str) -> "imgui_md.MarkdownDownloadResult":
- """Async download callback for desktop (Option B).
- Returns Downloading on first call, Ready/Failed once done.
- Manages pending downloads internally."""
- from imgui_bundle import imgui_md
- result = imgui_md.MarkdownDownloadResult()
- # Already in progress: check if done
- if url in _pending:
- future = _pending[url]
- if not future.done():
- result.status = imgui_md.MarkdownDownloadStatus.downloading
- return result
- # Done - get result
- del _pending[url]
- data = future.result()
- if data:
- result.fill_from_bytes(data)
- result.status = imgui_md.MarkdownDownloadStatus.ready
- else:
- result.status = imgui_md.MarkdownDownloadStatus.failed
- result.error_message = f"Download failed for {url}"
- return result
- # First call: start background download
- log.debug("Starting async download for %s", url)
- _pending[url] = _executor.submit(_do_download_desktop, url)
- result.status = imgui_md.MarkdownDownloadStatus.downloading
- return result
- _pyodide_js_initialized = False
- def _ensure_pyodide_js() -> None:
- """Initialize the JS-side download infrastructure (once)."""
- global _pyodide_js_initialized
- if _pyodide_js_initialized:
- return
- from pyodide.code import run_js # type: ignore
- run_js("""
- window._imgui_downloads = {};
- window._imgui_download_debug_delay_ms = 0; // set to e.g. 3000 to simulate slow downloads
- window._imgui_start_download = function(url) {
- if (window._imgui_downloads[url]) return; // already in progress
- window._imgui_downloads[url] = {status: 'downloading'};
- var doFetch = function() { fetch(url)
- .then(function(response) {
- if (!response.ok) throw new Error('HTTP ' + response.status);
- return response.arrayBuffer();
- })
- .then(function(buf) {
- window._imgui_downloads[url] = {status: 'ready', data: new Uint8Array(buf)};
- })
- .catch(function(e) {
- window._imgui_downloads[url] = {status: 'failed', error: e.toString()};
- }); };
- if (window._imgui_download_debug_delay_ms > 0)
- setTimeout(doFetch, window._imgui_download_debug_delay_ms);
- else
- doFetch();
- };
- window._imgui_poll_download = function(url) {
- return window._imgui_downloads[url] || {status: 'not_started'};
- };
- window._imgui_clear_download = function(url) {
- delete window._imgui_downloads[url];
- };
- """)
- _pyodide_js_initialized = True
- def _download_pyodide_async(url: str) -> "imgui_md.MarkdownDownloadResult":
- """Async download using JS fetch() in Pyodide.
- Returns Downloading on first call, Ready/Failed once done."""
- from imgui_bundle import imgui_md
- from pyodide.code import run_js
- result = imgui_md.MarkdownDownloadResult()
- try:
- _ensure_pyodide_js()
- # Start download if not already in progress
- run_js(f"window._imgui_start_download({repr(url)})")
- # Poll status
- js_result = run_js(f"window._imgui_poll_download({repr(url)})")
- status = js_result.status
- if status == 'downloading':
- result.status = imgui_md.MarkdownDownloadStatus.downloading
- elif status == 'ready':
- data = bytes(js_result.data)
- log.debug("Downloaded %d bytes from %s (Pyodide)", len(data), url)
- result.fill_from_bytes(data)
- result.status = imgui_md.MarkdownDownloadStatus.ready
- # Clean up JS-side storage
- run_js(f"window._imgui_clear_download({repr(url)})")
- elif status == 'failed':
- error_msg = str(js_result.error) if hasattr(js_result, 'error') else "Unknown error"
- log.warning("Failed to download %s (Pyodide): %s", url, error_msg)
- result.status = imgui_md.MarkdownDownloadStatus.failed
- result.error_message = error_msg
- run_js(f"window._imgui_clear_download({repr(url)})")
- else:
- result.status = imgui_md.MarkdownDownloadStatus.downloading
- except Exception as e:
- log.warning("Failed to download %s (Pyodide): %s", url, e)
- result.status = imgui_md.MarkdownDownloadStatus.failed
- result.error_message = str(e)
- return result
- def _get_download_function() -> Any:
- """Return the appropriate download function for the current platform."""
- from imgui_bundle import __bundle_pyodide__
- if __bundle_pyodide__:
- return _download_pyodide_async
- else:
- return _download_desktop_async
- def md_options_with_url_images() -> "imgui_md.MarkdownOptions":
- """Create MarkdownOptions with URL image download support enabled."""
- from imgui_bundle import imgui_md
- opts = imgui_md.MarkdownOptions()
- opts.callbacks.on_download_data = _get_download_function()
- return opts
|