From 649d530a6fb4414e9d27ee66468a240942071440 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 8 Sep 2026 17:25:40 +0900 Subject: [PATCH 1/2] rframe: add checked complete blend source domains --- crates/rframe/README.md | 37 ++ crates/rframe/src/lib.rs | 5 +- crates/rframe/src/scope.rs | 162 +++++++- crates/rframe/tests/blend_source_domain.rs | 445 +++++++++++++++++++++ 4 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 crates/rframe/tests/blend_source_domain.rs diff --git a/crates/rframe/README.md b/crates/rframe/README.md index e9429569..42cd8228 100644 --- a/crates/rframe/README.md +++ b/crates/rframe/README.md @@ -53,6 +53,43 @@ 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. +`BlendSourceDomain::new(rect, source_to_stream)` checks one strictly positive, +already-enclosed source-local rectangle and its map into the containing item +stream. `ScopeBlend::with_source_domain(domain)` attaches this complete source +declaration to the existing boundary. `source_domain()` returns it; the ordinary +`ScopeBlend::new` leaves it absent, making no completeness assertion. + +The source materializes against transparent black over the declared domain +before that boundary's final opacity/blend. Contribution discovery and local +enclosure have already happened. A consumer must preserve that domain rather +than reconstruct it from surviving paint, conservatively enlarge it, or enclose +contributors after mapping. Its transparent margins can distinguish sources +with identical painted nodes. `rect()` and `source_to_stream()` preserve the +supplied facts exactly, including when different declarations have the same +final enclosing box. The map targets frame space in a frame and tile-local +coordinates in a repeating program. It never becomes an inherited transform +for the enclosed nodes. Nested declarations stay with their existing boundary +and completed source; the checked stream remains the only composition order. + +This declaration supplies no paint, changes no geometry or gradient paint box, +and introduces no clip. A hard clip specifies coverage and cannot substitute +for a complete source domain. Output clipping remains separate. The declaration +carries no host view, device grid, layer allocation, or cache policy; current-view +mapping and device enclosure remain execution work. A consumer must refuse a +declaration it cannot honor rather than silently ignore it. Domain equality +does not remove the enclosing backdrop dependency. + +Construction proves numerical usability, not producer completeness: the local +rectangle must have finite members, positive extents, and finite, strictly +ordered endpoints in `f32`. The map must have a finite determinant and a +supported finite affine inverse, and +the mapped corners and their enclosing box must remain finite and positive. +The existing affine inverse's small-determinant refusal applies. No integer +coordinate rule or enclosure algorithm is imposed on the source's unit system. +Empty domains are refused; a domain never makes an empty blend scope meaningful. +Producer-only laws, including independent illustration construction, live in +[`tests/blend_source_domain.rs`](tests/blend_source_domain.rs). + `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 diff --git a/crates/rframe/src/lib.rs b/crates/rframe/src/lib.rs index b3cb46ca..6161eee0 100644 --- a/crates/rframe/src/lib.rs +++ b/crates/rframe/src/lib.rs @@ -37,7 +37,10 @@ pub use frame::{ }; pub use mask::{Mask, MaskMode}; pub use path::{FillRule, PathCommand, PathData, PathDataError}; -pub use scope::{Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, ScopeOpacity, ScopeOpacityError}; +pub use scope::{ + BlendSourceDomain, BlendSourceDomainError, 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 df8813d2..ee70fa87 100644 --- a/crates/rframe/src/scope.rs +++ b/crates/rframe/src/scope.rs @@ -20,6 +20,8 @@ use crate::clip::ClipPath; use crate::filter::Filter; use crate::frame::VisualRef; +use math2::Rectangle; +use math2::transform::AffineTransform; /// Why an opacity cannot be a scope fact. /// @@ -79,6 +81,140 @@ pub enum ScopeBlendMode { Screen, } +/// Why a complete blend-source domain cannot cross the resolved contract. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlendSourceDomainError { + /// The local rectangle is non-finite, empty, or has unrepresentable endpoints. + InvalidRectangle, + /// The map is non-finite or has no supported finite inverse. + InvalidTransform, + /// Mapped corners or their enclosing rectangle are non-finite or collapse. + InvalidMappedBounds, +} + +impl std::fmt::Display for BlendSourceDomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidRectangle => { + "a blend-source domain must have finite local bounds with positive extents and ordered endpoints" + } + Self::InvalidTransform => { + "a blend-source domain map must have finite members and a supported finite inverse" + } + Self::InvalidMappedBounds => { + "a blend-source domain must map to finite, strictly positive bounds" + } + }) + } +} + +impl std::error::Error for BlendSourceDomainError {} + +/// The complete, already-enclosed local domain of one isolated blend source. +/// +/// The producer has finished discovering contributions and enclosing them in +/// source-local coordinates. Materialize the isolated source over this domain +/// against transparent black, then apply the owning [`ScopeBlend`]'s final +/// opacity and blend. The domain includes the producer's resolved non-painted +/// extent contributions; it is neither a tight geometry box nor a supplemental +/// margin. Reconstructing it from visible paints, enlarging it conservatively, +/// or enclosing contributors after mapping states a different source. +/// +/// `source_to_stream` maps the already-enclosed rectangle into its containing +/// [`crate::FrameItems`] coordinates: frame space for a frame, tile-local space +/// for a repeating program. Child node transforms keep their existing meaning; +/// this mapping is not an inherited transform. Each nested blend boundary owns +/// its own declaration and completed source; it does not donate its descendants +/// as fresh geometry to an enclosing boundary. +/// +/// This fact supplies no paint and introduces no geometric clip. Output clipping +/// stays a separate operation. It carries no host view, device grid, allocation, +/// or raster policy. Current-view mapping and device enclosure remain execution +/// work. A consumer unable to honor a declaration must refuse it, not ignore it +/// or replace its map with identity. +/// +/// Construction checks numerical usability only. It neither performs enclosure +/// nor proves that the producer's declaration is complete. There is no integer +/// coordinate requirement: the producer has resolved the enclosure in its own +/// source space, whose unit need not be a device pixel. Empty domains are not +/// admitted; absence is represented by [`ScopeBlend::source_domain`] returning +/// `None`, which makes no completeness assertion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlendSourceDomain { + rect: Rectangle, + source_to_stream: AffineTransform, +} + +impl BlendSourceDomain { + /// Check an already-enclosed rectangle and retain both supplied facts exactly. + /// + /// Local endpoints must be finite and strictly ordered in `f32`. The map + /// must have a finite determinant and an inverse supported by + /// [`AffineTransform::inverse`] (including its small-determinant refusal); + /// every inverse member must also be finite. Mapped corners and their + /// enclosing rectangle must remain finite and strictly positive in `f32`. + /// These checks make no promise about a later host view. + pub fn new( + rect: Rectangle, + source_to_stream: AffineTransform, + ) -> Result { + if !valid_domain_rectangle(rect) { + return Err(BlendSourceDomainError::InvalidRectangle); + } + let [[a, c, _], [b, d, _]] = source_to_stream.matrix; + if !source_to_stream + .matrix + .into_iter() + .flatten() + .all(f32::is_finite) + || !(a * d - b * c).is_finite() + || !source_to_stream + .inverse() + .is_some_and(|inverse| inverse.matrix.into_iter().flatten().all(f32::is_finite)) + { + return Err(BlendSourceDomainError::InvalidTransform); + } + let corners = rect + .corners() + .map(|point| math2::vector2::transform(point, &source_to_stream)); + // Check every corner before bounding: min/max can otherwise hide NaN. + if !corners.into_iter().flatten().all(f32::is_finite) + || !valid_domain_rectangle(Rectangle::from_points(&corners)) + { + return Err(BlendSourceDomainError::InvalidMappedBounds); + } + Ok(Self { + rect, + source_to_stream, + }) + } + + /// The complete already-enclosed rectangle in source-local coordinates. + #[must_use] + pub const fn rect(self) -> Rectangle { + self.rect + } + + /// The exact source-local to containing-stream map, without a host view. + #[must_use] + pub const fn source_to_stream(self) -> AffineTransform { + self.source_to_stream + } +} + +fn valid_domain_rectangle(rect: Rectangle) -> bool { + rect.x.is_finite() + && rect.y.is_finite() + && rect.width.is_finite() + && rect.height.is_finite() + && rect.width > 0.0 + && rect.height > 0.0 + && (rect.x + rect.width).is_finite() + && (rect.y + rect.height).is_finite() + && rect.x + rect.width > rect.x + && rect.y + rect.height > rect.y +} + /// One isolated group's combined final blend and optional opacity. /// /// Children paint in order against transparent black. Their completed @@ -102,6 +238,12 @@ pub enum ScopeBlendMode { /// This names visual meaning, never a layer allocation, backdrop copy, cache /// policy, or authored group. /// +/// An optional [`BlendSourceDomain`] states the complete source domain before +/// this final operation. Absence preserves the existing group meaning without +/// asserting completeness. A domain neither creates another scope nor makes an +/// empty group meaningful, and equality of domains does not erase the backdrop +/// dependency. +/// /// ``` /// use rframe::{ScopeBlend, ScopeBlendMode, ScopeOpacity}; /// @@ -118,6 +260,7 @@ pub enum ScopeBlendMode { pub struct ScopeBlend { mode: ScopeBlendMode, opacity: Option, + source_domain: Option, } impl ScopeBlend { @@ -125,7 +268,24 @@ impl ScopeBlend { /// `None` means opacity 1; it never means absence of isolation. #[must_use] pub const fn new(mode: ScopeBlendMode, opacity: Option) -> Self { - Self { mode, opacity } + Self { + mode, + opacity, + source_domain: None, + } + } + + /// Declare the complete source domain without changing the final operation. + #[must_use] + pub const fn with_source_domain(mut self, domain: BlendSourceDomain) -> Self { + self.source_domain = Some(domain); + self + } + + /// The declared complete source domain, or no completeness assertion. + #[must_use] + pub const fn source_domain(self) -> Option { + self.source_domain } /// The blend function used only when the completed group joins its backdrop. diff --git a/crates/rframe/tests/blend_source_domain.rs b/crates/rframe/tests/blend_source_domain.rs new file mode 100644 index 00000000..4d1d159e --- /dev/null +++ b/crates/rframe/tests/blend_source_domain.rs @@ -0,0 +1,445 @@ +//! Producer-only laws for complete, already-enclosed blend-source domains. +//! +//! An illustration producer states paint and source enclosure independently. +//! These tests establish representation and refusal laws, not raster results. + +use std::sync::Arc; + +use cg::{CGColor, GradientStop, LinearGradientPaint, Paint, Paints}; +use math2::Rectangle; +use math2::transform::AffineTransform; +use rframe::{ + BlendSourceDomain, BlendSourceDomainError, ClipEdgeMode, ClipGeometry, ClipLayer, ClipPath, + Frame, FrameItem, FrameItems, FrameItemsError, FrameNode, Geometry, Identity, + MAX_PATTERN_DEPTH, MAX_SCOPE_DEPTH, Mask, MaskMode, PaintAlphaFactor, PaintStack, PatternPaint, + PatternPaintError, Provenance, Scope, ScopeBlend, ScopeBlendMode, ScopeEffect, ScopeOpacity, + Stroke, StrokeCap, StrokeJoin, VisualRef, +}; + +fn owner(id: u64) -> VisualRef { + VisualRef::new(Identity::new(id), Provenance::new(id + 100)) +} + +fn rect() -> Rectangle { + Rectangle::from_xywh(4.0, 6.0, 12.0, 8.0) +} + +fn domain() -> BlendSourceDomain { + BlendSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 24.0, 24.0), + AffineTransform::identity(), + ) + .unwrap() +} + +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, source: BlendSourceDomain) -> FrameItem { + FrameItem::ScopeBegin(Scope { + owner: owner(id), + effect: ScopeEffect::Blend( + ScopeBlend::new(ScopeBlendMode::Normal, None).with_source_domain(source), + ), + }) +} + +fn illustration(source: Option) -> Frame { + let mut blend = ScopeBlend::new( + ScopeBlendMode::Screen, + Some(ScopeOpacity::new(0.375).unwrap()), + ); + if let Some(source) = source { + blend = blend.with_source_domain(source); + } + let ramp = + PaintStack::try_from_paints(Paints::new([Paint::LinearGradient(LinearGradientPaint { + stops: vec![ + GradientStop { + offset: 0.0, + color: CGColor::RED.into(), + }, + GradientStop { + offset: 1.0, + color: CGColor::BLUE.into(), + }, + ], + ..Default::default() + })])) + .unwrap(); + Frame { + owner: owner(1), + bounds: Rectangle::from_xywh(0.0, 0.0, 64.0, 64.0), + items: FrameItems::try_new(vec![ + FrameItem::ScopeBegin(Scope { + owner: owner(2), + effect: ScopeEffect::Blend(blend), + }), + node(3, ramp), + node(4, PaintStack::solid(CGColor::from_rgba(40, 80, 160, 128))), + FrameItem::ScopeEnd, + ]) + .unwrap(), + } +} + +/// Attaching a domain adds no boundary and changes neither final operation. +#[test] +fn absence_makes_no_completeness_assertion_and_attachment_preserves_blend_and_opacity() { + for mode in [ + ScopeBlendMode::Normal, + ScopeBlendMode::Multiply, + ScopeBlendMode::Screen, + ] { + for opacity in [None, Some(ScopeOpacity::new(0.375).unwrap())] { + let ordinary = ScopeBlend::new(mode, opacity); + let declared = ordinary.with_source_domain(domain()); + assert_eq!(ordinary.source_domain(), None); + assert_eq!(declared.source_domain(), Some(domain())); + assert_eq!(declared.mode(), ordinary.mode()); + assert_eq!(declared.opacity(), ordinary.opacity()); + assert_ne!(declared, ordinary); + } + } +} + +/// A resolved non-painted extent can distinguish two otherwise identical +/// illustration products without becoming geometry, a paint, or a clip item. +#[test] +fn an_independent_illustration_changes_domain_without_changing_painted_nodes() { + let ordinary = illustration(None); + let enclosed = illustration(Some(domain())); + let wider = illustration(Some( + BlendSourceDomain::new( + Rectangle::from_xywh(-2.0, -2.0, 28.0, 28.0), + AffineTransform::identity(), + ) + .unwrap(), + )); + for frame in [&enclosed, &wider] { + assert_eq!(frame.owner, ordinary.owner); + assert_eq!(frame.bounds, ordinary.bounds); + assert_eq!(frame.nodes(), ordinary.nodes()); + assert_eq!(frame.items.len(), ordinary.items.len()); + assert_eq!( + frame.nodes().iter().map(|n| n.paints.len()).sum::(), + 2 + ); + assert!(frame.nodes().iter().all(|n| n.stroke.is_none())); + assert_ne!(frame, &ordinary); + } + assert_ne!(enclosed, wider); + assert_eq!(enclosed, illustration(Some(domain()))); +} + +/// Enclosure is already resolved in the producer's units. The contract does +/// not round fractional inputs or canonicalize local placement into the map. +#[test] +fn construction_preserves_the_declared_rectangle_and_map_exactly() { + let local = Rectangle::from_xywh(-0.0, -0.5, 10.25, 8.75); + let map = AffineTransform::from_acebdf(-1.0, 0.25, 25.5, 0.125, 2.0, -0.25); + let source = BlendSourceDomain::new(local, map).unwrap(); + let stored = source.rect(); + assert_eq!( + [stored.x, stored.y, stored.width, stored.height].map(f32::to_bits), + [local.x, local.y, local.width, local.height].map(f32::to_bits) + ); + assert_eq!( + source + .source_to_stream() + .matrix + .map(|row| row.map(f32::to_bits)), + map.matrix.map(|row| row.map(f32::to_bits)) + ); +} + +/// Mapping may hide distinctions in where the source was enclosed. A final +/// enclosing box cannot replace either of the two carried facts. +#[test] +fn equal_final_boxes_do_not_identify_different_local_domains() { + let first = BlendSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 4.0, 2.0), + AffineTransform::identity(), + ) + .unwrap(); + let second = BlendSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 2.0, 4.0), + AffineTransform::from_acebdf(0.0, 1.0, 0.0, 1.0, 0.0, 0.0), + ) + .unwrap(); + assert_eq!( + math2::rect_transform(first.rect(), &first.source_to_stream()), + math2::rect_transform(second.rect(), &second.source_to_stream()) + ); + assert_ne!(first, second); +} + +/// Enclosing two diagonal unit squares before a shear yields a different +/// domain than enclosing them afterwards. Only the former is declared here. +#[test] +fn the_declared_enclosure_precedes_its_map() { + let shear = AffineTransform::from_acebdf(1.0, -1.0, 0.0, 0.0, 1.0, 0.0); + let source = BlendSourceDomain::new(Rectangle::from_xywh(0.0, 0.0, 3.0, 3.0), shear).unwrap(); + assert_eq!(source.rect(), Rectangle::from_xywh(0.0, 0.0, 3.0, 3.0)); + let mapped = math2::rect_transform(source.rect(), &source.source_to_stream()); + assert_eq!(mapped, Rectangle::from_xywh(-3.0, 0.0, 6.0, 3.0)); + let individually_enclosed = Rectangle::from_xywh(-1.0, 0.0, 2.0, 3.0); + assert_ne!(mapped, individually_enclosed); +} + +/// An empty rectangle is not an empty-source API. Finite widths also need +/// ordered representable endpoints rather than overflowing or collapsing. +#[test] +fn empty_nonfinite_and_unrepresentable_local_rectangles_are_refused() { + let map = AffineTransform::identity(); + for rectangle in [ + Rectangle::from_xywh(0.0, 0.0, 0.0, 1.0), + Rectangle::from_xywh(0.0, 0.0, 1.0, -0.0), + Rectangle::from_xywh(0.0, 0.0, -1.0, 1.0), + Rectangle::from_xywh(0.0, 0.0, 1.0, -1.0), + Rectangle::from_xywh(f32::MAX, 0.0, f32::MAX, 1.0), + Rectangle::from_xywh(0.0, f32::MAX, 1.0, f32::MAX), + Rectangle::from_xywh(1.0, 0.0, f32::from_bits(1), 1.0), + Rectangle::from_xywh(0.0, 1.0, 1.0, f32::from_bits(1)), + ] { + assert_eq!( + BlendSourceDomain::new(rectangle, map), + Err(BlendSourceDomainError::InvalidRectangle) + ); + } + for index in 0..4 { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut members = [0.0, 0.0, 1.0, 1.0]; + members[index] = value; + let [x, y, width, height] = members; + assert_eq!( + BlendSourceDomain::new(Rectangle::from_xywh(x, y, width, height), map), + Err(BlendSourceDomainError::InvalidRectangle) + ); + } + } +} + +/// A finite forward matrix is insufficient when inverse arithmetic is +/// unsupported, overflows, or loses its determinant to non-finite arithmetic. +#[test] +fn unusable_coordinate_maps_are_refused_without_an_identity_fallback() { + for map in [ + AffineTransform::from_acebdf(1.0, 2.0, 0.0, 2.0, 4.0, 0.0), + AffineTransform::from_acebdf(f32::EPSILON / 2.0, 0.0, 0.0, 0.0, 1.0, 0.0), + AffineTransform::from_acebdf(f32::MAX, 0.0, 0.0, 0.0, 2.0, 0.0), + AffineTransform::from_acebdf(f32::MAX, f32::MAX, 0.0, f32::MAX, f32::MAX, 0.0), + AffineTransform::from_acebdf(0.5, 0.0, f32::MAX, 0.0, 1.0, 0.0), + ] { + assert_eq!( + BlendSourceDomain::new(rect(), map), + Err(BlendSourceDomainError::InvalidTransform) + ); + } + for row in 0..2 { + for column in 0..3 { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut map = AffineTransform::identity(); + map.matrix[row][column] = value; + assert_eq!( + BlendSourceDomain::new(rect(), map), + Err(BlendSourceDomainError::InvalidTransform) + ); + } + } + } +} + +/// Bounding must not hide a non-finite corner, an overflowing span between +/// finite corners, or a positive domain collapsed by finite translation. +#[test] +fn mapped_corners_and_their_enclosing_rectangle_must_remain_representable() { + for (rectangle, map) in [ + ( + Rectangle::from_xywh(0.0, 0.0, f32::MAX, 1.0), + AffineTransform::from_acebdf(2.0, 0.0, 0.0, 0.0, 1.0, 0.0), + ), + ( + Rectangle::from_xywh(-f32::MAX / 2.0, 0.0, f32::MAX, 1.0), + AffineTransform::from_acebdf(1.5, 0.0, 0.0, 0.0, 1.0, 0.0), + ), + ( + Rectangle::from_xywh(0.0, 0.0, 2.0, 2.0), + AffineTransform::from_acebdf(f32::MAX, -f32::MAX, 0.0, 1.0, -0.5, 0.0), + ), + ( + rect(), + AffineTransform::from_acebdf(1.0, 0.0, 1e20, 0.0, 1.0, 1e20), + ), + ] { + assert_eq!( + BlendSourceDomain::new(rectangle, map), + Err(BlendSourceDomainError::InvalidMappedBounds) + ); + } +} + +/// The numerical boundary imposes neither a minimum pixel nor a maximum +/// allocation size. Neither belongs to source-local units. +#[test] +fn representable_domains_have_no_device_size_limit() { + for size in [f32::from_bits(1), f32::MIN_POSITIVE, 1.0, f32::MAX] { + let rectangle = Rectangle::from_xywh(0.0, 0.0, size, size); + let source = BlendSourceDomain::new(rectangle, AffineTransform::identity()).unwrap(); + assert_eq!(source.rect(), rectangle); + } +} + +/// Extent is not paint and cannot satisfy stream content validation. +#[test] +fn a_declared_domain_creates_neither_paint_nor_an_empty_group_exception() { + let zero = PaintAlphaFactor::new(0.0).unwrap(); + assert!( + PaintStack::solid(CGColor::RED) + .with_alpha_factor(zero) + .is_empty() + ); + assert_eq!( + Stroke::new( + PaintStack::solid(CGColor::TRANSPARENT), + 8.0, + StrokeCap::Butt, + StrokeJoin::Miter, + 4.0, + ), + Ok(None) + ); + assert_eq!( + FrameItems::try_new(vec![begin(1, domain()), FrameItem::ScopeEnd]), + Err(FrameItemsError::EmptyScope { index: 0 }) + ); +} + +/// One stream owns completion and order. The source map does not rebase +/// children, collapse clip scopes into isolation, or combine mask phases. +#[test] +fn nested_domains_preserve_owners_maps_nodes_clips_and_mask_phases() { + let clip = ClipPath::new_with_edge_mode( + vec![ + ClipLayer::new(vec![ + ClipGeometry::new(AffineTransform::identity(), Geometry::Rect(domain().rect())) + .unwrap(), + ]) + .unwrap(), + ], + ClipEdgeMode::Hard, + ) + .unwrap(); + let child = BlendSourceDomain::new( + Rectangle::from_xywh(0.0, 0.0, 20.0, 20.0), + AffineTransform::from_acebdf(1.0, 0.0, 2.0, 0.0, 1.0, 3.0), + ) + .unwrap(); + let items = vec![ + begin(1, domain()), + FrameItem::ScopeBegin(Scope { + owner: owner(2), + effect: ScopeEffect::Clip(clip.clone()), + }), + FrameItem::MaskBegin(Mask::new(owner(3), MaskMode::Alpha, clip)), + node(4, PaintStack::solid(CGColor::RED)), + FrameItem::MaskSource, + begin(5, child), + node(6, PaintStack::solid(CGColor::WHITE)), + FrameItem::ScopeEnd, + FrameItem::MaskEnd, + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]; + let checked = FrameItems::try_new(items.clone()).unwrap(); + assert_eq!(checked.iter().cloned().collect::>(), items); + assert_eq!( + checked.nodes().map(|n| n.owner).collect::>(), + [owner(4), owner(6)] + ); + assert!( + checked + .nodes() + .all(|n| n.transform == AffineTransform::identity()) + ); + + assert_eq!( + FrameItems::try_new(vec![ + FrameItem::MaskBegin(Mask::new( + owner(1), + MaskMode::Alpha, + ClipPath::new(vec![ClipLayer::new(Vec::new()).unwrap()]).unwrap(), + )), + begin(2, child), + node(3, PaintStack::solid(CGColor::RED)), + FrameItem::MaskSource, + ]), + Err(FrameItemsError::UnexpectedMaskSource { index: 3 }) + ); +} + +/// The declaration adds no structural nesting and earns no additional depth. +#[test] +fn domains_preserve_the_existing_scope_depth_bound() { + let begins = || { + (0..MAX_SCOPE_DEPTH) + .map(|id| begin(id as u64, domain())) + .collect::>() + }; + let mut maximum = begins(); + maximum.push(node(100, PaintStack::solid(CGColor::RED))); + maximum.extend(std::iter::repeat_n(FrameItem::ScopeEnd, MAX_SCOPE_DEPTH)); + FrameItems::try_new(maximum).unwrap(); + let mut overflow = begins(); + overflow.push(begin(100, domain())); + assert_eq!( + FrameItems::try_new(overflow), + Err(FrameItemsError::ScopeTooDeep { + index: MAX_SCOPE_DEPTH + }) + ); +} + +fn repeating_illustration(paints: PaintStack) -> Result { + let items = Arc::new( + FrameItems::try_new(vec![ + begin(1, domain()), + node(2, paints), + FrameItem::ScopeEnd, + ]) + .unwrap(), + ); + let pattern = PatternPaint::new( + 24.0, + 24.0, + AffineTransform::from_acebdf(2.0, 0.0, 12.0, 0.0, 2.0, 16.0), + Arc::clone(&items), + 1.0, + )?; + assert!(Arc::ptr_eq(pattern.items(), &items)); + assert_eq!(pattern.items().iter().next(), Some(&begin(1, domain()))); + Ok(pattern) +} + +/// Program placement never rewrites a contained source-to-tile declaration, +/// and declarations cannot hide recursive programs from the depth check. +#[test] +fn repeating_programs_keep_tile_local_domains_immutable_and_recursion_bounded() { + let mut pattern = repeating_illustration(PaintStack::solid(CGColor::RED)).unwrap(); + for expected in 2..=MAX_PATTERN_DEPTH { + pattern = repeating_illustration(PaintStack::from_pattern(pattern)).unwrap(); + assert_eq!(pattern.depth(), expected); + } + assert_eq!( + repeating_illustration(PaintStack::from_pattern(pattern)), + Err(PatternPaintError::TooDeep) + ); +} From 8f356cc1e2855625c7f4e1e071d0755e20e3a5e2 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 8 Sep 2026 17:25:40 +0900 Subject: [PATCH 2/2] websem: carry invisible stroke extents through blend sources Preserve complete source domains without fake paint; admit the bounded solid-stroke omission profile with 44 exact Chromium cells. Deliberately promote two obsolete refusal witnesses into positive evidence and retain six new source-domain/root refusals. No checklist ticks or tolerance changes. --- crates/n0/README.md | 14 + crates/n0/src/frame.rs | 7 + crates/n0/src/glyphless.rs | 373 ++++++++++++++++ crates/n0/src/paint.rs | 77 +++- crates/n0/tests/group_blending.rs | 127 ++++++ crates/n0_cli/README.md | 21 +- crates/websem/src/svg.rs | 136 +++++- crates/websem/tests/svg_blending.rs | 220 +++++++++- crates/websem/tests/unsupported_corpus.rs | 24 +- docs/wg/consolidation/svg-engine-of-record.md | 148 ++++++- docs/wg/consolidation/web-checklist.md | 4 + fixtures/web-first/README.md | 8 +- fixtures/web-first/STATUS.md | 56 ++- .../svg-group-blend-source-multiply-bevel.png | Bin 0 -> 468 bytes ...oup-blend-source-multiply-currentcolor.png | Bin 0 -> 468 bytes ...svg-group-blend-source-multiply-dashed.png | Bin 0 -> 468 bytes .../svg-group-blend-source-multiply-edge.png | Bin 0 -> 483 bytes ...group-blend-source-multiply-fractional.png | Bin 0 -> 468 bytes ...vg-group-blend-source-multiply-integer.png | Bin 0 -> 461 bytes .../svg-group-blend-source-multiply-leaf.png | Bin 0 -> 468 bytes ...p-blend-source-multiply-nested-neutral.png | Bin 0 -> 468 bytes ...up-blend-source-multiply-normal-elided.png | Bin 0 -> 609 bytes ...oup-blend-source-multiply-opacity-half.png | Bin 0 -> 422 bytes ...oup-blend-source-multiply-opacity-near.png | Bin 0 -> 468 bytes ...group-blend-source-multiply-outer-clip.png | Bin 0 -> 571 bytes ...up-blend-source-multiply-outer-opacity.png | Bin 0 -> 513 bytes ...p-blend-source-multiply-painted-stroke.png | Bin 0 -> 885 bytes ...p-blend-source-multiply-pixel-boundary.png | Bin 0 -> 472 bytes ...blend-source-multiply-sibling-reversed.png | Bin 0 -> 618 bytes ...vg-group-blend-source-multiply-sibling.png | Bin 0 -> 585 bytes ...-group-blend-source-multiply-two-ramps.png | Bin 0 -> 749 bytes .../svg-group-blend-source-multiply-use.png | Bin 0 -> 468 bytes ...blend-source-multiply-width-fractional.png | Bin 0 -> 470 bytes ...-group-blend-source-multiply-width-one.png | Bin 0 -> 530 bytes ...g-group-blend-source-multiply-width-px.png | Bin 0 -> 468 bytes ...-group-blend-source-multiply-zeroalpha.png | Bin 0 -> 468 bytes .../svg-group-blend-source-screen-edge.png | Bin 0 -> 528 bytes ...g-group-blend-source-screen-fractional.png | Bin 0 -> 531 bytes ...vg-group-blend-source-screen-inherited.png | Bin 0 -> 531 bytes .../svg-group-blend-source-screen-integer.png | Bin 0 -> 510 bytes .../svg-group-blend-source-screen-leaf.png | Bin 0 -> 531 bytes ...oup-blend-source-screen-nested-neutral.png | Bin 0 -> 531 bytes ...group-blend-source-screen-opacity-half.png | Bin 0 -> 456 bytes ...group-blend-source-screen-opacity-near.png | Bin 0 -> 531 bytes ...g-group-blend-source-screen-outer-clip.png | Bin 0 -> 571 bytes ...roup-blend-source-screen-outer-opacity.png | Bin 0 -> 513 bytes ...oup-blend-source-screen-painted-stroke.png | Bin 0 -> 994 bytes ...oup-blend-source-screen-pixel-boundary.png | Bin 0 -> 525 bytes .../svg-group-blend-source-screen-round.png | Bin 0 -> 531 bytes ...p-blend-source-screen-sibling-reversed.png | Bin 0 -> 701 bytes .../svg-group-blend-source-screen-sibling.png | Bin 0 -> 630 bytes ...vg-group-blend-source-screen-two-ramps.png | Bin 0 -> 822 bytes .../svg-group-blend-source-screen-use.png | Bin 0 -> 531 bytes ...p-blend-source-screen-width-fractional.png | Bin 0 -> 510 bytes ...vg-group-blend-source-screen-width-one.png | Bin 0 -> 563 bytes ...roup-blend-source-screen-width-percent.png | Bin 0 -> 531 bytes ...vg-group-blend-source-screen-zeroalpha.png | Bin 0 -> 531 bytes fixtures/web-first/oracle-bake.json | 398 +++++++++++++++++- fixtures/web-first/primitives.json | 352 ++++++++++++++++ .../svg-group-blend-source-multiply-bevel.svg | 1 + ...oup-blend-source-multiply-currentcolor.svg | 1 + ...svg-group-blend-source-multiply-dashed.svg | 1 + .../svg-group-blend-source-multiply-edge.svg | 1 + ...group-blend-source-multiply-fractional.svg | 1 + ...vg-group-blend-source-multiply-integer.svg | 1 + .../svg-group-blend-source-multiply-leaf.svg | 1 + ...p-blend-source-multiply-nested-neutral.svg | 1 + ...up-blend-source-multiply-normal-elided.svg | 1 + ...oup-blend-source-multiply-opacity-half.svg | 1 + ...oup-blend-source-multiply-opacity-near.svg | 1 + ...group-blend-source-multiply-outer-clip.svg | 1 + ...up-blend-source-multiply-outer-opacity.svg | 1 + ...p-blend-source-multiply-painted-stroke.svg | 1 + ...p-blend-source-multiply-pixel-boundary.svg | 1 + ...blend-source-multiply-sibling-reversed.svg | 1 + ...vg-group-blend-source-multiply-sibling.svg | 1 + ...-group-blend-source-multiply-two-ramps.svg | 1 + .../svg-group-blend-source-multiply-use.svg | 1 + ...blend-source-multiply-width-fractional.svg | 1 + ...-group-blend-source-multiply-width-one.svg | 1 + ...g-group-blend-source-multiply-width-px.svg | 1 + ...-group-blend-source-multiply-zeroalpha.svg | 1 + .../svg-group-blend-source-screen-edge.svg | 1 + ...g-group-blend-source-screen-fractional.svg | 1 + ...vg-group-blend-source-screen-inherited.svg | 1 + .../svg-group-blend-source-screen-integer.svg | 1 + .../svg-group-blend-source-screen-leaf.svg | 1 + ...oup-blend-source-screen-nested-neutral.svg | 1 + ...group-blend-source-screen-opacity-half.svg | 1 + ...group-blend-source-screen-opacity-near.svg | 1 + ...g-group-blend-source-screen-outer-clip.svg | 1 + ...roup-blend-source-screen-outer-opacity.svg | 1 + ...oup-blend-source-screen-painted-stroke.svg | 1 + ...oup-blend-source-screen-pixel-boundary.svg | 1 + .../svg-group-blend-source-screen-round.svg | 1 + ...p-blend-source-screen-sibling-reversed.svg | 1 + .../svg-group-blend-source-screen-sibling.svg | 1 + ...vg-group-blend-source-screen-two-ramps.svg | 1 + .../svg-group-blend-source-screen-use.svg | 1 + ...p-blend-source-screen-width-fractional.svg | 1 + ...vg-group-blend-source-screen-width-one.svg | 1 + ...roup-blend-source-screen-width-percent.svg | 1 + ...vg-group-blend-source-screen-zeroalpha.svg | 1 + fixtures/web-first/unsupported/README.md | 5 +- ...lend-linear-extent-stroke-opacity-zero.svg | 1 - ...blend-linear-extent-stroke-transparent.svg | 1 - ...vg-group-blend-source-domain-enclosure.svg | 1 + ...-group-blend-source-domain-non-scaling.svg | 1 + ...up-blend-source-domain-pattern-sibling.svg | 1 + .../svg-group-blend-source-domain-range.svg | 1 + ...vg-group-blend-source-domain-root-bare.svg | 1 + ...p-blend-source-domain-root-transparent.svg | 1 + 112 files changed, 1992 insertions(+), 30 deletions(-) create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-bevel.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-currentcolor.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-dashed.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-edge.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-integer.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-leaf.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-nested-neutral.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-normal-elided.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-opacity-half.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-opacity-near.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-outer-clip.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-outer-opacity.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-painted-stroke.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-pixel-boundary.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-sibling-reversed.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-sibling.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-two-ramps.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-use.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-width-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-width-one.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-width-px.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-multiply-zeroalpha.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-edge.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-inherited.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-integer.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-leaf.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-nested-neutral.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-opacity-half.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-opacity-near.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-outer-clip.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-outer-opacity.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-painted-stroke.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-pixel-boundary.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-round.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-sibling-reversed.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-sibling.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-two-ramps.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-use.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-width-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-width-one.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-width-percent.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-source-screen-zeroalpha.png create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-bevel.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-currentcolor.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-dashed.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-edge.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-integer.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-leaf.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-nested-neutral.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-normal-elided.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-opacity-half.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-opacity-near.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-outer-clip.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-outer-opacity.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-painted-stroke.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-pixel-boundary.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-sibling-reversed.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-sibling.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-two-ramps.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-use.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-width-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-width-one.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-width-px.svg create mode 100644 fixtures/web-first/svg-group-blend-source-multiply-zeroalpha.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-edge.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-inherited.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-integer.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-leaf.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-nested-neutral.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-opacity-half.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-opacity-near.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-outer-clip.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-outer-opacity.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-painted-stroke.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-pixel-boundary.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-round.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-sibling-reversed.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-sibling.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-two-ramps.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-use.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-width-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-width-one.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-width-percent.svg create mode 100644 fixtures/web-first/svg-group-blend-source-screen-zeroalpha.svg delete mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-opacity-zero.svg delete mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-transparent.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-source-domain-enclosure.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-source-domain-non-scaling.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-source-domain-pattern-sibling.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-source-domain-range.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-source-domain-root-bare.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-source-domain-root-transparent.svg diff --git a/crates/n0/README.md b/crates/n0/README.md index 91ac7bdd..085f53db 100644 --- a/crates/n0/README.md +++ b/crates/n0/README.md @@ -308,6 +308,20 @@ 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. +An optional checked `BlendSourceDomain` on that operation is complete, +already-enclosed source material, not a geometry/damage box or an allocation +hint. The first glyphless consumer accepts identity-mapped declarations over +untransformed solid/linear rectangles and simple local strokes; it rejects +under-enclosing domains, nested effects within the declaration, nonidentity +declaration maps, enclosing filter/mask programs, and declarations inside +repeating programs. Ordinary undeclared scopes keep their existing profile. +The declaration targets the containing stream, independently of a scope item's +drawing map. Current-view mapping and device enclosure are recalculated for +every execution. Unsupported device bounds return an owner-bearing +`FrameExecutionError::SourceDomain` before touching the canvas. There is no +source-image cache. Consumer tests distinguish raster identity from painted +geometry and prove changed-view replay equals a fresh product. + 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. diff --git a/crates/n0/src/frame.rs b/crates/n0/src/frame.rs index 5cd5e4ea..1d251e78 100644 --- a/crates/n0/src/frame.rs +++ b/crates/n0/src/frame.rs @@ -140,6 +140,10 @@ pub enum FrameExecutionError { Environment(PaintEnvironmentMismatch), Image(crate::paint::ImagePreflightError), Pattern(crate::paint::PatternPreflightError), + /// A declared source domain cannot be represented under the current view. + SourceDomain { + owner: rframe::VisualRef, + }, } impl std::fmt::Display for FrameExecutionError { @@ -148,6 +152,9 @@ impl std::fmt::Display for FrameExecutionError { FrameExecutionError::Environment(error) => error.fmt(f), FrameExecutionError::Image(error) => error.fmt(f), FrameExecutionError::Pattern(error) => error.fmt(f), + FrameExecutionError::SourceDomain { owner } => { + write!(f, "blend source domain {owner:?} has an unsupported device enclosure under the current view") + } } } } diff --git a/crates/n0/src/glyphless.rs b/crates/n0/src/glyphless.rs index faaeb31d..b89980aa 100644 --- a/crates/n0/src/glyphless.rs +++ b/crates/n0/src/glyphless.rs @@ -175,6 +175,9 @@ pub struct FrameProduct { resolved: Frame, drawlist: DrawList, provenance: ProvenanceProjection, + /// Only declared domains need current-view validation. Empty means no + /// added execution traversal or allocation for ordinary products. + source_domains: Vec<(VisualRef, rframe::BlendSourceDomain)>, } impl FrameProduct { @@ -191,6 +194,7 @@ impl FrameProduct { ctx: &PaintCtx, ) -> Result<(), FrameExecutionError> { self.assert_provenance_complete(); + self.preflight_source_domains(view)?; crate::paint::preflight_patterns(&self.drawlist, ctx)?; crate::paint::execute_unchecked(canvas, &self.drawlist, &to_affine(*view), ctx); Ok(()) @@ -206,6 +210,7 @@ impl FrameProduct { ctx: &PaintCtx, ) -> Result, FrameExecutionError> { self.assert_provenance_complete(); + self.preflight_source_domains(view)?; crate::paint::preflight_patterns(&self.drawlist, ctx)?; Ok(crate::paint::raster_to_bytes_unchecked( &self.drawlist, @@ -216,6 +221,30 @@ impl FrameProduct { )) } + fn preflight_source_domains( + &self, + view: &math2::transform::AffineTransform, + ) -> Result<(), FrameExecutionError> { + for &(owner, domain) in &self.source_domains { + // The admitted declaration map is identity. Validate all view + // arithmetic before touching the caller's canvas; never ignore a + // domain just because its current device bounds cannot be formed. + if rframe::BlendSourceDomain::new(domain.rect(), *view).is_err() { + return Err(FrameExecutionError::SourceDomain { owner }); + } + let bounds = math2::rect_transform(domain.rect(), view); + if bounds + .corners() + .into_iter() + .flatten() + .any(|v| v.abs() > 8_388_608.0) + { + return Err(FrameExecutionError::SourceDomain { owner }); + } + } + Ok(()) + } + fn assert_provenance_complete(&self) { debug_assert!(self .drawlist @@ -361,11 +390,19 @@ pub fn compile(resolved: Frame) -> Result { // scope composites — and a child scope's union folds into its parent's // when it closes. let mut open_scopes: Vec = Vec::new(); + let mut active_source_domain: Option<(VisualRef, rframe::BlendSourceDomain)> = None; + let mut source_domains = Vec::new(); for frame_item in resolved.items.iter() { let node = match frame_item { FrameItem::Node(node) => node, FrameItem::ScopeBegin(scope) => { + if let Some((owner, _)) = active_source_domain { + return Err(BuildError::Blend { + owner, + reason: "a declared source domain does not yet admit nested effects".into(), + }); + } if !unique.insert(scope.owner) { return Err(BuildError::DuplicateOwner(scope.owner)); } @@ -402,6 +439,21 @@ pub fn compile(resolved: Frame) -> Result { (OpenScopeKind::Opacity, None) } ScopeEffect::Blend(blend) => { + if let Some(domain) = blend.source_domain() { + if domain.source_to_stream() + != math2::transform::AffineTransform::identity() + || open_scopes.iter().any(|scope| { + matches!( + scope.kind, + OpenScopeKind::Mask { .. } | OpenScopeKind::Filter { .. } + ) + }) + { + return Err(BuildError::Blend { owner: scope.owner, reason: "a declared source domain needs the unmapped, non-image-effect profile".into() }); + } + active_source_domain = Some((scope.owner, domain)); + source_domains.push((scope.owner, domain)); + } crate::paint::preflight_isolated_blend(*blend).map_err(|reason| { BuildError::Blend { owner: scope.owner, @@ -492,6 +544,11 @@ pub fn compile(resolved: Frame) -> Result { ), }; let slot = scope.slot; + if active_source_domain + .is_some_and(|(owner, _)| provenance.owners[slot.index()] == owner) + { + active_source_domain = None; + } provenance.coverage[slot.index()] = coverage; if let (Some(coverage), Some(parent)) = (coverage, open_scopes.last_mut()) { parent.coverage = Some(match parent.coverage { @@ -507,6 +564,13 @@ pub fn compile(resolved: Frame) -> Result { continue; } FrameItem::MaskBegin(mask) => { + if let Some((owner, _)) = active_source_domain { + return Err(BuildError::Blend { + owner, + reason: "a declared source domain does not yet admit nested image masks" + .into(), + }); + } if !unique.insert(mask.owner) { return Err(BuildError::DuplicateOwner(mask.owner)); } @@ -623,6 +687,12 @@ pub fn compile(resolved: Frame) -> Result { if node.bounds != math2::rect_transform(rect, &node.transform) { return Err(BuildError::VisualBoundsMismatch(node.owner)); } + if let Some((owner, domain)) = active_source_domain { + validate_source_domain_node(node, domain).map_err(|reason| BuildError::Blend { + owner, + reason: reason.into(), + })?; + } // The paint reference box is the geometry's own extent. Ordinary box // routes draw at their item origin, so their paint box already starts // there. A path's stream carries absolute local coordinates, so its box @@ -877,9 +947,59 @@ pub fn compile(resolved: Frame) -> Result { resolved, drawlist, provenance, + source_domains, }) } +/// The first domain consumer has a bounded, independently tested source +/// profile. Other declarations refuse before compilation returns a product. +fn validate_source_domain_node( + node: &rframe::FrameNode, + domain: rframe::BlendSourceDomain, +) -> Result<(), &'static str> { + let Geometry::Rect(rect) = node.geometry else { + return Err("a declared source domain requires rectangular source geometry"); + }; + if node.transform != math2::transform::AffineTransform::identity() + || node.paints.pattern().is_some() + || node + .paints + .iter() + .any(|paint| matches!(paint, CgPaint::RadialGradient(_))) + || (node.paints.is_empty() && node.stroke.is_none()) + { + return Err("a declared source domain requires unmapped solid/linear painted rectangles"); + } + let width = if let Some(stroke) = &node.stroke { + if stroke.space() != rframe::StrokeSpace::Local + || stroke.cap() != rframe::StrokeCap::Butt + || stroke.join() != rframe::StrokeJoin::Miter + || stroke.dash().is_some() + || stroke.dash_intervals().is_some() + || stroke.paints().pattern().is_some() + || stroke + .paints() + .iter() + .any(|paint| matches!(paint, CgPaint::RadialGradient(_))) + { + return Err("a declared source domain does not yet admit complex strokes"); + } + stroke.width() + } else { + 0.0 + }; + let bounds = domain.rect(); + let half = f64::from(width) / 2.0; + if f64::from(rect.x) - half < f64::from(bounds.x) + || f64::from(rect.y) - half < f64::from(bounds.y) + || f64::from(rect.x) + f64::from(rect.width) + half > f64::from(bounds.x + bounds.width) + || f64::from(rect.y) + f64::from(rect.height) + half > f64::from(bounds.y + bounds.height) + { + return Err("a declared source domain does not enclose its painted rectangle"); + } + Ok(()) +} + /// Project the contract's checked command stream into the engine's resolved /// path material. /// @@ -1395,6 +1515,9 @@ fn compile_pattern( owner, reason: format!("nested pattern program failed projection: {error}"), })?; + if !product.source_domains.is_empty() { + return Err(BuildError::Paint { owner, reason: "a declared blend source domain inside a repeating program needs its own execution profile".into() }); + } Ok(Arc::new(ResolvedPattern { width: pattern.width(), height: pattern.height(), @@ -4254,4 +4377,254 @@ mod tests { ); assert!(diff_frame(&before, &before).is_empty()); } + + fn domain_illustration( + domain: Option, + mode: rframe::ScopeBlendMode, + ) -> Frame { + let mut blend = rframe::ScopeBlend::new(mode, Some(ScopeOpacity::new(0.75).unwrap())); + if let Some(domain) = domain { + blend = blend.with_source_domain(domain); + } + let mut ramp = rect_node( + RECT_OWNER, + Rectangle::from_xywh(8.3, 12.7, 38.2, 28.4), + 0xFFCD_6843, + ); + ramp.bounds = math2::rect_transform(ramp.geometry.local_box(), &ramp.transform); + ramp.paints = PaintStack::try_from_paints(CgPaints::new([CgPaint::LinearGradient( + cg::LinearGradientPaint { + stops: vec![ + cg::GradientStop { + offset: 0.0, + color: CGColor::from_rgba(205, 104, 67, 255).into(), + }, + cg::GradientStop { + offset: 1.0, + color: CGColor::from_rgba(91, 172, 225, 153).into(), + }, + ], + ..Default::default() + }, + )])) + .unwrap(); + frame_of( + FrameItems::try_new(vec![ + FrameItem::Node(rect_node( + OTHER_OWNER, + Rectangle::from_xywh(0.0, 0.0, 64.0, 48.0), + 0xFF42_6589, + )), + FrameItem::ScopeBegin(Scope { + owner: SCOPE_OWNER, + effect: ScopeEffect::Blend(blend), + }), + FrameItem::Node(ramp), + FrameItem::ScopeEnd, + ]) + .unwrap(), + ) + } + + fn source_domain() -> rframe::BlendSourceDomain { + rframe::BlendSourceDomain::new( + Rectangle::from_xywh(6.0, 10.0, 43.0, 34.0), + AffineTransform::identity(), + ) + .unwrap() + } + + #[test] + fn declared_source_domain_is_raster_material_not_geometry_or_an_extra_draw() { + let ordinary = + compile(domain_illustration(None, rframe::ScopeBlendMode::Multiply)).unwrap(); + let declared = compile(domain_illustration( + Some(source_domain()), + rframe::ScopeBlendMode::Multiply, + )) + .unwrap(); + assert!(ordinary.source_domains.is_empty()); + assert_eq!( + declared.source_domains, + vec![(SCOPE_OWNER, source_domain())] + ); + assert_eq!(ordinary.resolved.nodes(), declared.resolved.nodes()); + assert_eq!( + ordinary.provenance.coverage, declared.provenance.coverage, + "source material is not damage coverage" + ); + assert_eq!(ordinary.drawlist.items.len(), declared.drawlist.items.len()); + assert!(!ordinary.drawlist.raster_eq(&declared.drawlist)); + let context = PaintCtx::new(None); + assert_ne!( + ordinary + .raster_to_bytes(&AffineTransform::identity(), 64, 48, &context) + .unwrap(), + declared + .raster_to_bytes(&AffineTransform::identity(), 64, 48, &context) + .unwrap(), + "the independent illustration exercises source material, not only field equality" + ); + } + + #[test] + fn declared_source_replay_under_changed_views_matches_fresh() { + let context = PaintCtx::new(None); + for mode in [ + rframe::ScopeBlendMode::Normal, + rframe::ScopeBlendMode::Multiply, + rframe::ScopeBlendMode::Screen, + ] { + let frame = domain_illustration(Some(source_domain()), mode); + let retained = compile(frame.clone()).unwrap(); + for matrix in [ + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 3.25], [0.0, 1.0, -2.5]], + [[1.25, 0.0, -4.0], [0.0, 0.75, 2.0]], + [[1.0, 0.2, 0.0], [0.1, 1.0, 0.0]], + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + ] { + let view = AffineTransform { matrix }; + let pixels = retained.raster_to_bytes(&view, 80, 64, &context).unwrap(); + assert_eq!( + pixels, + compile(frame.clone()) + .unwrap() + .raster_to_bytes(&view, 80, 64, &context) + .unwrap() + ); + let mut surface = surfaces::raster_n32_premul((80, 64)).unwrap(); + surface.canvas().clear(skia_safe::Color::WHITE); + let saves = surface.canvas().save_count(); + retained.execute(surface.canvas(), &view, &context).unwrap(); + assert_eq!(surface.canvas().save_count(), saves); + assert_eq!(crate::paint::read_pixels(&mut surface, 80, 64), pixels); + } + } + } + + #[test] + fn unrepresentable_current_source_view_refuses_before_touching_canvas() { + let product = compile(domain_illustration( + Some(source_domain()), + rframe::ScopeBlendMode::Screen, + )) + .unwrap(); + let context = PaintCtx::new(None); + let mut surface = surfaces::raster_n32_premul((64, 48)).unwrap(); + surface.canvas().clear(skia_safe::Color::MAGENTA); + let before = crate::paint::read_pixels(&mut surface, 64, 48); + let saves = surface.canvas().save_count(); + for matrix in [ + [[0.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + [[1.0, 0.0, f32::NAN], [0.0, 1.0, 0.0]], + [[1.0, 0.0, 10_000_000.0], [0.0, 1.0, 0.0]], + [[f32::MAX, 0.0, 0.0], [0.0, 1.0, 0.0]], + ] { + let view = AffineTransform { matrix }; + let error = product + .execute(surface.canvas(), &view, &context) + .unwrap_err(); + assert!(matches!( + error, + FrameExecutionError::SourceDomain { owner: SCOPE_OWNER } + )); + assert!(error.to_string().contains("blend source domain")); + assert!(matches!( + product.raster_to_bytes(&view, 64, 48, &context), + Err(FrameExecutionError::SourceDomain { owner: SCOPE_OWNER }) + )); + assert_eq!(crate::paint::read_pixels(&mut surface, 64, 48), before); + assert_eq!(surface.canvas().save_count(), saves); + } + } + + #[test] + fn declared_source_profile_refuses_unproved_contract_valid_programs() { + let reject = |items: Vec| { + let error = compile(frame_of(FrameItems::try_new(items).unwrap())) + .err() + .expect("unsupported source profile"); + assert!( + matches!( + error, + BuildError::Blend { + owner: SCOPE_OWNER, + .. + } + ), + "{error}" + ); + }; + for domain in [ + rframe::BlendSourceDomain::new( + Rectangle::from_xywh(8.0, 12.0, 2.0, 2.0), + AffineTransform::identity(), + ) + .unwrap(), + rframe::BlendSourceDomain::new( + source_domain().rect(), + AffineTransform { + matrix: [[1.0, 0.0, 3.0], [0.0, 1.0, 0.0]], + }, + ) + .unwrap(), + ] { + reject( + domain_illustration(Some(domain), rframe::ScopeBlendMode::Multiply) + .items + .iter() + .cloned() + .collect(), + ); + } + for variant in 0..5 { + let mut items: Vec<_> = + domain_illustration(Some(source_domain()), rframe::ScopeBlendMode::Multiply) + .items + .iter() + .cloned() + .collect(); + let FrameItem::Node(node) = &mut items[2] else { + unreachable!() + }; + match variant { + 0 => node.geometry = Geometry::Ellipse(node.geometry.local_box()), + 1 => { + node.transform.matrix[0][2] = 1.0; + node.bounds = math2::rect_transform(node.geometry.local_box(), &node.transform); + } + 2 => { + node.stroke = Some(checked_stroke( + 1.0, + rframe::StrokeCap::Round, + rframe::StrokeJoin::Miter, + 4.0, + None, + )) + } + 3 => { + node.stroke = Some(checked_stroke( + 1.0, + rframe::StrokeCap::Butt, + rframe::StrokeJoin::Miter, + 4.0, + Some(vec![2.0, 3.0]), + )) + } + 4 => node.paints = PaintStack::empty(), + _ => unreachable!(), + } + reject(items); + } + let mut items: Vec<_> = + domain_illustration(Some(source_domain()), rframe::ScopeBlendMode::Multiply) + .items + .iter() + .cloned() + .collect(); + items.insert(2, scope_begin(INNER_SCOPE_OWNER, 0.5)); + items.insert(4, FrameItem::ScopeEnd); + reject(items); + } } diff --git a/crates/n0/src/paint.rs b/crates/n0/src/paint.rs index bfbc6c0a..6fb74218 100644 --- a/crates/n0/src/paint.rs +++ b/crates/n0/src/paint.rs @@ -4464,11 +4464,12 @@ fn observe_blend_layer(canvas: &Canvas) -> crate::trace::blend_layers::Observati /// Device-space source extents for the rectangular linear-ramp profile. /// /// A ramp's ordered dither is anchored to its raster device, not the final -/// canvas. Derive bounds from the actual draw commands and current view, never -/// the damage envelope. Recompute on execution: raw drawlists are mutable and +/// canvas. Honor a declared complete source domain before considering private +/// reconstruction from draw commands. Map either through the current view, +/// never the damage envelope. Recompute on execution: raw drawlists are mutable and /// the host view is not part of the compiled product. Neutral lists never call /// this pass. Websem patrols sources whose local-space extent is not retained -/// by the resolved stream. Other raw drawlist programs keep their old route. +/// by the resolved stream. Other undeclared raw programs keep their old route. fn blend_source_extents(list: &DrawList, view: &Affine) -> Option>> { use skia_safe::RoundOut; @@ -4506,12 +4507,13 @@ fn blend_source_extents(list: &DrawList, view: &Affine) -> Option, known: bool, ramp: bool, + declared: bool, } impl Source { fn add(&mut self, bounds: Option, known: bool, ramp: bool) { self.known &= known; self.ramp |= ramp; - if let Some(bounds) = bounds { + if let Some(bounds) = bounds.filter(|_| !self.declared) { if let Some(accumulated) = &mut self.bounds { accumulated.join(bounds); } else { @@ -4523,6 +4525,7 @@ fn blend_source_extents(list: &DrawList, view: &Affine) -> Option linear(paints), ItemKind::RectStroke { stroke, .. } => linear(&stroke.paints), + ItemKind::BeginIsolatedBlend { blend } => blend.source_domain().is_some(), _ => false, }) { return None; @@ -4550,7 +4553,9 @@ fn blend_source_extents(list: &DrawList, view: &Affine) -> Option Rect { bounds.round_out() }); } } @@ -4594,11 +4599,24 @@ fn blend_source_extents(list: &DrawList, view: &Affine) -> Option blend.source_domain(), + _ => None, + }; + let bounds = declared.map(|domain| { + let [[a, c, e], [b, d, f]] = domain.source_to_stream().matrix; + let map = view.then(&Affine { a, b, c, d, e, f }); + let rect = domain.rect(); + skia_matrix(&map) + .map_rect(Rect::from_xywh(rect.x, rect.y, rect.width, rect.height)) + .0 + }); stack.push(Source { kind, - bounds: None, + bounds, known: true, ramp: false, + declared: declared.is_some(), }); } else if let Some(source) = stack.last_mut() { let bounds = bounds @@ -4763,6 +4781,53 @@ mod blend_source_extent_tests { assert!(blend_source_extents(&list, &Affine::IDENTITY).is_none()); } + #[test] + fn declared_domain_is_authoritative_and_never_a_draw_derived_hint() { + let domain = rframe::BlendSourceDomain::new( + math2::Rectangle::from_xywh(6.0, 10.0, 43.0, 34.0), + math2::transform::AffineTransform::identity(), + ) + .unwrap(); + for mode in [ + rframe::ScopeBlendMode::Normal, + rframe::ScopeBlendMode::Multiply, + rframe::ScopeBlendMode::Screen, + ] { + let mut list = scene(); + list.items[0].kind = ItemKind::BeginIsolatedBlend { + blend: rframe::ScopeBlend::new(mode, None).with_source_domain(domain), + }; + // The declaration targets the stream, not the scope item's local + // drawing map. A nonzero frame origin must not be applied twice. + list.items[0].world = Affine::translate(100.0, 200.0); + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[0], + Some(Rect::new(6.0, 10.0, 49.0, 44.0)) + ); + assert_eq!( + blend_source_extents(&list, &Affine::translate(3.25, -2.5)).unwrap()[0], + Some(Rect::new(9.0, 7.0, 53.0, 42.0)) + ); + // An independent producer can use the same fact with solid paint. + if let ItemKind::RectFill { paints, .. } = &mut list.items[1].kind { + *paints = Paints::new([ModelPaint::Solid(n0_model::model::SolidPaint::new( + n0_model::model::Color(0xFFCD_6843), + ))]); + } + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[0], + Some(Rect::new(6.0, 10.0, 49.0, 44.0)) + ); + list.items[0].kind = ItemKind::BeginIsolatedBlend { + blend: rframe::ScopeBlend::new(mode, None), + }; + assert!( + blend_source_extents(&list, &Affine::IDENTITY).is_none(), + "a mutable raw list cannot reuse an old declaration" + ); + } + } + #[test] fn unknown_sibling_does_not_disable_a_separate_known_source() { let mut list = scene(); diff --git a/crates/n0/tests/group_blending.rs b/crates/n0/tests/group_blending.rs index 34e1df66..83ac3de7 100644 --- a/crates/n0/tests/group_blending.rs +++ b/crates/n0/tests/group_blending.rs @@ -1207,3 +1207,130 @@ fn a_source_generating_filter_supplies_blend_pixels_and_scope_coverage() { assert_eq!(damage.changed, [owner(10)]); assert_eq!(damage.union_frame, Some(region)); } + +#[test] +fn declared_source_domains_do_not_inherit_the_wider_undeclared_image_profile() { + use rframe::{ + BlendSourceDomain, Filter, FilterColorSpace, FilterNode, FilterPrimitive, FilterProgram, + }; + let region = rect(0.0, 0.0, 48.0, 48.0); + let declared = || { + begin( + 10, + ScopeEffect::Blend( + ScopeBlend::new(ScopeBlendMode::Multiply, None).with_source_domain( + BlendSourceDomain::new(region, AffineTransform::identity()).unwrap(), + ), + ), + ) + }; + let source = || solid(1, rect(8.0, 8.0, 24.0, 24.0), FIRST); + let reject = |items| { + let error = compile(frame(items)).unwrap_err(); + assert!( + matches!(error, BuildError::Blend { owner: value, .. } if value == owner(10)), + "{error}" + ); + }; + // An inherited output clip is distinct from an effect inside the declared + // source. The first consumer accepts the former and refuses the latter. + compile(frame(vec![ + begin(11, ScopeEffect::Clip(clip(region))), + declared(), + source(), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ])) + .unwrap(); + reject(vec![ + declared(), + begin(11, ScopeEffect::Clip(clip(region))), + source(), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]); + let mask = || FrameItem::MaskBegin(Mask::new(owner(11), MaskMode::Alpha, clip(region))); + reject(vec![ + declared(), + mask(), + source(), + FrameItem::MaskSource, + solid(2, region, CGColor::WHITE), + FrameItem::MaskEnd, + FrameItem::ScopeEnd, + ]); + reject(vec![ + mask(), + declared(), + source(), + FrameItem::ScopeEnd, + FrameItem::MaskSource, + solid(2, region, CGColor::WHITE), + FrameItem::MaskEnd, + ]); + 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(); + reject(vec![ + begin(11, ScopeEffect::Filter(filter)), + declared(), + source(), + FrameItem::ScopeEnd, + FrameItem::ScopeEnd, + ]); + let tile = FrameItems::try_new(vec![declared(), source(), FrameItem::ScopeEnd]).unwrap(); + let pattern = + PatternPaint::new(48.0, 48.0, AffineTransform::identity(), Arc::new(tile), 1.0).unwrap(); + let error = compile(frame(vec![FrameItem::Node(node( + 1, + region, + PaintStack::from_pattern(pattern), + ))])) + .unwrap_err(); + assert!( + matches!(error, BuildError::Paint { owner: value, .. } if value == owner(1)), + "{error}" + ); + assert!(error.to_string().contains("own execution profile")); +} + +#[cfg(feature = "trace")] +#[test] +fn declared_source_changes_observed_extent_without_adding_a_layer() { + use n0::trace::sink::drain_blend_layers; + for declared in [false, true] { + let mut operation = ScopeBlend::new(ScopeBlendMode::Multiply, None); + if declared { + operation = operation.with_source_domain( + rframe::BlendSourceDomain::new( + rect(4.0, 6.0, 32.0, 28.0), + AffineTransform::identity(), + ) + .unwrap(), + ); + } + let product = compile(frame(vec![ + begin(10, ScopeEffect::Blend(operation)), + linear_node(1, rect(8.0, 8.0, 24.0, 24.0)), + FrameItem::ScopeEnd, + ])) + .unwrap(); + drain_blend_layers(); + raster(&product, BACKDROP); + let observed = drain_blend_layers(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].save_layer_calls, 1); + assert_eq!(observed[0].observed_raster_layers, 1); + assert_eq!( + observed[0].observed_raster_bytes, + if declared { 32 * 28 * 4 } else { 24 * 24 * 4 } + ); + } +} diff --git a/crates/n0_cli/README.md b/crates/n0_cli/README.md index 5bc81783..72f481ec 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,423 Chromium-baked cells plus + cells. The complete primitive corpus contains 1,467 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 325 rows. `feFlood`, `feComposite`, + Bungee), and the named refusal register has 329 rows. `feFlood`, `feComposite`, `feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`, `feComponentTransfer`, `feBlend`, `feMorphology`, `feConvolveMatrix`, `feDiffuseLighting`, `feDistantLight`, `fePointLight`, `feSpotLight`, @@ -862,9 +862,16 @@ isolate` have a bounded static SVG group profile. One Stylo computed value a narrower source-extent profile: untransformed rectangular draws, including simple strokes, solid/pattern-filled siblings and own Multiply/Screen group opacity. The temporary raster origin follows their outward-rounded - drawable bounds. Mapped contributors, nested source scopes/opacity, - non-painted geometry contributors, omitted transparent/unresolved/context stroke - extents (even beside a live fill), and patterned strokes in a source that + drawable bounds. A live rectangular linear fill with a resolved positive-width + transparent or zero-alpha solid local stroke now retains a complete source + domain on its blend scope, without inventing stroke paint. This narrower + declaration includes untransformed solid/linear rectangle siblings and simple + local painted strokes, enclosing each contributor before union. Its local + endpoints must stay within ±8,388,608 and enclose the resolved painted values + without inward rounding; a repeating-pattern sibling cannot yet + enter that declared domain. Mapped contributors, nested source scopes/opacity, + non-painted geometry contributors, omitted unresolved/context stroke extents, + non-scaling invisible strokes, and patterned strokes in a source that also paints a linear ramp retain the named `linear-gradient source-extent` refusal. This conservatively includes otherwise harmless combinations; a completed child blend image is not a bare ramp in its parent. This @@ -872,6 +879,10 @@ isolate` have a bounded static SVG group profile. One Stylo computed value all-transparent gradient strokes whose geometry remains in the frame. The same patrol covers a required root boundary, including root opacity around a mixed ramp/blend source, where both admissions refuse. + An authored non-normal root blend over a bare linear source also refuses in + both admissions, including without any omitted stroke. A root source cannot + borrow the child-boundary complete-domain exemption; elided Normal root + isolation without an omitted contribution keeps its established route. Solid-only 2D transforms retain their existing admission. Non-rectangular source geometry, radial source paints, wider strokes and curved, subpixel or rotated clip coverage retain the named `group-source precision` refusal. diff --git a/crates/websem/src/svg.rs b/crates/websem/src/svg.rs index 43c4213b..3da66b62 100644 --- a/crates/websem/src/svg.rs +++ b/crates/websem/src/svg.rs @@ -3425,6 +3425,19 @@ fn compile_svg_element( } let has_root_blend_source = adds_root_blend_boundary || (root_facts.has_blend && root_patrol.opacity > 0.0 && root_patrol.opacity < 1.0); + // Only compile_child consumes complete source domains. Do not let its + // omitted-stroke exemption reach the separately materialized root source. + // Even a bare ramp with no omitted stroke exposes a different root raster + // profile under authored non-normal blending. A full-canvas background + // masking that difference does not establish the general admission. + if (has_root_blend_source && root_facts.needs_source_domain) + || (root_facts.has_linear_source + && root_composite.is_some_and(|scope| scope.mode() != ScopeBlendMode::Normal)) + { + return Err(blend_linear_source_refusal( + "with an unproved root linear source", + )); + } if has_root_blend_source && let Some(reason) = blend_linear_source_boundary(root_facts) { return Err(blend_linear_source_refusal(reason)); } @@ -3542,6 +3555,11 @@ struct SpanFacts { has_linear_source: bool, /// Contributor facts that cannot be recovered from the resolved drawlist. linear_source_boundary: Option<&'static str>, + /// Complete local drawable enclosures for the untransformed rectangle + /// profile. These are separate from painted geometry and opacity passes. + source_domain_bounds: Option, + source_domain_incomplete: bool, + needs_source_domain: bool, } impl SpanFacts { @@ -3560,6 +3578,12 @@ impl SpanFacts { .or(other.blend_precision_boundary); self.has_linear_source |= other.has_linear_source; self.linear_source_boundary = self.linear_source_boundary.or(other.linear_source_boundary); + self.source_domain_bounds = match (self.source_domain_bounds, other.source_domain_bounds) { + (Some(left), Some(right)) => Some(math2::union(&[left, right])), + (left, right) => left.or(right), + }; + self.source_domain_incomplete |= other.source_domain_incomplete; + self.needs_source_domain |= other.needs_source_domain; } } @@ -3567,6 +3591,9 @@ fn blend_linear_source_boundary(facts: SpanFacts) -> Option<&'static str> { if !facts.has_linear_source { return None; } + if facts.needs_source_domain && facts.source_domain_incomplete { + return Some("with an unrepresented rectangular source domain"); + } facts.linear_source_boundary.or_else(|| { (facts.transformed || facts.has_scope || facts.has_opacity) .then_some("with a transformed or nested linear-gradient source") @@ -3585,6 +3612,58 @@ fn blend_precision_refusal(reason: &str) -> CompileError { )) } +/// The bounded rectangle's local visual enclosure, before source composition. +/// Blink encloses its local decorated box before effect-space mapping. The +/// untransformed profile retains that resolved domain without inventing paint. +/// The integer envelope is restricted so both endpoints and unions remain +/// exactly representable in f32; wider arithmetic retains the named patrol. +fn blend_rect_source_domain(node: &FrameNode, omitted_width: Option) -> Option { + let Geometry::Rect(rect) = node.geometry else { + return None; + }; + if node.transform != AffineTransform::identity() + || node.paints.pattern().is_some() + || blend_node_precision_boundary(node).is_some() + || node + .stroke + .as_ref() + .is_some_and(|stroke| stroke.paints().pattern().is_some()) + || rect.width <= 0.0 + || rect.height <= 0.0 + { + return None; + } + let width = omitted_width + .or_else(|| node.stroke.as_ref().map(Stroke::width)) + .unwrap_or(0.0); + let half = width / 2.0; + let x = rect.x - half; + let y = rect.y - half; + let right = x + (rect.width + width); + let bottom = y + (rect.height + width); + if [x, y, right, bottom] + .into_iter() + .any(|v| !v.is_finite() || v.abs() > 8_388_608.0) + { + return None; + } + let (x, y, right, bottom) = (x.floor(), y.floor(), right.ceil(), bottom.ceil()); + // The consumer's complete-domain contract also requires enclosure of the + // painted rectangle. f32 decoration can round an endpoint inward across + // an integer even when the authored numbers themselves are unaliased. + // Keep that conservative boundary attributable here, not a late renderer + // error that best-effort could no longer roll back by SVG owner path. + let painted_half = f64::from(node.stroke.as_ref().map_or(0.0, Stroke::width)) / 2.0; + if f64::from(rect.x) - painted_half < f64::from(x) + || f64::from(rect.y) - painted_half < f64::from(y) + || f64::from(rect.x) + f64::from(rect.width) + painted_half > f64::from(right) + || f64::from(rect.y) + f64::from(rect.height) + painted_half > f64::from(bottom) + { + return None; + } + (right > x && bottom > y).then(|| Rectangle::from_xywh(x, y, right - x, bottom - y)) +} + fn blend_node_precision_boundary(node: &FrameNode) -> Option<&'static str> { if node.paints.is_empty() && node.stroke.is_none() { return None; @@ -5665,6 +5744,17 @@ impl<'a> ChildWalk<'a> { self.elide_blend(blend_id); return Ok(facts); } + if facts.needs_source_domain { + let bounds = facts.source_domain_bounds.ok_or_else(|| { + blend_linear_source_refusal("without a complete rectangular source domain") + })?; + let domain = rframe::BlendSourceDomain::new(bounds, AffineTransform::identity()) + .map_err(|_| blend_linear_source_refusal("with an unrepresentable rectangular source domain"))?; + let FrameItem::ScopeBegin(scope) = &mut self.items[checkpoint.0] else { + unreachable!("the blend boundary precedes its content") + }; + scope.effect = ScopeEffect::Blend(composite.with_source_domain(domain)); + } facts.draws = 0; facts.opacity_passes = 0; facts.has_scope = true; @@ -5675,6 +5765,7 @@ impl<'a> ChildWalk<'a> { // it with a ramp rasterized directly into the parent. facts.has_linear_source = false; facts.linear_source_boundary = None; + facts.needs_source_domain = false; } } Ok(facts) @@ -6535,7 +6626,30 @@ impl<'a> ChildWalk<'a> { .chain(node.stroke.iter().flat_map(|stroke| stroke.paints().iter())) .any(|paint| matches!(paint, cg::Paint::LinearGradient(_))) }); - facts.linear_source_boundary = if outcome.omitted_stroke_extent { + // Only a live linear fill with a fully resolved local solid stroke + // omission earns this domain. Missing/context servers and non-painted + // siblings keep their existing, independently named boundary. + let retained_omission = outcome.omitted_local_stroke_width.is_some() + && facts.has_linear_source + && outcome.draws > 0 + && outcome.nodes.len() == 1 + && blend_rect_source_domain(&outcome.nodes[0], outcome.omitted_local_stroke_width) + .is_some(); + facts.needs_source_domain = retained_omission; + for node in &outcome.nodes { + if outcome.draws > 0 { + match blend_rect_source_domain(node, outcome.omitted_local_stroke_width) { + Some(bounds) => { + facts.source_domain_bounds = Some(match facts.source_domain_bounds { + Some(prior) => math2::union(&[prior, bounds]), + None => bounds, + }) + } + None => facts.source_domain_incomplete = true, + } + } + } + facts.linear_source_boundary = if outcome.omitted_stroke_extent && !retained_omission { Some("with a non-painted stroke extent") } else if outcome.has_geometry && outcome.draws == 0 { Some("with a non-painted source contributor") @@ -11669,6 +11783,7 @@ fn compile_tspan_text( has_geometry: true, transformed: false, omitted_stroke_extent: false, + omitted_local_stroke_width: None, })); } let one_pass_fold = (replay_opacity < 1.0 && paths.len() == 1).then_some(replay_opacity); @@ -11716,6 +11831,7 @@ fn compile_tspan_text( has_geometry: true, transformed: false, omitted_stroke_extent: false, + omitted_local_stroke_width: None, })) } @@ -12408,6 +12524,7 @@ struct ShapeOutcome { /// A selected stroke can enlarge Chromium's drawable bounds without a /// paint pass. Keep this separate from opacity-fold participation. omitted_stroke_extent: bool, + omitted_local_stroke_width: Option, /// The shape's own opacity composites fill and stroke through one /// isolated layer — the walk wraps the node in a scope. scope_opacity: Option, @@ -12772,6 +12889,7 @@ fn shape_node( let mut stroke = resolved_stroke.stroke; let stroke_opacity_pass = resolved_stroke.opacity_pass; let omitted_stroke_extent = resolved_stroke.omitted_extent; + let omitted_local_stroke_width = resolved_stroke.omitted_local_width; patrol_mixed_contour_cap(&geometry, stroke.as_ref())?; debug_assert!( @@ -12810,6 +12928,7 @@ fn shape_node( has_geometry: true, transformed: false, omitted_stroke_extent, + omitted_local_stroke_width, }); } if opacity < 1.0 && has_geometry { @@ -12881,6 +13000,7 @@ fn shape_node( draws, opacity_passes, omitted_stroke_extent, + omitted_local_stroke_width, scope_opacity, has_opacity, has_geometry, @@ -14126,6 +14246,9 @@ struct StrokeResolution { /// its paint is transparent or its server is unresolved. The exact extent /// is absent from FrameNode; source-origin-sensitive groups must patrol it. omitted_extent: bool, + /// A resolved local solid stroke's width remains a drawable contribution + /// even when zero alpha normalizes its paint to absence. + omitted_local_width: Option, } /// Blink's `markerUnits="strokeWidth"` scale for a non-scaling-stroke client. @@ -14252,6 +14375,7 @@ impl StrokeResolution { stroke: None, opacity_pass: false, omitted_extent: false, + omitted_local_width: None, } } } @@ -14275,6 +14399,7 @@ fn resolve_stroke( // for Chromium's stroke bounding box. Preserve that distinction before // following the context relation (which may also have no provider). let computed_stroke_is_none = matches!(style.clone_stroke().kind, SVGPaintKind::None); + let computed_stroke_is_color = matches!(style.clone_stroke().kind, SVGPaintKind::Color(_)); // Direct colours, valid paint servers, and invalid-reference fallbacks // stage element opacity exactly as [`resolve_fill`] describes. @@ -14295,6 +14420,7 @@ fn resolve_stroke( stroke: None, opacity_pass: false, omitted_extent: !computed_stroke_is_none, + omitted_local_width: None, }); }; let owner_data = selected @@ -14318,6 +14444,7 @@ fn resolve_stroke( stroke: None, opacity_pass: false, omitted_extent: !computed_stroke_is_none, + omitted_local_width: None, }); } SVGPaintKind::Color(ref color) => { @@ -14363,6 +14490,7 @@ fn resolve_stroke( stroke: None, opacity_pass: false, omitted_extent: true, + omitted_local_width: None, }); } @@ -14377,6 +14505,11 @@ fn resolve_stroke( stroke: None, opacity_pass: true, omitted_extent: true, + // A previously inert vector-effect grammar is not newly admitted: + // an unproved spelling simply leaves the composition patrol intact. + omitted_local_width: (computed_stroke_is_color + && matches!(resolve_vector_effect_space(el), Ok(StrokeSpace::Local))) + .then_some(width), }); } @@ -14538,6 +14671,7 @@ fn resolve_stroke( stroke, opacity_pass: true, omitted_extent: false, + omitted_local_width: None, }) } diff --git a/crates/websem/tests/svg_blending.rs b/crates/websem/tests/svg_blending.rs index 8dfb84dc..6e6e74a0 100644 --- a/crates/websem/tests/svg_blending.rs +++ b/crates/websem/tests/svg_blending.rs @@ -37,8 +37,11 @@ fn linear_source_extent_patrol_is_transactional_and_names_the_owner() { format!("{RAMP_RECT}"), format!("{RAMP_RECT}{RECT}"), format!("{RAMP_RECT}"), - RAMP_RECT.replace("/>", " stroke='transparent' stroke-width='4'/>"), - RAMP_RECT.replace("/>", " stroke='red' stroke-opacity='0' stroke-width='4'/>"), + RAMP_RECT.replace( + "/>", + " stroke='transparent' stroke-width='4' vector-effect='non-scaling-stroke'/>", + ), + RAMP_RECT.replace("/>", " stroke='transparent' stroke-width='100000000'/>"), format!( "{}", RAMP_RECT.replace("/>", " stroke='url(#empty)' stroke-width='4'/>") @@ -91,6 +94,176 @@ fn linear_source_extent_patrol_is_transactional_and_names_the_owner() { } } +#[test] +fn omitted_solid_stroke_changes_only_the_complete_source_domain() { + use math2::{Rectangle, transform::AffineTransform}; + let source = |attrs: &str| { + svg(&format!( + "{RAMP}{}", + RAMP_RECT.replace("/>", &format!(" {attrs}/>")) + )) + }; + let ordinary = compile_standalone_svg( + &source("stroke='none' stroke-width='4'"), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + for (attrs, expected) in [ + ( + "stroke='transparent' stroke-width='4'", + Rectangle::from_xywh(6.0, 10.0, 43.0, 34.0), + ), + ( + "stroke='red' stroke-opacity='0' stroke-width='4'", + Rectangle::from_xywh(6.0, 10.0, 43.0, 34.0), + ), + ( + "stroke='transparent' stroke-width='8'", + Rectangle::from_xywh(4.0, 8.0, 47.0, 38.0), + ), + ] { + let source = source(attrs); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)).unwrap(); + let best = SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!(strict, best.base_frame()); + assert!( + !best + .degradations() + .iter() + .any(|d| d.action() == websem::DegradationAction::Skipped) + ); + assert_eq!(strict.owner, ordinary.owner); + assert_eq!(strict.bounds, ordinary.bounds); + assert_eq!( + strict.nodes(), + ordinary.nodes(), + "no fake stroke, paint, or geometry bounds" + ); + assert_ne!( + strict, ordinary, + "the former full-Frame collapse is impossible" + ); + let mut domains = Vec::new(); + let without_domain = strict + .items + .iter() + .cloned() + .map(|item| match item { + FrameItem::ScopeBegin(mut scope) => { + if let ScopeEffect::Blend(blend) = scope.effect { + if let Some(domain) = blend.source_domain() { + domains.push(domain); + } + scope.effect = + ScopeEffect::Blend(ScopeBlend::new(blend.mode(), blend.opacity())); + } + FrameItem::ScopeBegin(scope) + } + item => item, + }) + .collect(); + assert_eq!( + rframe::FrameItems::try_new(without_domain).unwrap(), + ordinary.items + ); + assert_eq!(domains.len(), 1, "root isolation makes no declaration"); + assert_eq!(domains[0].rect(), expected); + assert_eq!(domains[0].source_to_stream(), AffineTransform::identity()); + } + let zero = compile_standalone_svg( + &source("stroke='transparent' stroke-width='0'"), + InitialViewport::new(64.0, 64.0), + ) + .unwrap(); + assert_eq!(zero, ordinary, "zero width contributes no source extent"); +} + +#[test] +fn source_domain_is_complete_order_independent_and_absent_on_elided_isolation() { + let omitted = RAMP_RECT.replace("/>", " stroke='transparent' stroke-width='4'/>"); + let sibling = ""; + for content in [ + format!("{omitted}{sibling}"), + format!("{sibling}{omitted}"), + ] { + let result = frame(&format!( + "{RAMP}{content}" + )); + let domain = blends(&result) + .into_iter() + .find_map(|blend| blend.source_domain()) + .unwrap(); + assert_eq!( + domain.rect(), + math2::Rectangle::from_xywh(3.0, 5.0, 46.0, 39.0) + ); + } + for content in [ + omitted.clone(), + format!("{omitted}"), + ] { + assert!(blends(&frame(&format!("{RAMP}{content}"))).is_empty()); + } + let result = frame(&format!( + "{RAMP}{omitted}" + )); + assert_eq!( + blends(&result) + .into_iter() + .filter(|blend| blend.source_domain().is_some()) + .count(), + 1 + ); +} + +#[test] +fn an_omitted_stroke_cannot_make_an_unknown_sibling_domain_complete() { + let source = svg(&format!( + "{RAMP}{}{RECT}", + RAMP_RECT.replace("/>", " stroke='transparent' stroke-width='4'/>") + )); + let error = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(error.contains("linear-gradient source-extent"), "{error}"); + 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("linear-gradient source-extent"))); +} + +#[test] +fn source_enclosure_rounding_refuses_at_the_svg_owner_before_consumer_preflight() { + let narrow = RAMP_RECT + .replace("width='38.2'", "width='.7'") + .replace("/>", " stroke='transparent' stroke-width='.0000001'/>"); + for mode in ["multiply", "screen"] { + let source = svg(&format!( + "{RAMP}{narrow}{RECT}" + )); + let error = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!(error.contains("linear-gradient source-extent"), "{error}"); + 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("linear-gradient source-extent") + )); + } +} + #[test] fn root_linear_source_extent_refusal_cannot_silently_fall_back() { let base = svg(&format!( @@ -115,6 +288,49 @@ fn root_linear_source_extent_refusal_cannot_silently_fall_back() { } } +#[test] +fn root_linear_blend_sources_cannot_borrow_the_child_domain_exemption() { + for (style, stroke) in [ + ("mix-blend-mode:multiply", "none"), + ("mix-blend-mode:screen", "none"), + ("mix-blend-mode:multiply", "transparent"), + ("mix-blend-mode:screen", "transparent"), + ("isolation:isolate", "transparent"), + ] { + for backdrop in ["", ""] { + let source = svg(&format!( + "{RAMP}{backdrop}{}", + RAMP_RECT.replace("/>", &format!(" stroke='{stroke}' stroke-width='4'/>")) + )) + .replacen(" The [B2a source-extent correction](./svg-engine-of-record.md#b2a-linear-gradient-blend-source-extents) > adds exact off-phase gradient controls and narrows unproved source > combinations by name. It closes a silent-pixel defect, not these rows. +> The [B2b source-domain rung](./svg-engine-of-record.md#b2b-complete-blend-source-domains) +> now carries a resolved invisible solid stroke's source contribution beside a +> live rectangular linear fill. Its forty-four exact cells remove two bounded +> refusals; wider source spaces and blend values still prevent a tick. ### CSS fonts diff --git a/fixtures/web-first/README.md b/fixtures/web-first/README.md index 7b0404ac..bcc0097a 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,423 primitive cells plus 16 sampled frames, those twenty-four text -witnesses, and 325 named refusal rows. Pixel cells use byte equality: what each +estate is 1,467 primitive cells plus 16 sampled frames, those twenty-four text +witnesses, and 329 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,7 +28,9 @@ pixel claim. | File | Role | | --- | --- | -| `svg-group-blend-extent-multiply-stroke-{none,clear-gradient}.svg` · `svg-group-blend-extent-screen-stroke-{zero-width,transparent-fill}.svg` | Four exact controls for the review-discovered omitted-stroke extent boundary. A retained all-transparent gradient stroke still supplies its geometry; `none` and zero width create no stroke extent; transparent fill does not lose the surrounding live gradient stroke's extent. Dropped transparent/unresolved stroke paints are separately refused rather than confused with these branches. | +| `svg-group-blend-source-{multiply,screen}-{fractional,zeroalpha,integer,width-one,width-fractional,pixel-boundary,edge,sibling,sibling-reversed,opacity-half,opacity-near,outer-opacity,leaf,nested-neutral}.svg` | Twenty-eight exact source-domain cells: a live linear fill retains the source contribution of a transparent or zero-alpha solid stroke. Fractional placement, width changes, source union/order, combined versus outer opacity and boundary ownership exercise the complete enclosure without adding visible stroke paint. | +| `svg-group-blend-source-multiply-{dashed,bevel,width-px,currentcolor,normal-elided}.svg` · `svg-group-blend-source-screen-{round,width-percent,inherited}.svg` · `svg-group-blend-source-{multiply,screen}-{two-ramps,painted-stroke,outer-clip,use}.svg` | Sixteen exact adjacent controls: alternate omitted-stroke spellings, an elided redundant isolation, multiple drawable enclosures, a live stroke contributor, ancestor clipping and unpositioned local instances. The [source-domain evidence](../../docs/wg/consolidation/svg-engine-of-record.md#b2b-complete-blend-source-domains) separates these admissions from the retained source-space refusals. | +| `svg-group-blend-extent-multiply-stroke-{none,clear-gradient}.svg` · `svg-group-blend-extent-screen-stroke-{zero-width,transparent-fill}.svg` | Four exact controls for the review-discovered omitted-stroke extent boundary. A retained all-transparent gradient stroke still supplies its geometry; `none` and zero width create no stroke extent; transparent fill does not lose the surrounding live gradient stroke's extent. The newer source-domain cells carry resolved transparent solid strokes; unresolved/context omissions remain separately refused. | | `svg-group-blend-extent-{multiply,screen}-{fractional,integer,opaque}.svg` · `svg-group-blend-extent-normal-fractional.svg` | B2a source-origin controls. B1 silently differed at 28/56 pixels for the fractional translucent pair and 50/84 for its integer-position pair, all at delta 1; the new cells are exact. The old gradient origin `(8,8)` hid the device-dither phase error. Normal and opaque-ramp controls separate source materialization from stop translucency. | | `svg-group-blend-extent-*.svg` (remaining fourteen cells) | Exact simple stroke/fill, own and outer opacity, sibling union/order, repeating-pattern sibling, gradient direction/transform, leaf blend, local use and offscreen-source controls. The [B2a evidence](../../docs/wg/consolidation/svg-engine-of-record.md#b2a-linear-gradient-blend-source-extents) records the bounded correction and its conservative named refusals. No general source-space or timing claim. | | `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. | diff --git a/fixtures/web-first/STATUS.md b/fixtures/web-first/STATUS.md index 0368ec65..7dc10b48 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 (1423) +## Chromium-baked cells (1467) Cells are checked against their committed Chromium oracles using exact bytes unless a manifest entry declares a measured, bounded @@ -732,6 +732,50 @@ to its fixture source. No new image is committed for this view. svg-group-blend-screen-opacity-small-partial svg-group-blend-screen-stroke svg-group-blend-screen-transparent +svg-group-blend-source-multiply-bevel +svg-group-blend-source-multiply-currentcolor +svg-group-blend-source-multiply-dashed +svg-group-blend-source-multiply-edge +svg-group-blend-source-multiply-fractional +svg-group-blend-source-multiply-integer +svg-group-blend-source-multiply-leaf +svg-group-blend-source-multiply-nested-neutral +svg-group-blend-source-multiply-normal-elided +svg-group-blend-source-multiply-opacity-half +svg-group-blend-source-multiply-opacity-near +svg-group-blend-source-multiply-outer-clip +svg-group-blend-source-multiply-outer-opacity +svg-group-blend-source-multiply-painted-stroke +svg-group-blend-source-multiply-pixel-boundary +svg-group-blend-source-multiply-sibling +svg-group-blend-source-multiply-sibling-reversed +svg-group-blend-source-multiply-two-ramps +svg-group-blend-source-multiply-use +svg-group-blend-source-multiply-width-fractional +svg-group-blend-source-multiply-width-one +svg-group-blend-source-multiply-width-px +svg-group-blend-source-multiply-zeroalpha +svg-group-blend-source-screen-edge +svg-group-blend-source-screen-fractional +svg-group-blend-source-screen-inherited +svg-group-blend-source-screen-integer +svg-group-blend-source-screen-leaf +svg-group-blend-source-screen-nested-neutral +svg-group-blend-source-screen-opacity-half +svg-group-blend-source-screen-opacity-near +svg-group-blend-source-screen-outer-clip +svg-group-blend-source-screen-outer-opacity +svg-group-blend-source-screen-painted-stroke +svg-group-blend-source-screen-pixel-boundary +svg-group-blend-source-screen-round +svg-group-blend-source-screen-sibling +svg-group-blend-source-screen-sibling-reversed +svg-group-blend-source-screen-two-ramps +svg-group-blend-source-screen-use +svg-group-blend-source-screen-width-fractional +svg-group-blend-source-screen-width-one +svg-group-blend-source-screen-width-percent +svg-group-blend-source-screen-zeroalpha svg-group-blend-translate svg-group-blend-translate-isolated svg-group-inherited-fill @@ -1451,7 +1495,7 @@ to its fixture source. No new image is committed for this view. svg-visibility-rule-beats-attribute svg-visibility-unhide -## The refusal register (325) +## The refusal register (329) What the slice refuses, by name, in the compiler's own words — **both refuse** is a document-level contract; **declared** renders @@ -1584,9 +1628,7 @@ its row into the cells above. | `svg-group-blend-linear-extent-stroke-empty-gradient` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | | `svg-group-blend-linear-extent-stroke-missing-reference` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | | `svg-group-blend-linear-extent-stroke-none-fallback` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | -| `svg-group-blend-linear-extent-stroke-opacity-zero` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | | `svg-group-blend-linear-extent-stroke-sibling` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | -| `svg-group-blend-linear-extent-stroke-transparent` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | | `svg-group-blend-linear-extent-transform` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a mapped source contributor needs the linear-gradient source-extent profile | | `svg-group-blend-linear-extent-transparent` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted source contributor needs the linear-gradient source-extent profile | | `svg-group-blend-linear-extent-viewport` | declared | skipped svg/svg[1]/g[1]: unsupported computed style: mix-blend-mode/isolation with a mapped source contributor needs the linear-gradient source-extent profile | @@ -1616,6 +1658,12 @@ its row into the cells above. | `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-domain-enclosure` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | +| `svg-group-blend-source-domain-non-scaling` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | +| `svg-group-blend-source-domain-pattern-sibling` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with an unrepresented rectangular source domain needs the linear-gradient source-extent profile | +| `svg-group-blend-source-domain-range` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a non-painted stroke extent needs the linear-gradient source-extent profile | +| `svg-group-blend-source-domain-root-bare` | **both refuse** | unsupported computed style: mix-blend-mode/isolation with an unproved root linear source needs the linear-gradient source-extent profile | +| `svg-group-blend-source-domain-root-transparent` | **both refuse** | unsupported computed style: mix-blend-mode/isolation with an unproved root linear source needs the linear-gradient source-extent profile | | `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 | diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-bevel.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-bevel.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-currentcolor.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-currentcolor.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-dashed.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-dashed.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-edge.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-edge.png new file mode 100644 index 0000000000000000000000000000000000000000..9510fe660b6ac4ea30596eda46529837db16c0d4 GIT binary patch literal 483 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFwXFFaSW-L^Jb=_-w^{I*SAZg zSdxymO)g-}JIH9kQ~pTqjU_|0nTJ}Tap&ciOjpiTDE(c&rRa{y$A0y%Nz0yJHtfoata+-;f1h39iCE&QHfw1r+nVLCmT;#!ym|KLDbI;awsjAArt~M4 zvmMd9chvBJeshVw|2n5}lf^5BR|o1HWgTR1aDbR@0qg=BI8nb7~b2l{3JF~4?NX_ofT;qwu|D9R*6DY#q>FVdQ&MBb@ E0CDEjApigX literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-fractional.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-fractional.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-integer.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-integer.png new file mode 100644 index 0000000000000000000000000000000000000000..181025f6cc3272d68fb60df06b9c10c5e9abee53 GIT binary patch literal 461 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFgAI*IEGZrc{9`3@2~?;Yp(j# zgyT$|+#4S7z1hLt{*W)GUAT}T*#DWiMyBwk2*>xjcUB%Zo|zwWI_TE^z4aF+s+JTm z9*Ac6AjYtVl_4L<{Cnld_v_1x{O_zRIVJ14KK<6_RKw+!yB@skIJ$3BivO3{Q%#<2 zIJKsiz2D=L@MaskJM(`X6}Q^luz4f@RfB6r>#yeYoZQB_W$O_s<;!+9KOVbzX`3=H zxY_c2>F3l!)7iEkYkU{1Ref4=?a)cfkeS=qB~lyoQX5uH?)}hieJ$&}8uK1+wiiKc zukIh?sNeBeYW2y6cas>eI9O#X+C=egsorMlV9s!34_n4Ewij#+D;kA5?KW^R)Gl!O zcUa0neg{*+XDNj*jj0STm|rm0vpDchIdR{#!LESyz%x^a7tE&_F0hxqWS;!q?fty? z^A+D@swKTL&xqvM@@<>d+x)P}ePYP88v&D!=cs32GTVb@t?nfo8cZ)!*+%T sx(t}iI$&J<|Nohpl^e)l1H}e2Lyf|mWxnkn6M-TOp00i_>zopr0LMbZ)Bpeg literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-leaf.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-leaf.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-nested-neutral.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-nested-neutral.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-normal-elided.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-normal-elided.png new file mode 100644 index 0000000000000000000000000000000000000000..70ec02e2ec5f9152312aceee08d4132f0a2bf33b GIT binary patch literal 609 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFo}7(IEGZrc@uds@3sL?ZOEL< z`x1}e{U&<2E>(WB|4sk5W(f}Zhxu}E9Z7QSn)Gx_Uet|7frj@7E^JZyII&J`0?+-r zKSy+x9~^tgQgxW~OPlZuUiAyoehbXomP*fm`Cr}2s6*3bV@Peoq8PS$kFw%R&-l;y zBhzlBG4bO^f%$wlx(<~8`kVUwy~sCrsY$k}(rkrC#I3vT&dspzl6B)s_Bfa4V^?-k zTzk6W?HM{}PA&c6s2nuCefR$M-S$5e?p3rb$vU$&Xr*6Hpc3zr-9-~$&seLsz5m

fb<6wQ*G&UZ!6 zRCdFa3nre~{I_|R^5@j;+pWsDtUQw;KXbvl{Yx0Lsa@;n4+#Ud_1Z1TvWx6!__%;k)$_onqP3=-8s7Tas6u#$S_@A z%krjf-}C?XcT@@7O|=N!(q2-(anaJgWs0vm&nEZ0y(bpw*JA47Q$OeHFPXSQ!lswL zaGX5#qmobMtQT|Sv1ukBUL6Y05ZW*;KGN%&vft)Q44qRxs$BW=KqYSqquwbc=Ew`p zX%1bRPB@5tSs-b^cCNv&fX(V2qvT)N2NUlx{&0}}(ZDGGg3T`B0Q;^+{wD|6ts0=D z&ThpY4&ome51(gWRlfN0ZR?XhKljJlEx+|L_SN&%?q_W~S8yflP0!tzwb%Z~frm(b uM`arCX|M$b^8f$0neCc@3^q_OGc!E2x+Y@tboNA`2!p4qpUXO@geCwsow2?E literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-opacity-near.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-opacity-near.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-outer-clip.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-outer-clip.png new file mode 100644 index 0000000000000000000000000000000000000000..0cd64d3abd3343c8d0d55473432aeb946487944c GIT binary patch literal 571 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFn;iKaSW-L^JZqe-(druWA@8u z@8CEu(_~%1!gtvE`D0#AMY#zR5)K4SzH#orr=wRZ=OVSNPZDXn{2mXXJ5>;lX1G1_0E4H zw#akKtcYU>PcJ`h@SkV1s;QxGhFIph&o)7uRV=-JE9xwqthUUP!%)7}EPq+$Wu+}A z?fM;`Dc+hBH(&0a{QJTMzxFy=W>l(lik)ma@lAEXCU+HX+o=cAtEU;Ch@E!h@VirM zYqsCu39x6VTu_lIclaL5R~^fQ7K6zptL!?Dw|e>V#{6l!7plb*c%SDr!I!=uXFqxfnzqh4pYxfff{^3x8Cx6UwZ3FvL_U7jrQ%$9F0*TbSN<-1{3=iIl)8^5dY3eUa1_QL+E z*AuFi?!M7|P3Xgs_xyiSXI%?Oz7e-}YpjI%yeSq1*Ydh~zfONVcdMb~VZZ2vBZh8F z|36p0vpW~k*{ZDa*LbqU7PqEm_Swb{@6N9CzxZ~A#QVvQGryU8hOIc(#4sLbc?#}Sh#Xp|v1 zfj*-sP!)S^f-WOS)SSyf{={@=`4wUTfy`5?F0fw{YOrgtD_Go+{H5_x{^I;+8xtkN zrhO5!`7A0mfAR9&FCU&dbj;Q0Sj^0009jNklLp zpIya~{3&Zq;>4NHcp>?^ma zLi0hWG>7t3APanIn^R+GhT6cq<#F(8KE8T(G6DAia!<0=f@S4l)2>uCONs2sz=I*J zPJ6!S?5wI(A$y#{Zz?MfoAx~ouSI}Dxv_S5vf8wV&dsY99k3#hc|}DTYKJw^_Rh(& zYnY!IgBBgIB9M87MHy;_)9cQwwNBdB-f6fj2cW|bZ0f7YfyaqJR}O%i0}a4V`-yTO zzFV!of7ZKEF^(b`PhWCbTc()|y+c z?(BlV*2${ofK^GYnnY(iTt!7VsOpTJ$3!0LfR2EiU#G*##QG-2f46b;z;lmDM8wk{ zX;mhzwUTy@9|yp!{vf#=un1n!k&SR!4oqIZIqcYe`?TNa{Q|F|bPHg$X7A99{&b)+ zqN@XG{UN_~pfaMX1KF2=*E#_1+g%;Nto2I=M3*DwUMQ_UNS^6{=yK%J96)!tL-Rsh zmIK~>4>+jL0r2d5;J65wcMu&>h0K$=UqJ^t5pStRXgX4KCsu#Kc>=nzQ6oo;~|0LDIYWxC){+7^pcyqO0X$l zPTM2?SDlY1c;)`9FidSeBi6_9y?p!f!mq*?ZytD_9LpEjZnA%MP5XwU-uJXM?Zl>Z z-m;lCH#KzYbioO)0?tN8%JQxj3M||#yjp$krmj_G8&r3jHO|=+-^l$#ljr)Eom;NF z=Q>fcsq&0n@m(&H@~1{qd|U0bQ}<0c&h*q?Yr_Keid_B=j@1pjZ5@8UXMFjM;pbO} z=AUv4=6+=;QRu(WD7S$5LgQJMPr3`3S!8c`F<3FkMliiNFmrp2Bl|RFtAOf-%-2;X zOnx8Bu6Dooo{e30UTv*s)l%mTe$J`p-e$I&zy1`TJ?UrXsu?Ddho))y2wB+LToi1u zw?G7w!vSf92fP9w*kGY&VbiD&jF$iZcia_X12Wh^5yQ;D?AEp5c-6DpKoJH{S3j3^ HP6BS7qFUm z1yqI3yy$b)FJ79jZqB@+`fPfyQHSi&r$*?nPKljPsK{$-47ZNI!x zpUtSe+*420TBsi<)#H0 ze#If>qr52t`?^NC)qx*+b$%}S^!LxE$n}A{S5NsEHM6ZXK#;ZN(t(AcKkX*O{C+W) zJ@yAj|AfY8eT#e@gVRF-on#QZ#@_TlYZi~m`Vc?) z83D|Gw%v9HYuTi{pDU-!Th#G>iRe9)n9ZQ9{q8*TCH5u!PaJ+ZY+w4NF~>vRmV+&24(>~;-_LW=~{H+@|F*J*nw@0?|7fiF}WzD7zdi*%}c0E!=6 t2pFGrj0_C_|2GTUya1*cHc(PwW|$acSuOh2s2(W7;OXk;vd$@?2>?6p5wQRO literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-sibling.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-sibling.png new file mode 100644 index 0000000000000000000000000000000000000000..6793f068a797ad005f52455df8621541aad60965 GIT binary patch literal 585 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YF#hv&aSW-L^Cr^U@34Wu+(Ml$ zhuF>O2Sslvy*rd>A>{6mbas>Xl6MX|5g`Ug3je4sEWDMKlsQkS{Ljp^dp9m$U-s+Y zL&2=Jrc;V6I|6MP->!Z8Ij!sP)wiZPoJB1=fPz4@ko)@Kcgfxj4IKMSHR_~Tn{w@BNabq>d@wJQ`3m;CA;`rv2 zb%6i-^XhnBmTF7W3%Z5J=1F-J>ALWWvu(2NfMT&3a6IdWjs>D z?zXokW{#CW?dpxz3GYMt`Od6hwBN%aJ0qO=+U4UXRx&SN53)~YJDb7BNB?XUtQ4#a zSQjxLp0|j3vZB?53}paL9l^R0yD#rMe^z0wuvD?5e83J KKbLh*2~7a4feFU| literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-two-ramps.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-two-ramps.png new file mode 100644 index 0000000000000000000000000000000000000000..ff8154591aa12d387be75e9b8075a3bcd068a59f GIT binary patch literal 749 zcmV0007_Nkl2YL`>eK-~$ zX^N1h{ru%CtQi7Uj!GamTJz}t-pkV?pa5*RkZK@zi5UiMZeZI8E>H$6f#c8b$HP4} zd)e&Bl`R(#8xmU*8)!BlS}r#??{43hLLkzE;(6i``XB@d`iN9#@CF)0Wyivg-J@M>tou4iA_k#PWj^^!W`2-Y_810--|BYT;EePV5pdR(r~gDGk&^)DwN@3uHMg6G z-v`+rRLbKi)>R2KI^PM?7`sW^Ho(h|y5{%b?T77*^l~N!YzFWKC}v3!Z*&vBb;nuq zUTkHCJOeXvCI&ExX&khR#VLB~Xo+O)x8q)nW0vH4_TF&O1K=zN3wZ#p>H+jDw~zj+pHMO90zlWCOugp&lft3tidSnuR3kvwSn?VTq7^M!F^*Frvc9h@mt8w&u(& zfUk}2BL+JOlo$gLtmm}ReS1qcAyQ5rfMC6`*6SfA`_eoQz$@kf2-X{Gy`J?zDpFD& zfM7kRjTu;Z4A`A<;TTvb52V=NecH($ecOz?xUUvYdBo{x_AaY=5JSn^#4Hle5*RbX zY};H1^^fk{s04DO63C58AU7(3+^7U{qY}uCN+35Xf!wGBa%1%bo&W#<|Nk^Z|B?Uz f00v1!K~w_(B#F-D^3R_?00000NkvXXu0mjfe9lce literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-use.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-use.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-width-fractional.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-width-fractional.png new file mode 100644 index 0000000000000000000000000000000000000000..6f916390cb8e5b2cabbeb4a161b2b59462951ef1 GIT binary patch literal 470 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`*oIEGZrc@r7vci4bu>*X%R z3+oPA3Y0%InA2yi@bM_We2v1g#7SzINxS;CMWt%(U9MRsWBz&b_qtu*9)9fH#^Wx{ z&Jn@PvWAiA8Utf?Lxbt9jm?jb&z7<4yitGTy`@FwSHZ1I&-G_dm-#(u^5e>L>Y4}t zaF`{`aU?r9CR&@MQPISwZUFfPCMSc`)|wsNx$dp5M0)3;k;m-8rzN^R<3n7 z16IHAsP%uvv9!o`b&Q`si!2u~v+$)E%<@8Kg8C z*!&o!Jicae^ZKxxOn7xwOlk)QV}^q(hm-|xhC*rvLVN~ahQig=x+VpJOcxruE_g{v z)S6e-mhb;rkjQb^zx z*uaJwWI*BB>K~XH82&8P=vwL)z4*}Q$iB}bZf;7 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-width-one.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-width-one.png new file mode 100644 index 0000000000000000000000000000000000000000..a214b6d4bd55f9a384c86e5e8c5f7877127676fc GIT binary patch literal 530 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp}GaSW-L^CmLZ@2~++-{mgF zx^)LlC+y&Ee^?~b#2#1ikdd=9bH*Qw>|NVDttH}?z7H0$TsimG=Esq*ZnYm*-<%e) zxu~JR%)vogVF9ne1vZY1#dE7R{H}QXHa)@P%{7-g39i#Br~5oNg?~O}sOM>a(x^Cp z@}V~ITgQ5jGR{@`@b%N4+IrI~8**=2K1sW6aejtWvb25s!IaCFb_uqw5RSO{S6Tme zanl=?=UaT8ZtUE-Z^zy!p9KjHYA$h3X){u07ft2#UjD9k=Xxg-oA{J-V%-4>3U%-G zIg2Ok{HOM=W{HpWWcL~VEQ{Nc*i!o5r!M+iYig?PXur-z>z~k3o)bFdor~*ZzA0~( za+~_QTTiSe<Y$X_K6BxcQerdFHke7(+^nStoxxvmLPt`$w1#@je zMyuThju*_2r@dg_yz!Tv!Oz?;pWfAd-ut?BL$U3C*S*$73cshEWRJb6H7BkA)Ik-- zEc5pLrRki14laa*Ei=m&My6W~jJZfbe(V+JA7F(3|3Cd? QEP)~np00i_>zopr0DuDGng9R* literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-width-px.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-width-px.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-multiply-zeroalpha.png b/fixtures/web-first/chromium/svg-group-blend-source-multiply-zeroalpha.png new file mode 100644 index 0000000000000000000000000000000000000000..de5227eeecaa1cc4e784b7b2f2f72ce9b9363e76 GIT binary patch literal 468 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFm`&nIEGZrc{4LG>#zY&o2vSn zF}G9-G>wWt8X5bbMszpwK4W~;pJ-!7fAict6$p%nzq$f|6ef8 z@QQ`N0$!F3X2x3%4Q2uheC@Yy`2Bn0TYJIV?r-L>AMU?4**9~`T?@9|)tm?OY>!NK zN&oRCkYmSDs#4vQW@;#-LLS_qCXpOktdP-l3-_J$DsbQi_ds)&ncO zw1(xT0d056c^B}z1TZgmkU!xdzk#aQ0=8F<@*kd` zz5jyw`sEtlGylwf{rI%&)_aS+($Bv9=2j1!DEo2hWB++q12)c%R7nbB_`doR-+O^S y2Nxm*5-RiZo(5)Ml>GnCme%tC$Y29S3Nu5{f}%>RbIPZIA`G6celF{r5}E*p{?HBp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-edge.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-edge.png new file mode 100644 index 0000000000000000000000000000000000000000..912b478d1336737d8502a2bb04b068b3321a5475 GIT binary patch literal 528 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp@EaSW-L^Jb=_-w^{I*SCS; zITI~dW14m=6teprI9tU&O(x;S28~(AOgb<7T*?Z$7`E)vf2VcozQOj@I^I5?|9tXu zzt+jO?%B(KejP0qQOEx&ozc5fmRNhuv;V>mi>3Wl;TfAe&h32vdFHm@&%b^YEDSzx zbEA9v9e(*v_WQRVgx4RIo1Wt@BBuF6E_$uY{q@$Ieh*&mn%}=|>D!V?7kPK4*Gty$ zc|NYHj(C=%s$0A<`bt4f!=sa*-Pf@`P}(9gxkY@^mD|ZOj~1Wbvc!7z-H)d$=1#b& ze4Q!d!=!st^g?%8)SW(lr+9aF&6dq891cuc z#bn7jYkJ57hkd#YOBkkqYw*!t@HO%h^PX`27av{uPfU^vV0k#(c0)4jmy(5>*mu0S zct`rO+yqvz$d_I0DYuv{`*QyK^9wv~ruQ4X@ zCGh1iG*~uRZeVg~wtpj&aqpJD;w6`r|%3LxL_t z1UJJqriQKfnZH;W82PB}IUD8k_B>gTe~DWM4flMv}k literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-fractional.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-fractional.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e5502d17c73439bf14fdf2fe43e3fe837d1ddf GIT binary patch literal 531 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFrM&qaSW-L^Jb>E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=6AG0A(>wNdU z?Cy?_^kmw&3t0RP?P5ryj!RaVp- znsqhtsZzssjt9C-72OQ?fK0RHGUvaS+wQX6weK}wSKKbf^q<@AmC0T$W~}keDs#5c=kD+M5(lW$TDzTNCZ7 zo39#O-f`LbZCY~ls#)7&mPj@f1lb&3bIroUo!=zAzhRbOs?f|Q<$EScla^EJ(+FRiIiE4zJ<%Xbvul9on9-h{Be5a zS00@;j4Sh&OF2X{TLm*q{#?Pf#i}oxVf#wPEv5>_rVabAFwBxVAeGIqdUfNmvvwc& z8NM-oYiwnB!~BMsoACy_LE&cpIlKq#4%lrFXsB(dO;BX`eZcqEfs^Tr_4DUT$=9}h zXs^iI`cbU?hxFxRSL=?JI7;!fuF_j)eB*sxfn&q<{>}3@@9Ox^4-G}607YWfF)}dx l|6koH*$a$CHc<32Gg#FYZu~#rnH4C);OXk;vd$@?2>@hy*{%Qp literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-leaf.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-leaf.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e5502d17c73439bf14fdf2fe43e3fe837d1ddf GIT binary patch literal 531 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFrM&qaSW-L^Jb>E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=YueJ%-@m^3WiMMa zm6`FDLxY*X0$!F3ATuXe*uQ-C-DN`AMW2h^%+2HqxA8pGxjf?-hxnxuyLUIj7vXcmC!zi<=jJJ$+-C7k10zWzI_`kBkoz<&UquRMI_mGrs+B z;r``5|7u&0$lv)nvEy?~31{-RPsUv7oOhj$o>|TK|J0Guz{q@-i3qAcwm->3S7L-i`b+$RUFiNI>Rl@ekY#4FCUU jxp^i4F6|XJ?iZFP(`njxgN@xNA+%d%C literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-opacity-near.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-opacity-near.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e5502d17c73439bf14fdf2fe43e3fe837d1ddf GIT binary patch literal 531 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFrM&qaSW-L^Jb>E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=rr=wRZ=OVSNPZDXn{2mXXJ5>;lX1G1_0E4H zw#akKtcYU>PcJ`h@SkV1s;QxGhFIph&o)7uRV=-JE9xwqthUUP!%)7}EPq+$Wu+}A z?fM;`Dc+hBH(&0a{QJTMzxFy=W>l(lik)ma@lAEXCU+HX+o=cAtEU;Ch@E!h@VirM zYqsCu39x6VTu_lIclaL5R~^fQ7K6zptL!?Dw|e>V#{6l!7plb*c%SDr!I!=uXFqxfnzqh4pYxfff{^3x8Cx6UwZ3FvL_U7jrQ%$9F0*TbSN<-1{3=iIl)8^5dY3eUa1_QL+E z*AuFi?!M7|P3Xgs_xyiSXI%?Oz7e-}YpjI%yeSq1*Ydh~zfONVcdMb~VZZ2vBZh8F z|36p0vpW~k*{ZDa*LbqU7PqEm_Swb{@6N9CzxZ~A#QVvQGryU8hOIc(#4sLbc?#}Sh#Xp|v1 zfj*-sP!)S^f-WOS)SSyf{={@=`4wUTfy`5?F0fw{YOrgtD_Go+{H5_x{^I;+8xtkN zrhO5!`7A0mfAR9&FCU&dbj;Q0Sj^000A*NkliKzH}7DZ!C=d=^#lU(ef8kz-XKZ<=NQa^Es)QF z$6^9Zj*y56VM>?~WB`kci|?nW;06i;clX1G2Rp*c2@56=PLa}hx~L?<&)pm1zyc&W4jr(g(NTi@uu29`%xWx#0-z|sO&8Q2;Pj0Vvl z3aD_p*vSEp_9Eg?qU)g`BW;9nG3I3E zSuqE>QZ)GUFXQv^7*>R8WbGOt((BmJfFvt+%K#S}Xa!b}q{Cugrx7ozPKedPs;f0L zP|kEemcEu6AahqFxh@T{a@N)fT6Cld)%j=}TJv~gHIFR~n7HbFc@G3K5hW`FL?+<& zNo8dKsm`Z%FWe{t<|<8?V3LZTop-P@pmT-wqNyy)*k9T-^G!0aOat15H8lWWXAICO z4Zx}zfPb%nS|7O6k6u3?&5?@-(rCE%V|OQEEM-B8;-KUUzJsL_N_)wQSsK9Q8p!25 z*Xy>;+8U_!f!&aghC4Il6X2}Zn+IXad5k<`L4qu2Olkltdz}Hh`q2~wmR+#YfT`7+ z(txfdbv|kjv^(TtA1E?#b`guf>38B-y%V1t?)8uo#yO@bV;s21fOzf9k?~{!wLT#E zTd_y1*LUS*QC->|YBu>M8u)nn`|`%yC$~?;ql9=65CTB;)SyTCWT6$=u?DXDw~E~d z0LUt8r}{uUnti}JD@#^R^nokT(gz5&myYy-Rstrj`YvVssC2NAf$pD$t_JD~Syuz~ zK2R6_a5a1%0N)4JB;fmi?*khW@O{8s z=SDRUv~9fq{4?agY8f~8v%_a^VPo;KPt-oi(+pNeD}ia6LkjN0|5D#IJb|v^33Lrl zplf&nUBeUT8lFJc@C3SsC(t!Kfv&Oj1pWd50RR8NqvPoS000I_L_t&o04HL!@ruVv QkpKVy07*qoM6N<$f~)V#YXATM literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-pixel-boundary.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-pixel-boundary.png new file mode 100644 index 0000000000000000000000000000000000000000..4750853aefce42649c0c6b06ba216aea8285e6db GIT binary patch literal 525 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp)BaSW-L^Jb>6-(d%y*0(FZ zKTyfp!QGyCUUUbScf{tT(`}&>S1dT==rz&QFZQdb&DNgRE#_#3dVoz={u2^tGV=DWnxuM@P;)M9M zzc)wC36FSF*thqW`--SddJZQUvrO47o;T>6Z9G#vhxsaFHgihy8%-Cv@1KhfB)#7* zvmklORt7J=4NoK%m=!aW9tdB+H;1jks=;c507EH5X@VldD~4AGoEWY!UTJJ)Si!u4 znVV5+CG+I>Ztv&CUH-Ut*1yf6r(NzBP1<*D%f1P{)$ZxJHu{&3JL3TGhGl>Y-S1H=FS%01sSfsx7vicMyQ4>Ps4BrbI228u9vy85}S Ib4q9e01TMr*#H0l literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-round.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-round.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e5502d17c73439bf14fdf2fe43e3fe837d1ddf GIT binary patch literal 531 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFrM&qaSW-L^Jb>E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=0007VNklACm2t#quS!*ao5^PsoIlXI>44ZPP`7u~WFor1{+<4UX*hpag_%;Wd zjlWse3)v&q`#az2GLHLM`WWcL-U-Ch+v(&aeFs8*b?vED%(xxGPR=JCo!UGC0bjG) z#YP9b!BE$@To*Z|obv;APCA?#8^`GY#a{CnU@3&|dzT=+Xx_A(U#1Iu2577qh^L3+ zV+P$bz-y1qo7R{CN`1hNDIbGC%r7(PoB>{YVxK~rW*}lcvOfZ82B=Y2$TL7gW&k^9 zpx+;kEM%>3DyM&b{QmaY7^AIDRb&-P+ZT>7HfKPw8Bq10tm#12tpia*Mx25CxlVNz zXQl&LS;Gy++35hrC$&2;7absecX%fO*fGtn^ z`v8azhz{&bKy*NKU`GO?10XuE8-dB|K<{s(mp2ce4jzI1G|O^A9$`z^3a;lw{Z6F~ zY>!?7D_wzx=|ld(Q5r&^G=xBD2!YZN0;M4YN<#>gh7c$XAy67ZpfqMr;2i(}|NjM+ j$}Io@00v1!K~w_(zd literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-sibling.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-sibling.png new file mode 100644 index 0000000000000000000000000000000000000000..125d421be5f973e01ef425ff62040acaf412beb5 GIT binary patch literal 630 zcmV-+0*U>JP)0006jNkl_&PZtHPy(?rdjgBfdbqyr^Ml9N7n%}u3&n;Khz%tW8;gPQ zbas5mMDBnXgJF<6n}ol#y#Z#DPH%s&RzK@C4a8sqH2-kM=WPM8c*hIle&$!m+0et`LSHmKn&kPTswhVPG_1VLH>kmk#Hso54gG! z!p+dQac>MKP@BqR!|;~R9w=fq!Yv1zaTzbtho?{5V5tOmx;hiZC{hmj*^#N{_M0ep#;$-r@zr-GQ+2|%ofh&7bV>FAPoW3o+5@$n zR_g(->VTFmAA>+g56HD=@f7;22gEID)Cz?8Tm^dc09t#X+wPAbWQ{kK`-_wFqXR2g zMyHJQ_|YmC>OGK%9!T8cV^at6b{!}+J2YUCwRCSH%?i^6vwx1F8c%5>Opb9oUh8>Hw(@ z>_%YnI?(xT^!e>`fA5RLr&*B`q(@vr!?~FgcN=+{(QtGUSfx3oG=1nF9I>GUVnYeU zh7yPkB@i1*AU2dhY$$=)Py(@`1Y%?M1bzVk0RR6C_y>Ie000I_L_t&o0Bh$4mbu0008(NklsC_aB%t1SXD5AT>(!<7Pg)oL$tQhNuCl1kh0>HZrjq#N&Fs-E1%p zSOQ;f-hEtLZK&9wd{9w5Ld7i=4@_J6QAOG^?HIrBmaF9w<3O~8WPw34GC>uaWX^2P z$Q))wT{0p>1k}tl3C%&y-p`dR4?V&d5e7PdumiT)A9q7Z=>$i(JYgR78=g_{v?uH!YI+1>&dfN_~@zd-j zB49P3);-T>MR8G9RPIDkk*GvblTJvynO)DX%S!uQ1<|OeTu&WDH5(t4(G5B)RO1YP zO6Srap)bJw-SYl;d)cc2{Yf!#+z4*^Q*snu#Df@Bz<@P=&V6O(kd+LmV zXq4SIv|8eqR5TsGgv{BN4+mGCtg8b)(7tc{-Fqc=tU4Xwfeuio1O633KAcS;_5p&8 z0mGnLhdO|P4xrNk!xn7^PUCQ*2KaCX@Lw9h3u=J81`sXsUI%c52JoyJK%{|I%|WDr z4q!AIz@P@A7`&GzrvYRi$UZPcAp1b}finqYAILs%CV~AL$Ug9F8nAofU$?8W`0Glg zp7HJT;?w)fiqx)G9%^eVOs@8_JkS9$e@Hs00n!}^*y3CbSOV*+fjk0mU5lm`phC4; zZo~!4UA?KBVnwb4WIMpU4!Ba2FSY3a2YecP<E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=h zmN~7u!o1;~>8`hyRWt4_I{s?Yn#}DB!aSFCHp{kOaZO0*__AL>s$c!0O~1SH!7neL ziY8xUQ5I)hpgBar)Q{P;jXPbZKtZH!DmCt#4pZ%vRtM&+5-=1+SAoJeh zxtqV%ufJ|To7a!^!=4@WH9NFZgB-Xf3Vl6(bHmwl_P2WRxbRezQ8R3C}`eY-5@AE)@Lhn9{V;_>am z%qJ9PC$P)Cw6M+9pIgv+tEaqT+r;Ck`{fg6_-;6w|3!=8$n#tKoSbT67_aUKYcggm z*}mxPV&{t!YGWE5tXD9UX(`g`|kT%U_Od%o%1mU7(ECfrv#H1JV@D&sZRR;KIv{|+YVGDUPVTvKY;%8`J| s%>Kf#9~gQ6|NmEB$p>VxfufI@;Wg*K%eopjTY(}Bp00i_>zopr0F2ewjQ{`u literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-width-one.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-width-one.png new file mode 100644 index 0000000000000000000000000000000000000000..015765d5cd7f251aca058a46abf8929485ab2349 GIT binary patch literal 563 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFuw3~aSW-L^Cr?e?~nse?XoGp ztsyJf6Ygf0+%EoNu-CQ!YHDQIqUeCtD(}vuJmTSGUdrO5Vt??#jn5x9ACG8ydHJ;V zu^vu0KIH|xNT@gS+=dzFpBbkw)v^gwP%~)mlt{Sp*pG3O*Ov@m$HN{|xBl51_dmx> zqU*rtNB=%PT*o46qUn%oyUX3j>yy)jyAiJ+n);o;7*Z)2$QNe6>3VqF`*=MKzjgAm z?dSWSX6-yO(W0cadNuQna`xr3FKQ$w9KOx7#r{j^Q=)azQw?VLjWE3G>n{n8NR~Z`$9xZ}0lN{CyMtm}n>b z^m{*R#o{;YQ$IU}PO*@SWO}{GsquCD--8R0gCC9Edr|lYD+9y-|22(Ao&%$v4HW0h Z46>n|5BS#wx&cKPJYD@<);T3K0RTG9@Vfv2 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-source-screen-width-percent.png b/fixtures/web-first/chromium/svg-group-blend-source-screen-width-percent.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e5502d17c73439bf14fdf2fe43e3fe837d1ddf GIT binary patch literal 531 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFrM&qaSW-L^Jb>E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO=E-(d%ywzmPh z9H&mOaJU}Rto!_d!JVe$jZ30KwykyW^ilg+Zxj0d6^Hi$8-0W#0FF`YhIV>q94qQLWM!R6Q2+w{K5$XLGrxa6UCwv*=G z`@i?*pPcO_S`E*i{(XEn_Nc38j8S+tpcjIO<2;&#mip5!R6-J*5dZh);r$rxaNAe$-@45`^>&8at!x& znjH3&T7UR*n!IE3j4OK|-b`b5-M_-R^&ImAq5hp4HWfG2ep~Zk$M1rA))!3L3%)Zi z4=j$85#lS>uY7lv%f-DMB&?t%?Ya2dIR~H27d#cmTh2T1{I6we3rk2!U$D2O;ZHKF zOV#hs5^rsp_gK5lTe&g4Vdk#ipLrNoFt=D42r-m0lqSeHc-i!HS~XY|C@{QYSjgAI z>>#Ufg;9#V0Vrm`r=TiVJTpdC;!pmUx3#s^pO= diff --git a/fixtures/web-first/svg-group-blend-source-multiply-currentcolor.svg b/fixtures/web-first/svg-group-blend-source-multiply-currentcolor.svg new file mode 100644 index 00000000..8bd89742 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-currentcolor.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-dashed.svg b/fixtures/web-first/svg-group-blend-source-multiply-dashed.svg new file mode 100644 index 00000000..221fc22f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-dashed.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-edge.svg b/fixtures/web-first/svg-group-blend-source-multiply-edge.svg new file mode 100644 index 00000000..7054f36f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-edge.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-fractional.svg b/fixtures/web-first/svg-group-blend-source-multiply-fractional.svg new file mode 100644 index 00000000..9025e6d6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-integer.svg b/fixtures/web-first/svg-group-blend-source-multiply-integer.svg new file mode 100644 index 00000000..aa926009 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-integer.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-leaf.svg b/fixtures/web-first/svg-group-blend-source-multiply-leaf.svg new file mode 100644 index 00000000..a532ef45 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-leaf.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-nested-neutral.svg b/fixtures/web-first/svg-group-blend-source-multiply-nested-neutral.svg new file mode 100644 index 00000000..e0b4a35a --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-nested-neutral.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-normal-elided.svg b/fixtures/web-first/svg-group-blend-source-multiply-normal-elided.svg new file mode 100644 index 00000000..ba5a27af --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-normal-elided.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-opacity-half.svg b/fixtures/web-first/svg-group-blend-source-multiply-opacity-half.svg new file mode 100644 index 00000000..2309f3e0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-opacity-half.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-opacity-near.svg b/fixtures/web-first/svg-group-blend-source-multiply-opacity-near.svg new file mode 100644 index 00000000..9ef22505 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-opacity-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-outer-clip.svg b/fixtures/web-first/svg-group-blend-source-multiply-outer-clip.svg new file mode 100644 index 00000000..b47bfbb6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-outer-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-outer-opacity.svg b/fixtures/web-first/svg-group-blend-source-multiply-outer-opacity.svg new file mode 100644 index 00000000..c41ff4d7 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-outer-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-painted-stroke.svg b/fixtures/web-first/svg-group-blend-source-multiply-painted-stroke.svg new file mode 100644 index 00000000..8885f121 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-painted-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-pixel-boundary.svg b/fixtures/web-first/svg-group-blend-source-multiply-pixel-boundary.svg new file mode 100644 index 00000000..65e735b0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-pixel-boundary.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-sibling-reversed.svg b/fixtures/web-first/svg-group-blend-source-multiply-sibling-reversed.svg new file mode 100644 index 00000000..a5840ac3 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-sibling-reversed.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-sibling.svg b/fixtures/web-first/svg-group-blend-source-multiply-sibling.svg new file mode 100644 index 00000000..1f2835c7 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-sibling.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-two-ramps.svg b/fixtures/web-first/svg-group-blend-source-multiply-two-ramps.svg new file mode 100644 index 00000000..66bb8d51 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-two-ramps.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-use.svg b/fixtures/web-first/svg-group-blend-source-multiply-use.svg new file mode 100644 index 00000000..1a45e1c4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-use.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-width-fractional.svg b/fixtures/web-first/svg-group-blend-source-multiply-width-fractional.svg new file mode 100644 index 00000000..518880dc --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-width-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-width-one.svg b/fixtures/web-first/svg-group-blend-source-multiply-width-one.svg new file mode 100644 index 00000000..3b0b6a56 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-width-one.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-width-px.svg b/fixtures/web-first/svg-group-blend-source-multiply-width-px.svg new file mode 100644 index 00000000..ca503ea1 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-width-px.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-multiply-zeroalpha.svg b/fixtures/web-first/svg-group-blend-source-multiply-zeroalpha.svg new file mode 100644 index 00000000..41eb9457 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-multiply-zeroalpha.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-edge.svg b/fixtures/web-first/svg-group-blend-source-screen-edge.svg new file mode 100644 index 00000000..000fe127 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-edge.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-fractional.svg b/fixtures/web-first/svg-group-blend-source-screen-fractional.svg new file mode 100644 index 00000000..e264eb14 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-inherited.svg b/fixtures/web-first/svg-group-blend-source-screen-inherited.svg new file mode 100644 index 00000000..568a6bea --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-inherited.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-integer.svg b/fixtures/web-first/svg-group-blend-source-screen-integer.svg new file mode 100644 index 00000000..20fb2ae0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-integer.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-leaf.svg b/fixtures/web-first/svg-group-blend-source-screen-leaf.svg new file mode 100644 index 00000000..48bcd234 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-leaf.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-nested-neutral.svg b/fixtures/web-first/svg-group-blend-source-screen-nested-neutral.svg new file mode 100644 index 00000000..99769696 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-nested-neutral.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-opacity-half.svg b/fixtures/web-first/svg-group-blend-source-screen-opacity-half.svg new file mode 100644 index 00000000..2693e3cb --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-opacity-half.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-opacity-near.svg b/fixtures/web-first/svg-group-blend-source-screen-opacity-near.svg new file mode 100644 index 00000000..be990f34 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-opacity-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-outer-clip.svg b/fixtures/web-first/svg-group-blend-source-screen-outer-clip.svg new file mode 100644 index 00000000..5d8c6a87 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-outer-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-outer-opacity.svg b/fixtures/web-first/svg-group-blend-source-screen-outer-opacity.svg new file mode 100644 index 00000000..aedfd341 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-outer-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-painted-stroke.svg b/fixtures/web-first/svg-group-blend-source-screen-painted-stroke.svg new file mode 100644 index 00000000..d360689b --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-painted-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-pixel-boundary.svg b/fixtures/web-first/svg-group-blend-source-screen-pixel-boundary.svg new file mode 100644 index 00000000..dc6506b3 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-pixel-boundary.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-round.svg b/fixtures/web-first/svg-group-blend-source-screen-round.svg new file mode 100644 index 00000000..813954d6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-round.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-sibling-reversed.svg b/fixtures/web-first/svg-group-blend-source-screen-sibling-reversed.svg new file mode 100644 index 00000000..68727fba --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-sibling-reversed.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-sibling.svg b/fixtures/web-first/svg-group-blend-source-screen-sibling.svg new file mode 100644 index 00000000..03b5b83f --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-sibling.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-two-ramps.svg b/fixtures/web-first/svg-group-blend-source-screen-two-ramps.svg new file mode 100644 index 00000000..745d23a5 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-two-ramps.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-use.svg b/fixtures/web-first/svg-group-blend-source-screen-use.svg new file mode 100644 index 00000000..948df921 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-use.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-width-fractional.svg b/fixtures/web-first/svg-group-blend-source-screen-width-fractional.svg new file mode 100644 index 00000000..c69a99bc --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-width-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-width-one.svg b/fixtures/web-first/svg-group-blend-source-screen-width-one.svg new file mode 100644 index 00000000..b98f9592 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-width-one.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-width-percent.svg b/fixtures/web-first/svg-group-blend-source-screen-width-percent.svg new file mode 100644 index 00000000..b817fd9c --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-width-percent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-source-screen-zeroalpha.svg b/fixtures/web-first/svg-group-blend-source-screen-zeroalpha.svg new file mode 100644 index 00000000..d3af596d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-source-screen-zeroalpha.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/README.md b/fixtures/web-first/unsupported/README.md index 63227ab2..2a8552e2 100644 --- a/fixtures/web-first/unsupported/README.md +++ b/fixtures/web-first/unsupported/README.md @@ -24,7 +24,10 @@ The scannable, generated view of this register (beside the baked cells) is | File | Required result | | --- | --- | | `svg-group-blend-linear-extent-stroke-context-{missing,none}.svg` | Preserve the original computed context-stroke/context-fill selection before following a missing provider or a provider selecting `none`. Chromium still retains the selected stroke's bounding-box contribution; both routes must reach the named source-extent guard, not become computed `stroke:none`. | -| `svg-group-blend-linear-extent-stroke-{transparent,opacity-zero,empty-gradient,missing-reference,none-fallback,sibling}.svg` | A selected stroke can enlarge Chromium's drawable bounds even when a live fill remains and the stroke paint disappears. Unresolved references can do so without an opacity pass. Preserve the producer-private omitted-extent fact and refuse the containing linear source by name; do not treat it as `stroke:none`. The sibling case guards propagation across distinct leaves. | +| `svg-group-blend-linear-extent-stroke-{empty-gradient,missing-reference,none-fallback,sibling}.svg` | A selected stroke can enlarge Chromium's drawable bounds even when a live fill remains and the stroke paint disappears. Unresolved references can do so without an opacity pass. Preserve the producer-private omitted-extent fact and refuse the containing linear source by name; do not treat it as `stroke:none`. The sibling case guards propagation across distinct leaves. The former transparent/zero-alpha solid-stroke refusals are now exact source-domain cells. | +| `svg-group-blend-source-domain-{non-scaling,range,pattern-sibling}.svg` | The first complete source-domain profile excludes non-scaling invisible strokes, unrepresentable local enclosures and a repeating-program sibling whose source contribution is not represented by the rectangular domain. Each retains `linear-gradient source-extent` in strict and best-effort; these are conservative refusal witnesses, not claims of measured wrong pixels. | +| `svg-group-blend-source-domain-enclosure.svg` | f32 decoration of `x=8.3`, `width=.7`, `stroke-width=.0000001` rounds the far edge to 9, which does not enclose the exact sum of the resolved painted values. Name `linear-gradient source-extent` and roll back the SVG group before the glyphless consumer's stricter complete-domain preflight. The prototype failed late in both CLI admissions without producing pixels (measured, not celled as a positive case). | +| `svg-group-blend-source-domain-root-{transparent,bare}.svg` | The separately materialized outer root cannot borrow a child blend's complete-domain exemption. Transparent-stroke and bare root ramps under authored Multiply/Screen each silently differed from Chromium at 85 pixels, maximum delta 6, in both admissions; a full-canvas backdrop hid the difference. Both admissions now refuse the unproved root linear source by `linear-gradient source-extent` (measured, not celled as a positive case). | | `svg-group-blend-linear-extent-{transform,rotation,viewport,clip,child-opacity,zero-opacity,transparent,fill-zero,empty-gradient,pattern-stroke,nested-blend,isolation}.svg` | Name `linear-gradient source-extent` and skip the complete affected group. The current resolved stream cannot prove the source-space extent for these combinations. The guard is conservative, not a claim that every member has a measured mismatch; the [B2a evidence](../../../docs/wg/consolidation/svg-engine-of-record.md#b2a-linear-gradient-blend-source-extents) separates those facts. | | `svg-group-blend-linear-extent-root{,-opacity}.svg` | The required root boundary contains a bare ramp and a completed child blend, with unit or partial root opacity. Refuse in both admissions under the conservative source-extent profile, even though these full-background controls were exact before the patrol (measured, not celled). | | `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. | diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-opacity-zero.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-opacity-zero.svg deleted file mode 100644 index 7156537d..00000000 --- a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-opacity-zero.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-transparent.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-transparent.svg deleted file mode 100644 index b6f612c4..00000000 --- a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-transparent.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-domain-enclosure.svg b/fixtures/web-first/unsupported/svg-group-blend-source-domain-enclosure.svg new file mode 100644 index 00000000..9a708d80 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-domain-enclosure.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-domain-non-scaling.svg b/fixtures/web-first/unsupported/svg-group-blend-source-domain-non-scaling.svg new file mode 100644 index 00000000..b81a6af3 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-domain-non-scaling.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-domain-pattern-sibling.svg b/fixtures/web-first/unsupported/svg-group-blend-source-domain-pattern-sibling.svg new file mode 100644 index 00000000..7ebd1ed9 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-domain-pattern-sibling.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-domain-range.svg b/fixtures/web-first/unsupported/svg-group-blend-source-domain-range.svg new file mode 100644 index 00000000..fa884e41 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-domain-range.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-domain-root-bare.svg b/fixtures/web-first/unsupported/svg-group-blend-source-domain-root-bare.svg new file mode 100644 index 00000000..c127c756 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-domain-root-bare.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-source-domain-root-transparent.svg b/fixtures/web-first/unsupported/svg-group-blend-source-domain-root-transparent.svg new file mode 100644 index 00000000..cca80baa --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-source-domain-root-transparent.svg @@ -0,0 +1 @@ +