Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/validation/css-path-clip-20260919.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# CSS `path()` clip — implementation checkpoint

Date: 2026-09-19

## Scope

The provider slice of issue #504 projects CSS `clip-path: path(...)` through
the existing retained kind-12 clip command. It accepts one quoted SVG path,
an optional `evenodd` or `nonzero` fill rule, bounded CSS string escapes, and
a maximum decoded path length of 16,384 bytes. Path coordinates are relative
to the element border-box origin.

Kind 12 retains bit 31 for a string-backed SVG path, adds bit 30 for even-odd
fill and bit 29 for border-box-relative coordinates, and uses the low 29 bits
as the scene-string index. Skia and Flutter consume the same flags. Invalid or
unparseable SVG data produces an empty clip instead of exposing unclipped
content.

Scene equality now compares kind-12 resource text and its metadata flags. This
prevents fill-rule or coordinate-space changes with identical path text from
being mistaken for an unchanged visual, while allowing string-table indices to
change without unnecessary damage when the effective resource is identical.

## Authored regression coverage

- The browser contract preserves quoted path syntax and the even-odd rule.
- The Skia backend fixture verifies that even-odd clipping cuts a transparent
inner region from a filled outer path.
- Existing retained scene contracts remain the integration point for kind-12
resource identity and localized publication behavior.

These tests were authored but not executed under the current implementation
throughput directive.

## Explicit boundary

This provider does not resolve `url(#clip)` or external SVG clip resources.
The same-document URL consumer remains the second PR in #504. Arbitrary CSS
shape commands, geometry boxes outside the current border-box behavior, and
unbounded path/resource inputs remain unsupported or fail closed.

## Required release gates

Before release promotion, run the focused native/backend tests, WPT-derived
contracts, malformed and maximum-size inputs, fill-rule and mutation damage
cases, Skia/Flutter pixel comparisons against the same Chromium fixture,
4,096-path publication and memory benchmarks, repeated detach/reattach cleanup,
the exact SDK/CLI package, and the relevant CI jobs from the final merged heads.
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,8 @@ void native_document::append_scene(
layout_rect bounds;
float radius{};
std::string path;
bool even_odd{};
bool relative_path{};
};
const auto resolve_effect_clip = [&]() -> std::optional<effect_clip_geometry> {
const auto& effects = node.style.textual().effect_values;
Expand All @@ -707,6 +709,7 @@ void native_document::append_scene(
constexpr std::string_view inset_prefix = "inset(";
constexpr std::string_view circle_prefix = "circle(";
constexpr std::string_view ellipse_prefix = "ellipse(";
constexpr std::string_view path_prefix = "path(";
constexpr std::string_view polygon_prefix = "polygon(";
if (value.empty() || value.back() != ')') return std::nullopt;
const auto has_prefix = [&](std::string_view prefix) {
Expand Down Expand Up @@ -817,6 +820,101 @@ void native_document::append_scene(
0.0F,
path.str()};
}
if (has_prefix(path_prefix)) {
auto arguments = value.substr(
path_prefix.size(), value.size() - path_prefix.size() - 1U);
const auto trim = [](std::string_view token) {
while (!token.empty()
&& std::isspace(static_cast<unsigned char>(token.front()))) {
token.remove_prefix(1U);
}
while (!token.empty()
&& std::isspace(static_cast<unsigned char>(token.back()))) {
token.remove_suffix(1U);
}
return token;
};
arguments = trim(arguments);
auto even_odd = false;
constexpr auto even_odd_prefix = std::string_view{"evenodd"};
constexpr auto nonzero_prefix = std::string_view{"nonzero"};
const auto consume_fill_rule = [&](std::string_view prefix, bool value)
-> bool {
if (arguments.size() < prefix.size()) return false;
for (size_t index = 0U; index < prefix.size(); ++index) {
if (!ascii_equal(arguments[index], prefix[index])) return false;
}
auto remaining = trim(arguments.substr(prefix.size()));
if (remaining.empty() || remaining.front() != ',') return false;
arguments = trim(remaining.substr(1U));
even_odd = value;
return true;
};
if (!consume_fill_rule(even_odd_prefix, true)) {
consume_fill_rule(nonzero_prefix, false);
}
if (arguments.size() < 2U
|| (arguments.front() != '\'' && arguments.front() != '"')
|| arguments.back() != arguments.front()) {
return std::nullopt;
}
const auto quote = arguments.front();
auto encoded = arguments.substr(1U, arguments.size() - 2U);
std::string decoded;
decoded.reserve(encoded.size());
for (size_t index = 0U; index < encoded.size(); ++index) {
const auto character = static_cast<unsigned char>(encoded[index]);
if (character == static_cast<unsigned char>(quote)
|| character == '\n' || character == '\r' || character == '\f') {
return std::nullopt;
}
if (character != '\\') {
decoded.push_back(static_cast<char>(character));
continue;
}
if (++index == encoded.size()) return std::nullopt;
if (encoded[index] == '\n') continue;
if (encoded[index] == '\r') {
if (index + 1U < encoded.size() && encoded[index + 1U] == '\n') ++index;
continue;
}
auto hexadecimal = uint32_t{};
auto digits = size_t{};
for (; index < encoded.size() && digits < 6U; ++index, ++digits) {
const auto byte = static_cast<unsigned char>(encoded[index]);
uint32_t nibble = 0U;
if (byte >= '0' && byte <= '9') nibble = byte - '0';
else if (byte >= 'a' && byte <= 'f') nibble = byte - 'a' + 10U;
else if (byte >= 'A' && byte <= 'F') nibble = byte - 'A' + 10U;
else break;
hexadecimal = hexadecimal * 16U + nibble;
}
if (digits != 0U) {
if (hexadecimal == 0U || hexadecimal > 0x7FU) return std::nullopt;
decoded.push_back(static_cast<char>(hexadecimal));
if (index < encoded.size()
&& std::isspace(static_cast<unsigned char>(encoded[index]))) {
if (encoded[index] == '\r' && index + 1U < encoded.size()
&& encoded[index + 1U] == '\n') ++index;
} else if (index != 0U) {
--index;
}
continue;
}
decoded.push_back(encoded[index]);
}
if (decoded.empty() || decoded.size() > 16384U) return std::nullopt;
return effect_clip_geometry{
layout_rect{
node.layout.x,
node.layout.y,
node.layout.width,
node.layout.height},
0.0F,
std::move(decoded),
even_odd,
true};
}
if (has_prefix(polygon_prefix)) {
auto arguments = value.substr(
polygon_prefix.size(), value.size() - polygon_prefix.size() - 1U);
Expand All @@ -831,6 +929,22 @@ void native_document::append_scene(
}
return token;
};
auto even_odd = false;
const auto first_comma = arguments.find(',');
if (first_comma != std::string_view::npos) {
const auto possible_rule = trim(arguments.substr(0U, first_comma));
const auto is_rule = [&](std::string_view expected) {
if (possible_rule.size() != expected.size()) return false;
for (size_t index = 0U; index < expected.size(); ++index) {
if (!ascii_equal(possible_rule[index], expected[index])) return false;
}
return true;
};
if (is_rule("evenodd") || is_rule("nonzero")) {
even_odd = is_rule("evenodd");
arguments = trim(arguments.substr(first_comma + 1U));
}
}
std::ostringstream path;
path.precision(std::numeric_limits<float>::max_digits10);
size_t point_count = 0U;
Expand Down Expand Up @@ -873,7 +987,8 @@ void native_document::append_scene(
node.layout.width,
node.layout.height},
0.0F,
path.str()};
path.str(),
even_odd};
}
if (!has_prefix(inset_prefix)) return std::nullopt;
const std::string_view arguments(value.data() + 6U, value.size() - 7U);
Expand Down Expand Up @@ -917,9 +1032,13 @@ void native_document::append_scene(
const auto effect_clip = resolve_effect_clip();
if (effect_clip.has_value()) {
constexpr uint32_t polygon_clip_resource = 1U << 31U;
constexpr uint32_t clip_even_odd = 1U << 30U;
constexpr uint32_t clip_relative_path = 1U << 29U;
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)
| append_scene_string(effect_clip->path, strings, string_bytes);
commands.push_back(webscene_scene_command{
12U,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,10 @@ 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; the remaining bits are its string index. A zero flag retains
// the rounded-rectangle fields used by existing producers and presenters.
// 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.
// 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,
// bit 29 for contrast, bit 28 for foreground blur, and bit 27 for saturation;
// bit 26 opens a neutral isolated layer for a following kind-47 alpha mask.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,24 @@ void store_maximum(std::atomic<uint64_t>& target, uint64_t value)

bool command_uses_dom_string(const webscene_scene_command& command)
{
return (command.kind >= 3U && command.kind <= 6U) || command.kind == 47U;
return (command.kind >= 3U && command.kind <= 6U)
|| (command.kind == 12U && (command.flags & (1U << 31U)) != 0U)
|| command.kind == 47U;
}

std::string_view command_dom_string(
const scene& owner,
const webscene_scene_command& command)
{
if (!command_uses_dom_string(command)
|| command.flags >= owner.canvas_strings.size()) {
if (!command_uses_dom_string(command)) {
return {};
}
const auto& value = owner.canvas_strings[command.flags];
constexpr auto clip_index_mask = (1U << 29U) - 1U;
const auto index = command.kind == 12U
? command.flags & clip_index_mask
: command.flags;
if (index >= owner.canvas_strings.size()) return {};
const auto& value = owner.canvas_strings[index];
if (value.byte_offset > owner.canvas_string_bytes.size()
|| value.byte_length > owner.canvas_string_bytes.size() - value.byte_offset) {
return {};
Expand Down Expand Up @@ -121,6 +127,11 @@ bool same_dom_command_visual(
return false;
}
if (command_uses_dom_string(next)) {
constexpr auto clip_index_mask = (1U << 29U) - 1U;
if (next.kind == 12U
&& (previous.flags & ~clip_index_mask) != (next.flags & ~clip_index_mask)) {
return false;
}
return command_dom_string(previous_owner, previous)
== command_dom_string(next_owner, next);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ struct clip_scene_counts final {
uint32_t inset_clip_begins{};
uint32_t inset_clip_ends{};
uint32_t ellipse_clip_begins{};
uint32_t path_clip_begins{};
uint32_t clipped_fills{};
uint32_t blur_filter_begins{};
uint32_t functional_blur_begins{};
uint32_t linear_mask_commands{};
uint32_t command_count{};
bool transform_clip_nested{};
bool compound_filter_ordered{};
bool path_clip_metadata{};
};

clip_scene_counts wait_for_inset_clip_scene(
Expand Down Expand Up @@ -139,6 +141,26 @@ clip_scene_counts wait_for_inset_clip_scene(
&& std::abs(command.width - 4.0F) < 0.01F
&& std::abs(command.height - 2.0F) < 0.01F) {
++latest.ellipse_clip_begins;
} else if (command.kind == 12U
&& (command.flags & (1U << 31U)) != 0U
&& 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;
const auto path_index = command.flags & path_index_mask;
if ((command.flags & (1U << 30U)) != 0U
&& (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(
scene->string_bytes + resource.byte_offset,
resource.byte_length)
== "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z";
}
}
} else if (command.kind == 13U) {
++latest.inset_clip_ends;
} else if ((command.kind == 1U || command.kind == 9U)
Expand Down Expand Up @@ -274,6 +296,7 @@ int main()
#effects.alternate > span { clip-path: circle(25%); filter: contrast(2); }
#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"); }
#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); }
Expand All @@ -285,6 +308,7 @@ int main()
for (let index = 0; index < 4096; index++) fragment.appendChild(document.createElement('span'));
host.appendChild(fragment);
host.children[1].id = 'ellipse-clip';
host.children[2].id = 'path-clip';
host.children[4093].id = 'functional-blur';
host.children[4094].id = 'compound-filter';
document.body.appendChild(host);
Expand Down Expand Up @@ -315,6 +339,10 @@ int main()
!== 'ellipse(25% 50% at 50% 50%)') {
throw new Error('initial ellipse clip value failed');
}
if (getComputedStyle(document.getElementById('path-clip')).getPropertyValue('clip-path')
!== 'path(evenodd, "M0 0 H8 V2 H0 Z M2 .5 H6 V1.5 H2 Z")') {
throw new Error('initial path clip value failed');
}
})()
)JS", "native-effects-fixture.js");

Expand Down Expand Up @@ -372,9 +400,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, 4095U, 4096U);
require(initial_clip_scene.inset_clip_begins == 4095U,
"retained scene did not emit 4095 inset clip begin commands");
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");
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,
Expand All @@ -383,6 +411,9 @@ 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.blur_filter_begins == 2U,
"retained scene did not emit both bounded foreground blur groups");
require(initial_clip_scene.compound_filter_ordered,
Expand Down Expand Up @@ -519,6 +550,8 @@ int main()
<< " clip-begins=" << initial_clip_scene.inset_clip_begins
<< " clip-ends=" << initial_clip_scene.inset_clip_ends
<< " 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
<< " 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
Expand Down
Loading
Loading