diff --git a/SkiaSharp.Extended.sln b/SkiaSharp.Extended.sln index 2a132310f3..ffc35e6dc2 100644 --- a/SkiaSharp.Extended.sln +++ b/SkiaSharp.Extended.sln @@ -21,38 +21,118 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Extended.UI.Maui. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharpDemo.Blazor", "samples\SkiaSharpDemo.Blazor\SkiaSharpDemo.Blazor.csproj", "{B7E4C45C-5CAB-444E-B2D3-294151544256}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SkiaSharp.Extended.ImagePyramid.Tests", "tests\SkiaSharp.Extended.ImagePyramid.Tests\SkiaSharp.Extended.ImagePyramid.Tests.csproj", "{C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SkiaSharp.Extended.UI.Blazor", "source\SkiaSharp.Extended.UI.Blazor\SkiaSharp.Extended.UI.Blazor.csproj", "{AB88B47A-1946-40E9-8418-0FD42D031690}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Debug|x64.ActiveCfg = Debug|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Debug|x64.Build.0 = Debug|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Debug|x86.ActiveCfg = Debug|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Debug|x86.Build.0 = Debug|Any CPU {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Release|Any CPU.ActiveCfg = Release|Any CPU {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Release|Any CPU.Build.0 = Release|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Release|x64.ActiveCfg = Release|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Release|x64.Build.0 = Release|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Release|x86.ActiveCfg = Release|Any CPU + {FDA62359-1C0D-4661-8ACF-023EF7DAF2A0}.Release|x86.Build.0 = Release|Any CPU {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Debug|x64.Build.0 = Debug|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Debug|x86.Build.0 = Debug|Any CPU {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Release|Any CPU.ActiveCfg = Release|Any CPU {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Release|Any CPU.Build.0 = Release|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Release|x64.ActiveCfg = Release|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Release|x64.Build.0 = Release|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Release|x86.ActiveCfg = Release|Any CPU + {B5A95CCE-FF80-4ACA-AA49-F150C23C65D5}.Release|x86.Build.0 = Release|Any CPU {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Debug|x64.Build.0 = Debug|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Debug|x86.Build.0 = Debug|Any CPU {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Release|Any CPU.Build.0 = Release|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Release|x64.ActiveCfg = Release|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Release|x64.Build.0 = Release|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Release|x86.ActiveCfg = Release|Any CPU + {2C0DAB3F-1246-4AE7-BFA5-E7F5DDD7E1C4}.Release|x86.Build.0 = Release|Any CPU {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|x64.ActiveCfg = Debug|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|x64.Build.0 = Debug|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Debug|x86.Build.0 = Debug|Any CPU {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|Any CPU.Build.0 = Release|Any CPU {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|Any CPU.Deploy.0 = Release|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|x64.ActiveCfg = Release|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|x64.Build.0 = Release|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|x86.ActiveCfg = Release|Any CPU + {2C67033A-2C49-4146-B942-9CDD2E0BA412}.Release|x86.Build.0 = Release|Any CPU {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Debug|x64.ActiveCfg = Debug|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Debug|x64.Build.0 = Debug|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Debug|x86.Build.0 = Debug|Any CPU {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Release|Any CPU.ActiveCfg = Release|Any CPU {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Release|Any CPU.Build.0 = Release|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Release|x64.ActiveCfg = Release|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Release|x64.Build.0 = Release|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Release|x86.ActiveCfg = Release|Any CPU + {4B4EC78C-33B5-456D-BD7D-4358D16272F4}.Release|x86.Build.0 = Release|Any CPU {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|x64.ActiveCfg = Debug|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|x64.Build.0 = Debug|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|x86.ActiveCfg = Debug|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Debug|x86.Build.0 = Debug|Any CPU {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|Any CPU.ActiveCfg = Release|Any CPU {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|Any CPU.Build.0 = Release|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|x64.ActiveCfg = Release|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|x64.Build.0 = Release|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|x86.ActiveCfg = Release|Any CPU + {B7E4C45C-5CAB-444E-B2D3-294151544256}.Release|x86.Build.0 = Release|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Debug|x64.Build.0 = Debug|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Debug|x86.Build.0 = Debug|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Release|Any CPU.Build.0 = Release|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Release|x64.ActiveCfg = Release|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Release|x64.Build.0 = Release|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Release|x86.ActiveCfg = Release|Any CPU + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D}.Release|x86.Build.0 = Release|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Debug|x64.ActiveCfg = Debug|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Debug|x64.Build.0 = Debug|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Debug|x86.Build.0 = Debug|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Release|Any CPU.Build.0 = Release|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Release|x64.ActiveCfg = Release|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Release|x64.Build.0 = Release|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Release|x86.ActiveCfg = Release|Any CPU + {AB88B47A-1946-40E9-8418-0FD42D031690}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,6 +144,8 @@ Global {2C67033A-2C49-4146-B942-9CDD2E0BA412} = {51B0C2C7-732B-4A5C-A4F2-55655D147866} {4B4EC78C-33B5-456D-BD7D-4358D16272F4} = {5555F827-12DF-4D15-BF07-3A720FC2EF3F} {B7E4C45C-5CAB-444E-B2D3-294151544256} = {51B0C2C7-732B-4A5C-A4F2-55655D147866} + {C8E5D3F1-2A47-4B89-AD16-7F3E2C1B9A5D} = {5555F827-12DF-4D15-BF07-3A720FC2EF3F} + {AB88B47A-1946-40E9-8418-0FD42D031690} = {5DEC7961-7CE3-44D7-A7FC-6185BA2D37FE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {08D78153-5DD7-4C52-A348-46AA448B2CFC} diff --git a/docs/docs/image-pyramid/blazor.md b/docs/docs/image-pyramid/blazor.md new file mode 100644 index 0000000000..1b06a61920 --- /dev/null +++ b/docs/docs/image-pyramid/blazor.md @@ -0,0 +1,165 @@ +# Image Pyramid for Blazor + +Use `SKImagePyramidController` with a plain `SKCanvasView` in Blazor WebAssembly to render Image Pyramid images. There is no custom component — the page wires the services directly to the canvas, giving you full control over layout, interaction, and lifecycle. + +## Quick Start + +```razor +@page "/deepzoom" +@implements IAsyncDisposable +@inject HttpClient Http +@using SkiaSharp.Extended + + + +@code { + private SKCanvasView? _canvas; + private SKImagePyramidController? _controller; + private readonly SKImagePyramidRenderer _renderer = new(); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + _controller = new SKImagePyramidController(); + _controller.InvalidateRequired += OnInvalidateRequired; + + var xml = await Http.GetStringAsync("deepzoom/image.dzi"); + var baseUrl = new Uri(Http.BaseAddress!, "deepzoom/image_files/").ToString(); + var source = SKImagePyramidDziSource.Parse(xml, baseUrl); + + _controller.Load(source, new SKTieredTileProvider(new SKHttpTileFetcher())); + } + + private void OnPaintSurface(SKPaintSurfaceEventArgs e) + { + if (_controller == null) return; + _controller.SetControlSize(e.Info.Width, e.Info.Height); + _controller.Update(); + _renderer.Canvas = e.Surface.Canvas; + _controller.Render(_renderer); + } + + private void OnInvalidateRequired(object? sender, EventArgs e) + => InvokeAsync(() => _canvas?.Invalidate()); + + public async ValueTask DisposeAsync() + { + if (_controller != null) + { + _controller.InvalidateRequired -= OnInvalidateRequired; + _controller.Dispose(); + } + } +} +``` + +## Serving Tile Assets + +Place `.dzi` and tile folder under `wwwroot`. In the project file, mark them as content: + +```xml + + + + +``` + +The `SKTieredTileProvider` with `SKHttpTileFetcher` fetches each tile via `HttpClient`; tile URLs are constructed automatically from the base URL you pass to `SKImagePyramidDziSource.Parse`. + +## Pan and Zoom + +Wire mouse and touch events to the controller's navigation methods: + +```razor + + +@code { + private bool _dragging; + private double _lastX, _lastY; + + private void OnMouseDown(MouseEventArgs e) + { + _dragging = true; + _lastX = e.ClientX; + _lastY = e.ClientY; + } + + private void OnMouseMove(MouseEventArgs e) + { + if (!_dragging || _controller == null) return; + _controller.Pan(e.ClientX - _lastX, e.ClientY - _lastY); + _lastX = e.ClientX; + _lastY = e.ClientY; + _canvas?.Invalidate(); + } + + private void OnMouseUp(MouseEventArgs e) => _dragging = false; + + private void OnWheel(WheelEventArgs e) + { + if (_controller == null) return; + double factor = e.DeltaY < 0 ? 1.15 : 1.0 / 1.15; + _controller.ZoomAboutScreenPoint(factor, e.OffsetX, e.OffsetY); + _canvas?.Invalidate(); + } +} +``` + +## Loading a DZC Collection + +```csharp +var xml = await Http.GetStringAsync("collection.dzc"); +var collection = SKImagePyramidDziCollectionSource.Parse(xml); +collection.TilesBaseUri = Http.BaseAddress!.ToString(); +_controller!.Load(collection, new SKTieredTileProvider(new SKHttpTileFetcher())); +``` + +## Custom Provider + +Pass a custom `ISKImagePyramidTileProvider` to the controller's `Load()` method for advanced scenarios: + +```csharp +// With disk cache (persists across Blazor restarts via OPFS or service worker) +controller.Load(source, new SKTieredTileProvider( + new SKHttpTileFetcher(), + new SKDiskTileCacheStore("/cache/tiles", expiry: TimeSpan.FromDays(7)))); +``` + +See the [Tile Providers](fetching.md) docs for implementing browser storage tiers, delay wrappers, and other custom strategies. + +## Canvas Resize + +When the Blazor page resizes, `SetControlSize` automatically picks up the new dimensions on the next paint. If you want to trigger a reset to fit the image after a resize: + +```csharp +// In a JS interop resize callback: +[JSInvokable] +public void OnCanvasResized() +{ + _controller?.ResetView(); + InvokeAsync(() => _canvas?.Invalidate()); +} +``` + +## Rendering Behaviour + +- **Fit and center**: On load the controller calls `ResetView()` — the full image is visible, centered horizontally and vertically, with aspect ratio preserved. No cropping or distortion. +- **LOD blending**: While high-resolution tiles are in-flight, lower-resolution parent tiles are upscaled and composited as placeholders. +- **Idle detection**: `controller.IsIdle` is `true` when no tiles are loading. You can pause periodic repaints when the view is idle. + +## Related + +- [Image Pyramid overview](index.md) +- [Controller & Viewport](controller.md) +- [Tile Fetching](fetching.md) +- [Caching](caching.md) +- [Image Pyramid for MAUI](maui.md) diff --git a/docs/docs/image-pyramid/caching.md b/docs/docs/image-pyramid/caching.md new file mode 100644 index 0000000000..4d1786cc8d --- /dev/null +++ b/docs/docs/image-pyramid/caching.md @@ -0,0 +1,144 @@ +# Image Pyramid — Caching + +The tile caching system is split into two separate concerns with distinct owners: + +| Concern | Owner | Interface | +| :------ | :---- | :-------- | +| Hot render buffer | Controller (internal) | `ISKImagePyramidTileCache` | +| Persistent storage (disk, browser) | Provider | `ISKImagePyramidTileProvider` | + +--- + +## The Render Buffer (ISKImagePyramidTileCache) + +The render buffer is a **sync-only, in-memory LRU cache** owned entirely by the controller. Its job is to hold decoded tiles so the renderer can draw the current viewport without any I/O. + +```csharp +public interface ISKImagePyramidTileCache : IDisposable +{ + int Count { get; } + bool Contains(SKImagePyramidTileId id); + bool TryGet(SKImagePyramidTileId id, out SKImagePyramidTile? tile); + void Put(SKImagePyramidTileId id, SKImagePyramidTile tile); + bool Remove(SKImagePyramidTileId id); + void Clear(); + + // Call once per frame before drawing to safely dispose evicted tiles + void FlushEvicted(); +} +``` + +The controller creates and manages this cache internally — you don't create or configure it. The `Cache` property on the controller exposes it for read-only monitoring (e.g. showing a tile count in a debug overlay): + +```csharp +// Read-only monitoring — do not call Put/Remove directly +int cachedTileCount = controller.Cache.Count; +``` + +> **Note:** The cache's `FlushEvicted()` is called automatically inside `Render()` — you do not need to call it yourself. + +--- + +## Persistent Storage (ISKImagePyramidTileProvider) + +Persistent tile storage is the **provider's** responsibility, not the controller's. The controller simply calls `provider.GetTileAsync(url)` and the provider decides how to fulfil that request — from a disk cache, browser storage, or directly from the network. + +See [Tile Fetching](fetching.md) for the full provider design and built-in implementations. + +### Remote tiles (HTTP + disk cache) + +```csharp +// SKTieredTileProvider with disk cache persists fetched tiles across app restarts +var provider = new SKTieredTileProvider( + new SKHttpTileFetcher(), + new SKDiskTileCacheStore("/tmp/mycache")); +controller.Load(source, provider); +``` + +### Local tiles (no disk cache needed) + +```csharp +// SKFileTileFetcher reads tiles directly from the filesystem — no extra caching +var provider = new SKTieredTileProvider(new SKFileTileFetcher()); +controller.Load(source, provider); +``` + +### Custom persistent cache + +To add your own persistent storage, wrap a provider in a decorator: + +```csharp +public sealed class MyPersistentProvider : ISKImagePyramidTileProvider +{ + private readonly ISKImagePyramidTileProvider _inner; + private readonly IMyStorage _storage; + + public MyPersistentProvider(ISKImagePyramidTileProvider inner, IMyStorage storage) + { + _inner = inner; + _storage = storage; + } + + public async Task GetTileAsync(string url, CancellationToken ct = default) + { + // 1. Check your persistent store first + var cached = await _storage.TryReadAsync(url, ct); + if (cached is not null) return cached; + + // 2. Delegate to the inner provider (e.g. SKTieredTileProvider) + var tile = await _inner.GetTileAsync(url, ct); + if (tile is null) return null; + + // 3. Persist for next time — use CancellationToken.None so a cancellation + // after fetch doesn't leave the tile un-stored + await _storage.WriteAsync(url, tile, CancellationToken.None); + return tile; + } + + public void Dispose() => _inner.Dispose(); +} +``` + +--- + +## SKImagePyramidTileId + +Each tile is identified by a `readonly record struct` with value equality: + +```csharp +// Level = pyramid level (0 = lowest resolution, MaxLevel = highest) +// Col = column index at that level +// Row = row index at that level +var id = new SKImagePyramidTileId(Level: 12, Col: 3, Row: 5); + +Console.WriteLine(id); // "(12,3,5)" + +// Value equality — safe to use as a dictionary key +var same = new SKImagePyramidTileId(12, 3, 5); +Assert.Equal(id, same); // ✅ +``` + +--- + +## Render Buffer Capacity + +The controller creates its render buffer with a default capacity of 256 tiles. Each tile is typically a 256×256 decoded image — roughly 256 KB at full colour. + +| Device | Approximate capacity | +| :----- | :------------------- | +| Desktop / laptop | 1024–4096 | +| Mid-range mobile | 256–512 | +| Low-memory devices | 64–128 | + +> **Custom capacity** is not currently exposed via the public API. The 256-tile default suits most use cases. + +--- + +## Related + +- [Image Pyramid overview](index.md) +- [Controller & Viewport](controller.md) +- [Tile Fetching](fetching.md) +- [API Reference — ISKImagePyramidTileCache](xref:SkiaSharp.Extended.ISKImagePyramidTileCache) +- [API Reference — ISKImagePyramidTileProvider](xref:SkiaSharp.Extended.ISKImagePyramidTileProvider) + diff --git a/docs/docs/image-pyramid/controller.md b/docs/docs/image-pyramid/controller.md new file mode 100644 index 0000000000..5093386d52 --- /dev/null +++ b/docs/docs/image-pyramid/controller.md @@ -0,0 +1,450 @@ +# Controller & Viewport + +`SKImagePyramidController` is the central orchestrator of the Image Pyramid system. It manages viewport math, tile scheduling, cache population, and rendering. All platform-specific code (canvas, touch, gestures) lives in the caller — the controller knows nothing about UI. + +## SKImagePyramidController + +### Construction + +```csharp +// Parameterless constructor — internal render buffer is managed automatically +var controller = new SKImagePyramidController(); +``` + +### Loading Images + +```csharp +// DZI — single image (HTTP) +var source = SKImagePyramidDziSource.Parse(xmlString, "https://example.com/image_files/"); +controller.Load(source, new SKTieredTileProvider(new SKHttpTileFetcher())); + +// DZC — collection of images +var collection = SKImagePyramidDziCollectionSource.Parse(xmlString); +collection.TilesBaseUri = "https://example.com/"; +controller.Load(collection, new SKTieredTileProvider(new SKHttpTileFetcher())); + +// DZI with disk cache (persists across app restarts) +controller.Load(source, new SKTieredTileProvider( + new SKHttpTileFetcher(), + new SKDiskTileCacheStore(Path.Combine(FileSystem.CacheDirectory, "tiles"), expiry: TimeSpan.FromDays(30)))); + +// Local files +controller.Load(source, new SKTieredTileProvider(new SKFileTileFetcher())); +``` + +`Load()` resets the viewport to show the full image and starts fetching tiles in the background. + +### Render Loop + +Call these methods from your canvas paint handler: + +```csharp +private readonly SKImagePyramidRenderer _renderer = new(); + +void OnPaintSurface(SKPaintSurfaceEventArgs e) +{ + // 1. Update control dimensions (call on every paint; no-op when unchanged) + controller.SetControlSize(e.Info.Width, e.Info.Height); + + // 2. Compute visible tiles and kick off background fetches + controller.Update(); + + // 3. Draw all cached tiles to the canvas + _renderer.Canvas = e.Surface.Canvas; + controller.Render(_renderer); +} +``` + +> **Performance tip:** `controller.IsIdle` is `true` when no tiles are loading. If you have a continuous render loop, you can pause it when idle. + +### Navigation + +```csharp +// Pan by screen pixel deltas (from mouse drag or touch gesture) +controller.Pan(deltaScreenX, deltaScreenY); + +// Zoom about a screen point (anchor stays fixed on screen) +controller.ZoomAboutScreenPoint(factor: 1.2, screenX, screenY); + +// Zoom about a logical image point (0-1 normalized) +controller.ZoomAboutLogicalPoint(factor: 1.5, logicalX: 0.5, logicalY: 0.5); + +// Set zoom level directly (1.0 = image fills control width) +controller.SetZoom(zoom: 2.0); + +// Set viewport state directly +controller.SetViewport(viewportWidth, originX, originY); + +// Fit image to canvas (centered, aspect-ratio preserved) +controller.ResetView(); +``` + +### Properties + +| Property | Type | Description | +| :------- | :--- | :---------- | +| `Viewport` | `SKImagePyramidViewport` | The current viewport (position and zoom). | +| `Cache` | `SKImagePyramidMemoryTileCache` | The internal render buffer (in-memory LRU). | +| `TileLayout` | `SKImagePyramidTileLayout` | The tile geometry and visibility calculator. | +| `TileSource` | `ISKImagePyramidSource?` | The loaded source, or `null`. | +| `SubImages` | `IReadOnlyList` | Sub-images from a DZC; empty for DZI. | +| `AspectRatio` | `double` | Width/height of the loaded image; 0 if not loaded. | +| `IsIdle` | `bool` | `true` when no tiles are in-flight. | +| `PendingTileCount` | `int` | Number of tile fetches currently in progress. | +| `NativeZoom` | `double` | Zoom level where 1 image pixel = 1 screen pixel. | +| `EnableLodBlending` | `bool` | Enable LOD blending (blurry placeholders while tiles load). Default `true`. | + +### LOD Blending + +The controller has an `EnableLodBlending` property that controls whether lower-resolution tiles are used as blurry placeholders while high-resolution tiles stream in: + +```csharp +controller.EnableLodBlending = false; // show blank areas instead of blurry placeholders +``` + +#### How two-pass rendering works + +`SKImagePyramidRenderer` uses a two-pass strategy each frame: + +1. **Pass 1 — fallback tiles** *(only when `EnableLodBlending = true`)*: For each visible tile that is not yet cached, the renderer walks up the tile pyramid to find the nearest available parent tile and draws a magnified crop of it as a placeholder. +2. **Pass 2 — exact tiles**: For each visible tile that is cached at the correct resolution, it is drawn on top. Missing tiles leave any placeholder from Pass 1 visible, or show blank if blending is off. + +#### `EnableLodBlending` — when to use each mode + +**`EnableLodBlending = true` (default):** The view always shows *something* — lower-resolution tiles are stretched up as blurry placeholders while high-res tiles stream in. As tiles arrive, they replace the placeholders and the image progressively sharpens. + +> **Best for:** interactive image exploration, presentations, consumer apps — any situation where a continuously filled viewport is more comfortable than flickering blanks. + +**`EnableLodBlending = false`:** Pass 1 is skipped entirely. Only fully loaded tiles at the exact requested zoom level are drawn; everything else is transparent/white until the tile arrives, then pops in. + +> **Best for:** +> - **Scientific or medical imaging** — blurry placeholders could be confused with real image data; showing "not yet loaded" (blank) is more honest. +> - **Pixel-accurate rendering** — when the consumer must see only confirmed, full-resolution data. +> - **Load-performance testing** — blank tiles make it immediately obvious which tiles are still in-flight. +> - **Bandwidth-constrained apps** — some UX designs prefer showing nothing over showing misleadingly blurry content. + +| Value | Pass 1 (fallbacks) | Pass 2 (exact tiles) | Missing tile appearance | +| :---- | :----------------- | :------------------- | :---------------------- | +| `true` (default) | Draws scaled parent tiles | Draws loaded tiles on top | Blurry placeholder from lower level | +| `false` | Skipped | Draws loaded tiles only | Blank (white) | + +```csharp +// Disable LOD blending on the controller +controller.EnableLodBlending = false; +``` + +> **Tip:** To see the difference interactively, load an image, add a simulated tile delay (e.g. 1–2 seconds), zoom in so new tiles must load, then toggle `EnableLodBlending`. With blending on you get a smooth blurry → sharp transition; with blending off you see white rectangles pop in. + +**Decorator pattern for debug overlays:** + +Instead of building debug features into the core renderer, wrap it with a decorator: + +```csharp +public sealed class TileBorderRenderer : ISKImagePyramidRenderer +{ + private readonly SKImagePyramidRenderer _inner; + private readonly SKPaint _borderPaint = new() { IsStroke = true, StrokeWidth = 1, Color = SKColors.Red.WithAlpha(180) }; + public bool ShowBorders { get; set; } + + public TileBorderRenderer(SKImagePyramidRenderer inner) => _inner = inner; + + // Expose Canvas so the caller sets it once and both renderers see it + public SKCanvas? Canvas + { + get => _inner.Canvas; + set => _inner.Canvas = value; + } + + public void BeginRender() => _inner.BeginRender(); + public void EndRender() => _inner.EndRender(); + + public void DrawTile(SKRect destRect, SKImagePyramidTile tile) + { + _inner.DrawTile(destRect, tile); + if (ShowBorders && _inner.Canvas != null) + _inner.Canvas.DrawRect(destRect, _borderPaint); + } + + public void DrawFallbackTile(SKRect destRect, SKRect sourceRect, SKImagePyramidTile tile) + => _inner.DrawFallbackTile(destRect, sourceRect, tile); + + public void Dispose() + { + _borderPaint.Dispose(); + _inner.Dispose(); + } +} + +// Wire it up — set Canvas once, the decorator forwards it to the inner renderer: +var coreRenderer = new SKImagePyramidRenderer(); +var debugRenderer = new TileBorderRenderer(coreRenderer) { ShowBorders = true }; + +// Each frame: +debugRenderer.Canvas = e.Surface.Canvas; +controller.Render(debugRenderer); +``` + +### Events + +| Event | Signature | Description | +| :---- | :-------- | :---------- | +| `ImageOpenSucceeded` | `EventHandler` | DZI parsed and ready to render. | +| `CollectionOpenSucceeded` | `EventHandler` | DZC collection parsed; `SubImages` is populated. | +| `ImageOpenFailed` | `EventHandler` | Image failed to parse. | +| `InvalidateRequired` | `EventHandler` | A tile loaded — trigger a canvas repaint. | +| `TileFailed` | `EventHandler` | A tile download failed. | +| `ViewportChanged` | `EventHandler` | Viewport position or zoom changed. | + +### Disposal + +```csharp +// Cancels all in-flight tile requests, clears the cache, and releases resources. +controller.Dispose(); +``` + +--- + +## SKImagePyramidViewport + +`SKImagePyramidViewport` handles all coordinate math. You rarely need to access it directly — the controller exposes the most common operations — but it's available via `controller.Viewport` when you need raw position values. + +### Coordinate System + +The viewport uses a **normalized logical coordinate system** where the full image width = 1.0, regardless of actual pixel dimensions. This makes zoom and pan math resolution-independent. + +``` +ViewportWidth = 1.0 → full image fits the control width (zoom = 1.0) +ViewportWidth = 0.5 → zoomed in 2x (zoom = 2.0) +ViewportWidth = 0.25 → zoomed in 4x (zoom = 4.0) +``` + +The `ViewportOriginX` / `ViewportOriginY` are the logical coordinates of the top-left corner of the visible area. + +### Key Properties + +| Property | Description | +| :------- | :---------- | +| `ViewportWidth` | Logical width of visible area (1.0 = full image). | +| `ViewportOriginX` | Logical X of the top-left corner. | +| `ViewportOriginY` | Logical Y of the top-left corner. | +| `Zoom` | `= 1.0 / ViewportWidth`. 1.0 = fit, 2.0 = 2× zoomed in. | +| `ViewportHeight` | Derived: `ViewportWidth × (controlHeight / controlWidth)`. | +| `ControlWidth` | Screen width in pixels. | +| `ControlHeight` | Screen height in pixels. | +| `Scale` | Pixels per logical unit (`controlWidth / viewportWidth`). | +| `MaxViewportWidth` | Maximum zoom-out level (default: unconstrained). | + +### Coordinate Conversion + +```csharp +var vp = controller.Viewport; + +// Screen → logical image coordinates (e.g., map a tap to the image) +var (lx, ly) = vp.ElementToLogicalPoint(screenX, screenY); + +// Logical → screen (e.g., draw an overlay at a known image position) +var (sx, sy) = vp.LogicalToElementPoint(logicalX, logicalY); + +// Visible logical rect at current zoom +var (x, y, w, h) = controller.GetZoomRect(); +``` + +### NativeZoom + +The **native zoom** is the zoom level at which one image pixel maps to exactly one screen pixel. At this level you see maximum detail with no upscaling: + +```csharp +double native = controller.NativeZoom; + +// Navigate to native zoom, centred on current view +var (cx, cy) = vp.ElementToLogicalPoint(vp.ControlWidth / 2, vp.ControlHeight / 2); +controller.ZoomAboutLogicalPoint(native / vp.Zoom, cx, cy); +``` + +--- + +## SKImagePyramidViewportState + +An immutable `readonly record struct` snapshot of the current viewport position and zoom. Useful for saving/restoring viewport state (e.g., for undo/redo, navigation history, or persisting the last position across sessions). + +```csharp +// Capture current state +var state = new SKImagePyramidViewportState( + controller.Viewport.ViewportWidth, + controller.Viewport.ViewportOriginX, + controller.Viewport.ViewportOriginY); + +// Store it (e.g., in a stack for undo) +_history.Push(state); + +// Restore it later +controller.SetViewport(state.ViewportWidth, state.OriginX, state.OriginY); +canvas.InvalidateSurface(); +``` + +| Property | Type | Description | +| :------- | :--- | :---------- | +| `ViewportWidth` | `double` | Logical width of the visible area (1.0 = full image). | +| `OriginX` | `double` | Logical X coordinate of the top-left corner. | +| `OriginY` | `double` | Logical Y coordinate of the top-left corner. | + +Because it's a `record struct`, it has value equality, works as a dictionary key, and supports `with` expressions: + +```csharp +var zoomed = state with { ViewportWidth = state.ViewportWidth * 0.5 }; +``` + +--- + +## SKImagePyramidDziSource + +`SKImagePyramidDziSource` describes a single DZI image and constructs tile URLs. + +### Parsing + +```csharp +// From XML string + base URI for tile requests +var source = SKImagePyramidDziSource.Parse(xmlString, "https://example.com/image_files/"); + +// From a stream +var source = SKImagePyramidDziSource.Parse(stream, "https://example.com/image_files/"); +``` + +### Properties + +| Property | Description | +| :------- | :---------- | +| `ImageWidth` | Full image width in pixels. | +| `ImageHeight` | Full image height in pixels. | +| `TileSize` | Tile edge length in pixels (typically 256 or 512). | +| `Overlap` | Pixel overlap between adjacent tiles (typically 1). | +| `Format` | Tile image format string (`"jpeg"` or `"png"`). | +| `MaxLevel` | Highest zoom level (log₂ of the larger dimension). | +| `AspectRatio` | `ImageWidth / ImageHeight`. | +| `TilesBaseUri` | Base URL for tile requests. | +| `TilesQueryString` | Optional query string appended to every tile URL. | + +### Tile URL Construction + +```csharp +// Relative tile path (Level/Col_Row.format) +string relUrl = source.GetTileUrl(level: 12, col: 3, row: 5); + +// Full absolute tile URL (includes TilesBaseUri) +string fullUrl = source.GetFullTileUrl(level: 12, col: 3, row: 5); + +// Grid dimensions at a given level +int tilesX = source.GetTileCountX(level: 10); +int tilesY = source.GetTileCountY(level: 10); + +// Optimal level to render at given viewport +int level = source.GetOptimalLevel(viewportWidth: vp.ViewportWidth, controlWidth: vp.ControlWidth); +``` + +--- + +## SKImagePyramidDziCollectionSource + +`SKImagePyramidDziCollectionSource` describes a DZC collection — many DZI images composited into one tile pyramid using Morton (Z-order) indexing. + +### Parsing + +```csharp +var collection = SKImagePyramidDziCollectionSource.Parse(xmlString); +collection.TilesBaseUri = "https://example.com/"; +controller.Load(collection, provider); +``` + +### Properties + +| Property | Description | +| :------- | :---------- | +| `MaxLevel` | Pyramid level count. | +| `TileSize` | Tile edge length (pixels). | +| `Format` | Tile image format. | +| `ItemCount` | Number of sub-images. | +| `Items` | All sub-images as `SKImagePyramidDziCollectionSubImage`. | +| `TilesBaseUri` | Base URL for tile requests. | + +### Sub-Images After Load + +```csharp +controller.Load(collection, provider); + +foreach (var sub in controller.SubImages) +{ + int id = sub.Id; + int morton = sub.MortonIndex; + double aspect = sub.AspectRatio; + string? source = sub.Source; +} +``` + +--- + +## SKImagePyramidDziCollectionSubImage + +Represents a single image within a DZC collection, including its position in the Morton-indexed mosaic grid. + +| Property | Type | Description | +| :------- | :--- | :---------- | +| `Id` | `int` | Item ID from the DZC XML. | +| `MortonIndex` | `int` | Z-order (Morton) index in the mosaic grid. | +| `Width` | `int` | Full image width in pixels. | +| `Height` | `int` | Full image height in pixels. | +| `AspectRatio` | `double` | `Width / Height`. | +| `Source` | `string?` | Optional path to the individual `.dzi` file. | +| `ViewportWidth` | `double` | Position in the DZC mosaic coordinate system. | +| `ViewportX` | `double` | X origin in the DZC mosaic coordinate system. | +| `ViewportY` | `double` | Y origin in the DZC mosaic coordinate system. | + +--- + +## SKImagePyramidDisplayRect + +Used in sparse Image Pyramid Images to describe a region of available pixels and the pyramid level range at which it appears. Typically set on `SKImagePyramidDziSource.DisplayRects` when parsing a sparse DZI. + +| Property | Type | Description | +| :------- | :--- | :---------- | +| `X` | `int` | X coordinate in full-image pixels. | +| `Y` | `int` | Y coordinate in full-image pixels. | +| `Width` | `int` | Width in full-image pixels. | +| `Height` | `int` | Height in full-image pixels. | +| `MinLevel` | `int` | Lowest pyramid level where this rect is visible. | +| `MaxLevel` | `int` | Highest pyramid level where this rect is visible. | + +```csharp +bool visible = rect.IsVisibleAtLevel(level: 12); // true if MinLevel ≤ 12 ≤ MaxLevel +``` + +--- + +## SKImagePyramidTileRequest + +Returned by `SKImagePyramidTileLayout.GetVisibleTiles()` — represents a single tile that should be fetched, with a priority that controls fetch order. + +| Property | Type | Description | +| :------- | :--- | :---------- | +| `TileId` | `SKImagePyramidTileId` | The tile to fetch (`Level`, `Col`, `Row`). | +| `Priority` | `double` | Fetch order: lower value = fetched first (higher visual importance). | + +Equality is based on `TileId` only, so a `SKImagePyramidTileRequest` can be de-duplicated by tile regardless of priority: + +```csharp +// Inspect the tile layout's current view +var tiles = controller.TileLayout.GetVisibleTiles(controller.Source, controller.Viewport); +foreach (var req in tiles) + Console.WriteLine($"{req.TileId} priority={req.Priority:F2}"); +``` + +--- + +## Related + +- [Image Pyramid overview](index.md) +- [Tile Fetching](fetching.md) +- [Caching](caching.md) +- [Blazor Integration](blazor.md) +- [MAUI Integration](maui.md) +- [API Reference — SKImagePyramidController](xref:SkiaSharp.Extended.SKImagePyramidController) +- [API Reference — SKImagePyramidViewport](xref:SkiaSharp.Extended.SKImagePyramidViewport) diff --git a/docs/docs/image-pyramid/deepzoom.md b/docs/docs/image-pyramid/deepzoom.md new file mode 100644 index 0000000000..788ac35680 --- /dev/null +++ b/docs/docs/image-pyramid/deepzoom.md @@ -0,0 +1,160 @@ +# Deep Zoom (DZI / DZC) + +Deep Zoom is a tile-based image format developed by Microsoft for Silverlight/Seadragon. It remains widely used for gigapixel image viewing. The format comes in two flavours: + +| Format | Extension | Description | +| :----- | :-------- | :---------- | +| Deep Zoom Image | `.dzi` | A single image sliced into a tile pyramid | +| Deep Zoom Collection | `.dzc` | A mosaic of multiple DZI images in one pyramid | + +Both formats plug into the `ISKImagePyramidSource` interface and are fully interchangeable in the controller. + +--- + +## DZI Format + +A `.dzi` file is a small XML descriptor: + +```xml + + + + +``` + +| Attribute | Description | +| :-------- | :---------- | +| `Format` | Tile image format (`jpeg`, `png`) | +| `Overlap` | Pixel overlap between adjacent tiles (0–2 typical) | +| `TileSize` | Tile size in pixels (256 or 512 common) | +| `Width`, `Height` | Full image dimensions at maximum resolution | + +Tiles are stored alongside the `.dzi` file in a directory named `{name}_files/`: + +``` +image.dzi +image_files/ + 0/0_0.jpeg ← level 0: 1×1 image + 1/0_0.jpeg + ... + 12/3_5.jpeg ← level 12: full res, tile at col=3, row=5 +``` + +### Parsing + +```csharp +using SkiaSharp.Extended; + +// From a URL string +string xml = await httpClient.GetStringAsync("https://example.com/image.dzi"); +string tilesBase = "https://example.com/image_files/"; +var source = SKImagePyramidDziSource.Parse(xml, tilesBase); + +// From a stream +using var stream = File.OpenRead("image.dzi"); +var source = SKImagePyramidDziSource.Parse(stream, "/path/to/image_files/"); +``` + +### Tile URL construction + +```csharp +// Level 0 = lowest resolution (1×1 px), MaxLevel = full resolution +string url = source.GetFullTileUrl(level: 12, col: 3, row: 5); +// → "https://example.com/image_files/12/3_5.jpeg" + +// Pyramid geometry helpers +int levelW = source.GetLevelWidth(12); // image width at level 12 +int levelH = source.GetLevelHeight(12); // image height at level 12 +int cols = source.GetTileCountX(12); // number of tile columns +int rows = source.GetTileCountY(12); // number of tile rows + +// Pixel bounds of a specific tile (includes overlap) +Rect bounds = source.GetTileBounds(level: 12, col: 3, row: 5); +``` + +### Signed / authenticated URLs + +Append a query string (e.g., SAS token) to every tile URL: + +```csharp +source.TilesQueryString = "?sig=abc123&se=2025-12-31"; +``` + +--- + +## DZC Format (Collections) + +A `.dzc` file describes a mosaic of many images composited into a single tile pyramid using Morton (Z-order) indexing: + +```xml + + + + + + + +``` + +### Loading a collection + +```csharp +string xml = await httpClient.GetStringAsync("https://example.com/collection.dzc"); +var collection = SKImagePyramidDziCollectionSource.Parse(xml); +collection.TilesBaseUri = "https://example.com/"; + +controller.Load(collection, new SKTieredTileProvider(new SKHttpTileFetcher())); + +// Access sub-images +foreach (var sub in collection.Items) +{ + Console.WriteLine($" Id={sub.Id} {sub.Width}×{sub.Height} source={sub.Source}"); +} +``` + +### Morton grid layout + +Items in a DZC collection are placed on a 2^N × 2^N Morton grid: + +```csharp +int gridSize = collection.GetMortonGridSize(); // e.g., 4 for a 4×4 grid +var (col, row) = SKImagePyramidDziCollectionSource.MortonToGrid(morton: 3); +int morton = SKImagePyramidDziCollectionSource.GridToMorton(col: 1, row: 1); +``` + +--- + +## Serving DZI Files + +You can generate DZI tile pyramids with: + +| Tool | Platform | Notes | +| :--- | :------- | :---- | +| [deepzoom.py](https://github.com/openzoom/deepzoom.py) | Python | Most widely used | +| [VIPS](https://www.libvips.org/) | CLI | Fastest for huge images | +| [Sharp](https://sharp.pixelplumbing.com/) | Node.js | High-performance image processing | +| [Zoomify converter](https://www.zoomify.com/free.htm) | Windows GUI | Cross-format | + +### Sample DZI URLs + +Try these public DZI files in the demo apps (all support CORS): + +| Source | URL | Dimensions | +| :--- | :--- | :--- | +| SkiaSharp.Extended testgrid | `https://raw.githubusercontent.com/mono/SkiaSharp.Extended/refs/heads/main/resources/collections/testgrid/testgrid.dzi` | Test grid | +| OpenSeadragon highsmith | `https://openseadragon.github.io/example-images/highsmith/highsmith.dzi` | 7026 × 9221 | +| OpenSeadragon duomo | `https://openseadragon.github.io/example-images/duomo/duomo.dzi` | 13920 × 10200 | + +--- + +## Related + +- [Image Pyramid overview](index.md) +- [IIIF Image API](iiif.md) +- [Tile Fetching](fetching.md) +- [API Reference — SKImagePyramidDziSource](xref:SkiaSharp.Extended.SKImagePyramidDziSource) +- [API Reference — SKImagePyramidDziCollectionSource](xref:SkiaSharp.Extended.SKImagePyramidDziCollectionSource) diff --git a/docs/docs/image-pyramid/fetching.md b/docs/docs/image-pyramid/fetching.md new file mode 100644 index 0000000000..bc553f95c0 --- /dev/null +++ b/docs/docs/image-pyramid/fetching.md @@ -0,0 +1,257 @@ +# Image Pyramid — Tile Fetching + +The fetching system is split into three layers with clear separation of concerns: + +``` +Controller + │ GetTileAsync(url) + ▼ +SKTieredTileProvider : ISKImagePyramidTileProvider + │ + ├── ISKTileCacheStore (persistent cache: disk, browser, etc.) + │ + └── ISKTileFetcher (origin fetch: HTTP, file, composite) +``` + +The controller only asks for a decoded tile by URL. Everything below — caching and fetching — is the provider's responsibility. + +--- + +## ISKTileFetcher + +Pure origin fetch — no caching logic. Returns raw encoded bytes, or `null` for a permanent miss (404, file not found). Throws on retriable failures (network errors) so the caller can decide the retry policy. + +```csharp +public interface ISKTileFetcher : IDisposable +{ + Task FetchAsync(string url, CancellationToken ct = default); +} +``` + +### Built-in fetchers + +**`SKHttpTileFetcher`** — HTTP GET. Pass your own `HttpClient` or let the fetcher manage one internally. + +```csharp +// Internal HttpClient (manages its own lifetime) +var fetcher = new SKHttpTileFetcher(); + +// Shared HttpClient (you manage its lifetime) +var fetcher = new SKHttpTileFetcher(myHttpClient); +``` + +**`SKFileTileFetcher`** — Reads from the local filesystem. Accepts plain paths and `file://` URIs. + +```csharp +var fetcher = new SKFileTileFetcher(); +``` + +**`SKCompositeTileFetcher`** — Tries multiple fetchers in order; first non-null result wins. + +```csharp +// Try app-package assets first, fall back to HTTP +var fetcher = new SKCompositeTileFetcher( + new MauiAssetFetcher(), + new SKHttpTileFetcher()); +``` + +--- + +## ISKTileCacheStore + +Persistent tile storage keyed by URL hash. Platform-specific implementations provide disk, browser storage, or database backends. + +```csharp +public interface ISKTileCacheStore : IDisposable +{ + Task TryGetAsync(string key, CancellationToken ct = default); + Task SetAsync(string key, SKImagePyramidTileData data, CancellationToken ct = default); + Task RemoveAsync(string key, CancellationToken ct = default); + Task ClearAsync(CancellationToken ct = default); +} +``` + +### Built-in stores + +**`SKDiskTileCacheStore`** — Filesystem-backed. Uses FNV-1a URL hashing, bucketed directories, and configurable expiry. + +```csharp +var store = new SKDiskTileCacheStore( + basePath: Path.Combine(FileSystem.CacheDirectory, "tiles"), + expiry: TimeSpan.FromDays(30)); // default: 30 days +``` + +**`SKNullTileCacheStore`** — No-op. Useful for testing or when persistence is unwanted. + +```csharp +var store = new SKNullTileCacheStore(); +``` + +**`SKChainedTileCacheStore`** — Tries stores in order for reads; writes go to all stores. + +```csharp +// Read from an app-bundle read-only store, write to disk +var store = new SKChainedTileCacheStore( + new AppBundleReadOnlyStore(), + new SKDiskTileCacheStore(cachePath)); +``` + +--- + +## SKTieredTileProvider + +Composes a fetcher and optional persistent cache into an `ISKImagePyramidTileProvider`. This is the standard implementation for most use cases. + +```csharp +public sealed class SKTieredTileProvider : ISKImagePyramidTileProvider +{ + public SKTieredTileProvider( + ISKTileFetcher fetcher, + ISKTileCacheStore? persistentCache = null) { ... } +} +``` + +**Flow:** persistent cache hit → return decoded tile. Cache miss → fetch from origin → persist (fire-and-forget, `CancellationToken.None`) → decode → return. + +--- + +## Common Compositions + +### HTTP only (no persistence) + +```csharp +var provider = new SKTieredTileProvider( + new SKHttpTileFetcher()); + +controller.Load(source, provider); +``` + +### HTTP + disk cache + +```csharp +var provider = new SKTieredTileProvider( + fetcher: new SKHttpTileFetcher(), + persistentCache: new SKDiskTileCacheStore(cachePath)); + +controller.SetProvider(provider); +controller.Load(source); +``` + +### Local file (no cache needed) + +```csharp +var provider = new SKTieredTileProvider(new SKFileTileFetcher()); +controller.Load(source, provider); +``` + +### MAUI app-bundled tiles + +```csharp +var provider = new SKTieredTileProvider( + fetcher: new MauiAssetFetcher()); // reads from app package + +controller.Load(localDziSource, provider); +``` + +### Blazor WASM (browser storage) + +```csharp +// Browser sessionStorage L2 cache via JS interop +var provider = new BrowserStorageTileProvider( + new SKTieredTileProvider(new SKHttpTileFetcher()), js); + +controller.Load(source, provider); +``` + +--- + +## Provider Lifecycle + +The controller does **NOT** own the provider lifecycle — the caller manages disposal. Always dispose the old provider before replacing it: + +```csharp +// Correct — dispose old before assigning new +private ISKImagePyramidTileProvider? _provider; + +private void SwitchProvider(ISKImagePyramidTileProvider newProvider) +{ + var old = _provider; + _provider = newProvider; + _controller.SetProvider(newProvider); + old?.Dispose(); +} + +// Correct — dispose on page/component teardown +public override void Dispose() +{ + _controller.Dispose(); + _provider?.Dispose(); +} +``` + +--- + +## Custom Provider + +Implement `ISKImagePyramidTileProvider` directly for full control (authentication, custom headers, etc.): + +```csharp +public sealed class AuthenticatedProvider(HttpClient http, string token) + : ISKImagePyramidTileProvider +{ + public async Task GetTileAsync(string url, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new("Bearer", token); + try + { + using var response = await http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) return null; + var bytes = await response.Content.ReadAsByteArrayAsync(ct); + var image = SKImage.FromEncodedData(bytes); + return image != null ? new SKImagePyramidTile(image, bytes) : null; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { return null; } + } + + public void Dispose() { } +} +``` + +**Return `null`** for permanent misses (404, file not found) — the controller records a temporary failure with exponential backoff via `TileFailureTracker`. + +**Throw `OperationCanceledException`** when `ct` is cancelled — the controller handles this without recording a failure, so the tile will be retried. + +--- + +## Provider Decorator + +Wrap any provider to add behaviour (logging, delay simulation, browser storage): + +```csharp +public sealed class DelayTileProvider(ISKImagePyramidTileProvider inner, int delayMs) + : ISKImagePyramidTileProvider +{ + public async Task GetTileAsync(string url, CancellationToken ct = default) + { + await Task.Delay(delayMs, ct); + return await inner.GetTileAsync(url, ct); + } + + public void Dispose() => inner.Dispose(); +} +``` + +--- + +## Related + +- [Image Pyramid overview](index.md) +- [Controller & Viewport](controller.md) +- [Caching](caching.md) +- [API Reference — ISKImagePyramidTileProvider](xref:SkiaSharp.Extended.ISKImagePyramidTileProvider) +- [API Reference — ISKTileFetcher](xref:SkiaSharp.Extended.ISKTileFetcher) +- [API Reference — ISKTileCacheStore](xref:SkiaSharp.Extended.ISKTileCacheStore) +- [API Reference — SKTieredTileProvider](xref:SkiaSharp.Extended.SKTieredTileProvider) diff --git a/docs/docs/image-pyramid/iiif.md b/docs/docs/image-pyramid/iiif.md new file mode 100644 index 0000000000..c457b122e8 --- /dev/null +++ b/docs/docs/image-pyramid/iiif.md @@ -0,0 +1,187 @@ +# IIIF Image API Support + +The Image Pyramid system supports [IIIF Image API](https://iiif.io/api/image/) v2 and v3 through +`SKImagePyramidIiifSource`. IIIF (International Image Interoperability Framework) is widely used +by libraries, museums, and archives to serve high-resolution images. + +## What is IIIF? + +IIIF Image API provides a standard way to serve image tiles. Each image server exposes an +`info.json` endpoint that describes the image dimensions and available tile sizes: + +```json +{ + "@context": "http://iiif.io/api/image/2/context.json", + "@id": "https://iiif.wellcomecollection.org/image/b20432033_B0008608.JP2", + "width": 3543, + "height": 2480, + "tiles": [ + { "width": 1024, "height": 1024, "scaleFactors": [1, 2, 4, 8, 16, 32] } + ] +} +``` + +## Using `SKImagePyramidIiifSource` + +### Parse from info.json + +```csharp +// Fetch and parse the IIIF info.json +using var http = new HttpClient(); +var json = await http.GetStringAsync("https://example.org/iiif/image/my-image/info.json"); +var source = SKImagePyramidIiifSource.Parse(json); + +// Load into the controller (same as DZI) +controller.Load(source, new SKTieredTileProvider(new SKHttpTileFetcher())); +``` + +### Construct manually + +```csharp +var source = new SKImagePyramidIiifSource( + baseId: "https://iiif.wellcomecollection.org/image/b20432033_B0008608.JP2", + imageWidth: 3543, + imageHeight: 2480, + tileWidth: 1024, + tileHeight: 1024, + scaleFactorsDescending: new[] { 32, 16, 8, 4, 2, 1 } +); +``` + +## Auto-detecting Source Type + +In the sample apps, the URL is inspected to select the right parser: + +```csharp +private async Task LoadFromUrlAsync(string url) +{ + bool isDzc = url.EndsWith(".dzc", StringComparison.OrdinalIgnoreCase); + bool isDzi = url.EndsWith(".dzi", StringComparison.OrdinalIgnoreCase); + bool isIiif = url.Contains("/iiif/", StringComparison.OrdinalIgnoreCase) + || url.Contains("iiif.io", StringComparison.OrdinalIgnoreCase) + || url.EndsWith("/info.json", StringComparison.OrdinalIgnoreCase); + + // Auto-append /info.json for bare IIIF base URLs + if (isIiif && !url.EndsWith("/info.json", StringComparison.OrdinalIgnoreCase)) + url += "/info.json"; + + var content = await http.GetStringAsync(url); + + if (isDzc) + { + var coll = SKImagePyramidDziCollectionSource.Parse(content); + coll.TilesBaseUri = url[..url.LastIndexOf('/')] + "/"; + controller.Load(coll, fetcher); + } + else if (isIiif || (!isDzi && content.TrimStart().StartsWith("{"))) + { + var source = SKImagePyramidIiifSource.Parse(content); + controller.Load(source, fetcher); + } + else + { + // DZI + string baseDir = url[..url.LastIndexOf('/')] + "/"; + string stem = Path.GetFileNameWithoutExtension(url); + var source = SKImagePyramidDziSource.Parse(content, $"{baseDir}{stem}_files/"); + controller.Load(source, fetcher); + } +} +``` + +## How IIIF Levels Map to the Pyramid + +IIIF uses **scale factors** to describe resolution levels. A scale factor of `S` means the image +is scaled down by `S` relative to full resolution. The `scaleFactors` array is sorted in descending +order internally so that pyramid level 0 = lowest resolution (largest scale factor) and +`MaxLevel` = full resolution (scale factor = 1): + +| IIIF scaleFactors | Pyramid Level | Resolution | +|---|---|---| +| 32 | 0 | Lowest (thumbnail) | +| 16 | 1 | | +| 8 | 2 | | +| 4 | 3 | | +| 2 | 4 | | +| 1 | 5 (MaxLevel) | Full resolution | + +## Tile URL Format + +IIIF tile URLs follow the pattern: + +``` +{baseId}/{region}/{size}/{rotation}/{quality}.{format} +``` + +For example, a full-resolution tile at column 0, row 0: + +``` +https://iiif.wellcomecollection.org/image/b20432033_B0008608.JP2/0,0,1024,1024/1024,1024/0/default.jpg +``` + +Where: +- `0,0,1024,1024` — region in full-resolution pixel coordinates (x, y, w, h) +- `1024,1024` — output size at the requested level +- `0` — rotation (always 0) +- `default.jpg` — quality and format + +## The `ISKImagePyramidSource` Interface + +Both `SKImagePyramidDziSource` and `SKImagePyramidIiifSource` implement `ISKImagePyramidSource`, +which abstracts the tile pyramid math: + +```csharp +public interface ISKImagePyramidSource +{ + int ImageWidth { get; } + int ImageHeight { get; } + int MaxLevel { get; } + double AspectRatio { get; } + + int GetLevelWidth(int level); + int GetLevelHeight(int level); + int GetTileCountX(int level); + int GetTileCountY(int level); + Rect GetTileBounds(int level, int col, int row); + string? GetFullTileUrl(int level, int col, int row); + int GetOptimalLevel(double viewportWidth, double controlWidth); +} +``` + +The `SKImagePyramidController.Load(ISKImagePyramidSource, ISKImagePyramidTileProvider)` overload +accepts any source type, making it easy to add support for other formats. + +## Adding Your Own Format (e.g., Zoomify) + +To support [Zoomify](http://www.zoomify.com/), implement `ISKImagePyramidSource`: + +```csharp +public class SKImagePyramidZoomifySource : ISKImagePyramidSource +{ + // Zoomify uses 256×256 tiles, stored as ZoomifyImage/TileGroup{N}/Level-Col-Row.jpg + // Implement GetFullTileUrl to return the correct URL for each tile. + // ... +} +``` + +## Sample IIIF URLs + +Try these public IIIF endpoints in the demo apps (all support CORS). Enter the base URL — `/info.json` is appended automatically: + +### IIIF v2 + +| Institution | Base URL | Dimensions | +|---|---|---| +| Wellcome Collection | `https://iiif.wellcomecollection.org/image/b20432033_B0008608.JP2` | 3543 × 2480 | +| National Gallery of Art (US) | `https://media.nga.gov/iiif/public/objects/4/6/2/5/8/46258-primary-0-nativeres.ptif` | 7993 × 8195 | +| Library of Congress | `https://tile.loc.gov/image-services/iiif/service:pnp:highsm:14000:14095` | 4717 × 5951 | +| Stanford University | `https://stacks.stanford.edu/image/iiif/hg676jb4964%2F0380_796-44` | 5426 × 3820 | +| Leipzig University Library | `https://iiif.ub.uni-leipzig.de/iiif/j2k/0000/0107/0000010732/00000072.jpx` | 5284 × 2410 | +| Bodleian Libraries, Oxford | `https://iiif.bodleian.ox.ac.uk/iiif/image/9cca8fdd-4a61-4429-8ac1-f648764b4d6d` | 5776 × 9125 | +| e-codices (Swiss manuscripts) | `https://www.e-codices.unifr.ch/loris/bge/bge-cl0015/bge-cl0015_e001.jp2` | 3328 × 4992 | + +### IIIF v3 + +| Institution | Base URL | Dimensions | +|---|---|---| +| IIIF.io reference image | `https://iiif.io/api/image/3.0/example/reference/918ecd18c2592080851777620de9bcb5-fountain` | 3024 × 4032 | diff --git a/docs/docs/image-pyramid/index.md b/docs/docs/image-pyramid/index.md new file mode 100644 index 0000000000..3e5e55de9b --- /dev/null +++ b/docs/docs/image-pyramid/index.md @@ -0,0 +1,135 @@ +# Image Pyramid + +Explore gigapixel images in your .NET MAUI and Blazor apps. The Image Pyramid system downloads only the tiles visible at the current zoom level, so even multi-gigapixel images load instantly — regardless of whether they come from Deep Zoom (DZI), IIIF, or any custom tile source. + +## What is an Image Pyramid? + +A **tiled image pyramid** pre-slices a large image into tiles at multiple resolutions. At any zoom level, only the small set of tiles visible in the viewport is loaded — making it practical to explore images with billions of pixels. + +**When to use Image Pyramid:** +- 🗺️ High-resolution maps, satellite imagery, or floor plans +- 🎨 Gigapixel art, museum collection viewers +- 🔬 Medical imaging, microscopy slides +- 📸 Any image too large to load in full at once + +## Architecture + +The system is intentionally minimal — there is **no custom control** and **no gesture system**. You wire the services directly to a plain `SKCanvasView`, giving you full control. + +> All types — controller, viewport, interfaces and SkiaSharp implementations — live in a single `SkiaSharp.Extended` library. There is no separate Abstractions package. + +```mermaid +graph TD + Src1["SKImagePyramidDziSource (DZI)"] + Src2["SKImagePyramidIiifSource (IIIF)"] + Src3["Your custom ISKImagePyramidSource"] + Source["ISKImagePyramidSource"] + Controller["SKImagePyramidController"] + Provider["ISKImagePyramidTileProvider"] + Renderer["ISKImagePyramidRenderer"] + Canvas["SKCanvasView / SKGLView"] + + Src1 --> Source + Src2 --> Source + Src3 --> Source + Source --> Controller + Controller --> Provider + Controller --> Renderer + Renderer --> Canvas +``` + +| Type | Responsibility | +| :---- | :------------- | +| `ISKImagePyramidSource` | Describes a tile pyramid: dimensions, level count, tile URL generation. | +| `SKImagePyramidDziSource` | Parses Microsoft Deep Zoom Image (`.dzi`) files. | +| `SKImagePyramidDziCollectionSource` | Parses Deep Zoom Collection (`.dzc`) files. | +| `SKImagePyramidIiifSource` | Parses IIIF Image API v2/v3 `info.json`. | +| `SKImagePyramidController` | The central orchestrator: viewport, scheduling, render buffer, rendering. | +| `SKImagePyramidViewport` | Coordinate math between screen pixels and logical (0–1) image space. | +| `ISKImagePyramidTileProvider` | Owns the full fetch+cache pipeline for a tile URL. | +| `SKTieredTileProvider` | Composes a fetcher + optional persistent cache into a tile provider. | +| `SKHttpTileFetcher` | HTTP origin fetch (no caching). | +| `SKFileTileFetcher` | Local file origin fetch (no caching). | +| `ISKImagePyramidRenderer` | Pluggable renderer interface. | +| `SKImagePyramidRenderer` | Default SkiaSharp renderer; LOD blending, tile compositing. | + +## Quick Start + +### 1. Create a controller + +```csharp +using SkiaSharp.Extended; + +var controller = new SKImagePyramidController(); +``` + +### 2. Load an image source + +```csharp +// Deep Zoom Image (DZI) +var xml = await httpClient.GetStringAsync("https://example.com/image.dzi"); +var source = SKImagePyramidDziSource.Parse(xml, "https://example.com/image_files/"); +controller.Load(source, new SKTieredTileProvider(new SKHttpTileFetcher())); + +// IIIF Image API +var json = await httpClient.GetStringAsync("https://example.com/image/info.json"); +var iiifSource = SKImagePyramidIiifSource.Parse(json); +controller.Load(iiifSource, new SKTieredTileProvider(new SKHttpTileFetcher())); +``` + +### 3. Wire the canvas + +```csharp +private readonly SKImagePyramidRenderer _renderer = new(); + +void OnPaintSurface(SKPaintSurfaceEventArgs e) +{ + controller.SetControlSize(e.Info.Width, e.Info.Height); + controller.Update(); + _renderer.Canvas = e.Surface.Canvas; + controller.Render(_renderer); +} +``` + +### 4. Trigger repaints when tiles arrive + +```csharp +controller.InvalidateRequired += (_, _) => myCanvasView.InvalidateSurface(); +``` + +### 5. Dispose when done + +```csharp +controller.Dispose(); +``` + +## Image Sources + +The `ISKImagePyramidSource` interface is the key abstraction. Swap sources to use different tile formats without changing any other code: + +| Source | Format | Notes | +| :----- | :----- | :---- | +| `SKImagePyramidDziSource` | Deep Zoom Image (`.dzi`) | Microsoft DZI format | +| `SKImagePyramidDziCollectionSource` | Deep Zoom Collection (`.dzc`) | Multi-image mosaics | +| `SKImagePyramidIiifSource` | IIIF Image API v2/v3 | Museum/archive images | +| Custom `ISKImagePyramidSource` | Any | Zoomify, custom tile servers, etc. | + +See the [Image Sources](deepzoom.md) section for details on each format. + +## Platform Integration + +- [Image Pyramid for Blazor](blazor.md) +- [Image Pyramid for .NET MAUI](maui.md) + +## Deeper Dives + +- [Controller & Viewport](controller.md) +- [Tile Fetching](fetching.md) +- [Caching](caching.md) + +## Learn More + +- [API Reference — SKImagePyramidController](xref:SkiaSharp.Extended.SKImagePyramidController) +- [OpenSeadragon](https://openseadragon.github.io/) — Popular JS viewer (DZI/IIIF compatible) +- [IIIF Image API 3.0 Spec](https://iiif.io/api/image/3.0/) + diff --git a/docs/docs/image-pyramid/maui.md b/docs/docs/image-pyramid/maui.md new file mode 100644 index 0000000000..2f9072617b --- /dev/null +++ b/docs/docs/image-pyramid/maui.md @@ -0,0 +1,226 @@ +# Image Pyramid for MAUI + +Use `SKImagePyramidController` with a plain `SKCanvasView` in .NET MAUI to render Image Pyramid images. There is no custom control — you wire the services directly to the canvas, keeping the integration minimal and transparent. + +## Quick Start + +### XAML + +```xml + + + + + +``` + +### Code-Behind + +```csharp +using SkiaSharp; +using SkiaSharp.Extended; +using SkiaSharp.Views.Maui; + +public partial class ImagePyramidPage : ContentPage +{ + private readonly SKImagePyramidController _controller = new(); + private readonly SKImagePyramidRenderer _renderer = new(); + + public ImagePyramidPage() + { + InitializeComponent(); + _controller.InvalidateRequired += (_, _) => canvas.InvalidateSurface(); + } + + protected override void OnAppearing() + { + base.OnAppearing(); + LoadAsync(); + } + + protected override void OnDisappearing() + { + base.OnDisappearing(); + _controller.Dispose(); + } + + private async void LoadAsync() + { + using var stream = await FileSystem.OpenAppPackageFileAsync("image.dzi"); + using var reader = new StreamReader(stream); + var xml = await reader.ReadToEndAsync(); + + var source = SKImagePyramidDziSource.Parse(xml, "image_files/"); + _controller.Load(source, new AppPackageProvider()); + canvas.InvalidateSurface(); + } + + private void OnPaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + _controller.SetControlSize(e.Info.Width, e.Info.Height); + _controller.Update(); + _renderer.Canvas = e.Surface.Canvas; + _controller.Render(_renderer); + } +} +``` + +## App-Package Tile Provider + +When tiles are bundled as MAUI assets, implement `ISKImagePyramidTileProvider` to read them via `FileSystem.OpenAppPackageFileAsync`, buffer the bytes, and decode with `SKImage.FromEncodedData`: + +```csharp +public sealed class AppPackageProvider : ISKImagePyramidTileProvider +{ + public async Task GetTileAsync(string url, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + try + { + using var stream = await FileSystem.OpenAppPackageFileAsync(url); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms, ct); + var bytes = ms.ToArray(); + var image = SKImage.FromEncodedData(bytes); + return image != null ? new SKImagePyramidTile(image, bytes) : null; + } + catch (OperationCanceledException) { throw; } + catch + { + return null; + } + } + + public void Dispose() { } +} +``` + +Use it directly: + +```csharp +_controller.Load(source, new AppPackageProvider()); +``` + +Include assets in the project file: + +```xml + + + + +``` + +## HTTP Tile Provider + +For remote images, use `SKTieredTileProvider` with `SKHttpTileFetcher`: + +```csharp +// HTTP only, no disk cache +_controller.Load(source, new SKTieredTileProvider(new SKHttpTileFetcher())); + +// With disk cache (persists across sessions) +_controller.Load(source, new SKTieredTileProvider( + fetcher: new SKHttpTileFetcher(), + persistentCache: new SKDiskTileCacheStore( + Path.Combine(FileSystem.CacheDirectory, "tiles"), + expiry: TimeSpan.FromDays(30)))); +``` + +## Pan and Zoom + +Add gesture recognizers or pointer events to enable interactive navigation: + +```csharp +public ImagePyramidPage() +{ + InitializeComponent(); + _controller.InvalidateRequired += (_, _) => canvas.InvalidateSurface(); + + // Pan with finger/mouse drag + var pan = new PanGestureRecognizer(); + pan.PanUpdated += OnPanUpdated; + canvas.GestureRecognizers.Add(pan); + + // Pinch to zoom + var pinch = new PinchGestureRecognizer(); + pinch.PinchUpdated += OnPinchUpdated; + canvas.GestureRecognizers.Add(pinch); +} + +// TotalX/Y is cumulative per gesture, so we must track the previous value +// to compute a per-frame delta. +private double _lastPanX, _lastPanY; + +private void OnPanUpdated(object? sender, PanUpdatedEventArgs e) +{ + switch (e.StatusType) + { + case GestureStatus.Started: + _lastPanX = 0; + _lastPanY = 0; + break; + case GestureStatus.Running: + var dx = e.TotalX - _lastPanX; + var dy = e.TotalY - _lastPanY; + _lastPanX = e.TotalX; + _lastPanY = e.TotalY; + _controller.Pan(dx, dy); + canvas.InvalidateSurface(); + break; + } +} + +private void OnPinchUpdated(object? sender, PinchGestureUpdatedEventArgs e) +{ + if (e.Status == GestureStatus.Running) + { + _controller.SetZoom(_controller.Viewport.Zoom * e.Scale); + canvas.InvalidateSurface(); + } +} +``` + +## Loading a DZC Collection + +```csharp +using var stream = await FileSystem.OpenAppPackageFileAsync("collection.dzc"); +using var reader = new StreamReader(stream); +var xml = await reader.ReadToEndAsync(); + +var collection = SKImagePyramidDziCollectionSource.Parse(xml); +collection.TilesBaseUri = "collection_files/"; +_controller.Load(collection, new AppPackageProvider()); +``` + +## Provider Configuration + +The controller's internal render buffer has a fixed 256-entry capacity — this is sufficient for most tile pyramid use cases. If you need disk persistence, pass a configured provider: + +```csharp +// With disk cache for memory-constrained devices that benefit from persistence +_controller.Load(source, new SKTieredTileProvider( + new SKHttpTileFetcher(), + new SKDiskTileCacheStore(Path.Combine(FileSystem.CacheDirectory, "tiles")))); +``` + +See the [Tile Providers docs](fetching.md) for custom providers. + +## Rendering Behaviour + +- **Fit and center**: On load the controller fits the full image into the canvas with `ResetView()`. The image is centered; neither cropping nor distortion occurs. +- **LOD blending**: While high-resolution tiles load, lower-resolution tiles from parent levels are upscaled and composited as placeholders. +- **Idle detection**: `controller.IsIdle` is `true` when no tiles are loading. + +## Related + +- [Image Pyramid overview](index.md) +- [Controller & Viewport](controller.md) +- [Tile Fetching](fetching.md) +- [Caching](caching.md) +- [Image Pyramid for Blazor](blazor.md) diff --git a/docs/docs/toc.yml b/docs/docs/toc.yml index 6fb3c47b97..fa3af30cbf 100644 --- a/docs/docs/toc.yml +++ b/docs/docs/toc.yml @@ -13,6 +13,27 @@ items: - name: Pixel Comparer href: pixel-comparer.md +- name: Image Pyramid + items: + - name: Overview + href: image-pyramid/index.md + - name: Controller & Viewport + href: image-pyramid/controller.md + - name: Image Sources + items: + - name: Deep Zoom (DZI/DZC) + href: image-pyramid/deepzoom.md + - name: IIIF Image API + href: image-pyramid/iiif.md + - name: Tile Fetching + href: image-pyramid/fetching.md + - name: Caching + href: image-pyramid/caching.md + - name: Blazor Integration + href: image-pyramid/blazor.md + - name: MAUI Integration + href: image-pyramid/maui.md + - name: SkiaSharp.Extended.UI.Maui - name: Confetti Effects href: confetti.md @@ -23,4 +44,4 @@ items: - name: Migration Guides items: - name: SVG Migration (Extended.Svg to Svg.Skia) - href: svg-migration.md \ No newline at end of file + href: svg-migration.md diff --git a/global.json b/global.json new file mode 100644 index 0000000000..82dabbbf1d --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": true + } +} diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/BrowserStorageTileProvider.cs b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/BrowserStorageTileProvider.cs new file mode 100644 index 0000000000..11b0cacdba --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/BrowserStorageTileProvider.cs @@ -0,0 +1,84 @@ +using Microsoft.JSInterop; +using SkiaSharp; +using SkiaSharp.Extended; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace SkiaSharpDemo; + +/// +/// A tile provider decorator that persists fetched tiles in browser sessionStorage via JS +/// interop, providing a URL-keyed L2 cache that survives page re-renders. Tiles are stored +/// as base64-encoded raw bytes so no re-encoding is needed on a storage hit. +/// +public sealed class BrowserStorageTileProvider(ISKImagePyramidTileProvider inner, IJSRuntime js) + : ISKImagePyramidTileProvider +{ + private readonly ISKImagePyramidTileProvider _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + private readonly IJSRuntime _js = js; + + /// + public async Task GetTileAsync(string url, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + // 1. Browser sessionStorage hit? + var cached = await TryReadFromStorageAsync(url, ct).ConfigureAwait(false); + if (cached != null) return cached; + + // 2. Delegate to inner provider (HTTP fetch) + var tile = await _inner.GetTileAsync(url, ct).ConfigureAwait(false); + if (tile == null) return null; + + // 3. Persist to browser storage — fire-and-forget with CancellationToken.None. + // Once the tile is in hand, don't let a cancellation skip storage. + // RawData is nullable; only write if bytes are available. + if (tile.RawData != null) + _ = WriteToBrowserAsync(url, tile.RawData); + + return tile; + } + + /// + public void Dispose() => _inner.Dispose(); + + // ---- Private ---- + + private async Task TryReadFromStorageAsync(string url, CancellationToken ct) + { + try + { + var base64 = await _js.InvokeAsync( + "deepZoomCacheGet", ct, StorageKey(url)).ConfigureAwait(false); + + if (base64 is null) return null; + + var bytes = Convert.FromBase64String(base64); + var image = SKImage.FromEncodedData(bytes); + if (image is null) return null; + + return new SKImagePyramidTile(image, bytes); + } + catch { return null; } + } + + private async Task WriteToBrowserAsync(string url, byte[] rawData) + { + try + { + var base64 = Convert.ToBase64String(rawData); + await _js.InvokeVoidAsync("deepZoomCacheSet", CancellationToken.None, StorageKey(url), base64) + .ConfigureAwait(false); + } + catch { } + } + + private static string StorageKey(string url) + { + // Use a short hash of the URL as the sessionStorage key + ulong h = 14695981039346656037UL; + foreach (char c in url) { h ^= c; h *= 1099511628211UL; } + return "skimgpyramid_" + h.ToString("x"); + } +} diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/DebugBorderRenderer.cs b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/DebugBorderRenderer.cs new file mode 100644 index 0000000000..8b804ab041 --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/DebugBorderRenderer.cs @@ -0,0 +1,92 @@ +using SkiaSharp; +using SkiaSharp.Extended; +using System; +using System.Collections.Generic; + +namespace SkiaSharpDemo; + +/// +/// A renderer decorator that draws tile borders on top of the inner renderer's output. +/// Used in the demo inspector to visually inspect tile boundaries without modifying +/// the core . +/// +public sealed class DebugBorderRenderer : ISKImagePyramidRenderer +{ + private readonly SKImagePyramidRenderer _inner; + private readonly SKPaint _borderPaint; + private readonly List _frameRects = new(); + + public DebugBorderRenderer(SKImagePyramidRenderer inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + _borderPaint = new SKPaint + { + Color = new SKColor(60, 60, 60, 160), + IsStroke = true, + StrokeWidth = 1, + IsAntialias = false, + }; + } + + /// + /// Gets or sets the canvas. Delegates to the inner . + /// The caller must set this before each frame, then pass this renderer to + /// . + /// + public SKCanvas? Canvas + { + get => _inner.Canvas; + set => _inner.Canvas = value; + } + + /// + /// When , a border is drawn around each visible tile. + /// When , rendering is identical to the inner renderer. + /// + public bool ShowTileBorders { get; set; } + + // ---- ISKImagePyramidRenderer ---- + + /// + public void BeginRender() + { + _frameRects.Clear(); + _inner.BeginRender(); + } + + /// + public void DrawTile(SKRect destRect, SKImagePyramidTile tile) + { + _inner.DrawTile(destRect, tile); + if (ShowTileBorders) + _frameRects.Add(destRect); + } + + /// + public void DrawFallbackTile(SKRect destRect, SKRect sourceRect, SKImagePyramidTile tile) + { + _inner.DrawFallbackTile(destRect, sourceRect, tile); + if (ShowTileBorders) + _frameRects.Add(destRect); + } + + /// + public void EndRender() + { + // Draw borders on top of all tiles drawn this frame + if (ShowTileBorders && _inner.Canvas != null) + { + foreach (var r in _frameRects) + _inner.Canvas.DrawRect(r, _borderPaint); + } + _inner.EndRender(); + _frameRects.Clear(); + } + + /// + public void Dispose() + { + _inner.Dispose(); + _borderPaint.Dispose(); + } +} diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/DelayTileProvider.cs b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/DelayTileProvider.cs new file mode 100644 index 0000000000..3d4e3e367b --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/DelayTileProvider.cs @@ -0,0 +1,43 @@ +using SkiaSharp.Extended; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace SkiaSharpDemo; + +/// +/// A tile provider decorator that adds an artificial random delay to +/// to simulate slow network tile delivery. Use in sample pages to experiment with progressive +/// loading behaviour. +/// +public sealed class DelayTileProvider(ISKImagePyramidTileProvider inner) : ISKImagePyramidTileProvider +{ + private readonly ISKImagePyramidTileProvider _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + private readonly Random _random = new(); + + /// When true, a random delay between and is applied before returning tiles. + public bool IsEnabled { get; set; } + + /// Minimum delay in milliseconds. + public int MinDelayMs { get; set; } + + /// Maximum delay in milliseconds. + public int MaxDelayMs { get; set; } = 500; + + /// + public async Task GetTileAsync(string url, CancellationToken ct = default) + { + if (IsEnabled) + { + int min = Math.Min(MinDelayMs, MaxDelayMs); + int max = Math.Max(MinDelayMs, MaxDelayMs); + int delayMs = min == max ? min : _random.Next(min, max + 1); + if (delayMs > 0) + await Task.Delay(delayMs, ct).ConfigureAwait(false); + } + return await _inner.GetTileAsync(url, ct).ConfigureAwait(false); + } + + /// + public void Dispose() => _inner.Dispose(); +} diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidInspector.razor b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidInspector.razor new file mode 100644 index 0000000000..6d2684d9a0 --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidInspector.razor @@ -0,0 +1,228 @@ +@using SkiaSharp.Extended + + + +@code { + [Parameter] public SKImagePyramidController? Controller { get; set; } + + [Parameter] public bool ShowTileBorders { get; set; } + [Parameter] public EventCallback ShowTileBordersChanged { get; set; } + + [Parameter] public bool EnableLodBlending { get; set; } + [Parameter] public EventCallback EnableLodBlendingChanged { get; set; } + + [Parameter] public float DebugZoom { get; set; } = 1.0f; + [Parameter] public EventCallback DebugZoomChanged { get; set; } + + [Parameter] public bool UseBrowserCache { get; set; } + [Parameter] public EventCallback UseBrowserCacheChanged { get; set; } + + [Parameter] public bool CacheDelayEnabled { get; set; } + [Parameter] public EventCallback CacheDelayEnabledChanged { get; set; } + + [Parameter] public int CacheDelayMin { get; set; } + [Parameter] public EventCallback CacheDelayMinChanged { get; set; } + + [Parameter] public int CacheDelayMax { get; set; } + [Parameter] public EventCallback CacheDelayMaxChanged { get; set; } + + [Parameter] public int CacheCapacity { get; set; } + [Parameter] public EventCallback CacheCapacityChanged { get; set; } + + [Parameter] public EventCallback OnDebugOptionsChanged { get; set; } + [Parameter] public EventCallback OnCacheTypeChanged { get; set; } + [Parameter] public EventCallback OnCacheDelayChanged { get; set; } + [Parameter] public EventCallback OnCacheSizeChanged { get; set; } + [Parameter] public EventCallback OnClearCache { get; set; } +} diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidInspector.razor.css b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidInspector.razor.css new file mode 100644 index 0000000000..6239907937 --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidInspector.razor.css @@ -0,0 +1,87 @@ +/* Root element of this component — styled directly (no ::deep needed) */ +.ip-inspector { + flex: 0 0 320px; + min-width: 320px; + max-width: 320px; + overflow-y: auto; + border-left: 1px solid #444; + padding: 0.75rem; + font-size: 0.8rem; + font-family: monospace; + background: #1a1a2e; + color: #e0e0e0; + box-sizing: border-box; +} + +.ip-inspector h3 { margin: 0 0 0.5rem; font-size: 1rem; } +.ip-inspector dl { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; margin: 0; } +.ip-inspector dt { color: #aaa; white-space: nowrap; } +.ip-inspector dd { margin: 0; word-break: break-all; } +.ip-inspector details { margin-bottom: 0.4rem; border: 1px solid #333; border-radius: 4px; } +.ip-inspector details > summary { + padding: 4px 8px; + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + color: #90caf9; + background: #1e2a38; + border-radius: 4px; + list-style: none; + user-select: none; +} +.ip-inspector details > summary::-webkit-details-marker { display: none; } +.ip-inspector details[open] > summary { border-radius: 4px 4px 0 0; border-bottom: 1px solid #333; } +.ip-inspector details > summary::after { content: " ▼"; font-size: 0.65rem; float: right; } +.ip-inspector details[open] > summary::after { content: " ▲"; } + +.ip-section-body { padding: 6px 8px; } + +.ip-tile-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; } +.ip-tile-table th, .ip-tile-table td { padding: 1px 4px; text-align: center; } +.ip-tile-table th { color: #90caf9; } +.tile-ok td { color: #81c784; } +.tile-missing td { color: #ffb74d; } + +.ip-muted { color: #888; font-size: 0.75rem; margin: 2px 0; } + +.ip-debug-label { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 0; + cursor: pointer; + user-select: none; +} + +.ip-label-row { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 0; + font-size: 0.82rem; + user-select: none; +} + +.ip-label-row input[type=range] { + flex: 1 1 auto; + min-width: 0; +} + +.ip-ms-label { + display: inline-block; + min-width: 4.5ch; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ip-small-btn { + margin-top: 4px; + padding: 2px 8px; + font-size: 0.8rem; + cursor: pointer; + border: 1px solid #555; + border-radius: 3px; + background: #333; + color: #ddd; +} +.ip-small-btn:hover { background: #444; } diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidView.razor b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidView.razor new file mode 100644 index 0000000000..8a7c073c58 --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidView.razor @@ -0,0 +1,20 @@ +@using SkiaSharp +@using SkiaSharp.Extended +@using SkiaSharp.Views.Blazor +@implements IAsyncDisposable +@inject IJSRuntime JS + +
+ +
diff --git a/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidView.razor.cs b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidView.razor.cs new file mode 100644 index 0000000000..70aebb62fd --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Components/ImagePyramid/ImagePyramidView.razor.cs @@ -0,0 +1,289 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using SkiaSharp; +using SkiaSharp.Extended; +using SkiaSharp.Views.Blazor; + +namespace SkiaSharpDemo.Blazor.Components.ImagePyramid; + +/// +/// A self-contained image pyramid view component. Owns the canvas, controller, and +/// rendering loop. The host page supplies an and +/// and reads back +/// for inspector/diagnostic use. +/// +public partial class ImagePyramidView : IAsyncDisposable +{ + // ---- Injected ---- + [Inject] private IJSRuntime JS2 { get; set; } = null!; + + // ---- Parameters ---- + + /// + /// The image source to display. Setting this loads it into the controller. + /// + [Parameter] public ISKImagePyramidSource? Source { get; set; } + + /// + /// The tile provider. Setting this calls . + /// The caller retains ownership and must dispose it. + /// + [Parameter] public ISKImagePyramidTileProvider? Provider { get; set; } + + /// + /// Renderer used for each paint cycle. Defaults to . + /// Provide a custom renderer (e.g. ) to add overlays. + /// + [Parameter] + public ISKImagePyramidRenderer? Renderer { get; set; } + + /// + /// Zoom range minimum (default: 0.01). + /// + [Parameter] public double MinZoom { get; set; } = 0.01; + + /// + /// Zoom range maximum (default: 100). + /// + [Parameter] public double MaxZoom { get; set; } = 100.0; + + /// + /// Debug zoom scale (0.1–1.0). Values below 1.0 shrink the rendered content so + /// off-screen tiles are visible. Useful for inspector/debugging. + /// + [Parameter] public float DebugZoom { get; set; } = 1.0f; + + /// Raised when the canvas requests a repaint (tile arrived, etc.). + [Parameter] public EventCallback OnInvalidate { get; set; } + + /// Captures any additional HTML attributes (e.g. style, class) to splat on the root element. + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + // ---- Exposed state (for inspector/host pages) ---- + + /// + /// The internal controller. Available after the component is first rendered. + /// Read-only: use and to configure it. + /// + public SKImagePyramidController? Controller => _controller; + + /// Current zoom level (linear, not log). + public double Zoom => _controller?.Viewport.Zoom ?? 1.0; + + // ---- Public methods ---- + + /// + /// Resets the viewport to fit the loaded image. + /// + public void ResetView() + { + _controller?.ResetView(); + SyncZoom(); + _canvas?.Invalidate(); + } + + /// + /// Sets the viewport zoom. Updates the internal zoom tracking. + /// + public void SetZoom(double zoom) + { + _controller?.SetZoom(zoom); + SyncZoom(); + } + + /// Syncs internal zoom tracking from the controller viewport. + public void SyncZoom() => _zoom = _controller?.Viewport.Zoom ?? 1.0; + + // ---- Private state ---- + + private SKGLView? _canvas; + private SKImagePyramidController? _controller; + private ISKImagePyramidRenderer? _defaultRenderer; + // Track last-applied values to detect changes in OnParametersSet + private ISKImagePyramidSource? _appliedSource; + private ISKImagePyramidTileProvider? _appliedProvider; + private double _zoom = 1.0; + + private bool _isDragging; + private double _lastMouseX; + private double _lastMouseY; + private int _lastControlWidth; + private int _lastControlHeight; + + private DotNetObjectReference? _thisRef; + + // ---- Lifecycle ---- + + protected override void OnParametersSet() + { + if (_controller == null) return; // Not yet initialised; OnAfterRenderAsync handles first apply + + var providerChanged = !ReferenceEquals(Provider, _appliedProvider); + var sourceChanged = !ReferenceEquals(Source, _appliedSource); + + if (providerChanged) + { + _appliedProvider = Provider; + if (Provider != null) + _controller.SetProvider(Provider); + } + + if ((providerChanged || sourceChanged) && Source != null && Provider != null) + { + _appliedSource = Source; + _controller.Load(Source, Provider); + _canvas?.Invalidate(); + } + else if (sourceChanged) + { + _appliedSource = Source; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + _thisRef = DotNetObjectReference.Create(this); + await JS2.InvokeVoidAsync("deepZoomRegisterResize", _thisRef); + + _defaultRenderer = new SKImagePyramidRenderer(); + _controller = new SKImagePyramidController(); + _controller.InvalidateRequired += OnInvalidateRequired; + + // Apply any source/provider that was set before the controller was ready + _appliedProvider = Provider; + _appliedSource = Source; + if (Provider != null) + _controller.SetProvider(Provider); + if (Source != null && Provider != null) + _controller.Load(Source, Provider); + + SyncZoom(); + _canvas?.Invalidate(); + } + + // ---- Rendering ---- + + private void OnPaintSurface(SKPaintGLSurfaceEventArgs e) + { + if (_controller == null) return; + var canvas = e.Surface.Canvas; + canvas.Clear(SKColors.White); + + var w = e.BackendRenderTarget.Width; + var h = e.BackendRenderTarget.Height; + + if (w != _lastControlWidth || h != _lastControlHeight) + { + _lastControlWidth = w; + _lastControlHeight = h; + _controller.SetControlSize(w, h); + } + + bool hasDebugZoom = DebugZoom < 0.999f; + if (hasDebugZoom) + { + canvas.Save(); + canvas.Translate(w * (1f - DebugZoom) / 2f, h * (1f - DebugZoom) / 2f); + canvas.Scale(DebugZoom); + } + + _controller.Update(); + var renderer = Renderer ?? _defaultRenderer; + if (renderer != null) + { + renderer.Canvas = canvas; + _controller.Render(renderer); + } + + if (hasDebugZoom) + { + using var dashEffect = SKPathEffect.CreateDash( + new float[] { 12f / DebugZoom, 6f / DebugZoom }, 0); + using var borderPaint = new SKPaint + { + Color = SKColors.Red, + Style = SKPaintStyle.Stroke, + StrokeWidth = 3f / DebugZoom, + PathEffect = dashEffect, + IsAntialias = true, + }; + canvas.DrawRect(0, 0, w, h, borderPaint); + canvas.Restore(); + } + } + + private void OnInvalidateRequired(object? sender, EventArgs e) + { + InvokeAsync(() => + { + _canvas?.Invalidate(); + if (OnInvalidate.HasDelegate) + OnInvalidate.InvokeAsync(); + }); + } + + [JSInvokable] + public void OnWindowResize() => InvokeAsync(() => _canvas?.Invalidate()); + + // ---- Pan/zoom input ---- + + private void OnMouseDown(Microsoft.AspNetCore.Components.Web.MouseEventArgs e) + { + _isDragging = true; + _lastMouseX = e.ClientX; + _lastMouseY = e.ClientY; + } + + private void OnMouseMove(Microsoft.AspNetCore.Components.Web.MouseEventArgs e) + { + if (!_isDragging || _controller == null) return; + var dx = e.ClientX - _lastMouseX; + var dy = e.ClientY - _lastMouseY; + _lastMouseX = e.ClientX; + _lastMouseY = e.ClientY; + _controller.Pan(dx, dy); + InvokeAsync(() => _canvas?.Invalidate()); + } + + private void OnMouseUp(Microsoft.AspNetCore.Components.Web.MouseEventArgs e) => _isDragging = false; + + private void OnTouchStart(Microsoft.AspNetCore.Components.Web.TouchEventArgs e) + { + if (e.Touches.Length > 0) + { + _isDragging = true; + _lastMouseX = e.Touches[0].ClientX; + _lastMouseY = e.Touches[0].ClientY; + } + } + + private void OnTouchMove(Microsoft.AspNetCore.Components.Web.TouchEventArgs e) + { + if (!_isDragging || _controller == null || e.Touches.Length == 0) return; + var dx = e.Touches[0].ClientX - _lastMouseX; + var dy = e.Touches[0].ClientY - _lastMouseY; + _lastMouseX = e.Touches[0].ClientX; + _lastMouseY = e.Touches[0].ClientY; + _controller.Pan(dx, dy); + InvokeAsync(() => _canvas?.Invalidate()); + } + + private void OnTouchEnd(Microsoft.AspNetCore.Components.Web.TouchEventArgs e) => _isDragging = false; + + // ---- Disposal ---- + + public async ValueTask DisposeAsync() + { + try { await JS2.InvokeVoidAsync("deepZoomUnregisterResize"); } catch { } + _thisRef?.Dispose(); + if (_controller != null) + { + _controller.InvalidateRequired -= OnInvalidateRequired; + _controller.Dispose(); + } + _defaultRenderer?.Dispose(); + } +} diff --git a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor index cca6db98fe..3b772c38a9 100644 --- a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor +++ b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor @@ -37,6 +37,11 @@ Pixel Comparer + diff --git a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css index 95cc1cd77b..717d87e42f 100644 --- a/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css +++ b/samples/SkiaSharpDemo.Blazor/Layout/NavMenu.razor.css @@ -98,3 +98,7 @@ overflow-y: auto; } } + +.bi-zoom-in-nav-menu { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z'/%3E%3Cpath d='M10.344 11.742c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1 6.538 6.538 0 0 1-1.398 1.4z'/%3E%3Cpath fill-rule='evenodd' d='M6.5 3a.5.5 0 0 1 .5.5V6h2.5a.5.5 0 0 1 0 1H7v2.5a.5.5 0 0 1-1 0V7H3.5a.5.5 0 0 1 0-1H6V3.5a.5.5 0 0 1 .5-.5z'/%3E%3C/svg%3E"); +} diff --git a/samples/SkiaSharpDemo.Blazor/Pages/Home.razor b/samples/SkiaSharpDemo.Blazor/Pages/Home.razor index 6744556e63..a4b36cf339 100644 --- a/samples/SkiaSharpDemo.Blazor/Pages/Home.razor +++ b/samples/SkiaSharpDemo.Blazor/Pages/Home.razor @@ -55,4 +55,15 @@ Open Demo + +
+
+
Image Pyramid
+

+ Render gigapixel images efficiently using tiled image pyramids. + Supports Deep Zoom (DZI/DZC) and IIIF Image API sources. +

+ Open Demo +
+
diff --git a/samples/SkiaSharpDemo.Blazor/Pages/ImagePyramid.razor b/samples/SkiaSharpDemo.Blazor/Pages/ImagePyramid.razor new file mode 100644 index 0000000000..d7ab6b05ff --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Pages/ImagePyramid.razor @@ -0,0 +1,350 @@ +@page "/image-pyramid" +@implements IAsyncDisposable +@inject HttpClient Http +@inject IJSRuntime JS +@using SkiaSharp.Extended +@using SkiaSharpDemo.Blazor.Components.ImagePyramid + +Image Pyramid + +
+ + + +
+ + + +
+
+ +
+ +
+ @if (_statusText != null) + { +
@_statusText
+ } + @if (_errorMessage != null) + { +
@_errorMessage
+ } + +
+ + @if (_showInspector) + { + + } +
+ + + +@code { + private const string DefaultDziUrl = + "https://openseadragon.github.io/example-images/highsmith/highsmith.dzi"; + + // ---- Component references ---- + private ImagePyramidView? _view; + + // ---- Current source (passed to view as parameter) ---- + private ISKImagePyramidSource? _currentSource; + + // ---- Status display ---- + private string? _statusText; + private string? _errorMessage; + private bool _showInspector = true; + + // ---- Debug overlay toggles (passed to view via Renderer parameter) ---- + private bool _showTileBorders; + private bool _enableLodBlending = true; + private DebugBorderRenderer? _debugRenderer; + private float _debugZoom = 1.0f; + + // ---- Provider/cache settings (page manages these; view gets the provider as a param) ---- + private bool _cacheDelayEnabled; + private int _cacheDelayMin; + private int _cacheDelayMax = 500; + private bool _useBrowserCache; + private int _cacheCapacity = 1024; + private DelayTileProvider? _delayProvider; + + // ---- URL input ---- + private string _urlInput = DefaultDziUrl; + private bool _urlLoading = false; + + // ---- Zoom (tracked for the toolbar slider) ---- + private double _zoom = 1.0; + private double _sliderValue = 0.0; + + private const double MinZoom = 0.01; + private const double MaxZoom = 100.0; + private static double ZoomToSlider(double zoom) + => (Math.Log(zoom) - Math.Log(MinZoom)) / (Math.Log(MaxZoom) - Math.Log(MinZoom)); + private static double SliderToZoom(double slider) + => MinZoom * Math.Pow(MaxZoom / MinZoom, slider); + + private System.Threading.Timer? _inspectorTimer; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + + _debugRenderer = new DebugBorderRenderer(new SKImagePyramidRenderer()); + _delayProvider = BuildDelayProvider(); + + // Refresh inspector every 500ms while tiles load + _inspectorTimer = new System.Threading.Timer(_ => + { + var prevZoom = _zoom; + SyncZoomFromView(); + var changed = Math.Abs(_zoom - prevZoom) > 0.001 || _view?.Controller?.IsIdle == false; + if (changed) InvokeAsync(StateHasChanged); + }, null, 500, 500); + + await LoadFromUrlAsync(DefaultDziUrl); + } + + // ---- View callbacks ---- + + private void OnViewInvalidate() + { + SyncZoomFromView(); + InvokeAsync(StateHasChanged); + } + + // ---- Zoom slider ---- + + private void OnZoomSlider(ChangeEventArgs e) + { + if (!double.TryParse(e.Value?.ToString(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var sliderPos)) return; + _sliderValue = sliderPos; + _zoom = SliderToZoom(sliderPos); + _view?.SetZoom(_zoom); + } + + private void OnResetView() + { + _view?.ResetView(); + SyncZoomFromView(); + } + + private void SyncZoomFromView() + { + if (_view == null) return; + _zoom = _view.Zoom; + _sliderValue = ZoomToSlider(Math.Clamp(_zoom, MinZoom, MaxZoom)); + } + + // ---- URL loading ---- + + private async Task OnUrlKeyDown(KeyboardEventArgs e) + { + if (_urlLoading) return; + if (e.Key == "Enter") await OnLoadUrl(); + } + + private async Task OnExampleSelected(ChangeEventArgs e) + { + if (_urlLoading) return; + var url = e.Value?.ToString(); + if (string.IsNullOrEmpty(url)) return; + _urlInput = url; + await LoadFromUrlAsync(url); + } + + private async Task OnLoadUrl() + { + var url = _urlInput?.Trim(); + if (string.IsNullOrEmpty(url)) return; + await LoadFromUrlAsync(url); + } + + private async Task LoadFromUrlAsync(string url) + { + _urlLoading = true; + _errorMessage = null; + _statusText = "Loading…"; + StateHasChanged(); + + try + { + bool isDzc = url.EndsWith(".dzc", StringComparison.OrdinalIgnoreCase); + bool isDzi = url.EndsWith(".dzi", StringComparison.OrdinalIgnoreCase); + bool isIiif = url.EndsWith("/info.json", StringComparison.OrdinalIgnoreCase) + || url.Contains("/iiif/", StringComparison.OrdinalIgnoreCase) + || url.Contains("iiif.io", StringComparison.OrdinalIgnoreCase); + + string fetchUrl = url; + if (isIiif && !url.EndsWith("/info.json", StringComparison.OrdinalIgnoreCase)) + fetchUrl = url.TrimEnd('/') + "/info.json"; + + var content = await Http.GetStringAsync(fetchUrl); + string baseDir = fetchUrl[..fetchUrl.LastIndexOf('/')] + "/"; + string stem = System.IO.Path.GetFileNameWithoutExtension(fetchUrl); + + if (isDzc) + { + var coll = SKImagePyramidDziCollectionSource.Parse(content); + coll.TilesBaseUri = baseDir; + // DZC collections use a separate Load overload — not ISKImagePyramidSource + _currentSource = null; + if (_view?.Controller is { } ctrl && _delayProvider != null) + ctrl.Load(coll, _delayProvider); + _statusText = $"⚠️ Collection loaded ({coll.ItemCount} images) — DZC collection rendering not yet supported. Use a .dzi URL instead."; + } + else if (isIiif || (!isDzi && content.TrimStart().StartsWith("{"))) + { + var tileSource = SKImagePyramidIiifSource.Parse(content); + _currentSource = tileSource; + _statusText = $"{tileSource.ImageWidth}×{tileSource.ImageHeight} ({tileSource.MaxLevel + 1} levels, IIIF)"; + } + else + { + string tilesBase = $"{baseDir}{stem}_files/"; + var tileSource = SKImagePyramidDziSource.Parse(content, tilesBase); + _currentSource = tileSource; + _statusText = $"{tileSource.ImageWidth}×{tileSource.ImageHeight} ({tileSource.MaxLevel + 1} levels)"; + } + + // The view's Source parameter setter calls controller.Load() automatically + } + catch (Exception ex) + { + _errorMessage = $"Failed to load: {ex.Message}"; + _statusText = null; + } + finally + { + _urlLoading = false; + } + + await InvokeAsync(StateHasChanged); + } + + // ---- Inspector toggle ---- + + private void ToggleInspector() => _showInspector = !_showInspector; + + // ---- Debug options ---- + + private void OnDebugOptionsChanged() + { + if (_debugRenderer != null) + _debugRenderer.ShowTileBorders = _showTileBorders; + if (_view?.Controller != null) + _view.Controller.EnableLodBlending = _enableLodBlending; + } + + private void OnDebugZoomChanged() { /* DebugZoom param update triggers re-render */ } + + // ---- Provider management (cache/delay options) ---- + + private void OnCacheDelayChanged() + { + if (_delayProvider == null) return; + _delayProvider.IsEnabled = _cacheDelayEnabled; + _delayProvider.MinDelayMs = _cacheDelayMin; + _delayProvider.MaxDelayMs = _cacheDelayMax; + } + + private void OnCacheTypeChanged() + { + RebuildProvider(); + } + + private void OnCacheSizeChanged() + { + RebuildProvider(); + } + + private void OnClearCache() => _view?.Controller?.Cache.Clear(); + + private ISKImagePyramidTileProvider BuildInnerProvider() + { + var http = new SKTieredTileProvider(new SKHttpTileFetcher()); + if (_useBrowserCache) + return new BrowserStorageTileProvider(http, JS); + return http; + } + + private DelayTileProvider BuildDelayProvider() => new(BuildInnerProvider()) + { + IsEnabled = _cacheDelayEnabled, + MinDelayMs = _cacheDelayMin, + MaxDelayMs = _cacheDelayMax, + }; + + private void RebuildProvider() + { + var old = _delayProvider; + _delayProvider = BuildDelayProvider(); + old?.Dispose(); + // Force the view to pick up the new provider by re-setting _currentSource via StateHasChanged + // The view's Provider parameter will be re-evaluated on next render + } + + // ---- Disposal ---- + + public async ValueTask DisposeAsync() + { + _inspectorTimer?.Dispose(); + // _view is disposed by Blazor's component tree teardown automatically + _delayProvider?.Dispose(); + _debugRenderer?.Dispose(); + await Task.CompletedTask; + } +} diff --git a/samples/SkiaSharpDemo.Blazor/Pages/ImagePyramid.razor.css b/samples/SkiaSharpDemo.Blazor/Pages/ImagePyramid.razor.css new file mode 100644 index 0000000000..fc8094e2fb --- /dev/null +++ b/samples/SkiaSharpDemo.Blazor/Pages/ImagePyramid.razor.css @@ -0,0 +1,114 @@ +.ip-toolbar { + padding: 6px 8px; + background: #222; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: #ddd; + cursor: default; + user-select: none; +} + +.ip-zoom-label { + display: inline-block; + min-width: 7.5ch; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.ip-layout { + display: flex; + width: 100%; + height: calc(100vh - 155px); + min-height: 300px; + overflow: hidden; +} + +.ip-canvas-area { + position: relative; + flex: 1 1 0px; + min-width: 0; + min-height: 0; + overflow: hidden; + cursor: grab; + touch-action: none; +} + +.ip-canvas-area.dragging { + cursor: grabbing; +} + +.ip-status { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + padding: 4px 8px; + background: rgba(0,0,0,0.65); + font-size: 0.8rem; + color: #aaa; +} + +.ip-error { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + padding: 4px 8px; + background: #5c1010; + color: #ff8a80; + font-size: 0.8rem; +} + +.ip-toggle-btn { + position: fixed; + bottom: 1rem; + right: 1rem; + z-index: 100; + padding: 0.4rem 0.8rem; + background: #1976d2; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.8rem; +} +.ip-toggle-btn:hover { background: #1565c0; } + +.ip-url-row { + display: flex; + flex: 1 1 auto; + gap: 4px; + min-width: 0; +} + +.ip-url-input { + flex: 1 1 auto; + min-width: 0; + background: #333; + color: #eee; + border: 1px solid #555; + border-radius: 3px; + padding: 2px 6px; + font-size: 0.8rem; +} + +.ip-example-select { + background: #333; + color: #eee; + border: 1px solid #555; + border-radius: 3px; + padding: 2px 4px; + font-size: 0.8rem; + cursor: pointer; + max-width: 220px; +} +.ip-example-select:hover { border-color: #888; } + +/* Separator between canvas area and inspector when inspector is open */ +.ip-layout.ip-with-inspector .ip-canvas-area { + border-right: 1px solid #333; +} diff --git a/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj b/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj index e22a8ae99e..c0fef22109 100644 --- a/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj +++ b/samples/SkiaSharpDemo.Blazor/SkiaSharpDemo.Blazor.csproj @@ -17,14 +17,6 @@ local - - - - <_Parameter1>BuildInfo - <_Parameter2>$(BuildInfo) - - - diff --git a/samples/SkiaSharpDemo.Blazor/wwwroot/index.html b/samples/SkiaSharpDemo.Blazor/wwwroot/index.html index b51db3ed41..3251e8121e 100644 --- a/samples/SkiaSharpDemo.Blazor/wwwroot/index.html +++ b/samples/SkiaSharpDemo.Blazor/wwwroot/index.html @@ -39,6 +39,48 @@ 🗙 + diff --git a/samples/SkiaSharpDemo/Demos/ImagePyramid/ImagePyramidPage.xaml b/samples/SkiaSharpDemo/Demos/ImagePyramid/ImagePyramidPage.xaml new file mode 100644 index 0000000000..a70218ab03 --- /dev/null +++ b/samples/SkiaSharpDemo/Demos/ImagePyramid/ImagePyramidPage.xaml @@ -0,0 +1,71 @@ + + + + + + +