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 `
""";
+ 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($$"""
+
+ """);
+
+ 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($$"""
+
+ """);
+
+ 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($$"""
+
+ """);
+
+ 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 = """
""";
+ private const string InvalidPaintedFallbackSvg = """
+
+ """;
+
private const string ClippedFrontSvg = """
""";
+ private const string MaskedFrontSvg = """
+
+ """;
+
private const string UseInstanceSvg = """