diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3008f87572..3316122742 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -136,11 +136,8 @@ jobs:
- name: Restore
run: dotnet restore
- - name: Build
- run: dotnet build -c Release --no-restore -p:VersionSuffix="${{ env.VERSION_SUFFIX }}"
-
- name: Pack
- run: dotnet pack -c Release --no-build -p:VersionSuffix="${{ env.VERSION_SUFFIX }}" -o artifacts/packages
+ run: dotnet pack -c Release --no-restore -p:VersionSuffix="${{ env.VERSION_SUFFIX }}" -o artifacts/packages
- name: Upload NuGet packages
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7f602a4300..9ec6208846 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -126,11 +126,8 @@ jobs:
- name: Restore
run: dotnet restore
- - name: Build
- run: dotnet build -c Release --no-restore
-
- name: Pack
- run: dotnet pack -c Release --no-build -o artifacts/packages
+ run: dotnet pack -c Release --no-restore -o artifacts/packages
- name: Upload NuGet packages
uses: actions/upload-artifact@v4
diff --git a/Svg.Skia.slnx b/Svg.Skia.slnx
index bb0c633335..a111bfa914 100644
--- a/Svg.Skia.slnx
+++ b/Svg.Skia.slnx
@@ -78,6 +78,7 @@
+
@@ -87,6 +88,7 @@
+
diff --git a/build/SkiaSharp.Native.v3.props b/build/SkiaSharp.Native.v3.props
index aa0caab38b..cdf486fa0f 100644
--- a/build/SkiaSharp.Native.v3.props
+++ b/build/SkiaSharp.Native.v3.props
@@ -1,9 +1,9 @@
-
-
-
-
+
+
+
+
diff --git a/build/SkiaSharp.v3.props b/build/SkiaSharp.v3.props
index 78c953fb67..d9689afb39 100644
--- a/build/SkiaSharp.v3.props
+++ b/build/SkiaSharp.v3.props
@@ -1,6 +1,6 @@
-
+
diff --git a/global.json b/global.json
index 5090070892..c8e2516db5 100644
--- a/global.json
+++ b/global.json
@@ -3,5 +3,8 @@
"version": "10.0.100",
"rollForward": "latestMinor",
"allowPrerelease": true
+ },
+ "msbuild-sdks": {
+ "Uno.Sdk": "6.5.31"
}
}
diff --git a/samples/UnoSvgSkiaSample/App.xaml b/samples/UnoSvgSkiaSample/App.xaml
new file mode 100644
index 0000000000..90dd94642a
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/App.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/App.xaml.cs b/samples/UnoSvgSkiaSample/App.xaml.cs
new file mode 100644
index 0000000000..f9889be23c
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/App.xaml.cs
@@ -0,0 +1,38 @@
+using Uno.Resizetizer;
+
+namespace UnoSvgSkiaSample;
+
+public sealed partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ private Window? MainWindow { get; set; }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ MainWindow = new Window();
+
+ if (MainWindow.Content is not Frame rootFrame)
+ {
+ rootFrame = new Frame();
+ MainWindow.Content = rootFrame;
+ rootFrame.NavigationFailed += OnNavigationFailed;
+ }
+
+ if (rootFrame.Content is null)
+ {
+ rootFrame.Navigate(typeof(MainPage), args.Arguments);
+ }
+
+ MainWindow.SetWindowIcon();
+ MainWindow.Activate();
+ }
+
+ private static void OnNavigationFailed(object sender, NavigationFailedEventArgs e)
+ {
+ throw new InvalidOperationException($"Failed to load {e.SourcePageType.FullName}: {e.Exception}");
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/GlobalUsings.cs b/samples/UnoSvgSkiaSample/GlobalUsings.cs
new file mode 100644
index 0000000000..fcc4e685ba
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/GlobalUsings.cs
@@ -0,0 +1,3 @@
+global using Microsoft.UI.Xaml;
+global using Microsoft.UI.Xaml.Controls;
+global using Microsoft.UI.Xaml.Input;
diff --git a/samples/UnoSvgSkiaSample/MainPage.xaml b/samples/UnoSvgSkiaSample/MainPage.xaml
new file mode 100644
index 0000000000..240008ee0f
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/MainPage.xaml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/MainPage.xaml.cs b/samples/UnoSvgSkiaSample/MainPage.xaml.cs
new file mode 100644
index 0000000000..3bfc1a5e3e
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/MainPage.xaml.cs
@@ -0,0 +1,116 @@
+using System.Linq;
+using Svg;
+
+namespace UnoSvgSkiaSample;
+
+public sealed partial class MainPage : Page
+{
+ private bool _useWarmTheme;
+
+ public MainPage()
+ {
+ InitializeComponent();
+ StyledSvg.CurrentCss = ColdCss;
+ }
+
+ public string InlineSvg =>
+ """
+
+ """;
+
+ public string StyledSvgMarkup =>
+ """
+
+ """;
+
+ public string FilterSvgMarkup =>
+ """
+
+ """;
+
+ private const string ColdCss = ".accent { fill: #2563eb; } .outline { stroke: #0f172a; stroke-width: 4; }";
+ private const string WarmCss = ".accent { fill: #ef4444; } .outline { stroke: #7c2d12; stroke-width: 4; }";
+
+ private void OnSwapThemeClick(object sender, RoutedEventArgs e)
+ {
+ _useWarmTheme = !_useWarmTheme;
+ StyledSvg.CurrentCss = _useWarmTheme ? WarmCss : ColdCss;
+ CssStatusText.Text = $"Current theme: {(_useWarmTheme ? "ember" : "cobalt")}";
+ }
+
+ private void OnZoomInClick(object sender, RoutedEventArgs e)
+ {
+ InteractiveSvg.ZoomToPoint(InteractiveSvg.Zoom * 1.2, new Windows.Foundation.Point(200, 130));
+ }
+
+ private void OnZoomOutClick(object sender, RoutedEventArgs e)
+ {
+ InteractiveSvg.ZoomToPoint(InteractiveSvg.Zoom / 1.2, new Windows.Foundation.Point(200, 130));
+ }
+
+ private void OnPanLeftClick(object sender, RoutedEventArgs e) => InteractiveSvg.PanX -= 20;
+
+ private void OnPanRightClick(object sender, RoutedEventArgs e) => InteractiveSvg.PanX += 20;
+
+ private void OnPanUpClick(object sender, RoutedEventArgs e) => InteractiveSvg.PanY -= 20;
+
+ private void OnPanDownClick(object sender, RoutedEventArgs e) => InteractiveSvg.PanY += 20;
+
+ private void OnResetViewClick(object sender, RoutedEventArgs e)
+ {
+ InteractiveSvg.Zoom = 1.0;
+ InteractiveSvg.PanX = 0.0;
+ InteractiveSvg.PanY = 0.0;
+ HitTestStatusText.Text = "Tap the camera SVG to inspect hit-tested elements.";
+ }
+
+ private void OnInteractiveSvgPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(InteractiveSvg).Position;
+ var hits = InteractiveSvg.HitTestElements(point).ToArray();
+
+ if (hits.Length == 0)
+ {
+ HitTestStatusText.Text = $"No SVG elements hit at ({point.X:F0}, {point.Y:F0}).";
+ return;
+ }
+
+ var labels = hits
+ .Select(static element => !string.IsNullOrWhiteSpace(element.ID) ? $"#{element.ID}" : element.GetType().Name)
+ .Distinct(StringComparer.Ordinal)
+ .Take(3);
+
+ HitTestStatusText.Text = $"Hit {hits.Length} element(s): {string.Join(", ", labels)}";
+ }
+
+ private void OnWireframeChanged(object sender, RoutedEventArgs e)
+ {
+ FilterSvg.Wireframe = sender is CheckBox { IsChecked: true };
+ }
+
+ private void OnDisableFiltersChanged(object sender, RoutedEventArgs e)
+ {
+ FilterSvg.DisableFilters = sender is CheckBox { IsChecked: true };
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/Package.appxmanifest b/samples/UnoSvgSkiaSample/Package.appxmanifest
new file mode 100644
index 0000000000..29885f3927
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Package.appxmanifest
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/Platforms/Android/AndroidManifest.xml b/samples/UnoSvgSkiaSample/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000000..95ae07533a
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/Platforms/Android/Main.Android.cs b/samples/UnoSvgSkiaSample/Platforms/Android/Main.Android.cs
new file mode 100644
index 0000000000..85401cbf01
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/Android/Main.Android.cs
@@ -0,0 +1,16 @@
+using Android.App;
+using Android.Runtime;
+
+namespace UnoSvgSkiaSample.Droid;
+
+[Application(
+ Label = "UnoSvgSkiaSample",
+ LargeHeap = true,
+ HardwareAccelerated = true)]
+public sealed class Application : Microsoft.UI.Xaml.NativeApplication
+{
+ public Application(IntPtr javaReference, JniHandleOwnership transfer)
+ : base(() => new App(), javaReference, transfer)
+ {
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/Platforms/Android/MainActivity.Android.cs b/samples/UnoSvgSkiaSample/Platforms/Android/MainActivity.Android.cs
new file mode 100644
index 0000000000..ae9e791c31
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/Android/MainActivity.Android.cs
@@ -0,0 +1,18 @@
+using Android.App;
+using Android.OS;
+using Android.Views;
+
+namespace UnoSvgSkiaSample.Droid;
+
+[Activity(
+ MainLauncher = true,
+ ConfigurationChanges = global::Uno.UI.ActivityHelper.AllConfigChanges,
+ WindowSoftInputMode = SoftInput.AdjustNothing | SoftInput.StateHidden)]
+public sealed class MainActivity : Microsoft.UI.Xaml.ApplicationActivity
+{
+ protected override void OnCreate(Bundle? savedInstanceState)
+ {
+ global::AndroidX.Core.SplashScreen.SplashScreen.InstallSplashScreen(this);
+ base.OnCreate(savedInstanceState);
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/Platforms/Android/environment.conf b/samples/UnoSvgSkiaSample/Platforms/Android/environment.conf
new file mode 100644
index 0000000000..c63fb909c2
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/Android/environment.conf
@@ -0,0 +1 @@
+MONO_GC_PARAMS=bridge-implementation=new,nursery-size=32m,soft-heap-limit=256m
diff --git a/samples/UnoSvgSkiaSample/Platforms/Desktop/Program.cs b/samples/UnoSvgSkiaSample/Platforms/Desktop/Program.cs
new file mode 100644
index 0000000000..e8cb597687
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/Desktop/Program.cs
@@ -0,0 +1,20 @@
+using Uno.UI.Hosting;
+
+namespace UnoSvgSkiaSample;
+
+internal static class Program
+{
+ [STAThread]
+ private static void Main(string[] args)
+ {
+ var host = UnoPlatformHostBuilder.Create()
+ .App(() => new App())
+ .UseX11()
+ .UseLinuxFrameBuffer()
+ .UseMacOS()
+ .UseWin32()
+ .Build();
+
+ host.Run();
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/Platforms/WebAssembly/LinkerConfig.xml b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/LinkerConfig.xml
new file mode 100644
index 0000000000..ccbfd3e6c1
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/LinkerConfig.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/samples/UnoSvgSkiaSample/Platforms/WebAssembly/Program.cs b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/Program.cs
new file mode 100644
index 0000000000..830a2e07f7
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/Program.cs
@@ -0,0 +1,16 @@
+using Uno.UI.Hosting;
+
+namespace UnoSvgSkiaSample;
+
+public static class Program
+{
+ public static async Task Main(string[] args)
+ {
+ var host = UnoPlatformHostBuilder.Create()
+ .App(() => new App())
+ .UseWebAssembly()
+ .Build();
+
+ await host.RunAsync();
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/Platforms/WebAssembly/manifest.webmanifest b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/manifest.webmanifest
new file mode 100644
index 0000000000..7f347c505b
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/manifest.webmanifest
@@ -0,0 +1,10 @@
+{
+ "background_color": "#ffffff",
+ "description": "Uno sample for Svg.Controls.Skia.Uno.",
+ "display": "standalone",
+ "name": "UnoSvgSkiaSample",
+ "short_name": "UnoSvgSkiaSample",
+ "start_url": "/index.html",
+ "theme_color": "#ffffff",
+ "scope": "/"
+}
diff --git a/samples/UnoSvgSkiaSample/Platforms/WebAssembly/wwwroot/staticwebapp.config.json b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/wwwroot/staticwebapp.config.json
new file mode 100644
index 0000000000..aa2faacb47
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/wwwroot/staticwebapp.config.json
@@ -0,0 +1,8 @@
+{
+ "navigationFallback": {
+ "rewrite": "/index.html",
+ "exclude": [
+ "/_framework/*"
+ ]
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/Platforms/WebAssembly/wwwroot/web.config b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/wwwroot/web.config
new file mode 100644
index 0000000000..ab4b1ed6e5
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/WebAssembly/wwwroot/web.config
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/Platforms/iOS/Entitlements.plist b/samples/UnoSvgSkiaSample/Platforms/iOS/Entitlements.plist
new file mode 100644
index 0000000000..1da3936a90
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/iOS/Entitlements.plist
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/Platforms/iOS/Info.plist b/samples/UnoSvgSkiaSample/Platforms/iOS/Info.plist
new file mode 100644
index 0000000000..2d36cf5631
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/iOS/Info.plist
@@ -0,0 +1,35 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+ UIApplicationSupportsIndirectInputEvents
+
+
+
diff --git a/samples/UnoSvgSkiaSample/Platforms/iOS/Main.iOS.cs b/samples/UnoSvgSkiaSample/Platforms/iOS/Main.iOS.cs
new file mode 100644
index 0000000000..0d20fac785
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/Platforms/iOS/Main.iOS.cs
@@ -0,0 +1,16 @@
+using Uno.UI.Hosting;
+
+namespace UnoSvgSkiaSample.iOS;
+
+public static class EntryPoint
+{
+ public static void Main(string[] args)
+ {
+ var host = UnoPlatformHostBuilder.Create()
+ .App(() => new App())
+ .UseAppleUIKit()
+ .Build();
+
+ host.Run();
+ }
+}
diff --git a/samples/UnoSvgSkiaSample/README.md b/samples/UnoSvgSkiaSample/README.md
new file mode 100644
index 0000000000..dfab4dc086
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/README.md
@@ -0,0 +1,44 @@
+# UnoSvgSkiaSample
+
+Standalone Uno Platform sample for `Svg.Controls.Skia.Uno`.
+
+## Prerequisites
+
+```bash
+uno-check --target desktop --target web --target android --target ios
+```
+
+## Build and publish
+
+Desktop:
+
+```bash
+dotnet build -c Release -f net10.0-desktop
+```
+
+WebAssembly:
+
+```bash
+dotnet publish -c Release -f net10.0-browserwasm
+```
+
+Android:
+
+```bash
+dotnet build -c Release -f net10.0-android
+```
+
+iOS on macOS:
+
+```bash
+dotnet build -c Release -f net10.0-ios
+```
+
+## What the sample covers
+
+- asset loading through `Path`
+- inline SVG text through `Source`
+- reusable `SvgSource` resources
+- runtime CSS restyling
+- zoom, pan, and hit testing
+- wireframe and filter toggles
diff --git a/samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj b/samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj
new file mode 100644
index 0000000000..432eaa457a
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0-android;net10.0-ios;net10.0-browserwasm;net10.0-desktop
+ Exe
+ true
+ UnoSvgSkiaSample
+ com.wieslawsoltes.unosvgskiasample
+ 1.0
+ 1
+ Svg.Skia
+ Uno sample for Svg.Controls.Skia.Uno.
+ enable
+ enable
+
+ SkiaRenderer;
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoSvgSkiaSample/app.manifest b/samples/UnoSvgSkiaSample/app.manifest
new file mode 100644
index 0000000000..2c52edf029
--- /dev/null
+++ b/samples/UnoSvgSkiaSample/app.manifest
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/site/articles/advanced/build-and-package.md b/site/articles/advanced/build-and-package.md
index a663e27b5e..d014f379ee 100644
--- a/site/articles/advanced/build-and-package.md
+++ b/site/articles/advanced/build-and-package.md
@@ -15,6 +15,8 @@ dotnet build Svg.Skia.slnx -c Release
dotnet test Svg.Skia.slnx -c Release
```
+The Uno sample app is intentionally not part of `Svg.Skia.slnx`, so those default commands do not require Uno workloads.
+
## CI workflows
The repository now has dedicated workflows for:
@@ -31,6 +33,7 @@ The repository ships more than one NuGet package. The main runtime packages are:
- `Svg.Skia`
- `Svg.Model`
+- `Svg.Controls.Skia.Uno`
- `Svg.Controls.Avalonia`
- `Svg.Controls.Skia.Avalonia`
- `Skia.Controls.Avalonia`
@@ -49,3 +52,15 @@ The `release.yml` workflow:
- packs the NuGet artifacts,
- pushes packages to NuGet,
- creates a GitHub release with the packaged artifacts attached.
+
+## Uno sample publishing
+
+Use the standalone sample project when validating the Uno control package:
+
+```bash
+uno-check --target desktop --target web --target android --target ios
+dotnet build samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-desktop
+dotnet publish samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-browserwasm
+dotnet build samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-android
+dotnet build samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-ios
+```
diff --git a/site/articles/advanced/menu.yml b/site/articles/advanced/menu.yml
index b9a9ea8c9e..2deb970b5c 100644
--- a/site/articles/advanced/menu.yml
+++ b/site/articles/advanced/menu.yml
@@ -3,4 +3,5 @@ advanced:
- {path: android-vectordrawable-support.md, title: "Android VectorDrawable Support"}
- {path: trimming-aot-and-nativeaot.md, title: "Trimming, AOT, and NativeAOT"}
- {path: testing-and-w3c-suite.md, title: "Testing and W3C Suite"}
+ - {path: uno-sample-publishing.md, title: "Uno Sample Publishing"}
- {path: build-and-package.md, title: "Build and Package"}
diff --git a/site/articles/advanced/uno-sample-publishing.md b/site/articles/advanced/uno-sample-publishing.md
new file mode 100644
index 0000000000..2de0d63dfb
--- /dev/null
+++ b/site/articles/advanced/uno-sample-publishing.md
@@ -0,0 +1,46 @@
+---
+title: "Uno Sample Publishing"
+---
+
+# Uno Sample Publishing
+
+The Uno sample app lives at `samples/UnoSvgSkiaSample` and stays out of `Svg.Skia.slnx`, so the default repository build and test workflow remains workload-free.
+
+## Prerequisite workloads
+
+```bash
+uno-check --target desktop --target web --target android --target ios
+```
+
+## Desktop
+
+```bash
+dotnet build samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-desktop
+```
+
+## WebAssembly
+
+```bash
+dotnet publish samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-browserwasm
+```
+
+## Android
+
+```bash
+dotnet build samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-android
+```
+
+## iOS on macOS
+
+```bash
+dotnet build samples/UnoSvgSkiaSample/UnoSvgSkiaSample.csproj -c Release -f net10.0-ios
+```
+
+## What to verify manually
+
+- asset loading through `Path`
+- inline SVG through `Source`
+- shared `SvgSource` resource usage
+- runtime CSS switching
+- zoom, pan, and hit testing
+- wireframe and filter toggle behavior
diff --git a/site/articles/getting-started/installation.md b/site/articles/getting-started/installation.md
index 919e22c5ab..1f202bb87d 100644
--- a/site/articles/getting-started/installation.md
+++ b/site/articles/getting-started/installation.md
@@ -12,6 +12,12 @@ Core renderer:
dotnet add package Svg.Skia
```
+Uno control package:
+
+```bash
+dotnet add package Svg.Controls.Skia.Uno
+```
+
Avalonia controls backed by Skia:
```bash
@@ -68,6 +74,12 @@ The local docs pipeline also uses a .NET tool manifest for Lunet:
dotnet tool restore
```
+The standalone Uno sample app additionally needs Uno workloads configured through `uno-check`, but the default repository solution does not:
+
+```bash
+uno-check --target desktop --target web --target android --target ios
+```
+
## Project workflow commands
Formatting:
diff --git a/site/articles/getting-started/menu.yml b/site/articles/getting-started/menu.yml
index bce7768865..34158f21dd 100644
--- a/site/articles/getting-started/menu.yml
+++ b/site/articles/getting-started/menu.yml
@@ -3,4 +3,5 @@ getting_started:
- {path: overview.md, title: "Overview"}
- {path: installation.md, title: "Installation"}
- {path: quickstart-loading-and-rendering.md, title: "Quickstart: Loading and Rendering"}
+ - {path: quickstart-uno.md, title: "Quickstart: Uno"}
- {path: quickstart-avalonia.md, title: "Quickstart: Avalonia"}
diff --git a/site/articles/getting-started/overview.md b/site/articles/getting-started/overview.md
index 50cca7e516..0b9e402404 100644
--- a/site/articles/getting-started/overview.md
+++ b/site/articles/getting-started/overview.md
@@ -12,6 +12,7 @@ Svg.Skia is a repository, not just a single package. The main entry points are:
| `Svg.Model` | You need the intermediate picture-recording model or SVG-related helper types. | `ShimSkiaSharp` command model | [Svg.Model](../packages/svg-model) |
| `Svg.Custom` | You want the underlying SVG DOM used by the renderer. | `SvgDocument`, `SvgElement`, parser APIs | [Svg.Custom](../packages/svg-custom) |
| `ShimSkiaSharp` | You want the cloneable drawing-command model directly. | `SKPicture`, `SKCanvas`, `SKPath`, `SKPaint` | [ShimSkiaSharp](../packages/shim-skiasharp) |
+| `Svg.Controls.Skia.Uno` | You want Uno controls that render through the Skia-backed pipeline. | `Svg`, `SvgSource`, hit testing, zoom/pan | [Svg.Controls.Skia.Uno](../packages/svg-controls-skia-uno) |
| `Svg.Controls.Skia.Avalonia` | You want Avalonia controls that render through the Skia-backed pipeline. | `Svg`, `SvgImage`, `SvgSource`, `SvgResource` | [Svg.Controls.Skia.Avalonia](../packages/svg-controls-skia-avalonia) |
| `Svg.Controls.Avalonia` | You want Avalonia controls without depending on the Skia-backed Avalonia renderer path. | `Svg`, `SvgImage`, `SvgSource`, `SvgResource` | [Svg.Controls.Avalonia](../packages/svg-controls-avalonia) |
| `Skia.Controls.Avalonia` | You need general-purpose `SKCanvas`, `SKPicture`, `SKBitmap`, or `SKPath` controls in Avalonia. | `SKCanvasControl`, `SKPictureImage`, and related controls | [Skia.Controls.Avalonia](../packages/skia-controls-avalonia) |
@@ -33,6 +34,10 @@ Start with `Svg.Controls.Skia.Avalonia` when the app is already on Avalonia plus
Choose `Svg.Controls.Avalonia` when you want the same SVG concepts exposed through the Avalonia drawing stack instead.
+### Uno application
+
+Start with `Svg.Controls.Skia.Uno` when the app is on Uno Platform and you want direct `SKCanvasElement` rendering plus async asset loading, hit testing, and viewport controls.
+
### Embedded editor
Start with `Svg.Editor.Skia.Avalonia` when the target is an editable SVG workspace rather than a viewer-only control.
diff --git a/site/articles/getting-started/quickstart-uno.md b/site/articles/getting-started/quickstart-uno.md
new file mode 100644
index 0000000000..29a51b98e5
--- /dev/null
+++ b/site/articles/getting-started/quickstart-uno.md
@@ -0,0 +1,74 @@
+---
+title: "Quickstart: Uno"
+---
+
+# Quickstart: Uno
+
+## Install
+
+```bash
+dotnet add package Svg.Controls.Skia.Uno
+```
+
+## Render a packaged asset
+
+```xml
+
+
+
+```
+
+## Render inline SVG text
+
+```xml
+
+```
+
+```csharp
+public string InlineSvg =>
+ """
+
+ """;
+```
+
+## Reuse one `SvgSource`
+
+```xml
+
+
+
+
+
+```
+
+## Apply runtime CSS
+
+```csharp
+MySvg.CurrentCss = ".accent { fill: #ef4444; }";
+```
+
+## Interactive features
+
+The Uno control also exposes:
+
+- `Wireframe`
+- `DisableFilters`
+- `Zoom`
+- `PanX`
+- `PanY`
+- `ZoomToPoint(...)`
+- `TryGetPicturePoint(...)`
+- `HitTestElements(...)`
+
+## Next steps
+
+- [Svg.Controls.Skia.Uno](../packages/svg-controls-skia-uno)
+- [Uno Svg Control](../xaml/uno-svg-control)
+- [Uno Sample Publishing](../advanced/uno-sample-publishing)
diff --git a/site/articles/packages/menu.yml b/site/articles/packages/menu.yml
index 20017a813a..7ca5d50b09 100644
--- a/site/articles/packages/menu.yml
+++ b/site/articles/packages/menu.yml
@@ -4,6 +4,7 @@ packages:
- {path: svg-model.md, title: "Svg.Model"}
- {path: svg-custom.md, title: "Svg.Custom"}
- {path: shim-skiasharp.md, title: "ShimSkiaSharp"}
+ - {path: svg-controls-skia-uno.md, title: "Svg.Controls.Skia.Uno"}
- {path: svg-controls-skia-avalonia.md, title: "Svg.Controls.Skia.Avalonia"}
- {path: svg-controls-avalonia.md, title: "Svg.Controls.Avalonia"}
- {path: skia-controls-avalonia.md, title: "Skia.Controls.Avalonia"}
diff --git a/site/articles/packages/readme.md b/site/articles/packages/readme.md
index 9eb1f41f79..35dc64dbfd 100644
--- a/site/articles/packages/readme.md
+++ b/site/articles/packages/readme.md
@@ -17,10 +17,11 @@ Packaged tools such as `Svg.Skia.Converter` and `svgc` stay documented under [Sa
| `Svg.Custom` | You want the underlying SVG DOM and parser that the renderer consumes. | [Svg.Custom](svg-custom) |
| `ShimSkiaSharp` | You need a cloneable command-model equivalent of key SkiaSharp drawing primitives. | [ShimSkiaSharp](shim-skiasharp) |
-## Avalonia UI packages
+## UI packages
| Package | Start here when | Guide |
| --- | --- | --- |
+| `Svg.Controls.Skia.Uno` | You want Uno Platform SVG controls backed by `Svg.Skia` and the live Skia canvas. | [Svg.Controls.Skia.Uno](svg-controls-skia-uno) |
| `Svg.Controls.Skia.Avalonia` | You want the richest Avalonia SVG integration, backed by `Svg.Skia` and real `SkiaSharp.SKPicture` output. | [Svg.Controls.Skia.Avalonia](svg-controls-skia-avalonia) |
| `Svg.Controls.Avalonia` | You want the same high-level Avalonia SVG concepts but rendered through the Avalonia drawing stack. | [Svg.Controls.Avalonia](svg-controls-avalonia) |
| `Skia.Controls.Avalonia` | You want reusable Avalonia controls and `IImage` wrappers for raw SkiaSharp content, with or without SVG. | [Skia.Controls.Avalonia](skia-controls-avalonia) |
@@ -45,6 +46,7 @@ Packaged tools such as `Svg.Skia.Converter` and `svgc` stay documented under [Sa
## Choosing quickly
- Choose `Svg.Skia` for direct runtime rendering and export.
+- Choose `Svg.Controls.Skia.Uno` for Uno Platform usage on the Skia-backed path.
- Choose `Svg.Controls.Skia.Avalonia` for interactive Avalonia usage on the Skia-backed path.
- Choose `Svg.Editor.Skia.Avalonia` when you want a reusable SVG editor instead of only a viewer/control package.
- Choose `Svg.Editor.Avalonia`, `Svg.Editor.Skia`, `Svg.Editor.Svg`, and `Svg.Editor.Core` when you need only parts of that editor stack.
diff --git a/site/articles/packages/svg-controls-skia-uno.md b/site/articles/packages/svg-controls-skia-uno.md
new file mode 100644
index 0000000000..f2f1e5dc50
--- /dev/null
+++ b/site/articles/packages/svg-controls-skia-uno.md
@@ -0,0 +1,80 @@
+---
+title: "Svg.Controls.Skia.Uno"
+---
+
+# Svg.Controls.Skia.Uno
+
+`Svg.Controls.Skia.Uno` brings the Skia-backed SVG control model to Uno Platform. It wraps `Svg.Skia`, renders directly through `Uno.WinUI.Graphics2DSK.SKCanvasElement`, and keeps the same control-focused API shape as the Avalonia Skia package where that maps cleanly to Uno.
+
+## Install
+
+```bash
+dotnet add package Svg.Controls.Skia.Uno
+```
+
+## Choose this package when
+
+- your app uses Uno Platform and wants the fastest SVG path through the live Skia canvas,
+- you want an `Svg` control with `Path`, `Source`, `SvgSource`, `Stretch`, `StretchDirection`, `EnableCache`, `Wireframe`, `DisableFilters`, `Zoom`, `PanX`, and `PanY`,
+- you need control-coordinate hit testing through `TryGetPicturePoint(...)` and `HitTestElements(...)`,
+- you want reusable `SvgSource` resources that can be cloned and restyled per control.
+
+## Main types
+
+| Type | Role |
+| --- | --- |
+| `Uno.Svg.Skia.Svg` | Uno control for direct SVG display on `SKCanvasElement` |
+| `Uno.Svg.Skia.SvgSource` | Reusable, cloneable, reloadable source object |
+| `Uno.Svg.Skia.StretchDirection` | Uno-side equivalent of the Avalonia stretch-direction API |
+
+## Basic XAML usage
+
+```xml
+
+
+
+```
+
+## Reusable `SvgSource` resources
+
+```xml
+
+
+
+
+
+```
+
+The control clones an external `SvgSource` before applying per-control CSS, wireframe, or filter settings, so one shared resource can safely back multiple controls with different runtime styling.
+
+## Async path loading
+
+Uno resource and network loading is async-first:
+
+```csharp
+var source = await SvgSource.LoadAsync(
+ "/Assets/__tiger.svg",
+ parameters: new SvgParameters(null, ".accent { fill: #2563eb; }"));
+
+await source.ReLoadAsync(new SvgParameters(null, ".accent { fill: #ef4444; }"));
+```
+
+Use the synchronous loaders for inline SVG strings, `Stream`, and `SvgDocument` inputs.
+
+## What differs from the Avalonia package
+
+- `SvgImage`, `SvgResource`, and markup extensions are not part of the Uno package in v1.
+- The Uno replacement for those scenarios is `Svg` plus reusable `SvgSource` resources.
+- The package is Skia-renderer-only and does not add a native-renderer fallback.
+
+## Related docs
+
+- [Quickstart: Uno](../getting-started/quickstart-uno)
+- [Uno Svg Control](../xaml/uno-svg-control)
+- [Uno Sample Publishing](../advanced/uno-sample-publishing)
+- [Svg.Controls.Skia.Avalonia](svg-controls-skia-avalonia)
diff --git a/site/articles/readme.md b/site/articles/readme.md
index 60723cdd03..afcf622f09 100644
--- a/site/articles/readme.md
+++ b/site/articles/readme.md
@@ -8,6 +8,7 @@ Svg.Skia spans a few related areas:
- `Svg.Skia` and `Svg.Model` handle SVG parsing, picture-model generation, SkiaSharp output, and model mutation.
- `Svg.Controls.Avalonia` and `Svg.Controls.Skia.Avalonia` expose Avalonia controls, images, and brush helpers.
+- `Svg.Controls.Skia.Uno` exposes a Skia-backed Uno control and reusable `SvgSource` resources.
- `Svg.Editor.*` exposes the reusable AvalonDraw editor stack, from document/session services up to the interactive Avalonia workspace.
- `Skia.Controls.Avalonia` hosts general-purpose Skia controls for Avalonia.
- `Svg.CodeGen.Skia`, `Svg.SourceGenerator.Skia`, `svgc`, and `Svg.Skia.Converter` cover generated code and CLI workflows.
@@ -20,7 +21,7 @@ Svg.Skia spans a few related areas:
3. [Editor](editor) when the goal is embedding or composing the reusable SVG editor stack.
4. [Concepts](concepts) to understand how files, models, pictures, and Avalonia resources relate.
5. [Guides](guides) for scenario-focused tasks such as exporting images, hit testing, or generating code.
-6. [XAML Usage](xaml) when the primary integration point is Avalonia.
+6. [XAML Usage](xaml) when the primary integration point is Avalonia or Uno.
7. [Reference](reference) for package maps, samples, licensing, and the docs pipeline.
## Generated API
diff --git a/site/articles/reference/api-coverage-index.md b/site/articles/reference/api-coverage-index.md
index 6ebb979599..77a1f03510 100644
--- a/site/articles/reference/api-coverage-index.md
+++ b/site/articles/reference/api-coverage-index.md
@@ -11,6 +11,7 @@ The generated API reference under `/api` is built from these projects:
- `../src/Svg.Custom/Svg.Custom.csproj`
- `../src/Svg.Controls.Avalonia/Svg.Controls.Avalonia.csproj`
- `../src/Svg.Controls.Skia.Avalonia/Svg.Controls.Skia.Avalonia.csproj`
+- `../src/Svg.Controls.Skia.Uno/Svg.Controls.Skia.Uno.csproj`
- `../src/Skia.Controls.Avalonia/Skia.Controls.Avalonia.csproj`
- `../src/Svg.Editor.Core/Svg.Editor.Core.csproj`
- `../src/Svg.Editor.Svg/Svg.Editor.Svg.csproj`
@@ -28,6 +29,8 @@ Current API settings:
- target framework override: `netstandard2.0`
- output path: `/api`
+The Uno control project uses a per-project override of `TargetFramework=net10.0` because it does not target `netstandard2.0`.
+
## Why `netstandard2.0`
This repository mixes:
@@ -47,6 +50,6 @@ Using `netstandard2.0` as the documentation build target keeps the API site alig
To keep the authored docs and generated API aligned:
1. Add the project to `site/config.scriban` under `api.dotnet.projects`.
-2. Add any new Avalonia or external assembly xrefs under `api.dotnet.external_apis` if the public API links out to assemblies that are not already covered.
+2. Add any new Avalonia, Uno, or external assembly xrefs under `api.dotnet.external_apis` if the public API links out to assemblies that are not already covered.
3. Update [Packages and Namespaces](packages-and-namespaces) and the package article under `site/articles/packages/`.
4. Rebuild the site with `./build-docs.sh`.
diff --git a/site/articles/reference/packages-and-namespaces.md b/site/articles/reference/packages-and-namespaces.md
index 326330987f..70ffe639c5 100644
--- a/site/articles/reference/packages-and-namespaces.md
+++ b/site/articles/reference/packages-and-namespaces.md
@@ -11,6 +11,7 @@ title: "Packages and Namespaces"
| [Svg.Skia](../packages/svg-skia) | `Svg.Skia` | Core runtime renderer and export helpers |
| [Svg.Model](../packages/svg-model) | `Svg.Model` | Picture model, parameters, and services |
| [Svg.Custom](../packages/svg-custom) | `Svg` | Vendored SVG document model package |
+| [Svg.Controls.Skia.Uno](../packages/svg-controls-skia-uno) | `Uno.Svg.Skia` | Skia-backed Uno control and reusable `SvgSource` |
| [Svg.Controls.Skia.Avalonia](../packages/svg-controls-skia-avalonia) | `Avalonia.Svg.Skia` | Skia-backed Avalonia controls, images, resources |
| [Svg.Controls.Avalonia](../packages/svg-controls-avalonia) | `Avalonia.Svg` | Avalonia drawing-stack controls, images, resources |
| [Skia.Controls.Avalonia](../packages/skia-controls-avalonia) | `Avalonia.Controls.Skia` | General-purpose Avalonia Skia controls |
diff --git a/site/articles/reference/samples-and-tools.md b/site/articles/reference/samples-and-tools.md
index efd7dce125..69c16b8faf 100644
--- a/site/articles/reference/samples-and-tools.md
+++ b/site/articles/reference/samples-and-tools.md
@@ -8,6 +8,7 @@ The `samples/` directory covers both end-user scenarios and repository-internal
## Avalonia samples
+- `UnoSvgSkiaSample`: standalone Uno Platform sample for `Svg.Controls.Skia.Uno`, including `Path`, `Source`, `SvgSource`, runtime CSS changes, hit testing, zoom/pan, and wireframe/filter toggles.
- `AvaloniaSvgSkiaSample`: end-to-end sample for `Avalonia.Svg.Skia`, including `Svg`, `SvgImage`, `SvgSource`, resource usage, and draw-control integration.
- `AvaloniaSvgSkiaStylingSample`: CSS-based restyling and pointer-over behavior.
- `AvaloniaSvgSample`: equivalent non-Skia Avalonia stack sample.
diff --git a/site/articles/xaml/menu.yml b/site/articles/xaml/menu.yml
index be96386d89..4a563fb46c 100644
--- a/site/articles/xaml/menu.yml
+++ b/site/articles/xaml/menu.yml
@@ -1,6 +1,7 @@
xaml:
- {path: readme.md, title: "XAML Usage"}
- {path: overview.md, title: "Overview"}
+ - {path: uno-svg-control.md, title: "Uno Svg Control"}
- {path: svg-control-and-svgimage.md, title: "Svg Control and SvgImage"}
- {path: svgresource-and-brushes.md, title: "SvgResource and Brushes"}
- {path: styling-and-previewer.md, title: "Styling and Previewer"}
diff --git a/site/articles/xaml/overview.md b/site/articles/xaml/overview.md
index 64991bd8f0..f5555da9c7 100644
--- a/site/articles/xaml/overview.md
+++ b/site/articles/xaml/overview.md
@@ -4,7 +4,15 @@ title: "Overview"
# Overview
-## Two SVG stacks
+## UI SVG stacks
+
+### `Uno.Svg.Skia`
+
+This package wraps the `Svg.Skia` runtime renderer for Uno Platform. Use it when:
+
+- you want Uno XAML integration through `SKCanvasElement`,
+- you need `SvgSource` resources with async asset loading,
+- you want `HitTestElements(...)`, `TryGetPicturePoint(...)`, zoom, pan, wireframe, or filter toggles.
### `Avalonia.Svg.Skia`
@@ -24,7 +32,11 @@ This package exposes a similar surface but draws through Avalonia's own drawing
## Shared concepts
-Both packages provide:
+The Uno and Avalonia Skia-backed packages all provide an `Svg` control and reusable `SvgSource`.
+
+The Avalonia packages additionally provide `SvgImage`, markup extensions, and brush helpers.
+
+The Avalonia packages provide:
- `Svg` control,
- `SvgImage`,
@@ -36,6 +48,7 @@ The namespaces differ:
| Package | Namespace |
| --- | --- |
+| `Svg.Controls.Skia.Uno` | `Uno.Svg.Skia` |
| `Svg.Controls.Skia.Avalonia` | `Avalonia.Svg.Skia` |
| `Svg.Controls.Avalonia` | `Avalonia.Svg` |
diff --git a/site/articles/xaml/uno-svg-control.md b/site/articles/xaml/uno-svg-control.md
new file mode 100644
index 0000000000..501a7e56f4
--- /dev/null
+++ b/site/articles/xaml/uno-svg-control.md
@@ -0,0 +1,65 @@
+---
+title: "Uno Svg Control"
+---
+
+# Uno Svg Control
+
+## Namespace
+
+```xml
+xmlns:svg="using:Uno.Svg.Skia"
+```
+
+## Core control properties
+
+The Uno `Svg` control keeps the control-facing API close to the Avalonia Skia package:
+
+- `SvgSource`
+- `Path`
+- `Source`
+- `Stretch`
+- `StretchDirection`
+- `EnableCache`
+- `Wireframe`
+- `DisableFilters`
+- `Zoom`
+- `PanX`
+- `PanY`
+- `Css`
+- `CurrentCss`
+
+## Reusable resource example
+
+```xml
+
+
+
+
+
+```
+
+## Inline source example
+
+```xml
+
+```
+
+## Hit testing
+
+```csharp
+var point = e.GetCurrentPoint(MySvg).Position;
+var hits = MySvg.HitTestElements(point);
+```
+
+`HitTestElements(...)` accepts Uno control coordinates and maps them back into picture coordinates using the current stretch, zoom, and pan state.
+
+## Current v1 limits
+
+- no `SvgImage`
+- no brush/resource markup extension equivalent
+- no native-renderer fallback
+
+For those scenarios in Uno, keep the SVG in a reusable `SvgSource` and render it through one or more `Svg` controls.
diff --git a/site/config.scriban b/site/config.scriban
index 2245ef248c..92eeb8761e 100644
--- a/site/config.scriban
+++ b/site/config.scriban
@@ -6,7 +6,7 @@ template_theme_default_mode = "system"
site_project_baseurl = "https://wieslawsoltes.github.io"
site_project_basepath = environment == "dev" ? "" : "/Svg.Skia"
site_project_name = "Svg.Skia"
-site_project_description = "SVG rendering, Avalonia controls, generated code, and conversion tooling backed by SkiaSharp."
+site_project_description = "SVG rendering, Avalonia and Uno controls, generated code, and conversion tooling backed by SkiaSharp."
site_project_logo_path = "/images/logo.svg"
site_project_social_banner_path = "/images/demo.png"
site_project_package_id = "Svg.Skia"
@@ -78,6 +78,7 @@ with api.dotnet
{ name: "Svg.Custom", path: "../src/Svg.Custom/Svg.Custom.csproj" },
{ name: "Svg.Controls.Avalonia", path: "../src/Svg.Controls.Avalonia/Svg.Controls.Avalonia.csproj" },
{ name: "Svg.Controls.Skia.Avalonia", path: "../src/Svg.Controls.Skia.Avalonia/Svg.Controls.Skia.Avalonia.csproj" },
+ { name: "Svg.Controls.Skia.Uno", path: "../src/Svg.Controls.Skia.Uno/Svg.Controls.Skia.Uno.csproj", properties: { TargetFramework: "net10.0" } },
{ name: "Skia.Controls.Avalonia", path: "../src/Skia.Controls.Avalonia/Skia.Controls.Avalonia.csproj" },
{ name: "Svg.Editor.Core", path: "../src/Svg.Editor.Core/Svg.Editor.Core.csproj" },
{ name: "Svg.Editor.Svg", path: "../src/Svg.Editor.Svg/Svg.Editor.Svg.csproj" },
diff --git a/src/Svg.Controls.Skia.Uno/Properties/AssemblyInfo.cs b/src/Svg.Controls.Skia.Uno/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..8fe84def6f
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Svg.Controls.Skia.Uno.UnitTests")]
diff --git a/src/Svg.Controls.Skia.Uno/StretchDirection.cs b/src/Svg.Controls.Skia.Uno/StretchDirection.cs
new file mode 100644
index 0000000000..8e207622db
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/StretchDirection.cs
@@ -0,0 +1,8 @@
+namespace Uno.Svg.Skia;
+
+public enum StretchDirection
+{
+ UpOnly,
+ DownOnly,
+ Both
+}
diff --git a/src/Svg.Controls.Skia.Uno/Svg.Controls.Skia.Uno.csproj b/src/Svg.Controls.Skia.Uno/Svg.Controls.Skia.Uno.csproj
new file mode 100644
index 0000000000..de21675945
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/Svg.Controls.Skia.Uno.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Library
+ net10.0
+ true
+ true
+ enable
+ enable
+ CS1591
+ true
+ Uno.Svg.Skia
+ Svg.Controls.Skia.Uno
+ SVG controls for Uno Platform backed by Svg.Skia and SKCanvasElement.
+ MIT
+ svg;skia;skiasharp;uno;winui;xaml;control;rendering
+
+ SkiaRenderer;
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Svg.Controls.Skia.Uno/Svg.cs b/src/Svg.Controls.Skia.Uno/Svg.cs
new file mode 100644
index 0000000000..8d537e2e40
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/Svg.cs
@@ -0,0 +1,639 @@
+using System.Diagnostics;
+using System.Numerics;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Markup;
+using Microsoft.UI.Xaml.Media;
+using Svg;
+using Svg.Model;
+using Svg.Skia;
+using Uno.WinUI.Graphics2DSK;
+using Windows.Foundation;
+using ShimPoint = ShimSkiaSharp.SKPoint;
+using SkiaAutoCanvasRestore = SkiaSharp.SKAutoCanvasRestore;
+using SkiaCanvas = SkiaSharp.SKCanvas;
+using SkiaMatrix = SkiaSharp.SKMatrix;
+using SkiaPicture = SkiaSharp.SKPicture;
+using SkiaRect = SkiaSharp.SKRect;
+
+namespace Uno.Svg.Skia;
+
+[ContentProperty(Name = nameof(Path))]
+public sealed class Svg : SKCanvasElement
+{
+ private SvgSource? _svg;
+ private Dictionary? _cache;
+ private CancellationTokenSource? _pendingLoadCts;
+ private long _loadVersion;
+
+ public static readonly DependencyProperty SvgSourceProperty =
+ DependencyProperty.Register(
+ nameof(SvgSource),
+ typeof(SvgSource),
+ typeof(Svg),
+ new PropertyMetadata(null, OnSourcePropertyChanged));
+
+ public static readonly DependencyProperty PathProperty =
+ DependencyProperty.Register(
+ nameof(Path),
+ typeof(string),
+ typeof(Svg),
+ new PropertyMetadata(null, OnSourcePropertyChanged));
+
+ public static readonly DependencyProperty SourceProperty =
+ DependencyProperty.Register(
+ nameof(Source),
+ typeof(string),
+ typeof(Svg),
+ new PropertyMetadata(null, OnSourcePropertyChanged));
+
+ public static readonly DependencyProperty StretchProperty =
+ DependencyProperty.Register(
+ nameof(Stretch),
+ typeof(Stretch),
+ typeof(Svg),
+ new PropertyMetadata(Stretch.Uniform, OnLayoutPropertyChanged));
+
+ public static readonly DependencyProperty StretchDirectionProperty =
+ DependencyProperty.Register(
+ nameof(StretchDirection),
+ typeof(StretchDirection),
+ typeof(Svg),
+ new PropertyMetadata(StretchDirection.Both, OnLayoutPropertyChanged));
+
+ public static readonly DependencyProperty EnableCacheProperty =
+ DependencyProperty.Register(
+ nameof(EnableCache),
+ typeof(bool),
+ typeof(Svg),
+ new PropertyMetadata(false, OnEnableCachePropertyChanged));
+
+ public static readonly DependencyProperty WireframeProperty =
+ DependencyProperty.Register(
+ nameof(Wireframe),
+ typeof(bool),
+ typeof(Svg),
+ new PropertyMetadata(false, OnRenderOptionPropertyChanged));
+
+ public static readonly DependencyProperty DisableFiltersProperty =
+ DependencyProperty.Register(
+ nameof(DisableFilters),
+ typeof(bool),
+ typeof(Svg),
+ new PropertyMetadata(false, OnRenderOptionPropertyChanged));
+
+ public static readonly DependencyProperty ZoomProperty =
+ DependencyProperty.Register(
+ nameof(Zoom),
+ typeof(double),
+ typeof(Svg),
+ new PropertyMetadata(1.0, OnLayoutPropertyChanged));
+
+ public static readonly DependencyProperty PanXProperty =
+ DependencyProperty.Register(
+ nameof(PanX),
+ typeof(double),
+ typeof(Svg),
+ new PropertyMetadata(0.0, OnLayoutPropertyChanged));
+
+ public static readonly DependencyProperty PanYProperty =
+ DependencyProperty.Register(
+ nameof(PanY),
+ typeof(double),
+ typeof(Svg),
+ new PropertyMetadata(0.0, OnLayoutPropertyChanged));
+
+ public static readonly DependencyProperty CssProperty =
+ DependencyProperty.Register(
+ nameof(Css),
+ typeof(string),
+ typeof(Svg),
+ new PropertyMetadata(null, OnSourcePropertyChanged));
+
+ public static readonly DependencyProperty CurrentCssProperty =
+ DependencyProperty.Register(
+ nameof(CurrentCss),
+ typeof(string),
+ typeof(Svg),
+ new PropertyMetadata(null, OnSourcePropertyChanged));
+
+ public Svg()
+ {
+ Unloaded += OnUnloaded;
+ }
+
+ public SvgSource? SvgSource
+ {
+ get => (SvgSource?)GetValue(SvgSourceProperty);
+ set => SetValue(SvgSourceProperty, value);
+ }
+
+ public string? Path
+ {
+ get => (string?)GetValue(PathProperty);
+ set => SetValue(PathProperty, value);
+ }
+
+ public string? Source
+ {
+ get => (string?)GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public Stretch Stretch
+ {
+ get => (Stretch)GetValue(StretchProperty);
+ set => SetValue(StretchProperty, value);
+ }
+
+ public StretchDirection StretchDirection
+ {
+ get => (StretchDirection)GetValue(StretchDirectionProperty);
+ set => SetValue(StretchDirectionProperty, value);
+ }
+
+ public bool EnableCache
+ {
+ get => (bool)GetValue(EnableCacheProperty);
+ set => SetValue(EnableCacheProperty, value);
+ }
+
+ public bool Wireframe
+ {
+ get => (bool)GetValue(WireframeProperty);
+ set => SetValue(WireframeProperty, value);
+ }
+
+ public bool DisableFilters
+ {
+ get => (bool)GetValue(DisableFiltersProperty);
+ set => SetValue(DisableFiltersProperty, value);
+ }
+
+ public double Zoom
+ {
+ get => (double)GetValue(ZoomProperty);
+ set => SetValue(ZoomProperty, value);
+ }
+
+ public double PanX
+ {
+ get => (double)GetValue(PanXProperty);
+ set => SetValue(PanXProperty, value);
+ }
+
+ public double PanY
+ {
+ get => (double)GetValue(PanYProperty);
+ set => SetValue(PanYProperty, value);
+ }
+
+ public string? Css
+ {
+ get => (string?)GetValue(CssProperty);
+ set => SetValue(CssProperty, value);
+ }
+
+ public string? CurrentCss
+ {
+ get => (string?)GetValue(CurrentCssProperty);
+ set => SetValue(CurrentCssProperty, value);
+ }
+
+ public SkiaPicture? Picture => _svg?.Picture;
+
+ public SKSvg? SkSvg => _svg?.Svg;
+
+ public void ZoomToPoint(double newZoom, Point point)
+ {
+ var result = SvgRenderLayout.ZoomToPoint(
+ Zoom,
+ PanX,
+ PanY,
+ newZoom,
+ new SvgPoint(point.X, point.Y));
+
+ PanX = result.PanX;
+ PanY = result.PanY;
+ Zoom = result.Zoom;
+ }
+
+ public bool TryGetPicturePoint(Point point, out ShimPoint picturePoint)
+ {
+ picturePoint = default;
+
+ if (!TryGetRenderInfo(out var renderInfo))
+ {
+ return false;
+ }
+
+ if (!renderInfo.TryMapToPicture(new SvgPoint(point.X, point.Y), out var mappedPoint))
+ {
+ return false;
+ }
+
+ picturePoint = new ShimPoint((float)mappedPoint.X, (float)mappedPoint.Y);
+ return true;
+ }
+
+ public IEnumerable HitTestElements(Point point)
+ {
+ if (SkSvg is { } skSvg && TryGetPicturePoint(point, out var picturePoint))
+ {
+ return skSvg.HitTestElements(picturePoint);
+ }
+
+ return Array.Empty();
+ }
+
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (_svg?.Picture is not { } picture)
+ {
+ return default;
+ }
+
+ var size = SvgRenderLayout.CalculateSize(
+ new SvgSize(availableSize.Width, availableSize.Height),
+ new SvgSize(picture.CullRect.Width, picture.CullRect.Height),
+ Stretch,
+ StretchDirection);
+
+ return new Size(size.Width, size.Height);
+ }
+
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ if (_svg?.Picture is not { } picture)
+ {
+ return default;
+ }
+
+ var size = SvgRenderLayout.CalculateSize(
+ new SvgSize(finalSize.Width, finalSize.Height),
+ new SvgSize(picture.CullRect.Width, picture.CullRect.Height),
+ Stretch,
+ StretchDirection);
+
+ return new Size(size.Width, size.Height);
+ }
+
+ protected override void RenderOverride(SkiaCanvas canvas, Size area)
+ {
+ var source = _svg;
+ if (source?.Svg is null || source.Picture is null)
+ {
+ return;
+ }
+
+ if (!SvgRenderLayout.TryCreateRenderInfo(
+ new SvgSize(area.Width, area.Height),
+ new SvgRect(
+ source.Picture.CullRect.Left,
+ source.Picture.CullRect.Top,
+ source.Picture.CullRect.Width,
+ source.Picture.CullRect.Height),
+ Stretch,
+ StretchDirection,
+ Zoom,
+ PanX,
+ PanY,
+ out var renderInfo))
+ {
+ return;
+ }
+
+ if (!source.BeginRender())
+ {
+ return;
+ }
+
+ try
+ {
+ using var restore = new SkiaAutoCanvasRestore(canvas, true);
+ canvas.ClipRect(ToSKRect(renderInfo.DestinationRect));
+ var matrix = ToSKMatrix(renderInfo.Matrix);
+ canvas.Concat(in matrix);
+ source.Svg.Draw(canvas);
+ }
+ finally
+ {
+ source.EndRender();
+ }
+ }
+
+ internal static SvgParameters? BuildParameters(SvgSource? source, string? css, string? currentCss)
+ {
+ var entities = source?.Parameters?.Entities is { } parametersEntities
+ ? new Dictionary(parametersEntities)
+ : source?.Entities is { } sourceEntities
+ ? new Dictionary(sourceEntities)
+ : null;
+
+ var combinedCss = CombineCss(source?.Parameters?.Css ?? source?.Css, css, currentCss);
+ return entities is null && string.IsNullOrWhiteSpace(combinedCss)
+ ? null
+ : new SvgParameters(entities, combinedCss);
+ }
+
+ internal static SvgSource PrepareWorkingSource(SvgSource source, string? css, string? currentCss, bool wireframe, bool disableFilters)
+ {
+ var clone = source.Clone();
+ var parameters = BuildParameters(source, css, currentCss);
+
+ if (parameters is not null)
+ {
+ if (clone.HasPathSource)
+ {
+ clone.ReLoadAsync(parameters).GetAwaiter().GetResult();
+ }
+ else if (clone.HasLoadedSource)
+ {
+ clone.ReLoad(parameters);
+ }
+ }
+
+ ApplyRenderOptions(clone, wireframe, disableFilters);
+ return clone;
+ }
+
+ private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ ((Svg)d).QueueSourceReload();
+ }
+
+ private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (Svg)d;
+ control.InvalidateMeasure();
+ control.InvalidateArrange();
+ control.Invalidate();
+ }
+
+ private static void OnEnableCachePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (Svg)d;
+ if ((bool)e.NewValue)
+ {
+ control._cache ??= new Dictionary(StringComparer.Ordinal);
+ }
+ else
+ {
+ control.DisposeCache();
+ }
+ }
+
+ private static void OnRenderOptionPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ var control = (Svg)d;
+ ApplyRenderOptions(control._svg, control.Wireframe, control.DisableFilters);
+ control.Invalidate();
+ }
+
+ private static void ApplyRenderOptions(SvgSource? source, bool wireframe, bool disableFilters)
+ {
+ if (source?.Svg is { } skSvg)
+ {
+ skSvg.Wireframe = wireframe;
+ skSvg.IgnoreAttributes = disableFilters ? DrawAttributes.Filter : DrawAttributes.None;
+ skSvg.ClearWireframePicture();
+ }
+ }
+
+ private static SkiaRect ToSKRect(SvgRect rect)
+ {
+ return new SkiaRect((float)rect.Left, (float)rect.Top, (float)rect.Right, (float)rect.Bottom);
+ }
+
+ private static SkiaMatrix ToSKMatrix(Matrix3x2 matrix)
+ {
+ return new SkiaMatrix
+ {
+ ScaleX = matrix.M11,
+ SkewX = matrix.M21,
+ TransX = matrix.M31,
+ SkewY = matrix.M12,
+ ScaleY = matrix.M22,
+ TransY = matrix.M32,
+ Persp0 = 0,
+ Persp1 = 0,
+ Persp2 = 1
+ };
+ }
+
+ private static string? CombineCss(params string?[] values)
+ {
+ var filtered = values
+ .Where(static value => !string.IsNullOrWhiteSpace(value))
+ .Select(static value => value!.Trim())
+ .ToArray();
+
+ return filtered.Length == 0 ? null : string.Join(" ", filtered);
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ CancelPendingLoad();
+ }
+
+ private void QueueSourceReload()
+ {
+ var loadVersion = Interlocked.Increment(ref _loadVersion);
+ CancelPendingLoad();
+
+ if (SvgSource is null && string.IsNullOrWhiteSpace(Path) && string.IsNullOrWhiteSpace(Source))
+ {
+ ClearSource();
+ return;
+ }
+
+ var cancellationTokenSource = new CancellationTokenSource();
+ _pendingLoadCts = cancellationTokenSource;
+ _ = ReloadSourceAsync(loadVersion, cancellationTokenSource.Token);
+ }
+
+ private async Task ReloadSourceAsync(long loadVersion, CancellationToken cancellationToken)
+ {
+ LoadResult result = default;
+
+ try
+ {
+ if (SvgSource is { } externalSource)
+ {
+ result = await LoadExternalSourceAsync(externalSource, cancellationToken).ConfigureAwait(false);
+ }
+ else if (!string.IsNullOrWhiteSpace(Path))
+ {
+ result = await LoadPathAsync(Path!, cancellationToken).ConfigureAwait(false);
+ }
+ else if (!string.IsNullOrWhiteSpace(Source))
+ {
+ result = LoadInlineSource(Source!);
+ }
+
+ if (cancellationToken.IsCancellationRequested || loadVersion != Volatile.Read(ref _loadVersion))
+ {
+ DisposeResultIfOwned(result);
+ return;
+ }
+
+ SetCurrentSource(result);
+ }
+ catch (OperationCanceledException)
+ {
+ DisposeResultIfOwned(result);
+ }
+ catch (Exception e)
+ {
+ Debug.WriteLine("Failed to load Uno svg control source.");
+ Debug.WriteLine(e);
+ DisposeResultIfOwned(result);
+ ClearSource();
+ }
+ }
+
+ private async Task LoadExternalSourceAsync(SvgSource source, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var clone = source.Clone();
+ var parameters = BuildParameters(source, Css, CurrentCss);
+
+ if (clone.HasPathSource)
+ {
+ await clone.ReLoadAsync(parameters, cancellationToken).ConfigureAwait(false);
+ }
+ else if (clone.HasLoadedSource && parameters is not null)
+ {
+ clone.ReLoad(parameters);
+ }
+
+ ApplyRenderOptions(clone, Wireframe, DisableFilters);
+ return new LoadResult(clone, false);
+ }
+
+ private async Task LoadPathAsync(string path, CancellationToken cancellationToken)
+ {
+ var parameters = BuildParameters(null, Css, CurrentCss);
+ var normalizedPath = SvgSource.NormalizePath(path).ToString();
+ var cacheKey = SvgCacheKey.Create(normalizedPath, parameters);
+
+ if (EnableCache && _cache is { } cache && cache.TryGetValue(cacheKey, out var cached))
+ {
+ ApplyRenderOptions(cached, Wireframe, DisableFilters);
+ return new LoadResult(cached, true);
+ }
+
+ var source = await SvgSource.LoadAsync(path, parameters: parameters, cancellationToken: cancellationToken)
+ .ConfigureAwait(false);
+ ApplyRenderOptions(source, Wireframe, DisableFilters);
+
+ if (EnableCache && _cache is { } cacheStore)
+ {
+ cacheStore[cacheKey] = source;
+ return new LoadResult(source, true);
+ }
+
+ return new LoadResult(source, false);
+ }
+
+ private LoadResult LoadInlineSource(string sourceText)
+ {
+ var parameters = BuildParameters(null, Css, CurrentCss);
+ var source = SvgSource.LoadFromSvg(sourceText, parameters);
+ ApplyRenderOptions(source, Wireframe, DisableFilters);
+ return new LoadResult(source, false);
+ }
+
+ private bool TryGetRenderInfo(out SvgRenderInfo renderInfo)
+ {
+ renderInfo = default;
+
+ if (_svg?.Picture is not { } picture)
+ {
+ return false;
+ }
+
+ return SvgRenderLayout.TryCreateRenderInfo(
+ new SvgSize(ActualWidth, ActualHeight),
+ new SvgRect(picture.CullRect.Left, picture.CullRect.Top, picture.CullRect.Width, picture.CullRect.Height),
+ Stretch,
+ StretchDirection,
+ Zoom,
+ PanX,
+ PanY,
+ out renderInfo);
+ }
+
+ private void SetCurrentSource(LoadResult result)
+ {
+ var previous = _svg;
+ _svg = result.Source;
+
+ if (previous is not null && !ReferenceEquals(previous, _svg) && !IsCached(previous))
+ {
+ previous.Dispose();
+ }
+
+ InvalidateMeasure();
+ InvalidateArrange();
+ Invalidate();
+ }
+
+ private void ClearSource()
+ {
+ CancelPendingLoad();
+
+ var previous = _svg;
+ _svg = null;
+
+ if (previous is not null && !IsCached(previous))
+ {
+ previous.Dispose();
+ }
+
+ DisposeCache();
+ InvalidateMeasure();
+ InvalidateArrange();
+ Invalidate();
+ }
+
+ private void CancelPendingLoad()
+ {
+ _pendingLoadCts?.Cancel();
+ _pendingLoadCts?.Dispose();
+ _pendingLoadCts = null;
+ }
+
+ private void DisposeCache()
+ {
+ if (_cache is null)
+ {
+ return;
+ }
+
+ foreach (var cached in _cache.Values)
+ {
+ if (!ReferenceEquals(cached, _svg))
+ {
+ cached.Dispose();
+ }
+ }
+
+ _cache = null;
+ }
+
+ private bool IsCached(SvgSource source)
+ {
+ return _cache is { } cache && cache.Values.Any(value => ReferenceEquals(value, source));
+ }
+
+ private void DisposeResultIfOwned(LoadResult result)
+ {
+ if (result.Source is not null && !result.IsCacheEntry)
+ {
+ result.Source.Dispose();
+ }
+ }
+
+ private readonly record struct LoadResult(SvgSource? Source, bool IsCacheEntry);
+}
diff --git a/src/Svg.Controls.Skia.Uno/SvgCacheKey.cs b/src/Svg.Controls.Skia.Uno/SvgCacheKey.cs
new file mode 100644
index 0000000000..d8eb475b3e
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/SvgCacheKey.cs
@@ -0,0 +1,30 @@
+using System.Text;
+using Svg.Model;
+
+namespace Uno.Svg.Skia;
+
+internal static class SvgCacheKey
+{
+ public static string Create(string path, SvgParameters? parameters)
+ {
+ var builder = new StringBuilder(path.Trim());
+ var css = parameters?.Css;
+ if (!string.IsNullOrWhiteSpace(css))
+ {
+ builder.Append("|css:").Append(css.Trim());
+ }
+
+ if (parameters?.Entities is { Count: > 0 } entities)
+ {
+ foreach (var entity in entities.OrderBy(static pair => pair.Key, StringComparer.Ordinal))
+ {
+ builder.Append("|entity:")
+ .Append(entity.Key)
+ .Append('=')
+ .Append(entity.Value);
+ }
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/src/Svg.Controls.Skia.Uno/SvgRenderLayout.cs b/src/Svg.Controls.Skia.Uno/SvgRenderLayout.cs
new file mode 100644
index 0000000000..c1c964bcc1
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/SvgRenderLayout.cs
@@ -0,0 +1,203 @@
+using System.Numerics;
+using Microsoft.UI.Xaml.Media;
+
+namespace Uno.Svg.Skia;
+
+internal readonly record struct SvgSize(double Width, double Height)
+{
+ public bool IsEmpty => Width <= 0 || Height <= 0;
+}
+
+internal readonly record struct SvgPoint(double X, double Y);
+
+internal readonly record struct SvgRect(double X, double Y, double Width, double Height)
+{
+ public double Left => X;
+ public double Top => Y;
+ public double Right => X + Width;
+ public double Bottom => Y + Height;
+
+ public SvgRect Intersect(SvgRect other)
+ {
+ var left = Math.Max(Left, other.Left);
+ var top = Math.Max(Top, other.Top);
+ var right = Math.Min(Right, other.Right);
+ var bottom = Math.Min(Bottom, other.Bottom);
+
+ return right <= left || bottom <= top
+ ? default
+ : new SvgRect(left, top, right - left, bottom - top);
+ }
+
+ public SvgRect CenterRect(SvgRect rect)
+ {
+ return new SvgRect(
+ X + ((Width - rect.Width) / 2.0),
+ Y + ((Height - rect.Height) / 2.0),
+ rect.Width,
+ rect.Height);
+ }
+}
+
+internal readonly record struct SvgRenderInfo(SvgRect DestinationRect, SvgRect SourceRect, Matrix3x2 Matrix)
+{
+ public bool TryMapToPicture(SvgPoint point, out SvgPoint picturePoint)
+ {
+ if (!Matrix3x2.Invert(Matrix, out var inverse))
+ {
+ picturePoint = default;
+ return false;
+ }
+
+ var mapped = Vector2.Transform(new Vector2((float)point.X, (float)point.Y), inverse);
+ picturePoint = new SvgPoint(mapped.X, mapped.Y);
+ return true;
+ }
+}
+
+internal static class SvgRenderLayout
+{
+ public static SvgSize CalculateSize(
+ SvgSize availableSize,
+ SvgSize sourceSize,
+ Stretch stretch,
+ StretchDirection stretchDirection)
+ {
+ if (sourceSize.IsEmpty)
+ {
+ return default;
+ }
+
+ if (double.IsInfinity(availableSize.Width) && double.IsInfinity(availableSize.Height))
+ {
+ return sourceSize;
+ }
+
+ var (scaleX, scaleY) = CalculateScaling(availableSize, sourceSize, stretch, stretchDirection);
+ return new SvgSize(sourceSize.Width * scaleX, sourceSize.Height * scaleY);
+ }
+
+ public static bool TryCreateRenderInfo(
+ SvgSize viewportSize,
+ SvgRect pictureBounds,
+ Stretch stretch,
+ StretchDirection stretchDirection,
+ double zoom,
+ double panX,
+ double panY,
+ out SvgRenderInfo renderInfo)
+ {
+ renderInfo = default;
+
+ var sourceSize = new SvgSize(pictureBounds.Width, pictureBounds.Height);
+ if (viewportSize.IsEmpty || sourceSize.IsEmpty)
+ {
+ return false;
+ }
+
+ var viewport = new SvgRect(0, 0, viewportSize.Width, viewportSize.Height);
+ var (scaleX, scaleY) = CalculateScaling(viewportSize, sourceSize, stretch, stretchDirection);
+ var scaledSize = new SvgRect(0, 0, sourceSize.Width * scaleX, sourceSize.Height * scaleY);
+ var destinationRect = viewport.CenterRect(scaledSize).Intersect(viewport);
+ if (destinationRect.Width <= 0 || destinationRect.Height <= 0)
+ {
+ return false;
+ }
+
+ var sourceRect = new SvgRect(0, 0, sourceSize.Width, sourceSize.Height)
+ .CenterRect(new SvgRect(0, 0, destinationRect.Width / scaleX, destinationRect.Height / scaleY));
+
+ if (sourceRect.Width <= 0 || sourceRect.Height <= 0)
+ {
+ return false;
+ }
+
+ var baseMatrix =
+ Matrix3x2.CreateScale(
+ (float)(destinationRect.Width / sourceRect.Width),
+ (float)(destinationRect.Height / sourceRect.Height))
+ * Matrix3x2.CreateTranslation(
+ (float)(-sourceRect.X + destinationRect.X - pictureBounds.X),
+ (float)(-sourceRect.Y + destinationRect.Y - pictureBounds.Y));
+
+ var userMatrix =
+ Matrix3x2.CreateScale((float)zoom)
+ * Matrix3x2.CreateTranslation((float)panX, (float)panY);
+
+ renderInfo = new SvgRenderInfo(destinationRect, sourceRect, baseMatrix * userMatrix);
+ return true;
+ }
+
+ public static (double Zoom, double PanX, double PanY) ZoomToPoint(
+ double zoom,
+ double panX,
+ double panY,
+ double newZoom,
+ SvgPoint point)
+ {
+ newZoom = Math.Clamp(newZoom, 0.1, 10.0);
+ var zoomFactor = newZoom / zoom;
+
+ return (
+ newZoom,
+ point.X - ((point.X - panX) * zoomFactor),
+ point.Y - ((point.Y - panY) * zoomFactor));
+ }
+
+ private static (double ScaleX, double ScaleY) CalculateScaling(
+ SvgSize availableSize,
+ SvgSize sourceSize,
+ Stretch stretch,
+ StretchDirection stretchDirection)
+ {
+ if (sourceSize.IsEmpty)
+ {
+ return (0, 0);
+ }
+
+ var scaleX = 1.0;
+ var scaleY = 1.0;
+
+ if (stretch != Stretch.None)
+ {
+ var hasWidth = !double.IsInfinity(availableSize.Width) && availableSize.Width > 0;
+ var hasHeight = !double.IsInfinity(availableSize.Height) && availableSize.Height > 0;
+
+ var candidateScaleX = hasWidth ? availableSize.Width / sourceSize.Width : 1.0;
+ var candidateScaleY = hasHeight ? availableSize.Height / sourceSize.Height : 1.0;
+
+ if (stretch == Stretch.Uniform)
+ {
+ var uniform = Math.Min(candidateScaleX, candidateScaleY);
+ scaleX = uniform;
+ scaleY = uniform;
+ }
+ else if (stretch == Stretch.UniformToFill)
+ {
+ var uniform = Math.Max(candidateScaleX, candidateScaleY);
+ scaleX = uniform;
+ scaleY = uniform;
+ }
+ else
+ {
+ scaleX = candidateScaleX;
+ scaleY = candidateScaleY;
+ }
+ }
+
+ scaleX = ApplyStretchDirection(scaleX, stretchDirection);
+ scaleY = ApplyStretchDirection(scaleY, stretchDirection);
+
+ return (scaleX, scaleY);
+ }
+
+ private static double ApplyStretchDirection(double scale, StretchDirection stretchDirection)
+ {
+ return stretchDirection switch
+ {
+ StretchDirection.UpOnly => Math.Max(1.0, scale),
+ StretchDirection.DownOnly => Math.Min(1.0, scale),
+ _ => scale
+ };
+ }
+}
diff --git a/src/Svg.Controls.Skia.Uno/SvgSource.cs b/src/Svg.Controls.Skia.Uno/SvgSource.cs
new file mode 100644
index 0000000000..9480042e20
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/SvgSource.cs
@@ -0,0 +1,649 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Net.Http;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Text;
+using Microsoft.UI.Xaml.Markup;
+using SkiaSharp;
+using Svg;
+using Svg.Model;
+using Svg.Skia;
+using Windows.Storage;
+
+namespace Uno.Svg.Skia;
+
+[TypeConverter(typeof(SvgSourceTypeConverter))]
+[ContentProperty(Name = nameof(Path))]
+public sealed class SvgSource : IDisposable
+{
+ public static readonly SkiaModel SkiaModel = new(new SKSvgSettings());
+
+ private static readonly HttpClient s_httpClient = new();
+
+ private readonly Uri? _baseUri;
+ private SKSvg? _skSvg;
+ private SKPicture? _picture;
+ private SvgParameters? _originalParameters;
+ private string? _originalPath;
+ private Stream? _originalStream;
+ private Uri? _originalBaseUri;
+ private int _activeRenders;
+ private readonly ThreadLocal _renderDepth = new(() => 0);
+ private bool _disposePending;
+ private bool _disposed;
+
+ public SvgSource()
+ : this((Uri?)null)
+ {
+ }
+
+ public SvgSource(Uri? baseUri)
+ {
+ _baseUri = baseUri;
+ }
+
+ public string? Path { get; set; }
+
+ public Dictionary? Entities { get; set; }
+
+ public string? Css { get; set; }
+
+ public SKSvg? Svg => Volatile.Read(ref _skSvg);
+
+ public SvgParameters? Parameters => _originalParameters;
+
+ public SKPicture? Picture
+ {
+ get
+ {
+ var picture = _picture;
+ if (picture is not null)
+ {
+ return picture;
+ }
+
+ var skSvg = Volatile.Read(ref _skSvg);
+ if (skSvg is { })
+ {
+ picture = skSvg.Picture;
+ lock (Sync)
+ {
+ _picture ??= picture;
+ }
+ }
+
+ return picture;
+ }
+ set => _picture = value;
+ }
+
+ public static bool EnableThrowOnMissingResource { get; set; }
+
+ internal bool HasPathSource
+ {
+ get
+ {
+ lock (Sync)
+ {
+ return _originalPath is not null || Path is not null;
+ }
+ }
+ }
+
+ internal bool HasLoadedSource
+ {
+ get
+ {
+ lock (Sync)
+ {
+ return _originalStream is not null || _originalPath is not null || _skSvg is not null;
+ }
+ }
+ }
+
+ public object Sync { get; } = new();
+
+ public static Uri NormalizePath(string path, Uri? baseUri = null)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ throw new ArgumentException("Path must not be null or empty.", nameof(path));
+ }
+
+ if (path.StartsWith("/Assets/", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(path, "/Assets", StringComparison.OrdinalIgnoreCase))
+ {
+ return new Uri($"ms-appx://{path}", UriKind.Absolute);
+ }
+
+ if (Uri.TryCreate(path, UriKind.Absolute, out var absoluteUri))
+ {
+ return absoluteUri;
+ }
+
+ if (System.IO.Path.IsPathRooted(path))
+ {
+ return new Uri(System.IO.Path.GetFullPath(path));
+ }
+
+ if (baseUri is not null)
+ {
+ return new Uri(baseUri, path);
+ }
+
+ return new Uri($"ms-appx:///{path.TrimStart('/')}", UriKind.Absolute);
+ }
+
+ public void Dispose()
+ {
+ SKPicture? picture = null;
+ SKSvg? skSvg = null;
+ Stream? originalStream = null;
+
+ lock (Sync)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposePending = true;
+
+ if (_activeRenders > 0)
+ {
+ if (_renderDepth.Value > 0)
+ {
+ return;
+ }
+
+ while (_activeRenders > 0)
+ {
+ Monitor.Wait(Sync);
+ }
+
+ if (_disposed)
+ {
+ return;
+ }
+ }
+
+ DisposeCoreLocked(out picture, out skSvg, out originalStream);
+ }
+
+ DisposeResources(picture, skSvg, originalStream);
+ }
+
+ public static async Task LoadAsync(
+ string path,
+ Uri? baseUri = null,
+ SvgParameters? parameters = null,
+ CancellationToken cancellationToken = default)
+ {
+ var source = new SvgSource(baseUri) { Path = path };
+ await LoadImplAsync(source, path, baseUri, parameters, cancellationToken).ConfigureAwait(false);
+ return source;
+ }
+
+ public static SvgSource LoadFromSvg(string svg)
+ {
+ return LoadFromSvg(svg, null);
+ }
+
+ public static SvgSource LoadFromSvg(string svg, SvgParameters? parameters)
+ {
+ var source = new SvgSource();
+ using var stream = CreateStream(svg);
+ Load(source, stream, parameters);
+ return source;
+ }
+
+ public static SvgSource LoadFromStream(Stream stream, SvgParameters? parameters = null)
+ {
+ var source = new SvgSource();
+ Load(source, stream, parameters);
+ return source;
+ }
+
+ public static SvgSource LoadFromSvgDocument(SvgDocument document)
+ {
+ return LoadFromSvgDocument(document, null);
+ }
+
+ public static SvgSource LoadFromSvgDocument(SvgDocument document, SvgParameters? parameters)
+ {
+ var source = new SvgSource();
+ if (parameters is null)
+ {
+ var originalStream = CreateStream(document);
+ var skSvg = new SKSvg();
+ skSvg.FromSvgDocument(document);
+ var picture = skSvg.Picture;
+
+ lock (source.Sync)
+ {
+ source._originalStream?.Dispose();
+ source._originalStream = originalStream;
+ source._originalPath = null;
+ source._originalParameters = null;
+ source._originalBaseUri = document.BaseUri;
+ source._skSvg = skSvg;
+ source._picture = picture;
+ }
+
+ return source;
+ }
+
+ using var stream = CreateStream(document);
+ Load(source, stream, parameters, document.BaseUri);
+ return source;
+ }
+
+ public void RebuildFromModel()
+ {
+ SKSvg? skSvg;
+ lock (Sync)
+ {
+ skSvg = _skSvg;
+ }
+
+ if (skSvg is null)
+ {
+ return;
+ }
+
+ skSvg.RebuildFromModel();
+
+ lock (Sync)
+ {
+ if (ReferenceEquals(_skSvg, skSvg))
+ {
+ _picture = skSvg.Picture;
+ }
+ }
+ }
+
+ public SvgSource Clone()
+ {
+ var clone = new SvgSource(_baseUri)
+ {
+ Path = Path,
+ Entities = Entities is null ? null : new Dictionary(Entities),
+ Css = Css
+ };
+
+ SvgParameters? originalParameters;
+ string? originalPath;
+ Uri? originalBaseUri;
+ SKSvg? skSvg;
+ SKPicture? picture;
+ MemoryStream? originalStreamCopy = null;
+ SKPicture? clonedPicture = null;
+ var canClonePicture = false;
+
+ lock (Sync)
+ {
+ originalParameters = _originalParameters;
+ originalPath = _originalPath;
+ originalBaseUri = _originalBaseUri;
+ skSvg = _skSvg;
+ picture = _picture;
+
+ if (_originalStream is { } originalStream)
+ {
+ originalStreamCopy = new MemoryStream();
+ var position = originalStream.Position;
+ originalStream.Position = 0;
+ originalStream.CopyTo(originalStreamCopy);
+ originalStreamCopy.Position = 0;
+ originalStream.Position = position;
+ }
+
+ canClonePicture = picture is { }
+ && _originalStream is null
+ && _originalPath is null
+ && Path is null;
+
+ if (canClonePicture && picture is { })
+ {
+ clonedPicture = ClonePicture(picture);
+ }
+ }
+
+ clone._originalParameters = CloneParameters(originalParameters);
+ clone._originalPath = originalPath;
+ clone._originalBaseUri = originalBaseUri;
+ clone._originalStream = originalStreamCopy;
+
+ if (skSvg is { })
+ {
+ clone._skSvg = skSvg.Clone();
+ }
+ else if (canClonePicture)
+ {
+ clone._picture = clonedPicture;
+ }
+
+ return clone;
+ }
+
+ public void ReLoad(SvgParameters? parameters)
+ {
+ ReLoadAsync(parameters).GetAwaiter().GetResult();
+ }
+
+ public async Task ReLoadAsync(SvgParameters? parameters, CancellationToken cancellationToken = default)
+ {
+ MemoryStream? streamCopy = null;
+ string? originalPath;
+ string? path;
+ Uri? originalBaseUri;
+ Uri? baseUri;
+
+ lock (Sync)
+ {
+ if (_originalStream is null && _originalPath is null && Path is null)
+ {
+ return;
+ }
+
+ if (_originalStream is { } originalStream)
+ {
+ streamCopy = new MemoryStream();
+ originalStream.Position = 0;
+ originalStream.CopyTo(streamCopy);
+ streamCopy.Position = 0;
+ }
+
+ originalPath = _originalPath;
+ originalBaseUri = _originalBaseUri;
+ path = Path;
+ baseUri = _baseUri;
+ _originalParameters = parameters;
+ }
+
+ if (streamCopy is { })
+ {
+ LoadFromCachedStream(this, streamCopy, parameters, originalBaseUri);
+ return;
+ }
+
+ if (originalPath is { })
+ {
+ await LoadImplAsync(this, originalPath, originalBaseUri, parameters, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ if (path is { })
+ {
+ await LoadImplAsync(this, path, baseUri, parameters, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ internal bool BeginRender()
+ {
+ lock (Sync)
+ {
+ if (_disposed || _disposePending)
+ {
+ return false;
+ }
+
+ _activeRenders++;
+ _renderDepth.Value++;
+ return true;
+ }
+ }
+
+ internal void EndRender()
+ {
+ SKPicture? picture = null;
+ SKSvg? skSvg = null;
+ Stream? originalStream = null;
+
+ lock (Sync)
+ {
+ if (_renderDepth.Value > 0)
+ {
+ _renderDepth.Value--;
+ }
+
+ if (_activeRenders > 0 && --_activeRenders == 0)
+ {
+ if (_disposePending)
+ {
+ DisposeCoreLocked(out picture, out skSvg, out originalStream);
+ }
+
+ Monitor.PulseAll(Sync);
+ }
+ }
+
+ if (picture is not null || skSvg is not null || originalStream is not null)
+ {
+ DisposeResources(picture, skSvg, originalStream);
+ }
+ }
+
+ private static SKPicture? Load(SvgSource source, string? path, SvgParameters? parameters)
+ {
+ if (path is null)
+ {
+ lock (source.Sync)
+ {
+ source._originalPath = null;
+ source._originalStream?.Dispose();
+ source._originalStream = null;
+ source._originalParameters = parameters;
+ source._originalBaseUri = null;
+ source._skSvg = null;
+ source._picture = null;
+ }
+
+ return null;
+ }
+
+ var skSvg = new SKSvg();
+ skSvg.Load(path, parameters);
+ var picture = skSvg.Picture;
+
+ lock (source.Sync)
+ {
+ source._originalPath = path;
+ source._originalStream?.Dispose();
+ source._originalStream = null;
+ source._originalParameters = parameters;
+ source._originalBaseUri = new Uri(path, UriKind.RelativeOrAbsolute);
+ source._skSvg = skSvg;
+ source._picture = picture;
+ }
+
+ return picture;
+ }
+
+ private static SKPicture? Load(SvgSource source, Stream stream, SvgParameters? parameters = null, Uri? baseUri = null)
+ {
+ var cachedStream = new MemoryStream();
+ stream.CopyTo(cachedStream);
+ return LoadFromCachedStream(source, cachedStream, parameters, baseUri);
+ }
+
+ private static SKPicture? LoadFromCachedStream(SvgSource source, MemoryStream cachedStream, SvgParameters? parameters, Uri? baseUri)
+ {
+ cachedStream.Position = 0;
+ var skSvg = new SKSvg();
+ skSvg.Load(cachedStream, parameters, baseUri);
+ var picture = skSvg.Picture;
+
+ lock (source.Sync)
+ {
+ source._originalStream?.Dispose();
+ source._originalStream = cachedStream;
+ source._originalPath = null;
+ source._originalParameters = parameters;
+ source._originalBaseUri = baseUri;
+ source._skSvg = skSvg;
+ source._picture = picture;
+ }
+
+ return picture;
+ }
+
+ private static async Task LoadImplAsync(
+ SvgSource source,
+ string path,
+ Uri? baseUri,
+ SvgParameters? parameters,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var normalizedUri = NormalizePath(path, baseUri);
+
+ if (normalizedUri.IsFile && File.Exists(normalizedUri.LocalPath))
+ {
+ return Load(source, normalizedUri.LocalPath, parameters);
+ }
+
+ await using var stream = await OpenStreamAsync(normalizedUri, cancellationToken).ConfigureAwait(false);
+ if (stream is null)
+ {
+ ThrowOnMissingResource(path);
+ return null;
+ }
+
+ return Load(source, stream, parameters, normalizedUri);
+ }
+
+ private static async Task OpenStreamAsync(Uri uri, CancellationToken cancellationToken)
+ {
+ if (uri.IsFile)
+ {
+ if (!File.Exists(uri.LocalPath))
+ {
+ return null;
+ }
+
+ return File.OpenRead(uri.LocalPath);
+ }
+
+ if (uri.Scheme is "http" or "https")
+ {
+ try
+ {
+ var response = await s_httpClient
+ .GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ return null;
+ }
+
+ return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpRequestException e)
+ {
+ Debug.WriteLine("Failed to connect to " + uri);
+ Debug.WriteLine(e);
+ return null;
+ }
+ }
+
+ if (uri.Scheme == "ms-appx")
+ {
+ try
+ {
+ var file = await StorageFile.GetFileFromApplicationUriAsync(uri);
+ var readStream = await file.OpenReadAsync();
+ return readStream.AsStreamForRead();
+ }
+ catch (Exception e)
+ {
+ Debug.WriteLine("Failed to load app asset " + uri);
+ Debug.WriteLine(e);
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ private static MemoryStream CreateStream(string svg)
+ {
+ return new MemoryStream(Encoding.UTF8.GetBytes(svg));
+ }
+
+ private static MemoryStream CreateStream(SvgDocument document)
+ {
+ var stream = new MemoryStream();
+ document.Write(stream, useBom: false);
+ stream.Position = 0;
+ return stream;
+ }
+
+ private static SvgSource? ThrowOnMissingResource(string path)
+ {
+ return EnableThrowOnMissingResource
+ ? throw new ArgumentException($"Invalid resource path: {path}", nameof(path))
+ : null;
+ }
+
+ private static SvgParameters? CloneParameters(SvgParameters? parameters)
+ {
+ if (parameters is null)
+ {
+ return null;
+ }
+
+ var entities = parameters.Value.Entities;
+ var entitiesCopy = entities is null ? null : new Dictionary(entities);
+ return new SvgParameters(entitiesCopy, parameters.Value.Css);
+ }
+
+ private static SKPicture? ClonePicture(SKPicture? picture)
+ {
+ if (picture is null)
+ {
+ return null;
+ }
+
+ using var recorder = new SKPictureRecorder();
+ var canvas = recorder.BeginRecording(picture.CullRect);
+ canvas.DrawPicture(picture);
+ return recorder.EndRecording();
+ }
+
+ private void DisposeCoreLocked(out SKPicture? picture, out SKSvg? skSvg, out Stream? originalStream)
+ {
+ picture = _picture;
+ skSvg = _skSvg;
+ originalStream = _originalStream;
+
+ _picture = null;
+ _skSvg = null;
+ _originalPath = null;
+ _originalParameters = null;
+ _originalBaseUri = null;
+ _originalStream = null;
+ _disposePending = false;
+ _disposed = true;
+ }
+
+ private static void DisposeResources(SKPicture? picture, SKSvg? skSvg, Stream? originalStream)
+ {
+ SKPicture? skSvgPicture = null;
+ if (skSvg is { } && picture is { })
+ {
+ skSvgPicture = skSvg.Picture;
+ }
+
+ skSvg?.Dispose();
+
+ if (picture is { } && !ReferenceEquals(picture, skSvgPicture))
+ {
+ picture.Dispose();
+ }
+
+ originalStream?.Dispose();
+ }
+}
diff --git a/src/Svg.Controls.Skia.Uno/SvgSourceTypeConverter.cs b/src/Svg.Controls.Skia.Uno/SvgSourceTypeConverter.cs
new file mode 100644
index 0000000000..9351d436c5
--- /dev/null
+++ b/src/Svg.Controls.Skia.Uno/SvgSourceTypeConverter.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel;
+using System.Globalization;
+
+namespace Uno.Svg.Skia;
+
+public sealed class SvgSourceTypeConverter : TypeConverter
+{
+ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType)
+ {
+ return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
+ }
+
+ public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value)
+ {
+ return value is string path
+ ? new SvgSource { Path = path }
+ : base.ConvertFrom(context, culture, value);
+ }
+}
diff --git a/tests/Svg.Controls.Skia.Uno.UnitTests/Svg.Controls.Skia.Uno.UnitTests.csproj b/tests/Svg.Controls.Skia.Uno.UnitTests/Svg.Controls.Skia.Uno.UnitTests.csproj
new file mode 100644
index 0000000000..72a2e9c692
--- /dev/null
+++ b/tests/Svg.Controls.Skia.Uno.UnitTests/Svg.Controls.Skia.Uno.UnitTests.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net10.0
+ Library
+ False
+ enable
+ Uno.Svg.Skia.UnitTests
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Svg.Controls.Skia.Uno.UnitTests/SvgSourceTests.cs b/tests/Svg.Controls.Skia.Uno.UnitTests/SvgSourceTests.cs
new file mode 100644
index 0000000000..8ffca0a977
--- /dev/null
+++ b/tests/Svg.Controls.Skia.Uno.UnitTests/SvgSourceTests.cs
@@ -0,0 +1,275 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.UI.Xaml.Media;
+using Svg.Model;
+using Svg.Model.Services;
+using Xunit;
+
+namespace Uno.Svg.Skia.UnitTests;
+
+public class SvgSourceTests
+{
+ private const string SampleSvg =
+ "";
+
+ [Fact]
+ public void LoadFromSvg_SetsSvg()
+ {
+ var source = SvgSource.LoadFromSvg(SampleSvg);
+
+ Assert.NotNull(source.Svg);
+ Assert.NotNull(source.Picture);
+ }
+
+ [Fact]
+ public void LoadFromSvgDocument_SetsSvg()
+ {
+ var document = SvgService.FromSvg(SampleSvg);
+
+ Assert.NotNull(document);
+
+ var source = SvgSource.LoadFromSvgDocument(document!);
+
+ Assert.NotNull(source.Svg);
+ Assert.NotNull(source.Picture);
+ }
+
+ [Fact]
+ public void RebuildFromModel_RefreshesPicture()
+ {
+ var source = SvgSource.LoadFromSvg(SampleSvg);
+ var original = source.Picture;
+
+ Assert.NotNull(original);
+
+ source.RebuildFromModel();
+
+ Assert.NotNull(source.Picture);
+ Assert.NotSame(original, source.Picture);
+ }
+
+ [Fact]
+ public async Task LoadAsync_FilePath_SetsSvg()
+ {
+ var filePath = CreateTempSvgFile();
+
+ try
+ {
+ var source = await SvgSource.LoadAsync(filePath);
+
+ Assert.NotNull(source.Svg);
+ Assert.NotNull(source.Picture);
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task ReLoadAsync_PathBackedSource_PreservesPicture()
+ {
+ var filePath = CreateTempSvgFile();
+
+ try
+ {
+ var source = await SvgSource.LoadAsync(filePath);
+
+ await source.ReLoadAsync(new SvgParameters(null, ".accent { fill: #000000; }"));
+
+ Assert.NotNull(source.Svg);
+ Assert.NotNull(source.Picture);
+ }
+ finally
+ {
+ File.Delete(filePath);
+ }
+ }
+
+ [Fact]
+ public async Task LoadAsync_CancelledToken_Throws()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(() =>
+ SvgSource.LoadAsync("/Assets/does-not-matter.svg", cancellationToken: cts.Token));
+ }
+
+ [Fact]
+ public void NormalizePath_LeadingSlash_UsesMsAppxUri()
+ {
+ var uri = SvgSource.NormalizePath("/Assets/Icon.svg");
+
+ Assert.Equal("ms-appx:///Assets/Icon.svg", uri.ToString());
+ }
+
+ [Fact]
+ public void NormalizePath_RelativePath_UsesBaseUri()
+ {
+ var uri = SvgSource.NormalizePath("Assets/Icon.svg", new Uri("ms-appx:///Samples/"));
+
+ Assert.Equal("ms-appx:///Samples/Assets/Icon.svg", uri.ToString());
+ }
+
+ [Fact]
+ public void Clone_DeepClonesModel()
+ {
+ var source = SvgSource.LoadFromSvg(SampleSvg);
+ var clone = source.Clone();
+
+ Assert.NotSame(source, clone);
+ Assert.NotNull(source.Svg);
+ Assert.NotNull(clone.Svg);
+ Assert.NotSame(source.Svg, clone.Svg);
+ Assert.NotSame(source.Svg?.Model, clone.Svg?.Model);
+ Assert.NotSame(source.Picture, clone.Picture);
+ }
+
+ [Fact]
+ public async Task Dispose_DuringRender_DoesNotDeadlock()
+ {
+ var source = SvgSource.LoadFromSvg(SampleSvg);
+ var beginRender = typeof(SvgSource).GetMethod("BeginRender", BindingFlags.Instance | BindingFlags.NonPublic);
+ var endRender = typeof(SvgSource).GetMethod("EndRender", BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.NotNull(beginRender);
+ Assert.NotNull(endRender);
+
+ var task = Task.Run(() =>
+ {
+ var started = (bool)(beginRender!.Invoke(source, null) ?? false);
+ if (!started)
+ {
+ return false;
+ }
+
+ source.Dispose();
+ endRender!.Invoke(source, null);
+
+ return source.Svg is null && source.Picture is null;
+ });
+
+ var completed = await task.WaitAsync(TimeSpan.FromSeconds(2));
+ Assert.True(completed);
+ }
+
+ [Fact]
+ public void CacheKey_ChangesWhenCssOrEntitiesChange()
+ {
+ var path = "ms-appx:///Assets/Icon.svg";
+ var first = SvgCacheKey.Create(path, new SvgParameters(
+ new Dictionary { ["accent"] = "#ff0000" },
+ ".accent { fill: red; }"));
+ var second = SvgCacheKey.Create(path, new SvgParameters(
+ new Dictionary { ["accent"] = "#0000ff" },
+ ".accent { fill: red; }"));
+ var third = SvgCacheKey.Create(path, new SvgParameters(
+ new Dictionary { ["accent"] = "#ff0000" },
+ ".accent { fill: blue; }"));
+
+ Assert.NotEqual(first, second);
+ Assert.NotEqual(first, third);
+ }
+
+ [Fact]
+ public void CacheKey_IgnoresEntityInsertionOrder()
+ {
+ var path = "ms-appx:///Assets/Icon.svg";
+ var first = SvgCacheKey.Create(path, new SvgParameters(
+ new Dictionary
+ {
+ ["accent"] = "#ff0000",
+ ["stroke"] = "#000000"
+ },
+ ".accent { fill: red; }"));
+ var second = SvgCacheKey.Create(path, new SvgParameters(
+ new Dictionary
+ {
+ ["stroke"] = "#000000",
+ ["accent"] = "#ff0000"
+ },
+ ".accent { fill: red; }"));
+
+ Assert.Equal(first, second);
+ }
+
+ [Fact]
+ public void RenderLayout_MapsControlPointToPicturePoint()
+ {
+ var created = SvgRenderLayout.TryCreateRenderInfo(
+ new SvgSize(200, 100),
+ new SvgRect(0, 0, 100, 50),
+ Stretch.Uniform,
+ StretchDirection.Both,
+ 1.0,
+ 0.0,
+ 0.0,
+ out var renderInfo);
+
+ Assert.True(created);
+ Assert.True(renderInfo.TryMapToPicture(new SvgPoint(100, 50), out var picturePoint));
+ Assert.Equal(50, picturePoint.X, 6);
+ Assert.Equal(25, picturePoint.Y, 6);
+ }
+
+ [Fact]
+ public void ZoomToPoint_AdjustsPanAndZoom()
+ {
+ var result = SvgRenderLayout.ZoomToPoint(1.0, 0.0, 0.0, 2.0, new SvgPoint(50, 25));
+
+ Assert.Equal(2.0, result.Zoom, 6);
+ Assert.Equal(-50.0, result.PanX, 6);
+ Assert.Equal(-25.0, result.PanY, 6);
+ }
+
+ [Fact]
+ public void BuildParameters_MergesSourceAndControlCss()
+ {
+ var source = new SvgSource
+ {
+ Css = ".source { fill: red; }",
+ Entities = new Dictionary { ["accent"] = "#ff0000" }
+ };
+
+ var parameters = Svg.BuildParameters(source, ".control { fill: blue; }", ".current { stroke: white; }");
+
+ Assert.NotNull(parameters);
+ Assert.Equal(".source { fill: red; } .control { fill: blue; } .current { stroke: white; }", parameters?.Css);
+ Assert.Equal("#ff0000", parameters?.Entities?["accent"]);
+ }
+
+ [Fact]
+ public void PrepareWorkingSource_KeepsSharedSourceIsolated_AndPropagatesRenderFlags()
+ {
+ var sharedSource = SvgSource.LoadFromSvg(SampleSvg);
+
+ var workingSource = Svg.PrepareWorkingSource(
+ sharedSource,
+ ".control { fill: blue; }",
+ ".current { stroke: white; }",
+ wireframe: true,
+ disableFilters: true);
+
+ Assert.NotSame(sharedSource, workingSource);
+ Assert.NotSame(sharedSource.Svg, workingSource.Svg);
+ Assert.NotNull(sharedSource.Svg);
+ Assert.NotNull(workingSource.Svg);
+ Assert.False(sharedSource.Svg!.Wireframe);
+ Assert.True(workingSource.Svg!.Wireframe);
+ Assert.Equal(DrawAttributes.None, sharedSource.Svg.IgnoreAttributes);
+ Assert.Equal(DrawAttributes.Filter, workingSource.Svg.IgnoreAttributes);
+ Assert.Null(sharedSource.Parameters);
+ }
+
+ private static string CreateTempSvgFile()
+ {
+ var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{Guid.NewGuid():N}.svg");
+ File.WriteAllText(path, SampleSvg);
+ return path;
+ }
+}