diff --git a/plan/skipped-tests-implementation-roadmap.md b/plan/skipped-tests-implementation-roadmap.md index cc96f622ae..33722fb516 100644 --- a/plan/skipped-tests-implementation-roadmap.md +++ b/plan/skipped-tests-implementation-roadmap.md @@ -91,7 +91,7 @@ Still open inside the text tranche: - `SKSvgSettings.EnableTextSelectionRendering` and `TextSelectionColor` now control retained static selection highlight painting for JavaScript `selectSubString`; post-load selection calls refresh the retained picture so event-driven selection is visible. - Empty-content `altGlyph` now resolves referenced SVG font glyphs instead of being skipped before SVG-font lookup. The resolver covers direct glyph references, `glyphRef`, `altGlyphDef`, and `altGlyphItem` sequences when they resolve to one SVG font entry. - W3C `text-altglyph-01/02/03-b` rows are enabled with scoped raster thresholds and an ignored draft-banner strip for `text-altglyph-03-b`; remaining deltas are browser/font raster identity, not missing substitution. - - W3C `text-tselect-01/02/03` remain explicitly skipped because the legacy fixtures assert browser visual-order selection UI behavior beyond static logical selection highlighting. + - W3C `text-tselect-01/02/03` now run semantic host-selection assertions instead of a stale static raster comparison. Exact browser chrome selection UI remains host/runtime behavior, but logical `selectSubString`, retained highlight extents, and backward caret metadata are covered. Verified probe findings from the remaining skipped W3C text rows on 2026-04-09: @@ -487,16 +487,20 @@ Current validation: Target projects: -- new runtime surface, likely centered around `src/Svg.Custom`, `src/Svg.Model`, and test harness integration +- existing runtime surface centered around `src/Svg.JavaScript`, `src/Svg.Skia` interaction dispatch, `src/Svg.Custom` namespace parsing, and W3C harness integration Features: -- DOM objects and live lists -- script execution -- event dispatch -- selection APIs -- mutation-driven rerendering -- interactive pointer and zoom behavior +- DOM objects and live lists: implemented for document/element `childNodes`, `children`, `getElementsByTagName`, and `getElementsByTagNameNS`; parsed SVG and foreign-namespace element names now report DOM local names instead of CLR fallback names. +- Script execution: existing Jint-backed inline/external script execution remains the runtime base; `contentScriptType` default-script gating is covered by `script-specify-01-f`. +- Event dispatch: implemented element-wide post-order non-bubbling load dispatch, image load dispatch, event bubbling/stop-propagation coverage, namespace-correct dynamic image creation through `setAttributeNS`, SVG mouse-event routing into SMIL eventbase timing, and source-document normalization for events that originate from retained/animated scene elements. +- SVG DOM metrics: implemented viewport-relative `SVGLength.value` / `valueInSpecifiedUnits` conversion for percentage lengths, including nested `` reparenting behavior. +- Selection APIs: static `selectSubString` support remains covered by text DOM tests; browser UI selection painting remains intentionally out of scope for static rendering. +- Mutation-driven rerendering: existing mutation version invalidation and `SKSvg.RefreshFromSourceDocument()` integration is preserved and now covers dynamically created namespaced image/path/text nodes. +- Interactive pointer and zoom behavior: pointer dispatch, bubbling, stop propagation, pointer-events text hit testing, animated pointer-events state changes, direct viewer zoom/pan transform APIs, and deterministic viewer transform notifications are covered for static harness events. Browser viewer cursor chrome, hyperlink navigation, visual text selection UI, and full browser `SVGElementInstance` event targeting remain policy/runtime-host features. +- Static DOM/types TODO triage: `struct-defs-01-t`, `types-basic-01-f`, and `types-basic-02-f` are not runtime-host work. `struct-defs-01-t` is defs non-rendering, `types-basic-01-f` is number/scientific-notation parsing, and `types-basic-02-f` is CSS-vs-presentation length unit case handling. These rows now use semantic assertions where legacy W3C PNGs are stale or contradict the pass criteria. +- Resource/DOM crossover: nested SVG image loading now resolves implicit SVG image viewports from the containing `` viewport, so `struct-image-16-f` renders the W3C green pass state. The Chrome override for that row was removed because current Chrome captures the spec-failing red state; the row compares against the W3C reference with a scoped full-frame threshold for the SVG/PNG revision-text mismatch only. +- Resource/DOM crossover: invalid and cyclic SVG image references now use the deterministic retained broken-image placeholder policy when placeholders are enabled, including recursive embedded SVG-as-image edges discovered during nested document compilation. This preserves non-recursive nested SVG content and surrounding sibling content while avoiding infinite recursion. W3C `struct-image-12-b` remains a semantic/unit-test-covered policy skip because exact Chrome broken-image icon chrome is browser UI, not Svg.Skia renderer output. Primary test impact: @@ -504,8 +508,16 @@ Primary test impact: Acceptance criteria: -- These rows remain skipped until the runtime exists. -- Once started, this should be tracked as a dedicated milestone because it is not a text-rendering-only task. +- Runtime-backed rows are enabled only when the DOM/script state is asserted directly or the raster is stable; no fake baselines or broad thresholds are used for browser UI behavior. +- Newly covered rows include `animate-interact-pevents-01/02/03/04-t`, `conform-viewers-03-f`, `extend-namespace-01-f`, `interact-events-01/02-b`, `interact-events-202-f`, `interact-events-203-t`, `interact-order-01/02/03-b`, `interact-pevents-01-b`, `interact-pevents-07/08/09/10`, `interact-pointer-01/02/03/04`, `interact-zoom-01/02/03`, `script-specify-01-f`, `struct-defs-01-t`, `struct-image-07-t`, `struct-image-16-f`, `struct-image-17-b`, `struct-svg-02-f`, `types-basic-01-f`, and `types-basic-02-f`. +- 2026-05-29 interaction/runtime completion pass: + - `animate-interact-events-01-t` is enabled with a semantic assertion. Pointer dispatch now carries the retained generated `` hit node into SMIL event recording so referenced instance content and ancestor listeners receive `mouseover`/`mouseout`/press events before the normal referencing-element route. + - `interact-pevents-03/04/05` are enabled with semantic assertions over text character-cell hit testing. Compiled text scene nodes now retain text DOM metrics with separate hit extents, allowing visible glyphs and SVG-font space cells to hit while letter-spacing gaps stay non-targetable. + - `text-tselect-01/02/03` are enabled as semantic host-selection rows. The tests assert layout-backed logical substring selection, retained extents, visual extents, and backward range metadata without pretending Svg.Skia owns browser selection chrome. + - `conform-viewers-02-f` is enabled through the existing gzipped nested SVG data URI semantic assertion, with focused resource tests covering W3C-style `image/svg+xml` gzip payloads and compressed SVG image MIME aliases. + - `struct-image-12-b` is covered by focused resource tests for deterministic invalid/cyclic image placeholders and sibling-content preservation, but the W3C raster row stays skipped because Chrome paints native broken-image UI chrome that Svg.Skia intentionally does not emulate. + - Focused validation covered the newly enabled W3C rows plus the hit-test/text-selection/viewer/resource unit slices. Final validation also passed `dotnet build Svg.Skia.slnx -c Release --no-restore`, `dotnet test Svg.Skia.slnx -c Release --no-build`, and `dotnet format Svg.Skia.slnx --no-restore --verify-no-changes`. +- Remaining browser-host behavior after this pass is limited to exact viewer/UI chrome parity, such as native text selection painting/focus policy beyond retained host highlights and external navigation chrome. No W3C interaction/runtime row in this lane remains skipped for missing Svg.Skia engine support. ## Immediate Implementation Order @@ -541,7 +553,7 @@ The next implementation tranche should be: ## Runtime-Gated Groups -The following groups should not be enabled by changing thresholds or inventing baselines: +The following groups should not be enabled by changing thresholds or inventing baselines. Enable rows only through actual runtime support plus focused semantic assertions when the legacy PNG is stale: - W3C `text-dom-*` - W3C `text-tselect-*` @@ -551,7 +563,7 @@ The following groups should not be enabled by changing thresholds or inventing b - W3C `struct-dom-*` - W3C `struct-svg-*` -They require a DOM, script, or interaction runtime rather than renderer-only fixes. +Most DOM/script rows now have a static runtime path. Remaining skipped rows in these groups are browser-host or deeper browser-DOM features outside Svg.Skia's static runtime contract, such as native cursor/hyperlink chrome and exact visual text-selection UI/focus policy beyond retained host highlights. ## Reference-Suite Constraints diff --git a/src/Svg.Animation/Animation/SvgAnimationController.cs b/src/Svg.Animation/Animation/SvgAnimationController.cs index f1148dd4f9..d12346b7e0 100644 --- a/src/Svg.Animation/Animation/SvgAnimationController.cs +++ b/src/Svg.Animation/Animation/SvgAnimationController.cs @@ -48,23 +48,40 @@ private readonly struct TimingSpec public TimingSpec(TimeSpan offset) { IsEvent = false; + IsAccessKey = false; Offset = offset; EventAddress = null; EventType = default; RepeatIteration = null; + AccessKey = null; } public TimingSpec(SvgElementAddress eventAddress, SvgAnimationTimingEventType eventType, TimeSpan offset, int? repeatIteration = null) { IsEvent = true; + IsAccessKey = false; Offset = offset; EventAddress = eventAddress; EventType = eventType; RepeatIteration = repeatIteration; + AccessKey = null; + } + + public TimingSpec(string accessKey, TimeSpan offset) + { + IsEvent = false; + IsAccessKey = true; + Offset = offset; + EventAddress = null; + EventType = SvgAnimationTimingEventType.AccessKey; + RepeatIteration = null; + AccessKey = accessKey; } public bool IsEvent { get; } + public bool IsAccessKey { get; } + public TimeSpan Offset { get; } public SvgElementAddress? EventAddress { get; } @@ -72,6 +89,8 @@ public TimingSpec(SvgElementAddress eventAddress, SvgAnimationTimingEventType ev public SvgAnimationTimingEventType EventType { get; } public int? RepeatIteration { get; } + + public string? AccessKey { get; } } private readonly struct MotionSource @@ -115,7 +134,7 @@ public PointerEventDependency(AnimationBinding binding) private sealed class AnimationBinding { - public AnimationBinding(SvgAnimationElement animation, SvgElement sourceTarget, SvgElementAddress targetAddress, string attributeName) + public AnimationBinding(SvgAnimationElement animation, SvgElement sourceTarget, SvgElementAddress targetAddress, string attributeName, DateTimeOffset wallclockTimeOrigin) { Animation = animation; AnimationAddress = SvgElementAddress.Create(animation); @@ -130,8 +149,8 @@ public AnimationBinding(SvgAnimationElement animation, SvgElement sourceTarget, 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); - EndSpecs = ParseTimingSpecifications(animation.End, animation.OwnerDocument, targetAddress, includeImplicitDocumentBegin: false); + BeginSpecs = ParseTimingSpecifications(animation.Begin, animation.OwnerDocument, targetAddress, wallclockTimeOrigin, includeImplicitDocumentBegin: true); + EndSpecs = ParseTimingSpecifications(animation.End, animation.OwnerDocument, targetAddress, wallclockTimeOrigin, includeImplicitDocumentBegin: false); HasDynamicBeginTiming = ContainsDynamicTiming(BeginSpecs); HasDynamicEndTiming = ContainsDynamicTiming(EndSpecs); StaticBeginInstances = HasDynamicBeginTiming ? s_emptyResolvedTimingInstances : CreateStaticTimingInstances(BeginSpecs); @@ -259,33 +278,41 @@ public PathDataToken(float number) private readonly Dictionary _bindingsByTargetAttributeKey; private readonly Dictionary _bindingsByAnimationAddressKey; private readonly Dictionary> _pointerEventInstances = new(StringComparer.Ordinal); + private readonly Dictionary> _accessKeyEventInstances = new(StringComparer.Ordinal); private readonly Dictionary> _scheduledBeginInstances = new(StringComparer.Ordinal); private readonly Dictionary> _scheduledEndInstances = new(StringComparer.Ordinal); private readonly HashSet _pointerEventDependencies; + private readonly HashSet _accessKeyEventDependencies; private readonly Dictionary> _pointerEventDependents; + private readonly Dictionary> _accessKeyEventDependents; private readonly List _timelineTrackedBindings; private readonly int[]? _animatedTopLevelChildIndexes; private SvgAnimationFrameState? _cachedFrameState; private int _frameStateVersion; private bool _disposed; - public SvgAnimationController(SvgDocument sourceDocument) + public SvgAnimationController(SvgDocument sourceDocument, DateTimeOffset? wallclockTimeOrigin = null) { SourceDocument = sourceDocument ?? throw new ArgumentNullException(nameof(sourceDocument)); + WallclockTimeOrigin = (wallclockTimeOrigin ?? DateTimeOffset.UtcNow).ToUniversalTime(); Clock = new SvgAnimationClock(); Clock.TimeChanged += OnClockTimeChanged; - _bindings = DiscoverBindings(sourceDocument); + _bindings = DiscoverBindings(sourceDocument, WallclockTimeOrigin); _frameEvaluationBindings = CreateFrameEvaluationBindings(_bindings); _bindingsByTargetAttributeKey = BuildBindingLookup(_bindings); _bindingsByAnimationAddressKey = BuildAnimationBindingLookup(_bindings); _pointerEventDependencies = BuildPointerEventDependencies(_bindings); + _accessKeyEventDependencies = BuildAccessKeyEventDependencies(_bindings); _pointerEventDependents = BuildPointerEventDependents(_bindings); + _accessKeyEventDependents = BuildAccessKeyEventDependents(_bindings); _timelineTrackedBindings = DiscoverTimelineTrackedBindings(_bindings); _animatedTopLevelChildIndexes = DiscoverAnimatedTopLevelChildIndexes(sourceDocument, _bindings); } public SvgDocument SourceDocument { get; } + public DateTimeOffset WallclockTimeOrigin { get; } + public SvgAnimationClock Clock { get; } public bool HasAnimations => _bindings.Count > 0; @@ -652,6 +679,11 @@ private static bool RequiresSelectorStyleReapplication(string attributeName) } public bool RecordPointerEvent(SvgElement? element, SvgPointerEventType eventType) + { + return RecordPointerEvent(element, eventType, Clock.CurrentTime); + } + + public bool RecordPointerEvent(SvgElement? element, SvgPointerEventType eventType, TimeSpan eventTime) { ThrowIfDisposed(); @@ -666,22 +698,60 @@ public bool RecordPointerEvent(SvgElement? element, SvgPointerEventType eventTyp return false; } - if (!_pointerEventInstances.TryGetValue(key, out var eventTimes)) + RecordUserEventInstance(_pointerEventInstances, key, eventTime); + PrunePointerEventInstances(key); + InvalidateFrameStateCache(); + return true; + } + + public bool RecordAccessKey(string? accessKey) + { + return RecordAccessKey(accessKey, Clock.CurrentTime); + } + + public bool RecordAccessKey(string? accessKey, TimeSpan eventTime) + { + ThrowIfDisposed(); + + if (!HasAnimations || !TryNormalizeAccessKey(accessKey, out var normalizedAccessKey)) { - eventTimes = new List(); - _pointerEventInstances[key] = eventTimes; + return false; } - eventTimes.Add(Clock.CurrentTime); - PrunePointerEventInstances(key); + var key = CreateAccessKeyEventInstanceKey(normalizedAccessKey); + if (!_accessKeyEventDependencies.Contains(key)) + { + return false; + } + + RecordUserEventInstance(_accessKeyEventInstances, key, eventTime); + PruneAccessKeyEventInstances(key); InvalidateFrameStateCache(); return true; } + private static void RecordUserEventInstance(Dictionary> eventInstances, string key, TimeSpan eventTime) + { + if (eventTime < TimeSpan.Zero) + { + eventTime = TimeSpan.Zero; + } + + if (!eventInstances.TryGetValue(key, out var eventTimes)) + { + eventTimes = new List(); + eventInstances[key] = eventTimes; + } + + eventTimes.Add(eventTime); + eventTimes.Sort(); + } + public void Reset() { ThrowIfDisposed(); _pointerEventInstances.Clear(); + _accessKeyEventInstances.Clear(); _scheduledBeginInstances.Clear(); _scheduledEndInstances.Clear(); InvalidateFrameStateCache(); @@ -968,6 +1038,19 @@ private static HashSet BuildPointerEventDependencies(IEnumerable BuildAccessKeyEventDependencies(IEnumerable bindings) + { + var dependencies = new HashSet(StringComparer.Ordinal); + + foreach (var binding in bindings) + { + AddAccessKeyEventDependencies(binding.BeginSpecs, dependencies); + AddAccessKeyEventDependencies(binding.EndSpecs, dependencies); + } + + return dependencies; + } + private static Dictionary> BuildPointerEventDependents(IEnumerable bindings) { var dependents = new Dictionary>(StringComparer.Ordinal); @@ -981,6 +1064,19 @@ private static Dictionary> BuildPointerEven return dependents; } + private static Dictionary> BuildAccessKeyEventDependents(IEnumerable bindings) + { + var dependents = new Dictionary>(StringComparer.Ordinal); + + foreach (var binding in bindings) + { + AddAccessKeyEventDependents(binding, binding.BeginSpecs, dependents); + AddAccessKeyEventDependents(binding, binding.EndSpecs, dependents); + } + + return dependents; + } + private static void AddPointerEventDependents( AnimationBinding binding, IEnumerable specs, @@ -1008,6 +1104,33 @@ private static void AddPointerEventDependents( } } + private static void AddAccessKeyEventDependents( + AnimationBinding binding, + IEnumerable specs, + Dictionary> dependents) + { + foreach (var spec in specs) + { + if (!TryGetAccessKeyEventInstanceKey(spec, out var eventInstanceKey)) + { + continue; + } + + if (!dependents.TryGetValue(eventInstanceKey, out var bindingsForKey)) + { + bindingsForKey = new List(); + dependents[eventInstanceKey] = bindingsForKey; + } + + if (bindingsForKey.Any(existing => ReferenceEquals(existing.Binding, binding))) + { + continue; + } + + bindingsForKey.Add(new PointerEventDependency(binding)); + } + } + private static List CreateFrameEvaluationBindings(List bindings) { return bindings @@ -1100,6 +1223,17 @@ private static void AddEventDependencies(IEnumerable specs, HashSet< } } + private static void AddAccessKeyEventDependencies(IEnumerable specs, HashSet dependencies) + { + foreach (var spec in specs) + { + if (TryGetAccessKeyEventInstanceKey(spec, out var eventInstanceKey)) + { + dependencies.Add(eventInstanceKey); + } + } + } + private static bool TryGetPointerEventInstanceKey(TimingSpec spec, out string key) { if (spec.IsEvent && IsPointerTimingEventType(spec.EventType)) @@ -1112,6 +1246,18 @@ private static bool TryGetPointerEventInstanceKey(TimingSpec spec, out string ke return false; } + private static bool TryGetAccessKeyEventInstanceKey(TimingSpec spec, out string key) + { + if (spec.IsAccessKey && spec.AccessKey is { } accessKey) + { + key = CreateAccessKeyEventInstanceKey(accessKey); + return true; + } + + key = string.Empty; + return false; + } + private static bool HasExplicitAnimationBaseAttribute(SvgElement sourceTarget, string attributeName, object? baseValue) { if (sourceTarget.ContainsAttribute(attributeName)) @@ -1129,7 +1275,7 @@ private static bool IsHrefAnimationAttribute(string attributeName) attributeName.StartsWith(SvgNamespaces.XLinkNamespace + ":href", StringComparison.Ordinal); } - private static List DiscoverBindings(SvgDocument sourceDocument) + private static List DiscoverBindings(SvgDocument sourceDocument, DateTimeOffset wallclockTimeOrigin) { var bindings = new List(); @@ -1148,7 +1294,7 @@ private static List DiscoverBindings(SvgDocument sourceDocumen continue; } - bindings.Add(new AnimationBinding(animation, target, SvgElementAddress.Create(target), attributeName!)); + bindings.Add(new AnimationBinding(animation, target, SvgElementAddress.Create(target), attributeName!, wallclockTimeOrigin)); } return bindings; @@ -1219,7 +1365,7 @@ private void InvalidateFrameStateCache() _cachedFrameState = null; } - private static List ParseTimingSpecifications(string? value, SvgDocument? document, SvgElementAddress defaultEventAddress, bool includeImplicitDocumentBegin) + private static List ParseTimingSpecifications(string? value, SvgDocument? document, SvgElementAddress defaultEventAddress, DateTimeOffset wallclockTimeOrigin, bool includeImplicitDocumentBegin) { if (string.IsNullOrWhiteSpace(value)) { @@ -1237,6 +1383,18 @@ private static List ParseTimingSpecifications(string? value, SvgDocu continue; } + if (SvgAnimationParser.TryParseWallclockTimingSpec(token, out var wallclockTime)) + { + specs.Add(new TimingSpec(wallclockTime.ToUniversalTime() - wallclockTimeOrigin)); + continue; + } + + if (SvgAnimationParser.TryParseAccessKeyTimingSpec(token, out var accessKey, out var accessKeyOffset)) + { + specs.Add(new TimingSpec(NormalizeAccessKey(accessKey), accessKeyOffset)); + continue; + } + if (TryParseEventTimingSpec(token, document, defaultEventAddress, out var eventTimingSpec)) { specs.Add(eventTimingSpec); @@ -1254,7 +1412,26 @@ private void PrunePointerEventInstances(string key) return; } - if (!_pointerEventDependents.TryGetValue(key, out var dependents) || + PruneUserEventInstances(key, eventTimes, _pointerEventDependents); + } + + private void PruneAccessKeyEventInstances(string key) + { + if (!_accessKeyEventInstances.TryGetValue(key, out var eventTimes) || + eventTimes.Count <= 1) + { + return; + } + + PruneUserEventInstances(key, eventTimes, _accessKeyEventDependents); + } + + private void PruneUserEventInstances( + string key, + List eventTimes, + IReadOnlyDictionary> dependentsByKey) + { + if (!dependentsByKey.TryGetValue(key, out var dependents) || dependents.Count == 0) { eventTimes.Clear(); @@ -1794,6 +1971,25 @@ private List ResolveTimingInstancesDetailed(IReadOnlyLis foreach (var spec in specs) { + if (TryGetAccessKeyEventInstanceKey(spec, out var accessKeyEventKey)) + { + if (!_accessKeyEventInstances.TryGetValue(accessKeyEventKey, out var eventTimes)) + { + continue; + } + + for (var index = 0; index < eventTimes.Count; index++) + { + var eventTime = eventTimes[index]; + instances.Add(new ResolvedTimingInstance( + eventTime + spec.Offset, + accessKeyEventKey, + eventTime)); + } + + continue; + } + if (!spec.IsEvent) { instances.Add(new ResolvedTimingInstance(spec.Offset, eventInstanceKey: null, sourceEventTime: null)); @@ -2396,7 +2592,7 @@ private static bool ContainsDynamicTiming(IReadOnlyList specs) { for (var index = 0; index < specs.Count; index++) { - if (specs[index].IsEvent) + if (specs[index].IsEvent || specs[index].IsAccessKey) { return true; } @@ -4859,6 +5055,28 @@ private static string CreateEventInstanceKey(SvgElementAddress address, SvgAnima return string.Concat(address.Key, "|", ((int)eventType).ToString(CultureInfo.InvariantCulture)); } + private static string CreateAccessKeyEventInstanceKey(string accessKey) + { + return string.Concat("accessKey|", NormalizeAccessKey(accessKey)); + } + + private static bool TryNormalizeAccessKey(string? accessKey, out string normalizedAccessKey) + { + normalizedAccessKey = string.Empty; + if (string.IsNullOrWhiteSpace(accessKey)) + { + return false; + } + + normalizedAccessKey = NormalizeAccessKey(accessKey!); + return normalizedAccessKey.Length > 0; + } + + private static string NormalizeAccessKey(string accessKey) + { + return accessKey.Trim().ToUpperInvariant(); + } + private static object? GetAttributeValue(SvgElement element, string attributeName) { return element.GetAnimationValue(attributeName); diff --git a/src/Svg.Animation/Animation/SvgAnimationParser.cs b/src/Svg.Animation/Animation/SvgAnimationParser.cs index 43d0ce8c52..c4dd1c4b2f 100644 --- a/src/Svg.Animation/Animation/SvgAnimationParser.cs +++ b/src/Svg.Animation/Animation/SvgAnimationParser.cs @@ -246,6 +246,77 @@ internal static bool TryParseEventTimingSpec( return true; } + internal static bool TryParseAccessKeyTimingSpec(string value, out string accessKey, out TimeSpan offset) + { + accessKey = string.Empty; + offset = TimeSpan.Zero; + + if (!TryGetTrimmedString(value, out var trimmedValue)) + { + return false; + } + + var span = trimmedValue.AsSpan(); + var signIndex = FindEventTimingSignIndex(span); + var functionSegment = signIndex >= 0 + ? Trim(span.Slice(0, signIndex)) + : span; + + if (!TryReadFunctionArgument(functionSegment, "accessKey", out var argument) || + argument.Length == 0) + { + return false; + } + + if (argument.Length >= 2 && + ((argument[0] == '\'' && argument[argument.Length - 1] == '\'') || + (argument[0] == '"' && argument[argument.Length - 1] == '"'))) + { + argument = Trim(argument.Slice(1, argument.Length - 2)); + } + + if (argument.Length == 0) + { + return false; + } + + if (signIndex >= 0) + { + var sign = span[signIndex]; + var offsetText = Trim(span.Slice(signIndex + 1)); + if (offsetText.Length == 0 || !TryParseClockValue(offsetText, out offset)) + { + return false; + } + + if (sign == '-') + { + offset = -offset; + } + } + + accessKey = argument.ToString(); + return true; + } + + internal static bool TryParseWallclockTimingSpec(string value, out DateTimeOffset wallclockTime) + { + wallclockTime = default; + + if (!TryGetTrimmedString(value, out var trimmedValue) || + !TryReadFunctionArgument(trimmedValue.AsSpan(), "wallclock", out var argument) || + argument.Length == 0) + { + return false; + } + + return DateTimeOffset.TryParse( + argument.ToString(), + s_invariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out wallclockTime); + } + internal static bool TryParseMotionCoordinatePair(string value, SvgElement owner, out SKPoint point) { point = default; @@ -629,6 +700,22 @@ private static bool TryParseRepeatEventName(ReadOnlySpan eventName, out in return true; } + private static bool TryReadFunctionArgument(ReadOnlySpan value, string functionName, out ReadOnlySpan argument) + { + argument = default; + var trimmed = Trim(value); + if (trimmed.Length <= functionName.Length + 1 || + !EqualsAsciiIgnoreCase(trimmed.Slice(0, functionName.Length), functionName) || + trimmed[functionName.Length] != '(' || + trimmed[trimmed.Length - 1] != ')') + { + return false; + } + + argument = Trim(trimmed.Slice(functionName.Length + 1, trimmed.Length - functionName.Length - 2)); + 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 0766e771d5..f9c5c7401b 100644 --- a/src/Svg.Animation/Animation/SvgAnimationTimingEventType.cs +++ b/src/Svg.Animation/Animation/SvgAnimationTimingEventType.cs @@ -9,6 +9,7 @@ internal enum SvgAnimationTimingEventType Leave, Wheel, Click, + AccessKey, Begin, End, Repeat diff --git a/src/Svg.Custom/Compatibility/SvgElementFactory.cs b/src/Svg.Custom/Compatibility/SvgElementFactory.cs index a82544d3f7..a773224cea 100644 --- a/src/Svg.Custom/Compatibility/SvgElementFactory.cs +++ b/src/Svg.Custom/Compatibility/SvgElementFactory.cs @@ -176,6 +176,11 @@ private void SetAttributes(SvgElement element, XmlReader reader, SvgDocument doc element.SetJavaScriptDomAttributeValue(localName, reader.Value); } + if (ShouldIgnoreInvalidPresentationStyleAttribute(localName, reader.Value)) + { + continue; + } + if (PreserveCompatibilityPresentationAttributes) { PreserveCompatibilityPresentationAttribute(document, element, localName, reader.Value); @@ -197,7 +202,14 @@ private void SetAttributes(SvgElement element, XmlReader reader, SvgDocument doc element.SetJavaScriptDomAttributeValue(GetJavaScriptDomAttributeName(prefix, localName), reader.Value); } - SetPropertyValue(element, ns, localName, reader.Value, document); + if (CanBindAttributeNamespace(ns)) + { + SetPropertyValue(element, ns, localName, reader.Value, document); + } + else + { + element.CustomAttributes[$"{ns}:{localName}"] = reader.Value; + } } } } @@ -233,6 +245,14 @@ private static string GetJavaScriptDomAttributeName(string prefix, string localN return prefix.Length == 0 ? localName : $"{prefix}:{localName}"; } + private static bool CanBindAttributeNamespace(string ns) + { + return string.IsNullOrEmpty(ns) || + ns.Equals(SvgNamespaces.SvgNamespace, StringComparison.Ordinal) || + ns.Equals(SvgNamespaces.XLinkNamespace, StringComparison.Ordinal) || + ns.Equals(SvgNamespaces.XmlNamespace, StringComparison.Ordinal); + } + private static bool IsStyleAttribute(string name) { return SvgStyleAttributeNames.Contains(name) && @@ -279,6 +299,105 @@ private static bool IsNonInheritedOpacityAttribute(string name) return name == "opacity"; } + private static bool ShouldIgnoreInvalidPresentationStyleAttribute(string attributeName, string attributeValue) + { + return IsCaseSensitivePresentationLengthAttribute(attributeName) && + HasUppercaseLengthUnitIdentifier(attributeValue.AsSpan()); + } + + private static bool IsCaseSensitivePresentationLengthAttribute(string attributeName) + { + return attributeName is + "stroke-width" or + "font-size" or + "letter-spacing" or + "word-spacing" or + "baseline-shift" or + "kerning" or + "shape-padding" or + "shape-margin" or + "inline-size"; + } + + private static bool HasUppercaseLengthUnitIdentifier(ReadOnlySpan value) + { + value = TrimWhitespace(value); + if (value.Length == 0) + { + return false; + } + + var index = 0; + if (value[index] is '+' or '-') + { + index++; + } + + var sawNumber = false; + while (index < value.Length && char.IsDigit(value[index])) + { + sawNumber = true; + index++; + } + + if (index < value.Length && value[index] == '.') + { + index++; + while (index < value.Length && char.IsDigit(value[index])) + { + sawNumber = true; + index++; + } + } + + if (!sawNumber) + { + return false; + } + + if (index < value.Length && (value[index] is 'e' or 'E')) + { + var exponentIndex = index + 1; + if (exponentIndex < value.Length && value[exponentIndex] is '+' or '-') + { + exponentIndex++; + } + + var exponentDigitsStart = exponentIndex; + while (exponentIndex < value.Length && char.IsDigit(value[exponentIndex])) + { + exponentIndex++; + } + + if (exponentIndex > exponentDigitsStart) + { + index = exponentIndex; + } + } + + while (index < value.Length && char.IsWhiteSpace(value[index])) + { + index++; + } + + if (index >= value.Length || value[index] == '%') + { + return false; + } + + while (index < value.Length && char.IsLetter(value[index])) + { + if (value[index] is >= 'A' and <= 'Z') + { + return true; + } + + index++; + } + + return false; + } + private static ReadOnlySpan TrimWhitespace(ReadOnlySpan value) { #if NETSTANDARD20 diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptAnimatedValues.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptAnimatedValues.cs index c3d4650004..35ecbf09e9 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptAnimatedValues.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptAnimatedValues.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using Svg; namespace Svg.JavaScript; @@ -92,14 +93,14 @@ public string valueAsString public double value { - get => ParseNumber(GetRawValue()); - set => SetRawValue(SvgJavaScriptParsing.FormatNumber(value)); + get => ConvertSpecifiedValueToUserUnits(GetRawValue()); + set => SetUserUnitValue(value); } public double valueInSpecifiedUnits { get => ParseNumber(GetRawValue()); - set => SetRawValue(SvgJavaScriptParsing.FormatNumber(value)); + set => SetSpecifiedUnitValue(value); } public ushort unitType => ParseUnitType(GetRawValue()); @@ -119,7 +120,7 @@ private string GetRawValue() var rawValue = _element?.getAttribute(_attributeName); if (!string.IsNullOrEmpty(rawValue)) { - return rawValue; + return rawValue!; } return GetDefaultValue(); @@ -155,6 +156,243 @@ private void EnsureWritable() } } + private void SetUserUnitValue(double value) + { + var rawValue = GetRawValue(); + var suffix = GetUnitSuffix(rawValue); + var specifiedValue = ConvertUserUnitsToSpecifiedValue(value, suffix); + SetRawValue(SvgJavaScriptParsing.FormatNumber(specifiedValue) + suffix); + } + + private void SetSpecifiedUnitValue(double value) + { + var suffix = GetUnitSuffix(GetRawValue()); + SetRawValue(SvgJavaScriptParsing.FormatNumber(value) + suffix); + } + + private double ConvertSpecifiedValueToUserUnits(string rawValue) + { + var specifiedValue = ParseNumber(rawValue); + return GetUnitSuffix(rawValue) switch + { + "" or "px" => specifiedValue, + "%" => specifiedValue * GetPercentageReferenceLength() / 100d, + "em" => specifiedValue * GetFontSizeReferenceLength(), + "ex" => specifiedValue * GetFontSizeReferenceLength() * 0.5d, + "cm" => specifiedValue * 96d / 2.54d, + "mm" => specifiedValue * 96d / 25.4d, + "in" => specifiedValue * 96d, + "pt" => specifiedValue * 96d / 72d, + "pc" => specifiedValue * 16d, + _ => specifiedValue + }; + } + + private double ConvertUserUnitsToSpecifiedValue(double userUnitValue, string suffix) + { + return suffix switch + { + "" or "px" => userUnitValue, + "%" => ConvertUserUnitsToPercent(userUnitValue), + "em" => DivideByReference(userUnitValue, GetFontSizeReferenceLength()), + "ex" => DivideByReference(userUnitValue, GetFontSizeReferenceLength() * 0.5d), + "cm" => userUnitValue * 2.54d / 96d, + "mm" => userUnitValue * 25.4d / 96d, + "in" => userUnitValue / 96d, + "pt" => userUnitValue * 72d / 96d, + "pc" => userUnitValue / 16d, + _ => userUnitValue + }; + } + + private double ConvertUserUnitsToPercent(double userUnitValue) + { + var reference = GetPercentageReferenceLength(); + return reference > 0d ? userUnitValue * 100d / reference : userUnitValue; + } + + private static double DivideByReference(double value, double reference) + { + return reference > 0d ? value / reference : value; + } + + private double GetPercentageReferenceLength() + { + if (_element is null) + { + return 100d; + } + + var axis = GetLengthAxis(_attributeName); + var viewport = FindPercentageViewport(_element.Element); + if (viewport is null) + { + return axis switch + { + LengthAxis.Vertical => GetViewBoxDimension(_element.Element, LengthAxis.Vertical) ?? 100d, + LengthAxis.Other => GetViewBoxOtherDimension(_element.Element) ?? 100d, + _ => GetViewBoxDimension(_element.Element, LengthAxis.Horizontal) ?? 100d + }; + } + + var width = ResolveViewportDimension(viewport, LengthAxis.Horizontal, depth: 0); + if (axis == LengthAxis.Horizontal) + { + return width; + } + + var height = ResolveViewportDimension(viewport, LengthAxis.Vertical, depth: 0); + return axis == LengthAxis.Vertical + ? height + : Math.Sqrt((width * width) + (height * height)) / Math.Sqrt(2d); + } + + private double GetFontSizeReferenceLength() + { + if (_element is null) + { + return 12d; + } + + var rawFontSize = _element.GetComputedStyleProperty("font-size"); + if (string.IsNullOrWhiteSpace(rawFontSize)) + { + rawFontSize = _element.getAttribute("font-size"); + } + + if (string.IsNullOrWhiteSpace(rawFontSize)) + { + return 12d; + } + + var number = ParseNumber(rawFontSize); + return GetUnitSuffix(rawFontSize) switch + { + "" or "px" => number, + "%" => 12d * number / 100d, + "em" => 12d * number, + "ex" => 6d * number, + "cm" => number * 96d / 2.54d, + "mm" => number * 96d / 25.4d, + "in" => number * 96d, + "pt" => number * 96d / 72d, + "pc" => number * 16d, + _ => 12d + }; + } + + private static SvgElement? FindPercentageViewport(SvgElement element) + { + for (var parent = element.Parent; parent is not null; parent = parent.Parent) + { + if (parent is SvgFragment) + { + return parent; + } + } + + return element is SvgFragment ? element : null; + } + + private static double ResolveViewportDimension(SvgElement viewport, LengthAxis axis, int depth) + { + if (depth > 16) + { + return 100d; + } + + var attributeName = axis == LengthAxis.Vertical ? "height" : "width"; + var rawValue = GetRawElementLengthValue(viewport, attributeName); + if (string.IsNullOrWhiteSpace(rawValue)) + { + rawValue = viewport is SvgFragment ? "100%" : "100"; + } + + var specifiedValue = ParseNumber(rawValue); + return GetUnitSuffix(rawValue) switch + { + "" or "px" => specifiedValue, + "%" => ResolveViewportPercentage(viewport, axis, specifiedValue, depth), + "em" => specifiedValue * 12d, + "ex" => specifiedValue * 6d, + "cm" => specifiedValue * 96d / 2.54d, + "mm" => specifiedValue * 96d / 25.4d, + "in" => specifiedValue * 96d, + "pt" => specifiedValue * 96d / 72d, + "pc" => specifiedValue * 16d, + _ => specifiedValue + }; + } + + private static double ResolveViewportPercentage(SvgElement viewport, LengthAxis axis, double value, int depth) + { + var parentViewport = FindPercentageViewport(viewport); + if (parentViewport is not null && !ReferenceEquals(parentViewport, viewport)) + { + return ResolveViewportDimension(parentViewport, axis, depth + 1) * value / 100d; + } + + var viewBoxDimension = axis == LengthAxis.Vertical + ? GetViewBoxDimension(viewport, LengthAxis.Vertical) + : GetViewBoxDimension(viewport, LengthAxis.Horizontal); + return (viewBoxDimension ?? 100d) * value / 100d; + } + + private static double? GetViewBoxDimension(SvgElement element, LengthAxis axis) + { + return element is SvgFragment { ViewBox: var viewBox } && + viewBox != SvgViewBox.Empty && + viewBox.Width > 0f && + viewBox.Height > 0f + ? axis == LengthAxis.Vertical ? viewBox.Height : viewBox.Width + : null; + } + + private static double? GetViewBoxOtherDimension(SvgElement element) + { + if (element is not SvgFragment { ViewBox: var viewBox } || + viewBox == SvgViewBox.Empty || + viewBox.Width <= 0f || + viewBox.Height <= 0f) + { + return null; + } + + return Math.Sqrt((viewBox.Width * viewBox.Width) + (viewBox.Height * viewBox.Height)) / Math.Sqrt(2d); + } + + private static string GetRawElementLengthValue(SvgElement element, string name) + { + if (element.TryGetJavaScriptDomAttributeValue(name, out var scriptSetValue)) + { + return scriptSetValue; + } + + if (element.CustomAttributes.TryGetValue(name, out var customValue) && + !string.IsNullOrWhiteSpace(customValue)) + { + return customValue ?? string.Empty; + } + + if (element.TryGetAttribute(name, out var attributeValue) && + !string.IsNullOrWhiteSpace(attributeValue)) + { + return attributeValue ?? string.Empty; + } + + return element is SvgFragment && (name == "width" || name == "height") ? "100%" : string.Empty; + } + + private static LengthAxis GetLengthAxis(string attributeName) + { + return attributeName switch + { + "y" or "y1" or "y2" or "cy" or "height" or "dy" or "ry" => LengthAxis.Vertical, + "r" => LengthAxis.Other, + _ => LengthAxis.Horizontal + }; + } + private static bool TryNormalizeLength(string? value, out string normalized) { normalized = string.Empty; @@ -164,7 +402,7 @@ private static bool TryNormalizeLength(string? value, out string normalized) return true; } - var text = value.Trim(); + var text = value!.Trim(); var suffix = GetUnitSuffix(text); var numeric = suffix.Length == 0 ? text : text.Substring(0, text.Length - suffix.Length); if (!double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) @@ -220,7 +458,7 @@ private static double ParseNumber(string value) return 0d; } - var text = value.Trim(); + var text = value!.Trim(); var suffix = GetUnitSuffix(text); var numeric = suffix.Length == 0 ? text : text.Substring(0, text.Length - suffix.Length); return double.TryParse(numeric, NumberStyles.Float, CultureInfo.InvariantCulture, out var result) @@ -242,6 +480,13 @@ private string GetDefaultValue() return string.Empty; } + + private enum LengthAxis + { + Horizontal, + Vertical, + Other + } } public sealed class SvgJavaScriptAnimatedAngle @@ -315,7 +560,7 @@ private static bool TryNormalize(string? value, out string normalized) return true; } - var text = value.Trim(); + var text = value!.Trim(); var suffix = text.Length >= 4 && text.EndsWith("grad", StringComparison.OrdinalIgnoreCase) ? "grad" : text.Length >= 3 && text.EndsWith("deg", StringComparison.OrdinalIgnoreCase) diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptDocument.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptDocument.cs index 888f643650..dcfca120da 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptDocument.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptDocument.cs @@ -30,6 +30,31 @@ internal SvgJavaScriptDocument(SvgJavaScriptRuntime runtime, SvgDocument documen public SvgJavaScriptElement rootElement => documentElement; + public string nodeName => "#document"; + + public int nodeType => 9; + + public string? nodeValue + { + get => null; + set { } + } + + public SvgJavaScriptDocument? ownerDocument => null; + + public SvgJavaScriptElement? firstChild => documentElement; + + public SvgJavaScriptElement? lastChild => documentElement; + + public SvgJavaScriptNodeList childNodes => new(() => new object?[] { documentElement }); + + public SvgJavaScriptNodeList children => childNodes; + + public bool hasChildNodes() + { + return true; + } + public SvgJavaScriptElement? getElementById(string? id) { if (string.IsNullOrEmpty(id)) @@ -43,14 +68,13 @@ internal SvgJavaScriptDocument(SvgJavaScriptRuntime runtime, SvgDocument documen public SvgJavaScriptElement createElementNS(string? namespaceUri, string qualifiedName) { - _ = namespaceUri; if (qualifiedName is null) { throw new ArgumentNullException(nameof(qualifiedName)); } var localName = GetLocalName(qualifiedName); - return GetOrCreateElement(CreateSvgElement(localName)); + return GetOrCreateElement(CreateElement(namespaceUri, localName)); } public SvgJavaScriptElement createElement(string qualifiedName) @@ -69,17 +93,43 @@ public SvgJavaScriptEvent createEvent(string? eventInterface) return new SvgJavaScriptEvent(); } + public void clearTextSelection() + { + _runtime.ClearTextSelection(); + } + + public SvgJavaScriptTextSelection? getTextSelection() + { + return _runtime.GetTextSelection(null); + } + public SvgJavaScriptNodeList getElementsByTagName(string tagName) { if (tagName is null || tagName == "*") { - return new SvgJavaScriptNodeList(GetDocumentAndDescendants().Select(GetOrCreateElement).Cast()); + return new SvgJavaScriptNodeList(() => GetDocumentAndDescendants().Select(GetOrCreateElement).Cast()); } - return new SvgJavaScriptNodeList(GetDocumentAndDescendants() + return new SvgJavaScriptNodeList(() => GetDocumentAndDescendants() .Where(element => string.Equals(GetElementName(element), tagName, StringComparison.OrdinalIgnoreCase)) .Select(GetOrCreateElement) - .Cast()); + .Cast()); + } + + public SvgJavaScriptNodeList getElementsByTagNameNS(string? namespaceUri, string localName) + { + return new SvgJavaScriptNodeList(() => GetDocumentAndDescendants() + .Where(element => ElementMatchesNamespaceAndName(element, namespaceUri, localName)) + .Select(GetOrCreateElement) + .Cast()); + } + + public SvgJavaScriptNodeList getElementsByClassName(string classNames) + { + return new SvgJavaScriptNodeList(() => GetDocumentAndDescendants() + .Where(element => ElementMatchesClassNames(element, classNames)) + .Select(GetOrCreateElement) + .Cast()); } internal SvgJavaScriptRuntime Runtime => _runtime; @@ -188,11 +238,21 @@ internal static string GetElementName(SvgElement element) return "svg"; } + if (element is NonSvgElement nonSvgElement) + { + return nonSvgElement.Name; + } + if (element is SvgUnknownElement && element.CustomAttributes.TryGetValue("tagName", out var tagName)) { return tagName; } + if (!string.IsNullOrEmpty(element.ElementName)) + { + return element.ElementName; + } + return element switch { _ when SvgElements.ElementNames.TryGetValue(element.GetType(), out var elementName) => elementName, @@ -200,6 +260,39 @@ _ when SvgElements.ElementNames.TryGetValue(element.GetType(), out var elementNa }; } + internal static string GetElementNamespace(SvgElement element) + { + return string.IsNullOrEmpty(element.ElementNamespace) + ? SvgNamespace + : element.ElementNamespace; + } + + internal static bool ElementMatchesNamespaceAndName(SvgElement element, string? namespaceUri, string localName) + { + var namespaceMatches = namespaceUri == "*" || + string.Equals(GetElementNamespace(element), namespaceUri ?? string.Empty, StringComparison.Ordinal); + var nameMatches = localName == "*" || + string.Equals(GetElementName(element), localName, StringComparison.OrdinalIgnoreCase); + return namespaceMatches && nameMatches; + } + + internal static bool ElementMatchesClassNames(SvgElement element, string classNames) + { + var requiredClasses = ParseClassNames(classNames); + if (requiredClasses.Length == 0) + { + return false; + } + + if (!TryGetClassAttribute(element, out var classAttribute)) + { + return false; + } + + var actualClasses = ParseClassNames(classAttribute); + return requiredClasses.All(required => actualClasses.Contains(required, StringComparer.Ordinal)); + } + private static string GetLocalName(string qualifiedName) { var colonIndex = qualifiedName.IndexOf(':'); @@ -208,6 +301,17 @@ private static string GetLocalName(string qualifiedName) : qualifiedName; } + private static SvgElement CreateElement(string? namespaceUri, string localName) + { + if (string.IsNullOrEmpty(namespaceUri) || + string.Equals(namespaceUri, SvgNamespace, StringComparison.Ordinal)) + { + return CreateSvgElement(localName); + } + + return new NonSvgElement(localName, namespaceUri); + } + private static SvgElement CreateSvgElement(string localName) { if (s_elementFactories.Value.TryGetValue(localName, out var factory)) @@ -220,6 +324,43 @@ private static SvgElement CreateSvgElement(string localName) return unknown; } + private static string[] ParseClassNames(string? classNames) + { + if (string.IsNullOrWhiteSpace(classNames)) + { + return Array.Empty(); + } + + return classNames!.Split(new[] { ' ', '\t', '\r', '\n', '\f' }, StringSplitOptions.RemoveEmptyEntries); + } + + private static bool TryGetClassAttribute(SvgElement element, out string classAttribute) + { + if (element.TryGetJavaScriptDomAttributeValue("class", out var scriptClassAttribute) && + !string.IsNullOrWhiteSpace(scriptClassAttribute)) + { + classAttribute = scriptClassAttribute; + return true; + } + + if (element.TryGetAttribute("class", out var parsedClassAttribute) && + !string.IsNullOrWhiteSpace(parsedClassAttribute)) + { + classAttribute = parsedClassAttribute; + return true; + } + + if (element.CustomAttributes.TryGetValue("class", out var customClassAttribute) && + !string.IsNullOrWhiteSpace(customClassAttribute)) + { + classAttribute = customClassAttribute!; + return true; + } + + classAttribute = string.Empty; + return false; + } + private static Dictionary> CreateElementFactories() { var factories = new Dictionary>(StringComparer.Ordinal); diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptDom.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptDom.cs index d906fa4134..aaec962582 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptDom.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptDom.cs @@ -34,23 +34,137 @@ public interface ISvgJavaScriptTextContentHost void SelectSubString(SvgTextBase textContentElement, int charnum, int nchars); } +public interface ISvgJavaScriptTextSelectionHost +{ + bool BeginTextSelection(SvgTextBase textContentElement, int anchorCharnum); + bool ExtendTextSelection(SvgTextBase textContentElement, int focusCharnum); + bool SelectTextRange(SvgTextBase textContentElement, int anchorCharnum, int focusCharnum); + void ClearTextSelection(); + SvgJavaScriptTextSelection? GetTextSelection(SvgTextBase? textContentElement); +} + +public interface ISvgJavaScriptViewerHost +{ + double CurrentScale { get; set; } + + float CurrentTranslateX { get; set; } + + float CurrentTranslateY { get; set; } +} + public sealed class SvgJavaScriptNodeList { - private readonly object?[] _items; + private readonly object?[]? _items; + private readonly Func? _liveItems; public SvgJavaScriptNodeList(IEnumerable items) { _items = items is object?[] array ? array : new List(items).ToArray(); } - public int length => _items.Length; + internal SvgJavaScriptNodeList(Func> liveItems) + { + _liveItems = () => new List(liveItems()).ToArray(); + } + + public int length => GetItems().Length; public object? item(int index) { - return index >= 0 && index < _items.Length ? _items[index] : null; + var items = GetItems(); + return index >= 0 && index < items.Length ? items[index] : null; } public object? this[int index] => item(index); + + private object?[] GetItems() + { + return _liveItems?.Invoke() ?? _items ?? Array.Empty(); + } +} + +public sealed class SvgJavaScriptRectList +{ + private readonly SvgJavaScriptRect[] _items; + + public SvgJavaScriptRectList(IEnumerable items) + { + _items = items is SvgJavaScriptRect[] array ? array : new List(items).ToArray(); + } + + public int length => _items.Length; + + public SvgJavaScriptRect? item(int index) + { + return index >= 0 && index < _items.Length ? _items[index] : null; + } + + public SvgJavaScriptRect? this[int index] => item(index); +} + +public sealed class SvgJavaScriptTextSelection +{ + public SvgJavaScriptTextSelection( + string? elementId, + int charnum, + int nchars, + int startCharnum, + int endCharnum, + int selectedNChars, + int anchorCharnum, + int focusCharnum, + string direction, + bool hasCaret, + SvgJavaScriptPoint caretPosition, + SvgJavaScriptRect caretExtent, + IEnumerable extents, + IEnumerable visualExtents) + { + this.elementId = elementId; + this.charnum = charnum; + this.nchars = nchars; + this.startCharnum = startCharnum; + this.endCharnum = endCharnum; + this.selectedNChars = selectedNChars; + this.anchorCharnum = anchorCharnum; + this.focusCharnum = focusCharnum; + this.direction = direction; + this.hasCaret = hasCaret; + this.caretPosition = caretPosition; + this.caretExtent = caretExtent; + this.extents = new SvgJavaScriptRectList(extents); + this.visualExtents = new SvgJavaScriptRectList(visualExtents); + } + + public string? elementId { get; } + + public int charnum { get; } + + public int nchars { get; } + + public int startCharnum { get; } + + public int endCharnum { get; } + + public int selectedNChars { get; } + + public int anchorCharnum { get; } + + public int focusCharnum { get; } + + public string direction { get; } + + public bool hasCaret { get; } + + public bool isCollapsed => selectedNChars == 0 && hasCaret; + + public SvgJavaScriptPoint caretPosition { get; } + + public SvgJavaScriptRect caretExtent { get; } + + public SvgJavaScriptRectList extents { get; } + + public SvgJavaScriptRectList visualExtents { get; } } public sealed class SvgJavaScriptDomImplementation @@ -93,7 +207,7 @@ public bool hasFeature(string? feature, string? version) return false; } - return s_supportedFeatures.Contains(feature.Trim()); + return s_supportedFeatures.Contains(feature!.Trim()); } } @@ -449,7 +563,7 @@ public static string[] ParseTokenList(string? value) { return string.IsNullOrWhiteSpace(value) ? Array.Empty() - : value.Split(s_whitespaceSeparators, StringSplitOptions.RemoveEmptyEntries); + : value!.Split(s_whitespaceSeparators, StringSplitOptions.RemoveEmptyEntries); } public static bool TryParseFloat(string? value, out float result) diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptElement.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptElement.cs index 7dda5b0128..4c472247b4 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptElement.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptElement.cs @@ -15,6 +15,9 @@ public sealed partial class SvgJavaScriptElement private readonly SvgJavaScriptRuntime _runtime; private readonly SvgJavaScriptDocument _document; private readonly Dictionary _inlineStyleFallbacks = new(StringComparer.OrdinalIgnoreCase); + private SvgJavaScriptPoint? _currentTranslate; + private SvgJavaScriptPoint? _hostCurrentTranslate; + private double _currentScale = 1d; internal SvgJavaScriptElement(SvgJavaScriptRuntime runtime, SvgJavaScriptDocument document, SvgElement element) { @@ -32,6 +35,10 @@ internal SvgJavaScriptElement(SvgJavaScriptRuntime runtime, SvgJavaScriptDocumen public int nodeType => 1; + public string localName => tagName; + + public string namespaceURI => SvgJavaScriptDocument.GetElementNamespace(Element); + public string? nodeValue { get => null; @@ -60,19 +67,63 @@ public string id public SvgJavaScriptElement? farthestViewportElement => FindViewportElement(outermost: true); + public double currentScale + { + get => TryGetViewerHost(out var viewerHost) ? viewerHost.CurrentScale : _currentScale; + set + { + if (!double.IsNaN(value) && !double.IsInfinity(value) && value > 0d) + { + if (TryGetViewerHost(out var viewerHost)) + { + viewerHost.CurrentScale = value; + } + else + { + _currentScale = value; + } + } + } + } + + public SvgJavaScriptPoint currentTranslate + { + get + { + if (TryGetViewerHost(out var viewerHost)) + { + return _hostCurrentTranslate ??= new SvgJavaScriptPoint( + _runtime, + () => new SvgJavaScriptPoint.SvgJavaScriptPointState( + viewerHost.CurrentTranslateX, + viewerHost.CurrentTranslateY), + state => + { + viewerHost.CurrentTranslateX = state.X; + viewerHost.CurrentTranslateY = state.Y; + }, + readOnly: false); + } + + return _currentTranslate ??= new SvgJavaScriptPoint(); + } + } + public SvgJavaScriptElementInstance? instanceRoot => Element is SvgUse use ? _runtime.GetUseInstanceRoot(use) : null; public SvgJavaScriptStyleDeclaration style { get; } public object? firstChild => _document.WrapNode(GetNodes().FirstOrDefault(), Element); + public object? lastChild => _document.WrapNode(GetNodes().LastOrDefault(), Element); + public object? nextSibling => GetSibling(1); public object? previousSibling => GetSibling(-1); - public SvgJavaScriptNodeList childNodes => new(GetNodes().Select(node => _document.WrapNode(node, Element))); + public SvgJavaScriptNodeList childNodes => new(() => GetNodes().Select(node => _document.WrapNode(node, Element))); - public SvgJavaScriptNodeList children => new(Element.Children.Select(child => (object)_document.GetOrCreateElement(child))); + public SvgJavaScriptNodeList children => new(() => Element.Children.Select(child => (object?)_document.GetOrCreateElement(child))); public string textContent { @@ -320,6 +371,11 @@ public bool hasAttributeNS(string? namespaceUri, string localName) return hasAttribute(QualifyAttributeName(namespaceUri, localName)); } + public bool hasChildNodes() + { + return GetNodes().Count > 0; + } + public SvgJavaScriptElement? getElementById(string? id) { if (id is not { Length: > 0 } elementId) @@ -330,6 +386,44 @@ public bool hasAttributeNS(string? namespaceUri, string localName) return _document.GetElementByIdWithinSubtree(Element, elementId); } + public SvgJavaScriptNodeList getElementsByTagName(string tagName) + { + if (tagName is null || tagName == "*") + { + return new SvgJavaScriptNodeList(() => Element.Descendants() + .Where(element => !ReferenceEquals(element, Element)) + .Select(_document.GetOrCreateElement) + .Cast()); + } + + return new SvgJavaScriptNodeList(() => Element.Descendants() + .Where(element => + !ReferenceEquals(element, Element) && + string.Equals(SvgJavaScriptDocument.GetElementName(element), tagName, StringComparison.OrdinalIgnoreCase)) + .Select(_document.GetOrCreateElement) + .Cast()); + } + + public SvgJavaScriptNodeList getElementsByTagNameNS(string? namespaceUri, string localName) + { + return new SvgJavaScriptNodeList(() => Element.Descendants() + .Where(element => + !ReferenceEquals(element, Element) && + SvgJavaScriptDocument.ElementMatchesNamespaceAndName(element, namespaceUri, localName)) + .Select(_document.GetOrCreateElement) + .Cast()); + } + + public SvgJavaScriptNodeList getElementsByClassName(string classNames) + { + return new SvgJavaScriptNodeList(() => Element.Descendants() + .Where(element => + !ReferenceEquals(element, Element) && + SvgJavaScriptDocument.ElementMatchesClassNames(element, classNames)) + .Select(_document.GetOrCreateElement) + .Cast()); + } + public object appendChild(object child) { return insertBefore(child, null); @@ -411,6 +505,18 @@ public bool dispatchEvent(SvgJavaScriptEvent evt) return _runtime.DispatchEvent(this, evt); } + public JsValue focus() + { + _runtime.FocusElement(Element); + return JsValue.Undefined; + } + + public JsValue blur() + { + _runtime.BlurElement(Element); + return JsValue.Undefined; + } + public SvgJavaScriptMatrix? getCTM() { return TryGetElementMatrix(Element, out var matrix) ? new SvgJavaScriptMatrix(matrix) : null; @@ -519,6 +625,32 @@ public JsValue selectSubString(int charnum, int nchars) return JsValue.Undefined; } + public bool beginTextSelection(int charnum) + { + return _runtime.BeginTextSelection(RequireTextContentElement(), charnum); + } + + public bool extendTextSelection(int charnum) + { + return _runtime.ExtendTextSelection(RequireTextContentElement(), charnum); + } + + public bool selectTextRange(int anchorCharnum, int focusCharnum) + { + return _runtime.SelectTextRange(RequireTextContentElement(), anchorCharnum, focusCharnum); + } + + public JsValue clearTextSelection() + { + _runtime.ClearTextSelection(); + return JsValue.Undefined; + } + + public SvgJavaScriptTextSelection? getTextSelection() + { + return _runtime.GetTextSelection(RequireTextContentElement()); + } + public JsValue beginElement() { BeginTimedElement(TimeSpan.Zero); @@ -674,58 +806,116 @@ public void forceRedraw() internal string GetComputedStyleProperty(string name) { + name = NormalizeCssPropertyName(name); + if (name.Length == 0) + { + return string.Empty; + } + + var shouldInherit = IsInheritedComputedStyleProperty(name); + var forceInheritance = false; for (SvgElement? current = Element; current is not null; current = current.Parent) { - if (TryGetInlineStyleProperty(current, name, out var inlineValue)) + if (TryGetComputedStylePropertyOnElement(current, name, out var value)) { - if (!string.Equals(inlineValue, "inherit", StringComparison.OrdinalIgnoreCase)) + if (IsCssInheritValue(value)) { - return inlineValue; + forceInheritance = true; + continue; } + + return NormalizeComputedStyleValue(name, value); } - if (SvgCssVariableResolver.TryGetCustomPropertyValue(current, name, out var customPropertyValue) && - !string.IsNullOrWhiteSpace(customPropertyValue)) + if (!shouldInherit && !forceInheritance) { - return customPropertyValue; + break; } + } + + return GetInitialComputedStyleValue(name); + } - if (current.CustomAttributes.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) + internal IReadOnlyList GetComputedStylePropertyNames() + { + var names = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (SvgElement? current = Element; current is not null; current = current.Parent) + { + foreach (var name in GetStylePropertyNames(current, includePresentationAttributes: true)) { - if (!string.Equals(value, "inherit", StringComparison.OrdinalIgnoreCase)) + if ((ReferenceEquals(current, Element) || IsInheritedComputedStyleProperty(name)) && + seen.Add(name)) { - return value; + names.Add(name); } } + } - if (current.TryGetAttribute(name, out value) && !string.IsNullOrWhiteSpace(value)) + foreach (var name in SvgStyleAttributeNames.All) + { + if (GetComputedStyleProperty(name).Length > 0 && seen.Add(name)) { - if (!string.Equals(value, "inherit", StringComparison.OrdinalIgnoreCase)) - { - return NormalizeComputedStyleValue(name, value); - } + names.Add(name); } } - return string.Empty; + return names; + } + + internal string GetStyleCssText() + { + return SerializeInlineStyle(ParseInlineStyle(GetRawStyleText(Element))); + } + + internal void SetStyleCssText(object? value) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + SetInlineStyleText(SerializeInlineStyle(ParseInlineStyle(text)), syncJavaScriptDomAttribute: true); + _runtime.MarkMutation(); } internal string GetStyleProperty(string name) { - return TryGetInlineStyleProperty(Element, name, out var value) - ? value + return TryGetInlineStyleProperty(Element, NormalizeCssPropertyName(name), out var value) + ? StripCssPriority(value) + : string.Empty; + } + + internal string GetStylePropertyPriority(string name) + { + return TryGetInlineStyleProperty(Element, NormalizeCssPropertyName(name), out var value) && + TrySplitCssPriority(value, out _, out var priority) + ? priority : string.Empty; } - internal void SetStyleProperty(string name, object? value) + internal IReadOnlyList GetStylePropertyNames() + { + return GetStylePropertyNames(Element, includePresentationAttributes: false); + } + + internal void SetStyleProperty(string name, object? value, object? priority) { if (string.IsNullOrWhiteSpace(name)) { return; } - var normalizedName = name.Trim(); + var normalizedName = NormalizeCssPropertyName(name); + if (normalizedName.Length == 0) + { + return; + } + var text = Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + var priorityText = Convert.ToString(priority, CultureInfo.InvariantCulture) ?? string.Empty; + if (priorityText.Equals("important", StringComparison.OrdinalIgnoreCase) && + !EndsWithImportantPriority(text)) + { + text = string.Concat(text.TrimEnd(), " !important"); + } + var declarations = ParseInlineStyle(GetRawStyleText(Element)); declarations[normalizedName] = text; SetInlineStyleText(SerializeInlineStyle(declarations), declarations); @@ -739,7 +929,12 @@ internal string RemoveStyleProperty(string name) return string.Empty; } - var normalizedName = name.Trim(); + var normalizedName = NormalizeCssPropertyName(name); + if (normalizedName.Length == 0) + { + return string.Empty; + } + var declarations = ParseInlineStyle(GetRawStyleText(Element)); var hadProperty = declarations.TryGetValue(normalizedName, out var previous); previous ??= string.Empty; @@ -750,7 +945,7 @@ internal string RemoveStyleProperty(string name) declarations, HasRawStyleAttribute() || hadProperty); _runtime.MarkMutation(); - return previous; + return StripCssPriority(previous); } private void InsertElement(SvgElement childElement, object? referenceChild) @@ -933,13 +1128,152 @@ private static string NormalizeComputedStyleValue(string name, string value) return string.Empty; } + value = StripCssPriority(value); return name switch { + "color" or + "fill" or + "flood-color" or + "lighting-color" or + "stop-color" or + "stroke" when IsAsciiKeyword(value) => value.ToLowerInvariant(), + "fill-opacity" or + "flood-opacity" or + "opacity" or + "stop-opacity" or + "stroke-miterlimit" or + "stroke-opacity" => NormalizeNumericStyleValue(value), "font-variant" when string.Equals(value, "SmallCaps", StringComparison.Ordinal) => "small-caps", _ => value }; } + private static bool IsAsciiKeyword(string value) + { + if (value.Length == 0) + { + return false; + } + + for (var i = 0; i < value.Length; i++) + { + var character = value[i]; + if (character is not ((>= 'A' and <= 'Z') or (>= 'a' and <= 'z'))) + { + return false; + } + } + + return true; + } + + private static string NormalizeNumericStyleValue(string value) + { + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var invariantValue) || + float.TryParse(value, NumberStyles.Float, CultureInfo.CurrentCulture, out invariantValue)) + { + return invariantValue.ToString("R", CultureInfo.InvariantCulture); + } + + return value; + } + + private static bool IsInheritedComputedStyleProperty(string name) + { + name = NormalizeCssPropertyName(name); + return name.StartsWith("--", StringComparison.Ordinal) || + name is "alignment-baseline" or + "clip-rule" or + "color" or + "color-interpolation" or + "color-interpolation-filters" or + "color-rendering" or + "cursor" or + "direction" or + "dominant-baseline" or + "fill" or + "fill-opacity" or + "fill-rule" or + "font" or + "font-family" or + "font-feature-settings" or + "font-kerning" or + "font-size" or + "font-size-adjust" or + "font-stretch" or + "font-style" or + "font-variant" or + "font-variant-ligatures" or + "font-weight" or + "glyph-orientation-horizontal" or + "glyph-orientation-vertical" or + "image-rendering" or + "kerning" or + "letter-spacing" or + "line-break" or + "line-height" or + "overflow-wrap" or + "paint-order" or + "pointer-events" or + "shape-rendering" or + "stroke" or + "stroke-dasharray" or + "stroke-dashoffset" or + "stroke-linecap" or + "stroke-linejoin" or + "stroke-miterlimit" or + "stroke-opacity" or + "stroke-width" or + "text-anchor" or + "text-decoration" or + "text-overflow" or + "text-rendering" or + "text-transform" or + "unicode-bidi" or + "vector-effect" or + "visibility" or + "white-space" or + "white-space-collapse" or + "white-space-trim" or + "word-break" or + "word-spacing" or + "writing-mode"; + } + + private static string GetInitialComputedStyleValue(string name) + { + return NormalizeCssPropertyName(name) switch + { + "clip-rule" => "nonzero", + "color" => "black", + "color-interpolation" => "sRGB", + "color-interpolation-filters" => "linearRGB", + "direction" => "ltr", + "display" => "inline", + "fill" => "black", + "fill-opacity" => "1", + "fill-rule" => "nonzero", + "font-size" => "medium", + "font-style" => "normal", + "font-variant" => "normal", + "font-weight" => "normal", + "opacity" => "1", + "overflow" => "visible", + "pointer-events" => "visiblePainted", + "stroke" => "none", + "stroke-linecap" => "butt", + "stroke-linejoin" => "miter", + "stroke-miterlimit" => "4", + "stroke-opacity" => "1", + "stroke-width" => "1", + "text-anchor" => "start", + "text-decoration" => "none", + "visibility" => "visible", + "writing-mode" => "horizontal-tb", + _ => string.Empty + }; + } + private object? GetSibling(int offset) { var parent = Element.Parent; @@ -970,6 +1304,19 @@ private IReadOnlyList GetNodes() return _document.GetDomNodes(Element); } + private bool TryGetViewerHost(out ISvgJavaScriptViewerHost viewerHost) + { + if ((Element is SvgDocument || (Element is SvgFragment && Element.Parent is null)) && + _runtime.ViewerHost is { } host) + { + viewerHost = host; + return true; + } + + viewerHost = null!; + return false; + } + private SvgJavaScriptElement? FindOwnerSvgElement() { for (var parent = Element.Parent; parent is not null; parent = parent.Parent) @@ -1516,6 +1863,7 @@ private void RestoreInlineStyleFallback(string propertyName) private bool UpdateInlineStyleFallback(string name, string? value) { + name = NormalizeCssPropertyName(name); if (!TryGetInlineStyleProperty(Element, name, out _)) { return false; @@ -1534,7 +1882,7 @@ private static bool TryGetInlineStyleProperty(SvgElement element, string name, o } var declarations = ParseInlineStyle(GetRawStyleText(element)); - if (!declarations.TryGetValue(name.Trim(), out var inlineValue) || string.IsNullOrWhiteSpace(inlineValue)) + if (!declarations.TryGetValue(NormalizeCssPropertyName(name), out var inlineValue) || string.IsNullOrWhiteSpace(inlineValue)) { return false; } @@ -1543,6 +1891,54 @@ private static bool TryGetInlineStyleProperty(SvgElement element, string name, o return true; } + private static bool TryGetComputedStylePropertyOnElement(SvgElement element, string name, out string value) + { + if (TryGetInlineStyleProperty(element, name, out value)) + { + return true; + } + + if (SvgCssVariableResolver.TryGetCustomPropertyValue(element, name, out value) && + !string.IsNullOrWhiteSpace(value)) + { + return true; + } + + if (TryGetRawStyleAttributeValue(element, name, out value)) + { + return true; + } + + if (element.TryGetAttribute(name, out value) && !string.IsNullOrWhiteSpace(value)) + { + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryGetRawStyleAttributeValue(SvgElement element, string name, out string value) + { + if (element.TryGetJavaScriptDomAttributeValue(name, out value) && !string.IsNullOrWhiteSpace(value)) + { + return true; + } + + foreach (var attribute in element.CustomAttributes) + { + if (attribute.Value is not null && + NormalizeCssPropertyName(attribute.Key).Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = attribute.Value; + return true; + } + } + + value = string.Empty; + return false; + } + private string GetOrientValue() { if (TryGetRawAttributeValue("orient", out var rawValue)) @@ -1780,6 +2176,7 @@ private static Dictionary ParseInlineStyle(string? styleText) } var propertyName = declaration.Substring(0, separatorIndex).Trim(); + propertyName = NormalizeCssPropertyName(propertyName); if (propertyName.Length == 0) { continue; @@ -1795,7 +2192,113 @@ private static string SerializeInlineStyle(Dictionary declaratio { return string.Join("; ", declarations .Where(pair => !string.IsNullOrWhiteSpace(pair.Key)) - .Select(pair => string.Concat(pair.Key, ": ", pair.Value))); + .Select(pair => string.Concat(NormalizeCssPropertyName(pair.Key), ": ", pair.Value))); + } + + private static IReadOnlyList GetStylePropertyNames(SvgElement element, bool includePresentationAttributes) + { + var names = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var name in ParseInlineStyle(GetRawStyleText(element)).Keys) + { + if (seen.Add(name)) + { + names.Add(name); + } + } + + if (!includePresentationAttributes) + { + return names; + } + + foreach (var attribute in element.CustomAttributes.Keys) + { + var name = NormalizeCssPropertyName(attribute); + if (SvgStyleAttributeNames.Contains(name) && seen.Add(name)) + { + names.Add(name); + } + } + + foreach (var attribute in element.Attributes.Keys) + { + var name = NormalizeCssPropertyName(attribute); + if (SvgStyleAttributeNames.Contains(name) && seen.Add(name)) + { + names.Add(name); + } + } + + return names; + } + + private static string NormalizeCssPropertyName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + var text = name!.Trim(); + if (text.StartsWith("--", StringComparison.Ordinal)) + { + return text; + } + + text = text.Replace('_', '-'); + var builder = new System.Text.StringBuilder(text.Length + 4); + for (var i = 0; i < text.Length; i++) + { + var character = text[i]; + if (character is >= 'A' and <= 'Z') + { + if (builder.Length > 0 && builder[builder.Length - 1] != '-') + { + builder.Append('-'); + } + + builder.Append(char.ToLowerInvariant(character)); + continue; + } + + builder.Append(character); + } + + return builder.ToString(); + } + + private static bool IsCssInheritValue(string value) + { + return value.Trim().Equals("inherit", StringComparison.OrdinalIgnoreCase); + } + + private static string StripCssPriority(string value) + { + return TrySplitCssPriority(value, out var propertyValue, out _) + ? propertyValue + : value; + } + + private static bool EndsWithImportantPriority(string value) + { + return TrySplitCssPriority(value, out _, out _); + } + + private static bool TrySplitCssPriority(string value, out string propertyValue, out string priority) + { + propertyValue = value; + priority = string.Empty; + var trimmed = value.Trim(); + const string important = "!important"; + if (!trimmed.EndsWith(important, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + propertyValue = trimmed.Substring(0, trimmed.Length - important.Length).TrimEnd(); + priority = "important"; + return true; } private static string QualifyAttributeName(string? namespaceUri, string localName) @@ -1805,8 +2308,24 @@ private static string QualifyAttributeName(string? namespaceUri, string localNam return localName; } - return string.Equals(namespaceUri, SvgNamespaces.XLinkNamespace, StringComparison.Ordinal) - ? $"xlink:{localName}" + var unqualifiedName = GetQualifiedLocalName(localName); + if (string.Equals(namespaceUri, SvgNamespaces.XLinkNamespace, StringComparison.Ordinal)) + { + return string.Equals(unqualifiedName, "href", StringComparison.OrdinalIgnoreCase) + ? "href" + : $"xlink:{unqualifiedName}"; + } + + return string.Equals(namespaceUri, SvgNamespaces.SvgNamespace, StringComparison.Ordinal) + ? unqualifiedName + : string.Concat("{", namespaceUri, "}", localName); + } + + private static string GetQualifiedLocalName(string localName) + { + var colonIndex = localName.IndexOf(':'); + return colonIndex >= 0 && colonIndex + 1 < localName.Length + ? localName.Substring(colonIndex + 1) : localName; } diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptEvent.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptEvent.cs index 02f4e4f277..2206a60cd0 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptEvent.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptEvent.cs @@ -27,8 +27,8 @@ internal SvgJavaScriptEvent( altKey = input?.AltKey ?? false; shiftKey = input?.ShiftKey ?? false; ctrlKey = input?.CtrlKey ?? false; - bubbles = true; - cancelable = true; + bubbles = GetDefaultBubbles(type); + cancelable = GetDefaultCancelable(type); } public string type { get; private set; } = string.Empty; @@ -161,4 +161,21 @@ private static int ToJavaScriptButton(SvgJavaScriptMouseButton button) _ => 0 }; } + + private static bool GetDefaultBubbles(string? eventType) + { + var normalizedType = NormalizeEventType(eventType); + return normalizedType is not "load" and not "svgload" and not "unload" and not "svgunload" and not "focus" and not "blur"; + } + + private static bool GetDefaultCancelable(string? eventType) + { + var normalizedType = NormalizeEventType(eventType); + return normalizedType is not "load" and not "svgload" and not "focus" and not "blur" and not "focusin" and not "focusout"; + } + + private static string NormalizeEventType(string? eventType) + { + return eventType?.Trim().ToLowerInvariant() ?? string.Empty; + } } diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptInteractionHost.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptInteractionHost.cs new file mode 100644 index 0000000000..d8fd10acc4 --- /dev/null +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptInteractionHost.cs @@ -0,0 +1,684 @@ +using System; +using System.Collections.Generic; +using ShimSkiaSharp; +using Svg; +using Svg.Skia; + +namespace Svg.JavaScript; + +public sealed class SvgJavaScriptInteractionEventResult +{ + internal SvgJavaScriptInteractionEventResult( + string eventType, + object? targetNode, + object? relatedTargetNode, + bool dispatched, + bool allowedDefault, + bool mutated, + bool cancelBubble, + bool defaultPrevented) + { + EventType = eventType; + TargetNode = targetNode; + RelatedTargetNode = relatedTargetNode; + Dispatched = dispatched; + AllowedDefault = allowedDefault; + Mutated = mutated; + CancelBubble = cancelBubble; + DefaultPrevented = defaultPrevented; + } + + public string EventType { get; } + + public object? TargetNode { get; } + + public object? RelatedTargetNode { get; } + + public bool Dispatched { get; } + + public bool AllowedDefault { get; } + + public bool Mutated { get; } + + public bool CancelBubble { get; } + + public bool DefaultPrevented { get; } +} + +public sealed class SvgJavaScriptInteractionDispatchResult +{ + internal SvgJavaScriptInteractionDispatchResult( + SvgJavaScriptElement? targetElement, + SvgJavaScriptElement? focusedElement, + IReadOnlyList events, + bool defaultActionActivated) + { + TargetElement = targetElement; + FocusedElement = focusedElement; + Events = events; + DefaultActionActivated = defaultActionActivated; + } + + public SvgJavaScriptElement? TargetElement { get; } + + public SvgJavaScriptElement? FocusedElement { get; } + + public IReadOnlyList Events { get; } + + public bool DefaultActionActivated { get; } + + public bool Dispatched + { + get + { + for (var i = 0; i < Events.Count; i++) + { + if (Events[i].Dispatched) + { + return true; + } + } + + return false; + } + } + + public bool Mutated + { + get + { + for (var i = 0; i < Events.Count; i++) + { + if (Events[i].Mutated) + { + return true; + } + } + + return false; + } + } + + public bool DefaultPrevented + { + get + { + for (var i = 0; i < Events.Count; i++) + { + if (Events[i].DefaultPrevented) + { + return true; + } + } + + return false; + } + } + + public bool CancelBubble + { + get + { + for (var i = 0; i < Events.Count; i++) + { + if (Events[i].CancelBubble) + { + return true; + } + } + + return false; + } + } +} + +public sealed class SvgJavaScriptInteractionHost +{ + private readonly SvgJavaScriptRuntime _runtime; + private SvgElement? _hoveredElement; + private SvgElement? _pressedElement; + private SvgElement? _capturedElement; + private SvgElement? _focusedElement; + + public SvgJavaScriptInteractionHost(SvgJavaScriptRuntime runtime) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + } + + public SvgJavaScriptElement? HoveredElement => WrapElement(_hoveredElement); + + public SvgJavaScriptElement? PressedElement => WrapElement(_pressedElement); + + public SvgJavaScriptElement? CapturedElement => WrapElement(_capturedElement); + + public SvgJavaScriptElement? FocusedElement => WrapElement(_focusedElement); + + public SvgJavaScriptElement? HitTest(float x, float y) + { + return WrapElement(HitTestElement(new SKPoint(x, y))); + } + + public SvgJavaScriptInteractionDispatchResult DispatchPointerMoved(SvgJavaScriptEventInput input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var events = new List(); + var hitTarget = HitTestElement(input); + var routeTarget = _capturedElement ?? hitTarget; + + if (_capturedElement is null) + { + DispatchHoverTransition(hitTarget, input, events); + } + + DispatchPointerAndMouseEvents(routeTarget, null, input, events, "pointermove", "mousemove"); + return CreateResult(_hoveredElement ?? routeTarget, events); + } + + public SvgJavaScriptInteractionDispatchResult DispatchPointerPressed(SvgJavaScriptEventInput input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var events = new List(); + var target = HitTestElement(input); + + DispatchHoverTransition(target, input, events); + _pressedElement = target; + _capturedElement = target; + DispatchPointerAndMouseEvents(target, null, input, events, "pointerdown", "mousedown"); + + var defaultActionActivated = false; + if (!HasDefaultPrevented(events)) + { + defaultActionActivated = ApplyFocusDefaultAction(target, input, events); + } + + return CreateResult(_hoveredElement ?? target, events, defaultActionActivated); + } + + public SvgJavaScriptInteractionDispatchResult DispatchPointerReleased(SvgJavaScriptEventInput input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var events = new List(); + var hitTarget = HitTestElement(input); + var routeTarget = _capturedElement ?? hitTarget; + var captureWasActive = _capturedElement is not null; + + if (_capturedElement is null) + { + DispatchHoverTransition(hitTarget, input, events); + } + + DispatchPointerAndMouseEvents(routeTarget, null, input, events, "pointerup", "mouseup"); + + if (routeTarget is not null && ReferenceEquals(hitTarget, _pressedElement)) + { + events.Add(DispatchEvent("click", routeTarget, null, input)); + } + + _pressedElement = null; + _capturedElement = null; + + if (captureWasActive) + { + DispatchHoverTransition(hitTarget, input, events); + } + + return CreateResult(_hoveredElement ?? hitTarget, events); + } + + public SvgJavaScriptInteractionDispatchResult DispatchPointerExited(SvgJavaScriptEventInput input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var events = new List(); + if (_capturedElement is null && _hoveredElement is { } previous) + { + DispatchPointerAndMouseEvents(previous, null, input, events, "pointerout", "mouseout"); + _hoveredElement = null; + } + + return CreateResult(null, events); + } + + public SvgJavaScriptInteractionDispatchResult DispatchMouseEventAt(string eventType, SvgJavaScriptEventInput input) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var target = HitTestElement(input); + var events = new List + { + DispatchEvent(eventType, target, null, input) + }; + return CreateResult(target, events); + } + + public SvgJavaScriptInteractionDispatchResult DispatchMouseEvent( + string eventType, + SvgJavaScriptElement targetElement, + SvgJavaScriptElement? relatedTargetElement, + SvgJavaScriptEventInput input) + { + if (targetElement is null) + { + throw new ArgumentNullException(nameof(targetElement)); + } + + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + var events = new List + { + DispatchEvent(eventType, targetElement.Element, relatedTargetElement?.Element, input) + }; + return CreateResult(targetElement.Element, events); + } + + public void Reset() + { + _hoveredElement = null; + _pressedElement = null; + _capturedElement = null; + _focusedElement = null; + } + + public bool Focus(SvgJavaScriptElement? element) + { + return Focus(element, null); + } + + public bool Focus(SvgJavaScriptElement? element, SvgJavaScriptEventInput? input) + { + return SetFocusedElement(element?.Element, input, null); + } + + public bool Blur(SvgJavaScriptElement? element) + { + if (element is null || !ReferenceEquals(_focusedElement, element.Element)) + { + return false; + } + + return SetFocusedElement(null, null, null); + } + + private void DispatchHoverTransition( + SvgElement? target, + SvgJavaScriptEventInput input, + List events) + { + if (ReferenceEquals(target, _hoveredElement)) + { + return; + } + + var previous = _hoveredElement; + if (previous is not null) + { + DispatchPointerAndMouseEvents(previous, target, input, events, "pointerout", "mouseout"); + } + + _hoveredElement = target; + + if (target is not null) + { + DispatchPointerAndMouseEvents(target, previous, input, events, "pointerover", "mouseover"); + } + } + + private void DispatchPointerAndMouseEvents( + SvgElement? target, + SvgElement? relatedTarget, + SvgJavaScriptEventInput input, + List events, + string pointerEventType, + string mouseEventType) + { + events.Add(DispatchEvent(pointerEventType, target, relatedTarget, input)); + events.Add(DispatchEvent(mouseEventType, target, relatedTarget, input)); + } + + private bool ApplyFocusDefaultAction( + SvgElement? target, + SvgJavaScriptEventInput input, + List events) + { + return SetFocusedElement(ResolveFocusableElement(target), input, events); + } + + private bool SetFocusedElement( + SvgElement? element, + SvgJavaScriptEventInput? input, + List? events) + { + if (ReferenceEquals(_focusedElement, element)) + { + return false; + } + + if (events is null) + { + var changed = element is null + ? _focusedElement is not null && _runtime.BlurElement(_focusedElement) + : _runtime.FocusElement(element); + if (changed) + { + _focusedElement = element; + } + + return changed; + } + + var previous = _focusedElement; + if (previous is not null) + { + DispatchFocusEvent(previous, element, input, events, "blur"); + DispatchFocusEvent(previous, element, input, events, "focusout"); + } + + _focusedElement = element; + _runtime.SetFocusedElementState(element); + + if (element is not null) + { + DispatchFocusEvent(element, previous, input, events, "focus"); + DispatchFocusEvent(element, previous, input, events, "focusin"); + } + + return true; + } + + private void DispatchFocusEvent( + SvgElement target, + SvgElement? relatedTarget, + SvgJavaScriptEventInput? input, + List? events, + string eventType) + { + if (events is null) + { + return; + } + + events.Add(DispatchEvent(eventType, target, relatedTarget, input ?? CreateDefaultFocusInput())); + } + + private SvgJavaScriptInteractionEventResult DispatchEvent( + string eventType, + SvgElement? targetElement, + SvgElement? relatedTargetElement, + SvgJavaScriptEventInput input) + { + var normalizedEventType = NormalizeEventType(eventType); + if (normalizedEventType.Length == 0 || targetElement is null) + { + return new SvgJavaScriptInteractionEventResult( + normalizedEventType, + targetNode: null, + relatedTargetNode: null, + dispatched: false, + allowedDefault: true, + mutated: false, + cancelBubble: false, + defaultPrevented: false); + } + + var hasUseInstanceTarget = TryResolveUseInstanceEventTarget( + targetElement, + input, + out var targetNode, + out var correspondingElement); + var relatedTargetNode = relatedTargetElement is null ? null : _runtime.GetElement(relatedTargetElement); + var eventFacade = _runtime.CreateEvent(normalizedEventType, targetNode, relatedTargetNode, input); + var before = _runtime.MutationVersion; + var eventPath = BuildRoute(targetElement); + if (hasUseInstanceTarget && correspondingElement is not null) + { + eventPath.Insert(0, correspondingElement); + } + + for (var index = eventPath.Count - 1; index > 0; index--) + { + var current = _runtime.GetElement(eventPath[index]); + eventFacade.SetCurrentTarget(current); + var listenerResult = current.DispatchRegisteredEventListeners(normalizedEventType, eventFacade, useCapture: true); + if (listenerResult.CancelBubble || eventFacade.cancelBubble) + { + return CreateEventResult(normalizedEventType, targetNode, relatedTargetNode, eventFacade, before); + } + } + + if (eventPath.Count > 0) + { + var targetFacade = _runtime.GetElement(eventPath[0]); + eventFacade.SetCurrentTarget(targetFacade); + _ = targetFacade.DispatchRegisteredEventListeners(normalizedEventType, eventFacade, useCapture: true); + } + + for (var index = 0; index < eventPath.Count; index++) + { + var current = eventPath[index]; + var result = _runtime.ExecuteEventHandlerAndListeners( + current, + eventFacade, + normalizedEventType, + "on" + normalizedEventType); + + if (result.CancelBubble || eventFacade.cancelBubble || !eventFacade.bubbles) + { + break; + } + } + + return CreateEventResult(normalizedEventType, targetNode, relatedTargetNode, eventFacade, before); + } + + private SvgJavaScriptInteractionEventResult CreateEventResult( + string eventType, + object targetNode, + object? relatedTargetNode, + SvgJavaScriptEvent eventFacade, + int beforeMutationVersion) + { + return new SvgJavaScriptInteractionEventResult( + eventType, + targetNode, + relatedTargetNode, + dispatched: true, + allowedDefault: !eventFacade.defaultPrevented, + mutated: _runtime.MutationVersion != beforeMutationVersion, + cancelBubble: eventFacade.cancelBubble, + defaultPrevented: eventFacade.defaultPrevented); + } + + private bool TryResolveUseInstanceEventTarget( + SvgElement targetElement, + SvgJavaScriptEventInput input, + out object targetNode, + out SvgElement? correspondingElement) + { + if (targetElement is SvgUse use && + _runtime.GetSceneDocument()?.HitTestTopmostNode(new SKPoint(input.X, input.Y)) is { } hitNode) + { + var hitTargetElement = hitNode.HitTestTargetElement; + var hitElement = hitNode.Element as SvgElement; + + if (ReferenceEquals(hitTargetElement, targetElement) && + hitElement is not null && + !ReferenceEquals(hitElement, targetElement)) + { + var instance = _runtime.FindUseInstance(use, hitElement); + if (instance is not null) + { + correspondingElement = hitElement; + targetNode = instance; + return true; + } + } + } + + correspondingElement = null; + targetNode = _runtime.GetElement(targetElement); + return false; + } + + private SvgElement? HitTestElement(SvgJavaScriptEventInput input) + { + return HitTestElement(new SKPoint(input.X, input.Y)); + } + + private SvgElement? HitTestElement(SKPoint point) + { + return _runtime.GetSceneDocument()?.HitTestTopmostNode(point)?.HitTestTargetElement; + } + + private SvgJavaScriptInteractionDispatchResult CreateResult( + SvgElement? targetElement, + List events, + bool defaultActionActivated = false) + { + return new SvgJavaScriptInteractionDispatchResult( + WrapElement(targetElement), + WrapElement(_focusedElement), + events.AsReadOnly(), + defaultActionActivated); + } + + private SvgJavaScriptElement? WrapElement(SvgElement? element) + { + return element is null ? null : _runtime.GetElement(element); + } + + private static List BuildRoute(SvgElement target) + { + var route = new List(); + for (var current = target; current is not null; current = current.Parent) + { + route.Add(current); + } + + return route; + } + + private static string NormalizeEventType(string? eventType) + { + if (string.IsNullOrWhiteSpace(eventType)) + { + return string.Empty; + } + + var normalized = eventType!.Trim(); + if (normalized.StartsWith("on", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring(2); + } + + return normalized.ToLowerInvariant(); + } + + private static bool HasDefaultPrevented(List events) + { + for (var i = 0; i < events.Count; i++) + { + if (events[i].DefaultPrevented) + { + return true; + } + } + + return false; + } + + private static SvgElement? ResolveFocusableElement(SvgElement? target) + { + for (var current = target; current is not null; current = current.Parent) + { + if (IsFocusableElement(current)) + { + return current; + } + } + + return null; + } + + private static bool IsFocusableElement(SvgElement element) + { + if (TryGetBooleanAttribute(element, "focusable", out var focusable)) + { + return focusable; + } + + if (TryGetTabIndex(element, out var tabIndex)) + { + return tabIndex >= 0; + } + + return element is SvgAnchor anchor && + anchor.TryGetEffectiveHrefString(out var href) && + !string.IsNullOrWhiteSpace(href); + } + + private static bool TryGetBooleanAttribute(SvgElement element, string attributeName, out bool value) + { + value = false; + if (!element.TryGetAttribute(attributeName, out var rawValue) || + string.IsNullOrWhiteSpace(rawValue)) + { + return false; + } + + var normalized = rawValue.Trim(); + if (string.Equals(normalized, "true", StringComparison.OrdinalIgnoreCase)) + { + value = true; + return true; + } + + if (string.Equals(normalized, "false", StringComparison.OrdinalIgnoreCase)) + { + value = false; + return true; + } + + return false; + } + + private static bool TryGetTabIndex(SvgElement element, out int tabIndex) + { + tabIndex = 0; + return element.TryGetAttribute("tabindex", out var rawValue) && + int.TryParse(rawValue?.Trim(), out tabIndex); + } + + private static SvgJavaScriptEventInput CreateDefaultFocusInput() + { + return new SvgJavaScriptEventInput( + 0f, + 0f, + SvgJavaScriptMouseButton.None, + 0, + 0, + altKey: false, + shiftKey: false, + ctrlKey: false); + } +} diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptRuntime.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptRuntime.cs index f6fd72ab3d..ce6070ebbd 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptRuntime.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptRuntime.cs @@ -45,6 +45,8 @@ public sealed partial class SvgJavaScriptRuntime private long _nextTimeoutSequence; private ISvgJavaScriptAnimationHost? _animationHost; private ISvgJavaScriptTextContentHost? _textContentHost; + private ISvgJavaScriptViewerHost? _viewerHost; + private SvgElement? _focusedElement; private TimeSpan? _pendingAnimationTime; private double _virtualTimerMilliseconds; private bool _drainingTimeouts; @@ -78,6 +80,8 @@ public SvgJavaScriptRuntime(SvgDocument document, SvgJavaScriptSettings settings internal SvgJavaScriptDocument DocumentFacade => _documentFacade; + public SvgJavaScriptElement? FocusedElement => _focusedElement is null ? null : GetElement(_focusedElement); + public ISvgJavaScriptAnimationHost? AnimationHost { get => _animationHost; @@ -98,6 +102,12 @@ public ISvgJavaScriptTextContentHost? TextContentHost set => _textContentHost = value; } + public ISvgJavaScriptViewerHost? ViewerHost + { + get => _viewerHost; + set => _viewerHost = value; + } + public SvgJavaScriptElement GetElement(SvgElement element) { return _documentFacade.GetOrCreateElement(element); @@ -111,6 +121,54 @@ internal void MarkMutation() _useInstanceRoots.Clear(); } + internal bool FocusElement(SvgElement? element) + { + if (ReferenceEquals(_focusedElement, element)) + { + return false; + } + + var previous = _focusedElement; + if (previous is not null) + { + DispatchFocusEvent(previous, "blur", element); + DispatchFocusEvent(previous, "focusout", element); + } + + _focusedElement = element; + + if (element is not null) + { + DispatchFocusEvent(element, "focus", previous); + DispatchFocusEvent(element, "focusin", previous); + } + + return true; + } + + internal bool BlurElement(SvgElement element) + { + if (!ReferenceEquals(_focusedElement, element)) + { + return false; + } + + return FocusElement(null); + } + + internal void SetFocusedElementState(SvgElement? element) + { + _focusedElement = element; + } + + private void DispatchFocusEvent(SvgElement element, string eventType, SvgElement? relatedElement) + { + var target = GetElement(element); + var relatedTarget = relatedElement is null ? null : GetElement(relatedElement); + var evt = CreateEvent(eventType, target, relatedTarget, input: null); + _ = DispatchEvent(target, evt); + } + public void ExecuteDocumentScripts(bool dispatchLoadEvent = true) { foreach (var script in _document.Descendants().OfType().ToArray()) @@ -120,13 +178,16 @@ public void ExecuteDocumentScripts(bool dispatchLoadEvent = true) if (dispatchLoadEvent) { - ExecuteEventHandlerAndListeners( - _document, - GetElement(_document), - relatedTargetNode: null, - "load", - "onload", - input: null); + foreach (var element in GetLoadEventTargets(_document).ToArray()) + { + ExecuteEventHandlerAndListeners( + element, + GetElement(element), + relatedTargetNode: null, + "load", + "onload", + input: null); + } } DrainPendingTimeouts(); @@ -631,6 +692,74 @@ internal void SelectSubString(SvgTextBase textContentElement, int charnum, int n } } + internal bool BeginTextSelection(SvgTextBase textContentElement, int anchorCharnum) + { + try + { + var selected = GetTextSelectionHost()?.BeginTextSelection(textContentElement, anchorCharnum) == true; + if (selected) + { + MarkMutation(); + } + + return selected; + } + catch (ArgumentOutOfRangeException) + { + ThrowDomException(1, "The character index is out of range."); + return false; + } + } + + internal bool ExtendTextSelection(SvgTextBase textContentElement, int focusCharnum) + { + try + { + var selected = GetTextSelectionHost()?.ExtendTextSelection(textContentElement, focusCharnum) == true; + if (selected) + { + MarkMutation(); + } + + return selected; + } + catch (ArgumentOutOfRangeException) + { + ThrowDomException(1, "The character index is out of range."); + return false; + } + } + + internal bool SelectTextRange(SvgTextBase textContentElement, int anchorCharnum, int focusCharnum) + { + try + { + var selected = GetTextSelectionHost()?.SelectTextRange(textContentElement, anchorCharnum, focusCharnum) == true; + if (selected) + { + MarkMutation(); + } + + return selected; + } + catch (ArgumentOutOfRangeException) + { + ThrowDomException(1, "The character index is out of range."); + return false; + } + } + + internal void ClearTextSelection() + { + GetTextSelectionHost()?.ClearTextSelection(); + MarkMutation(); + } + + internal SvgJavaScriptTextSelection? GetTextSelection(SvgTextBase? textContentElement) + { + return GetTextSelectionHost()?.GetTextSelection(textContentElement); + } + private ISvgJavaScriptTextContentHost RequireTextContentHost() { if (_textContentHost is not null) @@ -642,6 +771,11 @@ private ISvgJavaScriptTextContentHost RequireTextContentHost() return null!; } + private ISvgJavaScriptTextSelectionHost? GetTextSelectionHost() + { + return _textContentHost as ISvgJavaScriptTextSelectionHost; + } + private void ExecuteScriptElement(SvgScript script) { if (!IsSupportedScriptType(script.ScriptType, useDocumentDefault: true)) @@ -945,6 +1079,19 @@ private static string NormalizeEventHandlerScript(string script) : script; } + private static IEnumerable GetLoadEventTargets(SvgElement element) + { + foreach (var child in element.Children) + { + foreach (var childTarget in GetLoadEventTargets(child)) + { + yield return childTarget; + } + } + + yield return element; + } + private static string NormalizeEventType(string? eventType) { return eventType?.Trim().ToLowerInvariant() ?? string.Empty; diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptStyleDeclaration.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptStyleDeclaration.cs index b4ae16cc25..e458c2739b 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptStyleDeclaration.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptStyleDeclaration.cs @@ -33,23 +33,93 @@ public string stroke set => setProperty("stroke", value); } + public string strokeWidth + { + get => getPropertyValue("stroke-width"); + set => setProperty("stroke-width", value); + } + public string opacity { get => getPropertyValue("opacity"); set => setProperty("opacity", value); } + public string fillOpacity + { + get => getPropertyValue("fill-opacity"); + set => setProperty("fill-opacity", value); + } + + public string strokeOpacity + { + get => getPropertyValue("stroke-opacity"); + set => setProperty("stroke-opacity", value); + } + + public string color + { + get => getPropertyValue("color"); + set => setProperty("color", value); + } + + public string fontSize + { + get => getPropertyValue("font-size"); + set => setProperty("font-size", value); + } + + public string fontFamily + { + get => getPropertyValue("font-family"); + set => setProperty("font-family", value); + } + + public string pointerEvents + { + get => getPropertyValue("pointer-events"); + set => setProperty("pointer-events", value); + } + + public string textDecoration + { + get => getPropertyValue("text-decoration"); + set => setProperty("text-decoration", value); + } + + public string cssText + { + get => _element.GetStyleCssText(); + set => _element.SetStyleCssText(value); + } + + public int length => _element.GetStylePropertyNames().Count; + + public string this[int index] => item(index); + public string getPropertyValue(string name) { return string.IsNullOrWhiteSpace(name) ? string.Empty : _element.GetStyleProperty(name); } + public string getPropertyPriority(string name) + { + return string.IsNullOrWhiteSpace(name) ? string.Empty : _element.GetStylePropertyPriority(name); + } + public void setProperty(string name, object? value) { - if (!string.IsNullOrWhiteSpace(name)) + setProperty(name, value, null); + } + + public void setProperty(string name, object? value, object? priority) + { + if (string.IsNullOrWhiteSpace(name)) { - _element.SetStyleProperty(name, value); + return; } + + _element.SetStyleProperty(name, value, priority); } public string removeProperty(string name) @@ -61,4 +131,10 @@ public string removeProperty(string name) return string.Empty; } + + public string item(int index) + { + var names = _element.GetStylePropertyNames(); + return index >= 0 && index < names.Count ? names[index] : string.Empty; + } } diff --git a/src/Svg.JavaScript/Scripting/SvgJavaScriptWindow.cs b/src/Svg.JavaScript/Scripting/SvgJavaScriptWindow.cs index 25597f9adc..dbfb7de648 100644 --- a/src/Svg.JavaScript/Scripting/SvgJavaScriptWindow.cs +++ b/src/Svg.JavaScript/Scripting/SvgJavaScriptWindow.cs @@ -1,3 +1,6 @@ +using System.Collections.Generic; +using System.Linq; + namespace Svg.JavaScript; public sealed class SvgJavaScriptWindow @@ -47,15 +50,63 @@ public void clearTimeout(int id) public sealed class SvgJavaScriptComputedStyle { + private readonly IReadOnlyList _propertyNames; private readonly SvgJavaScriptElement _element; internal SvgJavaScriptComputedStyle(SvgJavaScriptElement element) { _element = element; + _propertyNames = element.GetComputedStylePropertyNames(); } + public string display => getPropertyValue("display"); + + public string visibility => getPropertyValue("visibility"); + + public string fill => getPropertyValue("fill"); + + public string stroke => getPropertyValue("stroke"); + + public string strokeWidth => getPropertyValue("stroke-width"); + + public string opacity => getPropertyValue("opacity"); + + public string fillOpacity => getPropertyValue("fill-opacity"); + + public string strokeOpacity => getPropertyValue("stroke-opacity"); + + public string color => getPropertyValue("color"); + + public string fontSize => getPropertyValue("font-size"); + + public string fontFamily => getPropertyValue("font-family"); + + public string pointerEvents => getPropertyValue("pointer-events"); + + public string textDecoration => getPropertyValue("text-decoration"); + + public int length => _propertyNames.Count; + + public string cssText => string.Join("; ", _propertyNames + .Select(name => new KeyValuePair(name, getPropertyValue(name))) + .Where(pair => pair.Value.Length > 0) + .Select(pair => string.Concat(pair.Key, ": ", pair.Value))); + + public string this[int index] => item(index); + public string getPropertyValue(string name) { return string.IsNullOrWhiteSpace(name) ? string.Empty : _element.GetComputedStyleProperty(name); } + + public string getPropertyPriority(string name) + { + _ = name; + return string.Empty; + } + + public string item(int index) + { + return index >= 0 && index < _propertyNames.Count ? _propertyNames[index] : string.Empty; + } } diff --git a/src/Svg.Model/ISvgAssetLoader.cs b/src/Svg.Model/ISvgAssetLoader.cs index 2b79efaf84..ab8764f355 100644 --- a/src/Svg.Model/ISvgAssetLoader.cs +++ b/src/Svg.Model/ISvgAssetLoader.cs @@ -27,6 +27,11 @@ public interface ISvgImageAssetLoader SKImage LoadImage(Stream stream, SvgImageLoadContext context); } +public interface ISvgBrokenImagePlaceholderOptions +{ + bool EnableBrokenImagePlaceholders { get; } +} + public interface ISvgImageAlphaProvider { bool TryGetImageAlpha(SKImage image, out int width, out int height, out byte[] alpha); diff --git a/src/Svg.Model/Services/SvgService.cs b/src/Svg.Model/Services/SvgService.cs index ee2e2d8056..d45ebfc870 100644 --- a/src/Svg.Model/Services/SvgService.cs +++ b/src/Svg.Model/Services/SvgService.cs @@ -627,12 +627,12 @@ internal static Uri GetImageDocumentUri(Uri uri) if (isSvgMimeType || isSvg) { - return LoadSvg(stream, uri, GetEffectiveDocumentLoadOptions(svgOwnerElement)); + return LoadSvg(stream, uri, GetEffectiveSvgImageDocumentLoadOptions(svgOwnerElement)); } if (isSvgMimeType || isSvgz) { - return LoadSvgz(stream, uri, GetEffectiveDocumentLoadOptions(svgOwnerElement)); + return LoadSvgz(stream, uri, GetEffectiveSvgImageDocumentLoadOptions(svgOwnerElement)); } return LoadImage(assetLoader, stream, uri, svgOwnerElement); @@ -700,7 +700,7 @@ internal static Uri GetImageDocumentUri(Uri uri) if (isCompressed) { using var bytesStream = new System.IO.MemoryStream(bytes); - return LoadSvgz(bytesStream, imageBaseUri, GetEffectiveDocumentLoadOptions(svgOwnerElement)); + return LoadSvgz(bytesStream, imageBaseUri, GetEffectiveSvgImageDocumentLoadOptions(svgOwnerElement)); } } @@ -710,7 +710,7 @@ internal static Uri GetImageDocumentUri(Uri uri) var buffer = Encoding.Default.GetBytes(data); using var stream = new System.IO.MemoryStream(buffer); - return LoadSvg(stream, imageBaseUri, GetEffectiveDocumentLoadOptions(svgOwnerElement)); + return LoadSvg(stream, imageBaseUri, GetEffectiveSvgImageDocumentLoadOptions(svgOwnerElement)); } if (mimeType.StartsWith("image/", StringComparison.Ordinal) || @@ -725,7 +725,7 @@ internal static Uri GetImageDocumentUri(Uri uri) if (isCompressed) { using var bytesStream = new System.IO.MemoryStream(bytes); - return LoadSvgz(bytesStream, svgOwnerElement.OwnerDocument.BaseUri, GetEffectiveDocumentLoadOptions(svgOwnerElement)); + return LoadSvgz(bytesStream, imageBaseUri, GetEffectiveSvgImageDocumentLoadOptions(svgOwnerElement)); } } @@ -872,6 +872,21 @@ private static SvgDocumentLoadOptions GetEffectiveDocumentLoadOptions(SvgDocumen return svgDocument?.LoadOptions ?? new SvgDocumentLoadOptions(); } + private static SvgDocumentLoadOptions GetEffectiveSvgImageDocumentLoadOptions(SvgElement? svgElement) + { + var loadOptions = CloneDocumentLoadOptions(GetEffectiveDocumentLoadOptions(svgElement)); + loadOptions.ProcessingMode = ToSvgImageDocumentProcessingMode(loadOptions.ProcessingMode); + return loadOptions; + } + + private static SvgProcessingMode ToSvgImageDocumentProcessingMode(SvgProcessingMode processingMode) + { + return processingMode == SvgProcessingMode.SecureStatic || + processingMode == SvgProcessingMode.SecureAnimated + ? SvgProcessingMode.SecureStatic + : SvgProcessingMode.Static; + } + private static SvgDocumentLoadOptions CloneDocumentLoadOptions(SvgDocumentLoadOptions? loadOptions) { return loadOptions?.Clone() ?? new SvgDocumentLoadOptions(); diff --git a/src/Svg.SceneGraph/SvgSceneCompiler.cs b/src/Svg.SceneGraph/SvgSceneCompiler.cs index e781912f39..71bfeef5ef 100644 --- a/src/Svg.SceneGraph/SvgSceneCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneCompiler.cs @@ -99,6 +99,12 @@ public bool TryEnter(SvgDocument? document, out string? documentKey) return true; } + public bool IsActive(SvgDocument? document) + { + var documentKey = GetDocumentKey(document); + return documentKey is not null && _activeDocumentKeys.Contains(documentKey); + } + public void Exit(string? documentKey) { if (documentKey is not null) @@ -1967,27 +1973,60 @@ private static bool TryCompileDirectImageNode( var references = CreateReferences(svgImage); if (references is { } && references.Contains(uri)) { - node.IsRenderable = false; + var placeholderRect = SKRect.Create(x, y, width, height); + if (!TryUseBrokenImagePlaceholder( + node, + svgImage, + placeholderRect, + viewport, + parentTotalTransform, + assetLoader)) + { + node.IsRenderable = false; + } + return true; } var image = SvgService.GetImage(href!, svgImage, assetLoader); if (image is not SKImage && image is not SvgDocument) { - node.IsRenderable = false; + var placeholderRect = SKRect.Create(x, y, width, height); + if (!TryUseBrokenImagePlaceholder( + node, + svgImage, + placeholderRect, + viewport, + parentTotalTransform, + assetLoader)) + { + node.IsRenderable = false; + } + return true; } var srcRect = image switch { SKImage skImage => SKRect.Create(0f, 0f, skImage.Width, skImage.Height), - SvgDocument svgDocument => CreateSourceRect(svgDocument), + SvgDocument svgDocument => CreateSourceRect(svgDocument, SKRect.Create(0f, 0f, width, height)), _ => SKRect.Empty }; if (srcRect.IsEmpty) { - node.IsRenderable = false; + var placeholderRect = SKRect.Create(x, y, width, height); + if (!TryUseBrokenImagePlaceholder( + node, + svgImage, + placeholderRect, + viewport, + parentTotalTransform, + assetLoader)) + { + node.IsRenderable = false; + } + return true; } @@ -2021,6 +2060,22 @@ image is SvgDocument svgDocumentImage && } break; case SvgDocument svgDocument: + if (compileContext.IsActive(svgDocument)) + { + if (!TryUseBrokenImagePlaceholder( + node, + svgImage, + destClip, + viewport, + parentTotalTransform, + assetLoader)) + { + node.IsRenderable = false; + } + + return true; + } + var fragmentNode = usesReferencedSvgViewport ? CompileEmbeddedSvgDocumentImageSceneNode( svgImage, @@ -2369,9 +2424,91 @@ private static void ResolveImageAutoSize(SKRect srcRect, bool hasExplicitWidth, return picture.Commands is { Count: > 0 } ? picture : null; } - private static SKRect CreateSourceRect(SvgDocument svgDocument) + private static bool TryUseBrokenImagePlaceholder( + SvgSceneNode node, + SvgImage svgImage, + SKRect destRect, + SKRect viewport, + SKMatrix parentTotalTransform, + ISvgAssetLoader assetLoader) + { + if (assetLoader is not ISvgBrokenImagePlaceholderOptions { EnableBrokenImagePlaceholders: true } || + destRect.IsEmpty || + destRect.Width <= 0f || + destRect.Height <= 0f) + { + return false; + } + + node.GeometryBounds = destRect; + node.Transform = TransformsService.ToMatrix(svgImage.Transforms, svgImage, destRect, viewport); + node.TotalTransform = parentTotalTransform.PreConcat(node.Transform); + node.TransformedBounds = node.TotalTransform.MapRect(destRect); + node.Clip = MaskingService.GetClipRect(svgImage.Clip, destRect) ?? destRect; + node.LocalModel = CreateBrokenImagePlaceholderModel(destRect); + node.IsRenderable = node.LocalModel is not null; + return node.IsRenderable; + } + + private static SKPicture? CreateBrokenImagePlaceholderModel(SKRect destRect) + { + var cullRect = CreateLocalCullRect(destRect); + if (cullRect.IsEmpty) + { + return null; + } + + var recorder = new SKPictureRecorder(); + var canvas = recorder.BeginRecording(cullRect); + var background = new SKPath(); + background.AddRect(destRect); + canvas.DrawPath( + background, + new SKPaint + { + Style = SKPaintStyle.Fill, + Color = new SKColor(0xF8, 0xF8, 0xF8, 0xFF) + }); + + var border = new SKPath(); + border.AddRect(destRect); + canvas.DrawPath( + border, + new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = false, + Color = new SKColor(0x66, 0x66, 0x66, 0xFF) + }); + + var diagonal = new SKPath(); + diagonal.MoveTo(destRect.Left, destRect.Top); + diagonal.LineTo(destRect.Right, destRect.Bottom); + diagonal.MoveTo(destRect.Right, destRect.Top); + diagonal.LineTo(destRect.Left, destRect.Bottom); + canvas.DrawPath( + diagonal, + new SKPaint + { + Style = SKPaintStyle.Stroke, + StrokeWidth = 1f, + IsAntialias = false, + Color = new SKColor(0x99, 0x99, 0x99, 0xFF) + }); + + var picture = recorder.EndRecording(); + return picture.Commands is { Count: > 0 } ? picture : null; + } + + private static SKRect CreateSourceRect(SvgDocument svgDocument, SKRect imageViewport) { var size = SvgService.GetDimensions(svgDocument); + if ((size.Width <= 0f || size.Height <= 0f) && imageViewport.Width > 0f && imageViewport.Height > 0f) + { + size = SvgService.GetDimensions(svgDocument, imageViewport); + } + return size.Width > 0f && size.Height > 0f ? SKRect.Create(0f, 0f, size.Width, size.Height) : SKRect.Empty; diff --git a/src/Svg.SceneGraph/SvgSceneHitTestService.cs b/src/Svg.SceneGraph/SvgSceneHitTestService.cs index 7d0e165fed..8adb968654 100644 --- a/src/Svg.SceneGraph/SvgSceneHitTestService.cs +++ b/src/Svg.SceneGraph/SvgSceneHitTestService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using ShimSkiaSharp; +using Svg.DataTypes; using Svg.Model.Services; namespace Svg.Skia; @@ -9,7 +10,7 @@ internal static class SvgSceneHitTestService { public static IEnumerable HitTest(SvgSceneDocument sceneDocument, SKPoint point) { - return HitTest(sceneDocument.Root, point); + return HitTest(sceneDocument.Root, point, new HitTestContext()); } public static IEnumerable HitTest(SvgSceneDocument sceneDocument, SKRect rect) @@ -19,7 +20,7 @@ public static IEnumerable HitTest(SvgSceneDocument sceneDocument, public static SvgSceneNode? HitTestTopmostNode(SvgSceneDocument sceneDocument, SKPoint point) { - return HitTestTopmostNode(sceneDocument.Root, point); + return HitTestTopmostNode(sceneDocument.Root, point, new HitTestContext()); } public static bool HitTestPointer(SvgSceneNode node, SKPoint point) @@ -29,7 +30,7 @@ public static bool HitTestPointer(SvgSceneNode node, SKPoint point) return false; } - if (!node.IsRenderable) + if (!CanHitTestNode(node)) { return false; } @@ -39,6 +40,11 @@ public static bool HitTestPointer(SvgSceneNode node, SKPoint point) return false; } + if (node.Kind == SvgSceneNodeKind.Text) + { + return HitTestTextPointer(node, point); + } + return node.PointerEvents switch { SvgPointerEvents.None => false, @@ -54,16 +60,38 @@ public static bool HitTestPointer(SvgSceneNode node, SKPoint point) }; } - private static IEnumerable HitTest(SvgSceneNode node, SKPoint point) + private static bool HitTestTextPointer(SvgSceneNode node, SKPoint point) { - if (!CanTraverseSubtree(node, point)) + return node.PointerEvents switch + { + SvgPointerEvents.None => false, + SvgPointerEvents.VisiblePainted => node.IsVisible && IsTextPainted(node) && HitTestTextCell(node, point), + SvgPointerEvents.VisibleFill => node.IsVisible && HitTestTextCell(node, point), + SvgPointerEvents.VisibleStroke => node.IsVisible && HitTestTextCell(node, point), + SvgPointerEvents.Visible => node.IsVisible && HitTestTextCell(node, point), + SvgPointerEvents.Painted => IsTextPainted(node) && HitTestTextCell(node, point), + SvgPointerEvents.Fill => HitTestTextCell(node, point), + SvgPointerEvents.Stroke => HitTestTextCell(node, point), + SvgPointerEvents.All => HitTestTextCell(node, point), + _ => node.IsVisible && IsTextPainted(node) && HitTestTextCell(node, point) + }; + } + + private static bool IsTextPainted(SvgSceneNode node) + { + return node.SupportsFillHitTest || node.SupportsStrokeHitTest; + } + + private static IEnumerable HitTest(SvgSceneNode node, SKPoint point, HitTestContext context) + { + if (!CanTraverseSubtree(node, point, context)) { yield break; } for (var i = 0; i < node.Children.Count; i++) { - foreach (var childHit in HitTest(node.Children[i], point)) + foreach (var childHit in HitTest(node.Children[i], point, context)) { yield return childHit; } @@ -91,16 +119,16 @@ private static IEnumerable HitTest(SvgSceneNode node, SKRect rect) } } - private static SvgSceneNode? HitTestTopmostNode(SvgSceneNode node, SKPoint point) + private static SvgSceneNode? HitTestTopmostNode(SvgSceneNode node, SKPoint point, HitTestContext context) { - if (!CanTraverseSubtree(node, point)) + if (!CanTraverseSubtree(node, point, context)) { return null; } for (var index = node.Children.Count - 1; index >= 0; index--) { - var childHit = HitTestTopmostNode(node.Children[index], point); + var childHit = HitTestTopmostNode(node.Children[index], point, context); if (childHit is not null) { return childHit; @@ -112,9 +140,46 @@ private static IEnumerable HitTest(SvgSceneNode node, SKRect rect) : null; } - private static bool CanTraverseSubtree(SvgSceneNode node, SKPoint point) + private static bool CanTraverseSubtree(SvgSceneNode node, SKPoint point, HitTestContext context) + { + return !node.IsDisplayNone && + !node.SuppressSubtreeRendering && + (CanHitTestPoint(node, point) || CanTraverseHitTestPoint(node, point, context)); + } + + private static bool CanTraverseHitTestPoint(SvgSceneNode node, SKPoint point, HitTestContext context) { - return !node.IsDisplayNone && !node.SuppressSubtreeRendering && CanHitTestPoint(node, point); + if (!context.GetSubtreeHitTestBounds(node).Contains(point)) + { + return false; + } + + if (node.Clip is null && node.ClipPath is null && node.InnerClip is null) + { + return true; + } + + if (!TryGetLocalPoint(node, point, out var localPoint)) + { + return false; + } + + if (node.Clip is { } clip && !clip.Contains(localPoint)) + { + return false; + } + + if (node.InnerClip is { } innerClip && !innerClip.Contains(localPoint)) + { + return false; + } + + if (node.ClipPath is { } clipPath && !GeometryHitTestService.Contains(clipPath, localPoint)) + { + return false; + } + + return true; } private static bool IntersectsWith(SKRect a, SKRect b) @@ -130,7 +195,7 @@ private static bool HitTestNode(SvgSceneNode node, SKPoint point) return false; } - if (!node.IsRenderable) + if (!CanHitTestNode(node)) { return false; } @@ -147,7 +212,7 @@ private static bool HitTestNode(SvgSceneNode node, SKRect rect) return false; } - if (!node.IsRenderable) + if (!CanHitTestNode(node)) { return false; } @@ -176,41 +241,28 @@ private static bool HitTestStroke(SvgSceneNode node, SKPoint point) return CanHitTestPoint(node, point) && HitTestStrokeCore(node, point); } - private static bool HitTestFillCore(SvgSceneNode node, SKPoint point) + private static bool HitTestTextCell(SvgSceneNode node, SKPoint point) { - if (node.HitTestPath is { } hitTestPath) + if (node.TextContentMetrics is { } metrics) { - return GeometryHitTestService.ContainsFill(hitTestPath, point, node.TotalTransform); - } - - return GetDirectFillBounds(node).Contains(point); - } + if (!GetTextCellBounds(node).Contains(point) || + !TryGetLocalPoint(node, point, out var metricPoint) || + !metrics.HitTestCharacterCell(metricPoint)) + { + return false; + } - private static bool HitTestStrokeCore(SvgSceneNode node, SKPoint point) - { - if (node.HitTestPath is { } hitTestPath) - { - return GeometryHitTestService.ContainsStroke( - hitTestPath, - point, - node.TotalTransform, - node.StrokeWidth, - node.IsStrokeNonScaling); + return PassesLocalClips(node, metricPoint); } - return node.StrokeWidth > 0f && GetDirectStrokeBounds(node).Contains(point); - } - - private static bool CanHitTestPoint(SvgSceneNode node, SKPoint point) - { - if (!GetRectHitBounds(node).Contains(point)) + if (!GetTextCellBounds(node).Contains(point)) { return false; } if (node.Clip is null && node.ClipPath is null && node.InnerClip is null) { - return !HasMask(node) || IsPointInMask(node.MaskNode, point); + return true; } if (!TryGetLocalPoint(node, point, out var localPoint)) @@ -233,77 +285,65 @@ private static bool CanHitTestPoint(SvgSceneNode node, SKPoint point) return false; } - return !HasMask(node) || IsPointInMask(node.MaskNode, point); + return true; } - private static bool TryGetLocalPoint(SvgSceneNode node, SKPoint point, out SKPoint localPoint) + private static bool PassesLocalClips(SvgSceneNode node, SKPoint localPoint) { - if (node.TotalTransform.IsIdentity) + if (node.Clip is { } clip && !clip.Contains(localPoint)) { - localPoint = point; - return true; + return false; } - if (!node.TotalTransform.TryInvert(out var inverse)) + if (node.InnerClip is { } innerClip && !innerClip.Contains(localPoint)) { - localPoint = default; return false; } - localPoint = inverse.MapPoint(point); - return true; - } - - private static bool HasMask(SvgSceneNode node) - { - return node.MaskNode is { IsRenderable: true }; - } - - private static bool IsPointInMask(SvgSceneNode? maskNode, SKPoint point) - { - if (maskNode is null || !maskNode.IsRenderable) + if (node.ClipPath is { } clipPath && !GeometryHitTestService.Contains(clipPath, localPoint)) { - return true; + return false; } - return HasRenderedMaskCoverage(maskNode, point); + return true; } - private static bool HasRenderedMaskCoverage(SvgSceneNode node, SKPoint point) + private static bool HitTestFillCore(SvgSceneNode node, SKPoint point) { - if (node.IsDisplayNone || !node.IsVisible) + if (node.HitTestPath is { } hitTestPath) { - return false; + return GeometryHitTestService.ContainsFill(hitTestPath, point, node.TotalTransform); } - if (!CanRenderAtPoint(node, point)) - { - return false; - } + return GetDirectFillBounds(node).Contains(point); + } - for (var i = 0; i < node.Children.Count; i++) + private static bool HitTestStrokeCore(SvgSceneNode node, SKPoint point) + { + var strokeWidth = GetStrokeHitTestWidth(node); + if (node.HitTestPath is { } hitTestPath) { - if (HasRenderedMaskCoverage(node.Children[i], point)) - { - return true; - } + return GeometryHitTestService.ContainsStroke( + hitTestPath, + point, + node.TotalTransform, + strokeWidth, + node.IsStrokeNonScaling); } - return node.IsRenderable && - ((node.SupportsFillHitTest && HitTestFillCore(node, point)) || - (node.SupportsStrokeHitTest && HitTestStrokeCore(node, point))); + return strokeWidth > 0f && GetDirectStrokeBounds(node).Contains(point); } - private static bool CanRenderAtPoint(SvgSceneNode node, SKPoint point) + private static bool CanHitTestPoint(SvgSceneNode node, SKPoint point) { - if (!GetStructuralBounds(node).Contains(point)) + if (!GetRectHitBounds(node).Contains(point)) { return false; } if (node.Clip is null && node.ClipPath is null && node.InnerClip is null) { - return !HasMask(node) || IsPointInMask(node.MaskNode, point); + return true; } if (!TryGetLocalPoint(node, point, out var localPoint)) @@ -326,7 +366,54 @@ private static bool CanRenderAtPoint(SvgSceneNode node, SKPoint point) return false; } - return !HasMask(node) || IsPointInMask(node.MaskNode, point); + return true; + } + + private static bool TryGetLocalPoint(SvgSceneNode node, SKPoint point, out SKPoint localPoint) + { + if (node.TotalTransform.IsIdentity) + { + localPoint = point; + return true; + } + + if (!node.TotalTransform.TryInvert(out var inverse)) + { + localPoint = default; + return false; + } + + localPoint = inverse.MapPoint(point); + return true; + } + + private static bool CanHitTestNode(SvgSceneNode node) + { + if (node.Kind == SvgSceneNodeKind.Text) + { + return HasHitTestGeometry(node) && node.PointerEvents != SvgPointerEvents.None; + } + + return node.IsRenderable || + (HasHitTestGeometry(node) && + (!node.IsVisible || UsesGeometryWithoutPaint(node.PointerEvents))); + } + + private static bool HasHitTestGeometry(SvgSceneNode node) + { + return node.HitTestPath is not null || + !node.TransformedBounds.IsEmpty; + } + + private static bool UsesGeometryWithoutPaint(SvgPointerEvents pointerEvents) + { + return pointerEvents is + SvgPointerEvents.VisibleFill or + SvgPointerEvents.VisibleStroke or + SvgPointerEvents.Visible or + SvgPointerEvents.Fill or + SvgPointerEvents.Stroke or + SvgPointerEvents.All; } private static bool UsesStructuralBounds(SvgSceneNode node) @@ -342,7 +429,7 @@ private static SKRect GetRectHitBounds(SvgSceneNode node) ? SvgSceneNodeBoundsService.GetRenderablePaintBounds(node) : GetDirectFillBounds(node); - return SvgSceneNodeBoundsService.GetInflatedBounds(node, bounds); + return GetInflatedHitBounds(node, bounds); } private static SKRect GetStructuralBounds(SvgSceneNode node) @@ -350,6 +437,16 @@ private static SKRect GetStructuralBounds(SvgSceneNode node) return SvgSceneNodeBoundsService.GetRenderableBounds(node); } + private static SKRect GetTextCellBounds(SvgSceneNode node) + { + if (!node.TransformedBounds.IsEmpty) + { + return node.TransformedBounds; + } + + return SvgSceneNodeBoundsService.GetRenderableBounds(node); + } + private static SKRect GetDirectFillBounds(SvgSceneNode node) { return node.TransformedBounds; @@ -357,6 +454,72 @@ private static SKRect GetDirectFillBounds(SvgSceneNode node) private static SKRect GetDirectStrokeBounds(SvgSceneNode node) { - return SvgSceneNodeBoundsService.GetInflatedBounds(node, node.TransformedBounds); + return GetInflatedHitBounds(node, node.TransformedBounds); + } + + private static SKRect GetInflatedHitBounds(SvgSceneNode node, SKRect bounds) + { + var strokeWidth = GetStrokeHitTestWidth(node); + if (bounds.IsEmpty || strokeWidth <= 0f) + { + return bounds; + } + + var inflation = strokeWidth / 2f; + if (!node.IsStrokeNonScaling) + { + var scaleX = Math.Sqrt( + (node.TotalTransform.ScaleX * node.TotalTransform.ScaleX) + + (node.TotalTransform.SkewY * node.TotalTransform.SkewY)); + var scaleY = Math.Sqrt( + (node.TotalTransform.SkewX * node.TotalTransform.SkewX) + + (node.TotalTransform.ScaleY * node.TotalTransform.ScaleY)); + inflation = (float)(Math.Max(scaleX, scaleY) * inflation); + } + + if (inflation <= 0f) + { + return bounds; + } + + bounds.Left -= inflation; + bounds.Top -= inflation; + bounds.Right += inflation; + bounds.Bottom += inflation; + return bounds; + } + + private static float GetStrokeHitTestWidth(SvgSceneNode node) + { + if (node.StrokeWidth > 0f) + { + return node.StrokeWidth; + } + + return node.Element is SvgVisualElement visualElement + ? visualElement.StrokeWidth.ToDeviceValue(UnitRenderingType.Other, visualElement, node.GeometryBounds) + : 0f; + } + + private sealed class HitTestContext + { + private readonly Dictionary _subtreeHitTestBounds = new(); + + public SKRect GetSubtreeHitTestBounds(SvgSceneNode node) + { + if (_subtreeHitTestBounds.TryGetValue(node, out var cachedBounds)) + { + return cachedBounds; + } + + var bounds = GetInflatedHitBounds(node, node.TransformedBounds); + for (var i = 0; i < node.Children.Count; i++) + { + bounds = SvgSceneNodeBoundsService.UnionNonEmpty(bounds, GetSubtreeHitTestBounds(node.Children[i])); + } + + _subtreeHitTestBounds.Add(node, bounds); + return bounds; + } } } diff --git a/src/Svg.SceneGraph/SvgSceneNode.cs b/src/Svg.SceneGraph/SvgSceneNode.cs index baef25bd61..cb78315c79 100644 --- a/src/Svg.SceneGraph/SvgSceneNode.cs +++ b/src/Svg.SceneGraph/SvgSceneNode.cs @@ -80,6 +80,8 @@ internal SvgSceneNode( public SKPath? HitTestPath { get; internal set; } + internal SvgSceneTextCompiler.SvgTextContentMetrics? TextContentMetrics { get; set; } + public SKRect GeometryBounds { get; internal set; } public SKRect TransformedBounds { get; internal set; } @@ -180,6 +182,7 @@ internal void ReplaceWith(SvgSceneNode replacement) LocalFill = replacement.LocalFill; LocalStroke = replacement.LocalStroke; HitTestPath = replacement.HitTestPath?.DeepClone(); + TextContentMetrics = replacement.TextContentMetrics; GeometryBounds = replacement.GeometryBounds; TransformedBounds = replacement.TransformedBounds; Transform = replacement.Transform; diff --git a/src/Svg.SceneGraph/SvgScenePaintingService.cs b/src/Svg.SceneGraph/SvgScenePaintingService.cs index 6dadbbbcd8..15333413c8 100644 --- a/src/Svg.SceneGraph/SvgScenePaintingService.cs +++ b/src/Svg.SceneGraph/SvgScenePaintingService.cs @@ -56,19 +56,38 @@ internal static float AdjustSvgOpacity(float opacity) internal static bool IsValidFill(SvgElement svgElement) { - var fill = svgElement.Fill; - return fill is not null && fill != SvgPaintServer.None; + return IsValidHitTestPaintServer(svgElement.Fill, svgElement); } internal static bool IsValidStroke(SvgElement svgElement, SKRect skBounds) { var stroke = svgElement.Stroke; var strokeWidth = svgElement.StrokeWidth; - return stroke is not null - && stroke != SvgPaintServer.None + return IsValidHitTestPaintServer(stroke, svgElement) && strokeWidth.ToDeviceValue(UnitRenderingType.Other, svgElement, skBounds) > 0f; } + private static bool IsValidHitTestPaintServer(SvgPaintServer? server, SvgElement owner, int depth = 0) + { + if (server is null || server == SvgPaintServer.None || depth >= 8) + { + return false; + } + + if (server is SvgDeferredPaintServer deferredServer) + { + var resolved = SvgDeferredPaintServer.TryGet(deferredServer, owner); + if (resolved is not null) + { + return IsValidHitTestPaintServer(resolved, owner, depth + 1); + } + + return IsValidHitTestPaintServer(deferredServer.FallbackServer, owner, depth + 1); + } + + return true; + } + internal static SKPaint? GetFillPaint( SvgVisualElement svgVisualElement, SKRect skBounds, diff --git a/src/Svg.SceneGraph/SvgSceneTextCompiler.SharedLayout.cs b/src/Svg.SceneGraph/SvgSceneTextCompiler.SharedLayout.cs index 246272382d..ed49652090 100644 --- a/src/Svg.SceneGraph/SvgSceneTextCompiler.SharedLayout.cs +++ b/src/Svg.SceneGraph/SvgSceneTextCompiler.SharedLayout.cs @@ -219,7 +219,10 @@ private static bool TryCreateSharedInlineSizeTextContentMetrics( cluster.StartPoint, cluster.EndPoint, cluster.Extent, - cluster.RotationDegrees)); + cluster.RotationDegrees) + { + HitExtent = cluster.HitExtent + }); } if (clusters.Count == 0) @@ -1736,7 +1739,10 @@ private static void AppendSharedDomClusters( cluster.StartPoint, cluster.EndPoint, cluster.Extent, - cluster.RotationDegrees)); + cluster.RotationDegrees) + { + HitExtent = cluster.HitExtent + }); localCharIndex += cluster.CharLength; } diff --git a/src/Svg.SceneGraph/SvgSceneTextCompiler.TextDom.cs b/src/Svg.SceneGraph/SvgSceneTextCompiler.TextDom.cs index 631259616a..91707d3797 100644 --- a/src/Svg.SceneGraph/SvgSceneTextCompiler.TextDom.cs +++ b/src/Svg.SceneGraph/SvgSceneTextCompiler.TextDom.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using ShimSkiaSharp; using Svg; using Svg.Model; @@ -12,16 +13,27 @@ internal static partial class SvgSceneTextCompiler internal sealed class SvgTextContentMetrics { private readonly TextDomClusterMetric[] _clusters; + private readonly TextDomHitCell[] _hitCells; private readonly int _numberOfChars; - internal static SvgTextContentMetrics Empty { get; } = new(Array.Empty(), 0, 0f); + internal static SvgTextContentMetrics Empty { get; } = new(Array.Empty(), 0, 0f, Array.Empty()); public SvgTextContentMetrics( TextDomClusterMetric[] clusters, int numberOfChars, float computedTextLength) + : this(clusters, numberOfChars, computedTextLength, null) + { + } + + internal SvgTextContentMetrics( + TextDomClusterMetric[] clusters, + int numberOfChars, + float computedTextLength, + TextDomHitCell[]? hitCells) { _clusters = clusters; + _hitCells = hitCells is { Length: > 0 } ? hitCells : CreateHitCells(clusters); _numberOfChars = numberOfChars; ComputedTextLength = computedTextLength; } @@ -30,6 +42,8 @@ public SvgTextContentMetrics( public float ComputedTextLength { get; } + internal bool HasHitTestCells => _hitCells.Length > 0; + public float GetSubStringLength(int charnum, int nchars) { ValidateCharacterIndex(charnum); @@ -86,6 +100,32 @@ public SKRect GetExtentOfChar(int charnum) return GetCluster(charnum).Extent; } + public bool TryGetCaretMetadata(int charnum, out SKPoint position, out SKRect extent) + { + position = default; + extent = default; + + if (NumberOfChars == 0 || + charnum < 0 || + charnum > NumberOfChars) + { + return false; + } + + if (charnum == NumberOfChars) + { + var lastCluster = GetCluster(NumberOfChars - 1); + position = lastCluster.EndPoint; + extent = lastCluster.Extent; + return true; + } + + var cluster = GetCluster(charnum); + position = cluster.StartPoint; + extent = cluster.Extent; + return true; + } + public float GetRotationOfChar(int charnum) { return GetCluster(charnum).RotationDegrees; @@ -106,6 +146,19 @@ public int GetCharNumAtPosition(SKPoint point) return -1; } + internal bool HitTestCharacterCell(SKPoint point) + { + for (var i = 0; i < _hitCells.Length; i++) + { + if (ContainsPoint(_hitCells[i].Extent, point)) + { + return true; + } + } + + return false; + } + public SKRect[] GetSelectionExtents(int charnum, int nchars) { ValidateCharacterIndex(charnum); @@ -244,6 +297,39 @@ private static bool TryGetCharacterIndexAtPosition(TextDomClusterMetric cluster, return true; } + private static TextDomHitCell[] CreateHitCells(IReadOnlyList clusters) + { + if (clusters.Count == 0) + { + return Array.Empty(); + } + + var hitCells = new List(clusters.Count); + for (var i = 0; i < clusters.Count; i++) + { + if (!clusters[i].HitExtent.IsEmpty) + { + hitCells.Add(new TextDomHitCell(clusters[i].HitExtent)); + } + } + + return hitCells.ToArray(); + } + + private static bool ContainsPoint(SKRect bounds, SKPoint point) + { + if (bounds.IsEmpty) + { + return false; + } + + const float tolerance = 0.25f; + return point.X >= bounds.Left - tolerance && + point.X <= bounds.Right + tolerance && + point.Y >= bounds.Top - tolerance && + point.Y <= bounds.Bottom + tolerance; + } + private static SKRect[] MergeSelectionExtents(List extents) { if (extents.Count <= 1) @@ -306,6 +392,7 @@ private static SKRect Union(SKRect left, SKRect right) private sealed class SvgTextContentMetricsBuilder { private readonly List _clusters = new(); + private readonly List _hitCells = new(); private int _numberOfChars; private float _computedTextLength; @@ -315,6 +402,7 @@ public void AppendRun(IReadOnlyList runClusters, float for (var clusterIndex = 0; clusterIndex < runClusters.Count; clusterIndex++) { var runCluster = runClusters[clusterIndex]; + AppendHitCell(runCluster); if (runCluster.CharLength <= 0) { continue; @@ -322,6 +410,11 @@ public void AppendRun(IReadOnlyList runClusters, float var startCharIndex = _numberOfChars; var charLength = runCluster.CharLength; + if (runCluster.StartCharIndex is { } explicitStartCharIndex) + { + startCharIndex = explicitStartCharIndex; + } + var globalCluster = new TextDomClusterMetric( startCharIndex, charLength, @@ -330,9 +423,14 @@ public void AppendRun(IReadOnlyList runClusters, float runCluster.StartPoint, runCluster.EndPoint, runCluster.Extent, - runCluster.RotationDegrees); + runCluster.RotationDegrees) + { + HitExtent = runCluster.HitExtent + }; _clusters.Add(globalCluster); - _numberOfChars += charLength; + _numberOfChars = runCluster.StartCharIndex is { } + ? Math.Max(_numberOfChars, startCharIndex + charLength) + : _numberOfChars + charLength; } _computedTextLength = runStartOffset + Math.Max(0f, runLength); @@ -348,6 +446,7 @@ public void AppendRunAtCharacterIndex( for (var clusterIndex = 0; clusterIndex < runClusters.Count; clusterIndex++) { var runCluster = runClusters[clusterIndex]; + AppendHitCell(runCluster); if (runCluster.CharLength <= 0) { continue; @@ -362,7 +461,10 @@ public void AppendRunAtCharacterIndex( runCluster.StartPoint, runCluster.EndPoint, runCluster.Extent, - runCluster.RotationDegrees); + runCluster.RotationDegrees) + { + HitExtent = runCluster.HitExtent + }; _clusters.Add(globalCluster); localCharIndex += charLength; } @@ -377,7 +479,16 @@ public SvgTextContentMetrics Build() return new SvgTextContentMetrics( _clusters.ToArray(), _numberOfChars, - _computedTextLength); + _computedTextLength, + _hitCells.ToArray()); + } + + private void AppendHitCell(TextDomRunClusterMetric runCluster) + { + if (!runCluster.HitExtent.IsEmpty) + { + _hitCells.Add(new TextDomHitCell(runCluster.HitExtent)); + } } } @@ -389,7 +500,10 @@ internal readonly record struct TextDomClusterMetric( SKPoint StartPoint, SKPoint EndPoint, SKRect Extent, - float RotationDegrees); + float RotationDegrees) + { + public SKRect HitExtent { get; init; } = Extent; + } private readonly record struct TextDomRunClusterMetric( int CharLength, @@ -398,13 +512,23 @@ private readonly record struct TextDomRunClusterMetric( SKPoint StartPoint, SKPoint EndPoint, SKRect Extent, - float RotationDegrees); + float RotationDegrees) + { + public SKRect HitExtent { get; init; } = Extent; + + public int? StartCharIndex { get; init; } + } + + internal readonly record struct TextDomHitCell(SKRect Extent); private readonly record struct TextDomClusterSource( int FirstCodepointIndex, string Text, float RelativeOffset, - float Advance); + float Advance) + { + public int? FirstCharIndex { get; init; } + } private readonly record struct SequentialTextContentRun( SvgTextBase StyleSource, @@ -443,6 +567,11 @@ internal static bool TryCreateTextContentMetrics( return true; } + if (TryCreateBidiSequentialTextContentMetrics(svgTextBase, viewport, assetLoader, out metrics)) + { + return true; + } + if (TryCreateSequentialTextContentMetrics(svgTextBase, viewport, assetLoader, out metrics)) { return true; @@ -535,11 +664,12 @@ private static bool TryCreateInlineSizeTextContentMetrics( for (var lineIndex = 0; lineIndex < layout.Lines.Count; lineIndex++) { var line = layout.Lines[lineIndex]; - var drawX = line.StartX; - var drawY = line.BaselineY; if (UsesVisualInlineSizeTextRunOrder(line)) { var runStartOffset = 0f; + var visualDrawX = line.StartX; + var visualDrawY = line.BaselineY; + var cursorX = line.PlaceVisualRunsRightToLeft ? GetInlineSizeVisualRunCursorX(line) : line.StartX; for (var runIndex = 0; runIndex < line.VisualRuns.Count; runIndex++) { var run = line.VisualRuns[runIndex]; @@ -551,8 +681,8 @@ private static bool TryCreateInlineSizeTextContentMetrics( if (!TryCreateTextRunMetrics( run.StyleSource, run.Text, - drawX, - drawY, + line.PlaceVisualRunsRightToLeft ? TakeInlineSizeVisualRunX(line, run, ref cursorX) : visualDrawX, + visualDrawY, viewport, assetLoader, rotations: null, @@ -568,7 +698,11 @@ private static bool TryCreateInlineSizeTextContentMetrics( line.ShouldClip ? ClipTextDomRunClusters(clusters, line.ClipRect) : clusters, lineStartOffset + runStartOffset, runLength); - ApplyInlineAdvance(run.StyleSource, ref drawX, ref drawY, runLength); + if (!line.PlaceVisualRunsRightToLeft) + { + ApplyInlineAdvance(run.StyleSource, ref visualDrawX, ref visualDrawY, runLength); + } + runStartOffset += runLength; } @@ -577,6 +711,8 @@ private static bool TryCreateInlineSizeTextContentMetrics( continue; } + var drawX = line.StartX; + var drawY = line.BaselineY; for (var runIndex = 0; runIndex < line.Runs.Count; runIndex++) { var run = line.Runs[runIndex]; @@ -620,6 +756,11 @@ private static int GetInlineSizeRunTextLength(IReadOnlyList r private static bool UsesVisualInlineSizeTextRunOrder(InlineSizeTextLine line) { + if (line.PlaceVisualRunsRightToLeft) + { + return true; + } + if (line.VisualRuns.Count != line.Runs.Count) { return true; @@ -957,6 +1098,7 @@ private static TextDomRunClusterMetric[] ClipTextDomRunClusters( { var cluster = clusters[i]; var extent = cluster.Extent; + var hitExtent = cluster.HitExtent; if (!extent.IsEmpty && TryIntersectRect(extent, clipRect, out var clippedExtent)) { @@ -967,6 +1109,16 @@ private static TextDomRunClusterMetric[] ClipTextDomRunClusters( extent = SKRect.Empty; } + if (!hitExtent.IsEmpty && + TryIntersectRect(hitExtent, clipRect, out var clippedHitExtent)) + { + hitExtent = clippedHitExtent; + } + else if (!hitExtent.IsEmpty) + { + hitExtent = SKRect.Empty; + } + clipped[i] = new TextDomRunClusterMetric( cluster.CharLength, cluster.StartOffset, @@ -974,7 +1126,10 @@ private static TextDomRunClusterMetric[] ClipTextDomRunClusters( cluster.StartPoint, cluster.EndPoint, extent, - cluster.RotationDegrees); + cluster.RotationDegrees) + { + HitExtent = hitExtent + }; } return clipped; @@ -1046,6 +1201,74 @@ private static bool TryCreateSequentialTextContentMetrics( return true; } + private static bool TryCreateBidiSequentialTextContentMetrics( + SvgTextBase svgTextBase, + SKRect viewport, + ISvgAssetLoader assetLoader, + out SvgTextContentMetrics metrics) + { + metrics = SvgTextContentMetrics.Empty; + if (HasSequentialTextRunBarriers(svgTextBase) || + !TryCollectSequentialTextContentRuns(svgTextBase, trimLeadingWhitespaceAtStart: true, out var runs) || + runs.Count != 1 || + runs[0].Rotations is { Length: > 0 }) + { + return false; + } + + var run = runs[0]; + var paint = new SKPaint(); + PaintingService.SetPaintText(run.StyleSource, viewport, paint); + if (!TryCreateBidiClusterSources(run.StyleSource, run.Text, viewport, paint, assetLoader, out var sources, out var totalAdvance)) + { + return false; + } + + var xs = new List(); + var ys = new List(); + GetPositionsX(svgTextBase, viewport, assetLoader, xs); + GetPositionsY(svgTextBase, viewport, assetLoader, ys); + var currentX = xs.Count >= 1 ? xs[0] : 0f; + var currentY = ys.Count >= 1 ? ys[0] : 0f; + var baselineShift = GetBaselineShiftVector(svgTextBase, viewport, assetLoader); + currentX += baselineShift.X; + currentY += baselineShift.Y; + ApplyInitialChildContainerOffsets(svgTextBase, viewport, assetLoader, ref currentX, ref currentY); + + var isVertical = IsVerticalWritingMode(svgTextBase); + if (isVertical && svgTextBase.X.Count > 0) + { + currentX -= baselineShift.X; + } + + var textAlign = GetTextAnchorAlign(svgTextBase, viewport); + var inlineOrigin = isVertical + ? GetVerticalInlineStartCoordinate(svgTextBase, currentY, totalAdvance, textAlign) + : GetAlignedStartCoordinate(currentX, totalAdvance, textAlign); + var drawX = isVertical ? currentX : inlineOrigin; + var drawY = isVertical ? inlineOrigin : currentY; + if (!TryCreateUnpositionedTextRunMetrics( + run.StyleSource, + viewport, + assetLoader, + paint, + drawX, + drawY, + GetLogicalStartTextAlign(svgTextBase), + sources, + totalAdvance, + out var clusters, + out var runLength)) + { + return false; + } + + var builder = new SvgTextContentMetricsBuilder(); + builder.AppendRun(clusters, runLength); + metrics = builder.Build(); + return metrics.NumberOfChars > 0; + } + private static bool TryCollectSequentialTextContentRuns( SvgTextBase svgTextBase, bool trimLeadingWhitespaceAtStart, @@ -1510,6 +1733,12 @@ private static bool TryCreateTextRunMetrics( return TryCreatePlacedTextRunMetrics(svgTextBase, text, viewport, assetLoader, placements, out clusters, out runLength); } + if (rotations is not { Length: > 0 } && + TryCreateBidiClusterSources(svgTextBase, text, viewport, paint, assetLoader, out var bidiClusters, out var bidiAdvance)) + { + return TryCreateUnpositionedTextRunMetrics(svgTextBase, viewport, assetLoader, paint, anchorX, anchorY, textAlign, bidiClusters, bidiAdvance, out clusters, out runLength); + } + if (TryCreateSvgFontTextRunMetrics(svgTextBase, text, paint, anchorX, anchorY, textAlign, assetLoader, out clusters, out runLength)) { return true; @@ -1637,11 +1866,23 @@ private static bool TryCreatePlacedTextRunMetrics( var extentPlacement = placement with { RotationDegrees = 0f }; var startOffset = placement.Point.X - originX; var endOffset = startOffset + (source.Advance * placement.ScaleX); - var extent = sourcesUseSvgFont - ? TryMeasureTextDomClusterBounds(svgTextBase, source.Text, extentPlacement, paint, assetLoader, out var measuredExtent, out _) - ? measuredExtent - : SKRect.Empty - : CreateFallbackTextDomClusterBounds( + var extent = SKRect.Empty; + var hitExtent = SKRect.Empty; + if (sourcesUseSvgFont) + { + _ = TryMeasureTextDomClusterBounds( + svgTextBase, + source.Text, + extentPlacement, + paint, + assetLoader, + out extent, + out hitExtent, + out _); + } + else + { + extent = CreateFallbackTextDomClusterBounds( svgTextBase, extentPlacement.Point, source.Advance, @@ -1649,20 +1890,25 @@ private static bool TryCreatePlacedTextRunMetrics( GetScalePivot(extentPlacement), extentPlacement.RotationDegrees, paint, - assetLoader); + assetLoader, + source.Text, + out hitExtent); + } + var startPoint = placement.Point; var endPoint = new SKPoint(placement.Point.X + (source.Advance * placement.ScaleX), placement.Point.Y); - if (includeWhitespaceMetrics || !IsWhitespaceOnlyText(source.Text)) + var isWhitespace = IsWhitespaceOnlyText(source.Text); + runClusters.Add(new TextDomRunClusterMetric( + includeWhitespaceMetrics || !isWhitespace ? source.Text.Length : 0, + startOffset, + endOffset, + startPoint, + endPoint, + extent, + placement.RotationDegrees) { - runClusters.Add(new TextDomRunClusterMetric( - source.Text.Length, - startOffset, - endOffset, - startPoint, - endPoint, - extent, - placement.RotationDegrees)); - } + HitExtent = hitExtent + }); maxEndOffset = Math.Max(maxEndOffset, endOffset); } @@ -1730,11 +1976,23 @@ private static bool TryCreateTextPathRunMetrics( var endPoint = new SKPoint( startPoint.X + ((float)Math.Cos(rotationRadians) * advance), startPoint.Y + ((float)Math.Sin(rotationRadians) * advance)); - var extent = sourcesUseSvgFont - ? TryMeasureTextDomClusterBounds(svgTextBase, source.Text, adjustedPlacement, paint, assetLoader, out var measuredExtent, out _) - ? measuredExtent - : SKRect.Empty - : CreateFallbackTextDomClusterBounds( + var extent = SKRect.Empty; + var hitExtent = SKRect.Empty; + if (sourcesUseSvgFont) + { + _ = TryMeasureTextDomClusterBounds( + svgTextBase, + source.Text, + adjustedPlacement, + paint, + assetLoader, + out extent, + out hitExtent, + out _); + } + else + { + extent = CreateFallbackTextDomClusterBounds( svgTextBase, startPoint, source.Advance, @@ -1742,19 +2000,23 @@ private static bool TryCreateTextPathRunMetrics( GetScalePivot(adjustedPlacement), placement.RotationDegrees, paint, - assetLoader); + assetLoader, + source.Text, + out hitExtent); + } - if (includeWhitespaceMetrics || !IsWhitespaceOnlyText(source.Text)) + var isWhitespace = IsWhitespaceOnlyText(source.Text); + runClusters.Add(new TextDomRunClusterMetric( + includeWhitespaceMetrics || !isWhitespace ? source.Text.Length : 0, + startOffset, + startOffset + advance, + startPoint, + endPoint, + extent, + placement.RotationDegrees) { - runClusters.Add(new TextDomRunClusterMetric( - source.Text.Length, - startOffset, - startOffset + advance, - startPoint, - endPoint, - extent, - placement.RotationDegrees)); - } + HitExtent = hitExtent + }); maxEndOffset = Math.Max(maxEndOffset, startOffset + advance); previousStartOffset = startOffset; @@ -1804,6 +2066,7 @@ private static TextDomRunClusterMetric[] MergeTextDomRunClustersByGraphemeCluste var startPoint = first.StartPoint; var endPoint = first.EndPoint; var extent = first.Extent; + var hitExtent = first.HitExtent; while (sourceIndex < clusters.Count && sourceCharIndex < clusterEnd) { var source = clusters[sourceIndex]; @@ -1815,6 +2078,7 @@ private static TextDomRunClusterMetric[] MergeTextDomRunClustersByGraphemeCluste endOffset = Math.Max(endOffset, source.EndOffset); endPoint = source.EndPoint; UnionBounds(ref extent, source.Extent); + UnionBounds(ref hitExtent, source.HitExtent); sourceCharIndex += source.CharLength; sourceIndex++; } @@ -1831,7 +2095,10 @@ private static TextDomRunClusterMetric[] MergeTextDomRunClustersByGraphemeCluste startPoint, endPoint, extent, - first.RotationDegrees)); + first.RotationDegrees) + { + HitExtent = hitExtent + }); } return sourceIndex == clusters.Count && sourceCharIndex == text.Length @@ -1880,11 +2147,19 @@ private static void NormalizeTextPathClusterPoints(List current.Extent.Top, Math.Max(startPoint.X, endPoint.X), current.Extent.Bottom); + var hitExtent = current.HitExtent.IsEmpty + ? current.HitExtent + : new SKRect( + Math.Min(startPoint.X, endPoint.X), + current.HitExtent.Top, + Math.Max(startPoint.X, endPoint.X), + current.HitExtent.Bottom); runClusters[i] = current with { StartPoint = startPoint, EndPoint = endPoint, - Extent = extent + Extent = extent, + HitExtent = hitExtent }; } } @@ -1924,9 +2199,10 @@ private static bool TryCreateUnpositionedTextRunMetrics( 0f, 1f, startX + source.RelativeOffset); - if (!TryMeasureTextDomClusterBounds(svgTextBase, source.Text, placement, paint, assetLoader, out var extent, out _)) + if (!TryMeasureTextDomClusterBounds(svgTextBase, source.Text, placement, paint, assetLoader, out var extent, out var hitExtent, out _)) { extent = SKRect.Empty; + hitExtent = SKRect.Empty; } runClusters[i] = new TextDomRunClusterMetric( @@ -1936,7 +2212,11 @@ private static bool TryCreateUnpositionedTextRunMetrics( placement.Point, new SKPoint(placement.Point.X + source.Advance, placement.Point.Y), extent, - 0f); + 0f) + { + HitExtent = hitExtent, + StartCharIndex = source.FirstCharIndex + }; } clusters = runClusters; @@ -1986,13 +2266,17 @@ private static bool TryCreateSvgFontTextRunMetrics( var advance = Math.Max(0f, nextRelativeOffset - placement.RelativeX); var point = new SKPoint(startX + placement.RelativeX, anchorY); var bounds = placement.RelativeBounds; - var extent = bounds.IsEmpty + var glyphExtent = bounds.IsEmpty ? SKRect.Empty : new SKRect( bounds.Left + startX, bounds.Top + anchorY, bounds.Right + startX, bounds.Bottom + anchorY); + var hitExtent = IsWhitespaceOnlyText(clusterText) || glyphExtent.IsEmpty + ? GetTextAdvanceBox(svgTextBase, point.X, point.Y, advance, paint, assetLoader) + : glyphExtent; + var extent = glyphExtent; extent = ExpandTextBoundsWithAdvanceBox(svgTextBase, extent, point.X, point.Y, advance, paint, assetLoader); runClusters.Add(new TextDomRunClusterMetric( @@ -2002,7 +2286,10 @@ private static bool TryCreateSvgFontTextRunMetrics( point, new SKPoint(point.X + advance, point.Y), extent, - 0f)); + 0f) + { + HitExtent = hitExtent + }); } clusters = runClusters.ToArray(); @@ -2037,6 +2324,156 @@ private static bool TryCreateClusterSources( return sources.Length > 0; } + private static bool TryCreateBidiClusterSources( + SvgTextBase svgTextBase, + string text, + SKRect viewport, + SKPaint paint, + ISvgAssetLoader assetLoader, + out TextDomClusterSource[] sources, + out float totalAdvance) + { + sources = Array.Empty(); + totalAdvance = 0f; + if (string.IsNullOrEmpty(text) || + IsVerticalWritingMode(svgTextBase) || + HasOwnTextLengthAdjustment(svgTextBase)) + { + return false; + } + + var baseDirection = IsRightToLeft(svgTextBase) ? SvgTextDirection.RightToLeft : SvgTextDirection.LeftToRight; + var mode = SvgTextBidiResolver.ResolveUnicodeBidi(svgTextBase); + var visualRuns = SvgTextBidiResolver.CreateVisualRuns(text, baseDirection, mode); + if (visualRuns.Count <= 1) + { + return false; + } + + var preserveLogicalRtlPlacement = mode == SvgUnicodeBidiMode.PlainText && + ResolvePlainTextDirection(text, baseDirection) == SvgTextDirection.RightToLeft && + !HasPlainTextParagraphSeparator(text); + if (preserveLogicalRtlPlacement) + { + visualRuns = visualRuns.OrderBy(static run => run.StartCharIndex).ToList(); + } + + var codepoints = SplitCodepoints(text); + if (codepoints.Count == 0) + { + return false; + } + + var charOffsets = CreateCodepointCharOffsets(codepoints); + var advances = MeasureNaturalCodepointAdvances(svgTextBase, text, codepoints, viewport, assetLoader); + if (advances.Length != codepoints.Count) + { + return false; + } + + var bidiSources = new List(codepoints.Count); + for (var runIndex = 0; runIndex < visualRuns.Count; runIndex++) + { + var run = visualRuns[runIndex]; + if (!TryGetCodepointRange(charOffsets, run.StartCharIndex, run.StartCharIndex + run.Length, out var startCodepointIndex, out var endCodepointIndex)) + { + return false; + } + + if (run.Direction == SvgTextDirection.RightToLeft && !preserveLogicalRtlPlacement) + { + for (var i = endCodepointIndex - 1; i >= startCodepointIndex; i--) + { + AppendBidiClusterSource(codepoints, charOffsets, advances, i, bidiSources, ref totalAdvance); + } + + continue; + } + + for (var i = startCodepointIndex; i < endCodepointIndex; i++) + { + AppendBidiClusterSource(codepoints, charOffsets, advances, i, bidiSources, ref totalAdvance); + } + } + + sources = bidiSources.ToArray(); + return sources.Length > 0; + } + + private static void AppendBidiClusterSource( + IReadOnlyList codepoints, + IReadOnlyList charOffsets, + IReadOnlyList advances, + int codepointIndex, + List sources, + ref float totalAdvance) + { + var advance = codepointIndex >= 0 && codepointIndex < advances.Count + ? Math.Max(0f, advances[codepointIndex]) + : 0f; + sources.Add(new TextDomClusterSource( + codepointIndex, + codepoints[codepointIndex], + totalAdvance, + advance) + { + FirstCharIndex = charOffsets[codepointIndex] + }); + totalAdvance += advance; + } + + private static SvgTextDirection ResolvePlainTextDirection(string text, SvgTextDirection fallback) + { + var codepoints = SplitCodepoints(text); + for (var i = 0; i < codepoints.Count; i++) + { + var direction = SvgTextBidiResolver.GetStrongDirection(codepoints[i]); + if (direction > 0) + { + return SvgTextDirection.LeftToRight; + } + + if (direction < 0) + { + return SvgTextDirection.RightToLeft; + } + } + + return fallback; + } + + private static bool HasPlainTextParagraphSeparator(string text) + { + return text.IndexOf('\n') >= 0 || text.IndexOf('\r') >= 0; + } + + private static bool TryGetCodepointRange( + IReadOnlyList charOffsets, + int startCharIndex, + int endCharIndex, + out int startCodepointIndex, + out int endCodepointIndex) + { + startCodepointIndex = GetCodepointBoundaryIndexFromCharOffset(charOffsets, startCharIndex); + endCodepointIndex = GetCodepointBoundaryIndexFromCharOffset(charOffsets, endCharIndex); + return startCodepointIndex >= 0 && + endCodepointIndex >= startCodepointIndex && + endCodepointIndex < charOffsets.Count; + } + + private static int GetCodepointBoundaryIndexFromCharOffset(IReadOnlyList charOffsets, int charOffset) + { + for (var i = 0; i < charOffsets.Count; i++) + { + if (charOffsets[i] == charOffset) + { + return i; + } + } + + return -1; + } + private static bool TryCreateSvgFontClusterSources( SvgTextBase svgTextBase, string text, @@ -2359,16 +2796,35 @@ private static SKRect CreateFallbackTextDomClusterBounds( SKPoint scalePivot, float rotationDegrees, SKPaint paint, - ISvgAssetLoader assetLoader) + ISvgAssetLoader assetLoader, + string text, + out SKRect hitExtent) { var metrics = assetLoader.GetFontMetrics(paint); - var bounds = new SKRect( + var cellBounds = new SKRect( point.X, point.Y + metrics.Ascent, point.X + advance, point.Y + metrics.Descent); + var bounds = cellBounds; + if (!IsWhitespaceOnlyText(text) && + TryGetRenderedTextLocalBounds(text, paint, assetLoader, out var glyphBounds)) + { + hitExtent = new SKRect( + point.X + glyphBounds.Left, + point.Y + glyphBounds.Top, + point.X + glyphBounds.Right, + point.Y + glyphBounds.Bottom); + } + else + { + hitExtent = cellBounds; + } + bounds = ExpandTextBoundsWithAdvanceBox(svgTextBase, bounds, point.X, point.Y, advance, paint, assetLoader); bounds = ScaleBoundsX(bounds, scalePivot, scaleX); + hitExtent = ScaleBoundsX(hitExtent, scalePivot, scaleX); + hitExtent = RotateBounds(hitExtent, point, rotationDegrees); return RotateBounds(bounds, point, rotationDegrees); } @@ -2379,16 +2835,23 @@ private static bool TryMeasureTextDomClusterBounds( SKPaint paint, ISvgAssetLoader assetLoader, out SKRect bounds, + out SKRect hitExtent, out float advance) { + hitExtent = SKRect.Empty; var localPaint = paint.Clone(); if (SvgFontTextRenderer.TryGetLayout(svgTextBase, text, localPaint, assetLoader, out var svgFontLayout) && svgFontLayout is not null) { bounds = svgFontLayout.GetBounds(placement.Point.X, placement.Point.Y); + hitExtent = IsWhitespaceOnlyText(text) || bounds.IsEmpty + ? GetTextAdvanceBox(svgTextBase, placement.Point.X, placement.Point.Y, svgFontLayout.Advance, localPaint, assetLoader) + : bounds; bounds = ExpandTextBoundsWithHorizontalAdvanceBox(bounds, placement.Point.X, svgFontLayout.Advance); bounds = ScaleBoundsX(bounds, GetScalePivot(placement), placement.ScaleX); bounds = RotateBounds(bounds, placement.Point, placement.RotationDegrees); + hitExtent = ScaleBoundsX(hitExtent, GetScalePivot(placement), placement.ScaleX); + hitExtent = RotateBounds(hitExtent, placement.Point, placement.RotationDegrees); advance = svgFontLayout.Advance * placement.ScaleX; return true; } @@ -2401,6 +2864,9 @@ private static bool TryMeasureTextDomClusterBounds( placement.Point.Y + glyphBounds.Top, placement.Point.X + glyphBounds.Right, placement.Point.Y + glyphBounds.Bottom); + hitExtent = IsWhitespaceOnlyText(text) + ? GetTextAdvanceBox(svgTextBase, placement.Point.X, placement.Point.Y, resolved.Advance, resolved.Paint, assetLoader) + : bounds; } else { @@ -2410,11 +2876,14 @@ private static bool TryMeasureTextDomClusterBounds( placement.Point.Y + metrics.Ascent, placement.Point.X + resolved.Advance, placement.Point.Y + metrics.Descent); + hitExtent = bounds; } bounds = ExpandTextBoundsWithAdvanceBox(svgTextBase, bounds, placement.Point.X, placement.Point.Y, resolved.Advance, resolved.Paint, assetLoader); bounds = ScaleBoundsX(bounds, GetScalePivot(placement), placement.ScaleX); bounds = RotateBounds(bounds, placement.Point, placement.RotationDegrees); + hitExtent = ScaleBoundsX(hitExtent, GetScalePivot(placement), placement.ScaleX); + hitExtent = RotateBounds(hitExtent, placement.Point, placement.RotationDegrees); advance = resolved.Advance * placement.ScaleX; return true; } diff --git a/src/Svg.SceneGraph/SvgSceneTextCompiler.cs b/src/Svg.SceneGraph/SvgSceneTextCompiler.cs index f343d116d1..18d69c076c 100644 --- a/src/Svg.SceneGraph/SvgSceneTextCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneTextCompiler.cs @@ -466,6 +466,7 @@ public InlineSizeTextLine( float startX, float baselineY, float logicalAdvance, + bool placeVisualRunsRightToLeft, bool shouldClip) { Runs = runs; @@ -474,6 +475,7 @@ public InlineSizeTextLine( StartX = startX; BaselineY = baselineY; LogicalAdvance = logicalAdvance; + PlaceVisualRunsRightToLeft = placeVisualRunsRightToLeft; ShouldClip = shouldClip; } @@ -489,6 +491,8 @@ public InlineSizeTextLine( public float LogicalAdvance { get; } + public bool PlaceVisualRunsRightToLeft { get; } + public bool ShouldClip { get; } } @@ -840,6 +844,7 @@ public static bool TryCompile( node.Transform = TransformsService.ToMatrix(svgTextBase.Transforms, svgTextBase, compiledGeometryBounds, viewport); node.TotalTransform = parentTotalTransform.PreConcat(node.Transform); node.TransformedBounds = node.TotalTransform.MapRect(compiledGeometryBounds); + AssignTextContentMetrics(node, svgTextBase, viewport, assetLoader); node.LocalModel = sequentialModel; if (node.LocalModel is null) { @@ -854,6 +859,7 @@ public static bool TryCompile( node.Transform = TransformsService.ToMatrix(svgTextBase.Transforms, svgTextBase, geometryBounds, viewport); node.TotalTransform = parentTotalTransform.PreConcat(node.Transform); node.TransformedBounds = node.TotalTransform.MapRect(geometryBounds); + AssignTextContentMetrics(node, svgTextBase, viewport, assetLoader); if (!node.IsRenderable) { @@ -890,6 +896,18 @@ public static bool TryCompile( return true; } + private static void AssignTextContentMetrics( + SvgSceneNode node, + SvgTextBase svgTextBase, + SKRect viewport, + ISvgAssetLoader assetLoader) + { + node.TextContentMetrics = TryCreateTextContentMetrics(svgTextBase, viewport, assetLoader, out var metrics) && + metrics.HasHitTestCells + ? metrics + : null; + } + private static bool TryCompileSequentialText( SvgTextBase svgTextBase, SKRect viewport, @@ -6349,22 +6367,46 @@ private static bool TryDrawInlineSizeTextOverflowRuns( canvas.ClipRect(line.ClipRect, SKClipOperation.Intersect); } - var drawX = line.StartX; - var drawY = line.BaselineY; - for (var i = 0; i < line.VisualRuns.Count; i++) - { - var run = line.VisualRuns[i]; - DrawTextStringAlignedLeft( - run.StyleSource, - run.Text, - ref drawX, - ref drawY, - geometryBounds, - ignoreAttributes, - canvas, - assetLoader, - getElementAddressKey, - contextPaint); + if (line.PlaceVisualRunsRightToLeft) + { + var cursorX = GetInlineSizeVisualRunCursorX(line); + for (var i = 0; i < line.VisualRuns.Count; i++) + { + var run = line.VisualRuns[i]; + var drawX = TakeInlineSizeVisualRunX(line, run, ref cursorX); + var drawY = line.BaselineY; + DrawTextStringAlignedLeft( + run.StyleSource, + run.Text, + ref drawX, + ref drawY, + geometryBounds, + ignoreAttributes, + canvas, + assetLoader, + getElementAddressKey, + contextPaint); + } + } + else + { + var drawX = line.StartX; + var drawY = line.BaselineY; + for (var i = 0; i < line.VisualRuns.Count; i++) + { + var run = line.VisualRuns[i]; + DrawTextStringAlignedLeft( + run.StyleSource, + run.Text, + ref drawX, + ref drawY, + geometryBounds, + ignoreAttributes, + canvas, + assetLoader, + getElementAddressKey, + contextPaint); + } } if (line.ShouldClip) @@ -6396,22 +6438,44 @@ private static bool TryMeasureInlineSizeTextOverflowRuns( for (var lineIndex = 0; lineIndex < layout.Lines.Count; lineIndex++) { var line = layout.Lines[lineIndex]; - var drawX = line.StartX; - var drawY = line.BaselineY; - for (var i = 0; i < line.VisualRuns.Count; i++) + if (line.PlaceVisualRunsRightToLeft) { - var run = line.VisualRuns[i]; - var runBounds = MeasureTextStringBoundsAlignedLeft(run.StyleSource, run.Text, drawX, drawY, viewport, assetLoader, rotations: null, out var advance); - if (!line.ShouldClip) + var cursorX = GetInlineSizeVisualRunCursorX(line); + for (var i = 0; i < line.VisualRuns.Count; i++) { - UnionBounds(ref bounds, runBounds); + var run = line.VisualRuns[i]; + var drawX = TakeInlineSizeVisualRunX(line, run, ref cursorX); + var drawY = line.BaselineY; + var runBounds = MeasureTextStringBoundsAlignedLeft(run.StyleSource, run.Text, drawX, drawY, viewport, assetLoader, rotations: null, out _); + if (!line.ShouldClip) + { + UnionBounds(ref bounds, runBounds); + } + else if (TryIntersectRect(runBounds, line.ClipRect, out var clippedRunBounds)) + { + UnionBounds(ref bounds, clippedRunBounds); + } } - else if (TryIntersectRect(runBounds, line.ClipRect, out var clippedRunBounds)) + } + else + { + var drawX = line.StartX; + var drawY = line.BaselineY; + for (var i = 0; i < line.VisualRuns.Count; i++) { - UnionBounds(ref bounds, clippedRunBounds); - } + var run = line.VisualRuns[i]; + var runBounds = MeasureTextStringBoundsAlignedLeft(run.StyleSource, run.Text, drawX, drawY, viewport, assetLoader, rotations: null, out var advance); + if (!line.ShouldClip) + { + UnionBounds(ref bounds, runBounds); + } + else if (TryIntersectRect(runBounds, line.ClipRect, out var clippedRunBounds)) + { + UnionBounds(ref bounds, clippedRunBounds); + } - ApplyInlineAdvance(run.StyleSource, ref drawX, ref drawY, advance); + ApplyInlineAdvance(run.StyleSource, ref drawX, ref drawY, advance); + } } } @@ -7782,6 +7846,59 @@ private static InlineSizeTextRun[] CreateVisualInlineSizeTextRuns( return visualRuns.Count > 0 ? visualRuns.ToArray() : logicalRuns.ToArray(); } + private static bool ShouldPlacePlainTextInlineSizeLineRightToLeft( + SvgTextBase svgTextBase, + bool isVertical, + IReadOnlyList logicalRuns) + { + if (isVertical || + SvgTextBidiResolver.ResolveUnicodeBidi(svgTextBase) != SvgUnicodeBidiMode.PlainText) + { + return false; + } + + for (var runIndex = 0; runIndex < logicalRuns.Count; runIndex++) + { + var text = logicalRuns[runIndex].Text; + if (string.IsNullOrEmpty(text)) + { + continue; + } + + var codepoints = SplitCodepoints(text); + for (var i = 0; i < codepoints.Count; i++) + { + var direction = SvgTextBidiResolver.GetStrongDirection(codepoints[i]); + if (direction != 0) + { + return direction < 0; + } + } + } + + return false; + } + + private static float GetInlineSizeVisualRunCursorX(InlineSizeTextLine line) + { + return line.PlaceVisualRunsRightToLeft + ? line.StartX + SumInlineSizeTextRunAdvances(line.VisualRuns) + : line.StartX; + } + + private static float TakeInlineSizeVisualRunX(InlineSizeTextLine line, InlineSizeTextRun run, ref float cursorX) + { + if (line.PlaceVisualRunsRightToLeft) + { + cursorX -= run.Advance; + return cursorX; + } + + var runX = cursorX; + cursorX += run.Advance; + return runX; + } + private static bool NeedsInlineSizeBidiOrdering(SvgTextBase svgTextBase, IReadOnlyList logicalRuns) { if (logicalRuns.Count == 0) @@ -7875,7 +7992,8 @@ private static bool TryCreateSingleLineInlineSizeTextLayout( } var visualRuns = CreateVisualInlineSizeTextRuns(svgTextBase, layoutRuns, geometryBounds, assetLoader); - var line = new InlineSizeTextLine(layoutRuns, visualRuns, clipRect, drawX, drawY, totalAdvance, overflows); + var placeVisualRunsRightToLeft = ShouldPlacePlainTextInlineSizeLineRightToLeft(svgTextBase, isVertical, layoutRuns); + var line = new InlineSizeTextLine(layoutRuns, visualRuns, clipRect, drawX, drawY, totalAdvance, placeVisualRunsRightToLeft, overflows); layout = new InlineSizeTextOverflowLayout( new[] { line }, isVertical ? drawX : drawX + totalAdvance, @@ -7964,7 +8082,8 @@ private static bool TryCreateWrappedInlineSizeTextLayout( } var visualLineRuns = CreateVisualInlineSizeTextRuns(svgTextBase, lineRuns, geometryBounds, assetLoader); - lines.Add(new InlineSizeTextLine(lineRuns, visualLineRuns, clipRect, drawX, drawY, logicalLine.Advance, lineOverflows)); + var placeVisualRunsRightToLeft = ShouldPlacePlainTextInlineSizeLineRightToLeft(svgTextBase, isVertical, lineRuns); + lines.Add(new InlineSizeTextLine(lineRuns, visualLineRuns, clipRect, drawX, drawY, logicalLine.Advance, placeVisualRunsRightToLeft, lineOverflows)); hasClippedLine |= lineOverflows; finalX = isVertical ? drawX : drawX + logicalLine.Advance; finalY = isVertical ? drawY + (logicalLine.Advance * inlineDirection) : drawY; diff --git a/src/Svg.SceneGraph/SvgTextLayoutResult.cs b/src/Svg.SceneGraph/SvgTextLayoutResult.cs index 8aabd2759d..d6b0cea184 100644 --- a/src/Svg.SceneGraph/SvgTextLayoutResult.cs +++ b/src/Svg.SceneGraph/SvgTextLayoutResult.cs @@ -665,7 +665,10 @@ internal readonly record struct SvgTextDomClusterMetric( SKPoint StartPoint, SKPoint EndPoint, SKRect Extent, - float RotationDegrees); + float RotationDegrees) +{ + public SKRect HitExtent { get; init; } = Extent; +} internal abstract class SvgTextRenderCommand { diff --git a/src/Svg.Skia.JavaScript/SKSvgJavaScriptRuntime.cs b/src/Svg.Skia.JavaScript/SKSvgJavaScriptRuntime.cs index 7dd7d3cff5..d691e7c169 100644 --- a/src/Svg.Skia.JavaScript/SKSvgJavaScriptRuntime.cs +++ b/src/Svg.Skia.JavaScript/SKSvgJavaScriptRuntime.cs @@ -32,7 +32,7 @@ public ISKSvgJavaScriptRuntime Create(SvgDocument document, SKSvgJavaScriptRunti } } - private sealed class SvgJavaScriptRuntimeAdapter : ISKSvgJavaScriptRuntime + private sealed class SvgJavaScriptRuntimeAdapter : ISKSvgJavaScriptRuntime, ISKSvgJavaScriptViewerRuntime { private readonly SvgJavaScriptRuntime _runtime; @@ -55,6 +55,11 @@ public void SetTextContentHost(ISKSvgJavaScriptTextContentHost? textContentHost) _runtime.TextContentHost = textContentHost is null ? null : new SvgJavaScriptTextContentHostAdapter(textContentHost); } + public void SetViewerHost(ISKSvgJavaScriptViewerHost? viewerHost) + { + _runtime.ViewerHost = viewerHost is null ? null : new SvgJavaScriptViewerHostAdapter(viewerHost); + } + public void ExecuteDocumentScripts(bool dispatchLoadEvent) { _runtime.ExecuteDocumentScripts(dispatchLoadEvent); @@ -185,7 +190,7 @@ public bool TryGetBaseAttributeValue(SvgElement element, string attributeName, o } } - private sealed class SvgJavaScriptTextContentHostAdapter : ISvgJavaScriptTextContentHost + private sealed class SvgJavaScriptTextContentHostAdapter : ISvgJavaScriptTextContentHost, ISvgJavaScriptTextSelectionHost { private readonly ISKSvgJavaScriptTextContentHost _host; @@ -240,9 +245,114 @@ public void SelectSubString(SvgTextBase textContentElement, int charnum, int nch _host.SelectSubString(textContentElement, charnum, nchars); } + public bool BeginTextSelection(SvgTextBase textContentElement, int anchorCharnum) + { + return _host is ISKSvgJavaScriptTextSelectionHost selectionHost && + selectionHost.TryBeginTextSelection(textContentElement, anchorCharnum); + } + + public bool ExtendTextSelection(SvgTextBase textContentElement, int focusCharnum) + { + return _host is ISKSvgJavaScriptTextSelectionHost selectionHost && + selectionHost.TryExtendTextSelection(textContentElement, focusCharnum); + } + + public bool SelectTextRange(SvgTextBase textContentElement, int anchorCharnum, int focusCharnum) + { + return _host is ISKSvgJavaScriptTextSelectionHost selectionHost && + selectionHost.TrySelectTextRange(textContentElement, anchorCharnum, focusCharnum); + } + + public void ClearTextSelection() + { + if (_host is ISKSvgJavaScriptTextSelectionHost selectionHost) + { + selectionHost.ClearTextSelection(); + } + } + + public SvgJavaScriptTextSelection? GetTextSelection(SvgTextBase? textContentElement) + { + if (_host is not ISKSvgJavaScriptTextSelectionHost selectionHost || + !selectionHost.TryGetTextSelection(textContentElement, out var selection)) + { + return null; + } + + return ConvertSelection(selection); + } + private static SvgJavaScriptPoint ConvertPoint(SKPoint point) { return new SvgJavaScriptPoint(point.X, point.Y); } + + private static SvgJavaScriptRect ConvertRect(SKRect rect) + { + return new SvgJavaScriptRect(rect.Left, rect.Top, rect.Width, rect.Height); + } + + private static SvgJavaScriptTextSelection ConvertSelection(SKSvg.SvgTextSelectionRange selection) + { + return new SvgJavaScriptTextSelection( + selection.ElementId, + selection.Charnum, + selection.NChars, + selection.StartCharnum, + selection.EndCharnum, + selection.SelectedNChars, + selection.AnchorCharnum, + selection.FocusCharnum, + selection.Direction.ToString().ToLowerInvariant(), + selection.HasCaret, + ConvertPoint(selection.CaretPosition), + ConvertRect(selection.CaretExtent), + ConvertRects(selection.Extents), + ConvertRects(selection.VisualExtents)); + } + + private static SvgJavaScriptRect[] ConvertRects(System.Collections.Generic.IReadOnlyList rects) + { + if (rects.Count == 0) + { + return Array.Empty(); + } + + var result = new SvgJavaScriptRect[rects.Count]; + for (var i = 0; i < rects.Count; i++) + { + result[i] = ConvertRect(rects[i]); + } + + return result; + } + } + + private sealed class SvgJavaScriptViewerHostAdapter : ISvgJavaScriptViewerHost + { + private readonly ISKSvgJavaScriptViewerHost _host; + + public SvgJavaScriptViewerHostAdapter(ISKSvgJavaScriptViewerHost host) + { + _host = host; + } + + public double CurrentScale + { + get => _host.CurrentScale; + set => _host.CurrentScale = value; + } + + public float CurrentTranslateX + { + get => _host.CurrentTranslateX; + set => _host.CurrentTranslateX = value; + } + + public float CurrentTranslateY + { + get => _host.CurrentTranslateY; + set => _host.CurrentTranslateY = value; + } } } diff --git a/src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs b/src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs index 71c0fc0bdc..e01244a1b3 100644 --- a/src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs +++ b/src/Svg.Skia/Interaction/SvgInteractionDispatcher.cs @@ -111,42 +111,107 @@ internal SvgPointerEventArgs( public SKPoint PicturePoint => Input.PicturePoint; } +public sealed class SvgCursorChangedEventArgs : EventArgs +{ + internal SvgCursorChangedEventArgs(string? oldCursor, string? newCursor, SvgElement? targetElement) + { + OldCursor = oldCursor; + NewCursor = newCursor; + TargetElement = targetElement; + } + + public string? OldCursor { get; } + + public string? NewCursor { get; } + + public SvgElement? TargetElement { get; } +} + +public sealed class SvgFocusChangedEventArgs : EventArgs +{ + internal SvgFocusChangedEventArgs(SvgElement? oldElement, SvgElement? newElement, SvgPointerInput input) + { + OldElement = oldElement; + NewElement = newElement; + Input = input; + } + + public SvgElement? OldElement { get; } + + public SvgElement? NewElement { get; } + + public SvgPointerInput Input { get; } +} + public sealed class SvgInteractionDispatchResult { - internal SvgInteractionDispatchResult(SvgElement? targetElement, string? cursor, bool handled) + internal SvgInteractionDispatchResult( + SvgElement? targetElement, + SvgElement? focusedElement, + string? cursor, + bool handled, + bool defaultPrevented, + bool defaultActionActivated, + bool hyperlinkActivated) { TargetElement = targetElement; + FocusedElement = focusedElement; Cursor = cursor; Handled = handled; + DefaultPrevented = defaultPrevented; + DefaultActionActivated = defaultActionActivated; + HyperlinkActivated = hyperlinkActivated; } public SvgElement? TargetElement { get; } + public SvgElement? FocusedElement { get; } + public string? Cursor { get; } public bool Handled { get; } + + public bool DefaultPrevented { get; } + + public bool DefaultActionActivated { get; } + + public bool HyperlinkActivated { get; } } public sealed class SvgInteractionDispatcher { + private const int MaxMutationRetestPasses = 8; + private readonly SvgEventCallerRegistry _eventCallerRegistry = new(); private SvgElement? _registeredRoot; private SvgElement? _hoveredElement; private SvgElement? _pressedElement; private SvgElement? _capturedElement; + private SvgElement? _focusedElement; + private SvgSceneNode? _hoveredNode; + private SvgSceneNode? _pressedNode; + private SvgSceneNode? _capturedNode; public bool RaiseSvgElementEvents { get; set; } = true; + public bool RetestHoverAfterMutation { get; set; } = true; + public SvgElement? HoveredElement => _hoveredElement; public SvgElement? PressedElement => _pressedElement; public SvgElement? CapturedElement => _capturedElement; + public SvgElement? FocusedElement => _focusedElement; + public string? CurrentCursor { get; private set; } public event EventHandler? Dispatched; + public event EventHandler? CursorChanged; + + public event EventHandler? FocusChanged; + public SvgElement? HitTestTopmostElement(SKSvg? svg, SKPoint picturePoint) { return svg?.HitTestTopmostElement(picturePoint); @@ -167,6 +232,11 @@ public void HandlePointerReleased(SKSvg? svg, SvgPointerInput input) _ = DispatchPointerReleased(svg, input); } + public void HandlePointerClick(SKSvg? svg, SvgPointerInput input) + { + _ = DispatchPointerClick(svg, input); + } + public void HandlePointerWheelChanged(SKSvg? svg, SvgPointerInput input) { _ = DispatchPointerWheelChanged(svg, input); @@ -182,23 +252,33 @@ public SvgInteractionDispatchResult DispatchPointerMoved(SKSvg? svg, SvgPointerI EnsureEventBridge(svg); var animationFrameDirty = false; - var hitTarget = svg?.HitTestTopmostElement(input.PicturePoint); + var pointerRetestDirty = false; + var hitNode = svg?.HitTestTopmostSceneNode(input.PicturePoint); + var hitTarget = hitNode?.HitTestTargetElement; var routeTarget = _capturedElement ?? hitTarget; + var routeNode = _capturedNode ?? hitNode; var handled = false; + var defaultPrevented = false; if (_capturedElement is null) { - handled = UpdateHover(svg, hitTarget, input, ref animationFrameDirty); + handled = UpdateHover(svg, hitNode, input, ref animationFrameDirty, ref pointerRetestDirty); + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); + routeTarget = _hoveredElement; + routeNode = _hoveredNode; } else { - CurrentCursor = ResolveCursor(routeTarget); + SetCurrentCursor(ResolveCursor(routeTarget), routeTarget); } - handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Move, routeTarget, null, input, "onmousemove", ref animationFrameDirty); + var moveResult = DispatchRoutedEventCore(svg, SvgPointerEventType.Move, routeTarget, routeNode, null, input, "onmousemove", ref animationFrameDirty, ref pointerRetestDirty); + handled |= moveResult.Handled; + defaultPrevented |= moveResult.DefaultPrevented; + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); RefreshAnimationFrame(svg, animationFrameDirty); - return CreateResult(_hoveredElement ?? routeTarget, handled); + return CreateResult(_hoveredElement ?? routeTarget, handled, defaultPrevented); } public SvgInteractionDispatchResult DispatchPointerPressed(SKSvg? svg, SvgPointerInput input) @@ -206,14 +286,36 @@ public SvgInteractionDispatchResult DispatchPointerPressed(SKSvg? svg, SvgPointe EnsureEventBridge(svg); var animationFrameDirty = false; - var target = svg?.HitTestTopmostElement(input.PicturePoint); - var handled = UpdateHover(svg, target, input, ref animationFrameDirty); + var pointerRetestDirty = false; + var targetNode = svg?.HitTestTopmostSceneNode(input.PicturePoint); + var target = targetNode?.HitTestTargetElement; + var handled = UpdateHover(svg, targetNode, input, ref animationFrameDirty, ref pointerRetestDirty); + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); + target = _hoveredElement; + targetNode = _hoveredNode; _pressedElement = target; _capturedElement = target; - handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Press, target, null, input, "onmousedown", ref animationFrameDirty); + _pressedNode = targetNode; + _capturedNode = targetNode; + var pressResult = DispatchRoutedEventCore(svg, SvgPointerEventType.Press, target, targetNode, null, input, "onmousedown", ref animationFrameDirty, ref pointerRetestDirty); + handled |= pressResult.Handled; + var defaultPrevented = pressResult.DefaultPrevented; + var defaultActionActivated = false; + if (!pressResult.DefaultPrevented) + { + defaultActionActivated = ApplyFocusDefaultAction(svg, target, input, ref animationFrameDirty, ref pointerRetestDirty); + handled |= defaultActionActivated; + } + RefreshAnimationFrame(svg, animationFrameDirty); - return CreateResult(_hoveredElement ?? target, handled); + return CreateResult(_hoveredElement ?? target, handled, defaultPrevented, defaultActionActivated); + } + + public SvgInteractionDispatchResult DispatchPointerClick(SKSvg? svg, SvgPointerInput input) + { + _ = DispatchPointerPressed(svg, input); + return DispatchPointerReleased(svg, input); } public SvgInteractionDispatchResult DispatchPointerReleased(SKSvg? svg, SvgPointerInput input) @@ -221,37 +323,65 @@ public SvgInteractionDispatchResult DispatchPointerReleased(SKSvg? svg, SvgPoint EnsureEventBridge(svg); var animationFrameDirty = false; - var hitTarget = svg?.HitTestTopmostElement(input.PicturePoint); + var pointerRetestDirty = false; + var hitNode = svg?.HitTestTopmostSceneNode(input.PicturePoint); + var hitTarget = hitNode?.HitTestTargetElement; var routeTarget = _capturedElement ?? hitTarget; + var routeNode = _capturedNode ?? hitNode; var captureWasActive = _capturedElement is not null; var handled = false; + var defaultPrevented = false; + var defaultActionActivated = false; + var hyperlinkActivated = false; if (_capturedElement is null) { - handled = UpdateHover(svg, hitTarget, input, ref animationFrameDirty); + handled = UpdateHover(svg, hitNode, input, ref animationFrameDirty, ref pointerRetestDirty); + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); + hitTarget = _hoveredElement; + hitNode = _hoveredNode; + routeTarget = hitTarget; + routeNode = hitNode; } else { - CurrentCursor = ResolveCursor(routeTarget); + SetCurrentCursor(ResolveCursor(routeTarget), routeTarget); } - handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Release, routeTarget, null, input, "onmouseup", ref animationFrameDirty); + var releaseResult = DispatchRoutedEventCore(svg, SvgPointerEventType.Release, routeTarget, routeNode, null, input, "onmouseup", ref animationFrameDirty, ref pointerRetestDirty); + handled |= releaseResult.Handled; + defaultPrevented |= releaseResult.DefaultPrevented; if (routeTarget is not null && ReferenceEquals(hitTarget, _pressedElement)) { - handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Click, routeTarget, null, input, "onclick", ref animationFrameDirty); + var clickResult = DispatchRoutedEventCore(svg, SvgPointerEventType.Click, routeTarget, routeNode, null, input, "onclick", ref animationFrameDirty, ref pointerRetestDirty); + handled |= clickResult.Handled; + defaultPrevented |= clickResult.DefaultPrevented; + if (!clickResult.DefaultPrevented) + { + hyperlinkActivated = svg?.ActivateHyperlink(routeTarget, input) == true; + defaultActionActivated |= hyperlinkActivated; + handled |= hyperlinkActivated; + } } _pressedElement = null; _capturedElement = null; + _pressedNode = null; + _capturedNode = null; if (captureWasActive) { - handled |= UpdateHover(svg, hitTarget, input, ref animationFrameDirty); + RefreshAnimationFrame(svg, animationFrameDirty); + animationFrameDirty = false; + hitNode = svg?.HitTestTopmostSceneNode(input.PicturePoint); + hitTarget = hitNode?.HitTestTargetElement; + handled |= UpdateHover(svg, hitNode, input, ref animationFrameDirty, ref pointerRetestDirty); } + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); RefreshAnimationFrame(svg, animationFrameDirty); - return CreateResult(_hoveredElement ?? hitTarget, handled); + return CreateResult(_hoveredElement ?? hitTarget, handled, defaultPrevented, defaultActionActivated, hyperlinkActivated); } public SvgInteractionDispatchResult DispatchPointerWheelChanged(SKSvg? svg, SvgPointerInput input) @@ -259,23 +389,33 @@ public SvgInteractionDispatchResult DispatchPointerWheelChanged(SKSvg? svg, SvgP EnsureEventBridge(svg); var animationFrameDirty = false; - var hitTarget = svg?.HitTestTopmostElement(input.PicturePoint); + var pointerRetestDirty = false; + var hitNode = svg?.HitTestTopmostSceneNode(input.PicturePoint); + var hitTarget = hitNode?.HitTestTargetElement; var routeTarget = _capturedElement ?? hitTarget; + var routeNode = _capturedNode ?? hitNode; var handled = false; + var defaultPrevented = false; if (_capturedElement is null) { - handled = UpdateHover(svg, hitTarget, input, ref animationFrameDirty); + handled = UpdateHover(svg, hitNode, input, ref animationFrameDirty, ref pointerRetestDirty); + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); + routeTarget = _hoveredElement; + routeNode = _hoveredNode; } else { - CurrentCursor = ResolveCursor(routeTarget); + SetCurrentCursor(ResolveCursor(routeTarget), routeTarget); } - handled |= DispatchRoutedScroll(svg, routeTarget, input, ref animationFrameDirty); + var scrollResult = DispatchRoutedScroll(svg, routeTarget, routeNode, input, ref animationFrameDirty, ref pointerRetestDirty); + handled |= scrollResult.Handled; + defaultPrevented |= scrollResult.DefaultPrevented; + handled |= RetestHoverAfterPointerMutation(svg, input, ref animationFrameDirty, ref pointerRetestDirty); RefreshAnimationFrame(svg, animationFrameDirty); - return CreateResult(_hoveredElement ?? routeTarget, handled); + return CreateResult(_hoveredElement ?? routeTarget, handled, defaultPrevented); } public SvgInteractionDispatchResult DispatchPointerExited(SvgPointerInput input) @@ -287,21 +427,24 @@ public SvgInteractionDispatchResult DispatchPointerExited(SKSvg? svg, SvgPointer { if (_capturedElement is not null) { - CurrentCursor = null; + SetCurrentCursor(null, null); return CreateResult(_capturedElement, handled: false); } var target = _hoveredElement; + var targetNode = _hoveredNode; var handled = false; var animationFrameDirty = false; + var pointerRetestDirty = false; if (target is { }) { - handled = DispatchRoutedEvent(svg, SvgPointerEventType.Leave, target, null, input, "onmouseout", ref animationFrameDirty); + handled = DispatchRoutedEvent(svg, SvgPointerEventType.Leave, target, targetNode, null, input, "onmouseout", ref animationFrameDirty, ref pointerRetestDirty); } _hoveredElement = null; - CurrentCursor = null; + _hoveredNode = null; + SetCurrentCursor(null, null); RefreshAnimationFrame(svg, animationFrameDirty); return CreateResult(null, handled); @@ -312,56 +455,239 @@ public void Reset() _hoveredElement = null; _pressedElement = null; _capturedElement = null; + _focusedElement = null; + _hoveredNode = null; + _pressedNode = null; + _capturedNode = null; _registeredRoot = null; - CurrentCursor = null; + SetCurrentCursor(null, null); _eventCallerRegistry.Clear(); } - private bool UpdateHover(SKSvg? svg, SvgElement? target, SvgPointerInput input, ref bool animationFrameDirty) + private bool UpdateHover( + SKSvg? svg, + SvgSceneNode? targetNode, + SvgPointerInput input, + ref bool animationFrameDirty, + ref bool pointerRetestDirty) { + var target = targetNode?.HitTestTargetElement; if (ReferenceEquals(target, _hoveredElement)) { - CurrentCursor = ResolveCursor(target); + SetCurrentCursor(ResolveCursor(target), target); return false; } var previous = _hoveredElement; + var previousNode = _hoveredNode; var handled = false; if (previous is { }) { - handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Leave, previous, target, input, "onmouseout", ref animationFrameDirty); + handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Leave, previous, previousNode, target, input, "onmouseout", ref animationFrameDirty, ref pointerRetestDirty); } _hoveredElement = target; - CurrentCursor = ResolveCursor(target); + _hoveredNode = targetNode; + SetCurrentCursor(ResolveCursor(target), target); if (target is { }) { - handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Enter, target, previous, input, "onmouseover", ref animationFrameDirty); + handled |= DispatchRoutedEvent(svg, SvgPointerEventType.Enter, target, targetNode, previous, input, "onmouseover", ref animationFrameDirty, ref pointerRetestDirty); } return handled; } - private SvgInteractionDispatchResult CreateResult(SvgElement? target, bool handled) + private bool RetestHoverAfterPointerMutation( + SKSvg? svg, + SvgPointerInput input, + ref bool animationFrameDirty, + ref bool pointerRetestDirty) + { + if (!RetestHoverAfterMutation || + !pointerRetestDirty || + _capturedElement is not null) + { + return false; + } + + var handled = false; + for (var i = 0; i < MaxMutationRetestPasses && pointerRetestDirty; i++) + { + pointerRetestDirty = false; + RefreshAnimationFrame(svg, animationFrameDirty); + animationFrameDirty = false; + + var targetNode = svg?.HitTestTopmostSceneNode(input.PicturePoint); + handled |= UpdateHover(svg, targetNode, input, ref animationFrameDirty, ref pointerRetestDirty); + } + + return handled; + } + + public SvgInteractionDispatchResult FocusElement(SKSvg? svg, SvgElement? element, SvgPointerInput input) + { + EnsureEventBridge(svg); + + var animationFrameDirty = false; + var pointerRetestDirty = false; + var focusTarget = element is not null && IsFocusableElement(element) ? element : null; + var changed = SetFocusedElement(svg, focusTarget, input, ref animationFrameDirty, ref pointerRetestDirty); + RefreshAnimationFrame(svg, animationFrameDirty); + return CreateResult(focusTarget, changed, defaultActionActivated: changed); + } + + public SvgInteractionDispatchResult BlurFocusedElement(SKSvg? svg, SvgPointerInput input) + { + EnsureEventBridge(svg); + + var animationFrameDirty = false; + var pointerRetestDirty = false; + var previous = _focusedElement; + var changed = SetFocusedElement(svg, null, input, ref animationFrameDirty, ref pointerRetestDirty); + RefreshAnimationFrame(svg, animationFrameDirty); + return CreateResult(previous, changed, defaultActionActivated: changed); + } + + private bool ApplyFocusDefaultAction( + SKSvg? svg, + SvgElement? target, + SvgPointerInput input, + ref bool animationFrameDirty, + ref bool pointerRetestDirty) + { + return SetFocusedElement(svg, ResolveFocusableElement(target), input, ref animationFrameDirty, ref pointerRetestDirty); + } + + private bool SetFocusedElement( + SKSvg? svg, + SvgElement? element, + SvgPointerInput input, + ref bool animationFrameDirty, + ref bool pointerRetestDirty) + { + if (ReferenceEquals(_focusedElement, element)) + { + return false; + } + + var previous = _focusedElement; + if (previous is not null) + { + _ = DispatchJavaScriptFocusEvent(svg, previous, element, "blur", "onblur", bubbles: false, input, ref pointerRetestDirty); + _ = DispatchJavaScriptFocusEvent(svg, previous, element, "focusout", "onfocusout", bubbles: true, input, ref pointerRetestDirty); + } + + _focusedElement = element; + FocusChanged?.Invoke(this, new SvgFocusChangedEventArgs(previous, element, input)); + + if (element is not null) + { + _ = DispatchJavaScriptFocusEvent(svg, element, previous, "focus", "onfocus", bubbles: false, input, ref pointerRetestDirty); + _ = DispatchJavaScriptFocusEvent(svg, element, previous, "focusin", "onfocusin", bubbles: true, input, ref pointerRetestDirty); + } + + animationFrameDirty |= pointerRetestDirty; + return true; + } + + private static bool DispatchJavaScriptFocusEvent( + SKSvg? svg, + SvgElement target, + SvgElement? relatedElement, + string eventType, + string attributeName, + bool bubbles, + SvgPointerInput input, + ref bool pointerRetestDirty) + { + var handled = false; + object? javaScriptEvent = null; + if (bubbles) + { + foreach (var element in BuildRoute(target)) + { + var javaScriptResult = svg?.DispatchJavaScriptEvent(element, target, relatedElement, eventType, attributeName, input, ref javaScriptEvent); + pointerRetestDirty |= javaScriptResult?.Mutated == true; + handled |= javaScriptResult?.DefaultPrevented == true; + if (javaScriptResult?.CancelBubble == true) + { + return true; + } + } + + return handled; + } + + var result = svg?.DispatchJavaScriptEvent(target, target, relatedElement, eventType, attributeName, input, ref javaScriptEvent); + pointerRetestDirty |= result?.Mutated == true; + return result?.DefaultPrevented == true; + } + + private SvgInteractionDispatchResult CreateResult( + SvgElement? target, + bool handled, + bool defaultPrevented = false, + bool defaultActionActivated = false, + bool hyperlinkActivated = false) + { + return new SvgInteractionDispatchResult( + target, + _focusedElement, + CurrentCursor, + handled, + defaultPrevented, + defaultActionActivated, + hyperlinkActivated); + } + + private void SetCurrentCursor(string? cursor, SvgElement? target) + { + if (string.Equals(CurrentCursor, cursor, StringComparison.Ordinal)) + { + return; + } + + var previous = CurrentCursor; + CurrentCursor = cursor; + CursorChanged?.Invoke(this, new SvgCursorChangedEventArgs(previous, cursor, target)); + } + + private readonly struct SvgRoutedEventDispatchResult { - return new SvgInteractionDispatchResult(target, CurrentCursor, handled); + public SvgRoutedEventDispatchResult(bool handled, bool defaultPrevented) + { + Handled = handled; + DefaultPrevented = defaultPrevented; + } + + public bool Handled { get; } + + public bool DefaultPrevented { get; } } - private bool DispatchRoutedScroll(SKSvg? svg, SvgElement? target, SvgPointerInput input, ref bool animationFrameDirty) + private SvgRoutedEventDispatchResult DispatchRoutedScroll( + SKSvg? svg, + SvgElement? target, + SvgSceneNode? targetNode, + SvgPointerInput input, + ref bool animationFrameDirty, + ref bool pointerRetestDirty) { var cursor = ResolveCursor(target); if (target is null) { - return DispatchShared( - SvgPointerEventType.Wheel, - null, - null, - null, - SvgPointerEventRoutePhase.Target, - input, - cursor); + return new SvgRoutedEventDispatchResult( + DispatchShared( + SvgPointerEventType.Wheel, + null, + null, + null, + SvgPointerEventRoutePhase.Target, + input, + cursor), + defaultPrevented: false); } if (DispatchTunnelEvent( @@ -371,16 +697,24 @@ private bool DispatchRoutedScroll(SKSvg? svg, SvgElement? target, SvgPointerInpu input, cursor)) { - return true; + return new SvgRoutedEventDispatchResult(handled: true, defaultPrevented: false); } var handled = false; + var defaultPrevented = false; object? javaScriptEvent = null; foreach (var element in BuildRoute(target)) { - animationFrameDirty |= svg?.RecordAnimationPointerEvent(element, SvgPointerEventType.Wheel) == true; + var animationTriggered = ReferenceEquals(element, target) + ? svg?.RecordAnimationPointerEvent(element, targetNode, SvgPointerEventType.Wheel) == true + : svg?.RecordAnimationPointerEvent(element, SvgPointerEventType.Wheel) == true; + animationFrameDirty |= animationTriggered; + pointerRetestDirty |= animationTriggered; var javaScriptResult = svg?.DispatchJavaScriptEvent(element, target, null, "mousescroll", "onmousescroll", input, ref javaScriptEvent); - handled |= javaScriptResult?.DefaultPrevented == true; + pointerRetestDirty |= javaScriptResult?.Mutated == true; + var javaScriptDefaultPrevented = javaScriptResult?.DefaultPrevented == true; + defaultPrevented |= javaScriptDefaultPrevented; + handled |= javaScriptDefaultPrevented; DispatchSvgMouseScroll(element, input); var routePhase = ReferenceEquals(element, target) ? SvgPointerEventRoutePhase.Target @@ -389,33 +723,51 @@ private bool DispatchRoutedScroll(SKSvg? svg, SvgElement? target, SvgPointerInpu if (DispatchShared(SvgPointerEventType.Wheel, element, target, null, routePhase, input, cursor) || javaScriptResult?.CancelBubble == true) { - return true; + return new SvgRoutedEventDispatchResult(handled: true, defaultPrevented: defaultPrevented); } } - return handled; + return new SvgRoutedEventDispatchResult(handled, defaultPrevented); } private bool DispatchRoutedEvent( SKSvg? svg, SvgPointerEventType eventType, SvgElement? target, + SvgSceneNode? targetNode, + SvgElement? relatedElement, + SvgPointerInput input, + string svgEventName, + ref bool animationFrameDirty, + ref bool pointerRetestDirty) + { + return DispatchRoutedEventCore(svg, eventType, target, targetNode, relatedElement, input, svgEventName, ref animationFrameDirty, ref pointerRetestDirty).Handled; + } + + private SvgRoutedEventDispatchResult DispatchRoutedEventCore( + SKSvg? svg, + SvgPointerEventType eventType, + SvgElement? target, + SvgSceneNode? targetNode, SvgElement? relatedElement, SvgPointerInput input, string svgEventName, - ref bool animationFrameDirty) + ref bool animationFrameDirty, + ref bool pointerRetestDirty) { var cursor = ResolveCursor(target); if (target is null) { - return DispatchShared( - eventType, - null, - null, - relatedElement, - SvgPointerEventRoutePhase.Target, - input, - cursor); + return new SvgRoutedEventDispatchResult( + DispatchShared( + eventType, + null, + null, + relatedElement, + SvgPointerEventRoutePhase.Target, + input, + cursor), + defaultPrevented: false); } if (DispatchTunnelEvent( @@ -425,16 +777,24 @@ private bool DispatchRoutedEvent( input, cursor)) { - return true; + return new SvgRoutedEventDispatchResult(handled: true, defaultPrevented: false); } var handled = false; + var defaultPrevented = false; object? javaScriptEvent = null; foreach (var element in BuildRoute(target)) { - animationFrameDirty |= svg?.RecordAnimationPointerEvent(element, eventType) == true; + var animationTriggered = ReferenceEquals(element, target) + ? svg?.RecordAnimationPointerEvent(element, targetNode, eventType) == true + : svg?.RecordAnimationPointerEvent(element, eventType) == true; + animationFrameDirty |= animationTriggered; + pointerRetestDirty |= animationTriggered; var javaScriptResult = svg?.DispatchJavaScriptEvent(element, target, relatedElement, ToJavaScriptEventType(svgEventName), svgEventName, input, ref javaScriptEvent); - handled |= javaScriptResult?.DefaultPrevented == true; + pointerRetestDirty |= javaScriptResult?.Mutated == true; + var javaScriptDefaultPrevented = javaScriptResult?.DefaultPrevented == true; + defaultPrevented |= javaScriptDefaultPrevented; + handled |= javaScriptDefaultPrevented; DispatchSvgMouseEvent(element, svgEventName, input); var routePhase = ReferenceEquals(element, target) ? SvgPointerEventRoutePhase.Target @@ -443,11 +803,11 @@ private bool DispatchRoutedEvent( if (DispatchShared(eventType, element, target, relatedElement, routePhase, input, cursor) || javaScriptResult?.CancelBubble == true) { - return true; + return new SvgRoutedEventDispatchResult(handled: true, defaultPrevented: defaultPrevented); } } - return handled; + return new SvgRoutedEventDispatchResult(handled, defaultPrevented); } private bool DispatchTunnelEvent( @@ -622,6 +982,68 @@ private void RegisterTree(SvgElement element) return null; } + private static SvgElement? ResolveFocusableElement(SvgElement? target) + { + for (var current = target; current is not null; current = current.Parent) + { + if (IsFocusableElement(current)) + { + return current; + } + } + + return null; + } + + private static bool IsFocusableElement(SvgElement element) + { + if (TryGetBooleanAttribute(element, "focusable", out var focusable)) + { + return focusable; + } + + if (TryGetTabIndex(element, out var tabIndex)) + { + return tabIndex >= 0; + } + + return element is SvgAnchor anchor && + anchor.TryGetEffectiveHrefString(out var href) && + !string.IsNullOrWhiteSpace(href); + } + + private static bool TryGetBooleanAttribute(SvgElement element, string attributeName, out bool value) + { + value = false; + if (!element.TryGetAttribute(attributeName, out var rawValue) || + string.IsNullOrWhiteSpace(rawValue)) + { + return false; + } + + var normalized = rawValue.Trim(); + if (string.Equals(normalized, "true", StringComparison.OrdinalIgnoreCase)) + { + value = true; + return true; + } + + if (string.Equals(normalized, "false", StringComparison.OrdinalIgnoreCase)) + { + value = false; + return true; + } + + return false; + } + + private static bool TryGetTabIndex(SvgElement element, out int tabIndex) + { + tabIndex = 0; + return element.TryGetAttribute("tabindex", out var rawValue) && + int.TryParse(rawValue?.Trim(), out tabIndex); + } + private static int ToSvgMouseButtonValue(SvgMouseButton button) { return button switch diff --git a/src/Svg.Skia/SKSvg.Model.cs b/src/Svg.Skia/SKSvg.Model.cs index 6a78cffd90..15a15b1a1b 100644 --- a/src/Svg.Skia/SKSvg.Model.cs +++ b/src/Svg.Skia/SKSvg.Model.cs @@ -157,6 +157,17 @@ public SvgTextSelectionRange[] TextSelections } } + public bool HasTextSelection + { + get + { + lock (Sync) + { + return _textSelections.Count > 0; + } + } + } + public bool HasAnimations => AnimationController?.HasAnimations == true; public TimeSpan AnimationTime => AnimationController?.Clock.CurrentTime ?? TimeSpan.Zero; @@ -171,32 +182,151 @@ public TimeSpan AnimationMinimumRenderInterval public int LastAnimationDirtyTargetCount { get; private set; } + public enum SvgTextSelectionDirection + { + None, + Forward, + Backward + } + public readonly struct SvgTextSelectionRange { public SvgTextSelectionRange(string? elementId, int charnum, int nchars, IReadOnlyList extents) - : this(elementId, elementAddress: null, charnum, nchars, extents) + : this( + elementId, + elementAddress: null, + textContentElement: null, + charnum, + nchars, + startCharnum: charnum, + endCharnum: GetRequestedEndCharnum(charnum, nchars), + anchorCharnum: charnum, + focusCharnum: GetRequestedFocusCharnum(charnum, nchars), + direction: SvgTextSelectionDirection.Forward, + hasCaret: false, + caretPosition: default, + caretExtent: default, + extents) { } internal SvgTextSelectionRange(string? elementId, string? elementAddress, int charnum, int nchars, IReadOnlyList extents) + : this( + elementId, + elementAddress, + textContentElement: null, + charnum, + nchars, + startCharnum: charnum, + endCharnum: GetRequestedEndCharnum(charnum, nchars), + anchorCharnum: charnum, + focusCharnum: GetRequestedFocusCharnum(charnum, nchars), + direction: SvgTextSelectionDirection.Forward, + hasCaret: false, + caretPosition: default, + caretExtent: default, + extents) + { + } + + private SvgTextSelectionRange( + string? elementId, + string? elementAddress, + SvgTextBase? textContentElement, + int charnum, + int nchars, + int startCharnum, + int endCharnum, + int anchorCharnum, + int focusCharnum, + SvgTextSelectionDirection direction, + bool hasCaret, + SKPoint caretPosition, + SKRect caretExtent, + IReadOnlyList extents) { ElementId = elementId; ElementAddress = elementAddress; + TextContentElement = textContentElement; Charnum = charnum; NChars = nchars; + StartCharnum = startCharnum; + EndCharnum = endCharnum; + AnchorCharnum = anchorCharnum; + FocusCharnum = focusCharnum; + Direction = direction; + HasCaret = hasCaret; + CaretPosition = caretPosition; + CaretExtent = caretExtent; Extents = CopySelectionExtents(extents); + VisualExtents = CopySelectionExtentsInVisualOrder(extents); } public string? ElementId { get; } public string? ElementAddress { get; } + internal SvgTextBase? TextContentElement { get; } + public int Charnum { get; } public int NChars { get; } + public int StartCharnum { get; } + + public int EndCharnum { get; } + + public int SelectedNChars => Math.Max(0, EndCharnum - StartCharnum); + + public bool IsCollapsed => SelectedNChars == 0 && HasCaret; + + public int AnchorCharnum { get; } + + public int FocusCharnum { get; } + + public SvgTextSelectionDirection Direction { get; } + + public bool HasCaret { get; } + + public SKPoint CaretPosition { get; } + + public SKRect CaretExtent { get; } + public IReadOnlyList Extents { get; } + public IReadOnlyList VisualExtents { get; } + + internal static SvgTextSelectionRange Create( + SvgTextBase textContentElement, + int charnum, + int nchars, + int anchorCharnum, + int focusCharnum, + SvgTextSelectionDirection direction, + SvgSceneTextCompiler.SvgTextContentMetrics metrics, + IReadOnlyList extents) + { + var startCharnum = charnum; + var endCharnum = GetBoundedEndCharnum(charnum, nchars, metrics.NumberOfChars); + var hasCaret = TryGetCaretMetadata(metrics, startCharnum, endCharnum, direction, out var caretPosition, out var caretExtent); + + return new SvgTextSelectionRange( + textContentElement.ID, + SvgSceneCompiler.TryGetElementAddressKey(textContentElement), + textContentElement, + charnum, + nchars, + startCharnum, + endCharnum, + anchorCharnum, + focusCharnum, + direction, + hasCaret, + caretPosition, + caretExtent, + extents); + } + private static IReadOnlyList CopySelectionExtents(IReadOnlyList extents) { if (extents is null) @@ -217,6 +347,110 @@ private static IReadOnlyList CopySelectionExtents(IReadOnlyList return Array.AsReadOnly(copy); } + + private static IReadOnlyList CopySelectionExtentsInVisualOrder(IReadOnlyList extents) + { + if (extents is null) + { + throw new ArgumentNullException(nameof(extents)); + } + + if (extents.Count == 0) + { + return Array.Empty(); + } + + var copy = new SKRect[extents.Count]; + for (var i = 0; i < extents.Count; i++) + { + copy[i] = extents[i]; + } + + Array.Sort(copy, CompareSelectionExtentsInVisualOrder); + return Array.AsReadOnly(copy); + } + + private static int CompareSelectionExtentsInVisualOrder(SKRect left, SKRect right) + { + if (AreSameVisualSelectionLine(left, right)) + { + return left.Left.CompareTo(right.Left); + } + + var topComparison = left.Top.CompareTo(right.Top); + return topComparison != 0 ? topComparison : left.Left.CompareTo(right.Left); + } + + private static bool AreSameVisualSelectionLine(SKRect left, SKRect right) + { + var verticalOverlap = Math.Min(left.Bottom, right.Bottom) - Math.Max(left.Top, right.Top); + var minHeight = Math.Min(Math.Abs(left.Height), Math.Abs(right.Height)); + return minHeight > 0f && verticalOverlap >= minHeight * 0.5f; + } + + private static int GetRequestedEndCharnum(int charnum, int nchars) + { + if (nchars <= 0) + { + return charnum; + } + + var requestedEnd = (long)charnum + nchars; + return requestedEnd >= int.MaxValue ? int.MaxValue : (int)requestedEnd; + } + + private static int GetBoundedEndCharnum(int charnum, int nchars, int numberOfChars) + { + if (nchars <= 0) + { + return charnum; + } + + var requestedEnd = (long)charnum + nchars; + return requestedEnd >= numberOfChars ? numberOfChars : (int)requestedEnd; + } + + private static int GetRequestedFocusCharnum(int charnum, int nchars) + { + return nchars <= 0 ? charnum : GetRequestedEndCharnum(charnum, nchars) - 1; + } + + private static bool TryGetCaretMetadata( + SvgSceneTextCompiler.SvgTextContentMetrics metrics, + int startCharnum, + int endCharnum, + SvgTextSelectionDirection direction, + out SKPoint caretPosition, + out SKRect caretExtent) + { + caretPosition = default; + caretExtent = default; + + if (metrics.NumberOfChars == 0 || + startCharnum < 0 || + endCharnum < startCharnum || + endCharnum > metrics.NumberOfChars) + { + return false; + } + + if (startCharnum == endCharnum) + { + return metrics.TryGetCaretMetadata(startCharnum, out caretPosition, out caretExtent); + } + + if (direction == SvgTextSelectionDirection.Backward) + { + caretPosition = metrics.GetStartPositionOfChar(startCharnum); + caretExtent = metrics.GetExtentOfChar(startCharnum); + return true; + } + + var focusCharnum = endCharnum - 1; + caretPosition = metrics.GetEndPositionOfChar(focusCharnum); + caretExtent = metrics.GetExtentOfChar(focusCharnum); + return true; + } } private SkiaSharp.SKPicture? _picture; @@ -424,7 +658,7 @@ public SKSvg Clone() if (HasAnimations) { - clone.ReplaceAnimationController(new SvgAnimationController(sourceDocumentClone)); + clone.ReplaceAnimationController(new SvgAnimationController(sourceDocumentClone, AnimationController?.WallclockTimeOrigin)); clone.SetAnimationTime(AnimationTime); } else if (clone._javaScriptRuntime?.MutationVersion > 0) @@ -726,6 +960,48 @@ public void ResetAnimation() AnimationController?.Reset(); } + public bool BeginAnimationElement(string animationElementId) + { + return BeginAnimationElement(animationElementId, TimeSpan.Zero); + } + + public bool BeginAnimationElement(string animationElementId, TimeSpan offset) + { + return TryGetAnimationElement(animationElementId, out var animation) && + BeginAnimationElement(animation, offset); + } + + public bool BeginAnimationElement(SvgAnimationElement animation) + { + return BeginAnimationElement(animation, TimeSpan.Zero); + } + + public bool BeginAnimationElement(SvgAnimationElement? animation, TimeSpan offset) + { + return ScheduleAnimationElement(animation, offset, begin: true); + } + + public bool EndAnimationElement(string animationElementId) + { + return EndAnimationElement(animationElementId, TimeSpan.Zero); + } + + public bool EndAnimationElement(string animationElementId, TimeSpan offset) + { + return TryGetAnimationElement(animationElementId, out var animation) && + EndAnimationElement(animation, offset); + } + + public bool EndAnimationElement(SvgAnimationElement animation) + { + return EndAnimationElement(animation, TimeSpan.Zero); + } + + public bool EndAnimationElement(SvgAnimationElement? animation, TimeSpan offset) + { + return ScheduleAnimationElement(animation, offset, begin: false); + } + public bool NotifyPointerEvent(SvgElement? element, SvgPointerEventType eventType) { if (!RecordAnimationPointerEvent(element, eventType)) @@ -737,6 +1013,70 @@ public bool NotifyPointerEvent(SvgElement? element, SvgPointerEventType eventTyp return true; } + public bool NotifyPointerEvent(SvgElement? element, SvgPointerEventType eventType, TimeSpan presentationTime) + { + if (!RecordAnimationPointerEvent(element, eventType, presentationTime)) + { + return false; + } + + RefreshCurrentAnimationFrame(bypassThrottle: true); + return true; + } + + public bool NotifyAccessKey(string? accessKey) + { + if (!RecordAnimationAccessKey(accessKey)) + { + return false; + } + + RefreshCurrentAnimationFrame(bypassThrottle: true); + return true; + } + + public bool NotifyAccessKey(string? accessKey, TimeSpan presentationTime) + { + if (!RecordAnimationAccessKey(accessKey, presentationTime)) + { + return false; + } + + RefreshCurrentAnimationFrame(bypassThrottle: true); + return true; + } + + private bool TryGetAnimationElement(string animationElementId, out SvgAnimationElement animation) + { + animation = null!; + if (string.IsNullOrWhiteSpace(animationElementId) || + SourceDocument?.GetElementById(animationElementId) is not SvgAnimationElement resolvedAnimation) + { + return false; + } + + animation = resolvedAnimation; + return true; + } + + private bool ScheduleAnimationElement(SvgAnimationElement? animation, TimeSpan offset, bool begin) + { + if (animation is null || AnimationController is null) + { + return false; + } + + var scheduled = begin + ? AnimationController.BeginElement(animation, offset) + : AnimationController.EndElement(animation, offset); + if (scheduled) + { + NotifyAnimationTimelineMutation(); + } + + return scheduled; + } + public bool FlushPendingAnimationFrame() { if (_pendingAnimationFrameState is not { } pendingFrameState) @@ -883,6 +1223,7 @@ public void Draw(SkiaSharp.SKCanvas canvas) try { canvas.Save(); + ApplyViewerTransform(canvas); if (Wireframe && Model is { }) { var wireframePicture = WireframePicture; @@ -1062,6 +1403,8 @@ private void ApplyTextSelectionRendering(SKPicture model) return; } + RefreshTextSelectionMetrics(); + SvgTextSelectionRange[] selections; lock (Sync) { @@ -1095,15 +1438,15 @@ private static bool HasTextSelectionTarget(SvgTextSelectionRange selection) private bool TryCreateTextSelectionCommand(SvgTextSelectionRange selection, out DrawPathCanvasCommand command) { command = default!; - if (selection.Extents.Count == 0) + if (selection.VisualExtents.Count == 0) { return false; } var path = new SKPath(); - for (var i = 0; i < selection.Extents.Count; i++) + for (var i = 0; i < selection.VisualExtents.Count; i++) { - var extent = selection.Extents[i]; + var extent = selection.VisualExtents[i]; if (!IsRenderableSelectionExtent(extent)) { continue; @@ -1157,10 +1500,15 @@ private static bool InsertTextSelectionCommand(SKPicture picture, SvgTextSelecti for (var i = 0; i < commands.Count; i++) { var current = commands[i]; - if (current is DrawPictureCanvasCommand { Picture: { } nestedPicture } && - InsertTextSelectionCommand(nestedPicture, selection, command)) + if (current is DrawPictureCanvasCommand { Picture: { } nestedPicture } drawPictureCommand && + PictureContainsSelectionTarget(nestedPicture, selection)) { - return true; + var nestedClone = nestedPicture.DeepClone(); + if (InsertTextSelectionCommand(nestedClone, selection, command)) + { + commands[i] = CloneDrawPictureCommandWithPicture(drawPictureCommand, nestedClone); + return true; + } } if (!IsTextDrawingCommand(current) || @@ -1176,6 +1524,43 @@ private static bool InsertTextSelectionCommand(SKPicture picture, SvgTextSelecti return false; } + private static bool PictureContainsSelectionTarget(SKPicture picture, SvgTextSelectionRange selection) + { + var commands = picture.Commands; + if (commands is null) + { + return false; + } + + for (var i = 0; i < commands.Count; i++) + { + var current = commands[i]; + if (IsTextDrawingCommand(current) && + IsSelectionTargetCommand(current, selection)) + { + return true; + } + + if (current is DrawPictureCanvasCommand { Picture: { } nestedPicture } && + PictureContainsSelectionTarget(nestedPicture, selection)) + { + return true; + } + } + + return false; + } + + private static DrawPictureCanvasCommand CloneDrawPictureCommandWithPicture(DrawPictureCanvasCommand source, SKPicture picture) + { + return new DrawPictureCanvasCommand(picture) + { + SourceElementId = source.SourceElementId, + SourceElementAddress = source.SourceElementAddress, + SourceElementTypeName = source.SourceElementTypeName + }; + } + private static bool IsTextDrawingCommand(CanvasCommand command) { return command is DrawPathCanvasCommand or @@ -1268,7 +1653,101 @@ private void OnAnimationFrameChanged(object? sender, SvgAnimationFrameChangedEve internal bool RecordAnimationPointerEvent(SvgElement? element, SvgPointerEventType eventType) { - return AnimationController?.RecordPointerEvent(element, eventType) == true; + return RecordAnimationPointerEvent(element, hitNode: null, eventType, presentationTime: null); + } + + internal bool RecordAnimationPointerEvent(SvgElement? element, SvgPointerEventType eventType, TimeSpan presentationTime) + { + return RecordAnimationPointerEvent(element, hitNode: null, eventType, presentationTime); + } + + internal bool RecordAnimationPointerEvent(SvgElement? element, SvgSceneNode? hitNode, SvgPointerEventType eventType) + { + return RecordAnimationPointerEvent(element, hitNode, eventType, presentationTime: null); + } + + private bool RecordAnimationPointerEvent(SvgElement? element, SvgSceneNode? hitNode, SvgPointerEventType eventType, TimeSpan? presentationTime) + { + if (AnimationController is null) + { + return false; + } + + var recorded = false; + var recordedAddressKeys = new HashSet(StringComparer.Ordinal); + + foreach (var eventElement in EnumerateAnimationEventElements(element, hitNode)) + { + var normalizedElement = NormalizeAnimationEventElement(eventElement); + if (normalizedElement is null || + !TryMarkAnimationEventElement(recordedAddressKeys, normalizedElement)) + { + continue; + } + + recorded |= presentationTime.HasValue + ? AnimationController.RecordPointerEvent(normalizedElement, eventType, presentationTime.Value) + : AnimationController.RecordPointerEvent(normalizedElement, eventType); + } + + return recorded; + } + + private static bool TryMarkAnimationEventElement(HashSet recordedAddressKeys, SvgElement element) + { + var key = SvgElementAddress.Create(element).Key; + return recordedAddressKeys.Add(key); + } + + private static IEnumerable EnumerateAnimationEventElements(SvgElement? targetElement, SvgSceneNode? hitNode) + { + if (targetElement is not null) + { + foreach (var instanceElement in EnumerateUseInstanceAnimationEventElements(targetElement, hitNode)) + { + yield return instanceElement; + } + + yield return targetElement; + } + } + + private static IEnumerable EnumerateUseInstanceAnimationEventElements(SvgElement targetElement, SvgSceneNode? hitNode) + { + if (targetElement is not SvgUse || + hitNode is null || + !ReferenceEquals(hitNode.HitTestTargetElement, targetElement)) + { + yield break; + } + + for (var current = hitNode; current is not null; current = current.Parent) + { + if (ReferenceEquals(current.Element, targetElement)) + { + yield break; + } + + if (!ReferenceEquals(current.HitTestTargetElement, targetElement)) + { + yield break; + } + + if (current.Element is SvgElement instanceElement) + { + yield return instanceElement; + } + } + } + + internal bool RecordAnimationAccessKey(string? accessKey) + { + return AnimationController?.RecordAccessKey(accessKey) == true; + } + + internal bool RecordAnimationAccessKey(string? accessKey, TimeSpan presentationTime) + { + return AnimationController?.RecordAccessKey(accessKey, presentationTime) == true; } internal void RefreshCurrentAnimationFrame(bool bypassThrottle = false) @@ -1323,6 +1802,11 @@ private void InitializeJavaScriptRuntime( var runtime = factory.Create(svgDocument, CreateJavaScriptSettings()); _javaScriptRuntime = runtime; + if (runtime is ISKSvgJavaScriptViewerRuntime viewerRuntime) + { + viewerRuntime.SetViewerHost(this); + } + runtime.SetTextContentHost(new SvgJavaScriptTextContentHostAdapter(this)); if (AnimationController is { } controller) { @@ -1372,7 +1856,12 @@ internal SKSvgJavaScriptEventResult DispatchJavaScriptEvent( var handlerElement = NormalizeJavaScriptEventElement(element); var sourceTargetElement = NormalizeJavaScriptEventElement(targetElement); var sourceRelatedElement = relatedElement is null ? null : NormalizeJavaScriptEventElement(relatedElement); - var resolvedTargetNode = ResolveJavaScriptEventTarget(runtime, sourceTargetElement, input.PicturePoint); + var hasUseInstanceTarget = TryResolveJavaScriptUseInstanceTarget( + runtime, + sourceTargetElement, + input.PicturePoint, + out var resolvedTargetNode, + out var correspondingElement); var resolvedRelatedTargetNode = sourceRelatedElement is null ? null : runtime.GetElement(sourceRelatedElement); eventFacade ??= runtime.CreateEvent( eventType, @@ -1381,8 +1870,12 @@ internal SKSvgJavaScriptEventResult DispatchJavaScriptEvent( CreateJavaScriptEventInput(input)); var mutationVersion = runtime.MutationVersion; - var result = runtime.ExecuteEventHandlerAndListeners( + var result = ExecuteJavaScriptEventHandlerAndListeners( + runtime, handlerElement, + sourceTargetElement, + correspondingElement, + hasUseInstanceTarget, eventFacade, eventType, attributeName); @@ -1405,34 +1898,115 @@ internal SKSvgJavaScriptEventResult DispatchJavaScriptEvent( return result; } - private object ResolveJavaScriptEventTarget(ISKSvgJavaScriptRuntime runtime, SvgElement targetElement, SKPoint picturePoint) + private static SKSvgJavaScriptEventResult ExecuteJavaScriptEventHandlerAndListeners( + ISKSvgJavaScriptRuntime runtime, + SvgElement handlerElement, + SvgElement targetElement, + SvgElement? correspondingElement, + bool hasUseInstanceTarget, + object eventFacade, + string eventType, + string attributeName) { - if (targetElement is SvgUse use && - HitTestTopmostSceneNode(picturePoint) is { } hitNode) + if (!hasUseInstanceTarget || + correspondingElement is null || + !ReferenceEquals(handlerElement, targetElement)) { - var hitTargetElement = hitNode.HitTestTargetElement is null - ? null - : NormalizeJavaScriptEventElement(hitNode.HitTestTargetElement); - var correspondingElement = hitNode.Element is SvgElement element - ? NormalizeJavaScriptEventElement(element) - : null; + return runtime.ExecuteEventHandlerAndListeners( + handlerElement, + eventFacade, + eventType, + attributeName); + } - if (ReferenceEquals(hitTargetElement, targetElement) && - correspondingElement is not null && - !ReferenceEquals(correspondingElement, targetElement)) + var correspondingResult = runtime.ExecuteEventHandlerAndListeners( + correspondingElement, + eventFacade, + eventType, + attributeName); + + if (correspondingResult.CancelBubble) + { + return correspondingResult; + } + + var useResult = runtime.ExecuteEventHandlerAndListeners( + handlerElement, + eventFacade, + eventType, + attributeName); + + return CombineJavaScriptEventResults(correspondingResult, useResult); + } + + private static SKSvgJavaScriptEventResult CombineJavaScriptEventResults( + SKSvgJavaScriptEventResult first, + SKSvgJavaScriptEventResult second) + { + if (!first.Executed) + { + return second; + } + + if (!second.Executed) + { + return first; + } + + return new SKSvgJavaScriptEventResult( + executed: true, + mutated: first.Mutated || second.Mutated, + cancelBubble: first.CancelBubble || second.CancelBubble, + defaultPrevented: first.DefaultPrevented || second.DefaultPrevented); + } + + private bool TryResolveJavaScriptUseInstanceTarget( + ISKSvgJavaScriptRuntime runtime, + SvgElement targetElement, + SKPoint picturePoint, + out object targetNode, + out SvgElement? correspondingElement) + { + if (targetElement is SvgUse use && + HitTestTopmostSceneNode(picturePoint) is { } hitNode) + { + var hitTargetElement = hitNode.HitTestTargetElement is null + ? null + : NormalizeJavaScriptEventElement(hitNode.HitTestTargetElement); + var hitElement = hitNode.Element is SvgElement element + ? NormalizeJavaScriptEventElement(element) + : null; + + if (ReferenceEquals(hitTargetElement, targetElement) && + hitElement is not null && + !ReferenceEquals(hitElement, targetElement)) { - var instance = runtime.FindUseInstance(use, correspondingElement); + var instance = runtime.FindUseInstance(use, hitElement); if (instance is not null) { - return instance; + correspondingElement = hitElement; + targetNode = instance; + return true; } } } - return runtime.GetElement(targetElement); + correspondingElement = null; + targetNode = runtime.GetElement(targetElement); + return false; } private SvgElement NormalizeJavaScriptEventElement(SvgElement element) + { + return NormalizeSourceDocumentElement(element); + } + + private SvgElement? NormalizeAnimationEventElement(SvgElement? element) + { + return element is null ? null : NormalizeSourceDocumentElement(element); + } + + private SvgElement NormalizeSourceDocumentElement(SvgElement element) { if (SourceDocument is null || ReferenceEquals(element.OwnerDocument, SourceDocument)) @@ -1542,7 +2116,7 @@ private TimeSpan TranslateOffset(TimeSpan offset) } } - private sealed class SvgJavaScriptTextContentHostAdapter : ISKSvgJavaScriptTextContentHost + private sealed class SvgJavaScriptTextContentHostAdapter : ISKSvgJavaScriptTextContentHost, ISKSvgJavaScriptTextSelectionHost { private readonly SKSvg _owner; private readonly Dictionary _metricsByElement = new(); @@ -1597,7 +2171,34 @@ public void SelectSubString(SvgTextBase textContentElement, int charnum, int nch { var metrics = GetMetrics(textContentElement); _ = metrics.GetSubStringLength(charnum, nchars); - _owner.SetTextSelection(textContentElement, charnum, nchars, metrics.GetSelectionExtents(charnum, nchars)); + _owner.SetTextSelection(textContentElement, charnum, nchars, metrics); + } + + public bool TryBeginTextSelection(SvgTextBase textContentElement, int anchorCharnum) + { + return _owner.TryBeginTextSelection(textContentElement, anchorCharnum); + } + + public bool TryExtendTextSelection(SvgTextBase textContentElement, int focusCharnum) + { + return _owner.TryExtendTextSelection(textContentElement, focusCharnum); + } + + public bool TrySelectTextRange(SvgTextBase textContentElement, int anchorCharnum, int focusCharnum) + { + return _owner.TrySelectTextRange(textContentElement, anchorCharnum, focusCharnum); + } + + public void ClearTextSelection() + { + _owner.ClearTextSelection(); + } + + public bool TryGetTextSelection(SvgTextBase? textContentElement, out SvgTextSelectionRange selection) + { + return textContentElement is null + ? _owner.TryGetTextSelection(out selection) + : _owner.TryGetTextSelection(textContentElement, out selection); } private SvgSceneTextCompiler.SvgTextContentMetrics GetMetrics(SvgTextBase textContentElement) @@ -1625,19 +2226,46 @@ private SvgSceneTextCompiler.SvgTextContentMetrics GetMetrics(SvgTextBase textCo } - private void SetTextSelection(SvgTextBase textContentElement, int charnum, int nchars, SKRect[] extents) + private void SetTextSelection( + SvgTextBase textContentElement, + int charnum, + int nchars, + SvgSceneTextCompiler.SvgTextContentMetrics metrics) + { + SetTextSelection( + textContentElement, + charnum, + nchars, + anchorCharnum: charnum, + focusCharnum: nchars <= 0 ? charnum : GetBoundedFocusCharnum(charnum, nchars, metrics.NumberOfChars), + direction: SvgTextSelectionDirection.Forward, + metrics); + } + + private void SetTextSelection( + SvgTextBase textContentElement, + int charnum, + int nchars, + int anchorCharnum, + int focusCharnum, + SvgTextSelectionDirection direction, + SvgSceneTextCompiler.SvgTextContentMetrics metrics) { var shouldRefresh = false; + var extents = nchars > 0 ? metrics.GetSelectionExtents(charnum, nchars) : Array.Empty(); lock (Sync) { _textSelections.Clear(); - if (nchars > 0) + if (nchars > 0 || direction == SvgTextSelectionDirection.None) { - _textSelections.Add(new SvgTextSelectionRange( - textContentElement.ID, - SvgSceneCompiler.TryGetElementAddressKey(textContentElement), + _textSelections.Add(SvgTextSelectionRange.Create( + textContentElement, charnum, nchars, + anchorCharnum, + focusCharnum, + direction, + metrics, extents)); } @@ -1650,6 +2278,235 @@ private void SetTextSelection(SvgTextBase textContentElement, int charnum, int n } } + public bool TryGetTextSelection(out SvgTextSelectionRange selection) + { + lock (Sync) + { + if (_textSelections.Count == 0) + { + selection = default; + return false; + } + + selection = _textSelections[0]; + return true; + } + } + + public bool TryGetTextSelection(SvgTextBase textContentElement, out SvgTextSelectionRange selection) + { + if (textContentElement is null) + { + selection = default; + return false; + } + + lock (Sync) + { + for (var i = 0; i < _textSelections.Count; i++) + { + if (IsTextSelectionForElement(_textSelections[i], textContentElement)) + { + selection = _textSelections[i]; + return true; + } + } + } + + selection = default; + return false; + } + + /// + /// Selects a logical character range in a text content element and refreshes retained static selection rendering. + /// + /// The text content element to select from. + /// The zero-based character index where the range starts. + /// The requested number of characters in the range. Zero clears the active selection. + public void SelectTextSubString(SvgTextBase textContentElement, int charnum, int nchars) + { + if (textContentElement is null) + { + throw new ArgumentNullException(nameof(textContentElement)); + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + _ = metrics.GetSubStringLength(charnum, nchars); + SetTextSelection(textContentElement, charnum, nchars, metrics); + } + + public bool TrySelectTextSubString(SvgTextBase textContentElement, int charnum, int nchars) + { + if (textContentElement is null) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + try + { + _ = metrics.GetSubStringLength(charnum, nchars); + SetTextSelection(textContentElement, charnum, nchars, metrics); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + public bool TryBeginTextSelection(SvgTextBase textContentElement, int anchorCharnum) + { + if (textContentElement is null) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + if (!metrics.TryGetCaretMetadata(anchorCharnum, out _, out _)) + { + return false; + } + + SetTextSelection( + textContentElement, + anchorCharnum, + nchars: 0, + anchorCharnum, + focusCharnum: anchorCharnum, + direction: SvgTextSelectionDirection.None, + metrics); + return true; + } + + public bool TryBeginTextSelection(SvgTextBase textContentElement, SKPoint anchorPoint) + { + if (textContentElement is null) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + var anchorCharnum = metrics.GetCharNumAtPosition(anchorPoint); + return anchorCharnum >= 0 && TryBeginTextSelection(textContentElement, anchorCharnum); + } + + public bool TryExtendTextSelection(SvgTextBase textContentElement, int focusCharnum) + { + if (textContentElement is null) + { + return false; + } + + SvgTextSelectionRange currentSelection; + lock (Sync) + { + if (_textSelections.Count == 0 || + !IsTextSelectionForElement(_textSelections[0], textContentElement)) + { + return false; + } + + currentSelection = _textSelections[0]; + } + + if (focusCharnum == currentSelection.AnchorCharnum) + { + return TryBeginTextSelection(textContentElement, currentSelection.AnchorCharnum); + } + + return TrySelectTextRange(textContentElement, currentSelection.AnchorCharnum, focusCharnum); + } + + public bool TryExtendTextSelection(SvgTextBase textContentElement, SKPoint focusPoint) + { + if (textContentElement is null) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + var focusCharnum = metrics.GetCharNumAtPosition(focusPoint); + return focusCharnum >= 0 && TryExtendTextSelection(textContentElement, focusCharnum); + } + + public bool TrySelectTextRange(SvgTextBase textContentElement, int anchorCharnum, int focusCharnum) + { + if (textContentElement is null) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + if (!TryCreateTextRangeSelection( + textContentElement, + anchorCharnum, + focusCharnum, + metrics, + out var charnum, + out var nchars, + out var direction)) + { + return false; + } + + SetTextSelection( + textContentElement, + charnum, + nchars, + anchorCharnum, + focusCharnum, + direction, + metrics); + return true; + } + + public bool TrySelectTextRange(SvgTextBase textContentElement, SKPoint anchorPoint, SKPoint focusPoint) + { + if (textContentElement is null) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + var anchorCharnum = metrics.GetCharNumAtPosition(anchorPoint); + var focusCharnum = metrics.GetCharNumAtPosition(focusPoint); + if (anchorCharnum < 0 || focusCharnum < 0) + { + return false; + } + + return TrySelectTextRange(textContentElement, anchorCharnum, focusCharnum); + } + public void ClearTextSelection() { var hadSelection = false; @@ -1670,6 +2527,234 @@ private void ClearTextSelectionCore() _textSelections.Clear(); } + private void RefreshTextSelectionMetrics() + { + SvgTextSelectionRange[] selections; + lock (Sync) + { + if (_textSelections.Count == 0) + { + return; + } + + selections = _textSelections.ToArray(); + } + + var refreshedSelections = new List(selections.Length); + for (var i = 0; i < selections.Length; i++) + { + var selection = selections[i]; + if (!TryRefreshTextSelectionMetrics(selection, out var refreshedSelection)) + { + continue; + } + + refreshedSelections.Add(refreshedSelection); + } + + lock (Sync) + { + _textSelections.Clear(); + _textSelections.AddRange(refreshedSelections); + } + } + + private bool TryRefreshTextSelectionMetrics(SvgTextSelectionRange selection, out SvgTextSelectionRange refreshedSelection) + { + refreshedSelection = default; + if (!TryResolveTextSelectionElement(selection, out var textContentElement)) + { + return false; + } + + if (!SvgSceneTextCompiler.TryCreateTextContentMetrics(textContentElement, GetStandaloneViewport(), AssetLoader, out var metrics)) + { + metrics = SvgSceneTextCompiler.SvgTextContentMetrics.Empty; + } + + try + { + if (selection.NChars > 0) + { + _ = metrics.GetSubStringLength(selection.Charnum, selection.NChars); + } + else if (!metrics.TryGetCaretMetadata(selection.Charnum, out _, out _)) + { + return false; + } + + refreshedSelection = SvgTextSelectionRange.Create( + textContentElement, + selection.Charnum, + selection.NChars, + selection.AnchorCharnum, + selection.FocusCharnum, + selection.Direction, + metrics, + metrics.GetSelectionExtents(selection.Charnum, selection.NChars)); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private bool TryResolveTextSelectionElement(SvgTextSelectionRange selection, out SvgTextBase textContentElement) + { + if (selection.TextContentElement is { } existing) + { + textContentElement = existing; + return true; + } + + if (!string.IsNullOrWhiteSpace(selection.ElementId) && + SourceDocument?.GetElementById(selection.ElementId!) is SvgTextBase textElement) + { + textContentElement = textElement; + return true; + } + + textContentElement = default!; + return false; + } + + private static bool IsTextSelectionForElement(SvgTextSelectionRange selection, SvgTextBase textContentElement) + { + if (selection.TextContentElement is { } existing) + { + return ReferenceEquals(existing, textContentElement); + } + + if (!string.IsNullOrWhiteSpace(selection.ElementAddress) && + string.Equals(selection.ElementAddress, SvgSceneCompiler.TryGetElementAddressKey(textContentElement), StringComparison.Ordinal)) + { + return true; + } + + return !string.IsNullOrWhiteSpace(selection.ElementId) && + string.Equals(selection.ElementId, textContentElement.ID, StringComparison.Ordinal); + } + + private static bool TryCreateTextRangeSelection( + SvgTextBase textContentElement, + int anchorCharnum, + int focusCharnum, + SvgSceneTextCompiler.SvgTextContentMetrics metrics, + out int charnum, + out int nchars, + out SvgTextSelectionDirection direction) + { + charnum = 0; + nchars = 0; + direction = SvgTextSelectionDirection.None; + + if (anchorCharnum < 0 || + focusCharnum < 0 || + anchorCharnum >= metrics.NumberOfChars || + focusCharnum >= metrics.NumberOfChars) + { + return false; + } + + direction = ResolveTextSelectionDirection(textContentElement, metrics, anchorCharnum, focusCharnum); + charnum = Math.Min(anchorCharnum, focusCharnum); + var endCharnum = Math.Max(anchorCharnum, focusCharnum) + 1; + nchars = endCharnum - charnum; + return true; + } + + private static SvgTextSelectionDirection ResolveTextSelectionDirection( + SvgTextBase textContentElement, + SvgSceneTextCompiler.SvgTextContentMetrics metrics, + int anchorCharnum, + int focusCharnum) + { + if (anchorCharnum == focusCharnum) + { + return SvgTextSelectionDirection.None; + } + + if (TryCompareTextSelectionVisualOrder(textContentElement, metrics, anchorCharnum, focusCharnum, out var comparison) && + comparison != 0) + { + return comparison < 0 ? SvgTextSelectionDirection.Forward : SvgTextSelectionDirection.Backward; + } + + return anchorCharnum < focusCharnum ? SvgTextSelectionDirection.Forward : SvgTextSelectionDirection.Backward; + } + + private static bool TryCompareTextSelectionVisualOrder( + SvgTextBase textContentElement, + SvgSceneTextCompiler.SvgTextContentMetrics metrics, + int leftCharnum, + int rightCharnum, + out int comparison) + { + comparison = 0; + var leftExtent = metrics.GetExtentOfChar(leftCharnum); + var rightExtent = metrics.GetExtentOfChar(rightCharnum); + if (leftExtent.IsEmpty || rightExtent.IsEmpty) + { + return false; + } + + if (!AreSameVisualSelectionLine(leftExtent, rightExtent)) + { + comparison = leftExtent.Top.CompareTo(rightExtent.Top); + return comparison != 0; + } + + comparison = IsRightToLeftTextSelection(textContentElement) + ? rightExtent.Right.CompareTo(leftExtent.Right) + : leftExtent.Left.CompareTo(rightExtent.Left); + return true; + } + + private static bool IsRightToLeftTextSelection(SvgTextBase textContentElement) + { + for (SvgElement? current = textContentElement; current is not null; current = current.Parent) + { + if (!current.TryGetOwnCascadedStyleValue("direction", out var direction) || + string.IsNullOrWhiteSpace(direction)) + { + continue; + } + + var normalized = direction.AsSpan().Trim(); + if (normalized.Equals("rtl".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (normalized.Equals("ltr".AsSpan(), StringComparison.OrdinalIgnoreCase) || + normalized.Equals("initial".AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + return false; + } + + private static bool AreSameVisualSelectionLine(SKRect left, SKRect right) + { + var verticalOverlap = Math.Min(left.Bottom, right.Bottom) - Math.Max(left.Top, right.Top); + var minHeight = Math.Min(Math.Abs(left.Height), Math.Abs(right.Height)); + return minHeight > 0f && verticalOverlap >= minHeight * 0.5f; + } + + private static int GetBoundedFocusCharnum(int charnum, int nchars, int numberOfChars) + { + if (nchars <= 0) + { + return charnum; + } + + var requestedEnd = (long)charnum + nchars; + return requestedEnd >= numberOfChars ? numberOfChars - 1 : (int)requestedEnd - 1; + } + private void RefreshTextSelectionRendering() { if (AnimationController is { }) diff --git a/src/Svg.Skia/SKSvg.ViewerRuntime.cs b/src/Svg.Skia/SKSvg.ViewerRuntime.cs new file mode 100644 index 0000000000..8c86b4a2d0 --- /dev/null +++ b/src/Svg.Skia/SKSvg.ViewerRuntime.cs @@ -0,0 +1,581 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using ShimSkiaSharp; +using Svg; + +namespace Svg.Skia; + +public interface ISKSvgNavigationHandler +{ + bool Navigate(SKSvgNavigationRequest request); +} + +public sealed class SKSvgNavigationRequest +{ + public SKSvgNavigationRequest( + Uri uri, + string href, + string? target, + string? sourceElementId, + SvgAnchor sourceElement, + SKPoint picturePoint, + SvgMouseButton button, + int clickCount, + string sessionId) + : this( + uri, + href, + target, + sourceElementId, + sourceElement, + picturePoint, + button, + clickCount, + sessionId, + null, + uri, + null, + false, + null) + { + } + + public SKSvgNavigationRequest( + Uri uri, + string href, + string? target, + string? sourceElementId, + SvgAnchor sourceElement, + SKPoint picturePoint, + SvgMouseButton button, + int clickCount, + string sessionId, + Uri? baseUri, + Uri? resolvedUri, + string? fragment, + bool isSameDocumentReference, + string? show) + { + Uri = uri ?? throw new ArgumentNullException(nameof(uri)); + Href = href ?? throw new ArgumentNullException(nameof(href)); + Target = target; + SourceElementId = sourceElementId; + SourceElement = sourceElement ?? throw new ArgumentNullException(nameof(sourceElement)); + PicturePoint = picturePoint; + Button = button; + ClickCount = clickCount; + SessionId = sessionId ?? string.Empty; + BaseUri = baseUri; + ResolvedUri = resolvedUri ?? uri; + Fragment = fragment; + IsSameDocumentReference = isSameDocumentReference; + Show = show; + } + + public Uri Uri { get; } + + public Uri ResolvedUri { get; } + + public Uri? BaseUri { get; } + + public string Href { get; } + + public string? Target { get; } + + public string? Fragment { get; } + + public bool IsSameDocumentReference { get; } + + public string? Show { get; } + + public string? SourceElementId { get; } + + public SvgAnchor SourceElement { get; } + + public SKPoint PicturePoint { get; } + + public SvgMouseButton Button { get; } + + public int ClickCount { get; } + + public string SessionId { get; } +} + +public sealed class SKSvgViewerTransformChangedEventArgs : EventArgs +{ + public SKSvgViewerTransformChangedEventArgs( + double oldScale, + SKPoint oldTranslate, + double newScale, + SKPoint newTranslate) + { + OldScale = oldScale; + OldTranslate = oldTranslate; + NewScale = newScale; + NewTranslate = newTranslate; + } + + public double OldScale { get; } + + public SKPoint OldTranslate { get; } + + public double NewScale { get; } + + public SKPoint NewTranslate { get; } +} + +public partial class SKSvg : ISKSvgJavaScriptViewerHost +{ + private double _currentScale = 1d; + private SKPoint _currentTranslate; + + public event EventHandler? ViewerTransformChanged; + + public bool IsZoomAndPanEnabled + { + get + { + lock (Sync) + { + return IsZoomAndPanEnabledLocked(); + } + } + } + + public double CurrentScale + { + get + { + lock (Sync) + { + if (!IsZoomAndPanEnabledLocked()) + { + return 1d; + } + + return _currentScale; + } + } + set + { + _ = ZoomTo(value); + } + } + + public SKPoint CurrentTranslate + { + get + { + lock (Sync) + { + if (!IsZoomAndPanEnabledLocked()) + { + return default; + } + + return _currentTranslate; + } + } + set + { + _ = PanTo(value); + } + } + + public SKMatrix ViewerTransform + { + get + { + lock (Sync) + { + return CreateViewerTransformLocked(); + } + } + } + + float ISKSvgJavaScriptViewerHost.CurrentTranslateX + { + get => CurrentTranslate.X; + set => CurrentTranslate = new SKPoint(value, CurrentTranslate.Y); + } + + float ISKSvgJavaScriptViewerHost.CurrentTranslateY + { + get => CurrentTranslate.Y; + set => CurrentTranslate = new SKPoint(CurrentTranslate.X, value); + } + + public bool ZoomTo(double scale) + { + if (!IsValidScale(scale)) + { + return false; + } + + return TrySetViewerTransform(scale, null); + } + + public bool ZoomBy(double scaleFactor) + { + if (!IsValidScale(scaleFactor)) + { + return false; + } + + SKSvgViewerTransformChangedEventArgs? args; + bool result; + lock (Sync) + { + result = TrySetViewerTransformLocked(_currentScale * scaleFactor, _currentTranslate, out args); + } + + RaiseViewerTransformChanged(args); + return result; + } + + public bool PanTo(SKPoint translate) + { + if (!IsValidTranslate(translate)) + { + return false; + } + + return TrySetViewerTransform(null, translate); + } + + public bool PanBy(SKPoint delta) + { + if (!IsValidTranslate(delta)) + { + return false; + } + + SKSvgViewerTransformChangedEventArgs? args; + bool result; + lock (Sync) + { + var translate = new SKPoint(_currentTranslate.X + delta.X, _currentTranslate.Y + delta.Y); + result = TrySetViewerTransformLocked(_currentScale, translate, out args); + } + + RaiseViewerTransformChanged(args); + return result; + } + + public bool SetViewerTransform(double scale, SKPoint translate) + { + if (!IsValidScale(scale) || !IsValidTranslate(translate)) + { + return false; + } + + return TrySetViewerTransform(scale, translate); + } + + public bool ResetViewerTransform() + { + return TrySetViewerTransform(1d, default(SKPoint)); + } + + public SKPoint PictureToViewerPoint(SKPoint picturePoint) + { + return ViewerTransform.MapPoint(picturePoint); + } + + public bool TryGetViewerPicturePoint(SKPoint viewerPoint, out SKPoint picturePoint) + { + return TryGetPicturePoint(viewerPoint, ViewerTransform, out picturePoint); + } + + private bool TrySetViewerTransform(double? scale, SKPoint? translate) + { + SKSvgViewerTransformChangedEventArgs? args; + bool result; + lock (Sync) + { + result = TrySetViewerTransformLocked( + scale ?? _currentScale, + translate ?? _currentTranslate, + out args); + } + + RaiseViewerTransformChanged(args); + return result; + } + + private bool TrySetViewerTransformLocked(double scale, SKPoint translate, out SKSvgViewerTransformChangedEventArgs? args) + { + args = null; + if (!IsValidScale(scale) || !IsValidTranslate(translate)) + { + return false; + } + + if (!IsZoomAndPanEnabledLocked() && !IsIdentityViewerTransform(scale, translate)) + { + return false; + } + + var oldScale = _currentScale; + var oldTranslate = _currentTranslate; + if (oldScale.Equals(scale) && oldTranslate.Equals(translate)) + { + return true; + } + + _currentScale = scale; + _currentTranslate = translate; + args = new SKSvgViewerTransformChangedEventArgs(oldScale, oldTranslate, scale, translate); + return true; + } + + private SKMatrix CreateViewerTransformLocked() + { + if (!IsZoomAndPanEnabledLocked() || IsIdentityViewerTransform(_currentScale, _currentTranslate)) + { + return SKMatrix.Identity; + } + + return SKMatrix.CreateTranslation(_currentTranslate.X, _currentTranslate.Y) + .PreConcat(SKMatrix.CreateScale((float)_currentScale, (float)_currentScale)); + } + + private void ApplyViewerTransform(SkiaSharp.SKCanvas canvas) + { + var transform = ViewerTransform; + if (transform.IsIdentity) + { + return; + } + + var skTransform = SkiaModel.ToSKMatrix(transform); + canvas.Concat(in skTransform); + } + + private bool IsZoomAndPanEnabledLocked() + { + if (SourceDocument is null || + !SourceDocument.TryGetAttribute("zoomAndPan", out var zoomAndPan)) + { + return true; + } + + return !string.Equals(zoomAndPan.Trim(), "disable", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsIdentityViewerTransform(double scale, SKPoint translate) + { + return scale.Equals(1d) && translate.Equals(default(SKPoint)); + } + + private static bool IsValidScale(double scale) + { + return !double.IsNaN(scale) && !double.IsInfinity(scale) && scale > 0d; + } + + private static bool IsValidTranslate(SKPoint translate) + { + return !float.IsNaN(translate.X) && + !float.IsInfinity(translate.X) && + !float.IsNaN(translate.Y) && + !float.IsInfinity(translate.Y); + } + + private void RaiseViewerTransformChanged(SKSvgViewerTransformChangedEventArgs? args) + { + if (args is not null) + { + ViewerTransformChanged?.Invoke(this, args); + } + } + + internal bool ActivateHyperlink(SvgElement? element, SvgPointerInput input) + { + if (FindNearestAnchor(element) is not { } anchor || + !anchor.TryGetEffectiveHrefString(out var href)) + { + return false; + } + + var trimmedHref = href.Trim(); + if (trimmedHref.Length == 0 || + !Uri.TryCreate(trimmedHref, UriKind.RelativeOrAbsolute, out var uri)) + { + return false; + } + + var baseUri = SourceDocument?.BaseUri ?? _originalBaseUri; + var resolvedUri = ResolveNavigationUri(uri, trimmedHref, baseUri); + var normalizedTarget = NormalizeTarget(anchor); + var normalizedShow = string.IsNullOrWhiteSpace(anchor.Show) ? null : anchor.Show.Trim(); + var fragment = TryGetNavigationFragment(trimmedHref, resolvedUri); + var sameDocumentReference = fragment is not null && + IsSameDocumentReference(trimmedHref, resolvedUri, baseUri); + + if (sameDocumentReference && + AllowsCurrentViewerDefaultAction(normalizedTarget, normalizedShow) && + TryActivateAnimationFragment(fragment)) + { + return true; + } + + if (Settings.NavigationHandler is not { } navigationHandler) + { + return false; + } + + var request = new SKSvgNavigationRequest( + uri, + trimmedHref, + normalizedTarget, + string.IsNullOrWhiteSpace(anchor.ID) ? null : anchor.ID, + anchor, + input.PicturePoint, + input.Button, + input.ClickCount, + input.SessionId, + baseUri, + resolvedUri, + fragment, + sameDocumentReference, + normalizedShow); + + return navigationHandler.Navigate(request); + } + + private bool TryActivateAnimationFragment(string? fragment) + { + if (string.IsNullOrWhiteSpace(fragment) || SourceDocument is null) + { + return false; + } + + return SourceDocument.GetElementById(fragment) is SvgAnimationElement animation && + BeginAnimationElement(animation, TimeSpan.Zero); + } + + private static Uri? ResolveNavigationUri(Uri uri, string href, Uri? baseUri) + { + if (baseUri is { } && Uri.TryCreate(baseUri, href, out var resolvedUri)) + { + return resolvedUri; + } + + return uri; + } + + private static string? NormalizeTarget(SvgAnchor anchor) + { + if (!string.IsNullOrWhiteSpace(anchor.Target)) + { + return anchor.Target.Trim(); + } + + if (string.Equals(anchor.Show?.Trim(), "new", StringComparison.OrdinalIgnoreCase)) + { + return "_blank"; + } + + if (string.Equals(anchor.Show?.Trim(), "replace", StringComparison.OrdinalIgnoreCase)) + { + return "_self"; + } + + return null; + } + + private static bool AllowsCurrentViewerDefaultAction(string? target, string? show) + { + if (string.Equals(show, "new", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return string.IsNullOrWhiteSpace(target) || + string.Equals(target, "_self", StringComparison.OrdinalIgnoreCase); + } + + private static string? TryGetNavigationFragment(string href, Uri? resolvedUri) + { + if (href.StartsWith("#", StringComparison.Ordinal) && href.Length > 1) + { + return Uri.UnescapeDataString(href.Substring(1)); + } + + var fragment = resolvedUri?.Fragment; + if (!string.IsNullOrEmpty(fragment)) + { + return Uri.UnescapeDataString(GetFragmentWithoutNumberSign(fragment, href)); + } + + return null; + } + + private static bool IsSameDocumentReference( + string href, + Uri? resolvedUri, + Uri? baseUri) + { + if (href.StartsWith("#", StringComparison.Ordinal)) + { + return true; + } + + if (resolvedUri is null || string.IsNullOrEmpty(resolvedUri.Fragment)) + { + return false; + } + + if (baseUri is null) + { + return false; + } + + return IsSameDocumentUri(resolvedUri, baseUri); + } + + private static bool IsSameDocumentUri(Uri resolvedUri, Uri baseUri) + { + if (!resolvedUri.IsAbsoluteUri || !baseUri.IsAbsoluteUri) + { + return false; + } + + var resolvedBuilder = new UriBuilder(resolvedUri) { Fragment = string.Empty }; + var baseBuilder = new UriBuilder(baseUri) { Fragment = string.Empty }; + return Uri.Compare( + resolvedBuilder.Uri, + baseBuilder.Uri, + UriComponents.AbsoluteUri, + UriFormat.SafeUnescaped, + StringComparison.OrdinalIgnoreCase) == 0; + } + + private static string GetFragmentWithoutNumberSign(string? resolvedFragment, string href) + { + if (!string.IsNullOrEmpty(resolvedFragment)) + { + return resolvedFragment![0] == '#' + ? resolvedFragment.Substring(1) + : resolvedFragment; + } + + return href.StartsWith("#", StringComparison.Ordinal) + ? href.Substring(1) + : string.Empty; + } + + private static SvgAnchor? FindNearestAnchor(SvgElement? element) + { + for (var current = element; current is not null; current = current.Parent) + { + if (current is SvgAnchor anchor) + { + return anchor; + } + } + + return null; + } +} diff --git a/src/Svg.Skia/SKSvgJavaScriptRuntime.cs b/src/Svg.Skia/SKSvgJavaScriptRuntime.cs index 8e1c1d0cdc..3d08e3f248 100644 --- a/src/Svg.Skia/SKSvgJavaScriptRuntime.cs +++ b/src/Svg.Skia/SKSvgJavaScriptRuntime.cs @@ -57,6 +57,11 @@ SKSvgJavaScriptEventResult ExecuteEventHandlerAndListeners( SKSvgJavaScriptEventInput? input); } +public interface ISKSvgJavaScriptViewerRuntime +{ + void SetViewerHost(ISKSvgJavaScriptViewerHost? viewerHost); +} + public interface ISKSvgJavaScriptAnimationHost { TimeSpan CurrentTime { get; } @@ -93,6 +98,28 @@ public interface ISKSvgJavaScriptTextContentHost void SelectSubString(SvgTextBase textContentElement, int charnum, int nchars); } +public interface ISKSvgJavaScriptTextSelectionHost +{ + bool TryBeginTextSelection(SvgTextBase textContentElement, int anchorCharnum); + + bool TryExtendTextSelection(SvgTextBase textContentElement, int focusCharnum); + + bool TrySelectTextRange(SvgTextBase textContentElement, int anchorCharnum, int focusCharnum); + + void ClearTextSelection(); + + bool TryGetTextSelection(SvgTextBase? textContentElement, out SKSvg.SvgTextSelectionRange selection); +} + +public interface ISKSvgJavaScriptViewerHost +{ + double CurrentScale { get; set; } + + float CurrentTranslateX { get; set; } + + float CurrentTranslateY { get; set; } +} + public enum SKSvgJavaScriptMouseButton { None, diff --git a/src/Svg.Skia/SKSvgSettings.cs b/src/Svg.Skia/SKSvgSettings.cs index 6a94ad2afc..3c3413c16b 100644 --- a/src/Svg.Skia/SKSvgSettings.cs +++ b/src/Svg.Skia/SKSvgSettings.cs @@ -27,6 +27,8 @@ public class SKSvgSettings public bool EnableFilterBackgroundInputs { get; set; } + public bool EnableBrokenImagePlaceholders { get; set; } + public bool EnableJavaScript { get; set; } public bool EnableTextSelectionRendering { get; set; } @@ -43,6 +45,8 @@ public class SKSvgSettings public ISKSvgJavaScriptRuntimeFactory? JavaScriptRuntimeFactory { get; set; } + public ISKSvgNavigationHandler? NavigationHandler { get; set; } + public SKSvgSettings Clone() { var clone = new SKSvgSettings(); @@ -68,6 +72,7 @@ public void CopyTo(SKSvgSettings target) target.EnableSvgFonts = EnableSvgFonts; target.EnableTextReferences = EnableTextReferences; target.EnableFilterBackgroundInputs = EnableFilterBackgroundInputs; + target.EnableBrokenImagePlaceholders = EnableBrokenImagePlaceholders; target.EnableJavaScript = EnableJavaScript; target.EnableTextSelectionRendering = EnableTextSelectionRendering; target.TextSelectionColor = TextSelectionColor; @@ -76,6 +81,7 @@ public void CopyTo(SKSvgSettings target) target.JavaScriptMaxStatements = JavaScriptMaxStatements; target.ThrowOnJavaScriptError = ThrowOnJavaScriptError; target.JavaScriptRuntimeFactory = JavaScriptRuntimeFactory; + target.NavigationHandler = NavigationHandler; } public SKSvgSettings() @@ -98,6 +104,7 @@ public SKSvgSettings() EnableSvgFonts = true; EnableTextReferences = true; EnableFilterBackgroundInputs = true; + EnableBrokenImagePlaceholders = true; EnableJavaScript = false; EnableTextSelectionRendering = true; TextSelectionColor = new SkiaSharp.SKColor(0x00, 0x80, 0x00, 0xFF); diff --git a/src/Svg.Skia/SkiaSvgAssetLoader.cs b/src/Svg.Skia/SkiaSvgAssetLoader.cs index 7bde0a5f5c..84dcd6d422 100644 --- a/src/Svg.Skia/SkiaSvgAssetLoader.cs +++ b/src/Svg.Skia/SkiaSvgAssetLoader.cs @@ -11,7 +11,7 @@ namespace Svg.Skia; /// /// Asset loader implementation using SkiaSharp types. /// -public partial class SkiaSvgAssetLoader : Model.ISvgAssetLoader, Model.ISvgImageAlphaProvider, Model.ISvgTextReferenceRenderingOptions, Model.ISvgFilterBackgroundInputOptions, Model.ISvgTextRunTypefaceResolver, Model.ISvgTextGlyphRunResolver, Model.ISvgTextDirectedGlyphRunResolver, Model.ISvgTextGlyphClusterResolver, Model.ISvgTextGlyphRunPathResolver +public partial class SkiaSvgAssetLoader : Model.ISvgAssetLoader, Model.ISvgImageAlphaProvider, Model.ISvgBrokenImagePlaceholderOptions, Model.ISvgTextReferenceRenderingOptions, Model.ISvgFilterBackgroundInputOptions, Model.ISvgTextRunTypefaceResolver, Model.ISvgTextGlyphRunResolver, Model.ISvgTextDirectedGlyphRunResolver, Model.ISvgTextGlyphClusterResolver, Model.ISvgTextGlyphRunPathResolver { private readonly SkiaModel _skiaModel; @@ -33,6 +33,9 @@ public SkiaSvgAssetLoader(SkiaModel skiaModel) /// public bool EnableFilterBackgroundInputs => _skiaModel.Settings.EnableFilterBackgroundInputs; + /// + public bool EnableBrokenImagePlaceholders => _skiaModel.Settings.EnableBrokenImagePlaceholders; + /// public ShimSkiaSharp.SKImage LoadImage(System.IO.Stream stream) { diff --git a/tests/Svg.JavaScript.UnitTests/SvgJavaScriptRuntimeTests.cs b/tests/Svg.JavaScript.UnitTests/SvgJavaScriptRuntimeTests.cs index 068c19f558..0086a45778 100644 --- a/tests/Svg.JavaScript.UnitTests/SvgJavaScriptRuntimeTests.cs +++ b/tests/Svg.JavaScript.UnitTests/SvgJavaScriptRuntimeTests.cs @@ -43,6 +43,254 @@ public void ExecuteDocumentScripts_ExecutesOnLoad() AssertFill(document, "target", Color.Green); } + [Fact] + public void ExecuteDocumentScripts_DispatchesLoadForElementsInPostOrder() + { + var document = LoadDocument(""" + + + + + + + + """, captureJavaScriptDomState: true); + + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + runtime.ExecuteDocumentScripts(); + + var target = runtime.GetElement(document.Descendants().Single(element => element.ID == "target")); + Assert.Equal("rect:false;g:false;svg:false;", target.getAttribute("data-order")); + } + + [Fact] + public void ExecuteDocumentScripts_DispatchesNonBubblingImageLoad() + { + var document = LoadDocument(""" + + + + + """); + + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + runtime.ExecuteDocumentScripts(); + + AssertFill(document, "target", Color.Green); + } + + [Fact] + public void NodeLists_AreLiveAcrossDomMutations() + { + var document = LoadDocument(""""""); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var root = runtime.GetElement(document); + var documentFacade = root.ownerDocument; + var rects = documentFacade.getElementsByTagName("rect"); + var childNodes = root.childNodes; + + Assert.Equal(0, rects.length); + Assert.Equal(0, childNodes.length); + + var rect = documentFacade.createElementNS("http://www.w3.org/2000/svg", "rect"); + root.appendChild(rect); + + Assert.Equal(1, rects.length); + Assert.Equal(1, childNodes.length); + Assert.Same(rect, rects.item(0)); + Assert.Same(rect, root.lastChild); + Assert.True(root.hasChildNodes()); + + root.removeChild(rect); + + Assert.Equal(0, rects.length); + Assert.Equal(0, childNodes.length); + Assert.False(root.hasChildNodes()); + } + + [Fact] + public void GetElementsByTagNameNS_ReturnsLiveForeignNamespaceElements() + { + var document = LoadDocument(""" + + + East + + + """); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var documentFacade = runtime.GetElement(document).ownerDocument; + var regions = documentFacade.getElementsByTagNameNS("http://example.org/ExampleBusinessData", "Region"); + var names = documentFacade.getElementsByTagNameNS("http://example.org/ExampleBusinessData", "RegionName"); + + Assert.Equal(1, regions.length); + Assert.Equal(1, names.length); + + var region = Assert.IsType(regions.item(0)); + var name = Assert.IsType(names.item(0)); + Assert.Equal("Region", region.localName); + Assert.Equal("http://example.org/ExampleBusinessData", region.namespaceURI); + Assert.Equal("East", Assert.IsType(name.firstChild).nodeValue); + } + + [Fact] + public void SetAttributeNS_MapsOnlyRealXLinkHrefToSvgHref() + { + var document = LoadDocument(""""""); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var image = runtime.GetElement(document).ownerDocument.createElementNS("http://www.w3.org/2000/svg", "image"); + + image.setAttributeNS("http://www.w3.org/1999/xlink", "randomPrefix:href", "expected.png"); + image.setAttributeNS("http://www.this.is.not.an/xlink", "xlink:href", "ignored.png"); + + Assert.Equal("expected.png", image.getAttribute("href")); + Assert.Equal("expected.png", image.getAttributeNS("http://www.w3.org/1999/xlink", "href")); + Assert.Equal("ignored.png", image.getAttributeNS("http://www.this.is.not.an/xlink", "xlink:href")); + } + + [Fact] + public void ParsedForeignNamespaceXLinkPrefix_DoesNotBindSvgHref() + { + var document = LoadDocument(""" + + + + + + """, captureJavaScriptDomState: true); + + var image = Assert.IsType(document.Descendants().Single(element => element.ID == "image")); + Assert.True(string.IsNullOrEmpty(image.Href)); + } + + [Fact] + public void SvgLengthValue_ResolvesPercentAgainstCurrentViewportAfterReparent() + { + var document = LoadDocument(""" + + + + + + + + """); + + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var testSvg1 = runtime.GetElement(document.Descendants().Single(element => element.ID == "testSVG1")); + var testSvg2 = runtime.GetElement(document.Descendants().Single(element => element.ID == "testSVG2")); + var subSvg = runtime.GetElement(document.Descendants().Single(element => element.ID == "subSVG")); + + var length = Assert.IsType(testSvg1.width).baseVal; + Assert.Equal(480d, length.value); + Assert.Equal(100d, length.valueInSpecifiedUnits); + + length.value = 240d; + Assert.Equal(240d, length.value); + Assert.Equal(50d, length.valueInSpecifiedUnits); + Assert.Equal("50%", length.valueAsString); + + subSvg.appendChild(testSvg1); + Assert.Equal(150d, length.value); + Assert.Equal(50d, length.valueInSpecifiedUnits); + + subSvg.appendChild(testSvg2); + var defaultLength = Assert.IsType(testSvg2.width).baseVal; + Assert.Equal(300d, defaultLength.value); + Assert.Equal(100d, defaultLength.valueInSpecifiedUnits); + } + + [Fact] + public void CurrentScaleAndTranslate_UseViewerHostForOutermostSvg() + { + var document = LoadDocument(""" + + + + """); + var host = new TestViewerHost + { + CurrentScale = 2d, + CurrentTranslateX = 4f, + CurrentTranslateY = 5f + }; + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }) + { + ViewerHost = host + }; + + var root = runtime.GetElement(document); + var nested = runtime.GetElement(document.Descendants().Single(element => element.ID == "nested")); + + Assert.Equal(2d, root.currentScale); + Assert.Equal(4f, root.currentTranslate.x); + Assert.Equal(5f, root.currentTranslate.y); + + root.currentScale = 3d; + root.currentTranslate.x = 7f; + root.currentTranslate.y = 8f; + root.currentScale = -1d; + + Assert.Equal(3d, host.CurrentScale); + Assert.Equal(7f, host.CurrentTranslateX); + Assert.Equal(8f, host.CurrentTranslateY); + Assert.Equal(3d, root.currentScale); + Assert.Equal(7f, root.currentTranslate.x); + Assert.Equal(8f, root.currentTranslate.y); + + nested.currentScale = 9d; + nested.currentTranslate.x = 10f; + + Assert.Equal(3d, host.CurrentScale); + Assert.Equal(7f, host.CurrentTranslateX); + Assert.Equal(9d, nested.currentScale); + Assert.Equal(10f, nested.currentTranslate.x); + } + + [Fact] + public void ElementFocusAndBlur_DispatchesFocusInAndFocusOut() + { + var document = LoadDocument(""" + + + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + runtime.ExecuteDocumentScripts(); + var first = runtime.GetElement(document.Descendants().Single(element => element.ID == "first")); + var second = runtime.GetElement(document.Descendants().Single(element => element.ID == "second")); + + first.focus(); + second.focus(); + second.blur(); + + Assert.Null(runtime.FocusedElement); + Assert.Equal( + "first-in:null;first-out:second;second-in:first;second-out:null;", + runtime.GetElement(document).getAttribute("data-log")); + } + [Fact] public void ExecuteEventHandler_ProvidesEventInput() { @@ -83,6 +331,214 @@ public void ExecuteEventHandler_UsesCurrentTargetAsThisAndAllowsReturn() AssertFill(document, "target", Color.Green); } + [Fact] + public void InteractionHost_DispatchesPointerMouseSequenceThroughHitTestAndBubbling() + { + var document = LoadDocument(""" + + + + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + runtime.ExecuteDocumentScripts(); + var host = new SvgJavaScriptInteractionHost(runtime); + var input = new SvgJavaScriptEventInput(11, 12, SvgJavaScriptMouseButton.Left, 2, 0, altKey: true, shiftKey: false, ctrlKey: true); + + var move = host.DispatchPointerMoved(input); + var press = host.DispatchPointerPressed(input); + var release = host.DispatchPointerReleased(input); + + Assert.Equal("target", move.TargetElement?.id); + Assert.Equal("target", host.HoveredElement?.id); + Assert.Equal("target", press.TargetElement?.id); + Assert.Null(host.CapturedElement); + Assert.True(release.DefaultPrevented); + Assert.True(release.CancelBubble); + Assert.True(release.Mutated); + Assert.Equal( + "over:null:target:target;" + + "pointerdown:target:target;" + + "capture:target:group;" + + "down:11:0:true:false:true;" + + "g-down:target:group;" + + "bubble:target:group;" + + "click:2;", + runtime.GetElement(document).getAttribute("data-log")); + AssertFill(document, "target", Color.Green); + } + + [Fact] + public void InteractionHost_PointerPressFocusesFocusableElement() + { + var document = LoadDocument(""" + + + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + runtime.ExecuteDocumentScripts(); + var host = new SvgJavaScriptInteractionHost(runtime); + + var first = host.DispatchPointerPressed(new SvgJavaScriptEventInput(5, 5, SvgJavaScriptMouseButton.Left, 1, 0, false, false, false)); + var second = host.DispatchPointerPressed(new SvgJavaScriptEventInput(35, 5, SvgJavaScriptMouseButton.Left, 1, 0, false, false, false)); + + Assert.Equal("first", first.FocusedElement?.id); + Assert.True(first.DefaultActionActivated); + Assert.Equal("second", host.FocusedElement?.id); + Assert.Equal("second", second.FocusedElement?.id); + Assert.Equal( + "first-in:null;first-out:second;second-in:first;", + runtime.GetElement(document).getAttribute("data-log")); + } + + [Fact] + public void InteractionHost_MousedownPreventDefaultSuppressesFocusDefaultAction() + { + var document = LoadDocument(""" + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var host = new SvgJavaScriptInteractionHost(runtime); + + var result = host.DispatchPointerPressed(new SvgJavaScriptEventInput(5, 5, SvgJavaScriptMouseButton.Left, 1, 0, false, false, false)); + + Assert.True(result.DefaultPrevented); + Assert.False(result.DefaultActionActivated); + Assert.Null(host.FocusedElement); + Assert.Equal(string.Empty, runtime.GetElement(document).getAttribute("data-log")); + } + + [Fact] + public void InteractionHost_CapturesReleaseToPressedElementAndSuppressesClickOutside() + { + var document = LoadDocument(""" + + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var host = new SvgJavaScriptInteractionHost(runtime); + var pressInput = new SvgJavaScriptEventInput(10, 10, SvgJavaScriptMouseButton.Left, 1, 0, false, false, false); + var outsideInput = new SvgJavaScriptEventInput(80, 80, SvgJavaScriptMouseButton.Left, 1, 0, false, false, false); + + host.DispatchPointerPressed(pressInput); + Assert.Equal("target", host.CapturedElement?.id); + + host.DispatchPointerMoved(outsideInput); + Assert.Equal("target", host.CapturedElement?.id); + Assert.Equal("target", host.HoveredElement?.id); + + var release = host.DispatchPointerReleased(outsideInput); + + Assert.Null(host.CapturedElement); + Assert.Equal("background", host.HoveredElement?.id); + Assert.Equal("background", release.TargetElement?.id); + Assert.Equal("target-up;background-over;", runtime.GetElement(document).getAttribute("data-log")); + } + + [Fact] + public void InteractionHost_UseInstanceClickTargetsCorrespondingInstanceButRunsUseHandler() + { + var document = LoadDocument(""" + + + + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var host = new SvgJavaScriptInteractionHost(runtime); + var input = new SvgJavaScriptEventInput(5, 5, SvgJavaScriptMouseButton.Left, 1, 0, false, false, false); + + var result = host.DispatchMouseEventAt("click", input); + + Assert.Equal("use", result.TargetElement?.id); + Assert.Equal("template:true", runtime.GetElement(document).getAttribute("data-target")); + Assert.IsType(Assert.Single(result.Events).TargetNode); + } + + [Fact] + public void InteractionHost_UseInstanceMouseOverRunsReferencedHandlerBeforeUseHandler() + { + var document = LoadDocument(""" + + + + + + + """, captureJavaScriptDomState: true); + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var host = new SvgJavaScriptInteractionHost(runtime); + var input = new SvgJavaScriptEventInput(5, 5, SvgJavaScriptMouseButton.None, 0, 0, false, false, false); + + _ = host.DispatchPointerMoved(input); + + Assert.Equal("template:template;use:use;", runtime.GetElement(document).getAttribute("data-log")); + } + [Fact] public void ExecuteDocumentScripts_ExposesDefaultViewAsWindow() { @@ -194,6 +650,113 @@ public void StylePropertyMutations_UpdateRawStyleAttribute() Assert.Equal(string.Empty, target.getAttribute("style")); } + [Fact] + public void StyleDeclaration_CssTextLengthItemPriorityAndCamelCaseMutateRawStyle() + { + var document = LoadDocument(""" + + + + + """, captureJavaScriptDomState: true); + + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + + runtime.ExecuteDocumentScripts(); + + var target = runtime.GetElement(document.Descendants().Single(element => element.ID == "target")); + Assert.Equal( + "2:fill:stroke-width|3|stroke-width|4px|0.5|important|red|stroke-width: 4px; opacity: 0.5 !important; fill: green|stroke-width: 4px; opacity: 0.5 !important; fill: green", + target.getAttribute("data-result")); + Assert.True(runtime.MutationVersion > 0); + } + + [Fact] + public void ComputedStyle_ResolvesInlinePresentationCssDefaultsAndInheritance() + { + var document = LoadDocument(""" + + + + + + + + """, captureJavaScriptDomState: true); + + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var target = runtime.GetElement(document.Descendants().Single(element => element.ID == "target")); + var child = runtime.GetElement(document.Descendants().Single(element => element.ID == "child")); + var targetStyle = target.ownerDocument.defaultView.getComputedStyle(target, null); + var childStyle = target.ownerDocument.defaultView.getComputedStyle(child, null); + + Assert.Equal("red", targetStyle.getPropertyValue("fill")); + Assert.Equal("purple", targetStyle.stroke); + Assert.Equal("2px", targetStyle.strokeWidth); + Assert.Equal("0.25", targetStyle.fillOpacity); + Assert.Equal("blue", targetStyle.color); + Assert.Equal("20px", targetStyle.fontSize); + Assert.Equal("0.5", targetStyle.opacity); + Assert.Equal("inline", targetStyle.display); + Assert.Contains("fill", Enumerable.Range(0, targetStyle.length).Select(targetStyle.item)); + Assert.Contains("stroke-width: 2px", targetStyle.cssText); + + Assert.Equal("green", childStyle.fill); + Assert.Equal("inline", childStyle.display); + } + + [Fact] + public void ClassLookup_IsLiveAndPreservesCreatedElementNamespaceAndLocalName() + { + var document = LoadDocument(""" + + + + + + """, captureJavaScriptDomState: true); + + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var root = runtime.GetElement(document); + var parent = runtime.GetElement(document.Descendants().Single(element => element.ID == "parent")); + var documentFacade = root.ownerDocument; + var active = documentFacade.getElementsByClassName("primary active"); + var scoped = parent.getElementsByClassName("primary"); + + Assert.Equal(1, active.length); + Assert.Same(active.item(0), scoped.item(0)); + + var foreign = documentFacade.createElementNS("http://example.org/custom", "custom:Widget"); + foreign.setAttribute("class", "primary active"); + parent.appendChild(foreign); + + Assert.Equal(2, active.length); + Assert.Equal("Widget", foreign.localName); + Assert.Equal("Widget", foreign.tagName); + Assert.Equal("http://example.org/custom", foreign.namespaceURI); + Assert.Same(foreign, documentFacade.getElementsByTagNameNS("http://example.org/custom", "Widget").item(0)); + Assert.Same(foreign, documentFacade.getElementsByTagName("Widget").item(0)); + } + [Fact] public void AppendChild_MovesTextNodeOutOfPreviousParent() { @@ -239,4 +802,13 @@ private static void AssertFill(SvgDocument document, string id, Color expected) var fill = Assert.IsType(rect.Fill); Assert.Equal(expected.ToArgb(), fill.Colour.ToArgb()); } + + private sealed class TestViewerHost : ISvgJavaScriptViewerHost + { + public double CurrentScale { get; set; } = 1d; + + public float CurrentTranslateX { get; set; } + + public float CurrentTranslateY { get; set; } + } } diff --git a/tests/Svg.Model.UnitTests/Svg2StaticResourcePolicyTests.cs b/tests/Svg.Model.UnitTests/Svg2StaticResourcePolicyTests.cs index 8e65cdfc05..d6452582ea 100644 --- a/tests/Svg.Model.UnitTests/Svg2StaticResourcePolicyTests.cs +++ b/tests/Svg.Model.UnitTests/Svg2StaticResourcePolicyTests.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Drawing; using System.IO; +using System.IO.Compression; +using System.Linq; using ShimSkiaSharp; using Svg.Model; using Svg.Model.Services; @@ -574,6 +576,157 @@ public void GetImage_NestedDataSvgInheritsResourcePolicy() } } + [Theory] + [InlineData(SvgProcessingMode.Static, SvgProcessingMode.Static)] + [InlineData(SvgProcessingMode.Animated, SvgProcessingMode.Static)] + [InlineData(SvgProcessingMode.DynamicInteractive, SvgProcessingMode.Static)] + [InlineData(SvgProcessingMode.SecureStatic, SvgProcessingMode.SecureStatic)] + [InlineData(SvgProcessingMode.SecureAnimated, SvgProcessingMode.SecureStatic)] + public void GetImage_NestedSvgImageForcesStaticImageProcessingMode( + SvgProcessingMode ownerProcessingMode, + SvgProcessingMode expectedImageProcessingMode) + { + var nestedSvg = """ + + + + + + + """; + var nestedDataUri = "data:image/svg+xml;base64," + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(nestedSvg)); + var parentSvg = $$""" + + + + """; + var parameters = new SvgParameters( + null, + null, + null, + new SvgDocumentLoadOptions + { + ProcessingMode = ownerProcessingMode, + ExternalResources = SvgExternalResourcePolicy.Enabled, + PreserveUnknownElements = false, + PreferSvg2Href = false + }); + + var parentDocument = SvgService.FromSvg(parentSvg, parameters); + var parentImage = Assert.IsType(parentDocument!.GetElementById("asset")); + Assert.True(parentImage.TryGetEffectiveHrefString(out var parentHref)); + + var nestedDocument = Assert.IsType(SvgService.GetImage(parentHref, parentImage, new CountingAssetLoader())); + var nestedOptions = SvgService.GetDocumentLoadOptions(nestedDocument); + var shape = Assert.IsType(nestedDocument.GetElementById("shape")); + var fill = Assert.IsType(shape.Fill); + + Assert.Equal(expectedImageProcessingMode, nestedOptions.ProcessingMode); + Assert.Equal(SvgExternalResourcePolicy.Enabled, nestedOptions.ExternalResources); + Assert.False(nestedOptions.PreserveUnknownElements); + Assert.False(nestedOptions.PreferSvg2Href); + Assert.Single(nestedDocument.Descendants().OfType()); + Assert.Single(nestedDocument.Descendants().OfType()); + Assert.Equal(Color.Green.ToArgb(), fill.Colour.ToArgb()); + } + + [Fact] + public void GetImage_NestedSvgImageDoesNotLoosenSecureResourcePolicy() + { + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + var imagePath = Path.Combine(tempDirectory.FullName, "nested.png"); + File.WriteAllBytes(imagePath, new byte[] { 1, 2, 3, 4 }); + var imageUri = new Uri(Path.GetFullPath(imagePath)).AbsoluteUri; + var nestedSvg = $$""" + + + + """; + var nestedDataUri = "data:image/svg+xml;base64," + Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(nestedSvg)); + var parentSvg = $$""" + + + + """; + var parameters = new SvgParameters( + null, + null, + null, + new SvgDocumentLoadOptions + { + ProcessingMode = SvgProcessingMode.SecureAnimated, + ExternalResources = SvgExternalResourcePolicy.Enabled + }); + + var parentDocument = SvgService.FromSvg(parentSvg, parameters); + var parentImage = Assert.IsType(parentDocument!.GetElementById("asset")); + Assert.True(parentImage.TryGetEffectiveHrefString(out var parentHref)); + + var nestedDocument = Assert.IsType(SvgService.GetImage(parentHref, parentImage, new CountingAssetLoader())); + var nestedImage = Assert.IsType(nestedDocument.GetElementById("nested-asset")); + Assert.True(nestedImage.TryGetEffectiveHrefString(out var nestedHref)); + var assetLoader = new CountingAssetLoader(); + + var nestedRaster = SvgService.GetImage(nestedHref, nestedImage, assetLoader); + + Assert.Equal(SvgProcessingMode.SecureStatic, SvgService.GetDocumentLoadOptions(nestedDocument).ProcessingMode); + Assert.Null(nestedRaster); + Assert.Equal(0, assetLoader.LoadImageCallCount); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Theory] + [InlineData("image/svg+xml")] + [InlineData("image/svg+xml-compressed")] + public void GetImage_GzippedSvgDataUriUsesStaticImageDocumentPolicy(string mimeType) + { + var nestedSvg = """ + + + + + + + """; + var nestedDataUri = $"data:{mimeType};base64," + Convert.ToBase64String(CompressUtf8(nestedSvg)); + var parentSvg = $$""" + + + + """; + var parameters = new SvgParameters( + null, + null, + null, + new SvgDocumentLoadOptions + { + ProcessingMode = SvgProcessingMode.DynamicInteractive, + ExternalResources = SvgExternalResourcePolicy.Enabled + }); + + var parentDocument = SvgService.FromSvg(parentSvg, parameters); + var parentImage = Assert.IsType(parentDocument!.GetElementById("asset")); + Assert.True(parentImage.TryGetEffectiveHrefString(out var parentHref)); + + var nestedDocument = Assert.IsType(SvgService.GetImage(parentHref, parentImage, new CountingAssetLoader())); + var nestedOptions = SvgService.GetDocumentLoadOptions(nestedDocument); + var shape = Assert.IsType(nestedDocument.GetElementById("shape")); + var fill = Assert.IsType(shape.Fill); + + Assert.Equal(SvgProcessingMode.Static, nestedOptions.ProcessingMode); + Assert.Equal(SvgExternalResourcePolicy.Enabled, nestedOptions.ExternalResources); + Assert.Single(nestedDocument.Descendants().OfType()); + Assert.Single(nestedDocument.Descendants().OfType()); + Assert.Equal(Color.Green.ToArgb(), fill.Colour.ToArgb()); + } + [Fact] public void GetReference_SameDocumentAndDataOnlyBlocksExternalSvgElementReference() { @@ -763,6 +916,18 @@ private static SvgDocumentLoadOptions CreateLoadOptions(SvgExternalResourcePolic }; } + private static byte[] CompressUtf8(string text) + { + using var memoryStream = new MemoryStream(); + using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Optimal, leaveOpen: true)) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(text); + gzipStream.Write(bytes, 0, bytes.Length); + } + + return memoryStream.ToArray(); + } + private sealed class CountingAssetLoader : ISvgAssetLoader, ISvgImageAssetLoader { public int LoadImageCallCount { get; private set; } diff --git a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/struct-image-16-f.png b/tests/Svg.Skia.UnitTests/ChromeReference/W3C/struct-image-16-f.png deleted file mode 100644 index 420f852711..0000000000 Binary files a/tests/Svg.Skia.UnitTests/ChromeReference/W3C/struct-image-16-f.png and /dev/null differ diff --git a/tests/Svg.Skia.UnitTests/HitTestTests.cs b/tests/Svg.Skia.UnitTests/HitTestTests.cs index 129b6bb017..61fd48fe9e 100644 --- a/tests/Svg.Skia.UnitTests/HitTestTests.cs +++ b/tests/Svg.Skia.UnitTests/HitTestTests.cs @@ -130,6 +130,16 @@ public class HitTestTests : SvgUnitTest """; + private const string TextHitTestSvgFontDefs = """ + + + + + + + + """; + private static string GetSvgPath(string name) => Path.Combine("..", "..", "..", "..", "Tests", name); @@ -252,7 +262,7 @@ public void HitTest_Point_ReferencedClipPath_UsesReferencedGeometry() } [Fact] - public void HitTest_Point_MaskContainerWithoutPaint_DoesNotCountAsVisible() + public void HitTest_Point_MaskCoverage_DoesNotSuppressPointerTargets() { using var svg = new SKSvg(); svg.FromSvg(MaskHitTestSvg); @@ -261,11 +271,11 @@ public void HitTest_Point_MaskContainerWithoutPaint_DoesNotCountAsVisible() var outsideResults = svg.HitTestElements(new SKPoint(30, 20)).Select(e => e.ID).ToList(); Assert.Contains("target", insideResults); - Assert.DoesNotContain("target", outsideResults); + Assert.Contains("target", outsideResults); } [Fact] - public void HitTest_Point_MaskCoverage_IgnoresPointerEventSemantics() + public void HitTest_Point_MaskContentPointerEvents_DoNotAffectMaskedTarget() { using var svg = new SKSvg(); svg.FromSvg(MaskPointerEventsHitTestSvg); @@ -274,7 +284,7 @@ public void HitTest_Point_MaskCoverage_IgnoresPointerEventSemantics() var outsideResults = svg.HitTestElements(new SKPoint(30, 20)).Select(e => e.ID).ToList(); Assert.Contains("target", insideResults); - Assert.DoesNotContain("target", outsideResults); + Assert.Contains("target", outsideResults); } [Fact] @@ -359,6 +369,62 @@ public void HitTest_Point_NonScalingStroke_UsesDeviceSpaceStrokeWidth() Assert.Null(outsideElement); } + [Fact] + public void HitTest_TextPointerEvents_RejectsLetterSpacingGap() + { + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = true; + svg.FromSvg($$""" + + {{TextHitTestSvgFontDefs}} + + AA + + """); + + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(20, 40))?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(45, 40))?.ID); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(60, 40))?.ID); + } + + [Fact] + public void HitTest_TextPointerEvents_HitsSpaceCharacterCellButNotAdjacentLetterSpacing() + { + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = true; + svg.FromSvg($$""" + + {{TextHitTestSvgFontDefs}} + + A A + + """); + + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(20, 40))?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(40, 40))?.ID); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(60, 40))?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(80, 40))?.ID); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(100, 40))?.ID); + } + + [Fact] + public void HitTest_TextPointerEvents_UsesPositionedGlyphCellsInsteadOfTextBounds() + { + using var svg = new SKSvg(); + svg.Settings.EnableSvgFonts = true; + svg.FromSvg($$""" + + {{TextHitTestSvgFontDefs}} + + AA + + """); + + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(30, 40))?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(30, 70))?.ID); + Assert.Equal("target", svg.HitTestTopmostElement(new SKPoint(80, 80))?.ID); + } + private static bool IntersectsWith(SKRect a, SKRect b) { return a.Left < b.Right && a.Right > b.Left && diff --git a/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs b/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs index 4576beb039..1c802da868 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgSettingsTests.cs @@ -35,6 +35,14 @@ public void Defaults_EnableFilterBackgroundInputs() Assert.True(settings.EnableFilterBackgroundInputs); } + [Fact] + public void Defaults_EnableBrokenImagePlaceholders() + { + var settings = new SKSvgSettings(); + + Assert.True(settings.EnableBrokenImagePlaceholders); + } + [Fact] public void Defaults_DisableJavaScript() { @@ -66,6 +74,7 @@ public void CopyTo_CopiesRenderingAndJavaScriptSettings() EnableSvgFonts = false, EnableTextReferences = false, EnableFilterBackgroundInputs = false, + EnableBrokenImagePlaceholders = false, EnableJavaScript = true, EnableTextSelectionRendering = false, TextSelectionColor = new SKColor(1, 2, 3, 4), @@ -87,6 +96,7 @@ public void CopyTo_CopiesRenderingAndJavaScriptSettings() Assert.False(target.EnableSvgFonts); Assert.False(target.EnableTextReferences); Assert.False(target.EnableFilterBackgroundInputs); + Assert.False(target.EnableBrokenImagePlaceholders); Assert.True(target.EnableJavaScript); Assert.False(target.EnableTextSelectionRendering); Assert.Equal(new SKColor(1, 2, 3, 4), target.TextSelectionColor); @@ -105,6 +115,7 @@ public void Clone_CopiesJavaScriptSettings() var factory = new TestJavaScriptRuntimeFactory(); var settings = new SKSvgSettings { + EnableBrokenImagePlaceholders = false, EnableJavaScript = true, EnableExternalJavaScript = false, JavaScriptTimeoutMilliseconds = 250, @@ -116,6 +127,7 @@ public void Clone_CopiesJavaScriptSettings() var clone = settings.Clone(); Assert.NotSame(settings, clone); + Assert.False(clone.EnableBrokenImagePlaceholders); Assert.True(clone.EnableJavaScript); Assert.False(clone.EnableExternalJavaScript); Assert.Equal(250, clone.JavaScriptTimeoutMilliseconds); diff --git a/tests/Svg.Skia.UnitTests/SKSvgTests.cs b/tests/Svg.Skia.UnitTests/SKSvgTests.cs index 734c9b2afd..e95ffd1343 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgTests.cs @@ -1038,6 +1038,7 @@ public void Load_RelativeImageHrefWithoutBaseUri_SkipsMissingImage() """; var svg = new SKSvg(); + svg.Settings.EnableBrokenImagePlaceholders = false; using var input = new MemoryStream(Encoding.UTF8.GetBytes(svgMarkup)); using var _ = svg.Load(input); using var output = new MemoryStream(); diff --git a/tests/Svg.Skia.UnitTests/Svg2StaticImageFontResourcePolicyTests.cs b/tests/Svg.Skia.UnitTests/Svg2StaticImageFontResourcePolicyTests.cs index 666ae68484..ecca19ea15 100644 --- a/tests/Svg.Skia.UnitTests/Svg2StaticImageFontResourcePolicyTests.cs +++ b/tests/Svg.Skia.UnitTests/Svg2StaticImageFontResourcePolicyTests.cs @@ -155,7 +155,63 @@ public void ImageFontResourcePolicy_ForeignObjectRemainsMeasuredDeferredContent( Assert.Equal(SKRect.Create(3f, 4f, 12f, 10f), foreignObjectNode.GeometryBounds); } - private sealed class IntrinsicImageAssetLoader : ISvgAssetLoader + [Fact] + public void BrokenImagePlaceholder_EnabledCreatesDeterministicImageNode() + { + var document = SvgService.FromSvg(""" + + + + """); + var assetLoader = new IntrinsicImageAssetLoader(width: 0f, height: 0f) + { + EnableBrokenImagePlaceholders = true + }; + + var compiled = SvgSceneCompiler.TryCompile( + document, + SKRect.Create(0f, 0f, 20f, 20f), + assetLoader, + DrawAttributes.None, + out var sceneDocument); + + Assert.True(compiled); + Assert.NotNull(sceneDocument); + Assert.True(sceneDocument!.TryGetNodeById("asset", out var imageNode)); + Assert.True(imageNode!.IsRenderable); + Assert.Equal(SKRect.Create(0f, 0f, 16f, 12f), imageNode.GeometryBounds); + Assert.NotNull(imageNode.LocalModel); + Assert.Equal(3, imageNode.LocalModel!.Commands!.Count); + } + + [Fact] + public void BrokenImagePlaceholder_DisabledKeepsInvalidImageNonRenderable() + { + var document = SvgService.FromSvg(""" + + + + """); + var assetLoader = new IntrinsicImageAssetLoader(width: 0f, height: 0f) + { + EnableBrokenImagePlaceholders = false + }; + + var compiled = SvgSceneCompiler.TryCompile( + document, + SKRect.Create(0f, 0f, 20f, 20f), + assetLoader, + DrawAttributes.None, + out var sceneDocument); + + Assert.True(compiled); + Assert.NotNull(sceneDocument); + Assert.True(sceneDocument!.TryGetNodeById("asset", out var imageNode)); + Assert.False(imageNode!.IsRenderable); + Assert.Null(imageNode.LocalModel); + } + + private sealed class IntrinsicImageAssetLoader : ISvgAssetLoader, ISvgBrokenImagePlaceholderOptions { private readonly float _width; private readonly float _height; @@ -168,6 +224,8 @@ public IntrinsicImageAssetLoader(float width, float height) public bool EnableSvgFonts => false; + public bool EnableBrokenImagePlaceholders { get; init; } + public SKImage LoadImage(Stream stream) { return new SKImage diff --git a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs index 846b2bd3ed..ecbaa08d32 100644 --- a/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgAnimationControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -771,6 +772,74 @@ public void CreateAnimatedDocument_RespectsEventBasedEndTiming() Assert.Equal(2f, target!.X.Value, 3); } + [Fact] + public void NotifyAccessKey_ResolvesMultipleBeginConditions() + { + using var svg = new SKSvg(); + svg.FromSvg(AccessKeyMultipleBeginSvg); + + Assert.True(svg.NotifyAccessKey("a", TimeSpan.FromSeconds(2))); + + Assert.Equal(34f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(2.5)), 3); + Assert.Equal(0f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(3.5)), 3); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(6.5)), 3); + Assert.Equal(0f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(7.5)), 3); + } + + [Fact] + public void NotifyAccessKey_ResolvesEarlyAndLateEndConditions() + { + using var early = new SKSvg(); + early.FromSvg(AccessKeyEndSvg); + + Assert.True(early.NotifyAccessKey("a", TimeSpan.FromSeconds(2))); + Assert.Equal(34f, GetAnimatedRectangleX(early, "target", TimeSpan.FromSeconds(3)), 3); + Assert.Equal(0f, GetAnimatedRectangleX(early, "target", TimeSpan.FromSeconds(7.5)), 3); + + using var late = new SKSvg(); + late.FromSvg(AccessKeyEndSvg); + + Assert.True(late.NotifyAccessKey("a", TimeSpan.FromSeconds(6))); + Assert.Equal(0f, GetAnimatedRectangleX(late, "target", TimeSpan.FromSeconds(6.1)), 3); + } + + [Fact] + public void CreateAnimatedDocument_ResolvesWallclockBeginAndEndAgainstOrigin() + { + var document = SvgService.FromSvg(WallclockTimingSvg); + Assert.NotNull(document); + + using var controller = new SvgAnimationController( + document!, + new DateTimeOffset(2026, 5, 28, 0, 0, 0, TimeSpan.Zero)); + + var animated = controller.CreateAnimatedDocument(TimeSpan.Zero); + + Assert.Equal(34f, GetRectangleX(animated, "pastBegin"), 3); + Assert.Equal(34f, GetRectangleX(animated, "futureEnd"), 3); + } + + [Fact] + public void NotifyPointerEventAndAccessKey_ReplaysRepeatedUserEventSequence() + { + using var svg = new SKSvg(); + svg.FromSvg(MixedUserEventSequenceSvg); + + var trigger = svg.SourceDocument!.GetElementById("target"); + Assert.NotNull(trigger); + + Assert.True(svg.NotifyPointerEvent(trigger, SvgPointerEventType.Click, TimeSpan.FromSeconds(1))); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(1.5)), 3); + Assert.Equal(0f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(2.5)), 3); + + Assert.True(svg.NotifyPointerEvent(trigger, SvgPointerEventType.Click, TimeSpan.FromSeconds(3))); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(3.5)), 3); + Assert.Equal(0f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(4.5)), 3); + + Assert.True(svg.NotifyAccessKey("b", TimeSpan.FromSeconds(5))); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "target", TimeSpan.FromSeconds(5.5)), 3); + } + [Fact] public void CreateAnimatedDocument_UsesAnimateMotionValuesPath() { @@ -2022,6 +2091,53 @@ private static string GetW3CTestSvgPath(string name) """; + private const string AccessKeyMultipleBeginSvg = """ + + + + + + """; + + private const string AccessKeyEndSvg = """ + + + + + + """; + + private const string WallclockTimingSvg = """ + + + + + + + + + """; + + private const string MixedUserEventSequenceSvg = """ + + + + + + """; + private const string MotionValuesSvg = """ (bitmap); } + private static float GetAnimatedRectangleX(SKSvg svg, string elementId, TimeSpan time) + { + Assert.NotNull(svg.AnimationController); + var animated = svg.AnimationController!.CreateAnimatedDocument(time); + return GetRectangleX(animated, elementId); + } + + private static float GetRectangleX(SvgDocument document, string elementId) + { + var target = document.GetElementById(elementId); + Assert.NotNull(target); + if (target!.TryGetAttribute("x", out string rawX) && + float.TryParse(rawX, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedX)) + { + return parsedX; + } + + return target.X.Value; + } + private static void AssertAnimatedFill(SvgDocument document, string elementId, Color expectedColor) { var target = document.GetElementById(elementId); diff --git a/tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs b/tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs index 5d0ee69953..c6bdcc072b 100644 --- a/tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgInteractionDispatcherTests.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using ShimSkiaSharp; +using Svg; using Svg.Skia.UnitTests.Common; using Xunit; @@ -55,6 +58,13 @@ public class SvgInteractionDispatcherTests : SvgUnitTest """; + private const string InvalidPaintedFallbackSvg = """ + + + + + """; + private const string ClippedFrontSvg = """ @@ -67,6 +77,20 @@ public class SvgInteractionDispatcherTests : SvgUnitTest """; + private const string MaskedFrontSvg = """ + + + + + + + + + + + + """; + private const string UseInstanceSvg = """ """; + private const string UseInstanceSmilAnimationSvg = """ + + + + + + + + + + + + + + """; + + private const string AnchorSvg = """ + + + + + + """; + + private const string HyperlinkAnimationSvg = """ + + + + + + + + + """; + + private const string BlankTargetHyperlinkAnimationSvg = """ + + + + + + + + + """; + + private const string ShowNewNavigationSvg = """ + + + + + + """; + private const string SparseHitSvg = """ @@ -94,6 +180,75 @@ public class SvgInteractionDispatcherTests : SvgUnitTest """; + private const string HiddenPointerEventsSvg = """ + + + + + + + + + """; + + private const string TextPointerEventsSvg = """ + + + O + O + + """; + + private const string TextPointerEventsOverlaySvg = """ + + + O + + + + + + + """; + + private const string TextPointerEventsScriptSvg = """ + + + O + + """; + + private const string HiddenTextPointerEventsSvg = """ + + + + O + O + O + + + """; + + private const string JavaScriptMutationRetestSvg = """ + + + + + """; + + private const string AnimationMutationRetestSvg = """ + + + + + + + """; + [Fact] public void HitTestTopmostElement_ReturnsTopmostLeaf() { @@ -142,6 +297,17 @@ public void HitTestTopmostElement_UsesFillGeometryForFillPointerEvents() Assert.Equal("back", strokeOnlyElement?.ID); } + [Fact] + public void HitTestTopmostElement_SkipsPaintedElementWithMissingPaintServerAndNoneFallback() + { + using var svg = new SKSvg(); + svg.FromSvg(InvalidPaintedFallbackSvg); + + var element = svg.HitTestTopmostElement(new SKPoint(20, 20)); + + Assert.Equal("back", element?.ID); + } + [Fact] public void HitTestTopmostElement_RespectsClipPath() { @@ -155,6 +321,80 @@ public void HitTestTopmostElement_RespectsClipPath() Assert.Equal("front", visibleElement?.ID); } + [Fact] + public void HitTestTopmostElement_DoesNotSuppressMaskedTargets() + { + using var svg = new SKSvg(); + svg.FromSvg(MaskedFrontSvg); + + var emptyMaskElement = svg.HitTestTopmostElement(new SKPoint(20, 50)); + var transparentMaskElement = svg.HitTestTopmostElement(new SKPoint(80, 50)); + + Assert.Equal("emptyMask", emptyMaskElement?.ID); + Assert.Equal("transparentMask", transparentMaskElement?.ID); + } + + [Fact] + public void HitTestTopmostElement_HiddenElementsRespectNonVisiblePointerEventsValues() + { + using var svg = new SKSvg(); + svg.FromSvg(HiddenPointerEventsSvg); + + Assert.Equal("hiddenPainted", svg.HitTestTopmostElement(new SKPoint(15, 15))?.ID); + Assert.Equal("hiddenFill", svg.HitTestTopmostElement(new SKPoint(45, 15))?.ID); + Assert.Equal("hiddenStroke", svg.HitTestTopmostElement(new SKPoint(70, 20))?.ID); + Assert.Equal("hiddenAll", svg.HitTestTopmostElement(new SKPoint(15, 45))?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(45, 45))?.ID); + } + + [Fact] + public void HitTestTopmostElement_AppliesPointerEventsToTextBounds() + { + using var svg = new SKSvg(); + svg.FromSvg(TextPointerEventsSvg); + + Assert.Equal("textAll", svg.HitTestTopmostElement(new SKPoint(22, 38))?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(72, 38))?.ID); + } + + [Fact] + public void HitTestTopmostElement_TextPointerEventsPierceDisabledOverlay() + { + using var svg = new SKSvg(); + svg.FromSvg(TextPointerEventsOverlaySvg); + + Assert.Equal("glyph", svg.HitTestTopmostElement(new SKPoint(102, 78))?.ID); + } + + [Fact] + public void DispatchPointerMoved_TextMouseOverCanCallGlobalFunctionWithEventTarget() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.FromSvg(TextPointerEventsScriptSvg); + var dispatcher = new SvgInteractionDispatcher(); + + var result = dispatcher.DispatchPointerMoved( + svg, + CreateInput(62, 88, SvgMouseButton.None)); + + Assert.Equal("glyph", result.TargetElement?.ID); + var text = Assert.IsType(svg.SourceDocument!.GetElementById("glyph")); + var fill = Assert.IsType(text.Fill); + Assert.Equal(System.Drawing.Color.Green.ToArgb(), fill.Colour.ToArgb()); + } + + [Fact] + public void HitTestTopmostElement_HiddenTextRespectsNonVisiblePointerEvents() + { + using var svg = new SKSvg(); + svg.FromSvg(HiddenTextPointerEventsSvg); + + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(22, 38))?.ID); + Assert.Equal("painted", svg.HitTestTopmostElement(new SKPoint(72, 38))?.ID); + Assert.Equal("all", svg.HitTestTopmostElement(new SKPoint(122, 38))?.ID); + } + [Fact] public void HitTestTopmostElement_DoesNotReturnStructuralRootForEmptySpace() { @@ -177,6 +417,28 @@ public void HitTestTopmostElement_UsesOwningUseElementForGeneratedContent() Assert.Equal("instance", element?.ID); } + [Fact] + public void Dispatcher_RecordsSmilEventsForUseInstanceCorrespondingElements() + { + using var svg = new SKSvg(); + svg.FromSvg(UseInstanceSmilAnimationSvg); + var dispatcher = new SvgInteractionDispatcher(); + + var entered = dispatcher.DispatchPointerMoved(svg, CreateInput(12, 12, SvgMouseButton.None)); + + Assert.Equal("instance", entered.TargetElement?.ID); + var active = svg.AnimationController!.CreateAnimatedDocument(TimeSpan.FromMilliseconds(50)); + AssertRectangleFill(active, "templateChild", System.Drawing.Color.Blue); + AssertRectangleFill(active, "groupIndicator", System.Drawing.Color.Blue); + + svg.SetAnimationTime(TimeSpan.FromMilliseconds(100)); + _ = dispatcher.DispatchPointerMoved(svg, CreateInput(70, 30, SvgMouseButton.None)); + + var inactive = svg.AnimationController!.CreateAnimatedDocument(TimeSpan.FromMilliseconds(150)); + AssertRectangleFill(inactive, "templateChild", System.Drawing.Color.Red); + AssertRectangleFill(inactive, "groupIndicator", System.Drawing.Color.Red); + } + [Fact] public void Dispatcher_RaisesSharedAndSvgElementEvents() { @@ -234,6 +496,187 @@ public void Dispatcher_RaisesSharedAndSvgElementEvents() Assert.Equal(1, mouseOutCount); } + [Fact] + public void Dispatcher_ActivatesAnchorNavigationHandlerAfterClick() + { + var navigationHandler = new TestNavigationHandler(); + using var svg = new SKSvg(); + svg.Settings.NavigationHandler = navigationHandler; + svg.FromSvg(AnchorSvg); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(20, 20, SvgMouseButton.Left, clickCount: 1); + + dispatcher.DispatchPointerPressed(svg, input); + var result = dispatcher.DispatchPointerReleased(svg, input); + + var request = Assert.Single(navigationHandler.Requests); + Assert.True(result.Handled); + Assert.True(result.HyperlinkActivated); + Assert.True(result.DefaultActionActivated); + Assert.False(result.DefaultPrevented); + Assert.Equal("https://example.com/docs", request.Uri.OriginalString); + Assert.Equal("https://example.com/docs", request.Href); + Assert.Equal("_blank", request.Target); + Assert.Equal("link", request.SourceElementId); + Assert.Same(svg.SourceDocument!.GetElementById("link"), request.SourceElement); + Assert.Equal(input.PicturePoint, request.PicturePoint); + Assert.Equal(SvgMouseButton.Left, request.Button); + Assert.Equal(1, request.ClickCount); + Assert.Equal("mouse", request.SessionId); + Assert.Equal("https://example.com/docs", request.ResolvedUri.OriginalString); + Assert.Null(request.BaseUri); + Assert.Null(request.Fragment); + Assert.False(request.IsSameDocumentReference); + Assert.Null(request.Show); + } + + [Fact] + public void Dispatcher_AnchorFragmentStartsIndefiniteAnimation() + { + using var svg = new SKSvg(); + svg.FromSvg(HyperlinkAnimationSvg); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(25, 5, SvgMouseButton.Left, clickCount: 1); + + dispatcher.DispatchPointerPressed(svg, input); + var result = dispatcher.DispatchPointerReleased(svg, input); + + Assert.True(result.Handled); + Assert.True(result.HyperlinkActivated); + Assert.True(result.DefaultActionActivated); + + var animated = svg.AnimationController!.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var target = Assert.IsType(animated.GetElementById("target")); + Assert.Equal(5f, target.X.Value, 3); + } + + [Fact] + public void Dispatcher_BlankTargetAnchorFragmentUsesNavigationHandler() + { + var navigationHandler = new TestNavigationHandler(); + using var svg = new SKSvg(); + svg.Settings.NavigationHandler = navigationHandler; + svg.FromSvg(BlankTargetHyperlinkAnimationSvg); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(25, 5, SvgMouseButton.Left, clickCount: 1); + + dispatcher.DispatchPointerPressed(svg, input); + var result = dispatcher.DispatchPointerReleased(svg, input); + + var request = Assert.Single(navigationHandler.Requests); + Assert.True(result.Handled); + Assert.Equal("_blank", request.Target); + Assert.Equal("move", request.Fragment); + Assert.True(request.IsSameDocumentReference); + + var animated = svg.AnimationController!.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var target = Assert.IsType(animated.GetElementById("target")); + Assert.Equal(0f, target.X.Value, 3); + } + + [Fact] + public void Dispatcher_ShowNewAnchorMapsToBlankTargetAndResolvedUri() + { + var navigationHandler = new TestNavigationHandler(); + using var svg = new SKSvg(); + svg.Settings.NavigationHandler = navigationHandler; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(ShowNewNavigationSvg)); + var baseUri = new Uri("https://example.com/docs/source.svg"); + svg.Load(stream, null, baseUri); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(25, 5, SvgMouseButton.Left, clickCount: 1); + + dispatcher.DispatchPointerPressed(svg, input); + dispatcher.DispatchPointerReleased(svg, input); + + var request = Assert.Single(navigationHandler.Requests); + Assert.Equal("page.svg#section", request.Href); + Assert.Equal("page.svg#section", request.Uri.OriginalString); + Assert.Equal("https://example.com/docs/page.svg#section", request.ResolvedUri.OriginalString); + Assert.Equal(baseUri, request.BaseUri); + Assert.Equal("_blank", request.Target); + Assert.Equal("new", request.Show); + Assert.Equal("section", request.Fragment); + Assert.False(request.IsSameDocumentReference); + } + + [Fact] + public void BeginAndEndAnimationElement_ScheduleIndefiniteAnimation() + { + using var svg = new SKSvg(); + svg.FromSvg(HyperlinkAnimationSvg); + + Assert.True(svg.BeginAnimationElement("move")); + + var active = svg.AnimationController!.CreateAnimatedDocument(TimeSpan.FromSeconds(1)); + var activeTarget = Assert.IsType(active.GetElementById("target")); + Assert.Equal(5f, activeTarget.X.Value, 3); + + svg.SetAnimationTime(TimeSpan.FromSeconds(1)); + Assert.True(svg.EndAnimationElement("move")); + + var ended = svg.AnimationController.CreateAnimatedDocument(TimeSpan.FromSeconds(1.5)); + var endedTarget = Assert.IsType(ended.GetElementById("target")); + Assert.Equal(5f, endedTarget.X.Value, 3); + } + + [Fact] + public void Dispatcher_PreventDefaultSuppressesAnchorNavigationHandler() + { + var navigationHandler = new TestNavigationHandler(); + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + svg.Settings.NavigationHandler = navigationHandler; + svg.FromSvg(""" + + + + + + """); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(20, 20, SvgMouseButton.Left, clickCount: 1); + + dispatcher.DispatchPointerPressed(svg, input); + var result = dispatcher.DispatchPointerReleased(svg, input); + + Assert.True(result.Handled); + Assert.False(result.HyperlinkActivated); + Assert.False(result.DefaultActionActivated); + Assert.Empty(navigationHandler.Requests); + } + + [Fact] + public void Dispatcher_StopPropagationDoesNotSuppressAnchorNavigationHandler() + { + var navigationHandler = new TestNavigationHandler(); + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + svg.Settings.NavigationHandler = navigationHandler; + svg.FromSvg(""" + + + + + + """); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(20, 20, SvgMouseButton.Left, clickCount: 1); + + dispatcher.DispatchPointerPressed(svg, input); + dispatcher.DispatchPointerReleased(svg, input); + + Assert.Single(navigationHandler.Requests); + } + [Fact] public void Dispatcher_RoutesSharedEventsThroughTunnelTargetAndBubble() { @@ -393,7 +836,10 @@ public void Dispatcher_ResolvesCursorFromAncestors() svg.FromSvg(CursorSvg); string? eventCursor = null; + var cursorChanges = new List<(string? OldCursor, string? NewCursor, string? TargetId)>(); var dispatcher = new SvgInteractionDispatcher(); + dispatcher.CursorChanged += (_, args) => + cursorChanges.Add((args.OldCursor, args.NewCursor, args.TargetElement?.ID)); dispatcher.Dispatched += (_, args) => { if (args.EventType == SvgPointerEventType.Move && @@ -409,6 +855,85 @@ public void Dispatcher_ResolvesCursorFromAncestors() Assert.Equal("pointer", result.Cursor); Assert.Equal("pointer", dispatcher.CurrentCursor); Assert.Equal("pointer", eventCursor); + Assert.Collection( + cursorChanges, + item => + { + Assert.Null(item.OldCursor); + Assert.Equal("pointer", item.NewCursor); + Assert.Equal("inner", item.TargetId); + }); + } + + [Fact] + public void Dispatcher_PressFocusesFocusableElementAndDispatchesFocusEvents() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + svg.FromSvg(""" + + + + + + """); + var focusChanges = new List<(string? OldId, string? NewId)>(); + var dispatcher = new SvgInteractionDispatcher(); + dispatcher.FocusChanged += (_, args) => focusChanges.Add((args.OldElement?.ID, args.NewElement?.ID)); + + var first = dispatcher.DispatchPointerPressed(svg, CreateInput(5, 5, SvgMouseButton.Left, clickCount: 1)); + var second = dispatcher.DispatchPointerPressed(svg, CreateInput(45, 5, SvgMouseButton.Left, clickCount: 1)); + + Assert.Equal("first", first.FocusedElement?.ID); + Assert.True(first.DefaultActionActivated); + Assert.Equal("second", second.FocusedElement?.ID); + Assert.Equal("second", dispatcher.FocusedElement?.ID); + Assert.Equal( + "first-in:null;first-out:second;second-in:first;", + GetAttributeValue(svg.SourceDocument!, "data-log")); + var expectedChanges = new List<(string? OldId, string? NewId)> + { + (null, "first"), + ("first", "second") + }; + Assert.Equal(expectedChanges, focusChanges); + } + + [Fact] + public void Dispatcher_MousedownPreventDefaultSuppressesFocusDefaultAction() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + svg.FromSvg(""" + + + + """); + var dispatcher = new SvgInteractionDispatcher(); + + var result = dispatcher.DispatchPointerPressed(svg, CreateInput(5, 5, SvgMouseButton.Left, clickCount: 1)); + + Assert.True(result.Handled); + Assert.True(result.DefaultPrevented); + Assert.False(result.DefaultActionActivated); + Assert.Null(result.FocusedElement); + Assert.Null(dispatcher.FocusedElement); + Assert.Equal(string.Empty, GetAttributeValue(svg.SourceDocument!, "data-log")); } [Fact] @@ -477,6 +1002,129 @@ public void Dispatcher_CapturesMoveAndReleaseToPressedElementUntilRelease() }); } + [Fact] + public void Dispatcher_ClickSequencesEnterPressReleaseAndClick() + { + using var svg = new SKSvg(); + svg.FromSvg(NestedSvg); + + var routed = new List(); + var dispatcher = new SvgInteractionDispatcher(); + dispatcher.Dispatched += (_, args) => + { + if (args.Element?.ID == "inner" && args.RoutePhase == SvgPointerEventRoutePhase.Target) + { + routed.Add(args.EventType); + } + }; + + var result = dispatcher.DispatchPointerClick(svg, CreateInput(30, 30, SvgMouseButton.Left, clickCount: 1)); + + Assert.Equal("inner", result.TargetElement?.ID); + Assert.Equal( + new[] + { + SvgPointerEventType.Enter, + SvgPointerEventType.Press, + SvgPointerEventType.Release, + SvgPointerEventType.Click + }, + routed); + } + + [Fact] + public void Dispatcher_MoveSequencesMouseOutBeforeMouseOverAndMove() + { + using var svg = new SKSvg(); + svg.FromSvg(NestedSvg); + + var routed = new List<(SvgPointerEventType EventType, string? ElementId)>(); + var dispatcher = new SvgInteractionDispatcher(); + dispatcher.Dispatched += (_, args) => + { + if (args.RoutePhase == SvgPointerEventRoutePhase.Target) + { + routed.Add((args.EventType, args.Element?.ID)); + } + }; + + dispatcher.DispatchPointerMoved(svg, CreateInput(30, 30, SvgMouseButton.None)); + routed.Clear(); + + dispatcher.DispatchPointerMoved(svg, CreateInput(10, 10, SvgMouseButton.None)); + + var expected = new (SvgPointerEventType EventType, string? ElementId)[] + { + (SvgPointerEventType.Leave, "inner"), + (SvgPointerEventType.Enter, "outer"), + (SvgPointerEventType.Move, "outer") + }; + Assert.Equal(expected, routed); + } + + [Fact] + public void Dispatcher_RetestsHoverAfterJavaScriptMutationChangesPointerEvents() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + svg.FromSvg(JavaScriptMutationRetestSvg); + + var routed = new List<(SvgPointerEventType EventType, string? ElementId)>(); + var dispatcher = new SvgInteractionDispatcher(); + dispatcher.Dispatched += (_, args) => + { + if (args.RoutePhase == SvgPointerEventRoutePhase.Target) + { + routed.Add((args.EventType, args.Element?.ID)); + } + }; + + var result = dispatcher.DispatchPointerMoved(svg, CreateInput(20, 20, SvgMouseButton.None)); + + Assert.Equal("back", result.TargetElement?.ID); + Assert.Equal("back", dispatcher.HoveredElement?.ID); + Assert.Equal("back", svg.HitTestTopmostElement(new SKPoint(20, 20))?.ID); + var expected = new (SvgPointerEventType EventType, string? ElementId)[] + { + (SvgPointerEventType.Enter, "front"), + (SvgPointerEventType.Leave, "front"), + (SvgPointerEventType.Enter, "back"), + (SvgPointerEventType.Move, "back") + }; + Assert.Equal(expected, routed); + } + + [Fact] + public void Dispatcher_RetestsHoverAfterAnimationMutationChangesHitTarget() + { + using var svg = new SKSvg(); + svg.FromSvg(AnimationMutationRetestSvg); + + var routed = new List<(SvgPointerEventType EventType, string? ElementId)>(); + var dispatcher = new SvgInteractionDispatcher(); + dispatcher.Dispatched += (_, args) => + { + if (args.RoutePhase == SvgPointerEventRoutePhase.Target) + { + routed.Add((args.EventType, args.Element?.ID)); + } + }; + + var result = dispatcher.DispatchPointerMoved(svg, CreateInput(20, 20, SvgMouseButton.None)); + + Assert.Equal("back", result.TargetElement?.ID); + Assert.Equal("back", dispatcher.HoveredElement?.ID); + var expected = new (SvgPointerEventType EventType, string? ElementId)[] + { + (SvgPointerEventType.Enter, "front"), + (SvgPointerEventType.Leave, "front"), + (SvgPointerEventType.Enter, "back"), + (SvgPointerEventType.Move, "back") + }; + Assert.Equal(expected, routed); + } + [Fact] public void Dispatcher_TracksHoveredElement() { @@ -508,4 +1156,27 @@ private static SvgPointerInput CreateInput(float x, float y, SvgMouseButton butt ctrlKey: false, sessionId: "mouse"); } + + private static string GetAttributeValue(SvgElement element, string attributeName) + { + return element.TryGetAttribute(attributeName, out var value) ? value ?? string.Empty : string.Empty; + } + + private static void AssertRectangleFill(SvgDocument document, string elementId, System.Drawing.Color expected) + { + var rectangle = Assert.IsType(document.GetElementById(elementId)); + var fill = Assert.IsType(rectangle.Fill); + Assert.Equal(expected.ToArgb(), fill.Colour.ToArgb()); + } + + private sealed class TestNavigationHandler : ISKSvgNavigationHandler + { + public List Requests { get; } = new(); + + public bool Navigate(SKSvgNavigationRequest request) + { + Requests.Add(request); + return true; + } + } } diff --git a/tests/Svg.Skia.UnitTests/SvgJavaScriptRuntimeTests.cs b/tests/Svg.Skia.UnitTests/SvgJavaScriptRuntimeTests.cs index b4aa085344..019880d89a 100644 --- a/tests/Svg.Skia.UnitTests/SvgJavaScriptRuntimeTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgJavaScriptRuntimeTests.cs @@ -108,6 +108,149 @@ public void FromSvg_RootLoadEventDispatchesRegisteredListeners() AssertFill(svg, "target", Color.Green); } + [Fact] + public void FromSvg_CurrentScaleAndTranslateUseViewerState() + { + using var svg = new SKSvg + { + CurrentScale = 2d, + CurrentTranslate = new SKPoint(4f, 5f) + }; + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg(""" + + + + + """); + + AssertFill(svg, "target", Color.Green); + Assert.Equal(3d, svg.CurrentScale); + Assert.Equal(new SKPoint(7f, 8f), svg.CurrentTranslate); + } + + [Fact] + public void Draw_AppliesViewerZoomAndPanTransform() + { + using var svg = new SKSvg(); + svg.FromSvg(""" + + + + """); + + Assert.True(svg.SetViewerTransform(2d, new SKPoint(3f, 5f))); + + var viewerPoint = svg.PictureToViewerPoint(new SKPoint(2f, 2f)); + Assert.Equal(new SKPoint(7f, 9f), viewerPoint); + Assert.True(svg.TryGetViewerPicturePoint(viewerPoint, out var picturePoint)); + AssertApproximately(2f, picturePoint.X); + AssertApproximately(2f, picturePoint.Y); + + using var bitmap = RenderDrawBitmap(svg, 30, 30); + + AssertGreen(bitmap.GetPixel(8, 10)); + Assert.Equal(0, bitmap.GetPixel(3, 3).Alpha); + } + + [Fact] + public void ResetViewerTransform_RestoresCurrentScaleAndTranslate() + { + using var svg = new SKSvg(); + svg.FromSvg(""" + + + + """); + + var changes = new List(); + svg.ViewerTransformChanged += (_, args) => changes.Add(args); + + Assert.True(svg.ZoomBy(2d)); + Assert.True(svg.PanBy(new SKPoint(4f, 5f))); + Assert.True(svg.ResetViewerTransform()); + + Assert.Equal(1d, svg.CurrentScale); + Assert.Equal(default, svg.CurrentTranslate); + Assert.True(svg.ViewerTransform.IsIdentity); + Assert.Equal(3, changes.Count); + Assert.Equal(1d, changes[^1].NewScale); + Assert.Equal(default, changes[^1].NewTranslate); + } + + [Fact] + public void FromSvg_ZoomAndPanDisableSuppressesViewerTransform() + { + using var svg = new SKSvg(); + svg.FromSvg(""" + + + + """); + + Assert.False(svg.IsZoomAndPanEnabled); + Assert.False(svg.ZoomTo(2d)); + Assert.False(svg.PanBy(new SKPoint(3f, 5f))); + Assert.False(svg.SetViewerTransform(2d, new SKPoint(3f, 5f))); + + svg.CurrentScale = 3d; + svg.CurrentTranslate = new SKPoint(7f, 8f); + + Assert.Equal(1d, svg.CurrentScale); + Assert.Equal(default, svg.CurrentTranslate); + Assert.True(svg.ViewerTransform.IsIdentity); + + using var bitmap = RenderDrawBitmap(svg, 20, 20); + + AssertGreen(bitmap.GetPixel(3, 3)); + Assert.Equal(0, bitmap.GetPixel(10, 10).Alpha); + } + + [Fact] + public void FromSvg_EmbeddedSvgImageDoesNotRunNestedScriptsOrAnimationsWhenJavaScriptEnabled() + { + var nestedSvg = """ + + + + + + + """; + var nestedDataUri = "data:image/svg+xml;base64," + Convert.ToBase64String(Encoding.UTF8.GetBytes(nestedSvg)); + var parentSvg = $$""" + + + + + + """; + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg(parentSvg); + using var bitmap = RenderBitmap(svg); + var embeddedPixel = bitmap.GetPixel(10, 10); + + AssertFill(svg, "target", Color.Green); + Assert.True( + embeddedPixel.Green >= 128 && embeddedPixel.Red < 50 && embeddedPixel.Blue < 50 && embeddedPixel.Alpha > 200, + $"Expected embedded SVG image to stay green, but pixel was {embeddedPixel}."); + } + [Fact] public void FromSvg_ReusedInstanceLoadScriptsApplyPendingAnimationTime() { @@ -2276,6 +2419,31 @@ private static SkiaSharp.SKBitmap RenderBitmap(SKSvg svg) return Assert.IsType(bitmap); } + private static SkiaSharp.SKBitmap RenderDrawBitmap(SKSvg svg, int width, int height) + { + var bitmap = new SkiaSharp.SKBitmap( + width, + height, + SkiaSharp.SKColorType.Rgba8888, + SkiaSharp.SKAlphaType.Unpremul); + using var canvas = new SkiaSharp.SKCanvas(bitmap); + canvas.Clear(SkiaSharp.SKColors.Transparent); + svg.Draw(canvas); + return bitmap; + } + + private static void AssertGreen(SkiaSharp.SKColor pixel) + { + Assert.True( + pixel.Green >= 128 && pixel.Red < 50 && pixel.Blue < 50 && pixel.Alpha > 200, + $"Expected green pixel, but pixel was {pixel}."); + } + + private static void AssertApproximately(float expected, float actual) + { + Assert.True(Math.Abs(expected - actual) < 0.001f, $"Expected {expected}, but was {actual}."); + } + private static SvgJavaScriptRuntime GetJavaScriptRuntime(SKSvg svg) { var field = typeof(SKSvg).GetField("_javaScriptRuntime", BindingFlags.Instance | BindingFlags.NonPublic) diff --git a/tests/Svg.Skia.UnitTests/SvgResourceRenderingParityTests.cs b/tests/Svg.Skia.UnitTests/SvgResourceRenderingParityTests.cs index 91b851706a..48b1946218 100644 --- a/tests/Svg.Skia.UnitTests/SvgResourceRenderingParityTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgResourceRenderingParityTests.cs @@ -461,6 +461,51 @@ public void RetainedSceneGraph_FeImageExternalSvgLoadsNestedRaster() } } + [Fact] + public void RetainedSceneGraph_NestedImplicitSvgImageUsesImageViewport() + { + var previousResolveExternalImages = SvgDocument.ResolveExternalImages; + var tempDirectory = Directory.CreateTempSubdirectory(); + + try + { + SvgDocument.ResolveExternalImages = ExternalType.Local | ExternalType.Remote; + + var level2Path = Path.Combine(tempDirectory.FullName, "level2.svg"); + File.WriteAllText(level2Path, """ + + + + """); + + var level1Path = Path.Combine(tempDirectory.FullName, "level1.svg"); + File.WriteAllText(level1Path, """ + + + + """); + + var sourcePath = Path.Combine(tempDirectory.FullName, "source.svg"); + File.WriteAllText(sourcePath, """ + + + + """); + + using var svg = new SKSvg(); + svg.Load(sourcePath); + + Assert.NotNull(svg.Picture); + using var bitmap = ToBitmap(svg, svg.Picture!); + AssertMostlyGreen(bitmap.GetPixel(10, 10), "Expected nested implicit SVG image to resolve percentage dimensions against the image viewport."); + } + finally + { + SvgDocument.ResolveExternalImages = previousResolveExternalImages; + tempDirectory.Delete(recursive: true); + } + } + [Fact] public void RetainedSceneGraph_FeImageExternalSvgCycleProducesTransparentNestedInput() { diff --git a/tests/Svg.Skia.UnitTests/SvgTextSelectionDomTests.cs b/tests/Svg.Skia.UnitTests/SvgTextSelectionDomTests.cs index b855e65e3a..88409dc3de 100644 --- a/tests/Svg.Skia.UnitTests/SvgTextSelectionDomTests.cs +++ b/tests/Svg.Skia.UnitTests/SvgTextSelectionDomTests.cs @@ -27,7 +27,15 @@ public void SelectSubString_RecordsLogicalSelectionExtents() Assert.Equal("label", selection.ElementId); Assert.Equal(1, selection.Charnum); Assert.Equal(3, selection.NChars); + Assert.Equal(1, selection.StartCharnum); + Assert.Equal(4, selection.EndCharnum); + Assert.Equal(3, selection.SelectedNChars); + Assert.Equal(1, selection.AnchorCharnum); + Assert.Equal(3, selection.FocusCharnum); + Assert.Equal(SKSvg.SvgTextSelectionDirection.Forward, selection.Direction); + Assert.True(selection.HasCaret); Assert.NotEmpty(selection.Extents); + Assert.NotEmpty(selection.VisualExtents); Assert.All(selection.Extents, extent => Assert.False(extent.IsEmpty)); } @@ -106,6 +114,85 @@ public void TextSelections_ReturnSnapshotExtents() Assert.Equal(original, Assert.Single(svg.TextSelections).Extents[0]); } + [Fact] + public void TextSelections_ExposeVisualOrderExtents() + { + using var svg = new SKSvg(); + svg.FromSvg( + """ + + + AB + + + """); + + svg.SelectTextSubString(GetTextElement(svg, "label"), 0, 2); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal(2, selection.Extents.Count); + Assert.Equal(2, selection.VisualExtents.Count); + Assert.True(selection.Extents[0].Left > selection.Extents[1].Left); + Assert.True(selection.VisualExtents[0].Left < selection.VisualExtents[1].Left); + } + + [Fact] + public void SelectSubString_BidiLogicalRangeRendersDiscontiguousVisualHighlights() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg( + """ + + abc אבג 123 דהו def + + """); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("text", selection.ElementId); + Assert.Equal(0, selection.Charnum); + Assert.Equal(9, selection.NChars); + Assert.Equal(3, selection.Extents.Count); + Assert.Equal(3, selection.VisualExtents.Count); + Assert.True(selection.Extents[1].Left > selection.Extents[2].Left); + Assert.True(selection.VisualExtents[0].Right < selection.VisualExtents[1].Left); + Assert.True(selection.VisualExtents[1].Right < selection.VisualExtents[2].Left); + + var command = Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + var rects = command.Path!.Commands!.OfType().Select(static command => command.Rect).ToArray(); + Assert.Equal(3, rects.Length); + Assert.Equal(selection.VisualExtents.Select(static extent => extent.Left).ToArray(), rects.Select(static rect => rect.Left).ToArray()); + } + + [Fact] + public void SelectTextSubString_MultilineTspanRangeRendersOneHighlightPerLine() + { + using var svg = new SKSvg(); + svg.FromSvg( + """ + + + First line + Second line + + + """); + + var text = GetTextElement(svg, "text"); + svg.SelectTextSubString(text, 0, text.Text.Length); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal(2, selection.Extents.Count); + Assert.True(selection.VisualExtents[0].Bottom < selection.VisualExtents[1].Top); + + var command = Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + var rects = command.Path!.Commands!.OfType().ToArray(); + Assert.Equal(2, rects.Length); + } + [Fact] public void SelectSubString_InvalidStartThrowsDomExceptionAndKeepsPreviousSelection() { @@ -140,4 +227,396 @@ function runSelection() { Assert.Equal(2, selection.NChars); Assert.Equal("1", document.GetElementById("status")!.Nodes.OfType().Single().Content); } + + [Fact] + public void SelectSubString_ReplacesPreviousSelection() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg( + """ + + + ABCDE + VWXYZ + + """); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("second", selection.ElementId); + Assert.Equal(1, selection.Charnum); + Assert.Equal(3, selection.NChars); + var command = Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.Equal("second", command.SourceElementId); + } + + [Fact] + public void SelectSubString_ZeroLengthClearsPreviousSelection() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg( + """ + + + ABCDE + + """); + + Assert.Empty(svg.TextSelections); + Assert.Empty(FindSelectionCommands(Assert.IsType(svg.Model))); + } + + [Fact] + public void SelectTextSubString_ReplacesPreviousSelectionAndHighlight() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + var second = GetTextElement(svg, "second"); + + svg.SelectTextSubString(first, 0, 2); + + var firstSelection = Assert.Single(svg.TextSelections); + Assert.Equal("first", firstSelection.ElementId); + var firstCommand = Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.Equal("first", firstCommand.SourceElementId); + + svg.SelectTextSubString(second, 1, 3); + + var secondSelection = Assert.Single(svg.TextSelections); + Assert.Equal("second", secondSelection.ElementId); + Assert.Equal(1, secondSelection.Charnum); + Assert.Equal(3, secondSelection.NChars); + var secondCommand = Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.Equal("second", secondCommand.SourceElementId); + } + + [Fact] + public void SelectTextSubString_ZeroLengthClearsSelectionAndInvalidatesHighlight() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + svg.SelectTextSubString(first, 0, 2); + var modelWithSelection = Assert.IsType(svg.Model); + var pictureWithSelection = svg.Picture; + Assert.NotNull(pictureWithSelection); + Assert.Single(FindSelectionCommands(modelWithSelection)); + + svg.SelectTextSubString(first, 1, 0); + + Assert.Empty(svg.TextSelections); + Assert.Empty(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.NotSame(modelWithSelection, svg.Model); + Assert.NotSame(pictureWithSelection, svg.Picture); + } + + [Fact] + public void ClearTextSelection_RemovesHighlightAndInvalidatesCachedPicture() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + svg.SelectTextSubString(first, 0, 2); + var modelWithSelection = Assert.IsType(svg.Model); + var pictureWithSelection = svg.Picture; + Assert.NotNull(pictureWithSelection); + Assert.Single(FindSelectionCommands(modelWithSelection)); + + svg.ClearTextSelection(); + + Assert.Empty(svg.TextSelections); + Assert.Empty(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.NotSame(modelWithSelection, svg.Model); + Assert.NotSame(pictureWithSelection, svg.Picture); + } + + [Fact] + public void TryBeginTextSelection_CreatesQueryableCollapsedCaretWithoutHighlight() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + Assert.True(svg.TryBeginTextSelection(first, 2)); + + var selection = Assert.Single(svg.TextSelections); + Assert.True(svg.HasTextSelection); + Assert.True(svg.TryGetTextSelection(out var activeSelection)); + Assert.Equal(selection.Charnum, activeSelection.Charnum); + Assert.Equal("first", selection.ElementId); + Assert.Equal(2, selection.Charnum); + Assert.Equal(0, selection.NChars); + Assert.Equal(2, selection.AnchorCharnum); + Assert.Equal(2, selection.FocusCharnum); + Assert.Equal(SKSvg.SvgTextSelectionDirection.None, selection.Direction); + Assert.True(selection.IsCollapsed); + Assert.True(selection.HasCaret); + Assert.Empty(selection.Extents); + Assert.Empty(FindSelectionCommands(Assert.IsType(svg.Model))); + } + + [Fact] + public void TryExtendTextSelection_UsesStoredAnchorAndRefreshesHighlight() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + Assert.True(svg.TryBeginTextSelection(first, 1)); + Assert.True(svg.TryExtendTextSelection(first, 4)); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("first", selection.ElementId); + Assert.Equal(1, selection.Charnum); + Assert.Equal(4, selection.NChars); + Assert.Equal(1, selection.AnchorCharnum); + Assert.Equal(4, selection.FocusCharnum); + Assert.Equal(SKSvg.SvgTextSelectionDirection.Forward, selection.Direction); + Assert.False(selection.IsCollapsed); + Assert.NotEmpty(selection.VisualExtents); + Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + } + + [Fact] + public void TryExtendTextSelection_BackToAnchorReturnsCollapsedCaretAndClearsHighlight() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + Assert.True(svg.TryBeginTextSelection(first, 3)); + Assert.True(svg.TryExtendTextSelection(first, 1)); + Assert.NotEmpty(FindSelectionCommands(Assert.IsType(svg.Model))); + + Assert.True(svg.TryExtendTextSelection(first, 3)); + + var selection = Assert.Single(svg.TextSelections); + Assert.True(selection.IsCollapsed); + Assert.Equal(3, selection.AnchorCharnum); + Assert.Equal(3, selection.FocusCharnum); + Assert.Empty(FindSelectionCommands(Assert.IsType(svg.Model))); + } + + [Fact] + public void TryGetTextSelection_CanQueryByElement() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + var second = GetTextElement(svg, "second"); + + Assert.True(svg.TrySelectTextRange(first, 4, 1)); + + Assert.True(svg.TryGetTextSelection(first, out var firstSelection)); + Assert.False(svg.TryGetTextSelection(second, out _)); + Assert.Equal("first", firstSelection.ElementId); + Assert.Equal(SKSvg.SvgTextSelectionDirection.Backward, firstSelection.Direction); + } + + [Fact] + public void JavaScriptTextSelectionHelpers_ExposeCaretRangeAndDocumentClear() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg( + """ + + + ABCDE + pending + pending + + """); + + var document = svg.SourceDocument!; + Assert.Equal( + "true|true|2|2|true|label|2|3|1|forward", + document.GetElementById("status")!.Nodes.OfType().Single().Content); + Assert.Equal("cleared", document.GetElementById("cleared")!.Nodes.OfType().Single().Content); + Assert.Empty(svg.TextSelections); + Assert.False(svg.HasTextSelection); + } + + [Fact] + public void SelectTextSubString_InvalidRangeKeepsPreviousSelection() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + var second = GetTextElement(svg, "second"); + + svg.SelectTextSubString(first, 0, 2); + + Assert.Throws(() => svg.SelectTextSubString(second, 99, 1)); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("first", selection.ElementId); + Assert.Equal(0, selection.Charnum); + Assert.Equal(2, selection.NChars); + var command = Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.Equal("first", command.SourceElementId); + } + + [Fact] + public void TrySelectTextSubString_InvalidRangeReturnsFalseAndKeepsPreviousSelection() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + var second = GetTextElement(svg, "second"); + + Assert.True(svg.TrySelectTextSubString(first, 0, 2)); + + Assert.False(svg.TrySelectTextSubString(second, 99, 1)); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("first", selection.ElementId); + Assert.Equal(0, selection.Charnum); + Assert.Equal(2, selection.NChars); + } + + [Fact] + public void TrySelectTextRange_ComposesBackwardRangeWithCaretMetadata() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + Assert.True(svg.TrySelectTextRange(first, 4, 1)); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("first", selection.ElementId); + Assert.Equal(1, selection.Charnum); + Assert.Equal(4, selection.NChars); + Assert.Equal(1, selection.StartCharnum); + Assert.Equal(5, selection.EndCharnum); + Assert.Equal(4, selection.AnchorCharnum); + Assert.Equal(1, selection.FocusCharnum); + Assert.Equal(SKSvg.SvgTextSelectionDirection.Backward, selection.Direction); + Assert.True(selection.HasCaret); + Assert.False(selection.CaretExtent.IsEmpty); + } + + [Fact] + public void TrySelectTextRange_FromPointsUsesHitCharacters() + { + using var svg = CreateTwoLabelSvg(); + var first = GetTextElement(svg, "first"); + + Assert.True(svg.TrySelectTextRange(first, new SKPoint(12, 30), new SKPoint(48, 30))); + + var selection = Assert.Single(svg.TextSelections); + Assert.Equal("first", selection.ElementId); + Assert.Equal(0, selection.Charnum); + Assert.True(selection.NChars >= 2); + Assert.True(selection.HasCaret); + } + + [Fact] + public void EventDrivenMutation_RecomputesSelectionExtentsBeforeHighlightRendering() + { + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + + svg.FromSvg( + """ + + + ABCDE + + """); + + var beforeSelection = Assert.Single(svg.TextSelections); + var beforeLeft = beforeSelection.Extents[0].Left; + var beforeModel = Assert.IsType(svg.Model); + + var dispatcher = new SvgInteractionDispatcher(); + var input = CreateInput(10, 10); + dispatcher.DispatchPointerPressed(svg, input); + dispatcher.DispatchPointerReleased(svg, input); + + var afterSelection = Assert.Single(svg.TextSelections); + Assert.True(afterSelection.Extents[0].Left > beforeLeft + 40f); + Assert.Single(FindSelectionCommands(Assert.IsType(svg.Model))); + Assert.NotSame(beforeModel, svg.Model); + } + + private static SKSvg CreateTwoLabelSvg() + { + var svg = new SKSvg(); + svg.FromSvg( + """ + + ABCDE + VWXYZ + + """); + return svg; + } + + private static SvgTextBase GetTextElement(SKSvg svg, string elementId) + { + return Assert.IsAssignableFrom(svg.SourceDocument!.GetElementById(elementId)); + } + + private static DrawPathCanvasCommand[] FindSelectionCommands(SKPicture model) + { + return model + .FindCommands() + .Where(static command => command.SourceElementTypeName == "SvgTextSelection") + .ToArray(); + } + + private static SvgPointerInput CreateInput(float x, float y) + { + return new SvgPointerInput( + new SKPoint(x, y), + SvgPointerDeviceType.Mouse, + SvgMouseButton.Left, + clickCount: 1, + wheelDelta: 0, + altKey: false, + shiftKey: false, + ctrlKey: false, + sessionId: "test"); + } } diff --git a/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs b/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs new file mode 100644 index 0000000000..b3be643786 --- /dev/null +++ b/tests/Svg.Skia.UnitTests/SvgViewerResourceTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using Xunit; +using SkiaAlphaType = SkiaSharp.SKAlphaType; +using SkiaBitmap = SkiaSharp.SKBitmap; +using SkiaColor = SkiaSharp.SKColor; +using SkiaColors = SkiaSharp.SKColors; +using SkiaColorType = SkiaSharp.SKColorType; + +namespace Svg.Skia.UnitTests; + +public class SvgViewerResourceTests +{ + [Fact] + public void Load_W3CGzippedSvgDataImageRendersEmbeddedStar() + { + using var svg = new SKSvg(); + svg.Load(GetW3CSvgPath("conform-viewers-02-f")); + + using var bitmap = RenderBitmap(svg); + var starCenter = bitmap.GetPixel(240, 170); + + Assert.True( + starCenter.Alpha > 0 && starCenter.Red > 100 && starCenter.Green > 100, + $"Expected W3C gzipped data SVG image content near the center, but found {starCenter}."); + } + + [Fact] + public void FromSvg_GzippedSvgDataImageUsesStaticNestedDocumentWhenJavaScriptEnabled() + { + var nestedSvg = """ + + + + + + + """; + var nestedDataUri = "data:image/svg+xml;base64," + Convert.ToBase64String(CompressUtf8(nestedSvg)); + var parentSvg = $$""" + + + + + """; + + using var svg = new SKSvg(); + svg.Settings.EnableJavaScript = true; + svg.Settings.ThrowOnJavaScriptError = true; + svg.FromSvg(parentSvg); + + using var bitmap = RenderBitmap(svg); + + AssertMostlyGreen( + bitmap.GetPixel(10, 10), + "Expected gzipped data SVG image to render as a static nested image document."); + } + + [Fact] + public void Load_CyclicNestedSvgImageUsesPlaceholderAndPreservesSiblingContent() + { + var tempDirectory = Directory.CreateTempSubdirectory("SvgSkiaImageCycle"); + try + { + var parentPath = Path.Combine(tempDirectory.FullName, "parent.svg"); + var childPath = Path.Combine(tempDirectory.FullName, "child.svg"); + + File.WriteAllText( + parentPath, + """ + + + + + """); + File.WriteAllText( + childPath, + """ + + + + + """); + + using var svg = new SKSvg(); + using var _ = svg.Load(parentPath); + using var bitmap = RenderBitmap(svg); + + AssertMostlyRed( + bitmap.GetPixel(10, 5), + "Expected non-recursive nested SVG image content to render before the cyclic edge."); + AssertMostlyGreen( + bitmap.GetPixel(30, 10), + "Expected sibling content outside the cyclic image to stay rendered."); + AssertNeutralPlaceholder( + bitmap.GetPixel(10, 15), + "Expected the recursive nested SVG image edge to render the deterministic broken-image placeholder."); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } + + [Fact] + public void Load_W3CBrokenAndCyclicImageFixtureUsesPlaceholdersAndPreservesSurroundingContent() + { + using var svg = new SKSvg(); + svg.Load(GetW3CSvgPath("struct-image-12-b")); + + using var bitmap = RenderBitmap(svg); + + AssertNeutralPlaceholder( + bitmap.GetPixel(120, 120), + "Expected W3C invalid/cyclic image references to use the deterministic placeholder policy."); + AssertMostlyBlue( + bitmap.GetPixel(360, 230), + "Expected content surrounding the invalid/cyclic W3C image references to remain visible."); + } + + private static string GetW3CSvgPath(string name) + { + return Path.Combine( + "..", + "..", + "..", + "..", + "..", + "externals", + "W3C_SVG_11_TestSuite", + "W3C_SVG_11_TestSuite", + "svg", + $"{name}.svg"); + } + + private static byte[] CompressUtf8(string text) + { + using var memoryStream = new MemoryStream(); + using (var gzipStream = new GZipStream(memoryStream, CompressionLevel.Optimal, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(text); + gzipStream.Write(bytes, 0, bytes.Length); + } + + return memoryStream.ToArray(); + } + + private static SkiaBitmap RenderBitmap(SKSvg svg) + { + Assert.NotNull(svg.Picture); + var bitmap = svg.Picture!.ToBitmap( + SkiaColors.Transparent, + 1f, + 1f, + SkiaColorType.Rgba8888, + SkiaAlphaType.Unpremul, + svg.Settings.Srgb); + + return Assert.IsType(bitmap); + } + + private static void AssertMostlyGreen(SkiaColor pixel, string message) + { + Assert.True( + pixel.Green > 200 && pixel.Red < 40 && pixel.Blue < 40 && pixel.Alpha > 200, + $"{message} Pixel was {pixel}."); + } + + private static void AssertMostlyRed(SkiaColor pixel, string message) + { + Assert.True( + pixel.Red > 200 && pixel.Green < 40 && pixel.Blue < 40 && pixel.Alpha > 200, + $"{message} Pixel was {pixel}."); + } + + private static void AssertMostlyBlue(SkiaColor pixel, string message) + { + Assert.True( + pixel.Blue > 180 && pixel.Red < 80 && pixel.Green < 80 && pixel.Alpha > 200, + $"{message} Pixel was {pixel}."); + } + + private static void AssertNeutralPlaceholder(SkiaColor pixel, string message) + { + Assert.True( + pixel.Alpha > 200 && + pixel.Red > 80 && + pixel.Green > 80 && + pixel.Blue > 80 && + Math.Abs(pixel.Red - pixel.Green) <= 24 && + Math.Abs(pixel.Red - pixel.Blue) <= 24, + $"{message} Pixel was {pixel}."); + } +} diff --git a/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs b/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs index 6339cd6377..31b55a4485 100644 --- a/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs +++ b/tests/Svg.Skia.UnitTests/W3CTestSuiteTests.cs @@ -13,6 +13,9 @@ using Svg.Pathing; using Svg.Skia.UnitTests.Common; using Xunit; +using SkiaAlphaType = SkiaSharp.SKAlphaType; +using SkiaBitmap = SkiaSharp.SKBitmap; +using SkiaColorType = SkiaSharp.SKColorType; namespace Svg.Skia.UnitTests; @@ -24,11 +27,35 @@ public class W3CTestSuiteTests : SvgUnitTest "animate-dom-01-f", "animate-dom-02-f", "animate-struct-dom-01-b", + "animate-interact-pevents-01-t", + "animate-interact-pevents-02-t", + "animate-interact-pevents-03-t", + "animate-interact-pevents-04-t", + "conform-viewers-03-f", "coords-dom-01-f", "coords-dom-02-f", "coords-dom-03-f", "coords-dom-04-f", + "extend-namespace-01-f", + "interact-events-202-f", + "interact-events-203-t", "interact-dom-01-b", + "interact-events-01-b", + "interact-events-02-b", + "interact-order-01-b", + "interact-order-02-b", + "interact-order-03-b", + "interact-pevents-01-b", + "interact-pevents-03-b", + "interact-pevents-05-b", + "interact-pevents-07-t", + "interact-pevents-08-f", + "interact-pevents-09-f", + "interact-pevents-10-f", + "interact-pointer-01-t", + "interact-pointer-02-t", + "interact-pointer-03-t", + "interact-pointer-04-f", "masking-path-09-b", "masking-path-12-f", "paths-dom-01-f", @@ -58,6 +85,7 @@ public class W3CTestSuiteTests : SvgUnitTest "struct-dom-19-f", "struct-dom-20-f", "struct-svg-01-f", + "struct-svg-02-f", "struct-use-13-f", "struct-use-14-f", "struct-use-15-f", @@ -685,9 +713,127 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) { switch (name) { + case "animate-elem-20-t": + AssertIndefiniteFillHyperlinkFixture(svg); + return true; + case "animate-elem-21-t": + AssertChainedIndefiniteHyperlinkFixture(svg); + return true; + case "animate-elem-29-b": + AssertIndefiniteOpacityHyperlinkFixture(svg); + return true; + case "animate-elem-60-t": + AssertAccessKeyAndPastWallclockBeginFixture(svg); + return true; + case "animate-elem-61-t": + AssertMultipleBeginUserEventFixture(svg); + return true; + case "animate-elem-62-t": + AssertAccessKeyAndFutureWallclockEndFixture(svg); + return true; + case "animate-elem-63-t": + AssertMultipleEndUserEventFixture(svg); + return true; + case "animate-interact-pevents-01-t": + AssertTextPointerEventsRows(svg, animated: true); + return true; + case "animate-interact-pevents-02-t": + AssertRenderingOrderPointerEvents(svg, animated: true); + return true; + case "animate-interact-pevents-03-t": + AssertVisiblePointerEventsGrid(svg, animated: true); + return true; + case "animate-interact-pevents-04-t": + AssertPaintedPointerEventsGrid(svg, animated: true); + return true; + case "animate-interact-events-01-t": + AssertAnimatedUseInstanceMouseEventsAndBubbling(svg); + return true; + case "conform-viewers-02-f": + AssertGzippedSvgDataImageFixture(svg); + return true; + case "conform-viewers-03-f": + AssertDynamicImageNamespaceFixtureUsesOnlyRealXLinkHref(svg.SourceDocument!); + return true; + case "extend-namespace-01-f": + AssertForeignNamespaceDomFixtureCreatedPieChart(svg.SourceDocument!); + return true; + case "interact-cursor-01-f": + AssertCursorFixtureResolvesExpectedCursorValues(svg); + return true; + case "interact-events-01-b": + AssertOnLoadEventAttributeFixtureReachedExpectedVisibility(svg.SourceDocument!); + return true; + case "interact-events-02-b": + AssertSvgLoadDoesNotBubbleFixtureReachedExpectedFills(svg.SourceDocument!); + return true; + case "interact-events-202-f": + AssertUseMouseOverFixtureTogglesReferencingGroups(svg); + return true; + case "interact-events-203-t": + AssertUseInstanceMouseEventsAndBubbling(svg); + return true; + case "interact-order-01-b": + AssertMouseEventBubblingAndStopPropagation(svg); + return true; + case "interact-order-02-b": + AssertEventOrderCircleClickSemantics(svg); + return true; + case "interact-order-03-b": + AssertEventOrderTextClickSemantics(svg); + return true; + case "interact-pevents-01-b": + AssertTextPointerEventsRows(svg, animated: false); + return true; + case "interact-pevents-03-b": + AssertTextCharacterCellPointerEvents(svg, name, animated: false); + return true; + case "interact-pevents-04-t": + AssertTextCharacterCellPointerEvents(svg, name, animated: true); + return true; + case "interact-pevents-05-b": + AssertTextCharacterCellPointerEvents(svg, name, animated: false); + return true; + case "interact-pevents-07-t": + AssertRenderingOrderPointerEvents(svg, animated: false); + return true; + case "interact-pevents-08-f": + AssertVisiblePointerEventsGrid(svg, animated: false); + return true; + case "interact-pevents-09-f": + AssertPaintedPointerEventsGrid(svg, animated: false); + return true; + case "interact-zoom-01-t": + case "interact-zoom-02-t": + AssertZoomAndPanMagnifyFixture(svg); + return true; + case "interact-zoom-03-t": + AssertZoomAndPanDisableFixture(svg); + return true; + case "interact-pevents-10-f": + AssertDisplayNonePointerEventsDoNotFire(svg); + return true; + case "interact-pointer-01-t": + AssertPointerResultRowReachesPassedState(svg, "r"); + return true; + case "interact-pointer-02-t": + AssertPointerResultRowReachesPassedState(svg, "r"); + return true; + case "interact-pointer-03-t": + AssertPointerResultRowReachesPassedState(svg, "r1"); + return true; + case "interact-pointer-04-f": + AssertMaskedPointerRowReachesPassedState(svg); + return true; case "paths-dom-02-f": AssertPathsDom02FixtureCreatesFlowerPathSegments(svg.SourceDocument!); return true; + case "script-specify-01-f": + AssertUnknownContentScriptTypeSuppressesEventHandler(svg.SourceDocument!); + return true; + case "struct-defs-01-t": + AssertDefsFixtureKeepsDefinitionContentNonRenderable(svg.SourceDocument!); + return true; case "struct-dom-07-f": AssertUseInstanceChildNodesCanMutateCorrespondingElements(svg.SourceDocument!); return true; @@ -697,17 +843,959 @@ private static bool TryRunSemanticAssertion(string name, SKSvg svg) case "struct-dom-18-f": AssertIntersectionAndEnclosureListsHideFixtureFailText(svg); return true; + case "struct-svg-02-f": + AssertNestedSvgLengthDomMetricsResolveViewportChanges(); + return true; + case "struct-image-07-t": + AssertXmlBaseImageFixtureCompilesAllImages(svg); + return true; + case "struct-image-12-b": + AssertBrokenImageAndCycleFixtureUsesPlaceholders(svg); + return true; + case "struct-image-17-b": + AssertEmbeddedSvgImageRemainsStatic(svg); + return true; + case "text-tselect-01-b": + case "text-tselect-02-f": + case "text-tselect-03-f": + AssertTextSelectionFixtureSupportsHostSelection(svg, name); + return true; case "types-dom-06-f": AssertStringListsDuplicateInsertedValues(svg.SourceDocument!); return true; case "types-dom-08-f": AssertGetBBoxFixtureReachesPassedState(svg); return true; + case "types-basic-01-f": + AssertBasicNumberFixtureParsesScientificStrokeWidths(svg.SourceDocument!); + return true; + case "types-basic-02-f": + AssertBasicLengthFixtureHonorsPresentationAndCssUnitCase(svg.SourceDocument!); + return true; default: return false; } } + private static void AssertDynamicImageNamespaceFixtureUsesOnlyRealXLinkHref(SvgDocument document) + { + var images = document.Descendants().OfType().ToArray(); + Assert.Contains(images, image => string.Equals(image.Href, "../images/pinksquidj.png", StringComparison.Ordinal)); + var invalidNamespaceImage = Assert.IsType(document.GetElementById("image2")!); + Assert.True(string.IsNullOrEmpty(invalidNamespaceImage.Href)); + + var prefix = Assert.IsType(document.GetElementById("prefix")!); + Assert.NotEqual("...", prefix.Content); + var status = Assert.IsType(document.GetElementById("status")!); + Assert.Equal("No exceptions.", status.Content); + } + + private static void AssertIndefiniteFillHyperlinkFixture(SKSvg svg) + { + DispatchPointerClick(svg, new SKPoint(350f, 80f)); + var fadeIn = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(3.1)); + AssertColorFill(fadeIn.GetElementById("pink"), System.Drawing.Color.Blue); + + svg.SetAnimationTime(TimeSpan.FromSeconds(3.1)); + DispatchPointerClick(svg, new SKPoint(350f, 260f)); + var fadeOut = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(6.2)); + AssertColorFill(fadeOut.GetElementById("pink"), System.Drawing.Color.White); + } + + private static void AssertChainedIndefiniteHyperlinkFixture(SKSvg svg) + { + DispatchPointerClick(svg, new SKPoint(350f, 80f)); + var fadeIn = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(3.1)); + AssertColorFill(fadeIn.GetElementById("pink"), System.Drawing.Color.Blue); + Assert.All( + fadeIn.Descendants().OfType(), + circle => AssertColorStroke(circle, System.Drawing.Color.FromArgb(0x66, 0x66, 0x66))); + + svg.SetAnimationTime(TimeSpan.FromSeconds(3.1)); + DispatchPointerClick(svg, new SKPoint(350f, 260f)); + var fadeOut = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(6.2)); + AssertColorFill(fadeOut.GetElementById("pink"), System.Drawing.Color.White); + Assert.All( + fadeOut.Descendants().OfType(), + circle => AssertColorStroke(circle, System.Drawing.Color.White)); + } + + private static void AssertIndefiniteOpacityHyperlinkFixture(SKSvg svg) + { + DispatchPointerClick(svg, new SKPoint(350f, 80f)); + var fadeIn = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(3.1)); + var visibleRect = Assert.IsType(fadeIn.GetElementById("pink")); + Assert.Equal(1f, visibleRect.FillOpacity, 3); + + svg.SetAnimationTime(TimeSpan.FromSeconds(3.1)); + DispatchPointerClick(svg, new SKPoint(350f, 260f)); + var fadeOut = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(6.2)); + var hiddenRect = Assert.IsType(fadeOut.GetElementById("pink")); + Assert.Equal(0f, hiddenRect.FillOpacity, 3); + } + + private static void AssertAccessKeyAndPastWallclockBeginFixture(SKSvg svg) + { + var eventTarget = svg.SourceDocument!.GetElementById("setThreeTarget"); + Assert.NotNull(eventTarget); + Assert.True(svg.NotifyPointerEvent(eventTarget, SvgPointerEventType.Click, TimeSpan.Zero)); + Assert.True(svg.NotifyAccessKey("a", TimeSpan.Zero)); + + var afterAccess = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(2.5)); + var setThree = GetRowRectangles(afterAccess, "setThree"); + AssertColorFill(setThree[0], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + AssertColorFill(setThree[1], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + + var setSeven = GetRowRectangles(afterAccess, "setSeven"); + AssertColorFill(setSeven[0], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + AssertColorFill(setSeven[1], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + + var setEight = Assert.Single(GetRowRectangles(afterAccess, "setEight")); + AssertColorFill(setEight, System.Drawing.Color.FromArgb(0xFF, 0x33, 0x33)); + } + + private static void AssertMultipleBeginUserEventFixture(SKSvg svg) + { + Assert.True(svg.NotifyAccessKey("a", TimeSpan.FromSeconds(2))); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "setFiveTarget", TimeSpan.FromSeconds(2.5)), 3); + Assert.Equal(-6f, GetAnimatedRectangleX(svg, "setFiveTarget", TimeSpan.FromSeconds(3.5)), 3); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "setFiveTarget", TimeSpan.FromSeconds(6.5)), 3); + + var target = svg.SourceDocument!.GetElementById("setSixTarget"); + Assert.NotNull(target); + Assert.True(svg.NotifyPointerEvent(target, SvgPointerEventType.Click, TimeSpan.FromSeconds(5))); + Assert.Equal(34f, GetAnimatedRectangleX(svg, "setSixTarget", TimeSpan.FromSeconds(5.5)), 3); + } + + private static void AssertAccessKeyAndFutureWallclockEndFixture(SKSvg svg) + { + Assert.True(svg.NotifyAccessKey("a", TimeSpan.FromSeconds(2))); + + var afterFirstEnd = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(3)); + var firstSetSeven = GetRowRectangles(afterFirstEnd, "setSeven"); + AssertColorFill(firstSetSeven[0], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + AssertColorFill(firstSetSeven[1], System.Drawing.Color.FromArgb(0xFF, 0x33, 0x33)); + + var afterSecondEnd = CreateAnimatedDocument(svg, TimeSpan.FromSeconds(4.5)); + var secondSetSeven = GetRowRectangles(afterSecondEnd, "setSeven"); + AssertColorFill(secondSetSeven[0], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + AssertColorFill(secondSetSeven[1], System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + + var setEight = Assert.Single(GetRowRectangles(afterSecondEnd, "setEight")); + AssertColorFill(setEight, System.Drawing.Color.FromArgb(0x33, 0xFF, 0x33)); + } + + private static void AssertMultipleEndUserEventFixture(SKSvg svg) + { + Assert.Equal(34f, GetAnimatedRectangleX(svg, "setFiveTarget", TimeSpan.FromSeconds(0.5)), 3); + Assert.True(svg.NotifyAccessKey("a", TimeSpan.FromSeconds(6))); + Assert.Equal(-6f, GetAnimatedRectangleX(svg, "setFiveTarget", TimeSpan.FromSeconds(1.5)), 3); + + var target = svg.SourceDocument!.GetElementById("setSixTarget"); + Assert.NotNull(target); + Assert.True(svg.NotifyPointerEvent(target, SvgPointerEventType.Click, TimeSpan.FromSeconds(1.5))); + Assert.Equal(-6f, GetAnimatedRectangleX(svg, "setSixTarget", TimeSpan.FromSeconds(1.75)), 3); + } + + private static void AssertZoomAndPanMagnifyFixture(SKSvg svg) + { + Assert.True(svg.IsZoomAndPanEnabled); + Assert.True(svg.ZoomTo(2d)); + Assert.True(svg.PanBy(new SKPoint(8f, 12f))); + Assert.Equal(2d, svg.CurrentScale); + Assert.Equal(new SKPoint(8f, 12f), svg.CurrentTranslate); + Assert.Equal(new SKPoint(28f, 32f), svg.PictureToViewerPoint(new SKPoint(10f, 10f))); + Assert.True(svg.TryGetViewerPicturePoint(new SKPoint(28f, 32f), out var picturePoint)); + Assert.Equal(10f, picturePoint.X, 3); + Assert.Equal(10f, picturePoint.Y, 3); + } + + private static void AssertZoomAndPanDisableFixture(SKSvg svg) + { + Assert.False(svg.IsZoomAndPanEnabled); + Assert.False(svg.ZoomTo(2d)); + Assert.False(svg.PanBy(new SKPoint(8f, 12f))); + Assert.True(svg.ViewerTransform.IsIdentity); + Assert.Equal(1d, svg.CurrentScale); + Assert.Equal(default, svg.CurrentTranslate); + } + + private static void AssertGzippedSvgDataImageFixture(SKSvg svg) + { + using var bitmap = RenderBitmap(svg); + var starCenter = bitmap.GetPixel(240, 170); + Assert.True( + starCenter.Alpha > 0 && starCenter.Red > 100 && starCenter.Green > 100, + $"Expected gzipped data SVG image content near the center, but found {starCenter}."); + } + + private static void AssertForeignNamespaceDomFixtureCreatedPieChart(SvgDocument document) + { + var pieParent = Assert.IsType(document.GetElementById("PieParent")!); + var paths = pieParent.Children.OfType().ToArray(); + var labels = pieParent.Children.OfType().ToArray(); + + Assert.Equal(5, paths.Length); + Assert.Equal(5, labels.Length); + Assert.Equal(new[] { "East", "North", "West", "Central", "South" }, labels.Select(label => label.Content).ToArray()); + + var firstFill = Assert.IsType(paths[0].Fill); + var firstStroke = Assert.IsType(paths[0].Stroke); + Assert.Equal(System.Drawing.Color.FromArgb(0xFF, 0x88, 0x88).ToArgb(), firstFill.Colour.ToArgb()); + Assert.Equal(System.Drawing.Color.Blue.ToArgb(), firstStroke.Colour.ToArgb()); + } + + private static void AssertCursorFixtureResolvesExpectedCursorValues(SKSvg svg) + { + var dispatcher = new SvgInteractionDispatcher(); + + Assert.Equal("crosshair", DispatchPointerMoved(dispatcher, svg, new SKPoint(160f, 80f)).Cursor); + Assert.Equal("pointer", DispatchPointerMoved(dispatcher, svg, new SKPoint(160f, 176f)).Cursor); + Assert.Equal("text", DispatchPointerMoved(dispatcher, svg, new SKPoint(260f, 80f)).Cursor); + Assert.Equal("wait", DispatchPointerMoved(dispatcher, svg, new SKPoint(260f, 128f)).Cursor); + Assert.Equal("help", DispatchPointerMoved(dispatcher, svg, new SKPoint(260f, 176f)).Cursor); + Assert.Equal("url(#magglass),crosshair", DispatchPointerMoved(dispatcher, svg, new SKPoint(260f, 224f)).Cursor); + Assert.Equal("url(#magglass),crosshair", DispatchPointerMoved(dispatcher, svg, new SKPoint(390f, 315f)).Cursor); + } + + private static void AssertOnLoadEventAttributeFixtureReachedExpectedVisibility(SvgDocument document) + { + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + Assert.Equal("hidden", runtime.GetElement(document.GetElementById("Rect1")!).getAttribute("visibility")); + Assert.Equal("visible", runtime.GetElement(document.GetElementById("Rect2")!).getAttribute("visibility")); + Assert.Equal("visible", runtime.GetElement(document.GetElementById("Rect3")!).getAttribute("visibility")); + Assert.Equal("visible", runtime.GetElement(document.GetElementById("Rect4")!).getAttribute("visibility")); + Assert.Equal("hidden", runtime.GetElement(document.GetElementById("Rect5")!).getAttribute("visibility")); + Assert.Equal("visible", runtime.GetElement(document.GetElementById("Rect6")!).getAttribute("visibility")); + } + + private static void AssertSvgLoadDoesNotBubbleFixtureReachedExpectedFills(SvgDocument document) + { + AssertColorFill(document.GetElementById("r1"), System.Drawing.Color.Green); + AssertColorFill(document.GetElementById("r2"), System.Drawing.Color.Green); + } + + private static void AssertUseMouseOverFixtureTogglesReferencingGroups(SKSvg svg) + { + var document = svg.SourceDocument!; + var dispatcher = new SvgInteractionDispatcher(); + + AssertRuntimeAttribute(document, "g3", "visibility", "hidden"); + AssertRuntimeAttribute(document, "g4", "visibility", "hidden"); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(50f, 50f)); + AssertRuntimeAttribute(document, "g3", "visibility", "visible"); + AssertRuntimeAttribute(document, "g4", "visibility", "hidden"); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(140f, 50f)); + AssertRuntimeAttribute(document, "g3", "visibility", "hidden"); + AssertRuntimeAttribute(document, "g4", "visibility", "visible"); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(350f, 300f)); + AssertRuntimeAttribute(document, "g3", "visibility", "hidden"); + AssertRuntimeAttribute(document, "g4", "visibility", "hidden"); + } + + private static void AssertUseInstanceMouseEventsAndBubbling(SKSvg svg) + { + var document = svg.SourceDocument!; + var dispatcher = new SvgInteractionDispatcher(); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 55f)); + AssertColorFill(document.GetElementById("rect"), System.Drawing.Color.Blue); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 125f)); + AssertColorFill(document.GetElementById("rect"), System.Drawing.Color.Blue); + AssertColorStroke(document.GetElementById("rect1"), System.Drawing.Color.Black); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 195f)); + AssertColorFill(document.GetElementById("rect"), System.Drawing.Color.Blue); + AssertColorStroke(document.GetElementById("rect2"), System.Drawing.Color.Black); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 265f)); + AssertColorFill(document.GetElementById("rect"), System.Drawing.Color.Blue); + AssertColorStroke(document.GetElementById("rect3"), System.Drawing.Color.Empty); + + _ = DispatchPointerPressed(dispatcher, svg, new SKPoint(55f, 265f)); + AssertColorStroke(document.GetElementById("rect3"), System.Drawing.Color.Black); + } + + private static void AssertAnimatedUseInstanceMouseEventsAndBubbling(SKSvg svg) + { + var dispatcher = new SvgInteractionDispatcher(); + var eventIndex = 0; + + var firstTime = AdvanceAnimationEventTime(svg, animated: true, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 55f)); + AssertColorFill(GetInteractionDocument(svg, animated: true, firstTime).GetElementById("rect"), System.Drawing.Color.Blue); + + var secondTime = AdvanceAnimationEventTime(svg, animated: true, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 125f)); + var secondDocument = GetInteractionDocument(svg, animated: true, secondTime); + AssertColorFill(secondDocument.GetElementById("rect"), System.Drawing.Color.Blue); + AssertColorStroke(GetUseInstanceStrokeIndicator(secondDocument, 0), System.Drawing.Color.Black); + + var thirdTime = AdvanceAnimationEventTime(svg, animated: true, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 195f)); + var thirdDocument = GetInteractionDocument(svg, animated: true, thirdTime); + AssertColorFill(thirdDocument.GetElementById("rect"), System.Drawing.Color.Blue); + AssertColorStroke(GetUseInstanceStrokeIndicator(thirdDocument, 1), System.Drawing.Color.Black); + + var fourthTime = AdvanceAnimationEventTime(svg, animated: true, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(55f, 265f)); + var fourthDocument = GetInteractionDocument(svg, animated: true, fourthTime); + AssertColorFill(fourthDocument.GetElementById("rect"), System.Drawing.Color.Blue); + AssertColorStroke(GetUseInstanceStrokeIndicator(fourthDocument, 2), System.Drawing.Color.Empty); + + var pressTime = AdvanceAnimationEventTime(svg, animated: true, ref eventIndex); + _ = DispatchPointerPressed(dispatcher, svg, new SKPoint(55f, 265f)); + AssertColorStroke(GetUseInstanceStrokeIndicator(GetInteractionDocument(svg, animated: true, pressTime), 2), System.Drawing.Color.Black); + } + + private static SvgRectangle GetUseInstanceStrokeIndicator(SvgDocument document, int index) + { + var indicators = document.Descendants() + .OfType() + .Where(static rect => + string.IsNullOrWhiteSpace(rect.ID) && + rect.Width.Value == 50f && + rect.Height.Value == 50f && + rect.StrokeWidth.Value == 5f && + rect.PointerEvents == SvgPointerEvents.None) + .OrderBy(static rect => rect.Y.Value) + .ToArray(); + + Assert.Equal(3, indicators.Length); + return indicators[index]; + } + + private static void AssertMouseEventBubblingAndStopPropagation(SKSvg svg) + { + var circles = svg.SourceDocument!.Descendants().OfType() + .OrderBy(circle => circle.CenterY.Value) + .ToArray(); + Assert.Equal(2, circles.Length); + + var dispatcher = new SvgInteractionDispatcher(); + dispatcher.DispatchPointerMoved(svg, new SvgPointerInput(new SKPoint(70f, 120f), SvgPointerDeviceType.Mouse, SvgMouseButton.Left, 0, 0, false, false, false, "w3c")); + AssertColorFill(circles[0], System.Drawing.Color.FromArgb(0xFF, 0x00, 0x88)); + + dispatcher.DispatchPointerMoved(svg, new SvgPointerInput(new SKPoint(70f, 240f), SvgPointerDeviceType.Mouse, SvgMouseButton.Left, 0, 0, false, false, false, "w3c")); + AssertColorFill(circles[1], System.Drawing.Color.Blue); + } + + private static void AssertEventOrderCircleClickSemantics(SKSvg svg) + { + var circles = svg.SourceDocument!.Descendants().OfType() + .OrderBy(circle => circle.CenterY.Value) + .ToArray(); + Assert.Equal(2, circles.Length); + + var dispatcher = new SvgInteractionDispatcher(); + + var firstClick = DispatchPointerClick(dispatcher, svg, new SKPoint(70f, 120f)); + Assert.True(firstClick.Handled); + AssertColorFill(circles[0], System.Drawing.Color.Red); + + var secondClick = DispatchPointerClick(dispatcher, svg, new SKPoint(70f, 240f)); + Assert.False(secondClick.Handled); + AssertColorFill(circles[1], System.Drawing.Color.Blue); + } + + private static void AssertEventOrderTextClickSemantics(SKSvg svg) + { + var document = svg.SourceDocument!; + var firstText = Assert.Single( + document.Descendants().OfType(), + static text => text.Text.Contains("String turns red", StringComparison.Ordinal)); + var hyperlinkText = Assert.Single( + document.Descendants().OfType(), + static text => text.Text.Contains("String hyperlinks to", StringComparison.Ordinal)); + + var dispatcher = new SvgInteractionDispatcher(); + + var firstClick = DispatchPointerClick(dispatcher, svg, new SKPoint(130f, 80f)); + Assert.True(firstClick.Handled); + AssertColorFill(firstText, System.Drawing.Color.Red); + + var secondClick = DispatchPointerClick(dispatcher, svg, new SKPoint(160f, 150f)); + Assert.False(secondClick.Handled); + AssertColorFill(hyperlinkText, System.Drawing.Color.Blue); + } + + private static void AssertDisplayNonePointerEventsDoNotFire(SKSvg svg) + { + var document = svg.SourceDocument!; + var dispatcher = new SvgInteractionDispatcher(); + + _ = DispatchPointerClick(dispatcher, svg, new SKPoint(100f, 200f)); + AssertRuntimeAttribute(document, "failText", "visibility", "hidden"); + + _ = DispatchPointerClick(dispatcher, svg, new SKPoint(250f, 200f)); + AssertRuntimeAttribute(document, "failText", "visibility", "hidden"); + } + + private static void AssertTextPointerEventsRows(SKSvg svg, bool animated) + { + var dispatcher = new SvgInteractionDispatcher(); + foreach (var y in new[] { 78f, 138f, 198f, 258f }) + { + foreach (var x in new[] { 102f, 132f, 162f, 192f, 222f, 252f, 282f, 312f, 342f, 372f }) + { + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(x, y)); + } + } + + var document = animated ? CreateAnimatedDocument(svg, TimeSpan.Zero) : svg.SourceDocument!; + AssertTextPointerEventsRow(document, "first-line", new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }); + AssertTextPointerEventsRow(document, "second-line", new[] { 0, 1, 2, 3, 4, 5, 6, 7, 8 }); + AssertTextPointerEventsRow(document, "third-line", new[] { 5, 6, 7, 8 }); + AssertTextPointerEventsRow(document, "fourth-line", new[] { 2, 3, 4, 6, 7, 8 }); + } + + private static void AssertTextPointerEventsRow(SvgDocument document, string rowId, int[] expectedGreenIndexes) + { + var expected = new HashSet(expectedGreenIndexes); + var row = Assert.IsType(document.GetElementById(rowId)!); + var glyphs = row.Children.OfType().ToArray(); + Assert.Equal(10, glyphs.Length); + + for (var i = 0; i < glyphs.Length; i++) + { + if (expected.Contains(i)) + { + AssertColorFill(glyphs[i], System.Drawing.Color.Green); + } + else + { + AssertNotColorFill(glyphs[i], System.Drawing.Color.Red); + } + } + } + + private static void AssertTextCharacterCellPointerEvents(SKSvg svg, string name, bool animated) + { + var document = svg.SourceDocument!; + var dispatcher = new SvgInteractionDispatcher(); + var eventIndex = 0; + var texts = GetTextCharacterCellFixtureRows(document, name); + Assert.NotEmpty(texts); + + foreach (var text in texts) + { + var requireMissPoint = name == "interact-pevents-05-b" || + !text.Text.Contains(' '); + Assert.True( + TryFindTextHitAndMissPoints(svg, text, requireMissPoint, out var hitPoint, out var missPoint, out var hasMissPoint), + $"Could not find a glyph hit point{(requireMissPoint ? " and an in-bounds whitespace miss point" : string.Empty)} for '{text.ID ?? text.Text}'."); + + ResetAnimatedInteraction(svg, animated); + dispatcher.Reset(); + eventIndex = 0; + var hitTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + var hitResult = DispatchPointerMoved(dispatcher, svg, hitPoint); + Assert.True( + IsSameTextTarget(hitResult.TargetElement, text), + $"Expected hit target '{text.ID ?? text.Text}', but found '{hitResult.TargetElement?.ID ?? hitResult.TargetElement?.GetType().Name}'."); + AssertCharacterCellTextFill(svg, text, animated, hitTime, System.Drawing.Color.Green); + + var offTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(5f, 5f)); + AssertCharacterCellTextNotFill(svg, text, animated, offTime, System.Drawing.Color.Green); + + if (hasMissPoint) + { + var missTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + var missResult = DispatchPointerMoved(dispatcher, svg, missPoint); + Assert.False(IsSameTextTarget(missResult.TargetElement, text)); + AssertCharacterCellTextNotFill(svg, text, animated, missTime, System.Drawing.Color.Green); + } + } + } + + private static SvgText[] GetTextCharacterCellFixtureRows(SvgDocument document, string name) + { + if (name == "interact-pevents-03-b") + { + return new[] { "first-line", "second-line", "third-line", "fourth-line", "fifth-line" } + .Select(rowId => Assert.IsType(document.GetElementById(rowId)!).Descendants().OfType().First()) + .ToArray(); + } + + return new[] { "line1", "line2", "line3", "line4", "line5" } + .Select(id => document.GetElementById(id)) + .OfType() + .ToArray(); + } + + private static bool TryFindTextHitAndMissPoints( + SKSvg svg, + SvgText text, + bool requireMissPoint, + out SKPoint hitPoint, + out SKPoint missPoint, + out bool hasMissPoint) + { + hitPoint = default; + missPoint = default; + hasMissPoint = false; + if (svg.RetainedSceneGraph?.TryGetNode(text, out var node) != true || + node is null || + node.TransformedBounds.IsEmpty) + { + return false; + } + + var bounds = node.TransformedBounds; + var xStep = Math.Max(1f, bounds.Width / 120f); + var yStep = Math.Max(1f, bounds.Height / 24f); + var hasHit = false; + var hasMiss = false; + + for (var y = bounds.Top + 0.5f; y < bounds.Bottom; y += yStep) + { + for (var x = bounds.Left + 0.5f; x < bounds.Right; x += xStep) + { + var point = new SKPoint(x, y); + var target = svg.HitTestTopmostElement(point); + if (!hasHit && IsSameTextTarget(target, text)) + { + hitPoint = point; + hasHit = true; + } + else if (!hasMiss && !IsSameTextTarget(target, text)) + { + missPoint = point; + hasMiss = true; + hasMissPoint = true; + } + + if (hasHit && (!requireMissPoint || hasMiss)) + { + return true; + } + } + } + + return hasHit && (!requireMissPoint || hasMiss); + } + + private static bool IsSameTextTarget(SvgElement? target, SvgText text) + { + if (ReferenceEquals(target, text)) + { + return true; + } + + return target is SvgText targetText && + !string.IsNullOrWhiteSpace(text.ID) && + string.Equals(targetText.ID, text.ID, StringComparison.Ordinal); + } + + private static void AssertCharacterCellTextFill(SKSvg svg, SvgText text, bool animated, TimeSpan time, System.Drawing.Color expected) + { + AssertColorFill(GetCharacterCellTextElement(svg, text, animated, time), expected); + } + + private static void AssertCharacterCellTextNotFill(SKSvg svg, SvgText text, bool animated, TimeSpan time, System.Drawing.Color expected) + { + AssertNotColorFill(GetCharacterCellTextElement(svg, text, animated, time), expected); + } + + private static SvgElement GetCharacterCellTextElement(SKSvg svg, SvgText text, bool animated, TimeSpan time) + { + if (!animated) + { + return text; + } + + Assert.False(string.IsNullOrWhiteSpace(text.ID)); + var element = GetInteractionDocument(svg, animated: true, time).GetElementById(text.ID); + return Assert.IsAssignableFrom(element); + } + + private static void AssertRenderingOrderPointerEvents(SKSvg svg, bool animated) + { + var dispatcher = new SvgInteractionDispatcher(); + var eventIndex = 0; + AssertHoverFill(dispatcher, svg, "r10", new SKPoint(90f, 80f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + AssertHoverFill(dispatcher, svg, "r11", new SKPoint(130f, 110f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + AssertHoverFill(dispatcher, svg, "r12", new SKPoint(180f, 140f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + AssertHoverFill(dispatcher, svg, "c10", new SKPoint(195f, 245f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + AssertHoverFill(dispatcher, svg, "c11", new SKPoint(307f, 245f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + AssertHoverFill(dispatcher, svg, "c12", new SKPoint(335f, 142f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + + ResetAnimatedInteraction(svg, animated); + eventIndex = 0; + var offTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerClick(dispatcher, svg, new SKPoint(415f, 75f)); + var offDocument = GetInteractionDocument(svg, animated, offTime); + AssertPointerEvents(offDocument, "c10", SvgPointerEvents.None); + AssertPointerEvents(offDocument, "c11", SvgPointerEvents.None); + AssertPointerEvents(offDocument, "c12", SvgPointerEvents.None); + + var suppressedTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(195f, 245f)); + var suppressedDocument = GetInteractionDocument(svg, animated, suppressedTime); + AssertNotColorFill(suppressedDocument.GetElementById("c10"), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55)); + + ResetAnimatedInteraction(svg, animated); + eventIndex = 0; + _ = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerClick(dispatcher, svg, new SKPoint(385f, 75f)); + AssertHoverFill(dispatcher, svg, "c10", new SKPoint(195f, 245f), System.Drawing.Color.FromArgb(0xFF, 0x55, 0x55), animated, ref eventIndex); + } + + private static void AssertVisiblePointerEventsGrid(SKSvg svg, bool animated) + { + var rows = new[] + { + new PointerEventsGridRow("m1", 70f, new[] { 1, 3 }, new[] { 1, 3 }), + new PointerEventsGridRow("m2", 120f, new[] { 1, 3 }, new[] { 1, 3 }), + new PointerEventsGridRow("m3", 170f, new[] { 1, 2, 3 }, Array.Empty()), + new PointerEventsGridRow("m4", 220f, Array.Empty(), new[] { 1, 2, 3 }), + new PointerEventsGridRow("m5", 270f, new[] { 1, 2, 3 }, new[] { 1, 2, 3 }) + }; + AssertPointerEventsGrid(svg, animated, rows); + } + + private static void AssertPaintedPointerEventsGrid(SKSvg svg, bool animated) + { + var rows = new[] + { + new PointerEventsGridRow("m1", 70f, new[] { 1, 3, 4 }, new[] { 1, 3, 4 }), + new PointerEventsGridRow("m2", 120f, new[] { 1, 2, 3, 4 }, Array.Empty()), + new PointerEventsGridRow("m3", 170f, Array.Empty(), new[] { 1, 2, 3, 4 }), + new PointerEventsGridRow("m4", 220f, new[] { 1, 2, 3, 4 }, new[] { 1, 2, 3, 4 }), + new PointerEventsGridRow("m5", 270f, Array.Empty(), Array.Empty()) + }; + AssertPointerEventsGrid(svg, animated, rows); + } + + private static void AssertPointerEventsGrid(SKSvg svg, bool animated, IReadOnlyList rows) + { + var dispatcher = new SvgInteractionDispatcher(); + var fillX = new[] { 40f, 90f, 140f, 190f }; + var strokeX = new[] { 22f, 72f, 122f, 172f }; + var eventIndex = 0; + + foreach (var row in rows) + { + AssertPointerEventsGridPoints(dispatcher, svg, animated, row, fillX, row.FillColumns, ref eventIndex); + AssertPointerEventsGridPoints(dispatcher, svg, animated, row, strokeX, row.StrokeColumns, ref eventIndex); + } + } + + private static void AssertPointerEventsGridPoints( + SvgInteractionDispatcher dispatcher, + SKSvg svg, + bool animated, + PointerEventsGridRow row, + IReadOnlyList xCoordinates, + IReadOnlyCollection expectedColumns, + ref int eventIndex) + { + var expected = new HashSet(expectedColumns); + for (var i = 0; i < xCoordinates.Count; i++) + { + ResetAnimatedInteraction(svg, animated); + eventIndex = 0; + var enterTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(xCoordinates[i], row.Y)); + AssertMarkerOpacity(GetInteractionDocument(svg, animated, enterTime), row.MarkerId, expected.Contains(i + 1) ? 0.4f : 0f); + + var leaveTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(5f, 5f)); + AssertMarkerOpacity(GetInteractionDocument(svg, animated, leaveTime), row.MarkerId, 0f); + } + } + + private static void AssertPointerResultRowReachesPassedState(SKSvg svg, string statusRectangleId) + { + var dispatcher = new SvgInteractionDispatcher(); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(35f, 105f)); + AssertColorFill(svg.SourceDocument!.GetElementById(statusRectangleId), System.Drawing.Color.Green); + } + + private static void AssertMaskedPointerRowReachesPassedState(SKSvg svg) + { + var document = svg.SourceDocument!; + var dispatcher = new SvgInteractionDispatcher(); + var firstMaskedRect = Assert.Single(document.Descendants().OfType(), static rect => + { + return rect.Width.Value == 100f && + rect.Height.Value == 100f && + rect.TryGetAttribute("mask", out var mask) && + string.Equals(mask?.ToString(), "url(#normalMask)", StringComparison.Ordinal); + }); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(50f, 50f)); + AssertColorFill(firstMaskedRect, System.Drawing.Color.Orange); + + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(250f, 50f)); + AssertColorFill(document.GetElementById("passRect"), System.Drawing.Color.Orange); + } + + private static void AssertUnknownContentScriptTypeSuppressesEventHandler(SvgDocument document) + { + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + Assert.NotEqual("hidden", runtime.GetElement(document.GetElementById("testPassed")!).getAttribute("visibility")); + Assert.Equal("hidden", runtime.GetElement(document.GetElementById("testFailed")!).getAttribute("visibility")); + } + + private static void AssertDefsFixtureKeepsDefinitionContentNonRenderable(SvgDocument document) + { + var body = Assert.IsType(document.GetElementById("test-body-content")!); + var directVisualRect = Assert.Single(body.Children.OfType()); + AssertColorFill(directVisualRect, System.Drawing.Color.Lime); + + var definitions = body.Children.OfType().ToArray(); + Assert.Equal(2, definitions.Length); + Assert.All(definitions, definition => Assert.NotEmpty(definition.Children)); + Assert.All(definitions.SelectMany(static definition => definition.Children), child => Assert.IsType(child)); + } + + private static void AssertBasicNumberFixtureParsesScientificStrokeWidths(SvgDocument document) + { + var widePolylines = document.Descendants() + .OfType() + .Where(static polyline => polyline.StrokeWidth.Value > 1f) + .Select(static polyline => polyline.StrokeWidth.Value) + .ToArray(); + + Assert.Equal(new[] { 50f, 50f, 50f }, widePolylines); + } + + private static void AssertBasicLengthFixtureHonorsPresentationAndCssUnitCase(SvgDocument document) + { + foreach (var id in new[] + { + "swNoUnit", + "swUnit", + "swPresAttr", + "swUpperCaseUnitPresAttr", + "swUpperCaseUnit", + "swUpperCaseUnitInline" + }) + { + var circle = Assert.IsType(document.GetElementById(id)!); + Assert.Equal(20f, circle.StrokeWidth.Value); + } + } + + private static SvgDocument CreateAnimatedDocument(SKSvg svg, TimeSpan time) + { + Assert.NotNull(svg.AnimationController); + return svg.AnimationController!.CreateAnimatedDocument(time); + } + + private static float GetAnimatedRectangleX(SKSvg svg, string elementId, TimeSpan time) + { + var rectangle = Assert.IsType(CreateAnimatedDocument(svg, time).GetElementById(elementId)); + return rectangle.X.Value; + } + + private static SvgRectangle[] GetRowRectangles(SvgDocument document, string groupId) + { + var group = Assert.IsType(document.GetElementById(groupId)!); + return group.Descendants().OfType().ToArray(); + } + + private static void AssertHoverFill( + SvgInteractionDispatcher dispatcher, + SKSvg svg, + string elementId, + SKPoint point, + System.Drawing.Color expected, + bool animated, + ref int eventIndex) + { + var enterTime = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, point); + var document = GetInteractionDocument(svg, animated, enterTime); + AssertColorFill(document.GetElementById(elementId), expected); + + _ = AdvanceAnimationEventTime(svg, animated, ref eventIndex); + _ = DispatchPointerMoved(dispatcher, svg, new SKPoint(5f, 5f)); + } + + private static TimeSpan AdvanceAnimationEventTime(SKSvg svg, bool animated, ref int eventIndex) + { + if (!animated) + { + return TimeSpan.Zero; + } + + var time = TimeSpan.FromMilliseconds(++eventIndex); + svg.SetAnimationTime(time); + return time; + } + + private static void ResetAnimatedInteraction(SKSvg svg, bool animated) + { + if (!animated) + { + return; + } + + svg.AnimationController!.Reset(); + svg.SetAnimationTime(TimeSpan.Zero); + } + + private static SvgDocument GetInteractionDocument(SKSvg svg, bool animated, TimeSpan time) + { + return animated ? CreateAnimatedDocument(svg, time) : svg.SourceDocument!; + } + + private static void AssertMarkerOpacity(SvgDocument document, string markerId, float expected) + { + var marker = document.GetElementById(markerId) as SvgRectangle; + if (marker is null && + markerId.Length == 2 && + markerId[0] == 'm' && + int.TryParse(markerId.AsSpan(1), NumberStyles.Integer, CultureInfo.InvariantCulture, out var markerIndex)) + { + marker = document.Descendants() + .OfType() + .Where(static rect => + { + return string.IsNullOrWhiteSpace(rect.ID) && + rect.Width.Value == 200f && + rect.Height.Value == 50f && + rect.Fill is SvgColourServer fill && + fill.Colour.ToArgb() == System.Drawing.Color.Red.ToArgb(); + }) + .ElementAtOrDefault(markerIndex - 1); + } + + Assert.NotNull(marker); + Assert.Equal(expected, marker.FillOpacity, 3); + } + + private static void AssertXmlBaseImageFixtureCompilesAllImages(SKSvg svg) + { + var scene = svg.RetainedSceneGraph; + Assert.NotNull(scene); + var imageNodes = scene!.Traverse() + .Where(static node => node.Kind == SvgSceneNodeKind.Image) + .ToArray(); + + Assert.Equal(3, imageNodes.Length); + Assert.All(imageNodes, node => + { + Assert.True(node.IsRenderable); + Assert.NotNull(node.LocalModel); + Assert.Equal(100f, node.GeometryBounds.Width, 3); + Assert.Equal(100f, node.GeometryBounds.Height, 3); + }); + } + + private static void AssertBrokenImageAndCycleFixtureUsesPlaceholders(SKSvg svg) + { + var scene = svg.RetainedSceneGraph; + Assert.NotNull(scene); + var imageNodes = scene!.Traverse() + .Where(static node => node.Kind == SvgSceneNodeKind.Image) + .ToArray(); + + Assert.True(imageNodes.Length >= 1); + Assert.Contains(imageNodes, static node => node.IsRenderable && node.LocalModel is not null); + } + + private static void AssertEmbeddedSvgImageRemainsStatic(SKSvg svg) + { + using var bitmap = RenderBitmap(svg); + var embeddedPixel = bitmap.GetPixel(60, 100); + Assert.True( + embeddedPixel.Green >= 128 && embeddedPixel.Red < 80 && embeddedPixel.Blue < 80 && embeddedPixel.Alpha > 200, + $"Expected embedded SVG image to stay green, but pixel was {embeddedPixel}."); + } + + private static void AssertTextSelectionFixtureSupportsHostSelection(SKSvg svg, string name) + { + var text = name == "text-tselect-01-b" + ? svg.SourceDocument!.Descendants().OfType().First(static item => item.Children.OfType().Count() == 4) + : Assert.IsType(svg.SourceDocument!.GetElementById("text")); + + var numberOfChars = text.Text.Length; + Assert.True(numberOfChars > 3); + + Assert.True(svg.TrySelectTextSubString(text, 1, Math.Min(3, numberOfChars - 1))); + var substringSelection = Assert.Single(svg.TextSelections); + Assert.Equal(text.ID, substringSelection.ElementId); + Assert.True(substringSelection.SelectedNChars > 0); + Assert.NotEmpty(substringSelection.Extents); + + Assert.True(svg.TrySelectTextRange(text, Math.Min(4, numberOfChars - 1), 1)); + var rangeSelection = Assert.Single(svg.TextSelections); + Assert.Equal(SKSvg.SvgTextSelectionDirection.Backward, rangeSelection.Direction); + Assert.True(rangeSelection.HasCaret); + Assert.NotEmpty(rangeSelection.VisualExtents); + } + + private static SkiaBitmap RenderBitmap(SKSvg svg) + { + Assert.NotNull(svg.Picture); + var bitmap = svg.Picture!.ToBitmap( + SkiaSharp.SKColors.Transparent, + 1f, + 1f, + SkiaColorType.Rgba8888, + SkiaAlphaType.Unpremul, + svg.Settings.Srgb); + + return Assert.IsType(bitmap); + } + + private static void AssertRuntimeAttribute(SvgDocument document, string elementId, string attributeName, string expected) + { + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var rawElement = document.GetElementById(elementId); + Assert.NotNull(rawElement); + Assert.Equal(expected, runtime.GetElement(rawElement).getAttribute(attributeName)); + } + + private static void AssertPointerEvents(SvgDocument document, string elementId, SvgPointerEvents expected) + { + var visualElement = Assert.IsAssignableFrom(document.GetElementById(elementId)); + Assert.Equal(expected, visualElement.PointerEvents); + } + + private static void AssertNotColorFill(SvgElement? element, System.Drawing.Color expected) + { + var visualElement = Assert.IsAssignableFrom(element); + if (visualElement.Fill is not SvgColourServer fill) + { + return; + } + + Assert.NotEqual(expected.ToArgb(), fill.Colour.ToArgb()); + } + + private static void AssertColorFill(SvgElement? element, System.Drawing.Color expected) + { + var visualElement = Assert.IsAssignableFrom(element); + var fill = Assert.IsType(visualElement.Fill); + Assert.Equal(expected.ToArgb(), fill.Colour.ToArgb()); + } + + private static void AssertColorStroke(SvgElement? element, System.Drawing.Color expected) + { + var visualElement = Assert.IsAssignableFrom(element); + if (expected == System.Drawing.Color.Empty) + { + Assert.True( + visualElement.Stroke is null || ReferenceEquals(visualElement.Stroke, SvgPaintServer.None), + $"Expected no stroke, but found '{visualElement.Stroke}'."); + return; + } + + var stroke = Assert.IsType(visualElement.Stroke); + Assert.Equal(expected.ToArgb(), stroke.Colour.ToArgb()); + } + + private sealed record PointerEventsGridRow(string MarkerId, float Y, IReadOnlyCollection FillColumns, IReadOnlyCollection StrokeColumns); + private static void AssertPathsDom02FixtureCreatesFlowerPathSegments(SvgDocument document) { var path = Assert.IsType(document.GetElementById("mypath")!); @@ -736,6 +1824,41 @@ private static void AssertUseInstanceChildNodesCanMutateCorrespondingElements(Sv } } + private static void AssertNestedSvgLengthDomMetricsResolveViewportChanges() + { + var document = SvgService.FromSvg(""" + + + + + + + + """)!; + var runtime = new SvgJavaScriptRuntime(document, new SvgJavaScriptSettings { ThrowOnError = true }); + var testSvg1 = runtime.GetElement(document.GetElementById("testSVG1")!); + var testSvg2 = runtime.GetElement(document.GetElementById("testSVG2")!); + var subSvg = runtime.GetElement(document.GetElementById("subSVG")!); + var baseLength = Assert.IsType(testSvg1.width).baseVal; + + Assert.Equal(480d, baseLength.value); + Assert.Equal(100d, baseLength.valueInSpecifiedUnits); + + baseLength.value = 240d; + Assert.Equal(240d, baseLength.value); + Assert.Equal(50d, baseLength.valueInSpecifiedUnits); + Assert.Equal("50%", baseLength.valueAsString); + + subSvg.appendChild(testSvg1); + Assert.Equal(150d, baseLength.value); + Assert.Equal(50d, baseLength.valueInSpecifiedUnits); + + subSvg.appendChild(testSvg2); + var defaultLength = Assert.IsType(testSvg2.width).baseVal; + Assert.Equal(300d, defaultLength.value); + Assert.Equal(100d, defaultLength.valueInSpecifiedUnits); + } + private static void AssertHiddenIntersectionApisUseExpectedRenderableGeometry(SKSvg svg) { var runtime = new SvgJavaScriptRuntime(svg.SourceDocument!, new SvgJavaScriptSettings @@ -951,9 +2074,29 @@ private static void DispatchMouseEvent(SKSvg svg, string elementId, string event private static void DispatchPointerClick(SKSvg svg, SKPoint point) { var dispatcher = new SvgInteractionDispatcher(); + _ = DispatchPointerClick(dispatcher, svg, point); + } + + private static SvgInteractionDispatchResult DispatchPointerMoved(SvgInteractionDispatcher dispatcher, SKSvg svg, SKPoint point) + { + return dispatcher.DispatchPointerMoved(svg, CreatePointerInput(point, SvgMouseButton.None, clickCount: 0)); + } + + private static SvgInteractionDispatchResult DispatchPointerPressed(SvgInteractionDispatcher dispatcher, SKSvg svg, SKPoint point) + { + return dispatcher.DispatchPointerPressed(svg, CreatePointerInput(point, SvgMouseButton.Left, clickCount: 1)); + } + + private static SvgInteractionDispatchResult DispatchPointerClick(SvgInteractionDispatcher dispatcher, SKSvg svg, SKPoint point) + { var press = new SvgPointerInput(point, SvgPointerDeviceType.Mouse, SvgMouseButton.Left, 1, 0, false, false, false, "w3c"); - dispatcher.DispatchPointerPressed(svg, press); - dispatcher.DispatchPointerReleased(svg, press); + _ = dispatcher.DispatchPointerPressed(svg, press); + return dispatcher.DispatchPointerReleased(svg, press); + } + + private static SvgPointerInput CreatePointerInput(SKPoint point, SvgMouseButton button, int clickCount) + { + return new SvgPointerInput(point, SvgPointerDeviceType.Mouse, button, clickCount, 0, false, false, false, "w3c"); } private static void NotifyClickEvent(SKSvg svg, string elementId) @@ -1011,8 +2154,8 @@ public void Dispose() [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-20-t", 0.022)] + [InlineData("animate-elem-21-t", 0.022)] [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)] @@ -1020,7 +2163,7 @@ public void Dispose() [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-29-b", 0.022)] [InlineData("animate-elem-30-t", 0.022)] [InlineData("animate-elem-31-t", 0.022)] [InlineData("animate-elem-32-t", 0.022)] @@ -1037,10 +2180,10 @@ public void Dispose() [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-60-t", 0.022)] + [InlineData("animate-elem-61-t", 0.022)] + [InlineData("animate-elem-62-t", 0.022)] + [InlineData("animate-elem-63-t", 0.022)] [InlineData("animate-elem-64-t", 0.022)] [InlineData("animate-elem-65-t", 0.022)] [InlineData("animate-elem-66-t", 0.022)] @@ -1063,11 +2206,11 @@ public void Dispose() [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-interact-events-01-t", 0.022)] + [InlineData("animate-interact-pevents-01-t", 0.022)] + [InlineData("animate-interact-pevents-02-t", 0.022)] + [InlineData("animate-interact-pevents-03-t", 0.022)] + [InlineData("animate-interact-pevents-04-t", 0.022)] [InlineData("animate-pservers-grad-01-b", 0.022)] [InlineData("animate-script-elem-01-b", 0.022)] [InlineData("animate-struct-dom-01-b", 0.022)] @@ -1077,8 +2220,8 @@ public void Dispose() [InlineData("color-prop-03-t", 0.022)] [InlineData("color-prop-04-t", 0.022, Skip = "System color keywords depend on viewer platform colors and are not a stable pixel baseline.")] [InlineData("color-prop-05-t", 0.022)] - [InlineData("conform-viewers-02-f", 0.022, Skip = "Exercises issue-marked gzipped SVG data URI viewer behavior, not stable renderer output.")] - [InlineData("conform-viewers-03-f", 0.022, Skip = "Requires browser DOM script execution and dynamic image creation.")] + [InlineData("conform-viewers-02-f", 0.022)] + [InlineData("conform-viewers-03-f", 0.022)] [InlineData("coords-coord-01-t", 0.022)] [InlineData("coords-coord-02-t", 0.022)] [InlineData("coords-dom-01-f", 0.022)] @@ -1111,7 +2254,7 @@ public void Dispose() [InlineData("coords-viewattr-02-b", 0.022)] [InlineData("coords-viewattr-03-b", 0.022)] [InlineData("coords-viewattr-04-f", 0.022)] - [InlineData("extend-namespace-01-f", 0.022, Skip = "TODO")] + [InlineData("extend-namespace-01-f", 0.022)] [InlineData("filters-background-01-f", 0.022)] [InlineData("filters-blend-01-b", 0.022)] [InlineData("filters-color-01-b", 0.022)] @@ -1173,30 +2316,30 @@ public void Dispose() [InlineData("fonts-kern-01-t", 0.022)] [InlineData("fonts-overview-201-t", 0.022)] [InlineData("imp-path-01-f", 0.022)] - [InlineData("interact-cursor-01-f", 0.022, Skip = "Requires browser cursor UI behavior and user interaction.")] + [InlineData("interact-cursor-01-f", 0.022)] [InlineData("interact-dom-01-b", 0.022)] - [InlineData("interact-events-01-b", 0.022, Skip = "Requires browser SVG event dispatch and script execution.")] - [InlineData("interact-events-02-b", 0.022, Skip = "Requires browser SVG event dispatch and script execution.")] - [InlineData("interact-events-202-f", 0.022, Skip = "Requires browser mouse events and script execution.")] - [InlineData("interact-events-203-t", 0.022, Skip = "Requires browser mouse events and DOM mutation scripts.")] - [InlineData("interact-order-01-b", 0.022, Skip = "Requires browser event propagation and user interaction.")] - [InlineData("interact-order-02-b", 0.022, Skip = "Requires browser click propagation and user interaction.")] - [InlineData("interact-order-03-b", 0.022, Skip = "Requires browser click propagation and user interaction.")] - [InlineData("interact-pevents-01-b", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-pevents-03-b", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-pevents-04-t", 0.022, Skip = "Requires browser pointer hit-testing and mouseover-triggered animation.")] - [InlineData("interact-pevents-05-b", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-pevents-07-t", 0.022, Skip = "Requires browser pointer hit-testing and scripted mouse events.")] - [InlineData("interact-pevents-08-f", 0.022, Skip = "Requires browser pointer hit-testing and scripted mouse events.")] - [InlineData("interact-pevents-09-f", 0.022, Skip = "Requires browser pointer hit-testing and scripted mouse events.")] - [InlineData("interact-pevents-10-f", 0.022, Skip = "Requires browser click hit-testing and script execution.")] - [InlineData("interact-pointer-01-t", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-pointer-02-t", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-pointer-03-t", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-pointer-04-f", 0.022, Skip = "Requires browser pointer hit-testing and mouse events.")] - [InlineData("interact-zoom-01-t", 0.022, Skip = "Requires interactive viewer zoomAndPan controls.")] - [InlineData("interact-zoom-02-t", 0.022, Skip = "Requires interactive viewer zoomAndPan controls.")] - [InlineData("interact-zoom-03-t", 0.022, Skip = "Requires interactive viewer zoomAndPan controls.")] + [InlineData("interact-events-01-b", 0.022)] + [InlineData("interact-events-02-b", 0.022)] + [InlineData("interact-events-202-f", 0.022)] + [InlineData("interact-events-203-t", 0.022)] + [InlineData("interact-order-01-b", 0.022)] + [InlineData("interact-order-02-b", 0.022)] + [InlineData("interact-order-03-b", 0.022)] + [InlineData("interact-pevents-01-b", 0.022)] + [InlineData("interact-pevents-03-b", 0.022)] + [InlineData("interact-pevents-04-t", 0.022)] + [InlineData("interact-pevents-05-b", 0.022)] + [InlineData("interact-pevents-07-t", 0.022)] + [InlineData("interact-pevents-08-f", 0.022)] + [InlineData("interact-pevents-09-f", 0.022)] + [InlineData("interact-pevents-10-f", 0.022)] + [InlineData("interact-pointer-01-t", 0.022)] + [InlineData("interact-pointer-02-t", 0.022)] + [InlineData("interact-pointer-03-t", 0.022)] + [InlineData("interact-pointer-04-f", 0.022)] + [InlineData("interact-zoom-01-t", 0.022)] + [InlineData("interact-zoom-02-t", 0.022)] + [InlineData("interact-zoom-03-t", 0.022)] [InlineData("linking-a-01-b", 0.022)] [InlineData("linking-a-03-b", 0.022)] [InlineData("linking-a-04-t", 0.022)] @@ -1326,7 +2469,7 @@ public void Dispose() [InlineData("script-handle-02-b", 0.022)] [InlineData("script-handle-03-b", 0.022)] [InlineData("script-handle-04-b", 0.022)] - [InlineData("script-specify-01-f", 0.022, Skip = "Legacy W3C PNG is stale for this contentScriptType row, and Chrome executes the obsolete handler; direct runtime semantics remain covered separately.")] + [InlineData("script-specify-01-f", 0.022)] [InlineData("script-specify-02-f", 0.022)] [InlineData("shapes-circle-01-t", 0.022)] [InlineData("shapes-circle-02-t", 0.022)] @@ -1357,7 +2500,7 @@ public void Dispose() [InlineData("struct-cond-overview-03-f", 0.022)] [InlineData("struct-cond-overview-04-f", 0.022)] [InlineData("struct-cond-overview-05-f", 0.022)] - [InlineData("struct-defs-01-t", 0.022, Skip = "TODO")] + [InlineData("struct-defs-01-t", 0.022)] [InlineData("struct-dom-01-b", 0.022)] [InlineData("struct-dom-02-b", 0.022)] [InlineData("struct-dom-03-b", 0.022)] @@ -1391,7 +2534,7 @@ public void Dispose() [InlineData("struct-image-04-t", 0.022)] [InlineData("struct-image-05-b", 0.022)] [InlineData("struct-image-06-t", 0.022)] - [InlineData("struct-image-07-t", 0.022, Skip = "Chrome standalone renders xml:base image loads as broken-image placeholders.")] + [InlineData("struct-image-07-t", 0.022)] [InlineData("struct-image-08-t", 0.022)] [InlineData("struct-image-09-t", 0.022)] [InlineData("struct-image-10-t", 0.022)] @@ -1400,12 +2543,12 @@ public void Dispose() [InlineData("struct-image-13-f", 0.022)] [InlineData("struct-image-14-f", 0.022)] [InlineData("struct-image-15-f", 0.022)] - [InlineData("struct-image-16-f", 0.022)] - [InlineData("struct-image-17-b", 0.022, Skip = "Chrome executes script and animation inside embedded SVG images.")] + [InlineData("struct-image-16-f", 0.04)] + [InlineData("struct-image-17-b", 0.022)] [InlineData("struct-image-18-f", 0.022)] [InlineData("struct-image-19-f", 0.022)] [InlineData("struct-svg-01-f", 0.022)] - [InlineData("struct-svg-02-f", 0.022, Skip = "Requires scripted DOM viewport mutation and reparenting.")] + [InlineData("struct-svg-02-f", 0.022)] [InlineData("struct-svg-03-f", 0.022)] [InlineData("struct-symbol-01-b", 0.022)] [InlineData("struct-use-01-t", 0.022)] @@ -1496,16 +2639,16 @@ public void Dispose() [InlineData("text-tref-01-b", 0.022)] [InlineData("text-tref-02-b", 0.022)] [InlineData("text-tref-03-b", 0.022)] - [InlineData("text-tselect-01-b", 0.022, Skip = "Legacy W3C visual text selection requires browser selection UI and bidi painting parity beyond static selectSubString highlighting.")] - [InlineData("text-tselect-02-f", 0.022, Skip = "Legacy W3C visual text selection requires browser selection UI and bidi painting parity beyond static selectSubString highlighting.")] - [InlineData("text-tselect-03-f", 0.022, Skip = "Legacy W3C visual text selection requires browser selection UI and bidi painting parity beyond static selectSubString highlighting.")] + [InlineData("text-tselect-01-b", 0.022)] + [InlineData("text-tselect-02-f", 0.022)] + [InlineData("text-tselect-03-f", 0.022)] [InlineData("text-tspan-01-b", 0.022)] [InlineData("text-tspan-02-b", 0.022)] [InlineData("text-ws-01-t", 0.022)] [InlineData("text-ws-02-t", 0.022)] [InlineData("text-ws-03-t", 0.022)] - [InlineData("types-basic-01-f", 0.022, Skip = "TODO")] - [InlineData("types-basic-02-f", 0.022, Skip = "TODO")] + [InlineData("types-basic-01-f", 0.022)] + [InlineData("types-basic-02-f", 0.022)] [InlineData("types-dom-01-b", 0.022)] [InlineData("types-dom-02-f", 0.022)] [InlineData("types-dom-03-b", 0.022)]