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 00000000..108b44cf
--- /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 0583158d..8d3440ca 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 35e765a8..930127cd 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 92e47b78..622e693b 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 d76532f8..7020a4ac 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 d37f4975..06052f8a 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 c1155037..2b9b410d 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 00000000..c076ba1e
--- /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 31e45d56..974cb2fe 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 f0d54244..2458db4d 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 bc1b57c3..56ede843 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 751011d8..50599e32 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 d777e02f..e2821746 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 c76c107c..2c4d0fa6 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 8d85880e..07aae2ec 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 f3aa8ea2..8aae6071 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 208c190b..37f016c9 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 78c3171a..cce80d7e 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 6b70b116..0f4eb88d 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 ee8ae988..dab78985 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 6b2fb5dc..996d785e 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 2806730d..988fac4d 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 = '''