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
53 changes: 53 additions & 0 deletions docs/validation/css-svg-mask-20260919.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# URL-backed CSS SVG masks — implementation checkpoint

Date: 2026-09-19

## Scope

Issue #500 adds a focused consumer on top of the mask-shorthand provider in
#499 / PR #501. It keeps unchanged Code OSS `mask` and `-webkit-mask` icon
assets inside the existing native CSS resource policy and retained scene.

The native engine loads an allowed SVG resource through the same host-backed
loader used by CSS backgrounds. A successful load publishes one versioned
`webscene-mask-svg-v1` string containing the view box, repeat, position, size,
and immutable SVG markup. Kind 30 opens the existing isolated effect group and
kind 47 applies the mask before the group closes. Failed or unsupported mask
resources publish `webscene-mask-invalid-v1`, which clears the isolated group
instead of exposing the unmasked foreground.

The Skia projection reuses `SharedSvgPictureCache`, paints every tile into one
bounded destination-in layer, and supports the existing SVG renderer. The
Flutter projection has no asynchronous SVG decoder on this command path, so it
uses a deliberately bounded synchronous geometry cache for untransformed,
filled path, rect, circle, ellipse, and polygon elements. Unsupported groups,
definitions, uses, images, text, line/polyline, strokes, transforms, and
nonzero-incompatible fill rules fail closed.

## Authored regression coverage

- The native retained-scene fixture loads one policy-approved SVG and asserts
a kind-47 `webscene-mask-svg-v1` command with exact geometry longhands.
- The Skia backend fixture repeats a half-filled 4 by 4 SVG across a 12 by 4
foreground and asserts the three retained islands plus transparent gaps.
- Existing shorthand and computed-value contracts cover standard/WebKit alias
expansion, mutation, omitted-component reset, and retained longhand values.

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

## Explicit boundary

This slice does not claim raster masks, multiple mask layers, luminance mode,
non-add composites, SVG stroke/group/transform parity in Flutter, or controlled
ServiceWorker-backed asynchronous mask resource replacement. Those forms fail
closed or remain separately scheduled under #256.

## Required release gates

Before release promotion, run the focused native and backend tests, the full
browser/native contract set, Skia/Flutter pixel comparison against the same
Chromium fixture, resource-policy and mutation tests, 4,096-icon decode/cache
and scene-publication benchmarks, 100 replacement/detach cycles with memory
return-to-baseline checks, exact SDK/CLI packaging, and relevant CI jobs from
the final merged heads.
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,47 @@ template<typename Decision,typename Protected,typename LoadSvg>
bool apply_paint_value(dom_node& node,const std::string& name,const std::string& value,
Decision& decision,Protected&& is_inline,LoadSvg&& load_svg)
{
if (name == "box-shadow" && !is_inline(inline_box_shadow)) {
if (name == "mask-image") {
auto& effects = node.style.mutable_textual().effect_values;
effects.erase("-webscene-mask-markup");
effects.erase("-webscene-mask-view-box");
effects.erase("-webscene-mask-resolved-url");
const auto normalized = normalize_effect_value(name, value);
if (!normalized.has_value()) {
decision.classification = "unsupported";
decision.semantic_slice = "invalid retained-effect syntax";
return true;
}
effects[name] = *normalized;
const auto url = first_css_url(*normalized);
if (!url.has_value()) {
decision.classification = "partially-supported";
decision.semantic_slice =
"syntax and computed value; retained-scene paint is separately qualified";
return true;
}
std::string markup;
std::string resolved_url;
std::string view_box;
if (!load_svg(*url, markup, resolved_url, view_box)) {
decision.classification = "unsupported";
decision.semantic_slice = "URL-backed SVG mask resource load failed";
return true;
}
if (view_box.empty()) {
decision.classification = "unsupported";
decision.semantic_slice =
"SVG masks with an explicit viewBox or numeric width and height";
return true;
}
effects[name] = "url(\"" + resolved_url + "\")";
effects["-webscene-mask-resolved-url"] = std::move(resolved_url);
effects["-webscene-mask-markup"] = std::move(markup);
effects["-webscene-mask-view-box"] = std::move(view_box);
decision.classification = "partially-supported";
decision.semantic_slice =
"single URL-backed SVG alpha mask with explicit viewBox or numeric dimensions";
} else if (name == "box-shadow" && !is_inline(inline_box_shadow)) {
auto complete = true;
if (!apply_box_shadow_value(node.style, value, complete)) {
decision.classification = "unsupported";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,67 +467,111 @@ void native_document::append_scene(
}
return result;
}();
const auto linear_mask_resource = [&]() -> std::optional<uint32_t> {
const auto mask_resource = [&]() -> std::optional<uint32_t> {
const auto& effects = node.style.textual().effect_values;
const auto known = effects.find("mask-image");
if (known == effects.end()) return std::nullopt;
auto image = std::string_view{known->second};
while (!image.empty()
&& std::isspace(static_cast<unsigned char>(image.front())))
image.remove_prefix(1U);
while (!image.empty()
&& std::isspace(static_cast<unsigned char>(image.back())))
image.remove_suffix(1U);
constexpr auto prefix = std::string_view{"linear-gradient("};
if (image.size() <= prefix.size()) return std::nullopt;
for (size_t index = 0U; index < prefix.size(); ++index) {
const auto character = static_cast<unsigned char>(image[index]);
const auto lower = character >= 'A' && character <= 'Z'
? static_cast<char>(character - 'A' + 'a')
: static_cast<char>(character);
if (lower != prefix[index]) return std::nullopt;
}
auto depth = 1U;
auto close = std::string_view::npos;
for (size_t index = prefix.size(); index < image.size(); ++index) {
if (image[index] == '(') ++depth;
else if (image[index] == ')' && --depth == 0U) {
close = index;
break;
const auto trim = [](std::string_view value) {
while (!value.empty()
&& std::isspace(static_cast<unsigned char>(value.front()))) {
value.remove_prefix(1U);
}
while (!value.empty()
&& std::isspace(static_cast<unsigned char>(value.back()))) {
value.remove_suffix(1U);
}
return value;
};
const auto starts_with_ascii_case_insensitive = [](
std::string_view value, std::string_view prefix) {
if (value.size() < prefix.size()) return false;
for (size_t index = 0U; index < prefix.size(); ++index) {
const auto character = static_cast<unsigned char>(value[index]);
const auto lower = character >= 'A' && character <= 'Z'
? static_cast<char>(character - 'A' + 'a')
: static_cast<char>(character);
if (lower != prefix[index]) return false;
}
return true;
};
image = trim(image);
if (image.size() == 4U
&& starts_with_ascii_case_insensitive(image, "none")) {
return std::nullopt;
}
if (close != image.size() - 1U) return std::nullopt;
const auto invalid = [&]() {
return append_scene_string(
"webscene-mask-invalid-v1", strings, string_bytes);
};
const auto value = [&](std::string_view name, std::string_view fallback) {
const auto found = effects.find(std::string{name});
return found == effects.end() ? std::string{fallback} : found->second;
};
const auto composite = value("mask-composite", "add");
if (composite != "add") return std::nullopt;
if (composite != "add") return invalid();
const auto mode = value("mask-mode", "match-source");
if (mode != "match-source" && mode != "alpha") return std::nullopt;
if (mode != "match-source" && mode != "alpha") return invalid();
auto repeat = value("mask-repeat", "repeat");
for (auto& character : repeat) {
const auto byte = static_cast<unsigned char>(character);
if (byte >= 'A' && byte <= 'Z') character = static_cast<char>(byte - 'A' + 'a');
if (byte >= 'A' && byte <= 'Z') {
character = static_cast<char>(byte - 'A' + 'a');
}
}
if (repeat != "repeat" && repeat != "no-repeat"
&& repeat != "repeat-x" && repeat != "repeat-y") {
return std::nullopt;
return invalid();
}
const auto position = value("mask-position", "0% 0%");
const auto size = value("mask-size", "auto");
if (position.find(',') != std::string::npos
|| size.find(',') != std::string::npos) {
return std::nullopt;
return invalid();
}
auto resource = std::string{"webscene-bg-v2\t"};
resource.append(image);

constexpr auto gradient_prefix = std::string_view{"linear-gradient("};
if (starts_with_ascii_case_insensitive(image, gradient_prefix)) {
auto depth = 1U;
auto close = std::string_view::npos;
for (size_t index = gradient_prefix.size(); index < image.size(); ++index) {
if (image[index] == '(') ++depth;
else if (image[index] == ')' && --depth == 0U) {
close = index;
break;
}
}
if (close != image.size() - 1U) return invalid();
auto resource = std::string{"webscene-bg-v2\t"};
resource.append(image);
resource += '\t';
resource += repeat;
resource += '\t';
resource += position;
resource += '\t';
resource += size;
return append_scene_string(resource, strings, string_bytes);
}

if (!starts_with_ascii_case_insensitive(image, "url(")) {
return invalid();
}
const auto markup = effects.find("-webscene-mask-markup");
const auto view_box = effects.find("-webscene-mask-view-box");
if (markup == effects.end() || markup->second.empty()
|| view_box == effects.end() || view_box->second.empty()) {
return invalid();
}
auto resource = std::string{"webscene-mask-svg-v1\t"};
resource += view_box->second;
resource += '\t';
resource += repeat;
resource += '\t';
resource += position;
resource += '\t';
resource += size;
resource += '\t';
resource += markup->second;
return append_scene_string(resource, strings, string_bytes);
}();
const auto has_opacity_group = opacity < 0.999F;
Expand All @@ -545,7 +589,7 @@ void native_document::append_scene(
255L)),
node.id});
}
if (linear_mask_resource.has_value()) {
if (mask_resource.has_value()) {
commands.push_back(webscene_scene_command{
30U,
1U << 26U,
Expand Down Expand Up @@ -2216,9 +2260,9 @@ void native_document::append_scene(
31U, 0U, node.layout.x, node.layout.y,
node.layout.width, node.layout.height, 0U, node.id});
}
if (linear_mask_resource.has_value()) {
if (mask_resource.has_value()) {
commands.push_back(webscene_scene_command{
47U, *linear_mask_resource,
47U, *mask_resource,
node.layout.x, node.layout.y,
node.layout.width, node.layout.height,
0U, node.id});
Expand Down Expand Up @@ -2584,9 +2628,9 @@ void native_document::append_scene(
31U, 0U, node.layout.x, node.layout.y,
node.layout.width, node.layout.height, 0U, node.id});
}
if (linear_mask_resource.has_value()) {
if (mask_resource.has_value()) {
commands.push_back(webscene_scene_command{
47U, *linear_mask_resource,
47U, *mask_resource,
node.layout.x, node.layout.y,
node.layout.width, node.layout.height,
0U, node.id});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,14 @@ typedef struct webscene_scene_header {
// 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 linear mask.
// bit 26 opens a neutral isolated layer for a following kind-47 alpha mask.
// stroke_width carries
// the bounded non-negative multiplier or CSS blur standard deviation.
// A zero flag retains the opacity-group alpha stored in the low byte of rgba.
// DOM kind 47 applies the indexed webscene-bg-v2 linear-gradient resource to
// the current isolated layer using destination-in before kind 31 restores it.
// DOM kind 47 applies an indexed webscene-bg-v2 linear gradient or
// webscene-mask-svg-v1 tiled SVG resource to the current isolated layer using
// destination-in before kind 31 restores it. webscene-mask-invalid-v1 clears
// the isolated layer for failed or unsupported authored masks.
typedef struct webscene_scene_command {
uint32_t kind;
uint32_t flags;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,11 +604,21 @@ void test_svg_background_image_reaches_scene_with_position_and_size(webscene_eng
background-position: 0 2px;
background-size: 48px auto;
}
.svg-mask {
width: 100px;
height: 60px;
background: rgb(20, 80, 160);
-webkit-mask: url("badge.svg") no-repeat 4px 6px / 48px auto;
mask: url("badge.svg") no-repeat 4px 6px / 48px auto;
}
`;
document.body.appendChild(style);
const target = document.createElement('div');
target.className = 'svg-background';
document.body.appendChild(target);
const masked = document.createElement('div');
masked.className = 'svg-mask';
document.body.appendChild(masked);
})()
)JS", "native-svg-background-setup.js");
require(
Expand All @@ -626,6 +636,7 @@ void test_svg_background_image_reaches_scene_with_position_and_size(webscene_eng

webscene_engine_request_scene_checkpoint(engine);
auto found = false;
auto found_mask = false;
for (auto attempt = 0; attempt < 100; ++attempt) {
const auto* scene = webscene_engine_acquire_latest_scene(engine);
if (scene != nullptr) {
Expand All @@ -645,20 +656,35 @@ void test_svg_background_image_reaches_scene_with_position_and_size(webscene_eng
&& bytes.find("\tno-repeat\t0 2px\t48px auto\t")
!= std::string_view::npos
&& bytes.find("<svg") != std::string_view::npos;
break;
} else if (command.kind == 47U
&& std::abs(command.width - 100.0F) < 0.01F
&& std::abs(command.height - 60.0F) < 0.01F
&& command.flags < scene->string_count) {
const auto resource = scene->strings[command.flags];
const std::string_view bytes(
scene->string_bytes + resource.byte_offset,
resource.byte_length);
found_mask = bytes.starts_with(
"webscene-mask-svg-v1\t0 0 2 1\t")
&& bytes.find("\tno-repeat\t4px 6px\t48px auto\t")
!= std::string_view::npos
&& bytes.find("<svg") != std::string_view::npos;
}
}
webscene_scene_acknowledge(scene);
webscene_scene_release(scene);
}
if (found) break;
if (found && found_mask) 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,
"URL-backed SVG background did not retain its 100x60 paint box and longhands");
require(
found_mask,
"URL-backed SVG mask did not retain its paint box, source, and longhands");
}

void test_generated_pseudo_font_family_scene_and_scale_gate();
Expand Down
Loading
Loading