From 3712911407462ea39bad768d96d0c3510183a849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sun, 20 Sep 2026 05:55:09 +0200 Subject: [PATCH] Implement object fit for retained images --- docs/validation/code-oss-object-fit-789.md | 46 +++++++ .../native/css_property_metadata.json | 13 +- .../webscene_css_property_identity.inc | 12 ++ .../generated/webscene_css_property_ids.inc | 2 + .../webscene_css_supported_properties.inc | 4 +- .../native/webscene_css_application.h | 2 + .../native/webscene_css_cascade_reset.h | 2 + .../native/webscene_css_replaced_values.h | 125 ++++++++++++++++++ .../native/webscene_css_reset.h | 8 ++ .../native/webscene_css_specified_coverage.h | 1 + .../native/webscene_native_dom.h | 4 + .../native/webscene_native_dom_metrics.inc | 2 + .../native/webscene_native_dom_scene.inc | 104 +++++++++++++-- .../native/webscene_v8_runtime_dom_core.inc | 2 + .../native/webscene_v8_runtime_style.inc | 37 ++++++ .../native/webscene_v8_runtime_support.inc | 9 ++ .../tests/css_property_identity_tests.cpp | 7 +- .../native_v8_runtime_css_layout_tests.inc | 57 ++++++++ ...ive_v8_runtime_rendering_metrics_tests.inc | 88 +++++++++++- .../tests/native_v8_runtime_tests.cpp | 8 ++ .../lib/src/scene_projector.dart | 14 +- .../test/scene_projector_test.dart | 17 ++- src/WebScene.Css/CssPropertyCatalog.cs | 45 +++++++ .../CssPropertyMetadata.Generated.cs | 6 + tests/WebPlatformSubset/capabilities.json | 6 +- .../contracts/css-object-fit-position.html | 54 ++++++++ .../webscene-component-profile.json | 7 + .../CssPropertyCatalogTests.cs | 8 ++ 28 files changed, 659 insertions(+), 31 deletions(-) create mode 100644 docs/validation/code-oss-object-fit-789.md create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_css_replaced_values.h create mode 100644 tests/WebPlatformSubset/contracts/css-object-fit-position.html diff --git a/docs/validation/code-oss-object-fit-789.md b/docs/validation/code-oss-object-fit-789.md new file mode 100644 index 000000000..108b44cf5 --- /dev/null +++ b/docs/validation/code-oss-object-fit-789.md @@ -0,0 +1,46 @@ +# Code OSS object-fit and object-position implementation (#789) + +## Product trigger + +Unchanged Code OSS uses `object-fit` in avatars, account imagery, thumbnails, +extension surfaces, and session UI. WebScene previously exposed neither +`object-fit` nor `object-position`, and every retained `` stretched to its +layout box. + +## Implemented slice + +- Adds generated native and managed property identities plus CSSOM aliases. +- Supports `fill`, `contain`, `cover`, `none`, and `scale-down`. +- Supports one- and two-component positions with horizontal/vertical keywords, + percentages, supported lengths, and existing math-function parsing. +- Serializes computed position keywords as browser-shaped percentages while + preserving the authored inline declaration. +- Computes image destination geometry from intrinsic dimensions and the content + box without changing layout geometry. +- Publishes an explicit content clip around retained raster and SVG image + commands, including the inner rounded corners. +- Preserves the active rectangular clip when Flutter defers retained SVG + rasterization. +- Treats CSSOM mutations as paint-only scene invalidations. + +The bounded position parser deliberately leaves three- and four-component +edge-offset syntax outside this slice because it is not present in the current +Code OSS inventory. + +## Authored gates + +- `contracts/css-object-fit-position.html`: CSSOM defaults, cascade, mutation, + removal, intrinsic dimensions, and unchanged element geometry. +- `test_image_elements_load_and_reach_scene`: exact `cover`/right-bottom and + `contain`/center destination and clip commands. +- `test_percentage_radius_reaches_raster_image_scene_clip`: rounded content clip + regression. +- `object-fit-performance`: 4,096 images, ten style transitions, zero added + layout passes, and a five-second guardrail. + +## Evidence state + +Implementation and gates are authored from source review. Per the active fast +merge direction, no build, test, WPT, visual, package, or performance command +was executed for this change. Runtime, exact-pixel, and benchmark evidence is +therefore zero until the later full-validation pass. diff --git a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json index 0583158d1..8d3440ca0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json +++ b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json @@ -149,7 +149,9 @@ {"id": "container_name", "name": "container-name"}, {"id": "content_visibility", "name": "content-visibility"}, {"id": "contain_intrinsic_size", "name": "contain-intrinsic-size"}, - {"id": "aspect_ratio", "name": "aspect-ratio"} + {"id": "aspect_ratio", "name": "aspect-ratio"}, + {"id": "object_fit", "name": "object-fit", "aliases": ["objectfit"]}, + {"id": "object_position", "name": "object-position", "aliases": ["objectposition"]} ], "nativeStorageOnlyProperties": [ "accent-color", "align-content", "animation-direction", "animation-fill-mode", "animation-play-state", @@ -170,12 +172,12 @@ "all", "display", "position", "floating", "flex_direction", "flex_wrap", "align_items", "align_self", "justify_content", "box_sizing", "vertical_align", "grid_auto_flow", "border_collapse", "table_layout", "border_style", "background_repeat", "visibility", "pointer_events", "text_anchor", "font_smoothing", "text_align", "text_transform", "white_space", "word_break", "overflow_wrap", "list_style_position", - "list_style_type", "scrollbar_width", "container", "container_type", "content_visibility" + "list_style_type", "scrollbar_width", "container", "container_type", "content_visibility", "object_fit" ], "componentList": [ "container_name", "background_position", "background_size", "transition", "transition_property", "transition_duration", "transition_delay", "transition_timing_function", "animation", "animation_name", "animation_duration", "animation_delay", - "animation_timing_function", "animation_iteration_count", "scrollbar_color" + "animation_timing_function", "animation_iteration_count", "scrollbar_color", "object_position" ], "length": [ "width", "height", "min_width", "min_height", "max_width", "max_height", "left", "top", @@ -213,7 +215,8 @@ "content", "aspect_ratio", "vertical_align", "grid_auto_columns", "grid_auto_flow", "table_layout", "border_inline", "border_block", "outline", "outline_width", "outline_color", "animation", "animation_name", "animation_duration", "animation_delay", "animation_timing_function", "animation_iteration_count", "background_repeat", "background_position", "background_size", - "font", "text_transform", "word_break", "overflow_wrap", "list_style", "list_style_position", "list_style_type" + "font", "text_transform", "word_break", "overflow_wrap", "list_style", "list_style_position", "list_style_type", + "object_fit", "object_position" ], "managedKnownProperties": [ "align-content", "align-items", "align-self", "all", "background", "background-color", @@ -234,7 +237,7 @@ "stroke-linecap", "stroke-linejoin", "stroke-width", "text-align", "text-indent", "text-transform", "top", "transform", "visibility", "white-space", "word-break", "overflow-wrap", "width", "word-spacing", "z-index", "outline", "outline-color", "outline-offset", "outline-style", "outline-width", - "grid-template-areas", "aspect-ratio" + "grid-template-areas", "aspect-ratio", "object-fit", "object-position" ], "supportedPropertyExtras": [ "animation", "animation-delay", "animation-direction", "animation-duration", "animation-fill-mode", diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc index 35e765a8b..930127cd1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_identity.inc @@ -213,6 +213,10 @@ inline constexpr std::array native_typed_property_identity_catalog{ native_typed_property_identity{"content-visibility", css_property_id::content_visibility}, native_typed_property_identity{"contain-intrinsic-size", css_property_id::contain_intrinsic_size}, native_typed_property_identity{"aspect-ratio", css_property_id::aspect_ratio}, + native_typed_property_identity{"object-fit", css_property_id::object_fit}, + native_typed_property_identity{"objectfit", css_property_id::object_fit}, + native_typed_property_identity{"object-position", css_property_id::object_position}, + native_typed_property_identity{"objectposition", css_property_id::object_position}, }; inline constexpr std::array native_storage_only_property_catalog{ @@ -430,6 +434,8 @@ inline constexpr std::array native_modeled_property_mask_catalog{ true, true, false, + false, + false, }; inline constexpr bool generated_property_has_modeled_mask( @@ -626,6 +632,8 @@ inline constexpr std::array native_property_grammar_catalog{ native_property_grammar::keyword, native_property_grammar::length, native_property_grammar::complex, + native_property_grammar::keyword, + native_property_grammar::component_list, }; inline constexpr native_property_grammar generated_property_grammar( @@ -847,5 +855,9 @@ inline css_property_id generated_property_id_lowercase(std::string_view name) no if (name == "content-visibility") return css_property_id::content_visibility; if (name == "contain-intrinsic-size") return css_property_id::contain_intrinsic_size; if (name == "aspect-ratio") return css_property_id::aspect_ratio; + if (name == "object-fit") return css_property_id::object_fit; + if (name == "objectfit") return css_property_id::object_fit; + if (name == "object-position") return css_property_id::object_position; + if (name == "objectposition") return css_property_id::object_position; return css_property_id::unknown; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc index 92e47b78e..622e693bb 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_property_ids.inc @@ -148,3 +148,5 @@ container_name, content_visibility, contain_intrinsic_size, aspect_ratio, +object_fit, +object_position, diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc index d76532f84..7020a4aca 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_css_supported_properties.inc @@ -186,6 +186,8 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"min-height", "minHeight"}, cssom_supported_property_metadata{"min-width", "minWidth"}, cssom_supported_property_metadata{"moz-transform", "mozTransform"}, + cssom_supported_property_metadata{"object-fit", "objectFit"}, + cssom_supported_property_metadata{"object-position", "objectPosition"}, cssom_supported_property_metadata{"opacity", "opacity"}, cssom_supported_property_metadata{"order", "order"}, cssom_supported_property_metadata{"orphans", "orphans"}, @@ -249,4 +251,4 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"zoom", "zoom"}, }; -inline constexpr auto cssom_style_template_property_accessor_count = 441U; +inline constexpr auto cssom_style_template_property_accessor_count = 445U; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h index d37f49753..06052f8a8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h @@ -8,6 +8,7 @@ #include "webscene_css_reset.h" #include "webscene_css_variables.h" #include "webscene_css_scrollbar_values.h" +#include "webscene_css_replaced_values.h" namespace webscene_native::css { // Apply an already-resolved declaration. The caller owns variable resolution, @@ -255,6 +256,7 @@ void apply_resolved_declaration(native_document& document,dom_node& node, } else if (css::apply_paint_value(node,name,value,decision,is_inline, load_svg)) { } else if (css::apply_visibility_value(document,node,name,value,is_inline)) { + } else if (css::apply_replaced_value(node,name,value,decision)) { } else if (css::apply_text_value(node,name,value,decision,is_inline)) { } else if (name == "border-style") { decision.classification = "partially-supported"; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h index c11550375..2b9b410d7 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h @@ -226,6 +226,8 @@ inline void reset_cascaded_style(dom_node& node, } textual->word_break.clear(); textual->overflow_wrap.clear(); + textual->object_fit.clear(); + textual->object_position.clear(); } if ((node.style.inline_property_mask & inline_font_weight) == 0U) node.style.font_weight = 0; if ((node.style.inline_property_mask & inline_line_height) == 0U) node.style.line_height = -1; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_replaced_values.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_replaced_values.h new file mode 100644 index 000000000..c076ba1e6 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_replaced_values.h @@ -0,0 +1,125 @@ +#pragma once +#include "webscene_css_property_mask.h" +#include +#include +#include +#include + +namespace webscene_native::css { +inline bool valid_object_position_component(std::string_view token) +{ + if (token == "left" || token == "right" || token == "top" + || token == "bottom" || token == "center") return true; + if (token.starts_with("calc(") || token.starts_with("min(") + || token.starts_with("max(")) return true; + const auto value = std::string(token); + char* end = nullptr; + const auto number = std::strtof(value.c_str(), &end); + if (end == value.c_str() || !std::isfinite(number)) return false; + const std::string_view unit(end); + return (number == 0.0F && unit.empty()) || unit == "%" || unit == "px" + || unit == "em" || unit == "rem" || unit == "vw" || unit == "vh" + || unit == "cqw" || unit == "cqh" || unit == "cqi" || unit == "cqb" + || unit == "cqmin" || unit == "cqmax" || unit == "in" || unit == "cm" + || unit == "mm" || unit == "pt" || unit == "pc" || unit == "q"; +} + +inline bool horizontal_object_position_keyword(std::string_view token) +{ + return token == "left" || token == "right"; +} + +inline bool vertical_object_position_keyword(std::string_view token) +{ + return token == "top" || token == "bottom"; +} + +inline std::string canonical_object_position_component(std::string value) +{ + if (value == "left" || value == "top") return "0%"; + if (value == "center") return "50%"; + if (value == "right" || value == "bottom") return "100%"; + return value; +} + +inline bool normalize_object_position(std::string_view raw_value, + std::string& normalized) +{ + std::istringstream tokens(std::string{raw_value}); + std::string first; + std::string second; + std::string extra; + tokens >> first >> second >> extra; + if (first.empty() || !extra.empty() || !valid_object_position_component(first) + || (!second.empty() && !valid_object_position_component(second))) { + return false; + } + if (second.empty()) { + if (vertical_object_position_keyword(first)) { + second = first; + first = "50%"; + } else { + second = "50%"; + } + } else { + if ((horizontal_object_position_keyword(first) + && horizontal_object_position_keyword(second)) + || (vertical_object_position_keyword(first) + && vertical_object_position_keyword(second))) { + return false; + } + if (vertical_object_position_keyword(first) + || horizontal_object_position_keyword(second)) { + std::swap(first, second); + } + } + normalized = canonical_object_position_component(std::move(first)) + " " + + canonical_object_position_component(std::move(second)); + return true; +} + +inline bool valid_object_position_value(std::string_view raw_value) +{ + const auto value = ascii_lower(trim_value(raw_value)); + if (value.find("var(") != std::string::npos) return true; + if (value == "inherit" || value == "initial" || value == "unset" + || value == "revert" || value == "revert-layer") return true; + std::string normalized; + return normalize_object_position(value, normalized); +} + +template +bool apply_replaced_value(dom_node& node,const std::string& name, + const std::string& raw_value,Decision& decision) +{ + if (name != "object-fit" && name != "object-position") return false; + auto value = ascii_lower(trim_value(raw_value)); + if (value == "inherit") { + value = node.parent == nullptr ? std::string{} + : name == "object-fit" ? node.parent->style.textual().object_fit + : node.parent->style.textual().object_position; + } else if (value == "initial" || value == "unset" || value == "revert" + || value == "revert-layer") { + value.clear(); + } + if (name == "object-fit") { + if (!value.empty() && value != "fill" && value != "contain" + && value != "cover" && value != "none" && value != "scale-down") { + decision.classification = "invalid-authoring"; + return true; + } + node.style.mutable_textual().object_fit = std::move(value); + return true; + } + if (!value.empty()) { + std::string normalized; + if (!normalize_object_position(value, normalized)) { + decision.classification = "invalid-authoring"; + return true; + } + value = std::move(normalized); + } + node.style.mutable_textual().object_position = std::move(value); + return true; +} +} // namespace webscene_native::css diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index 31e45d561..974cb2fe0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -231,6 +231,14 @@ inline void apply_all_unset( reset.mutable_textual().overflow_wrap = previous.textual().overflow_wrap; } + if (has_inline({"object-fit"})) { + reset.mutable_textual().object_fit = + previous.textual().object_fit; + } + if (has_inline({"object-position"})) { + reset.mutable_textual().object_position = + previous.textual().object_position; + } // These modeled properties do not yet have dedicated inline-mask // bits, so preserve their applied values by authored declaration. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h index f0d542447..2458db4dc 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h @@ -8,6 +8,7 @@ struct specified_property_sample final { std::string_view name; std::string_view inline constexpr auto specified_property_samples = std::to_array({ {"all","unset"},{"content","\"x\""},{"aspect-ratio","16 / 9"}, + {"object-fit","cover"},{"object-position","right 25%"}, {"width","12px"},{"height","20%"},{"min-width","1rem"},{"min-height","2px"}, {"max-width","none"},{"max-height","fit-content"}, {"left","1px"},{"top","2px"},{"right","3px"},{"bottom","4px"},{"inset","1px 2px 3px 4px"}, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index bc1b57c3b..56ede8435 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -789,6 +789,10 @@ struct node_style final { // state to descendants; the root resolves both properties to normal. std::string word_break; std::string overflow_wrap; + // Replaced-image paint is cold. Empty values represent the initial + // `fill` and `50% 50%` values without enlarging the hot node record. + std::string object_fit; + std::string object_position; std::string contain_value; std::string container_type{"normal"}; std::string container_name{"none"}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc index 751011d88..50599e326 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -343,6 +343,8 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + textual->white_space.capacity() + 1U + textual->word_break.capacity() + 1U + textual->overflow_wrap.capacity() + 1U + + textual->object_fit.capacity() + 1U + + textual->object_position.capacity() + 1U + textual->cursor.capacity() + 1U + textual->svg_fill.capacity() + 1U + textual->svg_stroke.capacity() + 1U 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 d777e02f4..e2821746d 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -2285,19 +2285,107 @@ void native_document::append_scene( ? std::string{"webscene-raster-v1\t"} + replaced_image.markup : replaced_image.view_box + "\t\t" + replaced_image.markup; const auto resource_index = append_scene_string(resource, strings, string_bytes); + const auto border_left = resolve_length( + node, node.style.border_left_width, node.layout.width, 0); + const auto border_top = resolve_length( + node, node.style.border_top_width, node.layout.width, 0); + const auto border_right = resolve_length( + node, node.style.border_right_width, node.layout.width, 0); + const auto border_bottom = resolve_length( + node, node.style.border_bottom_width, node.layout.width, 0); + const auto padding_left = resolve_length( + node, node.style.padding_left, node.layout.width, 0); + const auto padding_top = resolve_length( + node, node.style.padding_top, node.layout.width, 0); + const auto padding_right = resolve_length( + node, node.style.padding_right, node.layout.width, 0); + const auto padding_bottom = resolve_length( + node, node.style.padding_bottom, node.layout.width, 0); + const auto inset_left = std::max(0.0F, border_left + padding_left); + const auto inset_top = std::max(0.0F, border_top + padding_top); + const auto inset_right = std::max(0.0F, border_right + padding_right); + const auto inset_bottom = std::max(0.0F, border_bottom + padding_bottom); + const layout_rect content_box{ + node.layout.x + inset_left, + node.layout.y + inset_top, + std::max(0.0F, node.layout.width - inset_left - inset_right), + std::max(0.0F, node.layout.height - inset_top - inset_bottom)}; + auto object_width = content_box.width; + auto object_height = content_box.height; + const auto natural_width = replaced_image.natural_width; + const auto natural_height = replaced_image.natural_height; + const auto fit = node.style.textual().object_fit.empty() + ? std::string_view{"fill"} + : std::string_view{node.style.textual().object_fit}; + if (natural_width > 0 && natural_height > 0 && fit != "fill") { + auto scale = 1.0F; + if (fit == "contain" || fit == "cover" || fit == "scale-down") { + const auto width_scale = content_box.width / natural_width; + const auto height_scale = content_box.height / natural_height; + scale = fit == "cover" + ? std::max(width_scale, height_scale) + : std::min(width_scale, height_scale); + if (fit == "scale-down") scale = std::min(1.0F, scale); + } + object_width = std::max(0.0F, natural_width * scale); + object_height = std::max(0.0F, natural_height * scale); + } + auto horizontal_position = std::string{"50%"}; + auto vertical_position = std::string{"50%"}; + if (!node.style.textual().object_position.empty()) { + std::istringstream position(node.style.textual().object_position); + position >> horizontal_position >> vertical_position; + if (vertical_position.empty()) vertical_position = "50%"; + const auto vertical_keyword = [](std::string_view token) { + return token == "top" || token == "bottom"; + }; + const auto horizontal_keyword = [](std::string_view token) { + return token == "left" || token == "right"; + }; + if (vertical_keyword(horizontal_position) + || horizontal_keyword(vertical_position)) { + std::swap(horizontal_position, vertical_position); + } + } + const auto resolve_object_position = [&](std::string_view token, + float free_space) { + if (token == "left" || token == "top") return 0.0F; + if (token == "center") return free_space * 0.5F; + if (token == "right" || token == "bottom") return free_space; + const auto length = native_document::parse_length(std::string(token)); + return resolve_length(node, length, free_space, free_space * 0.5F); + }; + const auto object_x = content_box.x + resolve_object_position( + horizontal_position, content_box.width - object_width); + const auto object_y = content_box.y + resolve_object_position( + vertical_position, content_box.height - object_height); + const auto clip_top_left = std::max( + 0.0F, radii.top_left - std::max(inset_left, inset_top)); + const auto clip_top_right = std::max( + 0.0F, radii.top_right - std::max(inset_right, inset_top)); + const auto clip_bottom_right = std::max( + 0.0F, radii.bottom_right - std::max(inset_right, inset_bottom)); + const auto clip_bottom_left = std::max( + 0.0F, radii.bottom_left - std::max(inset_left, inset_bottom)); + commands.push_back(webscene_scene_command{ + 12U, 0U, content_box.x, content_box.y, + content_box.width, content_box.height, 0U, node.id, + clip_top_left, clip_top_right, clip_bottom_right, clip_bottom_left}); commands.push_back(webscene_scene_command{ 6U, resource_index, - node.layout.x, - node.layout.y, - node.layout.width, - node.layout.height, + object_x, + object_y, + object_width, + object_height, 0U, node.id, - radii.top_left, - radii.top_right, - radii.bottom_right, - radii.bottom_left}); + 0, + 0, + 0, + 0}); + commands.push_back(webscene_scene_command{ + 13U, 0U, 0, 0, 0, 0, 0U, node.id}); } if (paint_self && node.style.box_shadow_present && node.style.box_shadow_inset && node.layout.width > 0 && node.layout.height > 0) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index c76c107c3..2c4d0fa62 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -734,6 +734,8 @@ std::string_view{"color"}, std::string_view{"cursor"}, std::string_view{"opacity"}, + std::string_view{"object-fit"}, + std::string_view{"object-position"}, std::string_view{"pointer-events"}, std::string_view{"visibility"}, std::string_view{"-webkit-font-smoothing"} diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc index 8d85880ec..07aae2ec8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -251,6 +251,14 @@ node.style.aspect_ratio_inline = false; node.style.aspect_ratio_inline_important = false; node.style.aspect_ratio_important = false; + } else if (name == "objectFit" || name == "object-fit") { + if (auto* textual = node.style.mutable_textual_if_present()) { + textual->object_fit.clear(); + } + } else if (name == "objectPosition" || name == "object-position") { + if (auto* textual = node.style.mutable_textual_if_present()) { + textual->object_position.clear(); + } } else if (name == "boxSizing" || name == "box-sizing") { node.style.border_box = false; node.style.inline_property_mask &= ~inline_box_sizing; @@ -945,6 +953,12 @@ node->style.aspect_ratio_inline = true; node->style.aspect_ratio_inline_important = important; } + } else if (name == "object-fit" || name == "object-position") { + struct replaced_decision final { + std::string classification{"supported"}; + std::string semantic_slice; + } decision; + css::apply_replaced_value(*node, name, value, decision); } else if (name == "box-sizing") { node->style.border_box = value == "border-box"; node->style.inline_property_mask |= inline_box_sizing; @@ -1547,6 +1561,12 @@ value = resolved_line_break_property(*node, false); } else if (name == "overflow-wrap") { value = resolved_line_break_property(*node, true); + } else if (name == "object-fit") { + value = node->style.textual().object_fit.empty() + ? "fill" : node->style.textual().object_fit; + } else if (name == "object-position") { + value = node->style.textual().object_position.empty() + ? "50% 50%" : node->style.textual().object_position; } else if (name == "cursor") { value = resolved_cursor(*node); } else if (name == "font-size") { @@ -2004,6 +2024,12 @@ ? serialize_css_number(node->style.aspect_ratio_width) + " / " + serialize_css_number(node->style.aspect_ratio_height) : "auto"; + } else if (name == "objectFit") { + value = node->style.textual().object_fit.empty() + ? "fill" : node->style.textual().object_fit; + } else if (name == "objectPosition") { + value = node->style.textual().object_position.empty() + ? "50% 50%" : node->style.textual().object_position; } else if (name == "boxSizing") { value = node->style.border_box ? "border-box" : "content-box"; } else if (name == "borderRadius") { @@ -2572,6 +2598,17 @@ node->style.aspect_ratio_inline = true; node->style.aspect_ratio_inline_important = false; } + } else if (name == "objectFit" || name == "objectPosition") { + struct replaced_decision final { + std::string classification{"supported"}; + std::string semantic_slice; + } decision; + css::apply_replaced_value( + *node, + name == "objectFit" ? std::string{"object-fit"} + : std::string{"object-position"}, + value, + decision); } else if (name == "boxSizing") { node->style.border_box = value == "border-box"; node->style.inline_property_mask |= inline_box_sizing; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc index f3aa8ea2d..8aae60710 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc @@ -636,6 +636,9 @@ bool valid_cssom_declaration_value(std::string_view property_name, std::string_v if (canonical_name == "grid-template-areas") { return css::parse_grid_template_areas(trimmed).has_value(); } + if (canonical_name == "object-position") { + return css::valid_object_position_value(trimmed); + } auto normalized_value = std::string(trimmed); std::transform( normalized_value.begin(), normalized_value.end(), normalized_value.begin(), @@ -651,6 +654,12 @@ bool valid_cssom_declaration_value(std::string_view property_name, std::string_v return true; } + if (canonical_name == "object-fit") { + return normalized_value == "fill" || normalized_value == "contain" + || normalized_value == "cover" || normalized_value == "none" + || normalized_value == "scale-down"; + } + if (canonical_name == "position") { return normalized_value == "static" || normalized_value == "relative" || normalized_value == "absolute" || normalized_value == "fixed" diff --git a/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp index 208c190bd..37f016c98 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/css_property_identity_tests.cpp @@ -97,10 +97,11 @@ int main() static_assert(static_cast(css_property_id::unknown) == 0U); static_assert(static_cast(css_property_id::custom) == 1U); static_assert(static_cast(css_property_id::contain_intrinsic_size) == 145U); - static_assert(native_typed_property_identity_catalog.size() == 202U); + static_assert(static_cast(css_property_id::object_position) == 148U); + static_assert(native_typed_property_identity_catalog.size() == 206U); static_assert(native_storage_only_property_catalog.size() == 62U); - static_assert(cssom_supported_property_catalog.size() == 239U); - static_assert(cssom_style_template_property_accessor_count == 435U); + static_assert(cssom_supported_property_catalog.size() == 241U); + static_assert(cssom_style_template_property_accessor_count == 445U); for (const auto& entry : native_typed_property_identity_catalog) { require(property_id(entry.name) == entry.id, "typed name maps to its generated id", entry.name); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc index 78c3171a0..cce80d7ef 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_css_layout_tests.inc @@ -97,6 +97,63 @@ void test_word_break_layout_performance_gate(webscene_engine* engine) + std::to_string(elapsed) + " ms"); } +void test_object_fit_scene_performance_gate(webscene_engine* engine) +{ + resize(engine, 1024, 768, 1U); + execute(engine, R"JS( + (() => { + document.body.innerHTML = `
`; + const source = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyIiBoZWlnaHQ9IjEiPjxyZWN0IHdpZHRoPSIyIiBoZWlnaHQ9IjEiIGZpbGw9InJlZCIvPjwvc3ZnPg=='; + globalThis.objectFitGrid = document.getElementById('object-fit-grid'); + const fragment = document.createDocumentFragment(); + for (let index = 0; index < 4096; ++index) { + const image = document.createElement('img'); + image.src = source; + fragment.appendChild(image); + } + objectFitGrid.appendChild(fragment); + globalThis.objectFitImages = objectFitGrid.children; + })() + )JS", "native-object-fit-performance-setup.js"); + const auto loaded = evaluate_until_equals( + engine, + "String(objectFitImages.length === 4096 && objectFitImages[4095].complete)", + "native-object-fit-performance-loaded.js", + "true"); + require(loaded == "true", + "object-fit performance fixture did not load 4,096 images"); + webscene_engine_metrics before{}; + webscene_engine_get_metrics(engine, &before); + const auto started = std::chrono::steady_clock::now(); + const auto result = evaluate(engine, R"JS( + (() => { + let checksum = ''; + for (let cycle = 0; cycle < 10; ++cycle) { + objectFitGrid.className = cycle % 2 === 0 ? 'cover' : 'contain'; + const style = getComputedStyle(objectFitImages[4095]); + checksum += style.objectFit[0] + style.objectPosition[0]; + } + return { nodes: objectFitImages.length, checksum }; + })() + )JS", "native-object-fit-performance-gate.js"); + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + webscene_engine_metrics after{}; + webscene_engine_get_metrics(engine, &after); + require(result.find(R"JSON({"nodes":4096,"checksum":)JSON") == 0U, + "object-fit performance fixture did not retain 4,096 nodes: " + result); + require(after.layout_passes == before.layout_passes, + "paint-only object-fit transitions performed layout"); + require(elapsed < 5000.0, + "4,096-image object-fit transition gate exceeded 5 seconds: " + + std::to_string(elapsed) + " ms"); +} + void test_modal_backdrop_scene(webscene_engine* engine) { resize(engine,400,300,1); 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 6b70b116c..0f4eb88df 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 @@ -1400,9 +1400,84 @@ void test_image_elements_load_and_reach_scene(webscene_engine* engine) if (found) break; std::this_thread::sleep_for(std::chrono::milliseconds(2)); } + require(found, "loaded SVG IMG element did not reach the native scene"); + + const auto object_cssom = evaluate(engine, R"JS((() => { + const image = document.querySelector('#native-svg-image'); + image.style.objectFit = 'cover'; + image.style.objectPosition = 'right bottom'; + image.style.width = '100px'; + image.style.height = '100px'; + const style = getComputedStyle(image); + return [style.objectFit, style.objectPosition, image.style.objectFit, + image.style.objectPosition].join('|'); + })())JS", "native-object-fit-cover-setup.js"); + require(object_cssom == "cover|100% 100%|cover|right bottom", + "object-fit/object-position CSSOM did not retain cover alignment: " + + object_cssom); + webscene_engine_request_scene_checkpoint(engine); + auto cover_found = false; + for (auto attempt = 0; attempt < 100; ++attempt) { + const auto* scene = webscene_engine_acquire_latest_scene(engine); + if (scene != nullptr) { + for (uint32_t index = 1U; index + 1U < scene->header.command_count; ++index) { + const auto& command = scene->commands[index]; + const auto& clip = scene->commands[index - 1U]; + const auto& restore = scene->commands[index + 1U]; + if (command.kind == 6U && clip.kind == 12U && restore.kind == 13U + && std::abs(clip.width - 100.0F) < 0.01F + && std::abs(clip.height - 100.0F) < 0.01F + && std::abs(command.x + 100.0F) < 0.01F + && std::abs(command.width - 200.0F) < 0.01F + && std::abs(command.height - 100.0F) < 0.01F) { + cover_found = true; + break; + } + } + webscene_scene_acknowledge(scene); + webscene_scene_release(scene); + } + if (cover_found) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require(cover_found, + "cover/right-bottom intrinsic image geometry did not reach the retained scene"); + + execute(engine, R"JS( + (() => { + const image = document.querySelector('#native-svg-image'); + image.style.objectFit = 'contain'; + image.style.objectPosition = 'center'; + })() + )JS", "native-object-fit-contain-mutation.js"); + webscene_engine_request_scene_checkpoint(engine); + auto contain_found = false; + for (auto attempt = 0; attempt < 100; ++attempt) { + const auto* scene = webscene_engine_acquire_latest_scene(engine); + if (scene != nullptr) { + for (uint32_t index = 1U; index + 1U < scene->header.command_count; ++index) { + const auto& command = scene->commands[index]; + if (command.kind == 6U + && scene->commands[index - 1U].kind == 12U + && scene->commands[index + 1U].kind == 13U + && std::abs(command.x) < 0.01F + && std::abs(command.y - 25.0F) < 0.01F + && std::abs(command.width - 100.0F) < 0.01F + && std::abs(command.height - 50.0F) < 0.01F) { + contain_found = true; + break; + } + } + webscene_scene_acknowledge(scene); + webscene_scene_release(scene); + } + if (contain_found) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } std::error_code cleanup_error; std::filesystem::remove_all(resource_directory, cleanup_error); - require(found, "loaded SVG IMG element did not reach the native scene"); + require(contain_found, + "contain/center intrinsic image geometry did not reach the retained scene"); } void test_percentage_radius_reaches_raster_image_scene_clip(webscene_engine* engine) @@ -1432,13 +1507,14 @@ void test_percentage_radius_reaches_raster_image_scene_clip(webscene_engine* eng if (scene != nullptr) { for (uint32_t index = 0; index < scene->header.command_count; ++index) { const auto& command = scene->commands[index]; - if (command.kind == 6U + if (command.kind == 6U && index > 0U && std::abs(command.width - 40.0F) < 0.01F && std::abs(command.height - 40.0F) < 0.01F - && std::abs(command.radius_top_left - 20.0F) < 0.01F - && std::abs(command.radius_top_right - 20.0F) < 0.01F - && std::abs(command.radius_bottom_right - 20.0F) < 0.01F - && std::abs(command.radius_bottom_left - 20.0F) < 0.01F) { + && scene->commands[index - 1U].kind == 12U + && std::abs(scene->commands[index - 1U].radius_top_left - 20.0F) < 0.01F + && std::abs(scene->commands[index - 1U].radius_top_right - 20.0F) < 0.01F + && std::abs(scene->commands[index - 1U].radius_bottom_right - 20.0F) < 0.01F + && std::abs(scene->commands[index - 1U].radius_bottom_left - 20.0F) < 0.01F) { found = true; break; } diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index ee8ae988c..dab789858 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -389,6 +389,14 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "object-fit-performance") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "object-fit performance engine creation failed"); + test_object_fit_scene_performance_gate(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if(selected=="placeholder-pseudo") { auto* focused_engine=webscene_engine_create(0); require(focused_engine!=nullptr,"placeholder pseudo engine creation failed"); diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index 6b2fb5dc2..996d785e8 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -307,6 +307,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { scene.header.viewportHeight.clamp(1, double.infinity), ), ); + final retainedSvgClips = []; for (var index = 0; index < scene.header.commandCount; index++) { final command = scene.commands[index]; switch (command.kind) { @@ -363,7 +364,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { _drawDomSvgPath(canvas, scene, command, stroke: command.kind == 5); case 6 when foreground: if (!_drawDomRasterBackground(canvas, scene, command)) { - _retainDomSvg(scene, command); + _retainDomSvg(scene, command, retainedSvgClips); } case 7 when !foreground: case 10 when foreground: @@ -389,8 +390,12 @@ final class WebSceneSceneProjector extends ChangeNotifier { case 12: canvas.save(); _clipDomShape(canvas, scene, command); + retainedSvgClips.add(command.flags & _domPolygonClipResource == 0 + ? _domRRect(command) + : null); case 13: canvas.restore(); + if (retainedSvgClips.isNotEmpty) retainedSvgClips.removeLast(); } } return recorder.endRecording(); @@ -467,6 +472,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { void _retainDomSvg( WebSceneSceneView scene, WebSceneSceneCommand command, + List activeClips, ) { final resource = _domString(scene, command.flags); final separator = resource.indexOf('\t'); @@ -494,6 +500,7 @@ final class WebSceneSceneProjector extends ChangeNotifier { width: command.width, height: command.height, rotationDegrees: command.strokeWidth, + clips: activeClips.whereType().toList(growable: false), ), ); _ensureSvgPicture(markup); @@ -570,6 +577,9 @@ final class WebSceneSceneProjector extends ChangeNotifier { final picture = _svgPictures[placement.markup]?.picture; if (picture == null) continue; canvas.save(); + for (final clip in placement.clips) { + canvas.clipRRect(clip, doAntiAlias: true); + } if (placement.rotationDegrees.abs() >= 0.001) { final centerX = placement.x + placement.width / 2; final centerY = placement.y + placement.height / 2; @@ -2644,6 +2654,7 @@ final class _DomSvgPlacement { required this.width, required this.height, required this.rotationDegrees, + required this.clips, }); final String markup; @@ -2653,6 +2664,7 @@ final class _DomSvgPlacement { final double width; final double height; final double rotationDegrees; + final List clips; } final class _SvgPictureEntry { diff --git a/src/WebScene.Backend.Flutter/test/scene_projector_test.dart b/src/WebScene.Backend.Flutter/test/scene_projector_test.dart index 2806730d4..988fac4db 100644 --- a/src/WebScene.Backend.Flutter/test/scene_projector_test.dart +++ b/src/WebScene.Backend.Flutter/test/scene_projector_test.dart @@ -116,7 +116,7 @@ void main() { ); }); - testWidgets('projects and paints complete SVG scene commands', + testWidgets('projects complete SVG scene commands inside retained clips', (tester) async { const markup = ''' @@ -125,7 +125,7 @@ void main() { '''; final resource = utf8.encode('0 0 24 24\t$markup'); final scene = calloc(); - final commands = calloc(); + final commands = calloc(3); final strings = calloc(); final stringBytes = calloc(resource.length); final projector = WebSceneSceneProjector(); @@ -134,13 +134,20 @@ void main() { strings.ref ..byteOffset = 0 ..byteLength = resource.length; - commands.ref + commands[0] + ..kind = 12 + ..x = 8 + ..y = 8 + ..width = 16 + ..height = 16; + commands[1] ..kind = 6 ..flags = 0 ..x = 4 ..y = 4 ..width = 24 ..height = 24; + commands[2].kind = 13; scene.ref ..structSize = sizeOf() ..abiVersion = 2 @@ -159,7 +166,7 @@ void main() { ..baseRevision = 0 ..viewportWidth = 32 ..viewportHeight = 32 - ..commandCount = 1 + ..commandCount = 3 ..canvasLayerCount = 0 ..damageRectCount = 0 ..flags = 3; @@ -183,6 +190,8 @@ void main() { final center = (16 * 32 + 16) * 4; expect(pixels!.getUint8(center), greaterThan(200)); expect(pixels.getUint8(center + 3), greaterThan(200)); + final clipped = (5 * 32 + 5) * 4; + expect(pixels.getUint8(clipped + 3), 0); image!.dispose(); picture.dispose(); } finally { diff --git a/src/WebScene.Css/CssPropertyCatalog.cs b/src/WebScene.Css/CssPropertyCatalog.cs index bdb793f97..96fccf20c 100644 --- a/src/WebScene.Css/CssPropertyCatalog.cs +++ b/src/WebScene.Css/CssPropertyCatalog.cs @@ -11,6 +11,12 @@ namespace WebScene.Css; /// public static class CssPropertyCatalog { + private static readonly string[] CssPositionUnits = + [ + "cqmin", "cqmax", "rem", "cqw", "cqh", "cqi", "cqb", + "px", "em", "vw", "vh", "in", "cm", "mm", "pt", "pc", "q", "%" + ]; + private static readonly FrozenSet s_supported = CssGeneratedPropertyMetadata.SupportedNames .ToFrozenSet(StringComparer.OrdinalIgnoreCase); @@ -76,6 +82,8 @@ public static bool IsValidCssomValue(string propertyName, string value) { "position" => normalizedValue is "static" or "relative" or "absolute" or "fixed" or "sticky" or "-webkit-sticky", + "object-fit" => normalizedValue is "fill" or "contain" or "cover" or "none" or "scale-down", + "object-position" => IsObjectPosition(normalizedValue), "font-size" => IsFontSize(normalizedValue), "color-scheme" => normalizedValue is "normal" or "light" or "dark" or "light dark" or "dark light" or "only light" or "only dark", @@ -146,6 +154,43 @@ private static bool IsInvalidUnitlessLength(string value) && double.IsFinite(numeric) && numeric != 0; + private static bool IsObjectPosition(string value) + { + var tokens = value.Split([' ', '\t', '\r', '\n'], + StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length is not (1 or 2) || !tokens.All(static token => + token is "left" or "right" or "top" or "bottom" or "center" + || token.StartsWith("calc(", StringComparison.Ordinal) + || token.StartsWith("min(", StringComparison.Ordinal) + || token.StartsWith("max(", StringComparison.Ordinal) + || IsCssPositionLength(token))) + { + return false; + } + return tokens.Length == 1 + || !((IsHorizontalPositionKeyword(tokens[0]) && IsHorizontalPositionKeyword(tokens[1])) + || (IsVerticalPositionKeyword(tokens[0]) && IsVerticalPositionKeyword(tokens[1]))); + } + + private static bool IsHorizontalPositionKeyword(string token) => token is "left" or "right"; + + private static bool IsVerticalPositionKeyword(string token) => token is "top" or "bottom"; + + private static bool IsCssPositionLength(string token) + { + if (double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var unitless)) + { + return double.IsFinite(unitless) && unitless == 0; + } + foreach (var unit in CssPositionUnits) + { + if (!token.EndsWith(unit, StringComparison.Ordinal)) continue; + return double.TryParse(token[..^unit.Length], NumberStyles.Float, + CultureInfo.InvariantCulture, out var number) && double.IsFinite(number); + } + return false; + } + private static bool HasOnlyKeywords(string value, params string[] allowed) { var tokens = value.Split([',', ' ', '\t', '\r', '\n'], diff --git a/src/WebScene.Css/CssPropertyMetadata.Generated.cs b/src/WebScene.Css/CssPropertyMetadata.Generated.cs index dbf825397..90ef936c3 100644 --- a/src/WebScene.Css/CssPropertyMetadata.Generated.cs +++ b/src/WebScene.Css/CssPropertyMetadata.Generated.cs @@ -113,6 +113,8 @@ internal static class CssGeneratedPropertyMetadata "outline-width", "grid-template-areas", "aspect-ratio", + "object-fit", + "object-position", ]; internal static readonly string[] SupportedNames = @@ -298,6 +300,8 @@ internal static class CssGeneratedPropertyMetadata "min-height", "min-width", "moz-transform", + "object-fit", + "object-position", "opacity", "order", "orphans", @@ -473,6 +477,8 @@ internal static bool TryGetKnownId(string name, out int id) "outline-width" => 105, "grid-template-areas" => 106, "aspect-ratio" => 107, + "object-fit" => 108, + "object-position" => 109, _ => -1, }; return id >= 0; diff --git a/tests/WebPlatformSubset/capabilities.json b/tests/WebPlatformSubset/capabilities.json index fd5f95190..d3caab535 100644 --- a/tests/WebPlatformSubset/capabilities.json +++ b/tests/WebPlatformSubset/capabilities.json @@ -205,10 +205,10 @@ }, { "family": "html-raster-images", - "required": ["PNG", "JPEG", "GIF", "WebP dimension parsing", "base64 data image URLs", "naturalWidth and naturalHeight", "automatic replaced-image aspect ratio", "retained encoded raster scene publication", "rounded image clipping"], + "required": ["PNG", "JPEG", "GIF", "WebP dimension parsing", "base64 data image URLs", "naturalWidth and naturalHeight", "automatic replaced-image aspect ratio", "retained encoded raster scene publication", "rounded image clipping", "object-fit sizing", "object-position alignment", "content-box clipping"], "productEvidence": ["catalog artwork", "album and artist cards"], - "coverage": ["contracts/html-raster-image-intrinsic-size.html", "test_image_elements_load_and_reach_scene", "appscene_compositor_opacity encoded-raster regression"], - "gap": "The native pipeline preserves encoded raster bytes for the platform Skia decoder and parses intrinsic dimensions for PNG, JPEG, GIF, and WebP. Responsive object-fit/object-position modes, animated image playback, srcset/sizes selection, picture/source negotiation, decoding hints, and CSS raster background images remain outside this bounded claim." + "coverage": ["contracts/html-raster-image-intrinsic-size.html", "contracts/css-object-fit-position.html", "test_image_elements_load_and_reach_scene", "test_object_fit_scene_performance_gate", "appscene_compositor_opacity encoded-raster regression"], + "gap": "The native pipeline preserves encoded raster bytes for the platform Skia decoder, parses intrinsic dimensions for PNG, JPEG, GIF, and WebP, and now authors provider-neutral object-fit/object-position destination and content-clip commands for SVG and raster images. Direct native/browser execution, exact raster/SVG pixels, and the 4,096-image paint-only performance gate remain qualification debt for WebScene #789. Animated image playback, srcset/sizes selection, picture/source negotiation, decoding hints, and CSS raster background images remain outside this bounded claim." }, { "family": "svg-presentation-paint", diff --git a/tests/WebPlatformSubset/contracts/css-object-fit-position.html b/tests/WebPlatformSubset/contracts/css-object-fit-position.html new file mode 100644 index 000000000..3834514b3 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-object-fit-position.html @@ -0,0 +1,54 @@ + + +Replaced images retain object fit and position without changing their boxes + + + + + diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index ad1b587b4..a8e2d7563 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2822,6 +2822,13 @@ "capabilities": ["word-break-layout", "overflow-wrap-layout", "word-wrap-alias", "unicode-break-boundaries", "inherited-line-breaking"], "evidence": ["focused-native-contract-authored", "direct-chromium-contract-authored", "unchanged-code-oss-645f29c-inventory"], "reason": "Implementation-first WebScene #787 contract for retained long-token wrapping used by unchanged Code OSS Markdown preview, dialogs, notifications, hovers, hints, quick input, REPL and editor placeholders. It covers computed CSSOM, the legacy alias, inheritance, mutation/removal, geometry, and a supplementary-plane character. Direct browser/native execution, complete international line breaking, hyphenation, exact product pixels, and performance evidence remain qualification debt." + }, + { + "path": "contracts/css-object-fit-position.html", + "type": "testharness", + "capabilities": ["object-fit-replaced-image-paint", "object-position-alignment", "intrinsic-image-ratio", "content-box-clipping", "object-fit-cssom-mutation"], + "evidence": ["focused-native-scene-contract-authored", "direct-chromium-contract-authored", "unchanged-code-oss-645f29c-inventory"], + "reason": "Implementation-first WebScene #789 contract for the replaced-image fit and alignment used by unchanged Code OSS avatars, account imagery, thumbnails, extension surfaces, and session UI. It covers initial/computed CSSOM, inline mutation/removal, intrinsic dimensions, and stable element geometry; the native companion records cover/contain destination and clip commands. Direct browser/native execution, raster/SVG pixels, exact product regions, and performance evidence remain qualification debt." } ], "harnessBlocked": [], diff --git a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs index 6317867ba..ffd9ada33 100644 --- a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs +++ b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs @@ -51,6 +51,8 @@ public void DoesNotMisclassifyNonColorValueProperties(string propertyName) [InlineData("wordBreak")] [InlineData("overflowWrap")] [InlineData("wordWrap")] + [InlineData("objectFit")] + [InlineData("object-position")] public void ExposesSupportedCssomProperties(string name) => Assert.True(CssPropertyCatalog.IsSupported(name)); @@ -102,6 +104,12 @@ public void DoesNotExposeUnknownOrCustomPropertiesAsIdlAttributes(string name) [InlineData("filter", "unknown(1)", false)] [InlineData("backdrop-filter", "none", true)] [InlineData("backdrop-filter", "blur(8px) saturate(1.08)", true)] + [InlineData("object-fit", "cover", true)] + [InlineData("object-fit", "stretch", false)] + [InlineData("object-position", "right 25%", true)] + [InlineData("object-position", "left right", false)] + [InlineData("object-position", "top bottom", false)] + [InlineData("object-position", "left middle", false)] public void ValidatesCssomValuesWithoutFrameworkKnowledge(string name, string value, bool expected) => Assert.Equal(expected, CssPropertyCatalog.IsValidCssomValue(name, value)); }