From a99cab92a0a4552fc17971f2cc39653095e54e6f 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:28:45 +0200 Subject: [PATCH] Honor retained word breaking for Code OSS --- docs/validation/code-oss-word-breaking-787.md | 42 ++++++++ .../native/css_property_metadata.json | 12 ++- .../webscene_css_property_identity.inc | 20 +++- .../generated/webscene_css_property_ids.inc | 2 + .../webscene_css_supported_properties.inc | 5 +- .../native/webscene_css_application.h | 3 + .../native/webscene_css_cascade_reset.h | 2 + .../native/webscene_css_reset.h | 8 ++ .../native/webscene_css_specified_coverage.h | 3 +- .../native/webscene_css_text_values.h | 8 ++ .../native/webscene_native_dom.cpp | 31 ++++++ .../native/webscene_native_dom.h | 8 ++ .../native/webscene_native_dom_layout.inc | 75 ++++++++++++++ .../native/webscene_native_dom_metrics.inc | 2 + .../native/webscene_native_dom_tree.inc | 91 ++++++++++++++++- .../native/webscene_v8_runtime_style.inc | 48 +++++++++ .../native_v8_runtime_css_layout_tests.inc | 99 +++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 17 ++++ .../CssMutationInvalidationPlanner.cs | 6 +- .../CssPropertyMetadata.Generated.cs | 27 +++-- src/WebScene.Css/CssStylesheetCompiler.cs | 1 + .../contracts/css-word-breaking.html | 41 ++++++++ .../webscene-component-profile.json | 7 ++ .../CssPropertyCatalogTests.cs | 3 + .../CssStylesheetCompilerTests.cs | 13 +++ 25 files changed, 553 insertions(+), 21 deletions(-) create mode 100644 docs/validation/code-oss-word-breaking-787.md create mode 100644 tests/WebPlatformSubset/contracts/css-word-breaking.html diff --git a/docs/validation/code-oss-word-breaking-787.md b/docs/validation/code-oss-word-breaking-787.md new file mode 100644 index 000000000..cfdfd5623 --- /dev/null +++ b/docs/validation/code-oss-word-breaking-787.md @@ -0,0 +1,42 @@ +# Code OSS retained word breaking (#787) + +Date: 2026-09-20 + +## Source baseline + +- WebScene parent: `dd03421e180546c39aa4c272396b36af628d38c5` +- unchanged Code OSS: `645f29cc3176500b4b5762ba887cf2a7f0ffdf2c` +- tracking: WebScene #787, CSS epic #235 + +## Reduced defect + +The retained inline formatter and fallback text painter wrapped only at whitespace. A token wider than its used content box stayed on one line even when author CSS requested `word-break: break-all`, `word-break: break-word`, `overflow-wrap: anywhere`, `overflow-wrap: break-word`, or the legacy `word-wrap` alias. + +The unchanged Code OSS inventory reaches this behavior in Markdown preview, dialogs, notifications, hover and parameter-hint content, quick input, suggestions, REPL/debug surfaces, editor placeholders, comments, and chat/session UI. + +## Implementation + +- Generated CSS property metadata now gives `word-break` and `overflow-wrap` stable native/managed identities. `word-wrap`, `wordWrap`, and compact CSSOM spellings resolve to `overflow-wrap`. +- Both properties retain authored cascade values in the existing cold textual-style allocation and inherit through empty descendant sentinels. No hot `dom_node` field, timer, animation-frame participant, platform widget, document scan, or application CSS rewrite was added. +- Inline assignment/removal, stylesheet application, custom-property resolution, global keywords, `all: unset`, CSSOM computed values, and managed mutation invalidation include the new properties. +- Both retained inline fragments and fallback text lines use one UTF-8-boundary segmenter. It measures each code point once for the initial fit estimate, verifies the final shaped segment, never splits a multibyte sequence, and preserves source byte ranges used by selection/highlight paint. +- `break-all` may use any code-point boundary. `break-word`/`anywhere` use emergency breaks for over-wide tokens. `normal`, `initial`, `revert`, and the currently bounded Latin `keep-all` path preserve whitespace wrapping. + +## Authored gates + +- `tests/WebPlatformSubset/contracts/css-word-breaking.html`: browser-referenced geometry, inheritance, CSSOM aliases, mutation/removal, and supplementary-plane text. +- `test_word_break_and_overflow_wrap_layout`: native retained-layout regression for the same behavior. +- `test_word_break_layout_performance_gate`: 4,096 nodes and ten forced style/layout transitions, with at most one layout pass per transition and a five-second wall-time ceiling. +- The WPT-subset profile records the contract as candidate until direct Chromium/native evidence exists. + +## Evidence status + +No build, test, WPT run, benchmark, visual comparison, memory run, packaged-product run, or CI result was executed for this implementation. Evidence is zero. The gates above are source-authored and must be run during consolidated qualification. + +## Remaining qualification + +1. Run the focused native correctness and performance filters. +2. Run the candidate contract in the pinned Chromium and native WPT lanes. +3. Compare Markdown preview, dialog, notification, hover, parameter-hint, quick-input, suggestion, REPL, and editor-placeholder pixels and geometry with unchanged Chromium Code OSS. +4. Record long-token, mixed-script, resize, mutation, lifecycle, CPU, allocation, scene-publication, and package evidence. +5. Keep complete Unicode line-breaking, grapheme-cluster breaking, hyphenation, and international `keep-all` behavior outside the claim until separately implemented and qualified. diff --git a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json index b6b2435e6..0583158d1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json +++ b/experiments/WebScene.NativeEngine.Probe/native/css_property_metadata.json @@ -137,6 +137,8 @@ {"id": "text_align", "name": "text-align", "aliases": ["textalign"]}, {"id": "text_transform", "name": "text-transform", "aliases": ["texttransform"]}, {"id": "white_space", "name": "white-space", "aliases": ["whitespace"]}, + {"id": "word_break", "name": "word-break", "aliases": ["wordbreak"]}, + {"id": "overflow_wrap", "name": "overflow-wrap", "aliases": ["word-wrap", "overflowwrap", "wordwrap"]}, {"id": "list_style", "name": "list-style"}, {"id": "list_style_position", "name": "list-style-position"}, {"id": "list_style_type", "name": "list-style-type"}, @@ -167,7 +169,7 @@ "keyword": [ "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", "list_style_position", + "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" ], "componentList": [ @@ -205,13 +207,13 @@ "nativeInheritedProperties": [ "all", "color", "cursor", "direction", "font", "font-family", "font-size", "font-style", "font-weight", "letter-spacing", "line-height", "list-style", "list-style-position", "list-style-type", - "text-align", "text-transform", "visibility", "white-space", "word-spacing", "-webkit-font-smoothing" + "text-align", "text-transform", "visibility", "white-space", "word-break", "overflow-wrap", "word-spacing", "-webkit-font-smoothing" ], "nativeMasklessPropertyIds": [ "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", "list_style", "list_style_position", "list_style_type" + "font", "text_transform", "word_break", "overflow_wrap", "list_style", "list_style_position", "list_style_type" ], "managedKnownProperties": [ "align-content", "align-items", "align-self", "all", "background", "background-color", @@ -230,7 +232,7 @@ "order", "overflow", "overflow-x", "overflow-y", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top", "pointer-events", "position", "right", "row-gap", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-width", "text-align", "text-indent", "text-transform", - "top", "transform", "visibility", "white-space", "width", "word-spacing", "z-index", + "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" ], @@ -255,7 +257,7 @@ "moz-transform", "-webkit-transform", "webkit-transform", "-webkit-font-smoothing", "webkit-font-smoothing", "orphans", "resize", "scrollbar-color", "scrollbar-width", "table-layout", "text-anchor", "text-decoration", "text-overflow", "vertical-align", "transition", "transition-delay", "transition-duration", "transition-property", "transition-timing-function", "transform-origin", - "widows", "zoom" + "widows", "word-wrap", "zoom" ], "properties": [ {"name": "border-block-color", "mask": ["inline_border"], "expansion": ["border-top-color", "border-bottom-color"], "applyExpansion": true}, 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 7ac1160ab..35e765a8b 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 @@ -196,6 +196,12 @@ inline constexpr std::array native_typed_property_identity_catalog{ native_typed_property_identity{"texttransform", css_property_id::text_transform}, native_typed_property_identity{"white-space", css_property_id::white_space}, native_typed_property_identity{"whitespace", css_property_id::white_space}, + native_typed_property_identity{"word-break", css_property_id::word_break}, + native_typed_property_identity{"wordbreak", css_property_id::word_break}, + native_typed_property_identity{"overflow-wrap", css_property_id::overflow_wrap}, + native_typed_property_identity{"word-wrap", css_property_id::overflow_wrap}, + native_typed_property_identity{"overflowwrap", css_property_id::overflow_wrap}, + native_typed_property_identity{"wordwrap", css_property_id::overflow_wrap}, native_typed_property_identity{"list-style", css_property_id::list_style}, native_typed_property_identity{"list-style-position", css_property_id::list_style_position}, native_typed_property_identity{"list-style-type", css_property_id::list_style_type}, @@ -414,6 +420,8 @@ inline constexpr std::array native_modeled_property_mask_catalog{ false, false, false, + false, + false, true, true, true, @@ -432,7 +440,7 @@ inline constexpr bool generated_property_has_modeled_mask( && native_modeled_property_mask_catalog[index]; } -inline constexpr std::array native_inherited_property_catalog{ +inline constexpr std::array native_inherited_property_catalog{ "-webkit-font-smoothing", "all", "color", @@ -448,10 +456,12 @@ inline constexpr std::array native_inherited_property_cata "list-style", "list-style-position", "list-style-type", + "overflow-wrap", "text-align", "text-transform", "visibility", "white-space", + "word-break", "word-spacing", }; @@ -603,6 +613,8 @@ inline constexpr std::array native_property_grammar_catalog{ native_property_grammar::keyword, native_property_grammar::keyword, native_property_grammar::keyword, + native_property_grammar::keyword, + native_property_grammar::keyword, native_property_grammar::complex, native_property_grammar::keyword, native_property_grammar::keyword, @@ -818,6 +830,12 @@ inline css_property_id generated_property_id_lowercase(std::string_view name) no if (name == "texttransform") return css_property_id::text_transform; if (name == "white-space") return css_property_id::white_space; if (name == "whitespace") return css_property_id::white_space; + if (name == "word-break") return css_property_id::word_break; + if (name == "wordbreak") return css_property_id::word_break; + if (name == "overflow-wrap") return css_property_id::overflow_wrap; + if (name == "word-wrap") return css_property_id::overflow_wrap; + if (name == "overflowwrap") return css_property_id::overflow_wrap; + if (name == "wordwrap") return css_property_id::overflow_wrap; if (name == "list-style") return css_property_id::list_style; if (name == "list-style-position") return css_property_id::list_style_position; if (name == "list-style-type") return css_property_id::list_style_type; 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 4b50e9bc5..92e47b78e 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 @@ -135,6 +135,8 @@ line_height, text_align, text_transform, white_space, +word_break, +overflow_wrap, list_style, list_style_position, list_style_type, 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 6bce8fe94..d76532f84 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 @@ -195,6 +195,7 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"outline-style", "outlineStyle"}, cssom_supported_property_metadata{"outline-width", "outlineWidth"}, cssom_supported_property_metadata{"overflow", "overflow"}, + cssom_supported_property_metadata{"overflow-wrap", "overflowWrap"}, cssom_supported_property_metadata{"overflow-x", "overflowX"}, cssom_supported_property_metadata{"overflow-y", "overflowY"}, cssom_supported_property_metadata{"padding", "padding"}, @@ -241,9 +242,11 @@ inline constexpr std::array cssom_supported_property_catalog{ cssom_supported_property_metadata{"white-space", "whiteSpace"}, cssom_supported_property_metadata{"widows", "widows"}, cssom_supported_property_metadata{"width", "width"}, + cssom_supported_property_metadata{"word-break", "wordBreak"}, cssom_supported_property_metadata{"word-spacing", "wordSpacing"}, + cssom_supported_property_metadata{"word-wrap", "wordWrap"}, cssom_supported_property_metadata{"z-index", "zIndex"}, cssom_supported_property_metadata{"zoom", "zoom"}, }; -inline constexpr auto cssom_style_template_property_accessor_count = 435U; +inline constexpr auto cssom_style_template_property_accessor_count = 441U; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h index a31f417a6..d37f49753 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_application.h @@ -280,6 +280,9 @@ void apply_declaration(native_document& document,dom_node& node, normalized=authored;normalized->name="transform"; } else if(authored.name=="grid-gap" || authored.name=="grid-row-gap" || authored.name=="grid-column-gap") { normalized=authored;normalized->name=canonical_property_name(authored.name); + } else if (authored.name == "word-wrap") { + normalized = authored; + normalized->name = "overflow-wrap"; } const auto& declaration=normalized?*normalized:authored; if(declaration.name.starts_with("--")) { 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 0a3b5cd0a..c11550375 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_cascade_reset.h @@ -224,6 +224,8 @@ inline void reset_cascaded_style(dom_node& node, if ((node.style.inline_property_mask & inline_white_space) == 0U) { textual->white_space.clear(); } + textual->word_break.clear(); + textual->overflow_wrap.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_reset.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h index ccea8ba7f..31e45d561 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_reset.h @@ -223,6 +223,14 @@ inline void apply_all_unset( reset.mutable_textual().white_space = previous.textual().white_space; } + if (has_inline({"word-break"})) { + reset.mutable_textual().word_break = + previous.textual().word_break; + } + if (has_inline({"overflow-wrap", "word-wrap"})) { + reset.mutable_textual().overflow_wrap = + previous.textual().overflow_wrap; + } // 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 3ca74a0b4..f0d542447 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_css_specified_coverage.h @@ -56,7 +56,8 @@ inline constexpr auto specified_property_samples = std::to_arrayparent) { + const auto& value = current->style.textual().word_break; + if (value.empty() || value == "inherit" || value == "unset") continue; + return value == "initial" || value == "revert" + ? std::string_view{"normal"} : std::string_view{value}; + } + return "normal"; +} + +std::string_view resolved_overflow_wrap(const dom_node& node) +{ + for (auto* current = &node; current != nullptr; current = current->parent) { + const auto& value = current->style.textual().overflow_wrap; + if (value.empty() || value == "inherit" || value == "unset") continue; + return value == "initial" || value == "revert" + ? std::string_view{"normal"} : std::string_view{value}; + } + return "normal"; +} + +bool breaks_inside_words(const dom_node& node, bool token_exceeds_line) +{ + const auto word_break = resolved_word_break(node); + if (word_break == "break-all" || word_break == "break-word") return true; + const auto overflow_wrap = resolved_overflow_wrap(node); + return token_exceeds_line + && (overflow_wrap == "anywhere" || overflow_wrap == "break-word"); +} + bool has_visible_text(const std::string& value) { return std::any_of(value.begin(), value.end(), [](unsigned char character) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 15992227e..bc1b57c3b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -785,6 +785,10 @@ struct node_style final { std::string vertical_align; std::string text_transform; std::string white_space; + // Inherited line-breaking policy. Empty values inherit without adding + // state to descendants; the root resolves both properties to normal. + std::string word_break; + std::string overflow_wrap; std::string contain_value; std::string container_type{"normal"}; std::string container_name{"none"}; @@ -2733,6 +2737,10 @@ class native_document final { float available_width, const dom_node& node, bool allow_wrap) const; + std::vector> break_text_segments( + std::string_view value, + float available_width, + const dom_node& node) const; float resolve_length( const dom_node& context, css_length value, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc index 435468270..9d5565f77 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_layout.inc @@ -1671,6 +1671,67 @@ void native_document::layout_children(dom_node& parent) auto word_width = measure_text_width(word, *run); auto space_width = pending_space && cursor_x > 0 ? measure_text_width(" ", *run) : 0.0F; + const auto break_all = resolved_word_break(*run) == "break-all"; + const auto break_word = breaks_inside_words( + *run, word_width > content.width); + const auto split_inside_word = allow_wrap + && ((break_all + && cursor_x + space_width + word_width > content.width) + || (!break_all && break_word && word_width > content.width)); + if (split_inside_word) { + auto available = std::max( + 0.0F, content.width - cursor_x - space_width); + auto first_segments = break_text_segments(word, available, *run); + if (!first_segments.empty()) { + auto [first_start, first_end] = first_segments.front(); + auto first_text = word.substr( + first_start, first_end - first_start); + const auto first_width = measure_text_width(first_text, *run); + if (cursor_x > 0 && first_width > available) { + start_new_line(); + space_width = 0; + available = content.width; + first_segments = break_text_segments(word, available, *run); + std::tie(first_start, first_end) = first_segments.front(); + first_text = word.substr( + first_start, first_end - first_start); + } else if (space_width > 0) { + append_fragment( + pending_space_run != nullptr ? *pending_space_run : *run, + " ", + space_width, + pending_space_start, + pending_space_end); + } + append_fragment( + *run, + first_text, + measure_text_width(first_text, *run), + word_start + first_start, + word_start + first_end); + auto consumed = first_end; + if (consumed < word.size()) { + const auto remainder = std::string_view(word).substr(consumed); + const auto segments = break_text_segments( + remainder, content.width, *run); + for (const auto [start, end] : segments) { + start_new_line(); + const auto segment = std::string( + remainder.substr(start, end - start)); + append_fragment( + *run, + segment, + measure_text_width(segment, *run), + word_start + consumed + start, + word_start + consumed + end); + } + } + } + word.clear(); + pending_space = false; + pending_space_run = nullptr; + return; + } // Intrinsic sizing and the incremental word/space shaper can // differ at styled inline boundaries. An auto-sized flex item // is using that intrinsic contribution as its main size, so it @@ -4224,6 +4285,20 @@ float native_document::min_content_inline_size( if (!resolved_white_space_wraps(node)) { return constrain(padding + measure_text_width(text, node)); } + const auto word_break = resolved_word_break(node); + const auto overflow_wrap = resolved_overflow_wrap(node); + if (word_break == "break-all" || word_break == "break-word" + || overflow_wrap == "anywhere") { + auto widest_code_point = 0.0F; + for (const auto [start, end] : break_text_segments(text, 0, node)) { + widest_code_point = std::max( + widest_code_point, + measure_text_width( + std::string_view{text}.substr(start, end - start), + node)); + } + return constrain(padding + widest_code_point); + } auto widest_segment = 0.0F; size_t start = 0U; for (size_t index = 0U; index <= text.size(); ++index) { 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 abb163280..751011d88 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -341,6 +341,8 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + textual->vertical_align.capacity() + 1U + textual->text_transform.capacity() + 1U + textual->white_space.capacity() + 1U + + textual->word_break.capacity() + 1U + + textual->overflow_wrap.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_tree.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc index 7623cc50d..f76b07dec 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_tree.inc @@ -191,6 +191,54 @@ size_t native_document::text_caret_offset_at_x( return utf16_offset; } +std::vector> native_document::break_text_segments( + std::string_view value, + float available_width, + const dom_node& node) const +{ + std::vector> result; + if (value.empty()) return result; + + std::vector boundaries; + boundaries.reserve(value.size() + 1U); + boundaries.push_back(0U); + for (size_t offset = 0U; offset < value.size();) { + const auto lead = static_cast(value[offset]); + size_t length = 1U; + if ((lead & 0xE0U) == 0xC0U) length = 2U; + else if ((lead & 0xF0U) == 0xE0U) length = 3U; + else if ((lead & 0xF8U) == 0xF0U) length = 4U; + offset += std::min(length, value.size() - offset); + boundaries.push_back(offset); + } + + const auto limit = std::max(0.0F, available_width); + for (size_t first = 0U; first + 1U < boundaries.size();) { + auto last = first + 1U; + auto approximate_width = measure_text_width( + value.substr(boundaries[first], boundaries[last] - boundaries[first]), node); + while (last + 1U < boundaries.size()) { + const auto next_width = measure_text_width( + value.substr(boundaries[last], boundaries[last + 1U] - boundaries[last]), node); + if (approximate_width + next_width > limit && last > first) break; + approximate_width += next_width; + ++last; + } + auto exact_width = measure_text_width( + value.substr(boundaries[first], boundaries[last] - boundaries[first]), node); + while (last > first + 1U && exact_width > limit) { + --last; + exact_width = measure_text_width( + value.substr(boundaries[first], boundaries[last] - boundaries[first]), node); + } + // Always make progress. A single glyph may be wider than the box, but + // splitting its UTF-8 sequence would corrupt text and source ranges. + result.emplace_back(boundaries[first], boundaries[last]); + first = last; + } + return result; +} + std::vector native_document::wrap_text_lines( const std::string& value, float available_width, @@ -243,11 +291,52 @@ std::vector native_document::wrap_text_lines( std::string current; std::string word; while (source >> word) { + const auto word_width = measure_text_width(word, node); + const auto break_all = resolved_word_break(node) == "break-all"; + const auto break_word = breaks_inside_words( + node, word_width > available_width); if (current.empty()) { - current = std::move(word); + if (break_word && word_width > available_width) { + const auto segments = break_text_segments(word, available_width, node); + for (size_t index = 0; index < segments.size(); ++index) { + const auto [start, end] = segments[index]; + auto segment = word.substr(start, end - start); + if (index + 1U == segments.size()) current = std::move(segment); + else lines.push_back(std::move(segment)); + } + } else { + current = std::move(word); + } } else if (measure_text_width(current + " " + word, node) <= available_width) { current.push_back(' '); current += word; + } else if (break_all || (break_word && word_width > available_width)) { + const auto space_width = measure_text_width(current + " ", node); + const auto remaining = std::max(0.0F, available_width - space_width); + const auto first_segments = break_text_segments(word, remaining, node); + size_t consumed = 0U; + if (!first_segments.empty() && remaining > 0.0F) { + const auto [start, end] = first_segments.front(); + const auto segment = word.substr(start, end - start); + if (measure_text_width(segment, node) <= remaining) { + current.push_back(' '); + current += segment; + consumed = end; + } + } + lines.push_back(std::move(current)); + current.clear(); + if (consumed < word.size()) { + const auto remaining_word = std::string_view(word).substr(consumed); + const auto segments = break_text_segments( + remaining_word, available_width, node); + for (size_t index = 0; index < segments.size(); ++index) { + const auto [start, end] = segments[index]; + auto segment = std::string(remaining_word.substr(start, end - start)); + if (index + 1U == segments.size()) current = std::move(segment); + else lines.push_back(std::move(segment)); + } + } } else { lines.push_back(std::move(current)); current = std::move(word); 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 fda2b41f8..8d85880ec 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_style.inc @@ -36,6 +36,21 @@ return std::string(css::effect_initial_value(name)); } + static std::string resolved_line_break_property( + const dom_node& node, + bool overflow_wrap) + { + for (auto* current = &node; current != nullptr; current = current->parent) { + const auto& value = overflow_wrap + ? current->style.textual().overflow_wrap + : current->style.textual().word_break; + if (!value.empty() && value != "inherit" && value != "unset") { + return value == "initial" || value == "revert" ? "normal" : value; + } + } + return "normal"; + } + static bool clear_inline_style(dom_node& node, const std::string& name) { const auto canonical_name = canonical_css_property_name(name); @@ -399,6 +414,15 @@ textual->white_space.clear(); } node.style.inline_property_mask &= ~inline_white_space; + } else if (name == "wordBreak" || name == "word-break") { + if (auto* textual = node.style.mutable_textual_if_present()) { + textual->word_break.clear(); + } + } else if (name == "overflowWrap" || name == "overflow-wrap" + || name == "wordWrap" || name == "word-wrap") { + if (auto* textual = node.style.mutable_textual_if_present()) { + textual->overflow_wrap.clear(); + } } else { return false; } @@ -1083,6 +1107,12 @@ } else if (name == "white-space") { node->style.mutable_textual().white_space = value; node->style.inline_property_mask |= inline_white_space; + } else if (name == "word-break") { + node->style.mutable_textual().word_break = + value == "inherit" || value == "unset" ? std::string{} : value; + } else if (name == "overflow-wrap") { + node->style.mutable_textual().overflow_wrap = + value == "inherit" || value == "unset" ? std::string{} : value; } self->recompute_inline_font_relative_metrics(*node); sync_style_attribute(*node); @@ -1513,6 +1543,10 @@ value = computed_effect_value(*node, name); } else if (name == "white-space") { value = node->style.textual().white_space; + } else if (name == "word-break") { + value = resolved_line_break_property(*node, false); + } else if (name == "overflow-wrap") { + value = resolved_line_break_property(*node, true); } else if (name == "cursor") { value = resolved_cursor(*node); } else if (name == "font-size") { @@ -2220,6 +2254,14 @@ value = node->style.textual().text_align; } else if (name == "whiteSpace") { value = node->style.textual().white_space; + } else if (name == "wordBreak") { + value = computed + ? resolved_line_break_property(*node, false) + : node->style.textual().word_break; + } else if (name == "overflowWrap" || name == "wordWrap") { + value = computed + ? resolved_line_break_property(*node, true) + : node->style.textual().overflow_wrap; } else if (name == "visibility") { auto* current = node; while (current != nullptr && !current->style.visibility_specified) { @@ -2717,6 +2759,12 @@ } else if (name == "whiteSpace") { node->style.mutable_textual().white_space = value; node->style.inline_property_mask |= inline_white_space; + } else if (name == "wordBreak") { + node->style.mutable_textual().word_break = + value == "inherit" || value == "unset" ? std::string{} : value; + } else if (name == "overflowWrap" || name == "wordWrap") { + node->style.mutable_textual().overflow_wrap = + value == "inherit" || value == "unset" ? std::string{} : value; } self->recompute_inline_font_relative_metrics(*node); sync_style_attribute(*node); 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 565c6b366..78c3171a0 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 @@ -1,3 +1,102 @@ +void test_word_break_and_overflow_wrap_layout(webscene_engine* engine) +{ + resize(engine, 480, 360, 1U); + const auto result = evaluate(engine, R"JS( + (() => { + document.body.innerHTML = ` +
abcdefghijklmnopqrstuvwxyz
+
abcdefghijklmnopqrstuvwxyz
+
abcdefghijklmnopqrstuvwxyz
+
abc😀defghijklmnopqrstuvwxyz
+
abcdefghijklmnopqrstuvwxyz
`; + const normal = document.getElementById('normal'); + const anywhere = document.getElementById('anywhere'); + const legacy = document.getElementById('legacy'); + const all = document.getElementById('all'); + const child = document.getElementById('child'); + const height = node => node.getBoundingClientRect().height; + const before = { + normal: height(normal), + anywhere: height(anywhere), + legacy: height(legacy), + all: height(all), + inherited: height(child), + overflowWrap: getComputedStyle(anywhere).overflowWrap, + wordWrap: getComputedStyle(legacy).wordWrap, + wordBreak: getComputedStyle(all).wordBreak, + unicode: all.textContent + }; + anywhere.style.overflowWrap = 'normal'; + const removed = height(anywhere); + anywhere.style.wordWrap = 'break-word'; + const restored = height(anywhere); + return { + wraps: before.anywhere > before.normal + && before.legacy > before.normal + && before.all > before.normal + && before.inherited > before.normal, + cssom: before.overflowWrap === 'anywhere' + && before.wordWrap === 'break-word' + && before.wordBreak === 'break-all', + mutation: removed === before.normal && restored > removed, + unicode: before.unicode === 'abc😀defghijklmnopqrstuvwxyz' + }; + })() + )JS", "native-word-break-overflow-wrap.js"); + require( + result == R"JSON({"wraps":true,"cssom":true,"mutation":true,"unicode":true})JSON", + "word-break/overflow-wrap retained layout diverged: " + result); +} + +void test_word_break_layout_performance_gate(webscene_engine* engine) +{ + resize(engine, 1024, 768, 1U); + execute(engine, R"JS( + (() => { + document.body.innerHTML = `
`; + globalThis.wordWrapGrid = document.getElementById('word-wrap-grid'); + wordWrapGrid.innerHTML = '
abcdefghi
'.repeat(4096); + globalThis.wordWrapCells = wordWrapGrid.children; + wordWrapCells[4095].getBoundingClientRect(); + })() + )JS", "native-word-break-performance-setup.js"); + 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 = 0; + for (let cycle = 0; cycle < 10; ++cycle) { + wordWrapGrid.className = cycle % 2 === 0 ? 'break' : ''; + checksum += wordWrapCells[4095].getBoundingClientRect().height; + } + return { nodes: wordWrapCells.length, checksum }; + })() + )JS", "native-word-break-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, + "word-break performance fixture did not retain 4,096 nodes: " + result); + require(after.layout_passes <= before.layout_passes + 10U, + "word-break style transitions caused redundant layout passes"); + require(elapsed < 5000.0, + "4,096-node word-break 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_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 820c99160..ee8ae988c 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -373,6 +373,22 @@ int main() test_modal_backdrop_scene(focused_engine); webscene_engine_destroy(focused_engine);return 0; } + if (selected == "word-wrap") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "word-break/overflow-wrap engine creation failed"); + test_word_break_and_overflow_wrap_layout(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } + if (selected == "word-wrap-performance") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "word-break performance engine creation failed"); + test_word_break_layout_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"); @@ -1389,6 +1405,7 @@ int main() test_dimension_custom_property_inheritance(engine); test_geometry_variable_positions(engine); test_modal_backdrop_scene(engine); + test_word_break_and_overflow_wrap_layout(engine); test_placeholder_pseudo_color_and_opacity_reach_scene(engine); test_details_content_pseudo_static_open_close_layout(engine); test_details_content_keyword_size_transition(engine); diff --git a/src/WebScene.Css/CssMutationInvalidationPlanner.cs b/src/WebScene.Css/CssMutationInvalidationPlanner.cs index 007f4cbab..56d542ee5 100644 --- a/src/WebScene.Css/CssMutationInvalidationPlanner.cs +++ b/src/WebScene.Css/CssMutationInvalidationPlanner.cs @@ -28,7 +28,8 @@ public static class CssMutationInvalidationPlanner "font-size", "font-style", "font-variant", "font-weight", "letter-spacing", "line-height", "list-style", "list-style-position", "list-style-type", "pointer-events", "stroke", "stroke-linecap", "stroke-linejoin", "stroke-opacity", "stroke-width", - "text-align", "text-indent", "text-transform", "visibility", "white-space", "word-spacing" + "text-align", "text-indent", "text-transform", "visibility", "white-space", "word-break", + "overflow-wrap", "word-wrap", "word-spacing" }; private static readonly HashSet s_layoutProperties = new(StringComparer.OrdinalIgnoreCase) @@ -39,7 +40,8 @@ public static class CssMutationInvalidationPlanner "grid-template-columns", "grid-template-rows", "grid-template-areas", "grid-area", "grid-row", "grid-row-start", "grid-row-end", "grid-column", "grid-column-start", "grid-column-end", "align-content", "align-items", "align-self", "justify-content", - "gap", "list-style", "list-style-position", "list-style-type", "order", "row-gap", "column-gap", "z-index", "white-space" + "gap", "list-style", "list-style-position", "list-style-type", "order", "row-gap", "column-gap", "z-index", "white-space", + "word-break", "overflow-wrap", "word-wrap" }; internal static IReadOnlySet InheritedProperties => s_inheritedProperties; diff --git a/src/WebScene.Css/CssPropertyMetadata.Generated.cs b/src/WebScene.Css/CssPropertyMetadata.Generated.cs index 03c4a38af..dbf825397 100644 --- a/src/WebScene.Css/CssPropertyMetadata.Generated.cs +++ b/src/WebScene.Css/CssPropertyMetadata.Generated.cs @@ -101,6 +101,8 @@ internal static class CssGeneratedPropertyMetadata "transform", "visibility", "white-space", + "word-break", + "overflow-wrap", "width", "word-spacing", "z-index", @@ -305,6 +307,7 @@ internal static class CssGeneratedPropertyMetadata "outline-style", "outline-width", "overflow", + "overflow-wrap", "overflow-x", "overflow-y", "padding", @@ -351,7 +354,9 @@ internal static class CssGeneratedPropertyMetadata "white-space", "widows", "width", + "word-break", "word-spacing", + "word-wrap", "z-index", "zoom", ]; @@ -456,16 +461,18 @@ internal static bool TryGetKnownId(string name, out int id) "transform" => 93, "visibility" => 94, "white-space" => 95, - "width" => 96, - "word-spacing" => 97, - "z-index" => 98, - "outline" => 99, - "outline-color" => 100, - "outline-offset" => 101, - "outline-style" => 102, - "outline-width" => 103, - "grid-template-areas" => 104, - "aspect-ratio" => 105, + "word-break" => 96, + "overflow-wrap" => 97, + "width" => 98, + "word-spacing" => 99, + "z-index" => 100, + "outline" => 101, + "outline-color" => 102, + "outline-offset" => 103, + "outline-style" => 104, + "outline-width" => 105, + "grid-template-areas" => 106, + "aspect-ratio" => 107, _ => -1, }; return id >= 0; diff --git a/src/WebScene.Css/CssStylesheetCompiler.cs b/src/WebScene.Css/CssStylesheetCompiler.cs index ea4269ae0..ccff662da 100644 --- a/src/WebScene.Css/CssStylesheetCompiler.cs +++ b/src/WebScene.Css/CssStylesheetCompiler.cs @@ -522,6 +522,7 @@ private static string NormalizePropertyName(string name) "grid-gap" => "gap", "grid-row-gap" => "row-gap", "grid-column-gap" => "column-gap", + "word-wrap" => "overflow-wrap", var normalized => normalized }; } diff --git a/tests/WebPlatformSubset/contracts/css-word-breaking.html b/tests/WebPlatformSubset/contracts/css-word-breaking.html new file mode 100644 index 000000000..9f5815e5c --- /dev/null +++ b/tests/WebPlatformSubset/contracts/css-word-breaking.html @@ -0,0 +1,41 @@ + + +Word breaking CSS keeps long retained text inside used boxes + + + +
abcdefghijklmnopqrstuvwxyz
+
abcdefghijklmnopqrstuvwxyz
+
abcdefghijklmnopqrstuvwxyz
+
abc😀defghijklmnopqrstuvwxyz
+
abcdefghijklmnopqrstuvwxyz
+ diff --git a/tests/WebPlatformSubset/webscene-component-profile.json b/tests/WebPlatformSubset/webscene-component-profile.json index 7c92e9aee..ad1b587b4 100644 --- a/tests/WebPlatformSubset/webscene-component-profile.json +++ b/tests/WebPlatformSubset/webscene-component-profile.json @@ -2815,6 +2815,13 @@ "capabilities": ["css-starting-style-rule", "entry-transition-cascade", "display-none-reentry", "allow-discrete-display", "host-clock-opacity-transform-transition"], "evidence": ["native-parser-contract", "v8-free-native-cascade-contract", "focused-live-runtime-contract", "unchanged-code-oss-645f29c-inventory"], "reason": "Implementation-first WebScene #749 contract for the two @starting-style groups used by unchanged Code OSS. It checks grouping-rule CSSOM, deterministic starting values, discrete display deferral, re-entry, bounded lifecycle state, and host-clock settlement. Direct browser/native profile execution and exact product pixels remain qualification debt." + }, + { + "path": "contracts/css-word-breaking.html", + "type": "testharness", + "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." } ], "harnessBlocked": [], diff --git a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs index 2b7de8c64..6317867ba 100644 --- a/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs +++ b/tests/WebScene.Css.Tests/CssPropertyCatalogTests.cs @@ -48,6 +48,9 @@ public void DoesNotMisclassifyNonColorValueProperties(string propertyName) [InlineData("clipPath")] [InlineData("filter")] [InlineData("backdropFilter")] + [InlineData("wordBreak")] + [InlineData("overflowWrap")] + [InlineData("wordWrap")] public void ExposesSupportedCssomProperties(string name) => Assert.True(CssPropertyCatalog.IsSupported(name)); diff --git a/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs b/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs index cccc6cd46..e7a123bd4 100644 --- a/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs +++ b/tests/WebScene.Css.Tests/CssStylesheetCompilerTests.cs @@ -38,6 +38,19 @@ public void PreservesScrollbarThumbInteractionStateAfterParserProtection() && declaration.Value == "blue" && declaration.Important); } + [Fact] + public void CanonicalizesLegacyWordWrapAlias() + { + var result = CssStylesheetCompiler.Compile( + ".markdown { word-wrap: break-word; word-break: break-all; }"); + + var declarations = Assert.Single(result.Rules).Declarations; + Assert.Contains(declarations, static declaration => + declaration.Name == "overflow-wrap" && declaration.Value == "break-word"); + Assert.Contains(declarations, static declaration => + declaration.Name == "word-break" && declaration.Value == "break-all"); + } + [Fact] public void CompilerProducesPortableSelectorsDeclarationsAndNestedMedia() {