From 7b2ded6edbafb84d9c44bf5472fa800d491984a7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 16 Jun 2026 16:31:16 +0200 Subject: [PATCH 1/2] fixed tilerenderer caching --- .../DeepZoom/Renderer/TileRendererTests.cs | 42 +++++++++++++++++++ Svg/Basic Shapes/SvgImage.cs | 2 +- Svg/DeepZoom/Renderer/ITileRenderer.cs | 4 +- Svg/DeepZoom/Renderer/TileRenderer.cs | 20 ++++----- Svg/Svg.csproj | 6 ++- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs b/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs index ba34a99ae..6ffdb65a2 100644 --- a/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs +++ b/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs @@ -270,6 +270,48 @@ Stream CountingProvider(string folder, string file) $"Expected only visible tiles to be loaded (12); got {loadCalls} provider calls."); } + [Test] + public void RenderBitmap_WithDifferentSourceKeys_DoesNotReuseCachedTilesAcrossSources() + { + // Regression for #11850: a single (singleton) TileRenderer with an active cache is reused + // across plans. Tiles must be keyed by their source — otherwise plan B's "z0/y0_x0.png" + // lookup returns plan A's cached bitmap and every plan after the first renders the first + // plan's image. + SvgPlatform.Init(); + + // One renderer + one cache, reused for both "sources" (as the reporting singleton would be). + using var rndr = new TileRenderer(10, 10, new TileCacheOptions(TimeSpan.FromHours(1))); + + Stream RedProvider(string folder, string file) => SolidTilePng(SKColors.Red); + Stream BlueProvider(string folder, string file) => SolidTilePng(SKColors.Blue); + + // Sample an interior pixel: RenderBitmap strokes a red debug-border rectangle around each + // tile under #if DEBUG, so the (0,0) corner is unreliable — read the tile fill instead. + const int sx = 5, sy = 5; + + // Source "A" caches a red tile at z0/y0_x0. + using (var first = rndr.RenderBitmap(RedProvider, 0, 0, 1f, "planA.zip")) + first.GetPixel(sx, sy).ShouldBe(SKColors.Red, "first source should render its own (red) tile"); + + // Source "B" provides a blue tile for the same coordinate. With source-scoped keys this is a + // cache miss and renders blue; with the old coordinate-only key it would return A's red tile. + using var second = rndr.RenderBitmap(BlueProvider, 0, 0, 1f, "planB.zip"); + + second.GetPixel(sx, sy).ShouldBe(SKColors.Blue, + "second source must render its own tile, not the first source's cached tile"); + } + + private static MemoryStream SolidTilePng(SKColor color) + { + using var bmp = new SKBitmap(TileConstants.TileSize, TileConstants.TileSize); + bmp.Erase(color); + var ms = new MemoryStream(); + using (var w = new SKManagedWStream(ms)) + bmp.Encode(w, SKEncodedImageFormat.Png, 100); + ms.Position = 0; + return ms; + } + private static void AssertRenderedFile(string path, int expectedWidth, int expectedHeight) { File.Exists(path).ShouldBeTrue($"renderer should have written output file: {path}"); diff --git a/Svg/Basic Shapes/SvgImage.cs b/Svg/Basic Shapes/SvgImage.cs index 4f376d259..9e51f1d56 100644 --- a/Svg/Basic Shapes/SvgImage.cs +++ b/Svg/Basic Shapes/SvgImage.cs @@ -222,7 +222,7 @@ protected override void Render(ISvgRenderer renderer) var tileProvider = CreateZipTileProvider(zipArchive, (a, b) => fileSystem.PathCombine(a, b)); using var skImage = tileRenderer.RenderBitmap(tileProvider, xOffset, yOffset, - scaleX); + scaleX, Href); var image = new SkiaBitmap(skImage); diff --git a/Svg/DeepZoom/Renderer/ITileRenderer.cs b/Svg/DeepZoom/Renderer/ITileRenderer.cs index 9e4f72a1d..df2ce7a94 100644 --- a/Svg/DeepZoom/Renderer/ITileRenderer.cs +++ b/Svg/DeepZoom/Renderer/ITileRenderer.cs @@ -11,12 +11,12 @@ public interface ITileRenderer : IDisposable void RenderBitmap(string tileFolderPath, string outputPath, float x, float y, float zoomFactor = 1); - SKBitmap RenderBitmap(Func tileProvider, float offsetX, float offsetY, float zoomFactor = 1); + SKBitmap RenderBitmap(Func tileProvider, float offsetX, float offsetY, float zoomFactor = 1, string sourceKey = null); Task RenderBitmapAsync(string tileFolderPath, string outputPath, float x, float y, float zoomFactor = 1); Task RenderBitmapAsync(Func> tileProvider, float offsetX, - float offsetY, float zoomFactor = 1); + float offsetY, float zoomFactor = 1, string sourceKey = null); } } \ No newline at end of file diff --git a/Svg/DeepZoom/Renderer/TileRenderer.cs b/Svg/DeepZoom/Renderer/TileRenderer.cs index 5bb2ef198..5d7893f52 100644 --- a/Svg/DeepZoom/Renderer/TileRenderer.cs +++ b/Svg/DeepZoom/Renderer/TileRenderer.cs @@ -68,7 +68,7 @@ public void RenderBitmap(string tileFolderPath, string outputPath, float x, floa var tileProvider = new Func((folder, fileName) => LoadTileStream(_fileSystem.PathCombine(tileFolderPath, folder), fileName)); - using var tileBitmap = RenderBitmap(tileProvider, x, y, zoomFactor); + using var tileBitmap = RenderBitmap(tileProvider, x, y, zoomFactor, tileFolderPath); // Save the tile as a PNG file using var image = SKImage.FromBitmap(tileBitmap); @@ -86,7 +86,7 @@ public void RenderBitmap(string tileFolderPath, string outputPath, float x, floa /// Example zoom factor: 1.0 means fully zoomed in at highest detail (z0), 0.5 means one level zoomed out (z1), etc. /// public SKBitmap RenderBitmap(Func tileProvider, float offsetX, float offsetY, - float zoomFactor = 1) + float zoomFactor = 1, string sourceKey = null) { var tileSize = TileConstants.TileSize; SKBitmap bitmap = new SKBitmap(Width, Height); @@ -132,7 +132,7 @@ public SKBitmap RenderBitmap(Func tileProvider, float of continue; // Load the tile bitmap only for visible tiles; not disposing bitmap because its cached - var tileBitmap = LoadTile($"z{zoomLevel}", $"y{tileY}_x{tileX}.png", tileProvider); + var tileBitmap = LoadTile($"z{zoomLevel}", $"y{tileY}_x{tileX}.png", tileProvider, sourceKey); if (tileBitmap != null) { @@ -172,7 +172,7 @@ public async Task RenderBitmapAsync(string tileFolderPath, string outputPath, fl var tileProvider = new Func>((folder, fileName) => LoadTileStreamAsync(_fileSystem.PathCombine(tileFolderPath, folder), fileName)); - using var tileBitmap = await RenderBitmapAsync(tileProvider, x, y, zoomFactor); + using var tileBitmap = await RenderBitmapAsync(tileProvider, x, y, zoomFactor, tileFolderPath); // Save the tile as a PNG file @@ -183,7 +183,7 @@ public async Task RenderBitmapAsync(string tileFolderPath, string outputPath, fl } public async Task RenderBitmapAsync(Func> tileProvider, float offsetX, - float offsetY, float zoomFactor = 1) + float offsetY, float zoomFactor = 1, string sourceKey = null) { var tileSize = TileConstants.TileSize; SKBitmap bitmap = new SKBitmap(Width, Height); @@ -227,7 +227,7 @@ public async Task RenderBitmapAsync(Func> tileLoadTasks.Add(Task.Run(async () => { var bmp = await LoadTileAsync($"z{zoomLevel}", $"y{localTileY}_x{localTileX}.png", - tileProvider); + tileProvider, sourceKey); // Calculate the position to draw the tile on the canvas float drawX = offsetX + localTileX * tileSizeAtZoom; float drawY = offsetY + localTileY * tileSizeAtZoom; @@ -289,11 +289,11 @@ internal Stream LoadTileStream(string zoomFolderName, string tileFileName) // Asynchronous method to load a tile from disk private async Task LoadTileAsync(string zoomFolderName, string tileFileName, - Func> tileProvider) + Func> tileProvider, string sourceKey = null) { if (_cache is { } cache) { - var item = await cache.GetOrCreateAsync(Path.Combine(zoomFolderName, tileFileName), + var item = await cache.GetOrCreateAsync(Path.Combine(sourceKey ?? string.Empty, zoomFolderName, tileFileName), async () => { using var stream = await tileProvider.Invoke(zoomFolderName, tileFileName); @@ -313,9 +313,9 @@ private async Task LoadTileAsync(string zoomFolderName, string tileFil } private SKBitmap LoadTile(string zoomFolderName, string tileFileName, - Func tileProvider) + Func tileProvider, string sourceKey = null) { - var tilePath = Path.Combine(zoomFolderName, tileFileName); + var tilePath = Path.Combine(sourceKey ?? string.Empty, zoomFolderName, tileFileName); if (_cache is { } cache) { diff --git a/Svg/Svg.csproj b/Svg/Svg.csproj index c030dc414..edaf6b0a9 100644 --- a/Svg/Svg.csproj +++ b/Svg/Svg.csproj @@ -3,10 +3,14 @@ netstandard2.0;net48;net10.0 PackageReference - 3.2.0-optiq02 + 3.2.0-optiq03 gentledpp,zepr Opti-Q GmbH + #3.2.0-optiq03 + Fixed: tile cache key now includes the tile source (zip Href / tile folder) so the shared + singleton TileRenderer no longer returns one source's tiles for another — reports rendering + multiple file-tiled plans previously all showed the first plan's image. #3.2.0-optiq02 TileGenerator stores scale factor in dimensions.json (relative to svg dimensions) #3.2.0-optiq01 From 82496c0862d6a9938a2a74abedb787f1fd0407cf Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 17 Jun 2026 14:57:20 +0200 Subject: [PATCH 2/2] improved tiling efficiency --- .../DeepZoom/Cache/TileCacheTests.cs | 61 +++++++++++++ .../Renderer/TileRendererManagerTests.cs | 88 +++++++++++++++++++ .../DeepZoom/Renderer/TileRendererTests.cs | 1 + Svg/Basic Shapes/SvgImage.cs | 10 +-- Svg/DeepZoom/Cache/ITileCache.cs | 6 ++ Svg/DeepZoom/Cache/TileCache.cs | 27 ++++-- Svg/DeepZoom/Renderer/ITileRendererManager.cs | 11 ++- Svg/DeepZoom/Renderer/TileRenderer.cs | 6 ++ Svg/DeepZoom/Renderer/TileRendererManager.cs | 69 ++++++++++++++- 9 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 Svg.Tests.Win/DeepZoom/Renderer/TileRendererManagerTests.cs diff --git a/Svg.Tests.Win/DeepZoom/Cache/TileCacheTests.cs b/Svg.Tests.Win/DeepZoom/Cache/TileCacheTests.cs index b22dd080e..4535e6da5 100644 --- a/Svg.Tests.Win/DeepZoom/Cache/TileCacheTests.cs +++ b/Svg.Tests.Win/DeepZoom/Cache/TileCacheTests.cs @@ -311,6 +311,67 @@ public void CleanUp_ShouldNotRemoveNonExpiredItems() Assert.AreEqual(bitmap, cacheItem.Tile); } + [Test] + public void DrainPendingDisposals_AfterEviction_DisposesEvictedBitmap() + { + var opts = new TileCacheOptions { CleanupInterval = TimeSpan.FromHours(1), MaximalTiles = 2 }; + using var cache = new TileCache(opts); + + var evicted = new SKBitmap(1, 1); + cache.GetOrCreate("k0", () => evicted); + cache.GetOrCreate("k1", () => new SKBitmap(1, 1)); + + // Exceed capacity → k0 (LRU) is evicted but disposal is deferred. + cache.GetOrCreate("k2", () => new SKBitmap(1, 1)); + Assert.AreNotEqual(IntPtr.Zero, evicted.Handle, "evicted bitmap must not be disposed before drain"); + + cache.DrainPendingDisposals(); + Assert.AreEqual(IntPtr.Zero, evicted.Handle, "evicted bitmap must be disposed after drain"); + } + + [Test] + public void DrainPendingDisposals_AfterRemove_DisposesRemovedBitmap() + { + var removed = new SKBitmap(1, 1); + _cache.GetOrCreate("k", () => removed); + + _cache.Remove("k"); + Assert.AreNotEqual(IntPtr.Zero, removed.Handle, "removed bitmap must not be disposed before drain"); + + _cache.DrainPendingDisposals(); + Assert.AreEqual(IntPtr.Zero, removed.Handle, "removed bitmap must be disposed after drain"); + } + + [Test] + public void DrainPendingDisposals_DoesNotDisposeLiveItems() + { + var live = new SKBitmap(1, 1); + _cache.GetOrCreate("live", () => live); + + _cache.DrainPendingDisposals(); + + Assert.AreNotEqual(IntPtr.Zero, live.Handle, "a cached (live) bitmap must not be disposed"); + Assert.IsTrue(_cache.TryGetValue("live", out var item)); + Assert.AreEqual(live, item.Tile); + } + + [Test] + public void DrainPendingDisposals_AfterExpiryCleanup_DisposesExpiredBitmap() + { + var expired = new SKBitmap(1, 1); + var options = new TileCacheOptions { CleanupInterval = TimeSpan.FromMilliseconds(300) }; + using var cache = new TileCache(options); + + cache.GetOrCreate("k", () => expired); + + // Wait for the cleanup timer to remove the expired entry (still not disposed yet). + Thread.Sleep(800); + Assert.AreNotEqual(IntPtr.Zero, expired.Handle, "expired bitmap must not be disposed before drain"); + + cache.DrainPendingDisposals(); + Assert.AreEqual(IntPtr.Zero, expired.Handle, "expired bitmap must be disposed after drain"); + } + [Test] public void CleanUp_ShouldFreeCapacityForNewItems() { diff --git a/Svg.Tests.Win/DeepZoom/Renderer/TileRendererManagerTests.cs b/Svg.Tests.Win/DeepZoom/Renderer/TileRendererManagerTests.cs new file mode 100644 index 000000000..49d0eeb29 --- /dev/null +++ b/Svg.Tests.Win/DeepZoom/Renderer/TileRendererManagerTests.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.IO.Compression; +using NUnit.Framework; +using SkiaSharp; +using Svg; +using Svg.DeepZoom; + +namespace Svg.Tests.Win.DeepZoom.Renderer; + +/// +/// Verifies that caches the open zip archive per path +/// (opened/enumerated once per plan, not per render) and disposes it on teardown. +/// +public class TileRendererManagerTests +{ + private static string WriteTempZipWithTile(string entryName) + { + var path = Path.Combine(Path.GetTempPath(), $"tiles_{Guid.NewGuid():N}.zip"); + using var fs = File.Create(path); + using var zip = new ZipArchive(fs, ZipArchiveMode.Create); + + using var bmp = new SKBitmap(1, 1); + using var png = new MemoryStream(); + using (var w = new SKManagedWStream(png)) + bmp.Encode(w, SKEncodedImageFormat.Png, 100); + + var entry = zip.CreateEntry(entryName); + using var es = entry.Open(); + png.WriteTo(es); + return path; + } + + [Test] + public void GetOrCreateZipTileProvider_ReusesOpenArchive_AndDisposesOnTeardown() + { + SvgPlatform.Init(); + var zipPath = WriteTempZipWithTile("z0/y0_x0.png"); + var manager = new TileRendererManager(); + + try + { + var provider1 = manager.GetOrCreateZipTileProvider(zipPath); + var provider2 = manager.GetOrCreateZipTileProvider(zipPath); + + // Both providers read from the same cached, still-open archive across calls. + using (var s1 = provider1("z0", "y0_x0.png")) + Assert.IsNotNull(s1, "first provider should read an existing tile"); + using (var s2 = provider2("z0", "y0_x0.png")) + Assert.IsNotNull(s2, "second provider should read from the same cached archive"); + + // Tearing the manager down disposes the cached archive: a previously handed-out + // provider now fails, proving it shared one persistent open archive (not a reopen-per-call). + manager.DisposeTileRenderer(); + Assert.Catch(() => provider1("z0", "y0_x0.png"), + "after teardown the cached archive must be disposed"); + + // A fresh request reopens the archive and works again. + var provider3 = manager.GetOrCreateZipTileProvider(zipPath); + using var s3 = provider3("z0", "y0_x0.png"); + Assert.IsNotNull(s3, "a fresh request should reopen the archive"); + } + finally + { + manager.DisposeTileRenderer(); + File.Delete(zipPath); + } + } + + [Test] + public void GetOrCreateZipTileProvider_ReturnsNullForMissingEntry() + { + SvgPlatform.Init(); + var zipPath = WriteTempZipWithTile("z0/y0_x0.png"); + var manager = new TileRendererManager(); + + try + { + var provider = manager.GetOrCreateZipTileProvider(zipPath); + Assert.IsNull(provider("z0", "y99_x99.png"), "missing tile must yield null"); + } + finally + { + manager.DisposeTileRenderer(); + File.Delete(zipPath); + } + } +} diff --git a/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs b/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs index 6ffdb65a2..b65e554d9 100644 --- a/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs +++ b/Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs @@ -370,6 +370,7 @@ public async Task GetOrCreateAsync(string key, Func _inner.TryGetValue(key, out item); public void Remove(string key) => _inner.Remove(key); + public void DrainPendingDisposals() => _inner.DrainPendingDisposals(); public void Dispose() => _inner.Dispose(); } } diff --git a/Svg/Basic Shapes/SvgImage.cs b/Svg/Basic Shapes/SvgImage.cs index 9e51f1d56..a8b1ad09e 100644 --- a/Svg/Basic Shapes/SvgImage.cs +++ b/Svg/Basic Shapes/SvgImage.cs @@ -208,18 +208,18 @@ protected override void Render(ISvgRenderer renderer) { PushTransforms(renderer); - var fileSystem = SvgEngine.Resolve(); var scaleX = renderer.Transform.ScaleX; var xOffset = renderer.Transform.OffsetX; var yOffset = renderer.Transform.OffsetY; - var tileRenderer = SvgEngine.Resolve().GetOrCreateTileRenderer(); + var tileRendererManager = SvgEngine.Resolve(); + var tileRenderer = tileRendererManager.GetOrCreateTileRenderer(); tileRenderer.SetDimensions(renderer.ScreenWidth, renderer.ScreenHeight); - using var zipFileStream = fileSystem.OpenRead(Href); - using var zipArchive = new ZipArchive(zipFileStream, ZipArchiveMode.Read); - var tileProvider = CreateZipTileProvider(zipArchive, (a, b) => fileSystem.PathCombine(a, b)); + // Reuse the open archive + entry lookup across renders instead of reopening + // and re-enumerating the zip on every render. + var tileProvider = tileRendererManager.GetOrCreateZipTileProvider(Href); using var skImage = tileRenderer.RenderBitmap(tileProvider, xOffset, yOffset, scaleX, Href); diff --git a/Svg/DeepZoom/Cache/ITileCache.cs b/Svg/DeepZoom/Cache/ITileCache.cs index acbf14153..03ab9d9e1 100644 --- a/Svg/DeepZoom/Cache/ITileCache.cs +++ b/Svg/DeepZoom/Cache/ITileCache.cs @@ -10,5 +10,11 @@ public interface ITileCache : IDisposable bool TryGetValue(string key, out TileCacheItem item); void Remove(string key); + /// + /// Disposes the bitmaps of items that have left the cache (eviction, expiry, removal). + /// Must be called at a point where no tile is being drawn (e.g. the start of a render), + /// so an in-flight draw can never reference a just-disposed bitmap. + /// + void DrainPendingDisposals(); } } \ No newline at end of file diff --git a/Svg/DeepZoom/Cache/TileCache.cs b/Svg/DeepZoom/Cache/TileCache.cs index 1fdcd5934..e63b1458b 100644 --- a/Svg/DeepZoom/Cache/TileCache.cs +++ b/Svg/DeepZoom/Cache/TileCache.cs @@ -16,6 +16,11 @@ public class TileCache : ITileCache private readonly object _evictionLock = new(); private long _tickCounter = 0; + // Items that have left the cache but whose SKBitmap is not yet disposed. Disposal is + // deferred to DrainPendingDisposals (called at render start) so the background cleanup + // timer can never free a bitmap that a concurrent draw still references. + private readonly ConcurrentQueue _pendingDispose = new(); + public TileCache(TileCacheOptions options) { _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -61,7 +66,8 @@ public bool TryGetValue(string key, out TileCacheItem item) return true; } - _cache.TryRemove(key, out _); + if (_cache.TryRemove(key, out var expired)) + _pendingDispose.Enqueue(expired); } item = default; @@ -91,27 +97,36 @@ private void EvictLru() } } - if (lruKey != null) - _cache.TryRemove(lruKey, out _); + if (lruKey != null && _cache.TryRemove(lruKey, out var evicted)) + _pendingDispose.Enqueue(evicted); } public void Remove(string key) { if (key == null) throw new ArgumentNullException(nameof(key)); - _cache.TryRemove(key, out _); + if (_cache.TryRemove(key, out var removed)) + _pendingDispose.Enqueue(removed); } private void CleanUp(object state) { foreach (var key in _cache.Keys) { - if (_cache.TryGetValue(key, out var cacheItem) && cacheItem.IsExpired) - _cache.TryRemove(key, out _); + if (_cache.TryGetValue(key, out var cacheItem) && cacheItem.IsExpired + && _cache.TryRemove(key, out var expired)) + _pendingDispose.Enqueue(expired); } } + public void DrainPendingDisposals() + { + while (_pendingDispose.TryDequeue(out var item)) + item?.Dispose(); + } + public void Dispose() { + DrainPendingDisposals(); foreach (var tileCacheItem in _cache) { tileCacheItem.Value?.Dispose(); diff --git a/Svg/DeepZoom/Renderer/ITileRendererManager.cs b/Svg/DeepZoom/Renderer/ITileRendererManager.cs index abf3eee66..d2966e6ac 100644 --- a/Svg/DeepZoom/Renderer/ITileRendererManager.cs +++ b/Svg/DeepZoom/Renderer/ITileRendererManager.cs @@ -1,8 +1,17 @@ -namespace Svg.DeepZoom; +using System; +using System.IO; + +namespace Svg.DeepZoom; public interface ITileRendererManager { public ITileRenderer GetOrCreateTileRenderer(); + /// + /// Returns a tile provider backed by a cached, already-open zip archive for the given path, + /// so the archive is opened and enumerated once per plan rather than on every render. + /// + Func GetOrCreateZipTileProvider(string zipPath); + public void DisposeTileRenderer(); } \ No newline at end of file diff --git a/Svg/DeepZoom/Renderer/TileRenderer.cs b/Svg/DeepZoom/Renderer/TileRenderer.cs index 5d7893f52..47a207149 100644 --- a/Svg/DeepZoom/Renderer/TileRenderer.cs +++ b/Svg/DeepZoom/Renderer/TileRenderer.cs @@ -88,6 +88,9 @@ public void RenderBitmap(string tileFolderPath, string outputPath, float x, floa public SKBitmap RenderBitmap(Func tileProvider, float offsetX, float offsetY, float zoomFactor = 1, string sourceKey = null) { + // Free bitmaps evicted/expired since the last render now, while no tile is being drawn. + _cache?.DrainPendingDisposals(); + var tileSize = TileConstants.TileSize; SKBitmap bitmap = new SKBitmap(Width, Height); using var canvas = new SKCanvas(bitmap); @@ -185,6 +188,9 @@ public async Task RenderBitmapAsync(string tileFolderPath, string outputPath, fl public async Task RenderBitmapAsync(Func> tileProvider, float offsetX, float offsetY, float zoomFactor = 1, string sourceKey = null) { + // Free bitmaps evicted/expired since the last render now, while no tile is being drawn. + _cache?.DrainPendingDisposals(); + var tileSize = TileConstants.TileSize; SKBitmap bitmap = new SKBitmap(Width, Height); using var canvas = new SKCanvas(bitmap); diff --git a/Svg/DeepZoom/Renderer/TileRendererManager.cs b/Svg/DeepZoom/Renderer/TileRendererManager.cs index 2b17b0635..9be6c0e75 100644 --- a/Svg/DeepZoom/Renderer/TileRendererManager.cs +++ b/Svg/DeepZoom/Renderer/TileRendererManager.cs @@ -1,9 +1,20 @@ -namespace Svg.DeepZoom; +using System; +using System.Collections.Concurrent; +using System.IO; +using System.IO.Compression; +using Svg.Interfaces; + +namespace Svg.DeepZoom; public class TileRendererManager : ITileRendererManager { private ITileRenderer _tileRendererInstance; + + // Open zip archives + prebuilt tile lookups, keyed by zip path, so the archive is opened + // and its entry list enumerated once per plan instead of on every render. + private readonly ConcurrentDictionary> _zipArchives = new(); + public ITileRenderer GetOrCreateTileRenderer() { if (_tileRendererInstance == null) @@ -14,6 +25,22 @@ public ITileRenderer GetOrCreateTileRenderer() return _tileRendererInstance; } + /// + /// Returns a tile provider backed by a cached, already-open zip archive for + /// . The archive is opened (and its entries enumerated) only on + /// the first request for a given path; subsequent renders reuse it. Disposed in + /// . + /// + public Func GetOrCreateZipTileProvider(string zipPath) + { + if (zipPath == null) throw new ArgumentNullException(nameof(zipPath)); + + var archive = _zipArchives.GetOrAdd(zipPath, + p => new Lazy(() => new ZipTileArchive(p))).Value; + + return archive.GetEntryStream; + } + public void DisposeTileRenderer() { if (_tileRendererInstance != null) @@ -21,5 +48,43 @@ public void DisposeTileRenderer() _tileRendererInstance.Dispose(); _tileRendererInstance = null; } + + foreach (var key in _zipArchives.Keys) + { + if (_zipArchives.TryRemove(key, out var lazy) && lazy.IsValueCreated) + lazy.Value.Dispose(); + } + } + + /// + /// Holds an open zip archive and its prebuilt entry lookup. Entry reads are serialised + /// because does not support concurrent access. + /// + private sealed class ZipTileArchive : IDisposable + { + private readonly Stream _stream; + private readonly ZipArchive _archive; + private readonly Func _provider; + private readonly object _gate = new(); + + public ZipTileArchive(string zipPath) + { + var fileSystem = SvgEngine.Resolve(); + _stream = fileSystem.OpenRead(zipPath); + _archive = new ZipArchive(_stream, ZipArchiveMode.Read); + _provider = SvgImage.CreateZipTileProvider(_archive, (a, b) => fileSystem.PathCombine(a, b)); + } + + public Stream GetEntryStream(string folderName, string fileName) + { + lock (_gate) + return _provider(folderName, fileName); + } + + public void Dispose() + { + _archive.Dispose(); + _stream.Dispose(); + } } -} \ No newline at end of file +}