diff --git a/docs/validation/css-url-clip-20260919.md b/docs/validation/css-url-clip-20260919.md new file mode 100644 index 00000000..f7cd6119 --- /dev/null +++ b/docs/validation/css-url-clip-20260919.md @@ -0,0 +1,49 @@ +# Same-document SVG URL clip — implementation checkpoint + +Date: 2026-09-19 + +## Scope + +The consumer slice of issue #504 resolves only `clip-path: url(#fragment)` in +the current DOM tree. The fragment must identify a connected `clipPath` in the +same document and shadow scope. Quoted fragments are accepted; external URLs, +empty IDs, controls, whitespace, transforms, nested groups, uses, and unknown +geometry fail closed. + +The native scene compiler flattens a bounded set of direct `path`, `rect`, +`circle`, `ellipse`, and `polygon` children into the existing kind-12 SVG path +resource. It caps IDs at 256 bytes, children at 64, polygon coordinates at 256, +individual authored path data and the final flattened path at 16 KiB. Mixed or +unsupported clip rules fail closed. + +Kind-12 bit 28 identifies `objectBoundingBox` units. Skia and Flutter translate +the retained path to the target border-box origin and scale normalized geometry +by its width and height. User-space geometry uses the existing relative path +projection. The low 28 flag bits remain the scene-string index, and visual +equality includes every high metadata bit plus flattened resource text. + +## Authored regression coverage + +- The native retained-scene fixture creates a connected object-bounding-box + clip definition and asserts the flattened resource and ABI flags. +- The Skia backend fixture proves normalized half-width geometry scales and + translates into the target reference box. +- The browser contract preserves the same-document fragment computed value. + +These tests were authored but not executed under the current implementation +throughput directive. + +## Explicit boundary + +External-document and network clip resources, transforms, rounded SVG rects, +nested groups, `use`, text, strokes, animation, and mixed per-child fill rules +remain unsupported and fail closed. Add those only as separately bounded +provider/consumer work; do not broaden this fragment resolver implicitly. + +## Required release gates + +Before release promotion, run focused native/backend tests, malformed and +maximum-size cases, fragment replacement/removal/reparenting and shadow-scope +cases, Skia/Flutter pixel comparisons against the same Chromium fixture, +4,096-reference publication and memory benchmarks, detach/reattach cleanup, +the exact SDK/CLI package, and relevant CI from the final merged heads. 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 d32addc7..de9fee41 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -700,6 +700,7 @@ void native_document::append_scene( std::string path; bool even_odd{}; bool relative_path{}; + bool object_bounding_box{}; }; const auto resolve_effect_clip = [&]() -> std::optional { const auto& effects = node.style.textual().effect_values; @@ -711,6 +712,7 @@ void native_document::append_scene( constexpr std::string_view ellipse_prefix = "ellipse("; constexpr std::string_view path_prefix = "path("; constexpr std::string_view polygon_prefix = "polygon("; + constexpr std::string_view url_prefix = "url("; if (value.empty() || value.back() != ')') return std::nullopt; const auto has_prefix = [&](std::string_view prefix) { if (value.size() < prefix.size() + 1U) return false; @@ -820,6 +822,14 @@ void native_document::append_scene( 0.0F, path.str()}; } + const auto invalid_resource_clip = [&]() { + return effect_clip_geometry{ + layout_rect{node.layout.x, node.layout.y, node.layout.width, node.layout.height}, + 0.0F, + "M0 0", + false, + true}; + }; if (has_prefix(path_prefix)) { auto arguments = value.substr( path_prefix.size(), value.size() - path_prefix.size() - 1U); @@ -856,7 +866,7 @@ void native_document::append_scene( if (arguments.size() < 2U || (arguments.front() != '\'' && arguments.front() != '"') || arguments.back() != arguments.front()) { - return std::nullopt; + return invalid_resource_clip(); } const auto quote = arguments.front(); auto encoded = arguments.substr(1U, arguments.size() - 2U); @@ -866,13 +876,13 @@ void native_document::append_scene( const auto character = static_cast(encoded[index]); if (character == static_cast(quote) || character == '\n' || character == '\r' || character == '\f') { - return std::nullopt; + return invalid_resource_clip(); } if (character != '\\') { decoded.push_back(static_cast(character)); continue; } - if (++index == encoded.size()) return std::nullopt; + if (++index == encoded.size()) return invalid_resource_clip(); if (encoded[index] == '\n') continue; if (encoded[index] == '\r') { if (index + 1U < encoded.size() && encoded[index + 1U] == '\n') ++index; @@ -890,7 +900,9 @@ void native_document::append_scene( hexadecimal = hexadecimal * 16U + nibble; } if (digits != 0U) { - if (hexadecimal == 0U || hexadecimal > 0x7FU) return std::nullopt; + if (hexadecimal == 0U || hexadecimal > 0x7FU) { + return invalid_resource_clip(); + } decoded.push_back(static_cast(hexadecimal)); if (index < encoded.size() && std::isspace(static_cast(encoded[index]))) { @@ -903,7 +915,9 @@ void native_document::append_scene( } decoded.push_back(encoded[index]); } - if (decoded.empty() || decoded.size() > 16384U) return std::nullopt; + if (decoded.empty() || decoded.size() > 16384U) { + return invalid_resource_clip(); + } return effect_clip_geometry{ layout_rect{ node.layout.x, @@ -915,6 +929,198 @@ void native_document::append_scene( even_odd, true}; } + if (has_prefix(url_prefix)) { + const auto trim = [](std::string_view token) { + while (!token.empty() + && std::isspace(static_cast(token.front()))) { + token.remove_prefix(1U); + } + while (!token.empty() + && std::isspace(static_cast(token.back()))) { + token.remove_suffix(1U); + } + return token; + }; + auto reference = trim(value.substr( + url_prefix.size(), value.size() - url_prefix.size() - 1U)); + if (reference.size() >= 2U + && (reference.front() == '\'' || reference.front() == '"') + && reference.back() == reference.front()) { + reference = trim(reference.substr(1U, reference.size() - 2U)); + } + if (reference.size() < 2U || reference.size() > 257U + || reference.front() != '#') { + return invalid_resource_clip(); + } + reference.remove_prefix(1U); + if (reference.empty() + || std::any_of(reference.begin(), reference.end(), [](unsigned char byte) { + return std::isspace(byte) || std::iscntrl(byte); + })) { + return invalid_resource_clip(); + } + const auto definition = std::find_if( + nodes_.begin(), nodes_.end(), [&](const auto& candidate) { + if (candidate->id_attribute != reference) return false; + constexpr std::string_view expected = "clippath"; + if (candidate->tag.size() != expected.size()) return false; + for (size_t index = 0U; index < expected.size(); ++index) { + if (!ascii_equal(candidate->tag[index], expected[index])) return false; + } + return true; + }); + if (definition == nodes_.end() + || containing_shadow_root(node) + != containing_shadow_root(**definition)) { + return invalid_resource_clip(); + } + const auto& clip = **definition; + const auto root = [](const dom_node& candidate) { + auto* current = &candidate; + while (current->parent != nullptr) current = current->parent; + return current; + }; + if (root(node) != root(clip)) return invalid_resource_clip(); + if (clip.attributes.contains("transform") || clip.children.size() > 64U) { + return invalid_resource_clip(); + } + auto object_bounding_box = false; + auto units = clip.attributes.find("clipPathUnits"); + if (units == clip.attributes.end()) { + units = clip.attributes.find("clippathunits"); + } + if (units != clip.attributes.end()) { + auto normalized = units->second; + std::transform( + normalized.begin(), normalized.end(), normalized.begin(), + [](unsigned char byte) { return static_cast(std::tolower(byte)); }); + if (normalized == "objectboundingbox") object_bounding_box = true; + else if (normalized != "userspaceonuse") return invalid_resource_clip(); + } + const auto number = [&](const dom_node& shape, std::string_view name, + float fallback) -> std::optional { + const auto found = shape.attributes.find(std::string{name}); + if (found == shape.attributes.end()) return fallback; + if (found->second.size() > 64U) return std::nullopt; + const auto token = trim(found->second); + auto copy = std::string{token}; + char* end = nullptr; + const auto parsed = std::strtof(copy.c_str(), &end); + while (end != nullptr && *end != '\0' + && std::isspace(static_cast(*end))) ++end; + if (end == copy.c_str() || end == nullptr || *end != '\0' + || !std::isfinite(parsed)) return std::nullopt; + return parsed; + }; + std::ostringstream path; + path.precision(std::numeric_limits::max_digits10); + auto fill_rule = std::optional{}; + size_t geometry_count = 0U; + const auto apply_rule = [&](const dom_node& shape) { + const auto authored = shape.attributes.find("clip-rule"); + if (authored == shape.attributes.end()) return true; + auto normalized = authored->second; + std::transform( + normalized.begin(), normalized.end(), normalized.begin(), + [](unsigned char byte) { return static_cast(std::tolower(byte)); }); + const auto even_odd = normalized == "evenodd"; + if (!even_odd && normalized != "nonzero") return false; + if (fill_rule.has_value() && *fill_rule != even_odd) return false; + fill_rule = even_odd; + return true; + }; + if (!apply_rule(clip)) return invalid_resource_clip(); + for (const auto* child : clip.children) { + if (child == nullptr || child->tag == "#text") continue; + if (child->tag == "title" || child->tag == "desc") continue; + if (child->attributes.contains("transform") || !apply_rule(*child)) { + return invalid_resource_clip(); + } + if (++geometry_count > 64U) return invalid_resource_clip(); + if (child->tag == "path") { + const auto data = child->attributes.find("d"); + if (data == child->attributes.end() || data->second.empty() + || data->second.size() > 16384U) return invalid_resource_clip(); + path << data->second << ' '; + } else if (child->tag == "rect") { + if (child->attributes.contains("rx") || child->attributes.contains("ry")) { + return invalid_resource_clip(); + } + const auto x = number(*child, "x", 0.0F); + const auto y = number(*child, "y", 0.0F); + const auto width = number(*child, "width", -1.0F); + const auto height = number(*child, "height", -1.0F); + if (!x || !y || !width || !height || *width < 0.0F || *height < 0.0F) { + return invalid_resource_clip(); + } + path << 'M' << *x << ' ' << *y << 'H' << *x + *width + << 'V' << *y + *height << 'H' << *x << "Z "; + } else if (child->tag == "circle" || child->tag == "ellipse") { + const auto cx = number(*child, "cx", 0.0F); + const auto cy = number(*child, "cy", 0.0F); + const auto rx = number( + *child, child->tag == "circle" ? "r" : "rx", -1.0F); + const auto ry = child->tag == "circle" + ? rx : number(*child, "ry", -1.0F); + if (!cx || !cy || !rx || !ry || *rx < 0.0F || *ry < 0.0F) { + return invalid_resource_clip(); + } + path << 'M' << *cx - *rx << ' ' << *cy + << 'A' << *rx << ' ' << *ry << " 0 1 0 " + << *cx + *rx << ' ' << *cy + << 'A' << *rx << ' ' << *ry << " 0 1 0 " + << *cx - *rx << ' ' << *cy << "Z "; + } else if (child->tag == "polygon") { + const auto authored = child->attributes.find("points"); + if (authored == child->attributes.end() + || authored->second.empty() || authored->second.size() > 4096U) { + return invalid_resource_clip(); + } + std::vector points; + const auto* cursor = authored->second.c_str(); + const auto* limit = cursor + authored->second.size(); + while (cursor < limit) { + while (cursor < limit + && (std::isspace(static_cast(*cursor)) + || *cursor == ',')) ++cursor; + if (cursor == limit) break; + char* end = nullptr; + const auto parsed = std::strtof(cursor, &end); + if (end == cursor || !std::isfinite(parsed) || points.size() >= 256U) { + return invalid_resource_clip(); + } + points.push_back(parsed); + cursor = end; + } + if (points.size() < 4U || (points.size() & 1U) != 0U) { + return invalid_resource_clip(); + } + path << 'M' << points[0] << ' ' << points[1]; + for (size_t index = 2U; index < points.size(); index += 2U) { + path << 'L' << points[index] << ' ' << points[index + 1U]; + } + path << "Z "; + } else { + return invalid_resource_clip(); + } + const auto path_size = path.tellp(); + if (path_size == std::streampos{-1} + || static_cast(path_size) > 16384) { + return invalid_resource_clip(); + } + } + auto flattened = path.str(); + if (geometry_count == 0U || flattened.empty() || flattened.size() > 16384U) { + return invalid_resource_clip(); + } + return effect_clip_geometry{ + layout_rect{node.layout.x, node.layout.y, node.layout.width, node.layout.height}, + 0.0F, + std::move(flattened), + fill_rule.value_or(false), + true, + object_bounding_box}; + } if (has_prefix(polygon_prefix)) { auto arguments = value.substr( polygon_prefix.size(), value.size() - polygon_prefix.size() - 1U); @@ -1034,11 +1240,13 @@ void native_document::append_scene( constexpr uint32_t polygon_clip_resource = 1U << 31U; constexpr uint32_t clip_even_odd = 1U << 30U; constexpr uint32_t clip_relative_path = 1U << 29U; + constexpr uint32_t clip_object_bounding_box = 1U << 28U; const auto flags = effect_clip->path.empty() ? 0U : polygon_clip_resource | (effect_clip->even_odd ? clip_even_odd : 0U) | (effect_clip->relative_path ? clip_relative_path : 0U) + | (effect_clip->object_bounding_box ? clip_object_bounding_box : 0U) | append_scene_string(effect_clip->path, strings, string_bytes); commands.push_back(webscene_scene_command{ 12U, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index c376d803..b4524d8c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -153,8 +153,9 @@ typedef struct webscene_scene_header { // Shadow kinds 17/18: flags bit 0 selects an inverse rounded hole; // producers must bracket inverse shadows with clip commands 12/13. // Clip kind 12 uses flags bit 31 to select an SVG path stored in the indexed -// scene string. Bit 30 selects even-odd fill and bit 29 makes path coordinates -// relative to the command box origin; the remaining bits are its string index. +// scene string. Bit 30 selects even-odd fill, bit 29 makes path coordinates +// relative to the command box origin, and bit 28 scales objectBoundingBox path +// units by the command box; the remaining bits are its string index. // A zero flag retains the rounded-rectangle fields used by existing producers // and presenters. // Group kind 30 uses flags bit 31 for brightness, bit 30 for grayscale, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc index 9cbdcb1e..4c7453f4 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_scene_utils.inc @@ -91,7 +91,7 @@ std::string_view command_dom_string( if (!command_uses_dom_string(command)) { return {}; } - constexpr auto clip_index_mask = (1U << 29U) - 1U; + constexpr auto clip_index_mask = (1U << 28U) - 1U; const auto index = command.kind == 12U ? command.flags & clip_index_mask : command.flags; @@ -127,7 +127,7 @@ bool same_dom_command_visual( return false; } if (command_uses_dom_string(next)) { - constexpr auto clip_index_mask = (1U << 29U) - 1U; + constexpr auto clip_index_mask = (1U << 28U) - 1U; if (next.kind == 12U && (previous.flags & ~clip_index_mask) != (next.flags & ~clip_index_mask)) { return false; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp index cc672cab..6891ef76 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_css_effect_values_tests.cpp @@ -63,6 +63,7 @@ struct clip_scene_counts final { bool transform_clip_nested{}; bool compound_filter_ordered{}; bool path_clip_metadata{}; + bool url_clip_metadata{}; }; clip_scene_counts wait_for_inset_clip_scene( @@ -146,19 +147,23 @@ clip_scene_counts wait_for_inset_clip_scene( && std::abs(command.width - 8.0F) < 0.01F && std::abs(command.height - 2.0F) < 0.01F) { ++latest.path_clip_begins; - constexpr auto path_index_mask = (1U << 29U) - 1U; + constexpr auto path_index_mask = (1U << 28U) - 1U; const auto path_index = command.flags & path_index_mask; - if ((command.flags & (1U << 30U)) != 0U - && (command.flags & (1U << 29U)) != 0U + if ((command.flags & (1U << 29U)) != 0U && path_index < scene->string_count) { const auto& resource = scene->strings[path_index]; if (resource.byte_offset <= scene->string_byte_count && resource.byte_length <= scene->string_byte_count - resource.byte_offset) { - latest.path_clip_metadata = std::string_view( + const auto data = std::string_view( scene->string_bytes + resource.byte_offset, - resource.byte_length) - == "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z"; + resource.byte_length); + if ((command.flags & (1U << 28U)) != 0U) { + latest.url_clip_metadata = data == "M0 0H0.5V1H0Z "; + } else if ((command.flags & (1U << 30U)) != 0U) { + latest.path_clip_metadata = data + == "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z"; + } } } } else if (command.kind == 13U) { @@ -297,6 +302,7 @@ int main() #effects > span:first-child { transform: scale(1.25) rotate(3deg); } #ellipse-clip { clip-path: ellipse(25% 50% at 50% 50%); } #path-clip { clip-path: path(evenodd, "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z"); } + #url-clip { clip-path: url(#local-clip); } #functional-blur { filter: blur(max(4px, calc(8px * 0.25))); } #compound-filter { filter: blur(2px) saturate(1.08) contrast(1.5) grayscale(0.25); } #effects > span:last-child { filter: blur(2px); } @@ -309,9 +315,25 @@ int main() host.appendChild(fragment); host.children[1].id = 'ellipse-clip'; host.children[2].id = 'path-clip'; + host.children[3].id = 'url-clip'; host.children[4093].id = 'functional-blur'; host.children[4094].id = 'compound-filter'; document.body.appendChild(host); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.style.display = 'none'; + const definitions = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const clip = document.createElementNS('http://www.w3.org/2000/svg', 'clipPath'); + clip.id = 'local-clip'; + clip.setAttribute('clipPathUnits', 'objectBoundingBox'); + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('x', '0'); + rect.setAttribute('y', '0'); + rect.setAttribute('width', '0.5'); + rect.setAttribute('height', '1'); + clip.appendChild(rect); + definitions.appendChild(clip); + svg.appendChild(definitions); + document.body.appendChild(svg); const first = host.firstElementChild; const style = getComputedStyle(first); if (style.getPropertyValue('filter') !== 'brightness(0.5)' @@ -343,6 +365,10 @@ int main() !== 'path(evenodd, "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z")') { throw new Error('initial path clip value failed'); } + if (getComputedStyle(document.getElementById('url-clip')).getPropertyValue('clip-path') + !== 'url(#local-clip)') { + throw new Error('initial local URL clip value failed'); + } })() )JS", "native-effects-fixture.js"); @@ -400,9 +426,9 @@ int main() peak_memory.native_dom_textual_style_storage_bytes - before_memory.native_dom_textual_style_storage_bytes; - const auto initial_clip_scene = wait_for_inset_clip_scene(engine, 4094U, 4096U); - require(initial_clip_scene.inset_clip_begins == 4094U, - "retained scene did not emit 4094 inset clip begin commands"); + const auto initial_clip_scene = wait_for_inset_clip_scene(engine, 4093U, 4096U); + require(initial_clip_scene.inset_clip_begins == 4093U, + "retained scene did not emit 4093 inset clip begin commands"); require(initial_clip_scene.inset_clip_ends == 4096U, "retained scene did not emit 4096 balanced inset clip end commands"); require(initial_clip_scene.clipped_fills == 4096U, @@ -411,9 +437,10 @@ int main() "transform commands did not wrap the inset clip scope"); require(initial_clip_scene.ellipse_clip_begins == 1U, "retained scene did not emit the explicit ellipse path clip"); - require(initial_clip_scene.path_clip_begins == 1U - && initial_clip_scene.path_clip_metadata, - "retained scene did not emit the relative even-odd CSS path clip"); + require(initial_clip_scene.path_clip_begins == 2U + && initial_clip_scene.path_clip_metadata + && initial_clip_scene.url_clip_metadata, + "retained scene did not emit CSS path and local URL clips"); require(initial_clip_scene.blur_filter_begins == 2U, "retained scene did not emit both bounded foreground blur groups"); require(initial_clip_scene.compound_filter_ordered, @@ -552,6 +579,7 @@ int main() << " ellipse-clip-begins=" << initial_clip_scene.ellipse_clip_begins << " path-clip-begins=" << initial_clip_scene.path_clip_begins << " path-clip-metadata=" << initial_clip_scene.path_clip_metadata + << " url-clip-metadata=" << initial_clip_scene.url_clip_metadata << " clipped-fills=" << initial_clip_scene.clipped_fills << " blur-filter-begins=" << initial_clip_scene.blur_filter_begins << " functional-blur-begins=" << initial_clip_scene.functional_blur_begins diff --git a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs index 6072960d..fc35d425 100644 --- a/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs +++ b/src/WebScene.Backend.Avalonia/NativeCanvasSceneRenderer.cs @@ -45,8 +45,10 @@ internal sealed unsafe partial class NativeCanvasSceneRenderer private const uint DomPolygonClipResource = 1u << 31; private const uint DomClipEvenOdd = 1u << 30; private const uint DomClipRelativePath = 1u << 29; + private const uint DomClipObjectBoundingBox = 1u << 28; private const uint DomPolygonClipIndexMask = - ~(DomPolygonClipResource | DomClipEvenOdd | DomClipRelativePath); + ~(DomPolygonClipResource | DomClipEvenOdd | DomClipRelativePath + | DomClipObjectBoundingBox); private const uint DomBrightnessFilter = 1u << 31; private const uint DomGrayscaleFilter = 1u << 30; private const uint DomContrastFilter = 1u << 29; @@ -1982,49 +1984,80 @@ private static void ClipDomShape( ClipDomRoundedRect(canvas, command, radii); return; } - var relative = (command.Flags & DomClipRelativePath) != 0; + ClipDomPath( + canvas, + DomStringAt(view, command.Flags & DomPolygonClipIndexMask), + (command.Flags & DomClipEvenOdd) != 0, + (command.Flags & DomClipRelativePath) != 0, + (command.Flags & DomClipObjectBoundingBox) != 0, + command.X, + command.Y, + command.Width, + command.Height); + } + + private static void ClipDomPath( + SKCanvas canvas, + string pathData, + bool evenOdd = false, + bool relative = false, + bool objectBoundingBox = false, + float x = 0, + float y = 0, + float width = 0, + float height = 0) + { + using var path = SKPath.ParseSvgPathData(pathData); + if (path is null || (objectBoundingBox && (width <= 0 || height <= 0))) + { + canvas.ClipRect(SKRect.Empty, SKClipOperation.Intersect, antialias: false); + return; + } + path.FillType = evenOdd ? SKPathFillType.EvenOdd : SKPathFillType.Winding; if (relative) { - canvas.Translate(command.X, command.Y); + canvas.Translate(x, y); + } + if (objectBoundingBox) + { + canvas.Scale(width, height); } try { - ClipDomPath( - canvas, - DomStringAt(view, command.Flags & DomPolygonClipIndexMask), - (command.Flags & DomClipEvenOdd) != 0); + canvas.ClipPath(path, SKClipOperation.Intersect, antialias: true); } finally { // Restoring a saved canvas would also restore the prior clip. Undo // only the matrix so the device-space clip remains active. + if (objectBoundingBox) + { + canvas.Scale(1 / width, 1 / height); + } if (relative) { - canvas.Translate(-command.X, -command.Y); + canvas.Translate(-x, -y); } } } - private static void ClipDomPath( - SKCanvas canvas, - string pathData, - bool evenOdd = false) - { - using var path = SKPath.ParseSvgPathData(pathData); - if (path is null) - { - canvas.ClipRect(SKRect.Empty, SKClipOperation.Intersect, antialias: false); - return; - } - path.FillType = evenOdd ? SKPathFillType.EvenOdd : SKPathFillType.Winding; - canvas.ClipPath(path, SKClipOperation.Intersect, antialias: true); - } - internal static void ClipDomPathForTest( SKCanvas canvas, string pathData, - bool evenOdd = false) - => ClipDomPath(canvas, pathData, evenOdd); + bool evenOdd = false, + bool objectBoundingBox = false, + float x = 0, + float y = 0, + float width = 0, + float height = 0) + => ClipDomPath( + canvas, pathData, evenOdd, + relative: objectBoundingBox, + objectBoundingBox: objectBoundingBox, + x: x, + y: y, + width: width, + height: height); private void DrawDomText( SKCanvas canvas, diff --git a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart index b1f7cf37..d098660c 100644 --- a/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart +++ b/src/WebScene.Backend.Flutter/lib/src/scene_projector.dart @@ -21,7 +21,8 @@ const int _canvasEvenOdd = 1 << 16; const int _domPolygonClipResource = 1 << 31; const int _domClipEvenOdd = 1 << 30; const int _domClipRelativePath = 1 << 29; -const int _domPolygonClipIndexMask = _domClipRelativePath - 1; +const int _domClipObjectBoundingBox = 1 << 28; +const int _domPolygonClipIndexMask = _domClipObjectBoundingBox - 1; const int _domBrightnessFilter = 1 << 31; const int _domGrayscaleFilter = 1 << 30; const int _domContrastFilter = 1 << 29; @@ -971,12 +972,22 @@ final class WebSceneSceneProjector extends ChangeNotifier { path.fillType = ui.PathFillType.evenOdd; } final relative = (command.flags & _domClipRelativePath) != 0; + final objectBoundingBox = + (command.flags & _domClipObjectBoundingBox) != 0; + if (objectBoundingBox && (command.width <= 0 || command.height <= 0)) { + canvas.clipRect(ui.Rect.zero, doAntiAlias: false); + return; + } if (relative) canvas.translate(command.x, command.y); + if (objectBoundingBox) canvas.scale(command.width, command.height); try { canvas.clipPath(path, doAntiAlias: true); } finally { // Restoring a saved canvas would also discard the clip. Undo only the // matrix so the clip remains in device coordinates. + if (objectBoundingBox) { + canvas.scale(1 / command.width, 1 / command.height); + } if (relative) canvas.translate(-command.x, -command.y); } } catch (_) { diff --git a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html index a9c18a60..e7454ebd 100644 --- a/tests/WebPlatformSubset/contracts/css-retained-effect-values.html +++ b/tests/WebPlatformSubset/contracts/css-retained-effect-values.html @@ -22,6 +22,13 @@
+