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
136 changes: 136 additions & 0 deletions src/Svg.Skia/SKPictureExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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)
{
Expand All @@ -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 { })
{
Expand Down Expand Up @@ -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);
}
}
123 changes: 123 additions & 0 deletions tests/Svg.Skia.UnitTests/SKSvgTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = $$"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 609" width="500" height="609">
<defs>
<filter id="shadow" style="color-interpolation-filters:sRGB;" x="-100%" y="-100%" width="300%" height="400%">
<feFlood flood-opacity="1" flood-color="rgb(31 40 50 / 1)" result="flood" />
<feComposite in="flood" in2="SourceGraphic" operator="in" result="comp" />
<feOffset dx="3" dy="15" result="offset" />
<feGaussianBlur in="offset" stdDeviation="20" result="blur" />
<feBlend in="SourceGraphic" in2="blur" mode="normal" />
</filter>
<filter id="stroke-inside" x="-50%" y="-50%" width="200%" height="200%">
<feFlood flood-color="#fff" result="inside-color"/>
<feComposite in="inside-color" in2="SourceAlpha" operator="in" result="inside-stroke"/>
<feMorphology in="SourceAlpha" radius="8"/>
<feComposite in="SourceGraphic" operator="in" result="fill-area"/>
<feMerge>
<feMergeNode in="inside-stroke"/>
<feMergeNode in="fill-area"/>
</feMerge>
</filter>
</defs>
<style>
.marker { fill:#000000; fill-opacity:1; filter:url(#stroke-inside); }
.marker-shadow { filter:url(#shadow); stroke:#444; stroke-width:12px; }
</style>
<path class="marker-shadow" d="{{pathData}}"/>
<path class="marker" d="{{pathData}}"/>
</svg>
""";

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<Rgba32>(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 = """
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
</svg>
""";

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<Rgba32>(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 = """
<svg xmlns="http://www.w3.org/2000/svg" width="100000" height="100000" viewBox="0 0 100000 100000">
<rect width="100000" height="100000" fill="#ff0000" />
</svg>
""";

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<Rgba32>(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()
{
Expand Down
Loading