Table Of Contents

Source code for kivy.uix.svg

'''
SvgWidget
=========

A Kivy widget for displaying SVG content using adaptive rasterization via
the :mod:`kivy.core.svg` provider system.

.. rubric:: Features

- SVG loading from :attr:`~SvgWidget.source` (local file path or in-memory
  SVG ``bytes``; :class:`AsyncSvgWidget` additionally accepts HTTP/HTTPS URLs)
- Efficient downscaling using mipmaps (enabled by default)
- Re-rasterization at higher resolution when the display scale crosses an
  internal threshold
- Limited runtime document customisation:

  - :attr:`~SvgWidget.current_color` - sets the CSS ``currentColor`` value
  - :attr:`~SvgWidget.element_overrides` - per-element visibility and opacity
    by SVG element ``id``

- :class:`AsyncSvgWidget` adds asynchronous HTTP/HTTPS loading

.. rubric:: Difference from the SVG image provider

The SVG image provider (``kivy.core.image``) lets you use
``Image(source="file.svg")`` and renders once at a fixed size.
``SvgWidget`` differs in:

- **Adaptive re-rasterization** - re-renders when scaling up beyond threshold
- **Mipmap-first** - mipmaps enabled by default
- **``current_color``** - CSS ``currentColor`` injection not available via
  the image pipeline
- **Element overrides** - per-element visibility/opacity not available via
  the image pipeline

.. rubric:: Basic usage

::

    from kivy.uix.svg import SvgWidget

    svg = SvgWidget(source='diagram.svg', size_hint=(None, None), size=(64, 64))

KV::

    SvgWidget:
        source: 'diagram.svg'
        current_color: 1, 0, 0   # red currentColor
        on_load: print('SVG ready')

    AsyncSvgWidget:
        source: 'https://example.com/diagram.svg'

.. rubric:: SVG icon button

Combine :class:`~kivy.uix.behaviors.ToggleButtonBehavior` with
:class:`SvgWidget` to create a tappable icon that changes colour on toggle.
This pattern works with any SVG that uses ``stroke="currentColor"`` or
``fill="currentColor"``.  Most popular svg icon libraries use ``currentColor`` by
default, including `Lucide <https://lucide.dev>`_, `Heroicons
<https://heroicons.com>`_, and `Tabler Icons <https://tabler.io/icons>`_.

::

    # Python
    from kivy.uix.behaviors import ToggleButtonBehavior
    from kivy.uix.svg import SvgWidget

    class StarIconToggleButton(ToggleButtonBehavior, SvgWidget):
        pass

    # KV
    <StarIconToggleButton>:
        source: 'star.svg'
        size_hint: None, None
        size: '64dp', '64dp'
        fit_mode: 'contain'
        mipmap: False
        current_color: 'gold' if self.activated else 'grey'

.. versionadded:: 3.0.0
'''

from kivy.clock import Clock
from kivy.graphics.texture import Texture
from kivy.logger import Logger
from kivy.network.urlrequest import UrlRequest
from kivy.properties import (
    AliasProperty,
    BooleanProperty,
    ColorProperty,
    DictProperty,
    ListProperty,
    ObjectProperty,
    OptionProperty,
)
from kivy.resources import resource_find
from kivy.uix.image import Image
from kivy.uix.widget import Widget

__all__ = ('SvgWidget', 'AsyncSvgWidget')

_SvgLoader = None


def _next_power_of_2(n):
    '''Return the smallest power of 2 that is >= n (minimum 1).'''
    n = int(n)
    if n <= 1:
        return 1
    return 1 << (n - 1).bit_length()


def _get_svg_loader():
    global _SvgLoader
    if _SvgLoader is None:
        from kivy.core.svg import SvgLoader
        _SvgLoader = SvgLoader
    return _SvgLoader


[docs] class SvgWidget(Widget): '''Widget that displays an SVG document using adaptive rasterization. The SVG is rasterized to a :class:`~kivy.graphics.texture.Texture` via the :mod:`kivy.core.svg` provider. The texture is re-created when the display size grows beyond :attr:`_RERENDER_THRESHOLD` times the current raster size, keeping the image sharp at larger scales without wasting GPU memory at smaller ones. Downscaling is handled efficiently by OpenGL mipmaps (:attr:`mipmap` defaults to ``True``). ''' # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ source = ObjectProperty(None, allownone=True) '''SVG source - a file path, raw bytes, or (for :class:`AsyncSvgWidget`) an HTTP/HTTPS URL. The type determines how the SVG is loaded: - ``str`` or :class:`pathlib.Path` - resolved via ``resource_find`` and loaded from the local filesystem. - ``bytes`` or ``bytearray`` - loaded directly as in-memory SVG data; no file I/O is performed. Useful for embedding SVG content in code. When passing ``bytes``, Kivy forwards them to the SVG renderer unchanged. No encoding conversion is performed, so the byte sequence must already match the encoding declared in the SVG (e.g. ``encoding="utf-8"`` or ``encoding="gb2312"``). Use ``open(path, 'rb')`` or an explicit ``str.encode(encoding)`` call to produce correctly encoded bytes; the proper encoding is the caller's responsibility. Setting this property automatically triggers a load and rasterize. :attr:`source` is an :class:`~kivy.properties.ObjectProperty` and defaults to ``None``. ''' texture = ObjectProperty(None, allownone=True) '''Current rasterized :class:`~kivy.graphics.texture.Texture`. Updated after every successful rasterization (including re-renders on scale-up). :attr:`texture` is an :class:`~kivy.properties.ObjectProperty` and defaults to ``None``. ''' texture_size = ListProperty([0, 0]) '''Pixel dimensions of the current :attr:`texture`. Updated whenever :attr:`texture` changes. Reflects the size at which the SVG was last rasterized, which equals the widget size at render time. Use this to read back the rendered resolution (for example, to pass to a shader or to report in a UI label). .. note:: Do **not** use ``texture_size`` to drive the widget size (e.g. ``size: self.texture_size`` in KV). The widget must have a non-zero size before rasterization can produce a texture, so this creates a circular dependency that leaves the widget invisible. Use :attr:`viewbox_size` instead. :attr:`texture_size` is a :class:`~kivy.properties.ListProperty` and defaults to ``[0, 0]``. ''' fit_mode = OptionProperty( 'scale-down', options=('scale-down', 'fill', 'contain', 'cover'), ) '''Scaling mode for display within the widget bounds. Identical in meaning to :attr:`~kivy.uix.image.Image.fit_mode`. Available options: - ``"scale-down"`` - never upscales; preserves aspect ratio. - ``"fill"`` - stretches to fill the widget; ignores aspect ratio. - ``"contain"`` - fits inside widget; may upscale; preserves ratio. - ``"cover"`` - fills widget; may crop; preserves ratio. :attr:`fit_mode` is an :class:`~kivy.properties.OptionProperty` and defaults to ``'scale-down'``. ''' color = ColorProperty([1, 1, 1, 1]) '''Tint colour applied via the canvas ``Color`` instruction. :attr:`color` is a :class:`~kivy.properties.ColorProperty` and defaults to ``[1, 1, 1, 1]`` (opaque white - no tint). ''' mipmap = BooleanProperty(True) '''Enable mipmap generation on the rasterized texture. Mipmaps allow efficient GPU downscaling without aliasing artefacts. Defaults to ``True`` (unlike :class:`~kivy.uix.image.Image`). Changing this property after load triggers a re-rasterization. :attr:`mipmap` is a :class:`~kivy.properties.BooleanProperty` and defaults to ``True``. ''' current_color = ColorProperty([0, 0, 0, 1]) '''RGB colour injected for the CSS ``currentColor`` keyword at render time. Every ``currentColor`` token in the SVG source is replaced with this colour before rasterization. Changing this property triggers a re-render at the current raster size. Per the SVG spec, ``currentColor`` is a pure RGB value - opacity is a separate concern expressed via ``fill-opacity`` / ``stroke-opacity`` attributes in the SVG source. Only the red, green and blue components of this property are used; the alpha component is ignored. :attr:`current_color` is a :class:`~kivy.properties.ColorProperty` and defaults to ``[0, 0, 0, 1]`` (black). ''' element_overrides = DictProperty({}) '''Per-element render overrides keyed by SVG element ``id``. Each value is a dict with optional keys: - ``'visible'`` (bool) - ``False`` forces opacity to 0. - ``'opacity'`` (float 0.0-1.0) - overrides the SVG native opacity when the element is visible. Absent keys fall back to SVG defaults. Changing this property triggers a re-render at the current raster size. Example:: svg.element_overrides = { 'badge': {'visible': False}, 'highlight': {'opacity': 0.5}, } :attr:`element_overrides` is a :class:`~kivy.properties.DictProperty` and defaults to ``{}``. ''' viewbox_size = ListProperty([0, 0]) '''Intrinsic SVG dimensions from the ``viewBox`` or ``width``/``height`` attributes. Set at load time, before rasterization; ``[0, 0]`` before any source is loaded. This is the correct property to use when you want the widget to display at the SVG's natural pixel size. Because ``viewbox_size`` is populated before the deferred rasterize fires, a KV binding like:: SvgWidget: size_hint: None, None size: self.viewbox_size works without a circular dependency: the size is known by the time the first rasterization is scheduled, so the texture is produced at exactly the right dimensions. Contrast with :attr:`texture_size` (set *after* rasterization, so it cannot safely drive the widget size) and :attr:`norm_image_size` (depends on ``self.size`` internally, same circular problem). :attr:`viewbox_size` is a :class:`~kivy.properties.ListProperty` and defaults to ``[0, 0]``. ''' loaded = BooleanProperty(False) '''``True`` once the SVG has been successfully loaded and the first texture is ready. Resets to ``False`` when :attr:`source` changes or :meth:`reload` is called. Use this as a guard before calling element manipulation methods:: if svg.loaded: svg.hide_element('badge') :attr:`loaded` is a :class:`~kivy.properties.BooleanProperty` and defaults to ``False``. ''' status = OptionProperty( 'empty', options=('empty', 'loading', 'ready', 'error'), ) '''Current load state. +------------+-------------------------------------+ | Value | Meaning | +============+=====================================+ | ``empty`` | No source set | +------------+-------------------------------------+ | ``loading``| Source set; loading in progress | +------------+-------------------------------------+ | ``ready`` | SVG loaded and texture available | +------------+-------------------------------------+ | ``error`` | Load or render failed | +------------+-------------------------------------+ ``loaded`` is always equivalent to ``status == 'ready'``. Use whichever reads more clearly: ``status`` when you need to distinguish between states (e.g. show a spinner while ``'loading'``, an error icon on ``'error'``), or ``loaded`` for a simple boolean guard (``if self.loaded:``). :attr:`status` is an :class:`~kivy.properties.OptionProperty` and defaults to ``'empty'``. ''' # ------------------------------------------------------------------ # Events # ------------------------------------------------------------------ __events__ = ('on_load', 'on_error') # ------------------------------------------------------------------ # Private state # ------------------------------------------------------------------ def _get_image_ratio(self): if self.texture: w, h = self.texture.size return w / max(1.0, h) vw, vh = self.viewbox_size if vh: return vw / vh return 1.0 image_ratio = AliasProperty( _get_image_ratio, bind=('texture', 'viewbox_size'), cache=True, )
[docs] def get_norm_image_size(self): '''Return the display size of the SVG after applying :attr:`fit_mode`. Mirrors :meth:`~kivy.uix.image.Image.get_norm_image_size` exactly. ''' if not self.texture: return list(self.size) ratio = self.image_ratio w, h = self.size tw, th = self.texture.size if self.fit_mode == 'cover': widget_ratio = w / max(1, h) if widget_ratio > ratio: return [w, (w * th) / tw] else: return [(h * tw) / th, h] elif self.fit_mode == 'fill': return [w, h] elif self.fit_mode == 'contain': iw = w else: # scale-down iw = min(w, tw) ih = iw / ratio if ih > h: if self.fit_mode == 'contain': ih = h else: ih = min(h, th) iw = ih * ratio return [iw, ih]
norm_image_size = AliasProperty( get_norm_image_size, bind=('texture', 'size', 'image_ratio', 'fit_mode'), cache=True, ) '''The portion of the widget area actually occupied by the rendered image after :attr:`fit_mode` geometry is applied. For example, with ``fit_mode='contain'`` on a 400x400 widget displaying a 400x250 SVG, ``norm_image_size`` returns ``[400, 250]`` — the image fills the full width but only part of the height. Use this to center an overlay on the rendered content, to do hit-testing against the visible image region, or to place a sibling widget alongside the actual (not padded) image area. .. note:: Do **not** use ``norm_image_size`` to drive the widget size. :meth:`get_norm_image_size` reads ``self.size`` internally, so binding ``size: self.norm_image_size`` creates the same circular dependency as :attr:`texture_size`. Use :attr:`viewbox_size` for that purpose. :attr:`norm_image_size` is an :class:`~kivy.properties.AliasProperty` and is read-only. ''' # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ def __init__(self, **kwargs): self._svg_provider = None self._raster_size = [0, 0] self._deferred_rasterize = None fbind = self.fbind fbind('source', self.texture_update) fbind('mipmap', self._on_mipmap) fbind('current_color', self._on_render_param) fbind('element_overrides', self._on_render_param) fbind('size', self._check_rerender) super().__init__(**kwargs) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def texture_update(self, *largs): '''Load (or reload) the SVG from :attr:`source` and rasterize. Called automatically when :attr:`source` changes. Can also be called manually to force a reload. ''' source = self.source if not source: self._clear_svg() return self.loaded = False self.status = 'loading' loader = _get_svg_loader() if isinstance(source, (bytes, bytearray)): provider = loader.load_data(source) if not provider: msg = 'SvgWidget: failed to load SVG from in-memory bytes' Logger.error(msg) self._set_error(msg) return else: path = resource_find(str(source)) if not path: msg = f'SvgWidget: source not found: {source!r}' Logger.error(msg) self._set_error(msg) return provider = loader.load(path) if not provider: msg = f'SvgWidget: no SVG provider could load {path!r}' Logger.error(msg) self._set_error(msg) return self._svg_provider = provider self.viewbox_size = list(provider.get_document_size()) # Always defer the first rasterize by one clock tick. This lets KV # finish applying all rules (including size bindings) before we render, # so the first texture is produced at the correct widget size rather # than the Widget default of (100, 100). if self._deferred_rasterize: self._deferred_rasterize.cancel() self._deferred_rasterize = Clock.schedule_once( self._deferred_rasterize_cb, 0 )
[docs] def reload(self): '''Discard the current SVG document and reload from :attr:`source`. Resets :attr:`loaded` and :attr:`status` immediately; the new texture is available after the next successful rasterization. ''' self._svg_provider = None self._raster_size = [0, 0] self.loaded = False self.status = 'loading' old_source = self.source self.source = None self.source = old_source
[docs] def get_element_ids(self): '''Return a list of all SVG element ``id`` values in the document. The list is built once at load time. Returns an empty list if the SVG has not been loaded yet, and logs a warning. :rtype: list[str] ''' if not self.loaded: Logger.warning( 'SvgWidget: get_element_ids() called before SVG is loaded' ) return [] return self._svg_provider.get_element_ids()
[docs] def hide_element(self, element_id): '''Hide the SVG element with the given ``id``. Convenience wrapper for ``set_element_visible(element_id, False)``. :param str element_id: The SVG element ``id`` attribute value. ''' self.set_element_visible(element_id, False)
[docs] def show_element(self, element_id): '''Show the SVG element with the given ``id``. Convenience wrapper for ``set_element_visible(element_id, True)``. :param str element_id: The SVG element ``id`` attribute value. ''' self.set_element_visible(element_id, True)
[docs] def set_element_visible(self, element_id, visible): '''Set the visibility of an SVG element by its ``id``. Stores the override in :attr:`element_overrides` and triggers a re-render. If the SVG is not yet loaded, the override is still stored and will be applied when the document loads. If *element_id* is not found in the document, a warning is logged but the override is still stored. :param str element_id: The SVG element ``id``. :param bool visible: ``True`` to show, ``False`` to hide. ''' if not self.loaded: Logger.warning( 'SvgWidget: set_element_visible() called before SVG is ' 'loaded; override will be applied on load.' ) elif element_id not in self._svg_provider.get_element_ids(): Logger.warning( f'SvgWidget: element id {element_id!r} not found in SVG' ' document' ) self._set_element_override(element_id, 'visible', visible)
[docs] def is_element_visible(self, element_id): '''Return whether the element with *element_id* is currently visible. Returns ``True`` for elements with no override (SVG default). :param str element_id: The SVG element ``id``. :rtype: bool ''' return self.element_overrides.get(element_id, {}).get('visible', True)
[docs] def set_element_opacity(self, element_id, opacity): '''Set the opacity override for an SVG element by its ``id``. Stores the override in :attr:`element_overrides` and triggers a re-render. If the SVG is not yet loaded, the override is still stored. If *element_id* is not found in the document, a warning is logged. :param str element_id: The SVG element ``id``. :param float opacity: Opacity value 0.0 (transparent) - 1.0 (opaque). ''' if not self.loaded: Logger.warning( 'SvgWidget: set_element_opacity() called before SVG is ' 'loaded; override will be applied on load.' ) elif element_id not in self._svg_provider.get_element_ids(): Logger.warning( f'SvgWidget: element id {element_id!r} not found in SVG' ' document' ) self._set_element_override(element_id, 'opacity', float(opacity))
[docs] def get_element_opacity(self, element_id): '''Return the current opacity override for *element_id*. Returns ``1.0`` for elements with no opacity override. :param str element_id: The SVG element ``id``. :rtype: float ''' return float(self.element_overrides.get(element_id, {}).get('opacity', 1.0))
[docs] def reset_element_overrides(self): '''Clear all element visibility and opacity overrides. Triggers a re-render that restores all elements to their SVG defaults. ''' self.element_overrides = {}
# ------------------------------------------------------------------ # Event handlers # ------------------------------------------------------------------ def on_load(self, *args): pass def on_error(self, error): pass def on_texture(self, instance, texture): self.texture_size = texture.size if texture else [0, 0] # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _set_element_override(self, element_id, key, value): '''Update a single key in :attr:`element_overrides` for *element_id*. Creates the entry if absent. Triggers a re-render via the ``element_overrides`` property binding. ''' overrides = dict(self.element_overrides) entry = dict(overrides.get(element_id, {})) entry[key] = value overrides[element_id] = entry self.element_overrides = overrides def _on_mipmap(self, *largs): '''Re-rasterize when the mipmap setting changes.''' if self._svg_provider and self._raster_size[0] > 0: self._rasterize(*self._raster_size) def _on_render_param(self, *largs): '''Re-render at the current widget size when current_color or element_overrides change.''' if self._svg_provider and self._raster_size[0] > 0: w, h = self.size if w > 0 and h > 0: self._rasterize(int(w), int(h)) def _check_rerender(self, *largs): '''Called on every size change. Re-rasterizes when the widget size crosses into a larger power-of-2 bucket than the current raster size, aligning re-renders with mipmap level boundaries. Widget size (not ``norm_image_size``) is used as the threshold so that ``fit_mode='scale-down'`` does not suppress re-renders: even when the texture is smaller than the widget, we still want to produce a texture at the widget's full resolution. ''' if not self._svg_provider: return w, h = self.size if w <= 0 or h <= 0: return rw, rh = self._raster_size if rw <= 0 or rh <= 0: # Not yet rasterized yet. Schedule a deferred rasterize rather # than calling _rasterize() synchronously. Calling it here would # dispatch on_load before KV finishes applying all rules for this # widget (property bindings are applied in source order, so an # on_load: handler that appears after size: or source: in a KV # rule would miss the event entirely). Deferring by one clock # tick lets all KV rules settle first. if not self._deferred_rasterize: self._deferred_rasterize = Clock.schedule_once( self._deferred_rasterize_cb, 0 ) return # Re-rasterize when the dominant widget dimension crosses into the # next power-of-2 bucket. The dominant raster dimension is always a # power of 2 (the snapped value), so max(rw, rh) gives the bucket. dom_widget = max(int(w), int(h)) dom_raster = max(rw, rh) if _next_power_of_2(dom_widget) > dom_raster: self._rasterize(int(w), int(h)) def _deferred_rasterize_cb(self, dt): self._deferred_rasterize = None w, h = self.size if w > 0 and h > 0 and self._svg_provider: self._rasterize(int(w), int(h)) def _rasterize(self, widget_w, widget_h): '''Rasterize the SVG at a pixel size derived from (widget_w, widget_h). The render dimensions maintain the SVG's natural aspect ratio while fitting within widget_w and widget_h, producing a texture with the correct ratio. Kivy's ``norm_image_size`` then handles display scaling within the widget bounds. ''' if not self._svg_provider: return vw, vh = self.viewbox_size if vw > 0 and vh > 0: svg_ratio = vw / vh widget_ratio = widget_w / max(1, widget_h) if widget_ratio > svg_ratio: render_h = widget_h render_w = max(1, int(widget_h * svg_ratio)) else: render_w = widget_w render_h = max(1, int(widget_w / svg_ratio)) else: render_w, render_h = widget_w, widget_h # Snap the dominant (larger) dimension to the next power-of-2 bucket # and scale the other dimension proportionally. Snapping both # independently would break the aspect ratio for non-square SVGs # (e.g. 512x358 → 512x512, stretching the image). if render_w >= render_h: bucket_w = _next_power_of_2(render_w) bucket_h = max(1, round(bucket_w * render_h / render_w)) else: bucket_h = _next_power_of_2(render_h) bucket_w = max(1, round(bucket_h * render_w / render_h)) rgba = self._svg_provider.render( bucket_w, bucket_h, current_color=self.current_color, element_overrides=self.element_overrides or None, ) if rgba is None: msg = ( f'SvgWidget: render failed for {self.source!r}' f' at {bucket_w}x{bucket_h}' ) Logger.error(msg) self._set_error(msg) return tex = Texture.create( size=(bucket_w, bucket_h), colorfmt='rgba', mipmap=self.mipmap, ) # ``rgba`` is whatever the SVG provider's ``render()`` returned: for # the default ThorVG backend that is a :class:`kivy.lib.thorvg.SwCanvas` # buffer-protocol object wrapping ThorVG's own render target, not a # :class:`bytes` copy. ``blit_buffer`` accepts any buffer-protocol # object and uploads its pixels straight to the GPU, so we avoid the # ``~width * height * 4``-byte copy that ``bytes(canvas.buffer_arr)`` # would have cost per frame (~67 MB at 4K). # # Future optimisation: move the render() call itself off the main # thread (e.g. via ThreadPoolExecutor) so that rasterising a large or # complex SVG does not block Kivy's event loop. The SwCanvas # lifetime stays trivially safe as long as the reference returned # from render() is held until after blit_buffer has completed. tex.blit_buffer(rgba, colorfmt='rgba', bufferfmt='ubyte') tex.flip_vertical() self._raster_size = [bucket_w, bucket_h] self.texture = tex if not self.loaded: self.loaded = True self.status = 'ready' self.dispatch('on_load') def _clear_svg(self): self._svg_provider = None self._raster_size = [0, 0] self.texture = None self.viewbox_size = [0, 0] self.loaded = False self.status = 'empty' def _set_error(self, msg): self.loaded = False self.status = 'error' self.dispatch('on_error', msg)
[docs] class AsyncSvgWidget(SvgWidget): '''Asynchronous variant of :class:`SvgWidget` that downloads SVG files from HTTP/HTTPS URLs in a background thread. Local file paths are loaded synchronously (same behaviour as :class:`SvgWidget`). Remote URLs are downloaded in a daemon thread; the widget shows :attr:`loading_texture` while the download is in progress. .. note:: The loading image and error image are taken from Kivy's global ``Loader.loading_image`` and ``Loader.error_image``, matching :class:`~kivy.uix.image.AsyncImage`. To use a custom image, set these before creating any ``AsyncSvgWidget``:: The loading image supports GIF animation. from kivy.loader import Loader Loader.loading_image = 'my_spinner.gif' Loader.error_image = 'my_error.png' Example KV usage:: AsyncSvgWidget: source: 'https://example.com/icon.svg' on_load: print('downloaded and ready') on_error: print('failed:', args[1]) ''' loading_texture = ObjectProperty(None, allownone=True) '''Initial texture shown the moment a load starts, before the first GIF frame callback fires. Initialised to ``Loader.loading_image.texture`` in ``__init__``. While loading is in progress the widget automatically tracks ``Loader.loading_image.texture`` so animated GIF placeholders play at full frame rate. The binding is removed as soon as the SVG is ready or an error occurs. :attr:`loading_texture` is an :class:`~kivy.properties.ObjectProperty` and defaults to ``None``. ''' error_texture = ObjectProperty(None, allownone=True) '''Texture shown when loading or rendering fails. Initialised to ``Loader.error_image.texture`` in ``__init__``. :attr:`error_texture` is an :class:`~kivy.properties.ObjectProperty` and defaults to ``None``. ''' def __init__(self, **kwargs): self._pending_source = None self._pending_request = None self._loader_image = None # Initialise placeholder textures from Kivy's global Loader images, # matching how AsyncImage works. # # Loader.loading_image returns a raw ImageLoader (e.g. ImageLoaderSDL3) # which has no Kivy property system. We create a kivy.uix.image.Image # widget from the same filename - it has texture as an ObjectProperty # so we can bind to frame changes and drive GIF animation. from kivy.loader import Loader super().__init__(**kwargs) try: self._loader_image = Image( source=Loader.loading_image.filename, fit_mode='scale-down', anim_delay=Loader.loading_image.anim_delay if hasattr(Loader.loading_image, 'anim_delay') else 0.25, ) self.loading_texture = self._loader_image.texture except Exception: pass try: self.error_texture = Loader.error_image.texture except Exception: pass # Re-bind source to our async loader instead of the sync one. self.unbind(source=self.texture_update) self.fbind('source', self._load_source) # Trigger load if source was already set via kwargs. if self.source: self._load_source() # ------------------------------------------------------------------ # Overrides # ------------------------------------------------------------------
[docs] def texture_update(self, *largs): '''No-op - prevents the inherited synchronous load path.''' pass
[docs] def get_norm_image_size(self): '''Return the display size after applying :attr:`fit_mode`. While loading or showing an error placeholder the texture is drawn at its natural size (``scale-down`` behaviour) regardless of :attr:`fit_mode`, so a small spinner GIF is never stretched to fill the widget. Once the SVG is ready the base class logic applies. ''' if self.status in ('loading', 'error') and self._loader_image: self._loader_image.size = self.size return list(self._loader_image.norm_image_size) return super().get_norm_image_size()
norm_image_size = AliasProperty( get_norm_image_size, bind=('texture', 'size', 'image_ratio', 'fit_mode', 'status'), cache=True, ) '''Extends the base :attr:`~SvgWidget.norm_image_size` to also recompute when :attr:`~SvgWidget.status` changes.''' # ------------------------------------------------------------------ # Async load # ------------------------------------------------------------------ def _load_source(self, *args): '''Triggered on :attr:`source` change. Cancels any in-flight request, shows the loading placeholder, then dispatches to sync (local) or async (URI) loading. ''' self._stop_loading_animation() if self._pending_request is not None: self._pending_request.cancel() self._pending_request = None source = self.source if not source: self._clear_svg() return self.loaded = False self.status = 'loading' self._start_loading_animation() self._pending_source = source if isinstance(source, (bytes, bytearray)): loader = _get_svg_loader() provider = loader.load_data(source) if not provider: msg = 'AsyncSvgWidget: failed to load SVG from in-memory bytes' Logger.error(msg) self._async_error(source, msg) return self._install_provider(provider) elif self.is_uri(source): self._pending_request = UrlRequest( source, on_success=self._on_download_success, on_failure=self._on_download_failure, on_error=self._on_download_error, req_headers={ 'User-Agent': 'KivySvgWidget/3.0 (kivy svg widget; ' 'https://github.com/kivy/kivy)' }, timeout=30, ) else: path = resource_find(str(source)) if not path: msg = f'AsyncSvgWidget: source not found: {source!r}' Logger.error(msg) self._async_error(source, msg) return self._do_sync_load(path) def _do_sync_load(self, path): '''Load a local file synchronously (same as SvgWidget but via the async-widget flow, preserving status transitions).''' loader = _get_svg_loader() provider = loader.load(path) if not provider: msg = f'AsyncSvgWidget: no provider could load {path!r}' Logger.error(msg) self._async_error(path, msg) return self._install_provider(provider) def _on_download_success(self, request, data): '''UrlRequest success callback - already on the main thread.''' self._pending_request = None if request.url != self._pending_source: return # Source changed while download was in flight; discard. # UrlRequest may decode text/* responses to str even when decode=False. # svg_thorvg requires bytes throughout, so normalise here. if isinstance(data, str): data = data.encode('utf-8') loader = _get_svg_loader() provider = loader.load_data(data) if not provider: msg = f'AsyncSvgWidget: failed to load SVG data from {request.url!r}' Logger.error(msg) self._async_error(request.url, msg) return self._install_provider(provider) def _on_download_failure(self, request, result): '''UrlRequest failure callback - non-2xx HTTP status.''' self._pending_request = None if request.url != self._pending_source: return msg = f'AsyncSvgWidget: HTTP {request.resp_status} downloading {request.url!r}' Logger.error(msg) self._async_error(request.url, msg) def _on_download_error(self, request, error): '''UrlRequest error callback - network or timeout error.''' self._pending_request = None if request.url != self._pending_source: return msg = f'AsyncSvgWidget: error downloading {request.url!r}: {error}' Logger.error(msg) self._async_error(request.url, msg) def _async_error(self, source, msg): '''Main-thread error handler.''' if source != self._pending_source: return # Stale; ignore. self._stop_loading_animation() self.texture = self.error_texture self._set_error(msg) # ------------------------------------------------------------------ # Loading animation # ------------------------------------------------------------------ def _start_loading_animation(self): '''Show the loading placeholder and bind to its texture so GIF animation frames update :attr:`~SvgWidget.texture` automatically. Safe to call when no loader image is available - does nothing in that case. ''' img = self._loader_image if img is None: return self.texture = img.texture img.bind(texture=self._on_loading_texture) def _on_loading_texture(self, _instance, texture): '''Called each time the loading GIF advances a frame. Ignored once the SVG has finished loading so a late-arriving frame cannot overwrite the rasterized texture. ''' if self.status == 'loading': self.texture = texture def _stop_loading_animation(self): '''Detach from the loading image so GIF frame callbacks stop. Safe to call multiple times or before :meth:`_start_loading_animation`. ''' img = self._loader_image if img is not None: img.unbind(texture=self._on_loading_texture) def _install_provider(self, provider): '''Store *provider* and trigger the first rasterize. Rasterizes immediately if the widget already has a non-zero size, otherwise defers one clock tick (same pattern as :meth:`SvgWidget.texture_update`). ''' self._stop_loading_animation() self._svg_provider = provider self.viewbox_size = list(provider.get_document_size()) w, h = self.size if w > 0 and h > 0: self._rasterize(int(w), int(h)) else: if self._deferred_rasterize: self._deferred_rasterize.cancel() self._deferred_rasterize = Clock.schedule_once( self._deferred_rasterize_cb, 0 ) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------
[docs] @staticmethod def is_uri(filename): '''Return ``True`` if *filename* is an HTTP or HTTPS URL. Non-string values (e.g. ``bytes``, :class:`pathlib.Path`) always return ``False``. :param filename: Source value to test. :rtype: bool ''' if not isinstance(filename, str): return False proto = filename.split('://', 1)[0].lower() return proto in ('http', 'https')