From 45038f7d9f463b41d8f6abe3066b43ecb84e2d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 19 Sep 2026 00:40:34 +0200 Subject: [PATCH] Render linear gradient CSS masks --- .../native/webscene_native_dom_scene.inc | 94 +++++ .../native/webscene_native_engine.h | 3 + .../webscene_native_engine_scene_utils.inc | 2 +- .../tests/native_css_effect_values_tests.cpp | 6 + .../NativeCanvasSceneRenderer.cs | 116 ++++- .../lib/src/scene_projector.dart | 399 +++++++++++++++++- 6 files changed, 612 insertions(+), 8 deletions(-) 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 059e4652..e3bc075e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -467,6 +467,69 @@ void native_document::append_scene( } return result; }(); + const auto linear_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; + } + } + if (close != image.size() - 1U) return std::nullopt; + 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; + const auto mode = value("mask-mode", "match-source"); + if (mode != "match-source" && mode != "alpha") return std::nullopt; + 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 (repeat != "repeat" && repeat != "no-repeat" + && repeat != "repeat-x" && repeat != "repeat-y") { + return std::nullopt; + } + 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; + } + 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); + }(); const auto has_opacity_group = opacity < 0.999F; if (has_opacity_group) { commands.push_back(webscene_scene_command{ @@ -482,6 +545,17 @@ void native_document::append_scene( 255L)), node.id}); } + if (linear_mask_resource.has_value()) { + commands.push_back(webscene_scene_command{ + 30U, + 1U << 26U, + node.layout.x, + node.layout.y, + node.layout.width, + node.layout.height, + 255U, + node.id}); + } // SaveLayer effects apply when their groups restore. Open in reverse CSS // order so restoration applies the authored function list left-to-right. for (auto filter = foreground_filters.rbegin(); @@ -2142,6 +2216,16 @@ 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()) { + commands.push_back(webscene_scene_command{ + 47U, *linear_mask_resource, + node.layout.x, node.layout.y, + node.layout.width, node.layout.height, + 0U, node.id}); + commands.push_back(webscene_scene_command{ + 31U, 0U, node.layout.x, node.layout.y, + node.layout.width, node.layout.height, 0U, node.id}); + } if (has_opacity_group) { commands.push_back(webscene_scene_command{ 31U, 0U, node.layout.x, node.layout.y, @@ -2500,6 +2584,16 @@ 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()) { + commands.push_back(webscene_scene_command{ + 47U, *linear_mask_resource, + node.layout.x, node.layout.y, + node.layout.width, node.layout.height, + 0U, node.id}); + commands.push_back(webscene_scene_command{ + 31U, 0U, node.layout.x, node.layout.y, + node.layout.width, node.layout.height, 0U, node.id}); + } if (has_opacity_group) { commands.push_back(webscene_scene_command{ 31U, 0U, node.layout.x, node.layout.y, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index aa74aff0..922036e3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -157,9 +157,12 @@ 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. // 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. typedef struct webscene_scene_command { uint32_t kind; uint32_t flags; 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 039c7af1..6377f380 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,7 +79,7 @@ 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; + return (command.kind >= 3U && command.kind <= 6U) || command.kind == 47U; } std::string_view command_dom_string( 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 df79dd67..5d404adc 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 @@ -57,6 +57,7 @@ struct clip_scene_counts final { uint32_t clipped_fills{}; uint32_t blur_filter_begins{}; uint32_t functional_blur_begins{}; + uint32_t linear_mask_commands{}; uint32_t command_count{}; bool transform_clip_nested{}; bool compound_filter_ordered{}; @@ -151,6 +152,8 @@ clip_scene_counts wait_for_inset_clip_scene( && (command.flags & (1U << 28U)) != 0U && std::abs(command.stroke_width - 4.0F) < 0.01F) { ++latest.functional_blur_begins; + } else if (command.kind == 47U) { + ++latest.linear_mask_commands; } } webscene_scene_acknowledge_v3(lease); @@ -382,6 +385,8 @@ int main() "retained scene did not preserve compound foreground filter order"); require(initial_clip_scene.functional_blur_begins == 1U, "retained scene did not resolve the functional blur radius"); + require(initial_clip_scene.linear_mask_commands == 4096U, + "retained scene did not emit all linear-gradient mask commands"); const auto initial_scene_command_bytes = static_cast(initial_clip_scene.command_count) * sizeof(webscene_scene_command); @@ -514,6 +519,7 @@ int main() << " 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 + << " linear-mask-commands=" << initial_clip_scene.linear_mask_commands << " compound-filter-ordered=" << initial_clip_scene.compound_filter_ordered << " transform-clip-nested=" << initial_clip_scene.transform_clip_nested << " initial-scene-command-bytes=" << initial_scene_command_bytes diff --git a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs index 86fe6399..abcb2d08 100644 --- a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs +++ b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs @@ -49,9 +49,11 @@ internal sealed unsafe partial class NativeCanvasSceneRenderer private const uint DomContrastFilter = 1u << 29; private const uint DomBlurFilter = 1u << 28; private const uint DomSaturateFilter = 1u << 27; + private const uint DomLinearMaskGroup = 1u << 26; private const uint DomColorFilterMask = DomBrightnessFilter | DomGrayscaleFilter | DomContrastFilter | DomSaturateFilter; - private const uint DomEffectFilterMask = DomColorFilterMask | DomBlurFilter; + private const uint DomEffectFilterMask = + DomColorFilterMask | DomBlurFilter | DomLinearMaskGroup; private readonly Dictionary s_layers = new(); private readonly List s_orderedLayers = []; @@ -661,6 +663,10 @@ private static bool ValidateLayer(NativeSceneView* view, in NativeCanvasLayer la backdrop.Restore(); overlay.Restore(); break; + case 47: + DrawDomLinearMask(backdrop, view, command); + DrawDomLinearMask(overlay, view, command); + break; case 15: ApplyScale(backdrop, command); ApplyScale(overlay, command); @@ -856,6 +862,11 @@ private static void SaveDomGroup( canvas.SaveLayer(paint); return; } + if ((command.Flags & DomLinearMaskGroup) != 0) + { + canvas.SaveLayer(paint); + return; + } var amount = Math.Max(0, command.StrokeWidth); if ((command.Flags & DomBlurFilter) != 0) { @@ -1207,6 +1218,19 @@ private static void DrawDomLinearGradient( radii); } + private static void DrawDomLinearMask( + SKCanvas canvas, + NativeSceneView* view, + in SceneCommand command) + { + DrawDomBackgroundLayers( + canvas, + DomStringAt(view, command.Flags), + command, + default, + SKBlendMode.DstIn); + } + internal static void DrawDomBackgroundForTest( SKCanvas canvas, string resource, @@ -1217,7 +1241,8 @@ private static void DrawDomBackgroundLayers( SKCanvas canvas, string value, in SceneCommand command, - in DomCornerRadii radii) + in DomCornerRadii radii, + SKBlendMode blendMode = SKBlendMode.SrcOver) { var resource = DecodeDomBackgroundResource(value); var layers = SplitTopLevel(resource.Image, ','); @@ -1246,7 +1271,8 @@ private static void DrawDomBackgroundLayers( var repeat = LayerValue(repeats, layerIndex, "repeat"); var position = LayerValue(positions, layerIndex, "0% 0%"); var size = LayerValue(sizes, layerIndex, "auto"); - DrawDomGradientTiles(canvas, layer, command, repeat, position, size); + DrawDomGradientTiles( + canvas, layer, command, repeat, position, size, blendMode); } } finally @@ -1307,11 +1333,20 @@ private static void DrawDomGradientTiles( in SceneCommand command, string repeatValue, string positionValue, - string sizeValue) + string sizeValue, + SKBlendMode blendMode = SKBlendMode.SrcOver) { ResolveDomBackgroundSize(sizeValue, command.Width, command.Height, out var tileWidth, out var tileHeight); - if (tileWidth <= 0 || tileHeight <= 0) return; + if (tileWidth <= 0 || tileHeight <= 0) + { + if (blendMode == SKBlendMode.DstIn) + { + ClearDomMaskRect(canvas, command.X, command.Y, + command.Width, command.Height); + } + return; + } ResolveDomBackgroundPosition(positionValue, command.Width, command.Height, tileWidth, tileHeight, out var offsetX, out var offsetY); @@ -1335,6 +1370,12 @@ private static void DrawDomGradientTiles( var endX = repeatX ? command.X + command.Width : firstX + tileWidth; var endY = repeatY ? command.Y + command.Height : firstY + tileHeight; + if (blendMode == SKBlendMode.DstIn) + { + ClearDomMaskOutsideCoverage(canvas, command, + repeatX, repeatY, firstX, firstY, tileWidth, tileHeight); + } + var drewTile = false; for (var y = firstY; y < endY; y += tileHeight) { for (var x = firstX; x < endX; x += tileWidth) @@ -1360,13 +1401,64 @@ private static void DrawDomGradientTiles( { IsAntialias = false, Style = SKPaintStyle.Fill, - Shader = shader + Shader = shader, + BlendMode = blendMode }; canvas.DrawRect(x, y, tileWidth, tileHeight, paint); + drewTile = true; if (!repeatX) break; } if (!repeatY) break; } + if (blendMode == SKBlendMode.DstIn && !drewTile) + { + ClearDomMaskRect(canvas, command.X, command.Y, + command.Width, command.Height); + } + } + + private static void ClearDomMaskOutsideCoverage( + SKCanvas canvas, + in SceneCommand command, + bool repeatX, + bool repeatY, + float firstX, + float firstY, + float tileWidth, + float tileHeight) + { + var left = command.X; + var top = command.Y; + var right = command.X + command.Width; + var bottom = command.Y + command.Height; + var coverageLeft = repeatX ? left : Math.Clamp(firstX, left, right); + var coverageRight = repeatX ? right : Math.Clamp(firstX + tileWidth, left, right); + var coverageTop = repeatY ? top : Math.Clamp(firstY, top, bottom); + var coverageBottom = repeatY ? bottom : Math.Clamp(firstY + tileHeight, top, bottom); + if (coverageRight <= coverageLeft || coverageBottom <= coverageTop) + { + ClearDomMaskRect(canvas, left, top, command.Width, command.Height); + return; + } + ClearDomMaskRect(canvas, left, top, command.Width, coverageTop - top); + ClearDomMaskRect(canvas, left, coverageBottom, + command.Width, bottom - coverageBottom); + ClearDomMaskRect(canvas, left, coverageTop, + coverageLeft - left, coverageBottom - coverageTop); + ClearDomMaskRect(canvas, coverageRight, coverageTop, + right - coverageRight, coverageBottom - coverageTop); + } + + private static void ClearDomMaskRect( + SKCanvas canvas, + float x, + float y, + float width, + float height) + { + if (width <= 0 || height <= 0) return; + using var clear = new SKPaint { BlendMode = SKBlendMode.Clear }; + canvas.DrawRect(x, y, width, height, clear); } private static void ExpandPremultipliedGradientStops( @@ -1494,6 +1586,18 @@ private static float ResolveDomBackgroundLength( { var normalized = value.Trim().ToLowerInvariant(); if (normalized == "auto") return fallback; + var calc = System.Text.RegularExpressions.Regex.Match( + normalized, + @"^calc\(\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))%\s*([-+])\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))px\s*\)$"); + if (calc.Success + && float.TryParse(calc.Groups[1].Value, NumberStyles.Float, + CultureInfo.InvariantCulture, out var calcPercent) + && float.TryParse(calc.Groups[3].Value, NumberStyles.Float, + CultureInfo.InvariantCulture, out var calcPixels)) + { + var sign = calc.Groups[2].Value == "-" ? -1f : 1f; + return percentageBasis * calcPercent / 100f + sign * calcPixels; + } if (normalized.EndsWith('%') && float.TryParse(normalized[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percentage)) diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index 422e52c8..3721856a 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; @@ -24,10 +25,12 @@ const int _domGrayscaleFilter = 1 << 30; const int _domContrastFilter = 1 << 29; const int _domBlurFilter = 1 << 28; const int _domSaturateFilter = 1 << 27; +const int _domLinearMaskGroup = 1 << 26; const int _domColorFilterMask = _domBrightnessFilter | _domGrayscaleFilter | _domContrastFilter | _domSaturateFilter; -const int _domEffectFilterMask = _domColorFilterMask | _domBlurFilter; +const int _domEffectFilterMask = + _domColorFilterMask | _domBlurFilter | _domLinearMaskGroup; final class SceneApplyResult { const SceneApplyResult({ @@ -246,6 +249,8 @@ final class WebSceneSceneProjector extends ChangeNotifier { canvas.saveLayer(null, _domGroupPaint(command)); case 31: canvas.restore(); + case 47: + _drawDomLinearMask(canvas, scene, command); case 15: canvas ..save() @@ -966,11 +971,403 @@ final class WebSceneSceneProjector extends ChangeNotifier { } } + static void _drawDomLinearMask( + ui.Canvas canvas, + WebSceneSceneView scene, + WebSceneSceneCommand command, + ) { + const prefix = 'webscene-bg-v2\t'; + final resource = _domString(scene, command.flags); + if (!resource.startsWith(prefix)) return; + final fields = resource.substring(prefix.length).split('\t'); + if (fields.isEmpty) { + _clearDomMaskBounds(canvas, command); + return; + } + final image = fields[0].trim(); + final match = RegExp( + r'^linear-gradient\(([\s\S]*)\)$', + caseSensitive: false, + ).firstMatch(image); + if (match == null) { + _clearDomMaskBounds(canvas, command); + return; + } + final components = _splitCssTopLevel(match.group(1)!, ','); + if (components.length < 2) { + _clearDomMaskBounds(canvas, command); + return; + } + + var directionX = 0.0; + var directionY = 1.0; + var stopStart = 0; + final direction = components.first.trim().toLowerCase(); + if (direction.startsWith('to ')) { + directionX = direction.contains('right') + ? 1 + : direction.contains('left') ? -1 : 0; + directionY = direction.contains('bottom') + ? 1 + : direction.contains('top') ? -1 : 0; + final length = math.sqrt( + directionX * directionX + directionY * directionY, + ); + if (length <= 0) { + _clearDomMaskBounds(canvas, command); + return; + } + directionX /= length; + directionY /= length; + stopStart = 1; + } else { + final angle = RegExp( + r'^([-+]?(?:\d+(?:\.\d*)?|\.\d+))(deg|turn)$', + ).firstMatch(direction); + if (angle != null) { + var degrees = double.parse(angle.group(1)!); + if (angle.group(2) == 'turn') degrees *= 360; + final radians = degrees * math.pi / 180; + directionX = math.sin(radians); + directionY = -math.cos(radians); + stopStart = 1; + } + } + + final repeat = fields.length > 1 ? fields[1].trim().toLowerCase() : 'repeat'; + final position = fields.length > 2 ? fields[2].trim() : '0% 0%'; + final size = fields.length > 3 ? fields[3].trim() : 'auto'; + final resolvedSize = _resolveMaskSize(size, command.width, command.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 endX = repeatX ? command.x + command.width : firstX + tileWidth; + final endY = repeatY ? command.y + command.height : firstY + tileHeight; + final axisLength = math.max( + 1.0, + directionX.abs() * tileWidth + directionY.abs() * tileHeight, + ); + final colors = []; + final stops = []; + for (var index = stopStart; index < components.length; index++) { + final parsed = _parseMaskStop(components[index], axisLength); + if (parsed == null) continue; + for (final offset in parsed.$2) { + colors.add(parsed.$1); + stops.add(offset); + } + } + if (colors.length < 2) { + _clearDomMaskBounds(canvas, command); + return; + } + _fillMissingGradientStops(stops); + final centerX = tileWidth / 2; + final centerY = tileHeight / 2; + final halfProjection = ( + directionX.abs() * tileWidth + directionY.abs() * tileHeight + ) / 2; + canvas.save(); + try { + final bounds = ui.Rect.fromLTWH( + command.x, + command.y, + command.width, + command.height, + ); + canvas.clipRect(bounds, doAntiAlias: false); + _clearDomMaskOutsideCoverage( + canvas, + bounds, + repeatX, + repeatY, + firstX, + firstY, + tileWidth, + tileHeight, + ); + for (var y = firstY; y < endY; y += tileHeight) { + for (var x = firstX; x < endX; x += tileWidth) { + final start = ui.Offset( + x + centerX - directionX * halfProjection, + y + centerY - directionY * halfProjection, + ); + final end = ui.Offset( + x + centerX + directionX * halfProjection, + y + centerY + directionY * halfProjection, + ); + canvas.drawRect( + ui.Rect.fromLTWH(x, y, tileWidth, tileHeight), + ui.Paint() + ..isAntiAlias = false + ..blendMode = ui.BlendMode.dstIn + ..shader = ui.Gradient.linear(start, end, colors, stops), + ); + if (!repeatX) break; + } + if (!repeatY) break; + } + } finally { + canvas.restore(); + } + } + + static void _clearDomMaskBounds( + ui.Canvas canvas, + WebSceneSceneCommand command, + ) { + canvas.save(); + try { + final bounds = ui.Rect.fromLTWH( + command.x, + command.y, + command.width, + command.height, + ); + canvas.clipRect(bounds, doAntiAlias: false); + canvas.drawRect(bounds, ui.Paint()..blendMode = ui.BlendMode.clear); + } finally { + canvas.restore(); + } + } + + static void _clearDomMaskOutsideCoverage( + ui.Canvas canvas, + ui.Rect bounds, + bool repeatX, + bool repeatY, + double firstX, + double firstY, + double tileWidth, + double tileHeight, + ) { + final coverageLeft = repeatX + ? bounds.left + : firstX.clamp(bounds.left, bounds.right).toDouble(); + final coverageRight = repeatX + ? bounds.right + : (firstX + tileWidth).clamp(bounds.left, bounds.right).toDouble(); + final coverageTop = repeatY + ? bounds.top + : firstY.clamp(bounds.top, bounds.bottom).toDouble(); + final coverageBottom = repeatY + ? bounds.bottom + : (firstY + tileHeight).clamp(bounds.top, bounds.bottom).toDouble(); + final clear = ui.Paint()..blendMode = ui.BlendMode.clear; + if (coverageRight <= coverageLeft || coverageBottom <= coverageTop) { + canvas.drawRect(bounds, clear); + return; + } + canvas.drawRect( + ui.Rect.fromLTRB(bounds.left, bounds.top, bounds.right, coverageTop), + clear, + ); + canvas.drawRect( + ui.Rect.fromLTRB(bounds.left, coverageBottom, bounds.right, bounds.bottom), + clear, + ); + canvas.drawRect( + ui.Rect.fromLTRB(bounds.left, coverageTop, coverageLeft, coverageBottom), + clear, + ); + canvas.drawRect( + ui.Rect.fromLTRB(coverageRight, coverageTop, bounds.right, coverageBottom), + clear, + ); + } + + static List _splitCssTopLevel(String value, String separator) { + final result = []; + var start = 0; + var depth = 0; + String? quote; + for (var index = 0; index <= value.length; index++) { + final character = index < value.length ? value[index] : separator; + if (quote != null) { + if (character == '\\') index++; + else if (character == quote) quote = null; + } else if (character == '"' || character == "'") { + quote = character; + } else if (character == '(') { + depth++; + } else if (character == ')' && depth > 0) { + depth--; + } else if (character == separator && depth == 0) { + result.add(value.substring(start, index).trim()); + start = index + 1; + } + } + return result; + } + + static (ui.Color, List)? _parseMaskStop( + String component, + double axisLength, + ) { + final source = component.trim(); + if (source.isEmpty) return null; + var colorEnd = source.indexOf(RegExp(r'\s')); + final open = source.indexOf('('); + if (open >= 0 && (colorEnd < 0 || open < colorEnd)) { + var depth = 1; + colorEnd = open + 1; + while (colorEnd < source.length && depth > 0) { + if (source[colorEnd] == '(') depth++; + else if (source[colorEnd] == ')') depth--; + colorEnd++; + } + } + if (colorEnd < 0) colorEnd = source.length; + final color = _parseColor(source.substring(0, colorEnd)); + final positionSource = source.substring(colorEnd).trim(); + final offsets = []; + if (positionSource.isNotEmpty) { + for (final token in positionSource.split(RegExp(r'\s+(?![^()]*\))'))) { + if (token.isEmpty) continue; + final offset = _parseMaskPositionValue(token, axisLength); + if (offset != null) offsets.add(offset); + } + } + if (offsets.isEmpty) offsets.add(double.nan); + return (color, offsets); + } + + static double? _parseMaskPositionValue(String value, double axisLength) { + final normalized = value.trim().toLowerCase(); + if (normalized.endsWith('%')) { + final percentage = double.tryParse( + normalized.substring(0, normalized.length - 1), + ); + return percentage == null ? null : percentage / 100; + } + if (normalized.endsWith('px')) { + final pixels = double.tryParse(normalized.substring(0, normalized.length - 2)); + return pixels == null ? null : pixels / axisLength; + } + final calc = RegExp( + r'^calc\(100%\s*([-+])\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))px\)$', + ).firstMatch(normalized); + if (calc != null) { + final pixels = double.parse(calc.group(2)!); + return (axisLength + (calc.group(1) == '-' ? -pixels : pixels)) / axisLength; + } + return null; + } + + static void _fillMissingGradientStops(List stops) { + if (stops.first.isNaN) stops[0] = 0; + if (stops.last.isNaN) stops[stops.length - 1] = 1; + var index = 1; + while (index < stops.length - 1) { + if (!stops[index].isNaN) { + stops[index] = stops[index].clamp(stops[index - 1], 1); + index++; + continue; + } + final runStart = index - 1; + var runEnd = index + 1; + while (runEnd < stops.length && stops[runEnd].isNaN) runEnd++; + final from = stops[runStart]; + final to = runEnd < stops.length ? stops[runEnd] : 1; + for (var missing = index; missing < runEnd; missing++) { + stops[missing] = from + + (to - from) * (missing - runStart) / (runEnd - runStart); + } + index = runEnd; + } + for (var stop = 0; stop < stops.length; stop++) { + stops[stop] = stops[stop].clamp(stop == 0 ? 0 : stops[stop - 1], 1); + } + } + + static (double, double) _resolveMaskSize( + String value, + double width, + double height, + ) { + final tokens = _splitCssTopLevel(value.trim(), ' ') + .where((token) => token.isNotEmpty) + .toList(); + final first = tokens.isEmpty ? 'auto' : tokens[0]; + final second = tokens.length > 1 ? tokens[1] : first; + double resolve(String token, double available) { + if (token == 'auto') return available; + final calc = RegExp( + r'^calc\(\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))%\s*([-+])\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+))px\s*\)$', + caseSensitive: false, + ).firstMatch(token); + if (calc != null) { + final percentage = double.parse(calc.group(1)!); + final pixels = double.parse(calc.group(3)!); + return available * percentage / 100 + + (calc.group(2) == '-' ? -pixels : pixels); + } + if (token.endsWith('%')) { + return available * (double.tryParse(token.substring(0, token.length - 1)) ?? 100) / 100; + } + if (token.endsWith('px')) { + return double.tryParse(token.substring(0, token.length - 2)) ?? available; + } + return available; + } + return (resolve(first, width), resolve(second, height)); + } + + static (double, double) _resolveMaskPosition( + String value, + double width, + double height, + double tileWidth, + double tileHeight, + ) { + final tokens = _splitCssTopLevel(value.trim(), ' ') + .where((token) => token.isNotEmpty) + .toList(); + final first = tokens.isEmpty ? '0%' : tokens[0]; + final second = tokens.length > 1 ? tokens[1] : '0%'; + double resolve(String token, double remaining) { + if (token == 'center') return remaining / 2; + if (token == 'right' || token == 'bottom') return remaining; + if (token == 'left' || token == 'top') return 0; + if (token.endsWith('%')) { + return remaining * (double.tryParse(token.substring(0, token.length - 1)) ?? 0) / 100; + } + if (token.endsWith('px')) { + return double.tryParse(token.substring(0, token.length - 2)) ?? 0; + } + return 0; + } + return (resolve(first, width - tileWidth), resolve(second, height - tileHeight)); + } + static ui.Paint _domGroupPaint(WebSceneSceneCommand command) { if (command.flags & _domEffectFilterMask == 0) { return ui.Paint() ..color = ui.Color.fromARGB(command.rgba & 0xff, 255, 255, 255); } + if (command.flags & _domLinearMaskGroup != 0) return ui.Paint(); final amount = command.strokeWidth.clamp(0.0, double.infinity).toDouble(); if (command.flags & _domBlurFilter != 0) { return ui.Paint()