diff --git a/.gitignore b/.gitignore
index 772ccbe59c..0896ea0529 100644
--- a/.gitignore
+++ b/.gitignore
@@ -295,3 +295,6 @@ __pycache__/
# macOS
.DS_Store
+/output
+/.playwright-cli
+TestApp.json
diff --git a/AGENTS.md b/AGENTS.md
index 03e35ad8dd..0f81a5d2ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,23 @@ This repository contains the Svg.Skia library and associated tests. The followin
3. Build the solution using `dotnet build Svg.Skia.slnx -c Release`.
4. Run all tests with `dotnet test Svg.Skia.slnx -c Release`.
+## W3C Chrome Overrides
+- Files under `tests/Svg.Skia.UnitTests/ChromeReference/W3C/` must be generated from real Google Chrome captures, not copied from the legacy W3C PNG set.
+- Use `node scripts/capture_w3c_chrome_overrides.mjs` to regenerate the checked Chrome override set. Pass specific test names as comma-separated arguments to limit capture scope, for example `node scripts/capture_w3c_chrome_overrides.mjs masking-path-04-b,linking-a-09-b`.
+- The capture script serves the repository over HTTP and screenshots a `480x360` Chrome-hosted iframe, which matches the standalone viewport policy used by `W3CTestSuiteTests`.
+- When a Chrome override exists, keep `W3CTestSuiteTests` pointed at that override instead of the W3C reference PNG.
+- Do not reintroduce footer exclusion regions for W3C comparisons. Prefer Chrome overrides plus narrowly-scoped per-test thresholds only when the library is visually aligned with Chrome but still differs at the pixel-raster level.
+- If a W3C fixture depends on JavaScript, DOM APIs, or browser-only runtime behavior that Svg.Skia does not implement, keep that row skipped with an explicit reason instead of manufacturing a fake baseline.
+
+## Testing Workflow
+- For W3C work, first run focused rows with `dotnet test tests/Svg.Skia.UnitTests/Svg.Skia.UnitTests.csproj -f net10.0 -c Release --no-restore --filter "FullyQualifiedName~W3CTestSuiteTests.Tests"`.
+- When refreshing baselines, rerun the focused W3C subset that changed before running the full W3C suite.
+- Before committing renderer or baseline changes, run:
+ - `dotnet format Svg.Skia.slnx --no-restore`
+ - `dotnet build Svg.Skia.slnx -c Release`
+ - `dotnet test Svg.Skia.slnx -c Release`
+- Keep unrelated local state files, especially `samples/TestApp/TestApp.json`, out of commits unless the task explicitly requires them.
+
## Commit Guidelines
- Use concise commit messages summarizing your change in under 72 characters.
- Additional description lines may follow the summary if necessary.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b13ad5f02d..d75e70386b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Svg.Skia Changelog
+## Unreleased
+
+* Added SVG 1.1 animation object-model coverage in `Svg.Custom` for `animate`, `set`, `animateMotion`, `animateColor`, `animateTransform`, and `mpath`.
+* Added typed `pointer-events` support, geometry-aware hit testing, topmost-element targeting, and routed interaction dispatch with capture, tunnel, bubble, and cursor resolution.
+* Added shared animation playback in `SKSvg`, including animation time control, invalidation events, layered redraw, throttling helpers, and native-composition scene extraction.
+* Added host animation backends for Avalonia and Uno, including resolved-backend diagnostics and Avalonia retained `NativeComposition` playback with fallback.
+* Added an animation benchmark harness in `tests/Svg.Skia.Benchmarks` and exposed animation/backend controls in `samples/TestApp`.
+
## 0.3.0
* Updated NuGet packages.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bd28e25b2e..679fbbef92 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -33,6 +33,7 @@
+
diff --git a/README.md b/README.md
index 6a905d1d66..049b6d4b66 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,8 @@
## About
*Svg.Skia* can be used as a .NET library or as a CLI application
-to render SVG files based on a [static](http://www.w3.org/TR/SVG11/feature#SVG-static)
-[SVG Full 1.1](https://www.w3.org/TR/SVG11/) subset to raster images or
-to a backend's canvas.
+to render SVG files based on the [SVG Full 1.1](https://www.w3.org/TR/SVG11/)
+document model to raster images or to a backend's canvas.
The `Svg.Skia` is using [SVG](https://github.com/vvvv/SVG) library to load `Svg` object model.
@@ -33,6 +32,15 @@ The `Svg.Skia` can be used in same way as the [SkiaSharp.Extended.Svg](https://g
The `Svg` library has a more complete implementation of the `Svg` document model than [SkiaSharp.Extended.Svg](https://github.com/mono/SkiaSharp.Extended/tree/main/source/SkiaSharp.Extended.Svg)
and the `Svg.Skia` renderer will provide more complete rendering subsystem implementation.
+## Highlights
+
+- `Svg.Custom` now exposes the SVG 1.1 animation object model for `animate`, `set`, `animateMotion`, `animateColor`, `animateTransform`, and `mpath`.
+- `Svg.Custom` and `Svg.Skia` now include typed `pointer-events` handling plus geometry-aware topmost hit testing.
+- `Svg.Skia` now includes a shared interaction dispatcher, shared animation clock/controller, and host-driven animation playback APIs.
+- `Svg.Controls.Skia.Avalonia` and `Svg.Controls.Skia.Uno` now expose animation backend selection, playback rate, frame interval, and resolved-backend diagnostics.
+- Avalonia adds an optional `NativeComposition` animation backend with fallback to `RenderLoop` or `DispatcherTimer` when retained composition is unavailable.
+- `tests/Svg.Skia.Benchmarks` adds a local BenchmarkDotNet harness for the shared animation renderer, and `samples/TestApp` exposes backend and playback controls for manual verification.
+
## NuGet
Svg.Skia is delivered as a NuGet package.
@@ -202,7 +210,7 @@ The `SKSvg` class provides helpers for retrieving elements or drawables at a
given point. The hit-testing methods expect coordinates in picture space:
```C#
-using SkiaSharp;
+using ShimSkiaSharp;
using Svg.Skia;
var svg = new SKSvg();
@@ -220,6 +228,10 @@ When drawing on a transformed canvas you can convert canvas coordinates to
picture coordinates using `TryGetPicturePoint` and then use the hit-testing
methods.
+The runtime also exposes `HitTestTopmostElement(...)` for pointer-dispatch
+scenarios where only the topmost routed target should be returned, and the hit
+test path now respects typed `pointer-events` values.
+
#### Svg control
The `Svg` Avalonia control exposes a `HitTestElements` method that accepts
@@ -229,6 +241,47 @@ a point in control coordinates and returns the matching SVG elements:
var hits = svgControl.HitTestElements(new Point(x, y));
```
+### Animation and interaction
+
+`SKSvg` now exposes a shared animation runtime and a routed interaction layer
+that can be hosted from Avalonia, Uno, or custom Skia surfaces.
+
+```C#
+using System;
+using ShimSkiaSharp;
+using Svg.Skia;
+
+using var svg = new SKSvg();
+
+if (svg.Load("animated.svg") is not null && svg.HasAnimations)
+{
+ svg.AnimationInvalidated += (_, e) => Console.WriteLine(e.Time);
+
+ svg.SetAnimationTime(TimeSpan.FromSeconds(1));
+ svg.AdvanceAnimation(TimeSpan.FromMilliseconds(16));
+ svg.ResetAnimation();
+}
+
+var target = svg.HitTestTopmostElement(new SKPoint(10, 10));
+```
+
+The shared runtime surface includes:
+
+- `HasAnimations`
+- `AnimationTime`
+- `SetAnimationTime(...)`
+- `AdvanceAnimation(...)`
+- `ResetAnimation()`
+- `AnimationInvalidated`
+- `AnimationMinimumRenderInterval`
+- `HasPendingAnimationFrame`
+- `FlushPendingAnimationFrame()`
+- `LastAnimationDirtyTargetCount`
+
+The shared interaction surface includes `SvgInteractionDispatcher`,
+`SvgPointerInput`, routed `Dispatched` events, cursor hints, and optional
+compatibility bridging back into `SvgElement` mouse events.
+
### Avalonia
@@ -248,6 +301,26 @@ Install-Package Svg.Controls.Skia.Avalonia
```
+For animated content, the Skia-backed Avalonia and Uno controls also expose:
+
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
+- `ActualAnimationBackend`
+- `AnimationBackendFallbackReason`
+
+Avalonia supports `Default`, `Manual`, `DispatcherTimer`, `RenderLoop`, and
+`NativeComposition`. Uno supports the same host-driven playback model but falls
+back from `NativeComposition` because it does not currently expose a working
+retained child-visual path.
+
+```XAML
+
+```
+
#### Image control
```XAML
diff --git a/Svg.Skia.slnx b/Svg.Skia.slnx
index a111bfa914..8f5b09614b 100644
--- a/Svg.Skia.slnx
+++ b/Svg.Skia.slnx
@@ -66,7 +66,9 @@
+
+
@@ -81,11 +83,14 @@
+
+
+
diff --git a/externals/SVG b/externals/SVG
index fddd725b48..05f4897776 160000
--- a/externals/SVG
+++ b/externals/SVG
@@ -1 +1 @@
-Subproject commit fddd725b488611b11a28f32e2fcecc9e275c6297
+Subproject commit 05f48977768989cb889334eceb7804fbb822af0f
diff --git a/plan/animation-project-split-plan.md b/plan/animation-project-split-plan.md
new file mode 100644
index 0000000000..8d693820dc
--- /dev/null
+++ b/plan/animation-project-split-plan.md
@@ -0,0 +1,76 @@
+# Animation Project Split Plan
+
+## Goal
+
+Split the reusable animation runtime out of `src/Svg.Skia` into a dedicated project, similar to `src/Svg.SceneGraph`, while keeping `SKSvg`-specific render orchestration inside `Svg.Skia`.
+
+## Why
+
+- reduce `Svg.Skia` assembly scope to Skia/render-host integration
+- make the animation runtime reusable by controls and future backends without pulling in all of `SKSvg`
+- isolate trim/AOT-sensitive animation logic behind a smaller assembly boundary
+- align the architecture with the retained-scene split already introduced by `Svg.SceneGraph`
+
+## Clean Boundary
+
+### Move to `src/Svg.Animation`
+
+- `SvgAnimationClock`
+- `SvgAnimationController`
+- `SvgAnimationFrameState`
+- `SvgAnimationHostBackend` and resolver/capability types
+- `SvgNativeCompositionScene`, `SvgNativeCompositionFrame`, `SvgNativeCompositionLayer`
+- shared animation invalidation policy extracted from `SKSvg.AnimationLayers.cs`
+
+### Keep in `src/Svg.Skia`
+
+- `SKSvg.Model.cs` animation orchestration
+- `SKSvg.AnimationLayers.cs`
+- `SKSvg.NativeComposition.cs`
+- `SKSvg.SceneGraph.cs` retained-scene mutation/render preparation
+- SkiaSharp picture conversion, picture registration/disposal, and drawing
+
+Reason: those files are partial `SKSvg` implementation details and depend directly on `SKSvg` state, `SkiaModel`, and render lifecycle synchronization.
+
+## Dependency Rules
+
+### `Svg.Animation`
+
+- may reference:
+ - `Svg.Custom`
+ - `Svg.Model`
+ - `ShimSkiaSharp`
+ - `SkiaSharp`
+- must not depend on:
+ - `SKSvg`
+ - `Svg.Controls.*`
+ - retained-scene orchestration partials
+
+### `Svg.Skia`
+
+- references:
+ - `Svg.Animation`
+ - `Svg.SceneGraph`
+ - existing model/custom projects
+
+## Required Supporting Changes
+
+1. Add `InternalsVisibleTo` from `Svg.Custom` to `Svg.Animation`.
+2. Add `src/Svg.Animation/Properties/AssemblyInfo.cs` with `InternalsVisibleTo` for `Svg.Skia`.
+3. Add the new project to `Svg.Skia.slnx`.
+4. Update `Svg.Skia.csproj` to reference `Svg.Animation.csproj`.
+5. Physically move animation runtime files from `src/Svg.Skia/Animation` to `src/Svg.Animation`.
+6. Replace the inherited-animation-attribute table in `SKSvg.AnimationLayers.cs` with a shared helper owned by `Svg.Animation`.
+
+## Validation
+
+1. `dotnet format Svg.Skia.slnx --no-restore`
+2. `dotnet build Svg.Skia.slnx -c Release`
+3. `dotnet test Svg.Skia.slnx -c Release`
+
+## Success Criteria
+
+- `src/Svg.Skia/Animation` no longer contains the reusable runtime files
+- `Svg.Skia` builds against the new project boundary
+- Avalonia/Uno host backends and tests continue to compile against the moved types
+- `SKSvg.AnimationLayers.cs` retains only `SKSvg`-bound logic
diff --git a/plan/drawable-removal-scene-graph-cutover-plan.md b/plan/drawable-removal-scene-graph-cutover-plan.md
new file mode 100644
index 0000000000..b05255edd2
--- /dev/null
+++ b/plan/drawable-removal-scene-graph-cutover-plan.md
@@ -0,0 +1,152 @@
+# Drawable Removal And Scene Graph Cutover Plan
+
+Status date: 2026-04-06
+Branch: `feature/svg-animation-runtime`
+
+## Purpose
+
+This document records the retained scene graph migration status, summarizes the major fixes already delivered on this branch, and captures both the removal plan and the completed implementation status for deleting the legacy drawable subsystem.
+
+## Branch Progress So Far
+
+The branch already landed a large amount of retained-scene and animation stabilization work before the drawable-removal pass:
+
+- `4fd9761e` fixed root `viewBox` animation invalidation.
+- `8beb0318` fixed Uno animated picture lifetime after animation rebuilds.
+- `124c222a`, `25a833fe`, `60e4a19b` fixed embedded SVG image recursion and added W3C coverage.
+- `92637cc7`, `c160db8f` fixed retained recursive mask traversal and added regression coverage.
+- `68d97490`, `9b0c8a4b` extracted animation parsing helpers and added parsing regressions.
+- `0a3e9d29`, `e226d8e9`, `e3504346`, `9e86d693` fixed CI regressions around embedded SVG identity, duplicate image state, hidden-node bounds, and unresolved retained filters.
+- `8b7d52eb`, `17c72a04`, `9d701138`, `547c2b05` fixed animation event bridge, timing, playback-rate clamping, and parser edge cases raised during PR review.
+- `b7ce74c2` fixed the `animate-elem-38-t.svg` root `viewBox` regression by disabling retained caching/native composition when document-root animation targets are present.
+- `d6f4d3fd`, `3fe70d7e` restored retained positioned text rendering for Windows CI and added regression coverage.
+
+These fixes establish the retained scene graph as the primary `SKSvg` rendering path and close the major correctness gaps found during the migration.
+
+## Current Architecture
+
+### Main runtime path
+
+The main `SKSvg` path is retained-scene-based:
+
+- `SvgSceneRuntime.TryCompile(...)`
+- `SvgSceneDocument`
+- `SvgSceneRenderer.Render(...)`
+- `SkiaModel.ToSKPicture(...)`
+
+Key entry points:
+
+- `src/Svg.Skia/SKSvg.Model.cs`
+- `src/Svg.Skia/SKSvg.SceneGraph.cs`
+- `src/Svg.SceneGraph/SvgSceneCompiler.cs`
+- `src/Svg.SceneGraph/SvgSceneDocument.cs`
+- `src/Svg.SceneGraph/SvgSceneRenderer.cs`
+
+### Legacy drawable path
+
+The drawable subsystem existed at the start of this pass under:
+
+- `src/Svg.Model/Drawables/**`
+- `src/Svg.Model/Services/HitTestService.cs`
+- `src/Svg.Model/Editing/DrawableWalker.cs`
+- `src/Svg.Model/Editing/DrawableEditingExtensions.cs`
+
+That legacy path has now been removed from production code. The retained scene graph is the only runtime renderer and hit-test implementation.
+
+## Implementation Status After This Pass
+
+This pass completed the drawable retirement plan:
+
+- deleted `src/Svg.Model/Drawables/**`
+- deleted drawable-only infrastructure:
+ - `src/Svg.Model/Editing/DrawableWalker.cs`
+ - `src/Svg.Model/Editing/DrawableEditingExtensions.cs`
+ - `src/Svg.Model/Services/HitTestService.cs`
+ - `src/Svg.Model/Services/MarkerService.cs`
+ - `src/Svg.Model/SvgFilterContext.cs`
+- removed drawable-only APIs from shared services:
+ - `PaintingService.RecordPicture(...)`
+ - `PaintingService.CreatePicture(...)`
+ - `PaintingService.GetFillPaint(...)`
+ - `PaintingService.GetStrokePaint(...)`
+ - `PaintingService.GetOpacityPaint(SvgElement)`
+ - `MaskingService.GetSvgElementMask(...)`
+- deleted drawable-only unit tests and clone coverage
+- tightened the architecture guard so production code now allows zero `Svg.Model.Drawables` references
+
+Result:
+
+- retained scene graph is the only runtime renderer
+- retained scene graph is the only runtime hit-test implementation
+- retained scene graph is the only runtime implementation for filters, masks, and marker generation
+- `Svg.Model` now contains only shared helpers and non-drawable services that are still needed by retained code
+
+### Important dependency constraint
+
+`Svg.SceneGraph` references `Svg.Model`, not the other way around:
+
+- `Svg.Model` cannot directly call retained-scene compiler/renderer code today.
+- This prevents reusing retained-scene compiler entry points from inside `Svg.Model`.
+
+However, the deeper analysis for full drawable removal changes the conclusion:
+
+- full drawable deletion does **not** require `Svg.Model` to render through `Svg.SceneGraph`
+- all drawable types are internal implementation details
+- the public `SKSvg` runtime already renders, filters, hit-tests, masks, and generates markers through retained-scene code
+
+That means the lowest-risk path is to retire the legacy drawable renderer instead of preserving it. The dependency boundary still matters for cleanup sequencing, but it is **not** a blocker to deleting drawables completely.
+
+## Delivered Cleanup
+
+This cutover also shipped the follow-up cleanup needed to remove lingering drawable-era terminology and duplication:
+
+- `SvgPatternPaintStateResolver` centralizes pattern inheritance and transform resolution for both `Svg.Model` and the retained scene graph.
+- retained-scene internals now use `IsRenderable` terminology consistently instead of `IsDrawable`.
+- the zero-reference architecture guard prevents production code from depending on `Svg.Model.Drawables` again.
+
+## Final State Summary
+
+The repository is in the intended post-cutover state:
+
+- retained scene graph is the only `SKSvg` runtime render path
+- retained scene graph is the only runtime hit-test path
+- retained scene graph is the only runtime implementation for filters, masks, and marker generation
+- `Svg.Model` contains only shared helpers and non-drawable services needed by retained code
+- production code contains zero `Svg.Model.Drawables` references
+
+## Validation Status
+
+The drawable-removal cutover is considered complete because all of the following are true:
+
+- no production runtime path calls `DrawableFactory.Create(...)`
+- no production `src/**` file references `Svg.Model.Drawables`
+- build passes for `Svg.Skia.slnx`
+- full test suite passes for `Svg.Skia.slnx`
+
+## Optional Follow-Up
+
+Any remaining work is cosmetic rather than architectural:
+
+- continue renaming editor/private members that still use historical `Drawable` wording
+- simplify historical notes in this document if later branch history makes them obsolete
+
+## Validation Plan
+
+For each removal phase:
+
+1. Run targeted unit tests for touched subsystems.
+2. Run `dotnet build Svg.Skia.slnx -c Release`.
+3. Run `dotnet test Svg.Skia.slnx -c Release`.
+4. Keep W3C and resvg retained-scene regressions green, especially:
+ - animated root `viewBox`
+ - embedded SVG image recursion
+ - recursive mask payloads
+ - positioned text
+ - pattern resource mutation coverage
+
+## Immediate Next Steps After This Change
+
+1. Split shared retained helpers out of `PaintingService` so its drawable-only path can be deleted instead of preserved.
+2. Delete drawable-only editing and hit-test helpers that have no non-drawable production consumers.
+3. Remove `SvgFilterContext`, `MarkerService`, and `GetSvgElementMask(...)` together with `src/Svg.Model/Drawables/**`.
+4. Tighten the architecture guard allow-list to zero.
diff --git a/plan/pr-summary-svg-animation-runtime.md b/plan/pr-summary-svg-animation-runtime.md
new file mode 100644
index 0000000000..990327ea2e
--- /dev/null
+++ b/plan/pr-summary-svg-animation-runtime.md
@@ -0,0 +1,272 @@
+# PR Summary: SVG Animation Runtime, Retained Scene Graph, and Rendering Cutover
+
+## Overview
+
+This branch started as shared SVG animation and interaction work, then grew into a larger rendering-architecture change.
+
+It adds SVG 1.1 animation DOM support in `Svg.Custom`, shared pointer interaction and geometry-aware hit testing, a shared animation runtime in `SKSvg`, host playback backends for Avalonia and Uno, and an Avalonia retained `NativeComposition` path for supported scenes.
+
+On top of that original scope, the branch introduces a retained scene-graph runtime, migrates rendering/editor/animation flows onto that retained runtime, splits the retained scene graph into its own `Svg.SceneGraph` project, and then splits the shared animation runtime into a new `Svg.Animation` project. The branch also removes several unshipped compatibility shims and moves the rendering pipeline toward the retained scene graph as the single runtime representation for the next major release.
+
+Base branch: `master`
+Merge-base: `076e0a22e3fe0220db951710d7799dc92b42576d`
+
+Branch diff vs `master`:
+
+- `163` files changed
+- about `25,165` insertions
+- about `624` deletions
+
+## Commit Progression After The Last PR Summary Update
+
+The earlier PR summary stopped at the first animation/native-composition/doc pass. Since then the branch added:
+
+- retained scene graph foundation and mutation routing
+- retained-scene-first editor workflows
+- retained-scene rendering fallback for animation
+- removal of the retained compiler drawable bridge
+- retained hit-testing and filter/mask parity fixes
+- scene-graph major-release cutover work that removes drawable-first public rendering flows
+- `Svg.SceneGraph` split into a separate project
+- reflection removal from animation and scene compilation helpers
+- removal of unshipped obsolete compatibility APIs
+- `Svg.Animation` split into a separate project
+
+Relevant later commits include:
+
+- `f106e50a` Implement subtree animation invalidation
+- `a736db88` Prefer default animation backends
+- `20058d0a` Fix animation timing and additive state
+- `9e5c315d` Gate native composition to renderable roots
+- `7c3b5edf` Add retained scene graph foundation
+- `9e8bccf1` Migrate editor workflows to retained nodes
+- `65c23848` Route interaction through retained scene
+- `796370f3` Use retained scenes for animation fallback
+- `6e86c57a` Remove retained compiler drawable bridge
+- `41f4b71e` Fix retained mask hit testing
+- `537eb2ef` Fix event timing instance handling
+- `7865aab6` Prune hidden retained hit-test subtrees
+- `1cf14385` Remove drawable editor fallbacks
+- `aaa16665` Migrate retained animation runtime APIs
+- `0bba357f` Remove animation reflection access
+- `60767e4d` Drop obsolete retained API shims
+- `5fc313de` Split retained scene graph project
+- `754035ca` Cut over rendering to retained scenes
+- `eb990cc4` Fix retained filter parity and hit testing
+- `e06ca06f` Split animation runtime into `Svg.Animation`
+
+## Main Functional Changes
+
+### 1. SVG animation DOM and shared runtime
+
+The branch adds local SVG 1.1 animation element support in `Svg.Custom` and a shared animation runtime in `SKSvg`.
+
+This includes:
+
+- `set`, `animate`, `animateColor`, `animateTransform`, and `animateMotion`
+- event-driven begin/end timing
+- additive and accumulate handling
+- animation clock/time control APIs
+- host playback backend resolution for Avalonia and Uno
+- animation invalidation and frame-state tracking
+
+The runtime was later cleaned up by:
+
+- removing reflection-based animated attribute access in favor of explicit runtime bridges
+- fixing timing edge cases such as dotted event ids, repeat handling, spline handling, and zero-duration cases
+- moving the shared runtime into a dedicated `Svg.Animation` project
+
+Key files:
+
+- `src/Svg.Custom/Animation/*`
+- `src/Svg.Animation/Animation/*`
+- `src/Svg.Animation/SvgAnimationInvalidation.cs`
+- `src/Svg.Skia/SKSvg.AnimationLayers.cs`
+- `src/Svg.Controls.Skia.Avalonia/Svg.cs`
+- `src/Svg.Controls.Skia.Uno/Svg.cs`
+
+### 2. Shared interaction and geometry-aware hit testing
+
+The branch adds typed `pointer-events` support, routed pointer dispatch, geometry-aware hit testing, and retained-scene-first hit testing.
+
+This includes:
+
+- topmost-element and topmost-scene-node targeting
+- clip-path and mask-aware hit testing
+- routed tunnel/target/bubble dispatch
+- pressed-target capture behavior
+- cursor resolution
+- retained-scene hit-test pruning for hidden or suppressed subtrees
+
+Key files:
+
+- `src/Svg.Custom/Interaction/SvgPointerEvents.cs`
+- `src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs`
+- `src/Svg.SceneGraph/SvgSceneHitTestService.cs`
+- `tests/Svg.Skia.UnitTests/HitTestTests.cs`
+
+### 3. Avalonia/Uno host playback and native composition
+
+The host controls now expose:
+
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
+- `ActualAnimationBackend`
+- fallback/capability reporting
+
+Avalonia additionally gained a retained native-composition path for supported scenes, plus TestApp support for selecting and exercising it.
+
+This work also includes follow-up fixes for:
+
+- stale/disposed picture usage
+- initial retained visual activation
+- clipping of translated retained layers
+- descendant opacity preservation
+- fallback from unsupported native-composition scenes
+
+Key files:
+
+- `src/Svg.Controls.Skia.Avalonia/Svg.cs`
+- `src/Svg.Controls.Skia.Avalonia/Composition/SvgCompositionVisualScene.cs`
+- `src/Svg.Controls.Skia.Uno/Svg.cs`
+- `samples/TestApp/Views/MainView.axaml`
+- `samples/TestApp/Views/MainView.axaml.cs`
+
+### 4. Retained scene graph foundation
+
+The branch introduces a retained scene graph as a real runtime representation, not just a transient export format.
+
+The retained layer now covers:
+
+- scene compilation from SVG DOM
+- retained node/resource indexing
+- retained rendering
+- retained mutation routing
+- retained resource ownership for clip, mask, filter, paint, and text payloads
+- retained-node hit testing
+- retained-scene-based tooling helpers
+
+The compiler progressively moved away from drawable-bridge fallbacks and toward direct retained compilation for core shapes, structural wrappers, text, masks, filters, `use`, `switch`, and image-backed scenarios.
+
+Key files:
+
+- `src/Svg.SceneGraph/SvgSceneCompiler.cs`
+- `src/Svg.SceneGraph/SvgSceneDocument.cs`
+- `src/Svg.SceneGraph/SvgSceneNode.cs`
+- `src/Svg.SceneGraph/SvgSceneResource.cs`
+- `src/Svg.SceneGraph/SvgSceneRenderer.cs`
+- `src/Svg.SceneGraph/SvgSceneRuntime.cs`
+
+### 5. Major-release rendering cutover away from drawable-first public rendering APIs
+
+Because this work targets a future major release, the branch starts removing drawable-first public rendering flows instead of preserving backward-compatible shims.
+
+This cutover includes:
+
+- routing `SKSvg` render/model creation through retained scene compilation
+- removing `SvgService.ToDrawable(...)` / `SvgService.ToModel(...)`
+- switching `Svg.Controls.Avalonia`, source generation, and CLI/sample consumers to retained-scene model generation
+- shrinking drawable usage down to remaining internal compatibility seams instead of public rendering APIs
+
+Key files:
+
+- `src/Svg.Skia/SKSvg.Model.cs`
+- `src/Svg.Model/Services/SvgService.cs`
+- `src/Svg.Controls.Avalonia/SvgSource.cs`
+- `src/Svg.SourceGenerator.Skia/SvgSourceGenerator.cs`
+- `samples/SvgToPng/ViewModels/MainWindowViewModel.cs`
+- `samples/svgc/Program.cs`
+
+### 6. Animation performance and invalidation follow-up
+
+The branch adds layered animation redraw and then pushes further toward retained-scene incremental behavior.
+
+This includes:
+
+- static/animated layer caching
+- subtree animation invalidation
+- retained-scene-driven animation fallback rendering
+- fixes for inherited animated attributes and resource-driven invalidation
+- benchmark coverage for frame advancement
+
+Key files:
+
+- `src/Svg.Skia/SKSvg.AnimationLayers.cs`
+- `tests/Svg.Skia.Benchmarks/SvgAnimationFrameBenchmarks.cs`
+
+### 7. Project structure cleanup
+
+The branch splits the new architecture into dedicated projects:
+
+- `src/Svg.SceneGraph/Svg.SceneGraph.csproj`
+- `src/Svg.Animation/Svg.Animation.csproj`
+
+This keeps:
+
+- retained-scene runtime/compiler/resource logic in `Svg.SceneGraph`
+- shared animation runtime in `Svg.Animation`
+- SkiaSharp conversion, host integration, and `SKSvg` façade code in `Svg.Skia`
+
+It also removes unshipped obsolete compatibility APIs and replaces reflection-based internal access with explicit runtime bridges where possible.
+
+## Documentation And Planning Artifacts
+
+The branch adds and updates implementation docs and plans for the new architecture, including:
+
+- `plan/svg-interaction-animation-phased-implementation.md`
+- `plan/svg-retained-scene-graph-rewrite-spec.md`
+- `plan/remaining-scene-graph-animation-work-plan.md`
+- `plan/scene-graph-major-release-cutover-plan.md`
+- `plan/animation-project-split-plan.md`
+
+User-facing docs were also refreshed earlier in the branch, including:
+
+- `README.md`
+- `CHANGELOG.md`
+- `site/articles/guides/interaction-and-animation.md`
+- package docs for `Svg.Custom`, `Svg.Skia`, `Svg.Controls.Skia.Avalonia`, and `Svg.Controls.Skia.Uno`
+
+## Validation
+
+Branch work included repeated validation with:
+
+- `dotnet format Svg.Skia.slnx --no-restore`
+- `dotnet build Svg.Skia.slnx -c Release`
+- `dotnet test Svg.Skia.slnx -c Release`
+
+Recent validation specifically covered:
+
+- successful build of the new `Svg.SceneGraph` split
+- successful build of the new `Svg.Animation` split
+- retained-scene regression coverage for background-image filters, invalid filter suppression, and hit testing
+- targeted resvg regression slices for `e-feDiffuseLighting` and malformed `e-feConvolveMatrix`
+
+Expanded tests include work in:
+
+- `tests/Svg.Model.UnitTests/*`
+- `tests/Svg.Skia.UnitTests/HitTestTests.cs`
+- `tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs`
+- `tests/Svg.Skia.UnitTests/SvgRetainedSceneGraphTests.cs`
+- `tests/Svg.Skia.UnitTests/SKSvgNativeCompositionTests.cs`
+- `tests/Svg.Controls.Avalonia.UnitTests/SvgSourceTests.cs`
+- `tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs`
+
+## Architectural Outcome
+
+The branch is no longer just “animation support.”
+
+It now delivers:
+
+- shared SVG animation and interaction support
+- retained native composition support in Avalonia
+- a retained scene graph runtime with direct DOM compilation
+- major-release migration away from drawable-first rendering APIs
+- separate `Svg.SceneGraph` and `Svg.Animation` projects
+
+The remaining long-term direction is clear:
+
+- `SvgDocument` remains the DOM/source model
+- `Svg.SceneGraph` is the retained render/runtime representation
+- `Svg.Animation` owns shared animation timing/evaluation/runtime behavior
+- `Svg.Skia` becomes the Skia/host integration layer on top of those shared runtimes
diff --git a/plan/remaining-scene-graph-animation-work-plan.md b/plan/remaining-scene-graph-animation-work-plan.md
new file mode 100644
index 0000000000..14ddd19405
--- /dev/null
+++ b/plan/remaining-scene-graph-animation-work-plan.md
@@ -0,0 +1,171 @@
+# Remaining Scene Graph And Animation Work
+
+## Current Summary
+
+At this point, the core retained scene graph exists and the original six-phase animation plan is done. What is left is mostly removal of the remaining legacy seams and fallback paths.
+
+**Scene Graph**
+- Replace the last per-element drawable extraction bridge for unsupported visual/resource pipelines with direct retained display-list generation, as called out in [svg-retained-scene-graph-rewrite-spec.md](/Users/wieslawsoltes/GitHub/Svg.Skia/plan/svg-retained-scene-graph-rewrite-spec.md).
+- Reduce load-time duplication where retained compilation still temporarily generates drawables only for compatibility payload extraction.
+- Remove the remaining compatibility proxies at public/editor boundaries.
+- Migrate the last editor commands that still lean on drawables:
+ - selected-element export
+ - align/distribute helpers
+ - remaining path/shape manipulation helpers that still use drawable geometry/order
+- After parity is proven on those edge cases, remove the older non-retained fallback paths entirely.
+
+**Animation**
+- The planned shared animation runtime is complete per [svg-interaction-animation-phased-implementation.md](/Users/wieslawsoltes/GitHub/Svg.Skia/plan/svg-interaction-animation-phased-implementation.md). What remains is follow-up work beyond that plan.
+- Eliminate the remaining fallback full-frame rebuilds for non-drawable resource pipelines during animation:
+ - gradients
+ - clip-path resources
+ - filter graphs
+- Route those animation/resource changes fully through retained nodes/resources so animated frames do not fall back to the older drawable/picture rebuild path.
+- Remove the transient animation-layer caching architecture once retained-scene mutations cover those cases well enough.
+- Optional later step: map compatible SVG animation cases onto true host-native animation objects instead of only using host-native scheduling.
+
+**Practical order**
+1. Finish the remaining editor/public drawable-dependent commands.
+2. Remove the last retained-compiler drawable bridges.
+3. Move resource-driven animation updates fully onto retained node/resource mutations.
+4. Delete the old fallback rebuild paths.
+5. Only then consider deeper host-native animation object mapping.
+
+So the short answer is: the remaining work is not “build a scene graph” anymore. It is “finish removing the old drawable-first assumptions everywhere they still leak through,” especially in resource-heavy animation and a few editor commands.
+
+## Detailed Implementation Plan
+
+### Phase 1: Editor And Public API Drawable-Seam Removal
+
+Goals:
+- make retained scene nodes the primary geometry/render source for editor commands
+- keep drawable usage only as a temporary fallback
+- preserve current user-visible behavior while removing drawable-first assumptions
+
+Tasks:
+1. Export selected element from retained scene state.
+ - prefer `SvgSceneNode` plus `SKSvg.CreateRetainedSceneNodePicture(...)`
+ - fall back to drawable snapshot only when no retained node is available
+ - preserve current PNG export API and behavior
+2. Move align/distribute helpers from drawable bounds to explicit retained bounds.
+ - align/distribute should operate on `SKRect` bounds rather than `DrawableBase`
+ - workspace should gather bounds from retained scene nodes first
+3. Audit remaining editor commands that still depend on drawable geometry/order.
+ - path/shape manipulation helpers
+ - selection/export helpers
+ - any command using `DrawableBase.TransformedBounds` directly
+4. Add regression coverage for retained-scene-first editor operations.
+ - export through retained node when drawable is absent
+ - align/distribute translation updates driven by retained bounds
+
+Exit criteria:
+- editor export works with retained scene node only
+- align/distribute no longer require drawable instances
+- tests prove retained-scene-first behavior
+
+### Phase 2: Final Retained Compiler Bridge Removal
+
+Goals:
+- eliminate remaining per-element drawable extraction during retained compilation
+- reduce load duplication and stop using transient drawable generation for compatibility-only payload extraction
+
+Tasks:
+1. inventory remaining `DrawableBridge` compilation cases
+2. replace each remaining bridge with direct retained display-list generation
+3. move compatibility-only payload builders into retained compiler/runtime services
+4. extend parity coverage for the previously bridged cases
+
+Exit criteria:
+- retained compiler does not require per-element drawable extraction for supported rendering/resource cases
+- compilation strategy map shows only intentional temporary fallback categories, if any
+
+### Phase 3: Retained Resource-Driven Animation Updates
+
+Goals:
+- animate resource-backed changes through retained nodes/resources directly
+- stop falling back to full-frame drawable/picture rebuild for resource changes
+
+Tasks:
+1. route gradient, clip-path, and filter mutations through retained resource dependency updates
+2. ensure animation frame deltas can invalidate and rebuild only affected retained resources/nodes
+3. preserve hit testing and host extraction correctness under retained resource mutation
+4. expand parity coverage for animated resource-backed documents
+
+Exit criteria:
+- animated resource changes use retained mutation routing instead of fallback full-frame rebuild
+
+### Phase 4: Legacy Fallback Retirement
+
+Goals:
+- remove the older non-retained fallback render/update paths after retained parity is proven
+
+Tasks:
+1. delete now-redundant drawable-first fallback paths in shared rendering/update code
+2. remove temporary compatibility proxies where downstream consumers have retained replacements
+3. update docs/specs to mark retained graph as the only authoritative runtime path
+
+Exit criteria:
+- common render, interaction, and animation paths run entirely from retained scene state
+
+### Phase 5: Optional Native Animation Object Mapping
+
+Goals:
+- explore mapping compatible retained SVG animation cases onto host-native animation objects
+- keep shared retained runtime as the authoritative semantics layer
+
+Tasks:
+1. identify safe subsets for native object mapping
+2. prototype retained-node to host animation-object translation
+3. add capability/fallback reporting without changing shared semantic ownership
+
+Exit criteria:
+- optional native mapping exists only where parity and fallback behavior are explicit
+
+## Current Implementation Target
+
+This turn should finish the remaining Phases 1 through 4:
+- remove the remaining editor root-drawable seams and keep retained nodes as the primary editor selection/render source
+- delete the retained compiler's dead drawable-bridge path
+- move the remaining animation fallback render path onto retained scene state
+- update validation and status documentation to match the retained-scene-first runtime
+
+Validation for this slice:
+- `dotnet format Svg.Skia.slnx --no-restore`
+- `dotnet build Svg.Skia.slnx -c Release --no-restore`
+- `dotnet test Svg.Skia.slnx -c Release --no-build`
+
+## Implementation Status
+
+Phases 1 through 4 are now effectively implemented in the current working tree for the shared runtime and editor workflows.
+
+Completed:
+- selected-element export now prefers retained scene node rendering and falls back to drawable snapshot export only if no retained node export is available
+- align/distribute and additional editor selection workflows now gather bounds and transforms from retained scene nodes first instead of depending on root drawable lookup
+- layer loading no longer walks the root drawable tree to resolve editor layer metadata; retained scene nodes are the authoritative layer geometry source
+- the retained compiler no longer keeps the drawable-bridge compilation path alive for unsupported element fallback; non-rendering containers compile directly as retained nodes
+- dead drawable-bridge helper code has been removed from the retained compiler, reducing load-time duplication and eliminating the last internal drawable extraction path there
+- animation frames that fall outside animation-layer caching now render from the retained scene document instead of calling the older drawable-first `RenderSvgDocument(...)` path
+- regression coverage now proves retained-scene animation fallback for paint-server-backed animation targets and direct retained compilation for non-rendering container nodes
+
+Primary files:
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Editor.Skia.Avalonia/SvgEditorWorkspace.axaml.cs`
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Editor.Svg/LayerService.cs`
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Skia/SKSvg.Model.cs`
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Skia/SKSvg.SceneGraph.cs`
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/src/Svg.Skia/SceneGraph/SvgSceneCompiler.cs`
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs`
+- `/Users/wieslawsoltes/GitHub/Svg.Skia/tests/Svg.Skia.UnitTests/SvgRetainedSceneGraphTests.cs`
+
+What remains after this file's original scope:
+- the deepest future work is broader host-native animation object translation beyond the currently safe retained subset
+- retained-first public APIs are now primary, and drawable-returning helpers are explicit legacy compatibility shims
+
+## Additional Status Update
+
+Completed after the original write-up:
+- editor path/selection APIs no longer keep drawable fallbacks; retained scene nodes are the only geometry/edit source for those workflows
+- retained-node and retained-element picture/model helpers are now the primary public API for fragment extraction
+- drawable-returning public helpers such as drawable hit testing and retained-scene drawable creation are now legacy compatibility shims and are marked obsolete in favor of retained scene node APIs
+- canvas-space retained scene hit testing now has direct scene-node overloads, so callers no longer need drawable proxy APIs for transformed hit tests
+- retained resource-backed animation documents now participate in animation-layer caching, and the benchmark suite reflects resource-backed retained caching instead of the older fallback expectation
+- Avalonia native composition now keeps the existing retained visual content when only layer visual state changes and the picture instance is unchanged, reducing unnecessary compositor content re-submission for the safe retained-native subset
diff --git a/plan/scene-graph-major-release-cutover-plan.md b/plan/scene-graph-major-release-cutover-plan.md
new file mode 100644
index 0000000000..ea21dc3ab5
--- /dev/null
+++ b/plan/scene-graph-major-release-cutover-plan.md
@@ -0,0 +1,118 @@
+# Scene Graph Major-Release Cutover Plan
+
+## Goal
+
+Make the retained scene graph the only runtime rendering model for Svg.Skia.
+
+For the major release:
+
+- `SvgDocument` remains the parsed DOM/source model.
+- `Svg.SceneGraph` becomes the only renderable runtime representation.
+- `Svg.Skia` becomes a scene-to-Skia adapter and host/runtime integration layer.
+- drawable-based runtime conversion APIs are removed from the public surface.
+- old compatibility seams are removed instead of preserved.
+
+## Status
+
+Implemented in the current branch:
+
+1. `SKSvg` runtime rendering paths now compile and render through `Svg.SceneGraph`.
+2. `SvgService.ToDrawable(...)` and `SvgService.ToModel(...)` are removed.
+3. `Svg.Controls.Avalonia`, `Svg.SourceGenerator.Skia`, `svgc`, and `SvgToPng` now use retained-scene model creation.
+4. `Svg.SceneGraph` has its own project and owns retained runtime compilation/model creation.
+5. drawable-based editor hit-selection fallback is removed.
+6. `Svg.Model.Drawables` and drawable-only helpers are demoted to internal implementation instead of public runtime API.
+
+Remaining work after this cutover is optional follow-up, not required for the major-release architectural switch:
+
+1. delete drawable-only tests if the repo no longer wants to carry internal drawable implementation coverage
+2. optionally move the remaining internal drawable implementation out of `Svg.Model` entirely if the team wants a stricter package split
+3. optionally evolve native composition from retained-layer hosting into true host-native animation object mapping where parity can be preserved
+
+## Current Gap Summary
+
+The retained scene graph is already the primary runtime for rendering, hit testing, editor integration, incremental animation work, and native composition. The remaining cutover gaps are concentrated in legacy convenience APIs and a small number of drawable-era helper paths.
+
+The main seams to remove are:
+
+1. `SKSvg` still exposes drawable-shaped runtime state and static drawable-based helper paths.
+2. `SvgService` still publishes `ToDrawable(...)` and `ToModel(...)`.
+3. Some tooling and samples still depend on `SvgService.ToModel(...)` or drawable snapshots.
+4. The scene graph still carries a stale `DrawableBridge` compilation strategy marker even though retained compilation is now the only intended strategy.
+
+## Required End State
+
+### Public/runtime architecture
+
+1. `Svg.Model.Drawables` is no longer part of the supported runtime API surface.
+2. `SKSvg` no longer exposes `Drawable`.
+3. `SvgService` no longer exposes drawable-based render conversion methods.
+4. All runtime rendering paths use:
+
+`DOM -> Svg.SceneGraph -> Shim picture -> SkiaSharp picture`
+
+### Packaging split
+
+1. `Svg.SceneGraph` owns retained compilation, retained resources, retained hit testing, retained rendering, and retained model creation.
+2. `Svg.Skia` owns SkiaSharp conversion, host animation/composition integration, and framework-facing helpers.
+3. Consumers that need shim pictures should use retained-scene APIs instead of drawable conversion APIs.
+
+### Tooling/sample cutover
+
+1. `Svg.Controls.Avalonia` uses retained scene model creation.
+2. `Svg.SourceGenerator.Skia` uses retained scene model creation.
+3. `svgc` uses retained scene model creation.
+4. `SvgToPng` uses retained scene model creation.
+
+## Implementation Plan
+
+### Phase 1: Introduce retained-scene render helpers
+
+1. Add a public retained-scene runtime helper in `Svg.SceneGraph` for:
+ - compiling an `SvgFragment` or `SvgDocument`
+ - creating a shim `SKPicture`
+2. Ensure fragment rendering handles non-document fragments without requiring drawable fallback.
+
+### Phase 2: Remove drawable-based runtime entry points
+
+1. Reimplement `SKSvg` static helper paths on top of retained scene rendering.
+2. Reimplement `SKSvg` document rendering on top of retained scene rendering only.
+3. Remove `SKSvg.Drawable`.
+4. Remove drawable assignments from animation-layer and render paths.
+
+### Phase 3: Migrate remaining consumers
+
+1. Update `Svg.Controls.Avalonia` to use retained-scene picture creation.
+2. Update `Svg.SourceGenerator.Skia` to use retained-scene picture creation.
+3. Update `svgc` to use retained-scene picture creation.
+4. Update `SvgToPng` to use retained-scene picture creation and remove stored drawable state.
+
+### Phase 4: Remove drawable conversion APIs
+
+1. Delete `SvgService.ToDrawable(...)`.
+2. Delete `SvgService.ToModel(...)`.
+3. Remove unused drawable-related imports from `SvgService` and `SKSvg`.
+
+### Phase 5: Final architecture cleanup
+
+1. Remove the stale `DrawableBridge` compilation strategy state.
+2. Keep only retained compilation strategy metadata.
+3. Run full format/build/test validation and fix fallout.
+
+## Benefits
+
+1. One runtime truth instead of parallel drawable and retained render trees.
+2. Lower memory overhead by avoiding duplicated intermediate render objects.
+3. Faster loading by removing `DOM -> drawable -> picture` conversion.
+4. Cleaner incremental updates because retained nodes/resources remain the only mutable render state.
+5. Cleaner public API for the major release.
+6. Better future portability because retained scene graph code stays SkiaSharp-independent.
+
+## Completion Criteria
+
+This cutover is complete when:
+
+1. no runtime-facing API in `SKSvg` exposes drawable state
+2. no public `SvgService` API returns drawable-based render models
+3. all remaining repo consumers use retained-scene model creation
+4. full solution build and tests pass
diff --git a/plan/svg-interaction-animation-phased-implementation.md b/plan/svg-interaction-animation-phased-implementation.md
new file mode 100644
index 0000000000..f532f8aa72
--- /dev/null
+++ b/plan/svg-interaction-animation-phased-implementation.md
@@ -0,0 +1,528 @@
+# SVG Interaction and Animation Phased Implementation Plan
+
+## Goal
+
+Add a shared, renderer-level interaction and animation architecture to `Svg.Skia` that:
+
+- works across UI frameworks
+- keeps SVG semantics in shared code
+- uses framework-specific input, frame scheduling, invalidation, and optional native animation backends only at the edge
+
+This plan is intentionally split into phases so interaction can ship before the full SVG animation runtime.
+
+## Implementation status
+
+- Phase 1 interaction foundation has been implemented.
+- Phase 2 event routing has been implemented.
+- Phase 3 hit-test correctness has been implemented.
+- Phase 4 shared animation runtime core has been implemented.
+- Phase 5 performance and incremental redraw has been implemented for the current shared-renderer scope.
+- Phase 6 optional native host animation backends has been implemented for host-side scheduling and backend selection.
+- Post-phase follow-up work is now tracked in this document, with the first incremental redraw follow-up slice and the initial benchmark harness both implemented.
+- The first implementation slice is in place in:
+ - `src/Svg.Skia/SKSvg.Interaction.cs`
+ - `src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs`
+ - `src/Svg.Controls.Skia.Avalonia/Svg.cs`
+ - `src/Svg.Controls.Skia.Uno/Svg.cs`
+ - `tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs`
+- The shared animation runtime slice is now in place in:
+ - `src/Svg.Custom/Animation/SvgDocument.Animation.cs`
+ - `src/Svg.Skia/Animation/SvgAnimationClock.cs`
+ - `src/Svg.Skia/Animation/SvgAnimationController.cs`
+ - `src/Svg.Skia/Animation/SvgAnimationFrameState.cs`
+ - `src/Svg.Skia/Animation/SvgAnimationHostBackend.cs`
+ - `src/Svg.Skia/SKSvg.Model.cs`
+ - `src/Svg.Controls.Skia.Avalonia/Svg.cs`
+ - `src/Svg.Controls.Skia.Uno/Svg.cs`
+ - `tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs`
+ - `tests/Svg.Skia.UnitTests/SvgAnimationHostBackendResolverTests.cs`
+- Current behavior delivered by this slice:
+ - topmost leaf hit testing for event targeting
+ - shared pointer dispatcher
+ - shared event stream independent of framework-specific event args
+ - optional bridge into `SvgElement` mouse events for ID-bearing elements
+ - Avalonia and Uno adapter wiring
+ - tunnel, target, and bubble routing through the SVG ancestry path
+ - pressed-target capture routing for shared move, wheel, and release dispatch until pointer release
+ - routed event bubbling from target to SVG ancestors
+ - handled-state short-circuiting for shared routed dispatch
+ - cursor hint resolution from SVG `cursor` attributes through the shared contract
+ - native cursor application in the Avalonia and Uno host wrappers
+ - typed `SvgPointerEvents` parsing/inheritance in `Svg.Custom`
+ - geometry-aware point hit testing for path-backed drawables instead of bounds-only checks
+ - pointer-event-specific fill/stroke/all target resolution through shared hit-testing
+ - clip-path and conservative mask-aware hit rejection during point targeting
+ - manual shared animation clock and controller in `Svg.Skia`
+ - DOM-clone-based animated document evaluation from the `Svg.Custom` animation object model
+ - typed target-property application through the generated `Svg` property metadata
+ - initial interpolation support for `set`, `animate`, `animateColor`, `animateTransform`, and `animateMotion`
+ - event-based SMIL `begin` and `end` timing tied to shared pointer dispatch
+ - `animateMotion` evaluation from `path`, `mpath`, `values`, and `from`/`to`/`by`-derived motion paths
+ - additive and accumulate handling for scalar, transform, color, and motion animation cases covered by the shared runtime
+ - `SKSvg` render-state rebuild on animation time changes
+ - shared animation invalidation contract consumed by the Avalonia and Uno wrappers
+ - cached frame-state evaluation in the shared animation controller
+ - dirty-target diffing between the last rendered animation frame and the next evaluated frame
+ - persistent animated-document reuse instead of per-frame DOM deep-copy churn
+ - no-op render suppression when different clock times resolve to the same effective SVG state
+ - explicit pending-frame and minimum-render-interval hooks for host-level throttling
+ - shared requested/actual host animation backend resolution with capability and fallback reporting
+ - Avalonia host playback backends using `DispatcherTimer` and `TopLevel.RequestAnimationFrame(...)`
+ - Uno host playback backends using `DispatcherQueueTimer` and `CompositionTarget.Rendering`
+ - animated-source cache isolation so replayed cached documents restart from a clean shared-runtime state
+- The scoped phased plan is fully implemented.
+
+## Post-phase follow-up roadmap
+
+The scoped six-phase plan is complete. The remaining animation work is follow-up optimization and native-integration exploration beyond the original phased scope.
+
+### Order
+
+1. subtree and incremental picture invalidation in `SKSvg`
+2. benchmark and profiling harness for animation-frame cost
+3. limited true host-native composition mapping only after the shared runtime and incremental renderer are stable
+
+### Current follow-up slice
+
+- Implemented:
+ - incremental redraw in `SKSvg` using cached static content plus rebuilt animated top-level animated roots where the drawable/model pipeline can safely support it
+ - recursive subtree-level shim and `SkiaSharp.SKPicture` invalidation within animated top-level roots, reusing unchanged descendant subtree pictures instead of re-recording the whole animated top-level root on every effective frame
+ - benchmark and profiling harness for animation-frame cost in the shared `Svg.Skia` renderer
+ - retained native-composition mapping for the supported Avalonia host path, using one composition child visual per top-level SVG child and updating animated layers without falling back to the regular `Render(...)` path
+- Delivered in the follow-up slices:
+ - stop rebuilding the full document picture on every effective animation frame in the common renderer path when the document can be safely split into static and animated top-level layers
+ - stop rebuilding the full animated top-level subtree picture when only a descendant target changes, by caching descendant subtree pictures and rebuilding only the dirty path to the animated root
+ - keep hit testing and interaction semantics aligned with the animated drawable state
+ - preserve the current shared SVG runtime as the source of truth
+ - add a local BenchmarkDotNet harness in `tests/Svg.Skia.Benchmarks` that compares layered animation-frame updates against defs-backed fallback rebuilds, with and without drawing
+ - expose a shared native-composition scene/frame extraction contract from `SKSvg`
+ - attach Avalonia retained visuals through the compositor child-visual API when the animated document can be represented as top-level native-composition layers
+- Current limitation outside the shared runtime:
+ - Uno still falls back to the shared render-loop or dispatcher backends because the restored Uno package surface does not currently provide a working child-visual attachment path on the active target platforms
+- Explicit non-goals for the implemented follow-up slices:
+ - no attempt to translate arbitrary SVG nodes into Avalonia or Uno composition objects
+ - no broad retained-scene-graph rewrite of the existing drawable system
+ - no full retained scene-graph rewrite of every drawable node; subtree invalidation remains layered on top of the existing drawable/picture pipeline
+ - no platform-specific reimplementation of SVG timing semantics
+
+## Scope
+
+### In scope
+
+- shared event contract driven by `SKSvg` hit testing
+- framework adapters for Avalonia and Uno SVG controls
+- shared animation runtime design for SVG 1.1 animation elements already modeled in `Svg.Custom`
+- phased implementation strategy, validation plan, and performance gates
+
+### Out of scope for the first implementation slice
+
+- full SVG 1.1 bubbling/capturing event model
+- DOM scripting execution from attribute strings
+- full `pointer-events` paint/fill/stroke semantics
+- SMIL timing engine completeness
+- native-framework-specific accelerated animation backends
+
+## Current State
+
+### Event handling
+
+- `SKSvg` already exposes renderer-space hit testing in `src/Svg.Skia/SKSvg.HitTest.cs`.
+- `HitTestService` returns all matches in tree order, not a single event target, in `src/Svg.Model/Services/HitTestService.cs`.
+- Most drawables still use bounds-only hit testing in `src/Svg.Model/Drawables/DrawableBase.cs`.
+- `SvgElement` exposes mouse events and the dormant `ISvgEventCaller` registration contract in `externals/SVG/Source/SvgElement.cs`.
+- The repo does not currently wire any framework pointer events into that contract.
+
+### Rendering
+
+- `SKSvg` is a static picture model and snapshot replay engine in `src/Svg.Skia/SKSvg.Model.cs`.
+- `AnimatedVectorDrawable` is explicitly unsupported in `src/Svg.Model/Services/VectorDrawableConverter.cs`.
+- The Avalonia and Uno controls each render the SVG as a single custom surface, not as a framework-native visual tree.
+
+### Animation object model
+
+- `Svg.Custom` now has the SVG 1.1 animation element object model.
+- There is still no runtime timeline evaluator, target-property resolver, or frame invalidation loop.
+
+## Architecture
+
+## 1. Shared interaction layer
+
+Add a shared interaction subsystem under `src/Svg.Skia/Interaction`:
+
+- `SvgPointerInput`
+- `SvgPointerEventArgs`
+- `SvgPointerDeviceType`
+- `SvgMouseButton`
+- `SvgPointerEventType`
+- `SvgInteractionDispatcher`
+
+Responsibilities:
+
+- map picture-space pointer input to a single topmost SVG target
+- maintain hover and pressed state
+- emit a shared cross-framework event stream
+- optionally bridge into `SvgElement.RegisterEvents(...)` for existing CLR event subscribers
+
+This layer must not reference Avalonia or Uno.
+
+## 2. Renderer-side target resolution
+
+Extend `SKSvg` with topmost hit testing:
+
+- `HitTestTopmostDrawable(SKPoint)`
+- `HitTestTopmostElement(SKPoint)`
+- canvas-matrix overloads
+
+Rules:
+
+- reverse child traversal to match draw order
+- prefer the deepest painted leaf over ancestor containers
+- honor `display`, `visibility`, and `pointer-events:none`
+- leave full SVG `pointer-events` semantics for a later phase
+
+## 3. Framework adapters
+
+Each host framework keeps raw input and invalidation at the control layer:
+
+- Avalonia: override pointer methods on `Avalonia.Svg.Skia.Svg`
+- Uno: subscribe to control pointer events on `Uno.Svg.Skia.Svg` because the current host base type does not expose the same virtual pointer override surface
+
+Adapters translate framework pointer events into `SvgPointerInput` and feed the shared dispatcher.
+
+They remain responsible for:
+
+- pointer capture
+- coordinate conversion from control to picture space
+- invalidation
+- framework cursor integration later
+
+## 4. Shared animation runtime
+
+The animation runtime should live in shared code, not inside Avalonia or Uno:
+
+- `SvgAnimationClock`
+- `SvgAnimationController`
+- target resolution from animation element to animated element/property
+- timing state evaluation
+- typed interpolation pipeline
+- animated-value overlay over the static DOM model
+
+The framework host only supplies:
+
+- current time
+- frame scheduling
+- invalidation
+
+This keeps SVG timing semantics portable and testable.
+
+## 5. Optional framework-native animation backends
+
+Later, specific animation classes may map onto host-native engines when the target is compatible:
+
+- Avalonia transitions/composition
+- Uno XAML storyboards
+- Uno/WinUI composition where supported
+
+This is an optimization layer only. The shared SVG runtime remains the source of truth.
+
+## Phase Breakdown
+
+## Phase 1: Interaction foundation
+
+### Deliverables
+
+- shared pointer contract
+- topmost element hit testing in `SKSvg`
+- shared dispatcher with hover/press/click tracking
+- bridge into `SvgElement.RegisterEvents(...)` for ID-bearing elements
+- Avalonia and Uno control integration at the host control layer
+- unit tests for target resolution and dispatcher behavior
+
+### Behavioral constraints
+
+- leaf-target only, no bubbling yet
+- `pointer-events:none` honored
+- no full SVG `pointer-events` painted/fill/stroke matrix yet
+- one active pointer state per dispatcher instance
+- mouse-style event bridging only, matching current upstream `SvgElement` events
+
+### Implementation details
+
+- use reverse traversal of `DrawableContainer.ChildrenDrawables`
+- do not target ancestor containers directly in phase 1
+- register upstream `SvgElement` event actions only for elements with non-empty `ID`
+- surface a shared dispatcher event stream even when no SVG `ID` exists
+- reset interaction state when source content changes
+
+### Validation
+
+- topmost hit target for overlapping siblings
+- child beats ancestor container
+- `pointer-events:none` skips the front element
+- move enters/leaves correctly
+- press/release same target produces click
+- existing `SvgElement.Click`, `MouseDown`, `MouseUp`, `MouseOver`, `MouseOut`, `MouseMove`, `MouseScroll` can fire through the bridge for ID-bearing elements
+
+## Phase 2: Event routing semantics
+
+### Deliverables
+
+- ancestor event path building
+- bubble route
+- optional tunnel/capture route if needed
+- shared event cancellation model
+- cursor contract
+
+### Status
+
+- Implemented:
+ - target-to-root ancestry path routing
+ - bubble routing for shared events and the compatibility `SvgElement` bridge
+ - tunnel routing for shared routed events ahead of target/bubble dispatch
+ - pressed-target capture routing in the shared dispatcher until pointer release
+ - `Handled` short-circuiting in shared routed events
+ - shared cursor hint resolution via inherited `cursor` attributes
+ - framework-native cursor application in the Avalonia and Uno wrappers
+
+### Implementation details
+
+- compute ancestry from target element to document root
+- preserve leaf target while routing through ancestors
+- add `Handled` support to shared event args
+- expose cursor hints without hard-coding framework cursor types into shared code
+
+### Validation
+
+- group/anchor ancestors receive routed events
+- handled state stops downstream propagation where configured
+
+## Phase 3: Hit-test correctness
+
+### Deliverables
+
+- geometry-aware hit testing beyond bounds for more drawable types
+- better stroke/fill hit rules
+- clip and mask awareness where practical
+- typed `pointer-events` model in `Svg.Custom`
+
+### Implementation details
+
+- move from bounds-only to geometry-aware checks incrementally per drawable type
+- support `visiblePainted`, `visibleFill`, `visibleStroke`, `painted`, `fill`, `stroke`, `all`
+- keep a conservative fallback path for unsupported shapes
+
+### Validation
+
+- path stroke hit precision
+- fill-only vs stroke-only cases
+- clipped shapes not receiving hits outside clip
+
+### Status
+
+- Implemented:
+ - typed `SvgPointerEvents` enum/converter/property in `Svg.Custom`
+ - shared pointer-event-aware target evaluation in `HitTestService.HitTestPointer(...)`
+ - geometry-aware point hit testing for `DrawablePath`-based shapes
+ - `use` and marker wrappers delegating hit testing to their referenced drawables
+ - clip-path rejection and conservative mask rejection for point targeting
+ - focused model and renderer tests for `pointer-events`, stroke-only hits, and clipped hits
+- Current limitations within the phase:
+ - text hit testing still uses conservative bounds instead of glyph-outline geometry
+ - rectangle hit-test APIs remain bounds-based; the geometry-aware work in this phase is for point targeting
+ - mask handling is geometry-based, not alpha-precise
+ - hidden elements that would require `pointer-events` modes ignoring visibility are still limited by the current drawable-creation path
+
+## Phase 4: Shared animation runtime core
+
+### Deliverables
+
+- animation clock abstraction
+- animation timeline evaluator
+- target-property resolution
+- animated-value overlay state
+- invalidation contract back into controls/render hosts
+
+### Implementation details
+
+- treat the DOM as base state and runtime animation values as overlays
+- start with discrete, numeric, color, transform, and motion interpolation
+- parse shared timing fields from animation elements already added in `Svg.Custom`
+- rebuild or refresh render state from animated overlays per frame
+
+### Validation
+
+- `set`
+- `animate`
+- `animateColor`
+- `animateTransform`
+- `animateMotion`
+- timing attributes: `begin`, `dur`, `end`, `repeatCount`, `repeatDur`, `fill`
+
+### Status
+
+- Implemented:
+ - manual `SvgAnimationClock`
+ - shared `SvgAnimationController`
+ - source-document cloning via `SvgDocument.DeepCopy()`
+ - animated target resolution by cloned DOM address
+ - runtime application through the generated typed attribute setters
+ - `SKSvg.SetAnimationTime(...)`, `AdvanceAnimation(...)`, and `AnimationInvalidated`
+ - host invalidation wiring in the Avalonia and Uno controls
+ - event-based `begin` and `end` timing support using the shared interaction event stream
+ - `animateMotion` support for `path`, `mpath`, `values`, and synthesized `from`/`to`/`by` motion paths
+ - additive and accumulate handling for the phase-4 scalar, color, transform, and motion scope
+- Phase result:
+ - Phase 4 is complete for the planned shared-runtime scope.
+
+## Phase 5: Performance and incremental redraw
+
+### Deliverables
+
+- dirty-target tracking
+- selective rebuild strategy
+- cached interpolation state
+- animation throttling hooks
+
+### Implementation details
+
+- separate target/property evaluation from full picture rebuild when possible
+- keep frame scheduling outside the shared runtime
+- add profiling around rebuild cost, hit testing, and animation tick cost
+
+### Validation
+
+- frame-to-frame allocations
+- large SVG animation scenarios
+- host invalidation cadence
+
+### Status
+
+- Implemented:
+ - cached `SvgAnimationFrameState` evaluation and reuse in `SvgAnimationController`
+ - dirty-target counting and diff-based attribute application
+ - persistent animated `SvgDocument` reuse inside `SKSvg`
+ - no-op rebuild suppression for equivalent animation states
+ - `AnimationMinimumRenderInterval`, `HasPendingAnimationFrame`, `FlushPendingAnimationFrame()`, and `LastAnimationDirtyTargetCount`
+ - recursive subtree-level picture invalidation for animated top-level roots, with nested shim-picture composition and cached descendant `SkiaSharp.SKPicture` reuse
+ - benchmark and profiling harness for shared animation-frame cost
+ - unit coverage for equivalent-frame suppression, pending throttled frames, and reversion when an animation becomes inactive
+- Remaining limitations within the phase:
+ - subtree invalidation now covers top-level animated roots, nested animated subtrees within those roots, and rendered wrapper/generated drawables such as `use`, `switch`, and marker-hosted drawables, but it is still not a general retained scene-graph renderer
+ - the renderer now stops dirty-path rebuilds at the selected cache root inside the animated scope instead of always walking back to the animated top-level root, but it still recomposes the touched top-level animated scope on top of the static layer
+ - non-drawable resource pipelines such as gradients, clip-path resources, and filter graphs still use the fallback full-frame rebuild path because they do not map cleanly onto retained drawable subtrees
+
+### Follow-up implementation direction
+
+- first reduce animation-frame work by caching static document content and re-recording only the animated document regions that contain active animation targets
+- then refine the animated-layer path by caching descendant subtree pictures and only rebuilding the dirty path through the existing drawable graph
+- keep the animated drawable tree authoritative for hit testing so pointer routing follows animated geometry
+- treat the first redraw optimization as top-down and conservative; correctness takes priority over the finest possible invalidation granularity
+
+## Phase 6: Optional native host animation backends
+
+### Deliverables
+
+- Avalonia adapter experiment
+- Uno/WinUI adapter experiment
+- capability matrix and fallback rules
+
+### Implementation details
+
+- host-native backends in this implementation only provide scheduling and invalidation; SVG timing/value evaluation stays in the shared `SKSvg` runtime
+- the exposed contract is shared across wrappers:
+ - `SvgAnimationHostBackend`
+ - `SvgAnimationHostBackendCapabilities`
+ - `SvgAnimationHostBackendResolution`
+ - `SvgAnimationHostBackendResolver`
+- Avalonia uses:
+ - `DispatcherTimer` for timer-driven playback
+ - `TopLevel.RequestAnimationFrame(...)` for render-loop playback when attached to a `TopLevel`
+- Uno uses:
+ - `DispatcherQueueTimer`
+ - `CompositionTarget.Rendering`
+- wrapper defaults remain non-animated until opted in:
+ - requested backend defaults to `Manual`
+ - `Default` picks `RenderLoop`, then `DispatcherTimer`, then `Manual`
+- wrappers expose requested backend, frame interval, playback rate, actual backend, fallback reason, and capability matrix
+- render-loop availability is attachment-sensitive; detached controls fall back to `Manual` rather than running timers against an inactive host
+- animated cache entries are cloned per playback session so cached SVG content does not retain advanced animation time between reloads
+- this phase does not map SVG elements onto framework-native visual/composition trees; that remains a future optimization layer if ever needed
+
+### Validation
+
+- build and test coverage for backend resolution and fallback rules in shared tests
+- identical animated rendering semantics versus the shared runtime because all backends drive the same `SKSvg.AdvanceAnimation(...)` pipeline
+- safe fallback to `Manual` or `DispatcherTimer` when the requested backend is unavailable
+
+### Status
+
+- Implemented:
+ - shared backend-selection contract in `src/Svg.Skia/Animation/SvgAnimationHostBackend.cs`
+ - Avalonia host playback integration in `src/Svg.Controls.Skia.Avalonia/Svg.cs`
+ - Uno host playback integration in `src/Svg.Controls.Skia.Uno/Svg.cs`
+ - backend fallback-rule coverage in `tests/Svg.Skia.UnitTests/SvgAnimationHostBackendResolverTests.cs`
+- Remaining limitations within the phase:
+ - host-native backends currently schedule the shared runtime; they do not translate SVG nodes into framework-native animation objects
+ - wrapper-level capability reporting is attachment-time state, not a static platform-wide capability table
+
+## File and Type Plan
+
+### Shared interaction
+
+- `src/Svg.Skia/SKSvg.Interaction.cs`
+- `src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs`
+
+### Host adapters
+
+- `src/Svg.Controls.Skia.Avalonia/Svg.cs`
+- `src/Svg.Controls.Skia.Uno/Svg.cs`
+
+### Shared animation runtime
+
+- `src/Svg.Skia/Animation/` new folder in later phases
+
+### Tests
+
+- `tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs`
+- later: animation runtime tests in `tests/Svg.Skia.UnitTests`
+- later: host smoke tests where practical
+
+## Public API Direction
+
+### Phase 1 public additions
+
+- `SKSvg.HitTestTopmostDrawable(...)`
+- `SKSvg.HitTestTopmostElement(...)`
+- `SvgInteractionDispatcher`
+- `SvgPointerInput`
+- `SvgPointerEventArgs`
+- `SvgPointerDeviceType`
+- `SvgMouseButton`
+- `SvgPointerEventType`
+- `Avalonia.Svg.Skia.Svg.Interaction`
+- `Uno.Svg.Skia.Svg.Interaction`
+
+### Later public additions
+
+- routed event path APIs
+- cursor contract
+- animation clock/controller/runtime types
+- host invalidation/tick abstraction
+
+## Risk Areas
+
+- current hit test precision is not yet good enough for full `pointer-events` parity
+- `SvgElement.RegisterEvents(...)` assumes IDs and a mouse-centric event surface
+- `Use`, markers, clipping, masks, and text spans need targeted semantic decisions
+- a full animation engine can become expensive if every tick forces a whole-picture rebuild
+
+## Immediate Implementation Slice
+
+Implement phase 1 now:
+
+1. add the plan document
+2. add topmost hit testing to `SKSvg`
+3. add the shared interaction dispatcher and event bridge
+4. wire Avalonia and Uno controls to feed the dispatcher
+5. add focused unit tests
+6. build and test the solution
diff --git a/plan/svg-retained-scene-graph-rewrite-spec.md b/plan/svg-retained-scene-graph-rewrite-spec.md
new file mode 100644
index 0000000000..80f3374f9d
--- /dev/null
+++ b/plan/svg-retained-scene-graph-rewrite-spec.md
@@ -0,0 +1,499 @@
+# SVG Retained Scene-Graph Rewrite
+
+## Status
+- In progress
+- Scope: shared `Svg.Skia` retained renderer architecture, loading pipeline rewrite, incremental updates, and host integration strategy
+- This document defines the target architecture and the phased implementation plan for replacing the current transient `SvgElement -> Drawable -> SKPicture` pipeline with a retained scene graph
+
+## Current Branch Status
+
+The retained-scene rewrite is no longer only a proposed architecture. This branch now includes a working retained scene document, a DOM-first compiler path, retained resource indexing, and a mutation router that can update the live retained graph in place.
+
+- `SvgSceneDocument`, `SvgSceneNode`, `SvgSceneCompiler`, and `SvgSceneRenderer` exist in shared `Svg.Skia`
+- `SKSvg` exposes a lazy `RetainedSceneGraph` plus `CreateRetainedSceneGraphModel()` and `CreateRetainedSceneGraphPicture()`
+- retained scene compilation is driven from the SVG DOM tree, not from the live root drawable tree
+- retained resources are indexed and dependency-tracked for gradients, patterns, markers, masks, clip paths, paint servers, and related reference-style resources
+- scene mutation routing can update the current retained graph in place from DOM element mutations and animation frame deltas
+- animation frame integration now attempts retained-scene mutation first and only falls back to full retained invalidation when the mutation path cannot safely service the update
+- parity tests cover indexing, one-to-many address mapping for `use`, retained rendering parity, retained-graph reuse across animation updates, and dependency-driven updates for `use` and resource-backed content
+
+This is still not the final architecture. The retained graph now has the correct structural direction, but the runtime is still in transition:
+
+- core path-based primitives (`path`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`) now compile local retained visuals directly without transient per-element drawable generation
+- structural wrappers such as `g`, `a`, and nested `svg` fragments now compile wrapper state directly as retained nodes without transient drawable extraction
+- text, masked, and filtered visual nodes now compile and render as retained scene nodes without per-element transient drawable ownership in the scene graph
+- retained text compilation now covers nested `tspan`, `textPath`, and `tref` content with retained geometry estimation and parity-tested retained playback
+- retained resources are indexed, dependency-tracked, and now own clip, mask, and filter runtime payload generation directly inside `Svg.Skia`, with retained `feImage` evaluation and retained-source filter inputs instead of transient drawable snapshots
+- retained paint runtime now owns fill, stroke, opacity, gradients, and patterns inside `Svg.Skia`, including retained pattern tile rendering instead of drawable snapshot recording
+- direct retained marker expansion is now in place for path-based marker elements, including inherited group marker settings, without going through `MarkerService` and `MarkerDrawable`
+- the retained renderer is available and update-aware, but it is not yet the single mandatory primary render path for every host feature
+- hit testing and native host extraction are now driven from retained scene state, including retained visibility, pointer-event, background, transform, and opacity metadata, with lightweight drawable proxies only at legacy API boundaries
+- the retained scene root now compiles as a direct retained fragment instead of relying on an implicit drawable-backed root bridge
+- `SvgSceneDocument` and `SKSvg` now expose retained-scene-first lookup, mutation, node-rendering, and hit-testing APIs for editor/tooling consumers
+- retained mutation coverage now explicitly includes mask-resource and filter-resource dependent updates, and parity coverage now includes masked rich-text documents
+
+## Problem Statement
+
+`Svg.Skia` currently renders by:
+
+1. Parsing SVG into the mutable `SvgDocument` DOM
+2. Converting DOM subtrees into transient `DrawableBase` trees
+3. Recording transient drawables into shim `SKPicture` models
+4. Converting shim pictures into `SkiaSharp.SKPicture`
+
+That architecture works, but it has structural limits:
+
+- loading cost is front-loaded into repeated object graph construction
+- updates to the SVG model often rebuild too much state
+- animation and interaction have to layer invalidation on top of a transient renderer
+- resource resolution, draw ordering, and host-native composition have no single retained source of truth
+- the fast path today is cache-oriented, not graph-oriented
+
+The retained rewrite replaces the transient renderer with a retained scene graph that owns:
+
+- renderable node topology
+- resolved geometry, paint, clipping, masking, filter, and text state
+- resource dependency tracking
+- address-based lookup for granular model updates
+- subtree invalidation and regeneration
+- host-facing composition extraction
+
+## Goals
+
+- State-of-the-art retained rendering architecture for SVG in shared `Svg.Skia`
+- Fast initial load through staged compilation and reusable caches
+- Very fast granular updates from SVG DOM mutations to scene graph mutations
+- Eliminate full-picture rebuilds for common subtree updates
+- Single canonical retained graph for:
+ - static rendering
+ - animation
+ - hit testing
+ - native composition extraction
+ - editor/inspection tooling
+- Preserve visual correctness relative to the current renderer during transition
+- Keep the implementation framework-neutral, with Avalonia/Uno/native host adapters layered on top
+
+## Non-Goals
+
+- Rewriting the external SVG DOM library in the first wave
+- Shipping a second permanent renderer beside the retained graph
+- Cutting corners with host-specific rendering logic in the shared core
+- Treating animation caching as the long-term architecture
+
+## Target Architecture
+
+### Layers
+
+1. DOM Layer
+- `SvgDocument` and `SvgElement` remain the authoring and compatibility object model
+
+2. Compilation Layer
+- Converts DOM and resolved resources into retained scene nodes and retained resources
+- Tracks dependencies between DOM addresses and scene nodes/resources
+
+3. Retained Scene Layer
+- Mutable graph of retained render nodes and retained resources
+- Owns node identity, invalidation, subtree rebuild, and render ordering
+
+4. Rendering Layer
+- Traverses retained scene nodes into:
+ - shim `SKPicture`
+ - `SkiaSharp.SKPicture`
+ - native-composition layer extraction
+
+5. Host Layer
+- Avalonia, Uno, and future hosts consume retained scene outputs and invalidation signals
+
+### Core Principles
+
+- The retained graph is the source of truth, not a cache
+- Resources are first-class graph objects
+- Node identity is stable across updates
+- Dirty propagation is structural and dependency-aware
+- Subtree rebuilds are explicit and bounded
+- Rendering is pure playback from retained state
+- Host-native composition is an extraction of retained state, not a parallel animation system
+
+## Retained Scene Data Model
+
+### Retained Document
+
+`SvgSceneDocument`
+
+- root scene node
+- cull bounds / viewport / viewBox metadata
+- lookup by SVG element address key
+- lookup by SVG id
+- resource registry
+- dependency graph
+- revision counter
+- invalidation queue
+
+### Retained Nodes
+
+`SvgSceneNode`
+
+- stable scene node id
+- source SVG element reference and address key
+- node kind
+- parent pointer
+- ordered children
+- optional local display list
+- transform state
+- clip/overflow state
+- mask/filter/opacity state
+- geometry bounds
+- transformed bounds
+- dirty flags
+- version / generation counters
+
+### Retained Resources
+
+Resources become retained objects, not incidental data hidden inside drawables:
+
+- clip paths
+- masks
+- gradients
+- patterns
+- paint servers
+- filters and filter subgraphs
+- resolved images
+- resolved text assets / glyph runs
+
+Each resource has:
+
+- stable resource id
+- dependency list
+- reverse dependency list
+- dirty flags
+- resolved runtime payload
+
+### Display Lists
+
+Each scene node owns a local display list containing only node-local visuals.
+
+- Parent wrapper state is not baked into child display lists
+- Parent clip/mask/filter/opacity scopes are replayed structurally by the retained renderer
+- Child ordering remains explicit in the scene graph
+
+## Loading Pipeline Rewrite
+
+### Current
+
+`SvgDocument -> DrawableFactory -> Drawable tree -> Snapshot -> SKPicture`
+
+### Target
+
+`SvgDocument -> Scene compilation -> Retained scene/resource graph -> Render`
+
+### Compilation Stages
+
+1. Parse DOM
+- existing SVG DOM parser remains the front door
+
+2. Resolve document metadata
+- viewport, viewBox, units, external asset resolution context
+
+3. Pre-index DOM
+- stable address keys
+- id map
+- element type map
+- mutation routing tables
+
+4. Compile retained resources
+- gradients, patterns, clip paths, masks, filters, markers, symbol/use expansions
+
+5. Compile retained nodes
+- scene node tree with local display lists and structural visual state
+
+6. Build dependency graph
+- scene node -> resource dependencies
+- resource -> dependent scene nodes
+- DOM address -> scene node/resource mappings
+
+7. Produce retained document
+- immutable-at-boundary / mutable-internally retained graph
+
+### Loading Optimizations
+
+- parser-side pre-indexing of ids and addresses
+- stable URI and asset resolution caches
+- resource interning for repeated paint/filter structures
+- local display list memoization for unchanged subtrees
+- direct compilation to scene nodes without round-tripping through transient drawables in the final architecture
+
+## Update Model
+
+### Update Sources
+
+- animation frame mutations
+- interaction-driven mutations
+- editor changes to DOM attributes
+- resource changes
+- external asset reloads
+
+### Update Flow
+
+1. DOM mutation arrives with element address and changed attribute set
+2. mutation router maps changed addresses to retained nodes/resources
+3. dependency graph expands affected region
+4. dirty flags propagate to:
+- directly affected nodes/resources
+- dependent resources
+- dependent ancestor layout/clip scopes as required
+5. compiler regenerates only dirty subtrees/resources
+6. renderer replays only dirty retained outputs
+
+### Dirty Granularity Rules
+
+- inherited style changes dirty dependent descendant nodes
+- transform changes dirty subtree bounds and composition extraction
+- resource mutations dirty all dependents
+- text mutations dirty text shaping and affected layout nodes
+- filter changes dirty dependent filter graph and target nodes
+
+## Rendering Model
+
+### Retained Renderer Responsibilities
+
+- replay node-local display lists
+- manage transform stack
+- manage clip/overflow stack
+- manage filter/opacity/mask scopes
+- preserve draw order
+- produce subtree pictures on demand
+- support incremental render targets
+
+### Render Targets
+
+- full shim `SKPicture`
+- full `SkiaSharp.SKPicture`
+- subtree shim picture
+- subtree native picture
+- extracted host-native composition layer list
+
+### Native Composition
+
+Native composition becomes a direct extraction pass over retained nodes:
+
+- select renderable retained layer roots
+- extract stable bounds, opacity, transforms, and local display lists
+- keep scene-node identity stable across frames
+- reissue only changed retained layers
+
+## Hit Testing and Interaction
+
+The retained graph becomes the authoritative hit-test graph:
+
+- geometry-aware hit testing per retained node
+- retained clip and mask constraints
+- z-order and event route derivation directly from retained node order
+- pointer-events and visibility logic evaluated against retained node state
+
+## Animation Integration
+
+Animation does not rebuild transient drawables.
+
+Instead:
+
+1. animation runtime resolves attribute values
+2. mutation router applies updates to retained scene state
+3. dirty regions propagate through retained nodes/resources
+4. only impacted retained outputs are regenerated
+
+## Transition Strategy
+
+The rewrite must be staged, but the target is a full replacement.
+
+### Stage A
+- land retained scene core types
+- land compiler bridge from current drawable output
+- land retained renderer
+- wire `SKSvg` to build and expose retained scene documents
+
+### Stage B
+- use retained scene as the primary render path behind a feature toggle
+- validate image parity against current output
+- feed native composition and hit testing from retained nodes
+
+### Stage C
+- remove transient animation-layer caching architecture
+- route animation invalidation through retained nodes/resources
+
+### Stage D
+- remove drawable-tree-first rendering path
+- compile retained scene directly from DOM/resources
+
+## Implementation Status Against Plan
+
+### Phase 1: Retained Scene Foundation
+
+Implemented in this branch:
+
+- retained node, resource, and document types
+- retained scene renderer
+- `SKSvg` retained graph creation and exposure
+- indexing by address and id
+- scene-node identity and root replacement support
+
+### Phase 2: Renderer Parity Path
+
+Implemented for representative coverage:
+
+- retained-render parity tests for full-scene playback
+- retained parity coverage for `use`, markers, generated drawable children, and animation-frame refresh
+- retained parity coverage for rich text (`tspan`, `textPath`, `tref`) and resource-backed pattern paints
+- retained parity coverage for masked rich-text documents
+
+Still open:
+
+- broader parity expansion around the hardest combined filter-plus-text edge cases
+
+### Phase 3: Incremental Static Updates
+
+Implemented in this branch:
+
+- address-based mutation routing
+- retained dependency graph
+- in-place subtree recompilation and node replacement
+- id-based and resource-based reverse dependency expansion
+- public retained-scene lookup and mutation entry points for address-key, id, and node-targeted tooling flows
+
+### Phase 4: Animation Rewrite
+
+Implemented for retained-scene integration:
+
+- animation frame deltas are routed into retained scene mutations
+- retained scene documents are reused across animation updates when routing succeeds
+
+Still open:
+
+- fully remove the older non-retained fallback paths once retained coverage is proven across all remaining edge cases
+
+### Phase 5: Resource Graph Rewrite
+
+Implemented in this branch for the retained runtime path:
+
+- retained resource indexing
+- resource dependency tracking
+- mutation expansion from resource changes to dependent scene roots
+- retained-owned clip, mask, and filter runtime payload resolution
+- retained-owned fill/stroke/opacity paint resolution
+- retained-owned gradient and pattern paint server evaluation, including retained pattern tile playback
+- retained runtime payload refresh for temporary mask subtrees
+
+Still open:
+
+- eliminate remaining fallback full rebuilds for the most complex resource pipelines
+- continue replacing legacy helper usage in residual utility paths where retained-owned evaluators are not yet complete
+
+### Phase 6: Direct DOM-to-Scene Compilation
+
+Implemented structurally in this branch:
+
+- retained graph traversal is DOM-first
+- compilation roots and resource roots are derived from DOM element identity and addresses
+- direct retained local-visual compilation is in place for core path-based primitives
+- retained nodes now record compilation strategy so the shrinking drawable bridge is explicit and testable
+- the retained document root now compiles directly instead of entering the retained graph through the drawable bridge
+- degenerate path-based visual elements now stay on the direct retained path instead of dropping back to drawable-backed placeholder nodes
+- retained no-bridge coverage now includes selected W3C shape and DOM documents that previously exposed the last direct-compilation seam
+
+Still open:
+
+- replace the remaining per-element drawable extraction bridge for the last unsupported visual/resource pipelines with direct retained display-list generation
+- reduce load-time duplication caused by temporary drawable generation for compatibility-only payload extraction paths
+
+### Phase 7: Host and Tooling Convergence
+
+Implemented for runtime plumbing:
+
+- point hit testing and topmost hit testing are evaluated against retained scene metadata rather than live DOM state
+- native composition extraction is driven from retained scene nodes and retained node state rather than drawable-tree traversal
+- retained-scene-first node/resource lookup, node rendering, and mutation APIs are available on `SvgSceneDocument` and `SKSvg`
+- retained picture parity coverage now includes selected external W3C text, masking, and filter documents in addition to the synthetic in-repo stress cases
+- editor-facing layer/tooling entry points now bind retained scene nodes directly for layer inspection and retained bounds overlays instead of depending only on drawable lookup
+- `SKSvg` now exposes retained-scene element lookup and retained-node hit-test wrappers so downstream tools can stay on retained state instead of rebuilding legacy proxies first
+- editor selection bounds, resize/skew/rotate handle setup, path-edit transforms, polygon/polyline edit transforms, and related workspace selection state now resolve retained scene nodes first and only fall back to drawables when retained nodes are unavailable
+- editor path-selection activation and document-tree path/poly edit entry points now start from retained scene node transforms instead of drawable transforms, with regression coverage for retained edit matrices
+
+Still ahead in the plan:
+
+- remove the remaining compatibility proxies at legacy public API boundaries once downstream callers can move to retained-scene APIs
+- migrate remaining editor commands that still require drawable snapshots or drawable ordering helpers, such as selected-element export, align/distribute layout helpers, and path/shape manipulation helpers that still depend on drawable-specific geometry extraction
+
+## Full Implementation Plan
+
+### Phase 1: Retained Scene Foundation
+
+- add retained node/resource/document types
+- add scene compiler bridge from drawables
+- add retained renderer that replays structural state and local display lists
+- expose retained scene on `SKSvg`
+- add lookup and dirty-marking infrastructure
+
+### Phase 2: Renderer Parity Path
+
+- add parity tests comparing retained renderer output to current output
+- make retained renderer available for static full-document rendering
+- verify masks, filters, text, markers, `use`, nested fragments, and clipping
+
+### Phase 3: Incremental Static Updates
+
+- add DOM mutation router
+- add dependency graph
+- add subtree recompilation and retained output regeneration
+- add address-based and id-based scene updates
+
+### Phase 4: Animation Rewrite
+
+- route animation runtime into retained mutations
+- remove drawable rebuilds from animated frame generation
+- replace top-level layer caching with retained subtree invalidation
+
+### Phase 5: Resource Graph Rewrite
+
+- compile gradients/patterns/filters/masks into retained resources
+- add granular dependency invalidation
+- remove fallback full rebuilds for resource changes
+
+### Phase 6: Direct DOM-to-Scene Compilation
+
+- bypass transient drawable tree for retained compilation
+- keep drawables only as compatibility/editor helpers if still needed
+- optimize load time and memory footprint
+
+### Phase 7: Host and Tooling Convergence
+
+- feed native composition from retained graph only
+- feed hit testing from retained graph only
+- expose scene graph inspection hooks for editor/tooling
+
+## Risks
+
+- visual parity regressions in mask/filter/text edge cases
+- scene node local-display-list decomposition mistakes
+- accidental duplication of wrapper state between node and local display list
+- temporary memory pressure during transition while both pipelines exist
+
+## Success Criteria
+
+- retained scene graph is the primary shared renderer
+- static and animated updates use granular subtree invalidation
+- no full transient drawable rebuild for common attribute animations
+- native composition extraction is based on retained scene nodes
+- model updates are address-routed and subtree-bounded
+- load time and steady-state frame time improve relative to the current pipeline
+
+## Implemented Rewrite Slice In This Branch
+
+This branch now contains the first meaningful retained-scene rewrite tranche:
+
+- retained scene graph core types
+- retained scene renderer for full-scene playback
+- DOM-first retained compiler with generated-child bridging where required
+- retained resource registry and reverse dependency graph
+- in-place mutation routing for DOM edits and animation frame updates
+- focused parity/update tests for representative static, generated, `use`, and resource-backed cases
+
+That means the rewrite has moved past a pure bridge prototype. The current implementation is already exercising the retained graph as a live mutable rendering structure, while still keeping selective compatibility bridges where the final direct retained compiler is not finished yet.
diff --git a/samples/SvgToPng/ViewModels/Item.cs b/samples/SvgToPng/ViewModels/Item.cs
index f8b1693ed4..4694a91b15 100644
--- a/samples/SvgToPng/ViewModels/Item.cs
+++ b/samples/SvgToPng/ViewModels/Item.cs
@@ -23,9 +23,6 @@ public class Item : IDisposable
[IgnoreDataMember]
public SvgDocument Document { get; set; }
- [IgnoreDataMember]
- public SKDrawable Drawable { get; set; }
-
[IgnoreDataMember]
public SKPicture Picture { get; set; }
@@ -44,7 +41,6 @@ public class Item : IDisposable
public void Reset()
{
Document = null;
- Drawable = null;
Picture = null;
Code = null;
SkiaPicture?.Dispose();
diff --git a/samples/SvgToPng/ViewModels/MainWindowViewModel.cs b/samples/SvgToPng/ViewModels/MainWindowViewModel.cs
index fe0b76c8e4..ac9fdffb66 100644
--- a/samples/SvgToPng/ViewModels/MainWindowViewModel.cs
+++ b/samples/SvgToPng/ViewModels/MainWindowViewModel.cs
@@ -125,12 +125,9 @@ private void LoadSvg(Item item, Action statusOpen, Action status
{
var stopwatchToPicture = Stopwatch.StartNew();
- var references = new HashSet { item.Document.BaseUri };
- item.Drawable = SvgService.ToDrawable(item.Document, _assetLoader, references, out var bounds);
- if (item.Drawable is { } && bounds is { })
+ item.Picture = SvgSceneRuntime.CreateModel(item.Document, _assetLoader);
+ if (item.Picture is { })
{
- item.Picture = item.Drawable.Snapshot(bounds.Value);
-
item.SkiaPicture = _skiaModel.ToSKPicture(item.Picture);
if (item.Picture?.Commands is { })
diff --git a/samples/TestApp/Models/Configuration.cs b/samples/TestApp.Shared/Models/Configuration.cs
similarity index 81%
rename from samples/TestApp/Models/Configuration.cs
rename to samples/TestApp.Shared/Models/Configuration.cs
index b04315437d..ee27c49dce 100644
--- a/samples/TestApp/Models/Configuration.cs
+++ b/samples/TestApp.Shared/Models/Configuration.cs
@@ -2,7 +2,7 @@
namespace TestApp.Models;
-public class Configuration
+public sealed class Configuration
{
public List? Paths { get; set; }
public string? Query { get; set; }
diff --git a/samples/TestApp.Shared/Services/ITestAppStorageService.cs b/samples/TestApp.Shared/Services/ITestAppStorageService.cs
new file mode 100644
index 0000000000..68ef97ff96
--- /dev/null
+++ b/samples/TestApp.Shared/Services/ITestAppStorageService.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TestApp.Services;
+
+public interface ITestAppStorageService
+{
+ Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default);
+
+ Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default);
+
+ Task> PickSvgPathsAsync(CancellationToken cancellationToken = default);
+
+ Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default);
+}
+
+public sealed class TestAppSaveStreamResult
+{
+ public TestAppSaveStreamResult(Stream stream, string name)
+ {
+ Stream = stream;
+ Name = name;
+ }
+
+ public Stream Stream { get; }
+
+ public string Name { get; }
+}
+
+internal sealed class NullTestAppStorageService : ITestAppStorageService
+{
+ public static NullTestAppStorageService Instance { get; } = new();
+
+ public Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(null);
+
+ public Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default)
+ => Task.FromResult(null);
+
+ public Task> PickSvgPathsAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult>(System.Array.Empty());
+
+ public Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default)
+ => Task.FromResult(null);
+}
diff --git a/samples/TestApp.Shared/Services/PathLauncher.cs b/samples/TestApp.Shared/Services/PathLauncher.cs
new file mode 100644
index 0000000000..a24e3cb25d
--- /dev/null
+++ b/samples/TestApp.Shared/Services/PathLauncher.cs
@@ -0,0 +1,49 @@
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+
+namespace TestApp.Services;
+
+public static class PathLauncher
+{
+ public static void OpenInExplorer(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Process.Start("explorer", path);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Process.Start("xdg-open", path);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ Process.Start("open", path);
+ }
+ }
+
+ public static void OpenInTextEditor(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return;
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Process.Start("notepad", path);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Process.Start("xdg-open", path);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ Process.Start("open", "-t " + path);
+ }
+ }
+}
diff --git a/samples/TestApp.Shared/Services/TestAppExportService.cs b/samples/TestApp.Shared/Services/TestAppExportService.cs
new file mode 100644
index 0000000000..5dc3091fe8
--- /dev/null
+++ b/samples/TestApp.Shared/Services/TestAppExportService.cs
@@ -0,0 +1,65 @@
+using System.IO;
+using System.Threading.Tasks;
+using SkiaSharp;
+using Svg.Skia;
+
+namespace TestApp.Services;
+
+public static class TestAppExportService
+{
+ public static Task ExportAsync(
+ Stream stream,
+ string name,
+ SKPicture? picture,
+ string backgroundColor = "#00FFFFFF",
+ float scaleX = 1f,
+ float scaleY = 1f)
+ {
+ if (picture is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (!SKColor.TryParse(backgroundColor, out var skBackgroundColor))
+ {
+ return Task.CompletedTask;
+ }
+
+ switch (Path.GetExtension(name).ToLowerInvariant())
+ {
+ case ".png":
+ picture.ToImage(
+ stream,
+ skBackgroundColor,
+ SKEncodedImageFormat.Png,
+ 100,
+ scaleX,
+ scaleY,
+ SKColorType.Rgba8888,
+ SKAlphaType.Premul,
+ SKColorSpace.CreateSrgb());
+ break;
+ case ".jpg":
+ case ".jpeg":
+ picture.ToImage(
+ stream,
+ skBackgroundColor,
+ SKEncodedImageFormat.Jpeg,
+ 100,
+ scaleX,
+ scaleY,
+ SKColorType.Rgba8888,
+ SKAlphaType.Premul,
+ SKColorSpace.CreateSrgb());
+ break;
+ case ".pdf":
+ picture.ToPdf(stream, skBackgroundColor, scaleX, scaleY);
+ break;
+ case ".xps":
+ picture.ToXps(stream, skBackgroundColor, scaleX, scaleY);
+ break;
+ }
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/samples/TestApp.Shared/TestApp.Shared.csproj b/samples/TestApp.Shared/TestApp.Shared.csproj
new file mode 100644
index 0000000000..4062b0cd8c
--- /dev/null
+++ b/samples/TestApp.Shared/TestApp.Shared.csproj
@@ -0,0 +1,12 @@
+
+
+ net10.0
+ False
+ enable
+ TestApp
+
+
+
+
+
+
diff --git a/samples/TestApp.Shared/ViewModels/AsyncRelayCommand.cs b/samples/TestApp.Shared/ViewModels/AsyncRelayCommand.cs
new file mode 100644
index 0000000000..419679ffe1
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/AsyncRelayCommand.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows.Input;
+
+namespace TestApp.ViewModels;
+
+public sealed class AsyncRelayCommand : ICommand
+{
+ private readonly Func _executeAsync;
+ private readonly Func? _canExecute;
+ private bool _isExecuting;
+
+ public AsyncRelayCommand(Func executeAsync, Func? canExecute = null)
+ {
+ _executeAsync = executeAsync;
+ _canExecute = canExecute;
+ }
+
+ public event EventHandler? CanExecuteChanged;
+
+ public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true);
+
+ public async void Execute(object? parameter)
+ {
+ if (!CanExecute(parameter))
+ {
+ return;
+ }
+
+ try
+ {
+ _isExecuting = true;
+ NotifyCanExecuteChanged();
+ await _executeAsync();
+ }
+ finally
+ {
+ _isExecuting = false;
+ NotifyCanExecuteChanged();
+ }
+ }
+
+ public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+}
diff --git a/samples/TestApp.Shared/ViewModels/FileItemViewModel.cs b/samples/TestApp.Shared/ViewModels/FileItemViewModel.cs
new file mode 100644
index 0000000000..df4f7221cf
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/FileItemViewModel.cs
@@ -0,0 +1,38 @@
+using System;
+using System.Windows.Input;
+using TestApp.Services;
+
+namespace TestApp.ViewModels;
+
+public sealed class FileItemViewModel : ViewModelBase
+{
+ private string _name;
+ private string _path;
+
+ public FileItemViewModel(string name, string path, Action remove)
+ {
+ _name = name;
+ _path = path;
+ RemoveCommand = new RelayCommand(() => remove(this));
+ OpenInExplorerCommand = new RelayCommand(() => PathLauncher.OpenInExplorer(_path));
+ OpenInNotepadCommand = new RelayCommand(() => PathLauncher.OpenInTextEditor(_path));
+ }
+
+ public string Name
+ {
+ get => _name;
+ set => SetProperty(ref _name, value);
+ }
+
+ public string Path
+ {
+ get => _path;
+ set => SetProperty(ref _path, value);
+ }
+
+ public ICommand RemoveCommand { get; }
+
+ public ICommand OpenInExplorerCommand { get; }
+
+ public ICommand OpenInNotepadCommand { get; }
+}
diff --git a/samples/TestApp.Shared/ViewModels/ITestAppSvgViewAdapter.cs b/samples/TestApp.Shared/ViewModels/ITestAppSvgViewAdapter.cs
new file mode 100644
index 0000000000..f244e9a8fe
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/ITestAppSvgViewAdapter.cs
@@ -0,0 +1,26 @@
+using System.Collections.Generic;
+using Svg;
+using Svg.Skia;
+using ShimPoint = ShimSkiaSharp.SKPoint;
+using SkiaPicture = SkiaSharp.SKPicture;
+
+namespace TestApp.ViewModels;
+
+public interface ITestAppSvgViewAdapter
+{
+ SkiaPicture? Picture { get; }
+
+ SKSvg? SkSvg { get; }
+
+ double AnimationPlaybackRate { get; set; }
+
+ SvgAnimationHostBackend ActualAnimationBackend { get; }
+
+ string? AnimationBackendFallbackReason { get; }
+
+ bool TryGetPicturePoint(double x, double y, out ShimPoint picturePoint);
+
+ IEnumerable HitTestElements(double x, double y);
+
+ void InvalidateView();
+}
diff --git a/samples/TestApp.Shared/ViewModels/MainWindowViewModel.cs b/samples/TestApp.Shared/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000000..eaa8e704a7
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,240 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using SkiaSharp;
+using TestApp.Models;
+using TestApp.Services;
+
+namespace TestApp.ViewModels;
+
+public sealed class MainWindowViewModel : ViewModelBase
+{
+ private readonly ITestAppStorageService _storageService;
+ private readonly ObservableCollection _items = new();
+ private readonly ObservableCollection _filteredItems = new();
+ private readonly ReadOnlyObservableCollection _readOnlyFilteredItems;
+ private readonly RelayCommand _resetQueryCommand;
+ private readonly AsyncRelayCommand _loadConfigurationCommand;
+ private readonly AsyncRelayCommand _saveConfigurationCommand;
+ private readonly RelayCommand _clearConfigurationCommand;
+ private readonly AsyncRelayCommand _addItemCommand;
+ private FileItemViewModel? _selectedItem;
+ private string? _itemQuery;
+
+ public MainWindowViewModel()
+ : this(NullTestAppStorageService.Instance)
+ {
+ }
+
+ public MainWindowViewModel(ITestAppStorageService storageService)
+ {
+ _storageService = storageService;
+ _readOnlyFilteredItems = new ReadOnlyObservableCollection(_filteredItems);
+ SvgView = new SvgInteractionViewModel();
+
+ _resetQueryCommand = new RelayCommand(() => ItemQuery = string.Empty, () => !string.IsNullOrWhiteSpace(ItemQuery));
+ _loadConfigurationCommand = new AsyncRelayCommand(LoadConfigurationExecuteAsync);
+ _saveConfigurationCommand = new AsyncRelayCommand(SaveConfigurationExecuteAsync, () => _items.Count > 0);
+ _clearConfigurationCommand = new RelayCommand(ClearConfiguration);
+ _addItemCommand = new AsyncRelayCommand(AddItemExecuteAsync);
+
+ RefreshFilteredItems();
+ }
+
+ public FileItemViewModel? SelectedItem
+ {
+ get => _selectedItem;
+ set => SetProperty(ref _selectedItem, value);
+ }
+
+ public string? ItemQuery
+ {
+ get => _itemQuery;
+ set
+ {
+ if (SetProperty(ref _itemQuery, value))
+ {
+ RefreshFilteredItems();
+ _resetQueryCommand.NotifyCanExecuteChanged();
+ }
+ }
+ }
+
+ public ReadOnlyObservableCollection FilteredItems => _readOnlyFilteredItems;
+
+ public SvgInteractionViewModel SvgView { get; }
+
+ public ICommand ResetQueryCommand => _resetQueryCommand;
+
+ public ICommand LoadConfigurationCommand => _loadConfigurationCommand;
+
+ public ICommand SaveConfigurationCommand => _saveConfigurationCommand;
+
+ public ICommand ClearConfigurationCommand => _clearConfigurationCommand;
+
+ public ICommand AddItemCommand => _addItemCommand;
+
+ public void Drop(IEnumerable paths)
+ {
+ foreach (var path in paths)
+ {
+ if (!File.Exists(path) && !Directory.Exists(path))
+ {
+ continue;
+ }
+
+ if (Directory.Exists(path))
+ {
+ Drop(Directory.EnumerateFiles(path, "*.svg", new EnumerationOptions { RecurseSubdirectories = true }));
+ Drop(Directory.EnumerateFiles(path, "*.svgz", new EnumerationOptions { RecurseSubdirectories = true }));
+ continue;
+ }
+
+ switch (Path.GetExtension(path).ToLowerInvariant())
+ {
+ case ".svg":
+ case ".svgz":
+ AddItem(path);
+ break;
+ case ".json":
+ using (var stream = File.OpenRead(path))
+ {
+ LoadConfiguration(stream);
+ }
+ break;
+ }
+ }
+ }
+
+ public void LoadConfiguration(Stream stream)
+ {
+ using var reader = new StreamReader(stream);
+ var json = reader.ReadToEnd();
+ var configuration = JsonSerializer.Deserialize(json);
+
+ SelectedItem = null;
+ _items.Clear();
+
+ if (configuration?.Paths is not null)
+ {
+ foreach (var path in configuration.Paths)
+ {
+ AddItem(path);
+ }
+ }
+
+ ItemQuery = configuration?.Query;
+ SvgView.NotifySelectionChanged();
+ RefreshFilteredItems();
+ }
+
+ public void SaveConfiguration(Stream stream)
+ {
+ var configuration = new Configuration
+ {
+ Paths = _items.Select(x => x.Path).ToList(),
+ Query = ItemQuery
+ };
+
+ using var writer = new StreamWriter(stream);
+ writer.Write(JsonSerializer.Serialize(configuration));
+ }
+
+ public async Task ExportAsync(SKPicture? picture)
+ {
+ if (SelectedItem is null || picture is null)
+ {
+ return;
+ }
+
+ var exportTarget = await _storageService.OpenExportWriteStreamAsync(Path.GetFileNameWithoutExtension(SelectedItem.Path));
+ if (exportTarget is null)
+ {
+ return;
+ }
+
+ await using var stream = exportTarget.Stream;
+ await TestAppExportService.ExportAsync(stream, exportTarget.Name, picture);
+ }
+
+ private async Task LoadConfigurationExecuteAsync()
+ {
+ await using var stream = await _storageService.OpenConfigurationReadStreamAsync();
+ if (stream is not null)
+ {
+ LoadConfiguration(stream);
+ }
+ }
+
+ private async Task SaveConfigurationExecuteAsync()
+ {
+ await using var stream = await _storageService.OpenConfigurationWriteStreamAsync("TestApp.json");
+ if (stream is not null)
+ {
+ SaveConfiguration(stream);
+ }
+ }
+
+ private async Task AddItemExecuteAsync()
+ {
+ var paths = await _storageService.PickSvgPathsAsync();
+ foreach (var path in paths)
+ {
+ AddItem(path);
+ }
+ }
+
+ private void ClearConfiguration()
+ {
+ ItemQuery = null;
+ SelectedItem = null;
+ _items.Clear();
+ RefreshFilteredItems();
+ SvgView.NotifySelectionChanged();
+ _saveConfigurationCommand.NotifyCanExecuteChanged();
+ }
+
+ private void AddItem(string path)
+ {
+ if (_items.Any(x => string.Equals(x.Path, path, StringComparison.OrdinalIgnoreCase)))
+ {
+ return;
+ }
+
+ var item = new FileItemViewModel(Path.GetFileName(path), path, RemoveItem);
+ _items.Add(item);
+ RefreshFilteredItems();
+ _saveConfigurationCommand.NotifyCanExecuteChanged();
+ }
+
+ private void RemoveItem(FileItemViewModel item)
+ {
+ if (SelectedItem == item)
+ {
+ SelectedItem = null;
+ }
+
+ _items.Remove(item);
+ RefreshFilteredItems();
+ _saveConfigurationCommand.NotifyCanExecuteChanged();
+ }
+
+ private void RefreshFilteredItems()
+ {
+ var filtered = string.IsNullOrWhiteSpace(ItemQuery)
+ ? _items.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+ : _items.Where(x => x.Name.Contains(ItemQuery, StringComparison.OrdinalIgnoreCase))
+ .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase);
+
+ _filteredItems.Clear();
+ foreach (var item in filtered)
+ {
+ _filteredItems.Add(item);
+ }
+ }
+}
diff --git a/samples/TestApp.Shared/ViewModels/ObservableObject.cs b/samples/TestApp.Shared/ViewModels/ObservableObject.cs
new file mode 100644
index 0000000000..c152ae5a46
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/ObservableObject.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace TestApp.ViewModels;
+
+public abstract class ObservableObject : INotifyPropertyChanged
+{
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value))
+ {
+ return false;
+ }
+
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/samples/TestApp.Shared/ViewModels/RelayCommand.cs b/samples/TestApp.Shared/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000000..fbde56163f
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/RelayCommand.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Windows.Input;
+
+namespace TestApp.ViewModels;
+
+public sealed class RelayCommand : ICommand
+{
+ private readonly Action _execute;
+ private readonly Func? _canExecute;
+
+ public RelayCommand(Action execute, Func? canExecute = null)
+ {
+ _execute = execute;
+ _canExecute = canExecute;
+ }
+
+ public event EventHandler? CanExecuteChanged;
+
+ public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
+
+ public void Execute(object? parameter) => _execute();
+
+ public void NotifyCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
+}
diff --git a/samples/TestApp.Shared/ViewModels/SvgInteractionViewModel.cs b/samples/TestApp.Shared/ViewModels/SvgInteractionViewModel.cs
new file mode 100644
index 0000000000..b8638c9cc4
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/SvgInteractionViewModel.cs
@@ -0,0 +1,282 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using Svg.Skia;
+using ShimPoint = ShimSkiaSharp.SKPoint;
+using SkiaColors = SkiaSharp.SKColors;
+using SkiaPaint = SkiaSharp.SKPaint;
+using SkiaPaintStyle = SkiaSharp.SKPaintStyle;
+
+namespace TestApp.ViewModels;
+
+public sealed class SvgInteractionViewModel : ViewModelBase, IDisposable
+{
+ private readonly ObservableCollection _hitResults = new();
+ private readonly ReadOnlyObservableCollection _readOnlyHitResults;
+ private readonly IList _hitTestPoints = new List();
+ private ITestAppSvgViewAdapter? _view;
+ private SKSvg? _currentSkSvg;
+ private bool _showHitBounds;
+ private string _animationStatusText = "No animation";
+ private string _animationClockText = "00:00.000";
+ private string _animationBackendInfoText = "Default";
+ private string? _animationBackendFallbackReason;
+ private bool _canPlay;
+ private bool _canPause;
+ private bool _canRestart;
+ private double _resumeAnimationPlaybackRate = 1.0;
+
+ public SvgInteractionViewModel()
+ {
+ _readOnlyHitResults = new ReadOnlyObservableCollection(_hitResults);
+ }
+
+ public ReadOnlyObservableCollection HitResults => _readOnlyHitResults;
+
+ public bool ShowHitBounds
+ {
+ get => _showHitBounds;
+ set
+ {
+ if (SetProperty(ref _showHitBounds, value))
+ {
+ SubscribeOnDraw();
+ _view?.InvalidateView();
+ }
+ }
+ }
+
+ public string AnimationStatusText
+ {
+ get => _animationStatusText;
+ private set => SetProperty(ref _animationStatusText, value);
+ }
+
+ public string AnimationClockText
+ {
+ get => _animationClockText;
+ private set => SetProperty(ref _animationClockText, value);
+ }
+
+ public string AnimationBackendInfoText
+ {
+ get => _animationBackendInfoText;
+ private set => SetProperty(ref _animationBackendInfoText, value);
+ }
+
+ public string? AnimationBackendFallbackReason
+ {
+ get => _animationBackendFallbackReason;
+ private set => SetProperty(ref _animationBackendFallbackReason, value);
+ }
+
+ public bool CanPlay
+ {
+ get => _canPlay;
+ private set => SetProperty(ref _canPlay, value);
+ }
+
+ public bool CanPause
+ {
+ get => _canPause;
+ private set => SetProperty(ref _canPause, value);
+ }
+
+ public bool CanRestart
+ {
+ get => _canRestart;
+ private set => SetProperty(ref _canRestart, value);
+ }
+
+ public void Attach(ITestAppSvgViewAdapter view)
+ {
+ _view = view;
+ SubscribeOnDraw();
+ UpdateAnimationUi();
+ }
+
+ public void Detach()
+ {
+ if (_currentSkSvg is not null)
+ {
+ _currentSkSvg.OnDraw -= SkSvgOnDraw;
+ }
+
+ _currentSkSvg = null;
+ _view = null;
+ }
+
+ public void NotifySelectionChanged()
+ {
+ _hitResults.Clear();
+ _hitTestPoints.Clear();
+ SubscribeOnDraw();
+ _view?.InvalidateView();
+ UpdateAnimationUi();
+ }
+
+ public void HandlePointerPressed(double x, double y)
+ {
+ _hitResults.Clear();
+ _hitTestPoints.Clear();
+
+ if (_view?.SkSvg is null)
+ {
+ return;
+ }
+
+ if (_view.TryGetPicturePoint(x, y, out var picturePoint))
+ {
+ _hitTestPoints.Add(picturePoint);
+ var element = _view.HitTestElements(x, y).FirstOrDefault();
+ if (element is not null)
+ {
+ _hitResults.Add(element.ID ?? element.GetType().Name);
+ }
+ }
+
+ SubscribeOnDraw();
+ _view.InvalidateView();
+ }
+
+ public void Tick()
+ {
+ if (!ReferenceEquals(_currentSkSvg, _view?.SkSvg))
+ {
+ SubscribeOnDraw();
+ }
+
+ if (_view?.AnimationPlaybackRate > 0)
+ {
+ _resumeAnimationPlaybackRate = _view.AnimationPlaybackRate;
+ }
+
+ UpdateAnimationUi();
+ }
+
+ public void PlayAnimation()
+ {
+ if (_view?.SkSvg?.HasAnimations != true)
+ {
+ UpdateAnimationUi();
+ return;
+ }
+
+ if (_view.AnimationPlaybackRate <= 0)
+ {
+ _view.AnimationPlaybackRate = _resumeAnimationPlaybackRate > 0 ? _resumeAnimationPlaybackRate : 1.0;
+ }
+
+ UpdateAnimationUi();
+ }
+
+ public void PauseAnimation()
+ {
+ if (_view is null)
+ {
+ return;
+ }
+
+ if (_view.AnimationPlaybackRate > 0)
+ {
+ _resumeAnimationPlaybackRate = _view.AnimationPlaybackRate;
+ }
+
+ _view.AnimationPlaybackRate = 0;
+ UpdateAnimationUi();
+ }
+
+ public void RestartAnimation()
+ {
+ _view?.SkSvg?.ResetAnimation();
+ AutoStartAnimationIfNeeded();
+ _view?.InvalidateView();
+ UpdateAnimationUi();
+ }
+
+ public void Dispose()
+ {
+ Detach();
+ }
+
+ private void SubscribeOnDraw()
+ {
+ if (_currentSkSvg is not null)
+ {
+ _currentSkSvg.OnDraw -= SkSvgOnDraw;
+ }
+
+ _currentSkSvg = _view?.SkSvg;
+
+ if (_currentSkSvg is not null)
+ {
+ _currentSkSvg.OnDraw += SkSvgOnDraw;
+ }
+
+ AutoStartAnimationIfNeeded();
+ UpdateAnimationUi();
+ }
+
+ private void AutoStartAnimationIfNeeded()
+ {
+ if (_view?.SkSvg?.HasAnimations != true || _view.AnimationPlaybackRate > 0)
+ {
+ return;
+ }
+
+ _view.AnimationPlaybackRate = _resumeAnimationPlaybackRate > 0 ? _resumeAnimationPlaybackRate : 1.0;
+ }
+
+ private void UpdateAnimationUi()
+ {
+ var skSvg = _view?.SkSvg;
+ var hasAnimations = skSvg?.HasAnimations == true;
+ var animationTime = skSvg?.AnimationTime ?? TimeSpan.Zero;
+ var isPaused = (_view?.AnimationPlaybackRate ?? 0) <= 0;
+
+ CanPlay = hasAnimations && isPaused;
+ CanPause = hasAnimations && !isPaused;
+ CanRestart = hasAnimations;
+
+ AnimationStatusText = !hasAnimations
+ ? "No animation"
+ : isPaused
+ ? "Paused"
+ : "Playing";
+
+ AnimationClockText = animationTime.ToString(@"mm\:ss\.fff");
+ AnimationBackendInfoText = _view?.ActualAnimationBackend.ToString() ?? "Default";
+ AnimationBackendFallbackReason = _view?.AnimationBackendFallbackReason;
+ }
+
+ private void SkSvgOnDraw(object? sender, SKSvgDrawEventArgs e)
+ {
+ if (!_showHitBounds || sender is not SKSvg skSvg)
+ {
+ return;
+ }
+
+ var hits = new HashSet();
+
+ foreach (var point in _hitTestPoints)
+ {
+ foreach (var node in skSvg.HitTestSceneNodes(point))
+ {
+ hits.Add(node);
+ }
+ }
+
+ using var paint = new SkiaPaint
+ {
+ IsAntialias = true,
+ Style = SkiaPaintStyle.Stroke,
+ Color = SkiaColors.Cyan
+ };
+
+ foreach (var hit in hits.Take(1))
+ {
+ e.Canvas.DrawRect(skSvg.SkiaModel.ToSKRect(hit.TransformedBounds), paint);
+ }
+ }
+}
diff --git a/samples/TestApp.Shared/ViewModels/ViewModelBase.cs b/samples/TestApp.Shared/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000000..8aa861548c
--- /dev/null
+++ b/samples/TestApp.Shared/ViewModels/ViewModelBase.cs
@@ -0,0 +1,5 @@
+namespace TestApp.ViewModels;
+
+public abstract class ViewModelBase : ObservableObject
+{
+}
diff --git a/samples/TestApp/App.axaml.cs b/samples/TestApp/App.axaml.cs
index fb59392826..1a4bb0432c 100644
--- a/samples/TestApp/App.axaml.cs
+++ b/samples/TestApp/App.axaml.cs
@@ -4,6 +4,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using TestApp.Services;
using TestApp.ViewModels;
using TestApp.Views;
@@ -20,7 +21,7 @@ public override void Initialize()
public override void OnFrameworkInitializationCompleted()
{
- var mainWindowViewModel = new MainWindowViewModel();
+ var mainWindowViewModel = new MainWindowViewModel(new StorageService());
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
diff --git a/samples/TestApp/Program.cs b/samples/TestApp/Program.cs
index c6f59442c2..d38b8a6f3b 100644
--- a/samples/TestApp/Program.cs
+++ b/samples/TestApp/Program.cs
@@ -1,6 +1,5 @@
using System;
using Avalonia;
-using Avalonia.ReactiveUI;
using Avalonia.Svg.Skia;
namespace TestApp;
@@ -28,7 +27,6 @@ public static AppBuilder BuildAvaloniaApp()
.UsePlatformDetect()
.With(new X11PlatformOptions { })
.LogToTrace()
- .UseSkia()
- .UseReactiveUI();
+ .UseSkia();
}
}
diff --git a/samples/TestApp/Services/StorageService.cs b/samples/TestApp/Services/StorageService.cs
index bb5797b4e5..46a4bd4ab4 100644
--- a/samples/TestApp/Services/StorageService.cs
+++ b/samples/TestApp/Services/StorageService.cs
@@ -1,3 +1,9 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -6,98 +12,147 @@
namespace TestApp.Services;
-internal static class StorageService
+internal sealed class StorageService : ITestAppStorageService
{
- public static FilePickerFileType All { get; } = new("All")
+ private static FilePickerFileType All { get; } = new("All")
{
Patterns = new[] { "*.*" },
MimeTypes = new[] { "*/*" }
};
- public static FilePickerFileType Json { get; } = new("Json")
+ private static FilePickerFileType Json { get; } = new("Json")
{
Patterns = new[] { "*.json" },
AppleUniformTypeIdentifiers = new[] { "public.json" },
MimeTypes = new[] { "application/json" }
};
- public static FilePickerFileType CSharp { get; } = new("C#")
+ private static FilePickerFileType CSharp { get; } = new("C#")
{
Patterns = new[] { "*.cs" },
AppleUniformTypeIdentifiers = new[] { "public.csharp-source" },
MimeTypes = new[] { "text/plain" }
};
- public static FilePickerFileType ImagePng { get; } = new("PNG image")
+ private static FilePickerFileType ImagePng { get; } = new("PNG image")
{
Patterns = new[] { "*.png" },
AppleUniformTypeIdentifiers = new[] { "public.png" },
MimeTypes = new[] { "image/png" }
};
- public static FilePickerFileType ImageJpg { get; } = new("JPEG image")
+ private static FilePickerFileType ImageJpg { get; } = new("JPEG image")
{
Patterns = new[] { "*.jpg", "*.jpeg" },
AppleUniformTypeIdentifiers = new[] { "public.jpeg" },
MimeTypes = new[] { "image/jpeg" }
};
- public static FilePickerFileType ImageAll { get; } = new("All Images")
+ private static FilePickerFileType Pdf { get; } = new("PDF document")
{
- Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.bmp" },
- AppleUniformTypeIdentifiers = new[] { "public.image" },
- MimeTypes = new[] { "image/*" }
+ Patterns = new[] { "*.pdf" },
+ AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" },
+ MimeTypes = new[] { "application/pdf" }
};
- public static FilePickerFileType ImageSvg { get; } = new("Svg")
+ private static FilePickerFileType Xps { get; } = new("XPS document")
{
- Patterns = new[] { "*.svg" },
- AppleUniformTypeIdentifiers = new[] { "public.svg-image" },
- MimeTypes = new[] { "image/svg+xml" }
+ Patterns = new[] { "*.xps" },
+ AppleUniformTypeIdentifiers = new[] { "com.microsoft.xps" },
+ MimeTypes = new[] { "application/oxps", "application/vnd.ms-xpsdocument" }
};
- public static FilePickerFileType ImageSvgz { get; } = new("Svgz")
+ public async Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default)
{
- Patterns = new[] { "*.svgz" },
- // TODO:
- AppleUniformTypeIdentifiers = new[] { "public.svg-image" },
- // TODO:
- MimeTypes = new[] { "image/svg+xml" }
- };
+ var storageProvider = GetStorageProvider();
+ if (storageProvider is null)
+ {
+ return null;
+ }
- public static FilePickerFileType Xaml { get; } = new("Xaml")
- {
- Patterns = new[] { "*.xaml" },
- // TODO:
- AppleUniformTypeIdentifiers = new[] { "public.xaml" },
- // TODO:
- MimeTypes = new[] { "application/xaml" }
- };
+ var result = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open configuration",
+ FileTypeFilter = new[] { Json, All },
+ AllowMultiple = false
+ });
+
+ var file = result.FirstOrDefault();
+ return file is null ? null : await file.OpenReadAsync();
+ }
- public static FilePickerFileType Axaml { get; } = new("Axaml")
+ public async Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default)
{
- Patterns = new[] { "*.axaml" },
- // TODO:
- AppleUniformTypeIdentifiers = new[] { "public.axaml" },
- // TODO:
- MimeTypes = new[] { "application/axaml" }
- };
+ var storageProvider = GetStorageProvider();
+ if (storageProvider is null)
+ {
+ return null;
+ }
- public static FilePickerFileType Pdf { get; } = new("PDF document")
+ var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Save configuration",
+ FileTypeChoices = new[] { Json, All },
+ SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedFileName),
+ DefaultExtension = "json",
+ ShowOverwritePrompt = true
+ });
+
+ return file is null ? null : await file.OpenWriteAsync();
+ }
+
+ public async Task> PickSvgPathsAsync(CancellationToken cancellationToken = default)
{
- Patterns = new[] { "*.pdf" },
- AppleUniformTypeIdentifiers = new[] { "com.adobe.pdf" },
- MimeTypes = new[] { "application/pdf" }
- };
+ var storageProvider = GetStorageProvider();
+ if (storageProvider is null)
+ {
+ return Array.Empty();
+ }
+
+ var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open svg files",
+ AllowMultiple = true,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("Svg Files")
+ {
+ Patterns = new[] { "*.svg", "*.svgz" },
+ AppleUniformTypeIdentifiers = new[] { "public.svg-image" },
+ MimeTypes = new[] { "image/svg+xml", "application/gzip" }
+ },
+ All
+ }
+ });
+
+ return files
+ .Select(file => file.TryGetLocalPath())
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Cast()
+ .ToList();
+ }
- public static FilePickerFileType Xps { get; } = new("XPS document")
+ public async Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default)
{
- Patterns = new[] { "*.xps" },
- AppleUniformTypeIdentifiers = new[] { "com.microsoft.xps" },
- MimeTypes = new[] { "application/oxps", "application/vnd.ms-xpsdocument" }
- };
+ var storageProvider = GetStorageProvider();
+ if (storageProvider is null)
+ {
+ return null;
+ }
+
+ var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Export svg",
+ FileTypeChoices = new[] { ImagePng, ImageJpg, CSharp, Pdf, Xps, All },
+ SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedFileName),
+ DefaultExtension = "png",
+ ShowOverwritePrompt = true
+ });
+
+ return file is null ? null : new TestAppSaveStreamResult(await file.OpenWriteAsync(), file.Name);
+ }
- public static IStorageProvider? GetStorageProvider()
+ private static IStorageProvider? GetStorageProvider()
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } window })
{
diff --git a/samples/TestApp/TestApp.csproj b/samples/TestApp/TestApp.csproj
index 07d8b3d706..d2f9f32143 100644
--- a/samples/TestApp/TestApp.csproj
+++ b/samples/TestApp/TestApp.csproj
@@ -8,16 +8,6 @@
TestApp
-
- true
- $(BaseIntermediateOutputPath)\GeneratedFiles
-
-
-
- 13
- true
-
-
@@ -26,23 +16,15 @@
-
+
-
-
-
- all
- runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
diff --git a/samples/TestApp/ViewModels/FileItemViewModel.cs b/samples/TestApp/ViewModels/FileItemViewModel.cs
deleted file mode 100644
index f5b28f4503..0000000000
--- a/samples/TestApp/ViewModels/FileItemViewModel.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-using System;
-using System.Diagnostics;
-using System.Runtime.InteropServices;
-using System.Windows.Input;
-using ReactiveUI;
-
-namespace TestApp.ViewModels;
-
-public partial class FileItemViewModel : ViewModelBase
-{
- [Reactive]
- public partial string Name { get; set; }
-
- [Reactive]
- public partial string Path { get; set; }
-
- public ICommand RemoveCommand { get; }
-
- public ICommand OpenInExplorerCommand { get; }
-
- public ICommand OpenInNotepadCommand { get; }
-
- public FileItemViewModel(string name, string path, Action remove)
- {
- _name = name;
- _path = path;
-
- OpenInExplorerCommand = ReactiveCommand.Create(() =>
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Process.Start("explorer", _path);
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- {
- Process.Start("xdg-open", _path);
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- Process.Start("open", _path);
- }
- });
-
- OpenInNotepadCommand = ReactiveCommand.Create(() =>
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Process.Start("notepad", _path);
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- {
- Process.Start("xdg-open", _path);
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- Process.Start("open", "-t " + _path);
- }
- });
-
- RemoveCommand = ReactiveCommand.Create(() =>
- {
- remove(this);
- });
- }
-}
diff --git a/samples/TestApp/ViewModels/MainWindowViewModel.cs b/samples/TestApp/ViewModels/MainWindowViewModel.cs
deleted file mode 100644
index 362061d8fc..0000000000
--- a/samples/TestApp/ViewModels/MainWindowViewModel.cs
+++ /dev/null
@@ -1,391 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using System.Reactive.Linq;
-using System.Text.Json;
-using System.Threading.Tasks;
-using System.Windows.Input;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Platform.Storage;
-using DynamicData;
-using DynamicData.Binding;
-using ReactiveUI;
-using Svg.Skia;
-using TestApp.Models;
-using TestApp.Services;
-
-namespace TestApp.ViewModels;
-
-public partial class MainWindowViewModel : ViewModelBase
-{
- private readonly ObservableCollection? _items;
-
- [Reactive]
- public partial FileItemViewModel? SelectedItem { get; set; }
-
- [Reactive]
- public partial string? ItemQuery { get; set; }
-
- [Reactive]
- public partial ReadOnlyObservableCollection? FilteredItems { get; set; }
-
- public ICommand ResetQueryCommand { get; }
-
- public ICommand LoadConfigurationCommand { get; }
-
- public ICommand SaveConfigurationCommand { get; }
-
- public ICommand ClearConfigurationCommand { get; }
-
- public ICommand AddItemCommand { get; }
-
- public ICommand ExportCommand { get; }
-
- private List GetConfigurationFileTypes()
- {
- return new List
- {
- StorageService.Json,
- StorageService.All
- };
- }
-
- private static List GetExportFileTypes()
- {
- return new List
- {
- StorageService.ImagePng,
- StorageService.ImageJpg,
- StorageService.CSharp,
- StorageService.Pdf,
- StorageService.Xps,
- StorageService.All
- };
- }
-
- public MainWindowViewModel()
- {
- _items = new ObservableCollection();
-
- var queryFilter = this.WhenAnyItemQuery()
- .Throttle(TimeSpan.FromMilliseconds(100))
- .Select(ItemQueryFilter)
- .DistinctUntilChanged();
-
- _items
- .ToObservableChangeSet()
- .Filter(queryFilter)
- .Sort(SortExpressionComparer.Ascending(x => x.Name))
- .ObserveOn(RxApp.MainThreadScheduler)
- .Bind(out _filteredItems)
- .AsObservableList();
-
- var resetQueryCanExecute = this.WhenAnyItemQuery()
- .Select(x => !string.IsNullOrWhiteSpace(x))
- .ObserveOn(RxApp.MainThreadScheduler);
-
- ResetQueryCommand = ReactiveCommand.Create(() => ItemQuery = "", resetQueryCanExecute);
-
- LoadConfigurationCommand = ReactiveCommand.CreateFromTask(async () => await LoadConfigurationExecute());
-
- SaveConfigurationCommand = ReactiveCommand.CreateFromTask(async () => await SaveConfigurationExecute());
-
- ClearConfigurationCommand = ReactiveCommand.Create(ClearConfigurationExecute);
-
- AddItemCommand = ReactiveCommand.CreateFromTask(async () => await AddItemExecute());
-
- ExportCommand = ReactiveCommand.CreateFromTask(async svg => await ExportExecute(svg));
- }
-
- private async Task ExportExecute(Avalonia.Svg.Skia.Svg svg)
- {
- if (_selectedItem is null || svg.Picture is null)
- {
- return;
- }
-
- var storageProvider = StorageService.GetStorageProvider();
- if (storageProvider is null)
- {
- return;
- }
-
- var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
- {
- Title = "Export svg",
- FileTypeChoices = GetExportFileTypes(),
- SuggestedFileName = Path.GetFileNameWithoutExtension(_selectedItem.Path),
- DefaultExtension = "png",
- ShowOverwritePrompt = true
- });
-
- if (file is not null)
- {
- try
- {
- await using var stream = await file.OpenWriteAsync();
- await Export(stream, file.Name, svg, "#00FFFFFF", 1f, 1f);
- }
- catch (Exception ex)
- {
- Debug.WriteLine(ex.Message);
- Debug.WriteLine(ex.StackTrace);
- }
- }
- }
-
- private async Task AddItemExecute()
- {
- var window = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
- if (window is null)
- {
- return;
- }
-
- var dlg = new OpenFileDialog
- {
- AllowMultiple = true,
- Filters = new List
- {
- new() {Name = "Svg Files (*.svg;*.svgz)", Extensions = new List {"svg", "svgz"}},
- new() {Name = "All Files (*.*)", Extensions = new List {"*"}}
- }
- };
- var result = await dlg.ShowAsync(window);
- if (result is { })
- {
- var paths = result.ToList();
- foreach (var path in paths)
- {
- AddItem(path);
- }
- }
- }
-
- private void ClearConfigurationExecute()
- {
- ItemQuery = null;
- SelectedItem = null;
- _items?.Clear();
- }
-
- private async Task SaveConfigurationExecute()
- {
- var storageProvider = StorageService.GetStorageProvider();
- if (storageProvider is null)
- {
- return;
- }
-
- var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
- {
- Title = "Save configuration",
- FileTypeChoices = GetConfigurationFileTypes(),
- SuggestedFileName = Path.GetFileNameWithoutExtension("TestApp.json"),
- DefaultExtension = "json",
- ShowOverwritePrompt = true
- });
-
- if (file is not null)
- {
- try
- {
- await using var stream = await file.OpenWriteAsync();
- SaveConfiguration(stream);
- }
- catch (Exception ex)
- {
- Debug.WriteLine(ex.Message);
- Debug.WriteLine(ex.StackTrace);
- }
- }
- }
-
- private async Task LoadConfigurationExecute()
- {
- var storageProvider = StorageService.GetStorageProvider();
- if (storageProvider is null)
- {
- return;
- }
-
- var result = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
- {
- Title = "Open configuration",
- FileTypeFilter = GetConfigurationFileTypes(),
- AllowMultiple = false
- });
-
- var file = result.FirstOrDefault();
-
- if (file is not null)
- {
- try
- {
- await using var stream = await file.OpenReadAsync();
- LoadConfiguration(stream);
- }
- catch (Exception ex)
- {
- Debug.WriteLine(ex.Message);
- Debug.WriteLine(ex.StackTrace);
- }
- }
- }
-
- private Func ItemQueryFilter(string? searchQuery)
- {
- return item =>
- {
- if (!string.IsNullOrWhiteSpace(searchQuery))
- {
- return item.Name.Contains(searchQuery, StringComparison.OrdinalIgnoreCase);
- }
-
- return true;
- };
- }
-
- public void Drop(IEnumerable paths)
- {
- foreach (var path in paths)
- {
- if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
- {
- var svgPaths = Directory.EnumerateFiles(path, "*.svg", new EnumerationOptions { RecurseSubdirectories = true });
- var svgzPaths = Directory.EnumerateFiles(path, "*.svgz", new EnumerationOptions { RecurseSubdirectories = true });
- Drop(svgPaths);
- Drop(svgzPaths);
- continue;
- }
-
- var extension = Path.GetExtension(path);
- switch (extension.ToLower())
- {
- case ".svg":
- case ".svgz":
- {
- AddItem(path);
- break;
- }
- case ".json":
- {
- using var stream = File.OpenRead(path);
- LoadConfiguration(stream);
- break;
- }
- }
- }
- }
-
- private void AddItem(string path)
- {
- if (_items is { })
- {
- var item = new FileItemViewModel(Path.GetFileName(path), path, x => _items.Remove(x));
- _items.Add(item);
- }
- }
-
- public void LoadConfiguration(Stream stream)
- {
- using var reader = new StreamReader(stream);
- var json = reader.ReadToEnd();
- var configuration = JsonSerializer.Deserialize(json);
-
- if (configuration?.Paths is { })
- {
- SelectedItem = null;
- _items?.Clear();
-
- foreach (var path in configuration.Paths)
- {
- AddItem(path);
- }
- }
-
- ItemQuery = configuration?.Query;
- }
-
- public void SaveConfiguration(Stream stream)
- {
- var configuration = new Configuration
- {
- Paths = _items?.Select(x => x.Path).ToList(),
- Query = ItemQuery
- };
-
- var json = JsonSerializer.Serialize(configuration);
- using var writer = new StreamWriter(stream);
- writer.Write(json);
- }
-
- public async Task Export(Stream stream, string name, Avalonia.Svg.Skia.Svg? svg, string backgroundColor, float scaleX, float scaleY)
- {
- if (svg.Picture is null)
- {
- return;
- }
-
- if (!SkiaSharp.SKColor.TryParse(backgroundColor, out var skBackgroundColor))
- {
- return;
- }
-
- var extension = Path.GetExtension(name);
- switch (extension.ToLower())
- {
- case ".png":
- {
- svg.Picture?.ToImage(
- stream,
- skBackgroundColor,
- SkiaSharp.SKEncodedImageFormat.Png,
- 100,
- scaleX,
- scaleY,
- SkiaSharp.SKColorType.Rgba8888,
- SkiaSharp.SKAlphaType.Premul,
- SkiaSharp.SKColorSpace.CreateSrgb());
- break;
- }
- case ".jpg":
- case ".jpeg":
- {
- svg.Picture?.ToImage(
- stream,
- skBackgroundColor,
- SkiaSharp.SKEncodedImageFormat.Jpeg,
- 100,
- scaleX,
- scaleY,
- SkiaSharp.SKColorType.Rgba8888,
- SkiaSharp.SKAlphaType.Premul,
- SkiaSharp.SKColorSpace.CreateSrgb());
- break;
- }
- case ".pdf":
- {
- svg.Picture?.ToPdf(stream, skBackgroundColor, scaleX, scaleY);
- break;
- }
- case ".xps":
- {
- svg.Picture?.ToXps(stream, skBackgroundColor, scaleX, scaleY);
- break;
- }
- }
- }
-
- private static string CreateClassName(string filename)
- {
- string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filename);
- string className = fileNameWithoutExtension.Replace("-", "_");
- return $"Svg_{className}";
- }
-}
diff --git a/samples/TestApp/ViewModels/ViewModelBase.cs b/samples/TestApp/ViewModels/ViewModelBase.cs
deleted file mode 100644
index 4a8abcdd35..0000000000
--- a/samples/TestApp/ViewModels/ViewModelBase.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-using ReactiveUI;
-
-namespace TestApp.ViewModels;
-
-public class ViewModelBase : ReactiveObject
-{
-}
diff --git a/samples/TestApp/Views/MainView.axaml b/samples/TestApp/Views/MainView.axaml
index 7d8451e315..1f53613e17 100644
--- a/samples/TestApp/Views/MainView.axaml
+++ b/samples/TestApp/Views/MainView.axaml
@@ -1,9 +1,12 @@
-
@@ -58,8 +61,7 @@
-
+ IsHitTestVisible="False" />
@@ -103,76 +105,149 @@
Margin="6"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch">
-
-
-
-
- Disabled
- Auto
- Hidden
- Visible
-
-
-
- Disabled
- Auto
- Hidden
- Visible
-
-
-
- None
- Fill
- Uniform
- UniformToFill
-
+
+
+
+
+
+ Disabled
+ Auto
+ Hidden
+ Visible
+
+
+
+ Disabled
+ Auto
+ Hidden
+ Visible
+
+
+
+
+
+
+ None
+ Fill
+ Uniform
+ UniformToFill
+
+
+
+ Margin="0,0,12,6" />
+ Margin="0,0,12,6" />
+ Margin="0,0,12,6" />
+
+
+
+
+ Default
+ Manual
+ DispatcherTimer
+ RenderLoop
+ NativeComposition
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+ Margin="0,0,0,6"
+ Click="ExportButton_OnClick" />
+
+ Click="ExportButton_OnClick" />
+ Margin="6"
+ ItemsSource="{Binding SvgView.HitResults}" />
diff --git a/samples/TestApp/Views/MainView.axaml.cs b/samples/TestApp/Views/MainView.axaml.cs
index 4fe7790ad8..50f2459346 100644
--- a/samples/TestApp/Views/MainView.axaml.cs
+++ b/samples/TestApp/Views/MainView.axaml.cs
@@ -1,59 +1,53 @@
-using System;
+using System;
using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.Diagnostics;
using System.Linq;
-using System.Runtime.InteropServices;
+using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
-using ShimSkiaSharp;
-using SkiaSharp;
-using Svg.Model.Drawables;
-using Svg.Model.Services;
+using Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using Svg;
using Svg.Skia;
using TestApp.ViewModels;
+using ShimPoint = ShimSkiaSharp.SKPoint;
+using SkiaPicture = SkiaSharp.SKPicture;
namespace TestApp.Views;
public partial class MainView : UserControl
{
- private readonly ObservableCollection _hitResults = new();
- private SKSvg? _currentSkSvg;
- private bool _showHitBounds;
- private SkiaSharp.SKColor _hitBoundsColor = SKColors.Cyan;
- private readonly IList _hitTestPoints = new List();
- private readonly IList _hitTestRects = new List();
+ private readonly DispatcherTimer _animationUiTimer;
+ private readonly AvaloniaSvgViewAdapter _svgViewAdapter;
+ private MainWindowViewModel? _boundViewModel;
public MainView()
{
InitializeComponent();
+
+ _svgViewAdapter = new AvaloniaSvgViewAdapter(Svg);
+ _animationUiTimer = new DispatcherTimer(TimeSpan.FromMilliseconds(100), DispatcherPriority.Background, OnAnimationUiTick);
+ _animationUiTimer.Start();
+
AddHandler(DragDrop.DropEvent, Drop);
AddHandler(DragDrop.DragOverEvent, DragOver);
- HitResults.ItemsSource = _hitResults;
- SubscribeOnDraw();
+
+ DataContextChanged += OnDataContextChanged;
+ DetachedFromVisualTree += (_, _) => _boundViewModel?.SvgView.Detach();
}
- private void SubscribeOnDraw()
+ private void OnDataContextChanged(object? sender, EventArgs e)
{
- if (_currentSkSvg is { })
- {
- _currentSkSvg.OnDraw -= SkSvg_OnDraw;
- }
-
- _currentSkSvg = Svg.SkSvg;
-
- if (_currentSkSvg is { })
- {
- _currentSkSvg.OnDraw += SkSvg_OnDraw;
- }
+ _boundViewModel?.SvgView.Detach();
+ _boundViewModel = DataContext as MainWindowViewModel;
+ _boundViewModel?.SvgView.Attach(_svgViewAdapter);
}
private void DragOver(object? sender, DragEventArgs e)
{
e.DragEffects = e.DragEffects & (DragDropEffects.Copy | DragDropEffects.Link);
- if (!e.Data.Contains(DataFormats.Files))
+ if (!HasFileDrop(e))
{
e.DragEffects = DragDropEffects.None;
}
@@ -61,135 +55,129 @@ private void DragOver(object? sender, DragEventArgs e)
private void Drop(object? sender, DragEventArgs e)
{
- if (e.Data.Contains(DataFormats.Files))
+ if (!TryGetDroppedPaths(e, out var paths))
{
- var paths = e.Data.GetFileNames();
- if (paths is { })
- {
- if (DataContext is MainWindowViewModel vm)
- {
- try
- {
- vm.Drop(paths);
- }
- catch (Exception)
- {
- // ignored
- }
- }
- }
+ return;
}
- }
- private void FileItem_OnDoubleTapped(object? sender, TappedEventArgs e)
- {
- if (sender is Control control && control.DataContext is FileItemViewModel fileItemViewModel)
+ if (DataContext is MainWindowViewModel vm)
{
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- Process.Start("explorer", fileItemViewModel.Path);
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
- {
- Process.Start("xdg-open", fileItemViewModel.Path);
- }
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- Process.Start("open", fileItemViewModel.Path);
- }
+ vm.Drop(paths);
}
}
- private void ShowHitBoundsToggle_OnToggled(object? sender, RoutedEventArgs e)
+ private void FileItem_OnDoubleTapped(object? sender, TappedEventArgs e)
{
- _showHitBounds = ShowHitBoundsToggle.IsChecked == true;
- SubscribeOnDraw();
- Svg.InvalidateVisual();
+ if (sender is Control { DataContext: FileItemViewModel fileItem })
+ {
+ fileItem.OpenInExplorerCommand.Execute(null);
+ }
}
private void Svg_OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
- var pt = e.GetPosition(Svg);
-
- _hitResults.Clear();
-
- if (Svg.SkSvg is { })
+ if (DataContext is MainWindowViewModel vm)
{
- _hitTestPoints.Clear();
-
- if (Svg.TryGetPicturePoint(pt, out var skPoint))
- {
- _hitTestPoints.Add(skPoint);
-
- // foreach (var element in Svg.HitTestElements(pt))
- // {
- // _hitResults.Add(element.ID);
- // }
- var element = Svg.HitTestElements(pt).FirstOrDefault();
- if (element is { })
- {
- _hitResults.Add(element.ID ?? element.GetType().Name);
- }
- }
+ var point = e.GetPosition(Svg);
+ vm.SvgView.HandlePointerPressed(point.X, point.Y);
}
-
- SubscribeOnDraw();
- Svg.InvalidateVisual();
}
private void SelectingItemsControl_OnSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
- _hitResults.Clear();
-
- if (Svg.SkSvg is { })
+ if (DataContext is MainWindowViewModel vm)
{
- _hitTestPoints.Clear();
- _showHitBounds = ShowHitBoundsToggle.IsChecked == true;
- SubscribeOnDraw();
+ vm.SvgView.NotifySelectionChanged();
}
+ }
- Svg.InvalidateVisual();
+ private void PlayAnimationButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.SvgView.PlayAnimation();
+ }
}
- private void SkSvg_OnDraw(object? sender, SKSvgDrawEventArgs e)
+ private void PauseAnimationButton_OnClick(object? sender, RoutedEventArgs e)
{
- if (sender is not SKSvg skSvg || skSvg.Drawable is not DrawableBase drawable)
+ if (DataContext is MainWindowViewModel vm)
{
- return;
+ vm.SvgView.PauseAnimation();
}
+ }
- if (!_showHitBounds)
+ private void RestartAnimationButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
{
- return;
+ vm.SvgView.RestartAnimation();
}
+ }
- var hits = new HashSet();
+ private async void ExportButton_OnClick(object? sender, RoutedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ await vm.ExportAsync(Svg.Picture);
+ }
+ }
- foreach (var pt in _hitTestPoints)
+ private void OnAnimationUiTick(object? sender, EventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
{
- foreach (var d in HitTestService.HitTest(drawable, pt))
- {
- hits.Add(d);
- }
+ vm.SvgView.Tick();
}
+ }
- foreach (var r in _hitTestRects)
+ private sealed class AvaloniaSvgViewAdapter : ITestAppSvgViewAdapter
+ {
+ private readonly Avalonia.Svg.Skia.Svg _svg;
+
+ public AvaloniaSvgViewAdapter(Avalonia.Svg.Skia.Svg svg)
{
- foreach (var d in HitTestService.HitTest(drawable, r))
- {
- hits.Add(d);
- }
+ _svg = svg;
}
- using var paint = new SkiaSharp.SKPaint();
- paint.IsAntialias = true;
- paint.Style = SkiaSharp.SKPaintStyle.Stroke;
- paint.Color = _hitBoundsColor;
+ public SkiaPicture? Picture => _svg.Picture;
+
+ public SKSvg? SkSvg => _svg.SkSvg;
- foreach (var hit in hits.Take(1))
+ public double AnimationPlaybackRate
{
- var rect = skSvg.SkiaModel.ToSKRect(hit.TransformedBounds);
- e.Canvas.DrawRect(rect, paint);
+ get => _svg.AnimationPlaybackRate;
+ set => _svg.AnimationPlaybackRate = value;
}
+
+ public SvgAnimationHostBackend ActualAnimationBackend => _svg.ActualAnimationBackend;
+
+ public string? AnimationBackendFallbackReason => _svg.AnimationBackendFallbackReason;
+
+ public bool TryGetPicturePoint(double x, double y, out ShimPoint picturePoint)
+ => _svg.TryGetPicturePoint(new Point(x, y), out picturePoint);
+
+ public IEnumerable HitTestElements(double x, double y)
+ => _svg.HitTestElements(new Point(x, y));
+
+ public void InvalidateView() => _svg.InvalidateVisual();
+ }
+
+ private static bool HasFileDrop(DragEventArgs e)
+ => e.DataTransfer.Items.Any(item => item.Formats.Contains(DataFormat.File));
+
+ private static bool TryGetDroppedPaths(DragEventArgs e, out IReadOnlyList paths)
+ {
+ var items = e.DataTransfer.Items
+ .Where(item => item.Formats.Contains(DataFormat.File))
+ .Select(item => item.TryGetRaw(DataFormat.File))
+ .OfType()
+ .Select(item => item.TryGetLocalPath())
+ .Where(path => !string.IsNullOrWhiteSpace(path))
+ .Cast()
+ .ToList();
+
+ paths = items;
+ return items.Count > 0;
}
}
diff --git a/samples/UnoTestApp/App.xaml b/samples/UnoTestApp/App.xaml
new file mode 100644
index 0000000000..964b5af22c
--- /dev/null
+++ b/samples/UnoTestApp/App.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoTestApp/App.xaml.cs b/samples/UnoTestApp/App.xaml.cs
new file mode 100644
index 0000000000..7d1c0269b5
--- /dev/null
+++ b/samples/UnoTestApp/App.xaml.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using TestApp.ViewModels;
+
+namespace UnoTestApp;
+
+public sealed partial class App : Application
+{
+ private const string ConfigurationPath = "TestApp.json";
+ private Window? _mainWindow;
+
+ public App()
+ {
+ InitializeComponent();
+ ViewModel = new MainWindowViewModel(new StorageService());
+ }
+
+ internal MainWindowViewModel ViewModel { get; }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ TryLoadConfiguration();
+
+ _mainWindow = new Window();
+ _mainWindow.Closed += OnMainWindowClosed;
+
+ if (_mainWindow.Content is not Frame rootFrame)
+ {
+ rootFrame = new Frame();
+ rootFrame.NavigationFailed += OnNavigationFailed;
+ _mainWindow.Content = rootFrame;
+ }
+
+ if (rootFrame.Content is null)
+ {
+ rootFrame.Navigate(typeof(MainPage), args.Arguments);
+ }
+
+ _mainWindow.Activate();
+ }
+
+ private void TryLoadConfiguration()
+ {
+ if (!File.Exists(ConfigurationPath))
+ {
+ return;
+ }
+
+ try
+ {
+ using var stream = File.OpenRead(ConfigurationPath);
+ ViewModel.LoadConfiguration(stream);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex);
+ }
+ }
+
+ private void OnMainWindowClosed(object sender, WindowEventArgs args)
+ {
+ try
+ {
+ using var stream = File.Create(ConfigurationPath);
+ ViewModel.SaveConfiguration(stream);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex);
+ }
+ }
+
+ private static void OnNavigationFailed(object sender, NavigationFailedEventArgs e)
+ {
+ throw new InvalidOperationException($"Failed to load {e.SourcePageType.FullName}: {e.Exception}");
+ }
+}
diff --git a/samples/UnoTestApp/MainPage.xaml b/samples/UnoTestApp/MainPage.xaml
new file mode 100644
index 0000000000..a36bb5f08a
--- /dev/null
+++ b/samples/UnoTestApp/MainPage.xaml
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/UnoTestApp/MainPage.xaml.cs b/samples/UnoTestApp/MainPage.xaml.cs
new file mode 100644
index 0000000000..9c24ad6cee
--- /dev/null
+++ b/samples/UnoTestApp/MainPage.xaml.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Input;
+using Svg;
+using Svg.Skia;
+using TestApp.ViewModels;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.Foundation;
+using Windows.System;
+using SKPicture = SkiaSharp.SKPicture;
+using UnoDispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
+using UnoSvgControl = Uno.Svg.Skia.Svg;
+
+namespace UnoTestApp;
+
+public sealed partial class MainPage : Page
+{
+ private readonly UnoDispatcherQueueTimer _animationUiTimer;
+ private readonly UnoSvgViewAdapter _svgViewAdapter;
+ private readonly MainWindowViewModel _viewModel;
+
+ public MainPage()
+ {
+ InitializeComponent();
+
+ _viewModel = ((App)Application.Current).ViewModel;
+ DataContext = _viewModel;
+ _svgViewAdapter = new UnoSvgViewAdapter(Svg);
+
+ HorizontalScrollBarVisibilityBox.ItemsSource = Enum.GetValues();
+ VerticalScrollBarVisibilityBox.ItemsSource = Enum.GetValues();
+ SvgStretchBox.ItemsSource = Enum.GetValues();
+ AnimationBackendBox.ItemsSource = Enum.GetValues();
+ HorizontalScrollBarVisibilityBox.SelectedItem = ScrollBarVisibility.Disabled;
+ VerticalScrollBarVisibilityBox.SelectedItem = ScrollBarVisibility.Disabled;
+ SvgStretchBox.SelectedItem = Microsoft.UI.Xaml.Media.Stretch.None;
+ AnimationBackendBox.SelectedItem = SvgAnimationHostBackend.Default;
+ AnimationPlaybackRateSlider.Value = Svg.AnimationPlaybackRate;
+ AnimationPlaybackRateText.Text = $"{Svg.AnimationPlaybackRate:0.00}x";
+ EnableCacheToggle.IsOn = Svg.EnableCache;
+ WireframeToggle.IsOn = Svg.Wireframe;
+
+ _viewModel.SvgView.Attach(_svgViewAdapter);
+
+ _animationUiTimer = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread().CreateTimer();
+ _animationUiTimer.Interval = TimeSpan.FromMilliseconds(100);
+ _animationUiTimer.Tick += OnAnimationUiTick;
+ _animationUiTimer.Start();
+
+ Unloaded += OnUnloaded;
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ _animationUiTimer.Stop();
+ _viewModel.SvgView.Detach();
+ }
+
+ private async void Root_OnDrop(object sender, DragEventArgs e)
+ {
+ var paths = new List();
+
+ if (e.DataView.Contains(StandardDataFormats.StorageItems))
+ {
+ var items = await e.DataView.GetStorageItemsAsync();
+ paths.AddRange(items.Select(item => item.Path).Where(static path => !string.IsNullOrWhiteSpace(path)));
+ }
+
+ if (paths.Count > 0)
+ {
+ _viewModel.Drop(paths);
+ }
+ }
+
+ private void Root_OnDragOver(object sender, DragEventArgs e)
+ {
+ e.AcceptedOperation = DataPackageOperation.Copy;
+ }
+
+ private void FileItem_OnDoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
+ {
+ if (sender is FrameworkElement { DataContext: FileItemViewModel fileItem })
+ {
+ fileItem.OpenInExplorerCommand.Execute(null);
+ }
+ }
+
+ private void FileList_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ _viewModel.SvgView.NotifySelectionChanged();
+ }
+
+ private void FileList_OnKeyDown(object sender, KeyRoutedEventArgs e)
+ {
+ if (e.Key == VirtualKey.Delete && FileList.SelectedItem is FileItemViewModel item)
+ {
+ item.RemoveCommand.Execute(null);
+ e.Handled = true;
+ }
+ }
+
+ private void HorizontalScrollBarVisibilityBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (HorizontalScrollBarVisibilityBox.SelectedItem is ScrollBarVisibility value)
+ {
+ SvgScrollViewer.HorizontalScrollBarVisibility = value;
+ }
+ }
+
+ private void VerticalScrollBarVisibilityBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (VerticalScrollBarVisibilityBox.SelectedItem is ScrollBarVisibility value)
+ {
+ SvgScrollViewer.VerticalScrollBarVisibility = value;
+ }
+ }
+
+ private void SvgStretchBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (SvgStretchBox.SelectedItem is Microsoft.UI.Xaml.Media.Stretch value)
+ {
+ Svg.Stretch = value;
+ }
+ }
+
+ private void EnableCacheToggle_OnToggled(object sender, RoutedEventArgs e)
+ {
+ Svg.EnableCache = EnableCacheToggle.IsOn;
+ }
+
+ private void WireframeToggle_OnToggled(object sender, RoutedEventArgs e)
+ {
+ Svg.Wireframe = WireframeToggle.IsOn;
+ }
+
+ private void AnimationBackendBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (AnimationBackendBox.SelectedItem is SvgAnimationHostBackend value)
+ {
+ Svg.AnimationBackend = value;
+ }
+ }
+
+ private void AnimationPlaybackRateSlider_OnValueChanged(object sender, RangeBaseValueChangedEventArgs e)
+ {
+ Svg.AnimationPlaybackRate = e.NewValue;
+ AnimationPlaybackRateText.Text = $"{e.NewValue:0.00}x";
+ }
+
+ private void PlayAnimationButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ _viewModel.SvgView.PlayAnimation();
+ }
+
+ private void PauseAnimationButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ _viewModel.SvgView.PauseAnimation();
+ }
+
+ private void RestartAnimationButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ _viewModel.SvgView.RestartAnimation();
+ }
+
+ private void Svg_OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(Svg).Position;
+ _viewModel.SvgView.HandlePointerPressed(point.X, point.Y);
+ }
+
+ private async void ExportButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ await _viewModel.ExportAsync(Svg.Picture);
+ }
+
+ private void OnAnimationUiTick(UnoDispatcherQueueTimer sender, object args)
+ {
+ _viewModel.SvgView.Tick();
+ AnimationPlaybackRateText.Text = $"{Svg.AnimationPlaybackRate:0.00}x";
+ }
+
+ private sealed class UnoSvgViewAdapter : ITestAppSvgViewAdapter
+ {
+ private readonly UnoSvgControl _svg;
+
+ public UnoSvgViewAdapter(UnoSvgControl svg)
+ {
+ _svg = svg;
+ }
+
+ public SKPicture? Picture => _svg.Picture;
+
+ public SKSvg? SkSvg => _svg.SkSvg;
+
+ public double AnimationPlaybackRate
+ {
+ get => _svg.AnimationPlaybackRate;
+ set => _svg.AnimationPlaybackRate = value;
+ }
+
+ public SvgAnimationHostBackend ActualAnimationBackend => _svg.ActualAnimationBackend;
+
+ public string? AnimationBackendFallbackReason => _svg.AnimationBackendFallbackReason;
+
+ public bool TryGetPicturePoint(double x, double y, out ShimSkiaSharp.SKPoint picturePoint)
+ => _svg.TryGetPicturePoint(new Point(x, y), out picturePoint);
+
+ public IEnumerable HitTestElements(double x, double y)
+ => _svg.HitTestElements(new Point(x, y));
+
+ public void InvalidateView() => _svg.Invalidate();
+ }
+}
diff --git a/samples/UnoTestApp/Platforms/Desktop/Program.cs b/samples/UnoTestApp/Platforms/Desktop/Program.cs
new file mode 100644
index 0000000000..2c787fcb42
--- /dev/null
+++ b/samples/UnoTestApp/Platforms/Desktop/Program.cs
@@ -0,0 +1,20 @@
+using Uno.UI.Hosting;
+
+namespace UnoTestApp;
+
+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/UnoTestApp/StorageService.cs b/samples/UnoTestApp/StorageService.cs
new file mode 100644
index 0000000000..c4227e219c
--- /dev/null
+++ b/samples/UnoTestApp/StorageService.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using TestApp.Services;
+using Windows.Storage;
+using Windows.Storage.Pickers;
+
+namespace UnoTestApp;
+
+internal sealed class StorageService : ITestAppStorageService
+{
+ public async Task OpenConfigurationReadStreamAsync(CancellationToken cancellationToken = default)
+ {
+ var picker = new FileOpenPicker();
+ picker.FileTypeFilter.Add(".json");
+
+ var file = await picker.PickSingleFileAsync();
+ return file is null ? null : await file.OpenStreamForReadAsync();
+ }
+
+ public async Task OpenConfigurationWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default)
+ {
+ var picker = new FileSavePicker
+ {
+ SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedFileName),
+ SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
+ DefaultFileExtension = ".json"
+ };
+ picker.FileTypeChoices.Add("Json", new List { ".json" });
+
+ var file = await picker.PickSaveFileAsync();
+ return file is null ? null : await file.OpenStreamForWriteAsync();
+ }
+
+ public async Task> PickSvgPathsAsync(CancellationToken cancellationToken = default)
+ {
+ var picker = new FileOpenPicker();
+ picker.FileTypeFilter.Add(".svg");
+ picker.FileTypeFilter.Add(".svgz");
+
+ var files = await picker.PickMultipleFilesAsync();
+ return files.Select(file => file.Path).ToList();
+ }
+
+ public async Task OpenExportWriteStreamAsync(string suggestedFileName, CancellationToken cancellationToken = default)
+ {
+ var picker = new FileSavePicker
+ {
+ SuggestedFileName = Path.GetFileNameWithoutExtension(suggestedFileName),
+ SuggestedStartLocation = PickerLocationId.DocumentsLibrary,
+ DefaultFileExtension = ".png"
+ };
+ picker.FileTypeChoices.Add("PNG image", new List { ".png" });
+ picker.FileTypeChoices.Add("JPEG image", new List { ".jpg", ".jpeg" });
+ picker.FileTypeChoices.Add("C#", new List { ".cs" });
+ picker.FileTypeChoices.Add("PDF document", new List { ".pdf" });
+ picker.FileTypeChoices.Add("XPS document", new List { ".xps" });
+
+ var file = await picker.PickSaveFileAsync();
+ return file is null ? null : new TestAppSaveStreamResult(await file.OpenStreamForWriteAsync(), file.Name);
+ }
+}
diff --git a/samples/UnoTestApp/UnoTestApp.csproj b/samples/UnoTestApp/UnoTestApp.csproj
new file mode 100644
index 0000000000..42e03ffeff
--- /dev/null
+++ b/samples/UnoTestApp/UnoTestApp.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0-desktop
+ Exe
+ true
+ Svg.Skia Uno Test App
+ com.wieslawsoltes.unotestapp
+ 1.0
+ 1
+ Svg.Skia
+ Uno TestApp sample for Svg.Controls.Skia.Uno.
+ enable
+ enable
+
+ SkiaRenderer;
+
+ False
+
+
+
+
+
+
+
+
diff --git a/samples/svgc/Program.cs b/samples/svgc/Program.cs
index 4e7b5e0012..c1d5ca6a9a 100644
--- a/samples/svgc/Program.cs
+++ b/samples/svgc/Program.cs
@@ -5,6 +5,7 @@
using System.Text.Json;
using System.Threading.Tasks;
using Svg.CodeGen.Skia;
+using Svg.Skia;
namespace svgc;
@@ -33,7 +34,7 @@ static void Generate(string inputPath, string outputPath, string namespaceName =
var svgDocument = Svg.Model.Services.SvgService.FromSvg(svg);
if (svgDocument is { })
{
- var picture = Svg.Model.Services.SvgService.ToModel(svgDocument, AssetLoader, out _, out _);
+ var picture = SvgSceneRuntime.CreateModel(svgDocument, AssetLoader);
if (picture is { } && picture.Commands is { })
{
var text = SkiaCSharpCodeGen.Generate(picture, namespaceName, className);
diff --git a/samples/svgc/svgc.csproj b/samples/svgc/svgc.csproj
index b769f9c027..4f16067042 100644
--- a/samples/svgc/svgc.csproj
+++ b/samples/svgc/svgc.csproj
@@ -34,6 +34,7 @@
+
diff --git a/scripts/capture_w3c_chrome_overrides.mjs b/scripts/capture_w3c_chrome_overrides.mjs
new file mode 100644
index 0000000000..b2a8fd0437
--- /dev/null
+++ b/scripts/capture_w3c_chrome_overrides.mjs
@@ -0,0 +1,196 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import http from 'node:http';
+import { fileURLToPath } from 'node:url';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const repoRoot = path.resolve(__dirname, '..');
+const svgDir = path.join(repoRoot, 'externals', 'W3C_SVG_11_TestSuite', 'W3C_SVG_11_TestSuite', 'svg');
+const outputDir = path.join(repoRoot, 'tests', 'Svg.Skia.UnitTests', 'ChromeReference', 'W3C');
+const wrapperDir = path.join(repoRoot, 'output', 'playwright', 'w3c-capture');
+
+const mimeTypes = new Map([
+ ['.css', 'text/css; charset=utf-8'],
+ ['.html', 'text/html; charset=utf-8'],
+ ['.jpg', 'image/jpeg'],
+ ['.jpeg', 'image/jpeg'],
+ ['.js', 'application/javascript; charset=utf-8'],
+ ['.png', 'image/png'],
+ ['.svg', 'image/svg+xml; charset=utf-8'],
+ ['.txt', 'text/plain; charset=utf-8'],
+]);
+
+function getContentType(filePath)
+{
+ return mimeTypes.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream';
+}
+
+function createStaticServer(rootPath)
+{
+ return http.createServer(async (req, res) =>
+ {
+ try
+ {
+ const requestPath = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
+ const safePath = requestPath === '/'
+ ? path.join(rootPath, 'index.html')
+ : path.join(rootPath, decodeURIComponent(requestPath));
+ const normalizedPath = path.normalize(safePath);
+
+ if (!normalizedPath.startsWith(rootPath))
+ {
+ res.writeHead(403);
+ res.end('Forbidden');
+ return;
+ }
+
+ const stats = await fs.stat(normalizedPath);
+ const filePath = stats.isDirectory()
+ ? path.join(normalizedPath, 'index.html')
+ : normalizedPath;
+
+ const body = await fs.readFile(filePath);
+ res.writeHead(200, { 'Content-Type': getContentType(filePath) });
+ res.end(body);
+ }
+ catch
+ {
+ res.writeHead(404);
+ res.end('Not Found');
+ }
+ });
+}
+
+async function getNames()
+{
+ const cliNames = process.argv.slice(2)
+ .flatMap(arg => arg.split(','))
+ .map(name => name.trim())
+ .filter(Boolean)
+ .map(name => name.endsWith('.png') ? name.slice(0, -4) : name);
+
+ if (cliNames.length > 0)
+ {
+ return cliNames;
+ }
+
+ const entries = await fs.readdir(outputDir, { withFileTypes: true });
+ return entries
+ .filter(entry => entry.isFile() && entry.name.endsWith('.png'))
+ .map(entry => entry.name.slice(0, -4))
+ .sort((left, right) => left.localeCompare(right, 'en'));
+}
+
+async function writeWrapper(name)
+{
+ const wrapperPath = path.join(wrapperDir, `${name}.html`);
+ const svgUrl = `/externals/W3C_SVG_11_TestSuite/W3C_SVG_11_TestSuite/svg/${encodeURIComponent(name)}.svg`;
+ const html = `
+
+
+
+
+
+
+
+
+`;
+
+ await fs.mkdir(wrapperDir, { recursive: true });
+ await fs.writeFile(wrapperPath, html);
+ return wrapperPath;
+}
+
+async function captureOverride(baseUrl, name)
+{
+ const svgPath = path.join(svgDir, `${name}.svg`);
+ const outputPath = path.join(outputDir, `${name}.png`);
+ await fs.access(svgPath);
+
+ const wrapperPath = await writeWrapper(name);
+ const wrapperUrl = `${baseUrl}/${path.relative(repoRoot, wrapperPath).split(path.sep).map(encodeURIComponent).join('/')}`;
+
+ const args = [
+ 'playwright',
+ 'screenshot',
+ '--channel',
+ 'chrome',
+ '--viewport-size',
+ '480,360',
+ '--wait-for-timeout',
+ '1500',
+ '--timeout',
+ '30000',
+ wrapperUrl,
+ outputPath,
+ ];
+
+ await execFileAsync('npx', args, { cwd: repoRoot });
+ return outputPath;
+}
+
+async function main()
+{
+ const names = await getNames();
+ if (names.length < 1)
+ {
+ throw new Error(`No Chrome override targets found in ${outputDir}.`);
+ }
+
+ const server = createStaticServer(repoRoot);
+ await new Promise((resolve, reject) =>
+ {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+
+ const address = server.address();
+ if (!address || typeof address === 'string')
+ {
+ throw new Error('Unable to resolve local server address.');
+ }
+
+ const baseUrl = `http://127.0.0.1:${address.port}`;
+
+ try
+ {
+ for (const name of names)
+ {
+ const outputPath = await captureOverride(baseUrl, name);
+ console.log(`Captured ${name} -> ${path.relative(repoRoot, outputPath)}`);
+ }
+ }
+ finally
+ {
+ await new Promise(resolve => server.close(resolve));
+ }
+}
+
+main().catch(error =>
+{
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/site/articles/getting-started/overview.md b/site/articles/getting-started/overview.md
index 0b9e402404..009b5d9fb5 100644
--- a/site/articles/getting-started/overview.md
+++ b/site/articles/getting-started/overview.md
@@ -8,12 +8,12 @@ Svg.Skia is a repository, not just a single package. The main entry points are:
| Package or tool | Use it when | Main output | Detailed guide |
| --- | --- | --- |
-| `Svg.Skia` | You want to load SVG or Android VectorDrawable content and render with SkiaSharp. | `SKPicture`, bitmap, pdf, xps, svg | [Svg.Skia](../packages/svg-skia) |
+| `Svg.Skia` | You want to load SVG or Android VectorDrawable content and render with SkiaSharp. | `SKPicture`, bitmap, pdf, xps, svg, hit testing, animation runtime | [Svg.Skia](../packages/svg-skia) |
| `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) |
+| `Svg.Custom` | You want the underlying SVG DOM used by the renderer. | `SvgDocument`, `SvgElement`, animation DOM, 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.Skia.Uno` | You want Uno controls that render through the Skia-backed pipeline. | `Svg`, `SvgSource`, hit testing, zoom/pan, animation playback | [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`, animation playback, native composition | [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) |
| `Svg.Editor.*` | You want reusable SVG editor components, from session/services up to a full Avalonia workspace. | `SvgEditorSession`, editor services, panels, `SvgEditorWorkspace` | [Editor](../editor) |
@@ -30,13 +30,13 @@ Start with `Svg.Skia` if your application already uses SkiaSharp or needs direct
### Avalonia application
-Start with `Svg.Controls.Skia.Avalonia` when the app is already on Avalonia plus Skia.
+Start with `Svg.Controls.Skia.Avalonia` when the app is already on Avalonia plus Skia and you want the richest interaction and animation surface, including native-composition playback where supported.
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.
+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, viewport controls, and host-driven animation playback.
### Embedded editor
@@ -56,10 +56,10 @@ Use `Svg.Skia.Converter` when you need command-line automation for folders, patt
## What this repository emphasizes
-- SVG 1.1 subset rendering with SkiaSharp output.
+- SVG 1.1 DOM coverage with SkiaSharp output and shared animation playback support.
- Android VectorDrawable import and validation coverage.
-- Model-level editing and picture rebuild support.
-- Avalonia controls, image sources, and brush helpers.
+- Model-level editing, interaction routing, and picture rebuild support.
+- Avalonia and Uno controls with host animation backends and resolved-backend diagnostics.
- Verification through unit tests, UI tests, and W3C test-suite assets.
For extensive package-by-package coverage, continue with [Packages](../packages).
diff --git a/site/articles/guides/hit-testing-and-model-editing.md b/site/articles/guides/hit-testing-and-model-editing.md
index 0cc519d3db..3b0cfd5d71 100644
--- a/site/articles/guides/hit-testing-and-model-editing.md
+++ b/site/articles/guides/hit-testing-and-model-editing.md
@@ -18,6 +18,7 @@ svg.Load("image.svg");
var element = svg.HitTestElements(new SKPoint(10, 10)).FirstOrDefault();
var drawable = svg.HitTestDrawables(new SKRect(0, 0, 40, 40)).FirstOrDefault();
+var topmost = svg.HitTestTopmostElement(new SKPoint(10, 10));
```
## Hit testing on transformed canvases
@@ -49,6 +50,8 @@ There are overloads that take the canvas matrix directly:
The Skia-backed `Avalonia.Svg.Skia.Svg` control exposes `HitTestElements(Point point)` in control coordinates, so the control handles the coordinate transform for you.
+The shared hit-test path now also respects typed `pointer-events` values, geometry-aware element bounds, and topmost-target routing used by `SvgInteractionDispatcher`.
+
## Model editing
Once you identify the commands you care about, update the model and rebuild:
@@ -73,3 +76,5 @@ svg.RebuildFromModel();
```
In Avalonia, the same pattern applies through `SvgSource.RebuildFromModel()`.
+
+For the higher-level routed input and animation playback surface, continue with [Interaction and Animation](interaction-and-animation).
diff --git a/site/articles/guides/interaction-and-animation.md b/site/articles/guides/interaction-and-animation.md
new file mode 100644
index 0000000000..c583785ba5
--- /dev/null
+++ b/site/articles/guides/interaction-and-animation.md
@@ -0,0 +1,146 @@
+---
+title: "Interaction and Animation"
+---
+
+# Interaction and Animation
+
+This repository now documents four related layers for interactive or animated SVG:
+
+- the SVG DOM layer in `Svg.Custom`,
+- pointer-aware hit testing and routing in `Svg.Skia`,
+- the shared animation runtime in `SKSvg`,
+- host playback backends in the Avalonia and Uno controls.
+
+## SVG animation DOM in `Svg.Custom`
+
+`Svg.Custom` now includes the SVG 1.1 animation element family in the `Svg` namespace:
+
+- `SvgAnimationElement`
+- `SvgAnimationAttributeElement`
+- `SvgAnimationValueElement`
+- `SvgAnimate`
+- `SvgSet`
+- `SvgAnimateMotion`
+- `SvgAnimateColor`
+- `SvgAnimateTransform`
+- `SvgMPath`
+
+Typed enums and converters cover the stable attribute surface, including:
+
+- `attributeType`
+- `restart`
+- `fill`
+- `calcMode`
+- `additive`
+- `accumulate`
+- `type` on `animateTransform`
+
+The DOM layer also now exposes typed `pointer-events` values through `SvgPointerEvents`.
+
+That makes `Svg.Custom` suitable for tooling that needs to parse, inspect, clone, or rewrite animation elements even when rendering or playback happens elsewhere.
+
+## Pointer-aware hit testing
+
+`SKSvg` still exposes the broad hit-test APIs:
+
+- `HitTestElements(...)`
+- `HitTestDrawables(...)`
+- `TryGetPicturePoint(...)`
+- `TryGetPictureRect(...)`
+
+For interaction routing, the runtime now also exposes `HitTestTopmostElement(...)`, which resolves the topmost routed target instead of returning every matching element.
+
+The hit-test path is now aware of:
+
+- typed `pointer-events` values,
+- geometry-aware point hit testing for higher-impact drawable types,
+- clip and mask rejection where the shared drawable pipeline can evaluate it.
+
+Use the broad `HitTestElements(...)` helpers when you need inspection. Use `HitTestTopmostElement(...)` when you need a routed input target.
+
+## Shared interaction dispatcher
+
+`Svg.Skia` now includes `SvgInteractionDispatcher`, a framework-neutral pointer-routing layer. It accepts `SvgPointerInput` and raises routed `SvgPointerEventArgs` through these phases:
+
+- `Tunnel`
+- `Target`
+- `Bubble`
+
+The dispatcher handles:
+
+- hover enter and leave tracking,
+- press and release routing,
+- click generation,
+- wheel routing,
+- capture to the pressed target,
+- shared cursor resolution,
+- optional compatibility bridging back into `SvgElement` mouse events.
+
+The Avalonia and Uno controls expose one shared dispatcher instance through their `Interaction` property.
+
+## Shared animation runtime in `SKSvg`
+
+`SKSvg` now owns the shared animation runtime. The document model stays shared across hosts, while the UI package only provides timing and invalidation.
+
+Core runtime members include:
+
+- `HasAnimations`
+- `AnimationTime`
+- `SetAnimationTime(...)`
+- `AdvanceAnimation(...)`
+- `ResetAnimation()`
+- `AnimationInvalidated`
+- `AnimationMinimumRenderInterval`
+- `HasPendingAnimationFrame`
+- `FlushPendingAnimationFrame()`
+- `LastAnimationDirtyTargetCount`
+
+The current runtime supports the repository's shared playback surface for:
+
+- `set`
+- `animate`
+- `animateColor`
+- `animateTransform`
+- `animateMotion`
+- event-driven `begin` and `end` timing supported by the shared dispatcher
+
+## Host playback backends
+
+`SvgAnimationHostBackend` is the shared host selection enum:
+
+- `Default`
+- `Manual`
+- `DispatcherTimer`
+- `RenderLoop`
+- `NativeComposition`
+
+### Avalonia
+
+`Svg.Controls.Skia.Avalonia` exposes:
+
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
+- `ActualAnimationBackend`
+- `AnimationBackendFallbackReason`
+- `AnimationBackendResolution`
+- `AnimationBackendCapabilities`
+
+On supported hosts, `Default` prefers `NativeComposition`, then falls back to `RenderLoop`, `DispatcherTimer`, or `Manual`.
+
+The retained `NativeComposition` path uses one composition child visual per top-level SVG child and updates animated layers from `SKSvg.TryCreateNativeCompositionScene(...)` and `SKSvg.TryCreateNativeCompositionFrame(...)`.
+
+### Uno
+
+`Svg.Controls.Skia.Uno` exposes the same playback-property surface and the same resolved-backend diagnostics.
+
+Uno currently falls back away from `NativeComposition` because the active package surface does not provide a working retained child-visual attachment path for this implementation.
+
+## Benchmarks and sample host
+
+The repository also adds two practical validation paths for this work:
+
+- `tests/Svg.Skia.Benchmarks` measures the shared animation renderer and layered redraw behavior.
+- `samples/TestApp` exposes backend selection, playback rate, clock display, play, pause, and restart controls so you can exercise the runtime manually.
+
+For related lower-level guidance, see [Hit Testing and Model Editing](hit-testing-and-model-editing), [Svg.Skia](../packages/svg-skia), [Svg.Controls.Skia.Avalonia](../packages/svg-controls-skia-avalonia), and [Svg.Controls.Skia.Uno](../packages/svg-controls-skia-uno).
diff --git a/site/articles/guides/menu.yml b/site/articles/guides/menu.yml
index a9a79062ab..01cc4fd09c 100644
--- a/site/articles/guides/menu.yml
+++ b/site/articles/guides/menu.yml
@@ -3,5 +3,6 @@ guides:
- {path: loading-svg-and-vectordrawable.md, title: "Loading Svg and VectorDrawable"}
- {path: exporting-images-pdf-and-xps.md, title: "Exporting Images, Pdf, and Xps"}
- {path: hit-testing-and-model-editing.md, title: "Hit Testing and Model Editing"}
+ - {path: interaction-and-animation.md, title: "Interaction and Animation"}
- {path: cli-conversion.md, title: "CLI Conversion"}
- {path: source-generator-and-svgc.md, title: "Source Generator and svgc"}
diff --git a/site/articles/guides/readme.md b/site/articles/guides/readme.md
index fd8d0c7d15..45576a61f7 100644
--- a/site/articles/guides/readme.md
+++ b/site/articles/guides/readme.md
@@ -11,5 +11,6 @@ These pages focus on specific tasks instead of describing the whole architecture
- [Loading Svg and VectorDrawable](loading-svg-and-vectordrawable) compares the different load APIs.
- [Exporting Images, Pdf, and Xps](exporting-images-pdf-and-xps) covers output helpers and supported formats.
- [Hit Testing and Model Editing](hit-testing-and-model-editing) walks through interaction and command mutation.
+- [Interaction and Animation](interaction-and-animation) covers the shared pointer-routing surface, animation runtime, host backends, and retained native composition.
- [CLI Conversion](cli-conversion) covers `Svg.Skia.Converter`.
- [Source Generator and svgc](source-generator-and-svgc) covers generated-code workflows.
diff --git a/site/articles/packages/readme.md b/site/articles/packages/readme.md
index 35dc64dbfd..66f3de0035 100644
--- a/site/articles/packages/readme.md
+++ b/site/articles/packages/readme.md
@@ -12,17 +12,17 @@ Packaged tools such as `Svg.Skia.Converter` and `svgc` stay documented under [Sa
| Package | Start here when | Guide |
| --- | --- | --- |
-| `Svg.Skia` | You want the main SkiaSharp runtime renderer, export helpers, hit testing, or Android VectorDrawable support. | [Svg.Skia](svg-skia) |
+| `Svg.Skia` | You want the main SkiaSharp runtime renderer, export helpers, hit testing, shared interaction, animation playback, or Android VectorDrawable support. | [Svg.Skia](svg-skia) |
| `Svg.Model` | You need the intermediate drawable and picture model for inspection, mutation, or custom pipelines. | [Svg.Model](svg-model) |
-| `Svg.Custom` | You want the underlying SVG DOM and parser that the renderer consumes. | [Svg.Custom](svg-custom) |
+| `Svg.Custom` | You want the underlying SVG DOM and parser that the renderer consumes, including animation elements. | [Svg.Custom](svg-custom) |
| `ShimSkiaSharp` | You need a cloneable command-model equivalent of key SkiaSharp drawing primitives. | [ShimSkiaSharp](shim-skiasharp) |
## 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.Skia.Uno` | You want Uno Platform SVG controls backed by `Svg.Skia`, the live Skia canvas, and host-driven animation playback. | [Svg.Controls.Skia.Uno](svg-controls-skia-uno) |
+| `Svg.Controls.Skia.Avalonia` | You want the richest Avalonia SVG integration, backed by `Svg.Skia`, real `SkiaSharp.SKPicture` output, and retained native-composition playback where supported. | [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,9 +45,9 @@ 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.Skia` for direct runtime rendering, export, shared interaction, and animation playback.
+- Choose `Svg.Controls.Skia.Uno` for Uno Platform usage on the Skia-backed path with host-driven animation playback.
+- Choose `Svg.Controls.Skia.Avalonia` for interactive Avalonia usage on the Skia-backed path, especially when retained native composition matters.
- 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.
- Choose `Svg.Controls.Avalonia` for Avalonia drawing-context integration without the `SKSvg` runtime surface.
diff --git a/site/articles/packages/svg-controls-skia-avalonia.md b/site/articles/packages/svg-controls-skia-avalonia.md
index d76942f43a..a0ab1cefa1 100644
--- a/site/articles/packages/svg-controls-skia-avalonia.md
+++ b/site/articles/packages/svg-controls-skia-avalonia.md
@@ -17,7 +17,8 @@ dotnet add package Svg.Controls.Skia.Avalonia
- your Avalonia app already uses the Skia-backed rendering path,
- you want `Svg`, `SvgImage`, `SvgSource`, and `SvgResource` in XAML,
- you need control-coordinate hit testing,
-- you want zoom, pan, wireframe, filter toggles, or source reload support,
+- you want zoom, pan, wireframe, filter toggles, source reload support, or routed interaction,
+- you want host-driven SVG animation playback with optional retained native composition,
- you want direct access to the underlying `Svg.Skia.SKSvg`.
## Main types
@@ -29,6 +30,7 @@ dotnet add package Svg.Controls.Skia.Avalonia
| `Avalonia.Svg.Skia.SvgSource` | Reusable, cloneable, reloadable source object |
| `SvgImageExtension` | Markup extension for concise XAML image usage |
| `SvgResourceExtension` | Brush-producing markup extension for backgrounds and fills |
+| `Svg.Skia.SvgInteractionDispatcher` | Routed pointer helper exposed through `Svg.Interaction` |
## Basic XAML usage
@@ -88,10 +90,52 @@ The `Svg` control adds behavior that does not exist in the non-Skia Avalonia pac
- `ZoomToPoint(...)`
- `TryGetPicturePoint(...)`
- `HitTestElements(...)`
+- `Interaction`
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
+- `ActualAnimationBackend`
+- `AnimationBackendFallbackReason`
- `SkSvg` access
Those features make this package the better choice for editors, diagram viewers, and interactive inspection tools.
+## Animation playback and retained composition
+
+The Avalonia `Svg` control now hosts the shared `SKSvg` animation runtime.
+
+Available backends are:
+
+- `Default`
+- `Manual`
+- `DispatcherTimer`
+- `RenderLoop`
+- `NativeComposition`
+
+`Default` prefers `NativeComposition` when the host compositor and the loaded SVG can support the retained scene. If that path is unavailable, the control reports the resolved backend through `ActualAnimationBackend` and the reason through `AnimationBackendFallbackReason`.
+
+Example:
+
+```xml
+
+```
+
+`NativeComposition` uses retained compositor child visuals for supported top-level SVG layers while still relying on the shared `SKSvg` animation runtime for timing and property evaluation.
+
+## Routed interaction
+
+The control exposes one shared `SvgInteractionDispatcher` instance through `Interaction`.
+
+That surface is useful when a host wants:
+
+- routed pointer notifications,
+- capture-aware move and release handling,
+- cursor hints,
+- optional compatibility bridging back into `SvgElement` mouse events.
+
## Hit testing in Avalonia coordinates
```csharp
@@ -121,5 +165,6 @@ This is useful for icons, patterned surfaces, or backgrounds that should stay re
- [XAML Overview](../xaml/overview)
- [Svg Control and SvgImage](../xaml/svg-control-and-svgimage)
+- [Interaction and Animation](../guides/interaction-and-animation)
- [SvgResource and Brushes](../xaml/svgresource-and-brushes)
- [Styling and Previewer](../xaml/styling-and-previewer)
diff --git a/site/articles/packages/svg-controls-skia-uno.md b/site/articles/packages/svg-controls-skia-uno.md
index f2f1e5dc50..8a4af9b039 100644
--- a/site/articles/packages/svg-controls-skia-uno.md
+++ b/site/articles/packages/svg-controls-skia-uno.md
@@ -17,6 +17,7 @@ dotnet add package Svg.Controls.Skia.Uno
- 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 host-driven SVG animation playback backed by the shared `SKSvg` runtime,
- you want reusable `SvgSource` resources that can be cloned and restyled per control.
## Main types
@@ -26,6 +27,7 @@ dotnet add package Svg.Controls.Skia.Uno
| `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 |
+| `Svg.Skia.SvgInteractionDispatcher` | Routed pointer helper exposed through `Svg.Interaction` |
## Basic XAML usage
@@ -72,9 +74,32 @@ Use the synchronous loaders for inline SVG strings, `Stream`, and `SvgDocument`
- 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.
+## Animation playback
+
+The Uno `Svg` control exposes the same host-driven animation surface as the Avalonia Skia package:
+
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
+- `ActualAnimationBackend`
+- `AnimationBackendFallbackReason`
+- `AnimationBackendResolution`
+- `AnimationBackendCapabilities`
+
+The control shares the same backend enum:
+
+- `Default`
+- `Manual`
+- `DispatcherTimer`
+- `RenderLoop`
+- `NativeComposition`
+
+Uno currently falls back away from `NativeComposition` because this implementation does not have a working retained child-visual attachment path on the active package surface.
+
## Related docs
- [Quickstart: Uno](../getting-started/quickstart-uno)
+- [Interaction and Animation](../guides/interaction-and-animation)
- [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/packages/svg-custom.md b/site/articles/packages/svg-custom.md
index cc90f502e4..6a986a4db9 100644
--- a/site/articles/packages/svg-custom.md
+++ b/site/articles/packages/svg-custom.md
@@ -59,10 +59,32 @@ The repository wraps the vendored SVG sources with project-level concerns such a
- the package identity used by the repo,
- trimming and AOT annotations for supported target frameworks,
- embedded SVG 1.1 DTD resources,
-- analyzer and generator integration used by the vendored code path.
+- analyzer and generator integration used by the vendored code path,
+- repository-local SVG 1.1 animation object-model types,
+- typed `pointer-events` support used by the shared hit-test and interaction layers.
The rendering behavior still lives above this package, not inside it.
+## Animation DOM coverage
+
+`Svg.Custom` now includes the repository's SVG 1.1 animation object-model overlay in the `Svg` namespace.
+
+That surface includes:
+
+- `SvgAnimationElement`
+- `SvgAnimationAttributeElement`
+- `SvgAnimationValueElement`
+- `SvgAnimate`
+- `SvgSet`
+- `SvgAnimateMotion`
+- `SvgAnimateColor`
+- `SvgAnimateTransform`
+- `SvgMPath`
+
+The DOM layer also adds typed enums and converters for common animation attributes such as `attributeType`, `restart`, `fill`, `calcMode`, `additive`, `accumulate`, and transform `type`.
+
+This package does not execute animation by itself. It only parses and stores the DOM. Runtime evaluation lives in [Svg.Skia](svg-skia).
+
## Good use cases
- validating raw SVG input before rendering,
@@ -79,5 +101,6 @@ The rendering behavior still lives above this package, not inside it.
## Related docs
- [Source Formats and Assets](../concepts/source-formats-and-assets)
+- [Interaction and Animation](../guides/interaction-and-animation)
- [Svg.Skia](svg-skia)
- [Svg.Model](svg-model)
diff --git a/site/articles/packages/svg-skia.md b/site/articles/packages/svg-skia.md
index df4fef565c..8bccef0c7e 100644
--- a/site/articles/packages/svg-skia.md
+++ b/site/articles/packages/svg-skia.md
@@ -4,7 +4,7 @@ title: "Svg.Skia"
# Svg.Skia
-`Svg.Skia` is the main runtime rendering package in this repository. It loads SVG content into a `SkiaSharp.SKPicture`, preserves the intermediate model and drawable tree, and adds export and hit-testing helpers around that workflow.
+`Svg.Skia` is the main runtime rendering package in this repository. It loads SVG content into a `SkiaSharp.SKPicture`, preserves the intermediate model and drawable tree, and adds export, interaction, hit-testing, and animation helpers around that workflow.
## Install
@@ -18,6 +18,7 @@ dotnet add package Svg.Skia
- you need direct access to `SkiaSharp.SKPicture`,
- you want runtime export to bitmap, pdf, or xps formats,
- you need hit testing or model rebuild after editing,
+- you need shared pointer routing or animation playback outside a UI-specific package,
- you want Android `VectorDrawable` input support.
## Main types
@@ -29,6 +30,8 @@ dotnet add package Svg.Skia
| `SKSvgSettings` | Controls color-space and font-resolution behavior |
| `ITypefaceProvider` | Plug-in point for custom typeface lookup |
| `SkiaSvgAssetLoader` | `Svg.Model` asset-loader implementation for images and fonts |
+| `SvgInteractionDispatcher` | Framework-neutral pointer routing and cursor-resolution helper |
+| `SvgAnimationHostBackend` | Shared host playback backend enum used by the UI packages |
## Typical workflow
@@ -101,6 +104,51 @@ if (svg.Load("Assets/icon.svg") is not null)
Use `TryGetPicturePoint` or `TryGetPictureRect` when the pointer coordinates come from a transformed canvas rather than picture space.
+For routed input targets, use `HitTestTopmostElement(...)` instead of `HitTestElements(...)`.
+
+## Shared interaction
+
+`Svg.Skia` now includes a shared input-routing surface through `SvgInteractionDispatcher`.
+
+Use it when a host wants:
+
+- topmost-target routing instead of broad inspection-only hit testing,
+- tunnel, target, and bubble phases,
+- pointer capture to the pressed target,
+- cursor resolution,
+- optional compatibility dispatch back into `SvgElement` mouse events.
+
+The dispatcher accepts `SvgPointerInput` and raises `SvgPointerEventArgs` through its `Dispatched` event.
+
+## Shared animation runtime
+
+`SKSvg` now owns the shared animation runtime for the repository.
+
+The main members are:
+
+- `HasAnimations`
+- `AnimationTime`
+- `SetAnimationTime(...)`
+- `AdvanceAnimation(...)`
+- `ResetAnimation()`
+- `AnimationInvalidated`
+- `AnimationMinimumRenderInterval`
+- `HasPendingAnimationFrame`
+- `FlushPendingAnimationFrame()`
+- `LastAnimationDirtyTargetCount`
+
+The runtime also exposes `SupportsNativeComposition`, `TryCreateNativeCompositionScene(...)`, and `TryCreateNativeCompositionFrame(...)` so host packages can attach retained playback paths without reimplementing SVG timing or property evaluation.
+
+## Animation performance follow-up
+
+The shared renderer now includes layered redraw support for animation frames and a local benchmark harness under `tests/Svg.Skia.Benchmarks`.
+
+That benchmark project compares:
+
+- layered top-level animation updates that reuse cached static content,
+- defs-backed animation updates that still require full-document rebuilds,
+- the same paths with a draw pass included.
+
## Android VectorDrawable
`Svg.Skia` also handles Android drawable XML directly:
@@ -140,4 +188,5 @@ Add custom `ITypefaceProvider` implementations when your application resolves fo
- [Loading SVG and VectorDrawable](../guides/loading-svg-and-vectordrawable)
- [Exporting Images, PDF, and XPS](../guides/exporting-images-pdf-and-xps)
- [Hit Testing and Model Editing](../guides/hit-testing-and-model-editing)
+- [Interaction and Animation](../guides/interaction-and-animation)
- [Android VectorDrawable Support](../advanced/android-vectordrawable-support)
diff --git a/site/articles/readme.md b/site/articles/readme.md
index afcf622f09..bcba4d9b62 100644
--- a/site/articles/readme.md
+++ b/site/articles/readme.md
@@ -20,7 +20,7 @@ Svg.Skia spans a few related areas:
2. [Packages](packages) for library-by-library installation, responsibilities, and usage patterns.
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.
+5. [Guides](guides) for scenario-focused tasks such as exporting images, hit testing, interaction, animation playback, or generating code.
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.
diff --git a/site/articles/reference/samples-and-tools.md b/site/articles/reference/samples-and-tools.md
index 69c16b8faf..d2a0d1071b 100644
--- a/site/articles/reference/samples-and-tools.md
+++ b/site/articles/reference/samples-and-tools.md
@@ -15,7 +15,7 @@ The `samples/` directory covers both end-user scenarios and repository-internal
- `AvaloniaControlsSample`: `SKCanvasControl`, `SKBitmapControl`, `SKPathControl`, and `SKPictureControl`.
- `AvaloniaSKPictureImageSample`: `SKPictureImage` and animation examples.
- `AvalonDraw`: larger sample application for SVG editing-oriented scenarios.
-- `TestApp`: extra Avalonia test host.
+- `TestApp`: extra Avalonia test host with animation-backend selection, playback-rate control, play, pause, restart, resolved-backend diagnostics, and native-composition verification.
## CLI and generation samples
diff --git a/site/articles/xaml/overview.md b/site/articles/xaml/overview.md
index f5555da9c7..cfac86c150 100644
--- a/site/articles/xaml/overview.md
+++ b/site/articles/xaml/overview.md
@@ -12,7 +12,7 @@ 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.
+- you want `HitTestElements(...)`, `TryGetPicturePoint(...)`, zoom, pan, wireframe, filter toggles, or animation playback.
### `Avalonia.Svg.Skia`
@@ -20,7 +20,8 @@ This package wraps the `Svg.Skia` runtime renderer. Use it when:
- you already depend on Skia-backed rendering,
- you want `SKSvg` features such as hit testing or explicit model rebuild access,
-- you want the Skia-backed `SvgSource` behavior and reload support.
+- you want the Skia-backed `SvgSource` behavior and reload support,
+- you want host-driven animation playback with `RenderLoop`, `DispatcherTimer`, or retained `NativeComposition` when available.
### `Avalonia.Svg`
@@ -32,7 +33,7 @@ This package exposes a similar surface but draws through Avalonia's own drawing
## Shared concepts
-The Uno and Avalonia Skia-backed packages all provide an `Svg` control and reusable `SvgSource`.
+The Uno and Avalonia Skia-backed packages all provide an `Svg` control, reusable `SvgSource`, shared hit testing, shared interaction dispatch, and the same animation-backend selection model.
The Avalonia packages additionally provide `SvgImage`, markup extensions, and brush helpers.
diff --git a/site/articles/xaml/readme.md b/site/articles/xaml/readme.md
index f91fcf58f6..05b7e04ded 100644
--- a/site/articles/xaml/readme.md
+++ b/site/articles/xaml/readme.md
@@ -10,6 +10,7 @@ Svg.Skia supports two Avalonia-facing stacks plus a set of reusable Skia control
- [Overview](overview) explains when to choose `Avalonia.Svg.Skia` versus `Avalonia.Svg`.
- [Svg Control and SvgImage](svg-control-and-svgimage) covers the primary controls and image-source types.
+- [Uno Svg Control](uno-svg-control) covers the Uno-specific control surface on the Skia-backed path.
- [SvgResource and Brushes](svgresource-and-brushes) covers reusable brushes and resource dictionaries.
- [Styling and Previewer](styling-and-previewer) covers CSS overlays, state-based styling, and previewer setup.
- [Skia Controls and SKPictureImage](skia-controls-and-skpictureimage) covers the lower-level Avalonia Skia controls.
diff --git a/site/articles/xaml/svg-control-and-svgimage.md b/site/articles/xaml/svg-control-and-svgimage.md
index f114d6b9f3..5858804448 100644
--- a/site/articles/xaml/svg-control-and-svgimage.md
+++ b/site/articles/xaml/svg-control-and-svgimage.md
@@ -28,9 +28,26 @@ The Skia-backed control also adds:
- `PanX`
- `PanY`
- `ZoomToPoint(...)`
+- `Interaction`
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
+- `ActualAnimationBackend`
+- `AnimationBackendFallbackReason`
That makes it a better fit when you need interaction or viewport behavior.
+For animated SVG, use the host playback properties directly on the control:
+
+```xml
+
+```
+
+On Avalonia, `Default` prefers the retained `NativeComposition` path when it is supported by both the host and the loaded SVG scene.
+
## `SvgImage`
Use `SvgImage` when the target property expects `IImage`, for example on an Avalonia `Image` control:
@@ -68,3 +85,5 @@ var hits = svgControl.HitTestElements(new Point(x, y));
```
That method accepts control coordinates, not picture coordinates.
+
+For routed pointer events and animation playback details, see [Interaction and Animation](../guides/interaction-and-animation).
diff --git a/site/articles/xaml/uno-svg-control.md b/site/articles/xaml/uno-svg-control.md
index 501a7e56f4..79fd9d010b 100644
--- a/site/articles/xaml/uno-svg-control.md
+++ b/site/articles/xaml/uno-svg-control.md
@@ -27,6 +27,9 @@ The Uno `Svg` control keeps the control-facing API close to the Avalonia Skia pa
- `PanY`
- `Css`
- `CurrentCss`
+- `AnimationBackend`
+- `AnimationFrameInterval`
+- `AnimationPlaybackRate`
## Reusable resource example
@@ -56,10 +59,22 @@ 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.
+## Animation playback
+
+The Uno `Svg` control uses the shared `SKSvg` animation runtime and exposes the same resolved-backend diagnostics as the Avalonia Skia package:
+
+- `ActualAnimationBackend`
+- `AnimationBackendFallbackReason`
+- `AnimationBackendResolution`
+- `AnimationBackendCapabilities`
+
+Uno currently falls back away from `NativeComposition`, so the practical playback backends are `DispatcherTimer`, `RenderLoop`, or `Manual`.
+
## Current v1 limits
- no `SvgImage`
- no brush/resource markup extension equivalent
- no native-renderer fallback
+- no retained native-composition playback path
For those scenarios in Uno, keep the SVG in a reusable `SvgSource` and render it through one or more `Svg` controls.
diff --git a/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs b/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs
index 611676f5d8..db1ac566cb 100644
--- a/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs
+++ b/src/ShimSkiaSharp/Editing/CanvasCommandVisitorExtensions.cs
@@ -29,6 +29,9 @@ public static void Accept(this CanvasCommand command, ICanvasCommandVisitor visi
case DrawImageCanvasCommand drawImage:
visitor.Visit(drawImage);
break;
+ case DrawPictureCanvasCommand drawPicture:
+ visitor.Visit(drawPicture);
+ break;
case DrawPathCanvasCommand drawPath:
visitor.Visit(drawPath);
break;
diff --git a/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs b/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs
index 08590bd80e..b5a457f628 100644
--- a/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs
+++ b/src/ShimSkiaSharp/Editing/SKPictureEditingExtensions.cs
@@ -16,7 +16,7 @@ public static IEnumerable FindCommands(this SKPicture pictur
throw new ArgumentNullException(nameof(picture));
}
- return picture.Commands?.OfType() ?? Enumerable.Empty();
+ return EnumerateCommands(picture).OfType();
}
public static int ReplaceCommands(this SKPicture picture, Func replace)
@@ -31,6 +31,84 @@ public static int ReplaceCommands(this SKPicture picture, Func predicate,
+ Action update,
+ EditMode mode = EditMode.InPlace)
+ {
+ if (picture is null)
+ {
+ throw new ArgumentNullException(nameof(picture));
+ }
+
+ if (predicate is null)
+ {
+ throw new ArgumentNullException(nameof(predicate));
+ }
+
+ if (update is null)
+ {
+ throw new ArgumentNullException(nameof(update));
+ }
+
+ var context = mode == EditMode.CloneOnWrite ? new CloneContext() : null;
+ var visited = new HashSet(ReferenceEqualityComparer.Instance);
+ return UpdatePaintsRecursive(picture, predicate, update, mode, context, visited);
+ }
+
+ public static int UpdatePaths(
+ this SKPicture picture,
+ Func predicate,
+ Action update,
+ EditMode mode = EditMode.InPlace)
+ {
+ if (picture is null)
+ {
+ throw new ArgumentNullException(nameof(picture));
+ }
+
+ if (predicate is null)
+ {
+ throw new ArgumentNullException(nameof(predicate));
+ }
+
+ if (update is null)
+ {
+ throw new ArgumentNullException(nameof(update));
+ }
+
+ var context = mode == EditMode.CloneOnWrite ? new CloneContext() : null;
+ var visited = new HashSet(ReferenceEqualityComparer.Instance);
+ return UpdatePathsRecursive(picture, predicate, update, mode, context, visited);
+ }
+
+ private static IEnumerable EnumerateCommands(SKPicture picture)
+ {
+ if (picture.Commands is null)
+ {
+ yield break;
+ }
+
+ foreach (var command in picture.Commands)
+ {
+ yield return command;
+
+ if (command is DrawPictureCanvasCommand { Picture: { } nestedPicture })
+ {
+ foreach (var nestedCommand in EnumerateCommands(nestedPicture))
+ {
+ yield return nestedCommand;
+ }
+ }
+ }
+ }
+
+ private static int ReplaceCommandsRecursive(SKPicture picture, Func replace)
+ {
var commands = picture.Commands;
if (commands is null)
{
@@ -55,32 +133,24 @@ public static int ReplaceCommands(this SKPicture picture, Func predicate,
Action update,
- EditMode mode = EditMode.InPlace)
+ EditMode mode,
+ CloneContext? context,
+ HashSet visited)
{
- if (picture is null)
- {
- throw new ArgumentNullException(nameof(picture));
- }
-
- if (predicate is null)
- {
- throw new ArgumentNullException(nameof(predicate));
- }
-
- if (update is null)
- {
- throw new ArgumentNullException(nameof(update));
- }
-
var commands = picture.Commands;
if (commands is null)
{
@@ -88,12 +158,25 @@ public static int UpdatePaints(
}
var count = 0;
- var context = mode == EditMode.CloneOnWrite ? new CloneContext() : null;
- var visited = new HashSet(ReferenceEqualityComparer.Instance);
-
for (var i = 0; i < commands.Count; i++)
{
var command = commands[i];
+ if (command is DrawPictureCanvasCommand drawPictureCommand && drawPictureCommand.Picture is { } nestedPicture)
+ {
+ if (mode == EditMode.CloneOnWrite)
+ {
+ var clonedPicture = ClonePicture(context!, nestedPicture);
+ if (!ReferenceEquals(clonedPicture, nestedPicture))
+ {
+ command = drawPictureCommand with { Picture = clonedPicture };
+ commands[i] = command;
+ nestedPicture = clonedPicture;
+ }
+ }
+
+ count += UpdatePaintsRecursive(nestedPicture, predicate, update, mode, context, visited);
+ }
+
if (!TryGetPaint(command, out var paint) || paint is null || !predicate(paint))
{
continue;
@@ -124,27 +207,14 @@ public static int UpdatePaints(
return count;
}
- public static int UpdatePaths(
- this SKPicture picture,
+ private static int UpdatePathsRecursive(
+ SKPicture picture,
Func predicate,
Action update,
- EditMode mode = EditMode.InPlace)
+ EditMode mode,
+ CloneContext? context,
+ HashSet visited)
{
- if (picture is null)
- {
- throw new ArgumentNullException(nameof(picture));
- }
-
- if (predicate is null)
- {
- throw new ArgumentNullException(nameof(predicate));
- }
-
- if (update is null)
- {
- throw new ArgumentNullException(nameof(update));
- }
-
var commands = picture.Commands;
if (commands is null)
{
@@ -152,12 +222,25 @@ public static int UpdatePaths(
}
var count = 0;
- var context = mode == EditMode.CloneOnWrite ? new CloneContext() : null;
- var visited = new HashSet(ReferenceEqualityComparer.Instance);
-
for (var i = 0; i < commands.Count; i++)
{
var command = commands[i];
+ if (command is DrawPictureCanvasCommand drawPictureCommand && drawPictureCommand.Picture is { } nestedPicture)
+ {
+ if (mode == EditMode.CloneOnWrite)
+ {
+ var clonedPicture = ClonePicture(context!, nestedPicture);
+ if (!ReferenceEquals(clonedPicture, nestedPicture))
+ {
+ command = drawPictureCommand with { Picture = clonedPicture };
+ commands[i] = command;
+ nestedPicture = clonedPicture;
+ }
+ }
+
+ count += UpdatePathsRecursive(nestedPicture, predicate, update, mode, context, visited);
+ }
+
if (TryGetPath(command, out var path) && path is { } && predicate(path))
{
if (mode == EditMode.CloneOnWrite)
@@ -309,6 +392,16 @@ private static ClipPath CloneClipPath(CloneContext context, ClipPath clipPath)
return clipPath.DeepClone(context);
}
+ private static SKPicture ClonePicture(CloneContext context, SKPicture picture)
+ {
+ if (context.TryGet(picture, out SKPicture existing))
+ {
+ return existing;
+ }
+
+ return picture.DeepClone(context);
+ }
+
private static int UpdateClipPathPaths(
ClipPath original,
ClipPath target,
diff --git a/src/ShimSkiaSharp/ICanvasCommandVisitor.cs b/src/ShimSkiaSharp/ICanvasCommandVisitor.cs
index e98dd4d50e..719a8e6e6a 100644
--- a/src/ShimSkiaSharp/ICanvasCommandVisitor.cs
+++ b/src/ShimSkiaSharp/ICanvasCommandVisitor.cs
@@ -9,6 +9,7 @@ public interface ICanvasCommandVisitor
void Visit(ClipPathCanvasCommand cmd);
void Visit(ClipRectCanvasCommand cmd);
void Visit(DrawImageCanvasCommand cmd);
+ void Visit(DrawPictureCanvasCommand cmd);
void Visit(DrawPathCanvasCommand cmd);
void Visit(DrawTextBlobCanvasCommand cmd);
void Visit(DrawTextCanvasCommand cmd);
diff --git a/src/ShimSkiaSharp/IsExternalInit.cs b/src/ShimSkiaSharp/IsExternalInit.cs
index d5b2d8a26c..3fa1c2ae34 100644
--- a/src/ShimSkiaSharp/IsExternalInit.cs
+++ b/src/ShimSkiaSharp/IsExternalInit.cs
@@ -1,5 +1,6 @@
#if NET461 || NETSTANDARD
// ReSharper disable once CheckNamespace
namespace System.Runtime.CompilerServices;
+
internal static class IsExternalInit { }
#endif
diff --git a/src/ShimSkiaSharp/SKCanvas.cs b/src/ShimSkiaSharp/SKCanvas.cs
index 7b3ea576ab..73bc4eeb58 100644
--- a/src/ShimSkiaSharp/SKCanvas.cs
+++ b/src/ShimSkiaSharp/SKCanvas.cs
@@ -25,6 +25,7 @@ internal CanvasCommand DeepClone(CloneContext context)
ClipPathCanvasCommand clipPathCanvasCommand => new ClipPathCanvasCommand(clipPathCanvasCommand.ClipPath?.DeepClone(context), clipPathCanvasCommand.Operation, clipPathCanvasCommand.Antialias),
ClipRectCanvasCommand clipRectCanvasCommand => new ClipRectCanvasCommand(clipRectCanvasCommand.Rect, clipRectCanvasCommand.Operation, clipRectCanvasCommand.Antialias),
DrawImageCanvasCommand drawImageCanvasCommand => new DrawImageCanvasCommand(drawImageCanvasCommand.Image?.DeepClone(context), drawImageCanvasCommand.Source, drawImageCanvasCommand.Dest, drawImageCanvasCommand.Paint?.DeepClone(context)),
+ DrawPictureCanvasCommand drawPictureCanvasCommand => new DrawPictureCanvasCommand(drawPictureCanvasCommand.Picture?.DeepClone(context)),
DrawPathCanvasCommand drawPathCanvasCommand => new DrawPathCanvasCommand(drawPathCanvasCommand.Path?.DeepClone(context), drawPathCanvasCommand.Paint?.DeepClone(context)),
DrawTextBlobCanvasCommand drawTextBlobCanvasCommand => new DrawTextBlobCanvasCommand(drawTextBlobCanvasCommand.TextBlob?.DeepClone(context), drawTextBlobCanvasCommand.X, drawTextBlobCanvasCommand.Y, drawTextBlobCanvasCommand.Paint?.DeepClone(context)),
DrawTextCanvasCommand drawTextCanvasCommand => new DrawTextCanvasCommand(drawTextCanvasCommand.Text, drawTextCanvasCommand.X, drawTextCanvasCommand.Y, drawTextCanvasCommand.Paint?.DeepClone(context)),
@@ -52,6 +53,8 @@ public record ClipRectCanvasCommand(SKRect Rect, SKClipOperation Operation, bool
public record DrawImageCanvasCommand(SKImage? Image, SKRect Source, SKRect Dest, SKPaint? Paint = null) : CanvasCommand;
+public record DrawPictureCanvasCommand(SKPicture? Picture) : CanvasCommand;
+
public record DrawPathCanvasCommand(SKPath? Path, SKPaint? Paint) : CanvasCommand;
public record DrawTextBlobCanvasCommand(SKTextBlob? TextBlob, float X, float Y, SKPaint? Paint) : CanvasCommand;
@@ -133,6 +136,11 @@ public void DrawImage(SKImage image, SKRect source, SKRect dest, SKPaint? paint
Commands?.Add(new DrawImageCanvasCommand(image, source, dest, paint));
}
+ public void DrawPicture(SKPicture picture)
+ {
+ Commands?.Add(new DrawPictureCanvasCommand(picture));
+ }
+
public void DrawPath(SKPath path, SKPaint paint)
{
Commands?.Add(new DrawPathCanvasCommand(path, paint));
diff --git a/src/ShimSkiaSharp/SKColorFilter.cs b/src/ShimSkiaSharp/SKColorFilter.cs
index 930f6beebf..f513a5e57e 100644
--- a/src/ShimSkiaSharp/SKColorFilter.cs
+++ b/src/ShimSkiaSharp/SKColorFilter.cs
@@ -10,7 +10,7 @@ public static SKColorFilter CreateColorMatrix(float[] matrix)
=> new ColorMatrixColorFilter(matrix);
public static SKColorFilter CreateTable(byte[]? tableA, byte[]? tableR, byte[]? tableG, byte[]? tableB)
- => new TableColorFilter(tableA, tableB, tableG, tableR);
+ => new TableColorFilter(tableA, tableR, tableG, tableB);
public static SKColorFilter CreateBlendMode(SKColor c, SKBlendMode mode)
=> new BlendModeColorFilter(c, mode);
diff --git a/src/Svg.Animation/Animation/SvgAnimationClock.cs b/src/Svg.Animation/Animation/SvgAnimationClock.cs
new file mode 100644
index 0000000000..fc0a2003e6
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgAnimationClock.cs
@@ -0,0 +1,62 @@
+using System;
+
+namespace Svg.Skia;
+
+public sealed class SvgAnimationClockChangedEventArgs : EventArgs
+{
+ internal SvgAnimationClockChangedEventArgs(TimeSpan time)
+ {
+ Time = time;
+ }
+
+ public TimeSpan Time { get; }
+}
+
+public sealed class SvgAnimationClock
+{
+ private TimeSpan _currentTime;
+
+ public TimeSpan CurrentTime => _currentTime;
+
+ public event EventHandler? TimeChanged;
+
+ public void Reset()
+ {
+ Seek(TimeSpan.Zero);
+ }
+
+ public void Seek(TimeSpan time)
+ {
+ if (time < TimeSpan.Zero)
+ {
+ time = TimeSpan.Zero;
+ }
+
+ if (_currentTime == time)
+ {
+ return;
+ }
+
+ _currentTime = time;
+ TimeChanged?.Invoke(this, new SvgAnimationClockChangedEventArgs(time));
+ }
+
+ public void AdvanceBy(TimeSpan delta)
+ {
+ TimeSpan next;
+ if (delta >= TimeSpan.Zero)
+ {
+ next = delta >= TimeSpan.MaxValue - _currentTime
+ ? TimeSpan.MaxValue
+ : _currentTime + delta;
+ }
+ else
+ {
+ next = delta <= -_currentTime
+ ? TimeSpan.Zero
+ : _currentTime + delta;
+ }
+
+ Seek(next);
+ }
+}
diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs
new file mode 100644
index 0000000000..6994ca62df
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgAnimationController.cs
@@ -0,0 +1,3050 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Drawing;
+using System.Globalization;
+using System.Linq;
+using SkiaSharp;
+using Svg.Transforms;
+
+namespace Svg.Skia;
+
+public sealed class SvgAnimationFrameChangedEventArgs : EventArgs
+{
+ internal SvgAnimationFrameChangedEventArgs(TimeSpan time)
+ {
+ Time = time;
+ }
+
+ public TimeSpan Time { get; }
+}
+
+[RequiresUnreferencedCode("Uses TypeDescriptor-based converters for animated SVG values.")]
+public sealed class SvgAnimationController : IDisposable
+{
+ private readonly struct TimingSpec
+ {
+ public TimingSpec(TimeSpan offset)
+ {
+ IsEvent = false;
+ Offset = offset;
+ EventInstanceKey = null;
+ }
+
+ public TimingSpec(string eventInstanceKey, TimeSpan offset)
+ {
+ IsEvent = true;
+ Offset = offset;
+ EventInstanceKey = eventInstanceKey;
+ }
+
+ public bool IsEvent { get; }
+
+ public TimeSpan Offset { get; }
+
+ public string? EventInstanceKey { get; }
+ }
+
+ private readonly struct MotionSource
+ {
+ public MotionSource(string? pathData, IReadOnlyList? points)
+ {
+ PathData = pathData;
+ Points = points;
+ }
+
+ public string? PathData { get; }
+
+ public IReadOnlyList? Points { get; }
+ }
+
+ private readonly struct ResolvedTimingInstance
+ {
+ public ResolvedTimingInstance(TimeSpan time, string? eventInstanceKey, TimeSpan? sourceEventTime)
+ {
+ Time = time;
+ EventInstanceKey = eventInstanceKey;
+ SourceEventTime = sourceEventTime;
+ }
+
+ public TimeSpan Time { get; }
+
+ public string? EventInstanceKey { get; }
+
+ public TimeSpan? SourceEventTime { get; }
+ }
+
+ private sealed class PointerEventDependency
+ {
+ public PointerEventDependency(AnimationBinding binding, bool isBegin)
+ {
+ Binding = binding;
+ IsBegin = isBegin;
+ }
+
+ public AnimationBinding Binding { get; }
+
+ public bool IsBegin { get; }
+ }
+
+ private sealed class AnimationBinding
+ {
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.GetAttributeValue(SvgElement, String)")]
+ public AnimationBinding(SvgAnimationElement animation, SvgElement sourceTarget, SvgElementAddress targetAddress, string attributeName)
+ {
+ Animation = animation;
+ SourceTarget = sourceTarget;
+ TargetAddress = targetAddress;
+ AttributeName = attributeName;
+ HasExplicitBaseAttribute = sourceTarget.ContainsAttribute(attributeName);
+ BaseValue = GetAttributeValue(sourceTarget, attributeName);
+ BaseValueString = ConvertAttributeValueToString(BaseValue);
+ PropertyType = BaseValue?.GetType();
+ TargetAttributeKey = string.Concat(targetAddress.Key, "|", attributeName);
+ BeginSpecs = ParseTimingSpecifications(animation.Begin, animation.OwnerDocument, targetAddress, includeImplicitDocumentBegin: true);
+ EndSpecs = ParseTimingSpecifications(animation.End, animation.OwnerDocument, targetAddress, includeImplicitDocumentBegin: false);
+ }
+
+ public SvgAnimationElement Animation { get; }
+
+ public SvgElement SourceTarget { get; }
+
+ public SvgElementAddress TargetAddress { get; }
+
+ public string AttributeName { get; }
+
+ public bool HasExplicitBaseAttribute { get; }
+
+ public object? BaseValue { get; }
+
+ public string? BaseValueString { get; }
+
+ public Type? PropertyType { get; }
+
+ public string TargetAttributeKey { get; }
+
+ public IReadOnlyList BeginSpecs { get; }
+
+ public IReadOnlyList EndSpecs { get; }
+
+ private List? _resolvedAnimationValues;
+ private bool _resolvedAnimationValuesInitialized;
+ private MotionSource _resolvedMotionSource;
+ private bool _resolvedMotionSourceInitialized;
+
+ public List GetResolvedAnimationValues(Func> valueFactory)
+ {
+ if (!_resolvedAnimationValuesInitialized)
+ {
+ _resolvedAnimationValues = valueFactory();
+ _resolvedAnimationValuesInitialized = true;
+ }
+
+ return _resolvedAnimationValues ?? new List();
+ }
+
+ public MotionSource GetResolvedMotionSource(Func motionFactory)
+ {
+ if (!_resolvedMotionSourceInitialized)
+ {
+ _resolvedMotionSource = motionFactory();
+ _resolvedMotionSourceInitialized = true;
+ }
+
+ return _resolvedMotionSource;
+ }
+ }
+
+ private enum RepeatCountMode
+ {
+ DefaultOne,
+ Finite,
+ Indefinite
+ }
+
+ private enum RepeatDurationMode
+ {
+ None,
+ Finite,
+ Indefinite
+ }
+
+ private readonly struct AnimationSample
+ {
+ public AnimationSample(float progress, int iterationIndex)
+ {
+ Progress = progress;
+ IterationIndex = iterationIndex;
+ }
+
+ public float Progress { get; }
+
+ public int IterationIndex { get; }
+ }
+
+ private static readonly TypeConverter s_paintServerConverter = TypeDescriptor.GetConverter(typeof(SvgPaintServer));
+
+ private readonly List _bindings;
+ private readonly Dictionary _bindingsByTargetAttributeKey;
+ private readonly Dictionary> _pointerEventInstances = new(StringComparer.Ordinal);
+ private readonly HashSet _pointerEventDependencies;
+ private readonly Dictionary> _pointerEventDependents;
+ private readonly int[]? _animatedTopLevelChildIndexes;
+ private SvgAnimationFrameState? _cachedFrameState;
+ private int _frameStateVersion;
+ private bool _disposed;
+
+ public SvgAnimationController(SvgDocument sourceDocument)
+ {
+ SourceDocument = sourceDocument ?? throw new ArgumentNullException(nameof(sourceDocument));
+ Clock = new SvgAnimationClock();
+ Clock.TimeChanged += OnClockTimeChanged;
+ _bindings = DiscoverBindings(sourceDocument);
+ _bindingsByTargetAttributeKey = BuildBindingLookup(_bindings);
+ _pointerEventDependencies = BuildPointerEventDependencies(_bindings);
+ _pointerEventDependents = BuildPointerEventDependents(_bindings);
+ _animatedTopLevelChildIndexes = DiscoverAnimatedTopLevelChildIndexes(sourceDocument, _bindings);
+ }
+
+ public SvgDocument SourceDocument { get; }
+
+ public SvgAnimationClock Clock { get; }
+
+ public bool HasAnimations => _bindings.Count > 0;
+
+ public event EventHandler? FrameChanged;
+
+ internal bool TryGetAnimatedTopLevelChildIndexes(out IReadOnlyList childIndexes)
+ {
+ if (_animatedTopLevelChildIndexes is not { Length: > 0 })
+ {
+ childIndexes = Array.Empty();
+ return false;
+ }
+
+ childIndexes = _animatedTopLevelChildIndexes;
+ return true;
+ }
+
+ internal bool HasDocumentRootAnimationTargets()
+ {
+ for (var i = 0; i < _bindings.Count; i++)
+ {
+ if (string.IsNullOrWhiteSpace(_bindings[i].TargetAddress.Key))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ internal IReadOnlyList GetAnimatedTargetAddressKeys()
+ {
+ if (_bindings.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ var keys = new List(_bindings.Count);
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ foreach (var binding in _bindings)
+ {
+ var key = binding.TargetAddress.Key;
+ if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
+ {
+ continue;
+ }
+
+ keys.Add(key);
+ }
+
+ return keys;
+ }
+
+ public SvgDocument CreateAnimatedDocument()
+ {
+ return CreateAnimatedDocument(EvaluateFrameState(Clock.CurrentTime));
+ }
+
+ public SvgDocument CreateAnimatedDocument(TimeSpan time)
+ {
+ ThrowIfDisposed();
+
+ if (time < TimeSpan.Zero)
+ {
+ time = TimeSpan.Zero;
+ }
+
+ return CreateAnimatedDocument(EvaluateFrameState(time));
+ }
+
+ internal SvgDocument CreateAnimatedDocument(SvgAnimationFrameState frameState)
+ {
+ ThrowIfDisposed();
+
+ var clone = SourceDocument.DeepCopy() as SvgDocument
+ ?? throw new InvalidOperationException("Svg animation runtime requires SvgDocument.DeepCopy() to return SvgDocument.");
+
+ if (_bindings.Count == 0)
+ {
+ return clone;
+ }
+
+ ApplyFrameState(clone, frameState, previousState: null);
+
+ return clone;
+ }
+
+ internal SvgAnimationFrameState EvaluateFrameState(TimeSpan time)
+ {
+ ThrowIfDisposed();
+
+ if (time < TimeSpan.Zero)
+ {
+ time = TimeSpan.Zero;
+ }
+
+ if (_cachedFrameState is { } cachedFrameState &&
+ cachedFrameState.Time == time &&
+ cachedFrameState.Version == _frameStateVersion)
+ {
+ return cachedFrameState;
+ }
+
+ var attributes = new Dictionary(StringComparer.Ordinal);
+ foreach (var binding in _bindings)
+ {
+ attributes.TryGetValue(binding.TargetAttributeKey, out var currentAttributeState);
+ if (!TryResolveAnimatedAttributeValue(this, binding, time, currentAttributeState?.Value, out var value))
+ {
+ continue;
+ }
+
+ attributes[binding.TargetAttributeKey] = new SvgAnimationFrameAttributeState(
+ binding.TargetAttributeKey,
+ binding.TargetAddress,
+ binding.AttributeName,
+ value);
+ }
+
+ var frameState = new SvgAnimationFrameState(time, _frameStateVersion, attributes);
+ _cachedFrameState = frameState;
+ return frameState;
+ }
+
+ internal void ApplyFrameState(SvgDocument document, SvgAnimationFrameState frameState, SvgAnimationFrameState? previousState)
+ {
+ ThrowIfDisposed();
+
+ foreach (var attribute in frameState.EnumerateDirtyAttributes(previousState))
+ {
+ var target = attribute.TargetAddress.Resolve(document);
+ if (target is null)
+ {
+ continue;
+ }
+
+ _ = SetAttributeValue(target, attribute.AttributeName, attribute.Value);
+ }
+
+ foreach (var removedKey in frameState.EnumerateRemovedKeys(previousState))
+ {
+ if (!_bindingsByTargetAttributeKey.TryGetValue(removedKey, out var binding))
+ {
+ continue;
+ }
+
+ var target = binding.TargetAddress.Resolve(document);
+ if (target is null)
+ {
+ continue;
+ }
+
+ if (binding.BaseValueString is not null)
+ {
+ _ = SetAttributeValue(target, binding.AttributeName, binding.BaseValueString);
+
+ if (!binding.HasExplicitBaseAttribute)
+ {
+ _ = ClearAttributeValue(target, binding.AttributeName);
+ }
+
+ continue;
+ }
+
+ _ = ClearAttributeValue(target, binding.AttributeName);
+ }
+ }
+
+ public bool RecordPointerEvent(SvgElement? element, SvgPointerEventType eventType)
+ {
+ ThrowIfDisposed();
+
+ if (!HasAnimations || element is null)
+ {
+ return false;
+ }
+
+ var key = CreateEventInstanceKey(SvgElementAddress.Create(element), eventType);
+ if (!_pointerEventDependencies.Contains(key))
+ {
+ return false;
+ }
+
+ if (!_pointerEventInstances.TryGetValue(key, out var eventTimes))
+ {
+ eventTimes = new List();
+ _pointerEventInstances[key] = eventTimes;
+ }
+
+ eventTimes.Add(Clock.CurrentTime);
+ PrunePointerEventInstances(key);
+ InvalidateFrameStateCache();
+ return true;
+ }
+
+ public void Reset()
+ {
+ ThrowIfDisposed();
+ _pointerEventInstances.Clear();
+ InvalidateFrameStateCache();
+ var currentTime = Clock.CurrentTime;
+ Clock.Reset();
+
+ if (currentTime == TimeSpan.Zero && HasAnimations)
+ {
+ FrameChanged?.Invoke(this, new SvgAnimationFrameChangedEventArgs(TimeSpan.Zero));
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ Clock.TimeChanged -= OnClockTimeChanged;
+ _disposed = true;
+ }
+
+ private void OnClockTimeChanged(object? sender, SvgAnimationClockChangedEventArgs e)
+ {
+ if (_disposed || !HasAnimations)
+ {
+ return;
+ }
+
+ FrameChanged?.Invoke(this, new SvgAnimationFrameChangedEventArgs(e.Time));
+ }
+
+ private static HashSet BuildPointerEventDependencies(IEnumerable bindings)
+ {
+ var dependencies = new HashSet(StringComparer.Ordinal);
+
+ foreach (var binding in bindings)
+ {
+ AddEventDependencies(binding.BeginSpecs, dependencies);
+ AddEventDependencies(binding.EndSpecs, dependencies);
+ }
+
+ return dependencies;
+ }
+
+ private static Dictionary> BuildPointerEventDependents(IEnumerable bindings)
+ {
+ var dependents = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var binding in bindings)
+ {
+ AddPointerEventDependents(binding, binding.BeginSpecs, isBegin: true, dependents);
+ AddPointerEventDependents(binding, binding.EndSpecs, isBegin: false, dependents);
+ }
+
+ return dependents;
+ }
+
+ private static void AddPointerEventDependents(
+ AnimationBinding binding,
+ IEnumerable specs,
+ bool isBegin,
+ Dictionary> dependents)
+ {
+ foreach (var spec in specs)
+ {
+ if (!spec.IsEvent || spec.EventInstanceKey is not { Length: > 0 } eventInstanceKey)
+ {
+ continue;
+ }
+
+ if (!dependents.TryGetValue(eventInstanceKey, out var bindingsForKey))
+ {
+ bindingsForKey = new List();
+ dependents[eventInstanceKey] = bindingsForKey;
+ }
+
+ if (bindingsForKey.Any(existing => ReferenceEquals(existing.Binding, binding) && existing.IsBegin == isBegin))
+ {
+ continue;
+ }
+
+ bindingsForKey.Add(new PointerEventDependency(binding, isBegin));
+ }
+ }
+
+ private static Dictionary BuildBindingLookup(IEnumerable bindings)
+ {
+ var lookup = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var binding in bindings)
+ {
+ if (!lookup.ContainsKey(binding.TargetAttributeKey))
+ {
+ lookup.Add(binding.TargetAttributeKey, binding);
+ }
+ }
+
+ return lookup;
+ }
+
+ private static int[]? DiscoverAnimatedTopLevelChildIndexes(SvgDocument sourceDocument, IEnumerable bindings)
+ {
+ var indexes = new SortedSet();
+
+ foreach (var binding in bindings)
+ {
+ var current = binding.SourceTarget;
+ while (current.Parent is SvgElement parent && parent is not SvgDocument)
+ {
+ current = parent;
+ }
+
+ if (current.Parent is not SvgDocument)
+ {
+ return null;
+ }
+
+ var childIndex = sourceDocument.Children.IndexOf(current);
+ if (childIndex < 0)
+ {
+ return null;
+ }
+
+ indexes.Add(childIndex);
+ }
+
+ return indexes.Count > 0 ? indexes.ToArray() : null;
+ }
+
+ private static void AddEventDependencies(IEnumerable specs, HashSet dependencies)
+ {
+ foreach (var spec in specs)
+ {
+ if (spec.IsEvent && spec.EventInstanceKey is { Length: > 0 } eventInstanceKey)
+ {
+ dependencies.Add(eventInstanceKey);
+ }
+ }
+ }
+
+ private static List DiscoverBindings(SvgDocument sourceDocument)
+ {
+ var bindings = new List();
+
+ foreach (var animation in sourceDocument.Descendants().OfType())
+ {
+ var target = animation.TargetElement;
+ var attributeName = ResolveAttributeName(animation);
+
+ if (target is null || string.IsNullOrWhiteSpace(attributeName))
+ {
+ continue;
+ }
+
+ bindings.Add(new AnimationBinding(animation, target, SvgElementAddress.Create(target), attributeName!));
+ }
+
+ return bindings;
+ }
+
+ private void InvalidateFrameStateCache()
+ {
+ _frameStateVersion++;
+ _cachedFrameState = null;
+ }
+
+ private static List ParseTimingSpecifications(string? value, SvgDocument? document, SvgElementAddress defaultEventAddress, bool includeImplicitDocumentBegin)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return includeImplicitDocumentBegin
+ ? new List { new(TimeSpan.Zero) }
+ : new List();
+ }
+
+ var specs = new List();
+ foreach (var token in SvgAnimationParser.SplitSemicolonList(value))
+ {
+ if (SvgAnimationParser.TryParseClockValue(token, out var clockOffset))
+ {
+ specs.Add(new TimingSpec(clockOffset));
+ continue;
+ }
+
+ if (TryParseEventTimingSpec(token, document, defaultEventAddress, out var eventTimingSpec))
+ {
+ specs.Add(eventTimingSpec);
+ }
+ }
+
+ return specs;
+ }
+
+ private void PrunePointerEventInstances(string key)
+ {
+ if (!_pointerEventInstances.TryGetValue(key, out var eventTimes) ||
+ eventTimes.Count <= 1)
+ {
+ return;
+ }
+
+ if (!_pointerEventDependents.TryGetValue(key, out var dependents) ||
+ dependents.Count == 0)
+ {
+ eventTimes.Clear();
+ return;
+ }
+
+ eventTimes.Sort();
+
+ var currentTime = Clock.CurrentTime;
+ var relevantEventTimes = new HashSet();
+
+ foreach (var dependent in dependents)
+ {
+ CollectRelevantEventTimesForBinding(key, dependent.Binding, currentTime, relevantEventTimes);
+ }
+
+ eventTimes.RemoveAll(eventTime => !relevantEventTimes.Contains(eventTime.Ticks));
+ }
+
+ private void CollectRelevantEventTimesForBinding(
+ string key,
+ AnimationBinding binding,
+ TimeSpan currentTime,
+ HashSet relevantEventTimes)
+ {
+ var beginInstances = ResolveTimingInstancesDetailed(binding.BeginSpecs);
+ var endInstances = ResolveTimingInstancesDetailed(binding.EndSpecs);
+
+ PreserveFutureEventInstances(key, beginInstances, currentTime, relevantEventTimes);
+ PreserveFutureEventInstances(key, endInstances, currentTime, relevantEventTimes);
+
+ var allowIndefiniteDiscrete = binding.Animation is SvgSet;
+ if (!TryResolveCurrentIntervalDetailed(binding.Animation, currentTime, allowIndefiniteDiscrete, beginInstances, endInstances, out var interval))
+ {
+ return;
+ }
+
+ if (interval.BeginInstance.EventInstanceKey == key && interval.BeginInstance.SourceEventTime.HasValue)
+ {
+ relevantEventTimes.Add(interval.BeginInstance.SourceEventTime.Value.Ticks);
+ }
+
+ if (interval.EndInstance is { EventInstanceKey: var endKey, SourceEventTime: { } endSourceTime } &&
+ endKey == key)
+ {
+ relevantEventTimes.Add(endSourceTime.Ticks);
+ }
+ }
+
+ private static void PreserveFutureEventInstances(
+ string key,
+ IReadOnlyList instances,
+ TimeSpan currentTime,
+ HashSet relevantEventTimes)
+ {
+ for (var index = 0; index < instances.Count; index++)
+ {
+ var instance = instances[index];
+ if (instance.Time <= currentTime ||
+ instance.EventInstanceKey != key ||
+ !instance.SourceEventTime.HasValue)
+ {
+ continue;
+ }
+
+ relevantEventTimes.Add(instance.SourceEventTime.Value.Ticks);
+ }
+ }
+
+ private static bool TryParseEventTimingSpec(string value, SvgDocument? document, SvgElementAddress defaultEventAddress, out TimingSpec spec)
+ {
+ spec = default;
+
+ if (!SvgAnimationParser.TryParseEventTimingSpec(value, document, defaultEventAddress, out var parsedTiming))
+ {
+ return false;
+ }
+
+ spec = new TimingSpec(CreateEventInstanceKey(parsedTiming.EventAddress, parsedTiming.EventType), parsedTiming.Offset);
+ return true;
+ }
+
+ private static string? ResolveAttributeName(SvgAnimationElement animation)
+ {
+ if (animation is SvgAnimateMotion)
+ {
+ return "transform";
+ }
+
+ if (animation is SvgAnimateTransform animateTransform)
+ {
+ return string.IsNullOrWhiteSpace(animateTransform.AnimationAttributeName)
+ ? "transform"
+ : animateTransform.AnimationAttributeName;
+ }
+
+ if (animation is SvgAnimationAttributeElement attributeAnimation)
+ {
+ return attributeAnimation.AnimationAttributeName;
+ }
+
+ return null;
+ }
+
+ private static bool TryResolveAnimatedAttributeValue(
+ SvgAnimationController controller,
+ AnimationBinding binding,
+ TimeSpan time,
+ string? currentComposedValue,
+ out string value)
+ {
+ value = string.Empty;
+
+ switch (binding.Animation)
+ {
+ case SvgSet svgSet:
+ if (!TryGetSetSample(controller, binding, svgSet, time) ||
+ !SvgAnimationParser.TryGetTrimmedString(svgSet.To, out var setValue))
+ {
+ return false;
+ }
+
+ value = setValue;
+ return true;
+ case SvgAnimateMotion animateMotion:
+ if (!TryGetAnimationSample(controller, binding, animateMotion, time, allowIndefiniteDiscrete: false, out var motionSample) ||
+ !TryResolveMotionValue(binding, animateMotion, motionSample, out value))
+ {
+ return false;
+ }
+
+ if (animateMotion.Additive == SvgAnimationAdditive.Sum)
+ {
+ value = CombineTransformValue(ResolveCurrentComposedBaseValue(currentComposedValue, binding.BaseValueString), value);
+ }
+
+ return true;
+ case SvgAnimateTransform animateTransform:
+ if (!TryGetAnimationSample(controller, binding, animateTransform, time, allowIndefiniteDiscrete: false, out var transformSample) ||
+ !TryResolveTransformValue(binding, animateTransform, transformSample, out value))
+ {
+ return false;
+ }
+
+ if (animateTransform.Additive == SvgAnimationAdditive.Sum)
+ {
+ value = CombineTransformValue(ResolveCurrentComposedBaseValue(currentComposedValue, binding.BaseValueString), value);
+ }
+
+ return true;
+ case SvgAnimateColor animateColor:
+ if (!TryGetAnimationSample(controller, binding, animateColor, time, allowIndefiniteDiscrete: false, out var colorSample) ||
+ !TryResolveAnimatedValue(binding, animateColor, colorSample, forceColorInterpolation: true, out value))
+ {
+ return false;
+ }
+
+ if (animateColor.Additive == SvgAnimationAdditive.Sum &&
+ TryResolveAdditiveBaseValue(binding, animateColor, currentComposedValue, out var additiveColorBase) &&
+ TryAddValue(binding, additiveColorBase, value, out var additiveColorValue))
+ {
+ value = additiveColorValue;
+ }
+
+ return true;
+ case SvgAnimate animate:
+ if (!TryGetAnimationSample(controller, binding, animate, time, allowIndefiniteDiscrete: false, out var valueSample) ||
+ !TryResolveAnimatedValue(binding, animate, valueSample, forceColorInterpolation: false, out value))
+ {
+ return false;
+ }
+
+ if (animate.Additive == SvgAnimationAdditive.Sum &&
+ TryResolveAdditiveBaseValue(binding, animate, currentComposedValue, out var additiveValueBase) &&
+ TryAddValue(binding, additiveValueBase, value, out var additiveValue))
+ {
+ value = additiveValue;
+ }
+
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static void ApplyAnimation(SvgAnimationController controller, AnimationBinding binding, SvgDocument document, TimeSpan time)
+ {
+ var target = binding.TargetAddress.Resolve(document);
+ if (target is null)
+ {
+ return;
+ }
+
+ switch (binding.Animation)
+ {
+ case SvgSet svgSet:
+ ApplySet(controller, binding, target, svgSet, time);
+ break;
+ case SvgAnimateMotion animateMotion:
+ ApplyAnimateMotion(controller, binding, target, animateMotion, time);
+ break;
+ case SvgAnimateTransform animateTransform:
+ ApplyAnimateTransform(controller, binding, target, animateTransform, time);
+ break;
+ case SvgAnimateColor animateColor:
+ ApplyAnimateValue(controller, binding, target, animateColor, time, forceColorInterpolation: true);
+ break;
+ case SvgAnimate animate:
+ ApplyAnimateValue(controller, binding, target, animate, time, forceColorInterpolation: false);
+ break;
+ }
+ }
+
+ private static void ApplySet(SvgAnimationController controller, AnimationBinding binding, SvgElement target, SvgSet animation, TimeSpan time)
+ {
+ if (!TryGetSetSample(controller, binding, animation, time))
+ {
+ return;
+ }
+
+ if (!SvgAnimationParser.TryGetTrimmedString(animation.To, out var setValue))
+ {
+ return;
+ }
+
+ _ = SetAttributeValue(target, binding.AttributeName, setValue);
+ }
+
+ private static void ApplyAnimateValue(SvgAnimationController controller, AnimationBinding binding, SvgElement target, SvgAnimationValueElement animation, TimeSpan time, bool forceColorInterpolation)
+ {
+ if (!TryGetAnimationSample(controller, binding, animation, time, allowIndefiniteDiscrete: false, out var sample))
+ {
+ return;
+ }
+
+ if (!TryResolveAnimatedValue(binding, animation, sample, forceColorInterpolation, out var value))
+ {
+ return;
+ }
+
+ if (animation.Additive == SvgAnimationAdditive.Sum &&
+ TryResolveAdditiveBaseValue(
+ binding,
+ animation,
+ ConvertAttributeValueToString(GetAttributeValue(target, binding.AttributeName)),
+ out var baseValue) &&
+ TryAddValue(binding, baseValue, value, out var additiveValue))
+ {
+ value = additiveValue;
+ }
+
+ _ = SetAttributeValue(target, binding.AttributeName, value);
+ }
+
+ private static void ApplyAnimateTransform(SvgAnimationController controller, AnimationBinding binding, SvgElement target, SvgAnimateTransform animation, TimeSpan time)
+ {
+ if (!TryGetAnimationSample(controller, binding, animation, time, allowIndefiniteDiscrete: false, out var sample))
+ {
+ return;
+ }
+
+ if (!TryResolveTransformValue(binding, animation, sample, out var transformValue))
+ {
+ return;
+ }
+
+ if (animation.Additive == SvgAnimationAdditive.Sum)
+ {
+ transformValue = CombineTransformValue(
+ ResolveCurrentComposedBaseValue(
+ ConvertAttributeValueToString(GetAttributeValue(target, binding.AttributeName)),
+ binding.BaseValueString),
+ transformValue);
+ }
+
+ _ = SetAttributeValue(target, binding.AttributeName, transformValue);
+ }
+
+ private static void ApplyAnimateMotion(SvgAnimationController controller, AnimationBinding binding, SvgElement target, SvgAnimateMotion animation, TimeSpan time)
+ {
+ if (!TryGetAnimationSample(controller, binding, animation, time, allowIndefiniteDiscrete: false, out var sample))
+ {
+ return;
+ }
+
+ if (!TryResolveMotionValue(binding, animation, sample, out var transformValue))
+ {
+ return;
+ }
+
+ if (animation.Additive == SvgAnimationAdditive.Sum)
+ {
+ transformValue = CombineTransformValue(
+ ResolveCurrentComposedBaseValue(
+ ConvertAttributeValueToString(GetAttributeValue(target, binding.AttributeName)),
+ binding.BaseValueString),
+ transformValue);
+ }
+
+ _ = SetAttributeValue(target, binding.AttributeName, transformValue);
+ }
+
+ private static bool TryGetSetSample(SvgAnimationController controller, AnimationBinding binding, SvgAnimationElement animation, TimeSpan time)
+ {
+ if (!TryResolveCurrentInterval(controller, binding, animation, time, allowIndefiniteDiscrete: true, out var interval))
+ {
+ return false;
+ }
+
+ return interval.IsActive || (interval.IsFrozen && animation.AnimationFill == SvgAnimationFill.Freeze);
+ }
+
+ private readonly struct AnimationInterval
+ {
+ public AnimationInterval(TimeSpan begin, TimeSpan? activeEnd)
+ {
+ Begin = begin;
+ ActiveEnd = activeEnd;
+ }
+
+ public TimeSpan Begin { get; }
+
+ public TimeSpan? ActiveEnd { get; }
+
+ public bool IsActive(TimeSpan time) => !ActiveEnd.HasValue || time <= ActiveEnd.Value;
+ }
+
+ private readonly struct ResolvedAnimationInterval
+ {
+ public ResolvedAnimationInterval(ResolvedTimingInstance beginInstance, TimeSpan? activeEnd, ResolvedTimingInstance? endInstance)
+ {
+ BeginInstance = beginInstance;
+ ActiveEnd = activeEnd;
+ EndInstance = endInstance;
+ }
+
+ public ResolvedTimingInstance BeginInstance { get; }
+
+ public TimeSpan? ActiveEnd { get; }
+
+ public ResolvedTimingInstance? EndInstance { get; }
+
+ public bool IsActive(TimeSpan time) => !ActiveEnd.HasValue || time <= ActiveEnd.Value;
+ }
+
+ private static bool TryGetAnimationSample(SvgAnimationController controller, AnimationBinding binding, SvgAnimationElement animation, TimeSpan time, bool allowIndefiniteDiscrete, out AnimationSample sample)
+ {
+ sample = default;
+
+ if (!TryResolveCurrentInterval(controller, binding, animation, time, allowIndefiniteDiscrete, out var interval))
+ {
+ return false;
+ }
+
+ var hasDuration = TryParseClockValue(animation.Duration, out var duration);
+ if (!hasDuration)
+ {
+ if (!allowIndefiniteDiscrete)
+ {
+ return false;
+ }
+
+ sample = new AnimationSample(1f, 0);
+ return true;
+ }
+
+ if (duration < TimeSpan.Zero)
+ {
+ return false;
+ }
+
+ if (duration == TimeSpan.Zero)
+ {
+ sample = new AnimationSample(1f, 0);
+ return true;
+ }
+
+ var elapsed = interval.IsFrozen && interval.ActiveEnd.HasValue
+ ? interval.ActiveEnd.Value - interval.Begin
+ : time - interval.Begin;
+
+ sample = CreateSampleAtElapsed(duration, elapsed);
+ return true;
+ }
+
+ private static RepeatCountMode ParseRepeatCount(string? value, out double repeatCount)
+ {
+ repeatCount = 1d;
+
+ if (!SvgAnimationParser.TryGetFirstSemicolonSegment(value, out var trimmed))
+ {
+ return RepeatCountMode.DefaultOne;
+ }
+
+ if (SvgAnimationParser.EqualsKeywordIgnoreCase(trimmed.AsSpan(), "indefinite"))
+ {
+ return RepeatCountMode.Indefinite;
+ }
+
+ if (SvgAnimationParser.TryParseInvariantDouble(trimmed.AsSpan(), out repeatCount))
+ {
+ repeatCount = Math.Max(0d, repeatCount);
+ return RepeatCountMode.Finite;
+ }
+
+ repeatCount = 1d;
+ return RepeatCountMode.DefaultOne;
+ }
+
+ private static AnimationSample CreateSampleAtElapsed(TimeSpan simpleDuration, TimeSpan elapsed)
+ {
+ if (simpleDuration <= TimeSpan.Zero)
+ {
+ return new AnimationSample(1f, 0);
+ }
+
+ if (elapsed <= TimeSpan.Zero)
+ {
+ return new AnimationSample(0f, 0);
+ }
+
+ var elapsedTicks = elapsed.Ticks;
+ var durationTicks = simpleDuration.Ticks;
+ if (durationTicks <= 0)
+ {
+ return new AnimationSample(1f, 0);
+ }
+
+ var iterationIndex = ClampIterationIndex(elapsedTicks / durationTicks);
+ var localTicks = elapsedTicks % durationTicks;
+ if (localTicks == 0 && elapsedTicks > 0)
+ {
+ return new AnimationSample(1f, Math.Max(0, iterationIndex - 1));
+ }
+
+ var progress = (float)localTicks / durationTicks;
+ return new AnimationSample(Clamp01(progress), iterationIndex);
+ }
+
+ private static bool TryResolveCurrentInterval(SvgAnimationController controller, AnimationBinding binding, SvgAnimationElement animation, TimeSpan time, bool allowIndefiniteDiscrete, out (bool IsActive, bool IsFrozen, TimeSpan Begin, TimeSpan? ActiveEnd) interval)
+ {
+ interval = default;
+
+ var beginInstances = controller.ResolveTimingInstances(binding.BeginSpecs);
+ if (beginInstances.Count == 0)
+ {
+ return false;
+ }
+
+ var endInstances = controller.ResolveTimingInstances(binding.EndSpecs);
+ var selected = default(AnimationInterval?);
+
+ foreach (var begin in beginInstances)
+ {
+ if (begin > time)
+ {
+ break;
+ }
+
+ if (selected.HasValue)
+ {
+ switch (animation.Restart)
+ {
+ case SvgAnimationRestart.Never:
+ continue;
+ case SvgAnimationRestart.WhenNotActive:
+ if (!selected.Value.ActiveEnd.HasValue || begin < selected.Value.ActiveEnd.Value)
+ {
+ continue;
+ }
+ break;
+ }
+ }
+
+ if (!TryResolveIntervalEnd(animation, begin, endInstances, allowIndefiniteDiscrete, out var activeEnd))
+ {
+ continue;
+ }
+
+ selected = new AnimationInterval(begin, activeEnd);
+ }
+
+ if (!selected.HasValue)
+ {
+ return false;
+ }
+
+ var resolved = selected.Value;
+ if (resolved.IsActive(time))
+ {
+ interval = (true, false, resolved.Begin, resolved.ActiveEnd);
+ return true;
+ }
+
+ if (animation.AnimationFill == SvgAnimationFill.Freeze)
+ {
+ interval = (false, true, resolved.Begin, resolved.ActiveEnd);
+ return true;
+ }
+
+ return false;
+ }
+
+ private List ResolveTimingInstances(IReadOnlyList specs)
+ {
+ return ResolveTimingInstancesDetailed(specs)
+ .Select(static instance => instance.Time)
+ .ToList();
+ }
+
+ private List ResolveTimingInstancesDetailed(IReadOnlyList specs)
+ {
+ var instances = new List();
+
+ foreach (var spec in specs)
+ {
+ if (spec.IsEvent)
+ {
+ if (spec.EventInstanceKey is null ||
+ !_pointerEventInstances.TryGetValue(spec.EventInstanceKey, out var eventTimes))
+ {
+ continue;
+ }
+
+ instances.AddRange(eventTimes.Select(eventTime => new ResolvedTimingInstance(
+ eventTime + spec.Offset,
+ spec.EventInstanceKey,
+ eventTime)));
+ continue;
+ }
+
+ instances.Add(new ResolvedTimingInstance(spec.Offset, eventInstanceKey: null, sourceEventTime: null));
+ }
+
+ instances.Sort(static (left, right) => left.Time.CompareTo(right.Time));
+ return instances;
+ }
+
+ private static bool TryResolveCurrentIntervalDetailed(
+ SvgAnimationElement animation,
+ TimeSpan time,
+ bool allowIndefiniteDiscrete,
+ IReadOnlyList beginInstances,
+ IReadOnlyList endInstances,
+ out ResolvedAnimationInterval interval)
+ {
+ interval = default;
+
+ if (beginInstances.Count == 0)
+ {
+ return false;
+ }
+
+ ResolvedAnimationInterval? selected = null;
+
+ for (var index = 0; index < beginInstances.Count; index++)
+ {
+ var begin = beginInstances[index];
+ if (begin.Time > time)
+ {
+ break;
+ }
+
+ if (selected.HasValue)
+ {
+ switch (animation.Restart)
+ {
+ case SvgAnimationRestart.Never:
+ continue;
+ case SvgAnimationRestart.WhenNotActive:
+ if (!selected.Value.ActiveEnd.HasValue || begin.Time < selected.Value.ActiveEnd.Value)
+ {
+ continue;
+ }
+
+ break;
+ }
+ }
+
+ if (!TryResolveIntervalEndDetailed(animation, begin.Time, endInstances, allowIndefiniteDiscrete, out var activeEnd, out var endInstance))
+ {
+ continue;
+ }
+
+ selected = new ResolvedAnimationInterval(begin, activeEnd, endInstance);
+ }
+
+ if (!selected.HasValue)
+ {
+ return false;
+ }
+
+ var resolved = selected.Value;
+ if (resolved.IsActive(time) || animation.AnimationFill == SvgAnimationFill.Freeze)
+ {
+ interval = resolved;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryResolveIntervalEnd(SvgAnimationElement animation, TimeSpan begin, IReadOnlyList endInstances, bool allowIndefiniteDiscrete, out TimeSpan? activeEnd)
+ {
+ activeEnd = null;
+
+ TimeSpan? explicitEnd = null;
+ foreach (var endInstance in endInstances)
+ {
+ if (endInstance > begin)
+ {
+ explicitEnd = endInstance;
+ break;
+ }
+ }
+
+ var hasDuration = TryParseClockValue(animation.Duration, out var duration);
+ if (!hasDuration)
+ {
+ if (!allowIndefiniteDiscrete)
+ {
+ return false;
+ }
+
+ activeEnd = explicitEnd;
+ return true;
+ }
+
+ if (duration < TimeSpan.Zero)
+ {
+ return false;
+ }
+
+ activeEnd = ComputeActiveEnd(animation, begin, duration, explicitEnd);
+ return true;
+ }
+
+ private static bool TryResolveIntervalEndDetailed(
+ SvgAnimationElement animation,
+ TimeSpan begin,
+ IReadOnlyList endInstances,
+ bool allowIndefiniteDiscrete,
+ out TimeSpan? activeEnd,
+ out ResolvedTimingInstance? endInstance)
+ {
+ activeEnd = null;
+ endInstance = null;
+
+ TimeSpan? explicitEnd = null;
+ for (var index = 0; index < endInstances.Count; index++)
+ {
+ var candidate = endInstances[index];
+ if (candidate.Time <= begin)
+ {
+ continue;
+ }
+
+ explicitEnd = candidate.Time;
+ endInstance = candidate;
+ break;
+ }
+
+ var hasDuration = TryParseClockValue(animation.Duration, out var duration);
+ if (!hasDuration)
+ {
+ if (!allowIndefiniteDiscrete)
+ {
+ return false;
+ }
+
+ activeEnd = explicitEnd;
+ return true;
+ }
+
+ if (duration < TimeSpan.Zero)
+ {
+ return false;
+ }
+
+ activeEnd = ComputeActiveEnd(animation, begin, duration, explicitEnd);
+ return true;
+ }
+
+ private static TimeSpan? ComputeActiveEnd(SvgAnimationElement animation, TimeSpan begin, TimeSpan simpleDuration, TimeSpan? explicitEnd)
+ {
+ var totalDuration = ComputeTotalDuration(animation, simpleDuration, explicitEnd, begin);
+ return totalDuration.HasValue
+ ? begin + totalDuration.Value
+ : null;
+ }
+
+ private static TimeSpan? ComputeTotalDuration(SvgAnimationElement animation, TimeSpan simpleDuration, TimeSpan? explicitEnd, TimeSpan begin)
+ {
+ TimeSpan? totalDuration;
+ var repeatCountMode = ParseRepeatCount(animation.RepeatCount, out var repeatCount);
+
+ switch (repeatCountMode)
+ {
+ case RepeatCountMode.Indefinite:
+ totalDuration = null;
+ break;
+ case RepeatCountMode.Finite:
+ totalDuration = Multiply(simpleDuration, repeatCount);
+ break;
+ default:
+ totalDuration = simpleDuration;
+ break;
+ }
+
+ switch (ParseRepeatDuration(animation.RepeatDuration, out var repeatDuration))
+ {
+ case RepeatDurationMode.Indefinite:
+ if (repeatCountMode != RepeatCountMode.Finite)
+ {
+ totalDuration = null;
+ }
+
+ break;
+ case RepeatDurationMode.Finite:
+ totalDuration = MinDuration(totalDuration, repeatDuration);
+ break;
+ }
+
+ if (explicitEnd.HasValue && explicitEnd.Value > begin)
+ {
+ totalDuration = MinDuration(totalDuration, explicitEnd.Value - begin);
+ }
+
+ switch (ParseRepeatDuration(animation.Minimum, out var minimumDuration))
+ {
+ case RepeatDurationMode.Finite:
+ totalDuration = MaxDuration(totalDuration, minimumDuration);
+ break;
+ }
+
+ switch (ParseRepeatDuration(animation.Maximum, out var maximumDuration))
+ {
+ case RepeatDurationMode.Finite:
+ totalDuration = MinDuration(totalDuration, maximumDuration);
+ break;
+ }
+
+ return totalDuration;
+ }
+
+ private static bool TryParseClockValue(string? value, out TimeSpan result)
+ {
+ return SvgAnimationParser.TryParseClockValue(value, out result);
+ }
+
+ private static RepeatDurationMode ParseRepeatDuration(string? value, out TimeSpan repeatDuration)
+ {
+ repeatDuration = default;
+
+ if (!SvgAnimationParser.TryGetTrimmedString(value, out var trimmed))
+ {
+ return RepeatDurationMode.None;
+ }
+
+ if (SvgAnimationParser.EqualsKeywordIgnoreCase(trimmed.AsSpan(), "indefinite"))
+ {
+ return RepeatDurationMode.Indefinite;
+ }
+
+ return TryParseClockValue(trimmed, out repeatDuration) && repeatDuration >= TimeSpan.Zero
+ ? RepeatDurationMode.Finite
+ : RepeatDurationMode.None;
+ }
+
+ private static bool TryResolveAnimatedValue(AnimationBinding binding, SvgAnimationValueElement animation, AnimationSample sample, bool forceColorInterpolation, out string value)
+ {
+ value = string.Empty;
+
+ var values = ResolveAnimationValues(binding, animation);
+ if (values.Count == 0)
+ {
+ return false;
+ }
+
+ if (values.Count == 1)
+ {
+ value = values[0];
+ return TryApplyAccumulation(binding, animation, values, sample, forceColorInterpolation, ref value);
+ }
+
+ if (animation.CalcMode == SvgAnimationCalcMode.Discrete)
+ {
+ value = ResolveDiscreteValue(values, animation.KeyTimes, sample.Progress);
+ return TryApplyAccumulation(binding, animation, values, sample, forceColorInterpolation, ref value);
+ }
+
+ ResolveInterpolatedSegment(
+ values.Count,
+ animation.KeyTimes,
+ animation.KeySplines,
+ animation.CalcMode,
+ sample.Progress,
+ animation.CalcMode == SvgAnimationCalcMode.Paced
+ ? ResolvePacedSegmentLengths(binding, values, forceColorInterpolation)
+ : null,
+ out var startIndex,
+ out var endIndex,
+ out var localProgress);
+ var fromValue = values[startIndex];
+ var toValue = values[endIndex];
+
+ if (TryInterpolateValue(binding, fromValue, toValue, localProgress, forceColorInterpolation, out value))
+ {
+ return TryApplyAccumulation(binding, animation, values, sample, forceColorInterpolation, ref value);
+ }
+
+ value = localProgress >= 1f ? toValue : fromValue;
+ return TryApplyAccumulation(binding, animation, values, sample, forceColorInterpolation, ref value);
+ }
+
+ private static bool TryResolveTransformValue(AnimationBinding binding, SvgAnimateTransform animation, AnimationSample sample, out string transformValue)
+ {
+ transformValue = string.Empty;
+
+ var values = ResolveAnimationValues(binding, animation);
+ if (values.Count == 0)
+ {
+ return false;
+ }
+
+ if (values.Count == 1 || animation.CalcMode == SvgAnimationCalcMode.Discrete)
+ {
+ var discrete = values.Count == 1
+ ? values[0]
+ : ResolveDiscreteValue(values, animation.KeyTimes, sample.Progress);
+
+ var discreteValues = ParseTransformNumbers(discrete);
+ if (!TryApplyTransformAccumulation(binding, animation, values, sample, ref discreteValues))
+ {
+ return false;
+ }
+
+ return TryCreateTransformString(animation.TransformType, discreteValues, out transformValue);
+ }
+
+ ResolveInterpolatedSegment(
+ values.Count,
+ animation.KeyTimes,
+ animation.KeySplines,
+ animation.CalcMode,
+ sample.Progress,
+ animation.CalcMode == SvgAnimationCalcMode.Paced
+ ? ResolveTransformPacedSegmentLengths(animation.TransformType, values)
+ : null,
+ out var startIndex,
+ out var endIndex,
+ out var localProgress);
+ var fromValues = ParseTransformNumbers(values[startIndex]);
+ var toValues = ParseTransformNumbers(values[endIndex]);
+ var interpolated = InterpolateTransformNumbers(animation.TransformType, fromValues, toValues, localProgress);
+ if (!TryApplyTransformAccumulation(binding, animation, values, sample, ref interpolated))
+ {
+ return false;
+ }
+
+ return TryCreateTransformString(animation.TransformType, interpolated, out transformValue);
+ }
+
+ private static bool TryResolveMotionValue(AnimationBinding binding, SvgAnimateMotion animation, AnimationSample sample, out string transformValue)
+ {
+ transformValue = string.Empty;
+
+ var motionSource = ResolveMotionSource(binding, animation);
+ if (string.IsNullOrWhiteSpace(motionSource.PathData) &&
+ motionSource.Points is not { Count: > 0 })
+ {
+ return false;
+ }
+
+ var motionPosition = TryResolveMotionPoint(animation, sample, motionSource, out var position, out var tangent);
+ if (!motionPosition)
+ {
+ return false;
+ }
+
+ if (animation.Accumulate == SvgAnimationAccumulate.Sum &&
+ sample.IterationIndex > 0 &&
+ TryResolveMotionAccumulationOffset(animation, motionSource, out var accumulatedOffset))
+ {
+ position = new SKPoint(
+ position.X + (accumulatedOffset.X * sample.IterationIndex),
+ position.Y + (accumulatedOffset.Y * sample.IterationIndex));
+ }
+
+ var transforms = new List
+ {
+ new SvgTranslate(position.X, position.Y)
+ };
+
+ if (TryResolveMotionRotation(animation.Rotate, tangent, out var angle))
+ {
+ transforms.Add(new SvgRotate(angle));
+ }
+
+ transformValue = string.Join(" ", transforms.Select(static transform => transform.ToString()));
+ return true;
+ }
+
+ private static MotionSource ResolveMotionSource(AnimationBinding binding, SvgAnimateMotion animation)
+ {
+ return binding.GetResolvedMotionSource(() =>
+ {
+ if (animation.Children.OfType().FirstOrDefault()?.TargetPath?.PathData is { Count: > 0 } mpathData)
+ {
+ return new MotionSource(mpathData.ToString(), points: null);
+ }
+
+ if (animation.PathData is { Count: > 0 })
+ {
+ return new MotionSource(animation.PathData.ToString(), points: null);
+ }
+
+ var points = ResolveMotionCoordinatePoints(binding, animation);
+ if (points.Count == 0)
+ {
+ return default;
+ }
+
+ return new MotionSource(CreateMotionPathData(points), points);
+ });
+ }
+
+ private static float ResolveMotionProgress(SvgAnimateMotion animation, float progress)
+ {
+ if (animation.KeyPoints is not { Count: > 0 })
+ {
+ return progress;
+ }
+
+ if (animation.KeyPoints.Count == 1)
+ {
+ return animation.KeyPoints[0];
+ }
+
+ if (animation.CalcMode == SvgAnimationCalcMode.Discrete)
+ {
+ return ParseMotionDiscreteProgress(animation.KeyPoints, animation.KeyTimes, progress);
+ }
+
+ ResolveInterpolatedSegment(
+ animation.KeyPoints.Count,
+ animation.KeyTimes,
+ animation.KeySplines,
+ animation.CalcMode,
+ progress,
+ animation.CalcMode == SvgAnimationCalcMode.Paced
+ ? ResolveScalarPacedSegmentLengths(animation.KeyPoints)
+ : null,
+ out var startIndex,
+ out var endIndex,
+ out var localProgress);
+ return Lerp(animation.KeyPoints[startIndex], animation.KeyPoints[endIndex], localProgress);
+ }
+
+ private static bool TryResolveMotionPoint(SvgAnimateMotion animation, AnimationSample sample, MotionSource motionSource, out SKPoint position, out SKPoint tangent)
+ {
+ position = default;
+ tangent = new SKPoint(1f, 0f);
+
+ if (animation.KeyPoints is { Count: > 0 } || animation.CalcMode == SvgAnimationCalcMode.Paced || motionSource.Points is not { Count: > 1 })
+ {
+ return TryResolveMotionPointOnPath(animation, sample.Progress, motionSource.PathData, out position, out tangent);
+ }
+
+ return TryResolveMotionPointFromValues(animation, sample.Progress, motionSource.Points, out position, out tangent);
+ }
+
+ private static bool TryResolveMotionPointOnPath(SvgAnimateMotion animation, float progress, string? pathData, out SKPoint position, out SKPoint tangent)
+ {
+ position = default;
+ tangent = new SKPoint(1f, 0f);
+
+ if (string.IsNullOrWhiteSpace(pathData))
+ {
+ return false;
+ }
+
+ using var path = SKPath.ParseSvgPathData(pathData);
+ if (path is null)
+ {
+ return false;
+ }
+
+ using var measure = new SKPathMeasure(path, false);
+ if (measure.Length <= 0f)
+ {
+ return false;
+ }
+
+ var distanceProgress = ResolveMotionProgress(animation, progress);
+ var distance = measure.Length * Clamp01(distanceProgress);
+ return measure.GetPositionAndTangent(distance, out position, out tangent);
+ }
+
+ private static bool TryResolveMotionPointFromValues(SvgAnimateMotion animation, float progress, IReadOnlyList points, out SKPoint position, out SKPoint tangent)
+ {
+ position = default;
+ tangent = new SKPoint(1f, 0f);
+
+ if (points.Count == 0)
+ {
+ return false;
+ }
+
+ if (points.Count == 1)
+ {
+ position = points[0];
+ return true;
+ }
+
+ if (animation.CalcMode == SvgAnimationCalcMode.Discrete)
+ {
+ var discretePoint = ResolveDiscreteMotionPoint(points, animation.KeyTimes, progress);
+ position = discretePoint;
+ tangent = ResolveMotionTangent(points, Math.Min(points.Count - 2, ResolveDiscreteMotionIndex(points.Count, animation.KeyTimes, progress)));
+ return true;
+ }
+
+ ResolveInterpolatedSegment(
+ points.Count,
+ animation.KeyTimes,
+ animation.KeySplines,
+ animation.CalcMode,
+ progress,
+ animation.CalcMode == SvgAnimationCalcMode.Paced
+ ? ResolvePointPacedSegmentLengths(points)
+ : null,
+ out var startIndex,
+ out var endIndex,
+ out var localProgress);
+ var fromPoint = points[startIndex];
+ var toPoint = points[endIndex];
+ position = new SKPoint(
+ Lerp(fromPoint.X, toPoint.X, localProgress),
+ Lerp(fromPoint.Y, toPoint.Y, localProgress));
+ tangent = ResolveMotionTangent(points, startIndex);
+ return true;
+ }
+
+ private static bool TryResolveMotionAccumulationOffset(SvgAnimateMotion animation, MotionSource motionSource, out SKPoint offset)
+ {
+ offset = default;
+
+ if (!TryResolveMotionPoint(animation, new AnimationSample(0f, 0), motionSource, out var startPoint, out _) ||
+ !TryResolveMotionPoint(animation, new AnimationSample(1f, 0), motionSource, out var endPoint, out _))
+ {
+ return false;
+ }
+
+ offset = new SKPoint(endPoint.X - startPoint.X, endPoint.Y - startPoint.Y);
+ return true;
+ }
+
+ private static SKPoint ResolveDiscreteMotionPoint(IReadOnlyList points, SvgNumberCollection? keyTimes, float progress)
+ {
+ var index = ResolveDiscreteMotionIndex(points.Count, keyTimes, progress);
+ return points[Math.Max(0, Math.Min(points.Count - 1, index))];
+ }
+
+ private static int ResolveDiscreteMotionIndex(int count, SvgNumberCollection? keyTimes, float progress)
+ {
+ if (count <= 1)
+ {
+ return 0;
+ }
+
+ if (keyTimes is { Count: > 1 } && keyTimes.Count == count)
+ {
+ for (var index = keyTimes.Count - 1; index >= 0; index--)
+ {
+ if (progress >= keyTimes[index])
+ {
+ return index;
+ }
+ }
+
+ return 0;
+ }
+
+ var scaled = progress * count;
+ var discreteIndex = (int)Math.Floor(scaled);
+ if (discreteIndex >= count)
+ {
+ discreteIndex = count - 1;
+ }
+
+ return Math.Max(0, discreteIndex);
+ }
+
+ private static SKPoint ResolveMotionTangent(IReadOnlyList points, int startIndex)
+ {
+ if (points.Count <= 1)
+ {
+ return new SKPoint(1f, 0f);
+ }
+
+ var clampedStart = Math.Max(0, Math.Min(points.Count - 2, startIndex));
+ var fromPoint = points[clampedStart];
+ var toPoint = points[clampedStart + 1];
+ var tangent = new SKPoint(toPoint.X - fromPoint.X, toPoint.Y - fromPoint.Y);
+ if (tangent.X == 0f && tangent.Y == 0f)
+ {
+ return new SKPoint(1f, 0f);
+ }
+
+ return tangent;
+ }
+
+ private static List ResolveMotionCoordinatePoints(AnimationBinding binding, SvgAnimateMotion animation)
+ {
+ var values = SvgAnimationParser.SplitSemicolonList(animation.Values);
+ if (values.Count > 0)
+ {
+ return values
+ .Select(value => TryParseMotionCoordinatePair(value, binding.SourceTarget, out var point) ? point : (SKPoint?)null)
+ .Where(static point => point.HasValue)
+ .Select(static point => point!.Value)
+ .ToList();
+ }
+
+ var points = new List();
+
+ if (SvgAnimationParser.TryGetTrimmedString(animation.From, out var fromValue) &&
+ TryParseMotionCoordinatePair(fromValue, binding.SourceTarget, out var fromPoint))
+ {
+ points.Add(fromPoint);
+ }
+ else
+ {
+ points.Add(new SKPoint(0f, 0f));
+ }
+
+ if (SvgAnimationParser.TryGetTrimmedString(animation.To, out var toValue) &&
+ TryParseMotionCoordinatePair(toValue, binding.SourceTarget, out var toPoint))
+ {
+ points.Add(toPoint);
+ return points;
+ }
+
+ if (SvgAnimationParser.TryGetTrimmedString(animation.By, out var byValue) &&
+ TryParseMotionCoordinatePair(byValue, binding.SourceTarget, out var byPoint))
+ {
+ var startPoint = points[0];
+ points.Add(new SKPoint(startPoint.X + byPoint.X, startPoint.Y + byPoint.Y));
+ }
+
+ return points;
+ }
+
+ private static bool TryParseMotionCoordinatePair(string value, SvgElement owner, out SKPoint point)
+ {
+ return SvgAnimationParser.TryParseMotionCoordinatePair(value, owner, out point);
+ }
+
+ private static string CreateMotionPathData(IReadOnlyList points)
+ {
+ if (points.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ return string.Join(
+ " ",
+ points.Select((point, index) => index == 0
+ ? $"M{point.X.ToSvgString()} {point.Y.ToSvgString()}"
+ : $"L{point.X.ToSvgString()} {point.Y.ToSvgString()}"));
+ }
+
+ private static float ParseMotionDiscreteProgress(SvgNumberCollection keyPoints, SvgNumberCollection? keyTimes, float progress)
+ {
+ if (keyPoints.Count == 1)
+ {
+ return keyPoints[0];
+ }
+
+ if (keyTimes is { Count: > 1 } && keyTimes.Count == keyPoints.Count)
+ {
+ for (var index = keyTimes.Count - 1; index >= 0; index--)
+ {
+ if (progress >= keyTimes[index])
+ {
+ return keyPoints[index];
+ }
+ }
+
+ return keyPoints[0];
+ }
+
+ var scaled = progress * keyPoints.Count;
+ var discreteIndex = (int)Math.Floor(scaled);
+ if (discreteIndex >= keyPoints.Count)
+ {
+ discreteIndex = keyPoints.Count - 1;
+ }
+
+ return keyPoints[Math.Max(0, discreteIndex)];
+ }
+
+ private static bool TryResolveMotionRotation(string? rotateValue, SKPoint tangent, out float angle)
+ {
+ return SvgAnimationParser.TryResolveMotionRotation(rotateValue, tangent, out angle);
+ }
+
+ private static bool TryCreateTransformString(SvgAnimateTransformType transformType, float[] values, out string transformValue)
+ {
+ transformValue = string.Empty;
+
+ if (values.Length == 0)
+ {
+ return false;
+ }
+
+ SvgTransform transform = transformType switch
+ {
+ SvgAnimateTransformType.Translate => values.Length > 1 ? new SvgTranslate(values[0], values[1]) : new SvgTranslate(values[0]),
+ SvgAnimateTransformType.Scale => values.Length > 1 ? new SvgScale(values[0], values[1]) : new SvgScale(values[0]),
+ SvgAnimateTransformType.Rotate => values.Length > 2 ? new SvgRotate(values[0], values[1], values[2]) : new SvgRotate(values[0]),
+ SvgAnimateTransformType.SkewX => new SvgSkew(values[0], 0f),
+ SvgAnimateTransformType.SkewY => new SvgSkew(0f, values[0]),
+ _ => new SvgTranslate(values[0])
+ };
+
+ transformValue = transform.ToString();
+ return !string.IsNullOrWhiteSpace(transformValue);
+ }
+
+ private static float[] InterpolateTransformNumbers(SvgAnimateTransformType transformType, float[] fromValues, float[] toValues, float progress)
+ {
+ var length = GetExpectedTransformValueCount(transformType, fromValues, toValues);
+ var result = new float[length];
+
+ for (var index = 0; index < length; index++)
+ {
+ var fromValue = index < fromValues.Length ? fromValues[index] : GetDefaultTransformValue(transformType, index, fromValues);
+ var toValue = index < toValues.Length ? toValues[index] : GetDefaultTransformValue(transformType, index, toValues);
+ result[index] = Lerp(fromValue, toValue, progress);
+ }
+
+ return result;
+ }
+
+ private static int GetExpectedTransformValueCount(SvgAnimateTransformType transformType, float[] fromValues, float[] toValues)
+ {
+ return transformType switch
+ {
+ SvgAnimateTransformType.Rotate => Math.Max(1, Math.Max(fromValues.Length, toValues.Length)),
+ SvgAnimateTransformType.Translate => Math.Max(1, Math.Max(fromValues.Length, toValues.Length)),
+ SvgAnimateTransformType.Scale => Math.Max(1, Math.Max(fromValues.Length, toValues.Length)),
+ _ => 1
+ };
+ }
+
+ private static float GetDefaultTransformValue(SvgAnimateTransformType transformType, int index, float[] source)
+ {
+ if (transformType == SvgAnimateTransformType.Scale)
+ {
+ if (source.Length == 1 && index == 1)
+ {
+ return source[0];
+ }
+
+ return 1f;
+ }
+
+ if (transformType == SvgAnimateTransformType.Rotate)
+ {
+ return 0f;
+ }
+
+ if (transformType == SvgAnimateTransformType.Translate)
+ {
+ return 0f;
+ }
+
+ return 0f;
+ }
+
+ private static float[] ParseTransformNumbers(string value)
+ {
+ return SvgAnimationParser.ParseNumberList(value);
+ }
+
+ private static List ResolveAnimationValues(AnimationBinding binding, SvgAnimationValueElement animation)
+ {
+ return binding.GetResolvedAnimationValues(() =>
+ {
+ var values = SvgAnimationParser.SplitSemicolonList(animation.Values);
+ if (values.Count > 0)
+ {
+ return values;
+ }
+
+ var resolved = new List();
+
+ if (SvgAnimationParser.TryGetTrimmedString(animation.From, out var fromValue))
+ {
+ resolved.Add(fromValue);
+ }
+ else if (!string.IsNullOrWhiteSpace(binding.BaseValueString))
+ {
+ resolved.Add(binding.BaseValueString!);
+ }
+
+ if (SvgAnimationParser.TryGetTrimmedString(animation.To, out var toValue))
+ {
+ resolved.Add(toValue);
+ return resolved;
+ }
+
+ if (SvgAnimationParser.TryGetTrimmedString(animation.By, out var byValue))
+ {
+ var additiveBaseValue = resolved.Count > 0 ? resolved[resolved.Count - 1] : binding.BaseValueString;
+ if (additiveBaseValue is { } && TryAddValue(binding, additiveBaseValue, byValue, out var sumValue))
+ {
+ resolved.Add(sumValue);
+ }
+ }
+
+ return resolved;
+ });
+ }
+
+ private static bool UsesImplicitBaseValue(AnimationBinding binding, SvgAnimationValueElement animation)
+ {
+ return string.IsNullOrWhiteSpace(animation.Values) &&
+ string.IsNullOrWhiteSpace(animation.From) &&
+ !string.IsNullOrWhiteSpace(binding.BaseValueString);
+ }
+
+ private static bool TryApplyAccumulation(AnimationBinding binding, SvgAnimationValueElement animation, IReadOnlyList values, AnimationSample sample, bool forceColorInterpolation, ref string value)
+ {
+ if (animation.Accumulate != SvgAnimationAccumulate.Sum || sample.IterationIndex <= 0 || values.Count == 0)
+ {
+ return true;
+ }
+
+ var startValue = values[0];
+ var endValue = values[values.Count - 1];
+
+ if ((forceColorInterpolation || IsPaintServerType(binding.PropertyType) || TryGetColor(value, out _)) &&
+ TryGetColor(startValue, out var startColor) &&
+ TryGetColor(endValue, out var endColor) &&
+ TryGetColor(value, out var currentColor))
+ {
+ value = new SvgColourServer(Color.FromArgb(
+ ClampToByte(currentColor.A + ((endColor.A - startColor.A) * sample.IterationIndex)),
+ ClampToByte(currentColor.R + ((endColor.R - startColor.R) * sample.IterationIndex)),
+ ClampToByte(currentColor.G + ((endColor.G - startColor.G) * sample.IterationIndex)),
+ ClampToByte(currentColor.B + ((endColor.B - startColor.B) * sample.IterationIndex)))).ToString();
+ return true;
+ }
+
+ if (!TrySubtractValue(binding, endValue, startValue, forceColorInterpolation, out var deltaValue) ||
+ !TryScaleValue(binding, deltaValue, sample.IterationIndex, forceColorInterpolation, out var accumulatedDelta) ||
+ !TryAddValue(binding, value, accumulatedDelta, out var accumulatedValue))
+ {
+ return false;
+ }
+
+ value = accumulatedValue;
+ return true;
+ }
+
+ private static bool TryApplyTransformAccumulation(AnimationBinding binding, SvgAnimateTransform animation, IReadOnlyList values, AnimationSample sample, ref float[] currentValues)
+ {
+ if (animation.Accumulate != SvgAnimationAccumulate.Sum || sample.IterationIndex <= 0 || values.Count == 0)
+ {
+ return true;
+ }
+
+ var startValues = ParseTransformNumbers(values[0]);
+ var endValues = ParseTransformNumbers(values[values.Count - 1]);
+ var deltaValues = InterpolateTransformNumbers(animation.TransformType, startValues, endValues, 1f);
+ for (var index = 0; index < deltaValues.Length; index++)
+ {
+ deltaValues[index] -= index < startValues.Length
+ ? startValues[index]
+ : GetDefaultTransformValue(animation.TransformType, index, startValues);
+ }
+
+ var originalLength = currentValues.Length;
+ var length = Math.Max(currentValues.Length, deltaValues.Length);
+ if (currentValues.Length != length)
+ {
+ Array.Resize(ref currentValues, length);
+ }
+
+ for (var index = 0; index < length; index++)
+ {
+ var currentValue = index < originalLength
+ ? currentValues[index]
+ : GetDefaultTransformValue(animation.TransformType, index, currentValues);
+ var deltaValue = index < deltaValues.Length ? deltaValues[index] : 0f;
+ currentValues[index] = currentValue + (deltaValue * sample.IterationIndex);
+ }
+
+ return true;
+ }
+
+ private static string ResolveDiscreteValue(IReadOnlyList values, SvgNumberCollection? keyTimes, float progress)
+ {
+ if (values.Count == 1)
+ {
+ return values[0];
+ }
+
+ if (keyTimes is { Count: > 1 } && keyTimes.Count == values.Count)
+ {
+ for (var index = keyTimes.Count - 1; index >= 0; index--)
+ {
+ if (progress >= keyTimes[index])
+ {
+ return values[index];
+ }
+ }
+
+ return values[0];
+ }
+
+ var scaled = progress * values.Count;
+ var discreteIndex = (int)Math.Floor(scaled);
+ if (discreteIndex >= values.Count)
+ {
+ discreteIndex = values.Count - 1;
+ }
+
+ return values[Math.Max(0, discreteIndex)];
+ }
+
+ private static void ResolveInterpolatedSegment(
+ int valueCount,
+ SvgNumberCollection? keyTimes,
+ string? keySplines,
+ SvgAnimationCalcMode calcMode,
+ float progress,
+ IReadOnlyList? pacedSegmentLengths,
+ out int startIndex,
+ out int endIndex,
+ out float localProgress)
+ {
+ if (valueCount <= 1)
+ {
+ startIndex = 0;
+ endIndex = 0;
+ localProgress = 1f;
+ return;
+ }
+
+ if (calcMode == SvgAnimationCalcMode.Paced &&
+ TryResolvePacedSegment(valueCount, pacedSegmentLengths, progress, out startIndex, out endIndex, out localProgress))
+ {
+ return;
+ }
+
+ if (keyTimes is { Count: > 1 } && keyTimes.Count == valueCount)
+ {
+ for (var index = 0; index < keyTimes.Count - 1; index++)
+ {
+ var rangeStart = keyTimes[index];
+ var rangeEnd = keyTimes[index + 1];
+
+ if (progress <= rangeEnd || index == keyTimes.Count - 2)
+ {
+ startIndex = index;
+ endIndex = index + 1;
+ localProgress = rangeEnd > rangeStart
+ ? Clamp01((progress - rangeStart) / (rangeEnd - rangeStart))
+ : 0f;
+
+ if (calcMode == SvgAnimationCalcMode.Spline)
+ {
+ localProgress = ResolveSplineProgress(keySplines, startIndex, localProgress);
+ }
+
+ return;
+ }
+ }
+ }
+
+ var scaled = Clamp01(progress) * (valueCount - 1);
+ startIndex = (int)Math.Floor(scaled);
+ if (startIndex >= valueCount - 1)
+ {
+ startIndex = valueCount - 2;
+ endIndex = valueCount - 1;
+ localProgress = 1f;
+ return;
+ }
+
+ endIndex = startIndex + 1;
+ localProgress = Clamp01(scaled - startIndex);
+
+ if (calcMode == SvgAnimationCalcMode.Spline)
+ {
+ localProgress = ResolveSplineProgress(keySplines, startIndex, localProgress);
+ }
+ }
+
+ private static IReadOnlyList? ResolvePacedSegmentLengths(AnimationBinding binding, IReadOnlyList values, bool forceColorInterpolation)
+ {
+ if (values.Count <= 1)
+ {
+ return null;
+ }
+
+ var segmentLengths = new float[values.Count - 1];
+ for (var index = 0; index < segmentLengths.Length; index++)
+ {
+ if (!TryResolvePacedDistance(binding, values[index], values[index + 1], forceColorInterpolation, out var distance))
+ {
+ return null;
+ }
+
+ segmentLengths[index] = distance;
+ }
+
+ return segmentLengths;
+ }
+
+ private static IReadOnlyList? ResolveTransformPacedSegmentLengths(SvgAnimateTransformType transformType, IReadOnlyList values)
+ {
+ if (values.Count <= 1)
+ {
+ return null;
+ }
+
+ var segmentLengths = new float[values.Count - 1];
+ for (var index = 0; index < segmentLengths.Length; index++)
+ {
+ segmentLengths[index] = ResolveTransformDistance(
+ transformType,
+ ParseTransformNumbers(values[index]),
+ ParseTransformNumbers(values[index + 1]));
+ }
+
+ return segmentLengths;
+ }
+
+ private static IReadOnlyList? ResolvePointPacedSegmentLengths(IReadOnlyList points)
+ {
+ if (points.Count <= 1)
+ {
+ return null;
+ }
+
+ var segmentLengths = new float[points.Count - 1];
+ for (var index = 0; index < segmentLengths.Length; index++)
+ {
+ segmentLengths[index] = ResolvePointDistance(points[index], points[index + 1]);
+ }
+
+ return segmentLengths;
+ }
+
+ private static IReadOnlyList? ResolveScalarPacedSegmentLengths(SvgNumberCollection values)
+ {
+ if (values.Count <= 1)
+ {
+ return null;
+ }
+
+ var segmentLengths = new float[values.Count - 1];
+ for (var index = 0; index < segmentLengths.Length; index++)
+ {
+ segmentLengths[index] = Math.Abs(values[index + 1] - values[index]);
+ }
+
+ return segmentLengths;
+ }
+
+ private static bool TryResolvePacedSegment(
+ int valueCount,
+ IReadOnlyList? segmentLengths,
+ float progress,
+ out int startIndex,
+ out int endIndex,
+ out float localProgress)
+ {
+ startIndex = 0;
+ endIndex = 0;
+ localProgress = 1f;
+
+ if (valueCount <= 1 || segmentLengths is null || segmentLengths.Count != valueCount - 1)
+ {
+ return false;
+ }
+
+ double totalLength = 0d;
+ for (var index = 0; index < segmentLengths.Count; index++)
+ {
+ totalLength += Math.Max(0f, segmentLengths[index]);
+ }
+
+ if (totalLength <= 0d)
+ {
+ return false;
+ }
+
+ var targetLength = Clamp01(progress) * (float)totalLength;
+ var accumulatedLength = 0f;
+
+ for (var index = 0; index < segmentLengths.Count; index++)
+ {
+ var segmentLength = Math.Max(0f, segmentLengths[index]);
+ var segmentEndLength = accumulatedLength + segmentLength;
+ if (targetLength <= segmentEndLength || index == segmentLengths.Count - 1)
+ {
+ startIndex = index;
+ endIndex = index + 1;
+ localProgress = segmentLength > 0f
+ ? Clamp01((targetLength - accumulatedLength) / segmentLength)
+ : 0f;
+ return true;
+ }
+
+ accumulatedLength = segmentEndLength;
+ }
+
+ return false;
+ }
+
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.TryConvertStringToType(String, Type, out Object)")]
+ private static bool TryResolvePacedDistance(AnimationBinding binding, string fromValue, string toValue, bool forceColorInterpolation, out float distance)
+ {
+ distance = 0f;
+
+ if ((forceColorInterpolation || IsPaintServerType(binding.PropertyType)) &&
+ TryGetColor(fromValue, out var fromPaintColor) &&
+ TryGetColor(toValue, out var toPaintColor))
+ {
+ distance = ResolveColorDistance(fromPaintColor, toPaintColor);
+ return true;
+ }
+
+ if (binding.PropertyType is { } propertyType &&
+ TryConvertStringToType(fromValue, propertyType, out var fromObject) &&
+ TryConvertStringToType(toValue, propertyType, out var toObject) &&
+ TryResolveTypedPacedDistance(fromObject, toObject, out distance))
+ {
+ return true;
+ }
+
+ if (TryResolveNumberListDistance(fromValue, toValue, out distance))
+ {
+ return true;
+ }
+
+ if (TryGetColor(fromValue, out var fromColor) &&
+ TryGetColor(toValue, out var toColor))
+ {
+ distance = ResolveColorDistance(fromColor, toColor);
+ return true;
+ }
+
+ if (SvgAnimationParser.TryParseSvgUnit(fromValue, out var fromUnit) &&
+ SvgAnimationParser.TryParseSvgUnit(toValue, out var toUnit) &&
+ fromUnit.Type == toUnit.Type)
+ {
+ distance = Math.Abs(toUnit.Value - fromUnit.Value);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryResolveTypedPacedDistance(object? fromObject, object? toObject, out float distance)
+ {
+ distance = 0f;
+
+ switch (fromObject)
+ {
+ case float fromFloat when toObject is float toFloat:
+ distance = Math.Abs(toFloat - fromFloat);
+ return true;
+ case double fromDouble when toObject is double toDouble:
+ distance = (float)Math.Abs(toDouble - fromDouble);
+ return true;
+ case int fromInt when toObject is int toInt:
+ distance = Math.Abs(toInt - fromInt);
+ return true;
+ case SvgUnit fromUnit when toObject is SvgUnit toUnit && fromUnit.Type == toUnit.Type:
+ distance = Math.Abs(toUnit.Value - fromUnit.Value);
+ return true;
+ case SvgPaintServer fromPaint when toObject is SvgPaintServer toPaint:
+ if (TryGetColor(fromPaint, out var fromPaintColor) &&
+ TryGetColor(toPaint, out var toPaintColor))
+ {
+ distance = ResolveColorDistance(fromPaintColor, toPaintColor);
+ return true;
+ }
+
+ return false;
+ case SvgColourServer fromColour when toObject is SvgColourServer toColour:
+ distance = ResolveColorDistance(fromColour.Colour, toColour.Colour);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static bool TryResolveNumberListDistance(string fromValue, string toValue, out float distance)
+ {
+ distance = 0f;
+
+ var fromValues = SvgAnimationParser.ParseNumberList(fromValue);
+ var toValues = SvgAnimationParser.ParseNumberList(toValue);
+ if (fromValues.Length == 0 || fromValues.Length != toValues.Length)
+ {
+ return false;
+ }
+
+ distance = ResolveEuclideanDistance(fromValues, toValues);
+ return true;
+ }
+
+ private static float ResolveTransformDistance(SvgAnimateTransformType transformType, float[] fromValues, float[] toValues)
+ {
+ var length = GetExpectedTransformValueCount(transformType, fromValues, toValues);
+ var normalizedFromValues = new float[length];
+ var normalizedToValues = new float[length];
+
+ for (var index = 0; index < length; index++)
+ {
+ normalizedFromValues[index] = index < fromValues.Length
+ ? fromValues[index]
+ : GetDefaultTransformValue(transformType, index, fromValues);
+ normalizedToValues[index] = index < toValues.Length
+ ? toValues[index]
+ : GetDefaultTransformValue(transformType, index, toValues);
+ }
+
+ return ResolveEuclideanDistance(normalizedFromValues, normalizedToValues);
+ }
+
+ private static float ResolveEuclideanDistance(float[] fromValues, float[] toValues)
+ {
+ double sum = 0d;
+
+ for (var index = 0; index < fromValues.Length; index++)
+ {
+ var delta = toValues[index] - fromValues[index];
+ sum += delta * delta;
+ }
+
+ return (float)Math.Sqrt(sum);
+ }
+
+ private static float ResolveColorDistance(Color fromColor, Color toColor)
+ {
+ var deltaA = toColor.A - fromColor.A;
+ var deltaR = toColor.R - fromColor.R;
+ var deltaG = toColor.G - fromColor.G;
+ var deltaB = toColor.B - fromColor.B;
+ return (float)Math.Sqrt(
+ (deltaA * deltaA) +
+ (deltaR * deltaR) +
+ (deltaG * deltaG) +
+ (deltaB * deltaB));
+ }
+
+ private static float ResolvePointDistance(SKPoint fromPoint, SKPoint toPoint)
+ {
+ var deltaX = toPoint.X - fromPoint.X;
+ var deltaY = toPoint.Y - fromPoint.Y;
+ return (float)Math.Sqrt((deltaX * deltaX) + (deltaY * deltaY));
+ }
+
+ private static float ResolveSplineProgress(string? keySplines, int segmentIndex, float progress)
+ {
+ if (progress <= 0f || progress >= 1f)
+ {
+ return Clamp01(progress);
+ }
+
+ return TryGetSplineSegment(keySplines, segmentIndex, out var spline)
+ ? EvaluateSplineProgress(spline, progress)
+ : Clamp01(progress);
+ }
+
+ private static bool TryGetSplineSegment(string? keySplines, int segmentIndex, out CubicBezierSpline spline)
+ {
+ return SvgAnimationParser.TryParseSplineSegment(keySplines, segmentIndex, out spline);
+ }
+
+ private static float EvaluateSplineProgress(CubicBezierSpline spline, float progress)
+ {
+ var targetX = Clamp01(progress);
+ var t = targetX;
+
+ for (var iteration = 0; iteration < 8; iteration++)
+ {
+ var currentX = EvaluateCubicBezierComponent(spline.X1, spline.X2, t) - targetX;
+ if (Math.Abs(currentX) < 1e-5f)
+ {
+ return Clamp01(EvaluateCubicBezierComponent(spline.Y1, spline.Y2, t));
+ }
+
+ var derivative = EvaluateCubicBezierDerivative(spline.X1, spline.X2, t);
+ if (Math.Abs(derivative) < 1e-6f)
+ {
+ break;
+ }
+
+ t -= currentX / derivative;
+ if (t <= 0f || t >= 1f)
+ {
+ break;
+ }
+ }
+
+ var low = 0f;
+ var high = 1f;
+ t = targetX;
+
+ for (var iteration = 0; iteration < 12; iteration++)
+ {
+ var currentX = EvaluateCubicBezierComponent(spline.X1, spline.X2, t);
+ if (Math.Abs(currentX - targetX) < 1e-5f)
+ {
+ break;
+ }
+
+ if (currentX < targetX)
+ {
+ low = t;
+ }
+ else
+ {
+ high = t;
+ }
+
+ t = (low + high) * 0.5f;
+ }
+
+ return Clamp01(EvaluateCubicBezierComponent(spline.Y1, spline.Y2, t));
+ }
+
+ private static float EvaluateCubicBezierComponent(float control1, float control2, float t)
+ {
+ var inverse = 1f - t;
+ return (3f * inverse * inverse * t * control1) +
+ (3f * inverse * t * t * control2) +
+ (t * t * t);
+ }
+
+ private static float EvaluateCubicBezierDerivative(float control1, float control2, float t)
+ {
+ var inverse = 1f - t;
+ return (3f * inverse * inverse * control1) +
+ (6f * inverse * t * (control2 - control1)) +
+ (3f * t * t * (1f - control2));
+ }
+
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.TryConvertStringToType(String, Type, out Object)")]
+ private static bool TryInterpolateValue(AnimationBinding binding, string fromValue, string toValue, float progress, bool forceColorInterpolation, out string result)
+ {
+ result = string.Empty;
+
+ if ((forceColorInterpolation || IsPaintServerType(binding.PropertyType)) &&
+ TryInterpolateColor(fromValue, toValue, progress, out result))
+ {
+ return true;
+ }
+
+ if (binding.PropertyType is { } propertyType &&
+ TryConvertStringToType(fromValue, propertyType, out var fromObject) &&
+ TryConvertStringToType(toValue, propertyType, out var toObject) &&
+ TryInterpolateTypedValue(fromObject, toObject, progress, out result))
+ {
+ return true;
+ }
+
+ if (TryInterpolateColor(fromValue, toValue, progress, out result))
+ {
+ return true;
+ }
+
+ if (TryInterpolateSvgUnit(fromValue, toValue, progress, out result))
+ {
+ return true;
+ }
+
+ if (TryInterpolateNumeric(fromValue, toValue, progress, out result))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryInterpolateTypedValue(object? fromObject, object? toObject, float progress, out string result)
+ {
+ result = string.Empty;
+
+ switch (fromObject)
+ {
+ case float fromFloat when toObject is float toFloat:
+ result = Lerp(fromFloat, toFloat, progress).ToSvgString();
+ return true;
+ case double fromDouble when toObject is double toDouble:
+ result = Lerp((float)fromDouble, (float)toDouble, progress).ToSvgString();
+ return true;
+ case int fromInt when toObject is int toInt:
+ result = Lerp(fromInt, toInt, progress).ToSvgString();
+ return true;
+ case SvgUnit fromUnit when toObject is SvgUnit toUnit:
+ return TryInterpolateSvgUnit(fromUnit, toUnit, progress, out result);
+ case SvgPaintServer fromPaint when toObject is SvgPaintServer toPaint:
+ return TryInterpolatePaint(fromPaint, toPaint, progress, out result);
+ case SvgColourServer fromColour when toObject is SvgColourServer toColour:
+ return TryInterpolateColor(fromColour.Colour, toColour.Colour, progress, out result);
+ default:
+ return false;
+ }
+ }
+
+ private static bool TryInterpolateNumeric(string fromValue, string toValue, float progress, out string result)
+ {
+ result = string.Empty;
+
+ if (!SvgAnimationParser.TryParseInvariantFloat(fromValue, out var fromNumber) ||
+ !SvgAnimationParser.TryParseInvariantFloat(toValue, out var toNumber))
+ {
+ return false;
+ }
+
+ result = Lerp(fromNumber, toNumber, progress).ToSvgString();
+ return true;
+ }
+
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.TryConvertStringToType(String, Type, out Object)")]
+ private static bool TryInterpolateSvgUnit(string fromValue, string toValue, float progress, out string result)
+ {
+ result = string.Empty;
+
+ if (!TryConvertStringToType(fromValue, typeof(SvgUnit), out var fromObject) ||
+ !TryConvertStringToType(toValue, typeof(SvgUnit), out var toObject) ||
+ fromObject is not SvgUnit fromUnit ||
+ toObject is not SvgUnit toUnit)
+ {
+ return false;
+ }
+
+ return TryInterpolateSvgUnit(fromUnit, toUnit, progress, out result);
+ }
+
+ private static bool TryInterpolateSvgUnit(SvgUnit fromUnit, SvgUnit toUnit, float progress, out string result)
+ {
+ result = string.Empty;
+
+ if (fromUnit.Type != toUnit.Type)
+ {
+ return false;
+ }
+
+ var unit = new SvgUnit(fromUnit.Type, Lerp(fromUnit.Value, toUnit.Value, progress));
+ result = unit.ToString();
+ return true;
+ }
+
+ private static bool TryInterpolatePaint(SvgPaintServer fromPaint, SvgPaintServer toPaint, float progress, out string result)
+ {
+ result = string.Empty;
+ return TryGetColor(fromPaint, out var fromColor) &&
+ TryGetColor(toPaint, out var toColor) &&
+ TryInterpolateColor(fromColor, toColor, progress, out result);
+ }
+
+ private static bool TryInterpolateColor(string fromValue, string toValue, float progress, out string result)
+ {
+ result = string.Empty;
+
+ if (!TryGetColor(fromValue, out var fromColor) || !TryGetColor(toValue, out var toColor))
+ {
+ return false;
+ }
+
+ return TryInterpolateColor(fromColor, toColor, progress, out result);
+ }
+
+ private static bool TryInterpolateColor(Color fromColor, Color toColor, float progress, out string result)
+ {
+ var color = Color.FromArgb(
+ ClampToByte(Lerp(fromColor.A, toColor.A, progress)),
+ ClampToByte(Lerp(fromColor.R, toColor.R, progress)),
+ ClampToByte(Lerp(fromColor.G, toColor.G, progress)),
+ ClampToByte(Lerp(fromColor.B, toColor.B, progress)));
+
+ result = new SvgColourServer(color).ToString();
+ return true;
+ }
+
+ private static byte ClampToByte(float value)
+ {
+ if (value <= byte.MinValue)
+ {
+ return byte.MinValue;
+ }
+
+ if (value >= byte.MaxValue)
+ {
+ return byte.MaxValue;
+ }
+
+ return (byte)Math.Round(value, MidpointRounding.AwayFromZero);
+ }
+
+ private static bool TryGetColor(string value, out Color color)
+ {
+ color = default;
+
+ if (!SvgAnimationParser.TryGetTrimmedString(value, out var trimmed))
+ {
+ return false;
+ }
+
+ try
+ {
+ var paint = s_paintServerConverter.ConvertFrom(null, CultureInfo.InvariantCulture, trimmed) as SvgPaintServer;
+ return TryGetColor(paint, out color);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static bool TryGetColor(SvgPaintServer? paintServer, out Color color)
+ {
+ if (paintServer is SvgColourServer colourServer)
+ {
+ color = colourServer.Colour;
+ return true;
+ }
+
+ color = default;
+ return false;
+ }
+
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.TryConvertStringToType(String, Type, out Object)")]
+ private static bool TryAddValue(AnimationBinding binding, string baseValue, string byValue, out string result)
+ {
+ result = string.Empty;
+
+ if (binding.PropertyType is { } propertyType &&
+ TryConvertStringToType(baseValue, propertyType, out var baseObject) &&
+ TryConvertStringToType(byValue, propertyType, out var byObject) &&
+ TryAddTypedValue(baseObject, byObject, out result))
+ {
+ return true;
+ }
+
+ if (TryConvertStringToType(baseValue, typeof(SvgUnit), out var baseUnitObject) &&
+ TryConvertStringToType(byValue, typeof(SvgUnit), out var byUnitObject) &&
+ baseUnitObject is SvgUnit baseUnit &&
+ byUnitObject is SvgUnit byUnit &&
+ baseUnit.Type == byUnit.Type)
+ {
+ result = new SvgUnit(baseUnit.Type, baseUnit.Value + byUnit.Value).ToString();
+ return true;
+ }
+
+ if (SvgAnimationParser.TryParseInvariantFloat(baseValue, out var baseNumber) &&
+ SvgAnimationParser.TryParseInvariantFloat(byValue, out var byNumber))
+ {
+ result = (baseNumber + byNumber).ToSvgString();
+ return true;
+ }
+
+ if (TryGetColor(baseValue, out var baseColor) &&
+ TryGetColor(byValue, out var byColor))
+ {
+ result = new SvgColourServer(Color.FromArgb(
+ ClampToByte(baseColor.A + byColor.A),
+ ClampToByte(baseColor.R + byColor.R),
+ ClampToByte(baseColor.G + byColor.G),
+ ClampToByte(baseColor.B + byColor.B))).ToString();
+ return true;
+ }
+
+ return false;
+ }
+
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.TryConvertStringToType(String, Type, out Object)")]
+ private static bool TrySubtractValue(AnimationBinding binding, string endValue, string startValue, bool forceColorInterpolation, out string result)
+ {
+ result = string.Empty;
+
+ if (binding.PropertyType is { } propertyType &&
+ TryConvertStringToType(endValue, propertyType, out var endObject) &&
+ TryConvertStringToType(startValue, propertyType, out var startObject) &&
+ TrySubtractTypedValue(endObject, startObject, out result))
+ {
+ return true;
+ }
+
+ if (TryConvertStringToType(endValue, typeof(SvgUnit), out var endUnitObject) &&
+ TryConvertStringToType(startValue, typeof(SvgUnit), out var startUnitObject) &&
+ endUnitObject is SvgUnit endUnit &&
+ startUnitObject is SvgUnit startUnit &&
+ endUnit.Type == startUnit.Type)
+ {
+ result = new SvgUnit(endUnit.Type, endUnit.Value - startUnit.Value).ToString();
+ return true;
+ }
+
+ if (SvgAnimationParser.TryParseInvariantFloat(endValue, out var endNumber) &&
+ SvgAnimationParser.TryParseInvariantFloat(startValue, out var startNumber))
+ {
+ result = (endNumber - startNumber).ToSvgString();
+ return true;
+ }
+
+ return false;
+ }
+
+ [RequiresUnreferencedCode("Calls Svg.Skia.SvgAnimationController.TryConvertStringToType(String, Type, out Object)")]
+ private static bool TryScaleValue(AnimationBinding binding, string value, int factor, bool forceColorInterpolation, out string result)
+ {
+ result = string.Empty;
+
+ if (factor == 0)
+ {
+ result = "0";
+ return true;
+ }
+
+ if (binding.PropertyType is { } propertyType &&
+ TryConvertStringToType(value, propertyType, out var valueObject) &&
+ TryScaleTypedValue(valueObject, factor, out result))
+ {
+ return true;
+ }
+
+ if (TryConvertStringToType(value, typeof(SvgUnit), out var unitObject) &&
+ unitObject is SvgUnit unit)
+ {
+ result = new SvgUnit(unit.Type, unit.Value * factor).ToString();
+ return true;
+ }
+
+ if (SvgAnimationParser.TryParseInvariantFloat(value, out var numeric))
+ {
+ result = (numeric * factor).ToSvgString();
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryAddTypedValue(object? baseObject, object? byObject, out string result)
+ {
+ result = string.Empty;
+
+ switch (baseObject)
+ {
+ case float baseFloat when byObject is float byFloat:
+ result = (baseFloat + byFloat).ToSvgString();
+ return true;
+ case double baseDouble when byObject is double byDouble:
+ result = ((float)(baseDouble + byDouble)).ToSvgString();
+ return true;
+ case int baseInt when byObject is int byInt:
+ result = ((float)(baseInt + byInt)).ToSvgString();
+ return true;
+ case SvgUnit baseUnit when byObject is SvgUnit byUnit && baseUnit.Type == byUnit.Type:
+ result = new SvgUnit(baseUnit.Type, baseUnit.Value + byUnit.Value).ToString();
+ return true;
+ case SvgPaintServer basePaint when byObject is SvgPaintServer byPaint:
+ if (TryGetColor(basePaint, out var baseColor) && TryGetColor(byPaint, out var byColor))
+ {
+ result = new SvgColourServer(Color.FromArgb(
+ ClampToByte(baseColor.A + byColor.A),
+ ClampToByte(baseColor.R + byColor.R),
+ ClampToByte(baseColor.G + byColor.G),
+ ClampToByte(baseColor.B + byColor.B))).ToString();
+ return true;
+ }
+
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ private static bool TrySubtractTypedValue(object? endObject, object? startObject, out string result)
+ {
+ result = string.Empty;
+
+ switch (endObject)
+ {
+ case float endFloat when startObject is float startFloat:
+ result = (endFloat - startFloat).ToSvgString();
+ return true;
+ case double endDouble when startObject is double startDouble:
+ result = ((float)(endDouble - startDouble)).ToSvgString();
+ return true;
+ case int endInt when startObject is int startInt:
+ result = ((float)(endInt - startInt)).ToSvgString();
+ return true;
+ case SvgUnit endUnit when startObject is SvgUnit startUnit && endUnit.Type == startUnit.Type:
+ result = new SvgUnit(endUnit.Type, endUnit.Value - startUnit.Value).ToString();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static bool TryScaleTypedValue(object? valueObject, int factor, out string result)
+ {
+ result = string.Empty;
+
+ switch (valueObject)
+ {
+ case float floatValue:
+ result = (floatValue * factor).ToSvgString();
+ return true;
+ case double doubleValue:
+ result = ((float)doubleValue * factor).ToSvgString();
+ return true;
+ case int intValue:
+ result = ((float)intValue * factor).ToSvgString();
+ return true;
+ case SvgUnit unitValue:
+ result = new SvgUnit(unitValue.Type, unitValue.Value * factor).ToString();
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ [RequiresUnreferencedCode("Calls System.ComponentModel.TypeDescriptor.GetConverter(Type)")]
+ private static bool TryConvertStringToType(string value, Type targetType, out object? result)
+ {
+ result = null;
+
+ if (targetType == typeof(string))
+ {
+ result = value;
+ return true;
+ }
+
+ try
+ {
+ if (targetType == typeof(SvgUnit))
+ {
+ if (SvgAnimationParser.TryParseSvgUnit(value, out var unit))
+ {
+ result = unit;
+ return true;
+ }
+
+ return false;
+ }
+
+ var converter = TypeDescriptor.GetConverter(targetType);
+ if (converter.CanConvertFrom(typeof(string)))
+ {
+ result = converter.ConvertFrom(null, CultureInfo.InvariantCulture, value);
+ return result is not null;
+ }
+ }
+ catch
+ {
+ return false;
+ }
+
+ return false;
+ }
+
+ private static string CombineTransformValue(string? baseValue, string transformValue)
+ {
+ if (!SvgAnimationParser.TryGetTrimmedString(transformValue, out var trimmedTransformValue))
+ {
+ return string.Empty;
+ }
+
+ if (!SvgAnimationParser.TryGetTrimmedString(baseValue, out var trimmedBaseValue))
+ {
+ return trimmedTransformValue;
+ }
+
+ return string.Concat(trimmedBaseValue, " ", trimmedTransformValue);
+ }
+
+ private static string? ResolveCurrentComposedBaseValue(string? currentComposedValue, string? fallbackBaseValue)
+ {
+ if (!string.IsNullOrWhiteSpace(currentComposedValue))
+ {
+ return currentComposedValue;
+ }
+
+ return fallbackBaseValue;
+ }
+
+ private static bool TryResolveAdditiveBaseValue(
+ AnimationBinding binding,
+ SvgAnimationValueElement animation,
+ string? currentComposedValue,
+ out string baseValue)
+ {
+ baseValue = ResolveCurrentComposedBaseValue(currentComposedValue, binding.BaseValueString) ?? string.Empty;
+ if (!string.IsNullOrWhiteSpace(baseValue))
+ {
+ return true;
+ }
+
+ if (UsesImplicitBaseValue(binding, animation))
+ {
+ baseValue = string.Empty;
+ return false;
+ }
+
+ return false;
+ }
+
+ private static string CreateEventInstanceKey(SvgElementAddress address, SvgPointerEventType eventType)
+ {
+ return string.Concat(address.Key, "|", ((int)eventType).ToString(CultureInfo.InvariantCulture));
+ }
+
+ private static object? GetAttributeValue(SvgElement element, string attributeName)
+ {
+ return element.GetAnimationValue(attributeName);
+ }
+
+ private static bool SetAttributeValue(SvgElement element, string attributeName, string value)
+ {
+ return element.TrySetAnimationValue(attributeName, value);
+ }
+
+ private static bool ClearAttributeValue(SvgElement element, string attributeName)
+ {
+ return element.ClearAnimationValue(attributeName);
+ }
+
+ private static string? ConvertAttributeValueToString(object? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ return value switch
+ {
+ string stringValue => stringValue,
+ _ => value.ToString()
+ };
+ }
+
+ private static bool IsPaintServerType(Type? type)
+ {
+ return type is not null && typeof(SvgPaintServer).IsAssignableFrom(type);
+ }
+
+ private static float Lerp(float from, float to, float progress)
+ {
+ return from + ((to - from) * Clamp01(progress));
+ }
+
+ private static TimeSpan Multiply(TimeSpan duration, double factor)
+ {
+ if (factor <= 0d || duration <= TimeSpan.Zero)
+ {
+ return TimeSpan.Zero;
+ }
+
+ var ticks = duration.Ticks * factor;
+ if (ticks >= TimeSpan.MaxValue.Ticks)
+ {
+ return TimeSpan.MaxValue;
+ }
+
+ return TimeSpan.FromTicks((long)Math.Round(ticks, MidpointRounding.AwayFromZero));
+ }
+
+ private static TimeSpan? MinDuration(TimeSpan? left, TimeSpan right)
+ {
+ if (!left.HasValue)
+ {
+ return right;
+ }
+
+ return left.Value <= right ? left : right;
+ }
+
+ private static TimeSpan? MaxDuration(TimeSpan? left, TimeSpan right)
+ {
+ if (!left.HasValue)
+ {
+ return left;
+ }
+
+ return left.Value >= right ? left : right;
+ }
+
+ private static int ClampIterationIndex(long iterationIndex)
+ {
+ if (iterationIndex <= 0)
+ {
+ return 0;
+ }
+
+ return iterationIndex >= int.MaxValue
+ ? int.MaxValue
+ : (int)iterationIndex;
+ }
+
+ private static float Clamp01(float value)
+ {
+ if (value < 0f)
+ {
+ return 0f;
+ }
+
+ if (value > 1f)
+ {
+ return 1f;
+ }
+
+ return value;
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(SvgAnimationController));
+ }
+ }
+}
diff --git a/src/Svg.Animation/Animation/SvgAnimationFrameState.cs b/src/Svg.Animation/Animation/SvgAnimationFrameState.cs
new file mode 100644
index 0000000000..2a24199ade
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgAnimationFrameState.cs
@@ -0,0 +1,162 @@
+using System;
+using System.Collections.Generic;
+
+namespace Svg.Skia;
+
+internal sealed class SvgAnimationFrameAttributeState
+{
+ public SvgAnimationFrameAttributeState(string key, SvgElementAddress targetAddress, string attributeName, string value)
+ {
+ Key = key;
+ TargetAddress = targetAddress;
+ AttributeName = attributeName;
+ Value = value;
+ }
+
+ public string Key { get; }
+
+ public SvgElementAddress TargetAddress { get; }
+
+ public string AttributeName { get; }
+
+ public string Value { get; }
+
+ public bool HasSameValue(SvgAnimationFrameAttributeState other)
+ {
+ return string.Equals(AttributeName, other.AttributeName, StringComparison.Ordinal) &&
+ string.Equals(Value, other.Value, StringComparison.Ordinal) &&
+ string.Equals(TargetAddress.Key, other.TargetAddress.Key, StringComparison.Ordinal);
+ }
+}
+
+internal sealed class SvgAnimationFrameState
+{
+ private readonly Dictionary _attributes;
+
+ public SvgAnimationFrameState(TimeSpan time, int version, Dictionary attributes)
+ {
+ Time = time;
+ Version = version;
+ _attributes = attributes ?? throw new ArgumentNullException(nameof(attributes));
+ }
+
+ public TimeSpan Time { get; }
+
+ public int Version { get; }
+
+ public int Count => _attributes.Count;
+
+ public IEnumerable Attributes => _attributes.Values;
+
+ public bool TryGetAttribute(string key, out SvgAnimationFrameAttributeState attribute)
+ {
+ return _attributes.TryGetValue(key, out attribute!);
+ }
+
+ public bool IsEquivalentTo(SvgAnimationFrameState? other)
+ {
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (other is null || _attributes.Count != other._attributes.Count)
+ {
+ return false;
+ }
+
+ foreach (var pair in _attributes)
+ {
+ if (!other._attributes.TryGetValue(pair.Key, out var otherAttribute) ||
+ !pair.Value.HasSameValue(otherAttribute))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public int GetDirtyTargetCount(SvgAnimationFrameState? previous)
+ {
+ if (previous is null)
+ {
+ return _attributes.Count;
+ }
+
+ var dirtyCount = 0;
+
+ foreach (var pair in _attributes)
+ {
+ if (!previous._attributes.TryGetValue(pair.Key, out var previousAttribute) ||
+ !pair.Value.HasSameValue(previousAttribute))
+ {
+ dirtyCount++;
+ }
+ }
+
+ foreach (var pair in previous._attributes)
+ {
+ if (!_attributes.ContainsKey(pair.Key))
+ {
+ dirtyCount++;
+ }
+ }
+
+ return dirtyCount;
+ }
+
+ public IEnumerable EnumerateDirtyAttributes(SvgAnimationFrameState? previous)
+ {
+ if (previous is null)
+ {
+ foreach (var attribute in _attributes.Values)
+ {
+ yield return attribute;
+ }
+
+ yield break;
+ }
+
+ foreach (var pair in _attributes)
+ {
+ if (!previous._attributes.TryGetValue(pair.Key, out var previousAttribute) ||
+ !pair.Value.HasSameValue(previousAttribute))
+ {
+ yield return pair.Value;
+ }
+ }
+ }
+
+ public IEnumerable EnumerateRemovedKeys(SvgAnimationFrameState? previous)
+ {
+ if (previous is null)
+ {
+ yield break;
+ }
+
+ foreach (var pair in previous._attributes)
+ {
+ if (!_attributes.ContainsKey(pair.Key))
+ {
+ yield return pair.Key;
+ }
+ }
+ }
+
+ public IEnumerable EnumerateRemovedAttributes(SvgAnimationFrameState? previous)
+ {
+ if (previous is null)
+ {
+ yield break;
+ }
+
+ foreach (var pair in previous._attributes)
+ {
+ if (!_attributes.ContainsKey(pair.Key))
+ {
+ yield return pair.Value;
+ }
+ }
+ }
+}
diff --git a/src/Svg.Animation/Animation/SvgAnimationHostBackend.cs b/src/Svg.Animation/Animation/SvgAnimationHostBackend.cs
new file mode 100644
index 0000000000..f879eb09c6
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgAnimationHostBackend.cs
@@ -0,0 +1,205 @@
+using System;
+
+namespace Svg.Skia;
+
+public enum SvgAnimationHostBackend
+{
+ Default,
+ Manual,
+ DispatcherTimer,
+ RenderLoop,
+ NativeComposition
+}
+
+public sealed class SvgAnimationHostBackendCapabilities
+{
+ public SvgAnimationHostBackendCapabilities(
+ bool isHostReady,
+ bool supportsDispatcherTimer,
+ bool supportsRenderLoop,
+ bool supportsNativeComposition)
+ {
+ IsHostReady = isHostReady;
+ SupportsDispatcherTimer = supportsDispatcherTimer;
+ SupportsRenderLoop = supportsRenderLoop;
+ SupportsNativeComposition = supportsNativeComposition;
+ }
+
+ public bool IsHostReady { get; }
+
+ public bool SupportsDispatcherTimer { get; }
+
+ public bool SupportsRenderLoop { get; }
+
+ public bool SupportsNativeComposition { get; }
+}
+
+public sealed class SvgAnimationHostBackendResolution
+{
+ public SvgAnimationHostBackendResolution(
+ SvgAnimationHostBackend requestedBackend,
+ SvgAnimationHostBackend actualBackend,
+ string? fallbackReason)
+ {
+ RequestedBackend = requestedBackend;
+ ActualBackend = actualBackend;
+ FallbackReason = fallbackReason;
+ }
+
+ public SvgAnimationHostBackend RequestedBackend { get; }
+
+ public SvgAnimationHostBackend ActualBackend { get; }
+
+ public string? FallbackReason { get; }
+
+ public bool IsFallback =>
+ !string.IsNullOrWhiteSpace(FallbackReason) &&
+ RequestedBackend != ActualBackend;
+}
+
+public static class SvgAnimationHostBackendResolver
+{
+ public static SvgAnimationHostBackendResolution Resolve(
+ SvgAnimationHostBackend requestedBackend,
+ SvgAnimationHostBackendCapabilities capabilities,
+ bool hasAnimations)
+ {
+ if (requestedBackend == SvgAnimationHostBackend.Manual)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ null);
+ }
+
+ if (!hasAnimations)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ "SVG source does not contain animation elements.");
+ }
+
+ if (!capabilities.IsHostReady)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ "Animation playback requires an attached UI host.");
+ }
+
+ switch (requestedBackend)
+ {
+ case SvgAnimationHostBackend.DispatcherTimer:
+ {
+ return capabilities.SupportsDispatcherTimer
+ ? new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.DispatcherTimer,
+ null)
+ : new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ "Dispatcher timer animation playback is unavailable.");
+ }
+ case SvgAnimationHostBackend.NativeComposition:
+ {
+ if (capabilities.SupportsNativeComposition && SupportsAutomaticTicks(capabilities))
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.NativeComposition,
+ null);
+ }
+
+ return ResolveNativeCompositionFallback(requestedBackend, capabilities);
+ }
+ case SvgAnimationHostBackend.RenderLoop:
+ {
+ if (capabilities.SupportsRenderLoop)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.RenderLoop,
+ null);
+ }
+
+ if (capabilities.SupportsDispatcherTimer)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.DispatcherTimer,
+ "Render-loop animation playback is unavailable; falling back to dispatcher timer.");
+ }
+
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ "Render-loop animation playback is unavailable.");
+ }
+ case SvgAnimationHostBackend.Default:
+ default:
+ {
+ if (capabilities.SupportsNativeComposition && SupportsAutomaticTicks(capabilities))
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.NativeComposition,
+ null);
+ }
+
+ if (capabilities.SupportsRenderLoop)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.RenderLoop,
+ null);
+ }
+
+ if (capabilities.SupportsDispatcherTimer)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.DispatcherTimer,
+ null);
+ }
+
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ "Automatic animation playback backends are unavailable.");
+ }
+ }
+ }
+
+ private static bool SupportsAutomaticTicks(SvgAnimationHostBackendCapabilities capabilities)
+ {
+ return capabilities.SupportsRenderLoop || capabilities.SupportsDispatcherTimer;
+ }
+
+ private static SvgAnimationHostBackendResolution ResolveNativeCompositionFallback(
+ SvgAnimationHostBackend requestedBackend,
+ SvgAnimationHostBackendCapabilities capabilities)
+ {
+ if (capabilities.SupportsRenderLoop)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.RenderLoop,
+ "Native composition animation playback is unavailable; falling back to render loop.");
+ }
+
+ if (capabilities.SupportsDispatcherTimer)
+ {
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.DispatcherTimer,
+ "Native composition animation playback is unavailable; falling back to dispatcher timer.");
+ }
+
+ return new SvgAnimationHostBackendResolution(
+ requestedBackend,
+ SvgAnimationHostBackend.Manual,
+ "Native composition animation playback is unavailable.");
+ }
+}
diff --git a/src/Svg.Animation/Animation/SvgAnimationParser.cs b/src/Svg.Animation/Animation/SvgAnimationParser.cs
new file mode 100644
index 0000000000..20a54f81ad
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgAnimationParser.cs
@@ -0,0 +1,829 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using SkiaSharp;
+
+namespace Svg.Skia;
+
+internal readonly struct SvgAnimationEventTimingParseResult
+{
+ public SvgAnimationEventTimingParseResult(SvgElementAddress eventAddress, SvgPointerEventType eventType, TimeSpan offset)
+ {
+ EventAddress = eventAddress;
+ EventType = eventType;
+ Offset = offset;
+ }
+
+ public SvgElementAddress EventAddress { get; }
+
+ public SvgPointerEventType EventType { get; }
+
+ public TimeSpan Offset { get; }
+}
+
+internal static class SvgAnimationParser
+{
+ private static readonly char[] s_semicolonSeparators = { ';' };
+ private static readonly char[] s_coordinateSeparators = { ',', ' ', '\t', '\r', '\n' };
+ private static readonly CultureInfo s_invariantCulture = CultureInfo.InvariantCulture;
+
+ internal static bool TryGetTrimmedString(string? value, out string trimmed)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ trimmed = string.Empty;
+ return false;
+ }
+
+ var span = Trim(value.AsSpan());
+ if (span.Length == 0)
+ {
+ trimmed = string.Empty;
+ return false;
+ }
+
+ trimmed = span.Length == value!.Length
+ ? value
+ : span.ToString();
+ return true;
+ }
+
+ internal static List SplitSemicolonList(string? value)
+ {
+ var results = new List();
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return results;
+ }
+
+ var remaining = value.AsSpan();
+ while (TryReadSeparatedToken(ref remaining, s_semicolonSeparators, out var token))
+ {
+ var trimmed = Trim(token);
+ if (trimmed.Length > 0)
+ {
+ results.Add(trimmed.ToString());
+ }
+ }
+
+ return results;
+ }
+
+ internal static bool TryGetSemicolonSegment(string? value, int segmentIndex, out string segment)
+ {
+ segment = string.Empty;
+ if (segmentIndex < 0 || string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var remaining = value.AsSpan();
+ var currentIndex = 0;
+ while (TryReadSeparatedToken(ref remaining, s_semicolonSeparators, out var token))
+ {
+ var trimmed = Trim(token);
+ if (trimmed.Length == 0)
+ {
+ continue;
+ }
+
+ if (currentIndex == segmentIndex)
+ {
+ segment = trimmed.ToString();
+ return true;
+ }
+
+ currentIndex++;
+ }
+
+ return false;
+ }
+
+ internal static bool TryGetFirstSemicolonSegment(string? value, out string segment)
+ {
+ return TryGetSemicolonSegment(value, 0, out segment);
+ }
+
+ internal static bool EqualsKeywordIgnoreCase(ReadOnlySpan value, string keyword)
+ {
+ return EqualsAsciiIgnoreCase(Trim(value), keyword);
+ }
+
+ internal static bool TryParseClockValue(string? value, out TimeSpan result)
+ {
+ result = default;
+ return !string.IsNullOrWhiteSpace(value) && TryParseClockValue(value.AsSpan(), out result);
+ }
+
+ internal static bool TryParseClockValue(ReadOnlySpan value, out TimeSpan result)
+ {
+ result = default;
+
+ var part = Trim(value);
+ if (part.Length == 0 || EqualsAsciiIgnoreCase(part, "indefinite"))
+ {
+ return false;
+ }
+
+ var sign = 1;
+ if (part[0] is '+' or '-')
+ {
+ sign = part[0] == '-' ? -1 : 1;
+ part = Trim(part.Slice(1));
+ if (part.Length == 0 || EqualsAsciiIgnoreCase(part, "indefinite"))
+ {
+ return false;
+ }
+ }
+
+ if (TryParseColonClockValue(part, out result))
+ {
+ if (sign < 0)
+ {
+ result = -result;
+ }
+
+ return true;
+ }
+
+ double scalar;
+ if (TryParseClockValueWithSuffix(part, "ms", out scalar))
+ {
+ return TryCreateTimeSpan(scalar, sign, ClockTimeUnit.Milliseconds, out result);
+ }
+ else if (TryParseClockValueWithSuffix(part, "min", out scalar))
+ {
+ return TryCreateTimeSpan(scalar, sign, ClockTimeUnit.Minutes, out result);
+ }
+ else if (TryParseClockValueWithSuffix(part, "h", out scalar))
+ {
+ return TryCreateTimeSpan(scalar, sign, ClockTimeUnit.Hours, out result);
+ }
+ else if (TryParseClockValueWithSuffix(part, "s", out scalar))
+ {
+ return TryCreateTimeSpan(scalar, sign, ClockTimeUnit.Seconds, out result);
+ }
+ else if (TryParseInvariantDouble(part, out scalar))
+ {
+ return TryCreateTimeSpan(scalar, sign, ClockTimeUnit.Seconds, out result);
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ internal static bool TryParseEventTimingSpec(
+ string value,
+ SvgDocument? document,
+ SvgElementAddress defaultEventAddress,
+ out SvgAnimationEventTimingParseResult result)
+ {
+ result = default;
+
+ if (!TryGetTrimmedString(value, out var trimmedValue))
+ {
+ return false;
+ }
+
+ var span = trimmedValue.AsSpan();
+ var signIndex = FindEventTimingSignIndex(span);
+
+ var eventSegment = signIndex >= 0
+ ? Trim(span.Slice(0, signIndex))
+ : span;
+ if (eventSegment.Length == 0)
+ {
+ return false;
+ }
+
+ var eventAddress = defaultEventAddress;
+ var eventName = eventSegment;
+ var dotIndex = eventSegment.LastIndexOf('.');
+ if (dotIndex >= 0)
+ {
+ var eventId = Trim(eventSegment.Slice(0, dotIndex));
+ eventName = Trim(eventSegment.Slice(dotIndex + 1));
+ if (eventId.Length == 0 || eventName.Length == 0)
+ {
+ return false;
+ }
+
+ var eventElement = document?.GetElementById(eventId.ToString());
+ if (eventElement is null)
+ {
+ return false;
+ }
+
+ eventAddress = SvgElementAddress.Create(eventElement);
+ }
+
+ if (!TryMapEventName(eventName, out var eventType))
+ {
+ return false;
+ }
+
+ var offset = TimeSpan.Zero;
+ if (signIndex >= 0)
+ {
+ var sign = span[signIndex];
+ var offsetText = Trim(span.Slice(signIndex + 1));
+ if (offsetText.Length == 0 || !TryParseClockValue(offsetText, out offset))
+ {
+ return false;
+ }
+
+ if (sign == '-')
+ {
+ offset = -offset;
+ }
+ }
+
+ result = new SvgAnimationEventTimingParseResult(eventAddress, eventType, offset);
+ return true;
+ }
+
+ internal static bool TryParseMotionCoordinatePair(string value, SvgElement owner, out SKPoint point)
+ {
+ point = default;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return false;
+ }
+
+ var remaining = value.AsSpan();
+ if (!TryReadSeparatedToken(ref remaining, s_coordinateSeparators, out var xToken) ||
+ !TryReadSeparatedToken(ref remaining, s_coordinateSeparators, out var yToken))
+ {
+ return false;
+ }
+
+ if (TryReadSeparatedToken(ref remaining, s_coordinateSeparators, out _))
+ {
+ return false;
+ }
+
+ if (!TryParseMotionCoordinate(xToken, UnitRenderingType.Horizontal, owner, out var x) ||
+ !TryParseMotionCoordinate(yToken, UnitRenderingType.Vertical, owner, out var y))
+ {
+ return false;
+ }
+
+ point = new SKPoint(x, y);
+ return true;
+ }
+
+ internal static bool TryParseMotionCoordinate(ReadOnlySpan value, UnitRenderingType renderingType, SvgElement owner, out float coordinate)
+ {
+ coordinate = default;
+
+ var trimmed = Trim(value);
+ if (trimmed.Length == 0)
+ {
+ return false;
+ }
+
+ try
+ {
+ var unit = SvgUnitConverter.Parse(trimmed);
+ coordinate = ToMotionCoordinate(unit, renderingType, owner);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ internal static bool TryResolveMotionRotation(string? rotateValue, SKPoint tangent, out float angle)
+ {
+ angle = 0f;
+
+ if (!TryGetTrimmedString(rotateValue, out var trimmed))
+ {
+ return false;
+ }
+
+ var trimmedSpan = trimmed.AsSpan();
+ if (EqualsAsciiIgnoreCase(trimmedSpan, "auto") ||
+ EqualsAsciiIgnoreCase(trimmedSpan, "auto-reverse"))
+ {
+ angle = (float)(Math.Atan2(tangent.Y, tangent.X) * 180d / Math.PI);
+ if (EqualsAsciiIgnoreCase(trimmedSpan, "auto-reverse"))
+ {
+ angle += 180f;
+ }
+
+ return true;
+ }
+
+ return TryParseInvariantFloat(trimmedSpan, out angle) && angle != 0f;
+ }
+
+ internal static bool TryParseSvgUnit(string value, out SvgUnit unit)
+ {
+ unit = default;
+ if (!TryGetTrimmedString(value, out var trimmed))
+ {
+ return false;
+ }
+
+ try
+ {
+ unit = SvgUnitConverter.Parse(trimmed.AsSpan());
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ internal static float[] ParseNumberList(string value)
+ {
+ if (!TryGetTrimmedString(value, out var trimmed))
+ {
+ return Array.Empty();
+ }
+
+ try
+ {
+ var numbers = SvgNumberCollectionConverter.Parse(trimmed.AsSpan());
+ return numbers.ToArray();
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+
+ internal static bool TryParseNumberListSegment(string? value, int segmentIndex, out float[] values)
+ {
+ values = Array.Empty();
+ if (!TryGetSemicolonSegment(value, segmentIndex, out var segment))
+ {
+ return false;
+ }
+
+ try
+ {
+ values = SvgNumberCollectionConverter.Parse(segment.AsSpan()).ToArray();
+ return values.Length > 0;
+ }
+ catch
+ {
+ values = Array.Empty();
+ return false;
+ }
+ }
+
+ internal static bool TryParseSplineSegment(string? keySplines, int segmentIndex, out CubicBezierSpline spline)
+ {
+ spline = default;
+
+ if (!TryParseNumberListSegment(keySplines, segmentIndex, out var values) || values.Length != 4)
+ {
+ return false;
+ }
+
+ spline = new CubicBezierSpline(values[0], values[1], values[2], values[3]);
+ return true;
+ }
+
+ internal static bool TryParseInvariantFloat(string value, out float result)
+ {
+ result = default;
+ return !string.IsNullOrWhiteSpace(value) && TryParseInvariantFloat(value.AsSpan(), out result);
+ }
+
+ internal static bool TryParseInvariantFloat(ReadOnlySpan value, out float result)
+ {
+ value = Trim(value);
+ if (value.Length == 0)
+ {
+ result = default;
+ return false;
+ }
+
+#if NETSTANDARD2_1 || NETCOREAPP2_1_OR_GREATER
+ if (!float.TryParse(value, NumberStyles.Float, s_invariantCulture, out result))
+ {
+ return false;
+ }
+#else
+ if (!float.TryParse(value.ToString(), NumberStyles.Float, s_invariantCulture, out result))
+ {
+ return false;
+ }
+#endif
+
+ return IsFinite(result);
+ }
+
+ internal static bool TryParseInvariantDouble(ReadOnlySpan value, out double result)
+ {
+ value = Trim(value);
+ if (value.Length == 0)
+ {
+ result = default;
+ return false;
+ }
+
+#if NETSTANDARD2_1 || NETCOREAPP2_1_OR_GREATER
+ if (!double.TryParse(value, NumberStyles.Float, s_invariantCulture, out result))
+ {
+ return false;
+ }
+#else
+ if (!double.TryParse(value.ToString(), NumberStyles.Float, s_invariantCulture, out result))
+ {
+ return false;
+ }
+#endif
+
+ return IsFinite(result);
+ }
+
+ internal static bool TryParseInvariantInt(ReadOnlySpan value, out int result)
+ {
+ value = Trim(value);
+ if (value.Length == 0)
+ {
+ result = default;
+ return false;
+ }
+
+#if NETSTANDARD2_1 || NETCOREAPP2_1_OR_GREATER
+ return int.TryParse(value, NumberStyles.None, s_invariantCulture, out result);
+#else
+ return int.TryParse(value.ToString(), NumberStyles.None, s_invariantCulture, out result);
+#endif
+ }
+
+ private static bool TryParseClockValueWithSuffix(ReadOnlySpan value, string suffix, out double scalar)
+ {
+ scalar = default;
+ if (!TryStripSuffixIgnoreCase(value, suffix, out var scalarText))
+ {
+ return false;
+ }
+
+ scalarText = Trim(scalarText);
+ return scalarText.Length > 0 && TryParseInvariantDouble(scalarText, out scalar);
+ }
+
+ private static bool TryParseColonClockValue(ReadOnlySpan value, out TimeSpan result)
+ {
+ result = default;
+
+ var firstColonIndex = value.IndexOf(':');
+ if (firstColonIndex < 0)
+ {
+ return false;
+ }
+
+ var remaining = value.Slice(firstColonIndex + 1);
+ var secondRelativeIndex = remaining.IndexOf(':');
+
+ ReadOnlySpan hoursText = default;
+ ReadOnlySpan minutesText;
+ ReadOnlySpan secondsText;
+
+ if (secondRelativeIndex >= 0)
+ {
+ var secondColonIndex = firstColonIndex + secondRelativeIndex + 1;
+ if (value.Slice(secondColonIndex + 1).IndexOf(':') >= 0)
+ {
+ return false;
+ }
+
+ hoursText = value.Slice(0, firstColonIndex);
+ minutesText = value.Slice(firstColonIndex + 1, secondColonIndex - firstColonIndex - 1);
+ secondsText = value.Slice(secondColonIndex + 1);
+ }
+ else
+ {
+ minutesText = value.Slice(0, firstColonIndex);
+ secondsText = value.Slice(firstColonIndex + 1);
+ }
+
+ var hours = 0;
+ if (hoursText.Length > 0 && !TryParseInvariantInt(hoursText, out hours))
+ {
+ return false;
+ }
+
+ if (!TryParseInvariantInt(minutesText, out var minutes) ||
+ !TryParseInvariantDouble(secondsText, out var seconds))
+ {
+ return false;
+ }
+
+ if (hours < 0 || minutes < 0 || minutes >= 60 || seconds < 0d || seconds >= 60d)
+ {
+ return false;
+ }
+
+ result = TimeSpan.FromHours(hours) +
+ TimeSpan.FromMinutes(minutes) +
+ TimeSpan.FromSeconds(seconds);
+ return true;
+ }
+
+ private static bool TryMapEventName(ReadOnlySpan eventName, out SvgPointerEventType eventType)
+ {
+ if (EqualsAsciiIgnoreCase(eventName, "click"))
+ {
+ eventType = SvgPointerEventType.Click;
+ return true;
+ }
+
+ if (EqualsAsciiIgnoreCase(eventName, "mousedown"))
+ {
+ eventType = SvgPointerEventType.Press;
+ return true;
+ }
+
+ if (EqualsAsciiIgnoreCase(eventName, "mouseup"))
+ {
+ eventType = SvgPointerEventType.Release;
+ return true;
+ }
+
+ if (EqualsAsciiIgnoreCase(eventName, "mousemove"))
+ {
+ eventType = SvgPointerEventType.Move;
+ return true;
+ }
+
+ if (EqualsAsciiIgnoreCase(eventName, "mouseover"))
+ {
+ eventType = SvgPointerEventType.Enter;
+ return true;
+ }
+
+ if (EqualsAsciiIgnoreCase(eventName, "mouseout"))
+ {
+ eventType = SvgPointerEventType.Leave;
+ return true;
+ }
+
+ if (EqualsAsciiIgnoreCase(eventName, "mousescroll"))
+ {
+ eventType = SvgPointerEventType.Wheel;
+ return true;
+ }
+
+ eventType = default;
+ return false;
+ }
+
+ internal static float ToMotionCoordinate(SvgUnit unit, UnitRenderingType renderingType, SvgElement owner)
+ {
+ var ppi = owner.OwnerDocument?.Ppi ?? SvgDocument.PointsPerInch;
+
+ switch (unit.Type)
+ {
+ case SvgUnitType.Inch:
+ return unit.Value * ppi;
+ case SvgUnitType.Centimeter:
+ return (unit.Value / 2.54f) * ppi;
+ case SvgUnitType.Millimeter:
+ return (unit.Value / 25.4f) * ppi;
+ case SvgUnitType.Pica:
+ return ((unit.Value * 12f) / 72f) * ppi;
+ case SvgUnitType.Point:
+ return (unit.Value / 72f) * ppi;
+ case SvgUnitType.Percentage:
+ var document = owner.OwnerDocument;
+ var viewBox = document?.ViewBox;
+ var dimension = renderingType == UnitRenderingType.Horizontal
+ ? (viewBox?.Width ?? 0f)
+ : (viewBox?.Height ?? 0f);
+
+ if (dimension == 0f && document is not null)
+ {
+ dimension = renderingType == UnitRenderingType.Horizontal
+ ? ToViewportDimension(document.Width, document)
+ : ToViewportDimension(document.Height, document);
+ }
+
+ return dimension == 0f ? unit.Value : (dimension * unit.Value / 100f);
+ default:
+ return unit.Value;
+ }
+ }
+
+ private static int FindEventTimingSignIndex(ReadOnlySpan value)
+ {
+ for (var index = value.Length - 1; index > 0; index--)
+ {
+ if (value[index] is not ('+' or '-'))
+ {
+ continue;
+ }
+
+ var offsetText = Trim(value.Slice(index + 1));
+ if (offsetText.Length == 0 || !TryParseClockValue(offsetText, out _))
+ {
+ continue;
+ }
+
+ return index;
+ }
+
+ return -1;
+ }
+
+ private static bool TryCreateTimeSpan(double scalar, int sign, ClockTimeUnit unit, out TimeSpan result)
+ {
+ try
+ {
+ result = unit switch
+ {
+ ClockTimeUnit.Milliseconds => TimeSpan.FromMilliseconds(scalar),
+ ClockTimeUnit.Minutes => TimeSpan.FromMinutes(scalar),
+ ClockTimeUnit.Hours => TimeSpan.FromHours(scalar),
+ _ => TimeSpan.FromSeconds(scalar)
+ };
+ }
+ catch (OverflowException)
+ {
+ result = default;
+ return false;
+ }
+
+ if (sign < 0)
+ {
+ result = -result;
+ }
+
+ return true;
+ }
+
+ private static bool IsFinite(float value)
+ {
+ return !float.IsNaN(value) && !float.IsInfinity(value);
+ }
+
+ private static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ private enum ClockTimeUnit
+ {
+ Milliseconds,
+ Seconds,
+ Minutes,
+ Hours
+ }
+
+ private static float ToViewportDimension(SvgUnit unit, SvgElement owner)
+ {
+ var ppi = owner.OwnerDocument?.Ppi ?? SvgDocument.PointsPerInch;
+
+ switch (unit.Type)
+ {
+ case SvgUnitType.Inch:
+ return unit.Value * ppi;
+ case SvgUnitType.Centimeter:
+ return (unit.Value / 2.54f) * ppi;
+ case SvgUnitType.Millimeter:
+ return (unit.Value / 25.4f) * ppi;
+ case SvgUnitType.Pica:
+ return ((unit.Value * 12f) / 72f) * ppi;
+ case SvgUnitType.Point:
+ return (unit.Value / 72f) * ppi;
+ case SvgUnitType.Percentage:
+ return 0f;
+ default:
+ return unit.Value;
+ }
+ }
+
+ private static bool TryReadSeparatedToken(ref ReadOnlySpan remaining, ReadOnlySpan separators, out ReadOnlySpan token)
+ {
+ remaining = TrimSeparators(remaining, separators);
+ if (remaining.Length == 0)
+ {
+ token = default;
+ return false;
+ }
+
+ var separatorIndex = FindSeparatorIndex(remaining, separators);
+ if (separatorIndex < 0)
+ {
+ token = remaining;
+ remaining = ReadOnlySpan.Empty;
+ return true;
+ }
+
+ token = remaining.Slice(0, separatorIndex);
+ remaining = remaining.Slice(separatorIndex + 1);
+ return true;
+ }
+
+ private static int FindSeparatorIndex(ReadOnlySpan value, ReadOnlySpan separators)
+ {
+ for (var index = 0; index < value.Length; index++)
+ {
+ if (IsSeparator(value[index], separators))
+ {
+ return index;
+ }
+ }
+
+ return -1;
+ }
+
+ private static ReadOnlySpan Trim(ReadOnlySpan value)
+ {
+ var start = 0;
+ var end = value.Length - 1;
+
+ while (start <= end && char.IsWhiteSpace(value[start]))
+ {
+ start++;
+ }
+
+ while (end >= start && char.IsWhiteSpace(value[end]))
+ {
+ end--;
+ }
+
+ return start > end
+ ? ReadOnlySpan.Empty
+ : value.Slice(start, end - start + 1);
+ }
+
+ private static ReadOnlySpan TrimSeparators(ReadOnlySpan value, ReadOnlySpan separators)
+ {
+ var start = 0;
+ while (start < value.Length && IsSeparator(value[start], separators))
+ {
+ start++;
+ }
+
+ return start == 0
+ ? value
+ : value.Slice(start);
+ }
+
+ private static bool IsSeparator(char value, ReadOnlySpan separators)
+ {
+ for (var index = 0; index < separators.Length; index++)
+ {
+ if (value == separators[index])
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryStripSuffixIgnoreCase(ReadOnlySpan value, string suffix, out ReadOnlySpan remaining)
+ {
+ if (value.Length < suffix.Length)
+ {
+ remaining = default;
+ return false;
+ }
+
+ var startIndex = value.Length - suffix.Length;
+ if (!EqualsAsciiIgnoreCase(value.Slice(startIndex), suffix))
+ {
+ remaining = default;
+ return false;
+ }
+
+ remaining = value.Slice(0, startIndex);
+ return true;
+ }
+
+ private static bool EqualsAsciiIgnoreCase(ReadOnlySpan value, string expected)
+ {
+ if (value.Length != expected.Length)
+ {
+ return false;
+ }
+
+ for (var index = 0; index < value.Length; index++)
+ {
+ if (ToLowerAscii(value[index]) != ToLowerAscii(expected[index]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static char ToLowerAscii(char value)
+ {
+ return value is >= 'A' and <= 'Z'
+ ? (char)(value + ('a' - 'A'))
+ : value;
+ }
+}
diff --git a/src/Svg.Animation/Animation/SvgAnimationSpline.cs b/src/Svg.Animation/Animation/SvgAnimationSpline.cs
new file mode 100644
index 0000000000..c0f5a7745b
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgAnimationSpline.cs
@@ -0,0 +1,20 @@
+namespace Svg.Skia;
+
+internal readonly struct CubicBezierSpline
+{
+ public CubicBezierSpline(float x1, float y1, float x2, float y2)
+ {
+ X1 = x1;
+ Y1 = y1;
+ X2 = x2;
+ Y2 = y2;
+ }
+
+ public float X1 { get; }
+
+ public float Y1 { get; }
+
+ public float X2 { get; }
+
+ public float Y2 { get; }
+}
diff --git a/src/Svg.Animation/Animation/SvgNativeCompositionScene.cs b/src/Svg.Animation/Animation/SvgNativeCompositionScene.cs
new file mode 100644
index 0000000000..956fd0ce17
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgNativeCompositionScene.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using ShimSkiaSharp;
+
+namespace Svg.Skia;
+
+public sealed class SvgNativeCompositionLayer
+{
+ public SvgNativeCompositionLayer(
+ int documentChildIndex,
+ bool isAnimated,
+ SKPicture? picture,
+ SKPoint offset,
+ SKSize size,
+ float opacity,
+ bool isVisible)
+ {
+ DocumentChildIndex = documentChildIndex;
+ IsAnimated = isAnimated;
+ Picture = picture;
+ Offset = offset;
+ Size = size;
+ Opacity = opacity;
+ IsVisible = isVisible;
+ }
+
+ public int DocumentChildIndex { get; }
+
+ public bool IsAnimated { get; }
+
+ public SKPicture? Picture { get; }
+
+ public SKPoint Offset { get; }
+
+ public SKSize Size { get; }
+
+ public float Opacity { get; }
+
+ public bool IsVisible { get; }
+}
+
+public sealed class SvgNativeCompositionScene
+{
+ public SvgNativeCompositionScene(SKRect sourceBounds, IReadOnlyList layers)
+ {
+ SourceBounds = sourceBounds;
+ Layers = new ReadOnlyCollection(layers.ToArray());
+ }
+
+ public SKRect SourceBounds { get; }
+
+ public IReadOnlyList Layers { get; }
+}
+
+public sealed class SvgNativeCompositionFrame
+{
+ public SvgNativeCompositionFrame(SKRect sourceBounds, IReadOnlyList layers)
+ {
+ SourceBounds = sourceBounds;
+ Layers = new ReadOnlyCollection(layers.ToArray());
+ }
+
+ public SKRect SourceBounds { get; }
+
+ public IReadOnlyList Layers { get; }
+}
diff --git a/src/Svg.Animation/Animation/SvgPointerEventType.cs b/src/Svg.Animation/Animation/SvgPointerEventType.cs
new file mode 100644
index 0000000000..8324952c45
--- /dev/null
+++ b/src/Svg.Animation/Animation/SvgPointerEventType.cs
@@ -0,0 +1,12 @@
+namespace Svg.Skia;
+
+public enum SvgPointerEventType
+{
+ Move,
+ Press,
+ Release,
+ Enter,
+ Leave,
+ Wheel,
+ Click
+}
diff --git a/src/Svg.Animation/Diagnostics/RequiresUnreferencedCodeAttribute.cs b/src/Svg.Animation/Diagnostics/RequiresUnreferencedCodeAttribute.cs
new file mode 100644
index 0000000000..ec8e95deca
--- /dev/null
+++ b/src/Svg.Animation/Diagnostics/RequiresUnreferencedCodeAttribute.cs
@@ -0,0 +1,14 @@
+#if NET461 || NETFRAMEWORK || NETSTANDARD || NETSTANDARD2_0
+namespace System.Diagnostics.CodeAnalysis;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Constructor | AttributeTargets.Class, Inherited = false)]
+internal sealed class RequiresUnreferencedCodeAttribute : Attribute
+{
+ public RequiresUnreferencedCodeAttribute(string message)
+ {
+ Message = message;
+ }
+
+ public string Message { get; }
+}
+#endif
diff --git a/src/Svg.Animation/Properties/AssemblyInfo.cs b/src/Svg.Animation/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..9c2b21d068
--- /dev/null
+++ b/src/Svg.Animation/Properties/AssemblyInfo.cs
@@ -0,0 +1,8 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Svg.Skia,PublicKey=" +
+"00240000048000009400000006020000002400005253413100040000010001000958ee05055101" +
+"5f5db159c2fcc56a83ca8a54083e1ac6cac40312e0b0dcb26ce9e1cba2358c8644ffd7b21efbbc" +
+"1304b44f6d6487c23218986ab356ce0461e2e8886d8269a47e534b4a48310151719fdfdde82aad" +
+"3667eb87baad62c7bb7cf826a4095229fbed8904f90cf9dc553c9ad5d6a3e543058847431fdda7" +
+"58211bd3")]
diff --git a/src/Svg.Animation/Svg.Animation.csproj b/src/Svg.Animation/Svg.Animation.csproj
new file mode 100644
index 0000000000..456d4e6119
--- /dev/null
+++ b/src/Svg.Animation/Svg.Animation.csproj
@@ -0,0 +1,46 @@
+
+
+
+ Library
+ netstandard2.0;net461;net6.0;net8.0;net10.0
+ False
+ False
+ CS1591
+ True
+ enable
+
+
+
+ true
+ false
+ true
+
+
+
+ true
+ true
+
+
+
+ Shared SVG animation runtime and host playback contracts.
+ Svg.Animation
+ MIT
+ svg;animation;smil;rendering;host playback;runtime
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Svg.Animation/SvgAnimationInvalidation.cs b/src/Svg.Animation/SvgAnimationInvalidation.cs
new file mode 100644
index 0000000000..35444e9701
--- /dev/null
+++ b/src/Svg.Animation/SvgAnimationInvalidation.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+
+namespace Svg.Skia;
+
+public static class SvgAnimationInvalidation
+{
+ private static readonly HashSet s_inheritedAttributes = new(StringComparer.Ordinal)
+ {
+ "alphabetic",
+ "ascent",
+ "ascent-height",
+ "clip",
+ "clip-rule",
+ "color",
+ "color-interpolation",
+ "color-interpolation-filters",
+ "descent",
+ "dominant-baseline",
+ "fill",
+ "fill-opacity",
+ "fill-rule",
+ "flood-color",
+ "flood-opacity",
+ "font",
+ "font-family",
+ "font-size",
+ "font-stretch",
+ "font-style",
+ "font-variant",
+ "font-weight",
+ "glyph-name",
+ "horiz-adv-x",
+ "horiz-origin-x",
+ "horiz-origin-y",
+ "k",
+ "lengthAdjust",
+ "letter-spacing",
+ "shape-rendering",
+ "space",
+ "stop-color",
+ "stop-opacity",
+ "stroke",
+ "stroke-dasharray",
+ "stroke-dashoffset",
+ "stroke-linecap",
+ "stroke-linejoin",
+ "stroke-miterlimit",
+ "stroke-opacity",
+ "stroke-width",
+ "text-anchor",
+ "text-decoration",
+ "text-transform",
+ "textLength",
+ "units-per-em",
+ "vert-adv-y",
+ "vert-origin-x",
+ "vert-origin-y",
+ "visibility",
+ "word-spacing",
+ "x-height"
+ };
+
+ public static bool AffectsDescendantSubtree(string attributeName)
+ {
+ return !string.IsNullOrWhiteSpace(attributeName) &&
+ s_inheritedAttributes.Contains(attributeName);
+ }
+}
diff --git a/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs b/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs
index b092c12cd4..ce06c5532d 100644
--- a/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs
+++ b/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs
@@ -1871,6 +1871,17 @@ public static void ToSKPicture(this SKPicture? picture, SkiaCSharpCodeGenCounter
}
break;
}
+ case DrawPictureCanvasCommand drawPictureCanvasCommand:
+ {
+ if (drawPictureCanvasCommand.Picture is { })
+ {
+ var counterChildPicture = ++counter.Picture;
+ drawPictureCanvasCommand.Picture.ToSKPicture(counter, sb, indent);
+ sb.AppendLine($"{indent}{counter.CanvasVarName}{counterCanvas}.DrawPicture({counter.PictureVarName}{counterChildPicture});");
+ sb.AppendLine($"{indent}{counter.PictureVarName}{counterChildPicture}?.Dispose();");
+ }
+ break;
+ }
case DrawPathCanvasCommand drawPathCanvasCommand:
{
if (drawPathCanvasCommand.Path is { } && drawPathCanvasCommand.Paint is { })
diff --git a/src/Svg.Controls.Avalonia/AvaloniaPicture.cs b/src/Svg.Controls.Avalonia/AvaloniaPicture.cs
index 0edbb16714..207898aab9 100644
--- a/src/Svg.Controls.Avalonia/AvaloniaPicture.cs
+++ b/src/Svg.Controls.Avalonia/AvaloniaPicture.cs
@@ -179,6 +179,17 @@ private static void RecordCommand(CanvasCommand canvasCommand, List
}
break;
}
+ case DrawPictureCanvasCommand drawPictureCanvasCommand:
+ {
+ if (drawPictureCanvasCommand.Picture?.Commands is { } nestedCommands)
+ {
+ foreach (var nestedCommand in nestedCommands)
+ {
+ RecordCommand(nestedCommand, commands);
+ }
+ }
+ break;
+ }
case DrawPathCanvasCommand drawPathCanvasCommand:
{
RecordPathCommand(drawPathCanvasCommand, commands);
diff --git a/src/Svg.Controls.Avalonia/Svg.Controls.Avalonia.csproj b/src/Svg.Controls.Avalonia/Svg.Controls.Avalonia.csproj
index 8b88412ab8..c869f80d13 100644
--- a/src/Svg.Controls.Avalonia/Svg.Controls.Avalonia.csproj
+++ b/src/Svg.Controls.Avalonia/Svg.Controls.Avalonia.csproj
@@ -29,6 +29,7 @@
+
diff --git a/src/Svg.Controls.Avalonia/SvgSource.cs b/src/Svg.Controls.Avalonia/SvgSource.cs
index d9137ef0d2..6c97f928d4 100644
--- a/src/Svg.Controls.Avalonia/SvgSource.cs
+++ b/src/Svg.Controls.Avalonia/SvgSource.cs
@@ -7,8 +7,10 @@
using System.Net.Http;
using Avalonia.Platform;
using ShimSkiaSharp;
+using Svg;
using Svg.Model;
using Svg.Model.Services;
+using Svg.Skia;
using SM = Svg.Model;
namespace Avalonia.Svg;
@@ -35,7 +37,7 @@ public class SvgSource
if (File.Exists(path))
{
var document = SvgService.Open(path, parameters);
- return document is { } ? SvgService.ToModel(document, s_assetLoader, out _, out _) : default;
+ return CreateModel(document);
}
if (Uri.TryCreate(path, UriKind.Absolute, out var uriHttp) && (uriHttp.Scheme == "http" || uriHttp.Scheme == "https"))
@@ -47,7 +49,7 @@ public class SvgSource
{
var stream = response.Content.ReadAsStreamAsync().Result;
var document = SvgService.Open(stream, parameters);
- return document is { } ? SvgService.ToModel(document, s_assetLoader, out _, out _) : default;
+ return CreateModel(document);
}
}
catch (HttpRequestException e)
@@ -63,7 +65,7 @@ public class SvgSource
if (uri.IsAbsoluteUri && uri.IsFile)
{
var document = SvgService.Open(uri.LocalPath, parameters);
- return document is { } ? SvgService.ToModel(document, s_assetLoader, out _, out _) : default;
+ return CreateModel(document);
}
else
{
@@ -73,7 +75,7 @@ public class SvgSource
return default;
}
var document = SvgService.Open(stream, parameters);
- return document is { } ? SvgService.ToModel(document, s_assetLoader, out _, out _) : default;
+ return CreateModel(document);
}
}
@@ -98,7 +100,7 @@ public static SvgSource Load(string path, Uri? baseUri, SvgParameters? parameter
public static SKPicture? LoadPicture(Stream stream, SvgParameters? parameters = null)
{
var document = SvgService.Open(stream, parameters);
- return document is { } ? SvgService.ToModel(document, s_assetLoader, out _, out _) : default;
+ return CreateModel(document);
}
///
@@ -120,7 +122,7 @@ public static SvgSource Load(Stream stream, SvgParameters? parameters = null)
public static SKPicture? LoadPictureFromSvg(string source, SvgParameters? parameters = null)
{
var document = SvgService.FromSvg(source);
- return document is { } ? SvgService.ToModel(document, s_assetLoader, out _, out _) : default;
+ return CreateModel(document);
}
///
@@ -133,6 +135,11 @@ public static SvgSource LoadFromSvg(string source)
return new() { Picture = LoadPictureFromSvg(source) };
}
+ private static SKPicture? CreateModel(SvgDocument? document)
+ {
+ return document is { } ? SvgSceneRuntime.CreateModel(document, s_assetLoader) : default;
+ }
+
///
/// Rebuilds the from its underlying model, refreshing its associated picture.
///
diff --git a/src/Svg.Controls.Skia.Avalonia/Composition/SvgCompositionVisualScene.cs b/src/Svg.Controls.Skia.Avalonia/Composition/SvgCompositionVisualScene.cs
new file mode 100644
index 0000000000..5b0ef666f8
--- /dev/null
+++ b/src/Svg.Controls.Skia.Avalonia/Composition/SvgCompositionVisualScene.cs
@@ -0,0 +1,353 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Media;
+using Avalonia.Platform;
+using Avalonia.Rendering.Composition;
+using Avalonia.Skia;
+using ShimSkiaSharp;
+using Svg.Skia;
+using NativeSkPicture = SkiaSharp.SKPicture;
+
+namespace Avalonia.Svg.Skia;
+
+internal sealed class SvgCompositionVisualScene : IDisposable
+{
+ private sealed class LayerMessage
+ {
+ public LayerMessage(NativeSkPicture? picture)
+ {
+ Picture = picture;
+ }
+
+ public NativeSkPicture? Picture { get; }
+ }
+
+ private sealed class LayerHandler : CompositionCustomVisualHandler
+ {
+ private readonly Action _onRenderUnavailable;
+ private NativeSkPicture? _picture;
+ private bool _reportedRenderUnavailable;
+
+ public LayerHandler(NativeSkPicture? picture, Action onRenderUnavailable)
+ {
+ _picture = picture;
+ _onRenderUnavailable = onRenderUnavailable;
+ }
+
+ public override void OnMessage(object message)
+ {
+ if (message is not LayerMessage layerMessage)
+ {
+ return;
+ }
+
+ if (!ReferenceEquals(_picture, layerMessage.Picture))
+ {
+ _picture?.Dispose();
+ }
+
+ _picture = layerMessage.Picture;
+ Invalidate();
+ }
+
+ public override void OnRender(ImmediateDrawingContext drawingContext)
+ {
+ if (_picture is null)
+ {
+ return;
+ }
+
+ var leaseFeature = drawingContext.TryGetFeature();
+ if (leaseFeature is null)
+ {
+ ReportRenderUnavailable("Avalonia compositor custom visuals did not provide a Skia drawing lease.");
+ return;
+ }
+
+ using var lease = leaseFeature.Lease();
+ var canvas = lease?.SkCanvas;
+ if (canvas is null)
+ {
+ ReportRenderUnavailable("Avalonia compositor custom visuals did not provide a Skia canvas.");
+ return;
+ }
+
+ canvas.Save();
+ canvas.DrawPicture(_picture);
+ canvas.Restore();
+ }
+
+ private void ReportRenderUnavailable(string reason)
+ {
+ if (_reportedRenderUnavailable)
+ {
+ return;
+ }
+
+ _reportedRenderUnavailable = true;
+ _onRenderUnavailable(reason);
+ }
+ }
+
+ private sealed class LayerVisual
+ {
+ private SvgNativeCompositionLayer? _layer;
+ private SKPicture? _sourcePicture;
+
+ public LayerVisual(CompositionCustomVisual visual)
+ {
+ Visual = visual;
+ }
+
+ public CompositionCustomVisual Visual { get; }
+
+ public void Initialize(SvgNativeCompositionLayer layer)
+ {
+ _layer = layer;
+ _sourcePicture = layer.Picture;
+ ApplyVisualState(layer);
+ }
+
+ public void Update(SvgNativeCompositionLayer layer, bool wireframe)
+ {
+ var sourcePictureChanged = !ReferenceEquals(_sourcePicture, layer.Picture);
+ _layer = layer;
+ _sourcePicture = layer.Picture;
+ ApplyVisualState(layer);
+
+ if (sourcePictureChanged)
+ {
+ Visual.SendHandlerMessage(new LayerMessage(CreateRenderPicture(_sourcePicture, wireframe)));
+ }
+ }
+
+ public void UpdateWireframe(bool wireframe)
+ {
+ Visual.SendHandlerMessage(new LayerMessage(CreateRenderPicture(_sourcePicture, wireframe)));
+ }
+
+ public void Activate(bool wireframe)
+ {
+ if (_layer is { } layer)
+ {
+ ApplyVisualState(layer);
+ }
+
+ Visual.SendHandlerMessage(new LayerMessage(CreateRenderPicture(_sourcePicture, wireframe)));
+ }
+
+ public void Dispose()
+ {
+ Visual.SendHandlerMessage(new LayerMessage(null));
+ _layer = null;
+ _sourcePicture = null;
+ }
+
+ private void ApplyVisualState(SvgNativeCompositionLayer layer)
+ {
+ Visual.Visible = layer.IsVisible && layer.Picture?.Commands is { Count: > 0 };
+ Visual.Opacity = ClampOpacity(layer.Opacity);
+ Visual.Offset = new Vector3D(layer.Offset.X, layer.Offset.Y, 0d);
+ Visual.Size = new Vector(layer.Size.Width, layer.Size.Height);
+ }
+
+ private static NativeSkPicture? CreateRenderPicture(SKPicture? picture, bool wireframe)
+ {
+ if (picture?.Commands is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ return wireframe
+ ? SvgSource.s_skiaModel.ToWireframePicture(picture)
+ : SvgSource.s_skiaModel.ToSKPicture(picture);
+ }
+
+ private static float ClampOpacity(float opacity)
+ {
+ if (opacity <= 0f)
+ {
+ return 0f;
+ }
+
+ return opacity >= 1f ? 1f : opacity;
+ }
+ }
+
+ private readonly Svg _owner;
+ private readonly CompositionContainerVisual _rootVisual;
+ private readonly Dictionary