From 1869b3a501da71fca88af97fe07f3c98d57b5b10 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:04:07 +0200 Subject: [PATCH] Render URL-backed SVG masks --- docs/validation/css-svg-mask-20260919.md | 53 +++ .../native/webscene_css_paint_values.h | 42 +- .../native/webscene_native_dom_scene.inc | 116 ++++-- .../native/webscene_native_engine.h | 8 +- ...ive_v8_runtime_rendering_metrics_tests.inc | 30 +- .../NativeCanvasSceneRenderer.cs | 82 +++- .../lib/src/scene_projector.dart | 360 +++++++++++++++++- .../SvgPictureRenderingTests.cs | 29 ++ 8 files changed, 666 insertions(+), 54 deletions(-) create mode 100644 docs/validation/css-svg-mask-20260919.md diff --git a/docs/validation/css-svg-mask-20260919.md b/docs/validation/css-svg-mask-20260919.md new file mode 100644 index 000000000..707eb5c26 --- /dev/null +++ b/docs/validation/css-svg-mask-20260919.md @@ -0,0 +1,53 @@ +# URL-backed CSS SVG masks — implementation checkpoint + +Date: 2026-09-19 + +## Scope + +Issue #500 adds a focused consumer on top of the mask-shorthand provider in +#499 / PR #501. It keeps unchanged Code OSS `mask` and `-webkit-mask` icon +assets inside the existing native CSS resource policy and retained scene. + +The native engine loads an allowed SVG resource through the same host-backed +loader used by CSS backgrounds. A successful load publishes one versioned +`webscene-mask-svg-v1` string containing the view box, repeat, position, size, +and immutable SVG markup. Kind 30 opens the existing isolated effect group and +kind 47 applies the mask before the group closes. Failed or unsupported mask +resources publish `webscene-mask-invalid-v1`, which clears the isolated group +instead of exposing the unmasked foreground. + +The Skia projection reuses `SharedSvgPictureCache`, paints every tile into one +bounded destination-in layer, and supports the existing SVG renderer. The +Flutter projection has no asynchronous SVG decoder on this command path, so it +uses a deliberately bounded synchronous geometry cache for untransformed, +filled path, rect, circle, ellipse, and polygon elements. Unsupported groups, +definitions, uses, images, text, line/polyline, strokes, transforms, and +nonzero-incompatible fill rules fail closed. + +## Authored regression coverage + +- The native retained-scene fixture loads one policy-approved SVG and asserts + a kind-47 `webscene-mask-svg-v1` command with exact geometry longhands. +- The Skia backend fixture repeats a half-filled 4 by 4 SVG across a 12 by 4 + foreground and asserts the three retained islands plus transparent gaps. +- Existing shorthand and computed-value contracts cover standard/WebKit alias + expansion, mutation, omitted-component reset, and retained longhand values. + +These tests were authored but not executed under the current implementation +throughput directive. + +## Explicit boundary + +This slice does not claim raster masks, multiple mask layers, luminance mode, +non-add composites, SVG stroke/group/transform parity in Flutter, or controlled +ServiceWorker-backed asynchronous mask resource replacement. Those forms fail +closed or remain separately scheduled under #256. + +## Required release gates + +Before release promotion, run the focused native and backend tests, the full +browser/native contract set, Skia/Flutter pixel comparison against the same +Chromium fixture, resource-policy and mutation tests, 4,096-icon decode/cache +and scene-publication benchmarks, 100 replacement/detach cycles with memory +return-to-baseline checks, exact SDK/CLI packaging, and relevant CI jobs from +the final merged heads. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_paint_values.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_paint_values.h index 3f2cc76a6..80584f5d1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_paint_values.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_paint_values.h @@ -98,7 +98,47 @@ template bool apply_paint_value(dom_node& node,const std::string& name,const std::string& value, Decision& decision,Protected&& is_inline,LoadSvg&& load_svg) { - if (name == "box-shadow" && !is_inline(inline_box_shadow)) { + if (name == "mask-image") { + auto& effects = node.style.mutable_textual().effect_values; + effects.erase("-webscene-mask-markup"); + effects.erase("-webscene-mask-view-box"); + effects.erase("-webscene-mask-resolved-url"); + const auto normalized = normalize_effect_value(name, value); + if (!normalized.has_value()) { + decision.classification = "unsupported"; + decision.semantic_slice = "invalid retained-effect syntax"; + return true; + } + effects[name] = *normalized; + const auto url = first_css_url(*normalized); + if (!url.has_value()) { + decision.classification = "partially-supported"; + decision.semantic_slice = + "syntax and computed value; retained-scene paint is separately qualified"; + return true; + } + std::string markup; + std::string resolved_url; + std::string view_box; + if (!load_svg(*url, markup, resolved_url, view_box)) { + decision.classification = "unsupported"; + decision.semantic_slice = "URL-backed SVG mask resource load failed"; + return true; + } + if (view_box.empty()) { + decision.classification = "unsupported"; + decision.semantic_slice = + "SVG masks with an explicit viewBox or numeric width and height"; + return true; + } + effects[name] = "url(\"" + resolved_url + "\")"; + effects["-webscene-mask-resolved-url"] = std::move(resolved_url); + effects["-webscene-mask-markup"] = std::move(markup); + effects["-webscene-mask-view-box"] = std::move(view_box); + decision.classification = "partially-supported"; + decision.semantic_slice = + "single URL-backed SVG alpha mask with explicit viewBox or numeric dimensions"; + } else if (name == "box-shadow" && !is_inline(inline_box_shadow)) { auto complete = true; if (!apply_box_shadow_value(node.style, value, complete)) { decision.classification = "unsupported"; 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 e3bc075e9..662fa0918 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -467,67 +467,111 @@ void native_document::append_scene( } return result; }(); - const auto linear_mask_resource = [&]() -> std::optional { + const auto mask_resource = [&]() -> std::optional { const auto& effects = node.style.textual().effect_values; const auto known = effects.find("mask-image"); if (known == effects.end()) return std::nullopt; auto image = std::string_view{known->second}; - while (!image.empty() - && std::isspace(static_cast(image.front()))) - image.remove_prefix(1U); - while (!image.empty() - && std::isspace(static_cast(image.back()))) - image.remove_suffix(1U); - constexpr auto prefix = std::string_view{"linear-gradient("}; - if (image.size() <= prefix.size()) return std::nullopt; - for (size_t index = 0U; index < prefix.size(); ++index) { - const auto character = static_cast(image[index]); - const auto lower = character >= 'A' && character <= 'Z' - ? static_cast(character - 'A' + 'a') - : static_cast(character); - if (lower != prefix[index]) return std::nullopt; - } - auto depth = 1U; - auto close = std::string_view::npos; - for (size_t index = prefix.size(); index < image.size(); ++index) { - if (image[index] == '(') ++depth; - else if (image[index] == ')' && --depth == 0U) { - close = index; - break; + const auto trim = [](std::string_view value) { + while (!value.empty() + && std::isspace(static_cast(value.front()))) { + value.remove_prefix(1U); + } + while (!value.empty() + && std::isspace(static_cast(value.back()))) { + value.remove_suffix(1U); + } + return value; + }; + const auto starts_with_ascii_case_insensitive = []( + std::string_view value, std::string_view prefix) { + if (value.size() < prefix.size()) return false; + for (size_t index = 0U; index < prefix.size(); ++index) { + const auto character = static_cast(value[index]); + const auto lower = character >= 'A' && character <= 'Z' + ? static_cast(character - 'A' + 'a') + : static_cast(character); + if (lower != prefix[index]) return false; } + return true; + }; + image = trim(image); + if (image.size() == 4U + && starts_with_ascii_case_insensitive(image, "none")) { + return std::nullopt; } - if (close != image.size() - 1U) return std::nullopt; + const auto invalid = [&]() { + return append_scene_string( + "webscene-mask-invalid-v1", strings, string_bytes); + }; const auto value = [&](std::string_view name, std::string_view fallback) { const auto found = effects.find(std::string{name}); return found == effects.end() ? std::string{fallback} : found->second; }; const auto composite = value("mask-composite", "add"); - if (composite != "add") return std::nullopt; + if (composite != "add") return invalid(); const auto mode = value("mask-mode", "match-source"); - if (mode != "match-source" && mode != "alpha") return std::nullopt; + if (mode != "match-source" && mode != "alpha") return invalid(); auto repeat = value("mask-repeat", "repeat"); for (auto& character : repeat) { const auto byte = static_cast(character); - if (byte >= 'A' && byte <= 'Z') character = static_cast(byte - 'A' + 'a'); + if (byte >= 'A' && byte <= 'Z') { + character = static_cast(byte - 'A' + 'a'); + } } if (repeat != "repeat" && repeat != "no-repeat" && repeat != "repeat-x" && repeat != "repeat-y") { - return std::nullopt; + return invalid(); } const auto position = value("mask-position", "0% 0%"); const auto size = value("mask-size", "auto"); if (position.find(',') != std::string::npos || size.find(',') != std::string::npos) { - return std::nullopt; + return invalid(); } - auto resource = std::string{"webscene-bg-v2\t"}; - resource.append(image); + + constexpr auto gradient_prefix = std::string_view{"linear-gradient("}; + if (starts_with_ascii_case_insensitive(image, gradient_prefix)) { + auto depth = 1U; + auto close = std::string_view::npos; + for (size_t index = gradient_prefix.size(); index < image.size(); ++index) { + if (image[index] == '(') ++depth; + else if (image[index] == ')' && --depth == 0U) { + close = index; + break; + } + } + if (close != image.size() - 1U) return invalid(); + auto resource = std::string{"webscene-bg-v2\t"}; + resource.append(image); + resource += '\t'; + resource += repeat; + resource += '\t'; + resource += position; + resource += '\t'; + resource += size; + return append_scene_string(resource, strings, string_bytes); + } + + if (!starts_with_ascii_case_insensitive(image, "url(")) { + return invalid(); + } + const auto markup = effects.find("-webscene-mask-markup"); + const auto view_box = effects.find("-webscene-mask-view-box"); + if (markup == effects.end() || markup->second.empty() + || view_box == effects.end() || view_box->second.empty()) { + return invalid(); + } + auto resource = std::string{"webscene-mask-svg-v1\t"}; + resource += view_box->second; resource += '\t'; resource += repeat; resource += '\t'; resource += position; resource += '\t'; resource += size; + resource += '\t'; + resource += markup->second; return append_scene_string(resource, strings, string_bytes); }(); const auto has_opacity_group = opacity < 0.999F; @@ -545,7 +589,7 @@ void native_document::append_scene( 255L)), node.id}); } - if (linear_mask_resource.has_value()) { + if (mask_resource.has_value()) { commands.push_back(webscene_scene_command{ 30U, 1U << 26U, @@ -2216,9 +2260,9 @@ void native_document::append_scene( 31U, 0U, node.layout.x, node.layout.y, node.layout.width, node.layout.height, 0U, node.id}); } - if (linear_mask_resource.has_value()) { + if (mask_resource.has_value()) { commands.push_back(webscene_scene_command{ - 47U, *linear_mask_resource, + 47U, *mask_resource, node.layout.x, node.layout.y, node.layout.width, node.layout.height, 0U, node.id}); @@ -2584,9 +2628,9 @@ void native_document::append_scene( 31U, 0U, node.layout.x, node.layout.y, node.layout.width, node.layout.height, 0U, node.id}); } - if (linear_mask_resource.has_value()) { + if (mask_resource.has_value()) { commands.push_back(webscene_scene_command{ - 47U, *linear_mask_resource, + 47U, *mask_resource, node.layout.x, node.layout.y, node.layout.width, node.layout.height, 0U, node.id}); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 922036e3f..27053882c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -157,12 +157,14 @@ typedef struct webscene_scene_header { // 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 linear mask. +// bit 26 opens a neutral isolated layer for a following kind-47 alpha mask. // stroke_width carries // the bounded non-negative multiplier or CSS blur standard deviation. // A zero flag retains the opacity-group alpha stored in the low byte of rgba. -// DOM kind 47 applies the indexed webscene-bg-v2 linear-gradient resource to -// the current isolated layer using destination-in before kind 31 restores it. +// DOM kind 47 applies an indexed webscene-bg-v2 linear gradient or +// webscene-mask-svg-v1 tiled SVG resource to the current isolated layer using +// destination-in before kind 31 restores it. webscene-mask-invalid-v1 clears +// the isolated layer for failed or unsupported authored masks. typedef struct webscene_scene_command { uint32_t kind; uint32_t flags; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc index c37ba2407..6b70b116c 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc @@ -604,11 +604,21 @@ void test_svg_background_image_reaches_scene_with_position_and_size(webscene_eng background-position: 0 2px; background-size: 48px auto; } + .svg-mask { + width: 100px; + height: 60px; + background: rgb(20, 80, 160); + -webkit-mask: url("badge.svg") no-repeat 4px 6px / 48px auto; + mask: url("badge.svg") no-repeat 4px 6px / 48px auto; + } `; document.body.appendChild(style); const target = document.createElement('div'); target.className = 'svg-background'; document.body.appendChild(target); + const masked = document.createElement('div'); + masked.className = 'svg-mask'; + document.body.appendChild(masked); })() )JS", "native-svg-background-setup.js"); require( @@ -626,6 +636,7 @@ void test_svg_background_image_reaches_scene_with_position_and_size(webscene_eng webscene_engine_request_scene_checkpoint(engine); auto found = false; + auto found_mask = false; for (auto attempt = 0; attempt < 100; ++attempt) { const auto* scene = webscene_engine_acquire_latest_scene(engine); if (scene != nullptr) { @@ -645,13 +656,25 @@ void test_svg_background_image_reaches_scene_with_position_and_size(webscene_eng && bytes.find("\tno-repeat\t0 2px\t48px auto\t") != std::string_view::npos && bytes.find("string_count) { + const auto resource = scene->strings[command.flags]; + const std::string_view bytes( + scene->string_bytes + resource.byte_offset, + resource.byte_length); + found_mask = bytes.starts_with( + "webscene-mask-svg-v1\t0 0 2 1\t") + && bytes.find("\tno-repeat\t4px 6px\t48px auto\t") + != std::string_view::npos + && bytes.find(" 1 ? ResolveDomBackgroundLength(tokens[1], height, height) - : tileWidth; + : height; } private static void ResolveDomBackgroundPosition( @@ -2320,7 +2334,8 @@ private void DrawDomSvg( in DomCornerRadii radii) { var resource = DomStringAt(view, command.Flags); - if (TryDecodeDomSvgBackgroundResource(resource, out var background)) + if (TryDecodeDomSvgTiledResource( + resource, "webscene-bg-svg-v1\t", out var background)) { DrawDomSvgBackground(canvas, background, command, radii); return; @@ -2384,17 +2399,31 @@ internal void DrawDomSvgBackgroundForTest( string resource, in SceneCommand command) { - if (TryDecodeDomSvgBackgroundResource(resource, out var background)) + if (TryDecodeDomSvgTiledResource( + resource, "webscene-bg-svg-v1\t", out var background)) { DrawDomSvgBackground(canvas, background, command, default); } } - private static bool TryDecodeDomSvgBackgroundResource( + internal void DrawDomSvgMaskForTest( + SKCanvas canvas, + string resource, + in SceneCommand command) + { + if (TryDecodeDomSvgTiledResource( + resource, "webscene-mask-svg-v1\t", out var mask)) + { + DrawDomSvgBackground( + canvas, mask, command, default, SKBlendMode.DstIn); + } + } + + private static bool TryDecodeDomSvgTiledResource( string value, + string prefix, out DomSvgBackgroundResource resource) { - const string prefix = "webscene-bg-svg-v1\t"; resource = default; if (!value.StartsWith(prefix, StringComparison.Ordinal)) { @@ -2418,7 +2447,8 @@ private void DrawDomSvgBackground( SKCanvas canvas, in DomSvgBackgroundResource resource, in SceneCommand command, - in DomCornerRadii radii) + in DomCornerRadii radii, + SKBlendMode blendMode = SKBlendMode.SrcOver) { var viewBox = ParseSvgNumbers(resource.ViewBox); if (viewBox.Length < 4 @@ -2427,6 +2457,11 @@ private void DrawDomSvgBackground( || command.Width <= 0 || command.Height <= 0) { + if (blendMode == SKBlendMode.DstIn) + { + ClearDomMaskRect(canvas, command.X, command.Y, + command.Width, command.Height); + } return; } if (!s_svgPictures.TryGetValue(resource.Markup, out var svg)) @@ -2434,6 +2469,11 @@ private void DrawDomSvgBackground( var acquired = SharedSvgPictureCache.Acquire(resource.Markup); if (acquired is null) { + if (blendMode == SKBlendMode.DstIn) + { + ClearDomMaskRect(canvas, command.X, command.Y, + command.Width, command.Height); + } return; } svg = acquired; @@ -2450,6 +2490,11 @@ private void DrawDomSvgBackground( out var tileHeight); if (tileWidth <= 0 || tileHeight <= 0) { + if (blendMode == SKBlendMode.DstIn) + { + ClearDomMaskRect(canvas, command.X, command.Y, + command.Width, command.Height); + } return; } ResolveDomBackgroundPosition( @@ -2478,10 +2523,27 @@ private void DrawDomSvgBackground( while (firstY + tileHeight <= command.Y) firstY += tileHeight; } + if (blendMode == SKBlendMode.DstIn) + { + ClearDomMaskOutsideCoverage(canvas, command, + repeatX, repeatY, firstX, firstY, tileWidth, tileHeight); + } + + using var blendPaint = new SKPaint { BlendMode = blendMode }; var restore = canvas.Save(); try { ClipDomBackground(canvas, command, radii); + if (blendMode != SKBlendMode.SrcOver) + { + canvas.SaveLayer( + new SKRect( + command.X, + command.Y, + command.X + command.Width, + command.Y + command.Height), + blendPaint); + } var endX = repeatX ? command.X + command.Width : firstX + tileWidth; var endY = repeatY ? command.Y + command.Height : firstY + tileHeight; for (var y = firstY; y < endY; y += tileHeight) diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index 3721856aa..259cc8fc7 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -47,6 +47,7 @@ final class SceneApplyResult { final class WebSceneSceneProjector extends ChangeNotifier { final Map _layers = {}; final Map _svgPictures = {}; + final Map _svgMaskGeometry = {}; final List<_DomSvgPlacement> _domSvgPlacements = []; ui.Picture? _backdrop; ui.Picture? _overlay; @@ -203,6 +204,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { _backdrop = null; _overlay = null; _domSvgPlacements.clear(); + _svgMaskGeometry.clear(); for (final layer in _layers.values) { layer.dispose(); } @@ -250,7 +252,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { case 31: canvas.restore(); case 47: - _drawDomLinearMask(canvas, scene, command); + _drawDomMask(canvas, scene, command); case 15: canvas ..save() @@ -971,6 +973,23 @@ final class WebSceneSceneProjector extends ChangeNotifier { } } + void _drawDomMask( + ui.Canvas canvas, + WebSceneSceneView scene, + WebSceneSceneCommand command, + ) { + final resource = _domString(scene, command.flags); + if (resource.startsWith('webscene-mask-svg-v1\t')) { + _drawDomSvgMask(canvas, command, resource); + return; + } + if (!resource.startsWith('webscene-bg-v2\t')) { + _clearDomMaskBounds(canvas, command); + return; + } + _drawDomLinearMask(canvas, scene, command); + } + static void _drawDomLinearMask( ui.Canvas canvas, WebSceneSceneView scene, @@ -1134,6 +1153,337 @@ final class WebSceneSceneProjector extends ChangeNotifier { } } + void _drawDomSvgMask( + ui.Canvas canvas, + WebSceneSceneCommand command, + String resource, + ) { + const prefix = 'webscene-mask-svg-v1\t'; + final fields = resource.substring(prefix.length).split('\t'); + if (fields.length < 5) { + _clearDomMaskBounds(canvas, command); + return; + } + final viewBoxValues = _numbers(fields[0]); + if (viewBoxValues.length < 4 || + viewBoxValues[2] <= 0 || + viewBoxValues[3] <= 0 || + command.width <= 0 || + command.height <= 0) { + _clearDomMaskBounds(canvas, command); + return; + } + final markup = fields.sublist(4).join('\t'); + final geometry = _svgMaskGeometry.putIfAbsent( + markup, + () => _parseSvgMaskGeometry(markup), + ); + if (geometry == null) { + _clearDomMaskBounds(canvas, command); + return; + } + final viewBox = ui.Rect.fromLTWH( + viewBoxValues[0], + viewBoxValues[1], + viewBoxValues[2], + viewBoxValues[3], + ); + final repeat = fields[1].trim().toLowerCase(); + final position = fields[2].trim(); + final size = fields[3].trim(); + final resolvedSize = _resolveSvgMaskSize( + size, + command.width, + command.height, + viewBox.width, + viewBox.height, + ); + final tileWidth = resolvedSize.$1; + final tileHeight = resolvedSize.$2; + if (tileWidth <= 0 || tileHeight <= 0) { + _clearDomMaskBounds(canvas, command); + return; + } + final resolvedPosition = _resolveMaskPosition( + position, + command.width, + command.height, + tileWidth, + tileHeight, + ); + var firstX = command.x + resolvedPosition.$1; + var firstY = command.y + resolvedPosition.$2; + final repeatX = repeat != 'no-repeat' && repeat != 'repeat-y'; + final repeatY = repeat != 'no-repeat' && repeat != 'repeat-x'; + if (repeatX) { + while (firstX > command.x) firstX -= tileWidth; + while (firstX + tileWidth <= command.x) firstX += tileWidth; + } + if (repeatY) { + while (firstY > command.y) firstY -= tileHeight; + while (firstY + tileHeight <= command.y) firstY += tileHeight; + } + final bounds = ui.Rect.fromLTWH( + command.x, + command.y, + command.width, + command.height, + ); + canvas.save(); + try { + canvas.clipRect(bounds, doAntiAlias: false); + _clearDomMaskOutsideCoverage( + canvas, + bounds, + repeatX, + repeatY, + firstX, + firstY, + tileWidth, + tileHeight, + ); + final endX = repeatX ? bounds.right : firstX + tileWidth; + final endY = repeatY ? bounds.bottom : firstY + tileHeight; + canvas.saveLayer(bounds, ui.Paint()..blendMode = ui.BlendMode.dstIn); + try { + for (var y = firstY; y < endY; y += tileHeight) { + for (var x = firstX; x < endX; x += tileWidth) { + _drawSvgMaskTile( + canvas, + geometry, + viewBox, + ui.Rect.fromLTWH(x, y, tileWidth, tileHeight), + ); + if (!repeatX) break; + } + if (!repeatY) break; + } + } finally { + canvas.restore(); + } + } finally { + canvas.restore(); + } + } + + static void _drawSvgMaskTile( + ui.Canvas canvas, + _SvgMaskGeometry geometry, + ui.Rect viewBox, + ui.Rect tile, + ) { + final scale = math.min( + tile.width / viewBox.width, + tile.height / viewBox.height, + ); + final renderedWidth = viewBox.width * scale; + final renderedHeight = viewBox.height * scale; + final offsetX = tile.left + (tile.width - renderedWidth) / 2; + final offsetY = tile.top + (tile.height - renderedHeight) / 2; + canvas.save(); + try { + canvas.clipRect(tile, doAntiAlias: false); + canvas + ..translate(offsetX, offsetY) + ..scale(scale, scale) + ..translate(-viewBox.left, -viewBox.top); + for (final shape in geometry.shapes) { + canvas.drawPath( + shape.$1, + ui.Paint() + ..isAntiAlias = true + ..color = ui.Color.fromARGB( + (shape.$2.clamp(0.0, 1.0) * 255).round(), + 255, + 255, + 255, + ), + ); + } + } finally { + canvas.restore(); + } + } + + static _SvgMaskGeometry? _parseSvgMaskGeometry(String markup) { + if (RegExp( + r'<(?:defs|g|use|image|text|line|polyline)\b', + caseSensitive: false, + ).hasMatch(markup) || RegExp( + r'<(?:g|path|rect|circle|ellipse|polygon)\b[^>]*\btransform\s*=', + caseSensitive: false, + ).hasMatch(markup)) { + return null; + } + final hiddenClasses = {}; + for (final match in RegExp( + r'\.([_a-zA-Z][_a-zA-Z0-9-]*)\s*\{[^}]*\bfill\s*:\s*none\b[^}]*\}', + caseSensitive: false, + ).allMatches(markup)) { + hiddenClasses.add(match.group(1)!); + } + String? attribute(String source, String name) { + final match = RegExp( + "${RegExp.escape(name)}\\s*=\\s*([\"'])(.*?)\\1", + caseSensitive: false, + dotAll: true, + ).firstMatch(source); + return match?.group(2); + } + String? declaration(String source, String name) { + final style = attribute(source, 'style'); + if (style == null) return null; + return RegExp( + '(?:^|;)\\s*${RegExp.escape(name)}\\s*:\\s*([^;]+)', + caseSensitive: false, + ).firstMatch(style)?.group(1)?.trim(); + } + String? property(String source, String name) => + attribute(source, name) ?? declaration(source, name); + double number(String? value, [double fallback = 0]) => + value == null ? fallback : double.tryParse(value) ?? fallback; + final root = RegExp( + r']*)>', + caseSensitive: false, + dotAll: true, + ).firstMatch(markup); + final rootFill = root == null + ? null + : property(root.group(1)!, 'fill')?.trim().toLowerCase(); + bool hidden(String attributes) { + final fill = property(attributes, 'fill')?.trim().toLowerCase(); + if (fill == 'none' || (fill == null && rootFill == 'none')) { + return true; + } + final classes = (attribute(attributes, 'class') ?? '').split(RegExp(r'\s+')); + return classes.any(hiddenClasses.contains); + } + double alpha(String attributes) { + final value = property(attributes, 'fill-opacity') + ?? property(attributes, 'opacity'); + if (value == null) return 1; + final normalized = value.trim(); + if (normalized.endsWith('%')) { + return (double.tryParse( + normalized.substring(0, normalized.length - 1), + ) ?? 100) / 100; + } + return double.tryParse(normalized) ?? 1; + } + + final shapes = <(ui.Path, double)>[]; + final elements = RegExp( + r'<(path|rect|circle|ellipse|polygon)\b([^>]*)>', + caseSensitive: false, + dotAll: true, + ).allMatches(markup); + try { + for (final element in elements) { + final tag = element.group(1)!.toLowerCase(); + final attributes = element.group(2)!; + if (hidden(attributes)) continue; + if (attribute(attributes, 'transform') != null) return null; + final stroke = property(attributes, 'stroke')?.trim().toLowerCase(); + if (stroke != null && stroke != 'none') return null; + final fillRule = property(attributes, 'fill-rule')?.trim().toLowerCase(); + if (fillRule != null && fillRule != 'nonzero') return null; + final path = ui.Path(); + switch (tag) { + case 'path': + final data = attribute(attributes, 'd'); + if (data == null || data.trim().isEmpty) continue; + path.addPath(parseSvgPathData(data), ui.Offset.zero); + case 'rect': + final rect = ui.Rect.fromLTWH( + number(attribute(attributes, 'x')), + number(attribute(attributes, 'y')), + number(attribute(attributes, 'width')), + number(attribute(attributes, 'height')), + ); + final rx = number(attribute(attributes, 'rx')); + final ry = number(attribute(attributes, 'ry'), rx); + if (rx > 0 || ry > 0) { + path.addRRect(ui.RRect.fromRectXY(rect, rx, ry)); + } else { + path.addRect(rect); + } + case 'circle': + final radius = number(attribute(attributes, 'r')); + path.addOval(ui.Rect.fromCircle( + center: ui.Offset( + number(attribute(attributes, 'cx')), + number(attribute(attributes, 'cy')), + ), + radius: radius, + )); + case 'ellipse': + final cx = number(attribute(attributes, 'cx')); + final cy = number(attribute(attributes, 'cy')); + final rx = number(attribute(attributes, 'rx')); + final ry = number(attribute(attributes, 'ry')); + path.addOval(ui.Rect.fromLTRB(cx - rx, cy - ry, cx + rx, cy + ry)); + case 'polygon': + final points = _numbers(attribute(attributes, 'points') ?? ''); + if (points.length < 4 || points.length.isOdd) continue; + path.moveTo(points[0], points[1]); + for (var index = 2; index < points.length; index += 2) { + path.lineTo(points[index], points[index + 1]); + } + path.close(); + } + shapes.add((path, alpha(attributes))); + } + } catch (_) { + return null; + } + return shapes.isEmpty ? null : _SvgMaskGeometry(shapes); + } + + static (double, double) _resolveSvgMaskSize( + String value, + double width, + double height, + double intrinsicWidth, + double intrinsicHeight, + ) { + final tokens = _splitCssTopLevel(value.trim(), ' ') + .where((token) => token.isNotEmpty) + .toList(); + final first = tokens.isEmpty ? 'auto' : tokens[0].toLowerCase(); + final second = tokens.length > 1 ? tokens[1].toLowerCase() : 'auto'; + if (first == 'cover' || first == 'contain') { + final scaleX = width / intrinsicWidth; + final scaleY = height / intrinsicHeight; + final scale = first == 'cover' + ? math.max(scaleX, scaleY) + : math.min(scaleX, scaleY); + return (intrinsicWidth * scale, intrinsicHeight * scale); + } + double? resolve(String token, double available) { + if (token == 'auto') return null; + if (token.endsWith('%')) { + final percentage = double.tryParse(token.substring(0, token.length - 1)); + return percentage == null ? null : available * percentage / 100; + } + if (token.endsWith('px')) { + return double.tryParse(token.substring(0, token.length - 2)); + } + return double.tryParse(token); + } + final resolvedWidth = resolve(first, width); + final resolvedHeight = resolve(second, height); + if (resolvedWidth != null && resolvedHeight != null) { + return (resolvedWidth, resolvedHeight); + } + if (resolvedWidth != null) { + return (resolvedWidth, intrinsicHeight * resolvedWidth / intrinsicWidth); + } + if (resolvedHeight != null) { + return (intrinsicWidth * resolvedHeight / intrinsicHeight, resolvedHeight); + } + return (intrinsicWidth, intrinsicHeight); + } + static void _clearDomMaskBounds( ui.Canvas canvas, WebSceneSceneCommand command, @@ -1311,7 +1661,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { .where((token) => token.isNotEmpty) .toList(); final first = tokens.isEmpty ? 'auto' : tokens[0]; - final second = tokens.length > 1 ? tokens[1] : first; + final second = tokens.length > 1 ? tokens[1] : 'auto'; double resolve(String token, double available) { if (token == 'auto') return available; final calc = RegExp( @@ -1557,6 +1907,12 @@ final class WebSceneSceneProjector extends ChangeNotifier { }; } +final class _SvgMaskGeometry { + const _SvgMaskGeometry(this.shapes); + + final List<(ui.Path, double)> shapes; +} + final class _RetainedLayer { const _RetainedLayer({ required this.nodeId, diff --git a/tests/WebScene.Backend.Avalonia.Tests/SvgPictureRenderingTests.cs b/tests/WebScene.Backend.Avalonia.Tests/SvgPictureRenderingTests.cs index 78b879ca1..641d9f281 100644 --- a/tests/WebScene.Backend.Avalonia.Tests/SvgPictureRenderingTests.cs +++ b/tests/WebScene.Backend.Avalonia.Tests/SvgPictureRenderingTests.cs @@ -104,6 +104,35 @@ public void TradingViewOpacityPatternRepeatsAcrossTheEntireSwatch() Assert.Equal(firstDarkSquare, bitmap.GetPixel(20, 20)); } + [Fact] + public void RepeatedSvgMaskUnionsTilesBeforeDestinationInComposition() + { + const string markup = """ + + + + """; + const string resource = + "webscene-mask-svg-v1\t0 0 4 4\trepeat\t0% 0%\t4px 4px\t" + + markup; + using var bitmap = new SKBitmap( + 12, 4, SKColorType.Bgra8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + var foreground = new SKColor(40, 120, 220); + canvas.Clear(foreground); + + new NativeCanvasSceneRenderer().DrawDomSvgMaskForTest( + canvas, resource, new SceneCommand { Width = 12, Height = 4 }); + canvas.Flush(); + + Assert.Equal(foreground, bitmap.GetPixel(1, 2)); + Assert.Equal(SKColors.Transparent, bitmap.GetPixel(3, 2)); + Assert.Equal(foreground, bitmap.GetPixel(5, 2)); + Assert.Equal(SKColors.Transparent, bitmap.GetPixel(7, 2)); + Assert.Equal(foreground, bitmap.GetPixel(9, 2)); + Assert.Equal(SKColors.Transparent, bitmap.GetPixel(11, 2)); + } + [Theory] [InlineData("#9de640", 157, 230, 64)] [InlineData("#d19afc", 209, 154, 252)]