From 66d5ab4bfd136a7bf6230793544397564610e1eb Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 8 Sep 2026 03:34:18 +0900 Subject: [PATCH 1/2] websem: correct linear blend source extents --- crates/n0/src/paint.rs | 252 +++++++++++++++++- crates/n0/tests/group_blending.rs | 74 ++++- crates/n0_cli/README.md | 20 +- crates/websem/src/svg.rs | 103 ++++++- crates/websem/tests/svg_blending.rs | 128 +++++++++ crates/websem/tests/unsupported_corpus.rs | 123 +++++++++ docs/wg/consolidation/svg-engine-of-record.md | 143 +++++++++- docs/wg/consolidation/web-checklist.md | 3 + fixtures/web-first/README.md | 7 +- fixtures/web-first/STATUS.md | 51 +++- ...g-group-blend-extent-multiply-diagonal.png | Bin 0 -> 987 bytes ...group-blend-extent-multiply-fractional.png | Bin 0 -> 487 bytes ...vg-group-blend-extent-multiply-integer.png | Bin 0 -> 496 bytes .../svg-group-blend-extent-multiply-leaf.png | Bin 0 -> 487 bytes .../svg-group-blend-extent-multiply-many.png | Bin 0 -> 585 bytes ...oup-blend-extent-multiply-opacity-half.png | Bin 0 -> 413 bytes ...svg-group-blend-extent-multiply-opaque.png | Bin 0 -> 526 bytes ...up-blend-extent-multiply-outer-opacity.png | Bin 0 -> 508 bytes ...-extent-multiply-stroke-clear-gradient.png | Bin 0 -> 468 bytes ...roup-blend-extent-multiply-stroke-none.png | Bin 0 -> 487 bytes ...svg-group-blend-extent-multiply-stroke.png | Bin 0 -> 534 bytes ...g-group-blend-extent-normal-fractional.png | Bin 0 -> 609 bytes ...end-extent-screen-fill-stroke-gradient.png | Bin 0 -> 784 bytes ...g-group-blend-extent-screen-fractional.png | Bin 0 -> 523 bytes ...blend-extent-screen-gradient-transform.png | Bin 0 -> 1122 bytes .../svg-group-blend-extent-screen-integer.png | Bin 0 -> 528 bytes ...roup-blend-extent-screen-many-reversed.png | Bin 0 -> 692 bytes ...vg-group-blend-extent-screen-offscreen.png | Bin 0 -> 441 bytes ...group-blend-extent-screen-opacity-near.png | Bin 0 -> 523 bytes .../svg-group-blend-extent-screen-opaque.png | Bin 0 -> 580 bytes ...up-blend-extent-screen-pattern-sibling.png | Bin 0 -> 628 bytes ...up-blend-extent-screen-stroke-gradient.png | Bin 0 -> 690 bytes ...-extent-screen-stroke-transparent-fill.png | Bin 0 -> 690 bytes ...-blend-extent-screen-stroke-zero-width.png | Bin 0 -> 523 bytes .../svg-group-blend-extent-screen-use.png | Bin 0 -> 523 bytes fixtures/web-first/oracle-bake.json | 227 +++++++++++++++- fixtures/web-first/primitives.json | 200 ++++++++++++++ ...g-group-blend-extent-multiply-diagonal.svg | 1 + ...group-blend-extent-multiply-fractional.svg | 1 + ...vg-group-blend-extent-multiply-integer.svg | 1 + .../svg-group-blend-extent-multiply-leaf.svg | 1 + .../svg-group-blend-extent-multiply-many.svg | 1 + ...oup-blend-extent-multiply-opacity-half.svg | 1 + ...svg-group-blend-extent-multiply-opaque.svg | 1 + ...up-blend-extent-multiply-outer-opacity.svg | 1 + ...-extent-multiply-stroke-clear-gradient.svg | 1 + ...roup-blend-extent-multiply-stroke-none.svg | 1 + ...svg-group-blend-extent-multiply-stroke.svg | 1 + ...g-group-blend-extent-normal-fractional.svg | 1 + ...end-extent-screen-fill-stroke-gradient.svg | 1 + ...g-group-blend-extent-screen-fractional.svg | 1 + ...blend-extent-screen-gradient-transform.svg | 1 + .../svg-group-blend-extent-screen-integer.svg | 1 + ...roup-blend-extent-screen-many-reversed.svg | 1 + ...vg-group-blend-extent-screen-offscreen.svg | 1 + ...group-blend-extent-screen-opacity-near.svg | 1 + .../svg-group-blend-extent-screen-opaque.svg | 1 + ...up-blend-extent-screen-pattern-sibling.svg | 1 + ...up-blend-extent-screen-stroke-gradient.svg | 1 + ...-extent-screen-stroke-transparent-fill.svg | 1 + ...-blend-extent-screen-stroke-zero-width.svg | 1 + .../svg-group-blend-extent-screen-use.svg | 1 + fixtures/web-first/unsupported/README.md | 4 + ...roup-blend-linear-extent-child-opacity.svg | 1 + .../svg-group-blend-linear-extent-clip.svg | 1 + ...oup-blend-linear-extent-empty-gradient.svg | 1 + ...vg-group-blend-linear-extent-fill-zero.svg | 1 + ...vg-group-blend-linear-extent-isolation.svg | 1 + ...group-blend-linear-extent-nested-blend.svg | 1 + ...oup-blend-linear-extent-pattern-stroke.svg | 1 + ...group-blend-linear-extent-root-opacity.svg | 1 + .../svg-group-blend-linear-extent-root.svg | 1 + ...svg-group-blend-linear-extent-rotation.svg | 1 + ...d-linear-extent-stroke-context-missing.svg | 1 + ...lend-linear-extent-stroke-context-none.svg | 1 + ...nd-linear-extent-stroke-empty-gradient.svg | 1 + ...linear-extent-stroke-missing-reference.svg | 1 + ...end-linear-extent-stroke-none-fallback.svg | 1 + ...lend-linear-extent-stroke-opacity-zero.svg | 1 + ...oup-blend-linear-extent-stroke-sibling.svg | 1 + ...blend-linear-extent-stroke-transparent.svg | 1 + ...vg-group-blend-linear-extent-transform.svg | 1 + ...-group-blend-linear-extent-transparent.svg | 1 + ...svg-group-blend-linear-extent-viewport.svg | 1 + ...group-blend-linear-extent-zero-opacity.svg | 1 + 85 files changed, 1361 insertions(+), 21 deletions(-) create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-diagonal.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-integer.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-leaf.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-many.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-opacity-half.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-opaque.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-outer-opacity.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-stroke-clear-gradient.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-stroke-none.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-multiply-stroke.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-normal-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-fill-stroke-gradient.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-fractional.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-gradient-transform.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-integer.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-many-reversed.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-offscreen.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-opacity-near.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-opaque.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-pattern-sibling.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-gradient.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-transparent-fill.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-zero-width.png create mode 100644 fixtures/web-first/chromium/svg-group-blend-extent-screen-use.png create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-diagonal.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-integer.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-leaf.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-many.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-opacity-half.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-opaque.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-outer-opacity.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-stroke-clear-gradient.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-stroke-none.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-multiply-stroke.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-normal-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-fill-stroke-gradient.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-fractional.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-gradient-transform.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-integer.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-many-reversed.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-offscreen.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-opacity-near.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-opaque.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-pattern-sibling.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-stroke-gradient.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-stroke-transparent-fill.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-stroke-zero-width.svg create mode 100644 fixtures/web-first/svg-group-blend-extent-screen-use.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-child-opacity.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-clip.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-empty-gradient.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-fill-zero.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-isolation.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-nested-blend.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-pattern-stroke.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-root-opacity.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-root.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-rotation.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-missing.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-none.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-empty-gradient.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-missing-reference.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-none-fallback.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-opacity-zero.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-sibling.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-transparent.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-transform.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-transparent.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-viewport.svg create mode 100644 fixtures/web-first/unsupported/svg-group-blend-linear-extent-zero-opacity.svg diff --git a/crates/n0/src/paint.rs b/crates/n0/src/paint.rs index 7a9fcf42..b6cac587 100644 --- a/crates/n0/src/paint.rs +++ b/crates/n0/src/paint.rs @@ -4461,6 +4461,238 @@ 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 +/// 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. +fn blend_source_extents(list: &DrawList, view: &Affine) -> Option>> { + use skia_safe::RoundOut; + + fn linear(paints: &Paints) -> bool { + paints + .iter() + .any(|paint| matches!(paint, ModelPaint::LinearGradient(_))) + } + fn stroke_box(w: f32, h: f32, stroke: &Stroke, space: StrokeSpace) -> Option { + let StrokeWidth::Uniform(width) = stroke.width else { + return None; + }; + if space != StrokeSpace::Local + || stroke.align != StrokeAlign::Center + || stroke.cap != StrokeCap::Butt + || stroke.join != StrokeJoin::Miter + || stroke.dash_array.is_some() + || !width.is_finite() + || width < 0.0 + { + return None; + } + let mut rect = Rect::from_wh(w, h); + rect.outset((width * 0.5, width * 0.5)); + Some(rect) + } + #[derive(Clone, Copy)] + enum Kind { + Blend(usize, rframe::ScopeBlendMode), + Opacity, + Clip, + } + struct Source { + kind: Kind, + bounds: Option, + known: bool, + ramp: 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(accumulated) = &mut self.bounds { + accumulated.join(bounds); + } else { + self.bounds = Some(bounds); + } + } + } + } + if !list.items.iter().any(|item| match &item.kind { + ItemKind::RectFill { paints, .. } => linear(paints), + ItemKind::RectStroke { stroke, .. } => linear(&stroke.paints), + _ => false, + }) { + return None; + } + let mut output = vec![None; list.items.len()]; + let mut stack: Vec = Vec::new(); + for (index, item) in list.items.iter().enumerate() { + let matrix = skia_matrix(&view.then(&item.world)); + let mut begin = None; + let (bounds, ramp) = match &item.kind { + ItemKind::BeginIsolatedBlend { blend } => { + begin = Some(Kind::Blend(index, blend.mode())); + (None, false) + } + ItemKind::BeginOpacity { .. } | ItemKind::BeginIsolatedOpacity { .. } => { + begin = Some(Kind::Opacity); + (None, false) + } + ItemKind::BeginClipRect { .. } | ItemKind::BeginClipPath { .. } => { + begin = Some(Kind::Clip); + (None, false) + } + ItemKind::EndIsolatedBlend | ItemKind::EndOpacity | ItemKind::EndClip => { + let source = stack.pop()?; + // Chromium accumulates drawable bounds, not clipped ink. + // The canvas clip still limits the allocation at execution. + if let Kind::Blend(start, mode) = source.kind { + if mode != rframe::ScopeBlendMode::Normal && source.known && source.ramp { + output[start] = source.bounds.map(|bounds| -> Rect { bounds.round_out() }); + } + } + if let Some(parent) = stack.last_mut() { + parent.add( + source.bounds, + source.known, + matches!(source.kind, Kind::Clip) && source.ramp, + ); + } + continue; + } + ItemKind::RectFill { + w, + h, + corner_radius, + paints, + .. + } if corner_radius.is_zero() => (Some(Rect::from_wh(*w, *h)), linear(paints)), + ItemKind::RectStroke { + w, + h, + corner_radius, + stroke, + space, + .. + } if corner_radius.is_zero() => { + (stroke_box(*w, *h, stroke, *space), linear(&stroke.paints)) + } + ItemKind::PatternFill { + geometry: ResolvedPatternGeometry::Rect { x, y, w, h }, + .. + } => (Some(Rect::from_xywh(*x, *y, *w, *h)), false), + // These sources need their own materialization profile. In + // particular, never turn an effect region into a geometry bound. + _ => { + if let Some(source) = stack.last_mut() { + source.known = false; + } + continue; + } + }; + if let Some(kind) = begin { + stack.push(Source { + kind, + bounds: None, + known: true, + ramp: false, + }); + } else if let Some(source) = stack.last_mut() { + let bounds = bounds + .map(|bounds| matrix.map_rect(bounds).0) + .filter(|bounds| bounds.is_finite()); + source.add(bounds, bounds.is_some(), ramp); + } + } + Some(output) +} + +#[cfg(test)] +mod blend_source_extent_tests { + use super::*; + use crate::drawlist::Item; + + fn item(kind: ItemKind) -> Item<()> { + Item { + node: (), + world: Affine::IDENTITY, + kind, + } + } + + fn ramp() -> Item<()> { + let mut draw = item(ItemKind::RectFill { + w: 38.2, + h: 28.4, + corner_radius: Default::default(), + corner_smoothing: Default::default(), + paints: Paints::new([ModelPaint::LinearGradient(LinearGradientPaint::default())]), + post_paint_opacity: PostPaintOpacity::IDENTITY, + }); + draw.world = Affine::translate(8.3, 12.7); + draw + } + + fn scene() -> DrawList<()> { + DrawList::from_items(vec![ + item(ItemKind::BeginIsolatedBlend { + blend: rframe::ScopeBlend::new(rframe::ScopeBlendMode::Multiply, None), + }), + ramp(), + item(ItemKind::EndIsolatedBlend), + ]) + } + + #[test] + fn actual_list_and_current_view_are_the_only_extent_inputs() { + let mut list = scene(); + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[0], + Some(Rect::new(8.0, 12.0, 47.0, 42.0)) + ); + assert_eq!( + blend_source_extents(&list, &Affine::translate(3.0, 2.0)).unwrap()[0], + Some(Rect::new(11.0, 14.0, 50.0, 44.0)) + ); + list.items[1].world = Affine::translate(2.0, 5.0); + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[0], + Some(Rect::new(2.0, 5.0, 41.0, 34.0)) + ); + if let ItemKind::RectFill { paints, .. } = &mut list.items[1].kind { + *paints = Paints::default(); + } + assert!(blend_source_extents(&list, &Affine::IDENTITY).is_none()); + } + + #[test] + fn unknown_sibling_does_not_disable_a_separate_known_source() { + let mut list = scene(); + list.items.insert( + 0, + item(ItemKind::OvalFill { + w: 48.0, + h: 48.0, + paints: Paints::default(), + post_paint_opacity: PostPaintOpacity::IDENTITY, + }), + ); + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[1], + Some(Rect::new(8.0, 12.0, 47.0, 42.0)) + ); + let unknown = list.items.remove(0); + list.items.insert(2, unknown); + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[0], + None + ); + } +} + /// Replay a raw [`DrawList`] without a frame-environment check. /// /// This low-level entry exists for engine-owned resource-free glyphless @@ -4483,6 +4715,7 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, enum Scope { Opacity, Blend { + source_clip: bool, #[cfg(feature = "trace")] observed_bytes: u128, }, @@ -4497,7 +4730,8 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, let initial_save_count = canvas.save_count(); let mut scopes = Vec::new(); let mut glyph_scratch = GlyphScratch::default(); - for item in &list.items { + let mut blend_sources = None; + for (item_index, item) in list.items.iter().enumerate() { match &item.kind { ItemKind::BeginOpacity { opacity } => { // Copy the current backdrop into the group layer so descendant @@ -4549,8 +4783,16 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, rframe::ScopeBlendMode::Multiply => unreachable!(), }); } + let sources = blend_sources.get_or_insert_with(|| blend_source_extents(list, view)); + let bounds = sources.as_ref().and_then(|sources| sources[item_index]); + if let Some(bounds) = bounds { + canvas.save(); + canvas.reset_matrix(); + canvas.clip_rect(bounds, None, false); + } canvas.save_layer(&SaveLayerRec::default().paint(&restore_paint)); scopes.push(Scope::Blend { + source_clip: bounds.is_some(), #[cfg(feature = "trace")] observed_bytes: crate::trace::blend_layers::Execute::begin_layer( observe_blend_layer(canvas), @@ -4563,8 +4805,14 @@ pub fn execute_unchecked(canvas: &Canvas, list: &DrawList, view: &Affine, if scope.is_some() { canvas.restore(); } + if let Some(Scope::Blend { + source_clip: true, .. + }) = scope + { + canvas.restore(); + } #[cfg(feature = "trace")] - if let Some(Scope::Blend { observed_bytes }) = scope { + if let Some(Scope::Blend { observed_bytes, .. }) = scope { crate::trace::blend_layers::Execute::end_layer(observed_bytes); } } diff --git a/crates/n0/tests/group_blending.rs b/crates/n0/tests/group_blending.rs index f3d4f14a..34e1df66 100644 --- a/crates/n0/tests/group_blending.rs +++ b/crates/n0/tests/group_blending.rs @@ -75,17 +75,17 @@ fn frame(items: Vec) -> Frame { } fn raster(product: &FrameProduct, backdrop: CGColor) -> Vec { + raster_at(product, backdrop, &AffineTransform::identity()) +} + +fn raster_at(product: &FrameProduct, backdrop: CGColor, view: &AffineTransform) -> Vec { let mut surface = skia_safe::surfaces::raster_n32_premul((SIZE, SIZE)).unwrap(); surface.canvas().clear(skia_safe::Color::from_argb( backdrop.a, backdrop.r, backdrop.g, backdrop.b, )); let saves = surface.canvas().save_count(); product - .execute( - surface.canvas(), - &AffineTransform::identity(), - &PaintCtx::new(None), - ) + .execute(surface.canvas(), view, &PaintCtx::new(None)) .unwrap(); assert_eq!( surface.canvas().save_count(), @@ -100,11 +100,75 @@ fn at(pixels: &[u8], x: usize, y: usize) -> [u8; 4] { pixels[offset..offset + 4].try_into().unwrap() } +fn linear_node(id: u64, bounds: Rectangle) -> FrameItem { + let gradient = cg::LinearGradientPaint::from_colors(vec![FIRST, SECOND]); + let mut source = node( + id, + bounds, + PaintStack::try_from_paints(cg::Paints::new([cg::Paint::LinearGradient(gradient)])) + .unwrap(), + ); + source.bounds = math2::rect_transform(bounds, &source.transform); + FrameItem::Node(source) +} + +#[test] +fn linear_source_extent_replay_at_changed_views_matches_fresh() { + for mode in [ScopeBlendMode::Multiply, ScopeBlendMode::Screen] { + let source = frame(vec![ + blend(10, mode, Some(0.6)), + linear_node(1, rect(8.3, 12.7, 28.2, 18.4)), + FrameItem::ScopeEnd, + ]); + let retained = compile(source.clone()).unwrap(); + let original = raster(&retained, BACKDROP); + for view in [ + AffineTransform::new(3.0, 2.0, 0.0), + AffineTransform::from_acebdf(0.5, 0.0, 5.0, 0.0, 0.5, 3.0), + AffineTransform::identity(), + ] { + let fresh = compile(source.clone()).unwrap(); + assert_eq!( + raster_at(&retained, BACKDROP, &view), + raster_at(&fresh, BACKDROP, &view) + ); + assert_eq!( + raster(&retained, BACKDROP), + original, + "a different view must not leave a cached raster origin" + ); + } + } +} + #[cfg(feature = "trace")] mod layer_metrics { use super::*; use n0::trace::{sink::drain_blend_layers, BlendLayerMetrics}; + #[test] + fn linear_source_layer_observes_rounded_draw_bounds_not_the_frame_envelope() { + drain_blend_layers(); + let product = compile(frame(vec![ + blend(10, ScopeBlendMode::Multiply, None), + linear_node(1, rect(8.3, 12.7, 28.2, 18.4)), + FrameItem::ScopeEnd, + ])) + .unwrap(); + raster(&product, BACKDROP); + let metrics = drain_blend_layers(); + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].observed_raster_bytes, 29 * 20 * 4); + assert_eq!(metrics[0].save_layer_calls, 1); + raster_at( + &product, + BACKDROP, + &AffineTransform::from_acebdf(0.5, 0.0, 5.0, 0.0, 0.5, 3.0), + ); + let metrics = drain_blend_layers(); + assert_eq!(metrics[0].observed_raster_bytes, 15 * 10 * 4); + } + fn single() -> FrameProduct { compile(frame(vec![ blend(10, ScopeBlendMode::Multiply, Some(0.5)), diff --git a/crates/n0_cli/README.md b/crates/n0_cli/README.md index 958c183b..5bc81783 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,398 Chromium-baked cells plus + cells. The complete primitive corpus contains 1,423 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 303 rows. `feFlood`, `feComposite`, + Bungee), and the named refusal register has 325 rows. `feFlood`, `feComposite`, `feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`, `feComponentTransfer`, `feBlend`, `feMorphology`, `feConvolveMatrix`, `feDiffuseLighting`, `feDistantLight`, `fePointLight`, `feSpotLight`, @@ -858,7 +858,21 @@ isolate` have a bounded static SVG group profile. One Stylo computed value the resolved stream, independently of the caller's canvas clear color. The current group-source profile is sharp-cornered rectangles, including solid, linear-gradient and admitted repeating-pattern paints, fractional - placement, 2D transforms and simple local butt/miter strokes. Non-rectangular + placement and simple local butt/miter strokes. Linear-gradient sources have + 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 + 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 + profile still admits `stroke:none`, resolved zero-width strokes and retained + 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. + 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. These guards deliberately over-refuse unproved combinations. Eliding a diff --git a/crates/websem/src/svg.rs b/crates/websem/src/svg.rs index 0b95784e..43c4213b 100644 --- a/crates/websem/src/svg.rs +++ b/crates/websem/src/svg.rs @@ -3423,6 +3423,11 @@ fn compile_svg_element( if adds_root_blend_boundary && let Some(reason) = root_facts.blend_precision_boundary { return Err(blend_precision_refusal(reason)); } + let has_root_blend_source = adds_root_blend_boundary + || (root_facts.has_blend && root_patrol.opacity > 0.0 && root_patrol.opacity < 1.0); + if has_root_blend_source && let Some(reason) = blend_linear_source_boundary(root_facts) { + return Err(blend_linear_source_refusal(reason)); + } walk.compact_elided_blends(); let ChildWalk { mut items, @@ -3532,6 +3537,11 @@ struct SpanFacts { /// Classify source material once; adding an isolation/blend later must not /// silently switch its raster origin or its coverage/alpha materialization. blend_precision_boundary: Option<&'static str>, + /// A linear ramp still painted into this source, not an already completed + /// blend image. Its dither origin needs an exact source-space extent. + has_linear_source: bool, + /// Contributor facts that cannot be recovered from the resolved drawlist. + linear_source_boundary: Option<&'static str>, } impl SpanFacts { @@ -3548,7 +3558,25 @@ impl SpanFacts { self.blend_precision_boundary = self .blend_precision_boundary .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); + } +} + +fn blend_linear_source_boundary(facts: SpanFacts) -> Option<&'static str> { + if !facts.has_linear_source { + return None; } + facts.linear_source_boundary.or_else(|| { + (facts.transformed || facts.has_scope || facts.has_opacity) + .then_some("with a transformed or nested linear-gradient source") + }) +} + +fn blend_linear_source_refusal(reason: &str) -> CompileError { + CompileError::UnsupportedStyle(format!( + "mix-blend-mode/isolation {reason} needs the linear-gradient source-extent profile" + )) } fn blend_precision_refusal(reason: &str) -> CompileError { @@ -5617,6 +5645,9 @@ impl<'a> ChildWalk<'a> { if (composite.is_some() || facts.has_blend) && let Some(reason) = facts.blend_precision_boundary { return Err(blend_precision_refusal(reason)); } + if (composite.is_some() || facts.has_blend) && let Some(reason) = blend_linear_source_boundary(facts) { + return Err(blend_linear_source_refusal(reason)); + } if let Some(composite) = composite { if facts.has_image_effect { return Err(CompileError::UnsupportedStyle( @@ -5640,6 +5671,10 @@ impl<'a> ChildWalk<'a> { facts.has_blend = true; facts.has_opacity |= composite.opacity().is_some(); facts.escaping_blend = composite.mode() != ScopeBlendMode::Normal; + // The parent composites a completed image. Do not confuse + // it with a ramp rasterized directly into the parent. + facts.has_linear_source = false; + facts.linear_source_boundary = None; } } Ok(facts) @@ -6494,6 +6529,31 @@ impl<'a> ChildWalk<'a> { facts.transformed = outcome.transformed; facts.blend_precision_boundary = outcome.nodes.iter().find_map(blend_node_precision_boundary); + facts.has_linear_source = outcome.nodes.iter().any(|node| { + node.paints + .iter() + .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 { + Some("with a non-painted stroke extent") + } else if outcome.has_geometry && outcome.draws == 0 { + Some("with a non-painted source contributor") + } else { + outcome.nodes.iter().find_map(|node| { + if node.transform != AffineTransform::identity() { + Some("with a mapped source contributor") + } else if node + .stroke + .as_ref() + .is_some_and(|stroke| stroke.paints().pattern().is_some()) + { + Some("with a patterned source stroke") + } else { + None + } + }) + }; } let marker_facts = match self.compile_marker_instances( el, @@ -11608,6 +11668,7 @@ fn compile_tspan_text( has_opacity: true, has_geometry: true, transformed: false, + omitted_stroke_extent: false, })); } let one_pass_fold = (replay_opacity < 1.0 && paths.len() == 1).then_some(replay_opacity); @@ -11654,6 +11715,7 @@ fn compile_tspan_text( has_opacity: replay_opacity < 1.0, has_geometry: true, transformed: false, + omitted_stroke_extent: false, })) } @@ -12343,6 +12405,9 @@ struct ShapeOutcome { draws: usize, /// Structural paint passes Chromium's element-opacity fold observes. opacity_passes: usize, + /// A selected stroke can enlarge Chromium's drawable bounds without a + /// paint pass. Keep this separate from opacity-fold participation. + omitted_stroke_extent: bool, /// The shape's own opacity composites fill and stroke through one /// isolated layer — the walk wraps the node in a scope. scope_opacity: Option, @@ -12706,6 +12771,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; patrol_mixed_contour_cap(&geometry, stroke.as_ref())?; debug_assert!( @@ -12743,6 +12809,7 @@ fn shape_node( has_opacity: true, has_geometry: true, transformed: false, + omitted_stroke_extent, }); } if opacity < 1.0 && has_geometry { @@ -12813,6 +12880,7 @@ fn shape_node( nodes, draws, opacity_passes, + omitted_stroke_extent, scope_opacity, has_opacity, has_geometry, @@ -14054,6 +14122,10 @@ fn resolve_stroke_width( struct StrokeResolution { stroke: Option, opacity_pass: bool, + /// A non-none selected stroke may still enlarge SVG drawable bounds when + /// 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, } /// Blink's `markerUnits="strokeWidth"` scale for a non-scaling-stroke client. @@ -14179,6 +14251,7 @@ impl StrokeResolution { Self { stroke: None, opacity_pass: false, + omitted_extent: false, } } } @@ -14198,6 +14271,10 @@ fn resolve_stroke( ) -> Result { let data = el.borrow_data().ok_or(CompileError::MissingComputedStyle)?; let style: &ComputedValues = data.styles.primary(); + // Context paint that resolves to no paint is still not computed `none` + // 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); // Direct colours, valid paint servers, and invalid-reference fallbacks // stage element opacity exactly as [`resolve_fill`] describes. @@ -14214,7 +14291,11 @@ fn resolve_stroke( let Some(selected) = select_paint(el, PaintProperty::Stroke, paint_contexts) .map_err(CompileError::UnsupportedStroke)? else { - return Ok(StrokeResolution::none()); + return Ok(StrokeResolution { + stroke: None, + opacity_pass: false, + omitted_extent: !computed_stroke_is_none, + }); }; let owner_data = selected .owner @@ -14232,7 +14313,13 @@ fn resolve_stroke( _ => Ok(PaintResolution::none()), }; let paint = match paint.kind { - SVGPaintKind::None => return Ok(StrokeResolution::none()), + SVGPaintKind::None => { + return Ok(StrokeResolution { + stroke: None, + opacity_pass: false, + omitted_extent: !computed_stroke_is_none, + }); + } SVGPaintKind::Color(ref color) => { admitted_srgb(owner_style.resolve_color(color), solid_opacity) .map(PaintStack::solid) @@ -14268,7 +14355,15 @@ fn resolve_stroke( } }; if !paint.opacity_pass { - return Ok(StrokeResolution::none()); + // Paint-server failure is not computed `stroke:none`. Chromium's + // visual rectangle can retain the selected stroke's extent even when + // no drawing/opacity pass survives. Conservatively retain that risk; + // do not resolve otherwise-inert width grammar solely for this patrol. + return Ok(StrokeResolution { + stroke: None, + opacity_pass: false, + omitted_extent: true, + }); } // A valid transparent paint still records the stroke pass Chromium's @@ -14281,6 +14376,7 @@ fn resolve_stroke( return Ok(StrokeResolution { stroke: None, opacity_pass: true, + omitted_extent: true, }); } @@ -14441,6 +14537,7 @@ fn resolve_stroke( Ok(StrokeResolution { stroke, opacity_pass: true, + omitted_extent: false, }) } diff --git a/crates/websem/tests/svg_blending.rs b/crates/websem/tests/svg_blending.rs index 99619b4e..8dfb84dc 100644 --- a/crates/websem/tests/svg_blending.rs +++ b/crates/websem/tests/svg_blending.rs @@ -23,6 +23,134 @@ fn blends(frame: &Frame) -> Vec { .collect() } const RECT: &str = r#""#; +const RAMP: &str = ""; +const RAMP_RECT: &str = ""; + +#[test] +fn linear_source_extent_patrol_is_transactional_and_names_the_owner() { + for content in [ + format!("{RAMP_RECT}"), + format!("{RAMP_RECT}{RECT}"), + format!("{RAMP_RECT}{RECT}"), + format!("{RAMP_RECT}{RECT}"), + format!("{RAMP_RECT}"), + 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'/>"), + format!( + "{}", + RAMP_RECT.replace("/>", " stroke='url(#empty)' stroke-width='4'/>") + ), + format!( + "{RAMP_RECT}" + ), + RAMP_RECT.replace("/>", " stroke='url(#missing)' stroke-width='4'/>"), + RAMP_RECT.replace("/>", " stroke='context-stroke' stroke-width='4'/>"), + format!( + "{}", + RAMP_RECT.replace("/>", " id='ctx' stroke='context-fill' stroke-width='4'/>") + ), + RAMP_RECT.replace("/>", " stroke='url(#missing) none' stroke-width='4'/>"), + format!( + "{}", + RAMP_RECT.replace("/>", " stroke='url(#wrong)' stroke-width='4'/>") + ), + ] { + for style in [ + "mix-blend-mode:multiply", + "mix-blend-mode:screen", + "isolation:isolate", + ] { + let source = svg(&format!("{RAMP}{content}{RECT}")); + let strict = compile_standalone_svg(&source, InitialViewport::new(64.0, 64.0)) + .unwrap_err() + .to_string(); + assert!( + strict.contains("linear-gradient source-extent"), + "{strict}: {source}" + ); + 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, + "the entire failed group is rolled back: {source}" + ); + assert!( + best.degradations().iter().any(|d| d.path() == "svg/g[1]" + && d.reason().contains("linear-gradient source-extent")), + "{:?}", + best.degradations() + ); + } + } +} + +#[test] +fn root_linear_source_extent_refusal_cannot_silently_fall_back() { + let base = svg(&format!( + "{RAMP}{RAMP_RECT}{RECT}" + )); + for opacity in ["1", ".5", ".999"] { + let source = base.replace("width=\"64\"", &format!("opacity='{opacity}' width=\"64\"")); + for result in [ + SvgFrameSource::from_standalone_svg(source.as_str(), InitialViewport::new(64.0, 64.0)), + SvgFrameSource::from_standalone_svg_best_effort( + source.as_str(), + InitialViewport::new(64.0, 64.0), + ), + ] { + assert!( + result + .unwrap_err() + .to_string() + .contains("linear-gradient source-extent") + ); + } + } +} + +#[test] +fn completed_linear_blend_images_do_not_poison_outer_solid_groups() { + for content in [ + RAMP_RECT.to_string(), + format!("{RAMP_RECT}{RECT}"), + format!("{RAMP_RECT}"), + ] { + frame(&format!( + "{RAMP}{content}" + )); + } + // The new patrol is source-specific; ordinary transformed solids remain + // admitted, and an ordinary ramp without blending keeps its old route. + frame(&format!( + "{RAMP}{}", + RAMP_RECT.replace( + "fill='url(#r)'", + "fill='transparent' stroke='url(#r)' stroke-width='4'" + ) + )); + for attrs in [ + "stroke='none' stroke-width='4'", + "stroke='transparent' stroke-width='0'", + ] { + frame(&format!( + "{RAMP}{}", + RAMP_RECT.replace("/>", &format!(" {attrs}/>")) + )); + } + frame(&format!( + "{RECT}" + )); + frame(&format!( + "{RAMP}{RAMP_RECT}" + )); +} #[test] fn neutral_groups_have_no_scope_and_raw_attribute_lookalikes_are_inert() { diff --git a/crates/websem/tests/unsupported_corpus.rs b/crates/websem/tests/unsupported_corpus.rs index 8a51d0a1..78cb67e7 100644 --- a/crates/websem/tests/unsupported_corpus.rs +++ b/crates/websem/tests/unsupported_corpus.rs @@ -46,6 +46,116 @@ use Departure::{BothRefuse, DeclaredByBestEffort}; /// construct itself — a refusal that stopped naming what it refused would pass /// a bare "does it error" check and fail this one. const CORPUS: &[(&str, Departure, &str)] = &[ + ( + "svg-group-blend-linear-extent-stroke-context-missing", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-context-none", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-transparent", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-opacity-zero", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-empty-gradient", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-missing-reference", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-none-fallback", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-stroke-sibling", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-root-opacity", + BothRefuse, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-transform", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-rotation", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-viewport", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-clip", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-child-opacity", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-zero-opacity", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-transparent", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-fill-zero", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-empty-gradient", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-pattern-stroke", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-nested-blend", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-isolation", + DeclaredByBestEffort, + "linear-gradient source-extent", + ), + ( + "svg-group-blend-linear-extent-root", + BothRefuse, + "linear-gradient source-extent", + ), ( "svg-group-blend-root-filter-sibling", BothRefuse, @@ -1525,6 +1635,19 @@ fn every_unsupported_fixture_departs_by_name_in_both_admissions() { declared.iter().all(|d| !d.path().is_empty()), "{id}: every declaration carries a structural path" ); + if id.starts_with("svg-group-blend-linear-extent-") { + let path = if id.ends_with("-viewport") { + "svg/svg[1]/g[1]" + } else { + "svg/g[1]" + }; + assert!( + declared + .iter() + .any(|d| d.path() == path && d.reason().contains(named)), + "{id}: source-extent refusal must stay at {path}: {declared:?}" + ); + } } } } diff --git a/docs/wg/consolidation/svg-engine-of-record.md b/docs/wg/consolidation/svg-engine-of-record.md index dd6ef469..c0986c2a 100644 --- a/docs/wg/consolidation/svg-engine-of-record.md +++ b/docs/wg/consolidation/svg-engine-of-record.md @@ -60,7 +60,8 @@ from the dated addenda below: `` and `` containers, visibility, isolated element/group/root opacity, and HTML-ancestor opacity around the selected inline SVG; bounded static SVG group blending/isolation through a combined resolved blend/opacity scope - ([B1](#b1-svg-group-blending)); the whole + ([B1](#b1-svg-group-blending), with the + [B2a source-extent correction](#b2a-linear-gradient-blend-source-extents)); the whole `transform` grammar in both spellings (the attribute is a presentation hint of the CSS `transform` property, and `gradientTransform` is that attribute on gradient elements); @@ -116,7 +117,7 @@ from the dated addenda below: carrying admitted repeating-pattern paint and admitted source/target filter composition. `crates/n0_cli/README.md` is the statement of record. -- **The corpus** is 1,398 Chromium-baked primitive cells plus 16 sampled frames, +- **The corpus** is 1,423 Chromium-baked primitive cells plus 16 sampled frames, with a separate sixteen-cell exact text suite whose current cells select hash-pinned Ahem and Ahem-derived bytes from explicit family/face environments, and eight exact-number artifact-geometry @@ -127,7 +128,7 @@ from the dated addenda below: carrying declared one-code-value ramp-quantization bounds. The measured per-cell counts and causes are listed in the [corpus record](../../../fixtures/web-first/README.md). - The named refusal register has 303 rows. + The named refusal register has 325 rows. - **Not claimed:** no conformance score exists or may be computed — FLIP is unratified. The FLIP record and identity-changing review are prepared, but only the owner act on gridaco/nothing#49 may authorize them and the first @@ -6113,3 +6114,139 @@ repeated active-clip-sized source materialization with explicit byte arithmetic over those layers, not a larger semantic tree. Source extent/precision must be proved before tightening bounds or pooling layers; B1 records that cost and leaves the optimization unshipped. + +## B2a: linear-gradient blend source extents + +B2a repairs a silent pixel defect found while preparing B1's bounds work. +It is correctness work, not general layer-bounds optimization or a completed +blending grammar. The [admitted-slice record](../../../crates/n0_cli/README.md) +owns the resulting profile; all three CSS compositing rows remain unchecked. +The audience for this evidence is a maintainer extending that profile. + +### Cause and discriminators + +A rectangle at `(8.3,12.7)`, size `(38.2,28.4)`, uses an object-box ramp from +`#cd6843` to `#5bace1` with last-stop opacity `.6`, over `#426589`. +Both actual CLI admissions at the unmodified B1 baseline silently differed +from pinned Chromium by 28 pixels for Multiply and 56 for Screen, maximum +channel delta 1. The corresponding `svg-group-blend-extent-{multiply,screen}-fractional` +cells are now exact. Integer placement `(8,12)` also exposed the problem: +50/84 pixels at delta 1, now guarded by the `-integer` pair. Fractional +coordinates were not the cause. The normal and opaque-ramp cells separate +the blend source from ordinary gradient rendering and stop translucency. + +The etiology is the temporary raster's origin. Pinned Skia's gradient paint +enables ordered dithering, whose 8×8 phase uses device coordinates. B1's +unbounded source layer retained the active clip's origin instead of the +drawable source origin; its old `(8,8)` gradient cells happened to share the +same phase. In a separate backend diagnostic, a hard clip with origin `(8,12)` +made the original Multiply witness exact, whether its far edge was tight or +extended to the viewport. Changing only the layer size while keeping origin +`(0,0)` retained all 28 differing pixels **(measured, not celled)**. This +diagnostic isolated the mechanism; Chromium remained the pixel oracle. + +The upstream source explains the wider boundary: +[SVGDrawingRecorder](https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/core/paint/svg_model_object_painter.h) +encloses a shape's visual rectangle in local SVG coordinates, and +[paint-chunk conversion](https://github.com/chromium/chromium/blob/main/third_party/blink/renderer/platform/graphics/compositing/paint_chunks_to_cc_layer.cc) +maps drawable bounds into each effect's transform space. These are upstream +source observations, not instrumentation of the pinned browser. The pinned +capture measurements establish the actual witness verdicts. + +### Correction and guarded remainder + +The n0 executor derives source bounds from the actual rectangular draw +commands and current view, including simple stroke outsets and pattern-fill +contributors. It rounds outward and applies an inert hard clip before the +existing exact blend restore. It does not use the damage envelope, change +restore arithmetic, add a raster cache or add a field to `rframe`. The pass +is lazy: lists without a blend command never call it; lists without a linear +rectangle paint allocate no extent table. A completed child blend contributes +an image, not a newly rasterized ramp. Execution tests check balanced saves, +current-view replay, fresh/retained identity and mutable raw-list recomputation. + +The 58-source reduced matrix first proved the simple correction. A separate +72-source composition matrix and 16 boundary controls then found the limits: +transformed sources, nested image groups, zero-opacity/transparent/empty-paint +contributors and patterned strokes can require information absent from the +resolved draw stream. The original B1 source was restored byte-for-byte before +the broad baseline rerender; both admissions were run through the actual CLI, +not just a successful compiler or a backend-only renderer. These wider matrix +facts are **(measured, not celled)** except for the specific cells below. + +Websem now names the `linear-gradient source-extent` boundary before it can +silently reach the old route. The patrol is deliberately conservative: even +an exact root-bare-ramp control, a harmless non-painted contributor, or a +particular exact transformed/clip case does not prove the full source-space +profile. Fourteen initial registered refusals cover mapped/rotated/viewport sources, +child clip and opacity, zero element/fill opacity, transparent/empty-gradient +contributors, pattern stroke, mixed nested blend, explicit isolation and the +required root boundary with unit or partial opacity. Four root-opacity controls +at `.5`/`.999` were exact before the conservative guard, not newly found pixel +defects **(measured, not celled)**. Attributable cases roll back the whole affected group +and keep named siblings; the root cases refuse in both admissions. Completed +blend images remain distinct from bare ramps when ancestor facts are combined. + +Twenty-five new exact cells cover the off-phase integer/fractional and opaque +ramps, normal control, fill/stroke routes, combined/outer opacity, sibling +bounds and order, pattern-fill siblings, gradient direction/transform, leaf +ownership, an unpositioned local use instance, viewport-edge clipping and the +four omitted-stroke boundary controls described below. +They use the unchanged hash-pinned capture module through the common probe +harness and baker. No existing oracle, tolerance or FLIP record changes. +The primitive corpus moves from 1,398 to 1,423 and the named refusal register +from 303 to 325; the sixteen sampled frames, sixteen text-pixel cells and eight +text-geometry witnesses are unchanged. + +Independent TICK/LAW review found a missing contributor the initial patrol +missed: a selected transparent stroke can enlarge the source bounds while its +gradient fill remains visible. Sixteen follow-up source controls confirmed +transparent, zero-opacity and empty-gradient strokes differ at 30 pixels for +Multiply and 102 for Screen, delta 1. Twelve adjacent controls confirmed +unresolved/wrong-kind references and explicit `none` fallbacks expose the same +class, even without an opacity pass. A solid sibling with a dropped stroke +changes 89/124 pixels at delta 1 **(measured, not celled)**. + +`StrokeResolution` therefore carries a producer-private omitted-extent fact +separate from opacity participation. It survives the visible fill and combines +with other source contributors before the named guard; no fake paint or +backend hint enters `rframe`. Six additional refusal witnesses cover these +branches. Four exact cells distinguish `stroke:none`, resolved zero width, +a retained all-transparent gradient stroke and transparent fill surrounding +a live gradient stroke. An unresolved paint server with otherwise inert width +grammar is conservatively guarded without newly evaluating that grammar. + +Eight final context-paint controls found the same 30/102-pixel class when a +context stroke has no provider or follows a provider selecting `none` +**(measured, not celled)**. Two more registered refusals guard those early +returns. One final best-effort render crossed the guard update and is excluded +from pre-guard pixel evidence; its strict witness and both final guarded +admissions were independently checked. The original computed stroke kind is +preserved before context +selection: resolving a context paint to nothing must not relabel it as +computed `stroke:none` for the extent decision. This remains a compositing +gap, covered by the unclosed blending rows; ordinary paint selection outside +that composition is unchanged. + +Gate sensitivity is measured, not inferred from a green run. Disabling only +the source-extent clip makes `just gate` fail on twenty-three new cells, including +the original fractional pair at 28/56 pixels and the integer pair at 50/84, +all at delta 1. The normal and offscreen controls remain exact. Restoring the +exact source hash returns the primitive gate to green. Separately disabling +the omitted-stroke extent patrol makes the refusal gate fail on +`stroke-context-missing`; an actual best-effort CLI render of the context-fill +source becomes silently wrong at 102 pixels, delta 1. Exact source restoration +reinstates the named refusal. No oracle changes are involved in either direction. + +The full nine-crate affected-path tests, full n0 trace suite, trace-enabled +Chromium gate, formatting, strict no-dependency Clippy, fixture/status gates +and link/OSS audits pass locally. No Workflow runner was exposed, so the saved +verification workflow's independent TICK/LAW and REPRO roles were reproduced +manually. Both pass after the omitted-stroke finding and final sensitivity +checks; the code hashes were restored exactly before the final green gates. + +The follow-on is a source-coordinate-space contract that can carry the missing +extent facts, before wider geometry or blend modes. This correction does not +resolve the separate ordinary-opacity findings in gridaco/nothing#136 or +the generic damage/coverage follow-ups in gridaco/nothing#87/#88. No timing +improvement is claimed. diff --git a/docs/wg/consolidation/web-checklist.md b/docs/wg/consolidation/web-checklist.md index 0b27ae41..9d39392e 100644 --- a/docs/wg/consolidation/web-checklist.md +++ b/docs/wg/consolidation/web-checklist.md @@ -688,6 +688,9 @@ excluded. > boundaries, and the [B1 evidence](./svg-engine-of-record.md#b1-svg-group-blending) > records the measured split. Partial coverage is not a tick under > gridaco/nothing#81/#89/#90. +> 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. ### CSS fonts diff --git a/fixtures/web-first/README.md b/fixtures/web-first/README.md index 60a055eb..7b0404ac 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,398 primitive cells plus 16 sampled frames, those twenty-four text -witnesses, and 303 named refusal rows. Pixel cells use byte equality: what each +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 corpus admits is exactly what the engine renders pixel-for-pixel, except only the primitive rows carrying an explicit measured tolerance block. The real-font witness grades geometry before rasterization and makes no Chromium @@ -28,6 +28,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-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. | | `svg-group-blend-{multiply,screen}-opacity-small-{opaque,partial}.svg` | Opacity .123456 with opaque/translucent source colors over a translucent destination. The multiply partial-source cell rejects a float-first opacity prototype at 1,600 pixels/delta 1: byte opacity must be applied before the byte-domain blend. Hosted x86 also guards the low-precision backend's approximate division; no oracle or tolerance is relaxed. | | `svg-group-blend-near-unit-{isolated,plain,screen}-{unit,near,p999,p998}.svg` | Rotated groups at opacity 1, the next smaller f32, .999 and .998. The first three match for isolated blending children and screen; plain isolation instead changes 143 pixels/delta 1 at the unit-to-partial boundary. A byte-255 restore must not erase the authored partial-opacity source layer. The .998 controls discriminate the next opacity byte. | diff --git a/fixtures/web-first/STATUS.md b/fixtures/web-first/STATUS.md index d29600ba..0368ec65 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 (1398) +## Chromium-baked cells (1423) Cells are checked against their committed Chromium oracles using exact bytes unless a manifest entry declares a measured, bounded @@ -613,6 +613,31 @@ to its fixture source. No new image is committed for this view. svg-group-blend-css-keyword svg-group-blend-css-var svg-group-blend-css-winner +svg-group-blend-extent-multiply-diagonal +svg-group-blend-extent-multiply-fractional +svg-group-blend-extent-multiply-integer +svg-group-blend-extent-multiply-leaf +svg-group-blend-extent-multiply-many +svg-group-blend-extent-multiply-opacity-half +svg-group-blend-extent-multiply-opaque +svg-group-blend-extent-multiply-outer-opacity +svg-group-blend-extent-multiply-stroke +svg-group-blend-extent-multiply-stroke-clear-gradient +svg-group-blend-extent-multiply-stroke-none +svg-group-blend-extent-normal-fractional +svg-group-blend-extent-screen-fill-stroke-gradient +svg-group-blend-extent-screen-fractional +svg-group-blend-extent-screen-gradient-transform +svg-group-blend-extent-screen-integer +svg-group-blend-extent-screen-many-reversed +svg-group-blend-extent-screen-offscreen +svg-group-blend-extent-screen-opacity-near +svg-group-blend-extent-screen-opaque +svg-group-blend-extent-screen-pattern-sibling +svg-group-blend-extent-screen-stroke-gradient +svg-group-blend-extent-screen-stroke-transparent-fill +svg-group-blend-extent-screen-stroke-zero-width +svg-group-blend-extent-screen-use svg-group-blend-fractional-multiply svg-group-blend-fractional-normal svg-group-blend-fractional-screen @@ -1426,7 +1451,7 @@ to its fixture source. No new image is committed for this view. svg-visibility-rule-beats-attribute svg-visibility-unhide -## The refusal register (303) +## The refusal register (325) What the slice refuses, by name, in the compiler's own words — **both refuse** is a document-level contract; **declared** renders @@ -1544,6 +1569,28 @@ its row into the cells above. | `svg-group-blend-elided-mask-partial` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | | `svg-group-blend-elided-mask-unit` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | | `svg-group-blend-filter` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | +| `svg-group-blend-linear-extent-child-opacity` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a transformed or nested linear-gradient source needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-clip` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a transformed or nested linear-gradient source needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-empty-gradient` | 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-fill-zero` | 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-isolation` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a transformed or nested linear-gradient source needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-nested-blend` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a transformed or nested linear-gradient source needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-pattern-stroke` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a patterned source stroke needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-root` | **both refuse** | unsupported computed style: mix-blend-mode/isolation with a transformed or nested linear-gradient source needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-root-opacity` | **both refuse** | unsupported computed style: mix-blend-mode/isolation with a transformed or nested linear-gradient source needs the linear-gradient source-extent profile | +| `svg-group-blend-linear-extent-rotation` | 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-stroke-context-missing` | 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-context-none` | 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-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 | +| `svg-group-blend-linear-extent-zero-opacity` | 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-mask` | declared | skipped svg/g[1]: unsupported computed style: mix-blend-mode/isolation with a filter or mask needs its own image-effect composition profile | | `svg-group-blend-mode-color` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode Color is outside the admitted normal/multiply/screen group profile | | `svg-group-blend-mode-color-burn` | declared | skipped svg/rect[2]: unsupported computed style: mix-blend-mode ColorBurn is outside the admitted normal/multiply/screen group profile | diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-multiply-diagonal.png b/fixtures/web-first/chromium/svg-group-blend-extent-multiply-diagonal.png new file mode 100644 index 0000000000000000000000000000000000000000..483accbb7288c53f60f5b736f0c3cdbb7083e379 GIT binary patch literal 987 zcmV<110?*3P)000A!Nkla?JEBSzMvA4K$)xG@J^T43 zvk&h-e1vvvDD`VmM77Ddmn7%`7ClXY=O^PUyckd+MGpeJ65@_Hr?2n5n} z27!hA)P%+$FwnJPrVdINb&a5xBAjW+T>=Tjq7QCXAo5(uD~iegqF_~ znkjvzXaKuQSl{H1f{S`!ed?0lozGB-!>UWN-q4KHP}2e2KX_3SvwJSK=c+^o?T4rz z@O7~0RWprh**@Bk+eDiz(r#`B$vSa9WA}=5yPlJcW&qgPwmyN}{an4C1j>Vu`e0)(^S$VNMi&5|M?f?Ku-TW< zRZjxmLjiU)W9GLVprsi??>VkA7c?=|K4Vl%-e;&1cf)6F2aD1){k1uC+Km}sfjLACVK7;-S0qdj=eYvf+HIr{CCOy5!r_l*`QE`lN^rsfu*6D9rdSfF}_r4 zrq&p2#PYpxDh_+#z-NNZB_pGT&zL$Cj;Y?t9C{NlnU?wnsNE3k?*W6m?K7!cD6<9R z?gFCIEpWV%-Q1hlqrg<75_;(~as76NTmLBU%x6n*OF}!EApmIaO{HdXrk!TuLd>|+ zRpHQ=!1J58&n7F|5UUyC+t4xoi@3un61xOKSl}Mwqp~39UorCW_$8L*vdDFJTw%=*a+-0{WO+Wmm*EwcVp@si9mB^X< zzHXAe6CraUlIi|>{oDQKN!rIa_@A_Gbc!=`zQp4drIHJbm`mRAocI#=-PB?4 z^#ecT*2ggyHA+p0Vct~5`++r!p?1MO_SywZzZ|}7kGsg;#|~wLDK^*zWOCFd*k52@ zE4GrcF2c#cgYP~-{|KY@n2?iYeB5s{Ii=QrV?B&rk5@7#(YN1O}Os>KF z%duHk&rjTOMWlcJ!=Eodn#9#E&^9kHno~PbWWtxwO%K(Ai@UZS;as$Gl5y_bn^`(n zIz^e1s{MaQ)}~DJp1Q4i?V5twHNL5%DK6(8=V@ADW-xl9II8WQbH&vU<#O7_u zk7-;p&R@|;NIfr-Q764=5yM>V0~>!yYZYu{U-Krr>VVPHm`2a}ah$)Z7KjBKXD(*f zDPG84rF!E&^K7mQ`<73L=Fibm_&v`__xzly|8fUp4#?baW++~emc&;gctB>w%o)tF zEC%dZYR??R8>h-`*ks6l%4r6(c!Om?gXIPH2NJ~zAMd_6{JMI{^4~9{eT?R7-n{0& zZRaY!sotN@Ncq3hspqgY(h;)QyXT@{!+&;^091IuEAWAhqXHhTe;64U{{Pom6{rY| eH#Sg&F*6h;FI=g5`N0#Q2!p4qpUXO@geCxqKFzlP literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-multiply-leaf.png b/fixtures/web-first/chromium/svg-group-blend-extent-multiply-leaf.png new file mode 100644 index 0000000000000000000000000000000000000000..f7b68cc781925f269f075ea1042a556e5923f571 GIT binary patch literal 487 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFwXIGaSW-L^Cr^UkJ*rCtFfR$ z!1{xh0_6`4=JZ)BY)m@b#JWgJZSr{)#ks43eY2JJF4riO@qd(ldefC#$CszSl5DeL z=g44Y*}}+li-9q>p~39UorCW_$8L*vdDFJTw%=*a+-0{WO+Wmm*EwcVp@si9mB^X< zzHXAe6CraUlIi|>{oDQKN!rIa_@A_Gbc!=`zQp4drIHJbm`mRAocI#=-PB?4 z^#ecT*2ggyHA+p0Vct~5`++r!p?1MO_SywZzZ|}7kGsg;#|~wLDK^*zWOCFd*k52@ z7T^BBqGLr`}4QoHJ$W z?nrC7YXK5pb}hT=TP(r-Csiu8$ao~h@8ABvrDna?tIB*zF(E6!u{MV8GGKDCM>uuI(Op~y@M&XZu>IK z4TwJG;eC1n|adJTnOWU%?ey^_ASNFGV?JBq>(~uzLcd}7IUxoco z!T$aHPaP&qpUiw$NL}Vcqs!uBG7=ZHoHSBy&OY`1_2qY;?@p0ex;tyBrzt~kr$a?e z`gS*Skyj4e-cDBMulg=HPm!stf6-~P+36vHvM=6#^Xbso`?@16NjY7%qb5_iVTOA` z$m!chBiR?lo0>6w^ZRG^J%-%c!zH#bL)zId_8s2d-v>&{`*fwjD`J}qesk~lgHNmYtFOZ@;oa` ztTm8nMflFyVw^vlo28u^aH6ZiKbRR9{{NSF(WnPZ5Nx27z|5fZQGRjPt(PBwA`G6c KelF{r5}E+Qx%}4v literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-multiply-opacity-half.png b/fixtures/web-first/chromium/svg-group-blend-extent-multiply-opacity-half.png new file mode 100644 index 0000000000000000000000000000000000000000..13b23d1e0140ea65583ce9eb4697e333500633b8 GIT binary patch literal 413 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFa~+LIEGZrc{4NCkJ*r?v!w!1T$B|j>M%u<$|7vOIGW8zbq;LuMn+v^4LNK6Oo2C zrV9ZKQd|zkU)7#%7r(i8_fow}cJ;1jcUMJB>RmQFI)2Tryqo;lrGLV$zfa2Y(%P?@ z@coO$e!J(FC;!yFeDH(d$KR=kqvyviKjrc6-SsKAr#p-Fuh8GL$o~DVeLmqYo!BIQ zwXCsT?3=ymN8R14E;*dd^DKWvx3PSAob9{%-J`Pbd1l$=ohLuEZ~ioyL*{3Z22)%A zfri(%asc1{7iNboFyt=akR{0R9}V9smFU literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-multiply-opaque.png b/fixtures/web-first/chromium/svg-group-blend-extent-multiply-opaque.png new file mode 100644 index 0000000000000000000000000000000000000000..57475e867d94572e092e725977fa13e513bf3b1d GIT binary patch literal 526 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp`FaSW-L^Cr^U@34VD?PQUt zjjtDlKalCzT_E9iTvotZEN8-mu3HEFZ1Wz~2}iYgyw}W#81e%e%+=a?R-Z4Atz%rIbhgEWf7bOhHGeT^}#0%{aR{C(WzPBaP|pirb4^uAaG5*gWSDqlJ=#+40Le z9;W4AJU8#ioeO#U?(J>w!a~NZe!fu z8=Ao}-8?F7M@>W178cKDe3_QezdX^xnEA$8hAt_CbM~tl=d58oduHObhA->B*&>!%wz+aw7~2i@ZRc*V|KduJXZU^KJvZ#$xb4N?R_7j^Sai}bUvb*i3E3q94L-{bJed8n z{DHg-G*~qbNQ*q+<@&&e6xuy;2l#=J`TzfJMSnLSgAEj&%naMEwHGMw@R0(FFnGH9 KxvX_%$C`&Y?od;GOzGY*6wMu9)CWq{Z^9qt&G9~ zUV#g292v|kTNs&cSzP1!{-yT#Tmj*Y0ykMX+oQK_Kj@XSG_rbcaF9*L+H1`}B{C;Q zFU@Z6-lp{7$b0@jSIyh!&6?N#+Tg^w_0r3yY}io5(Oqpc{c5!38&0Xs1~!)0XDGMz z2X33c{M%y}?vLJ_o+5Rc_YMTQ@urKZ*Z7@_-Swm;c@m%Kw|KXP;|Gsv7zYI^&pdie zMd-8&|GXGMR&gQbcyZgTbaZ>%u!kO_0h35O5DMhE`Q zSh6M8Cn1dgNRr>QE6hPfE3Y$e*}?upr{dBZ###Thw@j8>k)Ggg`@r<6oW`yrGBbP` ziWj^QDo!wK%w#iQU$>5(qnH5O)e!e#ys p*cll9|9`i469+I3*+7xU%&@KY%(AW(@>_r+44$rjF6*2UngB+}%sl`A literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-multiply-stroke-clear-gradient.png b/fixtures/web-first/chromium/svg-group-blend-extent-multiply-stroke-clear-gradient.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-extent-multiply-stroke-none.png b/fixtures/web-first/chromium/svg-group-blend-extent-multiply-stroke-none.png new file mode 100644 index 0000000000000000000000000000000000000000..f7b68cc781925f269f075ea1042a556e5923f571 GIT binary patch literal 487 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFwXIGaSW-L^Cr^UkJ*rCtFfR$ z!1{xh0_6`4=JZ)BY)m@b#JWgJZSr{)#ks43eY2JJF4riO@qd(ldefC#$CszSl5DeL z=g44Y*}}+li-9q>p~39UorCW_$8L*vdDFJTw%=*a+-0{WO+Wmm*EwcVp@si9mB^X< zzHXAe6CraUlIi|>{oDQKN!rIa_@A_Gbc!=`zQp4drIHJbm`mRAocI#=-PB?4 z^#ecT*2ggyHA+p0Vct~5`++r!p?1MO_SywZzZ|}7kGsg;#|~wLDK^*zWOCFd*k52@ z7mJvr0gE418vl(%cg`RCdE9*g&E z=*w^wT2Q=3nnihG{|^C&4=*i(EdRaWwYCav`A{TuFQW&5%Dv0O{>1}nem`Ipy+zi6Ek&hdBg=ByRpn)e-fa7}Ls z*PM6E8|C^Qe@~fR=G@1y>e7K%bv8>+Jh=AM%ZBMql-Hak3)QVc8qS3;NL$uAjcY;x z>w_BBthA_%l?+^=5B7gy*f2$@`L^SC2z~LZ!*;F=ei^6d2Lz3EU&x$jIoEjLLD>ac z1|6W8Vb2}xO88jVwp+1(T3D=LutTHEUW(J*f1Gx--4`){F sDESLh9WYY=|2Ml6?g3=5fufa}!CuomOXS+M7N7`&r>mdKI;Vst0Bw-mZU6uP literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-normal-fractional.png b/fixtures/web-first/chromium/svg-group-blend-extent-normal-fractional.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_1Z1T0008TNklBvlHP-)z-agwr->k%mfDFK6i{^T&_ST-h-`mZkTLu9DK6agH5$w&wVO%ye`jqCst14Ys-*oleP~pmtZQarN}o)%rE|_qYmo5}3Ch z^MN)aR<9OKj+JolD?bL8BUJ~Q94i4IatmF6lv^5 zXusmf?PS@Tnknw6w9OTH$}0|(|5H{n_b3?j8! zdo~*9xdWfSe7m4=S5z%gEL)K`7ehj+Qbl`1``B!U4-SpKk~JP6KD-5 z&>BvlHJm_eIDyu10R-M9 literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-fractional.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-fractional.png new file mode 100644 index 0000000000000000000000000000000000000000..0f7673a12fcd612c995c24cfa845f660a6b56061 GIT binary patch literal 523 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp!9aSW-L^Jb=_-(dp**Z)&4 z@vdq)!no_1RE{Nk`$I9AZq-=vZEGDo+m39MRX15RYl@%J-}PEj{mZxBzj1lut*4hy zYd>4Vnx@AT(ams8sbMQef-X};_D%DRk?XImUdqIkIZvoPxBii4D2sCGHN&Y3gKGQC z&fWUI_x^twz8xY5K0o{S@uA*ySGJQbMN{}#EHCZFE7}geoWNzS zBT{(krpA0dhTZ*d^L8JdX;s*2qIOC>XwUuVSGk4C6DEqkiw(oW^Zi=T@}7k}a>Od9vZ|e6|KN|K8QVzCJy{$&eZM+T6Rx-`_#> z?wddC4tFNscb}};yBjDFb>^%4miImuj1&FJ+6;NtFsg*t7Vw^!n!O;-^w#lghjS|! z_ZIQ)aJ?+$@P7M+l57VMgEQfi=>a=|${Xx{XEyV1;O}8hkZ1UPz=Gi$Bb?&_CB|MQhny6zR&q_TkmUZl6k&bJYl}*34_{?{CwGh2clRu zh%v5lWk6$YX8*y;!0`Wnz-5k5V3e|f;*yy`E|cqlwEM>SKoJH{S3j3^P6000CRNklY&JLD@- z8th~h<7(asnHxMxmk;nQo=`Bm+Po^Jt_doCM@k}|1LZe-YIN6EPTPa(Y(w{WL zvKs82_8?!!RZ?G>qjb;c6V{#b1lhAdjsx)b@u+>e>YHSoSwulIsNQyk+-n>wNYcH` zrZn*~^-lwk-%6x@$Un^|YC_XY_9(V&;ZLzK zlUh9^ajOf*LX`W%Sy^`p0Iogfm<#F1b*fkquF7&pj!Nmmc;zP+$`;1~IDZ_BatRgt z72#mEk29#5Cb2QhKzf!O^#F0PC3(&f&KiHHXapl6O;! zWxfuO#-(PVJ^qBagjZk7LaK7WDbWvR#Arsvs3kJ%fzqo*{%Afeaap0#e4;Y}NZ=2- z_RN;G&SBAf5Nqcgv<&$N)x2euX$=lNcE~?${ZGt4EG^2404&0YLzP8Uzlx>Vd!CE| zaJy2h!5n!tiSkRF2XY?~vZwXKLv8!Q4IkIJQtvvLf7Y)9a?;~ciGm+0W@~hovkuWv z#V!YpnS;iBw)QwkA!4rss7{8HGk~4Ez`@nO2VhuFfk~(E_-+{wG*k9%H&j&rIXw0x`ffPmDjY`WkS!oO#6=tlJ}x6-(d%y*0(FZ zKhW^F(|WjovG0Iq#CHar8=Df@yBC=mKK@x8H1U#3?;4NyD@&I@E;$x2b3ABQbw$l0 z-PH?wxf$*;HEd^ipvzDJWZvqvocI2HR94@WExyG)Mvv6z0| z&o`yl-0&$~zTMB`&strTEgs?wkNgCkf_w~D2+fP^cHV1g#A;|i{lVj`kj%@IvKb5P z(v>9_eCD}gBe8sEHd~7q+ohY4mdqye812qGv?fT##F?CM_?P=^O?nD%gCnm8=jN1# z|7#j6UUsA|-o?8loLQ6k`SizU8Q4q@q)wjHSQHwO^8boMT*4Z*XU`IocrRq|szkqc zieB3|t8sN!!?pd~3;9LNe;wdq_`>+5v6bNk^OfSo{AU;(j14Sq^gKA#l)+Ji!*8)WtJYD@<);T3K F0RUf*-H`wQ literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-many-reversed.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-many-reversed.png new file mode 100644 index 0000000000000000000000000000000000000000..237412298572b614a63fcaa956e0026107b353fb GIT binary patch literal 692 zcmV;l0!#ggP)0007MNklZ=(X9}3?Av~%lYYN+Z||q*=Ew2oc;_NBdxUz|P@fP! z0dc$3ILFOondNwKZpBaZCaL5cK{*4Jj;|+Bc=YEd=f9X>&mou~a-f1b6dj?T?A7uS z=+mkMie<3csgxRNBm>ceqh-07Ig`Os^T`1?IbN9nkGZJzNrSMj(p6^+w%q_Sj*Hwtt^s(~%m0SP;$fgxV~I&d!ysOjzg9!QR@4p<$yj)2tx zs{>aOusT3i2d*Qq`8qKBZ1n5*pAR4WBK2ritqFQWP2p^Cye6LSxVEFaW0t_Fb=1=K zVSjMch7qU@BTySgpf-#^Z5V;tFaot<1Zu+w)P@nLjolOY3jhHB|I}^$!TMXVOtCeH`0000IEGZrc{5|L-{AnB*0&n( z|4aDv`0Qag)6>z)o{*eX*R#HMwtUpWt_~Tcx&Bj2AMaS@FME8_?c=-K9cRatMAU^; zUoPDCsO-b$4E-ckH=7)j&L1BtdaTc$e>de_NmXstwEfEkr~bHTI&Y%%_F3t^{qZfy zt`)D|a;&+3w|&_&^Yq2Va#EEUXU}Klb-y^UMC(j<$O^6B7hJ5iojCT)CT;f5%zJYe zuZ}2T)%$nZm37tD&DF7uTHm}_Swb5%H@~+vxcg%x`0fzD SAax2*gu&C*&t;ucLK6UdUBf~E literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-opacity-near.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-opacity-near.png new file mode 100644 index 0000000000000000000000000000000000000000..0f7673a12fcd612c995c24cfa845f660a6b56061 GIT binary patch literal 523 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp!9aSW-L^Jb=_-(dp**Z)&4 z@vdq)!no_1RE{Nk`$I9AZq-=vZEGDo+m39MRX15RYl@%J-}PEj{mZxBzj1lut*4hy zYd>4Vnx@AT(ams8sbMQef-X};_D%DRk?XImUdqIkIZvoPxBii4D2sCGHN&Y3gKGQC z&fWUI_x^twz8xY5K0o{S@uA*ySGJQbMN{}#EHCZFE7}geoWNzS zBT{(krpA0dhTZ*d^L8JdX;s*2qIOC>XwUuVSGk4C6DEqkiw(oW^Zi=T@}7k}a>Od9vZ|e6|KN|K8QVzCJy{$&eZM+T6Rx-`_#> z?wddC4tFNscb}};yBjDFb>^%4miImuj1&FJ+6;NtFsg*t7Vw^!n!O;-^w#lghjS|! z_ZIQ)aJ?+$@P7M+l57VMgEQfi=>a=|${Xx{XEyV1;O}8hkZ1UPz=Gi$Bb?&_CB|MQhny6zR&q_TkmUZl6k&bJYl}*34_{?{CwGh2clRu zh%v5lWk6$YX8*y;!0`Wnz-5k5V3e|f;*yy`E|cqlwEM>SKoJH{S3j3^P6)S1_ z*cu}?yl33O8h!3y;tp1Jtu>Jfehfjo7tA&@^;A1qVKg@^*R4qVr}2`$xh<8RJ*qS8 z_U<`kdiuarZ^k7{87>7eWNJ2S5pjsRyzs!EvNH48LJNK?xqPxKxo=&P`+_Z-GD$IQB0jxS@qYaqTg?a}UrbDzKd`?xpaK&MqntH5H%C%Ia=-yeSO zTOm6?=hJR!tHSFyH=W5-FZr9y@Z~;__iQPbH|I{xzu~r0X0qkYB96sO0?`|*nU44` zkeu1>w)cREX!u@V5a30C*&rS&8fY2Po7a> z8Z(2l<%NemmI966o;YNESK&SJjA3KsPp_}V3R~}?l6qkZ7>x5}c9%vE6rLcB2`ww;ohX4PykAIQ|rUf=oB4B1Po^5pAS@1yuP=vwL L)z4*}Q$iB}0_XKE literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-pattern-sibling.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-pattern-sibling.png new file mode 100644 index 0000000000000000000000000000000000000000..ceccbc72d421b4061447eb1d4d6aebc67eb07682 GIT binary patch literal 628 zcmV-)0*n2LP)0006hNkl!Kbvv6q&*$>7v!y>Z=oN|$B@i1*AT}l;L08kW z8jt`8ft&%I(R;LT!65vt?F=LW>2&tzYW1~V!;T0daCd!P0aj3an-vSTv1Cta(#4`A zuztT{O(qg5LJqb`mrSfCyBbf$wn^Q_+bvkWEtZP~?1-={WJ_cViGWSH+70A#vT2e; za1UL18OSDzbDC{&7{cbk|4aZ~u0Z@&@hX#z^;^qjkJ!fdGqW6Q#%;VvA02%-9D!a3 ze3IM&7t++e$FK_rY)%mEL3#YqJHQLNy0VGQDpL0Oxxd|TyG!If$L@gJaNTEbD7g5ztpoM24zwC_z!~tL>zV3+SJq5*fNxID zIzS}vZiYG#JOiofK=2Htssn>F;NKTt2mUkzVmtZo1F8e61A7us9Z((ElYr^~s1EE$ zAbA}a{cZH|_5JkZ1H@0WA}5p{v4uV7c1|>I)MW*`W0b(Etf7STq5t5B4J8m8N+33r zKx`<1*iZtop#)+>3B-mHhz%tW8`%^10RRC1|9$%)xc~qF21!IgR09A+Y9x^6vzQP7 O000078PLt literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-gradient.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-gradient.png new file mode 100644 index 0000000000000000000000000000000000000000..f2a2d6cc0f7c2e9666f7feed5d38a4286f5ca347 GIT binary patch literal 690 zcmV;j0!{siP)0007KNklV>=pUpmAUtt{r7t@2Y!-)-7fzp5#stQqqs;mvD(K&Un!vJSM zaPkk)Q0jD)LTiX^%{JK9cY&p3Cw(f*hq8o)u~-TbUlA~|V+SIJ3wIEAhleqJ7Wsv^ z^<TmY()oBaZ4$%DPcQHr}YzQl#1KU*t zn?DBzgVaDL*x-82qoCCQbHP8t*IOH724XJ@nu)!hcz&E%&J$^Uf^iT{M zX!t4PB~hUcC!?9>J*LV4O;)YnARdw9(kIHd^bUPP|p~nH7tW30<+4% z?m+(J%MBrr8$uvAgg|Zxf!q)Rxgi8{LkQ%C5Xg<4C-4OT0RR6C17sEe000I_L_t&o Y00yaO%z9QmX#fBK07*qoM6N<$f|ouxCjbBd literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-transparent-fill.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-transparent-fill.png new file mode 100644 index 0000000000000000000000000000000000000000..f2a2d6cc0f7c2e9666f7feed5d38a4286f5ca347 GIT binary patch literal 690 zcmV;j0!{siP)0007KNklV>=pUpmAUtt{r7t@2Y!-)-7fzp5#stQqqs;mvD(K&Un!vJSM zaPkk)Q0jD)LTiX^%{JK9cY&p3Cw(f*hq8o)u~-TbUlA~|V+SIJ3wIEAhleqJ7Wsv^ z^<TmY()oBaZ4$%DPcQHr}YzQl#1KU*t zn?DBzgVaDL*x-82qoCCQbHP8t*IOH724XJ@nu)!hcz&E%&J$^Uf^iT{M zX!t4PB~hUcC!?9>J*LV4O;)YnARdw9(kIHd^bUPP|p~nH7tW30<+4% z?m+(J%MBrr8$uvAgg|Zxf!q)Rxgi8{LkQ%C5Xg<4C-4OT0RR6C17sEe000I_L_t&o Y00yaO%z9QmX#fBK07*qoM6N<$f|ouxCjbBd literal 0 HcmV?d00001 diff --git a/fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-zero-width.png b/fixtures/web-first/chromium/svg-group-blend-extent-screen-stroke-zero-width.png new file mode 100644 index 0000000000000000000000000000000000000000..0f7673a12fcd612c995c24cfa845f660a6b56061 GIT binary patch literal 523 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLH@YFdp!9aSW-L^Jb=_-(dp**Z)&4 z@vdq)!no_1RE{Nk`$I9AZq-=vZEGDo+m39MRX15RYl@%J-}PEj{mZxBzj1lut*4hy zYd>4Vnx@AT(ams8sbMQef-X};_D%DRk?XImUdqIkIZvoPxBii4D2sCGHN&Y3gKGQC z&fWUI_x^twz8xY5K0o{S@uA*ySGJQbMN{}#EHCZFE7}geoWNzS zBT{(krpA0dhTZ*d^L8JdX;s*2qIOC>XwUuVSGk4C6DEqkiw(oW^Zi=T@}7k}a>Od9vZ|e6|KN|K8QVzCJy{$&eZM+T6Rx-`_#> z?wddC4tFNscb}};yBjDFb>^%4miImuj1&FJ+6;NtFsg*t7Vw^!n!O;-^w#lghjS|! z_ZIQ)aJ?+$@P7M+l57VMgEQfi=>a=|${Xx{XEyV1;O}8hkZ1UPz=Gi$Bb?&_CB|MQhny6zR&q_TkmUZl6k&bJYl}*34_{?{CwGh2clRu zh%v5lWk6$YX8*y;!0`Wnz-5k5V3e|f;*yy`E|cqlwEM>SKoJH{S3j3^P64Vnx@AT(ams8sbMQef-X};_D%DRk?XImUdqIkIZvoPxBii4D2sCGHN&Y3gKGQC z&fWUI_x^twz8xY5K0o{S@uA*ySGJQbMN{}#EHCZFE7}geoWNzS zBT{(krpA0dhTZ*d^L8JdX;s*2qIOC>XwUuVSGk4C6DEqkiw(oW^Zi=T@}7k}a>Od9vZ|e6|KN|K8QVzCJy{$&eZM+T6Rx-`_#> z?wddC4tFNscb}};yBjDFb>^%4miImuj1&FJ+6;NtFsg*t7Vw^!n!O;-^w#lghjS|! z_ZIQ)aJ?+$@P7M+l57VMgEQfi=>a=|${Xx{XEyV1;O}8hkZ1UPz=Gi$Bb?&_CB|MQhny6zR&q_TkmUZl6k&bJYl}*34_{?{CwGh2clRu zh%v5lWk6$YX8*y;!0`Wnz-5k5V3e|f;*yy`E|cqlwEM>SKoJH{S3j3^P6 diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-fractional.svg b/fixtures/web-first/svg-group-blend-extent-multiply-fractional.svg new file mode 100644 index 00000000..546e6eb6 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-integer.svg b/fixtures/web-first/svg-group-blend-extent-multiply-integer.svg new file mode 100644 index 00000000..2785269d --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-integer.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-leaf.svg b/fixtures/web-first/svg-group-blend-extent-multiply-leaf.svg new file mode 100644 index 00000000..2e59490c --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-leaf.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-many.svg b/fixtures/web-first/svg-group-blend-extent-multiply-many.svg new file mode 100644 index 00000000..ea61db33 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-many.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-opacity-half.svg b/fixtures/web-first/svg-group-blend-extent-multiply-opacity-half.svg new file mode 100644 index 00000000..c638228e --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-opacity-half.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-opaque.svg b/fixtures/web-first/svg-group-blend-extent-multiply-opaque.svg new file mode 100644 index 00000000..d13599bd --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-opaque.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-outer-opacity.svg b/fixtures/web-first/svg-group-blend-extent-multiply-outer-opacity.svg new file mode 100644 index 00000000..cd22c450 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-outer-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-stroke-clear-gradient.svg b/fixtures/web-first/svg-group-blend-extent-multiply-stroke-clear-gradient.svg new file mode 100644 index 00000000..0c5533c3 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-stroke-clear-gradient.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-stroke-none.svg b/fixtures/web-first/svg-group-blend-extent-multiply-stroke-none.svg new file mode 100644 index 00000000..956709dd --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-stroke-none.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-multiply-stroke.svg b/fixtures/web-first/svg-group-blend-extent-multiply-stroke.svg new file mode 100644 index 00000000..fbe18393 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-multiply-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-normal-fractional.svg b/fixtures/web-first/svg-group-blend-extent-normal-fractional.svg new file mode 100644 index 00000000..42b59ab4 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-normal-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-fill-stroke-gradient.svg b/fixtures/web-first/svg-group-blend-extent-screen-fill-stroke-gradient.svg new file mode 100644 index 00000000..68cc54c5 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-fill-stroke-gradient.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-fractional.svg b/fixtures/web-first/svg-group-blend-extent-screen-fractional.svg new file mode 100644 index 00000000..23e54621 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-fractional.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-gradient-transform.svg b/fixtures/web-first/svg-group-blend-extent-screen-gradient-transform.svg new file mode 100644 index 00000000..4ba97578 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-gradient-transform.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-integer.svg b/fixtures/web-first/svg-group-blend-extent-screen-integer.svg new file mode 100644 index 00000000..478225d0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-integer.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-many-reversed.svg b/fixtures/web-first/svg-group-blend-extent-screen-many-reversed.svg new file mode 100644 index 00000000..7c71db11 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-many-reversed.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-offscreen.svg b/fixtures/web-first/svg-group-blend-extent-screen-offscreen.svg new file mode 100644 index 00000000..236280fe --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-offscreen.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-opacity-near.svg b/fixtures/web-first/svg-group-blend-extent-screen-opacity-near.svg new file mode 100644 index 00000000..ed27a139 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-opacity-near.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-opaque.svg b/fixtures/web-first/svg-group-blend-extent-screen-opaque.svg new file mode 100644 index 00000000..6c7a9ac9 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-opaque.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-pattern-sibling.svg b/fixtures/web-first/svg-group-blend-extent-screen-pattern-sibling.svg new file mode 100644 index 00000000..1bd15ef0 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-pattern-sibling.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-stroke-gradient.svg b/fixtures/web-first/svg-group-blend-extent-screen-stroke-gradient.svg new file mode 100644 index 00000000..484afa85 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-stroke-gradient.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-stroke-transparent-fill.svg b/fixtures/web-first/svg-group-blend-extent-screen-stroke-transparent-fill.svg new file mode 100644 index 00000000..8cf57c2a --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-stroke-transparent-fill.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-stroke-zero-width.svg b/fixtures/web-first/svg-group-blend-extent-screen-stroke-zero-width.svg new file mode 100644 index 00000000..2e8eab02 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-stroke-zero-width.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/svg-group-blend-extent-screen-use.svg b/fixtures/web-first/svg-group-blend-extent-screen-use.svg new file mode 100644 index 00000000..4fd691e8 --- /dev/null +++ b/fixtures/web-first/svg-group-blend-extent-screen-use.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/README.md b/fixtures/web-first/unsupported/README.md index 45b846cd..63227ab2 100644 --- a/fixtures/web-first/unsupported/README.md +++ b/fixtures/web-first/unsupported/README.md @@ -23,6 +23,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-{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. | | `svg-group-blend-{filter,mask}.svg` | Name the unadmitted image-effect composition profile and skip the complete affected group transaction. | | `svg-group-blend-elided-{filter,mask}-{unit,partial}.svg` | Eliding redundant Normal isolation must retain its authored participation for an ancestor filter/mask patrol, without inventing a physical layer or blocking the established opacity fold. These four inputs keep the conservative image-effect refusal; they are not claims of measured pixel defects. | diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-child-opacity.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-child-opacity.svg new file mode 100644 index 00000000..db6ba235 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-child-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-clip.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-clip.svg new file mode 100644 index 00000000..18bf1672 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-clip.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-empty-gradient.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-empty-gradient.svg new file mode 100644 index 00000000..a7178035 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-empty-gradient.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-fill-zero.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-fill-zero.svg new file mode 100644 index 00000000..24373255 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-fill-zero.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-isolation.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-isolation.svg new file mode 100644 index 00000000..dc590ed7 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-isolation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-nested-blend.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-nested-blend.svg new file mode 100644 index 00000000..c4233626 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-nested-blend.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-pattern-stroke.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-pattern-stroke.svg new file mode 100644 index 00000000..9d690f8b --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-pattern-stroke.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-root-opacity.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-root-opacity.svg new file mode 100644 index 00000000..6982e693 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-root-opacity.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-root.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-root.svg new file mode 100644 index 00000000..6129ccd6 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-root.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-rotation.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-rotation.svg new file mode 100644 index 00000000..147419f2 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-rotation.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-missing.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-missing.svg new file mode 100644 index 00000000..42db30cf --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-missing.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-none.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-none.svg new file mode 100644 index 00000000..5b7fe1de --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-context-none.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-empty-gradient.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-empty-gradient.svg new file mode 100644 index 00000000..69786b57 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-empty-gradient.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-missing-reference.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-missing-reference.svg new file mode 100644 index 00000000..d9120bd0 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-missing-reference.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-none-fallback.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-none-fallback.svg new file mode 100644 index 00000000..da40b60c --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-none-fallback.svg @@ -0,0 +1 @@ + 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 new file mode 100644 index 00000000..7156537d --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-opacity-zero.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-sibling.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-sibling.svg new file mode 100644 index 00000000..8cf8df66 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-sibling.svg @@ -0,0 +1 @@ + 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 new file mode 100644 index 00000000..b6f612c4 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-stroke-transparent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-transform.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-transform.svg new file mode 100644 index 00000000..ca9c2f29 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-transform.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-transparent.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-transparent.svg new file mode 100644 index 00000000..07a2a1a0 --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-transparent.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-viewport.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-viewport.svg new file mode 100644 index 00000000..7912800b --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-viewport.svg @@ -0,0 +1 @@ + diff --git a/fixtures/web-first/unsupported/svg-group-blend-linear-extent-zero-opacity.svg b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-zero-opacity.svg new file mode 100644 index 00000000..4682d13d --- /dev/null +++ b/fixtures/web-first/unsupported/svg-group-blend-linear-extent-zero-opacity.svg @@ -0,0 +1 @@ + From 84501ee3a7045a978e04635feaf6e454be914df6 Mon Sep 17 00:00:00 2001 From: Universe Date: Tue, 8 Sep 2026 04:01:21 +0900 Subject: [PATCH 2/2] n0: normalize equivalent blend-source stroke widths --- crates/n0/src/paint.rs | 97 ++++++++++++++++++- docs/wg/consolidation/svg-engine-of-record.md | 8 ++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/n0/src/paint.rs b/crates/n0/src/paint.rs index b6cac587..bfbc6c0a 100644 --- a/crates/n0/src/paint.rs +++ b/crates/n0/src/paint.rs @@ -4478,7 +4478,7 @@ fn blend_source_extents(list: &DrawList, view: &Affine) -> Option Option { - let StrokeWidth::Uniform(width) = stroke.width else { + let StrokeWidth::Uniform(width) = stroke.width.normalized() else { return None; }; if space != StrokeSpace::Local @@ -4646,6 +4646,101 @@ mod blend_source_extent_tests { ]) } + fn stroked_scene(width: StrokeWidth, mode: rframe::ScopeBlendMode) -> DrawList<()> { + let mut list = scene(); + list.items[0].kind = ItemKind::BeginIsolatedBlend { + blend: rframe::ScopeBlend::new(mode, None), + }; + list.items[1].kind = ItemKind::RectStroke { + w: 38.2, + h: 28.4, + corner_radius: Default::default(), + corner_smoothing: Default::default(), + stroke: Stroke { + paints: Paints::new([ModelPaint::LinearGradient(LinearGradientPaint { + stops: vec![ + GradientStop { + offset: 0.0, + color: n0_model::model::Color(0xFFCD_6843).into(), + }, + GradientStop { + offset: 1.0, + color: n0_model::model::Color(0x995B_ACE1).into(), + }, + ], + ..Default::default() + })]), + width, + align: StrokeAlign::Center, + cap: StrokeCap::Butt, + join: StrokeJoin::Miter, + miter_limit: 4.0, + dash_array: None, + }, + space: StrokeSpace::Local, + dash_phase: StrokeDashPhase::ZERO, + post_paint_opacity: PostPaintOpacity::IDENTITY, + }; + list + } + + #[test] + fn equal_sided_stroke_spellings_have_identical_extents_and_pixels() { + let raster = |list: &DrawList<()>| { + let mut surface = skia_safe::surfaces::raster_n32_premul((64, 64)).unwrap(); + surface.canvas().clear(Color::from_rgb(66, 101, 137)); + let saves = surface.canvas().save_count(); + execute_unchecked( + surface.canvas(), + list, + &Affine::IDENTITY, + &PaintCtx::new(None), + ); + assert_eq!(surface.canvas().save_count(), saves); + read_pixels(&mut surface, 64, 64) + }; + for mode in [ + rframe::ScopeBlendMode::Multiply, + rframe::ScopeBlendMode::Screen, + ] { + let uniform = stroked_scene(StrokeWidth::Uniform(3.5), mode); + let rectangular = stroked_scene( + StrokeWidth::Rectangular(RectangularStrokeWidth::all(3.5)), + mode, + ); + let expected = blend_source_extents(&uniform, &Affine::IDENTITY).unwrap(); + assert_eq!(expected[0], Some(Rect::new(6.0, 10.0, 49.0, 43.0))); + assert_eq!( + blend_source_extents(&rectangular, &Affine::IDENTITY).unwrap(), + expected + ); + // Representation equivalence, not a replacement Chromium oracle. + let pixels = raster(&uniform); + assert!(pixels.chunks_exact(4).any(|pixel| pixel != &pixels[..4])); + assert_eq!(raster(&rectangular), pixels); + } + } + + #[test] + fn unequal_and_zero_stroke_widths_do_not_gain_an_extent() { + for width in [ + StrokeWidth::Rectangular(RectangularStrokeWidth { + stroke_top_width: 3.5, + stroke_right_width: 4.0, + stroke_bottom_width: 3.5, + stroke_left_width: 3.5, + }), + StrokeWidth::Rectangular(RectangularStrokeWidth::all(0.0)), + StrokeWidth::Uniform(0.0), + ] { + let list = stroked_scene(width, rframe::ScopeBlendMode::Multiply); + assert_eq!( + blend_source_extents(&list, &Affine::IDENTITY).unwrap()[0], + None + ); + } + } + #[test] fn actual_list_and_current_view_are_the_only_extent_inputs() { let mut list = scene(); diff --git a/docs/wg/consolidation/svg-engine-of-record.md b/docs/wg/consolidation/svg-engine-of-record.md index c0986c2a..ad6c6e33 100644 --- a/docs/wg/consolidation/svg-engine-of-record.md +++ b/docs/wg/consolidation/svg-engine-of-record.md @@ -6245,6 +6245,14 @@ verification workflow's independent TICK/LAW and REPRO roles were reproduced manually. Both pass after the omitted-stroke finding and final sensitivity checks; the code hashes were restored exactly before the final green gates. +Hosted review also caught a raw-drawlist representation mismatch: the painter +normalizes equal-sided rectangular stroke widths, while the extent helper +initially matched only the scalar spelling. The helper now uses the same +normalization. Consumer tests fail before that correction and prove identical +rounded extents, exact pixels and balanced saves for both spellings in Multiply +and Screen; unequal sides and zero-width raw strokes gain no extent. This is +representation-equivalence evidence, not additional Chromium cell coverage. + The follow-on is a source-coordinate-space contract that can carry the missing extent facts, before wider geometry or blend modes. This correction does not resolve the separate ordinary-opacity findings in gridaco/nothing#136 or