Skip to content

Commit e77d4d2

Browse files
websem: close the SVG <feColorMatrix> row
Close the static SVG feColorMatrix element row with 27 exact Chromium cells and three named precision patrols. The corpus is 502 baked cells plus 10 sampled frames; the refusal register has 131 rows.
1 parent 6e4808d commit e77d4d2

75 files changed

Lines changed: 1558 additions & 23 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎crates/n0/src/drawlist.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ pub(crate) enum ResolvedFilterPrimitive {
102102
sigma_y: f32,
103103
color: n0_model::model::Color32F,
104104
},
105+
ColorMatrix {
106+
matrix: [f32; 20],
107+
},
105108
Merge,
106109
}
107110

‎crates/n0/src/glyphless.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,9 @@ fn compile_filter(program: &FilterProgram, region: math2::Rectangle) -> Resolved
922922
sigma_y,
923923
color: compile_color32f(color),
924924
},
925+
FilterPrimitive::ColorMatrix { matrix } => {
926+
ResolvedFilterPrimitive::ColorMatrix { matrix }
927+
}
925928
FilterPrimitive::Merge => ResolvedFilterPrimitive::Merge,
926929
},
927930
})

‎crates/n0/src/paint.rs‎

Lines changed: 192 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,6 +1909,20 @@ struct BuiltFilterResult {
19091909
/// exact byte-domain SrcOver. Native sRGB shadows reach Skia's lowp 8888
19101910
/// restore, whose default division rounds differently on NEON and x86.
19111911
requires_exact_restore: bool,
1912+
/// A matrix node exposes a second measured final-layer boundary in the
1913+
/// pinned backend. Source-derived matrix output needs an explicit floating
1914+
/// SrcOver; generated-only matrix output needs the backend default.
1915+
matrix_restore: Option<MatrixRestore>,
1916+
/// Chromium snapshots source-derived matrix input through one additional
1917+
/// source-image boundary unless the matrix creates alpha from transparent
1918+
/// input. Preserve that measured boundary through later graph nodes.
1919+
matrix_source_preflatten: bool,
1920+
}
1921+
1922+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1923+
enum MatrixRestore {
1924+
Default,
1925+
Floating,
19121926
}
19131927

19141928
struct BuiltFilter {
@@ -1917,6 +1931,7 @@ struct BuiltFilter {
19171931
/// between their internal nodes. Source-derived coverage stays on Skia's
19181932
/// floating restore path.
19191933
restore_blender: Option<Blender>,
1934+
matrix_source_preflatten: bool,
19201935
}
19211936

19221937
fn source_alpha_filter() -> Option<ImageFilter> {
@@ -1959,6 +1974,10 @@ fn convert_filter_space(
19591974
// A color-space conversion resumes the floating filter path; its
19601975
// eventual restore must not be forced through the N32 shadow rule.
19611976
requires_exact_restore: false,
1977+
// Gamma conversion materializes a new floating result and ends the
1978+
// matrix-specific sRGB restore boundary.
1979+
matrix_restore: None,
1980+
matrix_source_preflatten: result.matrix_source_preflatten,
19621981
})
19631982
}
19641983

@@ -2093,6 +2112,8 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
20932112
color_space: ResolvedFilterColorSpace::Srgb,
20942113
source_dependent: true,
20952114
requires_exact_restore: false,
2115+
matrix_restore: None,
2116+
matrix_source_preflatten: false,
20962117
};
20972118
let source_alpha =
20982119
BuiltFilterResult {
@@ -2102,6 +2123,8 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
21022123
color_space: ResolvedFilterColorSpace::Srgb,
21032124
source_dependent: true,
21042125
requires_exact_restore: false,
2126+
matrix_restore: None,
2127+
matrix_source_preflatten: false,
21052128
};
21062129
let mut results: Vec<BuiltFilterResult> = Vec::with_capacity(filter.nodes.len());
21072130
for node in filter.nodes.iter() {
@@ -2120,6 +2143,36 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
21202143
let crop = Rect::from_xywh(node.region.x, node.region.y, node.region.w, node.region.h);
21212144
let source_dependent = inputs.iter().any(|input| input.source_dependent);
21222145
let requires_exact_restore = inputs.iter().any(|input| input.requires_exact_restore);
2146+
let inherited_matrix_restore = if inputs
2147+
.iter()
2148+
.any(|input| input.matrix_restore == Some(MatrixRestore::Floating))
2149+
{
2150+
Some(MatrixRestore::Floating)
2151+
} else if inputs
2152+
.iter()
2153+
.any(|input| input.matrix_restore == Some(MatrixRestore::Default))
2154+
{
2155+
Some(MatrixRestore::Default)
2156+
} else {
2157+
None
2158+
};
2159+
let matrix_restore = match node.primitive {
2160+
ResolvedFilterPrimitive::ColorMatrix { matrix } => {
2161+
Some(if matrix[19] > 0.0 || !source_dependent {
2162+
MatrixRestore::Default
2163+
} else {
2164+
MatrixRestore::Floating
2165+
})
2166+
}
2167+
ResolvedFilterPrimitive::SolidColor { .. } => None,
2168+
_ => inherited_matrix_restore,
2169+
};
2170+
let matrix_source_preflatten = inputs.iter().any(|input| input.matrix_source_preflatten)
2171+
|| matches!(
2172+
&node.primitive,
2173+
ResolvedFilterPrimitive::ColorMatrix { matrix }
2174+
if source_dependent && matrix[19] <= 0.0
2175+
);
21232176
let (image_filter, output_space, source_dependent, requires_exact_restore) = match node
21242177
.primitive
21252178
{
@@ -2321,6 +2374,24 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
23212374
node.color_space == ResolvedFilterColorSpace::Srgb,
23222375
)
23232376
}
2377+
ResolvedFilterPrimitive::ColorMatrix { matrix } => {
2378+
let input = inputs.pop().expect("color matrix has one checked input");
2379+
let color_filter = skia_safe::color_filters::matrix_row_major(&matrix, None);
2380+
let filter =
2381+
skia_safe::image_filters::color_filter(color_filter, input.image_filter, crop)
2382+
.ok_or_else(|| {
2383+
"the backend could not construct a color-matrix operation".to_string()
2384+
})?;
2385+
// Matrix arithmetic supersedes any final-restore policy on
2386+
// its input. Chromium's source-derived and generated-only
2387+
// matrix results take two distinct measured sRGB restores.
2388+
(
2389+
Some(filter),
2390+
node.color_space,
2391+
input.source_dependent,
2392+
false,
2393+
)
2394+
}
23242395
ResolvedFilterPrimitive::Merge => {
23252396
let mut inputs = inputs.into_iter();
23262397
let image_filter = if let Some(first) = inputs.next() {
@@ -2368,6 +2439,8 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
23682439
color_space: output_space,
23692440
source_dependent,
23702441
requires_exact_restore,
2442+
matrix_restore,
2443+
matrix_source_preflatten,
23712444
});
23722445
}
23732446
let output = results
@@ -2393,17 +2466,120 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
23932466
})?,
23942467
);
23952468
}
2396-
let restore_blender = if output.source_dependent && !output.requires_exact_restore {
2469+
let restore_blender = if output.requires_exact_restore {
2470+
Some(exact_unorm8_blender(ResolvedFilterComposite::Over)?)
2471+
} else if output.matrix_restore == Some(MatrixRestore::Floating) {
2472+
Some(floating_porter_duff_blender(ResolvedFilterComposite::Over)?)
2473+
} else if output.matrix_restore == Some(MatrixRestore::Default) || output.source_dependent {
23972474
None
23982475
} else {
23992476
Some(exact_unorm8_blender(ResolvedFilterComposite::Over)?)
24002477
};
24012478
Ok(BuiltFilter {
24022479
image_filter: output.image_filter,
24032480
restore_blender,
2481+
matrix_source_preflatten: output.matrix_source_preflatten,
24042482
})
24052483
}
24062484

2485+
#[cfg(test)]
2486+
mod color_matrix_filter_policy_tests {
2487+
use std::sync::Arc;
2488+
2489+
use n0_model::math::RectF;
2490+
use n0_model::model::Color32F;
2491+
2492+
use crate::drawlist::ResolvedFilterNode;
2493+
2494+
use super::{
2495+
build_filter, ResolvedFilter, ResolvedFilterColorSpace, ResolvedFilterInput,
2496+
ResolvedFilterPrimitive,
2497+
};
2498+
2499+
const REGION: RectF = RectF {
2500+
x: 0.0,
2501+
y: 0.0,
2502+
w: 16.0,
2503+
h: 16.0,
2504+
};
2505+
2506+
fn identity(alpha_offset: f32) -> [f32; 20] {
2507+
let mut matrix = [0.0; 20];
2508+
matrix[0] = 1.0;
2509+
matrix[6] = 1.0;
2510+
matrix[12] = 1.0;
2511+
matrix[18] = 1.0;
2512+
matrix[19] = alpha_offset;
2513+
matrix
2514+
}
2515+
2516+
fn source_matrix(alpha_offset: f32, color_space: ResolvedFilterColorSpace) -> ResolvedFilter {
2517+
ResolvedFilter {
2518+
region: REGION,
2519+
nodes: Arc::from([ResolvedFilterNode {
2520+
inputs: Arc::from([ResolvedFilterInput::Source]),
2521+
region: REGION,
2522+
color_space,
2523+
primitive: ResolvedFilterPrimitive::ColorMatrix {
2524+
matrix: identity(alpha_offset),
2525+
},
2526+
}]),
2527+
}
2528+
}
2529+
2530+
#[test]
2531+
fn source_generated_and_alpha_creating_matrices_keep_distinct_layer_policies() {
2532+
let source = build_filter(&source_matrix(0.0, ResolvedFilterColorSpace::Srgb))
2533+
.expect("source matrix builds");
2534+
assert!(source.matrix_source_preflatten);
2535+
assert!(
2536+
source.restore_blender.is_some(),
2537+
"source-derived sRGB output uses the measured floating restore"
2538+
);
2539+
2540+
let alpha_creating = build_filter(&source_matrix(0.25, ResolvedFilterColorSpace::Srgb))
2541+
.expect("alpha-creating matrix builds");
2542+
assert!(!alpha_creating.matrix_source_preflatten);
2543+
assert!(alpha_creating.restore_blender.is_none());
2544+
2545+
let generated = ResolvedFilter {
2546+
region: REGION,
2547+
nodes: Arc::from([
2548+
ResolvedFilterNode {
2549+
inputs: Arc::from([]),
2550+
region: REGION,
2551+
color_space: ResolvedFilterColorSpace::Srgb,
2552+
primitive: ResolvedFilterPrimitive::SolidColor {
2553+
color: Color32F::new(0.2, 0.4, 0.8, 0.5).expect("unit color"),
2554+
},
2555+
},
2556+
ResolvedFilterNode {
2557+
inputs: Arc::from([ResolvedFilterInput::Node(0)]),
2558+
region: REGION,
2559+
color_space: ResolvedFilterColorSpace::Srgb,
2560+
primitive: ResolvedFilterPrimitive::ColorMatrix {
2561+
matrix: identity(0.0),
2562+
},
2563+
},
2564+
]),
2565+
};
2566+
let generated = build_filter(&generated).expect("generated matrix builds");
2567+
assert!(!generated.matrix_source_preflatten);
2568+
assert!(
2569+
generated.restore_blender.is_none(),
2570+
"generated matrix output keeps the backend-default measured restore"
2571+
);
2572+
2573+
let converted = build_filter(&source_matrix(0.0, ResolvedFilterColorSpace::LinearRgb))
2574+
.expect("linear matrix builds");
2575+
assert!(converted.matrix_source_preflatten);
2576+
assert!(
2577+
converted.restore_blender.is_none(),
2578+
"the output gamma conversion ends the matrix-specific sRGB restore"
2579+
);
2580+
}
2581+
}
2582+
24072583
/// Product-build preflight for a resolved image-filter graph. Replay repeats
24082584
/// the same pure builders and may therefore treat success as proven.
24092585
pub(crate) fn preflight_filter(filter: &ResolvedFilter) -> Result<(), String> {
@@ -2694,7 +2870,7 @@ pub fn execute_unchecked<K>(canvas: &Canvas, list: &DrawList<K>, view: &Affine,
26942870
Clip,
26952871
MaskContent,
26962872
MaskSource,
2697-
Filter,
2873+
Filter { matrix_source_preflatten: bool },
26982874
}
26992875

27002876
let initial_save_count = canvas.save_count();
@@ -2833,12 +3009,23 @@ pub fn execute_unchecked<K>(canvas: &Canvas, list: &DrawList<K>, view: &Affine,
28333009
.bounds(&region)
28343010
.paint(&restore_paint);
28353011
canvas.save_layer(&layer);
2836-
scopes.push(Scope::Filter);
3012+
if built_filter.matrix_source_preflatten {
3013+
canvas.save_layer(&SaveLayerRec::default());
3014+
}
3015+
scopes.push(Scope::Filter {
3016+
matrix_source_preflatten: built_filter.matrix_source_preflatten,
3017+
});
28373018
}
28383019
ItemKind::EndFilter => {
28393020
let scope = scopes.pop();
2840-
debug_assert_eq!(scope, Some(Scope::Filter));
2841-
if scope.is_some() {
3021+
debug_assert!(matches!(scope, Some(Scope::Filter { .. })));
3022+
if let Some(Scope::Filter {
3023+
matrix_source_preflatten,
3024+
}) = scope
3025+
{
3026+
if matrix_source_preflatten {
3027+
canvas.restore();
3028+
}
28423029
// Restore the filtered layer, then the local-space hard
28433030
// region and transform saved immediately outside it.
28443031
canvas.restore();

‎crates/n0_cli/README.md‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,8 @@ cargo run -p n0_cli --bin n0 -- \
258258
The resolved frame carries a checked backend-neutral graph, never the URL or
259259
authored result names. Its current operations are `feGaussianBlur`, integer
260260
`feOffset`, zero-input `feFlood`, all seven `feComposite` operators,
261-
ordered `feMerge`/`feMergeNode`, and native one-input `feDropShadow`. Inputs
261+
ordered `feMerge`/`feMergeNode`, native one-input `feDropShadow`, and
262+
one-input `feColorMatrix`. Inputs
262263
resolve to `SourceGraphic`, `SourceAlpha`, the previous result, or an earlier
263264
named result before the frame; unknown values follow Chromium's measured
264265
first/previous fallback.
@@ -308,6 +309,21 @@ cargo run -p n0_cli --bin n0 -- \
308309
color-space conversion; applying it globally would change three unrelated
309310
floating-path cells. ARM and x86 now match every committed shadow oracle
310311
without tolerance while the resolved frame still carries one native operation.
312+
Color matrix carries one finite row-major 4×5 operation over
313+
non-premultiplied RGBA. Missing and invalid `type` use `matrix`; the complete
314+
`matrix | saturate | hueRotate | luminanceToAlpha` behavior, exact value
315+
counts, pass-through fallbacks, SVG number-list grammar, unclamped
316+
saturation, Blink-ordered hue arithmetic, ignored luminance values, channel
317+
crossing, alpha scaling/creation, clamping, generated input, SourceAlpha,
318+
and both filter color spaces are admitted. The source-neutral frame never
319+
carries the authored type or list. Source-dependent matrix output is limited
320+
to one direct admitted geometry with an opaque solid fill, no stroke, and no
321+
children; generated-only input bypasses that source profile. Non-quarter
322+
target mappings, broader source layers, and source-dependent graphs that
323+
also contain blur or native shadow refuse by three stable precision names.
324+
Fractional axis maps, reflections, exact quarter turns, target opacity,
325+
target clips, circles, and paths are Chromium-baked exact inside the admitted
326+
envelope.
311327
`filterUnits` and `primitiveUnits` carry their complete case-sensitive
312328
`userSpaceOnUse | objectBoundingBox` grammars, defaults, and invalid-value
313329
fallbacks. Filter and primitive regions accept admitted finite numbers,
@@ -325,12 +341,13 @@ cargo run -p n0_cli --bin n0 -- \
325341
one-input merge has no internal composition stage, so the final restore is
326342
where its generated-only rounding is enforced. Native sRGB shadow descendants
327343
add the independently measured exact-restore case described above. ARM and
328-
x86 are exact without a tolerance. One hundred fourteen
344+
x86 are exact without a tolerance. One hundred forty-one
329345
Chromium-baked filter cells are exact: twenty-six from the chassis/blur slice,
330-
sixty from the shadow-graph rung, and twenty-eight from native drop shadow.
331-
The complete corpus is 475 Chromium-baked cells plus 10 sampled frames, with
332-
128 named refusal rows. `feFlood`, `feComposite`, `feMerge`, `feMergeNode`,
333-
`feDropShadow`, and `k1`–`k4` close; `feOffset`, `feGaussianBlur`, `<filter>`,
346+
sixty from the shadow-graph rung, twenty-eight from native drop shadow, and
347+
twenty-seven from color matrix. The complete corpus is 502 Chromium-baked
348+
cells plus 10 sampled frames, with 131 named refusal rows. `feFlood`,
349+
`feComposite`, `feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`, and
350+
`k1`–`k4` close; `feOffset`, `feGaussianBlur`, `<filter>`,
334351
`filter`, `color-interpolation-filters`, `in`, `in2`, `operator`, `result`,
335352
`dx`, `dy`, `stdDeviation`, `flood-color`, and `flood-opacity` remain open for
336353
the named precision, applicability, resource, cascade, or value remainder.

0 commit comments

Comments
 (0)