diff --git a/crates/n0/README.md b/crates/n0/README.md index 33691e27..91ac7bdd 100644 --- a/crates/n0/README.md +++ b/crates/n0/README.md @@ -296,6 +296,49 @@ integration is not admitted or implemented yet. This is a source-neutral chassis seam, not a general SVG importer; source parsing, cascade, animation interpretation, I/O, and clocks remain outside n0. +A resolved `ScopeEffect::Blend` compiles to one empty-start group layer with +the checked blend and optional opacity applied in one restoration. Unit Normal +retains isolation. The native authored `BeginOpacity` operation continues to +copy its backdrop and restore arithmetically; it has a different meaning. +Blend scopes use the existing opaque owner projection, child coverage union, +exact drawlist equality, and complete-frame damage policy, including inside +repeating programs. An unchanged group must still replay against a changed +earlier backdrop; retained glyphless products reuse immutable commands, never +the previously blended pixels. `tests/group_blending.rs` pins these consumer +laws with hand-built frames and exact pixel probes. Those tests do not claim +Chromium parity or measured performance. + +Multiply and partial-opacity Screen restoration explicitly round byte opacity before scaling source +bytes and applying the byte-domain blend. This avoids pinned Skia's differing +ARM/x86 low-precision arithmetic and runtime-blender opacity ordering. +Construction is preflighted without drawing; failure returns an owner-bearing +`glyphless::BuildError::Blend`. A thread-local cache holds one compiled effect +per mode and at most 256 immutable opacity bindings per mode, never destination pixels. Tests +execute every opacity byte against integer arithmetic and prove binding reuse +equals fresh construction. Byte-255 Normal restoration also uses exact byte +source-over to avoid the x86 sprite blitter's separate approximation at +partial-alpha edges. This includes accepted near-unit opacity values whose +backend byte is 255. An existing `ScopeEffect::Opacity` in that bucket lowers +to the same checked Normal-blend command, retaining its original opacity, +owner and one source layer. Lower-byte Normal retains the native isolated +opacity path, and unit-opacity Screen remains native. The trace counters +include these promoted opacity layers; promotion changes their restore +operation, not how many source layers exist. + +With the `trace` feature, `n0::trace::sink::drain_blend_layers()` drains typed +`BlendLayerMetrics`, separate from duration samples: one aggregate per outermost +drawlist execution, including recursive resource recording. It counts blend +save calls, observed raster pixel-span bytes and area, peak live observed blend +bytes, inaccessible observations, and empty-clip saves. The latter never count +the parent surface as a new layer. Skia-internal allocations and later picture +playback are not counted; recording/GPU storage may be inaccessible. Preflight +recording can produce separate execute aggregates: one frame operation may +yield more than one record. Drain before and after the diagnostic frame. The +pinned raster accessor reads existing +storage but calls `notifyPixelsChanged`; use an untimed trace-enabled frame, +not these instrumented observations as default-build allocation or performance +claims. No layer-bounds optimization is applied. + ## Versioned `.n0.xml` ingestion There is deliberately no XML-specific engine API. Draft 0 still has the diff --git a/crates/n0/src/cache.rs b/crates/n0/src/cache.rs index 18718ba6..ab464eeb 100644 --- a/crates/n0/src/cache.rs +++ b/crates/n0/src/cache.rs @@ -535,6 +535,8 @@ fn explicit_radial_owner(list: &crate::drawlist::DrawList) -> Option } ItemKind::BeginOpacity { .. } | ItemKind::BeginIsolatedOpacity { .. } + | ItemKind::BeginIsolatedBlend { .. } + | ItemKind::EndIsolatedBlend | ItemKind::EndOpacity | ItemKind::BeginClipRect { .. } | ItemKind::BeginClipPath { .. } diff --git a/crates/n0/src/drawlist.rs b/crates/n0/src/drawlist.rs index 99815e36..b698dea1 100644 --- a/crates/n0/src/drawlist.rs +++ b/crates/n0/src/drawlist.rs @@ -416,6 +416,14 @@ pub enum ItemKind { BeginIsolatedOpacity { opacity: f32, }, + /// The resolved group's children start against transparent black. Blend + /// and opacity apply together when that completed source joins the + /// enclosing backdrop. Even Normal at unit opacity keeps this boundary. + /// The checked scope vocabulary cannot admit arbitrary native leaf blends. + BeginIsolatedBlend { + blend: rframe::ScopeBlend, + }, + EndIsolatedBlend, /// Closes the innermost opacity scope, of either meaning. EndOpacity, BeginClipRect { diff --git a/crates/n0/src/glyphless.rs b/crates/n0/src/glyphless.rs index 48f1c64f..faaeb31d 100644 --- a/crates/n0/src/glyphless.rs +++ b/crates/n0/src/glyphless.rs @@ -4,7 +4,7 @@ //! authored n0 document, HTML/CSS/SVG syntax, parser binding, backend object, //! I/O handle, or clock. This module admits its current solid-, gradient-, and //! resolved-pattern-painted rectangle, ellipse, and path slice plus checked -//! opacity, clip, mask, and image-filter effects, compiles them into n0's one +//! opacity, group blend, clip, mask, and image-filter effects, compiles them into n0's one //! private drawlist, and executes them through n0's one private painter. //! //! The resulting [`FrameProduct`] is intentionally separate from @@ -93,6 +93,12 @@ pub enum BuildError { owner: VisualRef, reason: String, }, + /// An isolated group's deterministic backend blender could not be built. + /// No product is returned that might silently substitute a native operation. + Blend { + owner: VisualRef, + reason: String, + }, } impl std::fmt::Display for BuildError { @@ -145,6 +151,12 @@ impl std::fmt::Display for BuildError { "glyphless visual {owner:?} filter preflight failed: {reason}" ) } + BuildError::Blend { owner, reason } => { + write!( + f, + "glyphless visual {owner:?} blend preflight failed: {reason}" + ) + } } } } @@ -226,6 +238,7 @@ pub struct Damage { #[derive(Debug, Clone)] enum OpenScopeKind { Opacity, + Blend, Clip { bounds: Option, }, @@ -293,7 +306,7 @@ fn damage_input(product: &FrameProduct) -> FrameDamageInput<'_, VisualRef, (), G /// rectangle) and paths, the contract's admitted `cg` paints (solids, linear /// and radial gradients — every gradient preflighted against its resolved /// paint box before the product exists), checked repeating vector programs, a -/// centred stroke over the fill, isolated opacity scopes, resolved geometric +/// centred stroke over the fill, isolated opacity and blend scopes, resolved geometric /// clip scopes, and the frame-bounds clip. /// /// The contract's item stream is a checked type ([`rframe::FrameItems`]): @@ -362,7 +375,22 @@ pub fn compile(resolved: Frame) -> Result { provenance.owners.push(scope.owner); // Placeholder until the scope closes and its union is known. provenance.coverage.push(None); - let (kind, initial_coverage) = match &scope.effect { + // Backend byte-255 opacity takes the same exact Normal + // restore as a Blend scope. Retain the original resolved + // opacity and the layer itself: byte quantization is not + // permission to erase this isolation boundary. Both spellings + // share preflight, owner coverage, and the matching close. + let promoted_opacity = match &scope.effect { + ScopeEffect::Opacity(opacity) => { + let blend = + rframe::ScopeBlend::new(rframe::ScopeBlendMode::Normal, Some(*opacity)); + crate::paint::uses_isolated_byte_blender(blend) + .then_some(ScopeEffect::Blend(blend)) + } + _ => None, + }; + let effect = promoted_opacity.as_ref().unwrap_or(&scope.effect); + let (kind, initial_coverage) = match effect { ScopeEffect::Opacity(opacity) => { items.push(Item { node: slot, @@ -373,6 +401,20 @@ pub fn compile(resolved: Frame) -> Result { }); (OpenScopeKind::Opacity, None) } + ScopeEffect::Blend(blend) => { + crate::paint::preflight_isolated_blend(*blend).map_err(|reason| { + BuildError::Blend { + owner: scope.owner, + reason, + } + })?; + items.push(Item { + node: slot, + world: frame_world, + kind: ItemKind::BeginIsolatedBlend { blend: *blend }, + }); + (OpenScopeKind::Blend, None) + } ScopeEffect::Clip(clip) => { let compiled = Arc::new(compile_clip_path(clip)); if !crate::paint::preflight_clip_path(&compiled) { @@ -429,6 +471,9 @@ pub fn compile(resolved: Frame) -> Result { let scope = open_scopes.pop().expect("checked stream is balanced"); let (coverage, world, end) = match scope.kind { OpenScopeKind::Opacity => (scope.coverage, frame_world, ItemKind::EndOpacity), + OpenScopeKind::Blend => { + (scope.coverage, frame_world, ItemKind::EndIsolatedBlend) + } OpenScopeKind::Clip { bounds } => match (scope.coverage, bounds) { (Some(coverage), Some(bounds)) => ( bounded_intersection_rectf(coverage, bounds, resolved.bounds), @@ -1881,6 +1926,8 @@ mod tests { } => Some(*post_paint_opacity), ItemKind::BeginOpacity { .. } | ItemKind::BeginIsolatedOpacity { .. } + | ItemKind::BeginIsolatedBlend { .. } + | ItemKind::EndIsolatedBlend | ItemKind::EndOpacity | ItemKind::BeginClipRect { .. } | ItemKind::BeginClipPath { .. } @@ -3693,6 +3740,180 @@ mod tests { ); } + #[test] + fn isolated_opacity_projection_preserves_facts_and_owner_across_byte_routes() { + let mut child = None; + let values = (0..=255_u32) + .map(|alpha| match alpha { + 0 => (0.001, false), + 255 => (1.0_f32.next_down(), true), + _ => (alpha as f32 / 255.0, false), + }) + .chain([(0.998, false), (0.999, true)]); + for (value, promoted) in values { + let source = frame_of( + FrameItems::try_new(vec![ + scope_begin(SCOPE_OWNER, value), + FrameItem::Node(base_node(PaintStack::solid(CGColor::RED))), + FrameItem::ScopeEnd, + ]) + .unwrap(), + ); + #[cfg(feature = "trace")] + crate::trace::sink::drain_blend_layers(); + let product = compile(source.clone()).unwrap(); + assert_eq!(product.resolved(), &source, "resolved facts at {value}"); + assert_eq!(product.drawlist, compile(source).unwrap().drawlist); + #[cfg(feature = "trace")] + assert!(crate::trace::sink::drain_blend_layers().is_empty()); + let items = &product.drawlist.items; + assert_eq!(items.len(), 5, "one scope remains at {value}"); + let (begin, end) = if promoted { + ( + ItemKind::BeginIsolatedBlend { + blend: rframe::ScopeBlend::new( + rframe::ScopeBlendMode::Normal, + Some(ScopeOpacity::new(value).unwrap()), + ), + }, + ItemKind::EndIsolatedBlend, + ) + } else { + ( + ItemKind::BeginIsolatedOpacity { opacity: value }, + ItemKind::EndOpacity, + ) + }; + assert_eq!(items[1].kind, begin, "begin at {value}"); + assert_eq!(items[3].kind, end, "end at {value}"); + assert_eq!(items[1].node, items[3].node); + assert_eq!( + product.provenance.get(items[1].node), + Some(( + SCOPE_OWNER, + Some(n0_model::math::RectF { + x: 8.0, + y: 6.0, + w: 20.0, + h: 16.0, + }), + )) + ); + if let Some(child) = &child { + assert_eq!(&items[2], child, "the child is not rewritten"); + } else { + child = Some(items[2].clone()); + } + } + } + + #[test] + fn isolated_opacity_byte_promotion_also_preflights_nested_programs() { + for value in [0.999, 1.0_f32.next_down()] { + let pattern = rframe::PatternPaint::new( + 64.0, + 48.0, + AffineTransform::identity(), + Arc::new( + FrameItems::try_new(vec![ + scope_begin(SCOPE_OWNER, value), + FrameItem::Node(base_node(PaintStack::solid(CGColor::RED))), + FrameItem::ScopeEnd, + ]) + .unwrap(), + ), + 1.0, + ) + .unwrap(); + #[cfg(feature = "trace")] + crate::trace::sink::drain_blend_layers(); + let compiled = compile_pattern(&pattern, RECT_OWNER).unwrap(); + #[cfg(feature = "trace")] + assert!(crate::trace::sink::drain_blend_layers().is_empty()); + let items = &compiled.program.items; + assert_eq!(items.len(), 5); + assert_eq!( + items[1].kind, + ItemKind::BeginIsolatedBlend { + blend: rframe::ScopeBlend::new( + rframe::ScopeBlendMode::Normal, + Some(ScopeOpacity::new(value).unwrap()), + ), + } + ); + assert_eq!(items[3].kind, ItemKind::EndIsolatedBlend); + assert_eq!(items[1].node, items[3].node); + } + } + + /// One checked final operation becomes one pair in the private stream. + /// Child paints stay identical, including at unit Normal and at the + /// extreme admitted opacity values; there is no per-paint rewrite. + #[test] + fn blend_projection_retains_one_combined_operation_and_its_owner() { + let mut lists = Vec::new(); + for mode in [ + rframe::ScopeBlendMode::Normal, + rframe::ScopeBlendMode::Multiply, + rframe::ScopeBlendMode::Screen, + ] { + for opacity in [ + None, + Some(f32::from_bits(1)), + Some(0.375), + Some(1.0_f32.next_down()), + ] { + let blend = rframe::ScopeBlend::new( + mode, + opacity.map(|value| ScopeOpacity::new(value).unwrap()), + ); + let source = frame_of( + FrameItems::try_new(vec![ + FrameItem::ScopeBegin(Scope { + owner: SCOPE_OWNER, + effect: ScopeEffect::Blend(blend), + }), + FrameItem::Node(base_node(PaintStack::solid(CGColor::RED))), + FrameItem::ScopeEnd, + ]) + .unwrap(), + ); + let product = compile(source.clone()).unwrap(); + assert_eq!(product.drawlist, compile(source).unwrap().drawlist); + let items = &product.drawlist.items; + assert_eq!( + items.len(), + 5, + "frame clip, blend, child, blend end, clip end" + ); + assert_eq!(items[1].kind, ItemKind::BeginIsolatedBlend { blend }); + assert_eq!(items[3].kind, ItemKind::EndIsolatedBlend); + assert_eq!(items[1].node, items[3].node); + assert_eq!( + product.provenance.get(items[1].node), + Some(( + SCOPE_OWNER, + Some(n0_model::math::RectF { + x: 8.0, + y: 6.0, + w: 20.0, + h: 16.0 + }) + )) + ); + for previous in &lists { + let previous: &DrawList = previous; + assert_ne!( + previous, &product.drawlist, + "mode and opacity affect equality" + ); + assert_eq!(previous.items[2], items[2], "the child stays unchanged"); + } + lists.push(product.drawlist); + } + } + } + /// A checked filter scope lowers to one private graph layer. The painter /// evaluates Source and SourceAlpha distinctly and never treats a valid /// graph as an unfiltered fallback. diff --git a/crates/n0/src/paint.rs b/crates/n0/src/paint.rs index 84ef6cd7..7a9fcf42 100644 --- a/crates/n0/src/paint.rs +++ b/crates/n0/src/paint.rs @@ -890,6 +890,8 @@ pub(crate) fn preflight_gradients( | ItemKind::PatternStroke { .. } | ItemKind::BeginOpacity { .. } | ItemKind::BeginIsolatedOpacity { .. } + | ItemKind::BeginIsolatedBlend { .. } + | ItemKind::EndIsolatedBlend | ItemKind::EndOpacity | ItemKind::BeginClipRect { .. } | ItemKind::BeginClipPath { .. } @@ -1129,6 +1131,8 @@ pub(crate) fn preflight_images( | ItemKind::PatternStroke { .. } | ItemKind::BeginOpacity { .. } | ItemKind::BeginIsolatedOpacity { .. } + | ItemKind::BeginIsolatedBlend { .. } + | ItemKind::EndIsolatedBlend | ItemKind::EndOpacity | ItemKind::BeginClipRect { .. } | ItemKind::BeginClipPath { .. } @@ -2508,9 +2512,22 @@ fn deterministic_porter_duff_blender( // that use that pipeline over explicit unorm8 values so both CPU families // reproduce the committed Chromium bytes. The remaining seven modes use Skia's // high-precision path and stay native unless measurement proves otherwise. -fn exact_unorm8_filter_blend_source(expression: &str) -> String { +const UNORM8_MULTIPLY_EXPRESSION: &str = "div255(s * (255.0 - d.a) + d * (255.0 - s.a) + s * d)"; + +fn exact_unorm8_blend_source(expression: &str, source_opacity_uniform: bool) -> String { + let declaration = if source_opacity_uniform { + "uniform float opacity_byte;" + } else { + "" + }; + let source_scale = if source_opacity_uniform { + "s = div255(s * opacity_byte);" + } else { + "" + }; format!( r#" +{declaration} float div255(float value) {{ return floor((value + 127.0) / 255.0); }} @@ -2540,6 +2557,7 @@ float hard_light_channel(float s, float d, float sa, float da) {{ half4 main(half4 src, half4 dst) {{ float4 s = floor(float4(src) * 255.0 + 0.5); float4 d = floor(float4(dst) * 255.0 + 0.5); + {source_scale} float4 result = {expression}; return half4(clamp(result, 0.0, 255.0) / 255.0); }} @@ -2581,10 +2599,7 @@ half4 main(half4 src, half4 dst) {{ fn deterministic_filter_blender(mode: ResolvedFilterBlend) -> Result { let (slot, expression) = match mode { ResolvedFilterBlend::Normal => (12, "s + div255(d * (255.0 - s.a))"), - ResolvedFilterBlend::Multiply => ( - 13, - "div255(s * (255.0 - d.a) + d * (255.0 - s.a) + s * d)", - ), + ResolvedFilterBlend::Multiply => (13, UNORM8_MULTIPLY_EXPRESSION), ResolvedFilterBlend::Screen => (14, "s + d - div255(s * d)"), ResolvedFilterBlend::Overlay => ( 15, @@ -2612,7 +2627,312 @@ fn deterministic_filter_blender(mode: ResolvedFilterBlend) -> Result return Ok(sk_filter_blend_mode(mode).into()), }; - cached_filter_blender(slot, || exact_unorm8_filter_blend_source(expression)) + cached_filter_blender(slot, || exact_unorm8_blend_source(expression, false)) +} + +thread_local! { + // Cache shader code and immutable uniform bindings, never destination + // pixels. All checked opacity values map to only 256 byte factors. Compile + // one effect per mode per painting thread, not one shader per opacity or + // replay. Unit-opacity Screen remains native. + static ISOLATED_BYTE_BLENDERS: RefCell<[Option; 3]> = + const { RefCell::new([None, None, None]) }; +} + +struct IsolatedByteBlenders { + effect: skia_safe::RuntimeEffect, + by_alpha: Vec>, +} + +/// The pinned N32 restore's paint-alpha conversion. Keep route selection and +/// shader uniforms in the same byte domain without changing the resolved fact. +fn isolated_opacity_byte(opacity: Option) -> u8 { + (opacity.map_or(1.0, |opacity| opacity.get()) * 255.0 + 0.5) as u8 +} + +fn isolated_byte_blender(blend: rframe::ScopeBlend) -> Result { + // Pinned Skia's N32 lowp sprite restore quantizes the paint opacity first, + // then rounds source-byte * opacity-byte / 255. A runtime blender promotes + // the surrounding pipeline to highp; leaving paint alpha active would + // instead scale by the original float before source quantization. + let alpha = isolated_opacity_byte(blend.opacity()); + let (slot, expression) = match blend.mode() { + rframe::ScopeBlendMode::Multiply => (0, UNORM8_MULTIPLY_EXPRESSION), + rframe::ScopeBlendMode::Screen => (1, "s + d - div255(s * d)"), + // N32 source-over sprite restoration has a separate x86 fast path: + // s + ((d * (256 - sa)) >> 8), unlike NEON's accurate /255. Rotated + // isolated sources expose it at partial-alpha edge pixels. + rframe::ScopeBlendMode::Normal => (2, "s + div255(d * (255.0 - s.a))"), + }; + ISOLATED_BYTE_BLENDERS.with(|caches| { + let mut caches = caches.borrow_mut(); + let cache = &mut caches[slot]; + if cache.is_none() { + let options = skia_safe::runtime_effect::Options { + force_unoptimized: false, + name: "n0_isolated_byte_blender", + }; + let effect = skia_safe::RuntimeEffect::make_for_blender( + exact_unorm8_blend_source(expression, true), + Some(&options), + ) + .map_err(|error| { + format!("the backend could not compile an isolated byte-domain blender: {error}") + })?; + *cache = Some(IsolatedByteBlenders { + effect, + by_alpha: Vec::new(), + }); + } + let cache = cache.as_mut().expect("effect initialized above"); + if let Some(blender) = cache + .by_alpha + .get(usize::from(alpha)) + .and_then(Option::as_ref) + { + return Ok(blender.clone()); + } + let blender = cache + .effect + .make_blender(Data::new_copy(&f32::from(alpha).to_ne_bytes()), None) + .ok_or_else(|| "the backend could not bind isolated blend opacity".to_string())?; + if cache.by_alpha.len() <= usize::from(alpha) { + cache.by_alpha.resize_with(usize::from(alpha) + 1, || None); + } + cache.by_alpha[usize::from(alpha)] = Some(blender.clone()); + Ok(blender) + }) +} + +/// Validate fallible backend construction before an immutable frame product +/// exists. The static shader's raster lowering is guarded by execution tests; +/// preflight itself issues no raster commands. +pub(crate) fn preflight_isolated_blend(blend: rframe::ScopeBlend) -> Result<(), String> { + if uses_isolated_byte_blender(blend) { + isolated_byte_blender(blend).map(|_| ()) + } else { + Ok(()) + } +} + +pub(crate) fn uses_isolated_byte_blender(blend: rframe::ScopeBlend) -> bool { + match blend.mode() { + rframe::ScopeBlendMode::Multiply => true, + // SkPaint::getAlpha() also makes near-unit Some values 255. Those + // select the same x86 SrcOver sprite approximation as None. Lower + // bytes retain the distinct native global-alpha restore arithmetic. + rframe::ScopeBlendMode::Normal => isolated_opacity_byte(blend.opacity()) == 255, + rframe::ScopeBlendMode::Screen => blend.opacity().is_some(), + } +} + +#[cfg(test)] +mod isolated_blend_policy_tests { + use super::*; + + fn scope(opacity: f32) -> rframe::ScopeBlend { + rframe::ScopeBlend::new( + rframe::ScopeBlendMode::Multiply, + if opacity == 1.0 { + None + } else { + Some(rframe::ScopeOpacity::new(opacity).unwrap()) + }, + ) + } + + fn pixel(blend: rframe::ScopeBlend, float_first: bool) -> Vec { + pixel_with_source_alpha(blend, float_first, 149) + } + + fn pixel_with_source_alpha(blend: rframe::ScopeBlend, float_first: bool, alpha: u8) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((1, 1)).unwrap(); + surface.canvas().clear(Color::from_argb(170, 66, 101, 137)); + let mut restore = Paint::default(); + if float_first { + restore.set_alpha_f(blend.opacity().unwrap().get()); + restore + .set_blender(deterministic_filter_blender(ResolvedFilterBlend::Multiply).unwrap()); + } else { + restore.set_blender(isolated_byte_blender(blend).unwrap()); + } + surface + .canvas() + .save_layer(&SaveLayerRec::default().paint(&restore)); + surface + .canvas() + .clear(Color::from_argb(alpha, 215, 104, 67)); + surface.canvas().restore(); + read_pixels(&mut surface, 1, 1) + } + + #[test] + fn isolated_blend_routing_uses_the_native_opacity_byte() { + let boundary = 254.5_f32 / 255.0; + let values = (0..=255_u32) + .map(|alpha| { + if alpha == 0 { + 0.001 + } else { + alpha as f32 / 255.0 + } + }) + .chain([ + 0.123456, + 0.6, + 0.998, + 0.999, + 1.0_f32.next_down(), + boundary.next_down(), + boundary, + boundary.next_up(), + ]); + for value in values { + let opacity = scope(value).opacity(); + let mut native = Paint::default(); + native.set_alpha_f(value); + assert_eq!(isolated_opacity_byte(opacity), native.alpha(), "{value}"); + for (mode, expected) in [ + (rframe::ScopeBlendMode::Normal, native.alpha() == 255), + (rframe::ScopeBlendMode::Multiply, true), + (rframe::ScopeBlendMode::Screen, opacity.is_some()), + ] { + assert_eq!( + uses_isolated_byte_blender(rframe::ScopeBlend::new(mode, opacity)), + expected, + "{mode:?} at {value}" + ); + } + } + assert_eq!(isolated_opacity_byte(scope(0.998).opacity()), 254); + assert_eq!(isolated_opacity_byte(scope(0.999).opacity()), 255); + } + + #[test] + fn isolated_multiply_raster_matches_ordered_integer_math_for_every_opacity_byte() { + let q = |v: u32| (v + 127) / 255; + let source = [q(215 * 149), q(104 * 149), q(67 * 149), 149]; + let destination = [q(66 * 170), q(101 * 170), q(137 * 170), 170]; + for alpha in 0..=255_u32 { + let blend = scope(if alpha == 0 { + 0.001 + } else { + alpha as f32 / 255.0 + }); + preflight_isolated_blend(blend).unwrap(); + let s = source.map(|v| q(v * alpha)); + let expected: Vec<_> = s + .iter() + .zip(destination) + .map(|(s_channel, d_channel)| { + q(s_channel * (255 - destination[3]) + + d_channel * (255 - s[3]) + + s_channel * d_channel) as u8 + }) + .collect(); + assert_eq!(pixel(blend, false), expected, "opacity byte {alpha}"); + } + ISOLATED_BYTE_BLENDERS.with(|cache| { + let cache = cache.borrow(); + let bindings = &cache[0].as_ref().unwrap().by_alpha; + assert_eq!(bindings.len(), 256); + assert!(bindings.iter().all(Option::is_some)); + }); + } + + #[test] + fn isolated_multiply_quantizes_opacity_before_scaling_source() { + let arbitrary = scope(0.123456); + assert_eq!(pixel(arbitrary, false), pixel(scope(31.0 / 255.0), false)); + assert_ne!(pixel(arbitrary, false), pixel(arbitrary, true)); + } + + #[test] + fn isolated_screen_raster_matches_ordered_integer_math_for_every_opacity_byte() { + let q = |v: u32| (v + 127) / 255; + let source = [q(215 * 149), q(104 * 149), q(67 * 149), 149]; + let destination = [q(66 * 170), q(101 * 170), q(137 * 170), 170]; + for alpha in 0..=255_u32 { + let opacity = scope(if alpha == 0 { + 0.001 + } else { + alpha as f32 / 255.0 + }) + .opacity(); + let blend = rframe::ScopeBlend::new(rframe::ScopeBlendMode::Screen, opacity); + preflight_isolated_blend(blend).unwrap(); + assert_eq!(uses_isolated_byte_blender(blend), opacity.is_some()); + let source = source.map(|v| q(v * alpha)); + let expected: Vec<_> = source + .iter() + .zip(destination) + .map(|(s, d)| (s + d - q(s * d)) as u8) + .collect(); + let warm = pixel(blend, false); + assert_eq!(warm, expected, "opacity byte {alpha}"); + ISOLATED_BYTE_BLENDERS.with(|cache| cache.borrow_mut()[1] = None); + assert_eq!( + pixel(blend, false), + warm, + "fresh binding at opacity byte {alpha}" + ); + } + } + + #[test] + fn isolated_multiply_blender_cache_matches_fresh() { + let blend = scope(0.123456); + let warm = pixel(blend, false); + let _ = pixel(scope(0.6), false); + assert_eq!(pixel(blend, false), warm); + ISOLATED_BYTE_BLENDERS.with(|cache| *cache.borrow_mut() = [None, None, None]); + assert_eq!(pixel(blend, false), warm); + } + + #[test] + fn isolated_normal_raster_matches_integer_math_for_every_source_alpha() { + let q = |v: u32| (v + 127) / 255; + let destination = [q(66 * 170), q(101 * 170), q(137 * 170), 170]; + let blend = rframe::ScopeBlend::new(rframe::ScopeBlendMode::Normal, None); + assert!( + !uses_isolated_byte_blender(rframe::ScopeBlend::new( + rframe::ScopeBlendMode::Normal, + scope(0.6).opacity(), + )), + "partial Normal must retain the existing isolated-opacity path" + ); + for alpha in 0..=255_u32 { + preflight_isolated_blend(blend).unwrap(); + assert!(uses_isolated_byte_blender(blend)); + let source = [q(215 * alpha), q(104 * alpha), q(67 * alpha), alpha]; + let expected: Vec<_> = source + .iter() + .zip(destination) + .map(|(s, d)| (s + q(d * (255 - source[3]))) as u8) + .collect(); + let warm = pixel_with_source_alpha(blend, false, alpha as u8); + assert_eq!(warm, expected, "source alpha byte {alpha}"); + ISOLATED_BYTE_BLENDERS.with(|cache| cache.borrow_mut()[2] = None); + assert_eq!( + pixel_with_source_alpha(blend, false, alpha as u8), + warm, + "fresh binding at source alpha byte {alpha}" + ); + for value in [0.999, 1.0_f32.next_down()] { + let alias = rframe::ScopeBlend::new( + rframe::ScopeBlendMode::Normal, + Some(rframe::ScopeOpacity::new(value).unwrap()), + ); + preflight_isolated_blend(alias).unwrap(); + assert!(uses_isolated_byte_blender(alias)); + assert_eq!( + pixel_with_source_alpha(alias, false, alpha as u8), + expected, + "opacity {value}, source alpha byte {alpha}" + ); + } + } + } } fn procedural_filter_blender( @@ -4109,6 +4429,38 @@ fn text_path( builder.snapshot() } +#[cfg(feature = "trace")] +fn observe_blend_layer(canvas: &Canvas) -> crate::trace::blend_layers::Observation { + use crate::trace::blend_layers::Observation; + + // An empty saveLayer can retain the prior device. Do not misattribute its + // backing storage to a new layer. Failed layer mappings also empty the clip. + if canvas.is_clip_empty() { + return Observation::EmptyClip; + } + // In pinned Skia, accessTopLayerPixels -> SkBitmapDevice::onAccessPixels + // peeks existing storage and calls notifyPixelsChanged. It does not allocate, + // but generation-state perturbation makes this diagnostic instrumentation. + // Never read/write the pixel slice or keep it across another canvas call. + let Some(top) = canvas.access_top_layer_pixels() else { + return Observation::Unavailable; + }; + let (Ok(width), Ok(height)) = ( + u64::try_from(top.info.width()), + u64::try_from(top.info.height()), + ) else { + return Observation::Unavailable; + }; + let bytes = top.info.compute_byte_size(top.row_bytes); + if bytes == usize::MAX { + return Observation::Unavailable; + } + Observation::Raster { + bytes, + pixels: width * height, + } +} + /// Replay a raw [`DrawList`] without a frame-environment check. /// /// This low-level entry exists for engine-owned resource-free glyphless @@ -4124,13 +4476,22 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, // noise and can move a boundary value across N32 quantization. skia_safe::graphics::init(); + #[cfg(feature = "trace")] + let _blend_trace = crate::trace::blend_layers::Execute::begin(); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Scope { Opacity, + Blend { + #[cfg(feature = "trace")] + observed_bytes: u128, + }, Clip, MaskContent, MaskSource, - Filter { source_preflatten: bool }, + Filter { + source_preflatten: bool, + }, } let initial_save_count = canvas.save_count(); @@ -4170,6 +4531,43 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, canvas.save_layer(&layer); scopes.push(Scope::Opacity); } + ItemKind::BeginIsolatedBlend { blend } => { + // One empty-start layer, one restoration. Nesting an opacity + // layer outside a blend would change the blend's backdrop; + // nesting it inside would introduce another quantization. + let mut restore_paint = Paint::default(); + if uses_isolated_byte_blender(*blend) { + restore_paint + .set_blender(isolated_byte_blender(*blend).expect( + "isolated blend construction was preflighted at product build", + )); + } else { + restore_paint.set_alpha_f(blend.opacity().map_or(1.0, |opacity| opacity.get())); + restore_paint.set_blend_mode(match blend.mode() { + rframe::ScopeBlendMode::Normal => skia_safe::BlendMode::SrcOver, + rframe::ScopeBlendMode::Screen => skia_safe::BlendMode::Screen, + rframe::ScopeBlendMode::Multiply => unreachable!(), + }); + } + canvas.save_layer(&SaveLayerRec::default().paint(&restore_paint)); + scopes.push(Scope::Blend { + #[cfg(feature = "trace")] + observed_bytes: crate::trace::blend_layers::Execute::begin_layer( + observe_blend_layer(canvas), + ), + }); + } + ItemKind::EndIsolatedBlend => { + let scope = scopes.pop(); + debug_assert!(matches!(scope, Some(Scope::Blend { .. }))); + if scope.is_some() { + canvas.restore(); + } + #[cfg(feature = "trace")] + if let Some(Scope::Blend { observed_bytes }) = scope { + crate::trace::blend_layers::Execute::end_layer(observed_bytes); + } + } ItemKind::EndOpacity => { let scope = scopes.pop(); debug_assert_eq!(scope, Some(Scope::Opacity)); diff --git a/crates/n0/src/trace.rs b/crates/n0/src/trace.rs index 7d978fc5..ba7dbd9d 100644 --- a/crates/n0/src/trace.rs +++ b/crates/n0/src/trace.rs @@ -8,6 +8,43 @@ //! Instrument only the three frame seams (resolve / build / execute) — //! more is noise. The on/off delta is a one-time documented measurement, //! not a per-frame check. +//! +//! Blend-layer counters are separate from duration samples. With `trace`, +//! each outermost execute publishes one `BlendLayerMetrics` through +//! `sink::drain_blend_layers`, including recursive resource executions. + +/// Diagnostic observations of blend layers during one outermost execution. +/// +/// These are not allocator-capacity, GPU-memory, or default-build measurements. +/// Raster bytes describe the accessible pixel span (`ImageInfo::compute_byte_size` +/// with the observed row stride), excluding allocator overhead. The pinned Skia +/// accessor does not allocate pixels, but marks their generation changed even +/// when n0 only reads metadata. Use a separate untimed trace-enabled frame. +/// Missing observations make byte totals and the live peak incomplete. +/// Only explicit execute-seam blend scopes are counted: allocations internal +/// to Skia (including later playback of a recorded picture) are not observable +/// here. Preflight picture recording can produce separate execute aggregates. +#[cfg(feature = "trace")] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct BlendLayerMetrics { + /// BeginIsolatedBlend save-layer calls, including empty clips. + pub save_layer_calls: u64, + /// Calls whose top-layer raster storage was observable. + pub observed_raster_layers: u64, + /// Sum of observed accessible pixel-span bytes, not retained memory. + pub observed_raster_bytes: u128, + /// Sum of observed raster width × height, in pixels. + pub observed_raster_pixels: u128, + /// Largest simultaneously live observed blend-layer byte total. + /// Other opacity, mask, filter, and root-surface storage is excluded. + pub peak_live_blend_bytes: u128, + /// Nonempty-clip calls without accessible raster storage (for example GPU, + /// recording canvases, failed allocations, or inaccessible layer mappings). + pub missing_observations: u64, + /// Empty-clip saves, which may leave the parent device on top; never counted + /// as observing another allocation of that parent surface. + pub empty_clip_saves: u64, +} #[cfg(feature = "trace")] pub mod sink { @@ -15,6 +52,7 @@ pub mod sink { thread_local! { static FRAME: RefCell> = const { RefCell::new(Vec::new()) }; + static BLEND_LAYERS: RefCell> = const { RefCell::new(Vec::new()) }; } /// Record a span sample (nanoseconds) under `name`. @@ -26,6 +64,195 @@ pub mod sink { pub fn drain() -> Vec<(&'static str, u128)> { FRAME.with(|f| f.borrow_mut().drain(..).collect()) } + + pub(super) fn record_blend_layers(metrics: super::BlendLayerMetrics) { + BLEND_LAYERS.with(|frames| frames.borrow_mut().push(metrics)); + } + + /// Take and clear this thread's completed outermost-execute blend-layer + /// observations. This does not drain duration samples or an active execute. + pub fn drain_blend_layers() -> Vec { + BLEND_LAYERS.with(|frames| frames.borrow_mut().drain(..).collect()) + } +} + +#[cfg(feature = "trace")] +pub(crate) mod blend_layers { + use std::cell::RefCell; + + use super::{sink, BlendLayerMetrics}; + + #[derive(Default)] + struct Active { + depth: usize, + live_bytes: u128, + metrics: BlendLayerMetrics, + } + + thread_local! { + static ACTIVE: RefCell = RefCell::new(Active::default()); + } + + pub(crate) enum Observation { + Raster { bytes: usize, pixels: u64 }, + EmptyClip, + Unavailable, + } + + // Recursive resource execution contributes to the same aggregate and sees + // the outer layers' live bytes. No per-layer events are retained. + pub(crate) struct Execute { + entry_live_bytes: u128, + } + + impl Execute { + pub(crate) fn begin() -> Self { + ACTIVE.with(|active| { + let mut active = active.borrow_mut(); + active.depth += 1; + Self { + entry_live_bytes: active.live_bytes, + } + }) + } + + pub(crate) fn begin_layer(observation: Observation) -> u128 { + ACTIVE.with(|active| { + let mut active = active.borrow_mut(); + debug_assert!(active.depth > 0); + active.metrics.save_layer_calls += 1; + match observation { + Observation::Raster { bytes, pixels } => { + let bytes = bytes as u128; + active.metrics.observed_raster_layers += 1; + active.metrics.observed_raster_bytes += bytes; + active.metrics.observed_raster_pixels += u128::from(pixels); + active.live_bytes += bytes; + active.metrics.peak_live_blend_bytes = + active.metrics.peak_live_blend_bytes.max(active.live_bytes); + bytes + } + Observation::EmptyClip => { + active.metrics.empty_clip_saves += 1; + 0 + } + Observation::Unavailable => { + active.metrics.missing_observations += 1; + 0 + } + } + }) + } + + pub(crate) fn end_layer(bytes: u128) { + ACTIVE.with(|active| { + let mut active = active.borrow_mut(); + debug_assert!(active.live_bytes >= bytes); + active.live_bytes = active.live_bytes.saturating_sub(bytes); + }); + } + } + + impl Drop for Execute { + fn drop(&mut self) { + let completed = ACTIVE.with(|active| { + let mut active = active.borrow_mut(); + active.depth -= 1; + active.live_bytes = self.entry_live_bytes; + if active.depth == 0 { + Some(std::mem::take(&mut active.metrics)) + } else { + None + } + }); + if let Some(metrics) = completed { + if !std::thread::panicking() { + sink::record_blend_layers(metrics); + } + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn recursive_execution_aggregates_live_bytes_and_drains_separately() { + sink::drain_blend_layers(); + sink::drain(); + sink::record("duration", 17); + { + let _outer = Execute::begin(); + let outer = Execute::begin_layer(Observation::Raster { + bytes: 20, + pixels: 5, + }); + { + let _resource = Execute::begin(); + let inner = Execute::begin_layer(Observation::Raster { + bytes: 32, + pixels: 8, + }); + Execute::end_layer(inner); + } + assert!(sink::drain_blend_layers().is_empty()); + Execute::end_layer(outer); + let sibling = Execute::begin_layer(Observation::Raster { + bytes: 12, + pixels: 3, + }); + Execute::end_layer(sibling); + } + assert_eq!( + sink::drain_blend_layers(), + [BlendLayerMetrics { + save_layer_calls: 3, + observed_raster_layers: 3, + observed_raster_bytes: 64, + observed_raster_pixels: 16, + peak_live_blend_bytes: 52, + ..BlendLayerMetrics::default() + }] + ); + assert!(sink::drain_blend_layers().is_empty()); + assert_eq!(sink::drain(), [("duration", 17)]); + drop(Execute::begin()); + assert_eq!(sink::drain_blend_layers(), [BlendLayerMetrics::default()]); + } + + #[test] + fn unavailable_and_empty_observations_do_not_invent_storage() { + sink::drain_blend_layers(); + { + let _execute = Execute::begin(); + Execute::end_layer(Execute::begin_layer(Observation::Unavailable)); + Execute::end_layer(Execute::begin_layer(Observation::EmptyClip)); + } + assert_eq!( + sink::drain_blend_layers(), + [BlendLayerMetrics { + save_layer_calls: 2, + missing_observations: 1, + empty_clip_saves: 1, + ..BlendLayerMetrics::default() + }] + ); + } + + #[test] + fn observations_are_thread_local() { + sink::drain_blend_layers(); + let metrics = std::thread::spawn(|| { + drop(Execute::begin()); + sink::drain_blend_layers() + }) + .join() + .unwrap(); + assert_eq!(metrics, [BlendLayerMetrics::default()]); + assert!(sink::drain_blend_layers().is_empty()); + } + } } /// Time `$body` under `$name`. Off: just the block. On: the block, plus a diff --git a/crates/n0/tests/drawlist.rs b/crates/n0/tests/drawlist.rs index 31a4f7e7..af135897 100644 --- a/crates/n0/tests/drawlist.rs +++ b/crates/n0/tests/drawlist.rs @@ -17,6 +17,8 @@ fn tag(k: &ItemKind) -> &'static str { match k { ItemKind::BeginOpacity { .. } => "opacity-begin", ItemKind::BeginIsolatedOpacity { .. } => "isolated-opacity-begin", + ItemKind::BeginIsolatedBlend { .. } => "isolated-blend-begin", + ItemKind::EndIsolatedBlend => "isolated-blend-end", ItemKind::EndOpacity => "opacity-end", ItemKind::BeginClipRect { .. } => "clip-begin", ItemKind::BeginClipPath { .. } => "path-clip-begin", diff --git a/crates/n0/tests/group_blending.rs b/crates/n0/tests/group_blending.rs new file mode 100644 index 00000000..f3d4f14a --- /dev/null +++ b/crates/n0/tests/group_blending.rs @@ -0,0 +1,1145 @@ +//! Independent hand-built rframe consumer probes, without Web lowering. +//! Interior RGBA-premultiplied pixels use exact, integer-valued source-over +//! blend equations, with explicitly named native layer quantization below. +//! Reuse checks are equivalence laws, not Chromium reftests; +//! Chromium's layer quantization is independently probed by the Web rung. + +use std::sync::Arc; + +use cg::CGColor; +use math2::{transform::AffineTransform, Rectangle}; +use n0::glyphless::{compile, diff_frame, BuildError, FrameProduct}; +use n0::paint::{read_pixels, PaintCtx}; +use rframe::{ + ClipGeometry, ClipLayer, ClipPath, Frame, FrameItem, FrameItems, FrameNode, Geometry, Identity, + Mask, MaskMode, PaintStack, PatternPaint, Provenance, Scope, ScopeBlend, ScopeBlendMode, + ScopeEffect, ScopeOpacity, Stroke, StrokeCap, StrokeJoin, VisualRef, +}; + +const SIZE: i32 = 48; +const MODES: [ScopeBlendMode; 3] = [ + ScopeBlendMode::Normal, + ScopeBlendMode::Multiply, + ScopeBlendMode::Screen, +]; +const BACKDROP: CGColor = CGColor::from_rgb(51, 102, 153); +const FIRST: CGColor = CGColor::from_rgb(85, 170, 255); +const SECOND: CGColor = CGColor::from_rgb(170, 85, 0); + +fn owner(id: u64) -> VisualRef { + VisualRef::new(Identity::new(id), Provenance::new(id + 1000)) +} + +fn rect(x: f32, y: f32, w: f32, h: f32) -> Rectangle { + Rectangle::from_xywh(x, y, w, h) +} + +fn node(id: u64, bounds: Rectangle, paints: PaintStack) -> FrameNode { + FrameNode { + owner: owner(id), + transform: AffineTransform::identity(), + geometry: Geometry::Rect(bounds), + bounds, + paints, + stroke: None, + } +} + +fn solid(id: u64, bounds: Rectangle, color: CGColor) -> FrameItem { + FrameItem::Node(node(id, bounds, PaintStack::solid(color))) +} + +fn begin(id: u64, effect: ScopeEffect) -> FrameItem { + FrameItem::ScopeBegin(Scope { + owner: owner(id), + effect, + }) +} + +fn blend(id: u64, mode: ScopeBlendMode, opacity: Option) -> FrameItem { + begin( + id, + ScopeEffect::Blend(ScopeBlend::new( + mode, + opacity.map(|opacity| ScopeOpacity::new(opacity).unwrap()), + )), + ) +} + +fn frame(items: Vec) -> Frame { + Frame { + owner: owner(900), + bounds: rect(0.0, 0.0, SIZE as f32, SIZE as f32), + items: FrameItems::try_new(items).unwrap(), + } +} + +fn raster(product: &FrameProduct, backdrop: CGColor) -> Vec { + let mut surface = skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).unwrap(); + surface.canvas().clear(skia_safe::Color::from_argb( + backdrop.a, backdrop.r, backdrop.g, backdrop.b, + )); + let saves = surface.canvas().save_count(); + product + .execute( + surface.canvas(), + &AffineTransform::identity(), + &PaintCtx::new(None), + ) + .unwrap(); + assert_eq!( + surface.canvas().save_count(), + saves, + "all group scopes restore" + ); + read_pixels(&mut surface, SIZE, SIZE) +} + +fn at(pixels: &[u8], x: usize, y: usize) -> [u8; 4] { + let offset = (y * SIZE as usize + x) * 4; + pixels[offset..offset + 4].try_into().unwrap() +} + +#[cfg(feature = "trace")] +mod layer_metrics { + use super::*; + use n0::trace::{sink::drain_blend_layers, BlendLayerMetrics}; + + fn single() -> FrameProduct { + compile(frame(vec![ + blend(10, ScopeBlendMode::Multiply, Some(0.5)), + solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), + FrameItem::ScopeEnd, + ])) + .unwrap() + } + + #[test] + fn combined_blend_and_opacity_observe_one_real_raster_layer() { + drain_blend_layers(); + let pixels = raster(&single(), BACKDROP); + let metrics = drain_blend_layers(); + assert_eq!( + metrics, + [BlendLayerMetrics { + save_layer_calls: 1, + observed_raster_layers: 1, + observed_raster_bytes: (SIZE * SIZE * 4) as u128, + observed_raster_pixels: (SIZE * SIZE) as u128, + peak_live_blend_bytes: (SIZE * SIZE * 4) as u128, + ..BlendLayerMetrics::default() + }] + ); + assert_eq!(at(&pixels, 0, 0), [51, 102, 153, 255]); + assert!(drain_blend_layers().is_empty()); + } + + #[test] + fn byte_255_opacity_is_observed_as_one_blend_operation_without_erasing_its_layer() { + for (value, count) in [(0.998, 0), (0.999, 1), (1.0_f32.next_down(), 1)] { + drain_blend_layers(); + let product = compile(frame(vec![ + begin(10, ScopeEffect::Opacity(ScopeOpacity::new(value).unwrap())), + solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), + FrameItem::ScopeEnd, + ])) + .unwrap(); + assert!( + drain_blend_layers().is_empty(), + "build issues no raster commands" + ); + raster(&product, BACKDROP); + let metrics = drain_blend_layers(); + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].save_layer_calls, count, "opacity {value}"); + assert_eq!(metrics[0].observed_raster_layers, count); + assert_eq!( + metrics[0].observed_raster_bytes, + u128::from(count) * (SIZE * SIZE * 4) as u128 + ); + assert_eq!(metrics[0].missing_observations, 0); + // Byte 254 still has a native opacity layer; it is deliberately + // outside these execute-seam blend-operation counters. + } + } + + #[test] + fn nested_and_sequential_layers_have_distinct_live_peaks() { + let scene = |nested| { + let first = solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST); + let second = solid(2, rect(16.0, 16.0, 24.0, 24.0), SECOND); + compile(frame(if nested { + vec![ + blend(10, ScopeBlendMode::Normal, None), + first, + blend(11, ScopeBlendMode::Multiply, None), + second, + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ] + } else { + vec![ + blend(10, ScopeBlendMode::Normal, None), + first, + FrameItem::ScopeEnd, + blend(11, ScopeBlendMode::Multiply, None), + second, + FrameItem::ScopeEnd, + ] + })) + .unwrap() + }; + for nested in [false, true] { + drain_blend_layers(); + raster(&scene(nested), BACKDROP); + let metrics = drain_blend_layers(); + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].save_layer_calls, 2); + assert_eq!(metrics[0].observed_raster_layers, 2); + let bytes = (SIZE * SIZE * 4) as u128; + assert_eq!(metrics[0].observed_raster_bytes, 2 * bytes); + assert_eq!( + metrics[0].peak_live_blend_bytes, + if nested { 2 * bytes } else { bytes } + ); + } + } + + #[test] + fn an_empty_clip_never_counts_the_parent_surface_as_a_blend_layer() { + drain_blend_layers(); + let mut surface = skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).unwrap(); + surface + .canvas() + .clip_rect(skia_safe::Rect::new_empty(), None, false); + single() + .execute( + surface.canvas(), + &AffineTransform::identity(), + &PaintCtx::new(None), + ) + .unwrap(); + assert_eq!( + drain_blend_layers(), + [BlendLayerMetrics { + save_layer_calls: 1, + empty_clip_saves: 1, + ..BlendLayerMetrics::default() + }] + ); + } + + #[test] + fn observed_storage_follows_the_real_clipped_layer_not_the_frame_bounds() { + drain_blend_layers(); + let mut surface = skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).unwrap(); + surface.canvas().clip_rect( + skia_safe::Rect::from_xywh(3.0, 5.0, 12.0, 10.0), + None, + false, + ); + single() + .execute( + surface.canvas(), + &AffineTransform::identity(), + &PaintCtx::new(None), + ) + .unwrap(); + assert_eq!( + drain_blend_layers(), + [BlendLayerMetrics { + save_layer_calls: 1, + observed_raster_layers: 1, + observed_raster_bytes: 12 * 10 * 4, + observed_raster_pixels: 12 * 10, + peak_live_blend_bytes: 12 * 10 * 4, + ..BlendLayerMetrics::default() + }] + ); + } + + #[test] + fn a_recording_canvas_reports_missing_storage_without_inventing_bytes() { + drain_blend_layers(); + let mut recorder = skia_safe::PictureRecorder::new(); + let canvas = + recorder.begin_recording(skia_safe::Rect::from_wh(SIZE as f32, SIZE as f32), false); + single() + .execute(canvas, &AffineTransform::identity(), &PaintCtx::new(None)) + .unwrap(); + assert_eq!( + drain_blend_layers(), + [BlendLayerMetrics { + save_layer_calls: 1, + missing_observations: 1, + ..BlendLayerMetrics::default() + }] + ); + assert!(recorder.finish_recording_as_picture(None).is_some()); + } + + #[test] + fn nested_resource_recording_contributes_missing_observations_to_execute() { + let tile = FrameItems::try_new(vec![ + blend(11, ScopeBlendMode::Screen, None), + solid(1, rect(0.0, 0.0, 16.0, 16.0), FIRST), + FrameItem::ScopeEnd, + ]) + .unwrap(); + let pattern = + PatternPaint::new(16.0, 16.0, AffineTransform::identity(), Arc::new(tile), 1.0) + .unwrap(); + let product = compile(frame(vec![ + blend(10, ScopeBlendMode::Normal, None), + FrameItem::Node(node( + 20, + rect(0.0, 0.0, 48.0, 48.0), + PaintStack::from_pattern(pattern), + )), + FrameItem::ScopeEnd, + ])) + .unwrap(); + // Compiling/preflighting a repeating program can itself record a + // picture. Discard that completed execute observation before replay. + drain_blend_layers(); + let pixels = raster(&product, BACKDROP); + assert_eq!(at(&pixels, 8, 8), [85, 170, 255, 255]); + let mut metrics = drain_blend_layers(); + // FrameProduct::execute preflights the pattern before its outer + // drawlist replay; that recording is a separate completed execution. + assert_eq!( + metrics.remove(0), + BlendLayerMetrics { + save_layer_calls: 1, + missing_observations: 1, + ..BlendLayerMetrics::default() + } + ); + assert_eq!( + metrics, + [BlendLayerMetrics { + save_layer_calls: 2, + observed_raster_layers: 1, + observed_raster_bytes: (SIZE * SIZE * 4) as u128, + observed_raster_pixels: (SIZE * SIZE) as u128, + peak_live_blend_bytes: (SIZE * SIZE * 4) as u128, + missing_observations: 1, + ..BlendLayerMetrics::default() + }] + ); + } + + #[test] + fn instrumented_execute_matches_equivalent_operations_without_observation() { + // Instrumentation equivalence, not an independent Chromium oracle. + // The control independently spells the ordered byte operations for + // Multiply/partial Screen; native lowp restoration is not portable. + // Partial Normal retains native opacity. No control operation calls + // access_top_layer_pixels or a production blend helper. + drain_blend_layers(); + let source = CGColor::from_rgba(205, 104, 67, 153); + let bounds = rect(8.25, 8.125, 24.5, 24.75); + for mode in MODES { + let product = compile(frame(vec![ + blend(10, mode, Some(0.5)), + solid(1, bounds, source), + FrameItem::ScopeEnd, + ])) + .unwrap(); + let instrumented = raster(&product, BACKDROP); + assert_eq!(drain_blend_layers().len(), 1, "{mode:?} execute observed"); + let mut control = skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).unwrap(); + let canvas = control.canvas(); + canvas.clear(skia_safe::Color::from_rgb( + BACKDROP.r, BACKDROP.g, BACKDROP.b, + )); + let mut restore = skia_safe::Paint::default(); + match mode { + ScopeBlendMode::Normal => { + restore.set_alpha_f(0.5); + restore.set_blend_mode(skia_safe::BlendMode::SrcOver); + } + ScopeBlendMode::Multiply | ScopeBlendMode::Screen => { + let expression = match mode { + ScopeBlendMode::Multiply => { + "q(s * (255.0 - d.a) + d * (255.0 - s.a) + s * d)" + } + ScopeBlendMode::Screen => "s + d - q(s * d)", + ScopeBlendMode::Normal => unreachable!(), + }; + let shader = format!( + r#" +uniform float opacity_byte; +float4 q(float4 value) {{ + return floor((value + 127.0) / 255.0); +}} +half4 main(half4 src, half4 dst) {{ + float4 s = floor(float4(src) * 255.0 + 0.5); + float4 d = floor(float4(dst) * 255.0 + 0.5); + s = q(s * opacity_byte); + float4 result = {expression}; + return half4(clamp(result, 0.0, 255.0) / 255.0); +}} +"# + ); + let effect = skia_safe::RuntimeEffect::make_for_blender(shader, None) + .expect("test control byte blender compiles"); + // round(0.5 * 255) = 128. Scale inside the blender once; + // restore paint alpha remains one, avoiding float-first scaling. + restore.set_blender( + effect + .make_blender(skia_safe::Data::new_copy(&128.0_f32.to_ne_bytes()), None) + .expect("test control opacity binding is valid"), + ); + } + } + canvas.save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&restore)); + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb( + source.a, source.r, source.g, source.b, + )); + canvas.draw_rect( + skia_safe::Rect::from_xywh(bounds.x, bounds.y, bounds.width, bounds.height), + &paint, + ); + canvas.restore(); + assert_eq!( + instrumented, + read_pixels(&mut control, SIZE, SIZE), + "{mode:?}" + ); + assert!( + drain_blend_layers().is_empty(), + "{mode:?} control unobserved" + ); + } + } +} + +#[test] +fn opaque_group_blends_once_after_overlapping_children_complete() { + // b*s and b+s-b*s are integer code values for this palette. Probes: + // (8,8) first only, (20,20) overlap, (36,36) second only, (2,2) untouched. + let first = [ + [85, 170, 255, 255], + [17, 68, 153, 255], + [119, 204, 255, 255], + ]; + let second = [[170, 85, 0, 255], [34, 34, 0, 255], [187, 153, 153, 255]]; + for (index, mode) in MODES.into_iter().enumerate() { + let product = compile(frame(vec![ + blend(10, mode, None), + solid(1, rect(4.0, 4.0, 24.0, 24.0), FIRST), + solid(2, rect(16.0, 16.0, 24.0, 24.0), SECOND), + FrameItem::ScopeEnd, + ])) + .unwrap(); + let pixels = raster(&product, BACKDROP); + assert_eq!(at(&pixels, 8, 8), first[index], "{mode:?} first"); + assert_eq!(at(&pixels, 20, 20), second[index], "{mode:?} overlap"); + assert_eq!(at(&pixels, 36, 36), second[index], "{mode:?} second"); + assert_eq!(at(&pixels, 2, 2), [51, 102, 153, 255]); + } +} + +#[test] +fn fill_and_stroke_complete_before_the_group_blends() { + // An opaque centred stroke overlaps the fill at (10,20). Applying the + // blend independently to fill and stroke would blend against the fill. + for (mode, expected) in [ + (ScopeBlendMode::Multiply, [34, 34, 0, 255]), + (ScopeBlendMode::Screen, [187, 153, 153, 255]), + ] { + let mut shape = node(1, rect(8.0, 8.0, 24.0, 24.0), PaintStack::solid(FIRST)); + shape.stroke = Stroke::new( + PaintStack::solid(SECOND), + 8.0, + StrokeCap::Butt, + StrokeJoin::Round, + 4.0, + ) + .unwrap(); + let product = compile(frame(vec![ + blend(10, mode, None), + FrameItem::Node(shape), + FrameItem::ScopeEnd, + ])) + .unwrap(); + let pixels = raster(&product, BACKDROP); + assert_eq!( + at(&pixels, 10, 20), + expected, + "fill/stroke overlap {mode:?}" + ); + assert_eq!(at(&pixels, 6, 20), expected, "stroke-only {mode:?}"); + } +} + +#[test] +fn partially_transparent_children_finish_source_over_before_group_blending() { + // Red alpha 153 followed by green alpha 170 yields premul [51,170,0,221]. + // Over opaque blue, multiply keeps only the uncovered blue (255-221); + // screen keeps the red/green source channels and all of the blue backdrop. + for (mode, expected) in [ + (ScopeBlendMode::Multiply, [0, 0, 34, 255]), + (ScopeBlendMode::Screen, [51, 170, 255, 255]), + ] { + let product = compile(frame(vec![ + blend(10, mode, None), + solid( + 1, + rect(4.0, 4.0, 24.0, 24.0), + CGColor::from_rgba(255, 0, 0, 153), + ), + solid( + 2, + rect(16.0, 16.0, 24.0, 24.0), + CGColor::from_rgba(0, 255, 0, 170), + ), + FrameItem::ScopeEnd, + ])) + .unwrap(); + assert_eq!( + at(&raster(&product, CGColor::BLUE), 20, 20), + expected, + "{mode:?}" + ); + } +} + +#[test] +fn partial_source_and_backdrop_use_source_over_alpha_for_every_mode() { + // as=153/255, ab=170/255: as*ab=102/255, ao=221/255. + // Green backdrop, red source make each blend channel exactly calculable. + let backdrop = CGColor::from_rgba(0, 255, 0, 170); + for (mode, expected) in [ + (ScopeBlendMode::Normal, [153, 68, 0, 221]), + (ScopeBlendMode::Multiply, [51, 68, 0, 221]), + (ScopeBlendMode::Screen, [153, 170, 0, 221]), + ] { + let product = compile(frame(vec![ + blend(10, mode, None), + solid( + 1, + rect(8.0, 8.0, 24.0, 24.0), + CGColor::from_rgba(255, 0, 0, 153), + ), + FrameItem::ScopeEnd, + ])) + .unwrap(); + assert_eq!( + at(&raster(&product, backdrop), 16, 16), + expected, + "{mode:?}" + ); + assert_eq!( + at(&raster(&product, CGColor::TRANSPARENT), 16, 16), + [153, 0, 0, 153], + "transparent backdrop {mode:?}" + ); + } +} + +#[test] +fn transparent_group_leaves_a_partial_backdrop_unchanged() { + for mode in MODES { + let product = compile(frame(vec![ + blend(10, mode, Some(0.6)), + solid(1, rect(8.0, 8.0, 24.0, 24.0), CGColor::TRANSPARENT), + FrameItem::ScopeEnd, + ])) + .unwrap(); + let pixels = raster(&product, CGColor::from_rgba(0, 255, 0, 170)); + assert_eq!(at(&pixels, 16, 16), [0, 170, 0, 170], "{mode:?}"); + } +} + +#[test] +fn group_opacity_attenuates_the_completed_source_once() { + // Two opaque overlapping red children become one opaque red source. + // Final opacity 0.6 produces as=153/255. The pinned native Normal layer + // restore gives green=67, one below the exact green=68 from an alpha-153 + // paint above. This is a native quantization regression assertion, not a + // claim of Chromium parity and not a tolerance over that difference. + for (mode, expected) in [ + (ScopeBlendMode::Normal, [153, 67, 0, 221]), + (ScopeBlendMode::Multiply, [51, 68, 0, 221]), + (ScopeBlendMode::Screen, [153, 170, 0, 221]), + ] { + let product = compile(frame(vec![ + blend(10, mode, Some(0.6)), + solid(1, rect(4.0, 4.0, 24.0, 24.0), CGColor::RED), + solid(2, rect(16.0, 16.0, 24.0, 24.0), CGColor::RED), + FrameItem::ScopeEnd, + ])) + .unwrap(); + let pixels = raster(&product, CGColor::from_rgba(0, 255, 0, 170)); + for (x, y) in [(8, 8), (20, 20), (36, 36)] { + assert_eq!(at(&pixels, x, y), expected, "{mode:?} at ({x},{y})"); + } + } +} + +#[test] +fn neutral_outer_group_isolates_a_nested_blend_from_the_external_backdrop() { + for mode in [ScopeBlendMode::Multiply, ScopeBlendMode::Screen] { + let child = vec![ + blend(11, mode, None), + solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), + FrameItem::ScopeEnd, + ]; + let direct = compile(frame(child.clone())).unwrap(); + let mut isolated = vec![blend(10, ScopeBlendMode::Normal, None)]; + isolated.extend(child); + isolated.push(FrameItem::ScopeEnd); + let isolated = compile(frame(isolated)).unwrap(); + let pixels = raster(&isolated, BACKDROP); + assert_eq!( + at(&pixels, 16, 16), + [85, 170, 255, 255], + "{mode:?} sees transparent" + ); + assert_ne!( + pixels, + raster(&direct, BACKDROP), + "unit normal must not be erased" + ); + } +} + +#[test] +fn combined_opacity_and_blend_differ_from_an_outer_opacity_scope() { + for (mode, combined_expected) in [ + (ScopeBlendMode::Multiply, [51, 68, 0, 221]), + (ScopeBlendMode::Screen, [153, 170, 0, 221]), + ] { + let shape = solid(1, rect(8.0, 8.0, 24.0, 24.0), CGColor::RED); + let combined = compile(frame(vec![ + blend(10, mode, Some(0.6)), + shape.clone(), + FrameItem::ScopeEnd, + ])) + .unwrap(); + let nested = compile(frame(vec![ + begin(12, ScopeEffect::Opacity(ScopeOpacity::new(0.6).unwrap())), + blend(10, mode, None), + shape, + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ])) + .unwrap(); + let backdrop = CGColor::from_rgba(0, 255, 0, 170); + assert_eq!(at(&raster(&combined, backdrop), 16, 16), combined_expected); + assert_eq!( + at(&raster(&nested, backdrop), 16, 16), + [153, 67, 0, 221], + "nested blend sees transparent black, then normal opacity sees green" + ); + } +} + +#[test] +fn normal_blend_with_opacity_matches_existing_isolated_opacity() { + let children = vec![ + solid(1, rect(4.0, 4.0, 24.0, 24.0), FIRST), + solid(2, rect(16.0, 16.0, 24.0, 24.0), SECOND), + ]; + let values = (0..=255_u32) + .map(|alpha| match alpha { + 0 => 0.001, + 255 => 1.0_f32.next_down(), + _ => alpha as f32 / 255.0, + }) + .chain([0.125, 0.375, 0.5, 0.6, 0.998, 0.999]); + for opacity in values { + let scene = |effect| { + let mut items = vec![begin(10, effect)]; + items.extend(children.clone()); + items.push(FrameItem::ScopeEnd); + compile(frame(items)).unwrap() + }; + let opacity = ScopeOpacity::new(opacity).unwrap(); + let old = scene(ScopeEffect::Opacity(opacity)); + let new = scene(ScopeEffect::Blend(ScopeBlend::new( + ScopeBlendMode::Normal, + Some(opacity), + ))); + for backdrop in [ + BACKDROP, + CGColor::TRANSPARENT, + CGColor::from_rgba(0, 255, 0, 170), + ] { + let pixels = raster(&old, backdrop); + assert_eq!(pixels, raster(&new, backdrop), "opacity {opacity:?}"); + let mut restore = skia_safe::Paint::default(); + restore.set_alpha_f(opacity.get()); + if restore.alpha() < 255 { + // Lower byte factors must remain exactly the old native + // saveLayer operation, not the ordered byte-blender formula. + let mut surface = skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).unwrap(); + surface.canvas().clear(skia_safe::Color::from_argb( + backdrop.a, backdrop.r, backdrop.g, backdrop.b, + )); + surface + .canvas() + .save_layer(&skia_safe::canvas::SaveLayerRec::default().paint(&restore)); + for (x, y, color) in [(4.0, 4.0, FIRST), (16.0, 16.0, SECOND)] { + let mut paint = skia_safe::Paint::default(); + paint.set_anti_alias(true); + paint.set_color(skia_safe::Color::from_argb( + color.a, color.r, color.g, color.b, + )); + surface + .canvas() + .draw_rect(skia_safe::Rect::from_xywh(x, y, 24.0, 24.0), &paint); + } + surface.canvas().restore(); + assert_eq!( + pixels, + read_pixels(&mut surface, SIZE, SIZE), + "native opacity {opacity:?}" + ); + } + } + } +} + +#[test] +fn near_unit_normal_spellings_share_exact_partial_alpha_restore_and_retain_the_frame() { + // Both alphas are partial. Unlike an opaque axis-aligned child, this + // palette distinguishes exact /255 from x86's native unit sprite /256. + let source_color = CGColor::from_rgba(215, 104, 67, 8); + let backdrop = CGColor::from_rgba(66, 101, 137, 170); + let source = [7_u32, 3, 2, 8]; + let destination = [44_u32, 67, 91, 170]; + let expected: [u8; 4] = std::array::from_fn(|i| { + (source[i] + (destination[i] * (255 - source[3]) + 127) / 255) as u8 + }); + let native_x86: [u8; 4] = + std::array::from_fn(|i| (source[i] + destination[i] * (256 - source[3]) / 256) as u8); + assert_ne!( + expected, native_x86, + "the witness must discriminate the rounding routes" + ); + let scene = |effect| { + frame(vec![ + begin(10, effect), + solid(1, rect(8.0, 8.0, 24.0, 24.0), source_color), + FrameItem::ScopeEnd, + ]) + }; + let unit = compile(scene(ScopeEffect::Blend(ScopeBlend::new( + ScopeBlendMode::Normal, + None, + )))) + .unwrap(); + let unit_pixels = raster(&unit, backdrop); + assert_eq!(at(&unit_pixels, 16, 16), expected); + for value in [0.999, 1.0_f32.next_down()] { + let opacity = ScopeOpacity::new(value).unwrap(); + for effect in [ + ScopeEffect::Opacity(opacity), + ScopeEffect::Blend(ScopeBlend::new(ScopeBlendMode::Normal, Some(opacity))), + ] { + let resolved = scene(effect); + let product = compile(resolved.clone()).unwrap(); + assert_eq!(product.resolved(), &resolved); + let retained = product.clone(); + let pixels = raster(&retained, backdrop); + assert_eq!(at(&pixels, 16, 16), expected, "opacity {value}"); + assert_eq!( + pixels, unit_pixels, + "same retained isolation topology at {value}" + ); + assert_eq!(pixels, raster(&compile(resolved).unwrap(), backdrop)); + } + } +} + +fn clip(bounds: Rectangle) -> ClipPath { + ClipPath::new(vec![ClipLayer::new(vec![ClipGeometry::new( + AffineTransform::identity(), + Geometry::Rect(bounds), + ) + .unwrap()]) + .unwrap()]) + .unwrap() +} + +#[test] +fn changing_blend_or_opacity_damages_only_the_scope_and_its_clipped_child_union() { + let scene = |mode, opacity| { + compile(frame(vec![ + blend(10, mode, opacity), + solid(1, rect(4.0, 8.0, 12.0, 12.0), FIRST), + begin(11, ScopeEffect::Clip(clip(rect(16.0, 12.0, 8.0, 8.0)))), + solid(2, rect(0.0, 0.0, 48.0, 48.0), SECOND), + FrameItem::ScopeEnd, + solid(3, rect(60.0, 60.0, 12.0, 12.0), FIRST), + FrameItem::ScopeEnd, + ])) + .unwrap() + }; + let before = scene(ScopeBlendMode::Multiply, None); + assert!(diff_frame(&before, &scene(ScopeBlendMode::Multiply, None)).is_empty()); + for after in [ + scene(ScopeBlendMode::Screen, None), + scene(ScopeBlendMode::Multiply, Some(0.6)), + ] { + let damage = diff_frame(&before, &after); + assert_eq!(damage.changed, [owner(10)]); + assert_eq!(damage.union_frame, Some(rect(4.0, 8.0, 20.0, 12.0))); + } +} + +#[test] +fn opacity_byte_254_to_255_keeps_scope_damage_coverage_and_retained_matches_fresh() { + for blend_spelling in [false, true] { + let scene = |value| { + let opacity = ScopeOpacity::new(value).unwrap(); + let effect = if blend_spelling { + ScopeEffect::Blend(ScopeBlend::new(ScopeBlendMode::Normal, Some(opacity))) + } else { + ScopeEffect::Opacity(opacity) + }; + frame(vec![ + begin(10, effect), + solid(1, rect(4.0, 8.0, 12.0, 12.0), FIRST), + begin(11, ScopeEffect::Clip(clip(rect(16.0, 12.0, 8.0, 8.0)))), + solid(2, rect(0.0, 0.0, 48.0, 48.0), SECOND), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]) + }; + for (old, new) in [(0.998, 0.999), (0.999, 0.998)] { + let before = compile(scene(old)).unwrap(); + let resolved = scene(new); + let retained = compile(resolved.clone()).unwrap().clone(); + assert_eq!(retained.resolved(), &resolved); + let fresh = compile(scene(new)).unwrap(); + assert!(diff_frame(&retained, &fresh).is_empty()); + let damage = diff_frame(&before, &retained); + assert_eq!(damage.changed, [owner(10)]); + assert_eq!(damage.union_frame, Some(rect(4.0, 8.0, 20.0, 12.0))); + let before_pixels = raster(&before, BACKDROP); + let after_pixels = raster(&retained, BACKDROP); + assert_eq!(after_pixels, raster(&fresh, BACKDROP)); + let union = damage.union_frame.unwrap(); + let mut changed_pixels = 0; + for y in 0..SIZE as usize { + for x in 0..SIZE as usize { + if at(&before_pixels, x, y) != at(&after_pixels, x, y) { + changed_pixels += 1; + assert!( + x as f32 >= union.x + && y as f32 >= union.y + && (x + 1) as f32 <= union.x + union.width + && (y + 1) as f32 <= union.y + union.height, + "{old} -> {new}: changed pixel ({x},{y}) escaped {union:?}" + ); + } + } + } + assert!(changed_pixels > 0, "byte-route crossing must change pixels"); + } + // Distinct resolved floats in byte 255 remain distinct damage facts, + // even though their restoration pixels alias on this backend. + let near = compile(scene(1.0_f32.next_down())).unwrap(); + let alias = compile(scene(0.999)).unwrap(); + assert_eq!(diff_frame(&near, &alias).changed, [owner(10)]); + assert_eq!(raster(&near, BACKDROP), raster(&alias, BACKDROP)); + } +} + +#[test] +fn fully_clipped_blend_edit_keeps_owner_but_has_no_coverage() { + let scene = |mode| { + compile(frame(vec![ + blend(10, mode, None), + solid(1, rect(60.0, 60.0, 12.0, 12.0), FIRST), + FrameItem::ScopeEnd, + ])) + .unwrap() + }; + let damage = diff_frame( + &scene(ScopeBlendMode::Multiply), + &scene(ScopeBlendMode::Screen), + ); + assert_eq!(damage.changed, [owner(10)]); + assert_eq!(damage.union_frame, None); +} + +#[test] +fn blend_scope_coverage_includes_its_childs_stroke_outset() { + let scene = |mode| { + let mut shape = node(1, rect(8.0, 8.0, 24.0, 24.0), PaintStack::solid(FIRST)); + shape.stroke = Stroke::new( + PaintStack::solid(SECOND), + 8.0, + StrokeCap::Butt, + StrokeJoin::Round, + 4.0, + ) + .unwrap(); + compile(frame(vec![ + blend(10, mode, None), + FrameItem::Node(shape), + FrameItem::ScopeEnd, + ])) + .unwrap() + }; + let damage = diff_frame( + &scene(ScopeBlendMode::Multiply), + &scene(ScopeBlendMode::Screen), + ); + assert_eq!(damage.changed, [owner(10)]); + // Stroke coverage deliberately rounds outwards. Its law is containment, + // not equality to the unrounded mathematical stroke box (4,4)-(36,36). + let coverage = damage.union_frame.unwrap(); + assert!(coverage.x <= 4.0 && coverage.y <= 4.0); + assert!(coverage.x + coverage.width >= 36.0); + assert!(coverage.y + coverage.height >= 36.0); + assert!(coverage.x >= 0.0 && coverage.y >= 0.0); + assert!(coverage.x + coverage.width <= SIZE as f32); + assert!(coverage.y + coverage.height <= SIZE as f32); +} + +#[test] +fn a_blend_owner_cannot_alias_a_child_owner() { + assert!( + matches!(compile(frame(vec![blend(1, ScopeBlendMode::Normal, None), + solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), FrameItem::ScopeEnd])), + Err(BuildError::DuplicateOwner(value)) if value == owner(1)) + ); +} + +#[test] +fn changed_earlier_backdrop_with_retained_group_matches_fresh() { + // Glyphless reuse is immutable compiled replay; there is no raster cache. + // Reuse the group under a changed host backdrop, then compare a complete + // fresh frame. An unchanged group's previous output is NOT reusable. + for mode in [ScopeBlendMode::Multiply, ScopeBlendMode::Screen] { + let children = vec![ + blend(10, mode, Some(0.6)), + solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), + FrameItem::ScopeEnd, + ]; + let retained = compile(frame(children.clone())).unwrap(); + let scene = |color| { + let mut items = vec![solid(3, rect(0.0, 0.0, 48.0, 48.0), color)]; + items.extend(children.clone()); + compile(frame(items)).unwrap() + }; + let before = scene(BACKDROP); + let after = scene(SECOND); + let damage = diff_frame(&before, &after); + assert_eq!( + damage.changed, + [owner(3)], + "damage attributes changed facts" + ); + assert_eq!(damage.union_frame, Some(rect(0.0, 0.0, 48.0, 48.0))); + let old_pixels = raster(&retained, BACKDROP); + let reused = raster(&retained, SECOND); + assert_ne!( + old_pixels, reused, + "unchanged group still depends on backdrop" + ); + assert_eq!(reused, raster(&after, CGColor::TRANSPARENT)); + assert_eq!(raster(&retained.clone(), SECOND), reused); + assert_eq!(raster(&before, CGColor::TRANSPARENT), old_pixels); + } +} + +#[test] +fn rect_group_edit_matrix_retained_matches_fresh_and_damage_contains_changed_pixels() { + #[derive(Clone, Copy, Debug)] + enum Edit { + Base, + SourceColor, + SourcePosition, + Reorder, + Removal, + IsolationToggle, + } + + let scene = |edit| { + let isolated = !matches!(edit, Edit::IsolationToggle); + let mut items = vec![solid(101, rect(0.0, 0.0, 48.0, 48.0), BACKDROP)]; + if isolated { + items.push(blend(10, ScopeBlendMode::Normal, None)); + } + items.push(solid( + 102, + if matches!(edit, Edit::SourcePosition) { + rect(8.0, 4.0, 24.0, 24.0) + } else { + rect(4.0, 8.0, 24.0, 24.0) + }, + FIRST, + )); + items.push(blend(11, ScopeBlendMode::Multiply, Some(0.6))); + let lower = solid( + 103, + rect(16.0, 12.0, 24.0, 24.0), + if matches!(edit, Edit::SourceColor) { + CGColor::from_rgb(255, 0, 0) + } else { + SECOND + }, + ); + let upper = solid( + 104, + rect(12.0, 20.0, 24.0, 24.0), + CGColor::from_rgba(0, 255, 0, 153), + ); + match edit { + Edit::Reorder => items.extend([upper, lower]), + Edit::Removal => items.push(lower), + _ => items.extend([lower, upper]), + } + items.push(FrameItem::ScopeEnd); + if isolated { + items.push(FrameItem::ScopeEnd); + } + frame(items) + }; + + let before = compile(scene(Edit::Base)).unwrap(); + let before_pixels = raster(&before, CGColor::TRANSPARENT); + for edit in [ + Edit::SourceColor, + Edit::SourcePosition, + Edit::Reorder, + Edit::Removal, + Edit::IsolationToggle, + ] { + let retained = compile(scene(edit)).unwrap().clone(); + // Interleave another replay; no earlier raster result may be reused + // as the completed source of this retained command product. + assert_eq!(raster(&before, CGColor::TRANSPARENT), before_pixels); + let reused = raster(&retained, CGColor::TRANSPARENT); + let fresh = compile(scene(edit)).unwrap(); + assert_eq!(reused, raster(&fresh, CGColor::TRANSPARENT), "{edit:?}"); + let union = diff_frame(&before, &retained) + .union_frame + .expect("every edit has a damage union"); + let mut changed_pixels = 0; + for y in 0..SIZE as usize { + for x in 0..SIZE as usize { + if at(&before_pixels, x, y) == at(&reused, x, y) { + continue; + } + changed_pixels += 1; + // Integer Rect-only sources: require the whole changed pixel, + // not merely its centre, to fit the declared damage union. + assert!( + x as f32 >= union.x + && y as f32 >= union.y + && (x + 1) as f32 <= union.x + union.width + && (y + 1) as f32 <= union.y + union.height, + "{edit:?}: changed pixel ({x},{y}) escaped {union:?}" + ); + } + } + assert!(changed_pixels > 0, "{edit:?} must not be a vacuous probe"); + } +} + +#[test] +fn blend_scopes_compile_inside_repeating_programs_and_damage_the_outer_client() { + let scene = |mode| { + let tile = FrameItems::try_new(vec![ + solid(1, rect(0.0, 0.0, 16.0, 16.0), BACKDROP), + blend(10, mode, None), + solid(2, rect(4.0, 4.0, 8.0, 8.0), FIRST), + FrameItem::ScopeEnd, + ]) + .unwrap(); + let pattern = + PatternPaint::new(16.0, 16.0, AffineTransform::identity(), Arc::new(tile), 1.0) + .unwrap(); + compile(frame(vec![FrameItem::Node(node( + 20, + rect(0.0, 0.0, 48.0, 48.0), + PaintStack::from_pattern(pattern), + ))])) + .unwrap() + }; + let before = scene(ScopeBlendMode::Multiply); + let after = scene(ScopeBlendMode::Screen); + for (product, expected) in [ + (&before, [17, 68, 153, 255]), + (&after, [119, 204, 255, 255]), + ] { + let pixels = raster(product, CGColor::TRANSPARENT); + for (x, y) in [(8, 8), (24, 24), (40, 40)] { + assert_eq!(at(&pixels, x, y), expected); + } + } + assert_eq!(diff_frame(&before, &after).changed, [owner(20)]); + assert_eq!( + raster(&after.clone(), BACKDROP), + raster(&scene(ScopeBlendMode::Screen), BACKDROP) + ); +} + +#[test] +fn blend_scopes_enclose_masks_and_replay_in_both_mask_phases() { + let product = compile(frame(vec![ + blend(10, ScopeBlendMode::Multiply, None), + FrameItem::MaskBegin(Mask::new( + owner(11), + MaskMode::Alpha, + clip(rect(0.0, 0.0, 48.0, 48.0)), + )), + blend(12, ScopeBlendMode::Screen, None), + solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST), + FrameItem::ScopeEnd, + FrameItem::MaskSource, + blend(13, ScopeBlendMode::Normal, None), + solid(2, rect(0.0, 0.0, 24.0, 48.0), CGColor::WHITE), + FrameItem::ScopeEnd, + FrameItem::MaskEnd, + FrameItem::ScopeEnd, + ])) + .unwrap(); + let pixels = raster(&product, BACKDROP); + assert_eq!(at(&pixels, 16, 16), [17, 68, 153, 255]); + assert_eq!(at(&pixels, 28, 16), [51, 102, 153, 255]); +} + +#[test] +fn a_source_generating_filter_supplies_blend_pixels_and_scope_coverage() { + use rframe::{Filter, FilterColorSpace, FilterNode, FilterPrimitive, FilterProgram}; + let region = rect(8.0, 8.0, 24.0, 24.0); + let scene = |mode| { + let program = FilterProgram::new(Arc::from([FilterNode::new( + Arc::from([]), + region, + FilterColorSpace::Srgb, + FilterPrimitive::SolidColor { + color: FIRST.into(), + }, + )])) + .unwrap(); + let filter = Filter::new(AffineTransform::identity(), region, program) + .unwrap() + .with_transparent_source(); + compile(frame(vec![ + blend(10, mode, None), + begin(11, ScopeEffect::Filter(filter)), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ])) + .unwrap() + }; + let before = scene(ScopeBlendMode::Multiply); + let after = scene(ScopeBlendMode::Screen); + assert_eq!(at(&raster(&before, BACKDROP), 16, 16), [17, 68, 153, 255]); + assert_eq!(at(&raster(&after, BACKDROP), 16, 16), [119, 204, 255, 255]); + let damage = diff_frame(&before, &after); + assert_eq!(damage.changed, [owner(10)]); + assert_eq!(damage.union_frame, Some(region)); +} diff --git a/crates/n0_cli/README.md b/crates/n0_cli/README.md index 761f7c2c..958c183b 100644 --- a/crates/n0_cli/README.md +++ b/crates/n0_cli/README.md @@ -607,10 +607,10 @@ cargo run -p n0_cli --bin n0 -- \ The filter estate contains 26 chassis/blur cells, 60 shadow-graph, 28 native drop-shadow, 27 color-matrix, 34 component-transfer, 38 blend, 37 morphology, 91 turbulence/displacement, 41 convolution-rung, and 71 diffuse-lighting - cells. The complete primitive corpus contains 1,284 Chromium-baked cells plus + cells. The complete primitive corpus contains 1,398 Chromium-baked cells plus 16 sampled frames; the text estate contains sixteen exact text pixel cells and eight exact-number artifact-geometry witnesses (six Allerta and two - Bungee), and the named refusal register has 260 rows. `feFlood`, `feComposite`, + Bungee), and the named refusal register has 303 rows. `feFlood`, `feComposite`, `feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`, `feComponentTransfer`, `feBlend`, `feMorphology`, `feConvolveMatrix`, `feDiffuseLighting`, `feDistantLight`, `fePointLight`, `feSpotLight`, @@ -843,6 +843,41 @@ cargo run -p n0_cli --bin n0 -- \ entries. Every non-identity HTML ancestor opacity is a distinct outer scope around the selected inline SVG; explicit `inherit` on the SVG compounds with those host scopes rather than flattening them. + CSS `mix-blend-mode: normal | multiply | screen` and `isolation: auto | +isolate` have a bounded static SVG group profile. One Stylo computed value + decides the operation; raw attribute lookalikes are inert. Neutral/default + groups add no layer. Normal isolation with no escaping child blend is + redundant; the compiler elides it and preserves the established opacity + fold/layer route, including over translucent backdrops. A required isolated + group begins transparent and + composites its completed source once with the same element's opacity; + blending each child separately or adding an outer opacity group is not the + same operation. Ordinary 2D transforms and nested viewport overflow clips + do not isolate descendants. Authored `clip-path` and existing partial-opacity + groups do. A standalone SVG supplies its transparent initial backdrop in + the resolved stream, independently of the caller's canvas clear color. + The current group-source profile is sharp-cornered rectangles, including + solid, linear-gradient and admitted repeating-pattern paints, fractional + placement, 2D transforms and simple local butt/miter strokes. Non-rectangular + source geometry, radial source paints, wider strokes and curved, subpixel or + rotated clip coverage retain the named `group-source precision` refusal. + These guards deliberately over-refuse unproved combinations. Eliding a + redundant Normal boundary does not admit its contribution beneath an + unproved ancestor filter or mask. A root blend with partial opacity has its + own `root-layer precision` refusal. Filter/mask + composition, blending inside resource source programs or on their roots, + text-local blending, and the fourteen other represented blend values remain + named refusals. CSS keyframes combined with either property retain the + animated-group patrol, including custom-property indirection and HTML-head + styles. Existing SMIL source guards are not relaxed. + HTML-inline blending is admitted only when the resolved SVG-local isolation + or opacity boundary contains its backdrop dependency. An escaping blend, + non-normal blend on the selected root, or blend/isolation on an HTML ancestor + refuses in both admissions: the command still extracts an SVG contribution, + not the exterior HTML page. Attributable unsupported groups are skipped and + named at their structural path in best-effort mode, without leaking a partial + source into surviving siblings. The CSS blending rows remain unchecked; + this does not broaden `feBlend`, per-paint blending, or native-model opacity. `` and `` paint servers are consumed (the gradient rung): `fill`/`stroke` `url(#…)` references resolve through a whole-document, first-id-wins gradient table (shadow-content clones diff --git a/crates/n0_cli/tests/group_blending.rs b/crates/n0_cli/tests/group_blending.rs new file mode 100644 index 00000000..fd0f5296 --- /dev/null +++ b/crates/n0_cli/tests/group_blending.rs @@ -0,0 +1,76 @@ +//! Cross-seam execution laws. The external pixel oracle remains the separate +//! Chromium corpus; these tests make the compiler's backdrop boundary explicit. +use math2::transform::AffineTransform; +use n0::paint::{PaintCtx, read_pixels}; +use rframe::{Frame, FrameItems}; +use skia_safe::{Color, surfaces}; +use websem::{InitialViewport, compile_standalone_svg}; + +fn compile(body: &str, style: &str) -> Frame { + let source = format!( + r#"{body}"# + ); + compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)).unwrap() +} + +fn paint(frame: Frame, backdrop: Color) -> Vec { + let product = n0::glyphless::compile(frame).unwrap(); + let mut surface = surfaces::raster_n32_premul((64, 64)).unwrap(); + surface.canvas().clear(backdrop); + product + .execute( + surface.canvas(), + &AffineTransform::identity(), + &PaintCtx::new(None), + ) + .unwrap(); + read_pixels(&mut surface, 64, 64) +} + +#[test] +fn standalone_root_blend_does_not_consume_the_callers_backdrop() { + let body = r##""##; + for color in [ + Color::from_argb(255, 51, 102, 153), + Color::from_argb(170, 0, 255, 0), + ] { + let reference = paint(compile(body, "mix-blend-mode:normal"), color); + for mode in ["multiply", "screen"] { + let frame = compile(body, &format!("mix-blend-mode:{mode}")); + assert_eq!(paint(frame.clone(), color), reference, "{mode}"); + // The colored destination makes removal of the initial transparent + // boundary observable; a transparent-only oracle cannot do that. + let mut without_initial_boundary = frame; + let items: Vec<_> = without_initial_boundary.items.iter().cloned().collect(); + without_initial_boundary.items = + FrameItems::try_new(items[1..items.len() - 1].to_vec()).unwrap(); + assert_ne!(paint(without_initial_boundary, color), reference, "{mode}"); + } + } +} + +#[test] +fn redundant_isolation_preserves_partial_backdrop_opacity_pixels() { + let backdrop = r##""##; + let plain = ""; + let reference = paint( + compile(&format!("{backdrop}{plain}"), ""), + Color::TRANSPARENT, + ); + let offset = (16 * 64 + 16) * 4; + assert_eq!(&reference[offset..offset + 4], &[153, 68, 0, 221]); + for body in [ + "", + "", + "", + ] { + assert_eq!( + paint( + compile(&format!("{backdrop}{body}"), ""), + Color::TRANSPARENT + ), + reference, + "{body}" + ); + } +} diff --git a/crates/rframe/README.md b/crates/rframe/README.md index 92ce127a..e9429569 100644 --- a/crates/rframe/README.md +++ b/crates/rframe/README.md @@ -24,7 +24,7 @@ producer (e.g. websem, from SVG) | `frame` | `Frame`, `FrameNode`, `Geometry`, leaf paint stacks or checked repeating vector programs, their post-paint alpha factor, and product identity | | `path` | `PathData` — checked absolute commands, fill rule, tight bounds solved once | | `stroke` | `Stroke` — centred width, cap, join, miter limit, optional checked dash pattern, and finite `f64` `outset` | -| `scope` | A checked painter-order scope stream: isolated opacity or source-neutral geometric clipping | +| `scope` | Resolved effect scopes: isolated opacity, combined group blend/opacity, geometric clipping, or image filtering | | `clip` | `ClipPath` — bounded path unions intersected in layers, with resolved transforms, conservative bounds, and an explicit anti-aliased or hard edge policy | Two details are load-bearing enough to state here. A node's `bounds` is the @@ -45,6 +45,42 @@ resolves the complete stack to no paint. Because a `Stroke` owns the same `PaintStack`, fill and stroke cross the contract with one meaning and no source-specific duplicate field. +`ScopeEffect::Blend(ScopeBlend)` states one isolated group's final blend and +opacity. Its children paint in order against transparent black. The completed +group's premultiplied color and alpha receive the opacity once, then blend and +composite source-over into the enclosing backdrop in one final operation. +Fill/stroke overlap is already part of that completed group. A separate outer +opacity scope around a blend scope states a different nesting and backdrop; +it is not an equivalent spelling of combined blend and opacity. + +`ScopeBlend::new(mode, opacity)` takes `ScopeBlendMode::{Normal, Multiply, +Screen}` and `Option`. `mode()` and `opacity()` return those +facts unchanged. `None` means opacity 1; `Some` reuses the finite, strictly +between-zero-and-one `ScopeOpacity` check. Zero opacity resolves to no emitted +group. In particular, `Normal` with `None` **retains isolation**; a group whose +children should paint directly into the enclosing backdrop has no scope. +The existing `ScopeEffect::Opacity` still means isolated normal composition +at its checked opacity, and its constructor still rejects both 0 and 1. + +The scope-specific blend enum admits only these three functions. Reusing all +of `cg::BlendMode` would admit modes beyond this contract; reusing +`FilterBlend` would confuse a group joining its enclosing backdrop with two +explicit filter inputs. Leaf paints remain normal-only. Additive composition +(including plus-lighter), backdrop-preserving group opacity, arbitrary +compositing operators, and configurable backdrop initialization are not part +of this scope. Source producers must guard unsupported operations at ingress. +No layer allocation, backdrop copy, cache policy, or authored tree is implied. +An unchanged group can produce a different blended result when its enclosing +backdrop changes; equality of group facts does not remove that dependency. + +The existing `FrameItems` validator checks blend scopes using the same balance, +non-empty-content, and combined scope/mask depth rules as other effects +(`MAX_SCOPE_DEPTH = 64`). Repeating programs retain checked immutable item +streams and their separate `MAX_PATTERN_DEPTH = 8` bound. Validation preserves +the supplied painter order and opaque owners. Independent diagram construction +and mixed-scope, mask, and repeating-program laws live in +[`tests/blend_contract.rs`](tests/blend_contract.rs). + `Stroke::outset()` widens only the arithmetic for that derived, direction-free bound. The resolved width and miter limit remain exact `f32` facts, while every stroke admitted from finite members has a finite `f64` diff --git a/crates/rframe/src/frame.rs b/crates/rframe/src/frame.rs index 09847e26..df5909c5 100644 --- a/crates/rframe/src/frame.rs +++ b/crates/rframe/src/frame.rs @@ -10,7 +10,7 @@ //! objects, and no serialization. //! //! It is deliberately minimal (solid- and gradient-filled rectangles, -//! ellipses, and paths, composited flat or through checked opacity, clip, +//! ellipses, and paths, composited flat or through checked opacity, group blend, clip, //! mask, and image-filter effects) and //! **breakable**: the enums grow as real producers force new visual facts, and //! the sharing boundary moves *down* (toward the engine's private drawlist) @@ -526,7 +526,8 @@ pub enum FrameItem { /// One resolved painted node. Node(FrameNode), /// The following items, up to the matching [`FrameItem::ScopeEnd`], - /// composite as one isolated group under this scope's effect. + /// share this resolved effect. Opacity, blend, and filter isolate the + /// group; geometric clipping alone does not. ScopeBegin(Scope), /// Closes the innermost open scope. ScopeEnd, diff --git a/crates/rframe/src/lib.rs b/crates/rframe/src/lib.rs index a932d35a..b3cb46ca 100644 --- a/crates/rframe/src/lib.rs +++ b/crates/rframe/src/lib.rs @@ -37,7 +37,7 @@ pub use frame::{ }; pub use mask::{Mask, MaskMode}; pub use path::{FillRule, PathCommand, PathData, PathDataError}; -pub use scope::{Scope, ScopeEffect, ScopeOpacity, ScopeOpacityError}; +pub use scope::{Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, ScopeOpacity, ScopeOpacityError}; pub use stroke::{ Stroke, StrokeCap, StrokeDash, StrokeDashError, StrokeDashIntervals, StrokeDashIntervalsError, StrokeError, StrokeJoin, StrokeSpace, diff --git a/crates/rframe/src/scope.rs b/crates/rframe/src/scope.rs index 480c5c6e..df8813d2 100644 --- a/crates/rframe/src/scope.rs +++ b/crates/rframe/src/scope.rs @@ -1,9 +1,10 @@ //! The compositing scope: the group fact of the resolved contract. //! -//! A scope states that the items it encloses composite as **one isolated -//! group** — its effect applies to the group's composite, never per item. -//! That is the only thing a scope is for: a fact a producer *could* state on -//! one paint pass is stated there instead. [`crate::PaintAlphaFactor`], for +//! Opacity, blend, and filter scopes state that the items they enclose +//! composite as **one isolated group** — their effect applies to the group's +//! composite, never per item. A clip scope instead constrains paint coverage +//! without isolation. A fact a producer *could* state on one paint pass is +//! stated there instead. [`crate::PaintAlphaFactor`], for //! example, modulates each paint entry without isolation after its own alpha //! materializes. A scope is the byte-distinct fact that no such per-paint //! statement can express — a translucent group whose contents overlap, or a @@ -22,9 +23,10 @@ use crate::frame::VisualRef; /// Why an opacity cannot be a scope fact. /// -/// A scope opacity lives in the **open** unit interval: `1` is identity -/// (the producer omits the scope) and `0` composites nothing (the -/// producer emits nothing), so neither is a fact a scope can carry. +/// A scope opacity lives in the **open** unit interval: for an opacity-only +/// effect, `1` is identity (the producer omits the scope) and `0` composites +/// nothing (the producer emits nothing). A [`ScopeBlend`] expresses unit +/// opacity with `None` while retaining its isolation boundary. #[derive(Clone, Copy, Debug, PartialEq)] pub struct ScopeOpacityError { pub value: f32, @@ -60,11 +62,94 @@ impl ScopeOpacity { } } +/// The admitted blend functions for an isolated group's final composition. +/// +/// These operate on unpremultiplied group and backdrop color channels; alpha +/// uses source-over for all three modes. This vocabulary is deliberately +/// narrower than [`cg::BlendMode`] and separate from [`crate::FilterBlend`]: +/// it admits neither arbitrary leaf blends nor two-image filter operations. +/// Additive composition and backdrop-preserving groups are inexpressible. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScopeBlendMode { + /// Use the completed group's color unchanged: `B(b, s) = s`. + Normal, + /// Multiply backdrop and completed-group color: `B(b, s) = b * s`. + Multiply, + /// Complement the product of complements: `B(b, s) = b + s - b * s`. + Screen, +} + +/// One isolated group's combined final blend and optional opacity. +/// +/// Children paint in order against transparent black. Their completed +/// composite is the source; the enclosing composite at this position in +/// painter order is the backdrop. Apply the optional opacity to the completed +/// source's premultiplied color and alpha, then blend and composite it over +/// that backdrop using [`ScopeBlendMode`]. Opacity and blend belong to this +/// **one** final operation: splitting them into nested scopes changes the +/// backdrop seen by the blend and can add an intermediate quantization step. +/// Neither operation is distributed over child paints, fills, or strokes. +/// +/// `None` means unit opacity, including for [`ScopeBlendMode::Normal`]. Such a +/// scope still isolates descendants and must not be erased just because its +/// final blend and opacity are neutral. Painting children directly into the +/// enclosing backdrop is represented by **no scope**. Zero opacity resolves +/// to no emitted group before construction, as with [`ScopeEffect::Opacity`]. +/// +/// Non-normal blending depends on the enclosing backdrop even when all +/// enclosed items are unchanged. Equality of this fact and its children does +/// not prove equality of the final blended result across different backdrops. +/// This names visual meaning, never a layer allocation, backdrop copy, cache +/// policy, or authored group. +/// +/// ``` +/// use rframe::{ScopeBlend, ScopeBlendMode, ScopeOpacity}; +/// +/// let isolated = ScopeBlend::new(ScopeBlendMode::Normal, None); +/// assert_eq!(isolated.opacity(), None); // unit opacity, still isolated +/// let translucent = ScopeBlend::new( +/// ScopeBlendMode::Multiply, +/// Some(ScopeOpacity::new(0.5)?), +/// ); +/// assert_eq!(translucent.mode(), ScopeBlendMode::Multiply); +/// # Ok::<(), rframe::ScopeOpacityError>(()) +/// ``` +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScopeBlend { + mode: ScopeBlendMode, + opacity: Option, +} + +impl ScopeBlend { + /// Combine an admitted blend function and already-checked opacity. + /// `None` means opacity 1; it never means absence of isolation. + #[must_use] + pub const fn new(mode: ScopeBlendMode, opacity: Option) -> Self { + Self { mode, opacity } + } + + /// The blend function used only when the completed group joins its backdrop. + #[must_use] + pub const fn mode(self) -> ScopeBlendMode { + self.mode + } + + /// The final group opacity, or `None` for unit opacity. + #[must_use] + pub const fn opacity(self) -> Option { + self.opacity + } +} + /// The compositing effect a scope applies to its group's composite. #[derive(Clone, Debug, PartialEq)] pub enum ScopeEffect { /// The group composites at this opacity through one isolated layer. Opacity(ScopeOpacity), + /// Isolate the group, then blend its completed composite with the enclosing + /// backdrop at the optional opacity in one final operation. Normal blend + /// at unit opacity still retains this scope's isolation boundary. + Blend(ScopeBlend), /// Intersect every enclosed paint with resolved geometric coverage. /// Unlike opacity this creates no isolated layer: the clip is paint state, /// and its path facts reference no source or external resource. diff --git a/crates/rframe/tests/blend_contract.rs b/crates/rframe/tests/blend_contract.rs new file mode 100644 index 00000000..e23baccb --- /dev/null +++ b/crates/rframe/tests/blend_contract.rs @@ -0,0 +1,538 @@ +//! Producer-only laws for isolated group blending. +//! +//! A small diagram producer originates these frames from geometry and explicit +//! composition choices. No document parser, authored model, or renderer is +//! involved: the contract must preserve those choices on its own. + +use std::sync::Arc; + +use cg::{BlendMode, CGColor, LinearGradientPaint, Paint, Paints, RadialGradientPaint, SolidPaint}; +use math2::Rectangle; +use math2::transform::AffineTransform; +use rframe::{ + ClipGeometry, ClipLayer, ClipPath, Filter, FilterColorSpace, FilterInput, FilterNode, + FilterPrimitive, FilterProgram, Frame, FrameItem, FrameItems, FrameItemsError, FrameNode, + Geometry, Identity, MAX_PATTERN_DEPTH, MAX_SCOPE_DEPTH, Mask, MaskMode, PaintAlphaFactor, + PaintStack, PaintStackError, PatternPaint, PatternPaintError, Provenance, Scope, ScopeBlend, + ScopeBlendMode, ScopeEffect, ScopeOpacity, VisualRef, +}; + +const MODES: [ScopeBlendMode; 3] = [ + ScopeBlendMode::Normal, + ScopeBlendMode::Multiply, + ScopeBlendMode::Screen, +]; + +fn owner(id: u64) -> VisualRef { + VisualRef::new(Identity::new(id), Provenance::new(id + 1000)) +} + +fn rect() -> Rectangle { + Rectangle::from_xywh(0.0, 0.0, 16.0, 16.0) +} + +fn node(id: u64, paints: PaintStack) -> FrameItem { + FrameItem::Node(FrameNode { + owner: owner(id), + transform: AffineTransform::identity(), + geometry: Geometry::Rect(rect()), + bounds: rect(), + paints, + stroke: None, + }) +} + +fn begin(id: u64, effect: ScopeEffect) -> FrameItem { + FrameItem::ScopeBegin(Scope { + owner: owner(id), + effect, + }) +} + +fn blend(id: u64, mode: ScopeBlendMode) -> FrameItem { + begin(id, ScopeEffect::Blend(ScopeBlend::new(mode, None))) +} + +fn clip() -> ClipPath { + ClipPath::new(vec![ + ClipLayer::new(vec![ + ClipGeometry::new(AffineTransform::identity(), Geometry::Rect(rect())).unwrap(), + ]) + .unwrap(), + ]) + .unwrap() +} + +fn mask(id: u64, mode: MaskMode) -> FrameItem { + FrameItem::MaskBegin(Mask::new(owner(id), mode, clip())) +} + +fn wrap(effect: ScopeEffect, children: Vec) -> Vec { + let mut items = vec![begin(100, effect)]; + items.extend(children); + items.push(FrameItem::ScopeEnd); + items +} + +fn checked(items: Vec) -> FrameItems { + let expected = items.clone(); + let checked = FrameItems::try_new(items).expect("resolved, balanced program"); + assert_eq!( + checked.iter().cloned().collect::>(), + expected, + "validation must preserve every fact, owner, and boundary in order" + ); + checked +} + +fn diagram(composition: Option) -> Frame { + // Two overlapping translucent shapes; the first also has a stroke and a + // separate paint alpha factor. Group composition cannot rewrite any of them. + let mut first = node( + 1, + PaintStack::solid(CGColor::from_rgba(220, 40, 60, 192)) + .with_alpha_factor(PaintAlphaFactor::new(0.75).unwrap()), + ); + let FrameItem::Node(first_node) = &mut first else { + unreachable!() + }; + first_node.stroke = rframe::Stroke::new( + PaintStack::solid(CGColor::BLUE), + 2.0, + rframe::StrokeCap::Round, + rframe::StrokeJoin::Round, + 4.0, + ) + .unwrap(); + let children = vec![ + first, + node(2, PaintStack::solid(CGColor::from_rgba(40, 200, 80, 128))), + ]; + Frame { + owner: owner(200), + bounds: rect(), + items: checked(match composition { + Some(composition) => wrap(ScopeEffect::Blend(composition), children), + None => children, + }), + } +} + +/// The exhaustive match is also an admission lock: growing the vocabulary +/// requires revisiting this producer's contract, not inheriting a shared enum. +#[test] +fn group_blending_admits_exactly_three_named_functions() { + let names = MODES.map(|mode| match mode { + ScopeBlendMode::Normal => "normal", + ScopeBlendMode::Multiply => "multiply", + ScopeBlendMode::Screen => "screen", + }); + assert_eq!(names, ["normal", "multiply", "screen"]); +} + +/// An explicit isolation boundary remains significant even if its own final +/// operation is neutral; a descendant may blend against that boundary later. +#[test] +fn an_independent_diagram_distinguishes_absence_isolation_and_blend() { + let direct = diagram(None); + let grouped = MODES.map(|mode| diagram(Some(ScopeBlend::new(mode, None)))); + assert_eq!(direct.items.len(), 2); + for (index, frame) in grouped.iter().enumerate() { + assert_eq!(frame.items.len(), 4); + assert_eq!(frame.nodes(), direct.nodes()); + assert_ne!(frame, &direct); + for other in &grouped[..index] { + assert_ne!(frame, other); + } + let FrameItem::ScopeBegin(scope) = frame.items.iter().next().unwrap() else { + panic!("unit opacity never erases the isolation boundary"); + }; + assert_eq!(scope.owner, owner(100)); + assert_eq!( + scope.effect, + ScopeEffect::Blend(ScopeBlend::new(MODES[index], None)) + ); + } +} + +/// Optional checked opacity adds the missing unit case without relaxing the +/// existing open interval or smuggling a raw scalar into a checked stream. +#[test] +fn unit_opacity_is_absence_of_attenuation_and_fractional_opacity_stays_exact() { + for mode in MODES { + let unit = ScopeBlend::new(mode, None); + assert_eq!(unit.mode(), mode); + assert_eq!(unit.opacity(), None); + for value in [ + f32::from_bits(1), + f32::MIN_POSITIVE, + 0.375, + 1.0_f32.next_down(), + ] { + let opacity = ScopeOpacity::new(value).unwrap(); + let group = ScopeBlend::new(mode, Some(opacity)); + assert_eq!(group.mode(), mode); + assert_eq!(group.opacity().unwrap().get().to_bits(), value.to_bits()); + assert_ne!(group, unit); + } + } + for value in [ + 0.0, + -0.0, + 1.0, + -0.5, + 1.5, + f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + ] { + let error = ScopeOpacity::new(value).expect_err("opacity domain stays unchanged"); + assert_eq!(error.value.to_bits(), value.to_bits()); + } +} + +/// A combined operation must survive as one scope, because a nested opacity +/// scope makes a different backdrop for its enclosed blend. +#[test] +fn combined_blend_and_opacity_is_distinct_from_two_nested_operations() { + let opacity = ScopeOpacity::new(0.375).unwrap(); + let combined = diagram(Some(ScopeBlend::new( + ScopeBlendMode::Multiply, + Some(opacity), + ))); + let blended = diagram(Some(ScopeBlend::new(ScopeBlendMode::Multiply, None))); + let mut nested = blended.clone(); + let mut items = vec![begin(101, ScopeEffect::Opacity(opacity))]; + items.extend(blended.items.iter().cloned()); + items.push(FrameItem::ScopeEnd); + nested.items = checked(items); + + assert_eq!(combined.items.len(), 4); + assert_eq!(nested.items.len(), 6); + assert_eq!(combined.nodes(), nested.nodes()); + assert_ne!(combined, nested); + assert_ne!(combined, blended); +} + +/// Isolation must remain present around a blending descendant, including when +/// the outer span contains only another scope rather than a direct node. +#[test] +fn unit_normal_isolation_retains_a_nested_blending_program() { + let children = diagram(Some(ScopeBlend::new(ScopeBlendMode::Screen, None))); + let mut items = vec![blend(101, ScopeBlendMode::Normal)]; + items.extend(children.items.iter().cloned()); + items.push(FrameItem::ScopeEnd); + let isolated = checked(items); + assert_eq!(isolated.len(), children.items.len() + 2); + assert_eq!(isolated.nodes().collect::>(), children.nodes()); + assert_ne!(isolated, children.items); +} + +/// No new effect gets a separate or weaker path through stream validation. +#[test] +fn blend_scopes_reject_empty_unclosed_and_unopened_boundaries() { + for mode in MODES { + assert_eq!( + FrameItems::try_new(vec![blend(1, mode), FrameItem::ScopeEnd]), + Err(FrameItemsError::EmptyScope { index: 0 }) + ); + assert_eq!( + FrameItems::try_new(vec![ + blend(1, mode), + node(2, PaintStack::solid(CGColor::RED)) + ]), + Err(FrameItemsError::UnclosedScope { index: 0 }) + ); + assert_eq!( + FrameItems::try_new(vec![ + blend(1, mode), + node(2, PaintStack::solid(CGColor::RED)), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]), + Err(FrameItemsError::UnopenedScopeEnd { index: 3 }) + ); + } +} + +/// Both mask phases are independent composites that can contain groups, and a +/// whole masked result can itself belong to an enclosing blend group. +#[test] +fn blending_can_enclose_masks_and_nest_in_either_mask_phase() { + for mode in [MaskMode::Alpha, MaskMode::Luminance] { + checked(vec![ + blend(1, ScopeBlendMode::Multiply), + mask(2, mode), + blend(3, ScopeBlendMode::Screen), + node(4, PaintStack::solid(CGColor::RED)), + FrameItem::ScopeEnd, + FrameItem::MaskSource, + blend(5, ScopeBlendMode::Normal), + node(6, PaintStack::solid(CGColor::WHITE)), + FrameItem::ScopeEnd, + FrameItem::MaskEnd, + FrameItem::ScopeEnd, + ]); + checked(vec![ + mask(1, mode), + blend(2, ScopeBlendMode::Normal), + node(3, PaintStack::solid(CGColor::RED)), + FrameItem::ScopeEnd, + FrameItem::MaskSource, + FrameItem::MaskEnd, + ]); + } +} + +/// Mask phase markers cannot close or bypass a blend boundary in either +/// direction, even though all effects use the same bounded stack. +#[test] +fn blend_boundaries_cannot_be_crossed_by_mask_phase_markers() { + for mode in MODES { + assert_eq!( + FrameItems::try_new(vec![ + mask(1, MaskMode::Alpha), + blend(2, mode), + node(3, PaintStack::solid(CGColor::RED)), + FrameItem::MaskSource, + ]), + Err(FrameItemsError::UnexpectedMaskSource { index: 3 }) + ); + assert_eq!( + FrameItems::try_new(vec![ + mask(1, MaskMode::Alpha), + node(2, PaintStack::solid(CGColor::RED)), + FrameItem::MaskSource, + blend(3, mode), + node(4, PaintStack::solid(CGColor::WHITE)), + FrameItem::MaskEnd, + ]), + Err(FrameItemsError::UnexpectedMaskEnd { index: 5 }) + ); + assert_eq!( + FrameItems::try_new(vec![ + blend(1, mode), + mask(2, MaskMode::Alpha), + node(3, PaintStack::solid(CGColor::RED)), + FrameItem::ScopeEnd, + ]), + Err(FrameItemsError::UnopenedScopeEnd { index: 3 }) + ); + } +} + +/// The bound counts all simultaneously open scopes and masks, with the same +/// inclusive limit for old and new effects. +#[test] +fn blends_and_masks_share_the_existing_depth_bound() { + let begins = || { + (0..MAX_SCOPE_DEPTH) + .map(|id| blend(id as u64, MODES[id % 3])) + .collect::>() + }; + let mut maximum = begins(); + maximum.push(node(100, PaintStack::solid(CGColor::RED))); + maximum.extend(std::iter::repeat_n(FrameItem::ScopeEnd, MAX_SCOPE_DEPTH)); + checked(maximum); + + let mut overflow = begins(); + overflow.push(blend(100, ScopeBlendMode::Normal)); + assert_eq!( + FrameItems::try_new(overflow), + Err(FrameItemsError::ScopeTooDeep { + index: MAX_SCOPE_DEPTH + }) + ); + let mut overflow = begins(); + overflow.push(mask(100, MaskMode::Alpha)); + assert_eq!( + FrameItems::try_new(overflow), + Err(FrameItemsError::MaskTooDeep { + index: MAX_SCOPE_DEPTH + }) + ); + + let mut masked = vec![mask(100, MaskMode::Alpha)]; + masked.extend(begins()); + assert_eq!( + FrameItems::try_new(masked), + Err(FrameItemsError::ScopeTooDeep { + index: MAX_SCOPE_DEPTH + }) + ); + + let mut maximum = begins(); + maximum.pop(); + maximum.extend([ + mask(100, MaskMode::Alpha), + node(101, PaintStack::solid(CGColor::RED)), + FrameItem::MaskSource, + FrameItem::MaskEnd, + ]); + maximum.extend(std::iter::repeat_n( + FrameItem::ScopeEnd, + MAX_SCOPE_DEPTH - 1, + )); + checked(maximum); +} + +fn repeating_masked_group(paints: PaintStack) -> Result { + // Put recursion in a mask-source stroke to prove neither mask markers nor + // group scopes hide nested programs from the existing depth calculation. + let mut source = node(4, PaintStack::empty()); + let FrameItem::Node(source_node) = &mut source else { + unreachable!() + }; + source_node.stroke = rframe::Stroke::new( + paints, + 1.0, + rframe::StrokeCap::Butt, + rframe::StrokeJoin::Round, + 4.0, + ) + .unwrap(); + let items = checked(vec![ + blend(1, ScopeBlendMode::Normal), + mask(2, MaskMode::Alpha), + node(3, PaintStack::solid(CGColor::WHITE)), + FrameItem::MaskSource, + blend(5, ScopeBlendMode::Multiply), + source, + FrameItem::ScopeEnd, + FrameItem::MaskEnd, + FrameItem::ScopeEnd, + ]); + let shared = Arc::new(items); + let pattern = PatternPaint::new( + 16.0, + 16.0, + AffineTransform::identity(), + Arc::clone(&shared), + 1.0, + )?; + assert!( + Arc::ptr_eq(pattern.items(), &shared), + "the checked program stays shared and immutable" + ); + Ok(pattern) +} + +/// Repeating programs admit exactly the same group and mask facts as a frame; +/// wrapping a recursive paint in them cannot reset its depth. +#[test] +fn immutable_repeating_programs_preserve_blends_and_the_recursion_bound() { + let mut pattern = repeating_masked_group(PaintStack::solid(CGColor::RED)).unwrap(); + assert_eq!(pattern.depth(), 1); + for depth in 2..=MAX_PATTERN_DEPTH { + pattern = repeating_masked_group(PaintStack::from_pattern(pattern)).unwrap(); + assert_eq!(pattern.depth(), depth); + } + assert_eq!( + repeating_masked_group(PaintStack::from_pattern(pattern)), + Err(PatternPaintError::TooDeep) + ); +} + +/// Legacy programs remain exact children of a new group. The only admitted +/// empty effect is still a filter that explicitly generates its own source. +#[test] +fn blending_preserves_opacity_clip_filter_and_mask_programs() { + let program = FilterProgram::new(Arc::from([FilterNode::new( + Arc::from([FilterInput::Source]), + rect(), + FilterColorSpace::Srgb, + FilterPrimitive::GaussianBlur { + sigma_x: 1.0, + sigma_y: 2.0, + }, + )])) + .unwrap(); + let filter = Filter::new(AffineTransform::identity(), rect(), program).unwrap(); + let legacy = checked(vec![ + begin(1, ScopeEffect::Opacity(ScopeOpacity::new(0.5).unwrap())), + begin(2, ScopeEffect::Clip(clip())), + begin(3, ScopeEffect::Filter(filter)), + mask(4, MaskMode::Luminance), + node(5, PaintStack::solid(CGColor::RED)), + FrameItem::MaskSource, + node(6, PaintStack::solid(CGColor::WHITE)), + FrameItem::MaskEnd, + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]); + for mode in MODES { + let enclosed = checked(wrap( + ScopeEffect::Blend(ScopeBlend::new(mode, None)), + legacy.iter().cloned().collect(), + )); + assert_eq!( + enclosed + .iter() + .skip(1) + .take(legacy.len()) + .cloned() + .collect::>(), + legacy.iter().cloned().collect::>() + ); + } + + let generated = FilterProgram::new(Arc::from([FilterNode::new( + Arc::from([]), + rect(), + FilterColorSpace::Srgb, + FilterPrimitive::SolidColor { + color: CGColor::RED.into(), + }, + )])) + .unwrap(); + let generated = Filter::new(AffineTransform::identity(), rect(), generated) + .unwrap() + .with_transparent_source(); + checked(vec![ + blend(1, ScopeBlendMode::Normal), + begin(2, ScopeEffect::Filter(generated)), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]); +} + +/// Admitting group functions cannot silently grant the same functions to any +/// visible leaf kind, including gradients used by fill or stroke stacks. +#[test] +fn group_multiply_and_screen_remain_forbidden_on_every_leaf_kind() { + let stops = || { + vec![ + cg::GradientStop { + offset: 0.0, + color: CGColor::RED.into(), + }, + cg::GradientStop { + offset: 1.0, + color: CGColor::BLUE.into(), + }, + ] + }; + for mode in [BlendMode::Multiply, BlendMode::Screen] { + let mut solid = SolidPaint::new_color(CGColor::RED); + solid.blend_mode = mode; + for paint in [ + Paint::Solid(solid), + Paint::LinearGradient(LinearGradientPaint { + blend_mode: mode, + stops: stops(), + ..Default::default() + }), + Paint::RadialGradient(RadialGradientPaint { + blend_mode: mode, + stops: stops(), + ..Default::default() + }), + ] { + assert_eq!( + PaintStack::try_from_paints(Paints::new([paint])), + Err(PaintStackError { index: 0 }) + ); + } + } +} diff --git a/crates/websem/src/svg.rs b/crates/websem/src/svg.rs index 66a26563..0b95784e 100644 --- a/crates/websem/src/svg.rs +++ b/crates/websem/src/svg.rs @@ -121,6 +121,8 @@ use csscascade::cascade::CascadeDriver; use csscascade::dom::{DemoDom, DemoNodeData, NodeId}; use style::color::{AbsoluteColor, ColorSpace}; +use style::computed_values::isolation::T as StyloIsolation; +use style::computed_values::mix_blend_mode::T as StyloBlend; use style::computed_values::stroke_linecap::T as StyloLinecap; use style::computed_values::stroke_linejoin::T as StyloLinejoin; use style::computed_values::visibility::T as Visibility; @@ -151,8 +153,9 @@ use rframe::{ FilterInput, FilterLightSource, FilterMorphology, FilterNode, FilterPrimitive, FilterProgram, FilterTurbulenceKind, Frame, FrameItem, FrameItems, FrameItemsError, FrameNode, Geometry, Identity, MAX_FILTER_CONVOLVE_KERNEL_VALUES, Mask, MaskMode, PaintAlphaFactor, PaintStack, - PathData, PatternPaint, Provenance, Scope, ScopeEffect, ScopeOpacity, Stroke, StrokeCap, - StrokeDash, StrokeDashIntervals, StrokeDashIntervalsError, StrokeJoin, StrokeSpace, VisualRef, + PathData, PatternPaint, Provenance, Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, + ScopeOpacity, Stroke, StrokeCap, StrokeDash, StrokeDashIntervals, StrokeDashIntervalsError, + StrokeJoin, StrokeSpace, VisualRef, }; use std::sync::Arc; @@ -1055,6 +1058,13 @@ fn host_ancestor_opacities(svg: HtmlElement<'_>) -> Result, CompileErro let data = element .borrow_data() .ok_or(CompileError::MissingComputedStyle)?; + if data.styles.primary().clone_mix_blend_mode() != StyloBlend::Normal + || data.styles.primary().clone_isolation() != StyloIsolation::Auto + { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode/isolation on an HTML ancestor needs the host backdrop and layer graph".to_string() + )); + } let opacity = data.styles.primary().clone_opacity().clamp(0.0, 1.0); if opacity < 1.0 { inner_to_outer.push(opacity); @@ -1346,8 +1356,8 @@ const CASCADE_PROPERTIES_NOT_REPRESENTED: &[&str] = &[ "mask-border-width", "mask-border-outset", "mask-border-repeat", - "mix-blend-mode", - "isolation", + // mix-blend-mode and isolation are read from the one computed cascade. + // Their unadmitted modes and composition contexts have value patrols. "paint-order", // The gradient rung's sheet-level patrol: Chromium consumes these from // the cascade (measured: `stop { stop-color: red }` beats the @@ -2009,6 +2019,8 @@ fn patrol_style_attribute( /// listed property (measured). Whatever is in force is what must be patrolled. fn stylesheet_findings(root: HtmlElement<'_>) -> Vec<(String, String)> { let mut found = Vec::new(); + let mut blend_declaration = false; + let mut keyframe_sheets = Vec::new(); // A sheet can declare a stroke-width in `em`/`rem` — an admitted unit whose // font-size basis may be poisoned anywhere: in the same sheet, another // sheet, or an element's own attributes. The walk visits all of them, so @@ -2026,6 +2038,11 @@ fn stylesheet_findings(root: HtmlElement<'_>) -> Vec<(String, String)> { sheet.push_str(text); } } + blend_declaration |= css_declares_property(&sheet, "mix-blend-mode") + || css_declares_property(&sheet, "isolation"); + if sheet.to_ascii_lowercase().contains("keyframes") { + keyframe_sheets.push(path.clone()); + } if sheet.contains('\\') { // An escape can hide a property name or a unit from every scan // below (`1\76 w` is `1vw` to the tokenizer) — measured painting @@ -2121,6 +2138,10 @@ fn stylesheet_findings(root: HtmlElement<'_>) -> Vec<(String, String)> { font_poison = stylesheet_font_size_poison(&sheet); } } + if let Some(style) = get_attr(element, "style") { + blend_declaration |= css_declares_property(&style, "mix-blend-mode") + || css_declares_property(&style, "isolation"); + } if font_poison.is_none() { for text in [ get_attr(element, "font-size"), @@ -2149,6 +2170,14 @@ fn stylesheet_findings(root: HtmlElement<'_>) -> Vec<(String, String)> { // Depth-first in document order: the stack pops in reverse. stack.extend(children.into_iter().rev()); } + // Static cascade does not apply CSS animation values. Do not match a + // second selector tree: keyframes and blend declarations anywhere can + // meet through custom properties, including an HTML-head stylesheet. + if blend_declaration { + for path in keyframe_sheets { + found.push(("CSS keyframes with mix-blend-mode/isolation need the animated group-composition profile".to_string(), path)); + } + } if let (Some((unit, path)), Some(poison)) = (sheet_width_font_relative, font_poison) { found.push(( format!( @@ -2259,6 +2288,56 @@ struct ComputedPatrol { opacity: f32, } +/// The computed group fact, not a second declaration parser. CSS attribute +/// lookalikes are deliberately absent from the presentation-hint inventory. +fn computed_blend_scope(element: HtmlElement<'_>) -> Result, CompileError> { + let data = element + .borrow_data() + .ok_or(CompileError::MissingComputedStyle)?; + let style = data.styles.primary(); + let mode = match style.clone_mix_blend_mode() { + StyloBlend::Normal => ScopeBlendMode::Normal, + StyloBlend::Multiply => ScopeBlendMode::Multiply, + StyloBlend::Screen => ScopeBlendMode::Screen, + other => { + return Err(CompileError::UnsupportedStyle(format!( + "mix-blend-mode {other:?} is outside the admitted normal/multiply/screen group profile" + ))); + } + }; + if mode == ScopeBlendMode::Normal && style.clone_isolation() == StyloIsolation::Auto { + return Ok(None); + } + let tag = element.local_name_string(); + if !matches!( + tag.as_str(), + "svg" + | "g" + | "a" + | "use" + | "rect" + | "circle" + | "ellipse" + | "line" + | "path" + | "polyline" + | "polygon" + ) { + return Err(CompileError::UnsupportedStyle(format!( + "mix-blend-mode/isolation on <{tag}> needs its own source composition profile" + ))); + } + let opacity = style.clone_opacity().clamp(0.0, 1.0); + Ok(Some(ScopeBlend::new( + mode, + if opacity > 0.0 && opacity < 1.0 { + Some(ScopeOpacity::new(opacity).expect("checked computed opacity")) + } else { + None + }, + ))) +} + #[derive(Debug, Clone, Copy)] enum MeasuredGeometry { /// An admitted subtree whose geometry is known to be empty. @@ -2870,6 +2949,9 @@ fn patrol_computed_style( opacity, }); } + // Also patrol resource-root and text consumers which do not enter the + // ordinary child walk. They must not silently discard a group fact. + computed_blend_scope(element)?; // SVG2 makes width/height geometry properties where they apply: a // cascaded (stylesheet or style-attribute) value beats both the // authored attribute and the auto default in Chromium, while this @@ -3144,6 +3226,23 @@ fn compile_svg_element( // both entries: the root paints nothing itself, and each descendant's // own computed (inherited) visibility decides its node. let root_patrol = patrol_computed_style(svg, true)?; + let root_composite = computed_blend_scope(svg)?; + if root_patrol.opacity > 0.0 + && root_patrol.opacity < 1.0 + && root_composite.is_some_and(|scope| scope.mode() != ScopeBlendMode::Normal) + { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode with partial opacity on the root crosses the root-layer precision boundary".to_string() + )); + } + if initial_viewport.is_none() + && root_composite.is_some_and(|scope| scope.mode() != ScopeBlendMode::Normal) + { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode on an inline root needs the host backdrop and layer graph" + .to_string(), + )); + } let root_disposition = root_patrol.disposition; reject_host_or_root_clip_path(svg)?; // The root's opacity composites the complete SVG-local raster, @@ -3289,29 +3388,75 @@ fn compile_svg_element( context_paint_transform: viewport, fonts, items: Vec::new(), + elided_blends: Vec::new(), top_level_shapes: Vec::new(), active_masks: Vec::new(), active_patterns: Vec::new(), active_markers: Vec::new(), next_id: 0, }; + let mut root_facts = SpanFacts::default(); if root_disposition != RenderDisposition::PrunedSubtree || initial_viewport.is_some() { let depth = host_opacities.len() + usize::from(root_patrol.opacity < 1.0); if depth > MAX_CONTAINER_DEPTH { return Err(CompileError::ContainerTooDeep(MAX_CONTAINER_DEPTH)); } - walk.compile_children(svg, viewport, bases, "svg", depth, 1.0)?; + root_facts = walk.compile_children(svg, viewport, bases, "svg", depth, 1.0)?; + } + let adds_root_blend_boundary = + root_composite.is_some() || (root_facts.escaping_blend && root_patrol.opacity == 1.0); + if adds_root_blend_boundary && root_facts.has_image_effect { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile".to_string() + )); + } + if initial_viewport.is_none() + && root_facts.escaping_blend + && root_composite.is_none() + && root_patrol.opacity == 1.0 + { + return Err(CompileError::UnsupportedStyle( + "unisolated mix-blend-mode in inline SVG needs the host backdrop and layer graph" + .to_string(), + )); + } + if adds_root_blend_boundary && let Some(reason) = root_facts.blend_precision_boundary { + return Err(blend_precision_refusal(reason)); } + walk.compact_elided_blends(); let ChildWalk { mut items, top_level_shapes, mut next_id, .. } = walk; - if root_patrol.opacity < 1.0 && !items.is_empty() { + if let Some(composite) = root_composite.filter(|scope| { + scope.mode() != ScopeBlendMode::Normal + || (scope.opacity().is_none() && root_facts.escaping_blend) + }) && !items.is_empty() + { + items.insert(0, blend_scope_item(&mut next_id, composite)); + items.push(FrameItem::ScopeEnd); + } else if root_patrol.opacity < 1.0 && !items.is_empty() { items.insert(0, scope_item(&mut next_id, root_patrol.opacity)); items.push(FrameItem::ScopeEnd); } + // A standalone SVG's initial backdrop is transparent, not the arbitrary + // destination the eventual Frame consumer supplies. Resolve that source + // boundary here; never turn every Frame into an implicitly isolated tree. + if initial_viewport.is_some() + && !items.is_empty() + && (root_composite.is_some_and(|scope| scope.mode() != ScopeBlendMode::Normal) + || (root_facts.escaping_blend + && root_composite.is_none() + && root_patrol.opacity == 1.0)) + { + items.insert( + 0, + blend_scope_item(&mut next_id, ScopeBlend::new(ScopeBlendMode::Normal, None)), + ); + items.push(FrameItem::ScopeEnd); + } for opacity in host_opacities.iter().rev() { if !items.is_empty() { items.insert(0, scope_item(&mut next_id, *opacity)); @@ -3377,6 +3522,16 @@ struct SpanFacts { /// transformed container, or a transformed draw, forces the layer; the /// scope element's own transform does not). transformed: bool, + /// Explicit blend/isolation participation, even if its boundary is elided. + has_blend: bool, + /// A descendant still reads this span's enclosing backdrop. A real + /// isolated group consumes this fact; geometric viewport clipping does not. + escaping_blend: bool, + /// B1 keeps image-effect composition outside its admitted group profile. + has_image_effect: bool, + /// Classify source material once; adding an isolation/blend later must not + /// silently switch its raster origin or its coverage/alpha materialization. + blend_precision_boundary: Option<&'static str>, } impl SpanFacts { @@ -3387,7 +3542,63 @@ impl SpanFacts { self.has_opacity |= other.has_opacity; self.has_geometry |= other.has_geometry; self.transformed |= other.transformed; + self.has_blend |= other.has_blend; + self.escaping_blend |= other.escaping_blend; + self.has_image_effect |= other.has_image_effect; + self.blend_precision_boundary = self + .blend_precision_boundary + .or(other.blend_precision_boundary); + } +} + +fn blend_precision_refusal(reason: &str) -> CompileError { + CompileError::UnsupportedStyle(format!( + "mix-blend-mode/isolation {reason} crosses the group-source precision boundary" + )) +} + +fn blend_node_precision_boundary(node: &FrameNode) -> Option<&'static str> { + if node.paints.is_empty() && node.stroke.is_none() { + return None; + } + if !matches!(node.geometry, Geometry::Rect(_)) { + return Some("with non-rectangular source geometry"); + } + if node + .paints + .iter() + .any(|paint| matches!(paint, cg::Paint::RadialGradient(_))) + { + return Some("with a radial source paint"); + } + if let Some(stroke) = &node.stroke + && (stroke.cap() != StrokeCap::Butt + || stroke.join() != StrokeJoin::Miter + || stroke.dash().is_some() + || stroke.dash_intervals().is_some() + || stroke.space() != StrokeSpace::Local + || stroke + .paints() + .iter() + .any(|paint| matches!(paint, cg::Paint::RadialGradient(_)))) + { + return Some("with a complex source stroke"); } + None +} + +fn blend_clip_precision_boundary(clip: &ClipPath) -> bool { + clip.layers().iter().any(|layer| { + layer.geometries().iter().any(|geometry| { + let [[a, c, _], [b, d, _]] = geometry.transform().matrix; + let bounds = geometry.bounds(); + !matches!(geometry.geometry(), Geometry::Rect(_)) + || !((b == 0.0 && c == 0.0) || (a == 0.0 && d == 0.0)) + || [bounds.x, bounds.y, bounds.width, bounds.height] + .into_iter() + .any(|value| value.fract() != 0.0) + }) + }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3458,9 +3669,15 @@ impl<'a> PatternCompiler<'a> { let (chain, external_tail) = self.template_chain(first)?; for element in &chain { + if let Some(reason) = self.override_skips.get(&element.node_id()) { + return Err(format!( + "pattern #{fragment} authored state is overridden at document load: {reason}" + )); + } patrol_rendering_attributes(*element, "pattern", &[]) .map_err(|error| error.to_string())?; patrol_style_attribute(*element, "pattern").map_err(|error| error.to_string())?; + computed_blend_scope(*element).map_err(|error| error.to_string())?; } let content_owner = chain @@ -3752,6 +3969,7 @@ impl<'a> PatternCompiler<'a> { context_paint_transform: content_to_tile, fonts: self.fonts, items: Vec::new(), + elided_blends: Vec::new(), top_level_shapes: Vec::new(), active_masks: Vec::new(), active_patterns: source_active_patterns, @@ -3769,6 +3987,7 @@ impl<'a> PatternCompiler<'a> { .map_err(|error| { format!("pattern #{fragment} source cannot compile completely: {error}") })?; + walk.compact_elided_blends(); let items = std::mem::take(&mut walk.items); drop(walk); if let Some(degradation) = degradations.first() { @@ -4766,7 +4985,7 @@ mod marker_resource { /// Geometric `clip-path` wraps a completed span in resolved path coverage; /// same-document image masks wrap it in a checked target/source composite, /// and resolved filter programs wrap it in an isolated image operation. -/// `mix-blend-mode` and `isolation` remain patrol refusals. +/// Computed blending adds only the source boundaries its descendants need. struct ChildWalk<'a> { values: &'a EffectiveValues, mode: CompileMode, @@ -4821,6 +5040,11 @@ struct ChildWalk<'a> { /// name instead of reaching for an ambient face. fonts: &'a textlayout::Environment, items: Vec, + /// Lazily allocated, identity-indexed tombstones for redundant normal + /// isolation. Keeping balanced placeholders during the walk avoids moving + /// an entire descendant span for each enclosing isolation. Program exit + /// compacts once; identity rewind also truncates this ledger. + elided_blends: Vec, /// The materialized nodes that are direct children of the root `` /// — the animation inventory's candidate targets, which it narrows /// further to ``. @@ -4851,6 +5075,45 @@ struct PaintContext<'d> { } impl<'a> ChildWalk<'a> { + fn reset_next_id(&mut self, next_id: u64) { + self.next_id = next_id; + self.elided_blends.truncate(next_id as usize); + } + + fn elide_blend(&mut self, identity: u64) { + if self.elided_blends.len() < identity as usize { + self.elided_blends.resize(identity as usize, false); + } + self.elided_blends[identity as usize - 1] = true; + } + + fn compact_elided_blends(&mut self) { + if self.elided_blends.is_empty() { + return; + } + let mut scopes = Vec::new(); + self.items.retain(|item| match item { + FrameItem::ScopeBegin(scope) => { + let elided = self + .elided_blends + .get(scope.owner.identity().get() as usize - 1) + .copied() + .unwrap_or(false); + debug_assert!( + !elided + || matches!(scope.effect, ScopeEffect::Blend(blend) + if blend.mode() == ScopeBlendMode::Normal && blend.opacity().is_none()) + ); + scopes.push(elided); + !elided + } + FrameItem::ScopeEnd => !scopes.pop().expect("balanced staged scope"), + _ => true, + }); + debug_assert!(scopes.is_empty()); + self.elided_blends.clear(); + } + fn resolve_clip( &self, element: HtmlElement<'a>, @@ -4929,6 +5192,8 @@ impl<'a> ChildWalk<'a> { draws: 0, opacity_passes: 0, has_scope: true, + has_image_effect: true, + escaping_blend: false, ..facts }; facts @@ -5009,7 +5274,7 @@ impl<'a> ChildWalk<'a> { self.degradations.truncate(degradation_checkpoint); if let Err(error) = source_result { self.items.truncate(checkpoint); - self.next_id = source_next_id; + self.reset_next_id(source_next_id); return Err(error); } @@ -5018,6 +5283,8 @@ impl<'a> ChildWalk<'a> { draws: 0, opacity_passes: 0, has_scope: true, + has_image_effect: true, + escaping_blend: false, ..facts }; Ok(facts) @@ -5054,6 +5321,7 @@ impl<'a> ChildWalk<'a> { opacity_passes: 0, has_scope: materialized, has_opacity: true, + escaping_blend: false, ..facts }; } @@ -5064,16 +5332,47 @@ impl<'a> ChildWalk<'a> { /// precedes the target's own opacity work, making clip outer and opacity /// inner — the exact same-element order Chromium's byte probe selects. fn wrap_span_with_clip( + &mut self, + checkpoint: usize, + facts: SpanFacts, + clip: Option, + ) -> SpanFacts { + self.wrap_span_with_clip_boundary(checkpoint, facts, clip, false) + } + + fn wrap_span_with_clip_boundary( &mut self, checkpoint: usize, mut facts: SpanFacts, clip: Option, + isolate_blending: bool, ) -> SpanFacts { if let Some(clip) = clip && (facts.draws > 0 || facts.has_scope) { - self.items - .insert(checkpoint, clip_scope_item(&mut self.next_id, clip)); + if blend_clip_precision_boundary(&clip) { + facts.blend_precision_boundary = facts + .blend_precision_boundary + .or(Some("with curved, subpixel, or rotated clip coverage")); + } + if isolate_blending && facts.escaping_blend { + let blend = blend_scope_item( + &mut self.next_id, + ScopeBlend::new(ScopeBlendMode::Normal, None), + ); + let clip = clip_scope_item(&mut self.next_id, clip); + // One exact-size gap shifts the existing clip's suffix once, + // not a second time for the added isolation boundary. The + // legacy clip walk is still depth-dependent; this is not a + // claim that the whole compiler has become linear. + self.items.splice(checkpoint..checkpoint, [clip, blend]); + self.items.push(FrameItem::ScopeEnd); + facts.has_blend = true; + facts.escaping_blend = false; + } else { + self.items + .insert(checkpoint, clip_scope_item(&mut self.next_id, clip)); + } self.items.push(FrameItem::ScopeEnd); facts = SpanFacts { draws: 0, @@ -5085,6 +5384,19 @@ impl<'a> ChildWalk<'a> { facts } + /// Authored SVG clip-path groups blend descendants, unlike the plain + /// viewport clip above. Carry the existing escaping-backdrop fact upward + /// rather than rescanning descendants or isolating every neutral group. + fn wrap_span_with_svg_clip( + &mut self, + checkpoint: usize, + facts: SpanFacts, + clip: Option, + already_isolated: bool, + ) -> SpanFacts { + self.wrap_span_with_clip_boundary(checkpoint, facts, clip, !already_isolated) + } + /// Compile a parent's children in painter order, accumulating the span /// facts the parent's own opacity decision reads. `replay_opacity` is an /// enclosing container's one-pass factor mid-replay (see @@ -5197,23 +5509,8 @@ impl<'a> ChildWalk<'a> { // its `href` is interaction, not paint), so the two share the // one container compiler and its patrols. `` is a // container whose children are its expanded shadow content. - let result = if tag == "g" || tag == "a" { - self.compile_container(c, transform, bases, &path, depth, &tag, replay_opacity) - } else if tag == "svg" { - self.compile_nested_viewport(c, transform, bases, &path, depth, replay_opacity) - } else if tag == "use" { - self.compile_use(c, transform, bases, &path, depth, replay_opacity) - } else { - self.compile_leaf( - c, - transform, - bases, - &path, - depth, - depth == 0, - replay_opacity, - ) - }; + let result = + self.compile_child(c, transform, bases, &path, depth, &tag, replay_opacity); match result { Ok(child_facts) => facts.absorb(child_facts), Err(error) => match self.mode { @@ -5230,6 +5527,134 @@ impl<'a> ChildWalk<'a> { Ok(facts) } + /// Append explicit blend boundaries before descending. Default containers + /// add no command or subtree rescan. Non-normal blending combines own + /// opacity in its final restore. Normal isolation instead preserves the + /// established opacity fold/layer route and elides redundant unit scopes. + #[allow(clippy::too_many_arguments)] + fn compile_child( + &mut self, + el: HtmlElement<'a>, + transform: AffineTransform, + bases: PercentBases, + path: &str, + depth: usize, + tag: &str, + replay_opacity: f32, + ) -> Result { + let composite = computed_blend_scope(el)?; + if composite.is_some() + && (!self.active_masks.is_empty() + || !self.active_patterns.is_empty() + || !self.active_markers.is_empty()) + { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode/isolation in a mask, pattern, or marker source needs its own source composition profile".to_string() + )); + } + if composite.is_some() && patrol_computed_style(el, false)?.opacity == 0.0 { + return Ok(SpanFacts { + has_opacity: true, + ..SpanFacts::default() + }); + } + let checkpoint = ( + self.items.len(), + self.next_id, + self.top_level_shapes.len(), + self.degradations.len(), + ); + // Own partial opacity already provides isolation through the existing + // measured fold/layer route. Normal isolation must not replace that + // route with a blend restore (different alpha rounding). + let emitted_composite = composite + .filter(|scope| scope.mode() != ScopeBlendMode::Normal || scope.opacity().is_none()); + if let Some(composite) = emitted_composite { + self.items + .push(blend_scope_item(&mut self.next_id, composite)); + } + let content_start = self.items.len(); + let blend_id = self.next_id; + let defer = emitted_composite.is_some(); + let result = if tag == "g" || tag == "a" { + self.compile_container( + el, + transform, + bases, + path, + depth, + tag, + replay_opacity, + defer, + ) + } else if tag == "svg" { + self.compile_nested_viewport(el, transform, bases, path, depth, replay_opacity, defer) + } else if tag == "use" { + self.compile_use(el, transform, bases, path, depth, replay_opacity, defer) + } else { + self.compile_leaf( + el, + transform, + bases, + path, + depth, + depth == 0, + replay_opacity, + defer, + ) + }; + let result = result.and_then(|mut facts| { + // Authored participation remains a patrol fact even when normal + // isolation needs no materialized boundary. Otherwise an enclosing + // image effect could bypass its conservative composition profile. + // This does not set has_scope or block the one-pass opacity fold. + facts.has_blend |= composite.is_some() && self.items.len() != content_start; + if facts.has_blend && facts.has_image_effect { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile".to_string() + )); + } + if (composite.is_some() || facts.has_blend) && let Some(reason) = facts.blend_precision_boundary { + return Err(blend_precision_refusal(reason)); + } + if let Some(composite) = composite { + if facts.has_image_effect { + return Err(CompileError::UnsupportedStyle( + "mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile".to_string() + )); + } + if emitted_composite.is_none() { + // The established opacity route already consumed it. + } else if self.items.len() == content_start { + self.items.truncate(checkpoint.0); + self.reset_next_id(checkpoint.1); + } else { + self.items.push(FrameItem::ScopeEnd); + if composite.mode() == ScopeBlendMode::Normal && !facts.escaping_blend { + self.elide_blend(blend_id); + return Ok(facts); + } + facts.draws = 0; + facts.opacity_passes = 0; + facts.has_scope = true; + facts.has_blend = true; + facts.has_opacity |= composite.opacity().is_some(); + facts.escaping_blend = composite.mode() != ScopeBlendMode::Normal; + } + } + Ok(facts) + }); + if result.is_err() { + // A named skipped element cannot leave a partially built group or + // any of its earlier source draws in the accepted sibling stream. + self.items.truncate(checkpoint.0); + self.reset_next_id(checkpoint.1); + self.top_level_shapes.truncate(checkpoint.2); + self.degradations.truncate(checkpoint.3); + } + result + } + /// A container element: patrolled like any admitted element, then /// descended with its own transform composed onto the inherited one. /// @@ -5250,13 +5675,17 @@ impl<'a> ChildWalk<'a> { depth: usize, element: &str, replay_opacity: f32, + defer_own_opacity: bool, ) -> Result { if depth >= MAX_CONTAINER_DEPTH { return Err(CompileError::ContainerTooDeep(MAX_CONTAINER_DEPTH)); } patrol_rendering_attributes(el, element, &[])?; patrol_style_attribute(el, element)?; - let patrol = patrol_computed_style(el, false)?; + let mut patrol = patrol_computed_style(el, false)?; + if defer_own_opacity { + patrol.opacity = 1.0; + } match patrol.disposition { // `display: none` generates no box: the subtree is pruned — // Chromium's correct nothing, not a hole to declare. A *hidden* @@ -5311,7 +5740,7 @@ impl<'a> ChildWalk<'a> { // Chromium's same-element effect order is byte-discriminating here: // filter is inside mask, mask is inside opacity, and all three are // inside the geometric clip. - let facts = self.wrap_span_with_clip(checkpoint, facts?, clip); + let facts = self.wrap_span_with_svg_clip(checkpoint, facts?, clip, defer_own_opacity); Ok(SpanFacts { transformed: facts.transformed || own_transformed, ..facts @@ -5336,6 +5765,7 @@ impl<'a> ChildWalk<'a> { path: &str, depth: usize, replay_opacity: f32, + defer_own_opacity: bool, ) -> Result { if depth >= MAX_CONTAINER_DEPTH { return Err(CompileError::ContainerTooDeep(MAX_CONTAINER_DEPTH)); @@ -5346,7 +5776,10 @@ impl<'a> ChildWalk<'a> { // current Blink intentionally excludes them from the viewport's used // geometry. Keep that source-provenance split named until the shared // sizing row owns it; direct attributes are resolved below. - let patrol = patrol_computed_style(el, true)?; + let mut patrol = patrol_computed_style(el, true)?; + if defer_own_opacity { + patrol.opacity = 1.0; + } if patrol.disposition == RenderDisposition::PrunedSubtree { return Ok(SpanFacts::default()); } @@ -5419,7 +5852,8 @@ impl<'a> ChildWalk<'a> { }; self.context_paint_transform = previous_context_paint_transform; - let facts = self.wrap_span_with_clip(checkpoint, facts?, authored_clip); + let facts = + self.wrap_span_with_svg_clip(checkpoint, facts?, authored_clip, defer_own_opacity); let viewport_mapping_is_identity = viewport.x == 0.0 && viewport.y == 0.0 && viewport.content_mapping == AffineTransform::identity(); @@ -5461,7 +5895,7 @@ impl<'a> ChildWalk<'a> { // geometry in Chromium, and therefore blocks an enclosing // fold, but its completed visual contribution is nothing. self.items.truncate(checkpoint.0); - self.next_id = checkpoint.1; + self.reset_next_id(checkpoint.1); self.degradations.truncate(checkpoint.2); facts = SpanFacts { has_opacity: true, @@ -5477,7 +5911,7 @@ impl<'a> ChildWalk<'a> { // Replay the span with the accumulated factor so the sole // draw can choose its solid-fold or post-paint-alpha route. self.items.truncate(checkpoint.0); - self.next_id = checkpoint.1; + self.reset_next_id(checkpoint.1); self.degradations.truncate(checkpoint.2); facts = self.compile_children( el, @@ -5500,6 +5934,7 @@ impl<'a> ChildWalk<'a> { opacity_passes: 0, has_scope: materialized, has_opacity: true, + escaping_blend: false, ..facts }; } @@ -5536,6 +5971,7 @@ impl<'a> ChildWalk<'a> { path: &str, depth: usize, replay_opacity: f32, + defer_own_opacity: bool, ) -> Result { if depth >= MAX_CONTAINER_DEPTH { return Err(CompileError::ContainerTooDeep(MAX_CONTAINER_DEPTH)); @@ -5580,7 +6016,10 @@ impl<'a> ChildWalk<'a> { "its referenced root needs the instance-sized viewport contract".to_string(), )); } - let patrol = patrol_computed_style(el, false)?; + let mut patrol = patrol_computed_style(el, false)?; + if defer_own_opacity { + patrol.opacity = 1.0; + } match patrol.disposition { RenderDisposition::PrunedSubtree => return Ok(SpanFacts::default()), RenderDisposition::Renders | RenderDisposition::HiddenPaint => {} @@ -5641,7 +6080,7 @@ impl<'a> ChildWalk<'a> { }; self.context_paint_transform = previous_context_paint_transform; self.paint_contexts.pop(); - let facts = self.wrap_span_with_clip(checkpoint, facts?, clip); + let facts = self.wrap_span_with_svg_clip(checkpoint, facts?, clip, defer_own_opacity); // The `x`/`y` translate is part of the use's own transform (SVG2 // §5.6.2), so like the transform property it stays *on* this // element — an enclosing one-pass route is broken only by a transform @@ -5680,7 +6119,7 @@ impl<'a> ChildWalk<'a> { ); if result.is_err() { self.items.truncate(checkpoint); - self.next_id = next_id; + self.reset_next_id(next_id); } result } @@ -5898,6 +6337,7 @@ impl<'a> ChildWalk<'a> { context_paint_transform: content_to_frame, fonts: self.fonts, items: Vec::new(), + elided_blends: Vec::new(), top_level_shapes: Vec::new(), active_masks: self.active_masks.clone(), active_patterns: self.active_patterns.clone(), @@ -5917,6 +6357,7 @@ impl<'a> ChildWalk<'a> { "marker #{fragment} source cannot compile completely: {error}" )) })?; + walk.compact_elided_blends(); let items = std::mem::take(&mut walk.items); let next_id = walk.next_id; drop(walk); @@ -5932,7 +6373,7 @@ impl<'a> ChildWalk<'a> { "marker #{fragment} source item stream is invalid: {error}" )) })?; - self.next_id = next_id; + self.reset_next_id(next_id); Ok(items) } @@ -5945,6 +6386,7 @@ impl<'a> ChildWalk<'a> { depth: usize, top_level: bool, replay_opacity: f32, + defer_own_opacity: bool, ) -> Result { // An admitted shape may resolve to no visual fact at all — a `` // whose `d` draws nothing. That is not a hole: the element is @@ -5989,11 +6431,12 @@ impl<'a> ChildWalk<'a> { } else { None }; - let deferred_opacity = if mask.is_some() || filter.is_some() || marker_selected { - patrol_computed_style(el, tag == "rect")?.opacity - } else { - 1.0 - }; + let deferred_opacity = + if !defer_own_opacity && (mask.is_some() || filter.is_some() || marker_selected) { + patrol_computed_style(el, tag == "rect")?.opacity + } else { + 1.0 + }; let compilation = compile_shape( el, transform, @@ -6005,7 +6448,7 @@ impl<'a> ChildWalk<'a> { &self.active_patterns, &self.paint_contexts, bases, - mask.is_some() || filter.is_some() || marker_selected, + defer_own_opacity || mask.is_some() || filter.is_some() || marker_selected, replay_opacity, self.fonts, marker_projection, @@ -6049,6 +6492,8 @@ impl<'a> ChildWalk<'a> { facts.has_opacity = outcome.has_opacity; facts.has_geometry = outcome.has_geometry; facts.transformed = outcome.transformed; + facts.blend_precision_boundary = + outcome.nodes.iter().find_map(blend_node_precision_boundary); } let marker_facts = match self.compile_marker_instances( el, @@ -6067,7 +6512,7 @@ impl<'a> ChildWalk<'a> { // best effort the parent will declare and skip this element; // no already-emitted fill or stroke may survive that skip. self.items.truncate(checkpoint); - self.next_id = next_id_checkpoint; + self.reset_next_id(next_id_checkpoint); return Err(error); } }; @@ -6091,7 +6536,7 @@ impl<'a> ChildWalk<'a> { Ok(order) => order, Err(error) => { self.items.truncate(checkpoint); - self.next_id = next_id_checkpoint; + self.reset_next_id(next_id_checkpoint); return Err(error); } } @@ -6178,6 +6623,14 @@ fn scope_item(next_id: &mut u64, opacity: f32) -> FrameItem { }) } +fn blend_scope_item(next_id: &mut u64, composite: ScopeBlend) -> FrameItem { + *next_id += 1; + FrameItem::ScopeBegin(Scope { + owner: VisualRef::new(Identity::new(*next_id), Provenance::new(*next_id)), + effect: ScopeEffect::Blend(composite), + }) +} + fn clip_scope_item(next_id: &mut u64, clip: ClipPath) -> FrameItem { let scope_id = *next_id + 1; *next_id += 1; @@ -6694,6 +7147,9 @@ mod clip_path { if patrol.disposition != RenderDisposition::Renders { return Ok(Contribution::None); } + if computed_blend_scope(element)?.is_some() { + return Err(CompileError::UnsupportedClipPath("mix-blend-mode/isolation on a geometric clip contributor needs its own source profile".to_string())); + } if element_has_computed_clip_path(element)? { return Ok(Contribution::Mask(format!( "a <{tag}> contributor with its own clip-path uses Chromium's raster-mask strategy" @@ -6726,6 +7182,9 @@ mod clip_path { if patrol.disposition != RenderDisposition::Renders { return Ok(Contribution::None); } + if computed_blend_scope(element)?.is_some() { + return Err(CompileError::UnsupportedClipPath("mix-blend-mode/isolation on a geometric clip contributor needs its own source profile".to_string())); + } if element_has_computed_clip_path(element)? { return Ok(Contribution::Mask( "a contributor with its own clip-path uses Chromium's raster-mask strategy" @@ -10090,6 +10549,7 @@ mod mask_resource { /// its dedicated decoder below. Everything else the pinned cascade drops /// must refuse before source paint can escape. fn patrol_resource_style(element: HtmlElement<'_>) -> Result<(), CompileError> { + computed_blend_scope(element)?; if let Some(style) = get_attr(element, "style") && let Some(property) = unrepresented_property_except(&style, &["filter", "mask", "mask-type"]) diff --git a/crates/websem/tests/capability_status.rs b/crates/websem/tests/capability_status.rs index 9ecdc91a..eb1d2182 100644 --- a/crates/websem/tests/capability_status.rs +++ b/crates/websem/tests/capability_status.rs @@ -16,13 +16,15 @@ #[path = "support/fixture_fonts.rs"] mod fixture_fonts; +#[path = "support/unsupported_fixture.rs"] +mod unsupported_fixture; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use serde::Deserialize; -use websem::{DegradationAction, InitialViewport, SvgFrameSource}; +use websem::DegradationAction; #[derive(Debug, Deserialize)] struct PrimitiveSuite { @@ -54,10 +56,6 @@ fn refusal_text_is_literal_inside_the_generated_table() { assert_eq!(cell("a | b\nc * d"), r"a \| b c \* d"); } -fn viewport() -> InitialViewport { - InitialViewport::new(64.0, 64.0) -} - #[test] fn the_committed_status_view_is_fresh() { let generated = generate(); @@ -85,8 +83,14 @@ fn generate() -> String { .expect("read the unsupported corpus") .filter_map(Result::ok) .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name.ends_with(".svg")) - .map(|name| name.trim_end_matches(".svg").to_string()) + .filter(|name| name.ends_with(".svg") || name.ends_with(".html")) + .map(|name| { + Path::new(&name) + .file_stem() + .unwrap() + .to_string_lossy() + .into_owned() + }) .collect(); refusals.sort(); @@ -147,20 +151,10 @@ fn generate() -> String { | --- | --- | --- |\n", ); for id in &refusals { - let source = fs::read_to_string(root.join("unsupported").join(format!("{id}.svg"))) - .unwrap_or_else(|error| panic!("{id}: read: {error}")); - let strict = SvgFrameSource::from_standalone_svg_with_fonts( - source.as_str(), - viewport(), - fixture_fonts::unsupported_environment(id), - ) - .err() - .unwrap_or_else(|| panic!("{id}: an unsupported fixture must refuse under strict")); - match SvgFrameSource::from_standalone_svg_best_effort_with_fonts( - source.as_str(), - viewport(), - fixture_fonts::unsupported_environment(id), - ) { + let strict = unsupported_fixture::compile(&root.join("unsupported"), id, false) + .err() + .unwrap_or_else(|| panic!("{id}: an unsupported fixture must refuse under strict")); + match unsupported_fixture::compile(&root.join("unsupported"), id, true) { Err(_) => { writeln!( out, diff --git a/crates/websem/tests/clip_path_contract.rs b/crates/websem/tests/clip_path_contract.rs index 4a1799f3..eb5117e7 100644 --- a/crates/websem/tests/clip_path_contract.rs +++ b/crates/websem/tests/clip_path_contract.rs @@ -46,7 +46,7 @@ fn clips(frame: &Frame) -> Vec<&rframe::ClipPath> { .filter_map(|item| match item { FrameItem::ScopeBegin(scope) => match &scope.effect { ScopeEffect::Clip(clip) => Some(clip), - ScopeEffect::Opacity(_) | ScopeEffect::Filter(_) => None, + ScopeEffect::Opacity(_) | ScopeEffect::Blend(_) | ScopeEffect::Filter(_) => None, }, FrameItem::Node(_) | FrameItem::ScopeEnd diff --git a/crates/websem/tests/filter_contract.rs b/crates/websem/tests/filter_contract.rs index 0e04047a..573310fb 100644 --- a/crates/websem/tests/filter_contract.rs +++ b/crates/websem/tests/filter_contract.rs @@ -93,7 +93,7 @@ fn resolved_filter(frame: &Frame) -> &Filter { .find_map(|item| match item { FrameItem::ScopeBegin(scope) => match &scope.effect { ScopeEffect::Filter(filter) => Some(filter), - ScopeEffect::Opacity(_) | ScopeEffect::Clip(_) => None, + ScopeEffect::Opacity(_) | ScopeEffect::Blend(_) | ScopeEffect::Clip(_) => None, }, _ => None, }) @@ -117,6 +117,7 @@ fn gaussian_blur_resolves_to_one_source_neutral_checked_graph() { FrameItem::ScopeBegin(scope) => match scope.effect { ScopeEffect::Filter(_) => "filter-begin", ScopeEffect::Opacity(_) => "opacity-begin", + ScopeEffect::Blend(_) => "blend-begin", ScopeEffect::Clip(_) => "clip-begin", }, FrameItem::ScopeEnd => "scope-end", @@ -133,7 +134,7 @@ fn gaussian_blur_resolves_to_one_source_neutral_checked_graph() { .find_map(|item| match item { FrameItem::ScopeBegin(scope) => match &scope.effect { ScopeEffect::Filter(filter) => Some(filter), - ScopeEffect::Opacity(_) | ScopeEffect::Clip(_) => None, + ScopeEffect::Opacity(_) | ScopeEffect::Blend(_) | ScopeEffect::Clip(_) => None, }, _ => None, }) @@ -178,7 +179,7 @@ fn hard_shadow_graph_resolves_zero_one_two_and_n_input_operations() { .find_map(|item| match item { FrameItem::ScopeBegin(scope) => match &scope.effect { ScopeEffect::Filter(filter) => Some(filter), - ScopeEffect::Opacity(_) | ScopeEffect::Clip(_) => None, + ScopeEffect::Opacity(_) | ScopeEffect::Blend(_) | ScopeEffect::Clip(_) => None, }, _ => None, }) @@ -238,7 +239,7 @@ fn drop_shadow_resolves_to_one_native_checked_operation() { .find_map(|item| match item { FrameItem::ScopeBegin(scope) => match &scope.effect { ScopeEffect::Filter(filter) => Some(filter), - ScopeEffect::Opacity(_) | ScopeEffect::Clip(_) => None, + ScopeEffect::Opacity(_) | ScopeEffect::Blend(_) | ScopeEffect::Clip(_) => None, }, _ => None, }) @@ -1649,6 +1650,7 @@ fn admitted_effect_order_is_clip_then_opacity_then_mask_then_filter() { ScopeEffect::Clip(_) => "clip-begin", ScopeEffect::Opacity(_) => "opacity-begin", ScopeEffect::Filter(_) => "filter-begin", + ScopeEffect::Blend(_) => "blend-begin", }, FrameItem::MaskBegin(_) => "mask-begin", FrameItem::Node(_) => "node", diff --git a/crates/websem/tests/marker_contract.rs b/crates/websem/tests/marker_contract.rs index 67a6c2ef..19aaafd7 100644 --- a/crates/websem/tests/marker_contract.rs +++ b/crates/websem/tests/marker_contract.rs @@ -95,7 +95,7 @@ fn instances_flatten_to_ordinary_nodes_inside_hard_viewport_clips() { .filter_map(|item| match item { FrameItem::ScopeBegin(scope) => match &scope.effect { ScopeEffect::Clip(clip) => Some(clip), - ScopeEffect::Opacity(_) | ScopeEffect::Filter(_) => None, + ScopeEffect::Opacity(_) | ScopeEffect::Blend(_) | ScopeEffect::Filter(_) => None, }, FrameItem::Node(_) | FrameItem::ScopeEnd diff --git a/crates/websem/tests/mask_contract.rs b/crates/websem/tests/mask_contract.rs index 7031b6c9..649d95a3 100644 --- a/crates/websem/tests/mask_contract.rs +++ b/crates/websem/tests/mask_contract.rs @@ -170,6 +170,7 @@ fn same_element_order_is_clip_then_opacity_then_mask() { ScopeEffect::Clip(_) => "clip-begin", ScopeEffect::Opacity(_) => "opacity-begin", ScopeEffect::Filter(_) => "filter-begin", + ScopeEffect::Blend(_) => "blend-begin", }, FrameItem::MaskBegin(_) => "mask-begin", FrameItem::Node(_) => "node", diff --git a/crates/websem/tests/nested_viewport_contract.rs b/crates/websem/tests/nested_viewport_contract.rs index 1113dd57..4e633f02 100644 --- a/crates/websem/tests/nested_viewport_contract.rs +++ b/crates/websem/tests/nested_viewport_contract.rs @@ -60,6 +60,7 @@ fn item_tags(frame: &Frame) -> Vec<&'static str> { ScopeEffect::Clip(_) => "clip-begin", ScopeEffect::Filter(_) => "filter-begin", ScopeEffect::Opacity(_) => "opacity-begin", + ScopeEffect::Blend(_) => "blend-begin", }, FrameItem::ScopeEnd => "scope-end", FrameItem::MaskBegin(_) => "mask-begin", diff --git a/crates/websem/tests/shapes_contract.rs b/crates/websem/tests/shapes_contract.rs index 777afabc..cb04e31a 100644 --- a/crates/websem/tests/shapes_contract.rs +++ b/crates/websem/tests/shapes_contract.rs @@ -802,7 +802,7 @@ fn cascade_properties_the_build_cannot_represent_refuse_by_name() { ), ( "mix-blend-mode", - r##""##, + r##""##, "mix-blend-mode", ), ] { diff --git a/crates/websem/tests/support/unsupported_fixture.rs b/crates/websem/tests/support/unsupported_fixture.rs new file mode 100644 index 00000000..3894f103 --- /dev/null +++ b/crates/websem/tests/support/unsupported_fixture.rs @@ -0,0 +1,38 @@ +//! One ingress dispatch for the SVG and HTML named-refusal corpus. +use std::path::Path; +use websem::{CompileError, InitialViewport, SvgFrameSource}; + +pub(crate) fn compile( + root: &Path, + id: &str, + best_effort: bool, +) -> Result { + let svg = root.join(format!("{id}.svg")); + let html = root.join(format!("{id}.html")); + assert_ne!( + svg.exists(), + html.exists(), + "{id}: exactly one declared source ingress" + ); + let source = std::fs::read_to_string(if svg.exists() { &svg } else { &html }) + .unwrap_or_else(|error| panic!("{id}: read: {error}")); + if html.exists() { + if best_effort { + SvgFrameSource::from_html_inline_svg_best_effort(source.as_str()) + } else { + SvgFrameSource::from_html_inline_svg(source.as_str()) + } + } else if best_effort { + SvgFrameSource::from_standalone_svg_best_effort_with_fonts( + source.as_str(), + InitialViewport::new(64.0, 64.0), + crate::fixture_fonts::unsupported_environment(id), + ) + } else { + SvgFrameSource::from_standalone_svg_with_fonts( + source.as_str(), + InitialViewport::new(64.0, 64.0), + crate::fixture_fonts::unsupported_environment(id), + ) + } +} diff --git a/crates/websem/tests/svg_blending.rs b/crates/websem/tests/svg_blending.rs new file mode 100644 index 00000000..99619b4e --- /dev/null +++ b/crates/websem/tests/svg_blending.rs @@ -0,0 +1,385 @@ +//! Computed group ownership and refusal transactions. Pixel meaning is +//! independently guarded by the Chromium cells, not by these frame assertions. +use rframe::{Frame, FrameItem, ScopeBlend, ScopeBlendMode, ScopeEffect}; +use websem::{InitialViewport, SvgFrameSource, compile_standalone_svg}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} +fn frame(body: &str) -> Frame { + compile_standalone_svg(&svg(body), InitialViewport::new(64.0, 64.0)).unwrap() +} +fn blends(frame: &Frame) -> Vec { + frame + .items + .iter() + .filter_map(|item| match item { + FrameItem::ScopeBegin(scope) => match scope.effect { + ScopeEffect::Blend(blend) => Some(blend), + _ => None, + }, + _ => None, + }) + .collect() +} +const RECT: &str = r#""#; + +#[test] +fn neutral_groups_have_no_scope_and_raw_attribute_lookalikes_are_inert() { + for attrs in [ + "", + "style='mix-blend-mode:normal;isolation:auto'", + "mix-blend-mode='multiply' isolation='isolate'", + ] { + let result = frame(&format!("{RECT}")); + assert_eq!(result.items.len(), 1); + } +} + +#[test] +fn combined_opacity_is_one_blend_operation_not_a_nested_opacity_scope() { + let result = frame(&format!( + "{RECT}{RECT}" + )); + let ops = blends(&result); + assert_eq!(ops.len(), 2); // standalone initial backdrop + completed group + assert_eq!(ops[0].mode(), ScopeBlendMode::Normal); + assert_eq!(ops[1].mode(), ScopeBlendMode::Multiply); + assert_eq!(ops[1].opacity().unwrap().get(), 0.5); + assert!(!result.items.iter().any(|item|matches!(item,FrameItem::ScopeBegin(scope) if matches!(scope.effect,ScopeEffect::Opacity(_))))); +} + +#[test] +fn neutral_isolation_survives_and_consumes_descendant_backdrop_dependency() { + let result = frame(&format!( + "{RECT}" + )); + let ops = blends(&result); + assert_eq!(ops.len(), 2); // no extra standalone root boundary needed + assert_eq!(ops[0].mode(), ScopeBlendMode::Normal); + assert_eq!(ops[1].mode(), ScopeBlendMode::Screen); +} + +#[test] +fn redundant_normal_isolation_preserves_the_one_pass_opacity_fold() { + for body in [ + "", + "", + "", + ] { + let result = frame(body); + assert_eq!(result.items.len(), 1, "{body}"); + assert!(matches!( + result.items.iter().next(), + Some(FrameItem::Node(_)) + )); + } + let deep = format!( + "{}{}{}", + "".repeat(48), + RECT, + "".repeat(48) + ); + assert_eq!(frame(&deep).items.len(), 1); +} + +#[test] +fn elision_rollback_cannot_erase_a_reused_scope_identity() { + let source = svg(&format!( + "{RECT}{RECT}" + )); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!( + best.degradations() + .iter() + .filter(|d| d.action() == websem::DegradationAction::Skipped) + .count(), + 1 + ); + let modes: Vec<_> = blends(&best.base_frame()) + .iter() + .map(|b| b.mode()) + .collect(); + assert_eq!( + modes, + [ + ScopeBlendMode::Normal, + ScopeBlendMode::Screen, + ScopeBlendMode::Normal, + ScopeBlendMode::Multiply + ] + ); +} + +#[test] +fn outer_root_blending_keeps_its_own_transparent_initial_backdrop() { + for (value, mode) in [ + ("multiply", ScopeBlendMode::Multiply), + ("screen", ScopeBlendMode::Screen), + ] { + let source = svg(RECT).replace( + "width=\"64\"", + &format!("style='mix-blend-mode:{value}' width=\"64\""), + ); + let result = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)).unwrap(); + let modes: Vec<_> = blends(&result).iter().map(|b| b.mode()).collect(); + assert_eq!(modes, [ScopeBlendMode::Normal, mode]); + assert!( + matches!(result.items.iter().next(), Some(FrameItem::ScopeBegin(scope)) + if matches!(scope.effect, ScopeEffect::Blend(b) if b.mode() == ScopeBlendMode::Normal)) + ); + } +} + +#[test] +fn computed_winners_are_consumed_without_a_second_matcher() { + for attrs in [ + "style='mix-blend-mode:screen;mix-blend-mode:invalid'", + "style='--blend:screen;mix-blend-mode:var(--blend)'", + "class='subject' style='mix-blend-mode:multiply'", + ] { + let result = frame(&format!( + "" + )); + assert_eq!( + blends(&result).last().unwrap().mode(), + ScopeBlendMode::Screen + ); + } +} + +#[test] +fn unsupported_mode_skips_one_named_element_and_keeps_its_sibling() { + let source = svg(&format!( + "{RECT}{RECT}" + )); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(strict.contains("mix-blend-mode Overlay"), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + let result = best.base_frame(); + assert_eq!(result.items.len(), 1); + let skipped: Vec<_> = best + .degradations() + .iter() + .filter(|d| d.action() == websem::DegradationAction::Skipped) + .collect(); + assert_eq!(skipped.len(), 1); + assert_eq!(skipped[0].path(), "svg/g[1]"); + assert!(skipped[0].reason().contains("mix-blend-mode Overlay")); +} + +#[test] +fn authored_clip_isolates_but_nested_viewport_clip_does_not() { + let child = format!("{RECT}"); + let clipped = frame(&format!( + "{child}" + )); + // Normal clip boundary + multiply child; no standalone-root layer needed. + assert_eq!(blends(&clipped).len(), 2); + let nested = frame(&format!("{child}")); + // The normal boundary belongs to the standalone root, outside viewport clip. + assert!( + matches!(nested.items.iter().next(),Some(FrameItem::ScopeBegin(scope)) if matches!(scope.effect,ScopeEffect::Blend(_))) + ); + assert!( + matches!(clipped.items.iter().next(),Some(FrameItem::ScopeBegin(scope)) if matches!(scope.effect,ScopeEffect::Clip(_))) + ); +} + +#[test] +fn image_effect_refusal_is_transactional() { + let body = format!( + "{RECT}{RECT}{RECT}" + ); + let source = svg(&body); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(strict.contains("image-effect composition"), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!( + best.base_frame().items.len(), + 1, + "no partial group source survives" + ); + assert!( + best.degradations() + .iter() + .any(|d| d.path() == "svg/g[1]" && d.reason().contains("image-effect composition")) + ); +} + +#[test] +fn elided_normal_isolation_keeps_ancestor_image_effect_patrols() { + for (effect, resource) in [ + ( + "filter", + "", + ), + ( + "mask", + "", + ), + ] { + for opacity in ["1", ".6"] { + let source = svg(&format!( + "{resource}{RECT}{RECT}" + )); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(strict.contains("image-effect composition"), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!(best.base_frame().items.len(), 1); + assert!( + best.degradations() + .iter() + .any(|d| d.path() == "svg/g[1]" + && d.reason().contains("image-effect composition")) + ); + } + } +} + +#[test] +fn empty_or_pruned_normal_isolation_does_not_poison_an_ancestor_profile() { + for opacity in ["1", ".6"] { + for child in [ + "", + "", + "", + ] { + let child = child.replacen("style=", &format!("opacity='{opacity}' style="), 1); + let result = frame(&format!( + "{child}{RECT}" + )); + assert!(blends(&result).is_empty()); + } + } +} + +#[test] +fn inline_backdrop_boundary_is_explicit() { + let body = svg(&format!("{RECT}")); + let html = format!("{body}"); + for result in [ + SvgFrameSource::from_html_inline_svg(html.as_str()), + SvgFrameSource::from_html_inline_svg_best_effort(html.as_str()), + ] { + assert!(result.unwrap_err().to_string().contains("host backdrop")); + } + let html = html.replace( + "width=\"64\" height=\"64\"", + "width=\"64\" height=\"64\" style='isolation:isolate'", + ); + assert!(SvgFrameSource::from_html_inline_svg(html.as_str()).is_ok()); +} + +#[test] +fn keyframes_cannot_bypass_the_static_computed_blend_patrol() { + let source = svg(&format!( + "{RECT}" + )); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(strict.contains("animated group-composition"), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert!( + best.degradations().iter().any( + |d| d.path() == "svg/style[1]" && d.reason().contains("animated group-composition") + ) + ); +} + +#[test] +fn group_source_precision_refuses_transactionally_and_keeps_named_siblings() { + for source in [ + "", + "", + ] { + for style in ["mix-blend-mode:multiply", "isolation:isolate"] { + let source = svg(&format!("{RECT}{source}{RECT}")); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(strict.contains("group-source precision"), "{strict}"); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!(best.base_frame().items.len(), 1); + assert!( + best.degradations().iter().any( + |d| d.path() == "svg/g[1]" && d.reason().contains("group-source precision") + ) + ); + } + } +} + +#[test] +fn root_blend_opacity_precision_has_no_best_effort_fallback() { + let source = svg(RECT).replace( + "width=\"64\"", + "style='mix-blend-mode:screen' opacity='.5' width=\"64\"", + ); + for result in [ + SvgFrameSource::from_standalone_svg(source.as_str(), InitialViewport::new(64.0, 64.0)), + SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ), + ] { + assert!( + result + .unwrap_err() + .to_string() + .contains("root-layer precision") + ); + } +} + +#[test] +fn implicit_standalone_root_isolation_cannot_smuggle_a_sibling_image_effect() { + let source = svg(&format!( + "{RECT}" + )); + for result in [ + SvgFrameSource::from_standalone_svg(source.as_str(), InitialViewport::new(64.0, 64.0)), + SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ), + ] { + assert!( + result + .unwrap_err() + .to_string() + .contains("image-effect composition") + ); + } +} diff --git a/crates/websem/tests/unsupported_corpus.rs b/crates/websem/tests/unsupported_corpus.rs index 4af7b251..8a51d0a1 100644 --- a/crates/websem/tests/unsupported_corpus.rs +++ b/crates/websem/tests/unsupported_corpus.rs @@ -18,12 +18,14 @@ #[path = "support/fixture_fonts.rs"] mod fixture_fonts; +#[path = "support/unsupported_fixture.rs"] +mod unsupported_fixture; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -use websem::{DegradationAction, InitialViewport, SvgFrameSource}; +use websem::DegradationAction; /// What the two admissions must do with a fixture. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,6 +46,217 @@ use Departure::{BothRefuse, DeclaredByBestEffort}; /// construct itself — a refusal that stopped naming what it refused would pass /// a bare "does it error" check and fail this one. const CORPUS: &[(&str, Departure, &str)] = &[ + ( + "svg-group-blend-root-filter-sibling", + BothRefuse, + "image-effect composition", + ), + ( + "svg-group-blend-source-path", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-source-ellipse", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-source-clip", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-source-isolation", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-root-opacity", + BothRefuse, + "root-layer precision", + ), + ( + "svg-group-blend-source-radial", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-source-stroke", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-source-subpixel-clip", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-source-rotated-clip", + DeclaredByBestEffort, + "group-source precision", + ), + ( + "svg-group-blend-mode-overlay", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-darken", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-lighten", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-color-dodge", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-color-burn", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-hard-light", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-soft-light", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-difference", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-exclusion", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-hue", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-saturation", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-color", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-luminosity", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-mode-plus-lighter", + DeclaredByBestEffort, + "mix-blend-mode", + ), + ( + "svg-group-blend-filter", + DeclaredByBestEffort, + "image-effect composition", + ), + ( + "svg-group-blend-elided-filter-unit", + DeclaredByBestEffort, + "image-effect composition", + ), + ( + "svg-group-blend-elided-filter-partial", + DeclaredByBestEffort, + "image-effect composition", + ), + ( + "svg-group-blend-elided-mask-unit", + DeclaredByBestEffort, + "image-effect composition", + ), + ( + "svg-group-blend-elided-mask-partial", + DeclaredByBestEffort, + "image-effect composition", + ), + ( + "svg-group-blend-mask", + DeclaredByBestEffort, + "image-effect composition", + ), + ( + "svg-group-blend-resource-pattern-root", + DeclaredByBestEffort, + "mix-blend-mode/isolation", + ), + ( + "svg-group-blend-resource-pattern-child", + DeclaredByBestEffort, + "mix-blend-mode/isolation", + ), + ( + "svg-group-blend-resource-mask-root", + DeclaredByBestEffort, + "mix-blend-mode/isolation", + ), + ( + "svg-group-blend-resource-mask-child", + DeclaredByBestEffort, + "mix-blend-mode/isolation", + ), + ( + "svg-group-blend-resource-clippath-root", + DeclaredByBestEffort, + "mix-blend-mode/isolation", + ), + ( + "svg-group-blend-resource-clippath-child", + DeclaredByBestEffort, + "mix-blend-mode/isolation", + ), + ( + "svg-group-blend-css-animation", + DeclaredByBestEffort, + "animated group-composition", + ), + ( + "svg-group-blend-css-var-animation", + DeclaredByBestEffort, + "animated group-composition", + ), + ( + "html-group-blend-head-animation", + DeclaredByBestEffort, + "animated group-composition", + ), + ("html-group-blend-unisolated", BothRefuse, "host backdrop"), + ( + "html-group-blend-ancestor-mix-blend-mode", + BothRefuse, + "host backdrop", + ), + ( + "html-group-blend-ancestor-isolation", + BothRefuse, + "host backdrop", + ), + ( + "svg-group-blend-pattern-root-animation", + DeclaredByBestEffort, + "document load", + ), ("svg-clip-path-animation", DeclaredByBestEffort, "animation"), ( "svg-clip-path-basic-shape", @@ -1246,18 +1459,20 @@ fn corpus_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/web-first/unsupported") } -fn viewport() -> InitialViewport { - InitialViewport::new(64.0, 64.0) -} - #[test] fn the_corpus_on_disk_is_exactly_the_declared_one() { let disk: BTreeSet = fs::read_dir(corpus_root()) .expect("read the unsupported corpus") .filter_map(Result::ok) .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .filter(|name| name.ends_with(".svg")) - .map(|name| name.trim_end_matches(".svg").to_string()) + .filter(|name| name.ends_with(".svg") || name.ends_with(".html")) + .map(|name| { + Path::new(&name) + .file_stem() + .unwrap() + .to_string_lossy() + .into_owned() + }) .collect(); let declared: BTreeSet = CORPUS.iter().map(|(id, _, _)| (*id).to_string()).collect(); @@ -1270,16 +1485,9 @@ fn the_corpus_on_disk_is_exactly_the_declared_one() { #[test] fn every_unsupported_fixture_departs_by_name_in_both_admissions() { for (id, departure, named) in CORPUS { - let source = fs::read_to_string(corpus_root().join(format!("{id}.svg"))) - .unwrap_or_else(|error| panic!("{id}: read: {error}")); - - let strict = SvgFrameSource::from_standalone_svg_with_fonts( - source.as_str(), - viewport(), - fixture_fonts::unsupported_environment(id), - ) - .err() - .unwrap_or_else(|| panic!("{id}: strict must refuse an unsupported fixture")); + let strict = unsupported_fixture::compile(&corpus_root(), id, false) + .err() + .unwrap_or_else(|| panic!("{id}: strict must refuse an unsupported fixture")); assert!( strict.to_string().contains(named), "{id}: the strict refusal must name {named:?}; got {strict}" @@ -1287,27 +1495,19 @@ fn every_unsupported_fixture_departs_by_name_in_both_admissions() { match departure { BothRefuse => { - let best = SvgFrameSource::from_standalone_svg_best_effort_with_fonts( - source.as_str(), - viewport(), - fixture_fonts::unsupported_environment(id), - ) - .err() - .unwrap_or_else(|| { - panic!("{id}: a document-level contract refuses in both admissions") - }); + let best = unsupported_fixture::compile(&corpus_root(), id, true) + .err() + .unwrap_or_else(|| { + panic!("{id}: a document-level contract refuses in both admissions") + }); assert!( best.to_string().contains(named), "{id}: the best-effort refusal must name {named:?}; got {best}" ); } DeclaredByBestEffort => { - let best = SvgFrameSource::from_standalone_svg_best_effort_with_fonts( - source.as_str(), - viewport(), - fixture_fonts::unsupported_environment(id), - ) - .unwrap_or_else(|error| panic!("{id}: best-effort compiles: {error}")); + let best = unsupported_fixture::compile(&corpus_root(), id, true) + .unwrap_or_else(|error| panic!("{id}: best-effort compiles: {error}")); let declared: Vec<&websem::Degradation> = best .degradations() .iter() diff --git a/docs/wg/consolidation/svg-engine-of-record.md b/docs/wg/consolidation/svg-engine-of-record.md index 61ae7653..dd6ef469 100644 --- a/docs/wg/consolidation/svg-engine-of-record.md +++ b/docs/wg/consolidation/svg-engine-of-record.md @@ -58,7 +58,9 @@ from the dated addenda below: geometry, instance, resource-source, paint, and effect routes, normalized to one source-neutral fill/stroke/marker item order before the frame closes; `` and `` containers, visibility, isolated element/group/root opacity, - and HTML-ancestor opacity around the selected inline SVG; the whole + and HTML-ancestor opacity around the selected inline SVG; bounded static SVG + group blending/isolation through a combined resolved blend/opacity scope + ([B1](#b1-svg-group-blending)); the whole `transform` grammar in both spellings (the attribute is a presentation hint of the CSS `transform` property, and `gradientTransform` is that attribute on gradient elements); @@ -114,7 +116,7 @@ from the dated addenda below: carrying admitted repeating-pattern paint and admitted source/target filter composition. `crates/n0_cli/README.md` is the statement of record. -- **The corpus** is 1,284 Chromium-baked primitive cells plus 16 sampled frames, +- **The corpus** is 1,398 Chromium-baked primitive cells plus 16 sampled frames, with a separate sixteen-cell exact text suite whose current cells select hash-pinned Ahem and Ahem-derived bytes from explicit family/face environments, and eight exact-number artifact-geometry @@ -125,7 +127,7 @@ from the dated addenda below: carrying declared one-code-value ramp-quantization bounds. The measured per-cell counts and causes are listed in the [corpus record](../../../fixtures/web-first/README.md). - The named refusal register has 260 rows. + The named refusal register has 303 rows. - **Not claimed:** no conformance score exists or may be computed — FLIP is unratified. The FLIP record and identity-changing review are prepared, but only the owner act on gridaco/nothing#49 may authorize them and the first @@ -5676,3 +5678,438 @@ frames, sixteen exact text cells, and eight real-font geometry witnesses are unchanged. External I/O, text, marker-source expansion, animation, and the separately tracked degenerate-gradient family remain separate work. No conformance score or FLIP record, rule, or baseline is touched. + +## B1: SVG group blending + +B1 introduces a bounded static CSS `mix-blend-mode`/`isolation` slice, not a +completed blending family. Normal, multiply and screen are consumed from the +existing Stylo computed values; the pin represents both longhands. Raw SVG +attribute lookalikes are inert, as their Chromium controls demonstrate. No +second matcher, presentation hint or cascade-pin change is involved. +`background-blend-mode` is untouched. All three CSS checklist rows remain open +under gridaco/nothing#81/#89/#90: the remaining honored operations and source +profiles do not acquire a tick merely because this slice is useful. The +[command README](../../../crates/n0_cli/README.md) remains the admitted-slice +statement of record. + +### Resolved meaning and the existing tree + +The source tree, resolved composition boundaries and backend allocations are +different structures. The existing balanced `FrameItems` stream carries the +new boundary without adding an authored tree or a Web-specific painter. +`ScopeEffect::Blend(ScopeBlend)` holds a checked, private-field normal/multiply/ +screen operation and optional `ScopeOpacity`; absent opacity means one and +does not erase isolation. Its source starts transparent and its completed +pixels composite once against the enclosing destination with the same +element's opacity. Producer-only tests originate this fact independently of +the SVG compiler. The contract remains backend-free and carries no resource +reference, backdrop image, layer bound, cache hint or budget. + +The renderer lowers that fact to one empty-start layer and one combined +restore. It does not change native `BeginOpacity`, whose backdrop-preserving +initialization and interpolation have a different meaning. Per-paint blending +and `feBlend` remain separate operations. The distinction follows the +[compositing model](https://www.w3.org/TR/compositing-1/) and is tested with +overlapping children: distributing the group operation changes the result. + +The compiler appends a known explicit boundary before descending and folds +private source/backdrop facts into its parent once. Neutral/default groups +emit no new command or layer. Normal isolation with no escaping descendant +blend is also redundant: balanced temporary scope markers are elided in one +linear program-exit pass, not by moving every nested descendant span. A lazy +identity-indexed ledger is truncated with every identity rewind; neutral +documents allocate no such ledger or compaction stack. Existing opacity +replay sees the unscoped facts and keeps its measured fold. Own partial +opacity already isolates, so normal isolation delegates directly to that +established opacity path. The resolved contract still retains every required +unit-normal isolation boundary. No new subtree replay discovers blending. +An implicit clip boundary inserts its opening pair in one suffix move; the +older clip insertion and opacity-fold machinery are not claimed to have become +linear in depth. No optimization policy or `DirtyClass` consumer is introduced. + +Immutable command replay remains distinct from caching completed blended +pixels. Consumer tests cover fresh-versus-reused execution over changed caller +backdrops, source edits, reorder/removal, isolation/opacity changes, scope +balance and conservative damage. No destination-dependent raster cache is +added. Standalone SVG initial transparency is resolved by the producer, not +hardcoded into the source-neutral `FrameProduct` painter. + +### Measured boundaries + +All probes and bakes use the unchanged hash-pinned `chromium_capture.ts` +through `probe_harness.ts`, Chromium 149.0.7827.55, with two deterministic +captures per source. Candidate sources also run through the actual `n0` +command in strict and best-effort admission. Exact equality here means decoded +RGBA equality, not equality of PNG encodings. No tolerance is added. + +| Chromium pair | Exact verdict | Evidence | +| --- | --- | --- | +| Whole-group multiply / per-child multiply | 576 pixels differ, maximum channel delta 89 | `svg-group-blend-multiply-{group,each}` | +| Whole-group screen / per-child screen | 576 pixels differ, maximum delta 98 | `svg-group-blend-screen-{group,each}` | +| Same-group blend plus opacity / outer opacity around blend | 2,048 pixels differ, maximum delta 76 | `svg-group-blend-multiply-opacity` / `svg-group-blend-outer-opacity` | +| Authored full/partial clip / same clip plus explicit isolate | Identical | `svg-group-blend-{clip,partial}` and their `-isolated` controls | +| Partial-opacity group / same group plus explicit isolate | Identical | `svg-group-blend-opacity{,-isolated}` | +| Translated/scaled/rotated group / explicit isolate | 2,048 / 1,536 / 2,160 pixels differ, maximum delta 152 | `svg-group-blend-{translate,scale,rotate}` and their `-isolated` controls | +| Nested viewport overflow clip / explicit isolate | 2,048 pixels differ, maximum delta 152 | `svg-group-blend-nested-hidden{,-isolated}` | +| Transparent source: normal / multiply | Identical | `svg-group-blend-{normal,multiply}-transparent` | +| Child normal / child multiply / child multiply with root isolation | Identical | `svg-group-blend-root-{normal,multiply,isolate}` | +| Actual outer-root normal / multiply / screen | Identical | `svg-group-blend-outer-root-{normal,multiply,screen}` | +| Opacity .5 or .6 over translucent green: plain / isolated shape, parent or child | Identical | `svg-group-blend-alpha-{half,six-tenths}-{plain,shape,parent,child}` | +| Multiple translucent draws, own opacity, transform or clip: plain / isolated | Identical | `svg-group-blend-normal-{unit,many-opacity,transform,clip}-{plain,isolate}` | + +In particular, authored `clip-path` isolates blending descendants, but a +nested SVG overflow clip does not. Neither a neutral container nor an ordinary +2D transform may acquire a layer merely for implementation convenience. +The rectangle matrix also covers both blend modes against mode-sensitive +colors, translucent sources, stroke overlap, gradients, repeating paints, +fractional placement, computed winners, invalid declarations and custom +properties. The self-contained HTML cell has an SVG-owned background and +explicit root isolation; it does not pretend the command paints the HTML page. +Five additional direct/instance/anchor/mapped-viewport cells and eight +explicit-Normal-isolation opacity controls cover opaque-backdrop interactions. +Nineteen further cells cover the actual outer root and redundant isolation +over translucent backdrops. Four more cross multiply/screen at opacity .123456 +with opaque/translucent source colors over a translucent destination. The rung +adds 114 exact primitive/HTML cells, taking the corpus from 1,284 to 1,398, +and 43 named refusals, taking that +register from 260 to 303. The sixteen +sampled frames and separate sixteen pixel/eight geometry text witnesses are +unchanged. + +Twelve review-boundary witnesses cross a rotated Normal-isolated group with +blending children, a plain Normal-isolated group, and a screen group at opacity +`1`, `0.9999999403953552` (the next smaller `f32`), `.999` and `.998`: +`svg-group-blend-near-unit-{isolated,plain,screen}-{unit,near,p999,p998}`. +Chromium gives identical pixels for the first three opacity values in the +blending-child and screen groups. Against unit, `.998` differs at 2,151 pixels +(maximum delta 2) and 2,003 pixels (maximum delta 1), respectively. The plain +group instead differs between unit and either near-unit value at 143 pixels +(maximum delta 1): even when the final opacity quantizes to byte 255, an +authored partial-opacity group must keep its source layer. All twelve sources +match Chromium in both command admissions on ARM. The corresponding four +multiply probes also match, with unit/near/`.999` identical and `.998` +differing at 1,013 pixels (maximum delta 1) **(measured, not celled)**. +Four `svg-group-blend-near-unit-bare-{unit,near,p999,p998}` controls remove +authored isolation from the plain group. Each is pixel-identical to its +explicitly isolated counterpart in Chromium and both command admissions on +ARM. The near-unit restore boundary therefore also belongs to ordinary +group opacity; the open blending rows cannot shelter a gap in that operation. + +HTML exterior-backdrop controls differ when isolation is added +**(measured, not celled)**. That exterior paint is absent from this command's +established SVG-local extraction contract. An escaping inline blend, a +non-normal inline-root blend, or blend/isolation on an HTML ancestor therefore +refuses in both admissions. Resource roots/contributors and CSS keyframes are +patrolled at their actual ingresses, including HTML-head styles and animated +custom-property indirection. A referenced pattern root's existing document-load +animation finding also survives best-effort compilation. These are named +refusals, not fallbacks to normal. Attributable failures roll back the complete +affected child transaction and retain valid siblings. +An implicit standalone-root boundary is subject to the same image-effect +profile as an explicit group: a filtered sibling cannot sneak into that new +source layer merely by residing outside the blending element. That root-level +combination refuses in both admissions and has its own registered witness. +Likewise, elision preserves authored blend/isolation participation separately +from physical scope and escaping-backdrop facts. Otherwise an ancestor filter +or mask could lose its conservative patrol when a Normal boundary disappears. +Four unit/partial-opacity ancestor witnesses guard that distinction; their +Chromium images equal their non-isolated controls **(measured, not celled)**, +not evidence of a new pixel defect. Empty/pruned contributions do not poison +an otherwise admitted ancestor. + +The final alpha patrol found a real defect in the initial B1 lowering: +redundant Normal isolation forced a different opacity restore over a +translucent green backdrop, changing 1,600 pixels by one code value. The +compiler correction above preserves the existing opacity fold instead of +introducing tolerance. Both admissions now match every new alpha cell. +Additional .375 and .5019607843137255 shape/parent/child controls also match +**(measured, not celled)**. A twelve-source cross-check of normal/multiply/screen +with opacity .5/.6, one opaque or two translucent source draws, and the same +partial backdrop is exact in both admissions **(measured, not celled)**. +Cross-seam tests separately execute actual +outer-root multiply/screen frames onto opaque and translucent colored caller +canvases; removing their initial Normal boundary changes those results. + +### Portable byte arithmetic + +Hosted x86 testing caught 24 new multiply cells departing by one or two code +values while the same cells were exact on ARM. In pinned Skia 0.99.0, +`SkRasterPipeline_opts.h` uses accurate divide-by-255 on NEON but approximate +`(value + 255) / 256` on x86 for this low-precision operation. For example, +source green 104 times destination green 101 produces 41 with accurate +rounding, but 42 with that approximation. This is the same backend arithmetic +class already established by the filter-blend rung, not a new tolerance. + +Multiply restoration now uses explicit byte arithmetic. Sharing only the +filter blender was insufficient: a runtime blender promotes its surrounding +pipeline to high precision, so leaving the original float paint opacity in +place changes normalization order. At opacity .123456 with partial source and +destination alpha, that prototype changed 1,600 pixels by one code value. +The four small-opacity cells guard the corrected order: quantize opacity to a +byte, round each premultiplied source-byte product, then blend. Restore-paint +alpha stays one, so opacity is not applied twice. Sixteen scratch candidates +cross multiply/screen, .123456/.499/.501/.999 opacity and opaque/translucent +sources; all match Chromium through both actual CLI admissions on ARM +**(measured, not celled)** apart from the four committed .123456 witnesses. + +Screen's final blend uses accurate arithmetic, but pinned source inspection +shows its preceding partial-opacity scale shares the same x86 approximation. +The ordered helper therefore also carries screen with non-unit opacity. +The next hosted x86 run passes multiply and all four small-opacity cells, but +leaves one isolated-rotation cell: 141 pixels at delta 1. Its first differing +RGBA pixel is `[70,100,134,255]` instead of `[71,101,135,255]`. + +This is a second source-over backend path. Pinned `SkBlitRow_opts.h` implements +the AVX2/SSE2 sprite restore as `s + floor(d * (256 - sa) / 256)`, while NEON +uses accurate divide-by-255 rounding. A mutation of only the new unit-Normal +restore to that formula reproduces 141 differing pixels and the same first +pixel. The raster-pipeline approximation alone produces a different 135-pixel +signature, so the two backend paths are not conflated. Exact byte source-over +first carried unit-Normal blend boundaries as well. At that point, +partial-opacity Normal retained the established native isolated-opacity +operation; unit-opacity screen also stayed native. The 1,382-cell gate +passed on ARM and +hosted x86 with the scoped correction, without an oracle or tolerance change. + +Review then exposed the near-unit boundary documented above. Pinned +`SkPaint::getAlpha()` rounds accepted partial opacities such as `.999` to byte +255. `ChooseL32` passes that byte into `Sprite_D32_S32`, which omits its global +alpha flag and selects the same source-over route as unit opacity. Route +selection and shader bindings now share one n0-owned byte conversion: Normal +uses the exact restore for byte 255, including a checked partial opacity. +Bytes below 255 retain the distinct native global-alpha operation; screen's +separately measured routing is unchanged. + +The Web compiler sends own partial Normal through `ScopeEffect::Opacity`, +not just the new Blend variant. The n0 consumer therefore lowers that existing +opacity fact into the same Normal-blend command when its backend byte is 255. +It preserves the original resolved opacity, source layer, owner, child coverage +union and balanced close. Both spellings share owner-bearing preflight and +reuse/damage guards. Backend quantization never enters websem or rframe, and +native backdrop-preserving `BeginOpacity` remains untouched. These promoted +opacity layers now appear in the trace blend-layer counters and incur the +exact helper's cost; they are not newly allocated layers. + +The pre-correction hosted x86 run fails exactly four of the twelve new +boundary cells: the isolated blending-child and plain isolated groups at the +next-smaller `f32` and `.999`. Each has 141 changed pixels at delta 1 and the +same first-pixel signature above; the unit, `.998` and screen controls pass. +The four ordinary-opacity controls were added after that run. A deliberate +AVX2-formula mutation of the corrected Normal restore makes `just gate` fail +eight cells with that same 141-pixel/delta-1 signature, including both ordinary +near-unit controls. No oracle or tolerance changes to absorb the failure. + +An additional ordinary-opacity regression patrol crosses eighteen rotated +source controls and six unrotated effect controls at `.999`/`.998`. +Circles, ellipses, curved paths, round strokes, curved clips and the admitted +alpha mask remain exact on ARM. Rotated pattern/mask and small-kernel blur +controls retain their existing named refusals. The radial ramp is silently +admitted with 1,312/1,307 one-code-value differences, the unrotated pattern +with one, and the circle-plus-blur control with 25 pixels at maximum delta +3/2. Every successful output is encoded-byte identical between the retained +pre-rung `fd4097f2` binary, the pre-follow-up build and the correction. +These are **(measured, not celled)** controls, not a new tolerance or an exact +parity claim for those departures. Their causes and disposition are tracked +separately in [gridaco/nothing#136](https://github.com/gridaco/nothing/issues/136). +Pre-existence establishes no regression from this correction, not that a +closed row is unaffected: both opacity rows and `` are ticked, +and causal attribution and tick ownership remain unresolved. A proven +closed-row defect requires repair/refusal and tick reassessment. These +controls do not widen the authored-blending source profile. + +One compiled effect per mode per thread and at most 256 immutable opacity +bindings per mode amortize shader construction; this is a code/uniform cache, never a pixel or +backdrop cache. Frame compilation preflights fallible construction and returns +an owner-bearing `BuildError::Blend` on failure. It issues no raster commands. +Execution tests guard the static shader's raster lowering against independent +integer arithmetic for every opacity byte in multiply/screen and every source +alpha byte in unit-Normal, distinguish float-first ordering, and prove warm +binding reuse equals fresh construction. Three effect slots are bounded; the +Normal slot is used only with byte-255 opacity. +The group shader enables Skia's optimizer; its exactness is independently +gated, and the existing filter-blender configuration is unchanged. + +### The precision stop + +Clean rendering did not establish correctness. Before the new guards, an +ordinary curved path matched Chromium exactly, but multiply changed 93 pixels +at maximum delta 19 and screen changed 100 at maximum delta 28. A curved clip +changed 28 pixels at delta 12 for multiply and 28 at delta 18 for screen. A +blended root with opacity one-half changed all 4,096 pixels at delta 2. These +pixel comparisons are **(measured, not celled)**; their exact sources become +named refusal witnesses, not accepted pixel cells. + +A causal scratch control adds corner draws to force full-viewport source +bounds. That removes the large multiply/screen path discrepancies, while +ordinary isolated-normal still differs at 92 pixels by one code value +**(measured, not celled)**. This supports a source-extent/raster-materialization +dependency; it does not prove a complete Skia/Blink precision model. Tightening +layer bounds or adding tolerance would not resolve the remaining question. + +B1 therefore quarantines non-rectangular group sources and wider coverage +before admitting the rectangular slice. Radial rectangle paints, complex +strokes and subpixel/rotated clips are conservative extension guards, not +claims that every such input has a measured mismatch. Root blend/partial +opacity has a separate document-level guard. Wider image-effect composition, +resource-program blending and the fourteen other represented modes remain +registered refusals. The next widening crux is source-bound and intermediate +precision, not simply another enum value. + +### Gate sensitivity and cost discipline + +Replacing only the new painter's multiply restore with normal source-over +makes `just gate` fail. The whole-group witness changes 2,048 pixels at maximum +delta 152, the leaf witness 1,600 at delta 152, and the combined-opacity witness +2,048 at delta 76. The mutation is removed, the painter's source hash returns +exactly to its pre-mutation value, and the complete fixture gate passes again. +Neither the oracle nor a tolerance changes to accommodate that failure. + +A second mutation disables only redundant unit-Normal elision. The gate fails +five new witnesses: both child-opacity cells at 1,600 pixels/delta 1 and the +unit, transformed and clipped isolation controls at 1,024 pixels/delta 1. +Restoring the compiler's exact pre-mutation bytes returns the full gate to +green. The alpha correction therefore has its own sensitive external witness. + +A third mutation replaces accurate division with the x86 approximation in +only the new ordered group shader. The gate fails 28 blend cells, including +both small-opacity source profiles in both modes at 1,600 pixels/delta 1 or 2. +Restoring the exact shader-source bytes returns all 1,382 positive cells and +303 named refusals to green. This separately guards the portable correction. + +On aarch64, baseline and B1 have identical `size_of` results: `FrameItem` and +`FrameNode` 200 bytes, `ScopeEffect` 64, drawlist `ItemKind` 136 and `Item` 168. +This is a data-layout observation, not proof of unchanged execution cost. +No layer-bounds optimization ships in B1. Trace-only execute aggregates record +observed raster pixel spans, area and peak live blend bytes separately from +duration samples. Empty clips and inaccessible storage are explicit outcomes. +The pinned accessor observes existing raster storage but changes its generation +state, so allocation observation belongs to a separate untimed frame. It does +not measure allocator capacity, Skia-internal allocations or GPU memory. + +The first matched CPU-raster measurement, before the near-unit follow-up, used the clean pre-rung revision +`fd4097f2` and B1 on the same Apple M2 Ultra, 128 GiB host, macOS 26.5.1, +Rust 1.92.0 aarch64, skia-safe 0.99.0, release builds with tracing off. +Each workload records its first frame compile and paint separately, warms five +replays, then takes 20 source-compile, 80 frame-compile and 40 paint samples; +three repetitions alternate baseline/current order. This bounded final +protocol applies equally to baseline and current. Earlier exploratory runs +used 20 warmups and 160 paint samples; their numbers are not substituted into +the matched table. +Cargo startup, file/PNG work, canvas clear and the input clone are outside +their respective timed loops. Source compilation includes document and cascade +construction. Other task builds, captures and tests are stopped during the +post-correction measurement. No GPU timing is claimed. + +The reproducible workload is a 256×256 SVG with an opaque `#426589` background. +For each group index `i`, set `x=(i%32)*8`, `y=((i/32)%32)*8`; paint a 6×6 +`#cd6843` rectangle at `(x,y)`, then a 3×3 `#5bace1` rectangle at `(x+2,y+2)`. +Use 100 or 1,000 sibling groups, optionally inside 16 or 48 nested groups. +The selected neutral, opacity .5, multiply or screen style applies to each +group, including the nesting wrappers. The single-large-group control instead +puts all those pairs inside one multiply group with neutral inner wrappers. +That is a different semantic workload, **not** a legal group-flattening rewrite. + +Numbers below are the median of three within-run p50s, in microseconds. + +| Workload | Source compile, B1 | Frame compile, baseline → B1 | Paint, baseline → B1 | +| --- | ---: | ---: | ---: | +| 100 neutral groups | 1,434.8 | 17.71 → 17.58 | 46.96 → 47.50 | +| 1,000 neutral groups | 15,509.0 | 185.67 → 186.50 | 428.25 → 432.21 | +| 100 neutral groups, depth 48 | 8,373.3 | 17.46 → 17.92 | 47.54 → 47.08 | +| 100 opacity groups | 1,528.0 | 25.46 → 26.79 | 3,329.75 → 3,348.25 | +| 100 multiply groups | 1,578.6 | not admitted → 27.21 | not admitted → 131,461.92 | +| 1,000 multiply groups | 16,717.9 | not admitted → 302.71 | not admitted → 1,304,405.25 | +| 100 screen groups, unit opacity | 1,567.3 | not admitted → 26.46 | not admitted → 5,205.21 | +| One multiply group containing 100 pairs | 1,555.9 | not admitted → 20.58 | not admitted → 2,531.71 | + +The old-opacity frame-compile median crosses the plan's 5% investigation +threshold (25.46 → 26.79 µs). A focused quiet rerun of that unchanged workload, +with five alternating baseline/current process pairs and the same per-stage +sampling, gives 25.21 → 25.83 µs; ranges overlap at 25.00–27.50 and +25.04–27.83 µs. Paint in that rerun is 3,344.71 → 3,351.38 µs. The alert does +not repeat above the threshold in that follow-up median; the original alert +is retained, not replaced. Other unaffected stage medians remain below the +threshold. This is not a universal regression guarantee. + +The near-unit follow-up uses the same 100-pair, 256×256 workload with ordinary +group opacity `.999`, `.998`, `.5`, and a neutral control. Before/after builds +share one dependency lock; the baseline is the pre-follow-up `7d42ab20` and +the corrected build retains the same public type sizes. The same hardware, +release/trace-off posture, first-use sample, five warmups, 20/80/40 stage +samples and three alternating repetitions apply, with other task builds, +captures and tests stopped. Medians of the three p50s are: + +| 100 groups | Frame compile, before → after (µs) | Paint, before → after (µs) | +| --- | ---: | ---: | +| Opacity .999, byte 255 | 25.29 → 26.25 | 1,959.50 → 119,690.54 | +| Opacity .998, byte 254 | 25.08 → 24.83 | 3,348.54 → 3,342.83 | +| Opacity .5 | 25.13 → 24.83 | 3,350.67 → 3,350.08 | +| Neutral | 17.42 → 17.21 | 47.54 → 47.08 | + +The byte-255 correction has a material CPU cost: near-unit opacity now pays +for the same explicit exact restore as unit Normal. It is not a performance +improvement. Its paint p50 spans 119,656.17–119,693.96 µs; maximum within-run +p95 is 120,200.83 µs. First frame compilation spans 654.58–2,151.42 µs and +first paint 119,923.29–140,139.42 µs; the high first repetition is retained. +No control-stage median regresses beyond the 5% investigation threshold in +this bounded run. Source-compile medians range from 1,405.54 to 1,486.33 µs +after correction, versus 1,443.88–1,509.63 µs before it; these small changes +are not a new optimization claim. + +Separate untimed trace observation records 100 promoted-opacity restores, +26,214,400 cumulative accessible raster bytes and 262,144 peak live bytes. +The `.998` and `.5` opacity layers remain outside these blend-operation +counters, not absent. Promotion retains the existing source layers rather +than creating 100 new ones. Reducing active-clip-sized exact-restore work +requires the same source-extent and intermediate-precision investigation as +the broader blending profile; no unchecked bounds or native fallback ships. + +One 100-neutral current repetition is slower: source, frame-compile and paint +p50 ranges are 1,421.92–2,156.58, 17.42–21.83 and 46.92–58.08 µs; baseline +paint is 46.75–47.25 µs. The 1,000-neutral current paint range is +427.79–432.54 µs, versus baseline 427.96–434.79 µs. Multiply-100 is +130,987.79–131,516.63 µs, with the largest within-run p95 136,350.08 µs. +The raw per-stage p50/p95/p99/min/max distributions +are retained in the ignored local execution record; no portable FPS claim is +drawn from this machine. + +The first multiply-100 frame compilation, including the thread's first +blend-effect construction, takes 671.96–1,021.50 µs, versus a steady median 27.21 µs. +Its first paint is 131,080.88–133,097.67 µs. These are whole-stage first-use +samples, not an isolated shader-compilation timer. The portable path is +substantially slower than the pre-correction native-only experiment (about +4.03 ms for 100 groups), which was pixel-wrong on x86. An unoptimized portable +shader took 137.50 ms for that workload in an exploratory repetition; enabling +the optimizer took 130.52 ms. A transparent-source shortcut instead took +158.00 ms and was removed. Those exploratory timings are not additional +matched repetitions. Partial-opacity screen uses the portable helper but was +not separately timed. The bounded slice is not a claim of realtime throughput. + +The redundant-isolation workload uses the same source pairs with explicit +`isolation:isolate` and no blending descendants. At 100 / 1,000 groups it +emits 201 / 2,001 frame items and zero blend saves; paint p50 is 47.38 / 432.96 +µs. Source compilation is 1,578.9 / 16,807.0 µs, including cascade and the +elision ledger. Adding 48 isolated ancestors to the 1,000-group workload +still emits 2,001 items and zero blend saves, with 432.04 µs paint p50. Its +90,182.0 µs source compilation includes existing depth-dependent walks; +the linear final compaction is not a claim that the entire compiler is linear. + +Untimed trace observation makes the cost concrete. Multiply-100 emits 403 +frame items and 101 observable 256×256 raster layers including standalone-root +isolation: 26,476,544 cumulative accessible bytes and 524,288 peak live blend +bytes. Multiply-1,000 emits 4,003 items and 1,001 layers, 262,406,144 cumulative +bytes but the same live peak. Depth 16 increases that live peak to 4,718,592 +bytes. The one-large-group workload uses two layers and 524,288 cumulative/peak +bytes. There are no missing observations in these raster-only workloads; root +surface and non-blend allocations are excluded. Neutral groups report zero +blend saves, not an estimate of zero total renderer allocation. + +With the 100-group source positions held fixed and the canvas changed to +128×128 or 512×512, paint p50 becomes 33,014.71 or 524,706.00 µs; observed +cumulative blend storage becomes 6,619,136 or 105,906,176 bytes. The smaller +canvas also clips some source draws, so this is an allocation/viewport +experiment, not pure equal-coverage area scaling. The practical cost combines +repeated active-clip-sized source materialization with explicit byte arithmetic +over those layers, not a larger semantic tree. Source +extent/precision must be proved before tightening bounds or pooling layers; +B1 records that cost and leaves the optimization unshipped. diff --git a/docs/wg/consolidation/web-checklist.md b/docs/wg/consolidation/web-checklist.md index 51532c6d..0b27ae41 100644 --- a/docs/wg/consolidation/web-checklist.md +++ b/docs/wg/consolidation/web-checklist.md @@ -678,6 +678,17 @@ excluded. - [ ] `background-blend-mode` - [ ] `isolation` +> B1 admits a bounded static SVG group-composition slice through the existing +> cascade: normal/multiply/screen and explicit isolation, with one combined +> group-opacity operation. These are CSS properties, not SVG presentation +> attributes. All three rows remain open: the other live blend values, +> source/effect precision profiles, resource programs, animation and HTML +> exterior-backdrop composition are not complete; `background-blend-mode` is +> untouched. The [admitted slice](../../../crates/n0_cli/README.md) names the +> boundaries, and the [B1 evidence](./svg-engine-of-record.md#b1-svg-group-blending) +> records the measured split. Partial coverage is not a tick under +> gridaco/nothing#81/#89/#90. + ### CSS fonts diff --git a/fixtures/web-first/README.md b/fixtures/web-first/README.md index 6a059c27..60a055eb 100644 --- a/fixtures/web-first/README.md +++ b/fixtures/web-first/README.md @@ -19,8 +19,8 @@ Every root primitive here is a closed enumeration in `primitives.json` with a committed Chromium oracle beside it. Text follows the ratified corpus-growth law in its own closed [text estate](./text/README.md): sixteen exact text cells and eight exact-number real-font artifact-geometry witnesses. The current evidence -estate is 1,284 primitive cells plus 16 sampled frames, those twenty-four text -witnesses, and 260 named refusal rows. Pixel cells use byte equality: what each +estate is 1,398 primitive cells plus 16 sampled frames, those twenty-four text +witnesses, and 303 named refusal rows. Pixel cells use byte equality: what each corpus admits is exactly what the engine renders pixel-for-pixel, except only the primitive rows carrying an explicit measured tolerance block. The real-font witness grades geometry before rasterization and makes no Chromium @@ -28,6 +28,18 @@ pixel claim. | File | Role | | --- | --- | +| `svg-group-blend-{normal,multiply,screen}-{leaf,group,opacity,alpha,transparent,stroke}.svg` · `svg-group-blend-{multiply,screen}-each.svg` | B1's exact group-operation controls: mode-sensitive backdrops, overlapping children, combined opacity, translucency, transparent initial source and fill/stroke composition. Whole-group versus per-child blending changes 576 pixels at maximum deltas 89 (multiply) and 98 (screen). No new tolerance. | +| `svg-group-blend-{multiply,screen}-opacity-small-{opaque,partial}.svg` | Opacity .123456 with opaque/translucent source colors over a translucent destination. The multiply partial-source cell rejects a float-first opacity prototype at 1,600 pixels/delta 1: byte opacity must be applied before the byte-domain blend. Hosted x86 also guards the low-precision backend's approximate division; no oracle or tolerance is relaxed. | +| `svg-group-blend-near-unit-{isolated,plain,screen}-{unit,near,p999,p998}.svg` | Rotated groups at opacity 1, the next smaller f32, .999 and .998. The first three match for isolated blending children and screen; plain isolation instead changes 143 pixels/delta 1 at the unit-to-partial boundary. A byte-255 restore must not erase the authored partial-opacity source layer. The .998 controls discriminate the next opacity byte. | +| `svg-group-blend-near-unit-bare-{unit,near,p999,p998}.svg` | Ordinary group opacity without an isolation or blending style. Each matches its explicit-isolation plain-group control exactly, guarding the shared opacity lowering rather than only the newly authored blending ingress. | +| `svg-group-blend-{neutral,isolated,clip,partial,opacity,outer-opacity}.svg` and their `-isolated` controls | Authored clip-path and partial opacity isolate descendants; a neutral group does not. Same-element blend plus opacity differs from an outer opacity group by 2,048 pixels at delta 76. Unit-opacity isolation is a real boundary, not an absent effect. | +| `svg-group-blend-{translate,scale,rotate,nested-hidden}.svg` and their `-isolated` controls | Ordinary affine transforms and a nested SVG overflow clip do not isolate. Adding isolation changes 2,048 / 1,536 / 2,160 pixels for the transform trio, and 2,048 for the viewport case, all at maximum delta 152. | +| `svg-group-blend-{linear,pattern,fractional}-{normal,multiply,screen}.svg` · `svg-group-blend-opacity-{0,1,p00392156862745098,p5019607843137255,p6}.svg` | Rectangular source paint/materialization and opacity boundaries, including a linear ramp, an existing repeating-vector paint, fractional rectangle edges, zero/unit opacity and fractional group alpha. This does not admit blending inside the pattern source. | +| `svg-group-blend-{css-winner,css-var,css-invalid,css-keyword,case-comment,attr-multiply,group-attr-isolate,root-normal,root-multiply,root-isolate}.svg` · `html-inline-svg-group-blend-isolated.html` | One-cascade syntax and neutral/raw-attribute controls; standalone initial transparency and a self-contained isolated inline SVG. The HTML cell has its own SVG background and makes no exterior-page rendering claim. | +| `svg-group-blend-{instance-direct,instance-use,anchor,mapped-direct,mapped-viewport}.svg` · `svg-group-blend-opacity-opaque-{fill,fillstroke}-{default,shape,parent,child}.svg` | Existing local `` and `` group composition match their direct control; mapped inner viewport composition matches the transformed control. The eight opacity controls pin fill-opacity .3 and element opacity .5 with explicit Normal isolation on the shape, parent or child, including fill/stroke overlap; they match their non-isolated counterparts exactly. | +| `svg-group-blend-outer-root-{normal,multiply,screen}.svg` | The computed mode is on the outermost SVG itself; all three blend the completed source against transparent initial black. The older `root-{normal,multiply,isolate}` trio instead compares a child blend and root isolation. A separate execution test uses a colored caller canvas and fails if the initial root boundary is removed. | +| `svg-group-blend-alpha-{half,six-tenths}-{plain,shape,parent,child}.svg` · `svg-group-blend-normal-{unit,many-opacity,transform,clip}-{plain,isolate}.svg` | Explicit Normal isolation preserves the established opacity route over a translucent backdrop. Before correction, shape/parent/child isolation changed 1,600 pixels by one code value; all now match exactly. Multiple translucent draws, transforms and clips also match their non-isolated controls when no child blend escapes. | +| *(measured, not celled — B1 precision remainder)* | An otherwise exact curved path acquires 93 differing pixels at delta 19 under multiply and 100 at delta 28 under screen; curved clip sources acquire 28 at deltas 12/18. Forcing full-viewport source bounds removes the large path-blend discrepancies, but isolated-normal still differs at 92 pixels by one code value. A root blend with opacity one-half differs at all 4,096 pixels by up to two. These sources are registered refusals, not tolerated positive cells; wider radial/stroke/clip profiles are conservatively guarded. Exterior HTML, image-effect and animated-group probes likewise retain named refusals. All CSS blending rows remain open. | | `svg-radial-start-{default,explicit-default,fx-*,fy-*,fxy-*,fr-*,outer-*,one-stop-*,zero-stops-focal,same-circle-*}.svg` | Ordered radial-circle evidence: independent focus coordinates, signed/zero/positive start radius, equal/reversed/zero end radii, exterior focus, tile behavior, and one-stop transparent domains. The older focal refusal source graduates unchanged as `svg-radial-start-graduated-focal.svg`; the one-stop witness preserves the previously silent constant-fold escape as positive evidence. | | `svg-radial-start-{grammar-*,box-percent,user-*,mapped-viewbox,template-*,transform-*,relation-*,spread-*,stops-*}.svg` | Resource-length grammar/defaults and placement: accepted signed/exponent forms versus invalid whitespace-only, trailing-dot, comma, and non-ASCII-whitespace fallbacks; axis and diagonal percentage bases; mapped user units; per-field template inheritance and local overrides; every spread mode across distinct circle relationships; transforms, stop order, duplicate stops, and alpha. Valid values surrounded by SVG whitespace match their unpadded controls in Chromium and both CLI admissions (measured, not celled). The decimal/percentage adjacent numeric controls and radius midpoint pair are byte-exact. | | `svg-radial-start-client-*.svg` · `svg-radial-start-transparent-cone.svg` · `html-inline-svg-radial-start.html` | Paint clients through shapes, path/stroke/dashes, paint order, channel/group opacity, existing `` instances, pattern source, clip, mask, offset filter, nested viewport, transparent exterior, and HTML ingress. This does not expand the marker-source paint profile or external-resource boundary. | diff --git a/fixtures/web-first/STATUS.md b/fixtures/web-first/STATUS.md index 877ec689..d29600ba 100644 --- a/fixtures/web-first/STATUS.md +++ b/fixtures/web-first/STATUS.md @@ -19,7 +19,7 @@ Not a conformance claim: no score is computed or implied (FLIP is unratified), and the corpus enumerates constructs, not the SVG surface. -## Chromium-baked cells (1284) +## Chromium-baked cells (1398) Cells are checked against their committed Chromium oracles using exact bytes unless a manifest entry declares a measured, bounded @@ -29,6 +29,7 @@ to its fixture source. No new image is committed for this view. html-inline-svg-ancestor-opacity html-inline-svg-currentcolor-rect +html-inline-svg-group-blend-isolated html-inline-svg-nested-viewport html-inline-svg-paint-order html-inline-svg-pattern @@ -595,6 +596,119 @@ to its fixture source. No new image is committed for this view. svg-gradient-zero-area-unit-split svg-gradient-zero-bbox svg-gradient-zero-stops-fallback +svg-group-blend-alpha-half-child +svg-group-blend-alpha-half-parent +svg-group-blend-alpha-half-plain +svg-group-blend-alpha-half-shape +svg-group-blend-alpha-six-tenths-child +svg-group-blend-alpha-six-tenths-parent +svg-group-blend-alpha-six-tenths-plain +svg-group-blend-alpha-six-tenths-shape +svg-group-blend-anchor +svg-group-blend-attr-multiply +svg-group-blend-case-comment +svg-group-blend-clip +svg-group-blend-clip-isolated +svg-group-blend-css-invalid +svg-group-blend-css-keyword +svg-group-blend-css-var +svg-group-blend-css-winner +svg-group-blend-fractional-multiply +svg-group-blend-fractional-normal +svg-group-blend-fractional-screen +svg-group-blend-group-attr-isolate +svg-group-blend-instance-direct +svg-group-blend-instance-use +svg-group-blend-isolated +svg-group-blend-linear-multiply +svg-group-blend-linear-normal +svg-group-blend-linear-screen +svg-group-blend-mapped-direct +svg-group-blend-mapped-viewport +svg-group-blend-multiply-alpha +svg-group-blend-multiply-each +svg-group-blend-multiply-group +svg-group-blend-multiply-leaf +svg-group-blend-multiply-opacity +svg-group-blend-multiply-opacity-small-opaque +svg-group-blend-multiply-opacity-small-partial +svg-group-blend-multiply-stroke +svg-group-blend-multiply-transparent +svg-group-blend-near-unit-bare-near +svg-group-blend-near-unit-bare-p998 +svg-group-blend-near-unit-bare-p999 +svg-group-blend-near-unit-bare-unit +svg-group-blend-near-unit-isolated-near +svg-group-blend-near-unit-isolated-p998 +svg-group-blend-near-unit-isolated-p999 +svg-group-blend-near-unit-isolated-unit +svg-group-blend-near-unit-plain-near +svg-group-blend-near-unit-plain-p998 +svg-group-blend-near-unit-plain-p999 +svg-group-blend-near-unit-plain-unit +svg-group-blend-near-unit-screen-near +svg-group-blend-near-unit-screen-p998 +svg-group-blend-near-unit-screen-p999 +svg-group-blend-near-unit-screen-unit +svg-group-blend-nested-hidden +svg-group-blend-nested-hidden-isolated +svg-group-blend-neutral +svg-group-blend-normal-alpha +svg-group-blend-normal-clip-isolate +svg-group-blend-normal-clip-plain +svg-group-blend-normal-group +svg-group-blend-normal-leaf +svg-group-blend-normal-many-opacity-isolate +svg-group-blend-normal-many-opacity-plain +svg-group-blend-normal-opacity +svg-group-blend-normal-stroke +svg-group-blend-normal-transform-isolate +svg-group-blend-normal-transform-plain +svg-group-blend-normal-transparent +svg-group-blend-normal-unit-isolate +svg-group-blend-normal-unit-plain +svg-group-blend-opacity +svg-group-blend-opacity-0 +svg-group-blend-opacity-1 +svg-group-blend-opacity-isolated +svg-group-blend-opacity-opaque-fill-child +svg-group-blend-opacity-opaque-fill-default +svg-group-blend-opacity-opaque-fill-parent +svg-group-blend-opacity-opaque-fill-shape +svg-group-blend-opacity-opaque-fillstroke-child +svg-group-blend-opacity-opaque-fillstroke-default +svg-group-blend-opacity-opaque-fillstroke-parent +svg-group-blend-opacity-opaque-fillstroke-shape +svg-group-blend-opacity-p00392156862745098 +svg-group-blend-opacity-p5019607843137255 +svg-group-blend-opacity-p6 +svg-group-blend-outer-opacity +svg-group-blend-outer-root-multiply +svg-group-blend-outer-root-normal +svg-group-blend-outer-root-screen +svg-group-blend-partial +svg-group-blend-partial-isolated +svg-group-blend-pattern-multiply +svg-group-blend-pattern-normal +svg-group-blend-pattern-screen +svg-group-blend-root-isolate +svg-group-blend-root-multiply +svg-group-blend-root-normal +svg-group-blend-rotate +svg-group-blend-rotate-isolated +svg-group-blend-scale +svg-group-blend-scale-isolated +svg-group-blend-screen-alpha +svg-group-blend-screen-each +svg-group-blend-screen-group +svg-group-blend-screen-leaf +svg-group-blend-screen-opacity +svg-group-blend-screen-opacity-small-opaque +svg-group-blend-screen-opacity-small-partial +svg-group-blend-screen-stroke +svg-group-blend-screen-transparent +svg-group-blend-translate +svg-group-blend-translate-isolated svg-group-inherited-fill svg-group-nested-transforms svg-group-paint-order @@ -1312,7 +1426,7 @@ to its fixture source. No new image is committed for this view. svg-visibility-rule-beats-attribute svg-visibility-unhide -## The refusal register (260) +## The refusal register (303) What the slice refuses, by name, in the compiler's own words — **both refuse** is a document-level contract; **declared** renders @@ -1321,6 +1435,10 @@ its row into the cells above. | Fixture | Admission | The compiler's departure | | --- | --- | --- | +| `html-group-blend-ancestor-isolation` | **both refuse** | unsupported computed style: mix-blend-mode/isolation on an HTML ancestor needs the host backdrop and layer graph | +| `html-group-blend-ancestor-mix-blend-mode` | **both refuse** | unsupported computed style: mix-blend-mode/isolation on an HTML ancestor needs the host backdrop and layer graph | +| `html-group-blend-head-animation` | declared | declaration ignored at html/head[1]/style[1]: CSS keyframes with mix-blend-mode/isolation need the animated group-composition profile | +| `html-group-blend-unisolated` | **both refuse** | unsupported computed style: unisolated mix-blend-mode in inline SVG needs the host backdrop and layer graph | | `svg-clip-path-animation` | declared | skipped svg/rect[2]: unsupported SVG clip-path: a contributor's authored geometry is overridden at document load: its authored state is overridden at document load by the unsupported animation at svg/clipPath[1]/rect[1]/animate[1]: must be a direct child of a materialized top-level | | `svg-clip-path-basic-shape` | declared | skipped svg/rect[2]: unsupported SVG clip-path: a CSS basic-shape clip-path uses the independently listed basic-shape route | | `svg-clip-path-cycle` | declared | skipped svg/rect[2]: unsupported SVG clip-path: url(#a) forms a cyclic clip-path chain, whose raster/path strategy is not admitted | @@ -1419,6 +1537,45 @@ its row into the cells above. | `svg-gradient-stop-var` | declared | skipped svg/rect[1]: unsupported fill value "url(#g): a stop-opacity resolves through var(), an indirection this patrol cannot follow" | | `svg-gradient-unit-basis` | declared | skipped svg/rect[1]: unsupported fill value "url(#g): gradient geometry x2=\"4em\" uses a unit whose basis this slice does not consume (numbers, px, and percentages only)" | | `svg-gradient-userspace-zero-area` | declared | skipped svg/line[1]: unsupported stroke value "url(#g): a live user-space gradient on zero-area geometry cannot be mapped into the resolved unit-box paint contract" | +| `svg-group-blend-css-animation` | declared | declaration ignored at svg/style[1]: CSS keyframes with mix-blend-mode/isolation need the animated group-composition profile | +| `svg-group-blend-css-var-animation` | declared | declaration ignored at svg/style[1]: CSS keyframes with mix-blend-mode/isolation need the animated group-composition profile | +| `svg-group-blend-elided-filter-partial` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-elided-filter-unit` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-elided-mask-partial` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-elided-mask-unit` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-filter` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-mask` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-mode-color` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Color is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-color-burn` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode ColorBurn is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-color-dodge` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode ColorDodge is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-darken` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Darken is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-difference` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Difference is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-exclusion` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Exclusion is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-hard-light` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode HardLight is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-hue` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Hue is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-lighten` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Lighten is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-luminosity` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Luminosity is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-overlay` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Overlay is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-plus-lighter` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode PlusLighter is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-saturation` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Saturation is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-mode-soft-light` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode SoftLight is outside the admitted normal/multiply/screen group profile | +| `svg-group-blend-pattern-root-animation` | declared | skipped svg/rect[2]: unsupported fill value "url(#q): pattern #q authored state is overridden at document load: its authored state is overridden at document load by the unsupported animation at svg/defs[1]/pattern[1]/set[1]: animation element is outside the rect-x proving slice" | +| `svg-group-blend-resource-clippath-child` | declared | skipped svg/rect[2]: unsupported SVG clip-path: mix-blend-mode/isolation on a geometric clip contributor needs its own source profile | +| `svg-group-blend-resource-clippath-root` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode/isolation on needs its own source composition profile | +| `svg-group-blend-resource-mask-child` | declared | skipped svg/rect[2]: unsupported SVG mask: mask source cannot be compiled completely: unsupported computed style: mix-blend-mode/isolation in a mask, pattern, or marker source needs its own source composition profile | +| `svg-group-blend-resource-mask-root` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode/isolation on needs its own source composition profile | +| `svg-group-blend-resource-pattern-child` | declared | skipped svg/rect[2]: unsupported fill value "url(#q): pattern #q source cannot compile completely: unsupported computed style: mix-blend-mode/isolation in a mask, pattern, or marker source needs its own source composition profile" | +| `svg-group-blend-resource-pattern-root` | declared | skipped svg/rect[2]: unsupported fill value "url(#q): unsupported computed style: mix-blend-mode/isolation on needs its own source composition profile" | +| `svg-group-blend-root-filter-sibling` | **both refuse** | unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-root-opacity` | **both refuse** | unsupported computed style: mix-blend-mode with partial opacity on the root crosses the root-layer precision boundary | +| `svg-group-blend-source-clip` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with curved, subpixel, or rotated clip coverage crosses the group-source precision boundary | +| `svg-group-blend-source-ellipse` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with non-rectangular source geometry crosses the group-source precision boundary | +| `svg-group-blend-source-isolation` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with non-rectangular source geometry crosses the group-source precision boundary | +| `svg-group-blend-source-path` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with non-rectangular source geometry crosses the group-source precision boundary | +| `svg-group-blend-source-radial` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode/isolation with a radial source paint crosses the group-source precision boundary | +| `svg-group-blend-source-rotated-clip` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with curved, subpixel, or rotated clip coverage crosses the group-source precision boundary | +| `svg-group-blend-source-stroke` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode/isolation with a complex source stroke crosses the group-source precision boundary | +| `svg-group-blend-source-subpixel-clip` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with curved, subpixel, or rotated clip coverage crosses the group-source precision boundary | | `svg-image` | declared | skipped svg/image[1]: unsupported element | | `svg-line-gradient-coordinates-calc-values` | declared | skipped svg/line[1]: attribute x1="calc(16px + 16px)" is not a number; skipped svg/line[2]: attribute x2="calc(50% + 8px)" is not a number; skipped svg/line[3]: attribute y1="min(24px, 50%)" is not a number; skipped svg/line[4]: attribute y2="max(24px, 25%)" is not a number; skipped svg/rect[2]: unsupported fill value "url(#gx1): gradient geometry x1 uses calc(), whose computed length is not represented by the direct resource decoder"; skipped svg/rect[3]: unsupported fill value "url(#gx2): gradient geometry x2 uses calc(), whose computed length is not represented by the direct resource decoder"; skipped svg/rect[4]: unsupported fill value "url(#gy1): gradient geometry y1 uses min(), whose computed length is not represented by the direct resource decoder"; skipped svg/rect[5]: unsupported fill value "url(#gy2): gradient geometry y2 uses max(), whose computed length is not represented by the direct resource decoder" | | `svg-line-gradient-coordinates-css-comments` | declared | skipped svg/line[1]: attribute x1="/\*\*/8/\*\*/" is not a number; skipped svg/line[2]: attribute x2="/\*\*/56/\*\*/" is not a number; skipped svg/line[3]: attribute y1="/\*\*/8/\*\*/" is not a number; skipped svg/line[4]: attribute y2="/\*\*/56/\*\*/" is not a number; skipped svg/rect[2]: unsupported fill value "url(#gx1): gradient geometry x1 contains a CSS comment this direct length parser cannot tokenize"; skipped svg/rect[3]: unsupported fill value "url(#gx2): gradient geometry x2 contains a CSS comment this direct length parser cannot tokenize"; skipped svg/rect[4]: unsupported fill value "url(#gy1): gradient geometry y1 contains a CSS comment this direct length parser cannot tokenize"; skipped svg/rect[5]: unsupported fill value "url(#gy2): gradient geometry y2 contains a CSS comment this direct length parser cannot tokenize" | diff --git a/fixtures/web-first/chromium/html-inline-svg-group-blend-isolated.png b/fixtures/web-first/chromium/html-inline-svg-group-blend-isolated.png new file mode 100644 index 00000000..b02eafef Binary files /dev/null and b/fixtures/web-first/chromium/html-inline-svg-group-blend-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-half-child.png b/fixtures/web-first/chromium/svg-group-blend-alpha-half-child.png new file mode 100644 index 00000000..2de0b221 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-half-child.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-half-parent.png b/fixtures/web-first/chromium/svg-group-blend-alpha-half-parent.png new file mode 100644 index 00000000..2de0b221 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-half-parent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-half-plain.png b/fixtures/web-first/chromium/svg-group-blend-alpha-half-plain.png new file mode 100644 index 00000000..2de0b221 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-half-plain.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-half-shape.png b/fixtures/web-first/chromium/svg-group-blend-alpha-half-shape.png new file mode 100644 index 00000000..2de0b221 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-half-shape.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-child.png b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-child.png new file mode 100644 index 00000000..7bb50e54 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-child.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-parent.png b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-parent.png new file mode 100644 index 00000000..7bb50e54 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-parent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-plain.png b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-plain.png new file mode 100644 index 00000000..7bb50e54 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-plain.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-shape.png b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-shape.png new file mode 100644 index 00000000..7bb50e54 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-alpha-six-tenths-shape.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-anchor.png b/fixtures/web-first/chromium/svg-group-blend-anchor.png new file mode 100644 index 00000000..3277a854 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-anchor.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-attr-multiply.png b/fixtures/web-first/chromium/svg-group-blend-attr-multiply.png new file mode 100644 index 00000000..2942b193 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-attr-multiply.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-case-comment.png b/fixtures/web-first/chromium/svg-group-blend-case-comment.png new file mode 100644 index 00000000..ed324685 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-case-comment.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-clip-isolated.png b/fixtures/web-first/chromium/svg-group-blend-clip-isolated.png new file mode 100644 index 00000000..cdb0ff1e Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-clip-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-clip.png b/fixtures/web-first/chromium/svg-group-blend-clip.png new file mode 100644 index 00000000..cdb0ff1e Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-clip.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-css-invalid.png b/fixtures/web-first/chromium/svg-group-blend-css-invalid.png new file mode 100644 index 00000000..ed324685 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-css-invalid.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-css-keyword.png b/fixtures/web-first/chromium/svg-group-blend-css-keyword.png new file mode 100644 index 00000000..ed324685 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-css-keyword.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-css-var.png b/fixtures/web-first/chromium/svg-group-blend-css-var.png new file mode 100644 index 00000000..ed324685 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-css-var.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-css-winner.png b/fixtures/web-first/chromium/svg-group-blend-css-winner.png new file mode 100644 index 00000000..ed324685 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-css-winner.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-fractional-multiply.png b/fixtures/web-first/chromium/svg-group-blend-fractional-multiply.png new file mode 100644 index 00000000..2749b6cd Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-fractional-multiply.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-fractional-normal.png b/fixtures/web-first/chromium/svg-group-blend-fractional-normal.png new file mode 100644 index 00000000..ec72e87a Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-fractional-normal.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-fractional-screen.png b/fixtures/web-first/chromium/svg-group-blend-fractional-screen.png new file mode 100644 index 00000000..acd52349 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-fractional-screen.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-group-attr-isolate.png b/fixtures/web-first/chromium/svg-group-blend-group-attr-isolate.png new file mode 100644 index 00000000..7b8cc8ab Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-group-attr-isolate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-instance-direct.png b/fixtures/web-first/chromium/svg-group-blend-instance-direct.png new file mode 100644 index 00000000..3277a854 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-instance-direct.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-instance-use.png b/fixtures/web-first/chromium/svg-group-blend-instance-use.png new file mode 100644 index 00000000..3277a854 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-instance-use.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-isolated.png b/fixtures/web-first/chromium/svg-group-blend-isolated.png new file mode 100644 index 00000000..cdb0ff1e Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-linear-multiply.png b/fixtures/web-first/chromium/svg-group-blend-linear-multiply.png new file mode 100644 index 00000000..7eb2d4e3 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-linear-multiply.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-linear-normal.png b/fixtures/web-first/chromium/svg-group-blend-linear-normal.png new file mode 100644 index 00000000..f7de162f Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-linear-normal.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-linear-screen.png b/fixtures/web-first/chromium/svg-group-blend-linear-screen.png new file mode 100644 index 00000000..54748769 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-linear-screen.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-mapped-direct.png b/fixtures/web-first/chromium/svg-group-blend-mapped-direct.png new file mode 100644 index 00000000..9e8b6808 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-mapped-direct.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-mapped-viewport.png b/fixtures/web-first/chromium/svg-group-blend-mapped-viewport.png new file mode 100644 index 00000000..9e8b6808 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-mapped-viewport.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-alpha.png b/fixtures/web-first/chromium/svg-group-blend-multiply-alpha.png new file mode 100644 index 00000000..c5eb4bf8 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-alpha.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-each.png b/fixtures/web-first/chromium/svg-group-blend-multiply-each.png new file mode 100644 index 00000000..0ca4c03a Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-each.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-group.png b/fixtures/web-first/chromium/svg-group-blend-multiply-group.png new file mode 100644 index 00000000..b63b68a3 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-group.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-leaf.png b/fixtures/web-first/chromium/svg-group-blend-multiply-leaf.png new file mode 100644 index 00000000..7b8cc8ab Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-leaf.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-opacity-small-opaque.png b/fixtures/web-first/chromium/svg-group-blend-multiply-opacity-small-opaque.png new file mode 100644 index 00000000..1914cb8d Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-opacity-small-opaque.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-opacity-small-partial.png b/fixtures/web-first/chromium/svg-group-blend-multiply-opacity-small-partial.png new file mode 100644 index 00000000..210c4d76 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-opacity-small-partial.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-opacity.png b/fixtures/web-first/chromium/svg-group-blend-multiply-opacity.png new file mode 100644 index 00000000..f034fce7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-opacity.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-stroke.png b/fixtures/web-first/chromium/svg-group-blend-multiply-stroke.png new file mode 100644 index 00000000..7aaefd0f Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-stroke.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-multiply-transparent.png b/fixtures/web-first/chromium/svg-group-blend-multiply-transparent.png new file mode 100644 index 00000000..95d2ecd2 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-multiply-transparent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-near.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-near.png new file mode 100644 index 00000000..b00aee94 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-near.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-p998.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-p998.png new file mode 100644 index 00000000..71ade3b6 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-p998.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-p999.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-p999.png new file mode 100644 index 00000000..b00aee94 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-p999.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-unit.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-unit.png new file mode 100644 index 00000000..3fea0fcf Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-bare-unit.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-near.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-near.png new file mode 100644 index 00000000..5f68ec88 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-near.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-p998.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-p998.png new file mode 100644 index 00000000..366e9568 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-p998.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-p999.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-p999.png new file mode 100644 index 00000000..5f68ec88 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-p999.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-unit.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-unit.png new file mode 100644 index 00000000..5f68ec88 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-isolated-unit.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-near.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-near.png new file mode 100644 index 00000000..b00aee94 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-near.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-p998.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-p998.png new file mode 100644 index 00000000..71ade3b6 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-p998.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-p999.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-p999.png new file mode 100644 index 00000000..b00aee94 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-p999.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-unit.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-unit.png new file mode 100644 index 00000000..3fea0fcf Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-plain-unit.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-near.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-near.png new file mode 100644 index 00000000..b2a2ad64 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-near.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-p998.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-p998.png new file mode 100644 index 00000000..af49b693 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-p998.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-p999.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-p999.png new file mode 100644 index 00000000..b2a2ad64 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-p999.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-unit.png b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-unit.png new file mode 100644 index 00000000..b2a2ad64 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-near-unit-screen-unit.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-nested-hidden-isolated.png b/fixtures/web-first/chromium/svg-group-blend-nested-hidden-isolated.png new file mode 100644 index 00000000..3611869a Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-nested-hidden-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-nested-hidden.png b/fixtures/web-first/chromium/svg-group-blend-nested-hidden.png new file mode 100644 index 00000000..67865bce Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-nested-hidden.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-neutral.png b/fixtures/web-first/chromium/svg-group-blend-neutral.png new file mode 100644 index 00000000..b02eafef Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-neutral.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-alpha.png b/fixtures/web-first/chromium/svg-group-blend-normal-alpha.png new file mode 100644 index 00000000..7f2f89eb Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-alpha.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-clip-isolate.png b/fixtures/web-first/chromium/svg-group-blend-normal-clip-isolate.png new file mode 100644 index 00000000..01e89078 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-clip-isolate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-clip-plain.png b/fixtures/web-first/chromium/svg-group-blend-normal-clip-plain.png new file mode 100644 index 00000000..01e89078 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-clip-plain.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-group.png b/fixtures/web-first/chromium/svg-group-blend-normal-group.png new file mode 100644 index 00000000..b8b30ff2 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-group.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-leaf.png b/fixtures/web-first/chromium/svg-group-blend-normal-leaf.png new file mode 100644 index 00000000..2942b193 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-leaf.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-many-opacity-isolate.png b/fixtures/web-first/chromium/svg-group-blend-normal-many-opacity-isolate.png new file mode 100644 index 00000000..62ed87a2 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-many-opacity-isolate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-many-opacity-plain.png b/fixtures/web-first/chromium/svg-group-blend-normal-many-opacity-plain.png new file mode 100644 index 00000000..62ed87a2 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-many-opacity-plain.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-opacity.png b/fixtures/web-first/chromium/svg-group-blend-normal-opacity.png new file mode 100644 index 00000000..f6dd73bd Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-opacity.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-stroke.png b/fixtures/web-first/chromium/svg-group-blend-normal-stroke.png new file mode 100644 index 00000000..ef256386 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-stroke.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-transform-isolate.png b/fixtures/web-first/chromium/svg-group-blend-normal-transform-isolate.png new file mode 100644 index 00000000..feb9fb72 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-transform-isolate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-transform-plain.png b/fixtures/web-first/chromium/svg-group-blend-normal-transform-plain.png new file mode 100644 index 00000000..feb9fb72 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-transform-plain.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-transparent.png b/fixtures/web-first/chromium/svg-group-blend-normal-transparent.png new file mode 100644 index 00000000..95d2ecd2 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-transparent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-unit-isolate.png b/fixtures/web-first/chromium/svg-group-blend-normal-unit-isolate.png new file mode 100644 index 00000000..01e89078 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-unit-isolate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-normal-unit-plain.png b/fixtures/web-first/chromium/svg-group-blend-normal-unit-plain.png new file mode 100644 index 00000000..01e89078 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-normal-unit-plain.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-0.png b/fixtures/web-first/chromium/svg-group-blend-opacity-0.png new file mode 100644 index 00000000..1a5a6126 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-0.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-1.png b/fixtures/web-first/chromium/svg-group-blend-opacity-1.png new file mode 100644 index 00000000..ff2be208 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-1.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-isolated.png b/fixtures/web-first/chromium/svg-group-blend-opacity-isolated.png new file mode 100644 index 00000000..fbd05ac7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-child.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-child.png new file mode 100644 index 00000000..af3a85ad Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-child.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-default.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-default.png new file mode 100644 index 00000000..af3a85ad Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-default.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-parent.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-parent.png new file mode 100644 index 00000000..af3a85ad Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-parent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-shape.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-shape.png new file mode 100644 index 00000000..af3a85ad Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fill-shape.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-child.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-child.png new file mode 100644 index 00000000..bc0be3a7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-child.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-default.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-default.png new file mode 100644 index 00000000..bc0be3a7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-default.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-parent.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-parent.png new file mode 100644 index 00000000..bc0be3a7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-parent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-shape.png b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-shape.png new file mode 100644 index 00000000..bc0be3a7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-opaque-fillstroke-shape.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-p00392156862745098.png b/fixtures/web-first/chromium/svg-group-blend-opacity-p00392156862745098.png new file mode 100644 index 00000000..bc12abc8 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-p00392156862745098.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-p5019607843137255.png b/fixtures/web-first/chromium/svg-group-blend-opacity-p5019607843137255.png new file mode 100644 index 00000000..b6a7d0ad Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-p5019607843137255.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity-p6.png b/fixtures/web-first/chromium/svg-group-blend-opacity-p6.png new file mode 100644 index 00000000..751774b3 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity-p6.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-opacity.png b/fixtures/web-first/chromium/svg-group-blend-opacity.png new file mode 100644 index 00000000..fbd05ac7 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-opacity.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-outer-opacity.png b/fixtures/web-first/chromium/svg-group-blend-outer-opacity.png new file mode 100644 index 00000000..f6dd73bd Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-outer-opacity.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-outer-root-multiply.png b/fixtures/web-first/chromium/svg-group-blend-outer-root-multiply.png new file mode 100644 index 00000000..454bd9f9 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-outer-root-multiply.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-outer-root-normal.png b/fixtures/web-first/chromium/svg-group-blend-outer-root-normal.png new file mode 100644 index 00000000..454bd9f9 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-outer-root-normal.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-outer-root-screen.png b/fixtures/web-first/chromium/svg-group-blend-outer-root-screen.png new file mode 100644 index 00000000..454bd9f9 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-outer-root-screen.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-partial-isolated.png b/fixtures/web-first/chromium/svg-group-blend-partial-isolated.png new file mode 100644 index 00000000..4917bf11 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-partial-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-partial.png b/fixtures/web-first/chromium/svg-group-blend-partial.png new file mode 100644 index 00000000..4917bf11 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-partial.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-pattern-multiply.png b/fixtures/web-first/chromium/svg-group-blend-pattern-multiply.png new file mode 100644 index 00000000..eaf2326f Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-pattern-multiply.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-pattern-normal.png b/fixtures/web-first/chromium/svg-group-blend-pattern-normal.png new file mode 100644 index 00000000..924a3fe9 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-pattern-normal.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-pattern-screen.png b/fixtures/web-first/chromium/svg-group-blend-pattern-screen.png new file mode 100644 index 00000000..58250ce1 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-pattern-screen.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-root-isolate.png b/fixtures/web-first/chromium/svg-group-blend-root-isolate.png new file mode 100644 index 00000000..507ceeb4 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-root-isolate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-root-multiply.png b/fixtures/web-first/chromium/svg-group-blend-root-multiply.png new file mode 100644 index 00000000..507ceeb4 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-root-multiply.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-root-normal.png b/fixtures/web-first/chromium/svg-group-blend-root-normal.png new file mode 100644 index 00000000..507ceeb4 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-root-normal.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-rotate-isolated.png b/fixtures/web-first/chromium/svg-group-blend-rotate-isolated.png new file mode 100644 index 00000000..5f68ec88 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-rotate-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-rotate.png b/fixtures/web-first/chromium/svg-group-blend-rotate.png new file mode 100644 index 00000000..46379cf5 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-rotate.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-scale-isolated.png b/fixtures/web-first/chromium/svg-group-blend-scale-isolated.png new file mode 100644 index 00000000..1ea0a6fd Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-scale-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-scale.png b/fixtures/web-first/chromium/svg-group-blend-scale.png new file mode 100644 index 00000000..6897e5ea Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-scale.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-alpha.png b/fixtures/web-first/chromium/svg-group-blend-screen-alpha.png new file mode 100644 index 00000000..e10a3ff5 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-alpha.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-each.png b/fixtures/web-first/chromium/svg-group-blend-screen-each.png new file mode 100644 index 00000000..c45fb774 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-each.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-group.png b/fixtures/web-first/chromium/svg-group-blend-screen-group.png new file mode 100644 index 00000000..cccee6ac Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-group.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-leaf.png b/fixtures/web-first/chromium/svg-group-blend-screen-leaf.png new file mode 100644 index 00000000..ed324685 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-leaf.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-opacity-small-opaque.png b/fixtures/web-first/chromium/svg-group-blend-screen-opacity-small-opaque.png new file mode 100644 index 00000000..6a2e6e9d Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-opacity-small-opaque.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-opacity-small-partial.png b/fixtures/web-first/chromium/svg-group-blend-screen-opacity-small-partial.png new file mode 100644 index 00000000..b1ef3550 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-opacity-small-partial.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-opacity.png b/fixtures/web-first/chromium/svg-group-blend-screen-opacity.png new file mode 100644 index 00000000..a211877c Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-opacity.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-stroke.png b/fixtures/web-first/chromium/svg-group-blend-screen-stroke.png new file mode 100644 index 00000000..eb51db8a Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-stroke.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-screen-transparent.png b/fixtures/web-first/chromium/svg-group-blend-screen-transparent.png new file mode 100644 index 00000000..95d2ecd2 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-screen-transparent.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-translate-isolated.png b/fixtures/web-first/chromium/svg-group-blend-translate-isolated.png new file mode 100644 index 00000000..5d295077 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-translate-isolated.png differ diff --git a/fixtures/web-first/chromium/svg-group-blend-translate.png b/fixtures/web-first/chromium/svg-group-blend-translate.png new file mode 100644 index 00000000..3930d175 Binary files /dev/null and b/fixtures/web-first/chromium/svg-group-blend-translate.png differ diff --git a/fixtures/web-first/html-inline-svg-group-blend-isolated.html b/fixtures/web-first/html-inline-svg-group-blend-isolated.html new file mode 100644 index 00000000..b4a60844 --- /dev/null +++ b/fixtures/web-first/html-inline-svg-group-blend-isolated.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/oracle-bake.json b/fixtures/web-first/oracle-bake.json index 377c2b73..ca98e80c 100644 --- a/fixtures/web-first/oracle-bake.json +++ b/fixtures/web-first/oracle-bake.json @@ -5,7 +5,7 @@ "bake_script_sha256": "2bdb5f933d072a1e87c9a675c3342fcf506c0955c0f5c26e2988f4e8fa37c4f2", "capture_module_sha256": "069296201718c43d29efe356fbea893781b73250dca51de3b6d47468d74027b0", "suite": "primitives.json", - "suite_sha256": "9cc3c4ad2a2e4e31483b2895e4458618189361e767c55a0b788605d8090ec468", + "suite_sha256": "0fac8f2a916aecd3ee3e975f162c4d73e27b7e0b35fc3fb06eb0dfe8c7745af5", "capture": { "device_scale_factor": 1, "omit_background": true, @@ -37,6 +37,15 @@ "width": 64, "height": 64 }, + { + "id": "html-inline-svg-group-blend-isolated", + "source": "html-inline-svg-group-blend-isolated.html", + "source_sha256": "f47e08650d407423c388a452c92cfc6ed36afeb8f7153d2070684605afa8239a", + "oracle": "chromium/html-inline-svg-group-blend-isolated.png", + "oracle_sha256": "5e59532df1bb80913c4ebb6998b6c62fb295bfe34eda57b23c84d94676dfc2e4", + "width": 64, + "height": 64 + }, { "id": "html-inline-svg-nested-viewport", "source": "html-inline-svg-nested-viewport.html", @@ -5131,6 +5140,1023 @@ "width": 64, "height": 64 }, + { + "id": "svg-group-blend-alpha-half-child", + "source": "svg-group-blend-alpha-half-child.svg", + "source_sha256": "0e8f90a7ab8356d28ba53bca7e78c0f06ffb31f097b28d51e1f03f13b9624910", + "oracle": "chromium/svg-group-blend-alpha-half-child.png", + "oracle_sha256": "0064e97502d2b608b5fcea684d89267368c0e4828a3494a7492c7bf39897a622", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-half-parent", + "source": "svg-group-blend-alpha-half-parent.svg", + "source_sha256": "09be35c3962cb3f575c6a29ed2f55a53587220476ac851c4722eecb96781f36e", + "oracle": "chromium/svg-group-blend-alpha-half-parent.png", + "oracle_sha256": "0064e97502d2b608b5fcea684d89267368c0e4828a3494a7492c7bf39897a622", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-half-plain", + "source": "svg-group-blend-alpha-half-plain.svg", + "source_sha256": "69c551f1248853186c1280aa4d11845de9c5daefad5e13a558421af0b0ae4e90", + "oracle": "chromium/svg-group-blend-alpha-half-plain.png", + "oracle_sha256": "0064e97502d2b608b5fcea684d89267368c0e4828a3494a7492c7bf39897a622", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-half-shape", + "source": "svg-group-blend-alpha-half-shape.svg", + "source_sha256": "8f05b1af0c570a54262ff99afebb15215e536ac083c590fcb2079c61de75923a", + "oracle": "chromium/svg-group-blend-alpha-half-shape.png", + "oracle_sha256": "0064e97502d2b608b5fcea684d89267368c0e4828a3494a7492c7bf39897a622", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-child", + "source": "svg-group-blend-alpha-six-tenths-child.svg", + "source_sha256": "889b8ae10075620af293bb5b2e03967419d25256b59a0fbbdaefacc796c69fde", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-child.png", + "oracle_sha256": "37201098af6a9e71f3a0bacc9720d4f39788c1803636d8a3c645bd6381e5cb57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-parent", + "source": "svg-group-blend-alpha-six-tenths-parent.svg", + "source_sha256": "d31953a8817ab6c0110527a14e0f28bfd77516f9f3500c643f5f017648622342", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-parent.png", + "oracle_sha256": "37201098af6a9e71f3a0bacc9720d4f39788c1803636d8a3c645bd6381e5cb57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-plain", + "source": "svg-group-blend-alpha-six-tenths-plain.svg", + "source_sha256": "8920bad73fe29d6d498cb4b1ebdd3d5bb393e37d2048c5a6c8d19d5ba4b491b9", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-plain.png", + "oracle_sha256": "37201098af6a9e71f3a0bacc9720d4f39788c1803636d8a3c645bd6381e5cb57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-shape", + "source": "svg-group-blend-alpha-six-tenths-shape.svg", + "source_sha256": "a2079c2260c00bb13fdd612a15f4448c6f20089e45a3c2b01b9382672e444ae3", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-shape.png", + "oracle_sha256": "37201098af6a9e71f3a0bacc9720d4f39788c1803636d8a3c645bd6381e5cb57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-anchor", + "source": "svg-group-blend-anchor.svg", + "source_sha256": "a3634e6c9c7093f65ea75e5494271549256c36fb98254cdc167c4c02f0308827", + "oracle": "chromium/svg-group-blend-anchor.png", + "oracle_sha256": "d360e0365deba1a2a7cde5c3964d8ce4fa4d39df3c364cebd7cb94faddcf95b7", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-attr-multiply", + "source": "svg-group-blend-attr-multiply.svg", + "source_sha256": "4cf595420040a12691a90b3d792b2e21989437f9c73b697581da8e38030ae9a5", + "oracle": "chromium/svg-group-blend-attr-multiply.png", + "oracle_sha256": "0cdb8029e01ea5cf2371a107cef5b04e9d03fcffb474e612fcba898a50ffc6fc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-case-comment", + "source": "svg-group-blend-case-comment.svg", + "source_sha256": "982334ffeb273176d63f0bc81bf2437693f6348d9847d1c8785eefbd57e5dcb2", + "oracle": "chromium/svg-group-blend-case-comment.png", + "oracle_sha256": "f9e03bf27cb8cc786c1315a7950f5300db5be8c3f79bc7dd2ca49b97d6bc2f57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-clip", + "source": "svg-group-blend-clip.svg", + "source_sha256": "a92b54c5019df4ba3d90abe5090c59071ccd0401bf03060822a0092c7807f83f", + "oracle": "chromium/svg-group-blend-clip.png", + "oracle_sha256": "7b072b98ed4a236b280394a73d336e2878fa854073c2e41eb276041018a4388c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-clip-isolated", + "source": "svg-group-blend-clip-isolated.svg", + "source_sha256": "f70e95a86d8c2372a4a8c52d35ab049345b4289cc54184a5a7bea302c1320240", + "oracle": "chromium/svg-group-blend-clip-isolated.png", + "oracle_sha256": "7b072b98ed4a236b280394a73d336e2878fa854073c2e41eb276041018a4388c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-invalid", + "source": "svg-group-blend-css-invalid.svg", + "source_sha256": "aa54cb79251a96c0182f41e790305e67db30722ee601f26f5f70971af35251fd", + "oracle": "chromium/svg-group-blend-css-invalid.png", + "oracle_sha256": "f9e03bf27cb8cc786c1315a7950f5300db5be8c3f79bc7dd2ca49b97d6bc2f57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-keyword", + "source": "svg-group-blend-css-keyword.svg", + "source_sha256": "0a95fd018a04d15db3347a2652e550120ab037effe6674b4c960989289fd760b", + "oracle": "chromium/svg-group-blend-css-keyword.png", + "oracle_sha256": "f9e03bf27cb8cc786c1315a7950f5300db5be8c3f79bc7dd2ca49b97d6bc2f57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-var", + "source": "svg-group-blend-css-var.svg", + "source_sha256": "fc6168cd6290e2cdaaa0103a20052b7ed6179ea62b96d1a5bd0b24b032e79ed6", + "oracle": "chromium/svg-group-blend-css-var.png", + "oracle_sha256": "f9e03bf27cb8cc786c1315a7950f5300db5be8c3f79bc7dd2ca49b97d6bc2f57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-winner", + "source": "svg-group-blend-css-winner.svg", + "source_sha256": "d9152fbfc7f7399d29cff07b8079f388c9bb48e692eb673d4abc65ed04e07894", + "oracle": "chromium/svg-group-blend-css-winner.png", + "oracle_sha256": "f9e03bf27cb8cc786c1315a7950f5300db5be8c3f79bc7dd2ca49b97d6bc2f57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-fractional-multiply", + "source": "svg-group-blend-fractional-multiply.svg", + "source_sha256": "57ba444f4eb9e7005d0b546b8d7b768b6f27c1f7b04cdcedf28a7ed71c0969d2", + "oracle": "chromium/svg-group-blend-fractional-multiply.png", + "oracle_sha256": "be743b053e34cd2284c6887e75e67817066caeff563d5171ae7074b500b081fc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-fractional-normal", + "source": "svg-group-blend-fractional-normal.svg", + "source_sha256": "9f6063e2d695659d4850a5792cd6de3515189bbfa4ef211b4a4f51d5cbdb3f27", + "oracle": "chromium/svg-group-blend-fractional-normal.png", + "oracle_sha256": "03a09feb9fa2902e7153f5463d627641dd829a2aedf81b65c1b5ca6d4092f95b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-fractional-screen", + "source": "svg-group-blend-fractional-screen.svg", + "source_sha256": "c728b7f97dce6d88191fc6dbac20121f804ec5ce6a32e48a5eab452609be96b0", + "oracle": "chromium/svg-group-blend-fractional-screen.png", + "oracle_sha256": "ac5313b7eae83edf12b3df20037f234c94fc9e5134be19269d897c1d98d09ac5", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-group-attr-isolate", + "source": "svg-group-blend-group-attr-isolate.svg", + "source_sha256": "f9a5a5d18b6f4340d94c9f9e98359615b93e74c6eba11f99de29f2fa3000981c", + "oracle": "chromium/svg-group-blend-group-attr-isolate.png", + "oracle_sha256": "5515347c495f699e7c072602f4f8ab0eaa34178a0f04dc22c0dbfd64b5f7643b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-instance-direct", + "source": "svg-group-blend-instance-direct.svg", + "source_sha256": "4b03df0d5684d87a36678fe46a06bf3bb9f2576c275208687766cdee528b6dab", + "oracle": "chromium/svg-group-blend-instance-direct.png", + "oracle_sha256": "d360e0365deba1a2a7cde5c3964d8ce4fa4d39df3c364cebd7cb94faddcf95b7", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-instance-use", + "source": "svg-group-blend-instance-use.svg", + "source_sha256": "ceb7691d506a7310d4d0cba79a1b923b173f8d475c0c04132f13f75fdec9c624", + "oracle": "chromium/svg-group-blend-instance-use.png", + "oracle_sha256": "d360e0365deba1a2a7cde5c3964d8ce4fa4d39df3c364cebd7cb94faddcf95b7", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-isolated", + "source": "svg-group-blend-isolated.svg", + "source_sha256": "ed60268e2e40a26deb4f3e932cfb258133f86aa1b0bfc1ed821c2b2f6b50a90a", + "oracle": "chromium/svg-group-blend-isolated.png", + "oracle_sha256": "7b072b98ed4a236b280394a73d336e2878fa854073c2e41eb276041018a4388c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-linear-multiply", + "source": "svg-group-blend-linear-multiply.svg", + "source_sha256": "ed1a1a58d6d92ce69a4b11b0700ff299582307f6447b0914f3c58a9da5e3db82", + "oracle": "chromium/svg-group-blend-linear-multiply.png", + "oracle_sha256": "79ea041f166e4e7324546ad75453502367ec13683dea02035eea076df8058f2e", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-linear-normal", + "source": "svg-group-blend-linear-normal.svg", + "source_sha256": "90e3b1c2b8d22df501c61c81f8f2371cfdffa8462adb70b799c69869e1cf484c", + "oracle": "chromium/svg-group-blend-linear-normal.png", + "oracle_sha256": "9ea6cff9f65c7a4ed971234df6189ac73460b7264f5136ac89ca7f92b4d9255c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-linear-screen", + "source": "svg-group-blend-linear-screen.svg", + "source_sha256": "522918e5fe3ab13d77cc83d572d748f09719cbb64a78457be86b37e9b78aeb17", + "oracle": "chromium/svg-group-blend-linear-screen.png", + "oracle_sha256": "0e86ae3e23fb31de9a2a80873fe005ad836f2aee9af1457a91573208520991c1", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-mapped-direct", + "source": "svg-group-blend-mapped-direct.svg", + "source_sha256": "2c0cbf57abd230d1110e3abc5713d534aca80a5e5c6a296499293ab2078ab22b", + "oracle": "chromium/svg-group-blend-mapped-direct.png", + "oracle_sha256": "6093674da14c781fe3dbd289d1bcf5d4137ef37469865f9402d68701c0b6e41b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-mapped-viewport", + "source": "svg-group-blend-mapped-viewport.svg", + "source_sha256": "7822fc089285d29b8945fb204618b6bc6967595cb9eddd5a6b86bd74a53aff5a", + "oracle": "chromium/svg-group-blend-mapped-viewport.png", + "oracle_sha256": "6093674da14c781fe3dbd289d1bcf5d4137ef37469865f9402d68701c0b6e41b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-alpha", + "source": "svg-group-blend-multiply-alpha.svg", + "source_sha256": "9d75dde4184e07571ead4e94089a49e6552ec1a72e0cf60b282ba8b15d33f2bc", + "oracle": "chromium/svg-group-blend-multiply-alpha.png", + "oracle_sha256": "b07fc2a0ff22d5588497c6c4d1ccc430942f66e8481a5047dc94b91943d0aa7e", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-each", + "source": "svg-group-blend-multiply-each.svg", + "source_sha256": "6be5c50d5649774107f282801fa48677c653a15776829c673198b9b1e25641e4", + "oracle": "chromium/svg-group-blend-multiply-each.png", + "oracle_sha256": "5a8e40e4bda3e1ef3b8632f01f952a9be85034276238b6b6b5a3db8c794a7e04", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-group", + "source": "svg-group-blend-multiply-group.svg", + "source_sha256": "74257582caae30bc1cec0838de3864c201e6f0b30076b074292e78740fee4db6", + "oracle": "chromium/svg-group-blend-multiply-group.png", + "oracle_sha256": "6ab8e06dcd76adcc2da46b4040318ec71217e1221e76e3079143648db44dbc07", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-leaf", + "source": "svg-group-blend-multiply-leaf.svg", + "source_sha256": "05c55e0f778e06ba79e1ccfad6a311a49c7d1e552f24fac8284781732412e1fa", + "oracle": "chromium/svg-group-blend-multiply-leaf.png", + "oracle_sha256": "5515347c495f699e7c072602f4f8ab0eaa34178a0f04dc22c0dbfd64b5f7643b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-opacity", + "source": "svg-group-blend-multiply-opacity.svg", + "source_sha256": "0a256736b20f89d6aba819cfcf27357e74caceb0fefa604e12c7a30871e1cb55", + "oracle": "chromium/svg-group-blend-multiply-opacity.png", + "oracle_sha256": "1bd2218479722f15e2b264e314fb0a9fbb429dff1915d2f39ea3dde0d548b4bf", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-opacity-small-opaque", + "source": "svg-group-blend-multiply-opacity-small-opaque.svg", + "source_sha256": "13351bb743d942584382ee7127af77d2e0e3cc524a58805e8d30de44e69022ea", + "oracle": "chromium/svg-group-blend-multiply-opacity-small-opaque.png", + "oracle_sha256": "e21b47f392f75aed7eea3f307ec61aa7a9da681435e64cdeb5844d66425f0788", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-opacity-small-partial", + "source": "svg-group-blend-multiply-opacity-small-partial.svg", + "source_sha256": "1f5508dab04deca5ba0017ba3e9c92b742a2f66a94aadc0b3405b390f34a44e8", + "oracle": "chromium/svg-group-blend-multiply-opacity-small-partial.png", + "oracle_sha256": "539083523e60b36fd82e240cfb81caa8fec4f6deae2bb01e486b9162ea2d37af", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-stroke", + "source": "svg-group-blend-multiply-stroke.svg", + "source_sha256": "146ef0867681a9d2ca22d5851a245350892cde01875a3cbdfb779be919d4a9c1", + "oracle": "chromium/svg-group-blend-multiply-stroke.png", + "oracle_sha256": "992fc0575d9111d74494a4c357b87927694fa5325379a5178a32563e3feeeb0e", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-transparent", + "source": "svg-group-blend-multiply-transparent.svg", + "source_sha256": "ab3419aa0ecf48d1d0ea1404677997de46bd5903f3b1194db55253d682edfde1", + "oracle": "chromium/svg-group-blend-multiply-transparent.png", + "oracle_sha256": "a36c2a26bfa284913592c9f2903cf39c76496e09e2f5eb3c0b06e31b9ba70211", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-near", + "source": "svg-group-blend-near-unit-bare-near.svg", + "source_sha256": "8f8db269ebdef200ed2c4d316eed6990914aae91c679461f9c924467474eae07", + "oracle": "chromium/svg-group-blend-near-unit-bare-near.png", + "oracle_sha256": "d1c94cd09b9e33a1a77e79063e0767163f1bfb027db78a8fa35f7bb02e8df024", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-p998", + "source": "svg-group-blend-near-unit-bare-p998.svg", + "source_sha256": "baf874f09357edd4c4db5b40903bdc40d64830007794216281d736f6d91fe144", + "oracle": "chromium/svg-group-blend-near-unit-bare-p998.png", + "oracle_sha256": "33846d98c6e04bed4fafb819f2815e6111ddb8362a6cce0803f56cd3753029e0", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-p999", + "source": "svg-group-blend-near-unit-bare-p999.svg", + "source_sha256": "b9c5938f544097ed4c1ff36c45fffc9702b4b8bb404da57805e27220ce890659", + "oracle": "chromium/svg-group-blend-near-unit-bare-p999.png", + "oracle_sha256": "d1c94cd09b9e33a1a77e79063e0767163f1bfb027db78a8fa35f7bb02e8df024", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-unit", + "source": "svg-group-blend-near-unit-bare-unit.svg", + "source_sha256": "b648bdfbb942eef2cfa73413a84b29e22bb398d7d46a9b186e0469c9fc64a3e3", + "oracle": "chromium/svg-group-blend-near-unit-bare-unit.png", + "oracle_sha256": "9e44a243459ac6f1c2168bbe52c63f045967614006873a3e13f5e964aa52960a", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-near", + "source": "svg-group-blend-near-unit-isolated-near.svg", + "source_sha256": "aaa4ef2682772a050e539adaabe4abd114854fdd458313da3f9644e10998e2a6", + "oracle": "chromium/svg-group-blend-near-unit-isolated-near.png", + "oracle_sha256": "ae1f25fdb182714d59ff23ce6d69bfdcfcc45edd3d1cc92e1a00bc1240404474", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-p998", + "source": "svg-group-blend-near-unit-isolated-p998.svg", + "source_sha256": "f18bebb39666caf06659cc5eb1bbdbd6a86244e72f782aceba3a0b993715b48a", + "oracle": "chromium/svg-group-blend-near-unit-isolated-p998.png", + "oracle_sha256": "3878501db74383b3dc1f63b239ba560a1cba01912e02e71ed814f5cbca51f939", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-p999", + "source": "svg-group-blend-near-unit-isolated-p999.svg", + "source_sha256": "c385a32f0f02c8cad38e7fab96851195ac670c0459430f684d16216b10acc41f", + "oracle": "chromium/svg-group-blend-near-unit-isolated-p999.png", + "oracle_sha256": "ae1f25fdb182714d59ff23ce6d69bfdcfcc45edd3d1cc92e1a00bc1240404474", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-unit", + "source": "svg-group-blend-near-unit-isolated-unit.svg", + "source_sha256": "52b6663a08132782cfffb2982f7cec5d2aff6d568b7b43a1ed816fd4f424a541", + "oracle": "chromium/svg-group-blend-near-unit-isolated-unit.png", + "oracle_sha256": "ae1f25fdb182714d59ff23ce6d69bfdcfcc45edd3d1cc92e1a00bc1240404474", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-near", + "source": "svg-group-blend-near-unit-plain-near.svg", + "source_sha256": "f849bae7a86202d6fd19b4f0315b995c9ea4f964c9a03f0b7f5fda111a22ec21", + "oracle": "chromium/svg-group-blend-near-unit-plain-near.png", + "oracle_sha256": "d1c94cd09b9e33a1a77e79063e0767163f1bfb027db78a8fa35f7bb02e8df024", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-p998", + "source": "svg-group-blend-near-unit-plain-p998.svg", + "source_sha256": "0a61a011847d2a483277590f9e2c3c9a8dbb7fb1a2269afd2e12d3db6974168f", + "oracle": "chromium/svg-group-blend-near-unit-plain-p998.png", + "oracle_sha256": "33846d98c6e04bed4fafb819f2815e6111ddb8362a6cce0803f56cd3753029e0", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-p999", + "source": "svg-group-blend-near-unit-plain-p999.svg", + "source_sha256": "63f88419106c1908cf331e63e603293e31d7fdac5a4780285742c461dcaff8e7", + "oracle": "chromium/svg-group-blend-near-unit-plain-p999.png", + "oracle_sha256": "d1c94cd09b9e33a1a77e79063e0767163f1bfb027db78a8fa35f7bb02e8df024", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-unit", + "source": "svg-group-blend-near-unit-plain-unit.svg", + "source_sha256": "061fcfbf58d9147cbef003c181721eb11f9765463ec0c48b832c9d26765fef04", + "oracle": "chromium/svg-group-blend-near-unit-plain-unit.png", + "oracle_sha256": "9e44a243459ac6f1c2168bbe52c63f045967614006873a3e13f5e964aa52960a", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-near", + "source": "svg-group-blend-near-unit-screen-near.svg", + "source_sha256": "005171685453d59c82568bf3af9b85525805284d0f881dd654989ba7c6292e43", + "oracle": "chromium/svg-group-blend-near-unit-screen-near.png", + "oracle_sha256": "bd4c0f568c7a7506da3cd6b87815dae06024266d2faa221e9333369630fc4e8b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-p998", + "source": "svg-group-blend-near-unit-screen-p998.svg", + "source_sha256": "328b41be82215dfb674b380015812cdfd74ff23540eab7e9f65aa6c5b7b9890b", + "oracle": "chromium/svg-group-blend-near-unit-screen-p998.png", + "oracle_sha256": "f14a992189272a005e92ceff7805f39f00b9aa5561e73f444c8a7b163d6f8674", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-p999", + "source": "svg-group-blend-near-unit-screen-p999.svg", + "source_sha256": "0d44f780c00630f1c5bac65ac695662f193b78916f2cd9ab9f45c2f1ea75e82e", + "oracle": "chromium/svg-group-blend-near-unit-screen-p999.png", + "oracle_sha256": "bd4c0f568c7a7506da3cd6b87815dae06024266d2faa221e9333369630fc4e8b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-unit", + "source": "svg-group-blend-near-unit-screen-unit.svg", + "source_sha256": "0fdcff87a62fee97684f01b87fa295c848b23861b682c71fb3ce486ed741603d", + "oracle": "chromium/svg-group-blend-near-unit-screen-unit.png", + "oracle_sha256": "bd4c0f568c7a7506da3cd6b87815dae06024266d2faa221e9333369630fc4e8b", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-nested-hidden", + "source": "svg-group-blend-nested-hidden.svg", + "source_sha256": "0d55f821d90d7cbe01e5a00a5b5bf93c9825c228e222a7bbecfb56977a569558", + "oracle": "chromium/svg-group-blend-nested-hidden.png", + "oracle_sha256": "20e0cb039f0c4d4328b4f88a18b7ee429408467de692cf34a56f89637ec26dd8", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-nested-hidden-isolated", + "source": "svg-group-blend-nested-hidden-isolated.svg", + "source_sha256": "f833ba059f2966df55ad1f16cb35771d2d68e5411afba1497b41f65393140430", + "oracle": "chromium/svg-group-blend-nested-hidden-isolated.png", + "oracle_sha256": "4f750c3565ac06374cf220c35156d762714905ed11c0ec517b4e588dbe8593c0", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-neutral", + "source": "svg-group-blend-neutral.svg", + "source_sha256": "e2eba72cd684eb9c0f201936567f59cca4d536cba3d1d78fee66604281c7e626", + "oracle": "chromium/svg-group-blend-neutral.png", + "oracle_sha256": "5e59532df1bb80913c4ebb6998b6c62fb295bfe34eda57b23c84d94676dfc2e4", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-alpha", + "source": "svg-group-blend-normal-alpha.svg", + "source_sha256": "3959bfaf133815abe55120ab88c5ed62df25a808a5ebdf791c7c7660695de04a", + "oracle": "chromium/svg-group-blend-normal-alpha.png", + "oracle_sha256": "63fe84b7011ea86b0377cb85366cd43f23cc92b51d29095c5c560c6ef0c430d3", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-clip-isolate", + "source": "svg-group-blend-normal-clip-isolate.svg", + "source_sha256": "108f4e55c7753c91a95170d888e4d96454b762f250b25849384a604add9c241d", + "oracle": "chromium/svg-group-blend-normal-clip-isolate.png", + "oracle_sha256": "bd8d68b0feb68dd13088acb2367a283242511014b8816fd8f8f5a7c3a6f76fd2", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-clip-plain", + "source": "svg-group-blend-normal-clip-plain.svg", + "source_sha256": "65d66f34a2d8f9cd6c7e325f37bedad66bbd07c0069010565c9f2775b6be6b6b", + "oracle": "chromium/svg-group-blend-normal-clip-plain.png", + "oracle_sha256": "bd8d68b0feb68dd13088acb2367a283242511014b8816fd8f8f5a7c3a6f76fd2", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-group", + "source": "svg-group-blend-normal-group.svg", + "source_sha256": "4ec40dc4812396d32256966fb594c41810c660d26bcaa2632bd15893906e40c9", + "oracle": "chromium/svg-group-blend-normal-group.png", + "oracle_sha256": "20a7bc4aa44feb90ea8e7059053a78677652aad48525b4fdf3c398a1c86ef3b4", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-leaf", + "source": "svg-group-blend-normal-leaf.svg", + "source_sha256": "1cff30b2b7f6f310219b8863589ece6b03cadab5389cf30e89911783b3a442a5", + "oracle": "chromium/svg-group-blend-normal-leaf.png", + "oracle_sha256": "0cdb8029e01ea5cf2371a107cef5b04e9d03fcffb474e612fcba898a50ffc6fc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-many-opacity-isolate", + "source": "svg-group-blend-normal-many-opacity-isolate.svg", + "source_sha256": "a90d4170e8367e7656a1a366aff86146379af54cbe80a8e170906be995cbc3e4", + "oracle": "chromium/svg-group-blend-normal-many-opacity-isolate.png", + "oracle_sha256": "1c1368472dd4321eb803805002b4b6db3373764b51521176feb4d92db08f0b39", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-many-opacity-plain", + "source": "svg-group-blend-normal-many-opacity-plain.svg", + "source_sha256": "83b24a4b8fae9c7cccb0b4231993ff5235d197ce26711e8e31594185f4cc1a6f", + "oracle": "chromium/svg-group-blend-normal-many-opacity-plain.png", + "oracle_sha256": "1c1368472dd4321eb803805002b4b6db3373764b51521176feb4d92db08f0b39", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-opacity", + "source": "svg-group-blend-normal-opacity.svg", + "source_sha256": "b31206adf82203bd18ffebdd892dfd3e2d240ef8884fe6fdf3ba2d2ad6943c83", + "oracle": "chromium/svg-group-blend-normal-opacity.png", + "oracle_sha256": "11af750a0de4f532dfafe4deeb4b80f006833e5daa20e166de0268c01b8e84d3", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-stroke", + "source": "svg-group-blend-normal-stroke.svg", + "source_sha256": "416fd0f382e942f31aae69a69de686803791d7c642a9882a5624bff3195b8d38", + "oracle": "chromium/svg-group-blend-normal-stroke.png", + "oracle_sha256": "41429090b99a4d95de7c5bbce830f3af1e9689d931ce27d86ddc723a28af3792", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-transform-isolate", + "source": "svg-group-blend-normal-transform-isolate.svg", + "source_sha256": "204c3a27b6b6f8b9b27b14ea98f0a688b556b909f3d4caaddf253173a6a6714f", + "oracle": "chromium/svg-group-blend-normal-transform-isolate.png", + "oracle_sha256": "ed7685e33753a7768cf160fe64229b77d69f6f52efd1c2b6a68c6487cad4fed5", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-transform-plain", + "source": "svg-group-blend-normal-transform-plain.svg", + "source_sha256": "7b4babce789762f422d3ced13872cf883c17a5d97936d6e1a8df3eeb67e6ebc9", + "oracle": "chromium/svg-group-blend-normal-transform-plain.png", + "oracle_sha256": "ed7685e33753a7768cf160fe64229b77d69f6f52efd1c2b6a68c6487cad4fed5", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-transparent", + "source": "svg-group-blend-normal-transparent.svg", + "source_sha256": "466ad4ae8a07fad36e4d114571e9157fd2de0e4f988ac33e5ec3d45a17f0f738", + "oracle": "chromium/svg-group-blend-normal-transparent.png", + "oracle_sha256": "a36c2a26bfa284913592c9f2903cf39c76496e09e2f5eb3c0b06e31b9ba70211", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-unit-isolate", + "source": "svg-group-blend-normal-unit-isolate.svg", + "source_sha256": "3508f57220153b5c60cb6eb5525924740e347cb148a70ac0407c7cddc1707986", + "oracle": "chromium/svg-group-blend-normal-unit-isolate.png", + "oracle_sha256": "bd8d68b0feb68dd13088acb2367a283242511014b8816fd8f8f5a7c3a6f76fd2", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-unit-plain", + "source": "svg-group-blend-normal-unit-plain.svg", + "source_sha256": "275110c584c5ad6626e4b819f5adfb9b87ced63649908a8441d8302b90516ff0", + "oracle": "chromium/svg-group-blend-normal-unit-plain.png", + "oracle_sha256": "bd8d68b0feb68dd13088acb2367a283242511014b8816fd8f8f5a7c3a6f76fd2", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity", + "source": "svg-group-blend-opacity.svg", + "source_sha256": "76a92d6eb7f5932a616a985db5fde8556972eafc7615c6bc3d5be85abfbfa8ee", + "oracle": "chromium/svg-group-blend-opacity.png", + "oracle_sha256": "d97d8066a6ce75e701c9f20ae3e6577068487f99863ee58f7657b5ad8f42e11f", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-0", + "source": "svg-group-blend-opacity-0.svg", + "source_sha256": "33b94c4481a4d9229bb32ddb3c44ea00bc7e2c78c6edaa6d3438a51262a6010f", + "oracle": "chromium/svg-group-blend-opacity-0.png", + "oracle_sha256": "4f35a6bbd50a8b9d04eca38be21c4a52f2a0bcc0e3040377f8ed954fc8295877", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-1", + "source": "svg-group-blend-opacity-1.svg", + "source_sha256": "1f1217a3b8df3694d702bf257a23b99a0613e351deb52bae8d4758a20824b1a4", + "oracle": "chromium/svg-group-blend-opacity-1.png", + "oracle_sha256": "2b7ba824b3f52347ea948f83db1d092da6aecc83514f00e6cff07ab7bc35dec2", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-isolated", + "source": "svg-group-blend-opacity-isolated.svg", + "source_sha256": "f997e08c6b219f82e0f6413a811bb44aae03ebc0b42c8e29bc64879a5cc38a1c", + "oracle": "chromium/svg-group-blend-opacity-isolated.png", + "oracle_sha256": "d97d8066a6ce75e701c9f20ae3e6577068487f99863ee58f7657b5ad8f42e11f", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-child", + "source": "svg-group-blend-opacity-opaque-fill-child.svg", + "source_sha256": "10a404506ea4223e29b5cb6b83e83c09d36c2cfd6a58490369f6c81180e9606e", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-child.png", + "oracle_sha256": "f94ffe454f89d39893ef02f15fc7be985c303246a70914ba9602898c120ac064", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-default", + "source": "svg-group-blend-opacity-opaque-fill-default.svg", + "source_sha256": "fbfc9ad964bc10d059d8daacf7d0ffdf481745ba819f0f040f691c1242caa9e2", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-default.png", + "oracle_sha256": "f94ffe454f89d39893ef02f15fc7be985c303246a70914ba9602898c120ac064", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-parent", + "source": "svg-group-blend-opacity-opaque-fill-parent.svg", + "source_sha256": "01d058727ca869a66c94f628d691bc04d83aee1233fc2f54edf5e293190b959f", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-parent.png", + "oracle_sha256": "f94ffe454f89d39893ef02f15fc7be985c303246a70914ba9602898c120ac064", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-shape", + "source": "svg-group-blend-opacity-opaque-fill-shape.svg", + "source_sha256": "259ef2201743943de1509ef090f293b2cdda6cd3844adf4bb6360e012149e5cf", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-shape.png", + "oracle_sha256": "f94ffe454f89d39893ef02f15fc7be985c303246a70914ba9602898c120ac064", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-child", + "source": "svg-group-blend-opacity-opaque-fillstroke-child.svg", + "source_sha256": "6b7a8e6a9b299c0f5e6ce82cdf8c57fa457fda60829f2e20e2231a30a6a9c5d5", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-child.png", + "oracle_sha256": "e800cb9c82642caaa9528bc04a5613e8c4c04f9bacf10256c1bc1aa2b5ba6895", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-default", + "source": "svg-group-blend-opacity-opaque-fillstroke-default.svg", + "source_sha256": "61a5415c1a4e59b9cf104ac10f8c1edf749af5d65898f287176a6a5446e9d848", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-default.png", + "oracle_sha256": "e800cb9c82642caaa9528bc04a5613e8c4c04f9bacf10256c1bc1aa2b5ba6895", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-parent", + "source": "svg-group-blend-opacity-opaque-fillstroke-parent.svg", + "source_sha256": "a14d4144348c6a91d4fdc20797d90eda60e3c3b256bb6e896877ddac1fa7a327", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-parent.png", + "oracle_sha256": "e800cb9c82642caaa9528bc04a5613e8c4c04f9bacf10256c1bc1aa2b5ba6895", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-shape", + "source": "svg-group-blend-opacity-opaque-fillstroke-shape.svg", + "source_sha256": "f7069d11198e484d28a6dbaccf000d1212739b69de9173042a8aa6b5bd73a065", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-shape.png", + "oracle_sha256": "e800cb9c82642caaa9528bc04a5613e8c4c04f9bacf10256c1bc1aa2b5ba6895", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-p00392156862745098", + "source": "svg-group-blend-opacity-p00392156862745098.svg", + "source_sha256": "8fb6592cccc9d008a410b3ecb7758293fa9dae1fb2776eccd31115c9b165e92f", + "oracle": "chromium/svg-group-blend-opacity-p00392156862745098.png", + "oracle_sha256": "4468feba489c7759231411e1fb42512257802c5563147287260585a65d156435", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-p5019607843137255", + "source": "svg-group-blend-opacity-p5019607843137255.svg", + "source_sha256": "8bf8caa841b2e51eac82397eb799a0dd3085437f75525eb5ebdd192b25b89e00", + "oracle": "chromium/svg-group-blend-opacity-p5019607843137255.png", + "oracle_sha256": "f92e9e97fd66f78f6fed11fe3759d6c9a1b494751ed8c387f72398469d051365", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-p6", + "source": "svg-group-blend-opacity-p6.svg", + "source_sha256": "4e0d599de32639e440c26ad4802e42cb461fbc2f2418acaafa56d4d9b05fee93", + "oracle": "chromium/svg-group-blend-opacity-p6.png", + "oracle_sha256": "03f99134ccec0cd67f3b1c66b10b46a4cedc48e9d211ac45fe2d35c8af3a9ccc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-opacity", + "source": "svg-group-blend-outer-opacity.svg", + "source_sha256": "20e5b3b6d20c1940c991f530c59bae8f5f5e944576f35dda80fcddf1c178e493", + "oracle": "chromium/svg-group-blend-outer-opacity.png", + "oracle_sha256": "11af750a0de4f532dfafe4deeb4b80f006833e5daa20e166de0268c01b8e84d3", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-root-multiply", + "source": "svg-group-blend-outer-root-multiply.svg", + "source_sha256": "2d509dcbea6dd61cf7942a8b05a92527d6084e6f9ad4d36bcd61a31b5881950c", + "oracle": "chromium/svg-group-blend-outer-root-multiply.png", + "oracle_sha256": "1e386dabcff2c0a27c5708ecde62dc0f04444603aeb1ad4ab0de6b68433e0d4c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-root-normal", + "source": "svg-group-blend-outer-root-normal.svg", + "source_sha256": "0cb537461ea5da4c2cd15ecb5f60e761b6046edeeb7f73931c16b387bfada1f7", + "oracle": "chromium/svg-group-blend-outer-root-normal.png", + "oracle_sha256": "1e386dabcff2c0a27c5708ecde62dc0f04444603aeb1ad4ab0de6b68433e0d4c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-root-screen", + "source": "svg-group-blend-outer-root-screen.svg", + "source_sha256": "0b080be182218b906f72c846cff2fed43c5e7251476d7bf7c635cc594d3def18", + "oracle": "chromium/svg-group-blend-outer-root-screen.png", + "oracle_sha256": "1e386dabcff2c0a27c5708ecde62dc0f04444603aeb1ad4ab0de6b68433e0d4c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-partial", + "source": "svg-group-blend-partial.svg", + "source_sha256": "e9148260bb6b8f02b596fbbd6f3785888c67d180f5fa201adb4064d8faad0fc6", + "oracle": "chromium/svg-group-blend-partial.png", + "oracle_sha256": "e39e713aeb4f42d9e9333bd1459f8d5c7ce21dc2f95803c906c76c2a5ee3ffc8", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-partial-isolated", + "source": "svg-group-blend-partial-isolated.svg", + "source_sha256": "e5c2585c4b31dbdd0491c559a518a9062891fd09ebaeda850dee73c05514eefc", + "oracle": "chromium/svg-group-blend-partial-isolated.png", + "oracle_sha256": "e39e713aeb4f42d9e9333bd1459f8d5c7ce21dc2f95803c906c76c2a5ee3ffc8", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-pattern-multiply", + "source": "svg-group-blend-pattern-multiply.svg", + "source_sha256": "8b21ab4bace343600ae7af70cc530cb68bf188088c86b939f81642ddcea81649", + "oracle": "chromium/svg-group-blend-pattern-multiply.png", + "oracle_sha256": "f223311ef466b9c99fc3bf77e474dc2550ff3c8e2cc2c0a4777539c622e92500", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-pattern-normal", + "source": "svg-group-blend-pattern-normal.svg", + "source_sha256": "56c16a7733dd9e550ad23bf1ec09bd0cff0980cfc06b1714417b1b7335fe4ebe", + "oracle": "chromium/svg-group-blend-pattern-normal.png", + "oracle_sha256": "28b95ececee897051226883a3bd0a06d345467396feb71fcdb94be52b389bfbc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-pattern-screen", + "source": "svg-group-blend-pattern-screen.svg", + "source_sha256": "69bd7c6d84e20518f02397c579e4834ebfd54d9fbe86fc257a3c2d8f954511dc", + "oracle": "chromium/svg-group-blend-pattern-screen.png", + "oracle_sha256": "7769a69eddea993ea93db61f19e0d43a96c9d529a733fc265ab616da1563a925", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-root-isolate", + "source": "svg-group-blend-root-isolate.svg", + "source_sha256": "2a8b2d3e0ffdfeec060bb44f98e406eb6b0218607735a1b201688b4b76403598", + "oracle": "chromium/svg-group-blend-root-isolate.png", + "oracle_sha256": "5769b4dfb16d44706feb9549d18affc9c6f68a36c2f69051fd746c59a1ebebdc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-root-multiply", + "source": "svg-group-blend-root-multiply.svg", + "source_sha256": "05d35a3cab2c6fa39b50f73f1a821a51ec6559f31ad6d721dd24f22a302c1305", + "oracle": "chromium/svg-group-blend-root-multiply.png", + "oracle_sha256": "5769b4dfb16d44706feb9549d18affc9c6f68a36c2f69051fd746c59a1ebebdc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-root-normal", + "source": "svg-group-blend-root-normal.svg", + "source_sha256": "c197cb2cb42d8da6bd725b5ba57172908fb8d45b539a4429cdf05d619ff2ead0", + "oracle": "chromium/svg-group-blend-root-normal.png", + "oracle_sha256": "5769b4dfb16d44706feb9549d18affc9c6f68a36c2f69051fd746c59a1ebebdc", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-rotate", + "source": "svg-group-blend-rotate.svg", + "source_sha256": "497ca6e7a4ecf440c3c5575156ea08d2ac603f99f90b7c9f770a0bd41087e9a6", + "oracle": "chromium/svg-group-blend-rotate.png", + "oracle_sha256": "a6993fc6207de91739ad7e010d0d89ea2b69de222ce6470a9660ef278ac907ac", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-rotate-isolated", + "source": "svg-group-blend-rotate-isolated.svg", + "source_sha256": "67a21534d38949bd943841a6688238327a69f34d5675c66bde7bc6c5dadb9fef", + "oracle": "chromium/svg-group-blend-rotate-isolated.png", + "oracle_sha256": "ae1f25fdb182714d59ff23ce6d69bfdcfcc45edd3d1cc92e1a00bc1240404474", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-scale", + "source": "svg-group-blend-scale.svg", + "source_sha256": "5a3bab5a04278adaf2863771bae1113e3a4d17ff7f4569e56fae1b5e24b3e9ba", + "oracle": "chromium/svg-group-blend-scale.png", + "oracle_sha256": "dd10710aaa7f119f098c13a8303950a3597d7a25bd5abf0f3d89c91e1e450f6c", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-scale-isolated", + "source": "svg-group-blend-scale-isolated.svg", + "source_sha256": "017d1b9dc9a413e918a1966c33bde49352c41b71aa5074ed63de518d5339914a", + "oracle": "chromium/svg-group-blend-scale-isolated.png", + "oracle_sha256": "13f144ee3e857d5e9d0b77163fdd06e1b56bc95e3029bb873a1bb79c3ebeff43", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-alpha", + "source": "svg-group-blend-screen-alpha.svg", + "source_sha256": "eddf0b9d5cc3e2826a6817c8300197e2c0a34771c27e41dad182cda97b32a264", + "oracle": "chromium/svg-group-blend-screen-alpha.png", + "oracle_sha256": "76400432c0273673006e009168d054983f206eb7b9ab0f85d5b54f3304a61d8f", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-each", + "source": "svg-group-blend-screen-each.svg", + "source_sha256": "e9f768648b18fb32cc4f18999b05bf61d6c5d906ec13b7a28683674fcc420a28", + "oracle": "chromium/svg-group-blend-screen-each.png", + "oracle_sha256": "ecd5b6f35b517aff2d85c710785a97740ded51a232b731153dfe29da47a7d6c2", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-group", + "source": "svg-group-blend-screen-group.svg", + "source_sha256": "077d1da59e0b3a0678e8d9846c489fd108a8292eaea635af456c6c32e6142c49", + "oracle": "chromium/svg-group-blend-screen-group.png", + "oracle_sha256": "3bf966d011f1cf36689ee490858582095d6f0d32e4c9f62d44a37fcdb144c5e8", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-leaf", + "source": "svg-group-blend-screen-leaf.svg", + "source_sha256": "cf6b40bf46ab6c1368c99cf3ba7e44dd67ac96bb6d61133f2e2a8cc9ee2693e4", + "oracle": "chromium/svg-group-blend-screen-leaf.png", + "oracle_sha256": "f9e03bf27cb8cc786c1315a7950f5300db5be8c3f79bc7dd2ca49b97d6bc2f57", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-opacity", + "source": "svg-group-blend-screen-opacity.svg", + "source_sha256": "aab37c63521e0da12f63ca7bba9d51b031a060e877309336ebb31c8894db7981", + "oracle": "chromium/svg-group-blend-screen-opacity.png", + "oracle_sha256": "2a2d24bee3fdbc3f44f7869a39c04e59285bc23e4504ad90a7debb5d468f4b07", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-opacity-small-opaque", + "source": "svg-group-blend-screen-opacity-small-opaque.svg", + "source_sha256": "38b9fea50a6e624873f33f7bb11eddd65d57cdd059646fb8b4d4d48a3cabec3a", + "oracle": "chromium/svg-group-blend-screen-opacity-small-opaque.png", + "oracle_sha256": "455d124b2e217a2f4daca6d0c499e090a4987b736fe36b45423d16ef0a4fe510", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-opacity-small-partial", + "source": "svg-group-blend-screen-opacity-small-partial.svg", + "source_sha256": "9d3c520b293771f211ae0113392641d44bc296360b058a7becdbd19e2a74e3d1", + "oracle": "chromium/svg-group-blend-screen-opacity-small-partial.png", + "oracle_sha256": "c021a884384583e269afd6279af3dcd9c81632c8c66b1ee1e5e88400c88aea4e", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-stroke", + "source": "svg-group-blend-screen-stroke.svg", + "source_sha256": "da7489d2796318f75f7bcac7e6df122a785e6a4c652e1cae445920fb090c53f4", + "oracle": "chromium/svg-group-blend-screen-stroke.png", + "oracle_sha256": "904fba724e03a7f2b9435977df3ce500882558ae9b3b525906f3594a3dcf0597", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-transparent", + "source": "svg-group-blend-screen-transparent.svg", + "source_sha256": "4cb0365fcfc6b345f287438637e089e363898674b474ec4bb59dc6fe8095c848", + "oracle": "chromium/svg-group-blend-screen-transparent.png", + "oracle_sha256": "a36c2a26bfa284913592c9f2903cf39c76496e09e2f5eb3c0b06e31b9ba70211", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-translate", + "source": "svg-group-blend-translate.svg", + "source_sha256": "1ac9490c448030963d0c524969c4e7a16bcc11055ecc6cbdca789b183a28615d", + "oracle": "chromium/svg-group-blend-translate.png", + "oracle_sha256": "06dd11d37f1b4c7a1e634071415260d993ec02669fdc6515e8d9f0370ede4933", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-translate-isolated", + "source": "svg-group-blend-translate-isolated.svg", + "source_sha256": "2c458e9ceac1a709af097645f566d759f411f9c7b4707b1a28f9de9d0527f869", + "oracle": "chromium/svg-group-blend-translate-isolated.png", + "oracle_sha256": "3cc3047cc1fb116dd4c42c92124fc9efa1dabb26b74c6fe56fd3de2444308e3b", + "width": 64, + "height": 64 + }, { "id": "svg-group-inherited-fill", "source": "svg-group-inherited-fill.svg", diff --git a/fixtures/web-first/primitives.json b/fixtures/web-first/primitives.json index d89a20ef..cffc860a 100644 --- a/fixtures/web-first/primitives.json +++ b/fixtures/web-first/primitives.json @@ -17,6 +17,14 @@ "width": 64, "height": 64 }, + { + "id": "html-inline-svg-group-blend-isolated", + "source": "html-inline-svg-group-blend-isolated.html", + "entry": "html-inline-svg", + "oracle": "chromium/html-inline-svg-group-blend-isolated.png", + "width": 64, + "height": 64 + }, { "id": "html-inline-svg-nested-viewport", "source": "html-inline-svg-nested-viewport.html", @@ -4633,6 +4641,910 @@ "width": 64, "height": 64 }, + { + "id": "svg-group-blend-alpha-half-child", + "source": "svg-group-blend-alpha-half-child.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-half-child.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-half-parent", + "source": "svg-group-blend-alpha-half-parent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-half-parent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-half-plain", + "source": "svg-group-blend-alpha-half-plain.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-half-plain.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-half-shape", + "source": "svg-group-blend-alpha-half-shape.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-half-shape.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-child", + "source": "svg-group-blend-alpha-six-tenths-child.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-child.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-parent", + "source": "svg-group-blend-alpha-six-tenths-parent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-parent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-plain", + "source": "svg-group-blend-alpha-six-tenths-plain.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-plain.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-alpha-six-tenths-shape", + "source": "svg-group-blend-alpha-six-tenths-shape.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-alpha-six-tenths-shape.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-anchor", + "source": "svg-group-blend-anchor.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-anchor.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-attr-multiply", + "source": "svg-group-blend-attr-multiply.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-attr-multiply.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-case-comment", + "source": "svg-group-blend-case-comment.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-case-comment.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-clip", + "source": "svg-group-blend-clip.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-clip.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-clip-isolated", + "source": "svg-group-blend-clip-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-clip-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-invalid", + "source": "svg-group-blend-css-invalid.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-css-invalid.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-keyword", + "source": "svg-group-blend-css-keyword.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-css-keyword.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-var", + "source": "svg-group-blend-css-var.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-css-var.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-css-winner", + "source": "svg-group-blend-css-winner.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-css-winner.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-fractional-multiply", + "source": "svg-group-blend-fractional-multiply.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-fractional-multiply.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-fractional-normal", + "source": "svg-group-blend-fractional-normal.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-fractional-normal.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-fractional-screen", + "source": "svg-group-blend-fractional-screen.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-fractional-screen.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-group-attr-isolate", + "source": "svg-group-blend-group-attr-isolate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-group-attr-isolate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-instance-direct", + "source": "svg-group-blend-instance-direct.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-instance-direct.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-instance-use", + "source": "svg-group-blend-instance-use.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-instance-use.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-isolated", + "source": "svg-group-blend-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-linear-multiply", + "source": "svg-group-blend-linear-multiply.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-linear-multiply.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-linear-normal", + "source": "svg-group-blend-linear-normal.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-linear-normal.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-linear-screen", + "source": "svg-group-blend-linear-screen.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-linear-screen.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-mapped-direct", + "source": "svg-group-blend-mapped-direct.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-mapped-direct.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-mapped-viewport", + "source": "svg-group-blend-mapped-viewport.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-mapped-viewport.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-alpha", + "source": "svg-group-blend-multiply-alpha.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-alpha.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-each", + "source": "svg-group-blend-multiply-each.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-each.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-group", + "source": "svg-group-blend-multiply-group.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-group.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-leaf", + "source": "svg-group-blend-multiply-leaf.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-leaf.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-opacity", + "source": "svg-group-blend-multiply-opacity.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-opacity.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-opacity-small-opaque", + "source": "svg-group-blend-multiply-opacity-small-opaque.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-opacity-small-opaque.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-opacity-small-partial", + "source": "svg-group-blend-multiply-opacity-small-partial.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-opacity-small-partial.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-stroke", + "source": "svg-group-blend-multiply-stroke.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-stroke.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-multiply-transparent", + "source": "svg-group-blend-multiply-transparent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-multiply-transparent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-near", + "source": "svg-group-blend-near-unit-bare-near.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-bare-near.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-p998", + "source": "svg-group-blend-near-unit-bare-p998.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-bare-p998.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-p999", + "source": "svg-group-blend-near-unit-bare-p999.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-bare-p999.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-bare-unit", + "source": "svg-group-blend-near-unit-bare-unit.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-bare-unit.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-near", + "source": "svg-group-blend-near-unit-isolated-near.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-isolated-near.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-p998", + "source": "svg-group-blend-near-unit-isolated-p998.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-isolated-p998.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-p999", + "source": "svg-group-blend-near-unit-isolated-p999.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-isolated-p999.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-isolated-unit", + "source": "svg-group-blend-near-unit-isolated-unit.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-isolated-unit.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-near", + "source": "svg-group-blend-near-unit-plain-near.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-plain-near.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-p998", + "source": "svg-group-blend-near-unit-plain-p998.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-plain-p998.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-p999", + "source": "svg-group-blend-near-unit-plain-p999.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-plain-p999.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-plain-unit", + "source": "svg-group-blend-near-unit-plain-unit.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-plain-unit.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-near", + "source": "svg-group-blend-near-unit-screen-near.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-screen-near.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-p998", + "source": "svg-group-blend-near-unit-screen-p998.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-screen-p998.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-p999", + "source": "svg-group-blend-near-unit-screen-p999.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-screen-p999.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-near-unit-screen-unit", + "source": "svg-group-blend-near-unit-screen-unit.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-near-unit-screen-unit.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-nested-hidden", + "source": "svg-group-blend-nested-hidden.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-nested-hidden.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-nested-hidden-isolated", + "source": "svg-group-blend-nested-hidden-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-nested-hidden-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-neutral", + "source": "svg-group-blend-neutral.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-neutral.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-alpha", + "source": "svg-group-blend-normal-alpha.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-alpha.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-clip-isolate", + "source": "svg-group-blend-normal-clip-isolate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-clip-isolate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-clip-plain", + "source": "svg-group-blend-normal-clip-plain.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-clip-plain.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-group", + "source": "svg-group-blend-normal-group.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-group.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-leaf", + "source": "svg-group-blend-normal-leaf.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-leaf.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-many-opacity-isolate", + "source": "svg-group-blend-normal-many-opacity-isolate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-many-opacity-isolate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-many-opacity-plain", + "source": "svg-group-blend-normal-many-opacity-plain.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-many-opacity-plain.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-opacity", + "source": "svg-group-blend-normal-opacity.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-opacity.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-stroke", + "source": "svg-group-blend-normal-stroke.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-stroke.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-transform-isolate", + "source": "svg-group-blend-normal-transform-isolate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-transform-isolate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-transform-plain", + "source": "svg-group-blend-normal-transform-plain.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-transform-plain.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-transparent", + "source": "svg-group-blend-normal-transparent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-transparent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-unit-isolate", + "source": "svg-group-blend-normal-unit-isolate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-unit-isolate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-normal-unit-plain", + "source": "svg-group-blend-normal-unit-plain.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-normal-unit-plain.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity", + "source": "svg-group-blend-opacity.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-0", + "source": "svg-group-blend-opacity-0.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-0.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-1", + "source": "svg-group-blend-opacity-1.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-1.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-isolated", + "source": "svg-group-blend-opacity-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-child", + "source": "svg-group-blend-opacity-opaque-fill-child.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-child.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-default", + "source": "svg-group-blend-opacity-opaque-fill-default.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-default.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-parent", + "source": "svg-group-blend-opacity-opaque-fill-parent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-parent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fill-shape", + "source": "svg-group-blend-opacity-opaque-fill-shape.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fill-shape.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-child", + "source": "svg-group-blend-opacity-opaque-fillstroke-child.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-child.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-default", + "source": "svg-group-blend-opacity-opaque-fillstroke-default.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-default.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-parent", + "source": "svg-group-blend-opacity-opaque-fillstroke-parent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-parent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-opaque-fillstroke-shape", + "source": "svg-group-blend-opacity-opaque-fillstroke-shape.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-opaque-fillstroke-shape.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-p00392156862745098", + "source": "svg-group-blend-opacity-p00392156862745098.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-p00392156862745098.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-p5019607843137255", + "source": "svg-group-blend-opacity-p5019607843137255.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-p5019607843137255.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-opacity-p6", + "source": "svg-group-blend-opacity-p6.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-opacity-p6.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-opacity", + "source": "svg-group-blend-outer-opacity.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-outer-opacity.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-root-multiply", + "source": "svg-group-blend-outer-root-multiply.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-outer-root-multiply.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-root-normal", + "source": "svg-group-blend-outer-root-normal.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-outer-root-normal.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-outer-root-screen", + "source": "svg-group-blend-outer-root-screen.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-outer-root-screen.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-partial", + "source": "svg-group-blend-partial.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-partial.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-partial-isolated", + "source": "svg-group-blend-partial-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-partial-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-pattern-multiply", + "source": "svg-group-blend-pattern-multiply.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-pattern-multiply.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-pattern-normal", + "source": "svg-group-blend-pattern-normal.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-pattern-normal.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-pattern-screen", + "source": "svg-group-blend-pattern-screen.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-pattern-screen.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-root-isolate", + "source": "svg-group-blend-root-isolate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-root-isolate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-root-multiply", + "source": "svg-group-blend-root-multiply.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-root-multiply.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-root-normal", + "source": "svg-group-blend-root-normal.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-root-normal.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-rotate", + "source": "svg-group-blend-rotate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-rotate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-rotate-isolated", + "source": "svg-group-blend-rotate-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-rotate-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-scale", + "source": "svg-group-blend-scale.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-scale.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-scale-isolated", + "source": "svg-group-blend-scale-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-scale-isolated.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-alpha", + "source": "svg-group-blend-screen-alpha.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-alpha.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-each", + "source": "svg-group-blend-screen-each.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-each.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-group", + "source": "svg-group-blend-screen-group.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-group.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-leaf", + "source": "svg-group-blend-screen-leaf.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-leaf.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-opacity", + "source": "svg-group-blend-screen-opacity.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-opacity.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-opacity-small-opaque", + "source": "svg-group-blend-screen-opacity-small-opaque.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-opacity-small-opaque.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-opacity-small-partial", + "source": "svg-group-blend-screen-opacity-small-partial.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-opacity-small-partial.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-stroke", + "source": "svg-group-blend-screen-stroke.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-stroke.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-screen-transparent", + "source": "svg-group-blend-screen-transparent.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-screen-transparent.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-translate", + "source": "svg-group-blend-translate.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-translate.png", + "width": 64, + "height": 64 + }, + { + "id": "svg-group-blend-translate-isolated", + "source": "svg-group-blend-translate-isolated.svg", + "entry": "standalone-svg", + "oracle": "chromium/svg-group-blend-translate-isolated.png", + "width": 64, + "height": 64 + }, { "id": "svg-group-inherited-fill", "source": "svg-group-inherited-fill.svg", diff --git a/fixtures/web-first/svg-group-blend-alpha-half-child.svg b/fixtures/web-first/svg-group-blend-alpha-half-child.svg new file mode 100644 index 00000000..8e32ebd6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-half-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-half-parent.svg b/fixtures/web-first/svg-group-blend-alpha-half-parent.svg new file mode 100644 index 00000000..6db821ac --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-half-parent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-half-plain.svg b/fixtures/web-first/svg-group-blend-alpha-half-plain.svg new file mode 100644 index 00000000..7d7ef9ea --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-half-plain.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-half-shape.svg b/fixtures/web-first/svg-group-blend-alpha-half-shape.svg new file mode 100644 index 00000000..22c4d2b4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-half-shape.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-six-tenths-child.svg b/fixtures/web-first/svg-group-blend-alpha-six-tenths-child.svg new file mode 100644 index 00000000..6627b4c2 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-six-tenths-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-six-tenths-parent.svg b/fixtures/web-first/svg-group-blend-alpha-six-tenths-parent.svg new file mode 100644 index 00000000..f9cf84f9 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-six-tenths-parent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-six-tenths-plain.svg b/fixtures/web-first/svg-group-blend-alpha-six-tenths-plain.svg new file mode 100644 index 00000000..ba679b60 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-six-tenths-plain.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-alpha-six-tenths-shape.svg b/fixtures/web-first/svg-group-blend-alpha-six-tenths-shape.svg new file mode 100644 index 00000000..35bd1765 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-alpha-six-tenths-shape.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-anchor.svg b/fixtures/web-first/svg-group-blend-anchor.svg new file mode 100644 index 00000000..bd34d442 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-anchor.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-attr-multiply.svg b/fixtures/web-first/svg-group-blend-attr-multiply.svg new file mode 100644 index 00000000..6c050ba6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-attr-multiply.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-case-comment.svg b/fixtures/web-first/svg-group-blend-case-comment.svg new file mode 100644 index 00000000..57e55a19 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-case-comment.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-clip-isolated.svg b/fixtures/web-first/svg-group-blend-clip-isolated.svg new file mode 100644 index 00000000..b0b8e60e --- /dev/null +++ b/fixtures/web-first/svg-group-blend-clip-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-clip.svg b/fixtures/web-first/svg-group-blend-clip.svg new file mode 100644 index 00000000..c57a32c4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-css-invalid.svg b/fixtures/web-first/svg-group-blend-css-invalid.svg new file mode 100644 index 00000000..bff1997f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-css-invalid.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-css-keyword.svg b/fixtures/web-first/svg-group-blend-css-keyword.svg new file mode 100644 index 00000000..d6a3bc07 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-css-keyword.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-css-var.svg b/fixtures/web-first/svg-group-blend-css-var.svg new file mode 100644 index 00000000..678e371f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-css-var.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-css-winner.svg b/fixtures/web-first/svg-group-blend-css-winner.svg new file mode 100644 index 00000000..d6060874 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-css-winner.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-fractional-multiply.svg b/fixtures/web-first/svg-group-blend-fractional-multiply.svg new file mode 100644 index 00000000..25e56114 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-fractional-multiply.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-fractional-normal.svg b/fixtures/web-first/svg-group-blend-fractional-normal.svg new file mode 100644 index 00000000..f1170af9 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-fractional-normal.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-fractional-screen.svg b/fixtures/web-first/svg-group-blend-fractional-screen.svg new file mode 100644 index 00000000..d1cee58f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-fractional-screen.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-group-attr-isolate.svg b/fixtures/web-first/svg-group-blend-group-attr-isolate.svg new file mode 100644 index 00000000..b9e80d36 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-group-attr-isolate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-instance-direct.svg b/fixtures/web-first/svg-group-blend-instance-direct.svg new file mode 100644 index 00000000..16a8218d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-instance-direct.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-instance-use.svg b/fixtures/web-first/svg-group-blend-instance-use.svg new file mode 100644 index 00000000..cca8a1ba --- /dev/null +++ b/fixtures/web-first/svg-group-blend-instance-use.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-isolated.svg b/fixtures/web-first/svg-group-blend-isolated.svg new file mode 100644 index 00000000..2664527b --- /dev/null +++ b/fixtures/web-first/svg-group-blend-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-linear-multiply.svg b/fixtures/web-first/svg-group-blend-linear-multiply.svg new file mode 100644 index 00000000..e9b9c18d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-linear-multiply.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-linear-normal.svg b/fixtures/web-first/svg-group-blend-linear-normal.svg new file mode 100644 index 00000000..3a55ca82 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-linear-normal.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-linear-screen.svg b/fixtures/web-first/svg-group-blend-linear-screen.svg new file mode 100644 index 00000000..22d720fb --- /dev/null +++ b/fixtures/web-first/svg-group-blend-linear-screen.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-mapped-direct.svg b/fixtures/web-first/svg-group-blend-mapped-direct.svg new file mode 100644 index 00000000..a18c20ad --- /dev/null +++ b/fixtures/web-first/svg-group-blend-mapped-direct.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-mapped-viewport.svg b/fixtures/web-first/svg-group-blend-mapped-viewport.svg new file mode 100644 index 00000000..1c340b93 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-mapped-viewport.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-alpha.svg b/fixtures/web-first/svg-group-blend-multiply-alpha.svg new file mode 100644 index 00000000..5d31cea7 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-alpha.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-each.svg b/fixtures/web-first/svg-group-blend-multiply-each.svg new file mode 100644 index 00000000..210c2c78 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-each.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-group.svg b/fixtures/web-first/svg-group-blend-multiply-group.svg new file mode 100644 index 00000000..ef9a7d09 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-group.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-leaf.svg b/fixtures/web-first/svg-group-blend-multiply-leaf.svg new file mode 100644 index 00000000..83d4dac1 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-leaf.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-opacity-small-opaque.svg b/fixtures/web-first/svg-group-blend-multiply-opacity-small-opaque.svg new file mode 100644 index 00000000..751dc867 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-opacity-small-opaque.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-opacity-small-partial.svg b/fixtures/web-first/svg-group-blend-multiply-opacity-small-partial.svg new file mode 100644 index 00000000..a2a207dd --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-opacity-small-partial.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-opacity.svg b/fixtures/web-first/svg-group-blend-multiply-opacity.svg new file mode 100644 index 00000000..3589aa2f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-stroke.svg b/fixtures/web-first/svg-group-blend-multiply-stroke.svg new file mode 100644 index 00000000..d2edc387 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-multiply-transparent.svg b/fixtures/web-first/svg-group-blend-multiply-transparent.svg new file mode 100644 index 00000000..f2df812d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-multiply-transparent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-bare-near.svg b/fixtures/web-first/svg-group-blend-near-unit-bare-near.svg new file mode 100644 index 00000000..16720c7d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-bare-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-bare-p998.svg b/fixtures/web-first/svg-group-blend-near-unit-bare-p998.svg new file mode 100644 index 00000000..c9a884db --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-bare-p998.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-bare-p999.svg b/fixtures/web-first/svg-group-blend-near-unit-bare-p999.svg new file mode 100644 index 00000000..7e55c9d2 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-bare-p999.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-bare-unit.svg b/fixtures/web-first/svg-group-blend-near-unit-bare-unit.svg new file mode 100644 index 00000000..27b1997e --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-bare-unit.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-isolated-near.svg b/fixtures/web-first/svg-group-blend-near-unit-isolated-near.svg new file mode 100644 index 00000000..f95b20ed --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-isolated-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-isolated-p998.svg b/fixtures/web-first/svg-group-blend-near-unit-isolated-p998.svg new file mode 100644 index 00000000..5e60b97b --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-isolated-p998.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-isolated-p999.svg b/fixtures/web-first/svg-group-blend-near-unit-isolated-p999.svg new file mode 100644 index 00000000..9a089017 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-isolated-p999.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-isolated-unit.svg b/fixtures/web-first/svg-group-blend-near-unit-isolated-unit.svg new file mode 100644 index 00000000..87099886 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-isolated-unit.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-plain-near.svg b/fixtures/web-first/svg-group-blend-near-unit-plain-near.svg new file mode 100644 index 00000000..41d9daa0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-plain-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-plain-p998.svg b/fixtures/web-first/svg-group-blend-near-unit-plain-p998.svg new file mode 100644 index 00000000..39b7b0bb --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-plain-p998.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-plain-p999.svg b/fixtures/web-first/svg-group-blend-near-unit-plain-p999.svg new file mode 100644 index 00000000..ff7736e1 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-plain-p999.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-plain-unit.svg b/fixtures/web-first/svg-group-blend-near-unit-plain-unit.svg new file mode 100644 index 00000000..3a2c917f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-plain-unit.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-screen-near.svg b/fixtures/web-first/svg-group-blend-near-unit-screen-near.svg new file mode 100644 index 00000000..6e6ab103 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-screen-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-screen-p998.svg b/fixtures/web-first/svg-group-blend-near-unit-screen-p998.svg new file mode 100644 index 00000000..2cca0943 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-screen-p998.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-screen-p999.svg b/fixtures/web-first/svg-group-blend-near-unit-screen-p999.svg new file mode 100644 index 00000000..f9924099 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-screen-p999.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-near-unit-screen-unit.svg b/fixtures/web-first/svg-group-blend-near-unit-screen-unit.svg new file mode 100644 index 00000000..dc8d0f9f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-near-unit-screen-unit.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-nested-hidden-isolated.svg b/fixtures/web-first/svg-group-blend-nested-hidden-isolated.svg new file mode 100644 index 00000000..53eb21f8 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-nested-hidden-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-nested-hidden.svg b/fixtures/web-first/svg-group-blend-nested-hidden.svg new file mode 100644 index 00000000..36d1ffb6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-nested-hidden.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-neutral.svg b/fixtures/web-first/svg-group-blend-neutral.svg new file mode 100644 index 00000000..783e7f5d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-neutral.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-alpha.svg b/fixtures/web-first/svg-group-blend-normal-alpha.svg new file mode 100644 index 00000000..f435c294 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-alpha.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-clip-isolate.svg b/fixtures/web-first/svg-group-blend-normal-clip-isolate.svg new file mode 100644 index 00000000..429c8174 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-clip-isolate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-clip-plain.svg b/fixtures/web-first/svg-group-blend-normal-clip-plain.svg new file mode 100644 index 00000000..b14ca445 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-clip-plain.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-group.svg b/fixtures/web-first/svg-group-blend-normal-group.svg new file mode 100644 index 00000000..9c7cebeb --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-group.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-leaf.svg b/fixtures/web-first/svg-group-blend-normal-leaf.svg new file mode 100644 index 00000000..e7e391ed --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-leaf.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-many-opacity-isolate.svg b/fixtures/web-first/svg-group-blend-normal-many-opacity-isolate.svg new file mode 100644 index 00000000..deb1b4be --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-many-opacity-isolate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-many-opacity-plain.svg b/fixtures/web-first/svg-group-blend-normal-many-opacity-plain.svg new file mode 100644 index 00000000..451bd549 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-many-opacity-plain.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-opacity.svg b/fixtures/web-first/svg-group-blend-normal-opacity.svg new file mode 100644 index 00000000..33ad8fc4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-stroke.svg b/fixtures/web-first/svg-group-blend-normal-stroke.svg new file mode 100644 index 00000000..dcc4c0da --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-transform-isolate.svg b/fixtures/web-first/svg-group-blend-normal-transform-isolate.svg new file mode 100644 index 00000000..cbaa5df5 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-transform-isolate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-transform-plain.svg b/fixtures/web-first/svg-group-blend-normal-transform-plain.svg new file mode 100644 index 00000000..e24705c3 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-transform-plain.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-transparent.svg b/fixtures/web-first/svg-group-blend-normal-transparent.svg new file mode 100644 index 00000000..f1583276 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-transparent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-unit-isolate.svg b/fixtures/web-first/svg-group-blend-normal-unit-isolate.svg new file mode 100644 index 00000000..426187b2 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-unit-isolate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-normal-unit-plain.svg b/fixtures/web-first/svg-group-blend-normal-unit-plain.svg new file mode 100644 index 00000000..31d24219 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-normal-unit-plain.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-0.svg b/fixtures/web-first/svg-group-blend-opacity-0.svg new file mode 100644 index 00000000..21474ab6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-0.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-1.svg b/fixtures/web-first/svg-group-blend-opacity-1.svg new file mode 100644 index 00000000..8f41fb34 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-1.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-isolated.svg b/fixtures/web-first/svg-group-blend-opacity-isolated.svg new file mode 100644 index 00000000..28add75d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fill-child.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-child.svg new file mode 100644 index 00000000..0bafaae0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fill-default.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-default.svg new file mode 100644 index 00000000..9aac6a38 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-default.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fill-parent.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-parent.svg new file mode 100644 index 00000000..9e21be3e --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-parent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fill-shape.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-shape.svg new file mode 100644 index 00000000..b1923fd8 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fill-shape.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-child.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-child.svg new file mode 100644 index 00000000..d6d1d923 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-default.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-default.svg new file mode 100644 index 00000000..f904091d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-default.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-parent.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-parent.svg new file mode 100644 index 00000000..4a3607cd --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-parent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-shape.svg b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-shape.svg new file mode 100644 index 00000000..60fa5144 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-opaque-fillstroke-shape.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-p00392156862745098.svg b/fixtures/web-first/svg-group-blend-opacity-p00392156862745098.svg new file mode 100644 index 00000000..adacacbb --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-p00392156862745098.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-p5019607843137255.svg b/fixtures/web-first/svg-group-blend-opacity-p5019607843137255.svg new file mode 100644 index 00000000..b9128328 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-p5019607843137255.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity-p6.svg b/fixtures/web-first/svg-group-blend-opacity-p6.svg new file mode 100644 index 00000000..05e4637e --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity-p6.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-opacity.svg b/fixtures/web-first/svg-group-blend-opacity.svg new file mode 100644 index 00000000..7e21c337 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-outer-opacity.svg b/fixtures/web-first/svg-group-blend-outer-opacity.svg new file mode 100644 index 00000000..917a30ba --- /dev/null +++ b/fixtures/web-first/svg-group-blend-outer-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-outer-root-multiply.svg b/fixtures/web-first/svg-group-blend-outer-root-multiply.svg new file mode 100644 index 00000000..7441b044 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-outer-root-multiply.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-outer-root-normal.svg b/fixtures/web-first/svg-group-blend-outer-root-normal.svg new file mode 100644 index 00000000..372d77f2 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-outer-root-normal.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-outer-root-screen.svg b/fixtures/web-first/svg-group-blend-outer-root-screen.svg new file mode 100644 index 00000000..cc36fdfe --- /dev/null +++ b/fixtures/web-first/svg-group-blend-outer-root-screen.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-partial-isolated.svg b/fixtures/web-first/svg-group-blend-partial-isolated.svg new file mode 100644 index 00000000..15a7df54 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-partial-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-partial.svg b/fixtures/web-first/svg-group-blend-partial.svg new file mode 100644 index 00000000..0194e338 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-partial.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-pattern-multiply.svg b/fixtures/web-first/svg-group-blend-pattern-multiply.svg new file mode 100644 index 00000000..68ddf6a5 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-pattern-multiply.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-pattern-normal.svg b/fixtures/web-first/svg-group-blend-pattern-normal.svg new file mode 100644 index 00000000..33796317 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-pattern-normal.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-pattern-screen.svg b/fixtures/web-first/svg-group-blend-pattern-screen.svg new file mode 100644 index 00000000..89093969 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-pattern-screen.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-root-isolate.svg b/fixtures/web-first/svg-group-blend-root-isolate.svg new file mode 100644 index 00000000..0ba0be3b --- /dev/null +++ b/fixtures/web-first/svg-group-blend-root-isolate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-root-multiply.svg b/fixtures/web-first/svg-group-blend-root-multiply.svg new file mode 100644 index 00000000..bfd0b62e --- /dev/null +++ b/fixtures/web-first/svg-group-blend-root-multiply.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-root-normal.svg b/fixtures/web-first/svg-group-blend-root-normal.svg new file mode 100644 index 00000000..bb579fb4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-root-normal.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-rotate-isolated.svg b/fixtures/web-first/svg-group-blend-rotate-isolated.svg new file mode 100644 index 00000000..5d6709df --- /dev/null +++ b/fixtures/web-first/svg-group-blend-rotate-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-rotate.svg b/fixtures/web-first/svg-group-blend-rotate.svg new file mode 100644 index 00000000..b9fd0ce2 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-rotate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-scale-isolated.svg b/fixtures/web-first/svg-group-blend-scale-isolated.svg new file mode 100644 index 00000000..28259665 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-scale-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-scale.svg b/fixtures/web-first/svg-group-blend-scale.svg new file mode 100644 index 00000000..92cd6c2c --- /dev/null +++ b/fixtures/web-first/svg-group-blend-scale.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-alpha.svg b/fixtures/web-first/svg-group-blend-screen-alpha.svg new file mode 100644 index 00000000..d8d29e06 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-alpha.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-each.svg b/fixtures/web-first/svg-group-blend-screen-each.svg new file mode 100644 index 00000000..915dd61d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-each.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-group.svg b/fixtures/web-first/svg-group-blend-screen-group.svg new file mode 100644 index 00000000..630c4229 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-group.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-leaf.svg b/fixtures/web-first/svg-group-blend-screen-leaf.svg new file mode 100644 index 00000000..81d9b88d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-leaf.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-opacity-small-opaque.svg b/fixtures/web-first/svg-group-blend-screen-opacity-small-opaque.svg new file mode 100644 index 00000000..9a89a2d5 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-opacity-small-opaque.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-opacity-small-partial.svg b/fixtures/web-first/svg-group-blend-screen-opacity-small-partial.svg new file mode 100644 index 00000000..6bbf84b3 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-opacity-small-partial.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-opacity.svg b/fixtures/web-first/svg-group-blend-screen-opacity.svg new file mode 100644 index 00000000..d483a832 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-stroke.svg b/fixtures/web-first/svg-group-blend-screen-stroke.svg new file mode 100644 index 00000000..6ee5db05 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-screen-transparent.svg b/fixtures/web-first/svg-group-blend-screen-transparent.svg new file mode 100644 index 00000000..bc61987f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-screen-transparent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-translate-isolated.svg b/fixtures/web-first/svg-group-blend-translate-isolated.svg new file mode 100644 index 00000000..0022c805 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-translate-isolated.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-translate.svg b/fixtures/web-first/svg-group-blend-translate.svg new file mode 100644 index 00000000..000a9ff4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-translate.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/README.md b/fixtures/web-first/unsupported/README.md index 220ac448..45b846cd 100644 --- a/fixtures/web-first/unsupported/README.md +++ b/fixtures/web-first/unsupported/README.md @@ -13,6 +13,9 @@ best-effort as well, and the attributable ones are declared by name at a structural path. What that gate defends is the invariant, stated over a whole directory — *nothing here renders silently*. Individual constructs are pinned a second time, from inline sources, by the contract law that owns each rung. +The source extension selects the actual ingress: `.svg` uses the standalone +entry and `.html` the inline-SVG entry. Exactly one source must exist per id; +the refusal gate and generated status share that dispatch. The scannable, generated view of this register (beside the baked cells) is [../STATUS.md](../STATUS.md), freshness-gated by @@ -20,6 +23,17 @@ The scannable, generated view of this register (beside the baked cells) is | File | Required result | | --- | --- | +| `svg-group-blend-mode-{overlay,darken,lighten,color-dodge,color-burn,hard-light,soft-light,difference,exclusion,hue,saturation,color,luminosity,plus-lighter}.svg` | Fourteen live computed values outside B1's normal/multiply/screen profile must name `mix-blend-mode`; best-effort skips the attributed group, never substitutes normal. | +| `svg-group-blend-{filter,mask}.svg` | Name the unadmitted image-effect composition profile and skip the complete affected group transaction. | +| `svg-group-blend-elided-{filter,mask}-{unit,partial}.svg` | Eliding redundant Normal isolation must retain its authored participation for an ancestor filter/mask patrol, without inventing a physical layer or blocking the established opacity fold. These four inputs keep the conservative image-effect refusal; they are not claims of measured pixel defects. | +| `svg-group-blend-resource-{pattern,mask,clippath}-{root,child}.svg` | Six resource-source ingress guards; a generic walk must not accidentally admit resource roots or geometric clip contributors. The failure propagates to the consuming client by name. | +| `svg-group-blend-{css-animation,css-var-animation}.svg` · `html-group-blend-head-animation.html` | Name the animated group-composition profile in both admissions, including indirect custom-property animation and an HTML-head stylesheet. | +| `svg-group-blend-pattern-root-animation.svg` | Preserve the existing document-load animation finding when a referenced pattern root would otherwise disappear from the best-effort source walk. | +| `html-group-blend-{unisolated,ancestor-mix-blend-mode,ancestor-isolation}.html` | Refuse in both admissions: the compiled SVG contribution has no exterior HTML backdrop/layer graph to honor these cases. | +| `svg-group-blend-source-{path,ellipse,clip,isolation}.svg` | Name the group-source precision boundary exposed by non-rectangular source or coverage materialization; best-effort skips the affected group, not its first offending draw alone. | +| `svg-group-blend-source-{radial,stroke,subpixel-clip,rotated-clip}.svg` | Conservative extension guards for radial paints, wider source strokes and unproved clip coverage. These are refusal witnesses, not claims that every member has a measured pixel mismatch. | +| `svg-group-blend-root-opacity.svg` | Refuse the partial-opacity root-blend precision class in both admissions; no root-level fallback can preserve its promised meaning. | +| `svg-group-blend-root-filter-sibling.svg` | Refuse in both admissions when an escaping blend requires a new standalone-root isolation layer around a separate filtered sibling. An implicit root boundary must not bypass the image-effect composition profile. | | `svg-context-paint-fallback-extension.svg` | Refuse Stylo's non-standard context-paint fallback extension by name. SVG2 permits a fallback only after a URL, and Chromium drops `context-fill red` as an invalid paint (measured); the pinned parser accepts it. The standard-track grammar remains the bar under gridaco/nothing#77, so this registered over-refusal cannot hold the four `fill`/`stroke` rows open. Attribute, inline-style, and stylesheet ingresses are guarded. | | `svg-viewbox-invalid-token.svg` | Reject the malformed `viewBox`; do not discard the bad token. | | `svg-viewbox-repeated-comma.svg` | Reject a repeated comma in the `viewBox` number list; do not filter empty separators. | diff --git a/fixtures/web-first/unsupported/html-group-blend-ancestor-isolation.html b/fixtures/web-first/unsupported/html-group-blend-ancestor-isolation.html new file mode 100644 index 00000000..2ba09a75 --- /dev/null +++ b/fixtures/web-first/unsupported/html-group-blend-ancestor-isolation.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/html-group-blend-ancestor-mix-blend-mode.html b/fixtures/web-first/unsupported/html-group-blend-ancestor-mix-blend-mode.html new file mode 100644 index 00000000..f59dedb5 --- /dev/null +++ b/fixtures/web-first/unsupported/html-group-blend-ancestor-mix-blend-mode.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/html-group-blend-head-animation.html b/fixtures/web-first/unsupported/html-group-blend-head-animation.html new file mode 100644 index 00000000..c9b34c5a --- /dev/null +++ b/fixtures/web-first/unsupported/html-group-blend-head-animation.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/html-group-blend-unisolated.html b/fixtures/web-first/unsupported/html-group-blend-unisolated.html new file mode 100644 index 00000000..9ecb30ab --- /dev/null +++ b/fixtures/web-first/unsupported/html-group-blend-unisolated.html @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-css-animation.svg b/fixtures/web-first/unsupported/svg-group-blend-css-animation.svg new file mode 100644 index 00000000..27ea899e --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-css-animation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-css-var-animation.svg b/fixtures/web-first/unsupported/svg-group-blend-css-var-animation.svg new file mode 100644 index 00000000..16e49ce9 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-css-var-animation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-elided-filter-partial.svg b/fixtures/web-first/unsupported/svg-group-blend-elided-filter-partial.svg new file mode 100644 index 00000000..61615b4b --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-elided-filter-partial.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-elided-filter-unit.svg b/fixtures/web-first/unsupported/svg-group-blend-elided-filter-unit.svg new file mode 100644 index 00000000..7a2bcbe0 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-elided-filter-unit.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-elided-mask-partial.svg b/fixtures/web-first/unsupported/svg-group-blend-elided-mask-partial.svg new file mode 100644 index 00000000..c4973654 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-elided-mask-partial.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-elided-mask-unit.svg b/fixtures/web-first/unsupported/svg-group-blend-elided-mask-unit.svg new file mode 100644 index 00000000..a0d45d6c --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-elided-mask-unit.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-filter.svg b/fixtures/web-first/unsupported/svg-group-blend-filter.svg new file mode 100644 index 00000000..6d589f57 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-filter.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mask.svg b/fixtures/web-first/unsupported/svg-group-blend-mask.svg new file mode 100644 index 00000000..b0e8983a --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mask.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-color-burn.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-color-burn.svg new file mode 100644 index 00000000..be70a8be --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-color-burn.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-color-dodge.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-color-dodge.svg new file mode 100644 index 00000000..c30089d7 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-color-dodge.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-color.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-color.svg new file mode 100644 index 00000000..ccaf6b9e --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-color.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-darken.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-darken.svg new file mode 100644 index 00000000..1a29e0bb --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-darken.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-difference.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-difference.svg new file mode 100644 index 00000000..9442bfa1 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-difference.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-exclusion.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-exclusion.svg new file mode 100644 index 00000000..40ce0b15 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-exclusion.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-hard-light.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-hard-light.svg new file mode 100644 index 00000000..f6538dc2 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-hard-light.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-hue.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-hue.svg new file mode 100644 index 00000000..2547a154 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-hue.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-lighten.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-lighten.svg new file mode 100644 index 00000000..97436658 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-lighten.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-luminosity.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-luminosity.svg new file mode 100644 index 00000000..58621a3c --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-luminosity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-overlay.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-overlay.svg new file mode 100644 index 00000000..35c7e526 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-overlay.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-plus-lighter.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-plus-lighter.svg new file mode 100644 index 00000000..9c9ae9ad --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-plus-lighter.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-saturation.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-saturation.svg new file mode 100644 index 00000000..c682ccb4 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-saturation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-mode-soft-light.svg b/fixtures/web-first/unsupported/svg-group-blend-mode-soft-light.svg new file mode 100644 index 00000000..b8da8d3f --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-mode-soft-light.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-pattern-root-animation.svg b/fixtures/web-first/unsupported/svg-group-blend-pattern-root-animation.svg new file mode 100644 index 00000000..5be3792c --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-pattern-root-animation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-resource-clippath-child.svg b/fixtures/web-first/unsupported/svg-group-blend-resource-clippath-child.svg new file mode 100644 index 00000000..24027f5c --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-resource-clippath-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-resource-clippath-root.svg b/fixtures/web-first/unsupported/svg-group-blend-resource-clippath-root.svg new file mode 100644 index 00000000..94c20e99 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-resource-clippath-root.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-resource-mask-child.svg b/fixtures/web-first/unsupported/svg-group-blend-resource-mask-child.svg new file mode 100644 index 00000000..a7b24b87 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-resource-mask-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-resource-mask-root.svg b/fixtures/web-first/unsupported/svg-group-blend-resource-mask-root.svg new file mode 100644 index 00000000..7cf8a8d6 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-resource-mask-root.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-resource-pattern-child.svg b/fixtures/web-first/unsupported/svg-group-blend-resource-pattern-child.svg new file mode 100644 index 00000000..90ce4133 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-resource-pattern-child.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-resource-pattern-root.svg b/fixtures/web-first/unsupported/svg-group-blend-resource-pattern-root.svg new file mode 100644 index 00000000..193bb438 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-resource-pattern-root.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-root-filter-sibling.svg b/fixtures/web-first/unsupported/svg-group-blend-root-filter-sibling.svg new file mode 100644 index 00000000..500f9371 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-root-filter-sibling.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-root-opacity.svg b/fixtures/web-first/unsupported/svg-group-blend-root-opacity.svg new file mode 100644 index 00000000..36b3a7d1 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-root-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-clip.svg b/fixtures/web-first/unsupported/svg-group-blend-source-clip.svg new file mode 100644 index 00000000..52a3df34 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-ellipse.svg b/fixtures/web-first/unsupported/svg-group-blend-source-ellipse.svg new file mode 100644 index 00000000..b87fe68f --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-ellipse.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-isolation.svg b/fixtures/web-first/unsupported/svg-group-blend-source-isolation.svg new file mode 100644 index 00000000..d31c01ec --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-isolation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-path.svg b/fixtures/web-first/unsupported/svg-group-blend-source-path.svg new file mode 100644 index 00000000..77137799 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-path.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-radial.svg b/fixtures/web-first/unsupported/svg-group-blend-source-radial.svg new file mode 100644 index 00000000..a63391f4 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-radial.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-rotated-clip.svg b/fixtures/web-first/unsupported/svg-group-blend-source-rotated-clip.svg new file mode 100644 index 00000000..9b5697a6 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-rotated-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-stroke.svg b/fixtures/web-first/unsupported/svg-group-blend-source-stroke.svg new file mode 100644 index 00000000..c0174124 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-subpixel-clip.svg b/fixtures/web-first/unsupported/svg-group-blend-source-subpixel-clip.svg new file mode 100644 index 00000000..9b3edbc1 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-subpixel-clip.svg @@ -0,0 +1 @@ +