From 94b749c4615c4e06641be8a558c3571b30c3a561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 19 Sep 2026 01:27:53 +0200 Subject: [PATCH] Render CSS path clip geometry --- docs/validation/css-path-clip-20260919.md | 48 +++++++ .../native/webscene_native_dom_scene.inc | 121 +++++++++++++++++- .../native/webscene_native_engine.h | 6 +- .../webscene_native_engine_scene_utils.inc | 19 ++- .../tests/native_css_effect_values_tests.cpp | 39 +++++- .../NativeCanvasSceneRenderer.cs | 39 +++++- .../lib/src/scene_projector.dart | 23 +++- .../contracts/css-retained-effect-values.html | 7 + .../NativeCanvasPathReplayTests.cs | 17 +++ 9 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 docs/validation/css-path-clip-20260919.md diff --git a/docs/validation/css-path-clip-20260919.md b/docs/validation/css-path-clip-20260919.md new file mode 100644 index 000000000..46787f52d --- /dev/null +++ b/docs/validation/css-path-clip-20260919.md @@ -0,0 +1,48 @@ +# CSS `path()` clip — implementation checkpoint + +Date: 2026-09-19 + +## Scope + +The provider slice of issue #504 projects CSS `clip-path: path(...)` through +the existing retained kind-12 clip command. It accepts one quoted SVG path, +an optional `evenodd` or `nonzero` fill rule, bounded CSS string escapes, and +a maximum decoded path length of 16,384 bytes. Path coordinates are relative +to the element border-box origin. + +Kind 12 retains bit 31 for a string-backed SVG path, adds bit 30 for even-odd +fill and bit 29 for border-box-relative coordinates, and uses the low 29 bits +as the scene-string index. Skia and Flutter consume the same flags. Invalid or +unparseable SVG data produces an empty clip instead of exposing unclipped +content. + +Scene equality now compares kind-12 resource text and its metadata flags. This +prevents fill-rule or coordinate-space changes with identical path text from +being mistaken for an unchanged visual, while allowing string-table indices to +change without unnecessary damage when the effective resource is identical. + +## Authored regression coverage + +- The browser contract preserves quoted path syntax and the even-odd rule. +- The Skia backend fixture verifies that even-odd clipping cuts a transparent + inner region from a filled outer path. +- Existing retained scene contracts remain the integration point for kind-12 + resource identity and localized publication behavior. + +These tests were authored but not executed under the current implementation +throughput directive. + +## Explicit boundary + +This provider does not resolve `url(#clip)` or external SVG clip resources. +The same-document URL consumer remains the second PR in #504. Arbitrary CSS +shape commands, geometry boxes outside the current border-box behavior, and +unbounded path/resource inputs remain unsupported or fail closed. + +## Required release gates + +Before release promotion, run the focused native/backend tests, WPT-derived +contracts, malformed and maximum-size inputs, fill-rule and mutation damage +cases, Skia/Flutter pixel comparisons against the same Chromium fixture, +4,096-path publication and memory benchmarks, repeated detach/reattach cleanup, +the exact SDK/CLI package, and the relevant CI jobs from the final merged heads. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc index 662fa0918..d32addc70 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -698,6 +698,8 @@ void native_document::append_scene( layout_rect bounds; float radius{}; std::string path; + bool even_odd{}; + bool relative_path{}; }; const auto resolve_effect_clip = [&]() -> std::optional { const auto& effects = node.style.textual().effect_values; @@ -707,6 +709,7 @@ void native_document::append_scene( constexpr std::string_view inset_prefix = "inset("; constexpr std::string_view circle_prefix = "circle("; constexpr std::string_view ellipse_prefix = "ellipse("; + constexpr std::string_view path_prefix = "path("; constexpr std::string_view polygon_prefix = "polygon("; if (value.empty() || value.back() != ')') return std::nullopt; const auto has_prefix = [&](std::string_view prefix) { @@ -817,6 +820,101 @@ void native_document::append_scene( 0.0F, path.str()}; } + if (has_prefix(path_prefix)) { + auto arguments = value.substr( + path_prefix.size(), value.size() - path_prefix.size() - 1U); + const auto trim = [](std::string_view token) { + while (!token.empty() + && std::isspace(static_cast(token.front()))) { + token.remove_prefix(1U); + } + while (!token.empty() + && std::isspace(static_cast(token.back()))) { + token.remove_suffix(1U); + } + return token; + }; + arguments = trim(arguments); + auto even_odd = false; + constexpr auto even_odd_prefix = std::string_view{"evenodd"}; + constexpr auto nonzero_prefix = std::string_view{"nonzero"}; + const auto consume_fill_rule = [&](std::string_view prefix, bool value) + -> bool { + if (arguments.size() < prefix.size()) return false; + for (size_t index = 0U; index < prefix.size(); ++index) { + if (!ascii_equal(arguments[index], prefix[index])) return false; + } + auto remaining = trim(arguments.substr(prefix.size())); + if (remaining.empty() || remaining.front() != ',') return false; + arguments = trim(remaining.substr(1U)); + even_odd = value; + return true; + }; + if (!consume_fill_rule(even_odd_prefix, true)) { + consume_fill_rule(nonzero_prefix, false); + } + if (arguments.size() < 2U + || (arguments.front() != '\'' && arguments.front() != '"') + || arguments.back() != arguments.front()) { + return std::nullopt; + } + const auto quote = arguments.front(); + auto encoded = arguments.substr(1U, arguments.size() - 2U); + std::string decoded; + decoded.reserve(encoded.size()); + for (size_t index = 0U; index < encoded.size(); ++index) { + const auto character = static_cast(encoded[index]); + if (character == static_cast(quote) + || character == '\n' || character == '\r' || character == '\f') { + return std::nullopt; + } + if (character != '\\') { + decoded.push_back(static_cast(character)); + continue; + } + if (++index == encoded.size()) return std::nullopt; + if (encoded[index] == '\n') continue; + if (encoded[index] == '\r') { + if (index + 1U < encoded.size() && encoded[index + 1U] == '\n') ++index; + continue; + } + auto hexadecimal = uint32_t{}; + auto digits = size_t{}; + for (; index < encoded.size() && digits < 6U; ++index, ++digits) { + const auto byte = static_cast(encoded[index]); + uint32_t nibble = 0U; + if (byte >= '0' && byte <= '9') nibble = byte - '0'; + else if (byte >= 'a' && byte <= 'f') nibble = byte - 'a' + 10U; + else if (byte >= 'A' && byte <= 'F') nibble = byte - 'A' + 10U; + else break; + hexadecimal = hexadecimal * 16U + nibble; + } + if (digits != 0U) { + if (hexadecimal == 0U || hexadecimal > 0x7FU) return std::nullopt; + decoded.push_back(static_cast(hexadecimal)); + if (index < encoded.size() + && std::isspace(static_cast(encoded[index]))) { + if (encoded[index] == '\r' && index + 1U < encoded.size() + && encoded[index + 1U] == '\n') ++index; + } else if (index != 0U) { + --index; + } + continue; + } + decoded.push_back(encoded[index]); + } + if (decoded.empty() || decoded.size() > 16384U) return std::nullopt; + return effect_clip_geometry{ + layout_rect{ + node.layout.x, + node.layout.y, + node.layout.width, + node.layout.height}, + 0.0F, + std::move(decoded), + even_odd, + true}; + } if (has_prefix(polygon_prefix)) { auto arguments = value.substr( polygon_prefix.size(), value.size() - polygon_prefix.size() - 1U); @@ -831,6 +929,22 @@ void native_document::append_scene( } return token; }; + auto even_odd = false; + const auto first_comma = arguments.find(','); + if (first_comma != std::string_view::npos) { + const auto possible_rule = trim(arguments.substr(0U, first_comma)); + const auto is_rule = [&](std::string_view expected) { + if (possible_rule.size() != expected.size()) return false; + for (size_t index = 0U; index < expected.size(); ++index) { + if (!ascii_equal(possible_rule[index], expected[index])) return false; + } + return true; + }; + if (is_rule("evenodd") || is_rule("nonzero")) { + even_odd = is_rule("evenodd"); + arguments = trim(arguments.substr(first_comma + 1U)); + } + } std::ostringstream path; path.precision(std::numeric_limits::max_digits10); size_t point_count = 0U; @@ -873,7 +987,8 @@ void native_document::append_scene( node.layout.width, node.layout.height}, 0.0F, - path.str()}; + path.str(), + even_odd}; } if (!has_prefix(inset_prefix)) return std::nullopt; const std::string_view arguments(value.data() + 6U, value.size() - 7U); @@ -917,9 +1032,13 @@ void native_document::append_scene( const auto effect_clip = resolve_effect_clip(); if (effect_clip.has_value()) { constexpr uint32_t polygon_clip_resource = 1U << 31U; + constexpr uint32_t clip_even_odd = 1U << 30U; + constexpr uint32_t clip_relative_path = 1U << 29U; const auto flags = effect_clip->path.empty() ? 0U : polygon_clip_resource + | (effect_clip->even_odd ? clip_even_odd : 0U) + | (effect_clip->relative_path ? clip_relative_path : 0U) | append_scene_string(effect_clip->path, strings, string_bytes); commands.push_back(webscene_scene_command{ 12U, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 27053882c..c376d803c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -153,8 +153,10 @@ typedef struct webscene_scene_header { // Shadow kinds 17/18: flags bit 0 selects an inverse rounded hole; // producers must bracket inverse shadows with clip commands 12/13. // Clip kind 12 uses flags bit 31 to select an SVG path stored in the indexed -// scene string; the remaining bits are its string index. A zero flag retains -// the rounded-rectangle fields used by existing producers and presenters. +// scene string. Bit 30 selects even-odd fill and bit 29 makes path coordinates +// relative to the command box origin; the remaining bits are its string index. +// A zero flag retains the rounded-rectangle fields used by existing producers +// and presenters. // Group kind 30 uses flags bit 31 for brightness, bit 30 for grayscale, // bit 29 for contrast, bit 28 for foreground blur, and bit 27 for saturation; // bit 26 opens a neutral isolated layer for a following kind-47 alpha mask. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc index 6377f3808..9cbdcb1ed 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc @@ -79,18 +79,24 @@ void store_maximum(std::atomic& target, uint64_t value) bool command_uses_dom_string(const webscene_scene_command& command) { - return (command.kind >= 3U && command.kind <= 6U) || command.kind == 47U; + return (command.kind >= 3U && command.kind <= 6U) + || (command.kind == 12U && (command.flags & (1U << 31U)) != 0U) + || command.kind == 47U; } std::string_view command_dom_string( const scene& owner, const webscene_scene_command& command) { - if (!command_uses_dom_string(command) - || command.flags >= owner.canvas_strings.size()) { + if (!command_uses_dom_string(command)) { return {}; } - const auto& value = owner.canvas_strings[command.flags]; + constexpr auto clip_index_mask = (1U << 29U) - 1U; + const auto index = command.kind == 12U + ? command.flags & clip_index_mask + : command.flags; + if (index >= owner.canvas_strings.size()) return {}; + const auto& value = owner.canvas_strings[index]; if (value.byte_offset > owner.canvas_string_bytes.size() || value.byte_length > owner.canvas_string_bytes.size() - value.byte_offset) { return {}; @@ -121,6 +127,11 @@ bool same_dom_command_visual( return false; } if (command_uses_dom_string(next)) { + constexpr auto clip_index_mask = (1U << 29U) - 1U; + if (next.kind == 12U + && (previous.flags & ~clip_index_mask) != (next.flags & ~clip_index_mask)) { + return false; + } return command_dom_string(previous_owner, previous) == command_dom_string(next_owner, next); } diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp index 34fcbb55c..cc672cabd 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp @@ -54,6 +54,7 @@ struct clip_scene_counts final { uint32_t inset_clip_begins{}; uint32_t inset_clip_ends{}; uint32_t ellipse_clip_begins{}; + uint32_t path_clip_begins{}; uint32_t clipped_fills{}; uint32_t blur_filter_begins{}; uint32_t functional_blur_begins{}; @@ -61,6 +62,7 @@ struct clip_scene_counts final { uint32_t command_count{}; bool transform_clip_nested{}; bool compound_filter_ordered{}; + bool path_clip_metadata{}; }; clip_scene_counts wait_for_inset_clip_scene( @@ -139,6 +141,26 @@ clip_scene_counts wait_for_inset_clip_scene( && std::abs(command.width - 4.0F) < 0.01F && std::abs(command.height - 2.0F) < 0.01F) { ++latest.ellipse_clip_begins; + } else if (command.kind == 12U + && (command.flags & (1U << 31U)) != 0U + && std::abs(command.width - 8.0F) < 0.01F + && std::abs(command.height - 2.0F) < 0.01F) { + ++latest.path_clip_begins; + constexpr auto path_index_mask = (1U << 29U) - 1U; + const auto path_index = command.flags & path_index_mask; + if ((command.flags & (1U << 30U)) != 0U + && (command.flags & (1U << 29U)) != 0U + && path_index < scene->string_count) { + const auto& resource = scene->strings[path_index]; + if (resource.byte_offset <= scene->string_byte_count + && resource.byte_length + <= scene->string_byte_count - resource.byte_offset) { + latest.path_clip_metadata = std::string_view( + scene->string_bytes + resource.byte_offset, + resource.byte_length) + == "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z"; + } + } } else if (command.kind == 13U) { ++latest.inset_clip_ends; } else if ((command.kind == 1U || command.kind == 9U) @@ -274,6 +296,7 @@ int main() #effects.alternate > span { clip-path: circle(25%); filter: contrast(2); } #effects > span:first-child { transform: scale(1.25) rotate(3deg); } #ellipse-clip { clip-path: ellipse(25% 50% at 50% 50%); } + #path-clip { clip-path: path(evenodd, "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z"); } #functional-blur { filter: blur(max(4px, calc(8px * 0.25))); } #compound-filter { filter: blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25); } #effects > span:last-child { filter: blur(2px); } @@ -285,6 +308,7 @@ int main() for (let index = 0; index < 4096; index++) fragment.appendChild(document.createElement('span')); host.appendChild(fragment); host.children[1].id = 'ellipse-clip'; + host.children[2].id = 'path-clip'; host.children[4093].id = 'functional-blur'; host.children[4094].id = 'compound-filter'; document.body.appendChild(host); @@ -315,6 +339,10 @@ int main() !== 'ellipse(25% 50% at 50% 50%)') { throw new Error('initial ellipse clip value failed'); } + if (getComputedStyle(document.getElementById('path-clip')).getPropertyValue('clip-path') + !== 'path(evenodd, "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z")') { + throw new Error('initial path clip value failed'); + } })() )JS", "native-effects-fixture.js"); @@ -372,9 +400,9 @@ int main() peak_memory.native_dom_textual_style_storage_bytes - before_memory.native_dom_textual_style_storage_bytes; - const auto initial_clip_scene = wait_for_inset_clip_scene(engine, 4095U, 4096U); - require(initial_clip_scene.inset_clip_begins == 4095U, - "retained scene did not emit 4095 inset clip begin commands"); + const auto initial_clip_scene = wait_for_inset_clip_scene(engine, 4094U, 4096U); + require(initial_clip_scene.inset_clip_begins == 4094U, + "retained scene did not emit 4094 inset clip begin commands"); require(initial_clip_scene.inset_clip_ends == 4096U, "retained scene did not emit 4096 balanced inset clip end commands"); require(initial_clip_scene.clipped_fills == 4096U, @@ -383,6 +411,9 @@ int main() "transform commands did not wrap the inset clip scope"); require(initial_clip_scene.ellipse_clip_begins == 1U, "retained scene did not emit the explicit ellipse path clip"); + require(initial_clip_scene.path_clip_begins == 1U + && initial_clip_scene.path_clip_metadata, + "retained scene did not emit the relative even-odd CSS path clip"); require(initial_clip_scene.blur_filter_begins == 2U, "retained scene did not emit both bounded foreground blur groups"); require(initial_clip_scene.compound_filter_ordered, @@ -519,6 +550,8 @@ int main() << " clip-begins=" << initial_clip_scene.inset_clip_begins << " clip-ends=" << initial_clip_scene.inset_clip_ends << " ellipse-clip-begins=" << initial_clip_scene.ellipse_clip_begins + << " path-clip-begins=" << initial_clip_scene.path_clip_begins + << " path-clip-metadata=" << initial_clip_scene.path_clip_metadata << " clipped-fills=" << initial_clip_scene.clipped_fills << " blur-filter-begins=" << initial_clip_scene.blur_filter_begins << " functional-blur-begins=" << initial_clip_scene.functional_blur_begins diff --git a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs index 5b66ab7ed..6072960d1 100644 --- a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs +++ b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs @@ -43,7 +43,10 @@ internal sealed unsafe partial class NativeCanvasSceneRenderer private const uint LayerUnchangedPrefix = 4; private const uint OffscreenCanvasLayer = 1u << 31; private const uint DomPolygonClipResource = 1u << 31; - private const uint DomPolygonClipIndexMask = ~DomPolygonClipResource; + private const uint DomClipEvenOdd = 1u << 30; + private const uint DomClipRelativePath = 1u << 29; + private const uint DomPolygonClipIndexMask = + ~(DomPolygonClipResource | DomClipEvenOdd | DomClipRelativePath); private const uint DomBrightnessFilter = 1u << 31; private const uint DomGrayscaleFilter = 1u << 30; private const uint DomContrastFilter = 1u << 29; @@ -1979,10 +1982,33 @@ private static void ClipDomShape( ClipDomRoundedRect(canvas, command, radii); return; } - ClipDomPath(canvas, DomStringAt(view, command.Flags & DomPolygonClipIndexMask)); + var relative = (command.Flags & DomClipRelativePath) != 0; + if (relative) + { + canvas.Translate(command.X, command.Y); + } + try + { + ClipDomPath( + canvas, + DomStringAt(view, command.Flags & DomPolygonClipIndexMask), + (command.Flags & DomClipEvenOdd) != 0); + } + finally + { + // Restoring a saved canvas would also restore the prior clip. Undo + // only the matrix so the device-space clip remains active. + if (relative) + { + canvas.Translate(-command.X, -command.Y); + } + } } - private static void ClipDomPath(SKCanvas canvas, string pathData) + private static void ClipDomPath( + SKCanvas canvas, + string pathData, + bool evenOdd = false) { using var path = SKPath.ParseSvgPathData(pathData); if (path is null) @@ -1990,9 +2016,16 @@ private static void ClipDomPath(SKCanvas canvas, string pathData) canvas.ClipRect(SKRect.Empty, SKClipOperation.Intersect, antialias: false); return; } + path.FillType = evenOdd ? SKPathFillType.EvenOdd : SKPathFillType.Winding; canvas.ClipPath(path, SKClipOperation.Intersect, antialias: true); } + internal static void ClipDomPathForTest( + SKCanvas canvas, + string pathData, + bool evenOdd = false) + => ClipDomPath(canvas, pathData, evenOdd); + private void DrawDomText( SKCanvas canvas, NativeSceneView* view, diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index 259cc8fc7..b1f7cf375 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -19,7 +19,9 @@ const int _layerReplace = 1; const int _layerRemove = 2; const int _canvasEvenOdd = 1 << 16; const int _domPolygonClipResource = 1 << 31; -const int _domPolygonClipIndexMask = _domPolygonClipResource - 1; +const int _domClipEvenOdd = 1 << 30; +const int _domClipRelativePath = 1 << 29; +const int _domPolygonClipIndexMask = _domClipRelativePath - 1; const int _domBrightnessFilter = 1 << 31; const int _domGrayscaleFilter = 1 << 30; const int _domContrastFilter = 1 << 29; @@ -962,12 +964,21 @@ final class WebSceneSceneProjector extends ChangeNotifier { return; } try { - canvas.clipPath( - parseSvgPathData( - _domString(scene, command.flags & _domPolygonClipIndexMask), - ), - doAntiAlias: true, + final path = parseSvgPathData( + _domString(scene, command.flags & _domPolygonClipIndexMask), ); + if ((command.flags & _domClipEvenOdd) != 0) { + path.fillType = ui.PathFillType.evenOdd; + } + final relative = (command.flags & _domClipRelativePath) != 0; + if (relative) canvas.translate(command.x, command.y); + try { + canvas.clipPath(path, doAntiAlias: true); + } finally { + // Restoring a saved canvas would also discard the clip. Undo only the + // matrix so the clip remains in device coordinates. + if (relative) canvas.translate(-command.x, -command.y); + } } catch (_) { canvas.clipRect(ui.Rect.zero, doAntiAlias: false); } diff --git a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html index 806ba2664..a9c18a600 100644 --- a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html +++ b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html @@ -82,6 +82,13 @@ effect.style.removeProperty('clip-path'); }, 'clip-path preserves explicit ellipse radii and center'); + test(() => { + const value = 'path(evenodd, "M0 0 H120 V80 H0 Z M20 20 H100 V60 H20 Z")'; + effect.style.setProperty('clip-path', value); + assert_equals(getComputedStyle(effect).getPropertyValue('clip-path'), value); + effect.style.removeProperty('clip-path'); + }, 'clip-path preserves a bounded quoted SVG path and fill rule'); + test(() => { assert_equals(getComputedStyle(effect).getPropertyValue('filter'), 'brightness(0.5) grayscale(1)'); diff --git a/tests/WebScene.Backend.Avalonia.Tests/NativeCanvasPathReplayTests.cs b/tests/WebScene.Backend.Avalonia.Tests/NativeCanvasPathReplayTests.cs index e93a5865d..4ada08de8 100644 --- a/tests/WebScene.Backend.Avalonia.Tests/NativeCanvasPathReplayTests.cs +++ b/tests/WebScene.Backend.Avalonia.Tests/NativeCanvasPathReplayTests.cs @@ -6,6 +6,23 @@ namespace WebScene.Backend.Avalonia.Tests; public sealed unsafe class NativeCanvasPathReplayTests { + [Fact] + public void DomSvgPathClipUsesEvenOddFillRule() + { + using var bitmap = new SKBitmap(20, 20); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + + NativeCanvasSceneRenderer.ClipDomPathForTest( + canvas, + "M0 0 H20 V20 H0 Z M5 5 H15 V15 H5 Z", + evenOdd: true); + canvas.DrawColor(SKColors.CornflowerBlue); + + Assert.NotEqual(0, bitmap.GetPixel(2, 2).Alpha); + Assert.Equal(0, bitmap.GetPixel(10, 10).Alpha); + } + [Fact] public void PathGeometryRetainsTransformActiveDuringConstruction() {