diff --git a/src/Svg.Skia/SKPictureExtensions.cs b/src/Svg.Skia/SKPictureExtensions.cs index 0c43eeb991..4cea094419 100644 --- a/src/Svg.Skia/SKPictureExtensions.cs +++ b/src/Svg.Skia/SKPictureExtensions.cs @@ -6,6 +6,9 @@ namespace Svg.Skia; public static class SKPictureExtensions { + private const float DownsampleRasterOversample = 4f; + private const long MaxDownsampleRasterPixels = 16L * 1024L * 1024L; + public static void Draw(this SkiaSharp.SKPicture skPicture, SkiaSharp.SKColor background, float scaleX, float scaleY, SkiaSharp.SKCanvas skCanvas) { skCanvas.Clear(background); @@ -28,6 +31,29 @@ public static void Draw(this SkiaSharp.SKPicture skPicture, SkiaSharp.SKColor ba return null; } + GetRasterRenderScales(skPicture, skImageInfo, scaleX, scaleY, out var renderScaleX, out var renderScaleY); + if (ShouldDownsample(scaleX, scaleY, renderScaleX, renderScaleY)) + { + if (!TryCreateImageInfo(skPicture, renderScaleX, renderScaleY, skColorType, skAlphaType, skColorSpace, out var renderImageInfo)) + { + return null; + } + + using var renderSurface = SkiaSharp.SKSurface.Create(renderImageInfo); + if (renderSurface is null) + { + return null; + } + + Draw(skPicture, background, renderScaleX, renderScaleY, renderSurface.Canvas); + using var renderImage = renderSurface.Snapshot(); + + var downsampledBitmap = new SkiaSharp.SKBitmap(skImageInfo); + using var downsampledCanvas = new SkiaSharp.SKCanvas(downsampledBitmap); + Downsample(renderImage, skImageInfo, downsampledCanvas); + return downsampledBitmap; + } + var skBitmap = new SkiaSharp.SKBitmap(skImageInfo); using var skCanvas = new SkiaSharp.SKCanvas(skBitmap); Draw(skPicture, background, scaleX, scaleY, skCanvas); @@ -41,6 +67,34 @@ public static bool ToImage(this SkiaSharp.SKPicture skPicture, Stream stream, Sk return false; } + GetRasterRenderScales(skPicture, skImageInfo, scaleX, scaleY, out var renderScaleX, out var renderScaleY); + if (ShouldDownsample(scaleX, scaleY, renderScaleX, renderScaleY)) + { + if (!TryCreateImageInfo(skPicture, renderScaleX, renderScaleY, skColorType, skAlphaType, skColorSpace, out var renderImageInfo)) + { + return false; + } + + using var renderSurface = SkiaSharp.SKSurface.Create(renderImageInfo); + if (renderSurface is null) + { + return false; + } + + Draw(skPicture, background, renderScaleX, renderScaleY, renderSurface.Canvas); + using var renderImage = renderSurface.Snapshot(); + + using var downsampledSurface = SkiaSharp.SKSurface.Create(skImageInfo); + if (downsampledSurface is null) + { + return false; + } + + Downsample(renderImage, skImageInfo, downsampledSurface.Canvas); + using var downsampledImage = downsampledSurface.Snapshot(); + return Encode(downsampledImage, stream, format, quality); + } + using var skSurface = SkiaSharp.SKSurface.Create(skImageInfo); if (skSurface is null) { @@ -49,6 +103,11 @@ public static bool ToImage(this SkiaSharp.SKPicture skPicture, Stream stream, Sk Draw(skPicture, background, scaleX, scaleY, skSurface.Canvas); using var skImage = skSurface.Snapshot(); + return Encode(skImage, stream, format, quality); + } + + private static bool Encode(SkiaSharp.SKImage skImage, Stream stream, SkiaSharp.SKEncodedImageFormat format, int quality) + { using var skData = skImage.Encode(format, quality); if (skData is { }) { @@ -160,4 +219,81 @@ private static bool TryCreateImageInfo(this SkiaSharp.SKPicture skPicture, float skImageInfo = new SkiaSharp.SKImageInfo((int)width, (int)height, skColorType, skAlphaType, skColorSpace); return true; } + + private static void GetRasterRenderScales(SkiaSharp.SKPicture skPicture, SkiaSharp.SKImageInfo targetImageInfo, float scaleX, float scaleY, out float renderScaleX, out float renderScaleY) + { + renderScaleX = GetRasterRenderScale(scaleX, float.PositiveInfinity); + renderScaleY = GetRasterRenderScale(scaleY, float.PositiveInfinity); + if (!ShouldDownsample(scaleX, scaleY, renderScaleX, renderScaleY) || FitsRasterBudget(skPicture, renderScaleX, renderScaleY)) + { + return; + } + + renderScaleX = GetRasterRenderScale(scaleX, DownsampleRasterOversample); + renderScaleY = GetRasterRenderScale(scaleY, DownsampleRasterOversample); + if (FitsRasterBudget(skPicture, renderScaleX, renderScaleY)) + { + return; + } + + var targetPixels = (long)targetImageInfo.Width * targetImageInfo.Height; + if (targetPixels <= 0L || targetPixels >= MaxDownsampleRasterPixels) + { + renderScaleX = scaleX; + renderScaleY = scaleY; + return; + } + + var boundedOversample = (float)System.Math.Sqrt((double)MaxDownsampleRasterPixels / targetPixels); + if (boundedOversample < 1f) + { + boundedOversample = 1f; + } + else if (boundedOversample > DownsampleRasterOversample) + { + boundedOversample = DownsampleRasterOversample; + } + + renderScaleX = GetRasterRenderScale(scaleX, boundedOversample); + renderScaleY = GetRasterRenderScale(scaleY, boundedOversample); + } + + private static float GetRasterRenderScale(float scale, float oversample) + { + if (scale >= 1f) + { + return scale; + } + + var oversampledScale = scale * oversample; + return oversampledScale < 1f ? oversampledScale : 1f; + } + + private static bool FitsRasterBudget(SkiaSharp.SKPicture skPicture, float scaleX, float scaleY) + { + var width = skPicture.CullRect.Width * scaleX; + var height = skPicture.CullRect.Height * scaleY; + if (!(width > 0) || !(height > 0)) + { + return false; + } + + return (double)width * height <= MaxDownsampleRasterPixels; + } + + private static bool ShouldDownsample(float scaleX, float scaleY, float renderScaleX, float renderScaleY) + { + return renderScaleX != scaleX || renderScaleY != scaleY; + } + + private static void Downsample(SkiaSharp.SKImage renderImage, SkiaSharp.SKImageInfo targetImageInfo, SkiaSharp.SKCanvas targetCanvas) + { + using var paint = new SkiaSharp.SKPaint + { + IsAntialias = true, + FilterQuality = SkiaSharp.SKFilterQuality.High, + BlendMode = SkiaSharp.SKBlendMode.Src + }; + targetCanvas.DrawImage(renderImage, SkiaSharp.SKRect.Create(0f, 0f, targetImageInfo.Width, targetImageInfo.Height), paint); + } } diff --git a/tests/Svg.Skia.UnitTests/SKSvgTests.cs b/tests/Svg.Skia.UnitTests/SKSvgTests.cs index 323bd11e01..2b89e88e0a 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgTests.cs @@ -155,6 +155,129 @@ public void Save_InheritedCurrentColor_UsesConsumingElementsColor() Assert.Equal((byte)255, pixel.A); } + [Fact] + public void Save_DownscaledMorphologyFilter_RetainsSubpixelInsideStroke() + { + const string pathData = "m228 91.1c-2.5 0.5-8.3 1.6-13 2.5-4.7 0.9-13.8 3.3-20.3 5.5-6.4 2.1-16.5 6.3-22.5 9.3-5.9 3-14.6 8-19.4 11.3-4.8 3.2-12.6 9.2-17.4 13.3-4.9 4.1-12.1 11.3-16.2 16-4.1 4.7-10.7 13.4-14.8 19.5-4 6-9.3 15.3-11.8 20.5-2.5 5.2-6.1 14.2-8 20-2 5.8-4.4 14.8-5.5 20-1.1 5.2-2.5 13.9-3 19.2-0.6 5.4-1.1 12.9-1.1 16.8 0 3.8 0.7 11.6 1.5 17.2 0.9 5.7 2.7 14.8 4.2 20.3 1.4 5.5 4.4 14.7 6.6 20.5 2.2 5.8 7 16.3 10.7 23.5 3.6 7.1 10.2 18.6 14.5 25.5 4.4 6.9 12.7 18.8 18.5 26.5 5.7 7.7 14.9 19.2 20.3 25.5 5.5 6.3 15.1 17.1 21.6 23.9 6.4 6.9 19 19.4 28.1 27.9 9.1 8.5 23.1 21 31.2 27.8 10.3 8.6 15.6 12.4 17.3 12.4 1.4 0 4-1 5.7-2.3 1.8-1.2 8-6.2 13.8-11 5.8-4.9 18.1-15.9 27.4-24.5 9.3-8.7 22.8-22 30-29.7 7.2-7.7 16.9-18.5 21.7-24 4.7-5.5 11.8-14.3 15.9-19.5 4.1-5.2 11.6-15.6 16.6-23 5-7.4 12.2-18.9 15.9-25.5 3.7-6.6 8.6-16.3 11-21.5 2.3-5.2 5.7-13.6 7.4-18.5 1.8-5 4.1-12.6 5.2-17 1.1-4.4 2.7-12.5 3.5-18 0.7-5.5 1.4-13.5 1.4-17.8 0-4.2-0.7-13-1.5-19.5-0.9-6.4-2.7-16.2-4.1-21.7-1.4-5.5-4.1-14.3-6.1-19.5-1.9-5.2-5.7-13.8-8.5-19-2.8-5.2-7.8-13.5-11.2-18.4-3.3-4.9-9.5-12.8-13.6-17.5-4.1-4.7-11.1-11.6-15.5-15.4-4.4-3.7-12.1-9.5-17-12.9-5-3.3-13.7-8.4-19.5-11.3-5.8-2.9-14.6-6.7-19.5-8.4-5-1.8-11.5-3.8-14.5-4.6-3-0.8-10.8-2.3-17.3-3.5-8.8-1.5-15.7-2-28-1.9-8.9 0.1-18.2 0.5-20.7 1z"; + var svgMarkup = $$""" + + + + + + + + + + + + + + + + + + + + + + + + + """; + + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(svgMarkup)); + using var _ = svg.Load(input); + using var output = new MemoryStream(); + + Assert.True(svg.Save( + output, + SkiaSharp.SKColors.Transparent, + scaleX: 38f / 609f, + scaleY: 38f / 609f)); + + output.Position = 0; + using var image = Image.Load(output); + var whitePixels = 0; + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + var pixel = image[x, y]; + if (pixel.A > 64 && pixel.R > 160 && pixel.G > 160 && pixel.B > 160) + { + whitePixels++; + } + } + } + + Assert.Equal(31, image.Width); + Assert.Equal(38, image.Height); + Assert.True(whitePixels > 0, "Expected visible white inside stroke pixels after downscaling."); + } + + [Fact] + public void Save_DownscaledTranslucentBackground_DoesNotCompositeBackgroundTwice() + { + const string svgMarkup = """ + + + """; + + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(svgMarkup)); + using var _ = svg.Load(input); + using var output = new MemoryStream(); + + Assert.True(svg.Save( + output, + new SkiaSharp.SKColor(10, 20, 30, 128), + scaleX: 0.5f, + scaleY: 0.5f)); + + output.Position = 0; + using var image = Image.Load(output); + var pixel = image[25, 25]; + + Assert.Equal(50, image.Width); + Assert.Equal(50, image.Height); + Assert.Equal((byte)10, pixel.R); + Assert.Equal((byte)20, pixel.G); + Assert.Equal((byte)30, pixel.B); + Assert.Equal((byte)128, pixel.A); + } + + [Fact] + public void Save_LargeDownscaledDocument_UsesBoundedIntermediate() + { + const string svgMarkup = """ + + + + """; + + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(svgMarkup)); + using var _ = svg.Load(input); + using var output = new MemoryStream(); + + Assert.True(svg.Save( + output, + SkiaSharp.SKColors.Transparent, + scaleX: 0.001f, + scaleY: 0.001f)); + + output.Position = 0; + using var image = Image.Load(output); + Assert.Equal(100, image.Width); + Assert.Equal(100, image.Height); + Assert.Equal((byte)255, image[50, 50].R); + } + [Fact] public void Load_CurrentColorParameter_ProvidesRootCurrentColor() {