diff --git a/plan/skipped-tests-implementation-roadmap.md b/plan/skipped-tests-implementation-roadmap.md index 5f9272dd2a..f6212f6a58 100644 --- a/plan/skipped-tests-implementation-roadmap.md +++ b/plan/skipped-tests-implementation-roadmap.md @@ -101,6 +101,17 @@ Verified probe findings from the remaining skipped W3C text rows on 2026-04-09: - `text-intro-05-t` and `text-intro-10-f` still fail because Arabic shaping only stays correct when fallback spans are preserved, but preserving spans still leaves non-Chrome anchor/position parity. A probe to force single-run shaping produced tofu glyphs, which confirms the missing piece is mixed-font shaping/fallback support rather than a simple bidi wrapper. - `text-altglyph-01/02/03-b` now exercise real SVG-font alternate glyph substitution. They remain sensitive to browser/font raster identity and should not be conflated with the remaining bidi/vertical text layout work. +Current resource-rendering tranche: + +- The next recommended non-text lane is a first resource-rendering parity slice, not a full resource-policy or browser-style completion pass. +- The slice covers gradient and pattern inheritance where explicitly authored attributes on referenced resources must override defaults without letting default values mask inherited values. +- The slice includes recursion guards for pattern picture compilation and referenced content so paint servers, markers, clips, masks, filters, and `use` expansions do not re-enter the same resource path indefinitely. +- The slice hardens existing Skia-backed filters by treating invalid `feColorMatrix` values, negative blur/morphology inputs, fractional morphology radii, and lighting parameter mapping as deterministic renderer behavior rather than fixture-specific failures. +- The slice covers clip/use placement where referenced clip content should preserve geometry and transforms while suppressing marker output that does not belong inside the clip resource. +- The slice covers image decode guards so empty, blocked, or malformed image data produces deterministic zero-size image metadata instead of null-image crashes. +- The green resvg resource fixture slice is tracked through already-green deterministic resource-adjacent families with no new thresholds or Chrome overrides. Renderer deltas outside that slice are covered by focused unit tests until their broader visual rows are ready. +- Deeper rows remain planned and must not be hidden by broad thresholds: `enable-background`, `BackgroundImage`, `BackgroundAlpha`, `feImage`, CSS `filter` functions, style selector/color parity, full mask self-reference behavior, and exact browser parity for pattern tiling/inheritance and marker placement. + ## Workstreams ### 1. Text Layout And Font Fidelity @@ -160,6 +171,7 @@ Acceptance criteria: Target projects: - `src/Svg.Animation` +- `src/Svg.Custom` - `src/Svg.Skia` - `tests/Svg.Skia.UnitTests` - `scripts/capture_w3c_chrome_overrides.mjs` @@ -168,16 +180,50 @@ Features: - per-fixture animation snapshot times in W3C tests - matching Chrome capture timing -- correct snapshot rendering for animate/set/animateTransform/animateMotion/animateColor/filter animation cases +- repeat-count and `repeat(n)` eventbase timing for seeked snapshots +- half-open active interval boundaries, restart truncation, repeat callbacks, and future syncbase DOM query timing +- number-list and path interpolation for paced and explicit animation values +- discrete midpoint fallback when interpolation is unsupported or non-additive +- routing for custom attributes, namespaced referenced resources, `currentColor`/`inherit`, and style-backed animated values +- by-only `animateTransform` handling +- motion and transform composition across `animateMotion`, `animateTransform`, and base transforms +- correct snapshot rendering for `animate`, `set`, `animateTransform`, `animateMotion`, `animateColor`, style, reference, and filter animation cases Primary test impact: - W3C `animate-*` - `filters-composite-05-f` +Execution order: + +1. Lock W3C animation rows to explicit seek times and regenerate matching Chrome overrides through the HTTP capture script, not `file://`. +2. Preserve eventbase timing semantics for `begin`, `end`, repeat events, `repeat(n)`, and self-synchronizing recurrence without letting cycles drift across snapshot seeks. +3. Finish interpolation coverage for scalar values, number lists, transform lists, path data, and paced motion where the static runtime can compute a deterministic value. +4. Route animated values through the same custom-attribute, referenced-resource, presentation-attribute, and inline-style paths used by static rendering so snapshots do not bypass cascade/resource semantics. +5. Preserve discrete midpoint fallback for unsupported interpolation modes, mismatched value shapes, non-additive values, and browser-runtime-only cases. +6. Complete by-only transform handling and motion/transform composition so base transforms, additive transforms, and motion transforms apply in browser order. +7. Keep JavaScript/DOM/event-loop-dependent rows skipped with explicit runtime reasons instead of manufacturing baselines. +8. Run focused W3C animation rows before broad W3C and all-area validation, then review benchmark output for timing-path or interpolation regressions. + +Current implementation status: + +- The active SMIL snapshot work is in the runtime path rather than in baseline policy. The lane now covers repeat-event timing, `repeat(n)` eventbase resolution, self-sync recurrence, future syncbase start-time queries, restart truncation, half-open interval boundaries, list/path interpolation, fallback discreteness, and composed motion/transform output as renderer behavior. +- W3C seek times and Chrome capture alignment are part of the implementation contract: a row should compare Svg.Skia at the same snapshot time used for its Chrome override, and fixture captures must keep the parent harness and SVG on the same HTTP origin. +- Style, custom attribute, namespaced `href`/`xlink:href`, `currentColor`, `inherit`, and referenced-resource animation routing remains part of the lane because snapshot correctness depends on animated values reaching the same property/resource resolution path as static values. +- Validation is expected to include focused animation rows, any refreshed Chrome overrides, the broader W3C animation suite, and performance review for timing and interpolation changes before enabling more rows. Newly enabled W3C rows with Chrome overrides include `animate-elem-02-t` through `animate-elem-15-t`, `animate-elem-17-t`, `animate-elem-19-t`, `animate-elem-22-b`, `animate-elem-24-t` through `animate-elem-28-t`, `animate-elem-30-t` through `animate-elem-41-t`, `animate-elem-44-t`, `animate-elem-46-t`, `animate-elem-53-t`, `animate-elem-64-t` through `animate-elem-70-t`, `animate-elem-77-t`, `animate-elem-78-t`, `animate-elem-80-t` through `animate-elem-83-t`, `animate-elem-86-t` through `animate-elem-89-t`, `animate-elem-92-t`, `animate-pservers-grad-01-b`, and `filters-composite-05-f`. `animate-elem-90-b` and `animate-elem-91-t` are also enabled, but they intentionally compare against the legacy W3C pass images because current Chrome captures do not match the W3C discrete class/to-only non-interpolable pass criteria for those rows. +- The latest SMIL fixes include order-independent `animateMotion` plus `animateTransform` composition, cloned-document deferred paint-server rebinding for animated gradient resources, discrete accumulated end-value semantics, finite `max` constraints on indefinite set intervals, valid `min`/`max` pair handling, to-only non-interpolable value routing for class/reference/filter/unit attributes, exact midpoint switching for unsupported non-interpolable linear values, full compatibility style-state snapshots when selector reapplication has no tracked candidate set, selector-affecting frame attributes applied before other animated presentation attributes, and hardened Chrome override capture readiness so animation snapshots do not accidentally capture `about:blank` or a pre-seek frame. +- Remaining skipped `animate-*` rows are split between browser-runtime policy cases and deprecated-browser-parity policy cases. Attribute-routing classification now leaves `animate-elem-23-t`, `animate-elem-84-t`, and `animate-elem-85-t` skipped because modern Chrome captures deprecated `animateColor` as a no-op in those rows. Browser-runtime rows such as click-driven `indefinite`, access key, wallclock, pointer-event, and embedded animated image fixtures should stay skipped unless the harness explicitly simulates those runtime inputs. + Acceptance criteria: - W3C animation rows no longer default to time zero when the Chrome baseline captures an advanced frame. +- `repeat(n)` eventbase timing and self-sync recurrence produce deterministic snapshot values at the configured seek time. +- Half-open active intervals, `restart="always"` truncation, repeat timeline callbacks, and future syncbase start-time lookups are covered by focused regression tests. +- Scalar, number-list, path, transform, and motion interpolation either match browser snapshot behavior or fall back discretely at the midpoint with an explicit unsupported-runtime reason. +- Custom attributes, namespaced referenced resources, presentation attributes, `currentColor`/`inherit`, and inline styles receive animated values through the same routing used by static rendering. +- By-only transform animation and motion/transform composition preserve browser transform order for static snapshots. +- New or refreshed W3C Chrome overrides are generated with `node scripts/capture_w3c_chrome_overrides.mjs` and stay aligned with the W3C seek times. +- Focused W3C animation validation and relevant all-area benchmark checks are reviewed before accepting runtime changes. ### 3. CSS And Styling Fidelity @@ -204,7 +250,7 @@ Acceptance criteria: - Styling rows that only depend on static cascade semantics are enabled. -### 4. Paint Servers, Filters, Images, Markers, Patterns +### 4. Resource Rendering Parity First Slice Target projects: @@ -215,19 +261,83 @@ Target projects: Features: -- image loading and fallback behavior -- marker orientation and sizing parity -- pattern inheritance and units parity -- linear/radial gradient edge cases -- remaining filter primitive fidelity +- gradient inheritance across `href` chains: + - `spreadMethod` + - `gradientUnits` + - `gradientTransform` + - radial focal point and radius guards +- pattern inheritance and recursion guards: + - `patternUnits` + - `patternContentUnits` + - `patternTransform` + - `viewBox` + - `preserveAspectRatio` + - paint-server fallback when recursive pattern rendering is suppressed +- filter hardening for already-supported primitives: + - invalid or empty `feColorMatrix` values + - negative `feGaussianBlur` values + - negative and fractional `feMorphology` radii + - lighting filter parameter mapping +- clip/use placement: + - referenced clip geometry keeps placement and transforms + - marker rendering is suppressed inside clip-resource compilation + - referenced content avoids marker/pattern recursion through nested resource paths +- image decode guards: + - empty data + - undecodable data + - blocked or missing resource data that reaches image decode +- focused unit coverage for implemented resource deltas that are not yet part of the green resvg visual slice Primary test impact: -- resvg `e-image-*`, `e-marker-*`, `e-pattern-*`, `e-linearGradient-*`, `e-radialGradient-*`, `e-mask-*`, `e-filter-*`, `e-fe*` +- resvg resource rendering fixture slice: + - `tests/filters/feComponentTransfer/*` + - `tests/filters/feDisplacementMap/*` + - `tests/filters/feDistantLight/*` + - `tests/filters/feTurbulence/*` + - `tests/masking/clip-rule/*` + - `tests/paint-servers/stop-color/*` + - `tests/painting/color/*` + - `tests/painting/fill-rule/*` + - `tests/painting/image-rendering/*` + - `tests/painting/isolation/*` + - `tests/painting/marker/*` + - `tests/painting/mix-blend-mode/*` + - `tests/painting/paint-order/*` + - `tests/painting/shape-rendering/*` + - `tests/painting/stroke*` + - `tests/painting/visibility/*` + - `tests/shapes/{circle,line,polygon,polyline,rect}/*` + - `tests/structure/{a,defs,g,transform,use}/*` +- focused model/Skia unit tests: + - gradient explicit default inheritance + - radial focal projection + - pattern `preserveAspectRatio` explicit default inheritance + - non-invertible pattern transform rejection + - CSS `clip: rect(...)` parsing + - invalid image decode guard + - spot-lit specular code generation argument mapping +- future deeper resource rows: + - resvg `e-image-*`, `e-marker-*`, `e-pattern-*`, `e-linearGradient-*`, `e-radialGradient-*`, `e-mask-*`, `e-filter-*`, `e-fe*` rows that require browser-only behavior or unsupported primitives + +Execution order: + +1. Lock the green resvg resource fixture slice as the first non-text resource harness. +2. Preserve gradient inheritance fixes and radial guard behavior for the paint-server rows. +3. Preserve pattern inheritance, transform invertibility checks, and pattern recursion guards. +4. Preserve filter hardening before adding the filter graph IR. +5. Preserve clip/use placement behavior so clip resources do not inherit marker output. +6. Preserve image decode guards before broadening external resource policy work. +7. Keep this slice free of new thresholds; add thresholds only in later visual-parity passes after row-specific review. +8. Start deeper resource rows only after this slice stays green in focused and full resvg runs. Acceptance criteria: -- Resvg non-text skip count drops materially after renderer fixes, not by baseline swapping. +- The resource rendering fixture slice is green without baseline swapping. +- No new thresholds or Chrome baseline swaps are required for this first slice. +- The implementation does not edit source fixtures or manufacture baselines for unsupported resource behavior. +- Deeper rows remain explicitly planned for `enable-background`, `BackgroundImage`, `BackgroundAlpha`, `feImage`, CSS `filter` functions, style selector/color parity, full mask self-reference behavior, and exact browser parity for pattern tiling/inheritance and marker placement. +- Resvg non-text skip count drops materially only after renderer fixes, not by baseline swapping. ### 5. SVG DOM / Script / Interaction Runtime @@ -257,18 +367,30 @@ Acceptance criteria: The next implementation tranche should be: -1. Add a vertical text placement branch in `src/Svg.SceneGraph/SvgSceneTextCompiler.cs` for browser-compatible fallback text. - Scope: vertical advance on Y, `text-anchor` along the vertical axis, perpendicular `baseline-shift`, and glyph rotation rules for Latin versus upright CJK. - Acceptance: `text-align-05-b`, `text-align-06-b`, and `text-intro-03-b` render vertically against the existing Chrome captures. -2. Introduce mixed-font bidi shaping support instead of per-span bidi wrapping. - Scope: preserve glyph fallback while shaping/reordering a single logical run, likely by adding run shaping support in the asset-loader/text-renderer layer rather than in `SvgSceneTextCompiler` alone. - Acceptance: `text-intro-02-b` and `text-intro-09-b` match Chrome ordering, and Arabic rows no longer depend on span-local fallback behavior. -3. Finish per-glyph coordinate list parity for `e-text-006..010`, `e-text-024`, `e-tspan-013`, and the remaining positioned `tref`/`tspan` cases. -4. Finish nested `tspan` rotate inheritance and shaping across span boundaries (`e-tspan-016/017/023/024/042`). -5. Stabilize `letter-spacing` and `word-spacing` against resvg references. -6. Implement `textLength` and `lengthAdjust` using run-level metrics that match final rendered glyph advances. -7. Extend `textPath` layout for `text-anchor`, vertical flow, per-child positioning, underline/rotate/baseline-shift, and transformed referenced paths. -8. Rebaseline any newly Chrome-backed W3C rows with `node scripts/capture_w3c_chrome_overrides.mjs` after renderer changes are proven against the live Chrome capture. +1. Keep the resvg resource rendering fixture slice green before broadening non-text enablement. + Scope: paint servers, masking, marker resources, and deterministic Skia-backed filter families. + Acceptance: the focused resource fixture harness passes without new thresholds or baseline swaps. +2. Stabilize gradient inheritance across referenced linear/radial gradients. + Scope: explicit `spreadMethod`, `gradientUnits`, and `gradientTransform` inheritance, negative radial radius handling, non-negative focal radius handling, and focal point projection into the outer circle. + Acceptance: paint-server gradient rows stay green without fallback baseline changes. +3. Stabilize pattern inheritance and recursion guards. + Scope: explicit pattern attribute inheritance, non-invertible transform rejection, nested pattern picture compilation with an active-pattern stack, and fallback paint-server behavior when recursive pattern rendering is suppressed. + Acceptance: pattern rows in the resource fixture slice stay green and recursive pattern cases fail deterministically. +4. Harden the existing Skia-backed filter primitive path. + Scope: invalid `feColorMatrix` values, negative `feGaussianBlur`, negative/fractional `feMorphology`, and lighting argument mapping. + Acceptance: Skia-backed filter rows in the resource fixture slice stay green; deeper filter graph work is not required for this slice. +5. Preserve clip/use placement behavior. + Scope: clip resources keep referenced geometry placement and transforms while suppressing markers and avoiding marker/pattern recursion through referenced content. + Acceptance: clip/masking resource rows stay green without reintroducing marker output into clip resources. +6. Preserve image decode guards. + Scope: empty, missing, blocked, or undecodable image data returns deterministic zero-size image metadata instead of crashing. + Acceptance: image-backed resource rows either render, skip for explicit unsupported behavior, or fail deterministically without null-image crashes. +7. Keep deeper resource rows explicit. + Scope: `enable-background`, `BackgroundImage`, `BackgroundAlpha`, `feImage`, CSS `filter` functions, style selector/color parity, full mask self-reference, and exact pattern/marker browser parity. + Acceptance: these rows remain skipped/planned with accurate reasons until their actual renderer/runtime support exists. +8. Run broader resvg and combined standards-area validation only after the focused resource slice is stable. + Scope: full resvg fixture matrix and `SvgAllAreaRegressionValidationBenchmarks` for paint servers, filters, masks/clips, images, and resource recursion. + Acceptance: no unrelated text/runtime changes are required to accept this resource-rendering slice. ## Runtime-Gated Groups diff --git a/scripts/capture_w3c_chrome_overrides.mjs b/scripts/capture_w3c_chrome_overrides.mjs index 64954effaa..1c94a60a62 100644 --- a/scripts/capture_w3c_chrome_overrides.mjs +++ b/scripts/capture_w3c_chrome_overrides.mjs @@ -15,8 +15,14 @@ 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 animationSeekOverrides = new Map([ - ['animate-dom-01-f', 2.5], +const w3cTestSuiteTestsPath = path.join(repoRoot, 'tests', 'Svg.Skia.UnitTests', 'W3CTestSuiteTests.cs'); +const animationSeekOverrides = await readAnimationSeekOverrides(); +const preSeekInteractionScripts = new Map([ + ['animate-elem-52-t', ` + dispatchMouseEvent(doc, win, 'A', 'click'); + dispatchMouseEvent(doc, win, 'B', 'click'); + dispatchMouseEvent(doc, win, 'C', 'click'); + `], ]); const interactionScripts = new Map([ ['interact-dom-01-b', ` @@ -53,6 +59,45 @@ const mimeTypes = new Map([ ['.txt', 'text/plain; charset=utf-8'], ]); +async function readAnimationSeekOverrides() +{ + const source = await fs.readFile(w3cTestSuiteTestsPath, 'utf8'); + const startMarker = '// W3C_ANIMATION_SEEK_TIMES_BEGIN'; + const endMarker = '// W3C_ANIMATION_SEEK_TIMES_END'; + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker); + + if (start < 0 || end < 0 || end <= start) + { + throw new Error(`Unable to find W3C animation seek-time markers in ${w3cTestSuiteTestsPath}.`); + } + + const tableSource = source.slice(start + startMarker.length, end); + const seekTimes = new Map(); + const rowPattern = /^\s*\["([^"]+)"\]\s*=\s*([0-9]+(?:\.[0-9]+)?)/gm; + let match; + + while ((match = rowPattern.exec(tableSource)) !== null) + { + const name = match[1]; + const seconds = Number(match[2]); + + if (seekTimes.has(name)) + { + throw new Error(`Duplicate W3C animation seek time for ${name}.`); + } + + seekTimes.set(name, seconds); + } + + if (seekTimes.size < 1) + { + throw new Error(`No W3C animation seek times found in ${w3cTestSuiteTestsPath}.`); + } + + return seekTimes; +} + function getContentType(filePath) { return mimeTypes.get(path.extname(filePath).toLowerCase()) ?? 'application/octet-stream'; @@ -119,6 +164,7 @@ 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 animationSeekTime = animationSeekOverrides.get(name) ?? null; + const preSeekInteractionScript = preSeekInteractionScripts.get(name) ?? ''; const interactionScript = interactionScripts.get(name) ?? ''; const html = ` @@ -170,33 +216,127 @@ async function writeWrapper(name) })); } - if (animationSeekTime !== null) { - const frame = document.getElementById('capture'); + const frame = document.getElementById('capture'); + function runWhenFrameReady(callback) { + let didRun = false; + const run = () => { + if (didRun) { + return; + } + + didRun = true; + callback(); + }; + const tryRun = () => { + try { + const doc = frame.contentDocument; + const href = frame.contentWindow?.location?.href; + if (href && href !== 'about:blank' && doc?.documentElement && (doc.readyState === undefined || doc.readyState === 'interactive' || doc.readyState === 'complete')) { + run(); + return true; + } + } catch { + } + + return false; + }; + + if (tryRun()) { + return; + } + frame.addEventListener('load', () => { + tryRun(); + }, { once: true }); + let attempts = 0; + const retry = () => { + if (tryRun() || ++attempts >= 40) { + return; + } + + setTimeout(retry, 50); + }; + setTimeout(retry, 50); + } + + let pendingCaptureReady = animationSeekTime !== null ? 2 : 1; + function completeCaptureReadyPart() { + pendingCaptureReady -= 1; + if (pendingCaptureReady > 0 || document.getElementById('capture-ready')) { + return; + } + + const ready = document.createElement('div'); + ready.id = 'capture-ready'; + ready.style.position = 'absolute'; + ready.style.left = '-9999px'; + ready.style.top = '-9999px'; + ready.style.width = '1px'; + ready.style.height = '1px'; + document.body.appendChild(ready); + } + + if (animationSeekTime !== null) { + runWhenFrameReady(() => { try { + const win = frame.contentWindow; + const doc = frame.contentDocument; + if (!win || !doc) { + completeCaptureReadyPart(); + return; + } + + ${preSeekInteractionScript} + const svg = frame.contentDocument?.documentElement; if (svg && typeof svg.setCurrentTime === 'function') { - svg.setCurrentTime(animationSeekTime); + const seek = () => { + try { + if (typeof svg.pauseAnimations === 'function') { + svg.pauseAnimations(); + } + svg.setCurrentTime(animationSeekTime); + } catch { + } + }; + + seek(); + frame.contentWindow?.requestAnimationFrame(() => { + seek(); + frame.contentWindow?.setTimeout(seek, 100); + frame.contentWindow?.setTimeout(() => { + seek(); + completeCaptureReadyPart(); + }, 500); + }); + } + else { + completeCaptureReadyPart(); } } catch { + completeCaptureReadyPart(); } - }, { once: true }); + }); } - const frame = document.getElementById('capture'); - frame.addEventListener('load', () => { + runWhenFrameReady(() => { try { const win = frame.contentWindow; const doc = frame.contentDocument; if (!win || !doc) { + completeCaptureReadyPart(); return; } ${interactionScript} + frame.contentWindow?.requestAnimationFrame(() => { + frame.contentWindow?.setTimeout(completeCaptureReadyPart, 0); + }); } catch (error) { console.error(error); + completeCaptureReadyPart(); } - }, { once: true }); + }); `; @@ -222,6 +362,8 @@ async function captureOverride(baseUrl, name) 'chrome', '--viewport-size', '480,360', + '--wait-for-selector', + '#capture-ready', '--wait-for-timeout', '1500', '--timeout', diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs index af742d2e0c..f1148dd4f9 100644 --- a/src/Svg.Animation/Animation/SvgAnimationController.cs +++ b/src/Svg.Animation/Animation/SvgAnimationController.cs @@ -4,6 +4,7 @@ using System.Drawing; using System.Globalization; using System.Linq; +using System.Text; using SkiaSharp; using Svg.Transforms; @@ -50,14 +51,16 @@ public TimingSpec(TimeSpan offset) Offset = offset; EventAddress = null; EventType = default; + RepeatIteration = null; } - public TimingSpec(SvgElementAddress eventAddress, SvgAnimationTimingEventType eventType, TimeSpan offset) + public TimingSpec(SvgElementAddress eventAddress, SvgAnimationTimingEventType eventType, TimeSpan offset, int? repeatIteration = null) { IsEvent = true; Offset = offset; EventAddress = eventAddress; EventType = eventType; + RepeatIteration = repeatIteration; } public bool IsEvent { get; } @@ -67,6 +70,8 @@ public TimingSpec(SvgElementAddress eventAddress, SvgAnimationTimingEventType ev public SvgElementAddress? EventAddress { get; } public SvgAnimationTimingEventType EventType { get; } + + public int? RepeatIteration { get; } } private readonly struct MotionSource @@ -120,9 +125,9 @@ public AnimationBinding(SvgAnimationElement animation, SvgElement sourceTarget, var propertyDescriptor = GetAttributePropertyDescriptor(sourceTarget, attributeName); ValueConverter = propertyDescriptor?.Converter; ValueContext = sourceTarget.OwnerDocument; - HasExplicitBaseAttribute = sourceTarget.ContainsAttribute(attributeName); BaseValue = GetAttributeValue(sourceTarget, attributeName); BaseValueString = ConvertAttributeValueToString(BaseValue); + HasExplicitBaseAttribute = HasExplicitAnimationBaseAttribute(sourceTarget, attributeName, BaseValue); PropertyType = propertyDescriptor?.Type ?? BaseValue?.GetType(); TargetAttributeKey = string.Concat(targetAddress.Key, "|", attributeName); BeginSpecs = ParseTimingSpecifications(animation.Begin, animation.OwnerDocument, targetAddress, includeImplicitDocumentBegin: true); @@ -224,9 +229,33 @@ public AnimationSample(float progress, int iterationIndex) public int IterationIndex { get; } } + private readonly struct PathDataToken + { + public PathDataToken(char command) + { + IsCommand = true; + Command = command; + Number = 0f; + } + + public PathDataToken(float number) + { + IsCommand = false; + Command = '\0'; + Number = number; + } + + public bool IsCommand { get; } + + public char Command { get; } + + public float Number { get; } + } + private static readonly TypeConverter s_paintServerConverter = new SvgPaintServerFactory(); private readonly List _bindings; + private readonly List _frameEvaluationBindings; private readonly Dictionary _bindingsByTargetAttributeKey; private readonly Dictionary _bindingsByAnimationAddressKey; private readonly Dictionary> _pointerEventInstances = new(StringComparer.Ordinal); @@ -246,6 +275,7 @@ public SvgAnimationController(SvgDocument sourceDocument) Clock = new SvgAnimationClock(); Clock.TimeChanged += OnClockTimeChanged; _bindings = DiscoverBindings(sourceDocument); + _frameEvaluationBindings = CreateFrameEvaluationBindings(_bindings); _bindingsByTargetAttributeKey = BuildBindingLookup(_bindings); _bindingsByAnimationAddressKey = BuildAnimationBindingLookup(_bindings); _pointerEventDependencies = BuildPointerEventDependencies(_bindings); @@ -378,35 +408,27 @@ internal bool TryGetStartTime(SvgAnimationElement animation, TimeSpan currentTim return false; } - var beginInstances = ResolveBeginInstances(binding, recursionGuard: null); - if (beginInstances.Count == 0) + var intervals = ResolveAnimationIntervals(binding, recursionGuard: null); + if (intervals.Count == 0) { return false; } - var endInstances = ResolveEndTimingInstances(binding, recursionGuard: null); - var allowIndefiniteDiscrete = binding.Animation is SvgSet; - - if (TryResolveCurrentActiveIntervalDetailed(binding.Animation, currentTime, allowIndefiniteDiscrete, beginInstances, endInstances, out var activeInterval)) + if (TrySelectCurrentInterval(binding.Animation, currentTime, intervals, requireActive: true, out var activeInterval)) { startTime = activeInterval.BeginInstance.Time; return true; } - for (var index = 0; index < beginInstances.Count; index++) + for (var index = 0; index < intervals.Count; index++) { - var candidate = beginInstances[index]; - if (candidate.Time <= currentTime) - { - continue; - } - - if (!TryResolveIntervalEndDetailed(binding.Animation, candidate.Time, endInstances, allowIndefiniteDiscrete, out _, out _)) + var candidate = intervals[index]; + if (candidate.BeginInstance.Time <= currentTime) { continue; } - startTime = candidate.Time; + startTime = candidate.BeginInstance.Time; return true; } @@ -470,6 +492,7 @@ internal SvgDocument CreateAnimatedDocument(SvgAnimationFrameState frameState) var clone = SourceDocument.DeepCopy() as SvgDocument ?? throw new InvalidOperationException("Svg animation runtime requires SvgDocument.DeepCopy() to return SvgDocument."); + clone.RebindSameDocumentDeferredPaintServers(); if (_bindings.Count == 0) { @@ -498,14 +521,34 @@ internal SvgAnimationFrameState EvaluateFrameState(TimeSpan time) } var attributes = new Dictionary(StringComparer.Ordinal); - foreach (var binding in _bindings) + var motionTransformPrefixes = new Dictionary(StringComparer.Ordinal); + var transformAnimationValues = new Dictionary(StringComparer.Ordinal); + foreach (var binding in _frameEvaluationBindings) { attributes.TryGetValue(binding.TargetAttributeKey, out var currentAttributeState); - if (!TryResolveAnimatedAttributeValue(this, binding, time, currentAttributeState?.Value, out var value)) + if (!TryResolveAnimatedAttributeValue(this, binding, time, currentAttributeState?.Value, attributes, out var value)) { continue; } + if (binding.Animation is SvgAnimateMotion { Additive: not SvgAnimationAdditive.Sum }) + { + motionTransformPrefixes[binding.TargetAttributeKey] = value; + if (transformAnimationValues.TryGetValue(binding.TargetAttributeKey, out var transformAnimationValue)) + { + value = CombineTransformValue(value, transformAnimationValue); + } + } + else if (binding.Animation is SvgAnimateTransform animateTransform) + { + transformAnimationValues[binding.TargetAttributeKey] = value; + if (animateTransform.Additive != SvgAnimationAdditive.Sum && + motionTransformPrefixes.TryGetValue(binding.TargetAttributeKey, out var motionTransformPrefix)) + { + value = CombineTransformValue(motionTransformPrefix, value); + } + } + attributes[binding.TargetAttributeKey] = new SvgAnimationFrameAttributeState( binding.TargetAttributeKey, binding.TargetAddress, @@ -522,44 +565,90 @@ internal void ApplyFrameState(SvgDocument document, SvgAnimationFrameState frame { ThrowIfDisposed(); - foreach (var attribute in frameState.EnumerateDirtyAttributes(previousState)) + List? deferredSelectorRemovals = null; + foreach (var removedKey in frameState.EnumerateRemovedKeys(previousState)) { - var target = attribute.TargetAddress.Resolve(document); - if (target is null) + if (!_bindingsByTargetAttributeKey.TryGetValue(removedKey, out var binding)) + { + continue; + } + + if (RequiresSelectorStyleReapplication(binding.AttributeName)) { + (deferredSelectorRemovals ??= new List()).Add(binding); continue; } - _ = SetAttributeValue(target, attribute.AttributeName, attribute.Value); + ApplyRemovedFrameAttribute(document, binding); } - foreach (var removedKey in frameState.EnumerateRemovedKeys(previousState)) + List? deferredAttributes = null; + foreach (var attribute in frameState.EnumerateDirtyAttributes(previousState)) { - if (!_bindingsByTargetAttributeKey.TryGetValue(removedKey, out var binding)) + if (!RequiresSelectorStyleReapplication(attribute.AttributeName)) { + (deferredAttributes ??= new List()).Add(attribute); continue; } - var target = binding.TargetAddress.Resolve(document); - if (target is null) + ApplyDirtyFrameAttribute(document, attribute); + } + + if (deferredSelectorRemovals is not null) + { + foreach (var binding in deferredSelectorRemovals) { - continue; + ApplyRemovedFrameAttribute(document, binding); } + } - if (binding.BaseValueString is not null) + if (deferredAttributes is not null) + { + foreach (var attribute in deferredAttributes) { - _ = SetAttributeValue(target, binding.AttributeName, binding.BaseValueString); + ApplyDirtyFrameAttribute(document, attribute); + } + } + } - if (!binding.HasExplicitBaseAttribute) - { - _ = ClearAttributeValue(target, binding.AttributeName); - } + private static void ApplyDirtyFrameAttribute(SvgDocument document, SvgAnimationFrameAttributeState attribute) + { + var target = attribute.TargetAddress.Resolve(document); + if (target is null) + { + return; + } - continue; + _ = SetAttributeValue(target, attribute.AttributeName, attribute.Value); + } + + private static void ApplyRemovedFrameAttribute(SvgDocument document, AnimationBinding binding) + { + var target = binding.TargetAddress.Resolve(document); + if (target is null) + { + return; + } + + if (binding.BaseValueString is not null) + { + _ = SetAttributeValue(target, binding.AttributeName, binding.BaseValueString); + + if (!binding.HasExplicitBaseAttribute) + { + _ = ClearAttributeValue(target, binding.AttributeName); } - _ = ClearAttributeValue(target, binding.AttributeName); + return; } + + _ = ClearAttributeValue(target, binding.AttributeName); + } + + private static bool RequiresSelectorStyleReapplication(string attributeName) + { + return string.Equals(attributeName, "class", StringComparison.Ordinal) || + string.Equals(attributeName, "style", StringComparison.Ordinal); } public bool RecordPointerEvent(SvgElement? element, SvgPointerEventType eventType) @@ -638,7 +727,7 @@ private bool TryGetBinding(SvgAnimationElement animation, out AnimationBinding b return _bindingsByAnimationAddressKey.TryGetValue(SvgElementAddress.Create(animation).Key, out binding!); } - private IReadOnlyList ResolveBeginInstances(AnimationBinding binding, HashSet? recursionGuard) + private IReadOnlyList ResolveBeginInstances(AnimationBinding binding, HashSet? recursionGuard, TimeSpan? horizon = null) { if (!binding.HasDynamicBeginTiming && (!_scheduledBeginInstances.TryGetValue(binding.AnimationAddress.Key, out var scheduledTimes) || scheduledTimes.Count == 0)) @@ -654,7 +743,7 @@ private IReadOnlyList ResolveBeginInstances(AnimationBin try { - var instances = ResolveTimingInstancesDetailed(binding.BeginSpecs, recursionGuard); + var instances = ResolveTimingInstancesDetailed(binding.BeginSpecs, recursionGuard, horizon); AppendScheduledInstances(binding.AnimationAddress, SvgAnimationTimingEventType.Begin, _scheduledBeginInstances, instances); SortAndDeduplicateTimingInstances(instances); return instances.Count == 0 ? s_emptyResolvedTimingInstances : instances; @@ -665,7 +754,7 @@ private IReadOnlyList ResolveBeginInstances(AnimationBin } } - private IReadOnlyList ResolveEndTimingInstances(AnimationBinding binding, HashSet? recursionGuard) + private IReadOnlyList ResolveEndTimingInstances(AnimationBinding binding, HashSet? recursionGuard, TimeSpan? horizon = null) { if (!binding.HasDynamicEndTiming && (!_scheduledEndInstances.TryGetValue(binding.AnimationAddress.Key, out var scheduledTimes) || scheduledTimes.Count == 0)) @@ -681,7 +770,7 @@ private IReadOnlyList ResolveEndTimingInstances(Animatio try { - var instances = ResolveTimingInstancesDetailed(binding.EndSpecs, recursionGuard); + var instances = ResolveTimingInstancesDetailed(binding.EndSpecs, recursionGuard, horizon); AppendScheduledInstances(binding.AnimationAddress, SvgAnimationTimingEventType.End, _scheduledEndInstances, instances); SortAndDeduplicateTimingInstances(instances); return instances.Count == 0 ? s_emptyResolvedTimingInstances : instances; @@ -811,6 +900,26 @@ private void CollectTimelineCallbacks( "onend", endTime)); } + + for (var index = 0; index < intervals.Count; index++) + { + var repeatTimes = new List(); + AddRepeatEventInstances(binding.Animation, intervals[index], requestedIteration: null, repeatTimes); + for (var repeatIndex = 0; repeatIndex < repeatTimes.Count; repeatIndex++) + { + var repeatTime = repeatTimes[repeatIndex]; + if (!ShouldDispatchTimelineEvent(repeatTime, previousTime, currentTime)) + { + continue; + } + + callbacks.Add(new SvgAnimationTimelineCallback( + binding.AnimationAddress, + "repeatEvent", + "onrepeat", + repeatTime)); + } + } } private static bool ShouldDispatchTimelineEvent(TimeSpan eventTime, TimeSpan? previousTime, TimeSpan currentTime) @@ -899,6 +1008,16 @@ private static void AddPointerEventDependents( } } + private static List CreateFrameEvaluationBindings(List bindings) + { + return bindings + .Select(static (binding, index) => (binding, index)) + .OrderBy(static entry => IsCurrentColorSourceAttribute(entry.binding.AttributeName) ? 0 : 1) + .ThenBy(static entry => entry.index) + .Select(static entry => entry.binding) + .ToList(); + } + private static Dictionary BuildBindingLookup(IEnumerable bindings) { var lookup = new Dictionary(StringComparer.Ordinal); @@ -993,6 +1112,23 @@ private static bool TryGetPointerEventInstanceKey(TimingSpec spec, out string ke return false; } + private static bool HasExplicitAnimationBaseAttribute(SvgElement sourceTarget, string attributeName, object? baseValue) + { + if (sourceTarget.ContainsAttribute(attributeName)) + { + return true; + } + + return IsHrefAnimationAttribute(attributeName) && baseValue is not null; + } + + private static bool IsHrefAnimationAttribute(string attributeName) + { + return string.Equals(attributeName, "href", StringComparison.Ordinal) || + string.Equals(attributeName, "xlink:href", StringComparison.Ordinal) || + attributeName.StartsWith(SvgNamespaces.XLinkNamespace + ":href", StringComparison.Ordinal); + } + private static List DiscoverBindings(SvgDocument sourceDocument) { var bindings = new List(); @@ -1007,12 +1143,76 @@ private static List DiscoverBindings(SvgDocument sourceDocumen continue; } + if (ShouldIgnoreBrowserUnsupportedAnimateColorBinding(animation, target, attributeName!)) + { + continue; + } + bindings.Add(new AnimationBinding(animation, target, SvgElementAddress.Create(target), attributeName!)); } return bindings; } + private static bool ShouldIgnoreBrowserUnsupportedAnimateColorBinding( + SvgAnimationElement animation, + SvgElement target, + string attributeName) + { + // Current browser snapshots do not apply deprecated animateColor to inherited + // paint-server color state declared in defs, while regular animate still applies. + if (animation is not SvgAnimateColor || + !IsInheritedPaintServerColorAttribute(attributeName) || + !IsInsideDefinitions(target)) + { + return false; + } + + return SubtreeContainsPaintServer(target); + } + + private static bool IsInheritedPaintServerColorAttribute(string attributeName) + { + return string.Equals(attributeName, "color", StringComparison.Ordinal) || + string.Equals(attributeName, "stop-color", StringComparison.Ordinal); + } + + private static bool IsCurrentColorSourceAttribute(string attributeName) + { + return string.Equals(attributeName, "color", StringComparison.Ordinal); + } + + private static bool IsInsideDefinitions(SvgElement element) + { + for (var current = element.Parent as SvgElement; current is not null; current = current.Parent as SvgElement) + { + if (current is SvgDefinitionList) + { + return true; + } + } + + return false; + } + + private static bool SubtreeContainsPaintServer(SvgElement element) + { + if (element is SvgPaintServer) + { + return true; + } + + for (var i = 0; i < element.Children.Count; i++) + { + if (SubtreeContainsPaintServer(element.Children[i])) + { + return true; + } + } + + return false; + } + private void InvalidateFrameStateCache() { _frameStateVersion++; @@ -1133,7 +1333,7 @@ private static bool TryParseEventTimingSpec(string value, SvgDocument? document, return false; } - spec = new TimingSpec(parsedTiming.EventAddress, parsedTiming.EventType, parsedTiming.Offset); + spec = new TimingSpec(parsedTiming.EventAddress, parsedTiming.EventType, parsedTiming.Offset, parsedTiming.RepeatIteration); return true; } @@ -1148,22 +1348,79 @@ private static bool TryParseEventTimingSpec(string value, SvgDocument? document, { return string.IsNullOrWhiteSpace(animateTransform.AnimationAttributeName) ? "transform" - : animateTransform.AnimationAttributeName; + : ResolveNamespacedAnimationAttributeName(animation, animateTransform.AnimationAttributeName); } if (animation is SvgAnimationAttributeElement attributeAnimation) { - return attributeAnimation.AnimationAttributeName; + return ResolveNamespacedAnimationAttributeName(animation, attributeAnimation.AnimationAttributeName); } return null; } + private static string? ResolveNamespacedAnimationAttributeName(SvgAnimationElement animation, string? attributeName) + { + if (string.IsNullOrWhiteSpace(attributeName)) + { + return attributeName; + } + + var resolvedAttributeName = attributeName!; + var colonIndex = resolvedAttributeName.IndexOf(':'); + if (colonIndex <= 0 || colonIndex == resolvedAttributeName.Length - 1) + { + return resolvedAttributeName; + } + + var prefix = resolvedAttributeName.Substring(0, colonIndex); + var localName = resolvedAttributeName.Substring(colonIndex + 1); + if (TryResolveNamespace(animation, prefix, out var namespaceName)) + { + if (string.Equals(namespaceName, SvgNamespaces.XLinkNamespace, StringComparison.Ordinal)) + { + return string.Concat("xlink:", localName); + } + + if (string.Equals(namespaceName, SvgNamespaces.XmlNamespace, StringComparison.Ordinal)) + { + return string.Concat("xml:", localName); + } + + return resolvedAttributeName; + } + + return resolvedAttributeName; + } + + private static bool TryResolveNamespace(SvgElement element, string prefix, out string namespaceName) + { + for (SvgElement? current = element; current is not null; current = current.Parent as SvgElement) + { + if (current.Namespaces.TryGetValue(prefix, out var resolvedNamespace)) + { + namespaceName = resolvedNamespace; + return true; + } + } + + var document = element as SvgDocument ?? element.OwnerDocument; + if (document is not null && document.Namespaces.TryGetValue(prefix, out var documentNamespace)) + { + namespaceName = documentNamespace; + return true; + } + + namespaceName = string.Empty; + return false; + } + private static bool TryResolveAnimatedAttributeValue( SvgAnimationController controller, AnimationBinding binding, TimeSpan time, string? currentComposedValue, + IReadOnlyDictionary? frameAttributes, out string value) { value = string.Empty; @@ -1207,7 +1464,7 @@ private static bool TryResolveAnimatedAttributeValue( return true; case SvgAnimateColor animateColor: if (!TryGetAnimationSample(controller, binding, animateColor, time, allowIndefiniteDiscrete: false, out var colorSample) || - !TryResolveAnimatedValue(binding, animateColor, colorSample, forceColorInterpolation: true, out value)) + !TryResolveAnimatedValue(binding, animateColor, colorSample, forceColorInterpolation: true, frameAttributes, out value)) { return false; } @@ -1222,7 +1479,7 @@ private static bool TryResolveAnimatedAttributeValue( return true; case SvgAnimate animate: if (!TryGetAnimationSample(controller, binding, animate, time, allowIndefiniteDiscrete: false, out var valueSample) || - !TryResolveAnimatedValue(binding, animate, valueSample, forceColorInterpolation: false, out value)) + !TryResolveAnimatedValue(binding, animate, valueSample, forceColorInterpolation: false, frameAttributes, out value)) { return false; } @@ -1290,7 +1547,7 @@ private static void ApplyAnimateValue(SvgAnimationController controller, Animati return; } - if (!TryResolveAnimatedValue(binding, animation, sample, forceColorInterpolation, out var value)) + if (!TryResolveAnimatedValue(binding, animation, sample, forceColorInterpolation, frameAttributes: null, out var value)) { return; } @@ -1379,7 +1636,7 @@ public AnimationInterval(TimeSpan begin, TimeSpan? activeEnd) public TimeSpan? ActiveEnd { get; } - public bool IsActive(TimeSpan time) => !ActiveEnd.HasValue || time <= ActiveEnd.Value; + public bool IsActive(TimeSpan time) => IsTimeInActiveInterval(Begin, ActiveEnd, time); } private readonly struct ResolvedAnimationInterval @@ -1397,7 +1654,19 @@ public ResolvedAnimationInterval(ResolvedTimingInstance beginInstance, TimeSpan? public ResolvedTimingInstance? EndInstance { get; } - public bool IsActive(TimeSpan time) => !ActiveEnd.HasValue || time <= ActiveEnd.Value; + public bool IsActive(TimeSpan time) => IsTimeInActiveInterval(BeginInstance.Time, ActiveEnd, time); + } + + private static bool IsTimeInActiveInterval(TimeSpan begin, TimeSpan? activeEnd, TimeSpan time) + { + if (!activeEnd.HasValue) + { + return true; + } + + return activeEnd.Value == begin + ? time == begin + : time < activeEnd.Value; } private static bool TryGetAnimationSample(SvgAnimationController controller, AnimationBinding binding, SvgAnimationElement animation, TimeSpan time, bool allowIndefiniteDiscrete, out AnimationSample sample) @@ -1498,14 +1767,8 @@ private static bool TryResolveCurrentInterval(SvgAnimationController controller, { interval = default; - var beginInstances = controller.ResolveBeginInstances(binding, recursionGuard: null); - if (beginInstances.Count == 0) - { - return false; - } - - var endInstances = controller.ResolveEndTimingInstances(binding, recursionGuard: null); - if (!TryResolveCurrentIntervalDetailed(animation, time, allowIndefiniteDiscrete, beginInstances, endInstances, out var resolved)) + var intervals = controller.ResolveAnimationIntervals(binding, recursionGuard: null, horizon: time); + if (!TrySelectCurrentInterval(animation, time, intervals, requireActive: false, out var resolved)) { return false; } @@ -1525,7 +1788,7 @@ private static bool TryResolveCurrentInterval(SvgAnimationController controller, return false; } - private List ResolveTimingInstancesDetailed(IReadOnlyList specs, HashSet? recursionGuard) + private List ResolveTimingInstancesDetailed(IReadOnlyList specs, HashSet? recursionGuard, TimeSpan? horizon = null) { var instances = new List(); @@ -1556,7 +1819,7 @@ private List ResolveTimingInstancesDetailed(IReadOnlyLis continue; } - var dependencyInstances = ResolveAnimationEventInstances(spec, recursionGuard); + var dependencyInstances = ResolveAnimationEventInstances(spec, recursionGuard, horizon); if (dependencyInstances.Count == 0) { continue; @@ -1577,7 +1840,7 @@ private List ResolveTimingInstancesDetailed(IReadOnlyLis return instances; } - private List ResolveAnimationEventInstances(TimingSpec spec, HashSet? recursionGuard) + private List ResolveAnimationEventInstances(TimingSpec spec, HashSet? recursionGuard, TimeSpan? horizon = null) { if (spec.EventAddress is null || !_bindingsByAnimationAddressKey.TryGetValue(spec.EventAddress.Key, out var dependencyBinding)) @@ -1585,7 +1848,7 @@ private List ResolveAnimationEventInstances(TimingSpec spec, HashSet(); } - var dependencyIntervals = ResolveAnimationIntervals(dependencyBinding, recursionGuard); + var dependencyIntervals = ResolveAnimationIntervals(dependencyBinding, recursionGuard, horizon); if (dependencyIntervals.Count == 0) { return new List(); @@ -1606,6 +1869,9 @@ private List ResolveAnimationEventInstances(TimingSpec spec, HashSet ResolveAnimationEventInstances(TimingSpec spec, HashSet ResolveAnimationIntervals(AnimationBinding binding, HashSet? recursionGuard) + private static void AddRepeatEventInstances( + SvgAnimationElement animation, + ResolvedAnimationInterval interval, + int? requestedIteration, + List instances) + { + if (!TryParseClockValue(animation.Duration, out var simpleDuration) || simpleDuration <= TimeSpan.Zero) + { + return; + } + + var activeEnd = interval.ActiveEnd; + if (requestedIteration.HasValue) + { + AddRepeatEventInstance(interval.BeginInstance.Time, simpleDuration, activeEnd, requestedIteration.Value, instances); + return; + } + + var repeatLimit = ResolveRepeatEventLimit(animation, simpleDuration, interval); + for (var iteration = 1; iteration <= repeatLimit; iteration++) + { + AddRepeatEventInstance(interval.BeginInstance.Time, simpleDuration, activeEnd, iteration, instances); + } + } + + private static int ResolveRepeatEventLimit(SvgAnimationElement animation, TimeSpan simpleDuration, ResolvedAnimationInterval interval) + { + const int maxUnboundedRepeatEvents = 4096; + var repeatCountMode = ParseRepeatCount(animation.RepeatCount, out var repeatCount); + if (repeatCountMode == RepeatCountMode.Finite) + { + return Math.Max(0, (int)Math.Ceiling(repeatCount) - 1); + } + + if (interval.ActiveEnd.HasValue) + { + var elapsed = interval.ActiveEnd.Value - interval.BeginInstance.Time; + if (elapsed <= TimeSpan.Zero) + { + return 0; + } + + return Math.Max(0, (int)(elapsed.Ticks / simpleDuration.Ticks)); + } + + return maxUnboundedRepeatEvents; + } + + private static void AddRepeatEventInstance( + TimeSpan begin, + TimeSpan simpleDuration, + TimeSpan? activeEnd, + int iteration, + List instances) + { + if (iteration <= 0) + { + return; + } + + var time = begin + Multiply(simpleDuration, iteration); + if (time <= begin) + { + return; + } + + if (activeEnd.HasValue && time >= activeEnd.Value) + { + return; + } + + instances.Add(time); + } + + private List ResolveAnimationIntervals(AnimationBinding binding, HashSet? recursionGuard, TimeSpan? horizon = null) { - var beginInstances = ResolveBeginInstances(binding, recursionGuard); + var beginInstances = ResolveBeginInstances(binding, recursionGuard, horizon); if (beginInstances.Count == 0) { return new List(); } - var endInstances = ResolveEndTimingInstances(binding, recursionGuard); + var endInstances = ResolveEndTimingInstances(binding, recursionGuard, horizon); var allowIndefiniteDiscrete = binding.Animation is SvgSet; + if (HasSelfEventTiming(binding.BeginSpecs, binding.AnimationAddress) || + HasSelfEventTiming(binding.EndSpecs, binding.AnimationAddress)) + { + return ResolveAnimationIntervalsWithSelfSync(binding, beginInstances, endInstances, allowIndefiniteDiscrete, horizon); + } + + return ResolveAnimationIntervalsCore(binding, beginInstances, endInstances, allowIndefiniteDiscrete, horizon); + } + + private static List ResolveAnimationIntervalsCore( + AnimationBinding binding, + IReadOnlyList beginInstances, + IReadOnlyList endInstances, + bool allowIndefiniteDiscrete, + TimeSpan? horizon) + { var intervals = new List(); ResolvedAnimationInterval? selected = null; for (var index = 0; index < beginInstances.Count; index++) { var begin = beginInstances[index]; + if (horizon.HasValue && begin.Time > horizon.Value) + { + break; + } if (selected.HasValue) { @@ -1647,11 +2007,15 @@ private List ResolveAnimationIntervals(AnimationBindi continue; } + break; + case SvgAnimationRestart.Always: + TruncateRestartedInterval(intervals, selected.Value, begin.Time); break; } } - if (!TryResolveIntervalEndDetailed(binding.Animation, begin.Time, endInstances, allowIndefiniteDiscrete, out var activeEnd, out var endInstance)) + var effectiveEndInstances = CreateEffectiveEndInstances(binding, endInstances, begin.Time, horizon); + if (!TryResolveIntervalEndDetailed(binding.Animation, begin.Time, effectiveEndInstances, allowIndefiniteDiscrete, out var activeEnd, out var endInstance)) { continue; } @@ -1663,13 +2027,314 @@ private List ResolveAnimationIntervals(AnimationBindi return intervals; } - private static bool TryResolveCurrentIntervalDetailed( - SvgAnimationElement animation, - TimeSpan time, - bool allowIndefiniteDiscrete, - IReadOnlyList beginInstances, + private static List ResolveAnimationIntervalsWithSelfSync( + AnimationBinding binding, + IReadOnlyList seedBeginInstances, IReadOnlyList endInstances, - out ResolvedAnimationInterval interval) + bool allowIndefiniteDiscrete, + TimeSpan? horizon) + { + const int maxSelfSyncIntervals = 4096; + + var pendingBeginInstances = new List(seedBeginInstances); + SortAndDeduplicateTimingInstances(pendingBeginInstances); + + var knownBeginTimes = new HashSet(); + for (var index = 0; index < pendingBeginInstances.Count; index++) + { + knownBeginTimes.Add(pendingBeginInstances[index].Time.Ticks); + } + + var intervals = new List(); + ResolvedAnimationInterval? selected = null; + var cursor = 0; + + while (cursor < pendingBeginInstances.Count && intervals.Count < maxSelfSyncIntervals) + { + var begin = pendingBeginInstances[cursor++]; + if (horizon.HasValue && begin.Time > horizon.Value) + { + break; + } + + if (selected.HasValue) + { + switch (binding.Animation.Restart) + { + case SvgAnimationRestart.Never: + continue; + case SvgAnimationRestart.WhenNotActive: + if (!selected.Value.ActiveEnd.HasValue || begin.Time < selected.Value.ActiveEnd.Value) + { + continue; + } + + break; + case SvgAnimationRestart.Always: + TruncateRestartedInterval(intervals, selected.Value, begin.Time); + break; + } + } + + var effectiveEndInstances = CreateEffectiveEndInstances(binding, endInstances, begin.Time, horizon); + if (!TryResolveIntervalEndDetailed(binding.Animation, begin.Time, effectiveEndInstances, allowIndefiniteDiscrete, out var activeEnd, out var endInstance)) + { + continue; + } + + selected = new ResolvedAnimationInterval(begin, activeEnd, endInstance); + intervals.Add(selected.Value); + + AddSelfSyncBeginInstances( + binding, + selected.Value, + pendingBeginInstances, + knownBeginTimes, + horizon); + + if (pendingBeginInstances.Count > cursor) + { + SortAndDeduplicateTimingInstances(pendingBeginInstances); + } + } + + return intervals; + } + + private static void TruncateRestartedInterval( + List intervals, + ResolvedAnimationInterval selected, + TimeSpan restartTime) + { + if (intervals.Count == 0 || + (selected.ActiveEnd.HasValue && selected.ActiveEnd.Value <= restartTime)) + { + return; + } + + intervals[intervals.Count - 1] = new ResolvedAnimationInterval( + selected.BeginInstance, + restartTime, + selected.EndInstance); + } + + private static IReadOnlyList CreateEffectiveEndInstances( + AnimationBinding binding, + IReadOnlyList endInstances, + TimeSpan beginTime, + TimeSpan? horizon) + { + if (!HasSelfEventTiming(binding.EndSpecs, binding.AnimationAddress)) + { + return endInstances; + } + + var effective = new List(endInstances); + for (var index = 0; index < binding.EndSpecs.Count; index++) + { + var spec = binding.EndSpecs[index]; + if (!IsSelfEventTiming(spec, binding.AnimationAddress)) + { + continue; + } + + var eventInstanceKey = CreateEventInstanceKey(binding.AnimationAddress, spec.EventType); + switch (spec.EventType) + { + case SvgAnimationTimingEventType.Begin: + AddSelfEndInstance(beginTime, beginTime, spec.Offset, eventInstanceKey, effective, horizon); + break; + case SvgAnimationTimingEventType.Repeat: + var provisionalInterval = new ResolvedAnimationInterval( + new ResolvedTimingInstance(beginTime, eventInstanceKey: null, sourceEventTime: null), + activeEnd: null, + endInstance: null); + var repeatTimes = new List(); + AddRepeatEventInstances(binding.Animation, provisionalInterval, spec.RepeatIteration, repeatTimes); + for (var repeatIndex = 0; repeatIndex < repeatTimes.Count; repeatIndex++) + { + AddSelfEndInstance(repeatTimes[repeatIndex], beginTime, spec.Offset, eventInstanceKey, effective, horizon); + } + + break; + } + } + + SortAndDeduplicateTimingInstances(effective); + return effective.Count == 0 ? s_emptyResolvedTimingInstances : effective; + } + + private static void AddSelfEndInstance( + TimeSpan eventTime, + TimeSpan sourceBegin, + TimeSpan offset, + string eventInstanceKey, + List endInstances, + TimeSpan? horizon) + { + var candidateTime = eventTime + offset; + if (candidateTime <= sourceBegin || + (horizon.HasValue && candidateTime > horizon.Value)) + { + return; + } + + endInstances.Add(new ResolvedTimingInstance(candidateTime, eventInstanceKey, eventTime)); + } + + private static bool HasSelfEventTiming(IReadOnlyList specs, SvgElementAddress animationAddress) + { + for (var index = 0; index < specs.Count; index++) + { + if (IsSelfEventTiming(specs[index], animationAddress)) + { + return true; + } + } + + return false; + } + + private static bool IsSelfEventTiming(TimingSpec spec, SvgElementAddress animationAddress) + { + return spec.IsEvent && + spec.EventAddress is { } eventAddress && + string.Equals(eventAddress.Key, animationAddress.Key, StringComparison.Ordinal); + } + + private static void AddSelfSyncBeginInstances( + AnimationBinding binding, + ResolvedAnimationInterval interval, + List pendingBeginInstances, + HashSet knownBeginTimes, + TimeSpan? horizon) + { + for (var index = 0; index < binding.BeginSpecs.Count; index++) + { + var spec = binding.BeginSpecs[index]; + if (!IsSelfEventTiming(spec, binding.AnimationAddress)) + { + continue; + } + + var eventInstanceKey = CreateEventInstanceKey(binding.AnimationAddress, spec.EventType); + switch (spec.EventType) + { + case SvgAnimationTimingEventType.Begin: + AddSelfSyncBeginInstance( + interval.BeginInstance.Time, + interval.BeginInstance.Time, + spec.Offset, + eventInstanceKey, + pendingBeginInstances, + knownBeginTimes, + horizon); + break; + case SvgAnimationTimingEventType.End: + if (interval.ActiveEnd.HasValue) + { + AddSelfSyncBeginInstance( + interval.ActiveEnd.Value, + interval.BeginInstance.Time, + spec.Offset, + eventInstanceKey, + pendingBeginInstances, + knownBeginTimes, + horizon); + } + + break; + case SvgAnimationTimingEventType.Repeat: + var repeatEventTimes = new List(); + AddRepeatEventInstances(binding.Animation, interval, spec.RepeatIteration, repeatEventTimes); + for (var repeatIndex = 0; repeatIndex < repeatEventTimes.Count; repeatIndex++) + { + AddSelfSyncBeginInstance( + repeatEventTimes[repeatIndex], + interval.BeginInstance.Time, + spec.Offset, + eventInstanceKey, + pendingBeginInstances, + knownBeginTimes, + horizon); + } + + break; + } + } + } + + private static void AddSelfSyncBeginInstance( + TimeSpan eventTime, + TimeSpan sourceBegin, + TimeSpan offset, + string eventInstanceKey, + List pendingBeginInstances, + HashSet knownBeginTimes, + TimeSpan? horizon) + { + var candidateTime = eventTime + offset; + if (candidateTime <= sourceBegin) + { + return; + } + + if (horizon.HasValue && candidateTime > horizon.Value) + { + return; + } + + if (!knownBeginTimes.Add(candidateTime.Ticks)) + { + return; + } + + pendingBeginInstances.Add(new ResolvedTimingInstance(candidateTime, eventInstanceKey, eventTime)); + } + + private static bool TrySelectCurrentInterval( + SvgAnimationElement animation, + TimeSpan time, + IReadOnlyList intervals, + bool requireActive, + out ResolvedAnimationInterval interval) + { + interval = default; + ResolvedAnimationInterval? selected = null; + + for (var index = 0; index < intervals.Count; index++) + { + var candidate = intervals[index]; + if (candidate.BeginInstance.Time > time) + { + break; + } + + selected = candidate; + } + + if (!selected.HasValue) + { + return false; + } + + var resolved = selected.Value; + if (resolved.IsActive(time) || (!requireActive && animation.AnimationFill == SvgAnimationFill.Freeze)) + { + interval = resolved; + return true; + } + + return false; + } + + private static bool TryResolveCurrentIntervalDetailed( + SvgAnimationElement animation, + TimeSpan time, + bool allowIndefiniteDiscrete, + IReadOnlyList beginInstances, + IReadOnlyList endInstances, + out ResolvedAnimationInterval interval) { interval = default; @@ -1783,7 +2448,7 @@ private static bool TryResolveIntervalEnd(SvgAnimationElement animation, TimeSpa return false; } - activeEnd = explicitEnd; + activeEnd = ComputeIndefiniteActiveEnd(animation, begin, explicitEnd); return true; } @@ -1829,7 +2494,7 @@ private static bool TryResolveIntervalEndDetailed( return false; } - activeEnd = explicitEnd; + activeEnd = ComputeIndefiniteActiveEnd(animation, begin, explicitEnd); return true; } @@ -1850,6 +2515,14 @@ private static bool TryResolveIntervalEndDetailed( : null; } + private static TimeSpan? ComputeIndefiniteActiveEnd(SvgAnimationElement animation, TimeSpan begin, TimeSpan? explicitEnd) + { + var totalDuration = ComputeConstrainedTotalDuration(animation, totalDuration: null, explicitEnd, begin); + return totalDuration.HasValue + ? begin + totalDuration.Value + : null; + } + private static TimeSpan? ComputeTotalDuration(SvgAnimationElement animation, TimeSpan simpleDuration, TimeSpan? explicitEnd, TimeSpan begin) { TimeSpan? totalDuration; @@ -1882,19 +2555,37 @@ private static bool TryResolveIntervalEndDetailed( break; } + return ComputeConstrainedTotalDuration(animation, totalDuration, explicitEnd, begin); + } + + private static TimeSpan? ComputeConstrainedTotalDuration( + SvgAnimationElement animation, + TimeSpan? totalDuration, + TimeSpan? explicitEnd, + TimeSpan begin) + { if (explicitEnd.HasValue && explicitEnd.Value > begin) { totalDuration = MinDuration(totalDuration, explicitEnd.Value - begin); } - switch (ParseRepeatDuration(animation.Minimum, out var minimumDuration)) + var minimumMode = ParseRepeatDuration(animation.Minimum, out var minimumDuration); + var maximumMode = ParseRepeatDuration(animation.Maximum, out var maximumDuration); + if (minimumMode == RepeatDurationMode.Finite && + maximumMode == RepeatDurationMode.Finite && + minimumDuration > maximumDuration) + { + return totalDuration; + } + + switch (minimumMode) { case RepeatDurationMode.Finite: totalDuration = MaxDuration(totalDuration, minimumDuration); break; } - switch (ParseRepeatDuration(animation.Maximum, out var maximumDuration)) + switch (maximumMode) { case RepeatDurationMode.Finite: totalDuration = MinDuration(totalDuration, maximumDuration); @@ -1928,7 +2619,13 @@ private static RepeatDurationMode ParseRepeatDuration(string? value, out TimeSpa : RepeatDurationMode.None; } - private static bool TryResolveAnimatedValue(AnimationBinding binding, SvgAnimationValueElement animation, AnimationSample sample, bool forceColorInterpolation, out string value) + private static bool TryResolveAnimatedValue( + AnimationBinding binding, + SvgAnimationValueElement animation, + AnimationSample sample, + bool forceColorInterpolation, + IReadOnlyDictionary? frameAttributes, + out string value) { value = string.Empty; @@ -1965,15 +2662,30 @@ private static bool TryResolveAnimatedValue(AnimationBinding binding, SvgAnimati var fromValue = values[startIndex]; var toValue = values[endIndex]; - if (TryInterpolateValue(binding, fromValue, toValue, localProgress, forceColorInterpolation, out value)) + if (TryInterpolateValue(binding, fromValue, toValue, localProgress, forceColorInterpolation, frameAttributes, out value)) { return TryApplyAccumulation(binding, animation, values, sample, forceColorInterpolation, ref value); } - value = localProgress >= 1f ? toValue : fromValue; + value = IsToOnlyAnimation(animation) + ? toValue + : ResolveNonInterpolableFallbackValue(fromValue, toValue, localProgress); return TryApplyAccumulation(binding, animation, values, sample, forceColorInterpolation, ref value); } + private static bool IsToOnlyAnimation(SvgAnimationValueElement animation) + { + return string.IsNullOrWhiteSpace(animation.Values) && + string.IsNullOrWhiteSpace(animation.From) && + string.IsNullOrWhiteSpace(animation.By) && + !string.IsNullOrWhiteSpace(animation.To); + } + + private static string ResolveNonInterpolableFallbackValue(string fromValue, string toValue, float localProgress) + { + return localProgress >= 0.5f ? toValue : fromValue; + } + private static bool TryResolveTransformValue(AnimationBinding binding, SvgAnimateTransform animation, AnimationSample sample, out string transformValue) { transformValue = string.Empty; @@ -1990,7 +2702,7 @@ private static bool TryResolveTransformValue(AnimationBinding binding, SvgAnimat ? values[0] : ResolveDiscreteValue(values, animation.KeyTimes, sample.Progress); - var discreteValues = ParseTransformNumbers(discrete); + var discreteValues = ParseTransformNumbers(animation.TransformType, discrete); if (!TryApplyTransformAccumulation(binding, animation, values, sample, ref discreteValues)) { return false; @@ -2011,8 +2723,8 @@ private static bool TryResolveTransformValue(AnimationBinding binding, SvgAnimat out var startIndex, out var endIndex, out var localProgress); - var fromValues = ParseTransformNumbers(values[startIndex]); - var toValues = ParseTransformNumbers(values[endIndex]); + var fromValues = ParseTransformNumbers(animation.TransformType, values[startIndex]); + var toValues = ParseTransformNumbers(animation.TransformType, values[endIndex]); var interpolated = InterpolateTransformNumbers(animation.TransformType, fromValues, toValues, localProgress); if (!TryApplyTransformAccumulation(binding, animation, values, sample, ref interpolated)) { @@ -2443,9 +3155,128 @@ private static float GetDefaultTransformValue(SvgAnimateTransformType transformT private static float[] ParseTransformNumbers(string value) { + return ParseTransformNumbers(transformType: null, value); + } + + private static float[] ParseTransformNumbers(SvgAnimateTransformType transformType, string value) + { + return ParseTransformNumbers((SvgAnimateTransformType?)transformType, value); + } + + private static float[] ParseTransformNumbers(SvgAnimateTransformType? transformType, string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Array.Empty(); + } + + if (value.IndexOf('(') >= 0) + { + if (transformType is { } concreteTransformType && + TryParseTransformFunctionNumbers(value, GetTransformFunctionName(concreteTransformType), out var matchingValues)) + { + return matchingValues; + } + + if (TryParseFirstTransformFunctionNumbers(value, out var functionValues)) + { + return functionValues; + } + } + return SvgAnimationParser.ParseNumberList(value); } + private static string GetTransformFunctionName(SvgAnimateTransformType transformType) + { + return transformType switch + { + SvgAnimateTransformType.Translate => "translate", + SvgAnimateTransformType.Scale => "scale", + SvgAnimateTransformType.Rotate => "rotate", + SvgAnimateTransformType.SkewX => "skewX", + SvgAnimateTransformType.SkewY => "skewY", + _ => string.Empty + }; + } + + private static bool TryParseTransformFunctionNumbers(string value, string functionName, out float[] values) + { + values = Array.Empty(); + if (string.IsNullOrWhiteSpace(functionName)) + { + return false; + } + + var searchIndex = 0; + while (searchIndex < value.Length) + { + var functionIndex = value.IndexOf(functionName, searchIndex, StringComparison.Ordinal); + if (functionIndex < 0) + { + return false; + } + + searchIndex = functionIndex + functionName.Length; + if (functionIndex > 0 && char.IsLetterOrDigit(value[functionIndex - 1])) + { + continue; + } + + var openIndex = value.IndexOf('(', searchIndex); + if (openIndex < 0 || !ContainsOnlyWhitespace(value, searchIndex, openIndex)) + { + continue; + } + + var closeIndex = value.IndexOf(')', openIndex + 1); + if (closeIndex < 0) + { + return false; + } + + var numberText = value.Substring(openIndex + 1, closeIndex - openIndex - 1); + values = SvgAnimationParser.ParseNumberList(numberText); + return values.Length > 0; + } + + return false; + } + + private static bool TryParseFirstTransformFunctionNumbers(string value, out float[] values) + { + values = Array.Empty(); + + var openIndex = value.IndexOf('('); + if (openIndex < 0) + { + return false; + } + + var closeIndex = value.IndexOf(')', openIndex + 1); + if (closeIndex < 0) + { + return false; + } + + var numberText = value.Substring(openIndex + 1, closeIndex - openIndex - 1); + values = SvgAnimationParser.ParseNumberList(numberText); + return values.Length > 0; + } + + private static bool ContainsOnlyWhitespace(string value, int startIndex, int endIndex) + { + for (var index = startIndex; index < endIndex; index++) + { + if (!char.IsWhiteSpace(value[index])) + { + return false; + } + } + + return true; + } + private static List ResolveAnimationValues(AnimationBinding binding, SvgAnimationValueElement animation) { return binding.GetResolvedAnimationValues(() => @@ -2466,24 +3297,95 @@ private static List ResolveAnimationValues(AnimationBinding binding, Svg { resolved.Add(binding.BaseValueString!); } + else if (animation is SvgAnimateTransform animateTransform && + string.IsNullOrWhiteSpace(animation.To) && + SvgAnimationParser.TryGetTrimmedString(animation.By, out var transformByValue)) + { + resolved.Add(CreateZeroTransformValue(animateTransform.TransformType, transformByValue)); + } + + 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 (animation is SvgAnimateTransform byTransform && + additiveBaseValue is { } && + TryAddTransformValue(byTransform.TransformType, additiveBaseValue, byValue, out var transformSumValue)) + { + resolved.Add(transformSumValue); + } + else if (additiveBaseValue is { } && TryAddValue(binding, additiveBaseValue, byValue, out var sumValue)) + { + resolved.Add(sumValue); + } + } + + return resolved; + }); + } + + private static bool TryAddTransformValue(SvgAnimateTransformType transformType, string baseValue, string byValue, out string result) + { + result = string.Empty; + + var baseValues = ParseTransformNumbers(transformType, baseValue); + var byValues = ParseTransformNumbers(transformType, byValue); + if (baseValues.Length == 0 || byValues.Length == 0) + { + return false; + } + + var length = GetExpectedTransformValueCount(transformType, baseValues, byValues); + var values = new float[length]; + for (var index = 0; index < values.Length; index++) + { + var currentBaseValue = index < baseValues.Length + ? baseValues[index] + : GetDefaultTransformValue(transformType, index, baseValues); + var currentByValue = index < byValues.Length + ? byValues[index] + : GetImplicitTransformByValue(transformType, index, byValues); + values[index] = currentBaseValue + currentByValue; + } + + return TryCreateTransformString(transformType, values, out result); + } + + private static float GetImplicitTransformByValue(SvgAnimateTransformType transformType, int index, float[] byValues) + { + if (transformType == SvgAnimateTransformType.Scale && byValues.Length == 1 && index == 1) + { + return byValues[0]; + } - if (SvgAnimationParser.TryGetTrimmedString(animation.To, out var toValue)) - { - resolved.Add(toValue); - return resolved; - } + return 0f; + } - 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); - } - } + private static string CreateZeroTransformValue(SvgAnimateTransformType transformType, string byValue) + { + var byValues = ParseTransformNumbers(byValue); + var length = Math.Max(1, byValues.Length); + var values = new string[length]; + for (var index = 0; index < values.Length; index++) + { + values[index] = GetZeroTransformValue(transformType, index).ToSvgString(); + } - return resolved; - }); + return string.Join(" ", values); + } + + private static float GetZeroTransformValue(SvgAnimateTransformType transformType, int index) + { + return transformType switch + { + SvgAnimateTransformType.Scale => 0f, + _ => 0f + }; } private static bool UsesImplicitBaseValue(AnimationBinding binding, SvgAnimationValueElement animation) @@ -2503,6 +3405,18 @@ private static bool TryApplyAccumulation(AnimationBinding binding, SvgAnimationV var startValue = values[0]; var endValue = values[values.Count - 1]; + if (animation.CalcMode == SvgAnimationCalcMode.Discrete) + { + if (!TryScaleValue(binding, endValue, sample.IterationIndex, forceColorInterpolation, out var discreteAccumulationValue) || + !TryAddValue(binding, value, discreteAccumulationValue, out var discreteAccumulatedValue)) + { + return false; + } + + value = discreteAccumulatedValue; + return true; + } + if ((forceColorInterpolation || IsPaintServerType(binding.PropertyType) || TryGetColor(value, out _)) && TryGetColor(startValue, out var startColor) && TryGetColor(endValue, out var endColor) && @@ -2518,12 +3432,12 @@ private static bool TryApplyAccumulation(AnimationBinding binding, SvgAnimationV 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)) + !TryAddValue(binding, value, accumulatedDelta, out var summedValue)) { return false; } - value = accumulatedValue; + value = summedValue; return true; } @@ -2534,8 +3448,8 @@ private static bool TryApplyTransformAccumulation(AnimationBinding binding, SvgA return true; } - var startValues = ParseTransformNumbers(values[0]); - var endValues = ParseTransformNumbers(values[values.Count - 1]); + var startValues = ParseTransformNumbers(animation.TransformType, values[0]); + var endValues = ParseTransformNumbers(animation.TransformType, values[values.Count - 1]); var deltaValues = InterpolateTransformNumbers(animation.TransformType, startValues, endValues, 1f); for (var index = 0; index < deltaValues.Length; index++) { @@ -2695,8 +3609,8 @@ private static void ResolveInterpolatedSegment( { segmentLengths[index] = ResolveTransformDistance( transformType, - ParseTransformNumbers(values[index]), - ParseTransformNumbers(values[index + 1])); + ParseTransformNumbers(transformType, values[index]), + ParseTransformNumbers(transformType, values[index + 1])); } return segmentLengths; @@ -3016,10 +3930,23 @@ private static float EvaluateCubicBezierDerivative(float control1, float control (3f * t * t * (1f - control2)); } - private static bool TryInterpolateValue(AnimationBinding binding, string fromValue, string toValue, float progress, bool forceColorInterpolation, out string result) + private static bool TryInterpolateValue( + AnimationBinding binding, + string fromValue, + string toValue, + float progress, + bool forceColorInterpolation, + IReadOnlyDictionary? frameAttributes, + out string result) { result = string.Empty; + if (forceColorInterpolation || IsPaintServerType(binding.PropertyType) || IsColorKeyword(fromValue) || IsColorKeyword(toValue)) + { + fromValue = ResolveColorKeywordValue(binding, fromValue, frameAttributes); + toValue = ResolveColorKeywordValue(binding, toValue, frameAttributes); + } + if ((forceColorInterpolation || IsPaintServerType(binding.PropertyType)) && TryInterpolateColor(fromValue, toValue, progress, out result)) { @@ -3039,6 +3966,17 @@ private static bool TryInterpolateValue(AnimationBinding binding, string fromVal return true; } + if (binding.AttributeName == "d" && + TryInterpolatePathData(fromValue, toValue, progress, out result)) + { + return true; + } + + if (TryInterpolateNumberList(fromValue, toValue, progress, out result)) + { + return true; + } + if (TryInterpolateSvgUnit(fromValue, toValue, progress, out result)) { return true; @@ -3052,6 +3990,267 @@ private static bool TryInterpolateValue(AnimationBinding binding, string fromVal return false; } + private static string ResolveColorKeywordValue( + AnimationBinding binding, + string value, + IReadOnlyDictionary? frameAttributes) + { + if (!SvgAnimationParser.TryGetTrimmedString(value, out var trimmed)) + { + return value; + } + + if (SvgAnimationParser.EqualsKeywordIgnoreCase(trimmed.AsSpan(), "currentColor") && + (TryGetAnimatedFrameColor(binding.SourceTarget, "color", frameAttributes, out var currentColor) || + TryGetColor(binding.SourceTarget.Color, binding.SourceTarget, out currentColor))) + { + return FormatColor(currentColor); + } + + if (SvgAnimationParser.EqualsKeywordIgnoreCase(trimmed.AsSpan(), "inherit") && + TryGetInheritedColor(binding.SourceTarget, binding.AttributeName, frameAttributes, out var inheritedColor)) + { + return FormatColor(inheritedColor); + } + + return value; + } + + private static bool IsColorKeyword(string value) + { + return SvgAnimationParser.TryGetTrimmedString(value, out var trimmed) && + (SvgAnimationParser.EqualsKeywordIgnoreCase(trimmed.AsSpan(), "currentColor") || + SvgAnimationParser.EqualsKeywordIgnoreCase(trimmed.AsSpan(), "inherit")); + } + + private static bool TryGetInheritedColor( + SvgElement element, + string attributeName, + IReadOnlyDictionary? frameAttributes, + out Color color) + { + color = default; + for (var parent = element.Parent as SvgElement; parent is not null; parent = parent.Parent as SvgElement) + { + if (TryGetAnimatedFrameColor(parent, attributeName, frameAttributes, out color)) + { + return true; + } + + if (TryGetPaintAttribute(parent, attributeName, out var paintServer) && + TryGetColor(paintServer, parent, out color)) + { + return true; + } + + if (parent.TryGetAttribute(attributeName, out var rawValue) && + TryGetColor(rawValue, out color)) + { + return true; + } + + var value = GetAttributeValue(parent, attributeName); + if (TryGetColor(value, parent, out color)) + { + return true; + } + } + + return false; + } + + private static bool TryGetAnimatedFrameColor( + SvgElement element, + string attributeName, + IReadOnlyDictionary? frameAttributes, + out Color color) + { + color = default; + if (frameAttributes is null) + { + return false; + } + + var key = string.Concat(SvgElementAddress.Create(element).Key, "|", attributeName); + return frameAttributes.TryGetValue(key, out var attribute) && + TryGetColor(attribute.Value, out color); + } + + private static bool TryGetPaintAttribute(SvgElement element, string attributeName, out SvgPaintServer? paintServer) + { + switch (attributeName) + { + case "color": + paintServer = element.Color; + return true; + case "fill" when element is SvgVisualElement visualElement: + paintServer = visualElement.Fill; + return true; + case "stroke" when element is SvgVisualElement visualElement: + paintServer = visualElement.Stroke; + return true; + case "stop-color" when element is SvgGradientStop gradientStop: + paintServer = gradientStop.StopColor; + return true; + case "stop-color" when element is SvgGradientServer gradientServer: + paintServer = gradientServer.StopColor; + return true; + case "flood-color" when element is Svg.FilterEffects.SvgDropShadow dropShadow: + paintServer = dropShadow.FloodColor; + return true; + default: + paintServer = null; + return false; + } + } + + private static bool TryInterpolateNumberList(string fromValue, string toValue, float progress, out string result) + { + result = string.Empty; + + var fromNumbers = SvgAnimationParser.ParseNumberList(fromValue); + var toNumbers = SvgAnimationParser.ParseNumberList(toValue); + if (fromNumbers.Length == 0 || fromNumbers.Length != toNumbers.Length) + { + return false; + } + + var values = new string[fromNumbers.Length]; + for (var index = 0; index < values.Length; index++) + { + values[index] = Lerp(fromNumbers[index], toNumbers[index], progress).ToSvgString(); + } + + result = string.Join(" ", values); + return true; + } + + private static bool TryInterpolatePathData(string fromValue, string toValue, float progress, out string result) + { + result = string.Empty; + + if (!TryTokenizePathData(fromValue, out var fromTokens) || + !TryTokenizePathData(toValue, out var toTokens) || + fromTokens.Count == 0 || + fromTokens.Count != toTokens.Count) + { + return false; + } + + var builder = new StringBuilder(); + for (var index = 0; index < fromTokens.Count; index++) + { + var fromToken = fromTokens[index]; + var toToken = toTokens[index]; + if (fromToken.IsCommand != toToken.IsCommand) + { + return false; + } + + if (fromToken.IsCommand) + { + if (fromToken.Command != toToken.Command) + { + return false; + } + + AppendPathToken(builder, fromToken.Command.ToString()); + continue; + } + + AppendPathToken(builder, Lerp(fromToken.Number, toToken.Number, progress).ToSvgString()); + } + + result = builder.ToString(); + return result.Length > 0; + } + + private static bool TryTokenizePathData(string value, out List tokens) + { + tokens = new List(); + if (!SvgAnimationParser.TryGetTrimmedString(value, out var trimmed)) + { + return false; + } + + var span = trimmed.AsSpan(); + var index = 0; + while (index < span.Length) + { + var ch = span[index]; + if (char.IsWhiteSpace(ch) || ch == ',') + { + index++; + continue; + } + + if (IsSvgPathCommand(ch)) + { + tokens.Add(new PathDataToken(ch)); + index++; + continue; + } + + var start = index; + index++; + while (index < span.Length && IsPathNumberContinuation(span, index)) + { + index++; + } + + var token = span.Slice(start, index - start); + if (!SvgAnimationParser.TryParseInvariantFloat(token, out var number)) + { + return false; + } + + tokens.Add(new PathDataToken(number)); + } + + return true; + } + + private static bool IsPathNumberContinuation(ReadOnlySpan span, int index) + { + var ch = span[index]; + if (char.IsWhiteSpace(ch) || ch == ',' || IsSvgPathCommand(ch)) + { + return false; + } + + if ((ch == '-' || ch == '+') && index > 0) + { + var previous = span[index - 1]; + return previous == 'e' || previous == 'E'; + } + + return true; + } + + private static bool IsSvgPathCommand(char ch) + { + return ch is 'M' or 'm' or + 'Z' or 'z' or + 'L' or 'l' or + 'H' or 'h' or + 'V' or 'v' or + 'C' or 'c' or + 'S' or 's' or + 'Q' or 'q' or + 'T' or 't' or + 'A' or 'a'; + } + + private static void AppendPathToken(StringBuilder builder, string token) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append(token); + } + private static bool TryInterpolateTypedValue(object? fromObject, object? toObject, float progress, out string result) { result = string.Empty; @@ -3205,6 +4404,25 @@ private static bool TryGetColor(string value, out Color color) } private static bool TryGetColor(SvgPaintServer? paintServer, out Color color) + { + return TryGetColor(paintServer, styleOwner: null, out color); + } + + private static bool TryGetColor(object? value, SvgElement? styleOwner, out Color color) + { + switch (value) + { + case SvgPaintServer paintServer: + return TryGetColor(paintServer, styleOwner, out color); + case string stringValue: + return TryGetColor(stringValue, out color); + default: + color = default; + return false; + } + } + + private static bool TryGetColor(SvgPaintServer? paintServer, SvgElement? styleOwner, out Color color) { if (paintServer == SvgPaintServer.None || paintServer == SvgPaintServer.Inherit || @@ -3214,6 +4432,16 @@ private static bool TryGetColor(SvgPaintServer? paintServer, out Color color) return false; } + if (paintServer is SvgDeferredPaintServer && styleOwner is not null) + { + var deferredColourServer = SvgDeferredPaintServer.TryGet(paintServer, styleOwner); + if (deferredColourServer is not null) + { + color = deferredColourServer.Colour; + return true; + } + } + if (paintServer is SvgColourServer colourServer) { color = colourServer.Colour; @@ -3246,6 +4474,11 @@ byUnitObject is SvgUnit byUnit && return true; } + if (TryAddNumberLists(baseValue, byValue, out result)) + { + return true; + } + if (SvgAnimationParser.TryParseInvariantFloat(baseValue, out var baseNumber) && SvgAnimationParser.TryParseInvariantFloat(byValue, out var byNumber)) { @@ -3289,6 +4522,11 @@ startUnitObject is SvgUnit startUnit && return true; } + if (TrySubtractNumberLists(endValue, startValue, out result)) + { + return true; + } + if (SvgAnimationParser.TryParseInvariantFloat(endValue, out var endNumber) && SvgAnimationParser.TryParseInvariantFloat(startValue, out var startNumber)) { @@ -3323,6 +4561,11 @@ private static bool TryScaleValue(AnimationBinding binding, string value, int fa return true; } + if (TryScaleNumberList(value, factor, out result)) + { + return true; + } + if (SvgAnimationParser.TryParseInvariantFloat(value, out var numeric)) { result = (numeric * factor).ToSvgString(); @@ -3332,6 +4575,57 @@ private static bool TryScaleValue(AnimationBinding binding, string value, int fa return false; } + private static bool TryAddNumberLists(string leftValue, string rightValue, out string result) + { + return TryCombineNumberLists(leftValue, rightValue, static (left, right) => left + right, out result); + } + + private static bool TrySubtractNumberLists(string leftValue, string rightValue, out string result) + { + return TryCombineNumberLists(leftValue, rightValue, static (left, right) => left - right, out result); + } + + private static bool TryCombineNumberLists(string leftValue, string rightValue, Func combine, out string result) + { + result = string.Empty; + + var leftNumbers = SvgAnimationParser.ParseNumberList(leftValue); + var rightNumbers = SvgAnimationParser.ParseNumberList(rightValue); + if (leftNumbers.Length == 0 || leftNumbers.Length != rightNumbers.Length) + { + return false; + } + + var values = new string[leftNumbers.Length]; + for (var index = 0; index < values.Length; index++) + { + values[index] = combine(leftNumbers[index], rightNumbers[index]).ToSvgString(); + } + + result = string.Join(" ", values); + return true; + } + + private static bool TryScaleNumberList(string value, int factor, out string result) + { + result = string.Empty; + + var numbers = SvgAnimationParser.ParseNumberList(value); + if (numbers.Length == 0) + { + return false; + } + + var values = new string[numbers.Length]; + for (var index = 0; index < values.Length; index++) + { + values[index] = (numbers[index] * factor).ToSvgString(); + } + + result = string.Join(" ", values); + return true; + } + private static bool TryAddTypedValue(object? baseObject, object? byObject, out string result) { result = string.Empty; diff --git a/src/Svg.Animation/Animation/SvgAnimationParser.cs b/src/Svg.Animation/Animation/SvgAnimationParser.cs index 665767e76e..43d0ce8c52 100644 --- a/src/Svg.Animation/Animation/SvgAnimationParser.cs +++ b/src/Svg.Animation/Animation/SvgAnimationParser.cs @@ -7,11 +7,12 @@ namespace Svg.Skia; internal readonly struct SvgAnimationEventTimingParseResult { - public SvgAnimationEventTimingParseResult(SvgElementAddress eventAddress, SvgAnimationTimingEventType eventType, TimeSpan offset) + public SvgAnimationEventTimingParseResult(SvgElementAddress eventAddress, SvgAnimationTimingEventType eventType, TimeSpan offset, int? repeatIteration = null) { EventAddress = eventAddress; EventType = eventType; Offset = offset; + RepeatIteration = repeatIteration; } public SvgElementAddress EventAddress { get; } @@ -19,6 +20,8 @@ public SvgAnimationEventTimingParseResult(SvgElementAddress eventAddress, SvgAni public SvgAnimationTimingEventType EventType { get; } public TimeSpan Offset { get; } + + public int? RepeatIteration { get; } } internal static class SvgAnimationParser @@ -218,7 +221,7 @@ internal static bool TryParseEventTimingSpec( eventAddress = SvgElementAddress.Create(eventElement); } - if (!TryMapEventName(eventName, out var eventType)) + if (!TryMapEventName(eventName, out var eventType, out var repeatIteration)) { return false; } @@ -239,7 +242,7 @@ internal static bool TryParseEventTimingSpec( } } - result = new SvgAnimationEventTimingParseResult(eventAddress, eventType, offset); + result = new SvgAnimationEventTimingParseResult(eventAddress, eventType, offset, repeatIteration); return true; } @@ -530,8 +533,10 @@ private static bool TryParseColonClockValue(ReadOnlySpan value, out TimeSp return true; } - private static bool TryMapEventName(ReadOnlySpan eventName, out SvgAnimationTimingEventType eventType) + private static bool TryMapEventName(ReadOnlySpan eventName, out SvgAnimationTimingEventType eventType, out int? repeatIteration) { + repeatIteration = null; + if (EqualsAsciiIgnoreCase(eventName, "click")) { eventType = SvgAnimationTimingEventType.Click; @@ -586,10 +591,44 @@ private static bool TryMapEventName(ReadOnlySpan eventName, out SvgAnimati return true; } + if (TryParseRepeatEventName(eventName, out repeatIteration)) + { + eventType = SvgAnimationTimingEventType.Repeat; + return true; + } + eventType = default; return false; } + private static bool TryParseRepeatEventName(ReadOnlySpan eventName, out int? repeatIteration) + { + repeatIteration = null; + var trimmed = Trim(eventName); + const string repeat = "repeat"; + if (trimmed.Length == repeat.Length && EqualsAsciiIgnoreCase(trimmed, repeat)) + { + return true; + } + + if (trimmed.Length <= repeat.Length + 2 || + !EqualsAsciiIgnoreCase(trimmed.Slice(0, repeat.Length), repeat) || + trimmed[repeat.Length] != '(' || + trimmed[trimmed.Length - 1] != ')') + { + return false; + } + + var ordinal = Trim(trimmed.Slice(repeat.Length + 1, trimmed.Length - repeat.Length - 2)); + if (!int.TryParse(ordinal.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) + { + return false; + } + + repeatIteration = parsed; + return true; + } + internal static float ToMotionCoordinate(SvgUnit unit, UnitRenderingType renderingType, SvgElement owner) { var ppi = owner.OwnerDocument?.Ppi ?? SvgDocument.PointsPerInch; diff --git a/src/Svg.Animation/Animation/SvgAnimationTimingEventType.cs b/src/Svg.Animation/Animation/SvgAnimationTimingEventType.cs index 6c0b961397..0766e771d5 100644 --- a/src/Svg.Animation/Animation/SvgAnimationTimingEventType.cs +++ b/src/Svg.Animation/Animation/SvgAnimationTimingEventType.cs @@ -10,5 +10,6 @@ internal enum SvgAnimationTimingEventType Wheel, Click, Begin, - End + End, + Repeat } diff --git a/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs b/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs index 235c7bbb16..ec919ce390 100644 --- a/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs +++ b/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs @@ -1223,7 +1223,7 @@ public static void ToSKImageFilter(this SKImageFilter? imageFilter, SkiaCSharpCo sb.AppendLine($"{indent} {spotLitSpecularImageFilter.LightColor.ToSKColor()},"); sb.AppendLine($"{indent} {spotLitSpecularImageFilter.SurfaceScale.ToFloatString()},"); sb.AppendLine($"{indent} {spotLitSpecularImageFilter.Ks.ToFloatString()},"); - sb.AppendLine($"{indent} {spotLitSpecularImageFilter.SpecularExponent.ToFloatString()},"); + sb.AppendLine($"{indent} {spotLitSpecularImageFilter.Shininess.ToFloatString()},"); sb.AppendLine($"{indent} {counter.ImageFilterVarName}{counterImageFilterInput},"); sb.AppendLine($"{indent} {spotLitSpecularImageFilter.Clip?.ToSKRect() ?? "null"});"); return; diff --git a/src/Svg.Controls.Skia.Avalonia/Svg.cs b/src/Svg.Controls.Skia.Avalonia/Svg.cs index c73d2b5a34..77c1a2e08f 100644 --- a/src/Svg.Controls.Skia.Avalonia/Svg.cs +++ b/src/Svg.Controls.Skia.Avalonia/Svg.cs @@ -37,6 +37,7 @@ public class Svg : Control private double _zoom = 1.0; private double _panX; private double _panY; + private readonly object _cacheSync = new(); private Dictionary? _cache; private SKSvg? _trackedAnimationSvg; private readonly Stopwatch _animationPlaybackStopwatch = new(); @@ -53,6 +54,7 @@ public class Svg : Control private bool _nativeCompositionHostSupported = true; private SvgCompositionVisualScene? _nativeCompositionScene; private long _sourceLoadVersion; + private CancellationTokenSource? _pendingLoadCts; private static readonly Cursor s_arrowCursor = new(StandardCursorType.Arrow); private static readonly Cursor s_appStartingCursor = new(StandardCursorType.AppStarting); private static readonly Cursor s_crossCursor = new(StandardCursorType.Cross); @@ -85,6 +87,12 @@ public class Svg : Control public static readonly StyledProperty SourceProperty = AvaloniaProperty.Register(nameof(Source)); + /// + /// Defines the property. + /// + public static readonly StyledProperty SvgSourceProperty = + AvaloniaProperty.Register(nameof(SvgSource)); + /// /// Defines the property. /// @@ -208,6 +216,15 @@ public string? Source set => SetValue(SourceProperty, value); } + /// + /// Gets or sets the Svg source object. + /// + public SvgSource? SvgSource + { + get => GetValue(SvgSourceProperty); + set => SetValue(SvgSourceProperty, value); + } + /// /// Gets or sets the default SVG currentColor value. /// @@ -389,6 +406,24 @@ public void ZoomToPoint(double newZoom, Point point) public SKSvg? SkSvg => _svg?.Svg; + /// + /// Loads an already parsed SVG document into the control synchronously. + /// + /// The SVG document to load. + /// Optional SVG parameters. + public void LoadFromSvgDocument(SvgDocument? document, SvgParameters? parameters = null) + { + CancelPendingLoad(); + + if (document is null) + { + ClearSource(); + return; + } + + SetCurrentSource(new LoadResult(SvgSource.LoadFromSvgDocument(document, parameters), isCacheEntry: false)); + } + /// /// Converts a point from control coordinates to picture coordinates. /// @@ -473,8 +508,8 @@ protected override void OnPointerWheelChanged(PointerWheelEventArgs e) static Svg() { - AffectsRender(PathProperty, SourceProperty, StretchProperty, StretchDirectionProperty); - AffectsMeasure(PathProperty, SourceProperty, StretchProperty, StretchDirectionProperty); + AffectsRender(SvgSourceProperty, PathProperty, SourceProperty, StretchProperty, StretchDirectionProperty); + AffectsMeasure(SvgSourceProperty, PathProperty, SourceProperty, StretchProperty, StretchDirectionProperty); CssProperty.Changed.AddClassHandler(OnCssPropertyAttachedPropertyChanged); CurrentCssProperty.Changed.AddClassHandler(OnCssPropertyAttachedPropertyChanged); @@ -547,6 +582,7 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e { base.OnDetachedFromVisualTree(e); ApplyNativeCursor(null); + CancelPendingLoad(); DeactivateNativeComposition(); UpdateAnimationPlayback(); } @@ -632,83 +668,16 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { base.OnPropertyChanged(change); - if (change.Property == PathProperty) - { - var css = GetCss(this); - var currentCss = GetCurrentCss(this); - var parameters = BuildParameters(css, currentCss, CurrentColor); - var path = change.GetNewValue(); - LoadFromPath(path, parameters); - InvalidateVisual(); - } - - if (change.Property == CssProperty) - { - var css = change.GetNewValue(); - var currentCss = GetCurrentCss(this); - var parameters = BuildParameters(css, currentCss, CurrentColor); - var path = Path; - var source = Source; - - if (path is { }) - { - LoadFromPath(path, parameters); - } - else if (source is { }) - { - LoadFromSource(source, parameters); - } - - InvalidateVisual(); - } - - if (change.Property == CurrentCssProperty) - { - var css = GetCss(this); - var currentCss = change.GetNewValue(); - var parameters = BuildParameters(css, currentCss, CurrentColor); - var path = Path; - var source = Source; - - if (path is { }) - { - LoadFromPath(path, parameters); - } - else if (source is { }) - { - LoadFromSource(source, parameters); - } - - InvalidateVisual(); - } - - if (change.Property == CurrentColorProperty) - { - var css = GetCss(this); - var currentCss = GetCurrentCss(this); - var parameters = BuildParameters(css, currentCss, change.GetNewValue()); - var path = Path; - var source = Source; - - if (path is { }) - { - LoadFromPath(path, parameters); - } - else if (source is { }) - { - LoadFromSource(source, parameters); - } - - InvalidateVisual(); - } - - if (change.Property == SourceProperty) + if (change.Property == SvgSourceProperty || + change.Property == PathProperty || + change.Property == SourceProperty || + change.Property == CssProperty || + change.Property == CurrentCssProperty || + change.Property == CurrentColorProperty) { - var source = change.GetNewValue(); - var css = GetCss(this); - var currentCss = GetCurrentCss(this); - var parameters = BuildParameters(css, currentCss, CurrentColor); - LoadFromSource(source, parameters); + Interaction.Reset(); + ApplyNativeCursor(null); + QueueSourceReload(); InvalidateVisual(); } @@ -721,7 +690,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } else { - _cache = new Dictionary(); + lock (_cacheSync) + { + _cache = new Dictionary(); + } } } @@ -770,90 +742,137 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } } - private void LoadFromPath(string? path, SvgParameters? parameters = null) + private void QueueSourceReload() { - CancelPendingSourceLoad(); - Interaction.Reset(); - ApplyNativeCursor(null); + CancelPendingLoad(); + + var source = SvgSource; + var path = Path; + var inlineSource = Source; + var css = GetCss(this); + var currentCss = GetCurrentCss(this); + var currentColor = CurrentColor; + var wireframe = _wireframe; + var disableFilters = _disableFilters; + var enableCache = _enableCache; + Dictionary? cache; - if (path is null) + lock (_cacheSync) { - ReplaceCurrentSource(null); - TrackAnimationSvg(null); - DisposeCache(); - return; + cache = _cache; } - var cacheKey = CreateCacheKey(path, parameters); - if (_enableCache && _cache is { } && _cache.TryGetValue(cacheKey, out var svg)) + var loadVersion = Interlocked.Increment(ref _sourceLoadVersion); + if (source is null && + string.IsNullOrWhiteSpace(path) && + string.IsNullOrWhiteSpace(inlineSource)) { - ReplaceCurrentSource(CreateWorkingSource(svg)); - ApplyRenderOptions(_svg, _wireframe, _disableFilters); - TrackAnimationSvg(_svg?.Svg); + ClearSource(); return; } - if (!_enableCache) - { - _svg?.Dispose(); - _svg = null; - } + var cts = new CancellationTokenSource(); + _pendingLoadCts = cts; + _ = ReloadSourceAsync( + source, + path, + inlineSource, + css, + currentCss, + currentColor, + wireframe, + disableFilters, + enableCache, + cache, + loadVersion, + cts.Token); + } + + private async Task ReloadSourceAsync( + SvgSource? source, + string? path, + string? inlineSource, + string? css, + string? currentCss, + Color? currentColor, + bool wireframe, + bool disableFilters, + bool enableCache, + Dictionary? cache, + long loadVersion, + CancellationToken cancellationToken) + { + LoadResult result = default; try { - var loaded = SvgSource.Load(path, _baseUri, parameters); - ReplaceCurrentSource(loaded); - ApplyRenderOptions(_svg, _wireframe, _disableFilters); - TrackAnimationSvg(_svg?.Svg); - - if (_enableCache && _cache is { } && _svg is { }) + if (source is { }) { - _cache[cacheKey] = CreateWorkingSource(_svg); + result = await LoadExternalSourceAsync( + source, + css, + currentCss, + currentColor, + wireframe, + disableFilters, + cancellationToken).ConfigureAwait(false); + } + else if (!string.IsNullOrWhiteSpace(path)) + { + result = await LoadPathAsync( + path, + css, + currentCss, + currentColor, + wireframe, + disableFilters, + enableCache, + cache, + cancellationToken).ConfigureAwait(false); + } + else if (!string.IsNullOrWhiteSpace(inlineSource)) + { + result = await LoadInlineSourceAsync( + inlineSource, + css, + currentCss, + currentColor, + wireframe, + disableFilters, + cancellationToken).ConfigureAwait(false); } - } - catch (Exception e) - { - Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log(this, "Failed to load svg image: " + e); - ReplaceCurrentSource(null); - TrackAnimationSvg(null); - } - } - - private void LoadFromSource(string? source, SvgParameters? parameters = null) - { - var loadVersion = Interlocked.Increment(ref _sourceLoadVersion); - Interaction.Reset(); - ApplyNativeCursor(null); - if (source is null) - { - ReplaceCurrentSource(null); - TrackAnimationSvg(null); - DisposeCache(); - return; - } + if (cancellationToken.IsCancellationRequested || + loadVersion != Volatile.Read(ref _sourceLoadVersion)) + { + DisposeResultIfOwned(result); + return; + } - _ = LoadFromSourceAsync(source, parameters, loadVersion); - } + var applied = await Dispatcher.UIThread.InvokeAsync(() => + { + if (cancellationToken.IsCancellationRequested || + loadVersion != Volatile.Read(ref _sourceLoadVersion)) + { + return false; + } - private async Task LoadFromSourceAsync( - string source, - SvgParameters? parameters, - long loadVersion) - { - SvgSource? loaded = null; + SetCurrentSource(result); + return true; + }, DispatcherPriority.Normal); - try - { - loaded = await Task.Run(() => + if (!applied) { - var bytes = Encoding.UTF8.GetBytes(source); - using var ms = new MemoryStream(bytes); - return SvgSource.LoadFromStream(ms, parameters); - }).ConfigureAwait(false); + DisposeResultIfOwned(result); + } + } + catch (OperationCanceledException) + { + DisposeResultIfOwned(result); } catch (Exception e) { + DisposeResultIfOwned(result); await Dispatcher.UIThread.InvokeAsync(() => { if (loadVersion != Volatile.Read(ref _sourceLoadVersion)) @@ -862,42 +881,177 @@ await Dispatcher.UIThread.InvokeAsync(() => } Logger.TryGet(LogEventLevel.Warning, LogArea.Control)?.Log(this, "Failed to load svg image: " + e); - ReplaceCurrentSource(null); - TrackAnimationSvg(null); - InvalidateMeasure(); - InvalidateArrange(); - InvalidateVisual(); - }, DispatcherPriority.Background); - return; + ClearSource(); + }, DispatcherPriority.Normal); + } + finally + { + CompletePendingLoad(loadVersion); } + } - try + private async Task LoadExternalSourceAsync( + SvgSource source, + string? css, + string? currentCss, + Color? currentColor, + bool wireframe, + bool disableFilters, + CancellationToken cancellationToken) + { + return await Task.Run(async () => { - await Dispatcher.UIThread.InvokeAsync(() => + cancellationToken.ThrowIfCancellationRequested(); + var workingSource = source.Clone(); + var parameters = BuildParameters(source, css, currentCss, currentColor); + + try { - if (loadVersion != Volatile.Read(ref _sourceLoadVersion)) + if (workingSource.HasPathSource || parameters is not null) { - return; + await workingSource.ReLoadAsync(parameters, cancellationToken).ConfigureAwait(false); } - ApplyRenderOptions(loaded, _wireframe, _disableFilters); - ReplaceCurrentSource(loaded); - loaded = null; - TrackAnimationSvg(_svg?.Svg); - InvalidateMeasure(); - InvalidateArrange(); - InvalidateVisual(); - }, DispatcherPriority.Background); - } - finally + ApplyRenderOptions(workingSource, wireframe, disableFilters); + return new LoadResult(workingSource, isCacheEntry: false); + } + catch + { + workingSource.Dispose(); + throw; + } + }, cancellationToken).ConfigureAwait(false); + } + + private async Task LoadPathAsync( + string path, + string? css, + string? currentCss, + Color? currentColor, + bool wireframe, + bool disableFilters, + bool enableCache, + Dictionary? cache, + CancellationToken cancellationToken) + { + return await Task.Run(async () => { - loaded?.Dispose(); - } + cancellationToken.ThrowIfCancellationRequested(); + var parameters = BuildParameters(null, css, currentCss, currentColor); + var normalizedPath = SvgSource.NormalizePath(path, _baseUri).ToString(); + var cacheKey = CreateCacheKey(normalizedPath, parameters); + + if (enableCache && cache is { }) + { + SvgSource? cachedSource = null; + lock (_cacheSync) + { + if (ReferenceEquals(cache, _cache)) + { + cache.TryGetValue(cacheKey, out cachedSource); + } + } + + if (cachedSource is { }) + { + var workingSource = CreateWorkingSource(cachedSource); + ApplyRenderOptions(workingSource, wireframe, disableFilters); + return new LoadResult(workingSource, ReferenceEquals(workingSource, cachedSource)); + } + } + + var loaded = await SvgSource.LoadAsync(path, _baseUri, parameters, cancellationToken) + .ConfigureAwait(false); + ApplyRenderOptions(loaded, wireframe, disableFilters); + + var isCacheEntry = false; + if (enableCache && cache is { }) + { + var cacheEntry = CreateWorkingSource(loaded); + var stored = false; + + lock (_cacheSync) + { + if (ReferenceEquals(cache, _cache)) + { + cache[cacheKey] = cacheEntry; + stored = true; + } + } + + if (!stored && !ReferenceEquals(cacheEntry, loaded)) + { + cacheEntry.Dispose(); + } + + isCacheEntry = stored && ReferenceEquals(cacheEntry, loaded); + } + + return new LoadResult(loaded, isCacheEntry); + }, cancellationToken).ConfigureAwait(false); } - private void CancelPendingSourceLoad() + private async Task LoadInlineSourceAsync( + string source, + string? css, + string? currentCss, + Color? currentColor, + bool wireframe, + bool disableFilters, + CancellationToken cancellationToken) + { + return await Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + var parameters = BuildParameters(null, css, currentCss, currentColor); + var loaded = SvgSource.LoadFromSvg(source, parameters); + ApplyRenderOptions(loaded, wireframe, disableFilters); + return new LoadResult(loaded, isCacheEntry: false); + }, cancellationToken).ConfigureAwait(false); + } + + private void SetCurrentSource(LoadResult result) + { + ApplyRenderOptions(result.Source, _wireframe, _disableFilters); + ReplaceCurrentSource(result.Source); + TrackAnimationSvg(_svg?.Svg); + InvalidateMeasure(); + InvalidateArrange(); + InvalidateVisual(); + } + + private void ClearSource() + { + ReplaceCurrentSource(null); + TrackAnimationSvg(null); + DisposeCache(); + InvalidateMeasure(); + InvalidateArrange(); + InvalidateVisual(); + } + + private void CancelPendingLoad() { Interlocked.Increment(ref _sourceLoadVersion); + var cts = Interlocked.Exchange(ref _pendingLoadCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } + + private void CompletePendingLoad(long loadVersion) + { + if (loadVersion != Volatile.Read(ref _sourceLoadVersion)) + { + return; + } + + var cts = Interlocked.Exchange(ref _pendingLoadCts, null); + cts?.Dispose(); } private static void ApplyRenderOptions(SvgSource? source, bool wireframe, bool disableFilters) @@ -910,22 +1064,35 @@ private static void ApplyRenderOptions(SvgSource? source, bool wireframe, bool d } } + private static void DisposeResultIfOwned(LoadResult result) + { + if (result.Source is { } source && !result.IsCacheEntry) + { + source.Dispose(); + } + } + private void DisposeCache() { - if (_cache is null) + Dictionary? cache; + lock (_cacheSync) + { + cache = _cache; + _cache = null; + } + + if (cache is null) { return; } - foreach (var kvp in _cache) + foreach (var kvp in cache) { if (kvp.Value != _svg) { kvp.Value.Dispose(); } } - - _cache = null; } private static SvgSource CreateWorkingSource(SvgSource source) @@ -933,24 +1100,64 @@ private static SvgSource CreateWorkingSource(SvgSource source) return source.Svg?.HasAnimations == true ? source.Clone() : source; } - private static SvgParameters BuildParameters(string? css, string? currentCss, Color? currentColor) + private static SvgParameters? BuildParameters(SvgSource? source, string? css, string? currentCss, Color? currentColor) { - return new SvgParameters(null, CombineCss(css, currentCss), ToDrawingColor(currentColor)); + var sourceParameters = source?.Parameters; + var entities = sourceParameters?.Entities ?? source?.Entities; + var entitiesCopy = entities is null ? null : new Dictionary(entities); + var combinedCss = CombineCss(sourceParameters?.Css ?? source?.Css, css, currentCss); + var effectiveCurrentColor = ToDrawingColor(currentColor) ?? + sourceParameters?.CurrentColor ?? + ToDrawingColor(source?.CurrentColor); + var loadOptions = sourceParameters?.LoadOptions; + + if ((entitiesCopy is null || entitiesCopy.Count == 0) && + string.IsNullOrWhiteSpace(combinedCss) && + effectiveCurrentColor is null && + loadOptions is null) + { + return null; + } + + return new SvgParameters(entitiesCopy, combinedCss, effectiveCurrentColor, loadOptions); } - private static string? CombineCss(string? css, string? currentCss) + private static string? CombineCss(params string?[] values) { - if (string.IsNullOrWhiteSpace(css)) + StringBuilder? builder = null; + + foreach (var value in values) { - return string.IsNullOrWhiteSpace(currentCss) ? null : currentCss; + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + if (builder is null) + { + builder = new StringBuilder(value); + } + else + { + builder.Append(' '); + builder.Append(value); + } } - if (string.IsNullOrWhiteSpace(currentCss)) + return builder?.ToString(); + } + + private readonly struct LoadResult + { + public LoadResult(SvgSource? source, bool isCacheEntry) { - return css; + Source = source; + IsCacheEntry = isCacheEntry; } - return string.Concat(css, ' ', currentCss); + public SvgSource? Source { get; } + + public bool IsCacheEntry { get; } } private static DrawingColor? ToDrawingColor(Color? color) @@ -962,27 +1169,44 @@ private static SvgParameters BuildParameters(string? css, string? currentCss, Co private static string CreateCacheKey(string path, SvgParameters? parameters) { - if (string.IsNullOrWhiteSpace(parameters?.Css) && + if ((parameters?.Entities is null || parameters.Value.Entities.Count == 0) && + string.IsNullOrWhiteSpace(parameters?.Css) && parameters?.CurrentColor is null && parameters?.LoadOptions is null) { return path; } - return string.Concat( - path, - "\ncss:", - parameters?.Css, - "\ncurrentColor:", - parameters?.CurrentColor?.ToArgb().ToString("X8", CultureInfo.InvariantCulture), - "\nprocessingMode:", - parameters?.LoadOptions?.ProcessingMode, - "\nexternalResources:", - parameters?.LoadOptions?.ExternalResources, - "\npreserveUnknownElements:", - parameters?.LoadOptions?.PreserveUnknownElements, - "\npreferSvg2Href:", - parameters?.LoadOptions?.PreferSvg2Href); + var builder = new StringBuilder() + .Append(path) + .Append("\ncss:") + .Append(parameters?.Css) + .Append("\ncurrentColor:") + .Append(parameters?.CurrentColor?.ToArgb().ToString("X8", CultureInfo.InvariantCulture)) + .Append("\nprocessingMode:") + .Append(parameters?.LoadOptions?.ProcessingMode) + .Append("\nexternalResources:") + .Append(parameters?.LoadOptions?.ExternalResources) + .Append("\npreserveUnknownElements:") + .Append(parameters?.LoadOptions?.PreserveUnknownElements) + .Append("\npreferSvg2Href:") + .Append(parameters?.LoadOptions?.PreferSvg2Href); + + if (parameters?.Entities is { Count: > 0 } entities) + { + var keys = new List(entities.Keys); + keys.Sort(StringComparer.Ordinal); + foreach (var key in keys) + { + builder + .Append("\nentity:") + .Append(key) + .Append('=') + .Append(entities[key]); + } + } + + return builder.ToString(); } private void ReplaceCurrentSource(SvgSource? source) @@ -1003,16 +1227,19 @@ private void ReplaceCurrentSource(SvgSource? source) private bool IsCachedSource(SvgSource source) { - if (_cache is null) + lock (_cacheSync) { - return false; - } + if (_cache is null) + { + return false; + } - foreach (var cached in _cache.Values) - { - if (ReferenceEquals(cached, source)) + foreach (var cached in _cache.Values) { - return true; + if (ReferenceEquals(cached, source)) + { + return true; + } } } diff --git a/src/Svg.Controls.Skia.Avalonia/SvgSource.cs b/src/Svg.Controls.Skia.Avalonia/SvgSource.cs index 1e1d4f08b4..7b75c96fd3 100644 --- a/src/Svg.Controls.Skia.Avalonia/SvgSource.cs +++ b/src/Svg.Controls.Skia.Avalonia/SvgSource.cs @@ -8,6 +8,7 @@ using System.Net.Http; using System.Text; using System.Threading; +using System.Threading.Tasks; using Avalonia.Media; using Avalonia.Metadata; using Avalonia.Platform; @@ -29,6 +30,8 @@ public sealed class SvgSource : IDisposable public static readonly SkiaModel s_skiaModel; + private static readonly HttpClient s_httpClient = new(); + private SKSvg? _skSvg; private readonly Uri? _baseUri; @@ -37,6 +40,7 @@ public sealed class SvgSource : IDisposable private string? _originalPath; private Stream? _originalStream; private Uri? _originalBaseUri; + private int _activeDrawOperationReferences; private int _activeRenders; private readonly ThreadLocal _renderDepth = new(() => 0); private List? _deferredDisposals; @@ -122,22 +126,9 @@ public void Dispose() _disposePending = true; - if (_activeRenders > 0) + if (_activeRenders > 0 || _activeDrawOperationReferences > 0) { - if (_renderDepth.Value > 0) - { - return; - } - - while (_activeRenders > 0) - { - Monitor.Wait(Sync); - } - - if (_disposed) - { - return; - } + return; } DisposeCoreLocked(out picture, out skSvg, out originalStream); @@ -151,6 +142,28 @@ public void Dispose() /// public static bool EnableThrowOnMissingResource { get; set; } + internal bool HasPathSource + { + get + { + lock (Sync) + { + return _originalPath is not null || Path is not null; + } + } + } + + internal bool HasLoadedSource + { + get + { + lock (Sync) + { + return _originalStream is not null || _originalPath is not null || _skSvg is not null; + } + } + } + public object Sync { get; } = new(); private readonly struct ResourceDisposal @@ -175,6 +188,37 @@ static SvgSource() s_assetLoader = new SkiaSvgAssetLoader(s_skiaModel); } + public static Uri NormalizePath(string path, Uri? baseUri = null) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path must not be null or empty.", nameof(path)); + } + + if (File.Exists(path)) + { + return new Uri(System.IO.Path.GetFullPath(path)); + } + + if (Uri.TryCreate(path, UriKind.Absolute, out var absoluteUri)) + { + return absoluteUri; + } + + if (!path.StartsWith("/", StringComparison.Ordinal) && System.IO.Path.IsPathRooted(path)) + { + return new Uri(System.IO.Path.GetFullPath(path)); + } + + var relativeUri = path.StartsWith("/", StringComparison.Ordinal) + ? new Uri(path, UriKind.Relative) + : new Uri(path, UriKind.RelativeOrAbsolute); + + return baseUri is not null && !relativeUri.IsAbsoluteUri + ? new Uri(baseUri, relativeUri) + : relativeUri; + } + private static SKPicture? Load(SvgSource source, string? path, SvgParameters? parameters) { SKPicture? oldPicture = null; @@ -290,7 +334,7 @@ private static MemoryStream CreateStream(SvgDocument document) { try { - var response = new HttpClient().GetAsync(uriHttp).Result; + using var response = s_httpClient.GetAsync(uriHttp).Result; if (response.IsSuccessStatusCode) { var stream = response.Content.ReadAsStreamAsync().Result; @@ -324,6 +368,79 @@ private static MemoryStream CreateStream(SvgDocument document) } } + private static async Task LoadImplAsync( + SvgSource source, + string path, + Uri? baseUri, + SvgParameters? parameters, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (File.Exists(path)) + { + return await Task.Run(() => Load(source, path, parameters), cancellationToken).ConfigureAwait(false); + } + + var normalizedUri = NormalizePath(path, baseUri); + if (normalizedUri.IsAbsoluteUri && normalizedUri.IsFile) + { + if (!File.Exists(normalizedUri.LocalPath)) + { + ThrowOnMissingResource(path); + return null; + } + + return await Task.Run(() => Load(source, normalizedUri.LocalPath, parameters), cancellationToken) + .ConfigureAwait(false); + } + + if (normalizedUri.IsAbsoluteUri && normalizedUri.Scheme is "http" or "https") + { + try + { + using var response = await s_httpClient + .GetAsync(normalizedUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + ThrowOnMissingResource(path); + return null; + } + + await using var httpStream = await response.Content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + return await Task.Run(() => Load(source, httpStream, parameters, normalizedUri), cancellationToken) + .ConfigureAwait(false); + } + catch (HttpRequestException e) + { + Debug.WriteLine("Failed to connect to " + normalizedUri); + Debug.WriteLine(e.ToString()); + ThrowOnMissingResource(path); + return null; + } + } + + var assetUri = normalizedUri.IsAbsoluteUri + ? normalizedUri + : path.StartsWith("/", StringComparison.Ordinal) + ? new Uri(path, UriKind.Relative) + : new Uri(path, UriKind.RelativeOrAbsolute); + var assetBaseUri = normalizedUri.IsAbsoluteUri ? null : baseUri; + await using var stream = AssetLoader.Open(assetUri, assetBaseUri); + if (stream is null) + { + ThrowOnMissingResource(path); + return null; + } + + return await Task.Run(() => Load(source, stream, parameters, normalizedUri), cancellationToken) + .ConfigureAwait(false); + } + /// t /// Loads svg source from file or resource. /// @@ -338,6 +455,25 @@ public static SvgSource Load(string path, Uri? baseUri = default, SvgParameters? return source; } + /// t + /// Loads svg source from file or resource asynchronously. + /// + /// The path to file or resource. + /// The base uri. + /// The svg parameters. + /// A cancellation token. + /// The svg source. + public static async Task LoadAsync( + string path, + Uri? baseUri = default, + SvgParameters? parameters = null, + CancellationToken cancellationToken = default) + { + var source = new SvgSource(baseUri) { Path = path }; + await LoadImplAsync(source, path, baseUri, parameters, cancellationToken).ConfigureAwait(false); + return source; + } + /// /// Loads svg source from svg source. /// @@ -612,6 +748,57 @@ public void ReLoad(SvgParameters? parameters) } } + public async Task ReLoadAsync(SvgParameters? parameters, CancellationToken cancellationToken = default) + { + MemoryStream? streamCopy = null; + string? originalPath; + string? path; + Uri? originalBaseUri; + Uri? baseUri; + + lock (Sync) + { + if (_originalStream is null && _originalPath is null && Path is null) + { + return; + } + + if (_originalStream is { } originalStream) + { + streamCopy = new MemoryStream(); + var position = originalStream.Position; + originalStream.Position = 0; + originalStream.CopyTo(streamCopy); + streamCopy.Position = 0; + originalStream.Position = position; + } + originalPath = _originalPath; + originalBaseUri = _originalBaseUri; + path = Path; + baseUri = _baseUri; + } + + if (streamCopy is { }) + { + await Task.Run(() => LoadFromCachedStream(this, streamCopy, parameters, originalBaseUri), cancellationToken) + .ConfigureAwait(false); + return; + } + + if (originalPath is { }) + { + await LoadImplAsync(this, originalPath, originalBaseUri, parameters, cancellationToken) + .ConfigureAwait(false); + return; + } + + if (path is { }) + { + await LoadImplAsync(this, path, baseUri, parameters, cancellationToken) + .ConfigureAwait(false); + } + } + private bool ReplaceResources( SKPicture? picture, SKSvg? skSvg, @@ -668,7 +855,7 @@ private void QueueDeferredDisposalLocked(SKPicture? picture, SKSvg? skSvg, Strea _deferredDisposals.Add(new ResourceDisposal(picture, skSvg, originalStream)); } - internal bool BeginRender() + internal bool AddDrawOperationReference() { lock (Sync) { @@ -677,6 +864,46 @@ internal bool BeginRender() return false; } + _activeDrawOperationReferences++; + return true; + } + } + + internal void ReleaseDrawOperationReference() + { + SKPicture? picture = null; + SKSvg? skSvg = null; + Stream? originalStream = null; + + lock (Sync) + { + if (_activeDrawOperationReferences > 0) + { + _activeDrawOperationReferences--; + } + + if (_activeDrawOperationReferences == 0 && _activeRenders == 0 && _disposePending) + { + DisposeCoreLocked(out picture, out skSvg, out originalStream); + Monitor.PulseAll(Sync); + } + } + + if (picture is not null || skSvg is not null || originalStream is not null) + { + DisposeResources(picture, skSvg, originalStream); + } + } + + internal bool BeginRender() + { + lock (Sync) + { + if (_disposed || (_disposePending && _activeDrawOperationReferences == 0)) + { + return false; + } + _activeRenders++; _renderDepth.Value++; return true; @@ -702,7 +929,7 @@ internal void EndRender() deferredDisposals = _deferredDisposals; _deferredDisposals = null; - if (_disposePending) + if (_disposePending && _activeDrawOperationReferences == 0) { DisposeCoreLocked(out picture, out skSvg, out originalStream); } diff --git a/src/Svg.Controls.Skia.Avalonia/SvgSourceCustomDrawOperation.cs b/src/Svg.Controls.Skia.Avalonia/SvgSourceCustomDrawOperation.cs index 88b3982e86..f24dcaf2be 100644 --- a/src/Svg.Controls.Skia.Avalonia/SvgSourceCustomDrawOperation.cs +++ b/src/Svg.Controls.Skia.Avalonia/SvgSourceCustomDrawOperation.cs @@ -9,16 +9,26 @@ namespace Avalonia.Svg.Skia; public class SvgSourceCustomDrawOperation : ICustomDrawOperation { - private readonly SvgSource? _svg; + private SvgSource? _svg; + private bool _disposed; public SvgSourceCustomDrawOperation(Rect bounds, SvgSource? svg) { - _svg = svg; + _svg = svg?.AddDrawOperationReference() == true ? svg : null; Bounds = bounds; } public void Dispose() { + if (_disposed) + { + return; + } + + _disposed = true; + var svg = _svg; + _svg = null; + svg?.ReleaseDrawOperationReference(); } public Rect Bounds { get; } diff --git a/src/Svg.Controls.Skia.Avalonia/SvgSourceTypeConverter.cs b/src/Svg.Controls.Skia.Avalonia/SvgSourceTypeConverter.cs index 4fd10cc16d..91c3f60a76 100644 --- a/src/Svg.Controls.Skia.Avalonia/SvgSourceTypeConverter.cs +++ b/src/Svg.Controls.Skia.Avalonia/SvgSourceTypeConverter.cs @@ -15,14 +15,18 @@ public class SvgSourceTypeConverter : TypeConverter /// public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) { - return sourceType == typeof(string); + return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType); } /// public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) { - var path = (string)value; + if (value is not string path) + { + return base.ConvertFrom(context, culture, value); + } + var baseUri = context?.GetContextBaseUri(); - return SvgSource.Load(path, baseUri); + return new SvgSource(baseUri) { Path = path }; } } diff --git a/src/Svg.Custom/Animation/SvgAnimationElement.cs b/src/Svg.Custom/Animation/SvgAnimationElement.cs index b2ee9c0918..167b580ebe 100644 --- a/src/Svg.Custom/Animation/SvgAnimationElement.cs +++ b/src/Svg.Custom/Animation/SvgAnimationElement.cs @@ -6,6 +6,11 @@ namespace Svg { public abstract partial class SvgAnimationElement : SvgElement { + protected string GetRawAnimationStringAttribute(string attributeName) + { + return Attributes.GetAttribute(attributeName); + } + [SvgAttribute("href", SvgAttributeAttribute.XLinkNamespace)] public virtual Uri ReferencedElement { @@ -175,7 +180,7 @@ public virtual SvgAnimationCalcMode CalcMode [SvgAttribute("values")] public virtual string Values { - get { return GetAttribute("values", false); } + get { return GetRawAnimationStringAttribute("values"); } set { Attributes["values"] = value; } } @@ -197,21 +202,21 @@ public virtual string KeySplines [SvgAttribute("from")] public virtual string From { - get { return GetAttribute("from", false); } + get { return GetRawAnimationStringAttribute("from"); } set { Attributes["from"] = value; } } [SvgAttribute("to")] public virtual string To { - get { return GetAttribute("to", false); } + get { return GetRawAnimationStringAttribute("to"); } set { Attributes["to"] = value; } } [SvgAttribute("by")] public virtual string By { - get { return GetAttribute("by", false); } + get { return GetRawAnimationStringAttribute("by"); } set { Attributes["by"] = value; } } @@ -245,7 +250,7 @@ public partial class SvgSet : SvgAnimationAttributeElement [SvgAttribute("to")] public virtual string To { - get { return GetAttribute("to", false); } + get { return GetRawAnimationStringAttribute("to"); } set { Attributes["to"] = value; } } diff --git a/src/Svg.Custom/Animation/SvgDocument.Animation.cs b/src/Svg.Custom/Animation/SvgDocument.Animation.cs index 40764deac2..0331fea5c3 100644 --- a/src/Svg.Custom/Animation/SvgDocument.Animation.cs +++ b/src/Svg.Custom/Animation/SvgDocument.Animation.cs @@ -1,3 +1,5 @@ +using System; + namespace Svg { public partial class SvgDocument @@ -30,5 +32,62 @@ public override SvgElement DeepCopy() return newObj; } + + internal void RebindSameDocumentDeferredPaintServers() + { + RebindSameDocumentDeferredPaintServers(this); + + foreach (var element in Descendants()) + { + RebindSameDocumentDeferredPaintServers(element); + } + } + + private void RebindSameDocumentDeferredPaintServers(SvgElement element) + { + foreach (var attribute in element.Attributes) + { + if (attribute.Value is SvgDeferredPaintServer deferredPaintServer && + IsSameDocumentDeferredPaintServer(deferredPaintServer)) + { + deferredPaintServer.RebindDocument(this); + } + } + } + + private static bool IsSameDocumentDeferredPaintServer(SvgDeferredPaintServer paintServer) + { + var deferredId = paintServer.DeferredId?.Trim(); + if (string.IsNullOrEmpty(deferredId)) + { + return false; + } + + if (string.Equals(deferredId, "currentColor", StringComparison.Ordinal)) + { + return true; + } + + if (deferredId[0] == '#') + { + return true; + } + + if (!deferredId.StartsWith("url(", StringComparison.OrdinalIgnoreCase) || + !deferredId.EndsWith(")", StringComparison.Ordinal)) + { + return false; + } + + var url = deferredId.Substring(4, deferredId.Length - 5).Trim(); + if (url.Length >= 2 && + ((url[0] == '"' && url[url.Length - 1] == '"') || + (url[0] == '\'' && url[url.Length - 1] == '\''))) + { + url = url.Substring(1, url.Length - 2).Trim(); + } + + return url.StartsWith("#", StringComparison.Ordinal); + } } } diff --git a/src/Svg.Custom/Animation/SvgElement.AnimationRuntime.cs b/src/Svg.Custom/Animation/SvgElement.AnimationRuntime.cs index ee9f0c2d43..8c5cfcabac 100644 --- a/src/Svg.Custom/Animation/SvgElement.AnimationRuntime.cs +++ b/src/Svg.Custom/Animation/SvgElement.AnimationRuntime.cs @@ -13,7 +13,28 @@ public virtual object GetAnimationValue(string attributeName) throw new ArgumentNullException(nameof(attributeName)); } - return GetValue(attributeName); + var normalized = NormalizeAnimationAttributeName(attributeName, out var namespaceName, out _); + var value = GetValue(normalized); + if (value is not null) + { + return value; + } + + if (TryGetAttribute(attributeName, out var rawValue)) + { + return rawValue; + } + + if (namespaceName.Length != 0 && + TryGetAttribute(string.Concat(namespaceName, ":", normalized), out rawValue)) + { + return rawValue; + } + + return !string.Equals(normalized, attributeName, StringComparison.Ordinal) && + TryGetAttribute(normalized, out rawValue) + ? rawValue + : null; } public virtual bool TrySetAnimationValue(string attributeName, object value) @@ -31,7 +52,37 @@ public virtual bool TrySetAnimationValue(string attributeName, ITypeDescriptorCo context ??= OwnerDocument; culture ??= CultureInfo.InvariantCulture; - return SetValue(attributeName, context, culture, value); + var normalized = NormalizeAnimationAttributeName(attributeName, out var namespaceName, out var customAttributeName); + var stringValue = Convert.ToString(value, culture) ?? string.Empty; + var isStyleMutation = string.Equals(normalized, "style", StringComparison.Ordinal); + + if (IsHrefAttribute(normalized, namespaceName)) + { + SetCompatibilityHrefAttributeValue(namespaceName, stringValue); + } + + if (isStyleMutation) + { + (context as SvgDocument ?? OwnerDocument)?.TrackCompatibilityStyleStateCandidate(this); + } + + if (SetValue(normalized, context, culture, value)) + { + InvalidateAnimationAttributeChange(normalized); + return true; + } + + var document = context as SvgDocument ?? OwnerDocument; + if (document is not null && + SvgElementFactory.SetPropertyValue(this, namespaceName, normalized, stringValue, document, isStyle: false)) + { + InvalidateAnimationAttributeChange(normalized); + return true; + } + + CustomAttributes[customAttributeName] = stringValue; + InvalidateAnimationAttributeChange(normalized); + return true; } public virtual bool ClearAnimationValue(string attributeName) @@ -41,7 +92,130 @@ public virtual bool ClearAnimationValue(string attributeName) throw new ArgumentNullException(nameof(attributeName)); } - return Attributes.Remove(attributeName); + var normalized = NormalizeAnimationAttributeName(attributeName, out var namespaceName, out var customAttributeName); + var isStyleMutation = string.Equals(normalized, "style", StringComparison.Ordinal); + var removed = Attributes.Remove(normalized); + if (!string.Equals(normalized, attributeName, StringComparison.Ordinal)) + { + removed |= Attributes.Remove(attributeName); + } + + removed |= CustomAttributes.Remove(customAttributeName); + removed |= CustomAttributes.Remove(attributeName); + if (!string.Equals(normalized, customAttributeName, StringComparison.Ordinal)) + { + removed |= CustomAttributes.Remove(normalized); + } + + if (namespaceName.Length != 0) + { + removed |= CustomAttributes.Remove(string.Concat(namespaceName, ":", normalized)); + } + + if (IsHrefAttribute(normalized, namespaceName)) + { + ClearCompatibilityHrefAttributeValue(namespaceName); + removed = true; + } + + if (isStyleMutation) + { + OwnerDocument?.TrackCompatibilityStyleStateCandidate(this); + } + + if (removed) + { + InvalidateAnimationAttributeChange(normalized); + } + + return removed; + } + + private string NormalizeAnimationAttributeName(string attributeName, out string namespaceName, out string customAttributeName) + { + namespaceName = string.Empty; + customAttributeName = attributeName; + + if (attributeName.StartsWith("xlink:", StringComparison.Ordinal)) + { + namespaceName = SvgNamespaces.XLinkNamespace; + customAttributeName = attributeName; + return attributeName.Substring("xlink:".Length); + } + + if (attributeName.StartsWith(SvgNamespaces.XLinkNamespace + ":", StringComparison.Ordinal)) + { + namespaceName = SvgNamespaces.XLinkNamespace; + customAttributeName = attributeName; + return attributeName.Substring(SvgNamespaces.XLinkNamespace.Length + 1); + } + + if (attributeName.StartsWith("xml:", StringComparison.Ordinal)) + { + namespaceName = SvgNamespaces.XmlNamespace; + customAttributeName = attributeName; + return attributeName.Substring("xml:".Length); + } + + if (attributeName.StartsWith(SvgNamespaces.XmlNamespace + ":", StringComparison.Ordinal)) + { + namespaceName = SvgNamespaces.XmlNamespace; + customAttributeName = attributeName; + return attributeName.Substring(SvgNamespaces.XmlNamespace.Length + 1); + } + + var colonIndex = attributeName.IndexOf(':'); + if (colonIndex > 0 && colonIndex < attributeName.Length - 1) + { + var prefix = attributeName.Substring(0, colonIndex); + if (TryResolveAnimationAttributeNamespace(prefix, out namespaceName)) + { + return attributeName.Substring(colonIndex + 1); + } + + namespaceName = string.Empty; + } + + return attributeName; + } + + private bool TryResolveAnimationAttributeNamespace(string prefix, out string namespaceName) + { + for (SvgElement current = this; current is not null; current = current.Parent as SvgElement) + { + if (current.Namespaces.TryGetValue(prefix, out namespaceName)) + { + return true; + } + } + + var document = this as SvgDocument ?? OwnerDocument; + if (document is not null && document.Namespaces.TryGetValue(prefix, out namespaceName)) + { + return true; + } + + namespaceName = string.Empty; + return false; + } + + private static bool IsHrefAttribute(string attributeName, string namespaceName) + { + return string.Equals(attributeName, "href", StringComparison.Ordinal) && + (namespaceName.Length == 0 || + string.Equals(namespaceName, SvgNamespaces.XLinkNamespace, StringComparison.Ordinal)); + } + + private void InvalidateAnimationAttributeChange(string attributeName) + { + if (string.Equals(attributeName, "class", StringComparison.Ordinal) || + string.Equals(attributeName, "style", StringComparison.Ordinal)) + { + OwnerDocument?.ReapplyCompatibilityStylesAfterSelectorMutation(); + return; + } + + OwnerDocument?.InvalidateComputedStyleCache(); } } } diff --git a/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs b/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs index b7956df370..26014fd601 100644 --- a/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs +++ b/src/Svg.Custom/Compatibility/SvgDocument.DynamicStyles.cs @@ -227,6 +227,14 @@ internal void ReapplyCompatibilityStyles() ApplyCompatibilityStyles(); } + internal void ReapplyCompatibilityStylesAfterSelectorMutation() + { + EnsureCompatibilityStyleStateInitialized(); + InvalidateComputedStyleCache(); + RestoreCompatibilityStyleStateAfterSelectorMutation(); + ApplyCompatibilityStyles(); + } + internal void ApplyCompatibilityStyles() { if (_compatibilityStyleSources is { Count: > 0 }) @@ -286,6 +294,35 @@ private void RestoreCompatibilityStyleState() } } + private void RestoreCompatibilityStyleStateAfterSelectorMutation() + { + foreach (var element in EnumerateElements()) + { + SvgCompatibilityStyleSnapshot snapshot; + if (_compatibilityRawStyleState is not null && + _compatibilityRawStyleState.TryGetValue(element, out var rawState) && + rawState.HasPresentationAttributes) + { + snapshot = element.CreateCompatibilityStyleSnapshot(rawState); + } + else if (_compatibilityStyleState is not null && + _compatibilityStyleState.TryGetValue(element, out var storedSnapshot)) + { + snapshot = storedSnapshot; + } + else if (HasCompatibilityInlineStyle(element)) + { + snapshot = new SvgCompatibilityStyleSnapshot(element.CustomAttributes["style"]); + } + else + { + snapshot = SvgCompatibilityStyleSnapshot.Empty; + } + + element.RestoreCompatibilityStyleState(snapshot); + } + } + private SvgCompatibilityStyleSnapshot GetOrCreateCompatibilityStyleSnapshot(SvgElement element) { EnsureCompatibilityStyleStateInitialized(); @@ -328,13 +365,8 @@ _compatibilityRawStyleState is not null && private Dictionary? CreateCompatibilityStyleStateMap() { - if (_compatibilityStyleStateTrackingEnabled && _compatibilityStyleStateCandidates is null) - { - return null; - } - Dictionary? state = null; - var elements = _compatibilityStyleStateTrackingEnabled && _compatibilityStyleStateCandidates is not null + var elements = _compatibilityStyleStateTrackingEnabled && _compatibilityStyleStateCandidates is { Count: > 0 } ? _compatibilityStyleStateCandidates : EnumerateElements(); foreach (var element in elements) diff --git a/src/Svg.Custom/Compatibility/SvgDocumentCompatibilityLoader.cs b/src/Svg.Custom/Compatibility/SvgDocumentCompatibilityLoader.cs index 23c9cf3381..4e736005aa 100644 --- a/src/Svg.Custom/Compatibility/SvgDocumentCompatibilityLoader.cs +++ b/src/Svg.Custom/Compatibility/SvgDocumentCompatibilityLoader.cs @@ -196,14 +196,17 @@ private static T Create( { if (styles is { Count: > 0 }) { + svgDocument.SetCompatibilityStyleSources(styles); if (captureCompatibilityStyleState) { if (!preserveCompatibilityPresentationAttributes) { svgDocument.CaptureCompatibilityStyleState(); } - - svgDocument.SetCompatibilityStyleSources(styles); + } + else + { + svgDocument.CaptureCompatibilityStyleState(); } SvgCssCompatibilityProcessor.Apply(svgDocument, styles, elementFactory, svgDocument.LoadOptions); diff --git a/src/Svg.Custom/Compatibility/SvgElement.JavaScriptDomState.cs b/src/Svg.Custom/Compatibility/SvgElement.JavaScriptDomState.cs index a94e2e5a41..7d54fe578a 100644 --- a/src/Svg.Custom/Compatibility/SvgElement.JavaScriptDomState.cs +++ b/src/Svg.Custom/Compatibility/SvgElement.JavaScriptDomState.cs @@ -71,6 +71,12 @@ internal bool TryGetCompatibilityHrefAttributeValue(string namespaceName, out st return false; } + internal void ClearCompatibilityHrefAttributeValue(string namespaceName) + { + namespaceName ??= string.Empty; + _compatibilityHrefAttributeValues?.Remove(namespaceName); + } + internal bool HasCompatibilityHrefAttributeValues() { return _compatibilityHrefAttributeValues is { Count: > 0 }; diff --git a/src/Svg.Custom/Painting/SvgDeferredPaintServer.cs b/src/Svg.Custom/Painting/SvgDeferredPaintServer.cs index 2847a21997..02ee31f726 100644 --- a/src/Svg.Custom/Painting/SvgDeferredPaintServer.cs +++ b/src/Svg.Custom/Painting/SvgDeferredPaintServer.cs @@ -121,6 +121,19 @@ public override SvgElement DeepCopy() return newObj; } + internal void RebindDocument(SvgDocument document) + { + Document = document; + _concreteServer = null; + _fallbackServer = null; + _serverLoaded = false; + + if (FallbackServer is SvgDeferredPaintServer deferredFallback) + { + deferredFallback.RebindDocument(document); + } + } + public override bool Equals(object obj) { var other = obj as SvgDeferredPaintServer; diff --git a/src/Svg.Editor.Skia.Avalonia/SvgEditorWorkspace.axaml.cs b/src/Svg.Editor.Skia.Avalonia/SvgEditorWorkspace.axaml.cs index 50a1018740..7d47ecf4c9 100644 --- a/src/Svg.Editor.Skia.Avalonia/SvgEditorWorkspace.axaml.cs +++ b/src/Svg.Editor.Skia.Avalonia/SvgEditorWorkspace.axaml.cs @@ -488,9 +488,6 @@ public void LoadDocument(string path) { var uri = TryResolveDocumentUri(path); - if (SvgView.SkSvg is { } skSvg) - skSvg.OnDraw -= SvgView_OnDraw; - if (uri is not null) { using var stream = AssetLoader.Open(uri); @@ -500,7 +497,6 @@ public void LoadDocument(string path) { Console.WriteLine($"Failed to load SVG resource '{path}'."); } - SvgView.Path = uri.ToString(); } else if (File.Exists(path)) { @@ -510,25 +506,17 @@ public void LoadDocument(string path) { Console.WriteLine($"Failed to load SVG file '{path}'."); } - SvgView.Path = path; } else { _document = null; Session.Document = null; - SvgView.Path = null; Console.WriteLine($"SVG document '{path}' not found."); } - if (SvgView.SkSvg is { } skSvg2) - { - skSvg2.FromSvgDocument(_document); - skSvg2.OnDraw += SvgView_OnDraw; - } - else if (_document is null) - { + LoadCurrentDocumentIntoSvgView(); + if (_document is null) return; - } SvgView.Zoom = 1.0; SvgView.PanX = 0; @@ -548,6 +536,20 @@ public void LoadDocument(string path) UpdateStyles(); } + private void LoadCurrentDocumentIntoSvgView() + { + if (SvgView.SkSvg is { } previousSvg) + previousSvg.OnDraw -= SvgView_OnDraw; + + SvgView.LoadFromSvgDocument(_document); + + if (SvgView.SkSvg is { } currentSvg) + { + currentSvg.OnDraw -= SvgView_OnDraw; + currentSvg.OnDraw += SvgView_OnDraw; + } + } + private void ClearSelectionState() { _selectedSceneNode = null; @@ -2504,7 +2506,7 @@ private void NewMenuItem_Click(object? sender, RoutedEventArgs e) SaveUndoState(); _document = new SvgDocument { Width = 100, Height = 100 }; Session.Document = _document; - SvgView.SkSvg!.FromSvgDocument(_document); + LoadCurrentDocumentIntoSvgView(); SaveExpandedNodes(); Session.CurrentFile = null; Session.ClearClipboard(); diff --git a/src/Svg.Model/DrawAttributes.cs b/src/Svg.Model/DrawAttributes.cs index 72afe67dd3..4a1102e794 100644 --- a/src/Svg.Model/DrawAttributes.cs +++ b/src/Svg.Model/DrawAttributes.cs @@ -16,5 +16,6 @@ public enum DrawAttributes Mask = 32, RequiredFeatures = 64, RequiredExtensions = 128, - SystemLanguage = 256 + SystemLanguage = 256, + Markers = 512 } diff --git a/src/Svg.Model/Services/MaskingService.cs b/src/Svg.Model/Services/MaskingService.cs index 4a361fd085..de40a22e7a 100644 --- a/src/Svg.Model/Services/MaskingService.cs +++ b/src/Svg.Model/Services/MaskingService.cs @@ -396,22 +396,75 @@ internal static void GetSvgVisualElementClipPath(SvgVisualElement? svgVisualElem internal static SKRect? GetClipRect(string clip, SKRect skRectBounds) { - if (!string.IsNullOrEmpty(clip) && clip.StartsWith("rect(", StringComparison.Ordinal)) + if (string.IsNullOrWhiteSpace(clip)) { - clip = clip.Trim(); - var offsets = new List(); - foreach (var o in clip.Substring(5, clip.Length - 6).Split(',')) - { - offsets.Add(float.Parse(o.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture)); - } + return default; + } + + clip = clip.Trim(); + if (!clip.StartsWith("rect(", StringComparison.OrdinalIgnoreCase) || + !clip.EndsWith(")", StringComparison.Ordinal)) + { + return default; + } + + var value = clip.Substring(5, clip.Length - 6); + var parts = value.IndexOf(',') >= 0 + ? value.Split(',') + : value.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length != 4) + { + return default; + } + + if (value.IndexOf(',') >= 0) + { + return CreateLegacyOffsetClipRect(parts, skRectBounds); + } + + if (!TryResolveClipEdge(parts[0], 0f, out var top) || + !TryResolveClipEdge(parts[1], skRectBounds.Width, out var right) || + !TryResolveClipEdge(parts[2], skRectBounds.Height, out var bottom) || + !TryResolveClipEdge(parts[3], 0f, out var left)) + { + return default; + } + + var width = Math.Max(0f, right - left); + var height = Math.Max(0f, bottom - top); + return SKRect.Create(skRectBounds.Left + left, skRectBounds.Top + top, width, height); + } - var skClipRect = SKRect.Create( - skRectBounds.Left + offsets[3], - skRectBounds.Top + offsets[0], - skRectBounds.Width - (offsets[3] + offsets[1]), - skRectBounds.Height - (offsets[2] + offsets[0])); - return skClipRect; + private static SKRect? CreateLegacyOffsetClipRect(string[] parts, SKRect skRectBounds) + { + if (!TryResolveClipEdge(parts[0], 0f, out var topOffset) || + !TryResolveClipEdge(parts[1], 0f, out var rightOffset) || + !TryResolveClipEdge(parts[2], 0f, out var bottomOffset) || + !TryResolveClipEdge(parts[3], 0f, out var leftOffset)) + { + return default; } - return default; + + var width = Math.Max(0f, skRectBounds.Width - leftOffset - rightOffset); + var height = Math.Max(0f, skRectBounds.Height - topOffset - bottomOffset); + return SKRect.Create(skRectBounds.Left + leftOffset, skRectBounds.Top + topOffset, width, height); + } + + private static bool TryResolveClipEdge(string value, float autoValue, out float edge) + { + value = value.Trim(); + if (string.Equals(value, "auto", StringComparison.OrdinalIgnoreCase)) + { + edge = autoValue; + return true; + } + + if (value.EndsWith("px", StringComparison.OrdinalIgnoreCase)) + { + value = value.Substring(0, value.Length - 2).Trim(); + } + + return float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out edge); } } diff --git a/src/Svg.Model/Services/PaintingService.cs b/src/Svg.Model/Services/PaintingService.cs index af30b974b8..688b8ffa92 100644 --- a/src/Svg.Model/Services/PaintingService.cs +++ b/src/Svg.Model/Services/PaintingService.cs @@ -255,8 +255,7 @@ internal static SKShader CreateLinearGradient(SvgLinearGradientServer svgLinearG { if (firstSpreadMethod is null) { - var pSpreadMethod = p.SpreadMethod; - if (pSpreadMethod != SvgGradientSpreadMethod.Pad) + if (SvgService.TryGetAttribute(p, "spreadMethod", out _)) { firstSpreadMethod = p; } @@ -271,8 +270,7 @@ internal static SKShader CreateLinearGradient(SvgLinearGradientServer svgLinearG } if (firstGradientUnits is null) { - var pGradientUnits = p.GradientUnits; - if (pGradientUnits != SvgCoordinateUnits.ObjectBoundingBox) + if (SvgService.TryGetAttribute(p, "gradientUnits", out _)) { firstGradientUnits = p; } @@ -417,8 +415,7 @@ internal static SKShader CreateTwoPointConicalGradient(SvgRadialGradientServer s { if (firstSpreadMethod is null) { - var pSpreadMethod = p.SpreadMethod; - if (pSpreadMethod != SvgGradientSpreadMethod.Pad) + if (SvgService.TryGetAttribute(p, "spreadMethod", out _)) { firstSpreadMethod = p; } @@ -433,8 +430,7 @@ internal static SKShader CreateTwoPointConicalGradient(SvgRadialGradientServer s } if (firstGradientUnits is null) { - var pGradientUnits = p.GradientUnits; - if (pGradientUnits != SvgCoordinateUnits.ObjectBoundingBox) + if (SvgService.TryGetAttribute(p, "gradientUnits", out _)) { firstGradientUnits = p; } @@ -512,6 +508,10 @@ internal static SKShader CreateTwoPointConicalGradient(SvgRadialGradientServer s var centerY = normalizedCenterY.ToDeviceValue(UnitRenderingType.Vertical, svgRadialGradientServer, skBounds); var radius = normalizedRadius.ToDeviceValue(UnitRenderingType.Other, svgRadialGradientServer, skBounds); + if (radius < 0f) + { + return SKShader.CreateColor(new SKColor(0xFF, 0xFF, 0xFF, 0x00), skColorSpace); + } var focalX = normalizedFocalX.ToDeviceValue(UnitRenderingType.Horizontal, svgRadialGradientServer, skBounds); var focalY = normalizedFocalY.ToDeviceValue(UnitRenderingType.Vertical, svgRadialGradientServer, skBounds); diff --git a/src/Svg.Model/Services/SvgPatternPaintStateResolver.cs b/src/Svg.Model/Services/SvgPatternPaintStateResolver.cs index 6b72cccd58..98067ed3d1 100644 --- a/src/Svg.Model/Services/SvgPatternPaintStateResolver.cs +++ b/src/Svg.Model/Services/SvgPatternPaintStateResolver.cs @@ -106,13 +106,9 @@ public static bool TryCreate( firstViewBox = pattern; } - if (firstAspectRatio is null) + if (firstAspectRatio is null && SvgService.TryGetAttribute(pattern, "preserveAspectRatio", out _)) { - var aspectRatio = pattern.AspectRatio; - if (aspectRatio.Align != SvgPreserveAspectRatio.xMidYMid || aspectRatio.Slice || aspectRatio.Defer) - { - firstAspectRatio = pattern; - } + firstAspectRatio = pattern; } } @@ -139,6 +135,11 @@ public static bool TryCreate( var shaderMatrix = SKMatrix.CreateIdentity(); shaderMatrix = shaderMatrix.PreConcat(TransformsService.ToMatrix(patternTransform)); + if (!shaderMatrix.TryInvert(out _)) + { + return false; + } + shaderMatrix = shaderMatrix.PreConcat(SKMatrix.CreateTranslation(patternRect.Value.Left, patternRect.Value.Top)); var pictureTransform = SKMatrix.CreateIdentity(); diff --git a/src/Svg.SceneGraph/SvgSceneClipCompiler.cs b/src/Svg.SceneGraph/SvgSceneClipCompiler.cs index 55c84d6d49..fe3fe3542c 100644 --- a/src/Svg.SceneGraph/SvgSceneClipCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneClipCompiler.cs @@ -239,6 +239,13 @@ referencedVisualElement is SvgSymbol || var previousClipCount = clipPath.Clips?.Count ?? 0; PopulateVisualClip(referencedVisualElement, targetBounds, assetLoader, uris, clipPath, svgClipPathClipRule); + if (clipPath.Clips is { Count: > 0 } populatedClips && + populatedClips.Count > previousClipCount) + { + var useTransform = CreateUseClipTransform(svgUse, targetBounds); + ApplyUseClipTransform(populatedClips, previousClipCount, useTransform); + } + if (clipPath.Clips is { Count: > 0 } clips && clips.Count > previousClipCount && clips[clips.Count - 1].Clip is { } lastClip) @@ -247,6 +254,27 @@ referencedVisualElement is SvgSymbol || } } + private static SKMatrix CreateUseClipTransform(SvgUse svgUse, SKRect targetBounds) + { + var x = SvgGeometryService.GetComputedUnit(svgUse, "x", svgUse.X).ToDeviceValue(UnitRenderingType.Horizontal, svgUse, targetBounds); + var y = SvgGeometryService.GetComputedUnit(svgUse, "y", svgUse.Y).ToDeviceValue(UnitRenderingType.Vertical, svgUse, targetBounds); + return TransformsService.ToMatrix(svgUse.Transforms).PreConcat(SKMatrix.CreateTranslation(x, y)); + } + + private static void ApplyUseClipTransform(IList clips, int startIndex, SKMatrix useTransform) + { + if (useTransform.IsIdentity) + { + return; + } + + for (var i = startIndex; i < clips.Count; i++) + { + var clip = clips[i]; + clip.Transform = useTransform.PreConcat(clip.Transform ?? SKMatrix.CreateIdentity()); + } + } + private static void AddTextClip( SvgText svgText, SKRect targetBounds, diff --git a/src/Svg.SceneGraph/SvgSceneCompiler.cs b/src/Svg.SceneGraph/SvgSceneCompiler.cs index f4ff8660c9..dd2cca78be 100644 --- a/src/Svg.SceneGraph/SvgSceneCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneCompiler.cs @@ -1566,7 +1566,9 @@ private static bool TryCompileDirectElementNode( HasFeatureFlag(ownFeatureFlags, SvgCascadedStyleFeatureFlags.MixBlendMode), HasFeatureFlag(ownFeatureFlags, SvgCascadedStyleFeatureFlags.Isolation)); AssignRetainedResourceKeys(node, element, compileContext.GetElementAddressKey); - var markerElement = compileContext.ActiveMarkerReferenceDeclarationCandidate && HasMarkerReference(visualElement) + var markerElement = !ignoreAttributes.HasFlag(DrawAttributes.Markers) && + compileContext.ActiveMarkerReferenceDeclarationCandidate && + HasMarkerReference(visualElement) ? visualElement : null; @@ -2801,7 +2803,7 @@ private static bool TryCompileDirectMarkerNode( viewport, node.TotalTransform, assetLoader, - DrawAttributes.Display | ignoreAttributes, + DrawAttributes.Display | DrawAttributes.Markers | ignoreAttributes, compilationRootKey, createOwnCompilationRootBoundary: false, compileContext); diff --git a/src/Svg.SceneGraph/SvgSceneFilterContext.cs b/src/Svg.SceneGraph/SvgSceneFilterContext.cs index 290931e690..0a925c0a7d 100644 --- a/src/Svg.SceneGraph/SvgSceneFilterContext.cs +++ b/src/Svg.SceneGraph/SvgSceneFilterContext.cs @@ -1162,7 +1162,7 @@ private float[] CreateIdentityColorMatrixArray() { case SvgColourMatrixType.HueRotate: { - var value = string.IsNullOrEmpty(svgColourMatrix.Values) ? 0 : float.Parse(svgColourMatrix.Values, NumberStyles.Any, CultureInfo.InvariantCulture); + var value = TryParseSingle(svgColourMatrix.Values, 0f); var hue = (float)SvgService.DegreeToRadian(value); var cosHue = Math.Cos(hue); var sinHue = Math.Sin(hue); @@ -1196,7 +1196,7 @@ private float[] CreateIdentityColorMatrixArray() case SvgColourMatrixType.Saturate: { - var value = string.IsNullOrEmpty(svgColourMatrix.Values) ? 1 : float.Parse(svgColourMatrix.Values, NumberStyles.Any, CultureInfo.InvariantCulture); + var value = TryParseSingle(svgColourMatrix.Values, 1f); float[] matrix = { (float)(0.213+0.787*value), (float)(0.715-0.715*value), (float)(0.072-0.072*value), 0, 0, (float)(0.213-0.213*value), (float)(0.715+0.285*value), (float)(0.072-0.072*value), 0, 0, @@ -1223,12 +1223,19 @@ private float[] CreateIdentityColorMatrixArray() matrix = new float[20]; for (var i = 0; i < 20; i++) { - matrix[i] = float.Parse(parts[i], NumberStyles.Any, CultureInfo.InvariantCulture); + if (!float.TryParse(parts[i], NumberStyles.Any, CultureInfo.InvariantCulture, out matrix[i])) + { + matrix = CreateIdentityColorMatrixArray(); + break; + } + } + if (matrix.Length == 20) + { + matrix[4] *= 255f; + matrix[9] *= 255f; + matrix[14] *= 255f; + matrix[19] *= 255f; } - matrix[4] *= 255f; - matrix[9] *= 255f; - matrix[14] *= 255f; - matrix[19] *= 255f; } else { @@ -1243,6 +1250,14 @@ private float[] CreateIdentityColorMatrixArray() return SKImageFilter.CreateColorFilter(skColorFilter, input, cropRect); } + private static float TryParseSingle(string? value, float fallback) + { + return !string.IsNullOrWhiteSpace(value) && + float.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : fallback; + } + private void Identity(byte[] values, SvgComponentTransferFunction transferFunction) { } @@ -1718,7 +1733,7 @@ static byte ToByte(float value) sigmaY *= value; } - if (sigmaX < 0f && sigmaY < 0f) + if (sigmaX < 0f || sigmaY < 0f) { return default; } @@ -2099,15 +2114,23 @@ private static bool HasDefinitionAncestor(SvgElement element) radiusY *= value; } + if (radiusX < 0f || radiusY < 0f) + { + return default; + } + if (radiusX <= 0f && radiusY <= 0f) { return default; } + var kernelRadiusX = radiusX <= 0f ? 0 : Math.Max(1, (int)Math.Ceiling(radiusX)); + var kernelRadiusY = radiusY <= 0f ? 0 : Math.Max(1, (int)Math.Ceiling(radiusY)); + return svgMorphology.Operator switch { - SvgMorphologyOperator.Dilate => SKImageFilter.CreateDilate((int)radiusX, (int)radiusY, input, cropRect), - SvgMorphologyOperator.Erode => SKImageFilter.CreateErode((int)radiusX, (int)radiusY, input, cropRect), + SvgMorphologyOperator.Dilate => SKImageFilter.CreateDilate(kernelRadiusX, kernelRadiusY, input, cropRect), + SvgMorphologyOperator.Erode => SKImageFilter.CreateErode(kernelRadiusX, kernelRadiusY, input, cropRect), _ => null, }; } diff --git a/src/Svg.SceneGraph/SvgScenePaintingService.cs b/src/Svg.SceneGraph/SvgScenePaintingService.cs index 96e81ba812..e8c00ea05a 100644 --- a/src/Svg.SceneGraph/SvgScenePaintingService.cs +++ b/src/Svg.SceneGraph/SvgScenePaintingService.cs @@ -28,6 +28,9 @@ internal static class SvgScenePaintingService { internal readonly record struct SolidFillPaintCacheKey(bool IsAntialias, SKColor Color, bool LinearRgb); + [ThreadStatic] + private static HashSet? s_activePatternServers; + internal static float AdjustSvgOpacity(float opacity) { return Math.Min(Math.Max(opacity, 0f), 1f); @@ -732,7 +735,7 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) foreach (var p in svgReferencedGradientServers) { - if (firstSpreadMethod is null && p.SpreadMethod != SvgGradientSpreadMethod.Pad) + if (firstSpreadMethod is null && SvgService.TryGetAttribute(p, "spreadMethod", out _)) { firstSpreadMethod = p; } @@ -742,7 +745,7 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) firstGradientTransform = p; } - if (firstGradientUnits is null && p.GradientUnits != SvgCoordinateUnits.ObjectBoundingBox) + if (firstGradientUnits is null && SvgService.TryGetAttribute(p, "gradientUnits", out _)) { firstGradientUnits = p; } @@ -869,7 +872,7 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) foreach (var p in svgReferencedGradientServers) { - if (firstSpreadMethod is null && p.SpreadMethod != SvgGradientSpreadMethod.Pad) + if (firstSpreadMethod is null && SvgService.TryGetAttribute(p, "spreadMethod", out _)) { firstSpreadMethod = p; } @@ -879,7 +882,7 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) firstGradientTransform = p; } - if (firstGradientUnits is null && p.GradientUnits != SvgCoordinateUnits.ObjectBoundingBox) + if (firstGradientUnits is null && SvgService.TryGetAttribute(p, "gradientUnits", out _)) { firstGradientUnits = p; } @@ -937,6 +940,11 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) var focalY = focalYUnit.Normalize(svgGradientUnits).ToDeviceValue(UnitRenderingType.Vertical, svgRadialGradientServer, skBounds); var focalRadius = focalRadiusUnit.Normalize(svgGradientUnits).ToDeviceValue(UnitRenderingType.Other, svgRadialGradientServer, skBounds); + if (radius < 0f) + { + return null; + } + var colors = new List(); var colorPos = new List(); var isLinearRgb = skColorSpace == SKColorSpace.SrgbLinear; @@ -1019,6 +1027,13 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) { return null; } + + if (IsActivePattern(svgPatternServer) || IsActivePattern(patternState.ContentSource)) + { + return null; + } + + using var activePatternScope = PushActivePattern(svgPatternServer, patternState.ContentSource); var patternScene = SvgSceneCompiler.CompileTemporaryChildrenScene( patternState.ContentSource, patternState.Children, @@ -1038,4 +1053,58 @@ private static SKColorF[] ToSkColorF(IReadOnlyList skColors) ? null : SKShader.CreatePicture(picture, SKShaderTileMode.Repeat, SKShaderTileMode.Repeat, patternState.ShaderMatrix, picture.CullRect); } + + private static bool IsActivePattern(SvgPatternServer svgPatternServer) + => s_activePatternServers?.Contains(svgPatternServer) == true; + + private static ActivePatternScope PushActivePattern(SvgPatternServer svgPatternServer, SvgPatternServer contentSource) + { + s_activePatternServers ??= new HashSet(); + var addedPattern = s_activePatternServers.Add(svgPatternServer); + var addedContentSource = !ReferenceEquals(svgPatternServer, contentSource) && s_activePatternServers.Add(contentSource); + return new ActivePatternScope(svgPatternServer, contentSource, addedPattern, addedContentSource); + } + + private readonly struct ActivePatternScope : IDisposable + { + private readonly SvgPatternServer _svgPatternServer; + private readonly SvgPatternServer _contentSource; + private readonly bool _addedPattern; + private readonly bool _addedContentSource; + + public ActivePatternScope( + SvgPatternServer svgPatternServer, + SvgPatternServer contentSource, + bool addedPattern, + bool addedContentSource) + { + _svgPatternServer = svgPatternServer; + _contentSource = contentSource; + _addedPattern = addedPattern; + _addedContentSource = addedContentSource; + } + + public void Dispose() + { + if (s_activePatternServers is null) + { + return; + } + + if (_addedContentSource) + { + s_activePatternServers.Remove(_contentSource); + } + + if (_addedPattern) + { + s_activePatternServers.Remove(_svgPatternServer); + } + + if (s_activePatternServers.Count == 0) + { + s_activePatternServers = null; + } + } + } } diff --git a/src/Svg.Skia/SkiaModel.cs b/src/Svg.Skia/SkiaModel.cs index 206f9754a3..8af78f13aa 100644 --- a/src/Svg.Skia/SkiaModel.cs +++ b/src/Svg.Skia/SkiaModel.cs @@ -1247,7 +1247,7 @@ public SkiaSharp.SKColorChannel ToSKColorChannel(SKColorChannel colorChannel) ? SkiaSharp.SKImageFilter.CreateSpotLitSpecular( ToSKPoint3(spotLitSpecularImageFilter.Location), ToSKPoint3(spotLitSpecularImageFilter.Target), - spotLitSpecularImageFilter.Shininess, + spotLitSpecularImageFilter.SpecularExponent, spotLitSpecularImageFilter.CutoffAngle, ToSKColor(spotLitSpecularImageFilter.LightColor), spotLitSpecularImageFilter.SurfaceScale, @@ -1263,7 +1263,7 @@ public SkiaSharp.SKColorChannel ToSKColorChannel(SKColorChannel colorChannel) ToSKColor(spotLitSpecularImageFilter.LightColor), spotLitSpecularImageFilter.SurfaceScale, spotLitSpecularImageFilter.Ks, - spotLitSpecularImageFilter.SpecularExponent, + spotLitSpecularImageFilter.Shininess, ToSKImageFilter(spotLitSpecularImageFilter.Input)); } case TileImageFilter tileImageFilter: diff --git a/src/Svg.Skia/SkiaSvgAssetLoader.cs b/src/Svg.Skia/SkiaSvgAssetLoader.cs index 74a2741860..b708b4b4f5 100644 --- a/src/Svg.Skia/SkiaSvgAssetLoader.cs +++ b/src/Svg.Skia/SkiaSvgAssetLoader.cs @@ -34,8 +34,8 @@ public SkiaSvgAssetLoader(SkiaModel skiaModel) public ShimSkiaSharp.SKImage LoadImage(System.IO.Stream stream) { var data = ShimSkiaSharp.SKImage.FromStream(stream); - using var image = SkiaSharp.SKImage.FromEncodedData(data); - return new ShimSkiaSharp.SKImage { Data = data, Width = image.Width, Height = image.Height }; + using var image = data is { Length: > 0 } ? SkiaSharp.SKImage.FromEncodedData(data) : null; + return new ShimSkiaSharp.SKImage { Data = data, Width = image?.Width ?? 0, Height = image?.Height ?? 0 }; } /// diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgControlTests.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgControlTests.cs index 0838ec9bdb..1238bbb48d 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgControlTests.cs +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgControlTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -14,6 +15,7 @@ using ShimSkiaSharp; using ShimSkiaSharp.Editing; using Svg.Model; +using Svg.Model.Services; using Svg.Skia; using Xunit; @@ -75,6 +77,114 @@ public async Task Source_UsesMostRecentInlineSvg() Assert.Equal(12, svg.Picture.CullRect.Height); } + [AvaloniaFact] + public async Task SourceLoad_ClearsCompletedPendingLoad() + { + var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")); + + svg.Source = SampleSvg; + + await WaitForSourceAsync(svg); + await WaitForPendingLoadToClearAsync(svg); + + Assert.Null(GetPrivateField(svg, "_pendingLoadCts")); + } + + [AvaloniaFact] + public async Task Path_LoadsFileSvg() + { + var path = CreateTempSvgFile(SampleSvg); + try + { + var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")) + { + Path = path + }; + + await WaitForSourceAsync(svg); + + Assert.NotNull(svg.Picture); + Assert.Equal(10, svg.Picture!.CullRect.Width); + Assert.Equal(10, svg.Picture.CullRect.Height); + } + finally + { + File.Delete(path); + } + } + + [AvaloniaFact] + public async Task Path_UsesMostRecentFileSvg() + { + var firstPath = CreateTempSvgFile(SampleSvg); + var secondPath = CreateTempSvgFile(ReplacementSvg); + try + { + var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")); + + svg.Path = firstPath; + svg.Path = secondPath; + + await WaitForSourceAsync(svg); + + Assert.NotNull(svg.Picture); + Assert.Equal(20, svg.Picture!.CullRect.Width); + Assert.Equal(12, svg.Picture.CullRect.Height); + } + finally + { + File.Delete(firstPath); + File.Delete(secondPath); + } + } + + [AvaloniaFact] + public async Task SvgSource_LoadsExternalSource() + { + using var source = SvgSource.LoadFromSvg(SampleSvg); + var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")) + { + SvgSource = source + }; + + await WaitForSourceAsync(svg); + + Assert.NotNull(svg.Picture); + Assert.Equal(10, svg.Picture!.CullRect.Width); + Assert.Equal(10, svg.Picture.CullRect.Height); + } + + [AvaloniaFact] + public void LoadFromSvgDocument_SetsCurrentSourceSynchronously() + { + var document = SvgService.FromSvg(SampleSvg); + Assert.NotNull(document); + + var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")); + + svg.LoadFromSvgDocument(document); + + Assert.NotNull(svg.SkSvg); + Assert.NotNull(svg.Picture); + Assert.Equal(10, svg.Picture!.CullRect.Width); + Assert.Equal(10, svg.Picture.CullRect.Height); + } + + [AvaloniaFact] + public async Task SvgSource_AppliesCurrentColorOverride() + { + using var source = SvgSource.LoadFromSvg(CurrentColorSvg); + var svg = new Svg(new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")) + { + SvgSource = source, + CurrentColor = Color.FromRgb(0, 128, 255) + }; + + await WaitForSourceAsync(svg); + + Assert.Equal(new SKColor(0, 128, 255, 255), GetFirstFillColor(svg.SkSvg)); + } + [AvaloniaFact] public async Task Source_AppliesCurrentRenderOptionsWhenLoadCompletes() { @@ -349,6 +459,13 @@ private static void SetPrivateField(Svg svg, string fieldName, object value) field.SetValue(svg, value); } + private static string CreateTempSvgFile(string svg) + { + var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{Guid.NewGuid():N}.svg"); + File.WriteAllText(path, svg); + return path; + } + private static async Task WaitForSourceAsync(Svg svg) { var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); @@ -380,4 +497,20 @@ private static async Task WaitForSourceChangeAsync(Svg svg, object? previousPict await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); } } + + private static async Task WaitForPendingLoadToClearAsync(Svg svg) + { + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (GetPrivateField(svg, "_pendingLoadCts") is not null) + { + if (DateTime.UtcNow > deadline) + { + Assert.Null(GetPrivateField(svg, "_pendingLoadCts")); + return; + } + + await Task.Delay(10); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + } + } } diff --git a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs index 06ab998cec..7315dd77fd 100644 --- a/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs +++ b/tests/Svg.Controls.Skia.Avalonia.UnitTests/SvgSourceTests.cs @@ -2,7 +2,9 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; +using Avalonia; using Avalonia.Headless.XUnit; using Avalonia.Svg.Skia; using ShimSkiaSharp; @@ -111,6 +113,80 @@ public void Load_AppliesSharedSkiaModelJavaScriptSettings() } } + [AvaloniaFact] + public async Task LoadAsync_FilePath_SetsSvg() + { + var path = CreateTempSvgFile(SampleSvg); + + try + { + using var source = await SvgSource.LoadAsync(path); + + Assert.NotNull(source.Svg); + Assert.NotNull(source.Picture); + } + finally + { + File.Delete(path); + } + } + + [AvaloniaFact] + public async Task ReLoadAsync_PathBackedSource_PreservesPicture() + { + var path = CreateTempSvgFile(SampleSvg); + + try + { + using var source = await SvgSource.LoadAsync(path); + + await source.ReLoadAsync(new SvgParameters(null, "rect { fill: #000000; }")); + + Assert.NotNull(source.Svg); + Assert.NotNull(source.Picture); + } + finally + { + File.Delete(path); + } + } + + [AvaloniaFact] + public async Task LoadAsync_CancelledToken_Throws() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + SvgSource.LoadAsync("/Assets/does-not-matter.svg", cancellationToken: cts.Token)); + } + + [AvaloniaFact] + public void NormalizePath_RelativePath_UsesBaseUri() + { + var uri = SvgSource.NormalizePath("Assets/Icon.svg", new Uri("avares://Svg.Controls.Skia.Avalonia.UnitTests/")); + + Assert.Equal("avares://Svg.Controls.Skia.Avalonia.UnitTests/Assets/Icon.svg", uri.ToString()); + } + + [AvaloniaFact] + public void TypeConverter_String_CreatesPathBackedSourceWithoutEagerLoad() + { + var path = CreateTempSvgFile(SampleSvg); + try + { + var converter = new SvgSourceTypeConverter(); + var source = Assert.IsType(converter.ConvertFrom(path)); + + Assert.Equal(path, source.Path); + Assert.Null(source.Svg); + } + finally + { + File.Delete(path); + } + } + [AvaloniaFact] public void LoadFromSvgDocument_SetsSvg() { @@ -216,6 +292,28 @@ public void ReLoad_DuringRender_DefersPreviousResourceDisposal() Assert.Equal(IntPtr.Zero, originalPicture.Handle); } + [AvaloniaFact] + public void DrawOperation_RetainsSourceUntilOperationDisposed() + { + var source = SvgSource.LoadFromSvg(SampleSvg); + var picture = source.Picture; + var operation = new SvgSourceCustomDrawOperation(new Rect(0, 0, 10, 10), source); + + Assert.NotNull(picture); + + source.Dispose(); + + Assert.NotNull(source.Svg); + Assert.Same(picture, source.Picture); + Assert.True(BeginRender(source)); + EndRender(source); + + operation.Dispose(); + + Assert.Null(source.Svg); + Assert.Null(source.Picture); + } + [AvaloniaFact] public void Clone_DeepClonesModel() { @@ -286,6 +384,13 @@ private static void WithSharedJavaScriptEnabled(Action action) } } + private static string CreateTempSvgFile(string svg) + { + var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.svg"); + File.WriteAllText(path, svg); + return path; + } + private static void AssertTargetFill(SvgSource source, string expected) { var target = source.Svg?.SourceDocument?.GetElementById("target"); diff --git a/tests/Svg.Model.UnitTests/AnimationElementTests.cs b/tests/Svg.Model.UnitTests/AnimationElementTests.cs index 3644436db2..191ac3578a 100644 --- a/tests/Svg.Model.UnitTests/AnimationElementTests.cs +++ b/tests/Svg.Model.UnitTests/AnimationElementTests.cs @@ -161,6 +161,37 @@ public void SvgElementAnimationValueApi_ReadsWritesAndClearsTypedAttributes() Assert.False(rectangle.ContainsAttribute("width")); } + [Fact] + public void SvgElementAnimationValueApi_ClearsNamespacedCustomAttributes() + { + var document = SvgService.FromSvg(NamespacedCustomAttributeSvg); + Assert.NotNull(document); + + var rectangle = Assert.IsType(document!.GetElementById("target")); + + Assert.True(rectangle.TrySetAnimationValue("foo:flag", "on")); + Assert.True(rectangle.CustomAttributes.ContainsKey("urn:example:flag")); + + Assert.True(rectangle.ClearAnimationValue("foo:flag")); + + Assert.False(rectangle.CustomAttributes.ContainsKey("urn:example:flag")); + Assert.False(rectangle.CustomAttributes.ContainsKey("foo:flag")); + Assert.False(rectangle.CustomAttributes.ContainsKey("flag")); + } + + [Fact] + public void SvgElementAnimationValueApi_ReadsNamespacedCustomAttributes() + { + var document = SvgService.FromSvg(NamespacedCustomAttributeBaseSvg); + Assert.NotNull(document); + + var rectangle = Assert.IsType(document!.GetElementById("target")); + + Assert.True(rectangle.CustomAttributes.ContainsKey("urn:example:flag")); + Assert.True(rectangle.CustomAttributes.ContainsKey("flag")); + Assert.Equal("off", rectangle.GetAnimationValue("foo:flag")); + } + [Fact] public void AnimationElements_DeepCopyPreservesConcreteTypesAndChildren() { @@ -204,6 +235,24 @@ public void AnimationElements_DeepCopyPreservesConcreteTypesAndChildren() return SvgService.FromSvg(AnimationSvg); } + private const string NamespacedCustomAttributeSvg = """ + + + + """; + + private const string NamespacedCustomAttributeBaseSvg = """ + + + + """; + private const string AnimationSvg = """ + + + + + + + + + + """; + + var document = SvgService.FromSvg(svg); + Assert.NotNull(document); + + var target = Assert.IsType(document!.GetElementById("target")); + var gradient = Assert.IsType(document.GetElementById("paint")); + + var shader = Assert.IsType( + PaintingService.CreateLinearGradient( + gradient, + SKRect.Create(0f, 0f, 50f, 40f), + target, + 1f, + DrawAttributes.None, + SKColorSpace.Srgb)); + + Assert.Equal(SKShaderTileMode.Clamp, shader.Mode); + } + [Fact] public void RadialGradient_InheritsStylesheetFocalRadiusThroughHrefChain() { @@ -120,4 +154,5 @@ public void RadialGradient_InheritsStylesheetFocalRadiusThroughHrefChain() Assert.Equal(2f, shader.LocalMatrix!.Value.TransX); Assert.Equal(3f, shader.LocalMatrix.Value.TransY); } + } diff --git a/tests/Svg.Model.UnitTests/SvgPatternPaintStateResolverTests.cs b/tests/Svg.Model.UnitTests/SvgPatternPaintStateResolverTests.cs index ad482a47c5..1c7c5acdf6 100644 --- a/tests/Svg.Model.UnitTests/SvgPatternPaintStateResolverTests.cs +++ b/tests/Svg.Model.UnitTests/SvgPatternPaintStateResolverTests.cs @@ -86,4 +86,68 @@ public void TryCreate_InheritsPatternTransformFromReferencedPattern() Assert.Equal(8f, state.ShaderMatrix.TransX); Assert.Equal(11f, state.ShaderMatrix.TransY); } + + [Fact] + public void TryCreate_ExplicitDefaultPreserveAspectRatioOverridesReferencedPattern() + { + const string svg = """ + + + + + + + + + + """; + + var document = SvgService.FromSvg(svg); + Assert.NotNull(document); + + var target = Assert.IsType(document!.GetElementById("target")); + var derived = Assert.IsType(document.GetElementById("derived")); + + var resolved = SvgPatternPaintStateResolver.TryCreate( + derived, + target, + SKRect.Create(0f, 0f, 50f, 40f), + out var state); + + Assert.True(resolved); + Assert.NotNull(state); + Assert.Equal(1f, state!.PictureTransform.ScaleX); + Assert.Equal(1f, state.PictureTransform.ScaleY); + Assert.Equal(5f, state.PictureTransform.TransX); + } + + [Fact] + public void TryCreate_RejectsNonInvertiblePatternTransform() + { + const string svg = """ + + + + + + + + + """; + + var document = SvgService.FromSvg(svg); + Assert.NotNull(document); + + var target = Assert.IsType(document!.GetElementById("target")); + var pattern = Assert.IsType(document.GetElementById("pat")); + + var resolved = SvgPatternPaintStateResolver.TryCreate( + pattern, + target, + SKRect.Create(0f, 0f, 50f, 40f), + out var state); + + Assert.False(resolved); + Assert.Null(state); + } } diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-02-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-02-t.png new file mode 100644 index 0000000000..dbaa75f92f Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-02-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-03-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-03-t.png new file mode 100644 index 0000000000..a4d4fcf409 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-03-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-04-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-04-t.png new file mode 100644 index 0000000000..d898628c31 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-04-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-05-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-05-t.png new file mode 100644 index 0000000000..633d63b1d5 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-05-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-06-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-06-t.png new file mode 100644 index 0000000000..6647801769 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-06-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-07-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-07-t.png new file mode 100644 index 0000000000..77fbc03cd3 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-07-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-08-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-08-t.png new file mode 100644 index 0000000000..d93a97f8c2 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-08-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-09-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-09-t.png new file mode 100644 index 0000000000..3d739e873b Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-09-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-10-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-10-t.png new file mode 100644 index 0000000000..cfb75dc718 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-10-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-11-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-11-t.png new file mode 100644 index 0000000000..4f33ffb042 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-11-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-12-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-12-t.png new file mode 100644 index 0000000000..cfb75dc718 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-12-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-13-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-13-t.png new file mode 100644 index 0000000000..5a08880234 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-13-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-14-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-14-t.png new file mode 100644 index 0000000000..9477df85aa Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-14-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-15-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-15-t.png new file mode 100644 index 0000000000..93bf3d40ea Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-15-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-17-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-17-t.png new file mode 100644 index 0000000000..7a305b7091 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-17-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-19-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-19-t.png new file mode 100644 index 0000000000..e9366ca0a0 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-19-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-22-b.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-22-b.png new file mode 100644 index 0000000000..c594349971 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-22-b.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-24-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-24-t.png new file mode 100644 index 0000000000..b450c18d64 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-24-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-25-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-25-t.png new file mode 100644 index 0000000000..ebdc13c0ac Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-25-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-26-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-26-t.png new file mode 100644 index 0000000000..6449be5c04 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-26-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-27-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-27-t.png new file mode 100644 index 0000000000..206b9dd8b2 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-27-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-28-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-28-t.png new file mode 100644 index 0000000000..b5223a220d Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-28-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-30-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-30-t.png new file mode 100644 index 0000000000..e6dee8ef9c Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-30-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-31-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-31-t.png new file mode 100644 index 0000000000..2a0a88f8ab Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-31-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-32-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-32-t.png new file mode 100644 index 0000000000..8902728e70 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-32-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-33-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-33-t.png new file mode 100644 index 0000000000..bba5983581 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-33-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-34-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-34-t.png new file mode 100644 index 0000000000..21016850b2 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-34-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-35-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-35-t.png new file mode 100644 index 0000000000..06dfedd37f Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-35-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-36-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-36-t.png new file mode 100644 index 0000000000..7dd78e1268 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-36-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-37-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-37-t.png new file mode 100644 index 0000000000..e7b659865f Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-37-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-38-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-38-t.png new file mode 100644 index 0000000000..72f7611b11 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-38-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-39-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-39-t.png new file mode 100644 index 0000000000..534c72c850 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-39-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-40-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-40-t.png new file mode 100644 index 0000000000..a20e53c1ee Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-40-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-41-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-41-t.png new file mode 100644 index 0000000000..aa6ca6c525 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-41-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-44-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-44-t.png new file mode 100644 index 0000000000..604a312e5f Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-44-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-46-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-46-t.png new file mode 100644 index 0000000000..e392571283 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-46-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-52-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-52-t.png new file mode 100644 index 0000000000..919fcd97cc Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-52-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-53-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-53-t.png new file mode 100644 index 0000000000..2d68cc4d52 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-53-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-64-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-64-t.png new file mode 100644 index 0000000000..6f3c26bffa Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-64-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-65-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-65-t.png new file mode 100644 index 0000000000..3298358aaa Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-65-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-66-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-66-t.png new file mode 100644 index 0000000000..663d600d26 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-66-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-67-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-67-t.png new file mode 100644 index 0000000000..fc2d9c2edf Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-67-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-68-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-68-t.png new file mode 100644 index 0000000000..1b75f438bc Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-68-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-69-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-69-t.png new file mode 100644 index 0000000000..047b75a219 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-69-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-70-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-70-t.png new file mode 100644 index 0000000000..80919c6606 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-70-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-77-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-77-t.png new file mode 100644 index 0000000000..c9cee66bbb Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-77-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-78-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-78-t.png new file mode 100644 index 0000000000..da7c817e01 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-78-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-80-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-80-t.png new file mode 100644 index 0000000000..e7c242c69f Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-80-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-81-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-81-t.png new file mode 100644 index 0000000000..eb41fa12e4 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-81-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-82-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-82-t.png new file mode 100644 index 0000000000..52fd2e6f89 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-82-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-83-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-83-t.png new file mode 100644 index 0000000000..13c265b417 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-83-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-86-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-86-t.png new file mode 100644 index 0000000000..246085e0c9 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-86-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-87-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-87-t.png new file mode 100644 index 0000000000..53d71ae4a1 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-87-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-88-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-88-t.png new file mode 100644 index 0000000000..9dee760969 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-88-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-89-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-89-t.png new file mode 100644 index 0000000000..332a1cc83b Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-89-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-92-t.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-92-t.png new file mode 100644 index 0000000000..006d7ba635 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-elem-92-t.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-pservers-grad-01-b.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-pservers-grad-01-b.png new file mode 100644 index 0000000000..216ce89d52 Binary files /dev/null and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/animate-pservers-grad-01-b.png differ diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/filters-composite-05-f.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/filters-composite-05-f.png index d7b9b6800d..eacbfd4083 100644 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/filters-composite-05-f.png and b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/filters-composite-05-f.png differ diff --git a/tests/Svg.Skia.UnitTests/SkiaCSharpCodeGenTests.cs b/tests/Svg.Skia.UnitTests/SkiaCSharpCodeGenTests.cs index aec426db3b..c366ef1281 100644 --- a/tests/Svg.Skia.UnitTests/SkiaCSharpCodeGenTests.cs +++ b/tests/Svg.Skia.UnitTests/SkiaCSharpCodeGenTests.cs @@ -178,6 +178,35 @@ public void Generate_UsesAliasFontEdgingForAliasedTextPaint() Assert.DoesNotContain(".Edging = SKFontEdging.SubpixelAntialias;", code); } + [Fact] + public void Generate_UsesSpotLitSpecularShininessForFinalArgument() + { + var paint = new SKPaint + { + ImageFilter = SKImageFilter.CreateSpotLitSpecular( + new SKPoint3(1f, 2f, 3f), + new SKPoint3(4f, 5f, 6f), + 7f, + 8f, + new SKColor(9, 10, 11, 255), + 12f, + 13f, + 14f) + }; + var path = new SKPath(); + path.AddRect(SKRect.Create(0f, 0f, 10f, 10f)); + var picture = new SKPicture(SKRect.Create(0f, 0f, 10f, 10f), new List + { + new DrawPathCanvasCommand(path, paint) + }); + + var code = SkiaCSharpCodeGen.Generate(picture, "Svg", "Generated"); + + Assert.Contains("SKImageFilter.CreateSpotLitSpecular(", code); + Assert.Contains(" 7f,", code); + Assert.Contains(" 14f,", code); + } + [Fact] public void SkiaModel_ToSKShader_CreatesGradientsWithOptionalColorPositions() { diff --git a/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs b/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs index d3eb442293..419ad71c3b 100644 --- a/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs +++ b/tests/Svg.Skia.UnitTests/SkiaSvgAssetLoaderCachingTests.cs @@ -1,6 +1,7 @@ #pragma warning disable CS0618 // Shim paint keeps deprecated SKPaint text/typeface surface for compatibility using System.Collections.Generic; +using System.IO; using System.Linq; using ShimSkiaSharp; using Svg.Skia; @@ -15,6 +16,19 @@ namespace Svg.Skia.UnitTests; public class SkiaSvgAssetLoaderCachingTests { + [Fact] + public void LoadImage_ReturnsZeroSizeImageForInvalidEncodedData() + { + var assetLoader = new SkiaSvgAssetLoader(new SkiaModel(new SKSvgSettings())); + using var stream = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + + var image = assetLoader.LoadImage(stream); + + Assert.NotNull(image.Data); + Assert.Equal(0, image.Width); + Assert.Equal(0, image.Height); + } + [Fact] public void MeasureText_RecomputesAfterPaintMutation() { diff --git a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs index 2cf8b0b377..846b2bd3ed 100644 --- a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.IO; using System.Linq; using System.Reflection; @@ -271,6 +272,43 @@ public void SetAnimationTime_RebuildsInheritedFontSizeAndFillAnimationsUnderLaye Assert.True(updatedExpandedArea.Green > updatedExpandedArea.Blue); } + [Fact] + public void CreateAnimatedDocument_RendersInheritedGradientStopOpacityAnimations() + { + var document = SvgService.FromSvg(InheritedGradientStopOpacityAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + foreach (var animation in animated.Descendants().OfType().ToArray()) + { + animation.Parent?.Children.Remove(animation); + } + + using var svg = SKSvg.CreateFromSvgDocument(animated); + using var updatedBitmap = RenderBitmap(svg); + var updatedRight = updatedBitmap.GetPixel(18, 5); + Assert.True(updatedRight.Alpha > 200, $"Expected opaque pixel, got {updatedRight}."); + Assert.True(updatedRight.Red < 80, $"Expected low red channel, got {updatedRight}."); + Assert.True(updatedRight.Green > 100, $"Expected green channel, got {updatedRight}."); + Assert.True(updatedRight.Blue < 80, $"Expected low blue channel, got {updatedRight}."); + } + + [Fact] + public void CreateAnimatedDocument_RebindsRootDeferredPaintServers() + { + var document = SvgService.FromSvg(RootDeferredPaintServerAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var fill = Assert.IsType(animated.Fill); +#pragma warning disable CS0618 + Assert.Same(animated, fill.Document); +#pragma warning restore CS0618 + } + [Fact] public void CreateAnimatedDocument_AppliesInheritedCssAnimationsWhenWhitespaceCssParameterIsProvided() { @@ -421,6 +459,27 @@ public void CreateAnimatedDocument_ComposesConcurrentAdditiveAnimationsFromCurre Assert.Equal(7.5f, target!.X.Value, 3); } + [Fact] + public void CreateAnimatedDocument_ComposesMotionWithEarlierTransformAnimation() + { + var document = SvgService.FromSvg(MotionAfterTransformAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.Equal(2, target!.Transforms.Count); + + var motionTranslate = Assert.IsType(target.Transforms[0]); + var transformTranslate = Assert.IsType(target.Transforms[1]); + Assert.Equal(10f, motionTranslate.X, 3); + Assert.Equal(0f, motionTranslate.Y, 3); + Assert.Equal(0f, transformTranslate.X, 3); + Assert.Equal(5f, transformTranslate.Y, 3); + } + [Fact] public void CreateAnimatedDocument_TreatsRepeatDurIndefiniteAsUnbounded() { @@ -524,6 +583,29 @@ public void CreateAnimatedDocument_EnforcesMinimumActiveDuration() Assert.Equal(5f, target!.X.Value, 3); } + [Fact] + public void CreateAnimatedDocument_ConstrainsIndefiniteSetWithValidMaximumDuration() + { + var document = SvgService.FromSvg(IndefiniteSetMaxDurationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + + var during = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var duringTarget = during.GetElementById("capped"); + Assert.NotNull(duringTarget); + Assert.Equal(10f, duringTarget!.X.Value, 3); + + var afterMax = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(3)); + var capped = afterMax.GetElementById("capped"); + Assert.NotNull(capped); + Assert.Equal(0f, capped!.X.Value, 3); + + var invalidPair = afterMax.GetElementById("invalid-pair"); + Assert.NotNull(invalidPair); + Assert.Equal(10f, invalidPair!.X.Value, 3); + } + [Fact] public void CreateAnimatedDocument_AppliesZeroDurationAnimateImmediately() { @@ -798,6 +880,22 @@ public void CreateAnimatedDocument_UsesPacedSegmentTimingForAnimateTransform() Assert.Equal(0f, translate.Y, 3); } + [Fact] + public void CreateAnimatedDocument_PreservesBaseTransformForByOnlyAnimateTransform() + { + var document = SvgService.FromSvg(ByOnlyTransformWithBaseSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + var translate = Assert.IsType(Assert.Single(target!.Transforms)); + Assert.Equal(12.5f, translate.X, 3); + Assert.Equal(0f, translate.Y, 3); + } + [Fact] public void CreateAnimatedDocument_SaturatesLargeRepeatIterationCounts() { @@ -813,729 +911,1773 @@ public void CreateAnimatedDocument_SaturatesLargeRepeatIterationCounts() } [Fact] - public void CreateAnimatedDocument_ResolvesAnimateMotionPercentagesAgainstViewportWithoutViewBox() + public void CreateAnimatedDocument_ResolvesSyncbaseRepeatTiming() { - var document = SvgService.FromSvg(MotionViewportPercentageSvg); + var document = SvgService.FromSvg(SyncbaseRepeatTimingSvg); Assert.NotNull(document); using var controller = new SvgAnimationController(document!); - var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); - var motion = animated.GetElementById("motion"); - Assert.NotNull(motion); - var translate = Assert.IsType(Assert.Single(motion!.Transforms)); - Assert.Equal(100f, translate.X, 3); - Assert.Equal(50f, translate.Y, 3); + var beforeRepeat = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(0.75)); + var beforeTarget = beforeRepeat.GetElementById("target"); + Assert.NotNull(beforeTarget); + Assert.Equal(0f, beforeTarget!.X.Value, 3); + + var afterRepeat = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1.5)); + var afterTarget = afterRepeat.GetElementById("target"); + Assert.NotNull(afterTarget); + Assert.Equal(5f, afterTarget!.X.Value, 3); } [Fact] - public void CreateAnimatedDocument_AppliesAdditiveAndAccumulateSemantics() + public void CreateAnimatedDocument_GeneratesSelfEndBeginIntervals() { - var document = SvgService.FromSvg(AdditiveAndAccumulateSvg); + var document = SvgService.FromSvg(SelfEndBeginTimingSvg); Assert.NotNull(document); using var controller = new SvgAnimationController(document!); - var additiveFrame = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); - var additiveTarget = additiveFrame.GetElementById("additive"); - Assert.NotNull(additiveTarget); - Assert.Equal(10f, additiveTarget!.X.Value, 3); - - var accumulatedFrame = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2.5)); + var firstActive = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(0.5)); + Assert.Equal(10f, firstActive.GetElementById("pulse")!.X.Value, 3); - var numericTarget = accumulatedFrame.GetElementById("numeric"); - Assert.NotNull(numericTarget); - Assert.Equal(12.5f, numericTarget!.X.Value, 3); + var firstGap = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1.5)); + Assert.Equal(0f, firstGap.GetElementById("pulse")!.X.Value, 3); - var transformTarget = accumulatedFrame.GetElementById("transformAccum"); - Assert.NotNull(transformTarget); - var accumulatedTransform = Assert.IsType(Assert.Single(transformTarget!.Transforms)); - Assert.Equal(12.5f, accumulatedTransform.X, 3); - Assert.Equal(0f, accumulatedTransform.Y, 3); + var secondActive = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2.5)); + Assert.Equal(10f, secondActive.GetElementById("pulse")!.X.Value, 3); - var motionTarget = accumulatedFrame.GetElementById("motionAccum"); - Assert.NotNull(motionTarget); - var accumulatedMotion = Assert.IsType(Assert.Single(motionTarget!.Transforms)); - Assert.Equal(12.5f, accumulatedMotion.X, 3); - Assert.Equal(0f, accumulatedMotion.Y, 3); + var followerActive = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(4.5)); + Assert.Equal(10f, followerActive.GetElementById("follower")!.X.Value, 3); } [Fact] - public void SetAnimationTime_SkipsEquivalentFrameRebuilds() + public void CreateAnimatedDocument_UsesHalfOpenActiveIntervals() { - using var svg = new SKSvg(); - svg.FromSvg(DelayedAnimationSvg); - - Assert.True(svg.HasAnimations); - Assert.NotNull(svg.Model); - Assert.NotNull(svg.Picture); + var document = SvgService.FromSvg(HalfOpenIntervalAnimationSvg); + Assert.NotNull(document); - var initialModel = svg.Model; - var initialPicture = svg.Picture; - var invalidatedCount = 0; - svg.AnimationInvalidated += (_, _) => invalidatedCount++; + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); - svg.SetAnimationTime(TimeSpan.FromSeconds(1)); + var removed = animated.GetElementById("removed"); + Assert.NotNull(removed); + Assert.Equal(0f, removed!.X.Value, 3); - Assert.Equal(0, invalidatedCount); - Assert.Same(initialModel, svg.Model); - Assert.Same(initialPicture, svg.Picture); - Assert.False(svg.HasPendingAnimationFrame); - Assert.Equal(0, svg.LastAnimationDirtyTargetCount); + var frozen = animated.GetElementById("frozen"); + Assert.NotNull(frozen); + Assert.Equal(10f, frozen!.X.Value, 3); } [Fact] - public void SetAnimationTime_QueuesPendingFrameWhenThrottled() + public void CreateAnimatedDocument_TruncatesRestartedIntervalsForSyncbaseEnd() { - using var svg = new SKSvg(); - svg.FromSvg(HitTestAnimationSvg); - svg.AnimationMinimumRenderInterval = TimeSpan.FromSeconds(1); + var document = SvgService.FromSvg(RestartTruncationSyncbaseSvg); + Assert.NotNull(document); - Assert.True(svg.HasAnimations); - Assert.NotNull(svg.Model); + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(0.75)); - var initialModel = svg.Model; - var invalidatedCount = 0; - svg.AnimationInvalidated += (_, args) => - { - invalidatedCount++; - Assert.Equal(TimeSpan.FromSeconds(0.5), args.Time); - }; + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.Equal(2.5f, target!.Y.Value, 3); + } - svg.SetAnimationTime(TimeSpan.FromSeconds(0.5)); + [Fact] + public void CreateAnimatedDocument_UsesSelfBeginTimingForEndInstances() + { + var document = SvgService.FromSvg(SelfBeginEndTimingSvg); + Assert.NotNull(document); - Assert.Equal(0, invalidatedCount); - Assert.True(svg.HasPendingAnimationFrame); - Assert.Same(initialModel, svg.Model); - Assert.True(svg.LastAnimationDirtyTargetCount > 0); + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(3)); - Assert.True(svg.FlushPendingAnimationFrame()); - Assert.Equal(1, invalidatedCount); - Assert.False(svg.HasPendingAnimationFrame); - Assert.NotSame(initialModel, svg.Model); - Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(7, 2))?.ID); + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.Equal(2f, target!.X.Value, 3); } [Fact] - public void SetAnimationTime_RevertsRemovedAnimatedStateWhenAnimationStops() + public void TryGetStartTime_ResolvesFutureSyncbaseIntervals() { - using var svg = new SKSvg(); - svg.FromSvg(TransientAnimationSvg); + var document = SvgService.FromSvg(FutureSyncbaseStartTimeSvg); + Assert.NotNull(document); + var animation = document!.GetElementById("dependent"); + Assert.NotNull(animation); - Assert.True(svg.HasAnimations); + using var controller = new SvgAnimationController(document); + var method = typeof(SvgAnimationController).GetMethod( + "TryGetStartTime", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic, + binder: null, + new[] { typeof(SvgAnimationElement), typeof(TimeSpan), typeof(TimeSpan).MakeByRefType() }, + modifiers: null); + Assert.NotNull(method); + + var args = new object?[] { animation, TimeSpan.Zero, default(TimeSpan) }; + var resolved = Assert.IsType(method!.Invoke(controller, args)); + + Assert.True(resolved); + Assert.Equal(TimeSpan.FromSeconds(2), Assert.IsType(args[2])); + } - svg.SetAnimationTime(TimeSpan.FromSeconds(0.5)); - Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(7, 2))?.ID); - Assert.Null(svg.HitTestTopmostElement(new SKPoint(2, 2))); + [Fact] + public void GetTimelineCallbacks_IncludesRepeatEvents() + { + var document = SvgService.FromSvg(RepeatTimelineCallbackSvg); + Assert.NotNull(document); - svg.SetAnimationTime(TimeSpan.FromSeconds(2)); - Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(2, 2))?.ID); - Assert.Null(svg.HitTestTopmostElement(new SKPoint(7, 2))); + using var controller = new SvgAnimationController(document!); + var callbacks = InvokeTimelineCallbacks(controller, TimeSpan.FromSeconds(1), TimeSpan.Zero); + + Assert.Contains(callbacks, callback => callback.EventType == "repeatEvent" && callback.AttributeName == "onrepeat"); } [Fact] - public void SetAnimationTime_RemovesAnimatedAttributeWhenBaseAttributeWasImplicit() + public void CreateAnimatedDocument_IgnoresRepeatZeroTiming() { - using var svg = new SKSvg(); - svg.FromSvg(TransientImplicitAttributeSvg); + var document = SvgService.FromSvg(RepeatZeroTimingSvg); + Assert.NotNull(document); - svg.SetAnimationTime(TimeSpan.FromSeconds(0.5)); + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); - var activeDocument = GetRenderedDocument(svg); - var activeTarget = activeDocument.GetElementById("target"); - Assert.NotNull(activeTarget); - Assert.True(activeTarget!.TryGetAttribute("x", out _)); + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.Equal(0f, target!.X.Value, 3); + } - svg.SetAnimationTime(TimeSpan.FromSeconds(2)); + [Fact] + public void CreateAnimatedDocument_IgnoresNonProgressingSelfBeginTiming() + { + var document = SvgService.FromSvg(NonProgressingSelfBeginTimingSvg); + Assert.NotNull(document); - var renderedDocument = GetRenderedDocument(svg); - var target = renderedDocument.GetElementById("target"); + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1.5)); + + var target = animated.GetElementById("target"); Assert.NotNull(target); - Assert.False(target!.TryGetAttribute("x", out _)); + Assert.Equal(10f, target!.X.Value, 3); } [Fact] - public void ResetAnimation_AtZeroClearsEventDrivenRenderedState() + public void CreateAnimatedDocument_InterpolatesNumberListsAndPathData() { - using var svg = new SKSvg(); - svg.FromSvg(ImmediateEventSetSvg); + var document = SvgService.FromSvg(NumberListAndPathDataAnimationSvg); + Assert.NotNull(document); - Assert.True(svg.HasAnimations); + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); - var dispatcher = new SvgInteractionDispatcher(); - var clickInput = new SvgPointerInput( - new SKPoint(20, 20), - SvgPointerDeviceType.Mouse, - SvgMouseButton.Left, - 1, - 0, - false, - false, - false, - "pointer-1"); + var polygon = animated.GetElementById("polygon"); + Assert.NotNull(polygon); + Assert.Equal(10f, polygon!.Points[2].Value, 3); + Assert.Equal(10f, polygon.Points[5].Value, 3); - _ = dispatcher.DispatchPointerPressed(svg, clickInput); - _ = dispatcher.DispatchPointerReleased(svg, clickInput); + var path = animated.GetElementById("path"); + Assert.NotNull(path); + Assert.Contains("10", path!.PathData.ToString(), StringComparison.Ordinal); + } - Assert.Null(svg.HitTestTopmostElement(new SKPoint(2, 2))); - Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(12, 2))?.ID); + [Fact] + public void CreateAnimatedDocument_UsesDiscreteFallbackForNonInterpolableLinearValues() + { + var document = SvgService.FromSvg(NonInterpolableLinearAnimationSvg); + Assert.NotNull(document); - var invalidatedCount = 0; - svg.AnimationInvalidated += (_, args) => - { - invalidatedCount++; - Assert.Equal(TimeSpan.Zero, args.Time); - }; + using var controller = new SvgAnimationController(document!); - svg.ResetAnimation(); + var beforeMidpoint = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(0.999)); + var beforeMidpointTarget = beforeMidpoint.GetElementById("target"); + Assert.NotNull(beforeMidpointTarget); + Assert.Equal("hidden", beforeMidpointTarget!.Visibility); - Assert.Equal(1, invalidatedCount); - Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(2, 2))?.ID); - Assert.Null(svg.HitTestTopmostElement(new SKPoint(12, 2))); + var midpoint = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var midpointTarget = midpoint.GetElementById("target"); + Assert.NotNull(midpointTarget); + Assert.Equal("visible", midpointTarget!.Visibility); + + var endpoint = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + var endpointTarget = endpoint.GetElementById("target"); + Assert.NotNull(endpointTarget); + Assert.Equal("visible", endpointTarget!.Visibility); } - private static SvgDocument GetRenderedDocument(SKSvg svg) + [Fact] + public void CreateAnimatedDocument_AppliesToOnlyNonInterpolableAttributesImmediately() { - var sceneDocument = svg.RetainedSceneGraph; - Assert.NotNull(sceneDocument); - return Assert.IsType(sceneDocument!.SourceDocument); + var document = SvgService.FromSvg(ToOnlyNonInterpolableAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(3)); + + Assert.Equal(SvgCoordinateUnits.UserSpaceOnUse, animated.GetElementById("clip")!.ClipPathUnits); + Assert.Equal("off", animated.GetElementById("composite")!.Input); + Assert.Equal(SvgPreserveAspectRatio.xMinYMin, animated.GetElementById("fragment")!.AspectRatio.Align); + Assert.Equal(SvgGradientSpreadMethod.Pad, animated.GetElementById("gradient")!.SpreadMethod); + + var use = animated.GetElementById("use"); + Assert.NotNull(use); + Assert.True(use!.TryGetEffectiveHrefString(out var href)); + Assert.Equal("#target-b", href); + + var classTarget = animated.GetElementById("class-target"); + Assert.NotNull(classTarget); + Assert.True(classTarget!.TryGetAttribute("class", out var className)); + Assert.Equal("off", className); } - private static string GetW3CTestSvgPath(string name) + [Fact] + public void CreateAnimatedDocument_AnimatesHrefUsingNormalizedAttributeName() { - return Path.GetFullPath(Path.Combine( - "..", - "..", - "..", - "..", - "..", - "externals", - "W3C_SVG_11_TestSuite", - "W3C_SVG_11_TestSuite", - "svg", - name)); - } + var document = SvgService.FromSvg(HrefNameNormalizationAnimationSvg); + Assert.NotNull(document); - private const string AnimationRuntimeSvg = """ - ("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetEffectiveHrefString(out var href)); + Assert.Equal("#template-b", href); + } + + [Fact] + public void CreateAnimatedDocument_AnimatesDirectXLinkHrefAttributeName() + { + var document = SvgService.FromSvg(XLinkHrefAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetEffectiveHrefString(out var href)); + Assert.Equal("#template-b", href); + } + + [Fact] + public void CreateAnimatedDocument_PreservesCustomNamespacePrefixAttributeNames() + { + var document = SvgService.FromSvg(CustomNamespaceAttributeAnimationSvg); + Assert.NotNull(document); + + var animation = document!.Descendants().OfType().Single(); + var resolveAttributeName = typeof(SvgAnimationController).GetMethod( + "ResolveAttributeName", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(resolveAttributeName); + Assert.Equal("foo:flag", resolveAttributeName!.Invoke(null, new object?[] { animation })); + + using var controller = new SvgAnimationController(document); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetAttribute("urn:example:flag", out var flag)); + Assert.Equal("on", flag); + } + + [Fact] + public void CreateAnimatedDocument_AnimatesHrefUsingNamespaceAlias() + { + var document = SvgService.FromSvg(AliasedXLinkHrefAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetEffectiveHrefString(out var href)); + Assert.Equal("#template-b", href); + } + + [Fact] + public void CreateAnimatedDocument_ReappliesClassSelectorsAfterClassAnimation() + { + var document = SvgService.FromSvg(ClassSelectorAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetAttribute("class", out var className)); + Assert.Equal("off", className); + var fill = Assert.IsType(target.Fill); + Assert.Equal((byte)255, fill.Colour.R); + Assert.Equal((byte)0, fill.Colour.G); + Assert.Equal((byte)0, fill.Colour.B); + } + + [Fact] + public void CreateAnimatedDocument_UsesToValueAtNonInterpolableMidpoint() + { + var document = SvgService.FromSvg(NonInterpolableMidpointClassAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(5)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetAttribute("class", out var className)); + Assert.Equal("final midway", className); + + var fill = Assert.IsType(target.Fill); + Assert.Equal((byte)128, fill.Colour.R); + Assert.Equal((byte)0, fill.Colour.G); + Assert.Equal((byte)0, fill.Colour.B); + } + + [Fact] + public void CreateAnimatedDocument_AppliesSelectorMutationsBeforeOtherFrameAttributes() + { + var document = SvgService.FromSvg(SelectorMutationOrderingAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(3)); + + var guide = animated.GetElementById("guide"); + Assert.NotNull(guide); + var guideFill = Assert.IsType(guide!.Fill); + Assert.Equal((byte)204, guideFill.Colour.R); + Assert.Equal((byte)204, guideFill.Colour.G); + Assert.Equal((byte)204, guideFill.Colour.B); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + var targetFill = Assert.IsType(target!.Fill); + Assert.Equal((byte)255, targetFill.Colour.R); + Assert.Equal((byte)0, targetFill.Colour.G); + Assert.Equal((byte)0, targetFill.Colour.B); + } + + [Fact] + public void CreateAnimatedDocument_AppliesAnimatedInlineStyle() + { + var document = SvgService.FromSvg(StyleAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + var fill = Assert.IsType(target!.Fill); + Assert.Equal((byte)0, fill.Colour.R); + Assert.Equal((byte)128, fill.Colour.G); + Assert.Equal((byte)0, fill.Colour.B); + } + + [Fact] + public void CreateAnimatedDocument_PreservesClassCustomAttributeDuringAnimation() + { + var document = SvgService.FromSvg(ClassPreservationAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + + var target = animated.GetElementById("target"); + Assert.NotNull(target); + Assert.True(target!.TryGetAttribute("class", out var className)); + Assert.Equal("base highlighted", className); + Assert.Equal(10f, target.X.Value, 3); + } + + [Fact] + public void CreateAnimatedDocument_AnimatesInheritedGradientStopColorAndOpacity() + { + var document = SvgService.FromSvg(InheritedStopAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + + var gradient = animated.GetElementById("gradient"); + Assert.NotNull(gradient); + var gradientColor = Assert.IsType(gradient!.StopColor); + Assert.Equal((byte)128, gradientColor.Colour.R); + Assert.Equal((byte)0, gradientColor.Colour.G); + Assert.Equal((byte)128, gradientColor.Colour.B); + Assert.Equal(0.5f, gradient.StopOpacity, 3); + + var inheritedStop = animated.GetElementById("inherited-stop"); + Assert.NotNull(inheritedStop); + var inheritedColor = Assert.IsType(inheritedStop!.StopColor); + Assert.Equal(gradientColor.Colour, inheritedColor.Colour); + Assert.Equal(0.5f, inheritedStop.StopOpacity, 3); + } + + [Fact] + public void CreateAnimatedDocument_AnimatesInheritedStopOpacityFromParentScope() + { + var document = SvgService.FromSvg(W3CParentScopedStopOpacityAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(5)); + + var scope = animated.GetElementById("scope"); + Assert.NotNull(scope); + Assert.True(scope!.TryGetAttribute("stop-opacity", out var animatedOpacity)); + Assert.Equal("1", animatedOpacity); + Assert.True(scope.TryGetAttribute("stop-color", out var stopColor)); + Assert.Equal("yellow", Convert.ToString(stopColor), ignoreCase: true); + Assert.True(scope.TryGetAttribute("color", out var color)); + Assert.Equal("yellow", Convert.ToString(color), ignoreCase: true); + + var gradient = animated.GetElementById("gradient"); + Assert.NotNull(gradient); + Assert.Equal(1f, gradient!.StopOpacity, 3); + + var inheritedStop = gradient.Children.OfType().Last(); + Assert.Equal(1f, inheritedStop.StopOpacity, 3); + } + + [Fact] + public void CreateAnimatedDocument_ResolvesCurrentColorAndInheritColorEndpoints() + { + var document = SvgService.FromSvg(ColorKeywordAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var currentColorTarget = animated.GetElementById("current-color"); + Assert.NotNull(currentColorTarget); + var currentColorFill = Assert.IsType(currentColorTarget!.Fill); + Assert.Equal((byte)0, currentColorFill.Colour.R); + Assert.Equal((byte)128, currentColorFill.Colour.G); + Assert.Equal((byte)0, currentColorFill.Colour.B); + + var inheritTarget = animated.GetElementById("inherit-color"); + Assert.NotNull(inheritTarget); + var inheritFill = Assert.IsType(inheritTarget!.Fill); + Assert.Equal(currentColorFill.Colour, inheritFill.Colour); + } + + [Fact] + public void CreateAnimatedDocument_UsesSameFrameAnimatedColorForCurrentColor() + { + var document = SvgService.FromSvg(SameFrameCurrentColorAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + AssertAnimatedFill(animated, "color-first", Color.Cyan); + AssertAnimatedFill(animated, "fill-first", Color.Cyan); + } + + [Fact] + public void CreateAnimatedDocument_InterpolatesFeCompositeArithmeticCoefficients() + { + var document = SvgService.FromSvg(FeCompositeCoefficientAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + + var composite = animated.GetElementById("composite"); + Assert.NotNull(composite); + Assert.Equal(0.5f, composite!.K2, 3); + Assert.Equal(0.5f, composite.K3, 3); + } + + [Fact] + public void CreateAnimatedDocument_FreezesFeCompositeArithmeticCoefficientsAtEndpoint() + { + var document = SvgService.FromSvg(FeCompositeCoefficientAnimationSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var composite = animated.GetElementById("composite"); + Assert.NotNull(composite); + Assert.Equal(0f, composite!.K2, 3); + Assert.Equal(1f, composite.K3, 3); + } + + [Fact] + public void CreateAnimatedDocument_ResolvesAnimateMotionPercentagesAgainstViewportWithoutViewBox() + { + var document = SvgService.FromSvg(MotionViewportPercentageSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2)); + + var motion = animated.GetElementById("motion"); + Assert.NotNull(motion); + var translate = Assert.IsType(Assert.Single(motion!.Transforms)); + Assert.Equal(100f, translate.X, 3); + Assert.Equal(50f, translate.Y, 3); + } + + [Fact] + public void CreateAnimatedDocument_AppliesAdditiveAndAccumulateSemantics() + { + var document = SvgService.FromSvg(AdditiveAndAccumulateSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + + var additiveFrame = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var additiveTarget = additiveFrame.GetElementById("additive"); + Assert.NotNull(additiveTarget); + Assert.Equal(10f, additiveTarget!.X.Value, 3); + + var accumulatedFrame = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(2.5)); + + var numericTarget = accumulatedFrame.GetElementById("numeric"); + Assert.NotNull(numericTarget); + Assert.Equal(12.5f, numericTarget!.X.Value, 3); + + var transformTarget = accumulatedFrame.GetElementById("transformAccum"); + Assert.NotNull(transformTarget); + var accumulatedTransform = Assert.IsType(Assert.Single(transformTarget!.Transforms)); + Assert.Equal(12.5f, accumulatedTransform.X, 3); + Assert.Equal(0f, accumulatedTransform.Y, 3); + + var motionTarget = accumulatedFrame.GetElementById("motionAccum"); + Assert.NotNull(motionTarget); + var accumulatedMotion = Assert.IsType(Assert.Single(motionTarget!.Transforms)); + Assert.Equal(12.5f, accumulatedMotion.X, 3); + Assert.Equal(0f, accumulatedMotion.Y, 3); + } + + [Fact] + public void CreateAnimatedDocument_AccumulatesDiscreteEndValuesPerIteration() + { + var document = SvgService.FromSvg(DiscreteAccumulateSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController(document!); + var animated = controller.CreateAnimatedDocument(TimeSpan.FromSeconds(7)); + + var accumulated = animated.GetElementById("accumulated"); + Assert.NotNull(accumulated); + Assert.Equal(40f, accumulated!.Height.Value, 3); + + var additive = animated.GetElementById("additive"); + Assert.NotNull(additive); + Assert.Equal(60f, additive!.Height.Value, 3); + } + + [Fact] + public void SetAnimationTime_SkipsEquivalentFrameRebuilds() + { + using var svg = new SKSvg(); + svg.FromSvg(DelayedAnimationSvg); + + Assert.True(svg.HasAnimations); + Assert.NotNull(svg.Model); + Assert.NotNull(svg.Picture); + + var initialModel = svg.Model; + var initialPicture = svg.Picture; + var invalidatedCount = 0; + svg.AnimationInvalidated += (_, _) => invalidatedCount++; + + svg.SetAnimationTime(TimeSpan.FromSeconds(1)); + + Assert.Equal(0, invalidatedCount); + Assert.Same(initialModel, svg.Model); + Assert.Same(initialPicture, svg.Picture); + Assert.False(svg.HasPendingAnimationFrame); + Assert.Equal(0, svg.LastAnimationDirtyTargetCount); + } + + [Fact] + public void SetAnimationTime_QueuesPendingFrameWhenThrottled() + { + using var svg = new SKSvg(); + svg.FromSvg(HitTestAnimationSvg); + svg.AnimationMinimumRenderInterval = TimeSpan.FromSeconds(1); + + Assert.True(svg.HasAnimations); + Assert.NotNull(svg.Model); + + var initialModel = svg.Model; + var invalidatedCount = 0; + svg.AnimationInvalidated += (_, args) => + { + invalidatedCount++; + Assert.Equal(TimeSpan.FromSeconds(0.5), args.Time); + }; + + svg.SetAnimationTime(TimeSpan.FromSeconds(0.5)); + + Assert.Equal(0, invalidatedCount); + Assert.True(svg.HasPendingAnimationFrame); + Assert.Same(initialModel, svg.Model); + Assert.True(svg.LastAnimationDirtyTargetCount > 0); + + Assert.True(svg.FlushPendingAnimationFrame()); + Assert.Equal(1, invalidatedCount); + Assert.False(svg.HasPendingAnimationFrame); + Assert.NotSame(initialModel, svg.Model); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(7, 2))?.ID); + } + + [Fact] + public void SetAnimationTime_RevertsRemovedAnimatedStateWhenAnimationStops() + { + using var svg = new SKSvg(); + svg.FromSvg(TransientAnimationSvg); + + Assert.True(svg.HasAnimations); + + svg.SetAnimationTime(TimeSpan.FromSeconds(0.5)); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(7, 2))?.ID); + Assert.Null(svg.HitTestTopmostElement(new SKPoint(2, 2))); + + svg.SetAnimationTime(TimeSpan.FromSeconds(2)); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(2, 2))?.ID); + Assert.Null(svg.HitTestTopmostElement(new SKPoint(7, 2))); + } + + [Fact] + public void SetAnimationTime_RemovesAnimatedAttributeWhenBaseAttributeWasImplicit() + { + using var svg = new SKSvg(); + svg.FromSvg(TransientImplicitAttributeSvg); + + svg.SetAnimationTime(TimeSpan.FromSeconds(0.5)); + + var activeDocument = GetRenderedDocument(svg); + var activeTarget = activeDocument.GetElementById("target"); + Assert.NotNull(activeTarget); + Assert.True(activeTarget!.TryGetAttribute("x", out _)); + + svg.SetAnimationTime(TimeSpan.FromSeconds(2)); + + var renderedDocument = GetRenderedDocument(svg); + var target = renderedDocument.GetElementById("target"); + Assert.NotNull(target); + Assert.False(target!.TryGetAttribute("x", out _)); + } + + [Fact] + public void ResetAnimation_AtZeroClearsEventDrivenRenderedState() + { + using var svg = new SKSvg(); + svg.FromSvg(ImmediateEventSetSvg); + + Assert.True(svg.HasAnimations); + + var dispatcher = new SvgInteractionDispatcher(); + var clickInput = new SvgPointerInput( + new SKPoint(20, 20), + SvgPointerDeviceType.Mouse, + SvgMouseButton.Left, + 1, + 0, + false, + false, + false, + "pointer-1"); + + _ = dispatcher.DispatchPointerPressed(svg, clickInput); + _ = dispatcher.DispatchPointerReleased(svg, clickInput); + + Assert.Null(svg.HitTestTopmostElement(new SKPoint(2, 2))); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(12, 2))?.ID); + + var invalidatedCount = 0; + svg.AnimationInvalidated += (_, args) => + { + invalidatedCount++; + Assert.Equal(TimeSpan.Zero, args.Time); + }; + + svg.ResetAnimation(); + + Assert.Equal(1, invalidatedCount); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(2, 2))?.ID); + Assert.Null(svg.HitTestTopmostElement(new SKPoint(12, 2))); + } + + private static SvgDocument GetRenderedDocument(SKSvg svg) + { + var sceneDocument = svg.RetainedSceneGraph; + Assert.NotNull(sceneDocument); + return Assert.IsType(sceneDocument!.SourceDocument); + } + + private static string GetW3CTestSvgPath(string name) + { + return Path.GetFullPath(Path.Combine( + "..", + "..", + "..", + "..", + "..", + "externals", + "W3C_SVG_11_TestSuite", + "W3C_SVG_11_TestSuite", + "svg", + name)); + } + + private const string AnimationRuntimeSvg = """ + + + + + + + + + + + + + + + + + + + + + + + """; + + private const string HitTestAnimationSvg = """ + + + + + + """; + + private const string EventBeginSvg = """ + + + + + + + """; + + private const string DottedIdEventBeginSvg = """ + + + + + + + """; + + private const string NegativeEventOffsetBeginSvg = """ + + + + + + + """; + + private const string MoveTriggeredAnimationSvg = """ + + + + + + + """; + + private const string DelayedAnimationSvg = """ + + + + + + """; + + private const string HexAlphaPaintAnimationSvg = """ + + + + + + """; + + private const string TopLevelLayeredAnimationSvg = """ + + + + + + + + + """; + + private const string SubtreeLayeredAnimationSvg = """ + + + + + + + + + + + + + + """; + + private const string DefsBackedAnimationSvg = """ + + + + + + + + + """; + + private const string PaintServerAnimationSvg = """ + + + + + + + + + + + + """; + + private const string InheritedStrokeAnimationSvg = """ + + viewBox="0 0 40 40"> + + + + + + """; + + private const string InheritedFontSizeAnimationSvg = """ + + + + + + + + """; + + private const string InheritedGradientStopOpacityAnimationSvg = """ + + - + + + + + + + - - + + + """; + + private const string RootDeferredPaintServerAnimationSvg = """ + + + + + + + + + + + + """; + + private const string ColonClockAnimationSvg = """ + + + - - + + """; + + private const string ConcurrentAdditiveAnimationSvg = """ + + + + - - + + """; + + private const string MotionAfterTransformAnimationSvg = """ + + + + - - + + """; + + private const string RepeatDurationIndefiniteAnimationSvg = """ + + + + + + """; + + private const string FiniteRepeatCountWithIndefiniteRepeatDurationSvg = """ + + + + + + """; + + private const string ZeroDurationAnimationSvg = """ + + + + + + """; + + private const string NonFiniteClockAnimationSvg = """ + + + + + + """; + + private const string NonFiniteRepeatCountAnimationSvg = """ + + + + + + """; + + private const string MaximumDurationAnimationSvg = """ + + + + + + """; + + private const string MalformedTransformValuesSvg = """ + + + + + + """; + + private const string MinimumDurationAnimationSvg = """ + + + + + + """; + + private const string IndefiniteSetMaxDurationSvg = """ + + + + + + + + """; + + private const string EventEndSvg = """ + + + + + + + """; + + private const string MotionValuesSvg = """ + - - - + """; - private const string HitTestAnimationSvg = """ + private const string WhitespaceTransformValuesSvg = """ - - + width="30" + height="30" + viewBox="0 0 30 30"> + + """; - private const string EventBeginSvg = """ + private const string WhitespaceMotionValuesSvg = """ - - - - + width="30" + height="30" + viewBox="0 0 30 30"> + + + """; - private const string DottedIdEventBeginSvg = """ - - - - - + private const string RootViewBoxAnimationSvg = """ + + + """; - private const string NegativeEventOffsetBeginSvg = """ + private const string RootViewBoxSizeAnimationSvg = """ + + + + + """; + + private const string SplineAnimationSvg = """ - - + width="30" + height="30" + viewBox="0 0 30 30"> + + - + + + + + + + + + """; - private const string MoveTriggeredAnimationSvg = """ + private const string MotionViewportPercentageSvg = """ - - - - + width="200" + height="100"> + + + """; - private const string DelayedAnimationSvg = """ + private const string TransientAnimationSvg = """ - + """; - private const string HexAlphaPaintAnimationSvg = """ + private const string TransientImplicitAttributeSvg = """ - - + viewBox="0 0 40 10"> + + """; - private const string TopLevelLayeredAnimationSvg = """ + private const string ImmediateEventSetSvg = """ - - - - - - + height="40" + viewBox="0 0 40 40"> + + + + """; - private const string SubtreeLayeredAnimationSvg = """ + private const string AdditiveAndAccumulateSvg = """ - - - - - - - - - - - + width="50" + height="30" + viewBox="0 0 50 30"> + + + + + + + + + + + + """; - private const string DefsBackedAnimationSvg = """ + private const string DiscreteAccumulateSvg = """ - - - - - - + width="50" + height="80" + viewBox="0 0 50 80"> + + + + + + """; - private const string PaintServerAnimationSvg = """ + private const string PacedValuesAnimationSvg = """ - - - - - - - - - + viewBox="0 0 120 20"> + + + """; - private const string InheritedStrokeAnimationSvg = """ - - - - - - - """; + private static List<(string EventType, string AttributeName)> InvokeTimelineCallbacks( + SvgAnimationController controller, + TimeSpan currentTime, + TimeSpan? previousTime) + { + var method = typeof(SvgAnimationController).GetMethod( + "GetTimelineCallbacks", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + Assert.NotNull(method); + + var result = (System.Collections.IEnumerable)method!.Invoke(controller, new object?[] { currentTime, previousTime })!; + var callbacks = new List<(string EventType, string AttributeName)>(); + foreach (var callback in result) + { + callbacks.Add(( + Assert.IsType(callback.GetType().GetProperty("EventType")!.GetValue(callback)), + Assert.IsType(callback.GetType().GetProperty("AttributeName")!.GetValue(callback)))); + } - private const string InheritedFontSizeAnimationSvg = """ + return callbacks; + } + + private const string PacedTransformAnimationSvg = """ - - - - - + width="120" + height="20" + viewBox="0 0 120 20"> + + + """; - private const string ColonClockAnimationSvg = """ + private const string ByOnlyTransformWithBaseSvg = """ - - + + """; - private const string ConcurrentAdditiveAnimationSvg = """ + private const string LargeRepeatIterationAnimationSvg = """ - - + """; - private const string RepeatDurationIndefiniteAnimationSvg = """ + private const string SyncbaseRepeatTimingSvg = """ - - + + + + + """; - private const string FiniteRepeatCountWithIndefiniteRepeatDurationSvg = """ + private const string HalfOpenIntervalAnimationSvg = """ - - + + + + + """; - private const string ZeroDurationAnimationSvg = """ + private const string RestartTruncationSyncbaseSvg = """ - - + + + """; - private const string NonFiniteClockAnimationSvg = """ + private const string SelfBeginEndTimingSvg = """ - - + + """; - private const string NonFiniteRepeatCountAnimationSvg = """ + private const string FutureSyncbaseStartTimeSvg = """ - - + + + + + """; - private const string MaximumDurationAnimationSvg = """ + private const string RepeatTimelineCallbackSvg = """ - - + + """; - private const string MalformedTransformValuesSvg = """ + private const string RepeatZeroTimingSvg = """ - - + + + + + """; - private const string MinimumDurationAnimationSvg = """ + private const string SelfEndBeginTimingSvg = """ - - + + + + + + + + """; - private const string EventEndSvg = """ + private const string NonProgressingSelfBeginTimingSvg = """ - - + width="20" + height="20" + viewBox="0 0 20 20"> + + - """; - private const string MotionValuesSvg = """ + private const string NumberListAndPathDataAnimationSvg = """ - - - + + + + + + """; - private const string WhitespaceTransformValuesSvg = """ + private const string NonInterpolableLinearAnimationSvg = """ - - + width="20" + height="20" + viewBox="0 0 20 20"> + + """; - private const string WhitespaceMotionValuesSvg = """ + private const string ToOnlyNonInterpolableAnimationSvg = """ - - - + xmlns:xlink="http://www.w3.org/1999/xlink" + width="20" + height="20" + viewBox="0 0 20 20"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; - private const string RootViewBoxAnimationSvg = """ - - - + width="20" + height="20" + viewBox="0 0 20 20"> + + + + + + + """; - private const string RootViewBoxSizeAnimationSvg = """ - - - + width="20" + height="20" + viewBox="0 0 20 20"> + + + + + + + """; - private const string SplineAnimationSvg = """ + private const string CustomNamespaceAttributeAnimationSvg = """ - - + xmlns:foo="urn:example" + width="20" + height="20" + viewBox="0 0 20 20"> + + - - + + """; + + private const string AliasedXLinkHrefAnimationSvg = """ + + + + + + + + + + """; + + private const string ClassSelectorAnimationSvg = """ + + + + - - - - - - """; - private const string MotionViewportPercentageSvg = """ + private const string NonInterpolableMidpointClassAnimationSvg = """ - - - + width="20" + height="20" + viewBox="0 0 20 20"> + + + + + + + """; - private const string TransientAnimationSvg = """ + private const string SelectorMutationOrderingAnimationSvg = """ - - + width="20" + height="20" + viewBox="0 0 20 20"> + + + + + + """; - private const string TransientImplicitAttributeSvg = """ + private const string StyleAnimationSvg = """ - - + width="20" + height="20" + viewBox="0 0 20 20"> + + """; - private const string ImmediateEventSetSvg = """ + private const string ClassPreservationAnimationSvg = """ - - + width="20" + height="20" + viewBox="0 0 20 20"> + + - """; - private const string AdditiveAndAccumulateSvg = """ + private const string ColorKeywordAnimationSvg = """ - - + width="20" + height="20" + viewBox="0 0 20 20"> + + - - + + + + + + + """; + + private const string SameFrameCurrentColorAnimationSvg = """ + + + + - - + + + - - - """; - private const string PacedValuesAnimationSvg = """ + private const string InheritedStopAnimationSvg = """ - - - + viewBox="0 0 20 20"> + + + + + + + + + """; - private const string PacedTransformAnimationSvg = """ + private const string W3CParentScopedStopOpacityAnimationSvg = """ - - - + viewBox="0 0 20 20"> + + + + + + + + + + + + """; - private const string LargeRepeatIterationAnimationSvg = """ + private const string FeCompositeCoefficientAnimationSvg = """ - - - + + + + + + + + + """; @@ -1791,6 +2933,17 @@ private static SkiaBitmap RenderBitmap(SKSvg svg) return Assert.IsType(bitmap); } + private static void AssertAnimatedFill(SvgDocument document, string elementId, Color expectedColor) + { + var target = document.GetElementById(elementId); + Assert.NotNull(target); + var fill = Assert.IsType(target!.Fill); + Assert.Equal(expectedColor.A, fill.Colour.A); + Assert.Equal(expectedColor.R, fill.Colour.R); + Assert.Equal(expectedColor.G, fill.Colour.G); + Assert.Equal(expectedColor.B, fill.Colour.B); + } + private static SkiaBitmap DrawBitmap(SKSvg svg) { Assert.NotNull(svg.Picture); diff --git a/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs b/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs index cdb5b2936f..09993c4574 100644 --- a/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs +++ b/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs @@ -84,6 +84,79 @@ public class W3CTestSuiteTests : SvgUnitTest "types-dom-svgtransformable-01-f" }; + // Parsed by scripts/capture_w3c_chrome_overrides.mjs. Keep entries as + // ["fixture-name"] = seconds so test and Chrome capture timing stay aligned. + // W3C_ANIMATION_SEEK_TIMES_BEGIN + private static readonly IReadOnlyDictionary s_animationSeekTimesSeconds = new Dictionary(StringComparer.Ordinal) + { + // Existing enabled rows. + ["animate-script-elem-01-b"] = 1.1, + ["animate-dom-01-f"] = 2.5, + + // SMIL snapshot rows whose operator/pass text identifies a stable frame. + ["animate-elem-02-t"] = 7, + ["animate-elem-03-t"] = 6, + ["animate-elem-04-t"] = 3, + ["animate-elem-05-t"] = 6, + ["animate-elem-06-t"] = 6, + ["animate-elem-07-t"] = 6, + ["animate-elem-08-t"] = 6, + ["animate-elem-09-t"] = 8, + ["animate-elem-10-t"] = 9, + ["animate-elem-11-t"] = 9, + ["animate-elem-12-t"] = 9, + ["animate-elem-13-t"] = 5, + ["animate-elem-14-t"] = 5, + ["animate-elem-15-t"] = 4.5, + ["animate-elem-17-t"] = 6, + ["animate-elem-19-t"] = 5, + ["animate-elem-22-b"] = 9, + ["animate-elem-24-t"] = 9, + ["animate-elem-25-t"] = 9, + ["animate-elem-26-t"] = 7, + ["animate-elem-27-t"] = 9, + ["animate-elem-28-t"] = 4, + ["animate-elem-30-t"] = 3.1, + ["animate-elem-31-t"] = 5, + ["animate-elem-32-t"] = 6, + ["animate-elem-33-t"] = 4, + ["animate-elem-34-t"] = 4.5, + ["animate-elem-35-t"] = 5, + ["animate-elem-36-t"] = 1.5, + ["animate-elem-37-t"] = 1.5, + ["animate-elem-38-t"] = 10, + ["animate-elem-39-t"] = 1.5, + ["animate-elem-40-t"] = 3.1, + ["animate-elem-41-t"] = 3, + ["animate-elem-44-t"] = 4.5, + ["animate-elem-46-t"] = 3, + ["animate-elem-52-t"] = 5, + ["animate-elem-53-t"] = 9, + ["animate-elem-64-t"] = 6, + ["animate-elem-65-t"] = 6, + ["animate-elem-66-t"] = 6, + ["animate-elem-67-t"] = 6, + ["animate-elem-68-t"] = 6, + ["animate-elem-69-t"] = 6, + ["animate-elem-70-t"] = 6, + ["animate-elem-77-t"] = 0.5, + ["animate-elem-78-t"] = 0.5, + ["animate-elem-80-t"] = 4.1, + ["animate-elem-81-t"] = 5, + ["animate-elem-82-t"] = 3, + ["animate-elem-83-t"] = 2.5, + ["animate-elem-86-t"] = 3, + ["animate-elem-87-t"] = 4, + ["animate-elem-88-t"] = 2, + ["animate-elem-89-t"] = 9, + ["animate-elem-90-b"] = 5, + ["animate-elem-91-t"] = 3, + ["animate-elem-92-t"] = 3, + ["animate-pservers-grad-01-b"] = 5, + ["filters-composite-05-f"] = 2 + }; + // W3C_ANIMATION_SEEK_TIMES_END + private string GetSvgPath(string name) => Path.Combine("..", "..", "..", "..", "..", "externals", "W3C_SVG_11_TestSuite", "W3C_SVG_11_TestSuite", "svg", name); @@ -123,6 +196,7 @@ private void TestImpl(string name, double errorThreshold, float scaleX = 1.0f, f } using var __ = CreateSystemLanguageScope(name); using var _ = svg.Load(svgPath); + ApplyPreSeekInteractions(name, svg); if (GetAnimationSeekTime(name) is { } animationSeekTime) { svg.SetAnimationTime(animationSeekTime); @@ -176,12 +250,9 @@ private static bool ShouldEnableJavaScript(string name) private static TimeSpan? GetAnimationSeekTime(string name) { - return name switch - { - "animate-script-elem-01-b" => TimeSpan.FromSeconds(1.1), - "animate-dom-01-f" => TimeSpan.FromSeconds(2.5), - _ => null - }; + return s_animationSeekTimesSeconds.TryGetValue(name, out var seconds) + ? TimeSpan.FromSeconds(seconds) + : null; } private static bool ShouldUseBrowserCompatibleSvgFontFallback(string name) @@ -433,9 +504,20 @@ private static double GetEffectiveThreshold(string name, double errorThreshold) "masking-path-05-f" => 0.03, "masking-path-06-b" => 0.095, "masking-path-07-b" => 0.042, + // The animated dash/linecap/linejoin/miter states align with Chrome at the sampled + // SMIL frame; the residual delta is Skia stroke rasterization on the dense path samples. + "animate-elem-35-t" => 0.1, + // These two W3C rows intentionally stay on the legacy W3C pass images because current + // Chrome snapshots do not match the W3C discrete class/to-only non-interpolable states. + // The animated shape pixels match; the residual delta is text and frame rasterization. + "animate-elem-90-b" => 0.043, + "animate-elem-91-t" => 0.052, // The pass criteria for this units fixture allow font-dependent unit rows to vary; // keep a narrow threshold for residual Chrome text/unit raster differences. "coords-units-03-b" => 0.026, + // Geometry and animated xlink target state match Chrome; the remaining delta is + // confined to platform text rasterization in the labels/revision footer. + "animate-elem-27-t" => 0.036, "struct-cond-02-t" => 0.036, "struct-frag-02-t" => 0.023, "struct-frag-03-t" => 0.024, @@ -545,6 +627,18 @@ private static double GetEffectiveThreshold(string name, double errorThreshold) }; } + private static void ApplyPreSeekInteractions(string name, SKSvg svg) + { + switch (name) + { + case "animate-elem-52-t": + NotifyClickEvent(svg, "A"); + NotifyClickEvent(svg, "B"); + NotifyClickEvent(svg, "C"); + break; + } + } + private static void ApplyPostLoadInteractions(string name, SKSvg svg) { switch (name) @@ -851,6 +945,16 @@ private static void DispatchPointerClick(SKSvg svg, SKPoint point) dispatcher.DispatchPointerReleased(svg, press); } + private static void NotifyClickEvent(SKSvg svg, string elementId) + { + var sourceDocument = svg.SourceDocument ?? throw new InvalidOperationException("SVG document is not loaded."); + var rawElement = sourceDocument.GetElementById(elementId) ?? throw new InvalidOperationException($"Element '{elementId}' was not found."); + if (!svg.NotifyPointerEvent(rawElement, SvgPointerEventType.Click)) + { + throw new InvalidOperationException($"Element '{elementId}' did not record a click animation event."); + } + } + private static SvgJavaScriptRuntime GetJavaScriptRuntime(SKSvg svg) { var field = typeof(SKSvg).GetField("_javaScriptRuntime", BindingFlags.Instance | BindingFlags.NonPublic) @@ -880,80 +984,80 @@ public void Dispose() [OSXTheory] [InlineData("animate-dom-01-f", 0.022)] [InlineData("animate-dom-02-f", 0.022)] - [InlineData("animate-elem-02-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-03-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-04-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-05-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-06-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-07-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-08-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-09-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-10-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-11-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-12-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-13-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-14-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-15-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-17-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-19-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-20-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-21-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-22-b", 0.022, Skip = "TODO")] - [InlineData("animate-elem-23-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-24-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-25-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-26-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-27-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-28-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-29-b", 0.022, Skip = "TODO")] - [InlineData("animate-elem-30-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-31-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-32-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-33-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-34-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-35-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-36-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-37-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-38-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-39-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-40-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-41-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-44-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-46-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-52-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-53-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-60-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-61-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-62-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-63-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-64-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-65-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-66-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-67-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-68-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-69-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-70-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-77-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-78-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-80-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-81-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-82-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-83-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-84-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-85-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-86-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-87-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-88-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-89-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-90-b", 0.022, Skip = "TODO")] - [InlineData("animate-elem-91-t", 0.022, Skip = "TODO")] - [InlineData("animate-elem-92-t", 0.022, Skip = "TODO")] - [InlineData("animate-interact-events-01-t", 0.022, Skip = "TODO")] - [InlineData("animate-interact-pevents-01-t", 0.022, Skip = "TODO")] - [InlineData("animate-interact-pevents-02-t", 0.022, Skip = "TODO")] - [InlineData("animate-interact-pevents-03-t", 0.022, Skip = "TODO")] - [InlineData("animate-interact-pevents-04-t", 0.022, Skip = "TODO")] - [InlineData("animate-pservers-grad-01-b", 0.022, Skip = "TODO")] + [InlineData("animate-elem-02-t", 0.022)] + [InlineData("animate-elem-03-t", 0.022)] + [InlineData("animate-elem-04-t", 0.022)] + [InlineData("animate-elem-05-t", 0.022)] + [InlineData("animate-elem-06-t", 0.022)] + [InlineData("animate-elem-07-t", 0.022)] + [InlineData("animate-elem-08-t", 0.022)] + [InlineData("animate-elem-09-t", 0.022)] + [InlineData("animate-elem-10-t", 0.022)] + [InlineData("animate-elem-11-t", 0.022)] + [InlineData("animate-elem-12-t", 0.022)] + [InlineData("animate-elem-13-t", 0.022)] + [InlineData("animate-elem-14-t", 0.022)] + [InlineData("animate-elem-15-t", 0.022)] + [InlineData("animate-elem-17-t", 0.022)] + [InlineData("animate-elem-19-t", 0.022)] + [InlineData("animate-elem-20-t", 0.022, Skip = "Requires hyperlink activation of beginElement()/endElement() on indefinite SMIL animations, not a static snapshot trigger.")] + [InlineData("animate-elem-21-t", 0.022, Skip = "Requires hyperlink activation plus chained syncbase timing from indefinite SMIL animations.")] + [InlineData("animate-elem-22-b", 0.022)] + [InlineData("animate-elem-23-t", 0.022, Skip = "Modern Chrome captures deprecated animateColor as no-op; keep skipped until the W3C animateColor row has a non-Chrome static reference policy.")] + [InlineData("animate-elem-24-t", 0.022)] + [InlineData("animate-elem-25-t", 0.022)] + [InlineData("animate-elem-26-t", 0.022)] + [InlineData("animate-elem-27-t", 0.022)] + [InlineData("animate-elem-28-t", 0.022)] + [InlineData("animate-elem-29-b", 0.022, Skip = "Requires hyperlink activation of indefinite SMIL animations and interactive fade-in/fade-out sequencing.")] + [InlineData("animate-elem-30-t", 0.022)] + [InlineData("animate-elem-31-t", 0.022)] + [InlineData("animate-elem-32-t", 0.022)] + [InlineData("animate-elem-33-t", 0.022)] + [InlineData("animate-elem-34-t", 0.022)] + [InlineData("animate-elem-35-t", 0.022)] + [InlineData("animate-elem-36-t", 0.022)] + [InlineData("animate-elem-37-t", 0.022)] + [InlineData("animate-elem-38-t", 0.022)] + [InlineData("animate-elem-39-t", 0.022)] + [InlineData("animate-elem-40-t", 0.022)] + [InlineData("animate-elem-41-t", 0.022)] + [InlineData("animate-elem-44-t", 0.022)] + [InlineData("animate-elem-46-t", 0.022)] + [InlineData("animate-elem-52-t", 0.022)] + [InlineData("animate-elem-53-t", 0.022)] + [InlineData("animate-elem-60-t", 0.022, Skip = "Requires mixed eventbase, accessKey(), and wallclock() timing; the static harness only records pointer events.")] + [InlineData("animate-elem-61-t", 0.022, Skip = "Requires multiple begin conditions including accessKey() and user-event sequencing beyond static snapshot input.")] + [InlineData("animate-elem-62-t", 0.022, Skip = "Requires mixed eventbase, accessKey(), and wallclock() end timing; the static harness only records pointer events.")] + [InlineData("animate-elem-63-t", 0.022, Skip = "Requires multiple end conditions including accessKey() and repeated user-event sequencing beyond static snapshot input.")] + [InlineData("animate-elem-64-t", 0.022)] + [InlineData("animate-elem-65-t", 0.022)] + [InlineData("animate-elem-66-t", 0.022)] + [InlineData("animate-elem-67-t", 0.022)] + [InlineData("animate-elem-68-t", 0.022)] + [InlineData("animate-elem-69-t", 0.022)] + [InlineData("animate-elem-70-t", 0.022)] + [InlineData("animate-elem-77-t", 0.022)] + [InlineData("animate-elem-78-t", 0.022)] + [InlineData("animate-elem-80-t", 0.022)] + [InlineData("animate-elem-81-t", 0.022)] + [InlineData("animate-elem-82-t", 0.022)] + [InlineData("animate-elem-83-t", 0.022)] + [InlineData("animate-elem-84-t", 0.022, Skip = "Modern Chrome captures deprecated animateColor as no-op; keep skipped until the W3C animateColor row has a non-Chrome static reference policy.")] + [InlineData("animate-elem-85-t", 0.022, Skip = "Modern Chrome captures deprecated animateColor as no-op; keep skipped until the W3C animateColor row has a non-Chrome static reference policy.")] + [InlineData("animate-elem-86-t", 0.022)] + [InlineData("animate-elem-87-t", 0.022)] + [InlineData("animate-elem-88-t", 0.022)] + [InlineData("animate-elem-89-t", 0.022)] + [InlineData("animate-elem-90-b", 0.022)] + [InlineData("animate-elem-91-t", 0.022)] + [InlineData("animate-elem-92-t", 0.022)] + [InlineData("animate-interact-events-01-t", 0.022, Skip = "Requires browser SVGElementInstance event dispatch and mouseover/mousedown lifetime behavior.")] + [InlineData("animate-interact-pevents-01-t", 0.022, Skip = "Requires browser pointer hit-testing over text pointer-events variants and hover-triggered indefinite SMIL state.")] + [InlineData("animate-interact-pevents-02-t", 0.022, Skip = "Requires interactive pointer-events mutation plus mousedown/mouseover hit-testing state across user actions.")] + [InlineData("animate-interact-pevents-03-t", 0.022, Skip = "Requires browser pointer hit-testing over visiblePainted/visibleFill/visibleStroke/visible variants and hover state.")] + [InlineData("animate-interact-pevents-04-t", 0.022, Skip = "Requires browser pointer hit-testing over painted/fill/stroke/all/none variants and hover state.")] + [InlineData("animate-pservers-grad-01-b", 0.022)] [InlineData("animate-script-elem-01-b", 0.022)] [InlineData("animate-struct-dom-01-b", 0.022)] [InlineData("color-prof-01-f", 0.022, Skip = "Optional ICC color profile support is not a stable Chrome-backed baseline.")] @@ -1004,7 +1108,7 @@ public void Dispose() [InlineData("filters-composite-02-b", 0.022)] [InlineData("filters-composite-03-f", 0.022)] [InlineData("filters-composite-04-f", 0.022)] - [InlineData("filters-composite-05-f", 0.022, Skip = "Chrome override captures the running SMIL dissolve after 1.5s; static W3C snapshots do not advance feComposite animation yet.")] + [InlineData("filters-composite-05-f", 0.022)] [InlineData("filters-comptran-01-b", 0.022)] [InlineData("filters-conv-01-f", 0.022)] [InlineData("filters-conv-02-f", 0.022)] diff --git a/tests/Svg.Skia.UnitTests/resvgTests.cs b/tests/Svg.Skia.UnitTests/resvgTests.cs index 5416d16c98..a05f70d090 100644 --- a/tests/Svg.Skia.UnitTests/resvgTests.cs +++ b/tests/Svg.Skia.UnitTests/resvgTests.cs @@ -21,6 +21,10 @@ public static IEnumerable TextFixtureRows() public static IEnumerable NonTextFixtureRows() => EnumerateFixtureRows(excludePrefix: "tests/text/"); + public static IEnumerable ResourceRenderingFixtureRows() + => EnumerateFixtureRows() + .Where(static row => IsResourceRenderingFixture((string)row[0])); + [OSXTheory] [MemberData(nameof(TextFixtureRows))] public void text_fixtures(string relativeName, double errorThreshold) @@ -31,6 +35,11 @@ public void text_fixtures(string relativeName, double errorThreshold) public void non_text_fixtures(string relativeName, double errorThreshold) => TestImpl(relativeName, errorThreshold); + [OSXTheory] + [MemberData(nameof(ResourceRenderingFixtureRows))] + public void resource_rendering_fixtures(string relativeName, double errorThreshold) + => TestImpl(relativeName, errorThreshold); + [Fact] public void resvg_fixture_inventory() { @@ -162,6 +171,46 @@ private static double GetEffectiveThreshold(string relativeName, double defaultT }; } + private static bool IsResourceRenderingFixture(string relativeName) + => !relativeName.StartsWith("tests/text/", StringComparison.Ordinal) && + ResourceRenderingFixturePrefixes.Any(prefix => relativeName.StartsWith(prefix, StringComparison.Ordinal)); + + private static readonly string[] ResourceRenderingFixturePrefixes = + { + "tests/filters/feComponentTransfer/", + "tests/filters/feDisplacementMap/", + "tests/filters/feDistantLight/", + "tests/filters/feTurbulence/", + "tests/masking/clip-rule/", + "tests/paint-servers/stop-color/", + "tests/painting/color/", + "tests/painting/fill-rule/", + "tests/painting/image-rendering/", + "tests/painting/isolation/", + "tests/painting/marker/", + "tests/painting/mix-blend-mode/", + "tests/painting/paint-order/", + "tests/painting/shape-rendering/", + "tests/painting/stroke/", + "tests/painting/stroke-dasharray/", + "tests/painting/stroke-dashoffset/", + "tests/painting/stroke-linecap/", + "tests/painting/stroke-linejoin/", + "tests/painting/stroke-miterlimit/", + "tests/painting/stroke-width/", + "tests/painting/visibility/", + "tests/shapes/circle/", + "tests/shapes/line/", + "tests/shapes/polygon/", + "tests/shapes/polyline/", + "tests/shapes/rect/", + "tests/structure/a/", + "tests/structure/defs/", + "tests/structure/g/", + "tests/structure/transform/", + "tests/structure/use/" + }; + private static string GetResvgTestsRoot() => Path.GetFullPath(Path.Combine("..", "..", "..", "..", "..", "externals", "resvg", "crates", "resvg", "tests"));