Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Svg.Tests.Win/DeepZoom/Cache/TileCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
88 changes: 88 additions & 0 deletions Svg.Tests.Win/DeepZoom/Renderer/TileRendererManagerTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that <see cref="TileRendererManager"/> caches the open zip archive per path
/// (opened/enumerated once per plan, not per render) and disposes it on teardown.
/// </summary>
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);
}
}
}
43 changes: 43 additions & 0 deletions Svg.Tests.Win/DeepZoom/Renderer/TileRendererTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
Expand Down Expand Up @@ -328,6 +370,7 @@ public async Task<TileCacheItem> GetOrCreateAsync(string key, Func<Task<SKBitmap

public bool TryGetValue(string key, out TileCacheItem item) => _inner.TryGetValue(key, out item);
public void Remove(string key) => _inner.Remove(key);
public void DrainPendingDisposals() => _inner.DrainPendingDisposals();
public void Dispose() => _inner.Dispose();
}
}
12 changes: 6 additions & 6 deletions Svg/Basic Shapes/SvgImage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,21 +208,21 @@ protected override void Render(ISvgRenderer renderer)
{
PushTransforms(renderer);

var fileSystem = SvgEngine.Resolve<IFileSystem>();
var scaleX = renderer.Transform.ScaleX;

var xOffset = renderer.Transform.OffsetX;
var yOffset = renderer.Transform.OffsetY;

var tileRenderer = SvgEngine.Resolve<ITileRendererManager>().GetOrCreateTileRenderer();
var tileRendererManager = SvgEngine.Resolve<ITileRendererManager>();
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);
scaleX, Href);

var image = new SkiaBitmap(skImage);

Expand Down
6 changes: 6 additions & 0 deletions Svg/DeepZoom/Cache/ITileCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ public interface ITileCache : IDisposable
bool TryGetValue(string key, out TileCacheItem item);
void Remove(string key);

/// <summary>
/// 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.
/// </summary>
void DrainPendingDisposals();
}
}
27 changes: 21 additions & 6 deletions Svg/DeepZoom/Cache/TileCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TileCacheItem> _pendingDispose = new();

public TileCache(TileCacheOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions Svg/DeepZoom/Renderer/ITileRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ public interface ITileRenderer : IDisposable

void RenderBitmap(string tileFolderPath, string outputPath, float x, float y, float zoomFactor = 1);

SKBitmap RenderBitmap(Func<string, string, Stream> tileProvider, float offsetX, float offsetY, float zoomFactor = 1);
SKBitmap RenderBitmap(Func<string, string, Stream> 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<SKBitmap> RenderBitmapAsync(Func<string, string, Task<Stream>> tileProvider, float offsetX,
float offsetY, float zoomFactor = 1);
float offsetY, float zoomFactor = 1, string sourceKey = null);

}
}
11 changes: 10 additions & 1 deletion Svg/DeepZoom/Renderer/ITileRendererManager.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
namespace Svg.DeepZoom;
using System;
using System.IO;

namespace Svg.DeepZoom;

public interface ITileRendererManager
{
public ITileRenderer GetOrCreateTileRenderer();

/// <summary>
/// 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.
/// </summary>
Func<string, string, Stream> GetOrCreateZipTileProvider(string zipPath);

public void DisposeTileRenderer();
}
Loading
Loading