From 819d0e32011eb96bf028bb4459687cefc3b379a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 18 Sep 2025 10:29:49 +0200 Subject: [PATCH 1/7] Add support for IBrush in SvgImageExtension --- .../SvgImageExtension.cs | 32 +++++++++++++++---- .../SvgImageExtension.cs | 18 ++++++++++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/Svg.Controls.Avalonia/SvgImageExtension.cs b/src/Svg.Controls.Avalonia/SvgImageExtension.cs index b0f30401c7..fd144cb2e6 100644 --- a/src/Svg.Controls.Avalonia/SvgImageExtension.cs +++ b/src/Svg.Controls.Avalonia/SvgImageExtension.cs @@ -31,14 +31,34 @@ public override object ProvideValue(IServiceProvider serviceProvider) var baseUri = context.BaseUri; var source = SvgSource.Load(path, baseUri); var target = (IProvideValueTarget)serviceProvider.GetService(typeof(IProvideValueTarget))!; - if (target.TargetProperty is AvaloniaProperty property) + var image = new SvgImage { Source = source }; + + if (target.TargetProperty is not AvaloniaProperty property) + { + return image; + } + + if (typeof(IImage).IsAssignableFrom(property.PropertyType)) + { + return image; + } + + if (typeof(IBrush).IsAssignableFrom(property.PropertyType)) { - if (property.PropertyType == typeof(IImage)) + return CreateSvgBrush(image); + } + + return new Image { Source = image }; + } + + private static IBrush CreateSvgBrush(IImage image) + { + return new VisualBrush + { + Visual = new Image { - return new SvgImage { Source = source }; + Source = image } - return new Image { Source = new SvgImage { Source = source } }; - } - return new SvgImage { Source = source }; + }; } } diff --git a/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs b/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs index 9d3d638324..800c255bab 100644 --- a/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs +++ b/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs @@ -39,11 +39,16 @@ public override object ProvideValue(IServiceProvider serviceProvider) return image; } - if (property.PropertyType == typeof(IImage)) + if (typeof(IImage).IsAssignableFrom(property.PropertyType)) { return image; } + if (typeof(IBrush).IsAssignableFrom(property.PropertyType)) + { + return CreateSvgBrush(image); + } + return new Image { Source = image }; } @@ -90,4 +95,15 @@ private static SvgImage CreateSvgImage(SvgSource? source, Control? targetControl return result; } + + private static IBrush CreateSvgBrush(IImage image) + { + return new VisualBrush + { + Visual = new Image + { + Source = image + } + }; + } } From 323c38fd16cef07ac9150e171dc2a0c5f8c450cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 18 Sep 2025 10:29:56 +0200 Subject: [PATCH 2/7] Add samples --- samples/AvaloniaSvgSample/MainWindow.axaml | 7 +++++++ samples/AvaloniaSvgSkiaSample/MainWindow.axaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/samples/AvaloniaSvgSample/MainWindow.axaml b/samples/AvaloniaSvgSample/MainWindow.axaml index 0ad439b3c2..16a8930e0d 100644 --- a/samples/AvaloniaSvgSample/MainWindow.axaml +++ b/samples/AvaloniaSvgSample/MainWindow.axaml @@ -105,6 +105,13 @@ + + + + + + + + + + + + Date: Thu, 18 Sep 2025 10:30:03 +0200 Subject: [PATCH 3/7] Add unit tests --- Svg.Skia.sln | 7 +++++ .../Assets/Icon.svg | 31 +++++++++++++++++++ .../Svg.Controls.Avalonia.UnitTests.csproj | 27 ++++++++++++++++ .../SvgImageTests.cs | 23 ++++++++++++++ .../TestApplication.cs | 24 ++++++++++++++ .../Views/SvgImageBackgroundView.axaml | 7 +++++ .../Views/SvgImageBackgroundView.axaml.cs | 12 +++++++ ...vg.Controls.Skia.Avalonia.UnitTests.csproj | 4 +++ .../SvgImageTests.cs | 30 ++++++++++++------ .../TestApplication.cs | 24 ++++++++++++++ .../Views/SvgImageBackgroundView.axaml | 7 +++++ .../Views/SvgImageBackgroundView.axaml.cs | 12 +++++++ 12 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/Assets/Icon.svg create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/Svg.Controls.Avalonia.UnitTests.csproj create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/TestApplication.cs create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs create mode 100644 tests/Svg.Controls.Skia.Avalonia.UnitTests/TestApplication.cs create mode 100644 tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml create mode 100644 tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs diff --git a/Svg.Skia.sln b/Svg.Skia.sln index 25d26c8a86..b21e3509c7 100644 --- a/Svg.Skia.sln +++ b/Svg.Skia.sln @@ -77,6 +77,8 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Svg.Custom", "src\Svg.Custom\Svg.Custom.csproj", "{CFA46E73-0050-4C57-85CE-6C5868A2483C}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Svg.Controls.Skia.Avalonia.UnitTests", "tests\Svg.Controls.Skia.Avalonia.UnitTests\Svg.Controls.Skia.Avalonia.UnitTests.csproj", "{D4467DCA-494D-4C32-9525-4A9713221A53}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Svg.Controls.Avalonia.UnitTests", "tests\Svg.Controls.Avalonia.UnitTests\Svg.Controls.Avalonia.UnitTests.csproj", "{55A5979A-292D-4A51-A89E-14FCEF5E6792}" +EndProject EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Svg.Model", "src\Svg.Model\Svg.Model.csproj", "{4C970B2C-6C96-445B-B80B-4EFBF803FD5F}" EndProject @@ -157,6 +159,10 @@ Global {D4467DCA-494D-4C32-9525-4A9713221A53}.Debug|Any CPU.Build.0 = Debug|Any CPU {D4467DCA-494D-4C32-9525-4A9713221A53}.Release|Any CPU.ActiveCfg = Release|Any CPU {D4467DCA-494D-4C32-9525-4A9713221A53}.Release|Any CPU.Build.0 = Release|Any CPU + {55A5979A-292D-4A51-A89E-14FCEF5E6792}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {55A5979A-292D-4A51-A89E-14FCEF5E6792}.Debug|Any CPU.Build.0 = Debug|Any CPU + {55A5979A-292D-4A51-A89E-14FCEF5E6792}.Release|Any CPU.ActiveCfg = Release|Any CPU + {55A5979A-292D-4A51-A89E-14FCEF5E6792}.Release|Any CPU.Build.0 = Release|Any CPU {4C970B2C-6C96-445B-B80B-4EFBF803FD5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4C970B2C-6C96-445B-B80B-4EFBF803FD5F}.Debug|Any CPU.Build.0 = Debug|Any CPU {4C970B2C-6C96-445B-B80B-4EFBF803FD5F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -241,6 +247,7 @@ Global {81724F00-B7C3-4E25-B473-C7433BABDC81} = {B65D5B3A-77BE-4AFF-B502-A136B9C932F8} {CFA46E73-0050-4C57-85CE-6C5868A2483C} = {C5FFCF4B-86DC-453E-8006-44EE9EEFEE39} {D4467DCA-494D-4C32-9525-4A9713221A53} = {7863AE7D-FF68-45BF-BA68-6FA0E5604CB7} + {55A5979A-292D-4A51-A89E-14FCEF5E6792} = {7863AE7D-FF68-45BF-BA68-6FA0E5604CB7} {4C970B2C-6C96-445B-B80B-4EFBF803FD5F} = {4C42912C-9F8C-43D9-A4B5-4427F7EC8F18} {29F59C87-EAE6-4DD3-8666-B79BFAF6B34D} = {4C42912C-9F8C-43D9-A4B5-4427F7EC8F18} {223B7A5A-E263-4D40-9A6E-FE31EAE92F45} = {4C42912C-9F8C-43D9-A4B5-4427F7EC8F18} diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Assets/Icon.svg b/tests/Svg.Controls.Avalonia.UnitTests/Assets/Icon.svg new file mode 100644 index 0000000000..382d97f02f --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/Assets/Icon.svg @@ -0,0 +1,31 @@ + + SVG Logo + + + + + + + + + + + + + + + + + + + + + + + SVG + + + + + + diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Svg.Controls.Avalonia.UnitTests.csproj b/tests/Svg.Controls.Avalonia.UnitTests/Svg.Controls.Avalonia.UnitTests.csproj new file mode 100644 index 0000000000..d9696b5e74 --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/Svg.Controls.Avalonia.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + Library + False + enable + Avalonia.Svg.UnitTests + + + + + + + + + + + + + + + + + + + diff --git a/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs b/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs new file mode 100644 index 0000000000..efe73db400 --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs @@ -0,0 +1,23 @@ +using Avalonia.Headless.XUnit; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Svg; +using Avalonia.Svg.UnitTests.Views; +using Xunit; + +namespace Avalonia.Svg.UnitTests; + +public class SvgImageTests +{ + [AvaloniaFact] + public void SvgImageExtension_Returns_VisualBrush_For_Brush_Property() + { + var view = new SvgImageBackgroundView(); + var host = Assert.IsType(view.BackgroundHost); + + var brush = Assert.IsType(host.Background); + var image = Assert.IsType(brush.Visual); + var svgImage = Assert.IsType(image.Source); + Assert.NotNull(svgImage.Source); + } +} diff --git a/tests/Svg.Controls.Avalonia.UnitTests/TestApplication.cs b/tests/Svg.Controls.Avalonia.UnitTests/TestApplication.cs new file mode 100644 index 0000000000..cc86a11e7d --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/TestApplication.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Headless; +using Avalonia.Platform; + +[assembly: Avalonia.Headless.AvaloniaTestApplication(typeof(Avalonia.Svg.UnitTests.SvgControlsAvaloniaTestsAppBuilder))] + +namespace Avalonia.Svg.UnitTests; + +internal static class SvgControlsAvaloniaTestsAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()) + .LogToTrace(); +} + +internal sealed class SvgControlsAvaloniaTestsApp : Application +{ + public override void OnFrameworkInitializationCompleted() + { + AssetLoader.SetDefaultAssembly(typeof(SvgControlsAvaloniaTestsApp).Assembly); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml new file mode 100644 index 0000000000..644fc099bd --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml @@ -0,0 +1,7 @@ + + + diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs new file mode 100644 index 0000000000..8cd6ea7335 --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Avalonia.Svg.UnitTests.Views; + +public partial class SvgImageBackgroundView : UserControl +{ + public SvgImageBackgroundView() + { + InitializeComponent(); + } +} diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Svg.Controls.Skia.Avalonia.UnitTests.csproj b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Svg.Controls.Skia.Avalonia.UnitTests.csproj index 14dfcc9ff9..307b76f3ac 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Svg.Controls.Skia.Avalonia.UnitTests.csproj +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Svg.Controls.Skia.Avalonia.UnitTests.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs index 4491ede805..fcc0bce086 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs @@ -1,24 +1,34 @@ -using System; -using Avalonia.Platform; +using Avalonia.Headless.XUnit; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Svg.Skia; +using Avalonia.Svg.Skia.UnitTests.Views; using Xunit; namespace Avalonia.Svg.Skia.UnitTests; public class SvgImageTests { - [Fact] + [AvaloniaFact] public void SvgImage_Load() { - var uri = new Uri($"avares://Svg.Controls.Skia.Avalonia.UnitTests/Assets/Icon.svg"); - var assetLoader = new StandardAssetLoader(); // AvaloniaLocator.Current.GetService() - - var svgFile = assetLoader.Open(uri); - Assert.NotNull(svgFile); - - var svgSource = SvgSource.LoadFromStream(svgFile); + var uri = new System.Uri($"avares://{typeof(SvgImageTests).Assembly.GetName().Name}/Assets/Icon.svg"); + using var stream = Avalonia.Platform.AssetLoader.Open(uri); + var svgSource = SvgSource.LoadFromStream(stream); Assert.NotNull(svgSource); var svgImage = new SvgImage() { Source = svgSource }; Assert.NotNull(svgImage); } + + [AvaloniaFact] + public void SvgImageExtension_Returns_VisualBrush_For_Brush_Property() + { + var view = new SvgImageBackgroundView(); + var host = Assert.IsType(view.BackgroundHost); + var brush = Assert.IsType(host.Background); + var image = Assert.IsType(brush.Visual); + var svgImage = Assert.IsType(image.Source); + Assert.NotNull(svgImage.Source); + } } diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/TestApplication.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/TestApplication.cs new file mode 100644 index 0000000000..e5fc6fe66b --- /dev/null +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/TestApplication.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Headless; +using Avalonia.Platform; + +[assembly: Avalonia.Headless.AvaloniaTestApplication(typeof(Avalonia.Svg.Skia.UnitTests.SvgControlsSkiaAvaloniaTestsAppBuilder))] + +namespace Avalonia.Svg.Skia.UnitTests; + +internal static class SvgControlsSkiaAvaloniaTestsAppBuilder +{ + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()) + .LogToTrace(); +} + +internal sealed class SvgControlsSkiaAvaloniaTestsApp : Application +{ + public override void OnFrameworkInitializationCompleted() + { + AssetLoader.SetDefaultAssembly(typeof(SvgControlsSkiaAvaloniaTestsApp).Assembly); + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml new file mode 100644 index 0000000000..37d9de1c3b --- /dev/null +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml @@ -0,0 +1,7 @@ + + + diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs new file mode 100644 index 0000000000..58f6340acf --- /dev/null +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgImageBackgroundView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Avalonia.Svg.Skia.UnitTests.Views; + +public partial class SvgImageBackgroundView : UserControl +{ + public SvgImageBackgroundView() + { + InitializeComponent(); + } +} From 4892b8f6790c88ffabaa5f7e67e80bd4e2d7375c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 18 Sep 2025 10:30:06 +0200 Subject: [PATCH 4/7] Update README.md --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 92180ca76d..7724fc5e71 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,14 @@ Install-Package Svg.Controls.Skia.Avalonia ``` +#### Background + +```XAML + +``` + ### CSS styling ```XAML From cf673b46c3c82e13676554d8f789d2207f5da430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 18 Sep 2025 11:12:06 +0200 Subject: [PATCH 5/7] Add SvgBrush --- samples/AvaloniaSvgSample/MainWindow.axaml | 10 ++ .../AvaloniaSvgSkiaSample/MainWindow.axaml | 10 ++ src/Svg.Controls.Avalonia/SvgBrush.cs | 60 ++++++++++++ .../SvgImageExtension.cs | 13 +-- src/Svg.Controls.Skia.Avalonia/SvgBrush.cs | 97 +++++++++++++++++++ .../SvgImageExtension.cs | 13 +-- .../SvgImageTests.cs | 12 +++ .../Views/SvgBrushBackgroundView.axaml | 10 ++ .../Views/SvgBrushBackgroundView.axaml.cs | 12 +++ .../SvgImageTests.cs | 11 +++ .../Views/SvgBrushBackgroundView.axaml | 10 ++ .../Views/SvgBrushBackgroundView.axaml.cs | 12 +++ 12 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 src/Svg.Controls.Avalonia/SvgBrush.cs create mode 100644 src/Svg.Controls.Skia.Avalonia/SvgBrush.cs create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml create mode 100644 tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs create mode 100644 tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml create mode 100644 tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs diff --git a/samples/AvaloniaSvgSample/MainWindow.axaml b/samples/AvaloniaSvgSample/MainWindow.axaml index 16a8930e0d..bd01435bc7 100644 --- a/samples/AvaloniaSvgSample/MainWindow.axaml +++ b/samples/AvaloniaSvgSample/MainWindow.axaml @@ -111,6 +111,16 @@ Background="{SvgImage /Assets/__tiger.svg}" /> + + + + /Assets/__tiger.svg + + + + + + + + + /Assets/__tiger.svg + + + + + +/// Provides an SVG-backed brush that can be declared in XAML resources. +/// +public class SvgBrush : MarkupExtension +{ + /// + /// Gets or sets the SVG resource or file path. + /// + [Content] + public string? Path { get; set; } + + /// + /// Creates a brush instance for a provided image. + /// + /// The image that should be rendered by the brush. + /// A brush that renders the supplied image. + internal static IBrush CreateFromImage(IImage image) + { + return new VisualBrush + { + Visual = new Image + { + Source = image + } + }; + } + + /// + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + if (Path is null) + { + throw new InvalidOperationException("SvgBrush requires a non-null Path."); + } + + var baseUri = serviceProvider.GetContextBaseUri(); + var source = SvgSource.Load(Path, baseUri); + var image = new SvgImage + { + Source = source + }; + + return CreateFromImage(image); + } +} diff --git a/src/Svg.Controls.Avalonia/SvgImageExtension.cs b/src/Svg.Controls.Avalonia/SvgImageExtension.cs index fd144cb2e6..72acdea6a1 100644 --- a/src/Svg.Controls.Avalonia/SvgImageExtension.cs +++ b/src/Svg.Controls.Avalonia/SvgImageExtension.cs @@ -45,20 +45,9 @@ public override object ProvideValue(IServiceProvider serviceProvider) if (typeof(IBrush).IsAssignableFrom(property.PropertyType)) { - return CreateSvgBrush(image); + return SvgBrush.CreateFromImage(image); } return new Image { Source = image }; } - - private static IBrush CreateSvgBrush(IImage image) - { - return new VisualBrush - { - Visual = new Image - { - Source = image - } - }; - } } diff --git a/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs b/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs new file mode 100644 index 0000000000..7bb15b7fb8 --- /dev/null +++ b/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs @@ -0,0 +1,97 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using Avalonia.Metadata; +using Svg.Model; + +namespace Avalonia.Svg.Skia; + +/// +/// Provides an SVG-backed brush that can be declared in XAML resources. +/// +public class SvgBrush : MarkupExtension +{ + /// + /// Gets or sets the SVG resource or file path. + /// + [Content] + public string? Path { get; set; } + + /// + /// Gets or sets the CSS applied when loading the SVG resource. + /// + public string? Css { get; set; } + + /// + /// Gets or sets the current CSS applied when loading the SVG resource. + /// + public string? CurrentCss { get; set; } + + /// + /// Creates a brush instance for a provided image. + /// + /// The image that should be rendered by the brush. + /// A brush that renders the supplied image. + internal static IBrush CreateFromImage(IImage image) + { + return new VisualBrush + { + Visual = new Image + { + Source = image + } + }; + } + + /// + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + if (Path is null) + { + throw new InvalidOperationException("SvgBrush requires a non-null Path."); + } + + var baseUri = serviceProvider.GetContextBaseUri(); + var parameters = CreateParameters(Css, CurrentCss); + var source = SvgSource.Load(Path, baseUri, parameters); + var image = new SvgImage + { + Source = source, + Css = Css, + CurrentCss = CurrentCss + }; + + return CreateFromImage(image); + } + + private static SvgParameters? CreateParameters(string? css, string? currentCss) + { + var combined = CombineCss(css, currentCss); + return string.IsNullOrWhiteSpace(combined) + ? null + : new SvgParameters(null, combined); + } + + private static string? CombineCss(string? css, string? currentCss) + { + if (string.IsNullOrWhiteSpace(css)) + { + return string.IsNullOrWhiteSpace(currentCss) ? null : currentCss; + } + + if (string.IsNullOrWhiteSpace(currentCss)) + { + return css; + } + + return string.Concat(css, ' ', currentCss); + } +} diff --git a/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs b/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs index 800c255bab..00ddfa6e76 100644 --- a/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs +++ b/src/Svg.Controls.Skia.Avalonia/SvgImageExtension.cs @@ -46,7 +46,7 @@ public override object ProvideValue(IServiceProvider serviceProvider) if (typeof(IBrush).IsAssignableFrom(property.PropertyType)) { - return CreateSvgBrush(image); + return SvgBrush.CreateFromImage(image); } return new Image { Source = image }; @@ -95,15 +95,4 @@ private static SvgImage CreateSvgImage(SvgSource? source, Control? targetControl return result; } - - private static IBrush CreateSvgBrush(IImage image) - { - return new VisualBrush - { - Visual = new Image - { - Source = image - } - }; - } } diff --git a/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs b/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs index efe73db400..abeaaff1e1 100644 --- a/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs +++ b/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs @@ -20,4 +20,16 @@ public void SvgImageExtension_Returns_VisualBrush_For_Brush_Property() var svgImage = Assert.IsType(image.Source); Assert.NotNull(svgImage.Source); } + + [AvaloniaFact] + public void SvgBrushResource_Returns_VisualBrush() + { + var view = new SvgBrushBackgroundView(); + var host = Assert.IsType(view.BackgroundHost); + + var brush = Assert.IsType(host.Background); + var image = Assert.IsType(brush.Visual); + var svgImage = Assert.IsType(image.Source); + Assert.NotNull(svgImage.Source); + } } diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml new file mode 100644 index 0000000000..a6fd2aa893 --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml @@ -0,0 +1,10 @@ + + + /Assets/Icon.svg + + + diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs new file mode 100644 index 0000000000..8b8e214b28 --- /dev/null +++ b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Avalonia.Svg.UnitTests.Views; + +public partial class SvgBrushBackgroundView : UserControl +{ + public SvgBrushBackgroundView() + { + InitializeComponent(); + } +} diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs index fcc0bce086..8e4efecb0b 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs @@ -31,4 +31,15 @@ public void SvgImageExtension_Returns_VisualBrush_For_Brush_Property() var svgImage = Assert.IsType(image.Source); Assert.NotNull(svgImage.Source); } + + [AvaloniaFact] + public void SvgBrushResource_Returns_VisualBrush() + { + var view = new SvgBrushBackgroundView(); + var host = Assert.IsType(view.BackgroundHost); + var brush = Assert.IsType(host.Background); + var image = Assert.IsType(brush.Visual); + var svgImage = Assert.IsType(image.Source); + Assert.NotNull(svgImage.Source); + } } diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml new file mode 100644 index 0000000000..10b1969c59 --- /dev/null +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml @@ -0,0 +1,10 @@ + + + /Assets/Icon.svg + + + diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs new file mode 100644 index 0000000000..1585f2935b --- /dev/null +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Avalonia.Svg.Skia.UnitTests.Views; + +public partial class SvgBrushBackgroundView : UserControl +{ + public SvgBrushBackgroundView() + { + InitializeComponent(); + } +} From 5ecebcc887cf7baf5c44010bcb8849915fec9048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 18 Sep 2025 11:39:41 +0200 Subject: [PATCH 6/7] Expanded both brush markup extensions with optional knobs --- src/Svg.Controls.Avalonia/SvgBrush.cs | 133 +++++++++++++++++- src/Svg.Controls.Skia.Avalonia/SvgBrush.cs | 133 +++++++++++++++++- .../SvgImageTests.cs | 23 ++- .../Views/SvgBrushBackgroundView.axaml | 11 +- .../SvgImageTests.cs | 23 ++- .../Views/SvgBrushBackgroundView.axaml | 11 +- 6 files changed, 318 insertions(+), 16 deletions(-) diff --git a/src/Svg.Controls.Avalonia/SvgBrush.cs b/src/Svg.Controls.Avalonia/SvgBrush.cs index b6e8043e93..1cd85c55d1 100644 --- a/src/Svg.Controls.Avalonia/SvgBrush.cs +++ b/src/Svg.Controls.Avalonia/SvgBrush.cs @@ -20,19 +20,130 @@ public class SvgBrush : MarkupExtension public string? Path { get; set; } /// - /// Creates a brush instance for a provided image. + /// Gets or sets the stretch applied to the resulting brush. /// - /// The image that should be rendered by the brush. - /// A brush that renders the supplied image. - internal static IBrush CreateFromImage(IImage image) + public Stretch? Stretch { get; set; } + + /// + /// Gets or sets the horizontal alignment applied to the resulting brush. + /// + public AlignmentX? AlignmentX { get; set; } + + /// + /// Gets or sets the vertical alignment applied to the resulting brush. + /// + public AlignmentY? AlignmentY { get; set; } + + /// + /// Gets or sets the tile mode applied to the resulting brush. + /// + public TileMode? TileMode { get; set; } + + /// + /// Gets or sets the destination rectangle applied to the resulting brush. + /// + public RelativeRect? DestinationRect { get; set; } + + /// + /// Gets or sets the source rectangle applied to the resulting brush. + /// + public RelativeRect? SourceRect { get; set; } + + /// + /// Gets or sets the opacity applied to the resulting brush. + /// + public double? Opacity { get; set; } + + /// + /// Gets or sets the transform applied to the resulting brush. + /// + public Transform? Transform { get; set; } + + /// + /// Gets or sets the transform origin applied to the resulting brush. + /// + public RelativePoint? TransformOrigin { get; set; } + + /// + /// Creates a configured with the supplied image and optional overrides. + /// + /// The SVG image instance rendered by the brush. + /// Optional stretch applied to the brush. + /// Optional horizontal alignment applied to the brush. + /// Optional vertical alignment applied to the brush. + /// Optional tile mode applied to the brush. + /// Optional destination rectangle for the brush content. + /// Optional source rectangle cropping the brush content. + /// Optional opacity multiplier applied to the brush. + /// Optional transform applied to the brush. + /// Optional transform origin applied when is set. + /// A that renders . + internal static IBrush CreateFromImage( + IImage image, + Stretch? stretch = null, + AlignmentX? alignmentX = null, + AlignmentY? alignmentY = null, + TileMode? tileMode = null, + RelativeRect? destinationRect = null, + RelativeRect? sourceRect = null, + double? opacity = null, + Transform? transform = null, + RelativePoint? transformOrigin = null) { - return new VisualBrush + var brush = new VisualBrush { Visual = new Image { Source = image } }; + + if (stretch.HasValue) + { + brush.Stretch = stretch.Value; + } + + if (alignmentX.HasValue) + { + brush.AlignmentX = alignmentX.Value; + } + + if (alignmentY.HasValue) + { + brush.AlignmentY = alignmentY.Value; + } + + if (tileMode.HasValue) + { + brush.TileMode = tileMode.Value; + } + + if (destinationRect.HasValue) + { + brush.DestinationRect = destinationRect.Value; + } + + if (sourceRect.HasValue) + { + brush.SourceRect = sourceRect.Value; + } + + if (opacity.HasValue) + { + brush.Opacity = opacity.Value; + } + + if (transform is not null) + { + brush.Transform = transform; + } + + if (transformOrigin.HasValue) + { + brush.TransformOrigin = transformOrigin.Value; + } + + return brush; } /// @@ -55,6 +166,16 @@ public override object ProvideValue(IServiceProvider serviceProvider) Source = source }; - return CreateFromImage(image); + return CreateFromImage( + image, + Stretch, + AlignmentX, + AlignmentY, + TileMode, + DestinationRect, + SourceRect, + Opacity, + Transform, + TransformOrigin); } } diff --git a/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs b/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs index 7bb15b7fb8..8e5b7426e2 100644 --- a/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs +++ b/src/Svg.Controls.Skia.Avalonia/SvgBrush.cs @@ -31,19 +31,130 @@ public class SvgBrush : MarkupExtension public string? CurrentCss { get; set; } /// - /// Creates a brush instance for a provided image. + /// Gets or sets the stretch applied to the resulting brush. /// - /// The image that should be rendered by the brush. - /// A brush that renders the supplied image. - internal static IBrush CreateFromImage(IImage image) + public Stretch? Stretch { get; set; } + + /// + /// Gets or sets the horizontal alignment applied to the resulting brush. + /// + public AlignmentX? AlignmentX { get; set; } + + /// + /// Gets or sets the vertical alignment applied to the resulting brush. + /// + public AlignmentY? AlignmentY { get; set; } + + /// + /// Gets or sets the tile mode applied to the resulting brush. + /// + public TileMode? TileMode { get; set; } + + /// + /// Gets or sets the destination rectangle applied to the resulting brush. + /// + public RelativeRect? DestinationRect { get; set; } + + /// + /// Gets or sets the source rectangle applied to the resulting brush. + /// + public RelativeRect? SourceRect { get; set; } + + /// + /// Gets or sets the opacity applied to the resulting brush. + /// + public double? Opacity { get; set; } + + /// + /// Gets or sets the transform applied to the resulting brush. + /// + public Transform? Transform { get; set; } + + /// + /// Gets or sets the transform origin applied to the resulting brush. + /// + public RelativePoint? TransformOrigin { get; set; } + + /// + /// Creates a configured with the provided image and optional overrides. + /// + /// The SVG image instance rendered by the brush. + /// Optional stretch applied to the brush. + /// Optional horizontal alignment applied to the brush. + /// Optional vertical alignment applied to the brush. + /// Optional tile mode applied to the brush. + /// Optional destination rectangle for the brush content. + /// Optional source rectangle cropping the brush content. + /// Optional opacity multiplier applied to the brush. + /// Optional transform applied to the brush. + /// Optional transform origin applied when is set. + /// A that renders . + internal static IBrush CreateFromImage( + IImage image, + Stretch? stretch = null, + AlignmentX? alignmentX = null, + AlignmentY? alignmentY = null, + TileMode? tileMode = null, + RelativeRect? destinationRect = null, + RelativeRect? sourceRect = null, + double? opacity = null, + Transform? transform = null, + RelativePoint? transformOrigin = null) { - return new VisualBrush + var brush = new VisualBrush { Visual = new Image { Source = image } }; + + if (stretch.HasValue) + { + brush.Stretch = stretch.Value; + } + + if (alignmentX.HasValue) + { + brush.AlignmentX = alignmentX.Value; + } + + if (alignmentY.HasValue) + { + brush.AlignmentY = alignmentY.Value; + } + + if (tileMode.HasValue) + { + brush.TileMode = tileMode.Value; + } + + if (destinationRect.HasValue) + { + brush.DestinationRect = destinationRect.Value; + } + + if (sourceRect.HasValue) + { + brush.SourceRect = sourceRect.Value; + } + + if (opacity.HasValue) + { + brush.Opacity = opacity.Value; + } + + if (transform is not null) + { + brush.Transform = transform; + } + + if (transformOrigin.HasValue) + { + brush.TransformOrigin = transformOrigin.Value; + } + + return brush; } /// @@ -69,7 +180,17 @@ public override object ProvideValue(IServiceProvider serviceProvider) CurrentCss = CurrentCss }; - return CreateFromImage(image); + return CreateFromImage( + image, + Stretch, + AlignmentX, + AlignmentY, + TileMode, + DestinationRect, + SourceRect, + Opacity, + Transform, + TransformOrigin); } private static SvgParameters? CreateParameters(string? css, string? currentCss) diff --git a/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs b/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs index abeaaff1e1..81f221d279 100644 --- a/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs +++ b/tests/Svg.Controls.Avalonia.UnitTests/SvgImageTests.cs @@ -1,4 +1,5 @@ -using Avalonia.Headless.XUnit; +using Avalonia; +using Avalonia.Headless.XUnit; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Svg; @@ -31,5 +32,25 @@ public void SvgBrushResource_Returns_VisualBrush() var image = Assert.IsType(brush.Visual); var svgImage = Assert.IsType(image.Source); Assert.NotNull(svgImage.Source); + Assert.Equal(Stretch.UniformToFill, brush.Stretch); + Assert.Equal(AlignmentX.Center, brush.AlignmentX); + Assert.Equal(AlignmentY.Bottom, brush.AlignmentY); + Assert.Equal(TileMode.Tile, brush.TileMode); + Assert.Equal(RelativeUnit.Absolute, brush.DestinationRect.Unit); + Assert.Equal(RelativeUnit.Absolute, brush.SourceRect.Unit); + Assert.Equal(0.1, brush.DestinationRect.Rect.X, 6); + Assert.Equal(0.2, brush.DestinationRect.Rect.Y, 6); + Assert.Equal(0.6, brush.DestinationRect.Rect.Width, 6); + Assert.Equal(0.7, brush.DestinationRect.Rect.Height, 6); + Assert.Equal(0.05, brush.SourceRect.Rect.X, 6); + Assert.Equal(0.05, brush.SourceRect.Rect.Y, 6); + Assert.Equal(0.9, brush.SourceRect.Rect.Width, 6); + Assert.Equal(0.9, brush.SourceRect.Rect.Height, 6); + Assert.Equal(0.5, brush.Opacity); + var matrixTransform = Assert.IsType(brush.Transform); + Assert.Equal(new Matrix(1, 0, 0, 1, 10, 20), matrixTransform.Matrix); + Assert.Equal(RelativeUnit.Absolute, brush.TransformOrigin.Unit); + Assert.Equal(0.25, brush.TransformOrigin.Point.X, 6); + Assert.Equal(0.75, brush.TransformOrigin.Point.Y, 6); } } diff --git a/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml index a6fd2aa893..e9405f0acb 100644 --- a/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml +++ b/tests/Svg.Controls.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml @@ -3,7 +3,16 @@ xmlns:svg="clr-namespace:Avalonia.Svg;assembly=Svg.Controls.Avalonia" x:Class="Avalonia.Svg.UnitTests.Views.SvgBrushBackgroundView"> - /Assets/Icon.svg + /Assets/Icon.svg diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs index 8e4efecb0b..735fa3150f 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgImageTests.cs @@ -1,4 +1,5 @@ -using Avalonia.Headless.XUnit; +using Avalonia; +using Avalonia.Headless.XUnit; using Avalonia.Controls; using Avalonia.Media; using Avalonia.Svg.Skia; @@ -41,5 +42,25 @@ public void SvgBrushResource_Returns_VisualBrush() var image = Assert.IsType(brush.Visual); var svgImage = Assert.IsType(image.Source); Assert.NotNull(svgImage.Source); + Assert.Equal(Stretch.UniformToFill, brush.Stretch); + Assert.Equal(AlignmentX.Center, brush.AlignmentX); + Assert.Equal(AlignmentY.Bottom, brush.AlignmentY); + Assert.Equal(TileMode.Tile, brush.TileMode); + Assert.Equal(RelativeUnit.Absolute, brush.DestinationRect.Unit); + Assert.Equal(RelativeUnit.Absolute, brush.SourceRect.Unit); + Assert.Equal(0.1, brush.DestinationRect.Rect.X, 6); + Assert.Equal(0.2, brush.DestinationRect.Rect.Y, 6); + Assert.Equal(0.6, brush.DestinationRect.Rect.Width, 6); + Assert.Equal(0.7, brush.DestinationRect.Rect.Height, 6); + Assert.Equal(0.05, brush.SourceRect.Rect.X, 6); + Assert.Equal(0.05, brush.SourceRect.Rect.Y, 6); + Assert.Equal(0.9, brush.SourceRect.Rect.Width, 6); + Assert.Equal(0.9, brush.SourceRect.Rect.Height, 6); + Assert.Equal(0.5, brush.Opacity); + var matrixTransform = Assert.IsType(brush.Transform); + Assert.Equal(new Matrix(1, 0, 0, 1, 10, 20), matrixTransform.Matrix); + Assert.Equal(RelativeUnit.Absolute, brush.TransformOrigin.Unit); + Assert.Equal(0.25, brush.TransformOrigin.Point.X, 6); + Assert.Equal(0.75, brush.TransformOrigin.Point.Y, 6); } } diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml index 10b1969c59..10d1785ec9 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/Views/SvgBrushBackgroundView.axaml @@ -3,7 +3,16 @@ xmlns:svg="clr-namespace:Avalonia.Svg.Skia;assembly=Svg.Controls.Skia.Avalonia" x:Class="Avalonia.Svg.Skia.UnitTests.Views.SvgBrushBackgroundView"> - /Assets/Icon.svg + /Assets/Icon.svg From ab4b3779ed735b2bd7391c51b52f4919db3bd3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 18 Sep 2025 11:41:36 +0200 Subject: [PATCH 7/7] Update README.md --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 7724fc5e71..38790aa5cc 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,29 @@ Install-Package Svg.Controls.Skia.Avalonia ``` +#### SvgBrush Markup Extension + +Use `SvgBrush` when you want to place an SVG-backed `VisualBrush` in a resource dictionary and reuse it for backgrounds or other brush targets: + +```XAML + + + /Assets/__tiger.svg + + + + +``` + +The optional properties mirror those on `VisualBrush`, so you can tweak layout, tiling, opacity, and transforms directly in XAML while the control takes care of loading and rendering the SVG content. + #### Avalonia Previewer To make controls work with `Avalonia Previewer` please add the following lines to `BuildAvaloniaApp()` method: