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 60063d14..11c7e2ab 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -339,73 +339,105 @@ void native_document::append_scene( || opacity <= 0.001F) { return; } - const auto resolve_single_filter = [&](std::string_view name, float maximum) - -> std::optional { - const auto& effects = node.style.textual().effect_values; - const auto known = effects.find("filter"); - if (known == effects.end()) return std::nullopt; - auto value = std::string_view{known->second}; - if (value.size() < name.size() + 2U - || value[name.size()] != '(' || value.back() != ')') return std::nullopt; - for (size_t index = 0; index < name.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 != name[index]) return std::nullopt; - } - value.remove_prefix(name.size() + 1U); - value.remove_suffix(1U); - 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); - } - const auto parsed = css::parse_ascii_number_prefix(value); - if (!parsed.has_value() || parsed->value < 0) return std::nullopt; - const auto suffix = value.substr(parsed->consumed); - const auto amount = suffix.empty() - ? parsed->value - : suffix == "%" ? parsed->value / 100.0F : -1.0F; - return amount >= 0 && amount <= maximum - ? std::optional{amount} : std::nullopt; + struct retained_foreground_filter final { + uint32_t flags{}; + float amount{}; }; - const auto brightness_filter = resolve_single_filter("brightness", 10.0F); - const auto grayscale_filter = resolve_single_filter("grayscale", 1.0F); - const auto contrast_filter = resolve_single_filter("contrast", 10.0F); - const auto blur_filter = [&]() -> std::optional { + const auto foreground_filters = [&]() { + std::vector result; const auto& effects = node.style.textual().effect_values; const auto known = effects.find("filter"); - if (known == effects.end()) return std::nullopt; - auto value = std::string_view{known->second}; - constexpr auto name = std::string_view{"blur"}; - if (value.size() < name.size() + 2U - || value[name.size()] != '(' || value.back() != ')') return std::nullopt; - for (size_t index = 0; index < name.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 != name[index]) return std::nullopt; + if (known == effects.end()) return result; + auto remaining = std::string_view{known->second}; + 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 equals_ascii_case_insensitive = [](std::string_view left, + std::string_view right) { + if (left.size() != right.size()) return false; + for (size_t index = 0; index < left.size(); ++index) { + const auto character = static_cast(left[index]); + const auto lower = character >= 'A' && character <= 'Z' + ? static_cast(character - 'A' + 'a') + : static_cast(character); + if (lower != right[index]) return false; + } + return true; + }; + remaining = trim(remaining); + if (equals_ascii_case_insensitive(remaining, "none")) return result; + constexpr size_t maximum_filter_functions = 16U; + while (!remaining.empty()) { + if (result.size() >= maximum_filter_functions) { + result.clear(); + return result; + } + const auto opening = remaining.find('('); + const auto closing = opening == std::string_view::npos + ? std::string_view::npos : remaining.find(')', opening + 1U); + if (opening == 0U || opening == std::string_view::npos + || closing == std::string_view::npos) { + result.clear(); + return result; + } + const auto name = trim(remaining.substr(0U, opening)); + const auto argument = trim( + remaining.substr(opening + 1U, closing - opening - 1U)); + const auto parsed = css::parse_ascii_number_prefix(argument); + if (!parsed.has_value() || parsed->value < 0.0F) { + result.clear(); + return result; + } + const auto suffix = argument.substr(parsed->consumed); + uint32_t flags = 0U; + float amount = -1.0F; + float maximum = 0.0F; + if (equals_ascii_case_insensitive(name, "blur")) { + flags = 1U << 28U; + maximum = 256.0F; + amount = suffix == "px" || (suffix.empty() && parsed->value == 0.0F) + ? parsed->value : -1.0F; + } else { + if (equals_ascii_case_insensitive(name, "brightness")) { + flags = 1U << 31U; + maximum = 10.0F; + } else if (equals_ascii_case_insensitive(name, "grayscale")) { + flags = 1U << 30U; + maximum = 1.0F; + } else if (equals_ascii_case_insensitive(name, "contrast")) { + flags = 1U << 29U; + maximum = 10.0F; + } else if (equals_ascii_case_insensitive(name, "saturate")) { + flags = 1U << 27U; + maximum = 10.0F; + } else { + result.clear(); + return result; + } + amount = suffix.empty() + ? parsed->value + : suffix == "%" ? parsed->value / 100.0F : -1.0F; + } + if (amount < 0.0F || amount > maximum) { + result.clear(); + return result; + } + result.push_back(retained_foreground_filter{flags, amount}); + remaining.remove_prefix(closing + 1U); + if (!remaining.empty() + && !std::isspace(static_cast(remaining.front()))) { + result.clear(); + return result; + } + remaining = trim(remaining); } - value.remove_prefix(name.size() + 1U); - value.remove_suffix(1U); - 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); - const auto parsed = css::parse_ascii_number_prefix(value); - if (!parsed.has_value() || parsed->value < 0.0F) return std::nullopt; - const auto suffix = value.substr(parsed->consumed); - if (suffix != "px" && !(suffix.empty() && parsed->value == 0.0F)) - return std::nullopt; - return parsed->value <= 256.0F - ? std::optional{parsed->value} : std::nullopt; + return result; }(); const auto has_opacity_group = opacity < 0.999F; if (has_opacity_group) { @@ -422,26 +454,13 @@ void native_document::append_scene( 255L)), node.id}); } - if (brightness_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 30U, - 1U << 31U, - node.layout.x, - node.layout.y, - node.layout.width, - node.layout.height, - 255U, - node.id, - 0, - 0, - 0, - 0, - *brightness_filter}); - } - if (grayscale_filter.has_value()) { + // 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(); + filter != foreground_filters.rend(); ++filter) { commands.push_back(webscene_scene_command{ 30U, - 1U << 30U, + filter->flags, node.layout.x, node.layout.y, node.layout.width, @@ -452,39 +471,7 @@ void native_document::append_scene( 0, 0, 0, - *grayscale_filter}); - } - if (contrast_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 30U, - 1U << 29U, - node.layout.x, - node.layout.y, - node.layout.width, - node.layout.height, - 255U, - node.id, - 0, - 0, - 0, - 0, - *contrast_filter}); - } - if (blur_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 30U, - 1U << 28U, - node.layout.x, - node.layout.y, - node.layout.width, - node.layout.height, - 255U, - node.id, - 0, - 0, - 0, - 0, - *blur_filter}); + filter->amount}); } const auto resolve_transform_origin = [](css_length value, float available, float fallback) { if (value.unit == length_unit::pixels) return value.value; @@ -2046,22 +2033,8 @@ void native_document::append_scene( if (has_scale_transform) { commands.push_back(webscene_scene_command{16U, 0U, 0, 0, 0, 0, 0U, node.id}); } - if (blur_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 31U, 0U, node.layout.x, node.layout.y, - node.layout.width, node.layout.height, 0U, node.id}); - } - if (contrast_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 31U, 0U, node.layout.x, node.layout.y, - node.layout.width, node.layout.height, 0U, node.id}); - } - if (grayscale_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 31U, 0U, node.layout.x, node.layout.y, - node.layout.width, node.layout.height, 0U, node.id}); - } - if (brightness_filter.has_value()) { + for (size_t filter_index = 0U; + filter_index < foreground_filters.size(); ++filter_index) { commands.push_back(webscene_scene_command{ 31U, 0U, node.layout.x, node.layout.y, node.layout.width, node.layout.height, 0U, node.id}); @@ -2418,22 +2391,8 @@ void native_document::append_scene( if (has_scale_transform) { commands.push_back(webscene_scene_command{16U, 0U, 0, 0, 0, 0, 0U, node.id}); } - if (blur_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 31U, 0U, node.layout.x, node.layout.y, - node.layout.width, node.layout.height, 0U, node.id}); - } - if (contrast_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 31U, 0U, node.layout.x, node.layout.y, - node.layout.width, node.layout.height, 0U, node.id}); - } - if (grayscale_filter.has_value()) { - commands.push_back(webscene_scene_command{ - 31U, 0U, node.layout.x, node.layout.y, - node.layout.width, node.layout.height, 0U, node.id}); - } - if (brightness_filter.has_value()) { + for (size_t filter_index = 0U; + filter_index < foreground_filters.size(); ++filter_index) { commands.push_back(webscene_scene_command{ 31U, 0U, 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 be5dfc47..aa74aff0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -155,8 +155,9 @@ typedef struct webscene_scene_header { // 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. -// Group kind 30 uses flags bit 31 for brightness, bit 30 for grayscale, and -// bit 29 for contrast, and bit 28 for foreground blur; stroke_width carries +// 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; +// 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. typedef struct webscene_scene_command { 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 f4d902a6..e9c483a6 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 blur_filter_begins{}; uint32_t command_count{}; bool transform_clip_nested{}; + bool compound_filter_ordered{}; }; clip_scene_counts wait_for_inset_clip_scene(webscene_engine* engine, uint32_t expected_count) @@ -78,6 +79,8 @@ clip_scene_counts wait_for_inset_clip_scene(webscene_engine* engine, uint32_t ex latest.command_count = scene->header.command_count; uint32_t transform_clip_node = 0U; auto transform_clip_stage = 0U; + uint32_t compound_filter_node = 0U; + auto compound_filter_stage = 0U; for (uint32_t index = 0; index < scene->header.command_count; ++index) { const auto& command = scene->commands[index]; if (transform_clip_stage == 0U && command.kind == 15U) { @@ -97,6 +100,30 @@ clip_scene_counts wait_for_inset_clip_scene(webscene_engine* engine, uint32_t ex transform_clip_stage = 6U; } } + if (command.kind == 30U) { + if (compound_filter_stage == 0U + && (command.flags & (1U << 30U)) != 0U + && std::abs(command.stroke_width - 0.25F) < 0.01F) { + compound_filter_node = command.node_id; + compound_filter_stage = 1U; + } else if (command.node_id == compound_filter_node + && compound_filter_stage == 1U + && (command.flags & (1U << 29U)) != 0U + && std::abs(command.stroke_width - 1.5F) < 0.01F) { + compound_filter_stage = 2U; + } else if (command.node_id == compound_filter_node + && compound_filter_stage == 2U + && (command.flags & (1U << 27U)) != 0U + && std::abs(command.stroke_width - 1.08F) < 0.01F) { + compound_filter_stage = 3U; + } else if (command.node_id == compound_filter_node + && compound_filter_stage == 3U + && (command.flags & (1U << 28U)) != 0U + && std::abs(command.stroke_width - 2.0F) < 0.01F) { + latest.compound_filter_ordered = true; + compound_filter_stage = 4U; + } + } if (command.kind == 12U && std::abs(command.width - 6.0F) < 0.01F && std::abs(command.height - 2.0F) < 0.01F) { @@ -182,6 +209,7 @@ int main() clip-path: inset(0px 1px); filter: brightness(0.5); backdrop-filter: blur(1px); } #effects.alternate > span { clip-path: circle(25%); filter: contrast(2); } #effects > span:first-child { transform: scale(1.25) rotate(3deg); } + #compound-filter { filter: blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25); } #effects > span:last-child { filter: blur(2px); } `; document.head.appendChild(rules); @@ -190,6 +218,7 @@ int main() const fragment = document.createDocumentFragment(); for (let index = 0; index < 4096; index++) fragment.appendChild(document.createElement('span')); host.appendChild(fragment); + host.children[4094].id = 'compound-filter'; document.body.appendChild(host); const first = host.firstElementChild; const style = getComputedStyle(first); @@ -202,6 +231,10 @@ int main() if (getComputedStyle(host.lastElementChild).getPropertyValue('filter') !== 'blur(2px)') { throw new Error('initial blur filter value failed'); } + if (getComputedStyle(document.getElementById('compound-filter')).getPropertyValue('filter') + !== 'blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25)') { + throw new Error('initial compound filter list failed'); + } })() )JS", "native-effects-fixture.js"); @@ -268,8 +301,10 @@ int main() "retained scene did not preserve all 4096 clipped fills"); require(initial_clip_scene.transform_clip_nested, "transform commands did not wrap the inset clip scope"); - require(initial_clip_scene.blur_filter_begins == 1U, - "retained scene did not emit the bounded foreground blur group"); + 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, + "retained scene did not preserve compound foreground filter order"); const auto initial_scene_command_bytes = static_cast(initial_clip_scene.command_count) * sizeof(webscene_scene_command); @@ -382,6 +417,7 @@ int main() << " clip-ends=" << initial_clip_scene.inset_clip_ends << " clipped-fills=" << initial_clip_scene.clipped_fills << " blur-filter-begins=" << initial_clip_scene.blur_filter_begins + << " 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 << " peak-textual-style-count-delta=" << peak_textual_style_count_delta diff --git a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs index f74eb468..86fe6399 100644 --- a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs +++ b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs @@ -48,8 +48,9 @@ internal sealed unsafe partial class NativeCanvasSceneRenderer private const uint DomGrayscaleFilter = 1u << 30; private const uint DomContrastFilter = 1u << 29; private const uint DomBlurFilter = 1u << 28; + private const uint DomSaturateFilter = 1u << 27; private const uint DomColorFilterMask = - DomBrightnessFilter | DomGrayscaleFilter | DomContrastFilter; + DomBrightnessFilter | DomGrayscaleFilter | DomContrastFilter | DomSaturateFilter; private const uint DomEffectFilterMask = DomColorFilterMask | DomBlurFilter; private readonly Dictionary s_layers = new(); @@ -883,6 +884,16 @@ private static void SaveDomGroup( 0, 0, 0, 1, 0 ]; } + else if ((command.Flags & DomSaturateFilter) != 0) + { + var inverse = 1 - amount; + matrix = [ + 0.2126f + 0.7874f * amount, 0.7152f * inverse, 0.0722f * inverse, 0, 0, + 0.2126f * inverse, 0.7152f + 0.2848f * amount, 0.0722f * inverse, 0, 0, + 0.2126f * inverse, 0.7152f * inverse, 0.0722f + 0.9278f * amount, 0, 0, + 0, 0, 0, 1, 0 + ]; + } else { var intercept = 127.5f * (1 - amount); diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index 248e2871..422e52c8 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -23,8 +23,10 @@ const int _domBrightnessFilter = 1 << 31; const int _domGrayscaleFilter = 1 << 30; const int _domContrastFilter = 1 << 29; const int _domBlurFilter = 1 << 28; +const int _domSaturateFilter = 1 << 27; const int _domColorFilterMask = - _domBrightnessFilter | _domGrayscaleFilter | _domContrastFilter; + _domBrightnessFilter | _domGrayscaleFilter | _domContrastFilter | + _domSaturateFilter; const int _domEffectFilterMask = _domColorFilterMask | _domBlurFilter; final class SceneApplyResult { @@ -989,6 +991,14 @@ final class WebSceneSceneProjector extends ChangeNotifier { 0.2126 * amount, 0.7152 * amount, 1 - 0.9278 * amount, 0, 0, 0, 0, 0, 1, 0, ]; + } else if (command.flags & _domSaturateFilter != 0) { + final inverse = 1 - amount; + matrix = [ + 0.2126 + 0.7874 * amount, 0.7152 * inverse, 0.0722 * inverse, 0, 0, + 0.2126 * inverse, 0.7152 + 0.2848 * amount, 0.0722 * inverse, 0, 0, + 0.2126 * inverse, 0.7152 * inverse, 0.0722 + 0.9278 * amount, 0, 0, + 0, 0, 0, 1, 0, + ]; } else { final intercept = 127.5 * (1 - amount); matrix = [ diff --git a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html index cead6bd3..d6dc57ea 100644 --- a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html +++ b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html @@ -71,6 +71,14 @@ 'brightness(0.5) grayscale(1)'); }, 'foreground filter exposes its computed function list'); + test(() => { + effect.style.setProperty('filter', + 'blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25)'); + assert_equals(getComputedStyle(effect).getPropertyValue('filter'), + 'blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25)'); + effect.style.removeProperty('filter'); + }, 'compound foreground filters preserve authored function order'); + test(() => { assert_equals(getComputedStyle(effect).getPropertyValue('backdrop-filter'), 'blur(3px)'); }, 'backdrop filter exposes its computed function list');